[New Model] support nemotron Audex (#4976)
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com> Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Yueqian Lin <linyueqian@outlook.com> Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
64 files changed
+7760
-39
No files matched your search
@@ -0,0 +1,114 @@
|
||||
# Audex (Nemotron-Labs-Audex-2B) offline inference
|
||||
|
||||
One checkpoint, four tasks — each script builds its own `Omni` engine with
|
||||
the right deploy yaml, so they run as plain `python <script>` with no extra
|
||||
flags. Pass the HF repo ROOT as `--model` (default
|
||||
`nvidia/Nemotron-Labs-Audex-2B`); per-stage subfolders and download subsets
|
||||
resolve automatically.
|
||||
|
||||
| script | pipeline (`vllm_omni/deploy/<name>.yaml`) | audio in | text out | speech out | general audio out |
|
||||
|---|---|---|---|---|---|
|
||||
| `text_to_speech.py` | `audex_tts` | ❌ | ❌ | ✅ | ❌ |
|
||||
| `text_to_audio.py` | `audex_tta` | ❌ | ❌ | ❌ | ✅ |
|
||||
| `audio_qa.py` | `audex_thinker_only` | ✅ | ✅ | ❌ | ❌ |
|
||||
| `speech_to_speech.py` | `audex_s2s` | ✅ | ✅ | ✅ | ❌ |
|
||||
|
||||
Shared plumbing (corpus/WAV IO, tokenizer loading, the CFG cond/uncond
|
||||
pair contract) lives in `common.py`; each task script keeps only its own
|
||||
prompt recipe and flow. Note `text_to_audio.py` here drives the Audex
|
||||
two-stage AR pipeline — for diffusion text-to-audio models (Stable Audio
|
||||
Open, AudioX) use the generic
|
||||
`examples/offline_inference/text_to_audio/text_to_audio.py` instead.
|
||||
|
||||
## text_to_speech.py — text → speech
|
||||
|
||||
Thinker generates `<speechcodec_N>` tokens; the streaming causal decoder
|
||||
turns them into 16 kHz WAVs (one file per prompt, slugified filename).
|
||||
|
||||
```bash
|
||||
# three built-in demo sentences -> results/audex_wavs/
|
||||
python examples/offline_inference/audex/text_to_speech.py
|
||||
|
||||
# your own texts, with classifier-free guidance (official quality setting)
|
||||
python examples/offline_inference/audex/text_to_speech.py \
|
||||
--texts "Hello there." "Nice to meet you." --cfg-scale 1.5 \
|
||||
--output-dir results/my_tts
|
||||
```
|
||||
|
||||
Key flags: `--texts` / `--texts-file` (TSV `utt_id<TAB>text`),
|
||||
`--cfg-scale` (default 1.0 = off; 1.5 recommended — guided requests run
|
||||
one at a time, each paired with a length-matched null prompt),
|
||||
`--temperature` (overrides the deploy yaml's stage-0 default).
|
||||
|
||||
## text_to_audio.py — caption → general audio (sound effects)
|
||||
|
||||
Same thinker over the interleaved 4-codebook `<audiocodec_N>` RVQ block,
|
||||
decoded by the external XCodec1 checkpoint. CFG is effectively mandatory
|
||||
(default scale 3.0). Clips are capped at `--codec-cap 4000` codec tokens
|
||||
(10 s).
|
||||
|
||||
```bash
|
||||
# three built-in demo captions -> results/audex_tta_wavs/
|
||||
python examples/offline_inference/audex/text_to_audio.py
|
||||
|
||||
python examples/offline_inference/audex/text_to_audio.py \
|
||||
--captions "Thunder rolling across a valley." --output-dir results/my_tta
|
||||
```
|
||||
|
||||
XCodec1 resolves from `--xcodec1-path`, the `XCODEC1_PATH` env var, or the
|
||||
default `hf-audio/xcodec-hubert-general-balanced` repo (downloaded on
|
||||
first use).
|
||||
|
||||
## audio_qa.py — audio (+ instruction) → text
|
||||
|
||||
Single-stage audio understanding on the full checkpoint (NV-Whisper
|
||||
encoder + projector + LM). The default question is ASR
|
||||
("Transcribe the input speech."); without `--audio-files`, vLLM's public
|
||||
`mary_had_lamb` asset is transcribed.
|
||||
|
||||
```bash
|
||||
# transcribe the built-in demo asset
|
||||
python examples/offline_inference/audex/audio_qa.py
|
||||
|
||||
# your own clips / free-form QA
|
||||
python examples/offline_inference/audex/audio_qa.py \
|
||||
--audio-files a.wav b.wav --question "What language is being spoken?"
|
||||
```
|
||||
|
||||
## speech_to_speech.py — spoken question → spoken answer
|
||||
|
||||
The official three-pass cascade over ONE `audex_s2s` deployment: ASR
|
||||
(audio → transcript, text-final) → chat (transcript → answer, text-final)
|
||||
→ TTS (answer → speech through the streaming decoder). Only the TTS pass
|
||||
touches the codec path. Without `--audio-file`, the `mary_had_lamb` asset
|
||||
is used as the spoken input.
|
||||
|
||||
```bash
|
||||
# full cascade on the built-in demo asset -> results/audex_s2s_answer.wav
|
||||
python examples/offline_inference/audex/speech_to_speech.py
|
||||
|
||||
python examples/offline_inference/audex/speech_to_speech.py \
|
||||
--audio-file question.wav --output results/answer.wav --cfg-scale 1.5
|
||||
```
|
||||
|
||||
`--cfg-scale` (default 1.5) applies to the TTS pass only; 1.0 disables it.
|
||||
|
||||
Online-serving counterparts (server + HTTP clients) live in
|
||||
`examples/online_serving/audex/`.
|
||||
|
||||
## 30B-A3B (nvidia/Nemotron-Labs-Audex-30B-A3B)
|
||||
|
||||
The same four scripts serve the 30B MoE checkpoint (hybrid Mamba + MoE
|
||||
NemotronH thinker; identical token space and decoder). The 30B REQUIRES an
|
||||
explicit 30B deploy yaml — the model root's default resolution lands on the
|
||||
2B-tuned configs:
|
||||
|
||||
python examples/offline_inference/audex/text_to_speech.py \
|
||||
--model nvidia/Nemotron-Labs-Audex-30B-A3B \
|
||||
--deploy-config vllm_omni/deploy/audex_tts_30b.yaml
|
||||
|
||||
Per-mode yamls: `audex_{tts,tta,thinker_only,s2s}_30b.yaml`. Defaults fit a
|
||||
single H100 80 GB (thinker + decoder share the card; prefix caching is off —
|
||||
hybrid Mamba); if long sequences OOM, set `tensor_parallel_size: 2` on
|
||||
stage 0. First run downloads ~60 GB. Verified on a single H100 80 GB: ~61 GiB
|
||||
weights + healthy KV headroom; TP2 was not needed.
|
||||
@@ -0,0 +1,93 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline Audex (Nemotron-Labs-Audex-2B) audio understanding example.
|
||||
|
||||
Speech (or general audio) + text instruction → text, through the
|
||||
single-stage ``audex_thinker_only`` pipeline: NV-Whisper
|
||||
encoder + relu2 projector + 2B dense LM on ``checkpoint_folder_full``.
|
||||
|
||||
The prompt format mirrors the official audioqa script: a ChatML user turn
|
||||
holding the ``<so_embedding>`` placeholder (expanded by the processor to
|
||||
750 embedding positions per 30 s clip) plus the instruction, with a closed
|
||||
``<think></think>`` priming.
|
||||
|
||||
Examples (without --audio-files, vLLM's public ``mary_had_lamb`` asset is
|
||||
transcribed):
|
||||
|
||||
# Transcribe WAVs (ASR):
|
||||
python examples/offline_inference/audex/audio_qa.py \\
|
||||
--audio-files a.wav b.wav
|
||||
|
||||
# Free-form audio QA:
|
||||
python examples/offline_inference/audex/audio_qa.py \\
|
||||
--audio-files a.wav --question "What language is being spoken?"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from common import default_deploy_config, load_audio_file, request_index
|
||||
from vllm.assets.audio import AudioAsset
|
||||
|
||||
from vllm_omni import Omni
|
||||
|
||||
ASR_QUESTION = "Transcribe the input speech."
|
||||
|
||||
|
||||
def build_prompt(question: str) -> str:
|
||||
return f"<|im_start|>user\n<so_embedding>\n{question}<|im_end|>\n<|im_start|>assistant\n<think></think>"
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Offline Audex audio understanding")
|
||||
parser.add_argument("--model", type=str, default="nvidia/Nemotron-Labs-Audex-2B")
|
||||
parser.add_argument(
|
||||
"--audio-files",
|
||||
type=str,
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="Input clips. Defaults to vLLM's mary_had_lamb audio asset.",
|
||||
)
|
||||
parser.add_argument("--question", type=str, default=ASR_QUESTION)
|
||||
parser.add_argument(
|
||||
"--deploy-config",
|
||||
type=str,
|
||||
# The model root's default deploy yaml is the TTS pipeline; audio
|
||||
# understanding needs the single-stage thinker-only pipeline.
|
||||
default=default_deploy_config("audex_thinker_only.yaml"),
|
||||
help="Deploy yaml (defaults to the audex_thinker_only pipeline).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
engine = Omni(model=args.model, deploy_config=args.deploy_config, trust_remote_code=True)
|
||||
|
||||
prompt_text = build_prompt(args.question)
|
||||
if args.audio_files:
|
||||
clips = [load_audio_file(path) for path in args.audio_files]
|
||||
else:
|
||||
clips = [AudioAsset("mary_had_lamb").audio_and_sample_rate]
|
||||
prompts = []
|
||||
for audio, sr in clips:
|
||||
prompts.append(
|
||||
{
|
||||
"prompt": prompt_text,
|
||||
"multi_modal_data": {"audio": (audio, sr)},
|
||||
}
|
||||
)
|
||||
|
||||
outputs = engine.generate(prompts)
|
||||
|
||||
ordered = sorted(outputs, key=request_index)
|
||||
names = [Path(p).name for p in args.audio_files] if args.audio_files else ["mary_had_lamb"]
|
||||
for name, req_output in zip(names, ordered):
|
||||
text = req_output.outputs[0].text if req_output.outputs else ""
|
||||
print(f"{name}: {text.strip()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Shared helpers for the offline Audex examples in this directory.
|
||||
|
||||
Import style follows the repo's example convention (same-directory module,
|
||||
see ``text_to_speech/qwen3_tts/tts_common.py``): scripts run as files, so
|
||||
``from common import ...`` resolves via the script's own directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_null_prompt
|
||||
|
||||
SAMPLE_RATE = 16_000
|
||||
_DEPLOY_DIR = Path(__file__).resolve().parents[3] / "vllm_omni" / "deploy"
|
||||
|
||||
# Guided decoding sharpens the distribution, so the unguided temperature
|
||||
# (0.1) adds excess sampling noise under CFG. Measured on the en-24 gate
|
||||
# corpus at cfg 1.5: temp 0.1 -> CER 7.31%, temp 0.05 -> CER 6.87% (vs the
|
||||
# unguided baseline 7.24%).
|
||||
GUIDED_TEMPERATURE = 0.05
|
||||
|
||||
|
||||
def default_deploy_config(name: str) -> str:
|
||||
"""Absolute path of a deploy yaml bundled with vllm_omni."""
|
||||
return str(_DEPLOY_DIR / name)
|
||||
|
||||
|
||||
def slugify(text: str, fallback: str = "prompt") -> str:
|
||||
slug = re.sub(r"\s+", "_", text.strip().lower())
|
||||
slug = re.sub(r"[^a-z0-9_]+", "", slug)
|
||||
return slug[:48] or fallback
|
||||
|
||||
|
||||
def load_corpus(
|
||||
corpus_file: str | None,
|
||||
texts: list[str] | None,
|
||||
default_texts: tuple[str, ...],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""(utt_id, text) pairs from a TSV corpus file, CLI texts, or defaults."""
|
||||
if corpus_file:
|
||||
corpus = []
|
||||
for line in Path(corpus_file).read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
utt, text = line.split("\t", 1)
|
||||
corpus.append((utt, text.strip()))
|
||||
return corpus
|
||||
resolved = texts if texts else list(default_texts)
|
||||
return [(slugify(t), t) for t in resolved]
|
||||
|
||||
|
||||
def load_audio_file(path: str) -> tuple[np.ndarray, int]:
|
||||
"""Mono float32 samples + native sample rate (processors resample)."""
|
||||
audio, sr = sf.read(path, dtype="float32")
|
||||
if audio.ndim == 2:
|
||||
audio = audio.mean(axis=1)
|
||||
return audio, sr
|
||||
|
||||
|
||||
def extract_pcm(multimodal_output: dict) -> torch.Tensor:
|
||||
"""Flatten one request's audio payload to a 1-D float32 CPU tensor."""
|
||||
audio = multimodal_output.get("model_outputs")
|
||||
if audio is None:
|
||||
audio = multimodal_output.get("audio")
|
||||
if audio is None:
|
||||
raise ValueError(f"no audio key in multimodal_output: {list(multimodal_output.keys())}")
|
||||
if isinstance(audio, list):
|
||||
valid = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio if a is not None]
|
||||
if not valid:
|
||||
raise ValueError("audio list is empty")
|
||||
return torch.cat(valid, dim=0) if len(valid) > 1 else valid[0]
|
||||
return torch.as_tensor(audio).float().cpu().reshape(-1)
|
||||
|
||||
|
||||
def write_wav(path: Path, pcm: torch.Tensor, sample_rate: int = SAMPLE_RATE) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
arr = (np.clip(pcm.numpy(), -1.0, 1.0) * 32767.0).astype(np.int16)
|
||||
sf.write(str(path), arr, sample_rate, format="WAV", subtype="PCM_16")
|
||||
|
||||
|
||||
def load_audex_tokenizer(model: str, profile: str = "tts", folder: str = "checkpoint_folder_audiogen"):
|
||||
"""Thinker tokenizer for CFG/TTA prompt building (resolves the snapshot)."""
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot
|
||||
|
||||
root = ensure_audex_snapshot(model, profile=profile)
|
||||
return AutoTokenizer.from_pretrained(str(Path(root) / folder), trust_remote_code=True)
|
||||
|
||||
|
||||
def request_index(req_output) -> int:
|
||||
"""Submission index from the numeric request-id prefix.
|
||||
|
||||
``Omni.generate`` may return outputs out of submission order, and a
|
||||
lexicographic sort would misplace "10_..." before "2_...".
|
||||
"""
|
||||
match = re.search(r"(\d+)", str(req_output.request_id))
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def guided_stage_params(
|
||||
engine: Omni,
|
||||
cond_prompt: str,
|
||||
tokenizer,
|
||||
cfg_scale: float,
|
||||
pair_id: str,
|
||||
*,
|
||||
temperature: float | None = None,
|
||||
extra_args: dict[str, Any] | None = None,
|
||||
null_prompt_builder: Callable[[str, Any], str] = build_null_prompt,
|
||||
) -> list:
|
||||
"""Stage sampling params carrying the CFG pair contract for ONE request.
|
||||
|
||||
Clones the engine's resolved defaults (never mutates the shared list),
|
||||
applies ``extra_args`` unconditionally (e.g. the TTA RVQ contract), and
|
||||
attaches the cond/uncond pair contract when ``cfg_scale > 1.0``. Guided
|
||||
requests must go one at a time: each needs its own pair id and a
|
||||
length-matched null prompt built from its own text.
|
||||
"""
|
||||
params = copy.deepcopy(engine.resolve_sampling_params_list(None))
|
||||
stage0 = params[0]
|
||||
if temperature is not None:
|
||||
stage0.temperature = temperature
|
||||
if stage0.extra_args is None:
|
||||
stage0.extra_args = {}
|
||||
if extra_args:
|
||||
stage0.extra_args.update(extra_args)
|
||||
if cfg_scale > 1.0:
|
||||
stage0.extra_args.update(
|
||||
{
|
||||
"cfg_scale": float(cfg_scale),
|
||||
"cfg_role": "cond",
|
||||
"cfg_pair_id": pair_id,
|
||||
"cfg_null_prompt": null_prompt_builder(cond_prompt, tokenizer),
|
||||
}
|
||||
)
|
||||
return params
|
||||
@@ -0,0 +1,277 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline Audex (Nemotron-Labs-Audex-2B) cascaded speech-to-speech example.
|
||||
|
||||
The official three-pass cascade over ONE ``audex_s2s``
|
||||
deployment:
|
||||
|
||||
1. ASR pass (audio + "Transcribe the input speech." → transcript, text)
|
||||
2. chat pass (transcript → answer, text)
|
||||
3. TTS pass (answer → speech, audio via the streaming causal decoder)
|
||||
|
||||
Text passes carry ``modalities: ["text"]`` and finish at stage 0; only the
|
||||
TTS pass (``modalities: ["audio"]``) streams codec frames into code2wav.
|
||||
|
||||
Example (without --audio-file, vLLM's public ``mary_had_lamb`` asset is
|
||||
used as the spoken input):
|
||||
|
||||
python examples/offline_inference/audex/speech_to_speech.py \\
|
||||
--audio-file question.wav --output results/answer.wav
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from common import (
|
||||
SAMPLE_RATE,
|
||||
default_deploy_config,
|
||||
guided_stage_params,
|
||||
load_audex_tokenizer,
|
||||
load_audio_file,
|
||||
write_wav,
|
||||
)
|
||||
from vllm.assets.audio import AudioAsset
|
||||
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt
|
||||
|
||||
ASR_QUESTION = "Transcribe the input speech."
|
||||
SYSTEM_PROMPT = "You are a helpful and harmless assistant.\n\nYou are not allowed to use any tools."
|
||||
# The official chat-pass recipe below (SYSTEM_PROMPT + TEXT_INSTRUCTION
|
||||
# prefix, open <think> priming, temp 1.0/top_p 0.95, seed retry) applies to
|
||||
# the 30B-A3B ONLY (see is_30b()/_chat_prompt below and
|
||||
# tests/e2e/offline_inference/test_audex_s2s.py, which mirrors this same
|
||||
# split). It is load-bearing on the 30B: without the formatting-instruction
|
||||
# prefix a bare transcript turn is treated as a TTS request and the model
|
||||
# emits literal <speechcodec_N> tokens instead of a text answer (the system
|
||||
# prompt alone does not prevent this). The 2B must NOT use this recipe: it
|
||||
# is long-verified stable on a bare prompt + engine-default sampling, while
|
||||
# the official temp-1.0 seed-0 draw hallucinates off-language text (yet
|
||||
# closes </think> cleanly, so the seed retry never fires) and fails the
|
||||
# round-trip WER gate — measured, reproduced byte-identically.
|
||||
TEXT_INSTRUCTION = (
|
||||
"SYSTEM & FORMATTING INSTRUCTION: You are Audex, created by NVIDIA based on the Nemotron-Cascade-2 architecture. "
|
||||
"You may output your reasoning, followed by your final response. "
|
||||
"You may format your reasoning block however you like. "
|
||||
"However, your final response must strictly follow these rules: "
|
||||
"* Write in plain, unformatted prose like a book or newspaper article. "
|
||||
"* Do not use markdown, bullet points, lists, or headers. "
|
||||
"* Standard numbers, abbreviations, and symbols are acceptable. "
|
||||
"* [CRITICAL] You must press enter after every single sentence, placing each sentence on its own separate line."
|
||||
)
|
||||
|
||||
|
||||
def is_30b(model: str) -> bool:
|
||||
# Users pass arbitrary repo IDs or local snapshot paths, so this is a
|
||||
# heuristic (not an exact match against the official 30B-A3B repo ID)
|
||||
# — good enough for an example script; keep in sync with the same
|
||||
# branch in examples/online_serving/audex/client.py and
|
||||
# tests/e2e/offline_inference/test_audex_s2s.py.
|
||||
return "30b" in model.lower()
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Offline Audex cascaded S2S")
|
||||
parser.add_argument("--model", type=str, default="nvidia/Nemotron-Labs-Audex-2B")
|
||||
parser.add_argument(
|
||||
"--audio-file",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Spoken question (WAV). Defaults to vLLM's mary_had_lamb audio asset.",
|
||||
)
|
||||
parser.add_argument("--output", type=str, default="results/audex_s2s_answer.wav")
|
||||
parser.add_argument(
|
||||
"--deploy-config",
|
||||
type=str,
|
||||
# The model root's default deploy yaml is the TTS-only pipeline; the
|
||||
# cascade needs the audio-capable 2-stage full pipeline.
|
||||
default=default_deploy_config("audex_s2s.yaml"),
|
||||
help="Deploy yaml (defaults to the audex_s2s pipeline).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cfg-scale",
|
||||
type=float,
|
||||
default=1.5,
|
||||
help="CFG strength for the TTS pass (1.0 disables; official setting 1.5).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _chat_prompt(user_text: str) -> str:
|
||||
# Official chat-pass recipe: system prompt + formatting instruction
|
||||
# prefixed to the user text, thinking ENABLED via an open <think> block
|
||||
# (the official demo's web UI ships "Enable reasoning" checked, i.e.
|
||||
# build_chat_prompt(..., enable_thinking=True); chat_template.jinja then
|
||||
# renders "<|im_start|>assistant\n<think>\n"). The instruction invites
|
||||
# the model to reason before answering — priming the block open keeps
|
||||
# that reasoning inside <think>...</think> so _extract_final_response
|
||||
# below can drop it; with a closed block the 30B leaks the reasoning as
|
||||
# plain text in front of the answer.
|
||||
return (
|
||||
f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
|
||||
f"<|im_start|>user\n{TEXT_INSTRUCTION}\n\n{user_text}<|im_end|>\n"
|
||||
"<|im_start|>assistant\n<think>\n"
|
||||
)
|
||||
|
||||
|
||||
def _asr_prompt() -> str:
|
||||
return f"<|im_start|>user\n<so_embedding>\n{ASR_QUESTION}<|im_end|>\n<|im_start|>assistant\n<think></think>"
|
||||
|
||||
|
||||
def _extract_final_response(text: str) -> str:
|
||||
# Mirror the official demo's extract_spoken_answer
|
||||
# (cascaded_s2s_web_server.py): keep only what follows the LAST closing
|
||||
# think tag, falling back to the full text when the model skipped
|
||||
# reasoning or never closed the block. Benign no-op for the 2B, whose
|
||||
# answers carry no think block.
|
||||
if "</think>" in text:
|
||||
answer = text.rsplit("</think>", 1)[-1].strip()
|
||||
if answer:
|
||||
return answer
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
# The full checkpoint prefixes transcripts with a language tag sentence
|
||||
# and quotes the content; extract the quoted payload when present. Span
|
||||
# first-to-last quote (not split on every apostrophe) so contractions
|
||||
# inside the transcript (e.g. "don't") survive intact.
|
||||
text = text.strip()
|
||||
match = re.search(r"'(.*)'", text, flags=re.S)
|
||||
if match and match.group(1).strip():
|
||||
return match.group(1).strip()
|
||||
return text
|
||||
|
||||
|
||||
def _text_of(outputs) -> str:
|
||||
(req_output,) = outputs
|
||||
return (req_output.outputs[0].text or "").strip()
|
||||
|
||||
|
||||
CHAT_MAX_TOKENS = 2048 # official --text-max-tokens default
|
||||
# The official demo samples this pass unseeded; some trajectories deliberate
|
||||
# past the token budget without ever closing the think block, so retry a few
|
||||
# deterministic seeds until one closes (see the chat pass below).
|
||||
CHAT_SEEDS = (0, 1, 2)
|
||||
|
||||
|
||||
def _chat_params(engine: Omni, seed: int):
|
||||
# Official demo text-pass sampling (cascaded_s2s_web_server.py defaults:
|
||||
# --text-temperature 1.0, --text-top-p 0.95, --text-max-tokens 2048).
|
||||
# The yaml stage-0 defaults (temperature 0.1, top_k 80) are TTS-thinker
|
||||
# settings; under near-greedy decoding the 30B loops inside its
|
||||
# reasoning block until the token budget runs out and never emits
|
||||
# </think>, so the extracted "answer" would be truncated reasoning.
|
||||
params = copy.deepcopy(engine.resolve_sampling_params_list(None))
|
||||
stage0 = params[0]
|
||||
stage0.temperature = 1.0
|
||||
stage0.top_p = 0.95
|
||||
stage0.top_k = -1
|
||||
stage0.max_tokens = CHAT_MAX_TOKENS
|
||||
stage0.seed = seed
|
||||
return params
|
||||
|
||||
|
||||
def _chat_once(engine: Omni, prompt: str, seed: int) -> tuple[str, bool]:
|
||||
(req_output,) = engine.generate([{"prompt": prompt, "modalities": ["text"]}], _chat_params(engine, seed))
|
||||
out = req_output.outputs[0]
|
||||
text = (out.text or "").strip()
|
||||
truncated = getattr(out, "finish_reason", None) == "length" or len(out.token_ids or []) >= CHAT_MAX_TOKENS
|
||||
return text, truncated
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
engine = Omni(model=args.model, deploy_config=args.deploy_config, trust_remote_code=True)
|
||||
|
||||
tokenizer = None
|
||||
if args.cfg_scale > 1.0:
|
||||
tokenizer = load_audex_tokenizer(args.model, profile="full", folder="checkpoint_folder_full")
|
||||
|
||||
if args.audio_file:
|
||||
audio, sr = load_audio_file(args.audio_file)
|
||||
else:
|
||||
audio, sr = AudioAsset("mary_had_lamb").audio_and_sample_rate
|
||||
|
||||
# Pass 1 — ASR (text-final; never touches the speech decoder).
|
||||
transcript = _clean(
|
||||
_text_of(
|
||||
engine.generate(
|
||||
[
|
||||
{
|
||||
"prompt": _asr_prompt(),
|
||||
"multi_modal_data": {"audio": (audio, sr)},
|
||||
"modalities": ["text"],
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
print(f"[1/3] transcript : {transcript}")
|
||||
if not transcript:
|
||||
raise SystemExit("ASR pass produced an empty transcript; aborting cascade")
|
||||
|
||||
# Pass 2 — chat answer (text-final). The recipe is size-conditional (see
|
||||
# the TEXT_INSTRUCTION comment above): the 30B requires the official
|
||||
# recipe, the 2B regresses under it.
|
||||
if is_30b(args.model):
|
||||
# Official recipe. The raw completion is
|
||||
# <reasoning...></think><answer>; speak only the final response.
|
||||
# Retry with the next seed only when the budget ran out before the
|
||||
# reasoning block closed (a natural stop without </think> is a
|
||||
# complete answer); if every seed spins, fall back to the official
|
||||
# demo's behavior of keeping the full text.
|
||||
raw = ""
|
||||
for seed in CHAT_SEEDS:
|
||||
raw, truncated = _chat_once(engine, _chat_prompt(transcript), seed)
|
||||
if "</think>" in raw or not truncated:
|
||||
break
|
||||
print(f"[2/3] chat seed {seed} hit the token budget mid-reasoning; retrying")
|
||||
answer = _extract_final_response(raw)
|
||||
else:
|
||||
# 2B: bare prompt with a closed think block, engine-default sampling
|
||||
# (yaml near-greedy), no retry; the answer is used as-is.
|
||||
chat_prompt = f"<|im_start|>user\n{transcript}<|im_end|>\n<|im_start|>assistant\n<think></think>"
|
||||
answer = _text_of(engine.generate([{"prompt": chat_prompt, "modalities": ["text"]}]))
|
||||
print(f"[2/3] answer : {answer}")
|
||||
if not answer:
|
||||
raise SystemExit("Chat pass produced an empty answer; aborting cascade")
|
||||
|
||||
# Pass 3 — TTS (audio-final; streams through code2wav). Both final
|
||||
# stages of the pipeline may emit an output record for the request;
|
||||
# collect the audio-bearing one.
|
||||
# The formatting instruction puts every sentence on its own line;
|
||||
# flatten to plain prose for the TTS thinker.
|
||||
tts_prompt = build_cond_prompt(re.sub(r"\s+", " ", answer).strip())
|
||||
outputs = engine.generate(
|
||||
[{"prompt": tts_prompt, "modalities": ["audio"]}],
|
||||
guided_stage_params(engine, tts_prompt, tokenizer, args.cfg_scale, "s2s-tts-pass"),
|
||||
)
|
||||
chunks: list[torch.Tensor] = []
|
||||
for req_output in outputs:
|
||||
mm = getattr(req_output, "multimodal_output", None) or {}
|
||||
if not mm and req_output.outputs:
|
||||
mm = getattr(req_output.outputs[0], "multimodal_output", None) or {}
|
||||
audio_val = mm.get("model_outputs", mm.get("audio"))
|
||||
if audio_val is None:
|
||||
continue
|
||||
if isinstance(audio_val, list):
|
||||
chunks.extend(torch.as_tensor(a).float().cpu().reshape(-1) for a in audio_val if a is not None)
|
||||
else:
|
||||
chunks.append(torch.as_tensor(audio_val).float().cpu().reshape(-1))
|
||||
pcm = torch.cat(chunks) if chunks else torch.empty(0)
|
||||
if pcm.numel() == 0:
|
||||
raise SystemExit("TTS pass produced empty audio")
|
||||
|
||||
out_path = Path(args.output)
|
||||
write_wav(out_path, pcm)
|
||||
print(f"[3/3] speech : {pcm.numel() / SAMPLE_RATE:.2f}s -> {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,157 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline Audex (Nemotron-Labs-Audex-2B) text-to-audio inference example.
|
||||
|
||||
Caption → general audio through the vLLM-Omni TTA pipeline: the audiogen
|
||||
thinker generates interleaved 4-codebook <audiocodec_N> RVQ tokens under an
|
||||
RVQ phase mask, and the external XCodec1 checkpoint decodes them to a 16 kHz
|
||||
mono WAV.
|
||||
|
||||
(For DIFFUSION text-to-audio models — Stable Audio Open, AudioX — use
|
||||
``examples/offline_inference/text_to_audio/text_to_audio.py`` instead; Audex
|
||||
TTA is a two-stage autoregressive pipeline with a different interface.)
|
||||
|
||||
Classifier-free guidance is effectively mandatory for TTA quality (official
|
||||
default scale 3.0), so every request submits a cond/uncond pair; requests
|
||||
run one at a time.
|
||||
|
||||
Pass the HF repo ROOT as --model. XCodec1 resolves from --xcodec1-path /
|
||||
XCODEC1_PATH / the default hf-audio repo.
|
||||
|
||||
Example:
|
||||
|
||||
python examples/offline_inference/audex/text_to_audio.py \\
|
||||
--captions "Heavy rain falling on a tin roof." \\
|
||||
--output-dir results/audex_tta_wavs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from common import (
|
||||
SAMPLE_RATE,
|
||||
default_deploy_config,
|
||||
extract_pcm,
|
||||
guided_stage_params,
|
||||
load_audex_tokenizer,
|
||||
load_corpus,
|
||||
write_wav,
|
||||
)
|
||||
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_tta_cond_prompt, build_tta_null_prompt
|
||||
from vllm_omni.model_executor.models.audex.tta import build_tta_phase_token_ids
|
||||
|
||||
DEFAULT_CAPTIONS = (
|
||||
"Heavy rain falling on a tin roof.",
|
||||
"A dog barking in the distance while birds chirp.",
|
||||
"Ocean waves crashing on a rocky shore.",
|
||||
)
|
||||
DEFAULT_CODEC_CAP = 4000
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Offline Audex TTA inference")
|
||||
parser.add_argument("--model", type=str, default="nvidia/Nemotron-Labs-Audex-2B")
|
||||
parser.add_argument("--captions", type=str, nargs="+", default=None, help="Audio captions.")
|
||||
parser.add_argument(
|
||||
"--captions-file",
|
||||
type=str,
|
||||
default=None,
|
||||
help="TSV corpus: one 'utt_id<TAB>caption' per line (overrides --captions).",
|
||||
)
|
||||
parser.add_argument("--output-dir", type=str, default="results/audex_tta_wavs")
|
||||
parser.add_argument(
|
||||
"--deploy-config",
|
||||
type=str,
|
||||
# The model root's default deploy yaml is the TTS pipeline; TTA
|
||||
# prompts and RVQ sampling params require the dedicated TTA pipeline.
|
||||
default=default_deploy_config("audex_tta.yaml"),
|
||||
help="Deploy yaml (defaults to the audex_tta pipeline).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--xcodec1-path",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Local XCodec1 checkpoint (defaults to $XCODEC1_PATH or the hf-audio repo).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cfg-scale",
|
||||
type=float,
|
||||
default=3.0,
|
||||
help="Classifier-free guidance strength (official TTA default 3.0; 1.0 disables).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--codec-cap",
|
||||
type=int,
|
||||
default=DEFAULT_CODEC_CAP,
|
||||
help="Max generated codec tokens before the phase mask forces <audiogen_end>.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
if args.xcodec1_path:
|
||||
os.environ["XCODEC1_PATH"] = args.xcodec1_path
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
corpus = load_corpus(args.captions_file, args.captions, DEFAULT_CAPTIONS)
|
||||
|
||||
tokenizer = load_audex_tokenizer(args.model, profile="tta")
|
||||
phase_token_ids, start_tid, end_tid = build_tta_phase_token_ids(tokenizer)
|
||||
tta_rvq = {
|
||||
"phase_token_ids": phase_token_ids,
|
||||
"start_tid": start_tid,
|
||||
"end_tid": end_tid,
|
||||
"codec_cap": args.codec_cap,
|
||||
# The prompt ends with <audiogen_start>, so generation begins at
|
||||
# RVQ phase 0 immediately.
|
||||
"start_in_prompt": True,
|
||||
}
|
||||
|
||||
engine = Omni(model=args.model, deploy_config=args.deploy_config, trust_remote_code=True)
|
||||
|
||||
print(f"Model : {args.model}")
|
||||
print(f"Captions : {len(corpus)}")
|
||||
print(f"CFG scale : {args.cfg_scale}")
|
||||
print(f"Codec cap : {args.codec_cap}")
|
||||
print(f"Output dir : {output_dir}")
|
||||
|
||||
total_elapsed = 0.0
|
||||
total_dur = 0.0
|
||||
for utt, caption in corpus:
|
||||
cond_prompt = build_tta_cond_prompt(caption)
|
||||
sampling = guided_stage_params(
|
||||
engine,
|
||||
cond_prompt,
|
||||
tokenizer,
|
||||
args.cfg_scale,
|
||||
f"tta-{utt}",
|
||||
extra_args={"tta_rvq": tta_rvq},
|
||||
null_prompt_builder=build_tta_null_prompt,
|
||||
)
|
||||
t_start = time.perf_counter()
|
||||
outputs = engine.generate([cond_prompt], sampling)
|
||||
total_elapsed += time.perf_counter() - t_start
|
||||
|
||||
(req_output,) = outputs
|
||||
pcm = extract_pcm(req_output.outputs[0].multimodal_output)
|
||||
dur = pcm.numel() / SAMPLE_RATE
|
||||
if dur <= 0:
|
||||
raise RuntimeError(f"empty audio for {utt}")
|
||||
out_path = output_dir / f"{utt}.wav"
|
||||
write_wav(out_path, pcm)
|
||||
total_dur += dur
|
||||
print(f" {utt:<48} dur={dur:6.2f}s -> {out_path}")
|
||||
|
||||
rtf = total_elapsed / total_dur if total_dur > 0 else float("inf")
|
||||
print(f"Total infer : {total_elapsed:.2f}s total audio: {total_dur:.2f}s RTF: {rtf:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,162 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline Audex (Nemotron-Labs-Audex-2B) TTS inference example.
|
||||
|
||||
Runs Stage 0 (thinker) + Stage 1 (streaming causal speech decoder) end-to-end
|
||||
through the vLLM-Omni engine and saves a 16 kHz mono WAV per prompt.
|
||||
|
||||
Pass the HF repo ROOT as --model; per-stage subfolders resolve automatically.
|
||||
|
||||
Examples:
|
||||
|
||||
python examples/offline_inference/audex/text_to_speech.py \\
|
||||
--texts "The weather is so good today." \\
|
||||
--output-dir results/audex_wavs
|
||||
|
||||
# TSV corpus (utt_id<TAB>text per line), all requests in flight at once:
|
||||
python examples/offline_inference/audex/text_to_speech.py \\
|
||||
--texts-file corpus.tsv --output-dir results/audex_wavs
|
||||
|
||||
# One request at a time:
|
||||
python examples/offline_inference/audex/text_to_speech.py \\
|
||||
--texts-file corpus.tsv --sequential --output-dir results/audex_wavs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from common import (
|
||||
GUIDED_TEMPERATURE,
|
||||
SAMPLE_RATE,
|
||||
extract_pcm,
|
||||
guided_stage_params,
|
||||
load_audex_tokenizer,
|
||||
load_corpus,
|
||||
request_index,
|
||||
write_wav,
|
||||
)
|
||||
|
||||
from vllm_omni import Omni
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt
|
||||
|
||||
DEFAULT_TEXTS = (
|
||||
"Hello world.",
|
||||
"The quick brown fox jumps over the lazy dog.",
|
||||
"Today is a beautiful day for a walk in the park.",
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Offline Audex TTS inference")
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default="nvidia/Nemotron-Labs-Audex-2B",
|
||||
help="HF repo root (or local snapshot root).",
|
||||
)
|
||||
parser.add_argument("--texts", type=str, nargs="+", default=None, help="Plain-text prompts.")
|
||||
parser.add_argument(
|
||||
"--texts-file",
|
||||
type=str,
|
||||
default=None,
|
||||
help="TSV corpus: one 'utt_id<TAB>text' per line (overrides --texts).",
|
||||
)
|
||||
parser.add_argument("--output-dir", type=str, default="results/audex_wavs")
|
||||
parser.add_argument("--deploy-config", type=str, default=None, help="Override the deploy config path.")
|
||||
parser.add_argument(
|
||||
"--sequential",
|
||||
action="store_true",
|
||||
help="Submit one request at a time instead of all at once.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cfg-scale",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help=(
|
||||
"Classifier-free guidance strength. 1.0 disables guidance (default, "
|
||||
"matches the v1 baseline); 1.5 is the official quality setting. "
|
||||
"Guided runs submit one request at a time (each carries its own "
|
||||
"length-matched null prompt and pair id)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--temperature",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Override the deploy yaml's stage-0 sampling temperature.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _write_outputs(outputs, batch: list[tuple[str, str]], output_dir: Path) -> float:
|
||||
ordered = sorted(outputs, key=request_index)
|
||||
if len(ordered) != len(batch):
|
||||
raise RuntimeError(f"expected {len(batch)} outputs, got {len(ordered)}")
|
||||
|
||||
total_dur = 0.0
|
||||
for (utt, _text), req_output in zip(batch, ordered):
|
||||
pcm = extract_pcm(req_output.outputs[0].multimodal_output)
|
||||
dur = pcm.numel() / SAMPLE_RATE
|
||||
if dur <= 0:
|
||||
raise RuntimeError(f"empty audio for {utt}")
|
||||
out_path = output_dir / f"{utt}.wav"
|
||||
write_wav(out_path, pcm)
|
||||
total_dur += dur
|
||||
print(f" {utt:<40} dur={dur:6.2f}s -> {out_path}")
|
||||
return total_dur
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
corpus = load_corpus(args.texts_file, args.texts, DEFAULT_TEXTS)
|
||||
|
||||
engine = Omni(model=args.model, deploy_config=args.deploy_config, trust_remote_code=True)
|
||||
|
||||
cfg_enabled = args.cfg_scale > 1.0
|
||||
tokenizer = load_audex_tokenizer(args.model) if cfg_enabled else None
|
||||
|
||||
print(f"Model : {args.model}")
|
||||
print(f"Prompts : {len(corpus)}")
|
||||
print(f"Mode : {'sequential' if args.sequential or cfg_enabled else 'batched'}")
|
||||
print(f"CFG scale : {args.cfg_scale}" + (" (guidance off)" if not cfg_enabled else ""))
|
||||
print(f"Output dir : {output_dir}")
|
||||
|
||||
total_elapsed = 0.0
|
||||
total_dur = 0.0
|
||||
# CFG runs one request at a time: each request needs its own pair id and
|
||||
# text-specific null prompt in the stage-0 sampling params.
|
||||
batches = [[item] for item in corpus] if (args.sequential or cfg_enabled) else [corpus]
|
||||
for batch in batches:
|
||||
prompts = [build_cond_prompt(text) for _utt, text in batch]
|
||||
sampling_params_list = None
|
||||
if cfg_enabled:
|
||||
sampling_params_list = guided_stage_params(
|
||||
engine,
|
||||
prompts[0],
|
||||
tokenizer,
|
||||
args.cfg_scale,
|
||||
f"cfg-{batch[0][0]}",
|
||||
temperature=GUIDED_TEMPERATURE,
|
||||
)
|
||||
if args.temperature is not None:
|
||||
import copy
|
||||
|
||||
if sampling_params_list is None:
|
||||
sampling_params_list = copy.deepcopy(engine.resolve_sampling_params_list(None))
|
||||
sampling_params_list[0].temperature = args.temperature
|
||||
t_start = time.perf_counter()
|
||||
outputs = engine.generate(prompts, sampling_params_list)
|
||||
total_elapsed += time.perf_counter() - t_start
|
||||
total_dur += _write_outputs(outputs, batch, output_dir)
|
||||
|
||||
rtf = total_elapsed / total_dur if total_dur > 0 else float("inf")
|
||||
print(f"Total infer : {total_elapsed:.2f}s total audio: {total_dur:.2f}s RTF: {rtf:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,89 @@
|
||||
# Audex (Nemotron-Labs-Audex-2B) online serving
|
||||
|
||||
One checkpoint, four deployment modes. `run_server.sh` starts the server
|
||||
for a given `MODE`; `client.py --mode <mode>` tests it. The capability
|
||||
matrix:
|
||||
|
||||
| mode (`vllm_omni/deploy/audex_<mode>.yaml`) | audio in | text out | speech out | general audio out | endpoint |
|
||||
|---|---|---|---|---|---|
|
||||
| `tts` | ❌ | ❌ | ✅ | ❌ | `/v1/audio/speech` |
|
||||
| `tta` | ❌ | ❌ | ❌ | ✅ | `/v1/audio/speech` |
|
||||
| `thinker_only` | ✅ | ✅ | ❌ | ❌ | `/v1/chat/completions` |
|
||||
| `s2s` | ✅ | ✅ | ✅ | ❌ | both |
|
||||
|
||||
Audio-input modes of `client.py` fall back to vLLM's public
|
||||
`mary_had_lamb` asset when `--audio-file` is omitted; CFG defaults follow
|
||||
the official settings per mode (tts/s2s 1.5, tta 3.0; `--cfg-scale 1.0`
|
||||
disables).
|
||||
|
||||
## tts — text → speech
|
||||
|
||||
Text-only thinker (`checkpoint_folder_audiogen`) emits `<speechcodec_N>`
|
||||
tokens; the streaming causal decoder produces 16 kHz WAVs. This is also
|
||||
the DEFAULT pipeline when the repo root is served without a deploy config.
|
||||
|
||||
```bash
|
||||
./run_server.sh # MODE=tts, port 8097
|
||||
|
||||
python client.py --mode tts --text "Hello there." --output hello.wav
|
||||
# or raw curl:
|
||||
curl -s http://localhost:8097/v1/audio/speech \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"model": "nvidia/Nemotron-Labs-Audex-2B", "input": "Hello there.", "response_format": "wav", "extra_params": {"cfg_scale": 1.5}}' \
|
||||
-o hello.wav
|
||||
```
|
||||
|
||||
## tta — caption → general audio
|
||||
|
||||
Same thinker over the 4-codebook `<audiocodec_N>` RVQ block, decoded by
|
||||
the external XCodec1 checkpoint (auto-downloaded; override with
|
||||
`XCODEC1_PATH`). Clips are capped at 10 s.
|
||||
|
||||
```bash
|
||||
MODE=tta ./run_server.sh
|
||||
|
||||
python client.py --mode tta --caption "Heavy rain falling on a tin roof." \
|
||||
--output rain.wav
|
||||
```
|
||||
|
||||
## thinker_only — audio (+ instruction) → text
|
||||
|
||||
Single-stage audio understanding on the full checkpoint (NV-Whisper
|
||||
encoder + projector + LM). Send the clip as `input_audio` chat content.
|
||||
|
||||
```bash
|
||||
MODE=thinker_only ./run_server.sh
|
||||
|
||||
python client.py --mode thinker_only # transcribe the demo asset
|
||||
python client.py --mode thinker_only --audio-file a.wav \
|
||||
--question "What language is being spoken?"
|
||||
```
|
||||
|
||||
## s2s — spoken question → spoken answer
|
||||
|
||||
The audio-capable thinker plus the speech decoder in one deployment.
|
||||
`client.py --mode s2s` runs the official three passes: ASR (chat with
|
||||
`input_audio`) → chat on the transcript → TTS on the answer. Text-final
|
||||
passes carry `"modalities": ["text"]`; only the TTS pass streams through
|
||||
the decoder.
|
||||
|
||||
```bash
|
||||
MODE=s2s PORT=8098 ./run_server.sh
|
||||
|
||||
python client.py --mode s2s --port 8098 \
|
||||
--audio-file question.wav --output answer.wav
|
||||
```
|
||||
|
||||
Offline counterparts (no HTTP, one `Omni` engine per script) live in
|
||||
`examples/offline_inference/audex/`.
|
||||
|
||||
## 30B-A3B (nvidia/Nemotron-Labs-Audex-30B-A3B)
|
||||
|
||||
`SIZE=30b` switches run_server.sh to the 30B yamls and model id:
|
||||
|
||||
SIZE=30b ./run_server.sh # 30B tts on port 8097
|
||||
SIZE=30b MODE=s2s PORT=8098 ./run_server.sh
|
||||
|
||||
Pass `--model nvidia/Nemotron-Labs-Audex-30B-A3B` to the client — the server
|
||||
validates the model id and 404s on a mismatch. Single-H100 defaults; see the
|
||||
yaml comments for the TP2 fallback. First launch downloads ~60 GB.
|
||||
@@ -0,0 +1,319 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Audex online client for all four deployment modes.
|
||||
|
||||
Pick ``--mode`` to match the server's deploy yaml (see run_server.sh):
|
||||
|
||||
tts /v1/audio/speech: text -> speech WAV
|
||||
tta /v1/audio/speech: caption -> general-audio WAV
|
||||
thinker_only /v1/chat/completions: audio (+ question) -> text
|
||||
s2s the official three-pass cascade against one audex_s2s
|
||||
server: ASR (chat, input_audio) -> chat -> TTS
|
||||
|
||||
Audio-input modes fall back to vLLM's public ``mary_had_lamb`` asset when
|
||||
``--audio-file`` is omitted.
|
||||
|
||||
Examples:
|
||||
|
||||
python examples/online_serving/audex/client.py --mode tts \\
|
||||
--text "Hello there." --output hello.wav
|
||||
python examples/online_serving/audex/client.py --mode tta \\
|
||||
--caption "Heavy rain falling on a tin roof." --output rain.wav
|
||||
python examples/online_serving/audex/client.py --mode thinker_only
|
||||
python examples/online_serving/audex/client.py --mode s2s \\
|
||||
--audio-file question.wav --output answer.wav
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
ASR_QUESTION = "Transcribe the input speech."
|
||||
SYSTEM_PROMPT = "You are a helpful and harmless assistant.\n\nYou are not allowed to use any tools."
|
||||
# The official chat-pass recipe below (SYSTEM_PROMPT + TEXT_INSTRUCTION
|
||||
# prefix, reasoning enabled, temp 1.0/top_p 0.95, seed retry) applies to the
|
||||
# 30B-A3B ONLY (see is_30b() below and
|
||||
# tests/e2e/offline_inference/test_audex_s2s.py, which mirrors this same
|
||||
# split). It is load-bearing on the 30B: without the formatting-instruction
|
||||
# prefix a bare transcript turn is treated as a TTS request and the model
|
||||
# emits literal <speechcodec_N> tokens instead of a text answer (the system
|
||||
# prompt alone does not prevent this). The 2B must NOT use this recipe: the
|
||||
# official temp-1.0 seed-0 draw measurably hallucinates off-language text on
|
||||
# the 2B (yet closes </think> cleanly, so the seed retry never fires) and
|
||||
# fails the round-trip WER gate — measured, reproduced byte-identically. The
|
||||
# 2B instead keeps its original bare-message / enable_thinking=False /
|
||||
# temperature=0.0 recipe.
|
||||
TEXT_INSTRUCTION = (
|
||||
"SYSTEM & FORMATTING INSTRUCTION: You are Audex, created by NVIDIA based on the Nemotron-Cascade-2 architecture. "
|
||||
"You may output your reasoning, followed by your final response. "
|
||||
"You may format your reasoning block however you like. "
|
||||
"However, your final response must strictly follow these rules: "
|
||||
"* Write in plain, unformatted prose like a book or newspaper article. "
|
||||
"* Do not use markdown, bullet points, lists, or headers. "
|
||||
"* Standard numbers, abbreviations, and symbols are acceptable. "
|
||||
"* [CRITICAL] You must press enter after every single sentence, placing each sentence on its own separate line."
|
||||
)
|
||||
DEFAULT_TEXT = "Hello there! This is the Audex text to speech pipeline."
|
||||
DEFAULT_CAPTION = "Heavy rain falling on a tin roof."
|
||||
# Official quality settings; 1.0 disables guidance.
|
||||
DEFAULT_CFG = {"tts": 1.5, "tta": 3.0, "s2s": 1.5}
|
||||
|
||||
|
||||
def is_30b(model: str) -> bool:
|
||||
# Users pass arbitrary repo IDs or local snapshot paths, so this is a
|
||||
# heuristic (not an exact match against the official 30B-A3B repo ID)
|
||||
# — good enough for an example script; keep in sync with the same
|
||||
# branch in examples/offline_inference/audex/speech_to_speech.py and
|
||||
# tests/e2e/offline_inference/test_audex_s2s.py.
|
||||
return "30b" in model.lower()
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Audex online client (tts / tta / thinker_only / s2s)")
|
||||
parser.add_argument("--mode", choices=("tts", "tta", "thinker_only", "s2s"), default="s2s")
|
||||
parser.add_argument("--host", type=str, default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8097)
|
||||
parser.add_argument("--model", type=str, default="nvidia/Nemotron-Labs-Audex-2B")
|
||||
parser.add_argument("--text", type=str, default=DEFAULT_TEXT, help="TTS input text (mode=tts).")
|
||||
parser.add_argument("--caption", type=str, default=DEFAULT_CAPTION, help="TTA caption (mode=tta).")
|
||||
parser.add_argument(
|
||||
"--audio-file",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Input WAV for thinker_only/s2s. Defaults to vLLM's mary_had_lamb asset.",
|
||||
)
|
||||
parser.add_argument("--question", type=str, default=ASR_QUESTION, help="Instruction (mode=thinker_only).")
|
||||
parser.add_argument("--output", type=str, default="results/audex_online.wav")
|
||||
parser.add_argument(
|
||||
"--cfg-scale",
|
||||
type=float,
|
||||
default=None,
|
||||
help="CFG strength; defaults per mode (tts/s2s 1.5, tta 3.0). 1.0 disables.",
|
||||
)
|
||||
parser.add_argument("--max-tokens", type=int, default=512)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _audio_b64(audio_file: str | None) -> str:
|
||||
if audio_file:
|
||||
return base64.b64encode(Path(audio_file).read_bytes()).decode()
|
||||
# Fall back to vLLM's public asset, re-encoded as WAV for the chat API.
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from vllm.assets.audio import AudioAsset
|
||||
|
||||
audio, sr = AudioAsset("mary_had_lamb").audio_and_sample_rate
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, np.asarray(audio, dtype="float32"), int(sr), format="WAV")
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def _chat(
|
||||
base_url: str,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
max_tokens: int,
|
||||
enable_thinking: bool = False,
|
||||
temperature: float = 0.0,
|
||||
top_p: float = 1.0,
|
||||
seed: int = 0,
|
||||
) -> tuple[str, str]:
|
||||
resp = requests.post(
|
||||
f"{base_url}/v1/chat/completions",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
"seed": seed,
|
||||
# Text-final passes must say so explicitly: without "modalities"
|
||||
# the server falls back to the deployment's configured output
|
||||
# modalities, which for the s2s pipeline include the audio final
|
||||
# stage — routing ASR/chat through code2wav instead of stopping
|
||||
# at stage 0.
|
||||
"modalities": ["text"],
|
||||
# The checkpoint's chat template thinking toggle. The official
|
||||
# cascaded demo transcribes with enable_thinking=False but runs
|
||||
# the chat pass with reasoning ENABLED (its web UI ships the
|
||||
# "Enable reasoning" checkbox checked), priming the generation
|
||||
# prompt with an open "<think>\n" so the model's reasoning stays
|
||||
# inside the think block; _extract_final_response then drops it.
|
||||
# With thinking disabled the 30B leaks its reasoning as plain
|
||||
# text in front of the final response.
|
||||
"chat_template_kwargs": {"enable_thinking": enable_thinking},
|
||||
},
|
||||
timeout=300,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
choice = resp.json()["choices"][0]
|
||||
return choice["message"]["content"].strip(), choice.get("finish_reason") or ""
|
||||
|
||||
|
||||
def _speech(base_url: str, model: str, text: str, cfg_scale: float, out_path: Path) -> None:
|
||||
payload: dict = {"model": model, "input": text, "response_format": "wav"}
|
||||
if cfg_scale > 1.0:
|
||||
payload["extra_params"] = {"cfg_scale": cfg_scale}
|
||||
resp = requests.post(f"{base_url}/v1/audio/speech", json=payload, timeout=600)
|
||||
resp.raise_for_status()
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(resp.content)
|
||||
|
||||
|
||||
def _audio_question(audio_b64: str, question: str) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}},
|
||||
{"type": "text", "text": question},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _tts_text(answer: str) -> str:
|
||||
# The formatting instruction's CRITICAL rule puts each final-response
|
||||
# sentence on its own line, and the official demo TTS-es those sentence
|
||||
# segments (split_sentence_segments). Mirror that per-line segmentation
|
||||
# but keep a single /v1/audio/speech request: speak the leading whole
|
||||
# sentences of the CLEANED final response up to ~200 chars, never
|
||||
# cutting mid-sentence. Markdown markup is stripped as before (it makes
|
||||
# the TTS thinker stop almost immediately).
|
||||
sentences = []
|
||||
for line in answer.splitlines():
|
||||
line = re.sub(r"[*#_`]+", "", line)
|
||||
line = re.sub(r"\s+", " ", line).strip()
|
||||
if line:
|
||||
sentences.append(line)
|
||||
picked: list[str] = []
|
||||
total = 0
|
||||
for sentence in sentences:
|
||||
if picked and total + len(sentence) > 200:
|
||||
break
|
||||
picked.append(sentence)
|
||||
total += len(sentence) + 1
|
||||
return " ".join(picked)
|
||||
|
||||
|
||||
def _extract_final_response(text: str) -> str:
|
||||
# Mirror the official demo's extract_spoken_answer
|
||||
# (cascaded_s2s_web_server.py): the chat pass reasons inside the primed
|
||||
# <think> block, so keep only what follows the LAST closing tag; fall
|
||||
# back to the full text when the model skipped reasoning or never
|
||||
# closed the block. Benign no-op for the 2B's clean answers.
|
||||
if "</think>" in text:
|
||||
answer = text.rsplit("</think>", 1)[-1].strip()
|
||||
if answer:
|
||||
return answer
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _clean_transcript(text: str) -> str:
|
||||
# The full checkpoint prefixes transcripts with a language-tag sentence
|
||||
# and quotes the content; extract the quoted payload when present. Span
|
||||
# first-to-last quote (not split on every apostrophe) so contractions
|
||||
# inside the transcript (e.g. "don't") survive intact.
|
||||
text = text.strip()
|
||||
match = re.search(r"'(.*)'", text, flags=re.S)
|
||||
if match and match.group(1).strip():
|
||||
return match.group(1).strip()
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
base_url = f"http://{args.host}:{args.port}"
|
||||
cfg_scale = args.cfg_scale if args.cfg_scale is not None else DEFAULT_CFG.get(args.mode, 1.0)
|
||||
out_path = Path(args.output)
|
||||
|
||||
if args.mode == "tts":
|
||||
_speech(base_url, args.model, args.text, cfg_scale, out_path)
|
||||
print(f"speech: {out_path.stat().st_size} bytes -> {out_path}")
|
||||
return
|
||||
|
||||
if args.mode == "tta":
|
||||
_speech(base_url, args.model, args.caption, cfg_scale, out_path)
|
||||
print(f"audio : {out_path.stat().st_size} bytes -> {out_path}")
|
||||
return
|
||||
|
||||
audio_b64 = _audio_b64(args.audio_file)
|
||||
|
||||
if args.mode == "thinker_only":
|
||||
content, _ = _chat(base_url, args.model, _audio_question(audio_b64, args.question), args.max_tokens)
|
||||
print(f"answer: {_extract_final_response(content)}")
|
||||
return
|
||||
|
||||
# s2s — the official three-pass cascade.
|
||||
asr_content, _ = _chat(base_url, args.model, _audio_question(audio_b64, ASR_QUESTION), args.max_tokens)
|
||||
transcript = _clean_transcript(asr_content)
|
||||
print(f"[1/3] transcript : {transcript}")
|
||||
if not transcript:
|
||||
raise SystemExit("ASR pass produced an empty transcript; aborting cascade")
|
||||
|
||||
# The chat-pass recipe is size-conditional (see the TEXT_INSTRUCTION
|
||||
# comment above): the 30B requires the official recipe, the 2B keeps its
|
||||
# original bare-message recipe.
|
||||
if is_30b(args.model):
|
||||
# Official recipe: system prompt + formatting instruction prefixed
|
||||
# to the transcript, reasoning enabled, and the official demo's
|
||||
# text-pass sampling (its defaults: --text-temperature 1.0,
|
||||
# --text-top-p 0.95, --text-max-tokens 2048). Near-greedy decoding
|
||||
# makes the 30B loop inside its reasoning block until the token
|
||||
# budget runs out without ever closing </think>. Even at the
|
||||
# official temperature some trajectories spin past the budget, so
|
||||
# retry a few deterministic seeds; a natural stop without </think>
|
||||
# is a complete answer, and if every seed spins we keep the full
|
||||
# text like the official demo does.
|
||||
chat_messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"{TEXT_INSTRUCTION}\n\n{transcript}"},
|
||||
]
|
||||
raw = ""
|
||||
for seed in (0, 1, 2):
|
||||
raw, finish_reason = _chat(
|
||||
base_url,
|
||||
args.model,
|
||||
chat_messages,
|
||||
max(args.max_tokens, 2048),
|
||||
enable_thinking=True,
|
||||
temperature=1.0,
|
||||
top_p=0.95,
|
||||
seed=seed,
|
||||
)
|
||||
if "</think>" in raw or finish_reason != "length":
|
||||
break
|
||||
print(f"[2/3] chat seed {seed} hit the token budget mid-reasoning; retrying")
|
||||
answer = _extract_final_response(raw)
|
||||
else:
|
||||
# 2B: bare user message (no system/instruction prefix), reasoning
|
||||
# disabled, temperature 0.0, no retry — its original recipe. The
|
||||
# official temp-1.0 seed-0 recipe measurably makes the 2B
|
||||
# hallucinate off-language answers (see the module-level comment).
|
||||
chat_messages = [{"role": "user", "content": transcript}]
|
||||
raw, _ = _chat(
|
||||
base_url,
|
||||
args.model,
|
||||
chat_messages,
|
||||
args.max_tokens,
|
||||
enable_thinking=False,
|
||||
temperature=0.0,
|
||||
)
|
||||
answer = _extract_final_response(raw)
|
||||
print(f"[2/3] answer : {answer}")
|
||||
if not answer:
|
||||
raise SystemExit("Chat pass produced an empty answer; aborting cascade")
|
||||
|
||||
_speech(base_url, args.model, _tts_text(answer), cfg_scale, out_path)
|
||||
print(f"[3/3] speech : {out_path.stat().st_size} bytes -> {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Launch the vLLM-Omni server for Audex (Nemotron-Labs-Audex-2B).
|
||||
#
|
||||
# MODE picks the deployment (see README.md for the capability matrix):
|
||||
# tts text -> speech /v1/audio/speech (default)
|
||||
# tta caption -> general audio /v1/audio/speech
|
||||
# thinker_only audio -> text /v1/chat/completions
|
||||
# s2s cascaded speech-to-speech (both endpoints)
|
||||
#
|
||||
# Pass the HF repo ROOT as MODEL — per-stage subfolders resolve
|
||||
# automatically.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_server.sh # 2B tts on port 8097, GPU 0
|
||||
# SIZE=30b ./run_server.sh # 30B-A3B tts (explicit 30B yaml)
|
||||
# SIZE=30b MODE=s2s PORT=8098 ./run_server.sh
|
||||
# MODE=tta ./run_server.sh # needs XCodec1 (auto-downloaded)
|
||||
# MODEL=/path/to/local/snapshot ./run_server.sh
|
||||
|
||||
set -e
|
||||
|
||||
MODE="${MODE:-${1:-tts}}"
|
||||
SIZE="${SIZE:-2b}"
|
||||
if [ "$SIZE" = "30b" ]; then
|
||||
MODEL="${MODEL:-nvidia/Nemotron-Labs-Audex-30B-A3B}"
|
||||
YAML_SUFFIX="_30b"
|
||||
elif [ "$SIZE" = "2b" ]; then
|
||||
MODEL="${MODEL:-nvidia/Nemotron-Labs-Audex-2B}"
|
||||
YAML_SUFFIX=""
|
||||
else
|
||||
echo "Unknown SIZE '$SIZE'; expected 2b|30b" >&2
|
||||
exit 1
|
||||
fi
|
||||
PORT="${PORT:-8097}"
|
||||
GPUS="${GPUS:-0}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
DEPLOY_YAML="$REPO_ROOT/vllm_omni/deploy/audex_${MODE}${YAML_SUFFIX}.yaml"
|
||||
if [ ! -f "$DEPLOY_YAML" ]; then
|
||||
echo "Unknown MODE '$MODE' / SIZE '$SIZE' (no $DEPLOY_YAML); expected MODE=tts|tta|thinker_only|s2s, SIZE=2b|30b" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting Audex server"
|
||||
echo " MODE=$MODE ($DEPLOY_YAML)"
|
||||
echo " SIZE=$SIZE"
|
||||
echo " MODEL=$MODEL"
|
||||
echo " PORT=$PORT"
|
||||
echo " CUDA_VISIBLE_DEVICES=$GPUS"
|
||||
|
||||
CUDA_VISIBLE_DEVICES="$GPUS" \
|
||||
vllm-omni serve "$MODEL" \
|
||||
--host 0.0.0.0 \
|
||||
--port "$PORT" \
|
||||
--trust-remote-code \
|
||||
--stage-configs-path "$DEPLOY_YAML" \
|
||||
--omni
|
||||
@@ -0,0 +1,142 @@
|
||||
# Nemotron-Labs Audex (2B / 30B-A3B)
|
||||
|
||||
> Speech suite on 1×H100: TTS, text-to-audio, audio understanding, and cascaded speech-to-speech
|
||||
|
||||
## Summary
|
||||
|
||||
- Vendor: NVIDIA
|
||||
- Model: `nvidia/Nemotron-Labs-Audex-2B`, `nvidia/Nemotron-Labs-Audex-30B-A3B`
|
||||
- Task: Text-to-speech (English), text-to-audio (sound effects), audio understanding (ASR / audio QA), cascaded speech-to-speech
|
||||
- Mode: Online serving (`/v1/audio/speech`, `/v1/chat/completions`) and offline batch inference
|
||||
- Maintainer: Community
|
||||
|
||||
## When to use this recipe
|
||||
|
||||
Use this recipe to serve either Audex checkpoint through one of its four
|
||||
deployment pipelines. Both sizes share the same repo layout, codec token
|
||||
space, and streaming causal speech decoder (16 kHz output); they differ in
|
||||
the thinker: the 2B is a dense LM, the 30B-A3B is a hybrid Mamba + MoE
|
||||
NemotronH (~3B active parameters). Pick the pipeline by task:
|
||||
|
||||
| pipeline (`vllm_omni/deploy/<name>[_30b].yaml`) | audio in | text out | speech out | general audio out | endpoint |
|
||||
|---|---|---|---|---|---|
|
||||
| `audex_tts` | ❌ | ❌ | ✅ | ❌ | `/v1/audio/speech` |
|
||||
| `audex_tta` | ❌ | ❌ | ❌ | ✅ | `/v1/audio/speech` |
|
||||
| `audex_thinker_only` | ✅ | ✅ | ❌ | ❌ | `/v1/chat/completions` |
|
||||
| `audex_s2s` | ✅ | ✅ | ✅ | ❌ | both |
|
||||
|
||||
## References
|
||||
|
||||
- Model cards: [Nemotron-Labs-Audex-2B](https://huggingface.co/nvidia/Nemotron-Labs-Audex-2B), [Nemotron-Labs-Audex-30B-A3B](https://huggingface.co/nvidia/Nemotron-Labs-Audex-30B-A3B)
|
||||
- Offline examples: [`examples/offline_inference/audex/`](../../examples/offline_inference/audex/)
|
||||
- Online examples: [`examples/online_serving/audex/`](../../examples/online_serving/audex/)
|
||||
|
||||
## Hardware Support
|
||||
|
||||
## GPU
|
||||
|
||||
### 1×H100 80GB — Audex-2B
|
||||
|
||||
#### Environment
|
||||
|
||||
- OS: Linux
|
||||
- Python: 3.12+
|
||||
- CUDA: 12.x
|
||||
- vLLM version: 0.24.0
|
||||
- vLLM-Omni version or commit: PR #4976 (branch `audex`)
|
||||
|
||||
#### Command
|
||||
|
||||
**Online serving** (TTS is the default pipeline; the repo root's
|
||||
`model_type` auto-resolves `audex_tts.yaml`):
|
||||
|
||||
```bash
|
||||
vllm-omni serve nvidia/Nemotron-Labs-Audex-2B \
|
||||
--host 0.0.0.0 --port 8097 \
|
||||
--trust-remote-code --omni
|
||||
# other modes: add --stage-configs-path vllm_omni/deploy/audex_{tta,thinker_only,s2s}.yaml
|
||||
# or use the launcher: MODE=s2s PORT=8098 examples/online_serving/audex/run_server.sh
|
||||
```
|
||||
|
||||
**Offline batch inference** (one script per task, each defaults to its
|
||||
correct deploy yaml):
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/audex/text_to_speech.py \
|
||||
--texts "Hello world." --cfg-scale 1.5 --output-dir results/audex_wavs
|
||||
python examples/offline_inference/audex/text_to_audio.py # sound effects
|
||||
python examples/offline_inference/audex/audio_qa.py # ASR / audio QA
|
||||
python examples/offline_inference/audex/speech_to_speech.py # 3-pass cascade
|
||||
```
|
||||
|
||||
#### Verification
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8097/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "nvidia/Nemotron-Labs-Audex-2B",
|
||||
"input": "Hello, how are you?",
|
||||
"response_format": "wav",
|
||||
"extra_params": {"cfg_scale": 1.5}
|
||||
}' --output hello.wav
|
||||
# or the universal client (all four modes):
|
||||
python examples/online_serving/audex/client.py --mode tts --output hello.wav
|
||||
```
|
||||
|
||||
#### Notes
|
||||
|
||||
- Memory usage: TTS deploy — stage 0 (thinker) 0.4, stage 1 (decoder) 0.25
|
||||
GPU-memory fraction; both stages share GPU 0.
|
||||
- Key flags: `--trust-remote-code` and `--omni` are required.
|
||||
- Classifier-free guidance: `extra_params.cfg_scale` (1.0 = off; 1.5 is the
|
||||
official quality setting; TTA effectively requires its default 3.0).
|
||||
Measured en-24 self-transcribed CER: 6.87% guided vs 7.24% unguided.
|
||||
- Output: 16 kHz mono WAV. English, single built-in voice (no cloning).
|
||||
- TTA decodes through the external XCodec1 checkpoint
|
||||
(`hf-audio/xcodec-hubert-general-balanced`, auto-downloaded; override
|
||||
with `XCODEC1_PATH`).
|
||||
|
||||
### 1×H100 80GB — Audex-30B-A3B
|
||||
|
||||
#### Environment
|
||||
|
||||
Same as the 2B row above.
|
||||
|
||||
#### Command
|
||||
|
||||
The 30B REQUIRES its explicit deploy yaml (the shared HF `model_type`
|
||||
auto-resolves to the 2B-tuned config otherwise):
|
||||
|
||||
```bash
|
||||
vllm-omni serve nvidia/Nemotron-Labs-Audex-30B-A3B \
|
||||
--host 0.0.0.0 --port 8097 \
|
||||
--trust-remote-code --omni \
|
||||
--stage-configs-path vllm_omni/deploy/audex_tts_30b.yaml
|
||||
# or: SIZE=30b MODE=tts examples/online_serving/audex/run_server.sh
|
||||
```
|
||||
|
||||
Offline: same four scripts with
|
||||
`--model nvidia/Nemotron-Labs-Audex-30B-A3B --deploy-config vllm_omni/deploy/audex_<mode>_30b.yaml`.
|
||||
|
||||
#### Verification
|
||||
|
||||
Same curl / `client.py` as the 2B with
|
||||
`"model": "nvidia/Nemotron-Labs-Audex-30B-A3B"` (the server validates the
|
||||
model id). First launch downloads ~60 GB.
|
||||
|
||||
#### Notes
|
||||
|
||||
- Memory usage: ~61 GiB weights on one H100 80 GB (stage 0 at 0.85 GPU
|
||||
fraction + decoder at 0.08 on the same card); verified with healthy KV
|
||||
headroom. If long sequences OOM, set `tensor_parallel_size: 2` on
|
||||
stage 0 (documented in the yaml headers).
|
||||
- `enable_prefix_caching: false` on stage 0 — hybrid Mamba constraint.
|
||||
- CUDA graphs stay ON for the thinker: the NemotronH stage captures
|
||||
cleanly and eager mode costs ~7× single-stream TTS RTF (review-measured
|
||||
0.30 vs 2.08).
|
||||
- Quality (en-24 self-transcribed CER): 0.72% unguided baseline; 0.72% at
|
||||
cfg 1.5 with guided temperature 0.1.
|
||||
- Known limitation: the S2S chat pass needs the official
|
||||
formatting-instruction prompt (the bundled examples/client send it);
|
||||
a bare transcript turn makes the 30B answer in speech-codec tokens.
|
||||
@@ -57,6 +57,7 @@ recipes/
|
||||
| [`OpenBMB/MiniCPM-o-4_5.md`](./OpenBMB/MiniCPM-o-4_5.md) | Online serving for omni multimodal chat (text / image / audio / video → text + 24 kHz speech) | 2x A100/H100 80GB / 3x mid-tier GPU / 8x RTX 4090 24GB |
|
||||
| [`OpenBMB/VoxCPM2.md`](./OpenBMB/VoxCPM2.md) | Online + offline TTS with native AR pipeline (48 kHz, 30+ languages) | 1x RTX 4090 24GB |
|
||||
| [`OpenMOSS/MOSS-TTS.md`](./OpenMOSS/MOSS-TTS.md) | Online + offline multilingual TTS (MOSS-TTS family, 8B) | 1x H100 80GB |
|
||||
| [`NVIDIA/Nemotron-Labs-Audex.md`](./NVIDIA/Nemotron-Labs-Audex.md) | TTS / text-to-audio / audio understanding / cascaded S2S (Audex 2B + 30B-A3B) | 1x H100 80GB |
|
||||
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt mixed FP8/NVFP4 | 1x A100 80GB / 2x B200 |
|
||||
| [`Qwen/Qwen-Image-2512.md`](./Qwen/Qwen-Image-2512.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt FP8 / mixed FP8/NVFP4 | 1x A800 80GB / 2x B200 |
|
||||
| [`Qwen/Qwen-Image-Edit.md`](./Qwen/Qwen-Image-Edit.md) | Text-guided single-image editing | 1x or 2x H200 141GB |
|
||||
|
||||
@@ -392,6 +392,11 @@ def test_sub_config_fields_match_rfc_scopes():
|
||||
"enable_multithread_weight_load",
|
||||
"num_weight_load_threads",
|
||||
"disable_autocast",
|
||||
# Per-stage checkpoint resolution for repos whose stages live in
|
||||
# subfolders (e.g. Audex): mirrors StagePipelineConfig on the
|
||||
# legacy engine-args path.
|
||||
"model_subdir",
|
||||
"tokenizer_subdir",
|
||||
}
|
||||
assert {f.name for f in fields(OmniStageLoadConfig)} == {
|
||||
"load_format",
|
||||
|
||||
@@ -121,6 +121,10 @@ class TestFullPayloadCoordinatorSelection(unittest.TestCase):
|
||||
("Qwen3TTSCode2Wav", "code2wav"),
|
||||
("MiniCPMO45Code2Wav", "code2wav"),
|
||||
("CosyVoice3Model", "cosyvoice3_code2wav"),
|
||||
# audex: sync-path TTS decoder (async_chunk: false deploys) and
|
||||
# the TTA XCodec1 stage (always sync full-payload).
|
||||
("AudexCode2Wav", "audex_code2wav"),
|
||||
("AudexXCodec1", "audex_xcodec"),
|
||||
("IndexTTS2S2MelDecoder", "indextts2_s2mel_decoder"),
|
||||
("DyninOmniForConditionalGeneration", "token2image"),
|
||||
("DyninOmniForConditionalGeneration", "token2audio"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""HTTP + WebSocket validation for Qwen3-TTS Base and Higgs Audio V3 ``/v1/audio/speech`` (and related routes)."""
|
||||
"""HTTP + WebSocket validation for Qwen3-TTS Base, Higgs Audio V3 and Audex TTA ``/v1/audio/speech`` (and related routes)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -70,6 +70,19 @@ _HIGGS_AUDIO_V3_SPEECH = [
|
||||
),
|
||||
]
|
||||
|
||||
_AUDEX_TTA_CAPTION = "Heavy rain falling on a tin roof."
|
||||
_AUDEX_TTA_SPEECH = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model="nvidia/Nemotron-Labs-Audex-2B",
|
||||
stage_config_path=get_deploy_config_path("audex_tta.yaml"),
|
||||
server_args=_SPEECH_SERVER_ARGS,
|
||||
),
|
||||
id="audex_tta",
|
||||
marks=_L4_SPEECH_HW,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _apply_batch_overrides(body: dict[str, object], *, loc: str, overrides: dict[str, object]) -> None:
|
||||
if loc == "batch":
|
||||
@@ -325,6 +338,37 @@ def test_speech_higgs_invalid_field_values(
|
||||
)
|
||||
|
||||
|
||||
# Audex TTA (``audex_tta`` deployment): the serving layer builds the caption
|
||||
# prompt and injects the RVQ phase contract + default guidance server-side, so
|
||||
# guidance knobs and speech-only fields must be rejected before generation.
|
||||
@pytest.mark.parametrize(
|
||||
"overrides, err_message",
|
||||
[
|
||||
pytest.param({"extra_params": {"cfg_scale": 99}}, "cfg_scale", id="cfg_scale_out_of_range"),
|
||||
pytest.param({"extra_params": {"cfg_scale": "big"}}, "cfg_scale", id="cfg_scale_wrong_type"),
|
||||
pytest.param({"extra_params": {"tta_rvq": {}}}, "tta_rvq", id="tta_rvq_not_overridable"),
|
||||
pytest.param({"input": " "}, "caption", id="caption_whitespace_only"),
|
||||
pytest.param({"voice": "alloy"}, "voice", id="voice_not_supported"),
|
||||
pytest.param(
|
||||
{"ref_audio": "https://example.com/ref.wav"},
|
||||
"reference audio",
|
||||
id="ref_audio_not_supported",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("omni_server", _AUDEX_TTA_SPEECH, indirect=True)
|
||||
def test_speech_audex_tta_invalid_field_values(
|
||||
omni_server: OmniServer,
|
||||
openai_client: OpenAIClientHandler,
|
||||
overrides: dict[str, object],
|
||||
err_message: str | tuple[str, ...],
|
||||
) -> None:
|
||||
body = {"model": omni_server.model, "input": _AUDEX_TTA_CAPTION, **overrides}
|
||||
openai_client.send_audio_speech_http_request(
|
||||
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# POST /v1/audio/speech/batch (JSON)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline E2E test for the Audex cascaded speech-to-speech pipeline.
|
||||
|
||||
One ``audex_s2s`` deployment serves all three official
|
||||
passes. The routing contract is the hard gate here: text-modality passes
|
||||
(ASR, chat) finish at stage 0 and must never produce stage-1 audio; the
|
||||
audio-modality TTS pass streams through the causal speech decoder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from vllm.assets.audio import AudioAsset
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import concat_audio
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt
|
||||
|
||||
MODEL = "nvidia/Nemotron-Labs-Audex-2B"
|
||||
MODEL_DIR_ENV = "VLLM_OMNI_AUDEX_MODEL_DIR"
|
||||
SAMPLE_RATE = 16_000
|
||||
|
||||
ASR_PROMPT = (
|
||||
"<|im_start|>user\n<so_embedding>\nTranscribe the input speech.<|im_end|>\n<|im_start|>assistant\n<think></think>"
|
||||
)
|
||||
|
||||
# Official chat-pass recipe (examples/offline_inference/audex/speech_to_speech.py,
|
||||
# mirroring the official cascaded web demo) — 30B-A3B ONLY. The
|
||||
# formatting-instruction prefix is load-bearing on the 30B-A3B: without it a bare
|
||||
# transcript turn is treated as a TTS request and the model emits literal
|
||||
# <speechcodec_N> tokens instead of text, and under the yaml's near-greedy
|
||||
# defaults its reasoning block never closes. The 2B must NOT use this recipe:
|
||||
# it is long-verified stable on a bare prompt + engine-default sampling, while
|
||||
# the official temp-1.0 seed-0 draw hallucinates off-language text (yet closes
|
||||
# </think> cleanly, so the seed retry never fires) and fails the round-trip
|
||||
# WER gate — measured, reproduced byte-identically.
|
||||
SYSTEM_PROMPT = "You are a helpful and harmless assistant.\n\nYou are not allowed to use any tools."
|
||||
TEXT_INSTRUCTION = (
|
||||
"SYSTEM & FORMATTING INSTRUCTION: You are Audex, created by NVIDIA based on the Nemotron-Cascade-2 architecture. "
|
||||
"You may output your reasoning, followed by your final response. "
|
||||
"You may format your reasoning block however you like. "
|
||||
"However, your final response must strictly follow these rules: "
|
||||
"* Write in plain, unformatted prose like a book or newspaper article. "
|
||||
"* Do not use markdown, bullet points, lists, or headers. "
|
||||
"* Standard numbers, abbreviations, and symbols are acceptable. "
|
||||
"* [CRITICAL] You must press enter after every single sentence, placing each sentence on its own separate line."
|
||||
)
|
||||
CHAT_MAX_TOKENS = 2048 # official --text-max-tokens default
|
||||
# The official demo samples this pass unseeded; some trajectories deliberate
|
||||
# past the token budget without ever closing the think block, so retry a few
|
||||
# deterministic seeds until one closes.
|
||||
CHAT_SEEDS = (0, 1, 2)
|
||||
|
||||
_audex_deployment = get_deploy_config_path("audex_s2s.yaml")
|
||||
_audex_model = os.environ.get(MODEL_DIR_ENV) or MODEL
|
||||
_OMNI_RUNNER_PARAMS = [
|
||||
pytest.param(
|
||||
(_audex_model, _audex_deployment, {"async_chunk": True}),
|
||||
id="s2s_full",
|
||||
),
|
||||
]
|
||||
# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1.
|
||||
_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B"
|
||||
if os.environ.get("AUDEX_E2E_30B") == "1":
|
||||
_OMNI_RUNNER_PARAMS.append(
|
||||
pytest.param(
|
||||
(_AUDEX_30B_MODEL, get_deploy_config_path("audex_s2s_30b.yaml"), {"async_chunk": True}),
|
||||
id="s2s_30b",
|
||||
)
|
||||
)
|
||||
pytestmark = [
|
||||
pytest.mark.slow,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True),
|
||||
]
|
||||
|
||||
|
||||
def _normalize(text: str) -> list[str]:
|
||||
return "".join(c.lower() if c.isalnum() or c.isspace() else " " for c in text).split()
|
||||
|
||||
|
||||
def _wer(hyp: str, ref: str) -> float:
|
||||
hyp_words, ref_words = _normalize(hyp), _normalize(ref)
|
||||
if not ref_words:
|
||||
return 0.0
|
||||
rows = list(range(len(hyp_words) + 1))
|
||||
for i, ref_word in enumerate(ref_words, 1):
|
||||
prev, rows[0] = rows[0], i
|
||||
for j, hyp_word in enumerate(hyp_words, 1):
|
||||
cur = min(rows[j] + 1, rows[j - 1] + 1, prev + (ref_word != hyp_word))
|
||||
prev, rows[j] = rows[j], cur
|
||||
return rows[-1] / len(ref_words)
|
||||
|
||||
|
||||
def _chat_prompt(user_text: str) -> str:
|
||||
# Official chat-pass recipe: system prompt + formatting instruction
|
||||
# prefixed to the user text, thinking ENABLED via an open <think> block
|
||||
# (chat_template.jinja with enable_thinking=True renders
|
||||
# "<|im_start|>assistant\n<think>\n"). Priming the block open keeps the
|
||||
# reasoning inside <think>...</think> so _extract_final_response can drop
|
||||
# it; with a closed block the 30B leaks the reasoning as plain text.
|
||||
return (
|
||||
f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
|
||||
f"<|im_start|>user\n{TEXT_INSTRUCTION}\n\n{user_text}<|im_end|>\n"
|
||||
"<|im_start|>assistant\n<think>\n"
|
||||
)
|
||||
|
||||
|
||||
def _extract_final_response(text: str) -> str:
|
||||
# Mirror the official demo's extract_spoken_answer: keep only what
|
||||
# follows the LAST closing think tag, falling back to the full text when
|
||||
# the model skipped reasoning or never closed the block. Benign no-op for
|
||||
# the 2B, whose answers carry no think block.
|
||||
if "</think>" in text:
|
||||
answer = text.rsplit("</think>", 1)[-1].strip()
|
||||
if answer:
|
||||
return answer
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _chat_params(runner: OmniRunner, seed: int):
|
||||
# Official demo text-pass sampling (--text-temperature 1.0, --text-top-p
|
||||
# 0.95, --text-max-tokens 2048). The yaml stage-0 defaults (temperature
|
||||
# 0.1, top_k 80) are TTS-thinker settings; under near-greedy decoding the
|
||||
# 30B loops inside its reasoning block until the token budget runs out
|
||||
# and never emits </think>.
|
||||
params = copy.deepcopy(runner.omni.resolve_sampling_params_list(None))
|
||||
stage0 = params[0]
|
||||
stage0.temperature = 1.0
|
||||
stage0.top_p = 0.95
|
||||
stage0.top_k = -1
|
||||
stage0.max_tokens = CHAT_MAX_TOKENS
|
||||
stage0.seed = seed
|
||||
return params
|
||||
|
||||
|
||||
def _chat_once(runner: OmniRunner, prompt: str, seed: int) -> tuple[str, bool]:
|
||||
(req_output,) = runner.omni.generate([{"prompt": prompt, "modalities": ["text"]}], _chat_params(runner, seed))
|
||||
out = req_output.outputs[0]
|
||||
text = (out.text or "").strip()
|
||||
truncated = getattr(out, "finish_reason", None) == "length" or len(out.token_ids or []) >= CHAT_MAX_TOKENS
|
||||
return text, truncated
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
def test_audex_s2s_cascade_round_trip(omni_runner: OmniRunner, run_level: str) -> None:
|
||||
"""ASR → chat → TTS over one deployment; text passes never emit audio."""
|
||||
real_weights = run_level in {"advanced_model", "full_model"}
|
||||
# Spoken question: vLLM's public audio asset (downloaded on first use).
|
||||
audio, sr = AudioAsset("mary_had_lamb").audio_and_sample_rate
|
||||
|
||||
# Pass 1 — ASR (text-final).
|
||||
asr_outputs = omni_runner.omni.generate(
|
||||
[
|
||||
{
|
||||
"prompt": ASR_PROMPT,
|
||||
"multi_modal_data": {"audio": (audio, sr)},
|
||||
"modalities": ["text"],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert len(asr_outputs) == 1
|
||||
asr_out = asr_outputs[0]
|
||||
transcript = (asr_out.outputs[0].text or "").strip()
|
||||
mm = getattr(asr_out, "multimodal_output", None) or {}
|
||||
stage1_audio = concat_audio(mm.get("audio")) if "audio" in mm else np.zeros((0,), dtype=np.float32)
|
||||
assert stage1_audio.size == 0, "Text-modality ASR pass produced stage-1 audio (routing regression)"
|
||||
|
||||
# Pass 2 — chat (text-final). The recipe is size-conditional (see the
|
||||
# SYSTEM_PROMPT comment above): the 30B requires the official recipe,
|
||||
# the 2B regresses under it.
|
||||
if omni_runner.model_name == _AUDEX_30B_MODEL:
|
||||
# Official recipe. The raw completion is
|
||||
# <reasoning...></think><answer>; speak only the final response.
|
||||
# Retry with the next seed only when the budget ran out before the
|
||||
# reasoning block closed (a natural stop without </think> is a
|
||||
# complete answer); if every seed spins, keep the full text
|
||||
# (official demo behavior).
|
||||
chat_prompt = _chat_prompt(transcript or "Hello.")
|
||||
raw = ""
|
||||
for seed in CHAT_SEEDS:
|
||||
raw, truncated = _chat_once(omni_runner, chat_prompt, seed)
|
||||
if "</think>" in raw or not truncated:
|
||||
break
|
||||
answer = _extract_final_response(raw)
|
||||
else:
|
||||
# 2B: bare prompt with a closed think block, engine-default sampling
|
||||
# (yaml near-greedy), no retry; the answer is used as-is.
|
||||
chat_prompt = f"<|im_start|>user\n{transcript or 'Hello.'}<|im_end|>\n<|im_start|>assistant\n<think></think>"
|
||||
(chat_output,) = omni_runner.omni.generate([{"prompt": chat_prompt, "modalities": ["text"]}])
|
||||
answer = (chat_output.outputs[0].text or "").strip()
|
||||
|
||||
if real_weights:
|
||||
assert transcript, "ASR pass produced an empty transcript"
|
||||
assert answer, "Chat pass produced an empty answer"
|
||||
|
||||
# Pass 3 — TTS (audio-final; streams through code2wav). Both final
|
||||
# stages may emit an output record; collect the audio-bearing one.
|
||||
# The formatting instruction puts every sentence on its own line;
|
||||
# flatten to plain prose for the TTS thinker.
|
||||
tts_text = re.sub(r"\s+", " ", answer).strip() if answer else "The weather is nice today."
|
||||
tts_text = tts_text[:200]
|
||||
tts_prompt = {"prompt": build_cond_prompt(tts_text), "modalities": ["audio"]}
|
||||
tts_outputs = omni_runner.omni.generate([tts_prompt])
|
||||
assert tts_outputs, "TTS pass returned no outputs"
|
||||
speech = np.zeros((0,), dtype=np.float32)
|
||||
for req_output in tts_outputs:
|
||||
mm = getattr(req_output, "multimodal_output", None) or {}
|
||||
if "audio" in mm:
|
||||
speech = concat_audio(mm["audio"])
|
||||
break
|
||||
assert speech.size > 0, "TTS pass produced empty audio"
|
||||
|
||||
if real_weights:
|
||||
rms = float(np.sqrt(np.mean(np.square(speech))))
|
||||
assert rms > 1e-3, f"TTS-pass audio near-silent (rms={rms})"
|
||||
|
||||
# Pass 4 — output verification: transcribe the synthesized answer
|
||||
# with the SAME deployment's ASR pass and compare to the answer
|
||||
# text (self-contained; no external ASR dependency).
|
||||
verify_outputs = omni_runner.omni.generate(
|
||||
[
|
||||
{
|
||||
"prompt": ASR_PROMPT,
|
||||
"multi_modal_data": {"audio": (speech, SAMPLE_RATE)},
|
||||
"modalities": ["text"],
|
||||
}
|
||||
]
|
||||
)
|
||||
verify_text = (verify_outputs[0].outputs[0].text or "").strip()
|
||||
assert verify_text, "Output-verification ASR pass returned empty text"
|
||||
# The full checkpoint prefixes transcripts with a language-tag
|
||||
# sentence and quotes the content; extract the quoted payload.
|
||||
if "'" in verify_text and len(verify_text.split("'")) >= 3:
|
||||
verify_text = verify_text.split("'")[1].strip()
|
||||
wer = _wer(verify_text, tts_text)
|
||||
# Hard gate: the synthesized WAV must transcribe back to the answer
|
||||
# (lenient threshold absorbs ASR quoting/prefix differences).
|
||||
assert wer <= 0.5, f"Output WAV does not match the answer (WER={wer:.2f}): {verify_text!r} vs {tts_text!r}"
|
||||
print(f"[trend] Audex S2S output-ASR WER vs answer: {wer:.3f} ({verify_text!r})")
|
||||
|
||||
# Final pass — time-to-first-audio for the streaming TTS path. This
|
||||
# MUST be the last engine call: the py_generator API closes the engine
|
||||
# when the generator is exhausted.
|
||||
t_start = time.perf_counter()
|
||||
first_audio_s: float | None = None
|
||||
for req_output in omni_runner.omni.generate([tts_prompt], py_generator=True):
|
||||
mm = getattr(req_output, "multimodal_output", None) or {}
|
||||
if "audio" in mm and concat_audio(mm["audio"]).size > 0:
|
||||
if first_audio_s is None:
|
||||
first_audio_s = time.perf_counter() - t_start
|
||||
assert first_audio_s is not None, "streaming TTS never produced an audio-bearing chunk"
|
||||
print(f"[trend] Audex S2S TTS-pass first-audio latency: {first_audio_s * 1000.0:.0f} ms")
|
||||
@@ -0,0 +1,106 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline E2E smoke test for Audex audio understanding (thinker-only).
|
||||
|
||||
Single-stage ``audex_thinker_only`` pipeline on
|
||||
``checkpoint_folder_full``: WAV (+ instruction) in, text out. The input clip
|
||||
is vLLM's public ``mary_had_lamb`` audio asset (downloaded on first use), so
|
||||
its transcript is known.
|
||||
|
||||
WER is a trend metric (recorded in the assertion message, no numeric hard
|
||||
gate); the hard gates are structural: non-empty, non-degenerate text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from vllm.assets.audio import AudioAsset
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
MODEL = "nvidia/Nemotron-Labs-Audex-2B"
|
||||
MODEL_DIR_ENV = "VLLM_OMNI_AUDEX_MODEL_DIR"
|
||||
|
||||
# Full transcript of the mary_had_lamb asset (Edison's phonograph recording);
|
||||
# a shortened reference would count the extra spoken words as insertions and
|
||||
# inflate WER past the trend threshold.
|
||||
_REFERENCE = (
|
||||
"the first words i spoke in the original phonograph a little piece of practical poetry "
|
||||
"mary had a little lamb its fleece was white as snow and everywhere that mary went the "
|
||||
"lamb was sure to go"
|
||||
)
|
||||
|
||||
ASR_PROMPT = (
|
||||
"<|im_start|>user\n<so_embedding>\nTranscribe the input speech.<|im_end|>\n<|im_start|>assistant\n<think></think>"
|
||||
)
|
||||
|
||||
_audex_deployment = get_deploy_config_path("audex_thinker_only.yaml")
|
||||
_audex_model = os.environ.get(MODEL_DIR_ENV) or MODEL
|
||||
_OMNI_RUNNER_PARAMS = [
|
||||
pytest.param(
|
||||
(_audex_model, _audex_deployment, {"async_chunk": False}),
|
||||
id="thinker_only",
|
||||
),
|
||||
]
|
||||
# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1.
|
||||
_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B"
|
||||
if os.environ.get("AUDEX_E2E_30B") == "1":
|
||||
_OMNI_RUNNER_PARAMS.append(
|
||||
pytest.param(
|
||||
(_AUDEX_30B_MODEL, get_deploy_config_path("audex_thinker_only_30b.yaml"), {"async_chunk": False}),
|
||||
id="thinker_only_30b",
|
||||
)
|
||||
)
|
||||
pytestmark = [
|
||||
pytest.mark.slow,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True),
|
||||
]
|
||||
|
||||
|
||||
def _normalize(text: str) -> list[str]:
|
||||
return "".join(c.lower() if c.isalnum() or c.isspace() else " " for c in text).split()
|
||||
|
||||
|
||||
def _wer(hyp: str, ref: str) -> float:
|
||||
hyp_words, ref_words = _normalize(hyp), _normalize(ref)
|
||||
if not ref_words:
|
||||
return 0.0
|
||||
rows = list(range(len(hyp_words) + 1))
|
||||
for i, ref_word in enumerate(ref_words, 1):
|
||||
prev, rows[0] = rows[0], i
|
||||
for j, hyp_word in enumerate(hyp_words, 1):
|
||||
cur = min(
|
||||
rows[j] + 1,
|
||||
rows[j - 1] + 1,
|
||||
prev + (ref_word != hyp_word),
|
||||
)
|
||||
prev, rows[j] = rows[j], cur
|
||||
return rows[-1] / len(ref_words)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_audex_offline_asr_smoke(omni_runner: OmniRunner, run_level: str) -> None:
|
||||
"""Transcribing the public audio asset yields coherent, non-degenerate text."""
|
||||
audio, sr = AudioAsset("mary_had_lamb").audio_and_sample_rate
|
||||
outputs = omni_runner.omni.generate([{"prompt": ASR_PROMPT, "multi_modal_data": {"audio": (audio, sr)}}])
|
||||
|
||||
assert len(outputs) == 1
|
||||
text = outputs[0].outputs[0].text if outputs[0].outputs else ""
|
||||
assert isinstance(text, str)
|
||||
|
||||
if run_level in {"advanced_model", "full_model"}:
|
||||
words = _normalize(text)
|
||||
assert words, f"Empty transcription: {text!r}"
|
||||
# Degenerate repetition guard: no word may dominate the output.
|
||||
most_common = max(words.count(w) for w in set(words))
|
||||
assert most_common <= max(3, len(words) // 2), f"Degenerate transcript: {text!r}"
|
||||
wer = _wer(text, _REFERENCE)
|
||||
# Trend metric (recorded, not gated): substantially-correct check only.
|
||||
assert wer <= 0.9, f"Transcript unrelated to reference (WER={wer:.2f}): {text!r}"
|
||||
print(f"[trend] Audex S2T WER vs reference: {wer:.3f} ({text!r})")
|
||||
@@ -0,0 +1,145 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline E2E smoke test for Audex text-to-audio (TTA).
|
||||
|
||||
Caption → RVQ-phase-masked <audiocodec_*> tokens → XCodec1 waveform through
|
||||
the ``audex_tta`` pipeline. RVQ phase validity is enforced by
|
||||
the mask logits processor (unit-tested against the official validator); the
|
||||
hard gates here are decode-side: non-silent, finite audio at the documented
|
||||
sample rate. CFG runs at the official TTA default (3.0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import concat_audio
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_tta_cond_prompt
|
||||
|
||||
MODEL = "nvidia/Nemotron-Labs-Audex-2B"
|
||||
MODEL_DIR_ENV = "VLLM_OMNI_AUDEX_MODEL_DIR"
|
||||
SAMPLE_RATE = 16_000
|
||||
|
||||
# The acceptance gate requires eight captions decoding to non-silent,
|
||||
# finite audio through XCodec1.
|
||||
CAPTIONS = (
|
||||
"Heavy rain falling on a tin roof.",
|
||||
"A dog barking in the distance while birds chirp.",
|
||||
"Ocean waves crashing on a rocky shore.",
|
||||
"A crackling campfire at night with crickets.",
|
||||
"Thunder rumbling during a storm.",
|
||||
"A steam train whistle blowing as it departs.",
|
||||
"Wind chimes ringing in a gentle breeze.",
|
||||
"Footsteps crunching through fresh snow.",
|
||||
)
|
||||
|
||||
_audex_deployment = get_deploy_config_path("audex_tta.yaml")
|
||||
_audex_model = os.environ.get(MODEL_DIR_ENV) or MODEL
|
||||
_OMNI_RUNNER_PARAMS = [
|
||||
pytest.param(
|
||||
(_audex_model, _audex_deployment, {"async_chunk": False}),
|
||||
id="tta_full_payload",
|
||||
),
|
||||
]
|
||||
# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1.
|
||||
_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B"
|
||||
if os.environ.get("AUDEX_E2E_30B") == "1":
|
||||
_OMNI_RUNNER_PARAMS.append(
|
||||
pytest.param(
|
||||
(_AUDEX_30B_MODEL, get_deploy_config_path("audex_tta_30b.yaml"), {"async_chunk": False}),
|
||||
id="tta_30b",
|
||||
)
|
||||
)
|
||||
pytestmark = [
|
||||
pytest.mark.slow,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tta_tokenizer_bits():
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot
|
||||
from vllm_omni.model_executor.models.audex.tta import build_tta_phase_token_ids
|
||||
|
||||
root = ensure_audex_snapshot(_audex_model, profile="tta")
|
||||
tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, "checkpoint_folder_audiogen"), trust_remote_code=True)
|
||||
phase_token_ids, start_tid, end_tid = build_tta_phase_token_ids(tokenizer)
|
||||
return tokenizer, phase_token_ids, start_tid, end_tid
|
||||
|
||||
|
||||
def _tta_sampling_params(runner: OmniRunner, cond_prompt: str, tokenizer_bits, cfg_scale: float = 3.0):
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_tta_null_prompt
|
||||
|
||||
tokenizer, phase_token_ids, start_tid, end_tid = tokenizer_bits
|
||||
params = copy.deepcopy(runner.omni.resolve_sampling_params_list(None))
|
||||
stage0 = params[0]
|
||||
if stage0.extra_args is None:
|
||||
stage0.extra_args = {}
|
||||
stage0.extra_args["tta_rvq"] = {
|
||||
"phase_token_ids": phase_token_ids,
|
||||
"start_tid": start_tid,
|
||||
"end_tid": end_tid,
|
||||
"codec_cap": 4000,
|
||||
"start_in_prompt": True,
|
||||
}
|
||||
if cfg_scale > 1.0:
|
||||
stage0.extra_args.update(
|
||||
{
|
||||
"cfg_scale": float(cfg_scale),
|
||||
"cfg_role": "cond",
|
||||
"cfg_pair_id": f"e2e-tta-{abs(hash(cond_prompt)) % 10_000_000}",
|
||||
"cfg_null_prompt": build_tta_null_prompt(cond_prompt, tokenizer),
|
||||
}
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_audex_offline_tta_eight_captions(omni_runner: OmniRunner, run_level: str, tta_tokenizer_bits) -> None:
|
||||
"""All eight captions decode through XCodec1 to non-silent, finite audio.
|
||||
|
||||
RVQ phase validity is enforced by the mask logits processor by
|
||||
construction; the final user output carries only decoded audio (the
|
||||
audio-final pipeline emits no stage-0 token record), and disabling the
|
||||
mask in-engine as an e2e control would require a second engine boot
|
||||
with a different logits-processor config. The mask's sensitivity is
|
||||
therefore proven at unit level (see test_audex_tta.py's mask tests and
|
||||
the unmasked-sampling control) against the official validator.
|
||||
"""
|
||||
real_weights = run_level in {"advanced_model", "full_model"}
|
||||
for caption in CAPTIONS:
|
||||
prompt = build_tta_cond_prompt(caption)
|
||||
params = _tta_sampling_params(omni_runner, prompt, tta_tokenizer_bits)
|
||||
outputs = omni_runner.omni.generate([prompt], params)
|
||||
|
||||
assert len(outputs) == 1, f"{caption!r}: expected one output, got {len(outputs)}"
|
||||
audio_mm = outputs[0].multimodal_output
|
||||
assert "audio" in audio_mm, f"{caption!r}: no audio output: {list(audio_mm.keys())}"
|
||||
audio = concat_audio(audio_mm["audio"])
|
||||
assert audio.size > 0, f"{caption!r}: generated audio is empty"
|
||||
assert np.isfinite(audio).all(), f"{caption!r}: audio contains NaN/Inf"
|
||||
|
||||
sr_val = audio_mm.get("sr", SAMPLE_RATE)
|
||||
if isinstance(sr_val, list) and sr_val:
|
||||
sr_val = sr_val[-1]
|
||||
if hasattr(sr_val, "item"):
|
||||
sr_val = sr_val.item()
|
||||
assert int(sr_val) == SAMPLE_RATE, f"{caption!r}: unexpected sample_rate={sr_val}"
|
||||
|
||||
if real_weights:
|
||||
duration_s = audio.size / SAMPLE_RATE
|
||||
# The decode cap is 500 frames = 10 s at 50 fps.
|
||||
assert 0.1 <= duration_s <= 10.5, f"{caption!r}: unexpected duration={duration_s:.3f}s"
|
||||
rms = float(np.sqrt(np.mean(np.square(audio))))
|
||||
assert rms > 1e-4, f"{caption!r}: audio is near-silent (rms={rms})"
|
||||
@@ -0,0 +1,161 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Offline E2E smoke test for Audex (Nemotron-Labs-Audex-2B) TTS.
|
||||
|
||||
Passes the HF repo ROOT and verifies the 2-stage pipeline (thinker →
|
||||
streaming causal speech decoder): root-path/subfolder resolution, codec-token
|
||||
extraction, per-request streaming sessions, and the lookahead flush at EOS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import concat_audio
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt
|
||||
|
||||
MODEL = "nvidia/Nemotron-Labs-Audex-2B"
|
||||
MODEL_DIR_ENV = "VLLM_OMNI_AUDEX_MODEL_DIR"
|
||||
SAMPLE_RATE = 16_000
|
||||
|
||||
SYNTH_TEXTS = (
|
||||
"The weather is so good, and I want to enjoy the beautiful morning in the park.",
|
||||
"The quick brown fox jumps over the lazy dog.",
|
||||
)
|
||||
|
||||
|
||||
_audex_deployment = get_deploy_config_path("audex_tts.yaml")
|
||||
# Collection must not download anything: pass the repo id (or a local
|
||||
# override) and let the engine's stage-init path resolve/download the
|
||||
# required snapshot subset at execution time (ensure_audex_snapshot).
|
||||
_audex_model = os.environ.get(MODEL_DIR_ENV) or MODEL
|
||||
_OMNI_RUNNER_PARAMS = [
|
||||
pytest.param(
|
||||
(_audex_model, _audex_deployment, {"async_chunk": True}),
|
||||
id="async_chunk",
|
||||
),
|
||||
]
|
||||
# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1.
|
||||
_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B"
|
||||
if os.environ.get("AUDEX_E2E_30B") == "1":
|
||||
_OMNI_RUNNER_PARAMS.append(
|
||||
pytest.param(
|
||||
(_AUDEX_30B_MODEL, get_deploy_config_path("audex_tts_30b.yaml"), {"async_chunk": True}),
|
||||
id="tts_30b",
|
||||
)
|
||||
)
|
||||
pytestmark = [
|
||||
pytest.mark.slow,
|
||||
pytest.mark.tts,
|
||||
pytest.mark.parametrize("omni_runner", _OMNI_RUNNER_PARAMS, indirect=True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_audex_offline_tts_smoke(omni_runner: OmniRunner, run_level: str) -> None:
|
||||
"""Audex TTS from the repo root should produce sane 16 kHz audio per prompt.
|
||||
|
||||
At ``core_model`` level the runner loads DUMMY weights (structural smoke
|
||||
only); real-speech assertions apply from ``advanced_model`` up.
|
||||
"""
|
||||
prompts = [build_cond_prompt(text) for text in SYNTH_TEXTS]
|
||||
outputs = omni_runner.omni.generate(prompts)
|
||||
|
||||
real_weights = run_level in {"advanced_model", "full_model"}
|
||||
assert len(outputs) == len(SYNTH_TEXTS), f"expected {len(SYNTH_TEXTS)} outputs, got {len(outputs)}"
|
||||
for output in outputs:
|
||||
audio_mm = output.multimodal_output
|
||||
assert "audio" in audio_mm, f"No audio output found: {list(audio_mm.keys())}"
|
||||
audio = concat_audio(audio_mm["audio"])
|
||||
assert audio.size > 0, "Generated audio is empty"
|
||||
|
||||
sr_val = audio_mm.get("sr", SAMPLE_RATE)
|
||||
if isinstance(sr_val, list) and sr_val:
|
||||
sr_val = sr_val[-1]
|
||||
if hasattr(sr_val, "item"):
|
||||
sr_val = sr_val.item()
|
||||
assert int(sr_val) == SAMPLE_RATE, f"Unexpected sample_rate={sr_val}"
|
||||
|
||||
if real_weights:
|
||||
duration_s = audio.size / SAMPLE_RATE
|
||||
assert 0.5 <= duration_s <= 20.0, f"Unexpected duration={duration_s:.3f}s"
|
||||
# Real speech, not silence.
|
||||
rms = float(np.sqrt(np.mean(np.square(audio))))
|
||||
assert rms > 1e-3, f"Audio is near-silent (rms={rms})"
|
||||
|
||||
|
||||
def _cfg_sampling_params(runner: OmniRunner, cfg_scale: float, pair_id: str, cond_prompt: str):
|
||||
"""Stage sampling params carrying the CFG pair contract for one request."""
|
||||
import copy
|
||||
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_null_prompt
|
||||
|
||||
# Resolve the ACTIVE param's model (2B or 30B), not the module default:
|
||||
# the null prompt must be built with the same tokenizer the engine uses.
|
||||
root = ensure_audex_snapshot(runner.model_name)
|
||||
tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, "checkpoint_folder_audiogen"), trust_remote_code=True)
|
||||
params = copy.deepcopy(runner.omni.resolve_sampling_params_list(None))
|
||||
stage0 = params[0]
|
||||
if stage0.extra_args is None:
|
||||
stage0.extra_args = {}
|
||||
stage0.extra_args.update(
|
||||
{
|
||||
"cfg_scale": float(cfg_scale),
|
||||
"cfg_role": "cond",
|
||||
"cfg_pair_id": pair_id,
|
||||
"cfg_null_prompt": build_null_prompt(cond_prompt, tokenizer),
|
||||
}
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_audex_offline_cfg_guided_single_stream(omni_runner: OmniRunner, run_level: str) -> None:
|
||||
"""A guided request must yield exactly ONE audio stream (companion suppressed)."""
|
||||
prompt = build_cond_prompt(SYNTH_TEXTS[0])
|
||||
params = _cfg_sampling_params(omni_runner, 1.5, "e2e-cfg-pair", prompt)
|
||||
outputs = omni_runner.omni.generate([prompt], params)
|
||||
|
||||
assert len(outputs) == 1, f"expected exactly one output (no companion leak), got {len(outputs)}"
|
||||
audio = concat_audio(outputs[0].multimodal_output["audio"])
|
||||
assert audio.size > 0, "Guided generation produced empty audio"
|
||||
if run_level in {"advanced_model", "full_model"}:
|
||||
rms = float(np.sqrt(np.mean(np.square(audio))))
|
||||
assert rms > 1e-3, f"Guided audio is near-silent (rms={rms})"
|
||||
duration_s = audio.size / SAMPLE_RATE
|
||||
assert 0.5 <= duration_s <= 20.0, f"Unexpected duration={duration_s:.3f}s"
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_audex_offline_cfg_disabled_is_byte_identical(omni_runner: OmniRunner, run_level: str) -> None:
|
||||
"""cfg_scale=1.0 must be indistinguishable from not passing cfg at all."""
|
||||
prompt = build_cond_prompt(SYNTH_TEXTS[1])
|
||||
|
||||
baseline = omni_runner.omni.generate([prompt])
|
||||
base_audio = concat_audio(baseline[0].multimodal_output["audio"])
|
||||
|
||||
import copy
|
||||
|
||||
params = copy.deepcopy(omni_runner.omni.resolve_sampling_params_list(None))
|
||||
if params[0].extra_args is None:
|
||||
params[0].extra_args = {}
|
||||
params[0].extra_args["cfg_scale"] = 1.0
|
||||
disabled = omni_runner.omni.generate([prompt], params)
|
||||
disabled_audio = concat_audio(disabled[0].multimodal_output["audio"])
|
||||
|
||||
assert base_audio.shape == disabled_audio.shape, (
|
||||
f"cfg_scale=1.0 changed output length: {base_audio.shape} vs {disabled_audio.shape}"
|
||||
)
|
||||
np.testing.assert_array_equal(base_audio, disabled_audio)
|
||||
@@ -0,0 +1,84 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""E2E online tests for Audex (Nemotron-Labs-Audex-2B) text-to-audio.
|
||||
|
||||
Covers /v1/audio/speech through the ``audex_tta`` deployment:
|
||||
the ``audex_tta`` adapter builds the caption prompt and the serving layer
|
||||
injects the RVQ phase contract plus the official default guidance
|
||||
(cfg_scale 3.0) server-side.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
pytestmark = [pytest.mark.slow, pytest.mark.tts]
|
||||
|
||||
MODEL = "nvidia/Nemotron-Labs-Audex-2B"
|
||||
CAPTION = "Heavy rain falling on a tin roof."
|
||||
SAMPLE_RATE = 16_000
|
||||
|
||||
audex_tta_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=get_deploy_config_path("audex_tta.yaml"),
|
||||
server_args=["--trust-remote-code"],
|
||||
),
|
||||
id="audex_tta",
|
||||
)
|
||||
]
|
||||
# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1.
|
||||
_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B"
|
||||
if os.environ.get("AUDEX_E2E_30B") == "1":
|
||||
audex_tta_server_params.append(
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=_AUDEX_30B_MODEL,
|
||||
stage_config_path=get_deploy_config_path("audex_tta_30b.yaml"),
|
||||
server_args=["--trust-remote-code"],
|
||||
),
|
||||
id="online_tta_30b",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_tta_server_params, indirect=True)
|
||||
def test_audex_tta_online_smoke(omni_server, openai_client, run_level) -> None:
|
||||
"""A caption returns a valid finite non-silent WAV at the default cfg 3.0."""
|
||||
import io
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech"
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={"model": omni_server.model, "input": CAPTION, "response_format": "wav"},
|
||||
timeout=600,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text[:200]
|
||||
assert resp.content[:4] == b"RIFF", "not a WAV response"
|
||||
|
||||
with wave.open(io.BytesIO(resp.content)) as wav:
|
||||
assert wav.getframerate() == SAMPLE_RATE, f"unexpected sample rate {wav.getframerate()}"
|
||||
frames = wav.readframes(wav.getnframes())
|
||||
duration_s = wav.getnframes() / wav.getframerate()
|
||||
|
||||
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
assert audio.size > 0, "empty audio body"
|
||||
assert np.isfinite(audio).all(), "audio contains NaN/Inf"
|
||||
|
||||
if run_level in {"advanced_model", "full_model"}:
|
||||
# The decode cap is 500 frames = 10 s at 50 fps.
|
||||
assert 0.1 <= duration_s <= 10.5, f"unexpected duration={duration_s:.3f}s"
|
||||
rms = float(np.sqrt(np.mean(np.square(audio))))
|
||||
assert rms > 1e-4, f"audio is near-silent (rms={rms})"
|
||||
@@ -0,0 +1,230 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""E2E online tests for Audex (Nemotron-Labs-Audex-2B) TTS.
|
||||
|
||||
Covers /v1/audio/speech non-streaming and audio-byte streaming, plus the
|
||||
Audex request policies: single built-in voice (missing/empty/"default" only),
|
||||
no reference audio, and cfg_scale reserved at 1.0.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
pytestmark = [pytest.mark.slow, pytest.mark.tts]
|
||||
|
||||
MODEL = "nvidia/Nemotron-Labs-Audex-2B"
|
||||
PROMPT = "The weather is so good, and I want to enjoy the beautiful morning in the park."
|
||||
|
||||
audex_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=get_deploy_config_path("audex_tts.yaml"),
|
||||
server_args=["--trust-remote-code"],
|
||||
),
|
||||
id="audex",
|
||||
)
|
||||
]
|
||||
# 30B-A3B variant: opt-in (pulls ~60 GB); enable with AUDEX_E2E_30B=1.
|
||||
_AUDEX_30B_MODEL = os.environ.get("VLLM_OMNI_AUDEX_30B_MODEL_DIR") or "nvidia/Nemotron-Labs-Audex-30B-A3B"
|
||||
if os.environ.get("AUDEX_E2E_30B") == "1":
|
||||
audex_server_params.append(
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=_AUDEX_30B_MODEL,
|
||||
stage_config_path=get_deploy_config_path("audex_tts_30b.yaml"),
|
||||
server_args=["--trust-remote-code"],
|
||||
),
|
||||
id="online_tts_30b",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True)
|
||||
def test_audex_tts_nonstream(omni_server, openai_client) -> None:
|
||||
"""Plain English TTS, non-streaming WAV response."""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": PROMPT,
|
||||
"stream": False,
|
||||
"response_format": "wav",
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True)
|
||||
def test_audex_tts_stream_audio(omni_server, openai_client) -> None:
|
||||
"""Plain English TTS, raw audio-byte streaming."""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": PROMPT,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True)
|
||||
def test_audex_tts_max_new_tokens_caps_duration(omni_server, openai_client, run_level) -> None:
|
||||
"""max_new_tokens must cap stage-0 generation (review P2 regression)."""
|
||||
import io
|
||||
import wave
|
||||
|
||||
import requests
|
||||
|
||||
if run_level not in {"advanced_model", "full_model"}:
|
||||
pytest.skip("duration cap needs real weights")
|
||||
|
||||
url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech"
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={
|
||||
"model": omni_server.model,
|
||||
"input": PROMPT,
|
||||
"response_format": "wav",
|
||||
"max_new_tokens": 25,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
with wave.open(io.BytesIO(resp.content)) as wav:
|
||||
duration_s = wav.getnframes() / wav.getframerate()
|
||||
# 25 codec frames at 50 fps = 0.5 s (+ small lookahead tail); the full
|
||||
# sentence would be ~3 s, so a capped result proves the override applied.
|
||||
assert duration_s <= 1.0, f"max_new_tokens ignored: duration={duration_s:.2f}s"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True)
|
||||
def test_audex_tts_request_policies(omni_server, openai_client) -> None:
|
||||
"""Unsupported voice / CFG / empty input must be rejected with 400."""
|
||||
import requests
|
||||
|
||||
url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech"
|
||||
|
||||
ok = requests.post(url, json={"model": omni_server.model, "input": PROMPT, "voice": "default"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
|
||||
for bad_payload, needle in (
|
||||
({"input": PROMPT, "voice": "alloy"}, "voice"),
|
||||
({"input": " "}, "non-empty"),
|
||||
({"input": PROMPT, "extra_params": {"cfg_scale": 0.5}}, "cfg_scale"),
|
||||
({"input": PROMPT, "extra_params": {"cfg_scale": 10.5}}, "cfg_scale"),
|
||||
({"input": PROMPT, "extra_params": {"cfg_scale": "big"}}, "cfg_scale"),
|
||||
({"input": PROMPT, "extra_params": {"cfg_role": "cond"}}, "cfg_role"),
|
||||
({"input": PROMPT, "extra_params": {"cfg_pair_id": "x"}}, "cfg_pair_id"),
|
||||
({"input": PROMPT, "ref_audio": "https://example.com/ref.wav"}, "reference audio"),
|
||||
):
|
||||
resp = requests.post(url, json={"model": omni_server.model, **bad_payload})
|
||||
assert resp.status_code == 400, f"{bad_payload} -> {resp.status_code}: {resp.text[:200]}"
|
||||
assert needle in resp.text, f"{bad_payload} error missing {needle!r}: {resp.text[:200]}"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True)
|
||||
def test_audex_tts_cfg_nonstream(omni_server, openai_client) -> None:
|
||||
"""Guided (cfg_scale=1.5) non-streaming request returns one WAV."""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": PROMPT,
|
||||
"stream": False,
|
||||
"response_format": "wav",
|
||||
"extra_params": {"cfg_scale": 1.5},
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True)
|
||||
def test_audex_tts_cfg_stream_audio(omni_server, openai_client) -> None:
|
||||
"""Guided (cfg_scale=1.5) streaming request yields a single audio stream."""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": PROMPT,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
"extra_params": {"cfg_scale": 1.5},
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True)
|
||||
def test_audex_tts_mixed_cfg_concurrency(omni_server, openai_client) -> None:
|
||||
"""Mixed guided/unguided requests at concurrency 4 all complete with audio."""
|
||||
import concurrent.futures
|
||||
|
||||
import requests
|
||||
|
||||
url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech"
|
||||
payloads = [
|
||||
{"model": omni_server.model, "input": f"{PROMPT} Take {i}.", "response_format": "wav"}
|
||||
| ({"extra_params": {"cfg_scale": 1.5}} if i % 2 == 0 else {})
|
||||
for i in range(4)
|
||||
]
|
||||
|
||||
def _post(payload):
|
||||
return requests.post(url, json=payload, timeout=300)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
responses = list(pool.map(_post, payloads))
|
||||
|
||||
for payload, resp in zip(payloads, responses):
|
||||
assert resp.status_code == 200, f"{payload} -> {resp.status_code}: {resp.text[:200]}"
|
||||
assert resp.content[:4] == b"RIFF", f"{payload}: not a WAV response"
|
||||
assert len(resp.content) > 44, f"{payload}: empty audio body"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", audex_server_params, indirect=True)
|
||||
def test_audex_tts_stream_abort_leaves_server_healthy(omni_server, openai_client) -> None:
|
||||
"""Aborting a guided streaming request mid-flight must not wedge the server.
|
||||
|
||||
The abort cascades to the CFG companion (cfg_companion_tracker); a
|
||||
follow-up guided request on the same server must still succeed.
|
||||
"""
|
||||
import requests
|
||||
|
||||
url = f"{openai_client.base_url.rstrip('/')}/v1/audio/speech"
|
||||
long_input = " ".join([PROMPT] * 4)
|
||||
|
||||
with requests.post(
|
||||
url,
|
||||
json={
|
||||
"model": omni_server.model,
|
||||
"input": long_input,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
"extra_params": {"cfg_scale": 1.5},
|
||||
},
|
||||
stream=True,
|
||||
timeout=300,
|
||||
) as resp:
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Read one chunk, then abort the connection mid-stream.
|
||||
next(resp.iter_content(chunk_size=4096))
|
||||
|
||||
follow_up = requests.post(
|
||||
url,
|
||||
json={
|
||||
"model": omni_server.model,
|
||||
"input": PROMPT,
|
||||
"response_format": "wav",
|
||||
"extra_params": {"cfg_scale": 1.5},
|
||||
},
|
||||
timeout=300,
|
||||
)
|
||||
assert follow_up.status_code == 200, follow_up.text[:200]
|
||||
assert follow_up.content[:4] == b"RIFF" and len(follow_up.content) > 44
|
||||
@@ -0,0 +1,376 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Serving-layer regressions for the Audex zero-codec-token contract.
|
||||
|
||||
A zero-codec-token (or phase-invalid TTA) request reaches the serving layer
|
||||
as a stream whose only audio payloads are zero-size tensors. The streaming
|
||||
generator must not turn that into a header-only/empty successful stream: the
|
||||
raw-bytes generator raises (before any WAV header), and the SSE wrapper
|
||||
surfaces ``speech.audio.error`` instead of ``speech.audio.done``. The
|
||||
contract covers both audex pipelines (``audex`` TTS and ``audex_tta``).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.entrypoints.openai.serving_speech import (
|
||||
_AUDEX_NO_AUDIO_GUARD_MODEL_TYPES,
|
||||
OmniOpenAIServingSpeech,
|
||||
)
|
||||
|
||||
|
||||
def _serving(model_type: str = "audex") -> OmniOpenAIServingSpeech:
|
||||
serving = OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech)
|
||||
serving._tts_model_type = model_type
|
||||
serving._mark_ref_audio_artifact_ready_for_request = lambda request_id: None
|
||||
serving._discard_ref_audio_artifact_warmup = lambda request_id: None
|
||||
return serving
|
||||
|
||||
|
||||
def _result(audio: torch.Tensor) -> SimpleNamespace:
|
||||
return SimpleNamespace(multimodal_output={"model_outputs": audio, "sr": torch.tensor(16000, dtype=torch.int32)})
|
||||
|
||||
|
||||
async def _gen(*results):
|
||||
for res in results:
|
||||
yield res
|
||||
|
||||
|
||||
async def _collect(chunks):
|
||||
out = []
|
||||
async for chunk in chunks:
|
||||
out.append(chunk)
|
||||
return out
|
||||
|
||||
|
||||
def test_no_audio_guard_covers_both_audex_pipelines():
|
||||
# The non-streaming CreateAudio guard keys off the same constant; pin
|
||||
# its membership so neither pipeline regresses to empty-200 responses.
|
||||
assert _AUDEX_NO_AUDIO_GUARD_MODEL_TYPES == {"audex", "audex_tta"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_type", ["audex", "audex_tta"])
|
||||
async def test_raw_stream_raises_on_empty_audio_before_any_header(model_type):
|
||||
serving = _serving(model_type)
|
||||
chunks = serving._generate_audio_chunks(
|
||||
_gen(_result(torch.empty(0)), _result(torch.empty(0))),
|
||||
request_id="req-empty",
|
||||
response_format="wav",
|
||||
)
|
||||
with pytest.raises(ValueError, match="no audio"):
|
||||
await _collect(chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_stream_yields_audio_normally_when_non_empty():
|
||||
serving = _serving()
|
||||
chunks = serving._generate_audio_chunks(
|
||||
_gen(_result(torch.zeros(320)), _result(torch.empty(0))),
|
||||
request_id="req-ok",
|
||||
response_format="wav",
|
||||
)
|
||||
collected = await _collect(chunks)
|
||||
# WAV header + one PCM chunk; the trailing empty chunk is skipped silently.
|
||||
assert len(collected) == 2
|
||||
assert collected[0][:4] == b"RIFF"
|
||||
assert len(collected[1]) == 320 * 2 # int16 PCM
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_type", ["audex", "audex_tta"])
|
||||
async def test_sse_stream_emits_error_not_done_on_empty_audio(model_type):
|
||||
serving = _serving(model_type)
|
||||
events = serving._generate_audio_sse_events(
|
||||
_gen(_result(torch.empty(0))),
|
||||
request_id="req-empty",
|
||||
response_format="pcm",
|
||||
)
|
||||
collected = await _collect(events)
|
||||
assert any("speech.audio.error" in event for event in collected)
|
||||
assert not any("speech.audio.done" in event for event in collected)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_audex_empty_chunks_keep_legacy_behavior():
|
||||
"""Other models keep their existing semantics (no new rejection)."""
|
||||
serving = _serving(model_type="qwen3_tts")
|
||||
chunks = serving._generate_audio_chunks(
|
||||
_gen(_result(torch.empty(0))),
|
||||
request_id="req-other",
|
||||
response_format="pcm",
|
||||
)
|
||||
await _collect(chunks) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- CFG serving surface
|
||||
|
||||
import re # noqa: E402
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest # noqa: E402
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.audex import AudexAdapter # noqa: E402
|
||||
|
||||
|
||||
def _adapter() -> AudexAdapter:
|
||||
return AudexAdapter.__new__(AudexAdapter)
|
||||
|
||||
|
||||
def _speech_request(**extra_params) -> OpenAICreateSpeechRequest:
|
||||
payload = {"model": "audex", "input": "Hello world."}
|
||||
if extra_params:
|
||||
payload["extra_params"] = extra_params
|
||||
return OpenAICreateSpeechRequest(**payload)
|
||||
|
||||
|
||||
class _MarkerTokenizer:
|
||||
"""Special markers count one token; words and punctuation split."""
|
||||
|
||||
_TOKEN_RE = re.compile(r"<unk>|<\|[^|]*\|>|<[a-z_]+>|\w+|[^\w\s]")
|
||||
|
||||
def encode(self, text: str) -> list[int]:
|
||||
return [0] * len(self._TOKEN_RE.findall(text))
|
||||
|
||||
|
||||
class TestAudexCfgValidation:
|
||||
def test_no_cfg_scale_accepted(self):
|
||||
assert _adapter().validate(_speech_request()) is None
|
||||
|
||||
@pytest.mark.parametrize("scale", [1.0, 1.5, 3.0, 10.0])
|
||||
def test_in_range_scale_accepted(self, scale):
|
||||
assert _adapter().validate(_speech_request(cfg_scale=scale)) is None
|
||||
|
||||
@pytest.mark.parametrize("scale", [0.5, 0.0, -1.0, 10.5, "big"])
|
||||
def test_out_of_range_or_malformed_scale_rejected(self, scale):
|
||||
error = _adapter().validate(_speech_request(cfg_scale=scale))
|
||||
assert error is not None and "cfg_scale" in error
|
||||
|
||||
def test_explicit_null_scale_treated_as_absent(self):
|
||||
assert _adapter().validate(_speech_request(cfg_scale=None)) is None
|
||||
serving = TestAudexCfgInjection()._serving_with_fake_tokenizer()
|
||||
params = SimpleNamespace(extra_args={"cfg_scale": None})
|
||||
out = serving._inject_audex_cfg_pair_args("req-1", {"prompt": "p"}, params)
|
||||
assert out.extra_args == {}
|
||||
|
||||
@pytest.mark.parametrize("key", ["cfg_role", "cfg_pair_id", "cfg_null_prompt"])
|
||||
def test_internal_cfg_keys_rejected(self, key):
|
||||
error = _adapter().validate(_speech_request(**{key: "x"}))
|
||||
assert error is not None and key in error
|
||||
|
||||
|
||||
class TestAudexCfgInjection:
|
||||
def _serving_with_fake_tokenizer(self):
|
||||
serving = _serving()
|
||||
serving._get_audex_tokenizer = lambda: _MarkerTokenizer()
|
||||
return serving
|
||||
|
||||
def _cond_prompt(self, text: str = "Hello world.") -> str:
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt
|
||||
|
||||
return build_cond_prompt(text)
|
||||
|
||||
def test_no_extra_args_is_untouched(self):
|
||||
serving = self._serving_with_fake_tokenizer()
|
||||
params = SimpleNamespace(extra_args=None)
|
||||
out = serving._inject_audex_cfg_pair_args("req-1", {"prompt": self._cond_prompt()}, params)
|
||||
assert out.extra_args is None
|
||||
assert params.extra_args is None
|
||||
|
||||
def test_scale_one_leaves_params_byte_identical(self):
|
||||
serving = self._serving_with_fake_tokenizer()
|
||||
params = SimpleNamespace(extra_args={"cfg_scale": 1.0})
|
||||
out = serving._inject_audex_cfg_pair_args("req-1", {"prompt": self._cond_prompt()}, params)
|
||||
assert out.extra_args == {}
|
||||
|
||||
def test_guided_request_gains_pair_contract(self):
|
||||
serving = self._serving_with_fake_tokenizer()
|
||||
params = SimpleNamespace(extra_args={"cfg_scale": 1.5})
|
||||
cond = self._cond_prompt("Some transcription text here.")
|
||||
out = serving._inject_audex_cfg_pair_args("req-42", {"prompt": cond}, params)
|
||||
|
||||
extra = out.extra_args
|
||||
assert extra["cfg_scale"] == 1.5
|
||||
assert extra["cfg_role"] == "cond"
|
||||
assert extra["cfg_pair_id"] == "req-42"
|
||||
null_prompt = extra["cfg_null_prompt"]
|
||||
assert "<unk>" in null_prompt
|
||||
assert "Some transcription" not in null_prompt
|
||||
tok = _MarkerTokenizer()
|
||||
assert len(tok.encode(null_prompt)) == len(tok.encode(cond))
|
||||
|
||||
def test_missing_text_prompt_raises(self):
|
||||
serving = self._serving_with_fake_tokenizer()
|
||||
params = SimpleNamespace(extra_args={"cfg_scale": 1.5})
|
||||
with pytest.raises(ValueError, match="adapter-built text prompt"):
|
||||
serving._inject_audex_cfg_pair_args("req-1", {"prompt_token_ids": [1]}, params)
|
||||
|
||||
def test_injection_never_mutates_input_params(self):
|
||||
"""The input may be the engine's SHARED default params (review P2):
|
||||
pair state must land only on the returned clone."""
|
||||
serving = self._serving_with_fake_tokenizer()
|
||||
shared = SimpleNamespace(extra_args={"cfg_scale": 1.5}, temperature=0.1)
|
||||
out = serving._inject_audex_cfg_pair_args("req-7", {"prompt": self._cond_prompt()}, shared)
|
||||
assert out is not shared
|
||||
assert shared.extra_args == {"cfg_scale": 1.5}
|
||||
assert shared.temperature == 0.1
|
||||
assert out.extra_args["cfg_pair_id"] == "req-7"
|
||||
assert out.temperature == 0.05
|
||||
|
||||
|
||||
class TestAudexStageDetection:
|
||||
"""audex_omni is speech-capable only alongside the speech decoder."""
|
||||
|
||||
def _serving_with_stages(self, stages: list[str]) -> OmniOpenAIServingSpeech:
|
||||
serving = OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech)
|
||||
serving.engine_client = SimpleNamespace(
|
||||
stage_configs=[SimpleNamespace(engine_args=SimpleNamespace(model_stage=s)) for s in stages]
|
||||
)
|
||||
return serving
|
||||
|
||||
def test_full_pipeline_detected_as_tts(self):
|
||||
serving = self._serving_with_stages(["audex_omni", "audex_code2wav"])
|
||||
stage = serving._find_tts_stage()
|
||||
assert stage is not None and stage.engine_args.model_stage == "audex_omni"
|
||||
|
||||
def test_thinker_only_not_detected_as_tts(self):
|
||||
serving = self._serving_with_stages(["audex_omni"])
|
||||
assert serving._find_tts_stage() is None
|
||||
|
||||
def test_tts_pipeline_still_detected(self):
|
||||
serving = self._serving_with_stages(["audex_thinker", "audex_code2wav"])
|
||||
stage = serving._find_tts_stage()
|
||||
assert stage is not None and stage.engine_args.model_stage == "audex_thinker"
|
||||
|
||||
|
||||
def test_guided_injection_sets_guided_temperature():
|
||||
"""Guided requests decode at the measured CFG temperature (0.05)."""
|
||||
serving = TestAudexCfgInjection()._serving_with_fake_tokenizer()
|
||||
cond = TestAudexCfgInjection()._cond_prompt()
|
||||
params = SimpleNamespace(extra_args={"cfg_scale": 1.5}, temperature=0.1)
|
||||
out = serving._inject_audex_cfg_pair_args("req-1", {"prompt": cond}, params)
|
||||
assert out.temperature == 0.05
|
||||
|
||||
unguided = SimpleNamespace(extra_args={"cfg_scale": 1.0}, temperature=0.1)
|
||||
out = serving._inject_audex_cfg_pair_args("req-2", {"prompt": cond}, unguided)
|
||||
assert out.temperature == 0.1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- TTA serving surface
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import resolve_adapter # noqa: E402
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.audex_tta import AudexTTAAdapter # noqa: E402
|
||||
|
||||
|
||||
def _tta_adapter() -> AudexTTAAdapter:
|
||||
return AudexTTAAdapter.__new__(AudexTTAAdapter)
|
||||
|
||||
|
||||
class _TTAVocabTokenizer(_MarkerTokenizer):
|
||||
"""Marker tokenizer + a full audiocodec vocab for phase-id building."""
|
||||
|
||||
def get_vocab(self):
|
||||
vocab = {"<audiogen_start>": 7, "<audiogen_end>": 8}
|
||||
for n in range(8192):
|
||||
vocab[f"<audiocodec_{n}>"] = 1000 + n
|
||||
return vocab
|
||||
|
||||
|
||||
class TestAudexTTAAdapterValidation:
|
||||
def test_default_caption_accepted(self):
|
||||
assert _tta_adapter().validate(_speech_request()) is None
|
||||
|
||||
def test_empty_caption_rejected(self):
|
||||
req = _speech_request()
|
||||
req.input = " "
|
||||
assert _tta_adapter().validate(req) is not None
|
||||
|
||||
@pytest.mark.parametrize("scale", [1.0, 3.0, 10.0])
|
||||
def test_in_range_scale_accepted(self, scale):
|
||||
assert _tta_adapter().validate(_speech_request(cfg_scale=scale)) is None
|
||||
|
||||
@pytest.mark.parametrize("scale", [0.5, 10.5, "big"])
|
||||
def test_out_of_range_scale_rejected(self, scale):
|
||||
error = _tta_adapter().validate(_speech_request(cfg_scale=scale))
|
||||
assert error is not None and "cfg_scale" in error
|
||||
|
||||
@pytest.mark.parametrize("key", ["cfg_role", "cfg_pair_id", "cfg_null_prompt", "tta_rvq"])
|
||||
def test_internal_keys_rejected(self, key):
|
||||
error = _tta_adapter().validate(_speech_request(**{key: "x"}))
|
||||
assert error is not None and key in error
|
||||
|
||||
|
||||
class TestAdapterRouting:
|
||||
def test_tta_adapter_resolves_by_model_type(self):
|
||||
adapter_cls = resolve_adapter("audex_tta")
|
||||
assert adapter_cls is AudexTTAAdapter
|
||||
assert adapter_cls.stage_keys == frozenset({"audex_tta_thinker"})
|
||||
|
||||
def test_tts_adapter_never_selects_tta_stage(self):
|
||||
assert "audex_tta_thinker" not in AudexAdapter.stage_keys
|
||||
assert not (AudexAdapter.stage_keys & AudexTTAAdapter.stage_keys)
|
||||
|
||||
def test_detection_separates_tts_and_tta(self):
|
||||
tta_serving = TestAudexStageDetection()._serving_with_stages(["audex_tta_thinker", "audex_xcodec"])
|
||||
tta_serving._tts_stage = tta_serving._find_tts_stage()
|
||||
assert tta_serving._tts_stage.engine_args.model_stage == "audex_tta_thinker"
|
||||
assert tta_serving._detect_tts_model_type() == "audex_tta"
|
||||
|
||||
tts_serving = TestAudexStageDetection()._serving_with_stages(["audex_thinker", "audex_code2wav"])
|
||||
tts_serving._tts_stage = tts_serving._find_tts_stage()
|
||||
assert tts_serving._detect_tts_model_type() == "audex"
|
||||
|
||||
|
||||
class TestAudexTTAInjection:
|
||||
def _serving(self):
|
||||
serving = _serving(model_type="audex_tta")
|
||||
serving._get_audex_tokenizer = lambda: _TTAVocabTokenizer()
|
||||
serving._audex_tta_rvq = None
|
||||
return serving
|
||||
|
||||
def _prompt(self):
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_tta_cond_prompt
|
||||
|
||||
return {"prompt": build_tta_cond_prompt("Rain on a tin roof.")}
|
||||
|
||||
def test_default_injection_applies_rvq_and_cfg3(self):
|
||||
serving = self._serving()
|
||||
params = SimpleNamespace(extra_args=None)
|
||||
out = serving._inject_audex_tta_args("req-9", self._prompt(), params)
|
||||
|
||||
extra = out.extra_args
|
||||
rvq = extra["tta_rvq"]
|
||||
assert len(rvq["phase_token_ids"]) == 4
|
||||
assert all(len(ids) == 1024 for ids in rvq["phase_token_ids"])
|
||||
assert (rvq["start_tid"], rvq["end_tid"]) == (7, 8)
|
||||
assert rvq["codec_cap"] == 4000 and rvq["start_in_prompt"] is True
|
||||
assert extra["cfg_scale"] == 3.0
|
||||
assert extra["cfg_role"] == "cond" and extra["cfg_pair_id"] == "req-9"
|
||||
assert "<unk>" in extra["cfg_null_prompt"]
|
||||
|
||||
def test_explicit_scale_one_disables_cfg_but_keeps_rvq(self):
|
||||
serving = self._serving()
|
||||
params = SimpleNamespace(extra_args={"cfg_scale": 1.0})
|
||||
out = serving._inject_audex_tta_args("req-1", self._prompt(), params)
|
||||
assert "tta_rvq" in out.extra_args
|
||||
assert "cfg_scale" not in out.extra_args
|
||||
assert "cfg_role" not in out.extra_args
|
||||
|
||||
def test_rvq_contract_cached_per_process(self):
|
||||
serving = self._serving()
|
||||
out_a = serving._inject_audex_tta_args("a", self._prompt(), SimpleNamespace(extra_args=None))
|
||||
out_b = serving._inject_audex_tta_args("b", self._prompt(), SimpleNamespace(extra_args=None))
|
||||
assert out_a.extra_args["tta_rvq"] is out_b.extra_args["tta_rvq"]
|
||||
|
||||
def test_injection_never_mutates_input_params(self):
|
||||
"""The default online path hands the injector an element of the
|
||||
engine's SHARED default_sampling_params_list (review P2): the
|
||||
per-request pair state must land only on the returned clone."""
|
||||
serving = self._serving()
|
||||
shared = SimpleNamespace(extra_args=None)
|
||||
out_1 = serving._inject_audex_tta_args("req-1", self._prompt(), shared)
|
||||
out_2 = serving._inject_audex_tta_args("req-2", self._prompt(), shared)
|
||||
assert shared.extra_args is None
|
||||
assert out_1 is not shared and out_2 is not shared
|
||||
assert out_1.extra_args["cfg_pair_id"] == "req-1"
|
||||
assert out_2.extra_args["cfg_pair_id"] == "req-2"
|
||||
@@ -529,6 +529,26 @@ def decode_b64_image(b64: str):
|
||||
return img
|
||||
|
||||
|
||||
def concat_audio(audio_val) -> np.ndarray:
|
||||
"""Flatten a multimodal audio payload to mono float32 samples.
|
||||
|
||||
Engines return ``multimodal_output["audio"]`` as a tensor, a list of
|
||||
per-chunk tensors (streaming decoders), or an array-like; concatenate
|
||||
in order and return a 1-D ``np.float32`` array (empty when a list has
|
||||
no tensors).
|
||||
"""
|
||||
import torch
|
||||
|
||||
if isinstance(audio_val, list):
|
||||
tensors = [t.detach().cpu().float().reshape(-1) for t in audio_val if isinstance(t, torch.Tensor)]
|
||||
if not tensors:
|
||||
return np.zeros((0,), dtype=np.float32)
|
||||
return torch.cat(tensors, dim=-1).numpy().astype(np.float32, copy=False)
|
||||
if isinstance(audio_val, torch.Tensor):
|
||||
return audio_val.detach().cpu().float().reshape(-1).numpy()
|
||||
return np.asarray(audio_val, dtype=np.float32).reshape(-1)
|
||||
|
||||
|
||||
def preprocess_text(text):
|
||||
import opencc
|
||||
|
||||
@@ -695,6 +715,7 @@ def convert_audio_bytes_to_text(raw_bytes: bytes, model_size: str = "small") ->
|
||||
|
||||
__all__ = [
|
||||
"_merge_base64_audio_to_segment",
|
||||
"concat_audio",
|
||||
"convert_audio_bytes_to_text",
|
||||
"convert_audio_file_to_text",
|
||||
"cosine_similarity_text",
|
||||
|
||||
@@ -45,6 +45,26 @@ logger = init_logger(__name__)
|
||||
_DEFAULT_PARALLEL_DEGREE = 1
|
||||
|
||||
|
||||
def with_trust_remote_code_override(
|
||||
overrides: Mapping[str, Any],
|
||||
trust_remote_code: bool | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge the tri-state ``trust_remote_code`` into an override mapping.
|
||||
|
||||
Single home for the precedence rule (explicit caller value > deploy
|
||||
yaml per-stage value > vLLM default False): a non-None value becomes an
|
||||
explicit override; ``None`` means "not specified" and leaves the deploy
|
||||
yaml's per-stage setting in effect. The serve ``--trust-remote-code``
|
||||
flag is store_true — its absent-False must be mapped to ``None`` at the
|
||||
CLI boundary before reaching here, since it cannot express an explicit
|
||||
False.
|
||||
"""
|
||||
merged = dict(overrides)
|
||||
if trust_remote_code is not None:
|
||||
merged["trust_remote_code"] = trust_remote_code
|
||||
return merged
|
||||
|
||||
|
||||
class StageConfigFactory:
|
||||
"""Factory that loads pipeline YAML and merges CLI overrides.
|
||||
|
||||
@@ -293,7 +313,7 @@ class StageConfigFactory:
|
||||
cls,
|
||||
model: str,
|
||||
*,
|
||||
trust_remote_code: bool,
|
||||
trust_remote_code: bool | None,
|
||||
cli_overrides: dict[str, Any],
|
||||
deploy_config_path: str | None,
|
||||
) -> VllmOmniConfig | None:
|
||||
@@ -301,18 +321,19 @@ class StageConfigFactory:
|
||||
user_deploy_config = cls._load_user_deploy_config(deploy_config_path)
|
||||
pipeline_cfg = cls.get_pipeline_config(
|
||||
model=model,
|
||||
trust_remote_code=trust_remote_code,
|
||||
# HF config resolution needs a real bool: transformers treats
|
||||
# None as "prompt for consent", which blocks non-interactively.
|
||||
trust_remote_code=bool(trust_remote_code),
|
||||
deploy_config_path=deploy_config_path,
|
||||
user_deploy_config=user_deploy_config,
|
||||
)
|
||||
if pipeline_cfg is None:
|
||||
return None
|
||||
|
||||
registry_cli_overrides = {
|
||||
**cli_overrides,
|
||||
"trust_remote_code": trust_remote_code,
|
||||
"model": model,
|
||||
}
|
||||
registry_cli_overrides = with_trust_remote_code_override(
|
||||
{**cli_overrides, "model": model},
|
||||
trust_remote_code,
|
||||
)
|
||||
return VllmOmniConfig.from_pipeline_config(
|
||||
pipeline_cfg,
|
||||
user_deploy_config=user_deploy_config,
|
||||
@@ -325,7 +346,7 @@ class StageConfigFactory:
|
||||
cls,
|
||||
model: str,
|
||||
*,
|
||||
trust_remote_code: bool,
|
||||
trust_remote_code: bool | None,
|
||||
cli_overrides: dict[str, Any],
|
||||
deploy_config_path: str | None,
|
||||
strategy_specs: Mapping[Any, Any] | None = None,
|
||||
@@ -339,17 +360,15 @@ class StageConfigFactory:
|
||||
user_deploy_config = cls._load_user_deploy_config(deploy_config_path)
|
||||
pipeline_cfg = cls.get_pipeline_config(
|
||||
model=model,
|
||||
trust_remote_code=trust_remote_code,
|
||||
# See create_from_model: HF resolution needs a real bool.
|
||||
trust_remote_code=bool(trust_remote_code),
|
||||
deploy_config_path=deploy_config_path,
|
||||
user_deploy_config=user_deploy_config,
|
||||
)
|
||||
if pipeline_cfg is None:
|
||||
return None, None
|
||||
|
||||
legacy_cli_overrides = {
|
||||
**cli_overrides,
|
||||
"trust_remote_code": trust_remote_code,
|
||||
}
|
||||
legacy_cli_overrides = with_trust_remote_code_override(cli_overrides, trust_remote_code)
|
||||
return cls._create_legacy_from_registry(
|
||||
pipeline_cfg,
|
||||
legacy_cli_overrides,
|
||||
|
||||
@@ -146,7 +146,10 @@ class OmniModelConfig(ModelConfig):
|
||||
|
||||
@property
|
||||
def architectures(self) -> list[str]:
|
||||
if self.model_arch is not None:
|
||||
# Falsy (None or "") means "no stage override": fall back to the
|
||||
# checkpoint config's own architectures. The stage-config builder
|
||||
# emits None; "" is tolerated for legacy callers.
|
||||
if self.model_arch:
|
||||
return [self.model_arch]
|
||||
return super().architectures
|
||||
|
||||
|
||||
@@ -270,6 +270,11 @@ class OmniStageModelConfig:
|
||||
enable_multithread_weight_load: bool = True
|
||||
num_weight_load_threads: int = Field(default=4, ge=1)
|
||||
disable_autocast: bool = False
|
||||
# Per-stage checkpoint/tokenizer subdirectories under the model root
|
||||
# (e.g. Audex stage 0 → checkpoint_folder_audiogen). Mirrors
|
||||
# StagePipelineConfig.model_subdir/tokenizer_subdir on the legacy path.
|
||||
model_subdir: str | None = None
|
||||
tokenizer_subdir: str | None = None
|
||||
|
||||
|
||||
@config
|
||||
@@ -1117,6 +1122,10 @@ def _build_model_config(
|
||||
kwargs = _config_kwargs(engine)
|
||||
if "has_sampling_extra_args" not in kwargs:
|
||||
kwargs["has_sampling_extra_args"] = bool((default_sampling_params or {}).get("extra_args"))
|
||||
if "model_subdir" not in kwargs and topology.model_subdir is not None:
|
||||
kwargs["model_subdir"] = topology.model_subdir
|
||||
if "tokenizer_subdir" not in kwargs and topology.tokenizer_subdir is not None:
|
||||
kwargs["tokenizer_subdir"] = topology.tokenizer_subdir
|
||||
return OmniStageModelConfig(
|
||||
default_sampling_params=default_sampling_params,
|
||||
**kwargs,
|
||||
|
||||
@@ -33,6 +33,12 @@ from vllm.logger import init_logger
|
||||
from vllm_omni.config.stage_config import (
|
||||
PipelineConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.models.audex.pipeline import (
|
||||
AUDEX_S2S_PIPELINE,
|
||||
AUDEX_THINKER_ONLY_PIPELINE,
|
||||
AUDEX_TTA_PIPELINE,
|
||||
AUDEX_TTS_PIPELINE,
|
||||
)
|
||||
from vllm_omni.model_executor.models.aura_omni.pipeline import AURA_OMNI_PIPELINE
|
||||
from vllm_omni.model_executor.models.bagel.pipeline import (
|
||||
BAGEL_PIPELINE,
|
||||
@@ -126,6 +132,14 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
|
||||
"wan2_2_ti2v": WAN2_2_TI2V_PIPELINE,
|
||||
"voxcpm2": VOXCPM2_PIPELINE,
|
||||
"cosyvoice3": COSYVOICE3_PIPELINE,
|
||||
"audex_tts": AUDEX_TTS_PIPELINE,
|
||||
"audex_tta": AUDEX_TTA_PIPELINE,
|
||||
"audex_thinker_only": AUDEX_THINKER_ONLY_PIPELINE,
|
||||
"audex_s2s": AUDEX_S2S_PIPELINE,
|
||||
# Alias: the Nemotron-Labs-Audex-2B repo-root config.json reports
|
||||
# ``model_type: nemotron_labs_audex``; bare ``vllm-omni serve <repo>``
|
||||
# auto-detects through it and must land on the default (TTS) pipeline.
|
||||
"nemotron_labs_audex": AUDEX_TTS_PIPELINE,
|
||||
"mimo_audio": MIMO_AUDIO_PIPELINE,
|
||||
"ming_tts": MING_TTS_PIPELINE,
|
||||
"ming_tts_moe": MING_TTS_MOE_PIPELINE,
|
||||
|
||||
@@ -821,7 +821,7 @@ def _build_engine_args(
|
||||
per-stage StageDeployConfig overrides take precedence when present (e.g.
|
||||
``engine_extras`` can still carry a stage-specific ``dtype``).
|
||||
"""
|
||||
engine_args: dict[str, Any] = {"model_arch": ps.model_arch or pipeline.model_arch}
|
||||
engine_args: dict[str, Any] = {"model_arch": ps.model_arch or pipeline.model_arch or None}
|
||||
if ps.execution_type == StageExecutionType.DIFFUSION and ps.model_arch:
|
||||
engine_args.setdefault("model_class_name", ps.model_arch)
|
||||
if ps.engine_output_type:
|
||||
|
||||
@@ -51,6 +51,12 @@ _FULL_PAYLOAD_INPUT_STAGES: frozenset[tuple[str, str]] = frozenset(
|
||||
("MiniCPMO45Code2Wav", "code2wav"),
|
||||
# cosyvoice3: cosyvoice3_talker (Stage 0) -> cosyvoice3_code2wav (Stage 1).
|
||||
("CosyVoice3Model", "cosyvoice3_code2wav"),
|
||||
# audex TTS sync path: thinker (Stage 0) -> streaming decoder (Stage 1).
|
||||
# The default deploy is async_chunk; this covers async_chunk: false.
|
||||
("AudexCode2Wav", "audex_code2wav"),
|
||||
# audex TTA: tta thinker (Stage 0) -> XCodec1 (Stage 1, always sync
|
||||
# full-payload — CNN codec decoded over the full sequence).
|
||||
("AudexXCodec1", "audex_xcodec"),
|
||||
# indextts2: indextts2_talker (Stage 0) -> indextts2_s2mel_decoder
|
||||
# (Stage 1). Stage 1 consumes the complete mel/latent payload.
|
||||
("IndexTTS2S2MelDecoder", "indextts2_s2mel_decoder"),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Audex (Nemotron-Labs-Audex-2B) full deploy: cascaded speech-to-speech.
|
||||
#
|
||||
# One audio-capable thinker (checkpoint_folder_full) serves the official
|
||||
# three-pass cascade — (1) ASR: audio -> transcript, (2) chat: transcript ->
|
||||
# answer, (3) TTS: answer -> speech — plus the streaming causal speech
|
||||
# decoder as stage 1. Per-request output modalities route the passes: text
|
||||
# passes finish at stage 0; only audio-modality requests stream codec frames
|
||||
# into code2wav. CFG applies to the TTS pass via extra_params.cfg_scale
|
||||
# (recommended 1.5; 1.0 = off).
|
||||
pipeline: audex_s2s
|
||||
async_chunk: true
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
shm_threshold_bytes: 65536
|
||||
codec_streaming: true
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
codec_chunk_frames: 25
|
||||
initial_codec_chunk_frames: 1
|
||||
codec_token_offset: 131077
|
||||
codec_vocab_size: 65536
|
||||
decoder_emit_chunk_frames: 5
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.5
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: true
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 8192
|
||||
# Sound embeddings (up to 30 clips x 750) + speech-token generation.
|
||||
max_model_len: 32768
|
||||
dtype: bfloat16
|
||||
devices: "0"
|
||||
logits_processors:
|
||||
- "vllm_omni.model_executor.models.audex.cfg:AudexCFGLogitsProcessor"
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.1
|
||||
top_k: 80
|
||||
max_tokens: 2048
|
||||
seed: 0
|
||||
|
||||
- stage_id: 1
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.25
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: false
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 8192
|
||||
devices: "0"
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
max_tokens: 8192
|
||||
seed: 0
|
||||
@@ -0,0 +1,60 @@
|
||||
# Audex 30B-A3B full deploy: cascaded speech-to-speech.
|
||||
# See audex_s2s.yaml for the cascade/routing notes and audex_tts_30b.yaml
|
||||
# for the 30B sizing notes (hybrid Mamba: no prefix caching; single-H100
|
||||
# layout, TP2 fallback; pass this yaml explicitly).
|
||||
pipeline: audex_s2s
|
||||
async_chunk: true
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
shm_threshold_bytes: 65536
|
||||
codec_streaming: true
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
codec_chunk_frames: 25
|
||||
initial_codec_chunk_frames: 1
|
||||
codec_token_offset: 131077
|
||||
codec_vocab_size: 65536
|
||||
decoder_emit_chunk_frames: 5
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.85
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 8192
|
||||
# Sound embeddings (up to 30 clips x 750) + speech-token generation.
|
||||
max_model_len: 32768
|
||||
dtype: bfloat16
|
||||
devices: "0"
|
||||
logits_processors:
|
||||
- "vllm_omni.model_executor.models.audex.cfg:AudexCFGLogitsProcessor"
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.1
|
||||
top_k: 80
|
||||
max_tokens: 2048
|
||||
seed: 0
|
||||
|
||||
- stage_id: 1
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.08
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: false
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 8192
|
||||
devices: "0"
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
max_tokens: 8192
|
||||
seed: 0
|
||||
@@ -0,0 +1,25 @@
|
||||
# Audex (Nemotron-Labs-Audex-2B) thinker-only deploy: audio understanding
|
||||
# (ASR / audio QA) -> text. Single-stage configuration of the audio-capable
|
||||
# full checkpoint (checkpoint_folder_full): NV-Whisper encoder + relu2
|
||||
# projector + 2B dense LM.
|
||||
#
|
||||
# The official audioqa script decodes ASR at temperature 0; max_tokens 256
|
||||
# covers transcription and short QA answers (raise for long-form answers).
|
||||
pipeline: audex_thinker_only
|
||||
async_chunk: false
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.7
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: true
|
||||
max_num_batched_tokens: 8192
|
||||
# 30 clips x 750 sound embeddings (900 s cap) + text headroom.
|
||||
max_model_len: 32768
|
||||
dtype: bfloat16
|
||||
devices: "0"
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
max_tokens: 256
|
||||
seed: 0
|
||||
@@ -0,0 +1,22 @@
|
||||
# Audex 30B-A3B thinker-only deploy: audio understanding (ASR / audio QA).
|
||||
# Single-stage NemotronHAudexForConditionalGeneration on
|
||||
# checkpoint_folder_full. Hybrid Mamba: no prefix caching. Pass this yaml
|
||||
# explicitly (see audex_tts_30b.yaml).
|
||||
pipeline: audex_thinker_only
|
||||
async_chunk: false
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.9
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
max_num_batched_tokens: 8192
|
||||
# 30 clips x 750 sound embeddings (900 s cap) + text headroom.
|
||||
max_model_len: 32768
|
||||
dtype: bfloat16
|
||||
devices: "0"
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
max_tokens: 256
|
||||
seed: 0
|
||||
@@ -0,0 +1,69 @@
|
||||
# Audex (Nemotron-Labs-Audex-2B) text-to-audio deploy: TTA thinker → XCodec1.
|
||||
#
|
||||
# Notable choices:
|
||||
# * Sync full-payload (async_chunk: false): XCodec1 is a CNN codec the
|
||||
# official flow decodes over the full sequence; there is no streaming
|
||||
# reference and chunked decode risks boundary artifacts.
|
||||
# * Stage 0 sampling defaults follow the official TTA script (temperature
|
||||
# 1.0, top_k 80; stop on <audiogen_end>; codec cap 4000 tokens = 1000
|
||||
# frames generated, decode truncates at 500 frames ≈ 10 s).
|
||||
# * Classifier-free guidance is effectively mandatory for TTA quality; the
|
||||
# official default is cfg_scale 3.0. The RVQ phase mask is required for
|
||||
# validity — both logits processors are registered here and driven per
|
||||
# request via extra_args (cfg_* and tta_rvq).
|
||||
# * Stage 1's model is the EXTERNAL hf-audio XCodec1 checkpoint (override
|
||||
# with a local path here or via XCODEC1_PATH); its tokenizer is borrowed
|
||||
# from the thinker checkpoint automatically.
|
||||
pipeline: audex_tta
|
||||
async_chunk: false
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
shm_threshold_bytes: 65536
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
audiocodec_token_offset: 196613
|
||||
audiocodec_vocab_size: 8192
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.4
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: true
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 512
|
||||
max_model_len: 8192
|
||||
dtype: bfloat16
|
||||
devices: "0"
|
||||
logits_processors:
|
||||
- "vllm_omni.model_executor.models.audex.cfg:AudexCFGLogitsProcessor"
|
||||
- "vllm_omni.model_executor.models.audex.tta:TTARVQPhaseMaskLogitsProcessor"
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 1.0
|
||||
top_k: 80
|
||||
max_tokens: 4200
|
||||
seed: 0
|
||||
|
||||
- stage_id: 1
|
||||
model: hf-audio/xcodec-hubert-general-balanced
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.25
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: false
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 8192
|
||||
devices: "0"
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
max_tokens: 8192
|
||||
seed: 0
|
||||
@@ -0,0 +1,57 @@
|
||||
# Audex 30B-A3B text-to-audio deploy: TTA thinker → XCodec1.
|
||||
# See audex_tta.yaml for the task notes and audex_tts_30b.yaml for the
|
||||
# 30B sizing notes (hybrid Mamba: no prefix caching; single-H100 layout,
|
||||
# TP2 fallback; pass this yaml explicitly).
|
||||
pipeline: audex_tta
|
||||
async_chunk: false
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
shm_threshold_bytes: 65536
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
audiocodec_token_offset: 196613
|
||||
audiocodec_vocab_size: 8192
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.85
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 512
|
||||
max_model_len: 8192
|
||||
dtype: bfloat16
|
||||
devices: "0"
|
||||
logits_processors:
|
||||
- "vllm_omni.model_executor.models.audex.cfg:AudexCFGLogitsProcessor"
|
||||
- "vllm_omni.model_executor.models.audex.tta:TTARVQPhaseMaskLogitsProcessor"
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 1.0
|
||||
top_k: 80
|
||||
max_tokens: 4200
|
||||
seed: 0
|
||||
|
||||
- stage_id: 1
|
||||
model: hf-audio/xcodec-hubert-general-balanced
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.08
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: false
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 8192
|
||||
devices: "0"
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
max_tokens: 8192
|
||||
seed: 0
|
||||
@@ -0,0 +1,83 @@
|
||||
# Audex (Nemotron-Labs-Audex-2B) TTS deploy: thinker → code2wav via
|
||||
# shared-memory chunk streaming. Verified on 1x H100.
|
||||
#
|
||||
# Notable choices:
|
||||
# * The code2wav decoder is a stateful streaming session per request — no
|
||||
# left-context replay; chunks carry only new frames, so
|
||||
# codec_left_context_frames stays 0.
|
||||
# * codec_token_offset / codec_vocab_size mirror the checkpoint tokenizer's
|
||||
# <speechcodec_N> block (pinned by a unit test).
|
||||
# * Stage 0 sampling defaults follow the official TTS script
|
||||
# (temperature 0.1, top_k 80, max_tokens 2048).
|
||||
# * Classifier-free guidance: the stage-0 logits processor + scheduler
|
||||
# pair patches are always installed but idle until a request carries
|
||||
# extra_args.cfg_scale > 1.0 (serving: extra_params.cfg_scale; offline:
|
||||
# --cfg-scale). Guided requests decode at temperature 0.05 (CFG
|
||||
# sharpens the distribution; measured en-24 cfg1.5 CER: 6.87% at
|
||||
# temp 0.05 vs 7.31% at the unguided 0.1 — baseline 7.24%).
|
||||
# Every guided request adds an unconditional companion sequence in
|
||||
# stage 0 — at max_num_seqs 8 that means at most 4 guided requests
|
||||
# decode concurrently; raise max_num_seqs if CFG-heavy traffic needs
|
||||
# more parallelism.
|
||||
pipeline: audex_tts
|
||||
async_chunk: true
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
shm_threshold_bytes: 65536
|
||||
codec_streaming: true
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
codec_chunk_frames: 25
|
||||
# Emit the first chunk early for a fast first audible sample.
|
||||
initial_codec_chunk_frames: 1
|
||||
codec_token_offset: 131077
|
||||
codec_vocab_size: 65536
|
||||
# Decoder-internal emit granularity (frames per decode call).
|
||||
decoder_emit_chunk_frames: 5
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.4
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: true
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 512
|
||||
max_model_len: 4096
|
||||
dtype: bfloat16
|
||||
devices: "0"
|
||||
logits_processors:
|
||||
- "vllm_omni.model_executor.models.audex.cfg:AudexCFGLogitsProcessor"
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.1
|
||||
top_k: 80
|
||||
max_tokens: 2048
|
||||
seed: 0
|
||||
|
||||
- stage_id: 1
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.25
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
# Sync scheduling: with async scheduling the runner builds per-request
|
||||
# payloads against the NEXT step's input batch, tripping the one-entry-
|
||||
# per-request check when chunk arrival staggers requests.
|
||||
async_scheduling: false
|
||||
# async_chunk appends codec frames per step; max_model_len must cover the
|
||||
# accumulated stream (max_tokens 2048 codes plus prompt headroom).
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 8192
|
||||
devices: "0"
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
max_tokens: 8192
|
||||
seed: 0
|
||||
@@ -0,0 +1,76 @@
|
||||
# Audex 30B-A3B (nvidia/Nemotron-Labs-Audex-30B-A3B) TTS deploy.
|
||||
#
|
||||
# Same pipeline/topology/token space as the 2B (audex_tts.yaml); the thinker
|
||||
# is the hybrid Mamba+MoE NemotronHForCausalLM (~60 GB bf16), so:
|
||||
# * enable_prefix_caching: false — hybrid Mamba stage.
|
||||
# * Single H100 80GB layout: thinker 0.85 + decoder 0.08 share GPU 0.
|
||||
# If weights + state do not fit (long sequences OOM), set
|
||||
# tensor_parallel_size: 2 on stage 0 and move stage 1 to devices "2".
|
||||
# * CUDA graphs stay ON for stage 0: the hybrid Mamba+MoE thinker
|
||||
# captures cleanly and review benchmarks measured ~7x lower TTS RTF
|
||||
# vs enforce_eager (0.30 vs 2.08 single-stream), on par with the 2B
|
||||
# and NVIDIA's reference demo.
|
||||
# * Classifier-free guidance quality (en-24, self-transcribed ASR CER):
|
||||
# baseline cfg=1.0 -> 0.72%; guided cfg=1.5 at temperature 0.1 -> 0.72%
|
||||
# (gate: guided <= baseline, PASS). Note the 30B's best guided
|
||||
# temperature is 0.1 (the yaml default) — unlike the 2B's 0.05, which
|
||||
# measured 0.79% here. The serving layer currently injects 0.05 for
|
||||
# guided requests on all sizes; acceptable (still within the anchor
|
||||
# +2pp standing gate) but a size-aware refinement is a known follow-up.
|
||||
# ALWAYS pass this yaml explicitly (--stage-configs-path): bare serve of the
|
||||
# 30B repo auto-detects the shared model_type and lands on the 2B-tuned yaml.
|
||||
pipeline: audex_tts
|
||||
async_chunk: true
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
shm_threshold_bytes: 65536
|
||||
codec_streaming: true
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
codec_chunk_frames: 25
|
||||
initial_codec_chunk_frames: 1
|
||||
codec_token_offset: 131077
|
||||
codec_vocab_size: 65536
|
||||
decoder_emit_chunk_frames: 5
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.85
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 512
|
||||
max_model_len: 4096
|
||||
dtype: bfloat16
|
||||
devices: "0"
|
||||
logits_processors:
|
||||
- "vllm_omni.model_executor.models.audex.cfg:AudexCFGLogitsProcessor"
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.1
|
||||
top_k: 80
|
||||
max_tokens: 2048
|
||||
seed: 0
|
||||
|
||||
- stage_id: 1
|
||||
max_num_seqs: 8
|
||||
gpu_memory_utilization: 0.08
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: false
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 8192
|
||||
devices: "0"
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
max_tokens: 8192
|
||||
seed: 0
|
||||
@@ -30,7 +30,7 @@ from vllm.logger import init_logger
|
||||
from vllm.v1.engine import EngineCoreRequest
|
||||
from vllm.v1.engine.input_processor import InputProcessor
|
||||
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.config.config_factory import StageConfigFactory, with_trust_remote_code_override
|
||||
from vllm_omni.config.stage_config import (
|
||||
DuplexSessionRuntimeConfig,
|
||||
load_deploy_config,
|
||||
@@ -151,7 +151,7 @@ class AsyncOmniEngine:
|
||||
transfer_emitter: Any = None,
|
||||
log_stats: bool = False,
|
||||
tokenizer: str | None = None,
|
||||
trust_remote_code: bool = False,
|
||||
trust_remote_code: bool | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.model = model
|
||||
@@ -230,7 +230,11 @@ class AsyncOmniEngine:
|
||||
if deploy_config_path is not None:
|
||||
self.duplex_session_config = load_deploy_config(deploy_config_path).duplex_session
|
||||
|
||||
kwargs["trust_remote_code"] = trust_remote_code
|
||||
# Tri-state: None means "not specified" — the deploy yaml's per-stage
|
||||
# trust_remote_code stays in effect. An explicit True/False here is a
|
||||
# global override (precedence: caller > deploy yaml > default False);
|
||||
# the merge rule lives in with_trust_remote_code_override.
|
||||
kwargs = with_trust_remote_code_override(kwargs, trust_remote_code)
|
||||
self.config_path, self.stage_configs = self._resolve_stage_configs(
|
||||
model,
|
||||
kwargs,
|
||||
@@ -786,6 +790,11 @@ class AsyncOmniEngine:
|
||||
supported_tasks=self.supported_tasks,
|
||||
)
|
||||
request.external_req_id = cid
|
||||
# Companions are stage-0-final: without this worker-visible tag
|
||||
# the AR runner would still emit downstream connector payloads
|
||||
# for them (the orchestrator-side final_stage_id=0 registration
|
||||
# alone does not reach the worker).
|
||||
request = apply_omni_final_stage_metadata(request, 0)
|
||||
|
||||
# Registration of this companion on stage-0's output processor is
|
||||
# deferred to Orchestrator._handle_add_companion, which routes
|
||||
@@ -1142,7 +1151,7 @@ class AsyncOmniEngine:
|
||||
model: str,
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
trust_remote_code: bool,
|
||||
trust_remote_code: bool | None,
|
||||
) -> tuple[str, list[Any]]:
|
||||
"""Resolve stage configs and inject defaults shared by orchestrator/headless."""
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ from vllm.v1.engine.utils import (
|
||||
|
||||
from vllm_omni.distributed.omni_coordinator import create_stage_coord_client
|
||||
from vllm_omni.engine import OmniEngineCoreRequest
|
||||
from vllm_omni.engine.stage_init_utils import set_death_signal
|
||||
from vllm_omni.engine.stage_init_utils import (
|
||||
maybe_apply_audex_cfg_patches,
|
||||
set_death_signal,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -131,6 +134,10 @@ class StageEngineCoreProc(EngineCoreProc):
|
||||
_vllm_engine_core_module.EngineCoreRequest,
|
||||
)
|
||||
|
||||
# Audex CFG scheduler patches must land before EngineCore builds
|
||||
# its Scheduler; gated on the stage's logits_processors config.
|
||||
maybe_apply_audex_cfg_patches(kwargs.get("vllm_config"))
|
||||
|
||||
engine_core = StageEngineCoreProc(
|
||||
*args,
|
||||
engine_index=dp_rank,
|
||||
|
||||
@@ -767,11 +767,53 @@ def build_engine_args_dict(
|
||||
stage_id = stage_config.stage_id
|
||||
|
||||
engine_args_dict = _to_dict(engine_args)
|
||||
pipeline_model_root = model
|
||||
model = engine_args_dict.pop("model", None) or model
|
||||
stage_defines_tokenizer = (
|
||||
engine_args_dict.get("tokenizer") is not None or engine_args_dict.get("tokenizer_subdir") is not None
|
||||
)
|
||||
audex_stage = str(engine_args_dict.get("model_stage") or "")
|
||||
if audex_stage == "audex_xcodec":
|
||||
# TTA stage 1 decodes with the external XCodec1 checkpoint, not a
|
||||
# subfolder of the Audex repo: the source comes from XCODEC1_PATH,
|
||||
# the stage yaml's own ``model`` entry, or the default HF repo — the
|
||||
# pipeline-level model (the Audex root) is never a valid source.
|
||||
from vllm_omni.model_executor.models.audex.checkpoint import (
|
||||
ensure_audex_snapshot,
|
||||
ensure_xcodec1_snapshot,
|
||||
)
|
||||
|
||||
stage_model = None if model == pipeline_model_root else model
|
||||
model = ensure_xcodec1_snapshot(os.environ.get("XCODEC1_PATH") or stage_model)
|
||||
if not stage_defines_tokenizer:
|
||||
# XCodec1 ships no tokenizer files; borrow the thinker's (same
|
||||
# workaround as the TTS decoder stage).
|
||||
audex_root = ensure_audex_snapshot(pipeline_model_root, profile="tta")
|
||||
engine_args_dict["tokenizer"] = os.path.join(audex_root, "checkpoint_folder_audiogen")
|
||||
stage_defines_tokenizer = True
|
||||
elif audex_stage.startswith("audex"):
|
||||
# Audex users pass the HF repo ROOT; make sure the required snapshot
|
||||
# subset exists locally BEFORE subdir resolution, otherwise the
|
||||
# subdirs get joined onto the raw repo id on a fresh cache. The
|
||||
# profile keeps TTS-only deployments from pulling the full-checkpoint
|
||||
# extras.
|
||||
from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot
|
||||
|
||||
if audex_stage == "audex_omni":
|
||||
audex_profile = "full"
|
||||
elif audex_stage == "audex_tta_thinker":
|
||||
audex_profile = "tta"
|
||||
else:
|
||||
audex_profile = "tts"
|
||||
model = ensure_audex_snapshot(model, profile=audex_profile)
|
||||
model = _resolve_model_tokenizer_paths(model, engine_args_dict)
|
||||
if engine_args_dict.get("model_stage") in ("audex_thinker", "audex_tta_thinker"):
|
||||
# Audex ships its thinker weights deduplicated into a sibling folder;
|
||||
# replicate the official prepare-script symlink on first use. The TTA
|
||||
# thinker loads the same checkpoint_folder_audiogen checkpoint.
|
||||
from vllm_omni.model_executor.models.audex.checkpoint import ensure_audiogen_weights
|
||||
|
||||
ensure_audiogen_weights(model)
|
||||
apply_cli_tokenizer(
|
||||
engine_args_dict,
|
||||
cli_tokenizer=cli_tokenizer,
|
||||
@@ -1197,3 +1239,26 @@ def initialize_diffusion_stage(
|
||||
|
||||
od_config = build_diffusion_config(model, stage_cfg, metadata)
|
||||
return create_diffusion_client(model, od_config, metadata, stage_init_timeout, batch_size, use_inline)
|
||||
|
||||
|
||||
def maybe_apply_audex_cfg_patches(vllm_config: Any) -> None:
|
||||
"""Install the Audex CFG scheduler patches for CFG-configured engines.
|
||||
|
||||
Must run in the engine-core process BEFORE ``Scheduler`` is constructed:
|
||||
the patch wraps ``Scheduler.__init__`` to add the pair registry, so a
|
||||
scheduler built earlier would never become pair-aware. Gated on the
|
||||
engine's ``logits_processors`` so non-CFG stages stay untouched.
|
||||
"""
|
||||
model_config = getattr(vllm_config, "model_config", None)
|
||||
processors = getattr(model_config, "logits_processors", None) or []
|
||||
if not any("AudexCFGLogitsProcessor" in getattr(proc, "__name__", str(proc)) for proc in processors):
|
||||
return
|
||||
|
||||
from vllm_omni.model_executor.models.audex.cfg import apply_cfg_patches
|
||||
|
||||
apply_cfg_patches()
|
||||
|
||||
from vllm.v1.core.sched.scheduler import Scheduler
|
||||
|
||||
if not getattr(Scheduler.schedule, "_audex_cfg_patched", False):
|
||||
raise RuntimeError("Audex CFG scheduler patches failed to install before Scheduler construction")
|
||||
@@ -852,7 +852,9 @@ def run_headless(args: TrackingNamespace) -> None:
|
||||
model,
|
||||
stage_configs_path,
|
||||
args_dict,
|
||||
trust_remote_code=bool(getattr(args, "trust_remote_code", False)),
|
||||
# store_true cannot express an explicit False: absent maps to None
|
||||
# ("not specified") so the deploy yaml's per-stage value applies.
|
||||
trust_remote_code=getattr(args, "trust_remote_code", None) or None,
|
||||
deploy_config_path=args_dict.get("deploy_config"),
|
||||
stage_overrides=stage_overrides,
|
||||
strategy_config_path=args_dict.get("strategy_config"),
|
||||
|
||||
@@ -100,6 +100,10 @@ _HIGGS_V3_TTS_MODEL_STAGES = {"higgs_audio_v3"}
|
||||
_GLM_TTS_MODEL_STAGES = {"glm_tts"}
|
||||
_STEP_AUDIO2_TTS_MODEL_STAGES = {"step_audio2_thinker"}
|
||||
_INDEXTTS2_TTS_MODEL_STAGES = {"indextts2_talker"}
|
||||
# audex_omni covers the audex_s2s S2S deployment, whose
|
||||
# TTS pass uses the same /v1/audio/speech surface.
|
||||
_AUDEX_TTS_MODEL_STAGES = {"audex_thinker", "audex_omni"}
|
||||
_AUDEX_TTA_MODEL_STAGES = {"audex_tta_thinker"}
|
||||
_TTS_MODEL_STAGES: set[str] = (
|
||||
_VOXTRAL_TTS_MODEL_STAGES
|
||||
| _QWEN3_TTS_MODEL_STAGES
|
||||
@@ -117,6 +121,8 @@ _TTS_MODEL_STAGES: set[str] = (
|
||||
| _GLM_TTS_MODEL_STAGES
|
||||
| _STEP_AUDIO2_TTS_MODEL_STAGES
|
||||
| _INDEXTTS2_TTS_MODEL_STAGES
|
||||
| _AUDEX_TTS_MODEL_STAGES
|
||||
| _AUDEX_TTA_MODEL_STAGES
|
||||
)
|
||||
_SAMPLING_MAX_TOKENS_TTS_MODEL_TYPES = {
|
||||
"fish_tts",
|
||||
@@ -127,7 +133,13 @@ _SAMPLING_MAX_TOKENS_TTS_MODEL_TYPES = {
|
||||
"higgs_audio_v2",
|
||||
"higgs_audio_v3",
|
||||
"indextts2",
|
||||
"audex",
|
||||
"audex_tta",
|
||||
}
|
||||
# Audex contract: zero-codec / invalid generations arrive as empty terminal
|
||||
# payloads and must fail the request, never serialize as a successful empty
|
||||
# WAV. Covers both the TTS ("audex") and TTA ("audex_tta") pipelines.
|
||||
_AUDEX_NO_AUDIO_GUARD_MODEL_TYPES = frozenset({"audex", "audex_tta"})
|
||||
_TTS_LANGUAGES = frozenset(
|
||||
{
|
||||
"Auto",
|
||||
@@ -493,6 +505,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
|
||||
self._tts_tokenizer = None
|
||||
self._voxcpm2_tokenizer = None
|
||||
self._audex_tokenizer = None
|
||||
self._audex_tta_rvq = None
|
||||
self._voxcpm2_split_map: dict[int, list[int]] = {}
|
||||
|
||||
logger.info("Loaded %d supported speakers: %s", len(self.supported_speakers), sorted(self.supported_speakers))
|
||||
@@ -673,12 +687,19 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
|
||||
def _find_tts_stage(self):
|
||||
"""Find and return the TTS stage config, or None if not found."""
|
||||
all_stages = {getattr(stage.engine_args, "model_stage", None) for stage in self.engine_client.stage_configs}
|
||||
for stage in self.engine_client.stage_configs:
|
||||
engine_args = stage.engine_args
|
||||
model_stage = engine_args.model_stage
|
||||
model_arch = getattr(engine_args, "model_arch", None)
|
||||
worker_type = getattr(engine_args, "worker_type", None)
|
||||
if model_stage in _TTS_MODEL_STAGES:
|
||||
# The audio-capable Audex thinker is only speech-capable when
|
||||
# deployed WITH the speech decoder (audex_s2s);
|
||||
# the thinker-only deployment is text-final and must not
|
||||
# accept /v1/audio/speech requests.
|
||||
if model_stage == "audex_omni" and "audex_code2wav" not in all_stages:
|
||||
continue
|
||||
return stage
|
||||
# Ming dense identifies its AR entry stage by architecture because
|
||||
# it does not use a dedicated TTS model_stage value.
|
||||
@@ -729,6 +750,10 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
return "step_audio2"
|
||||
if model_stage in _INDEXTTS2_TTS_MODEL_STAGES:
|
||||
return "indextts2"
|
||||
if model_stage in _AUDEX_TTS_MODEL_STAGES:
|
||||
return "audex"
|
||||
if model_stage in _AUDEX_TTA_MODEL_STAGES:
|
||||
return "audex_tta"
|
||||
return None
|
||||
|
||||
def _get_custom_voice_dir(self) -> str | None:
|
||||
@@ -2318,6 +2343,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
)
|
||||
if chunk_np.ndim > 1:
|
||||
chunk_np = chunk_np.squeeze()
|
||||
if self._tts_model_type in _AUDEX_NO_AUDIO_GUARD_MODEL_TYPES and int(np.size(chunk_np)) == 0:
|
||||
# Zero-size chunks must not emit a WAV header or count
|
||||
# as first audio; the post-loop guard below needs to
|
||||
# see an audio-less stream to fail the request.
|
||||
continue
|
||||
# For WAV format, emit header before first audio chunk
|
||||
if response_format == "wav" and first_chunk:
|
||||
# Assert that sample rate has been set from chunk metadata (not just default)
|
||||
@@ -2349,6 +2379,10 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
yield audio_bytes, sample_rate_val
|
||||
else:
|
||||
yield audio_bytes
|
||||
if self._tts_model_type in _AUDEX_NO_AUDIO_GUARD_MODEL_TYPES and first_audio_chunk_s is None:
|
||||
# Audex contract: zero codec tokens must abort the stream, not
|
||||
# complete it cleanly with zero audio bytes.
|
||||
raise ValueError("Audex produced no audio (the thinker emitted zero or invalid codec tokens)")
|
||||
self._mark_ref_audio_artifact_ready_for_request(request_id)
|
||||
artifact_ready = True
|
||||
total_ms = (time.perf_counter() - stream_start_s) * 1000.0
|
||||
@@ -2822,6 +2856,140 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
)
|
||||
return {"prompt_token_ids": prompt_ids}
|
||||
|
||||
def _get_audex_tokenizer(self):
|
||||
"""Load (once per process) the Audex thinker tokenizer for CFG/TTA prompts.
|
||||
|
||||
The folder follows the serving stage: the full-pipeline thinker
|
||||
(``audex_omni``) tokenizes with ``checkpoint_folder_full``; the TTS
|
||||
and TTA thinkers use ``checkpoint_folder_audiogen``.
|
||||
"""
|
||||
if self._audex_tokenizer is None:
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot
|
||||
|
||||
stage = getattr(getattr(self._tts_stage, "engine_args", None), "model_stage", None)
|
||||
if stage == "audex_omni":
|
||||
profile, folder = "full", "checkpoint_folder_full"
|
||||
elif stage == "audex_tta_thinker":
|
||||
profile, folder = "tta", "checkpoint_folder_audiogen"
|
||||
else:
|
||||
profile, folder = "tts", "checkpoint_folder_audiogen"
|
||||
# The serving model_config may already hold the RESOLVED per-stage
|
||||
# checkpoint folder (stage init joins model_subdir onto the
|
||||
# snapshot root); joining again would produce a non-existent path
|
||||
# that transformers then mistakes for a repo id.
|
||||
model_path = os.path.normpath(self.engine_client.model_config.model)
|
||||
if os.path.basename(model_path).startswith("checkpoint_folder"):
|
||||
root = os.path.dirname(model_path)
|
||||
else:
|
||||
root = ensure_audex_snapshot(model_path, profile=profile)
|
||||
self._audex_tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, folder))
|
||||
return self._audex_tokenizer
|
||||
|
||||
def _inject_audex_tta_args(self, request_id: str, prompt: Any, stage0_params: Any) -> Any:
|
||||
"""Complete the Audex TTA contract on a CLONE of the stage-0 params.
|
||||
|
||||
Always attaches the RVQ phase-mask contract (required for token
|
||||
validity) and the CFG pair contract at the official default scale
|
||||
3.0 unless the caller passed ``cfg_scale`` (1.0 disables guidance).
|
||||
|
||||
Returns the cloned params; the input is never mutated. On the
|
||||
no-extra_params online path the input is an element of the engine's
|
||||
SHARED ``default_sampling_params_list`` — writing per-request pair
|
||||
state (``cfg_pair_id``/``cfg_null_prompt``) into it would race
|
||||
concurrent requests and leak stale CFG metadata into later ones.
|
||||
"""
|
||||
import copy
|
||||
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_tta_null_prompt
|
||||
from vllm_omni.model_executor.models.audex.tta import build_tta_phase_token_ids
|
||||
|
||||
cond_prompt = prompt.get("prompt") if isinstance(prompt, dict) else prompt
|
||||
if not isinstance(cond_prompt, str) or not cond_prompt:
|
||||
raise ValueError("Audex TTA requires the adapter-built caption prompt")
|
||||
|
||||
stage0_params = copy.deepcopy(stage0_params)
|
||||
tokenizer = self._get_audex_tokenizer()
|
||||
if self._audex_tta_rvq is None:
|
||||
phase_token_ids, start_tid, end_tid = build_tta_phase_token_ids(tokenizer)
|
||||
self._audex_tta_rvq = {
|
||||
"phase_token_ids": phase_token_ids,
|
||||
"start_tid": start_tid,
|
||||
"end_tid": end_tid,
|
||||
# Official generation cap (decode truncates at 500 frames).
|
||||
"codec_cap": 4000,
|
||||
# The TTA prompt ends with <audiogen_start>.
|
||||
"start_in_prompt": True,
|
||||
}
|
||||
|
||||
extra_args = getattr(stage0_params, "extra_args", None)
|
||||
if extra_args is None:
|
||||
extra_args = {}
|
||||
stage0_params.extra_args = extra_args
|
||||
extra_args["tta_rvq"] = self._audex_tta_rvq
|
||||
|
||||
cfg_scale = extra_args.get("cfg_scale")
|
||||
cfg_scale = 3.0 if cfg_scale is None else float(cfg_scale)
|
||||
if cfg_scale <= 1.0:
|
||||
extra_args.pop("cfg_scale", None)
|
||||
return stage0_params
|
||||
extra_args.update(
|
||||
{
|
||||
"cfg_scale": cfg_scale,
|
||||
"cfg_role": "cond",
|
||||
"cfg_pair_id": request_id,
|
||||
"cfg_null_prompt": build_tta_null_prompt(cond_prompt, tokenizer),
|
||||
}
|
||||
)
|
||||
return stage0_params
|
||||
|
||||
def _inject_audex_cfg_pair_args(self, request_id: str, prompt: Any, stage0_params: Any) -> Any:
|
||||
"""Complete the Audex CFG contract on a CLONE of the stage-0 params.
|
||||
|
||||
The adapter validated ``cfg_scale``; here (where the final request id
|
||||
exists) the cond role, pair id, and the length-matched null prompt are
|
||||
attached. ``cfg_scale`` absent/1.0 returns a clone value-identical to
|
||||
the non-CFG flow (the key is dropped rather than forwarded).
|
||||
|
||||
Returns the cloned params; the input is never mutated (it may be an
|
||||
element of the engine's shared ``default_sampling_params_list`` —
|
||||
see ``_inject_audex_tta_args``).
|
||||
"""
|
||||
import copy
|
||||
|
||||
stage0_params = copy.deepcopy(stage0_params)
|
||||
extra_args = getattr(stage0_params, "extra_args", None)
|
||||
if not extra_args or extra_args.get("cfg_scale") is None:
|
||||
if extra_args:
|
||||
extra_args.pop("cfg_scale", None)
|
||||
return stage0_params
|
||||
cfg_scale = float(extra_args["cfg_scale"])
|
||||
if cfg_scale <= 1.0:
|
||||
extra_args.pop("cfg_scale", None)
|
||||
return stage0_params
|
||||
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_null_prompt
|
||||
|
||||
cond_prompt = prompt.get("prompt") if isinstance(prompt, dict) else prompt
|
||||
if not isinstance(cond_prompt, str) or not cond_prompt:
|
||||
raise ValueError("Audex CFG requires the adapter-built text prompt")
|
||||
null_prompt = build_null_prompt(cond_prompt, self._get_audex_tokenizer())
|
||||
extra_args.update(
|
||||
{
|
||||
"cfg_scale": cfg_scale,
|
||||
"cfg_role": "cond",
|
||||
"cfg_pair_id": request_id,
|
||||
"cfg_null_prompt": null_prompt,
|
||||
}
|
||||
)
|
||||
# Guided decoding sharpens the distribution; the unguided default
|
||||
# temperature (0.1) adds excess sampling noise under CFG. Measured
|
||||
# on the en-24 gate corpus at cfg 1.5: temp 0.1 -> CER 7.31%,
|
||||
# temp 0.05 -> CER 6.87% (unguided baseline 7.24%).
|
||||
stage0_params.temperature = 0.05
|
||||
return stage0_params
|
||||
|
||||
def _apply_cosyvoice3_dynamic_tokens(
|
||||
self,
|
||||
sampling_params_list: list,
|
||||
@@ -3205,6 +3373,17 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
sampling_params_list[0].extra_args.update(request.extra_params)
|
||||
logger.info("Applied extra_params: %s", request.extra_params)
|
||||
|
||||
# Audex CFG: turn an adapter-validated cfg_scale into the engine-side
|
||||
# pair contract. This must run here (not in the adapter) because the
|
||||
# pair id is the final request_id, which the adapter never sees.
|
||||
# The injectors return a CLONE: without extra_params above,
|
||||
# sampling_params_list still holds the engine's shared default params,
|
||||
# which must never carry per-request CFG state.
|
||||
if self._tts_model_type == "audex" and sampling_params_list:
|
||||
sampling_params_list[0] = self._inject_audex_cfg_pair_args(request_id, prompt, sampling_params_list[0])
|
||||
elif self._tts_model_type == "audex_tta" and sampling_params_list:
|
||||
sampling_params_list[0] = self._inject_audex_tta_args(request_id, prompt, sampling_params_list[0])
|
||||
|
||||
# Some TTS model defaults come from deploy YAML. Their AR
|
||||
# generation length is controlled by SamplingParams.max_tokens, so only
|
||||
# override it when the caller explicitly requests max_new_tokens.
|
||||
@@ -3405,6 +3584,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
if audio_tensor.ndim > 1:
|
||||
audio_tensor = audio_tensor.squeeze()
|
||||
|
||||
if self._tts_model_type in _AUDEX_NO_AUDIO_GUARD_MODEL_TYPES and int(np.size(audio_tensor)) == 0:
|
||||
# Audex contract: zero codec tokens must fail the request, not
|
||||
# serialize as an empty-but-successful WAV.
|
||||
raise ValueError("Audex produced no audio (the thinker emitted zero or invalid codec tokens)")
|
||||
|
||||
audio_obj = CreateAudio(
|
||||
audio_tensor=audio_tensor,
|
||||
sample_rate=sample_rate,
|
||||
|
||||
@@ -51,6 +51,8 @@ def resolve_adapter(model_type: str | None) -> type[TTSModelAdapter] | None:
|
||||
# Import adapter modules for their registration side effects. Keep at the bottom
|
||||
# so the registry helpers above are defined first.
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import ( # noqa: E402,F401
|
||||
audex,
|
||||
audex_tta,
|
||||
cosyvoice3,
|
||||
covo_audio,
|
||||
fish_speech,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Audex (Nemotron-Labs-Audex-2B) TTS serving adapter."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_cond_prompt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
# Classifier-free guidance strength accepted on /v1/audio/speech via
|
||||
# extra_params. 1.0 disables guidance (identical to omitting it); the
|
||||
# official TTS quality setting is 1.5.
|
||||
AUDEX_CFG_SCALE_MIN = 1.0
|
||||
AUDEX_CFG_SCALE_MAX = 10.0
|
||||
|
||||
# Internal CFG pair-plumbing keys; injected by the serving layer once the
|
||||
# request id exists, never accepted from callers.
|
||||
_AUDEX_INTERNAL_CFG_KEYS = ("cfg_role", "cfg_pair_id", "cfg_null_prompt")
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class AudexAdapter(ARTTSAdapter):
|
||||
"""Plain English TTS: single built-in voice, no reference audio, optional CFG."""
|
||||
|
||||
stage_keys = frozenset({"audex_thinker", "audex_omni"})
|
||||
name = "audex"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
if not request.input or not request.input.strip():
|
||||
return "Audex TTS requires non-empty input text"
|
||||
voice = (request.voice or "").strip().lower()
|
||||
if voice not in ("", "default"):
|
||||
return (
|
||||
f"Audex has a single built-in voice and no voice cloning; got voice={request.voice!r}. "
|
||||
"Omit 'voice' or pass 'default'."
|
||||
)
|
||||
if request.ref_audio is not None:
|
||||
return "Audex does not support reference audio (no voice cloning)."
|
||||
extra = request.extra_params or {}
|
||||
for key in _AUDEX_INTERNAL_CFG_KEYS:
|
||||
if key in extra:
|
||||
return f"extra_params.{key} is managed internally by the server; only cfg_scale may be set."
|
||||
cfg_scale = extra.get("cfg_scale")
|
||||
if cfg_scale is not None:
|
||||
try:
|
||||
cfg_value = float(cfg_scale)
|
||||
except (TypeError, ValueError):
|
||||
return (
|
||||
f"extra_params.cfg_scale must be a number in "
|
||||
f"[{AUDEX_CFG_SCALE_MIN}, {AUDEX_CFG_SCALE_MAX}]; got {cfg_scale!r}."
|
||||
)
|
||||
if not (AUDEX_CFG_SCALE_MIN <= cfg_value <= AUDEX_CFG_SCALE_MAX):
|
||||
return (
|
||||
f"extra_params.cfg_scale must be within [{AUDEX_CFG_SCALE_MIN}, {AUDEX_CFG_SCALE_MAX}]; "
|
||||
f"got {cfg_scale!r}. 1.0 disables guidance; 1.5 is the recommended quality setting."
|
||||
)
|
||||
return None
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt = {"prompt": build_cond_prompt(request.input)}
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="audex")
|
||||
@@ -0,0 +1,69 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Audex (Nemotron-Labs-Audex-2B) text-to-audio serving adapter.
|
||||
|
||||
Caption → general audio through the ``audex_tta`` pipeline.
|
||||
The adapter builds the TTA prompt; the serving layer completes the request
|
||||
after the final request id exists: the RVQ phase-mask contract
|
||||
(``extra_args["tta_rvq"]``) and the CFG pair contract (official default
|
||||
scale 3.0 — guidance is effectively mandatory for TTA quality).
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
|
||||
from vllm_omni.model_executor.models.audex.prompt import build_tta_cond_prompt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
AUDEX_TTA_DEFAULT_CFG_SCALE = 3.0
|
||||
AUDEX_TTA_CFG_SCALE_MIN = 1.0
|
||||
AUDEX_TTA_CFG_SCALE_MAX = 10.0
|
||||
|
||||
# Internal plumbing keys; injected by the serving layer, never accepted
|
||||
# from callers.
|
||||
_INTERNAL_KEYS = ("cfg_role", "cfg_pair_id", "cfg_null_prompt", "tta_rvq")
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class AudexTTAAdapter(ARTTSAdapter):
|
||||
"""Caption-conditioned general audio: no voices, no reference audio."""
|
||||
|
||||
stage_keys = frozenset({"audex_tta_thinker"})
|
||||
name = "audex_tta"
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
if not request.input or not request.input.strip():
|
||||
return "Audex TTA requires a non-empty caption"
|
||||
voice = (request.voice or "").strip().lower()
|
||||
if voice not in ("", "default"):
|
||||
return f"Audex TTA generates general audio and has no voices; got voice={request.voice!r}."
|
||||
if request.ref_audio is not None:
|
||||
return "Audex TTA does not support reference audio."
|
||||
extra = request.extra_params or {}
|
||||
for key in _INTERNAL_KEYS:
|
||||
if key in extra:
|
||||
return f"extra_params.{key} is managed internally by the server; only cfg_scale may be set."
|
||||
cfg_scale = extra.get("cfg_scale")
|
||||
if cfg_scale is not None:
|
||||
try:
|
||||
cfg_value = float(cfg_scale)
|
||||
except (TypeError, ValueError):
|
||||
return (
|
||||
f"extra_params.cfg_scale must be a number in "
|
||||
f"[{AUDEX_TTA_CFG_SCALE_MIN}, {AUDEX_TTA_CFG_SCALE_MAX}]; got {cfg_scale!r}."
|
||||
)
|
||||
if not (AUDEX_TTA_CFG_SCALE_MIN <= cfg_value <= AUDEX_TTA_CFG_SCALE_MAX):
|
||||
return (
|
||||
f"extra_params.cfg_scale must be within "
|
||||
f"[{AUDEX_TTA_CFG_SCALE_MIN}, {AUDEX_TTA_CFG_SCALE_MAX}]; got {cfg_scale!r}. "
|
||||
f"The official TTA setting is {AUDEX_TTA_DEFAULT_CFG_SCALE}; 1.0 disables guidance."
|
||||
)
|
||||
return None
|
||||
|
||||
async def build(
|
||||
self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool
|
||||
) -> PreparedRequest:
|
||||
prompt = {"prompt": build_tta_cond_prompt(request.input)}
|
||||
return PreparedRequest(prompt=prompt, tts_params={}, model_type="audex_tta")
|
||||
@@ -12,7 +12,7 @@ from vllm.sampling_params import RequestOutputKind, SamplingParams
|
||||
from vllm.transformers_utils.config import get_config, get_hf_file_to_dict
|
||||
from vllm.transformers_utils.repo_utils import file_or_path_exists
|
||||
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.config.config_factory import StageConfigFactory, with_trust_remote_code_override
|
||||
from vllm_omni.config.yaml_util import create_config, load_yaml_config, merge_configs
|
||||
from vllm_omni.diffusion.utils.hf_utils import _looks_like_dreamzero
|
||||
from vllm_omni.entrypoints.stage_utils import _to_dict
|
||||
@@ -103,12 +103,7 @@ def _filter_dict_like_object(obj: dict | Any) -> dict:
|
||||
return True
|
||||
return isinstance(
|
||||
value,
|
||||
(
|
||||
types.FunctionType,
|
||||
types.MethodType,
|
||||
types.BuiltinFunctionType,
|
||||
types.BuiltinMethodType,
|
||||
),
|
||||
types.FunctionType | types.MethodType | types.BuiltinFunctionType | types.BuiltinMethodType,
|
||||
)
|
||||
|
||||
result = {}
|
||||
@@ -193,10 +188,10 @@ def _convert_dataclasses_to_dict(obj: Any) -> Any:
|
||||
if callable(obj):
|
||||
return None
|
||||
# Handle lists and tuples (recurse into items)
|
||||
if isinstance(obj, (list, tuple)):
|
||||
if isinstance(obj, list | tuple):
|
||||
return type(obj)(_convert_dataclasses_to_dict(item) for item in obj if not callable(item))
|
||||
# Try to convert any dict-like object (has keys/values methods) to dict
|
||||
if hasattr(obj, "keys") and hasattr(obj, "values") and not isinstance(obj, (str, bytes)):
|
||||
if hasattr(obj, "keys") and hasattr(obj, "values") and not isinstance(obj, str | bytes):
|
||||
try:
|
||||
return _filter_dict_like_object(obj)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
@@ -315,7 +310,7 @@ def resolve_model_config_path(model: str) -> str:
|
||||
def load_stage_configs_from_model(
|
||||
model: str,
|
||||
*,
|
||||
trust_remote_code: bool,
|
||||
trust_remote_code: bool | None,
|
||||
base_engine_args: dict | None = None,
|
||||
deploy_config_path: str | None = None,
|
||||
stage_overrides: dict[str, dict[str, Any]] | None = None,
|
||||
@@ -349,7 +344,12 @@ def load_stage_configs_from_model(
|
||||
base_engine_args = {}
|
||||
|
||||
cli_overrides = _convert_dataclasses_to_dict(dict(base_engine_args))
|
||||
cli_overrides["trust_remote_code"] = trust_remote_code
|
||||
# A False inherited from the engine-args dump is the store_true flag's
|
||||
# default, not an explicit choice — drop it so only the tri-state
|
||||
# parameter below decides (see with_trust_remote_code_override).
|
||||
if not cli_overrides.get("trust_remote_code"):
|
||||
cli_overrides.pop("trust_remote_code", None)
|
||||
cli_overrides = with_trust_remote_code_override(cli_overrides, trust_remote_code)
|
||||
if stage_overrides:
|
||||
for stage_id_str, overrides in stage_overrides.items():
|
||||
for key, val in overrides.items():
|
||||
@@ -559,7 +559,7 @@ def load_and_resolve_stage_configs(
|
||||
stage_configs_path: str | None,
|
||||
kwargs: dict | None,
|
||||
*,
|
||||
trust_remote_code: bool,
|
||||
trust_remote_code: bool | None,
|
||||
default_stage_cfg_factory: Any = None,
|
||||
deploy_config_path: str | None = None,
|
||||
stage_overrides: dict[str, dict[str, Any]] | None = None,
|
||||
@@ -728,7 +728,7 @@ def filter_dataclass_kwargs(cls: Any, kwargs: dict) -> dict:
|
||||
if origin in (list, tuple, set):
|
||||
args = get_args(annotation)
|
||||
inner = args[0] if args else None
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
if isinstance(value, list | tuple | set):
|
||||
return type(value)(_filter_value(v, inner) for v in value)
|
||||
return value
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
@@ -0,0 +1,227 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Audex code2wav: streaming speech-codec-token → waveform stage.
|
||||
|
||||
Wraps the vendored Audex causal speech decoder (see ``speech_decoder/``).
|
||||
Unlike the flow/vocoder stages of other TTS models, the decoder is natively
|
||||
stateful and streaming: one ``AudexCausalSpeechDecoderSession`` per request
|
||||
holds the decode cache, each engine step pushes only the newly received codec
|
||||
frames, and the session's 4-frame lookahead tail is drained by ``flush()``
|
||||
when the request finishes. The stage therefore never re-decodes left context
|
||||
and always returns delta audio.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.model_executor.models.audex.speech_decoder import (
|
||||
AudexCausalSpeechDecoderConfig,
|
||||
AudexCausalSpeechDecoderModel,
|
||||
AudexCausalSpeechDecoderSession,
|
||||
)
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Frames per decoder emit. 5 frames = 100 ms at 50 fps — the granularity the
|
||||
# official streaming demo uses; small enough for a fast first audible chunk.
|
||||
_DEFAULT_EMIT_CHUNK_FRAMES = 5
|
||||
|
||||
|
||||
def _meta_int(value: Any, default: int = 0) -> int:
|
||||
if isinstance(value, torch.Tensor):
|
||||
return int(value.reshape(-1)[0].item()) if value.numel() else default
|
||||
if isinstance(value, list | tuple):
|
||||
return _meta_int(value[0], default) if value else default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _meta_bool(value: Any) -> bool:
|
||||
if isinstance(value, torch.Tensor):
|
||||
return bool(value.reshape(-1)[0].item()) if value.numel() else False
|
||||
if isinstance(value, list | tuple):
|
||||
return _meta_bool(value[0]) if value else False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _meta_str(value: Any) -> str | None:
|
||||
if isinstance(value, list | tuple):
|
||||
return _meta_str(value[0]) if value else None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _codes_from_runtime_info(runtime_info: dict[str, Any] | None) -> list[int]:
|
||||
"""Extract this step's new codec frame ids from the connector payload."""
|
||||
if not isinstance(runtime_info, dict):
|
||||
return []
|
||||
codes = runtime_info.get("codes")
|
||||
if not isinstance(codes, dict):
|
||||
return []
|
||||
audio = codes.get("audio")
|
||||
if isinstance(audio, torch.Tensor):
|
||||
return audio.reshape(-1).tolist()
|
||||
if isinstance(audio, list | tuple):
|
||||
return [int(c) for c in audio]
|
||||
return []
|
||||
|
||||
|
||||
class AudexCode2Wav(nn.Module):
|
||||
"""Stage-1 model for Audex TTS (GenerationModelRunner)."""
|
||||
|
||||
input_modalities = "audio"
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
self.vllm_config = vllm_config
|
||||
self.model_path = vllm_config.model_config.model
|
||||
|
||||
self.have_multimodal_outputs = True
|
||||
self.has_preprocess = False
|
||||
self.has_postprocess = False
|
||||
self.enable_update_additional_information = True
|
||||
self.requires_raw_input_tokens = True
|
||||
|
||||
# Build the decoder skeleton from config so vLLM's memory profiler
|
||||
# sees it at startup; weights load later in load_weights().
|
||||
config = AudexCausalSpeechDecoderConfig.from_pretrained(self.model_path)
|
||||
self.decoder = AudexCausalSpeechDecoderModel(config)
|
||||
self.decoder.eval()
|
||||
self._sample_rate = int(config.sample_rate)
|
||||
|
||||
extra_cfg = self._connector_extra_config()
|
||||
self._emit_chunk_frames = _meta_int(extra_cfg.get("decoder_emit_chunk_frames"), _DEFAULT_EMIT_CHUNK_FRAMES)
|
||||
if self._emit_chunk_frames <= 0:
|
||||
raise ValueError(f"decoder_emit_chunk_frames must be positive, got {self._emit_chunk_frames}")
|
||||
|
||||
# One streaming session per in-flight request, keyed by the request id
|
||||
# carried in each chunk payload's meta (equal to the stage-1 engine
|
||||
# request id, so on_requests_finished can free aborted requests too).
|
||||
self._sessions: dict[str, AudexCausalSpeechDecoderSession] = {}
|
||||
|
||||
def _connector_extra_config(self) -> dict[str, Any]:
|
||||
connector_cfg = getattr(self.vllm_config.model_config, "stage_connector_config", None)
|
||||
if isinstance(connector_cfg, dict):
|
||||
extra = connector_cfg.get("extra", connector_cfg)
|
||||
return extra if isinstance(extra, dict) else {}
|
||||
extra = getattr(connector_cfg, "extra", None)
|
||||
return extra if isinstance(extra, dict) else {}
|
||||
|
||||
# -------------------- vLLM model interface --------------------
|
||||
|
||||
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
|
||||
# This stage ignores token embeddings; keep a stable dummy for the runner.
|
||||
if input_ids.numel() == 0:
|
||||
return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32)
|
||||
return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32)
|
||||
|
||||
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> None:
|
||||
return None
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
state_dict = dict(weights)
|
||||
self.decoder.load_state_dict(state_dict, strict=True)
|
||||
device = self.vllm_config.device_config.device
|
||||
self.decoder.to(device=device, dtype=torch.float32)
|
||||
return {f"decoder.{name}" for name in state_dict}
|
||||
|
||||
def on_requests_finished(self, finished_req_ids: Iterable[str]) -> None:
|
||||
"""Free decoder sessions for finished/aborted requests."""
|
||||
for req_id in finished_req_ids:
|
||||
self._sessions.pop(req_id, None)
|
||||
|
||||
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput | tuple, **kwargs: Any) -> OmniOutput:
|
||||
if isinstance(model_outputs, OmniOutput):
|
||||
return model_outputs
|
||||
# The runner may unpack the NamedTuple in transit; rebuild it.
|
||||
if isinstance(model_outputs, tuple) and len(model_outputs) == len(OmniOutput._fields):
|
||||
return OmniOutput(*model_outputs)
|
||||
raise TypeError(f"AudexCode2Wav expected OmniOutput, got {type(model_outputs)}")
|
||||
|
||||
# -------------------- decoding --------------------
|
||||
|
||||
def _session_for(self, req_id: str) -> AudexCausalSpeechDecoderSession:
|
||||
session = self._sessions.get(req_id)
|
||||
if session is None:
|
||||
session = self.decoder.create_session(
|
||||
chunk_frames=self._emit_chunk_frames,
|
||||
return_numpy=False,
|
||||
)
|
||||
self._sessions[req_id] = session
|
||||
return session
|
||||
|
||||
def _decode_request(self, req_id: str, codes: list[int], finished: bool) -> torch.Tensor:
|
||||
"""Push new frames into the request's session; return this step's delta audio."""
|
||||
if finished and not codes and req_id not in self._sessions:
|
||||
# Zero codec tokens for the whole request. Do NOT raise: an
|
||||
# exception in forward() kills the engine core for every request.
|
||||
# Returning empty audio lets the serving layer fail this request.
|
||||
logger.error("Audex code2wav: request %s finished with no codec tokens; no audio generated", req_id)
|
||||
return torch.empty(0, dtype=torch.float32)
|
||||
chunks: list[torch.Tensor] = []
|
||||
session = self._session_for(req_id)
|
||||
with torch.inference_mode():
|
||||
if codes:
|
||||
for _sr, chunk in session.push([[int(c)] for c in codes]):
|
||||
chunks.append(chunk.reshape(-1))
|
||||
if finished:
|
||||
for _sr, chunk in session.flush():
|
||||
chunks.append(chunk.reshape(-1))
|
||||
if finished:
|
||||
self._sessions.pop(req_id, None)
|
||||
if not chunks:
|
||||
return torch.empty(0, dtype=torch.float32)
|
||||
return torch.cat(chunks).to(torch.float32)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None = None,
|
||||
positions: torch.Tensor | None = None,
|
||||
intermediate_tensors: Any = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
runtime_additional_information: list[dict[str, Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> OmniOutput:
|
||||
runtime_infos = runtime_additional_information or []
|
||||
# One output entry per scheduled request, even on steps where a
|
||||
# request's chunk payload has not arrived yet.
|
||||
seq_token_counts = kwargs.get("seq_token_counts")
|
||||
if seq_token_counts is not None:
|
||||
num_reqs = len(seq_token_counts)
|
||||
elif input_ids is not None and input_ids.numel() > 0 and not runtime_infos:
|
||||
num_reqs = 1
|
||||
else:
|
||||
num_reqs = len(runtime_infos)
|
||||
|
||||
audios: list[torch.Tensor] = []
|
||||
sr_tensor = torch.tensor(self._sample_rate, dtype=torch.int32)
|
||||
for i in range(num_reqs):
|
||||
info = runtime_infos[i] if i < len(runtime_infos) else {}
|
||||
info = info if isinstance(info, dict) else {}
|
||||
meta = info.get("meta", {})
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
req_id = _meta_str(meta.get("req_id"))
|
||||
# async_chunk delivers ``stream_finished`` (the adapter strips
|
||||
# ``finished``); the sync full-payload path delivers ``finished``.
|
||||
finished = _meta_bool(meta.get("stream_finished")) or _meta_bool(meta.get("finished"))
|
||||
codes = _codes_from_runtime_info(info)
|
||||
if req_id is None:
|
||||
if codes:
|
||||
raise ValueError("Audex code2wav chunk payload is missing meta.req_id")
|
||||
audios.append(torch.empty(0, dtype=torch.float32))
|
||||
continue
|
||||
audios.append(self._decode_request(req_id, codes, finished))
|
||||
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": audios, "sr": [sr_tensor] * num_reqs},
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
#
|
||||
# Adapted from nvidia/Nemotron-Labs-Audex-2B (Apache-2.0):
|
||||
# inference_scripts_vllm/audioqa_scripts/audex_2b_vllm/modeling_audex_vllm.py
|
||||
# inference_scripts_vllm/audioqa_scripts/audex_2b_vllm/audio_encoder.py
|
||||
"""Audex audio-understanding model (``checkpoint_folder_full``): S2T / audio QA.
|
||||
|
||||
Mirrors the Qwen2-Audio pattern with the 2B dense backbone:
|
||||
* ``language_model`` = the vllm-omni ``NemotronDenseForCausalLM`` port
|
||||
(wrapped via ``init_vllm_registered_model``; resolved through the omni
|
||||
model registry).
|
||||
* ``audio_tower`` = transformers ``Qwen2AudioEncoder`` ("NV-Whisper");
|
||||
a parameter-free avg pool maps 1500 → 750 frames per padded 30 s clip.
|
||||
* ``audio_projector`` = RMSNorm → fc1 → relu² → fc2 (no bias), matching the
|
||||
release ``NemotronHAudexProjector``.
|
||||
|
||||
``embed_multimodal(...)`` runs encoder+projector and returns one embedding
|
||||
tensor per audio item; vLLM's ``SupportsMultiModal.embed_input_ids`` merges
|
||||
them into the ``<so_embedding>`` placeholder positions. The encoder runs in
|
||||
microbatches over clips so peak memory is bounded regardless of audio length
|
||||
(up to the 900 s / 30-clip cap enforced by the processor).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.model_executor.models.interfaces import (
|
||||
HasInnerState,
|
||||
IsHybrid,
|
||||
MultiModalEmbeddings,
|
||||
SupportsMambaPrefixCaching,
|
||||
SupportsMultiModal,
|
||||
SupportsPP,
|
||||
)
|
||||
from vllm.model_executor.models.utils import (
|
||||
init_vllm_registered_model,
|
||||
maybe_prefix,
|
||||
)
|
||||
from vllm.multimodal import MULTIMODAL_REGISTRY
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.utils.tensor_schema import TensorSchema, TensorShape
|
||||
|
||||
from vllm_omni.model_executor.models.audex.audex_omni_processing import (
|
||||
AUDIO_ENCODER_MICRO_BATCH,
|
||||
SOUND_TOKEN,
|
||||
AudexDummyInputsBuilder,
|
||||
AudexMultiModalProcessor,
|
||||
AudexProcessingInfo,
|
||||
)
|
||||
|
||||
|
||||
class AudexProjector(nn.Module):
|
||||
"""Megatron sound_projection equivalent (matches HF NemotronHAudexProjector)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_encoder_hidden_size: int,
|
||||
intermediate_size: int,
|
||||
text_hidden_size: int,
|
||||
activation: str = "relu2",
|
||||
norm_eps: float = 1e-5,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.norm_weight = nn.Parameter(torch.ones(audio_encoder_hidden_size))
|
||||
self.norm_eps = norm_eps
|
||||
self.fc1 = nn.Linear(audio_encoder_hidden_size, intermediate_size, bias=False)
|
||||
self.fc2 = nn.Linear(intermediate_size, text_hidden_size, bias=False)
|
||||
self.activation = activation
|
||||
|
||||
def _rms_norm(self, x: torch.Tensor) -> torch.Tensor:
|
||||
dtype = x.dtype
|
||||
x = x.float()
|
||||
variance = x.pow(2).mean(dim=-1, keepdim=True)
|
||||
x = x * torch.rsqrt(variance + self.norm_eps)
|
||||
return (self.norm_weight.float() * x).to(dtype)
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states = self._rms_norm(hidden_states)
|
||||
hidden_states = self.fc1(hidden_states)
|
||||
if self.activation == "relu2":
|
||||
hidden_states = F.relu(hidden_states).pow(2)
|
||||
elif self.activation == "gelu":
|
||||
hidden_states = F.gelu(hidden_states)
|
||||
else:
|
||||
raise ValueError(f"Unsupported audio projector activation: {self.activation}")
|
||||
return self.fc2(hidden_states)
|
||||
|
||||
|
||||
def _build_qwen2_audio_encoder(audio_config: dict) -> nn.Module:
|
||||
from transformers.models.qwen2_audio.configuration_qwen2_audio import (
|
||||
Qwen2AudioEncoderConfig,
|
||||
)
|
||||
from transformers.models.qwen2_audio.modeling_qwen2_audio import Qwen2AudioEncoder
|
||||
|
||||
cfg = Qwen2AudioEncoderConfig(**dict(audio_config))
|
||||
return Qwen2AudioEncoder(cfg)
|
||||
|
||||
|
||||
class AudexAudioFeatureInputs(TensorSchema):
|
||||
"""
|
||||
Dimensions:
|
||||
- c: total number of 30s clips (flattened across audio items)
|
||||
- b: number of audio items
|
||||
"""
|
||||
|
||||
type: Literal["audio_features"] = "audio_features"
|
||||
input_features: Annotated[torch.Tensor, TensorShape("c", 128, 3000)]
|
||||
audio_num_clips: Annotated[list[int], None] = None
|
||||
|
||||
|
||||
@MULTIMODAL_REGISTRY.register_processor(
|
||||
AudexMultiModalProcessor,
|
||||
info=AudexProcessingInfo,
|
||||
dummy_inputs=AudexDummyInputsBuilder,
|
||||
)
|
||||
class NemotronDenseAudexForConditionalGeneration(
|
||||
nn.Module,
|
||||
SupportsMultiModal,
|
||||
SupportsPP,
|
||||
):
|
||||
_LM_ARCHITECTURE = "NemotronDenseForCausalLM"
|
||||
|
||||
@classmethod
|
||||
def get_placeholder_str(cls, modality: str, i: int) -> str | None:
|
||||
if modality.startswith("audio"):
|
||||
return SOUND_TOKEN
|
||||
raise ValueError(f"Only audio modality is supported, got {modality}")
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
|
||||
super().__init__()
|
||||
config = vllm_config.model_config.hf_config
|
||||
self.config = config
|
||||
|
||||
with self._mark_language_model(vllm_config):
|
||||
self.language_model = init_vllm_registered_model(
|
||||
vllm_config=vllm_config,
|
||||
hf_config=config,
|
||||
prefix=maybe_prefix(prefix, "language_model"),
|
||||
architectures=[self._LM_ARCHITECTURE],
|
||||
)
|
||||
llm_dtype = self.language_model.config.dtype
|
||||
assert isinstance(llm_dtype, torch.dtype)
|
||||
self.llm_dtype = llm_dtype
|
||||
|
||||
with self._mark_tower_model(vllm_config, "audio"):
|
||||
self.audio_tower = _build_qwen2_audio_encoder(config.audio_config).to(llm_dtype)
|
||||
self.audio_projector = AudexProjector(
|
||||
audio_encoder_hidden_size=config.audio_encoder_hidden_size,
|
||||
intermediate_size=config.audio_projector_intermediate_size,
|
||||
text_hidden_size=config.hidden_size,
|
||||
activation=config.audio_projector_activation,
|
||||
norm_eps=config.audio_projector_norm_eps,
|
||||
).to(llm_dtype)
|
||||
|
||||
self.make_empty_intermediate_tensors = self.language_model.make_empty_intermediate_tensors
|
||||
|
||||
def get_language_model(self) -> nn.Module:
|
||||
return self.language_model
|
||||
|
||||
def _parse_and_validate_audio_input(self, **kwargs: object):
|
||||
input_features = kwargs.pop("input_features", None)
|
||||
audio_num_clips = kwargs.pop("audio_num_clips", None)
|
||||
if input_features is None:
|
||||
return None
|
||||
return AudexAudioFeatureInputs(
|
||||
type="audio_features",
|
||||
input_features=input_features,
|
||||
audio_num_clips=audio_num_clips,
|
||||
validate=False,
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
def _encode_clips(self, input_features: torch.Tensor) -> torch.Tensor:
|
||||
"""(C, 128, 3000) -> (C, 750, hidden) via microbatched encoder+projector."""
|
||||
param = next(self.audio_tower.parameters())
|
||||
input_features = input_features.to(device=param.device, dtype=param.dtype)
|
||||
out_chunks = []
|
||||
for start in range(0, input_features.shape[0], AUDIO_ENCODER_MICRO_BATCH):
|
||||
chunk = input_features[start : start + AUDIO_ENCODER_MICRO_BATCH]
|
||||
hidden = self.audio_tower(input_features=chunk, return_dict=True).last_hidden_state
|
||||
out_chunks.append(self.audio_projector(hidden))
|
||||
return torch.cat(out_chunks, dim=0)
|
||||
|
||||
def _process_audio_input(self, audio_input) -> tuple[torch.Tensor, ...]:
|
||||
input_features = audio_input["input_features"]
|
||||
audio_num_clips = audio_input["audio_num_clips"]
|
||||
if input_features.ndim == 4:
|
||||
input_features = input_features.flatten(0, 1)
|
||||
|
||||
projected = self._encode_clips(input_features)
|
||||
per_clip = projected.reshape(projected.shape[0], -1, projected.shape[-1])
|
||||
|
||||
if audio_num_clips is None:
|
||||
num_clips_list = [projected.shape[0]]
|
||||
elif isinstance(audio_num_clips, torch.Tensor):
|
||||
num_clips_list = audio_num_clips.flatten().tolist()
|
||||
else:
|
||||
num_clips_list = [int(x) for x in audio_num_clips]
|
||||
|
||||
grouped: list[torch.Tensor] = []
|
||||
offset = 0
|
||||
for n in num_clips_list:
|
||||
clips = per_clip[offset : offset + n]
|
||||
grouped.append(clips.reshape(-1, clips.shape[-1]))
|
||||
offset += n
|
||||
return tuple(grouped)
|
||||
|
||||
def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
|
||||
audio_input = self._parse_and_validate_audio_input(**kwargs)
|
||||
if audio_input is None:
|
||||
return []
|
||||
return self._process_audio_input(audio_input)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None,
|
||||
positions: torch.Tensor,
|
||||
intermediate_tensors: IntermediateTensors | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
**kwargs: object,
|
||||
) -> torch.Tensor | IntermediateTensors:
|
||||
if intermediate_tensors is not None:
|
||||
inputs_embeds = None
|
||||
return self.language_model.model(input_ids, positions, intermediate_tensors, inputs_embeds=inputs_embeds)
|
||||
|
||||
def compute_logits(self, hidden_states: torch.Tensor, sampling_metadata=None) -> torch.Tensor | None:
|
||||
return self.language_model.compute_logits(hidden_states)
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
"""Split the combined weight stream into LLM / audio_tower / projector.
|
||||
|
||||
vLLM streams *all* ``.safetensors`` in the model dir; route
|
||||
``audio_encoder.*``/``audio_projector.*`` to the audio modules and
|
||||
leave the remaining ``model.*``/``lm_head.*`` names untouched so the
|
||||
wrapped NemotronDenseForCausalLM loader applies its own qkv fusion
|
||||
and remapping.
|
||||
"""
|
||||
loaded: set[str] = set()
|
||||
lm_weights: list[tuple[str, torch.Tensor]] = []
|
||||
enc_sd: dict[str, torch.Tensor] = {}
|
||||
proj_map: dict[str, torch.Tensor] = {}
|
||||
|
||||
for name, w in weights:
|
||||
if name.startswith("audio_encoder."):
|
||||
enc_sd[name[len("audio_encoder.") :]] = w
|
||||
elif name == "audio_projector.norm.weight":
|
||||
proj_map["norm_weight"] = w
|
||||
elif name.startswith("audio_projector."):
|
||||
proj_map[name[len("audio_projector.") :]] = w
|
||||
else:
|
||||
lm_weights.append((name, w))
|
||||
|
||||
lm_loaded = self.language_model.load_weights(lm_weights)
|
||||
loaded |= {f"language_model.{n}" for n in lm_loaded}
|
||||
|
||||
if not enc_sd:
|
||||
raise ValueError("No audio_encoder.* weights found in checkpoint stream.")
|
||||
missing, unexpected = self.audio_tower.load_state_dict(enc_sd, strict=False)
|
||||
if missing:
|
||||
raise ValueError(f"Missing audio_tower params: {list(missing)[:8]}")
|
||||
if unexpected:
|
||||
raise ValueError(f"Unexpected audio_tower keys: {list(unexpected)[:8]}")
|
||||
loaded |= {f"audio_tower.{k}" for k in enc_sd}
|
||||
|
||||
if set(proj_map) != {"norm_weight", "fc1.weight", "fc2.weight"}:
|
||||
raise ValueError(f"Unexpected audio_projector keys: {sorted(proj_map)}")
|
||||
self.audio_projector.load_state_dict(proj_map, strict=True)
|
||||
loaded |= {f"audio_projector.{k}" for k in proj_map}
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
@MULTIMODAL_REGISTRY.register_processor(
|
||||
AudexMultiModalProcessor,
|
||||
info=AudexProcessingInfo,
|
||||
dummy_inputs=AudexDummyInputsBuilder,
|
||||
)
|
||||
class NemotronHAudexForConditionalGeneration(
|
||||
NemotronDenseAudexForConditionalGeneration,
|
||||
IsHybrid,
|
||||
HasInnerState,
|
||||
SupportsMambaPrefixCaching,
|
||||
):
|
||||
"""Audex 30B-A3B audio-understanding model (``checkpoint_folder_full``).
|
||||
|
||||
Mirrors nvidia/Nemotron-Labs-Audex-30B-A3B's
|
||||
``inference_scripts_vllm/audioqa_scripts/audex_30b_a3b_vllm/modeling_audex_vllm.py``:
|
||||
the same encoder/projector/processing as the dense 2B wrapper around
|
||||
vLLM's native hybrid Mamba+MoE ``NemotronHForCausalLM``, whose state
|
||||
hooks are delegated below.
|
||||
"""
|
||||
|
||||
_LM_ARCHITECTURE = "NemotronHForCausalLM"
|
||||
|
||||
is_hybrid = True
|
||||
has_inner_state = True
|
||||
supports_mamba_prefix_caching = True
|
||||
|
||||
@classmethod
|
||||
def get_mamba_state_shape_from_config(cls, vllm_config):
|
||||
from vllm.model_executor.models.nemotron_h import NemotronHForCausalLM
|
||||
|
||||
return NemotronHForCausalLM.get_mamba_state_shape_from_config(vllm_config)
|
||||
|
||||
@classmethod
|
||||
def get_mamba_state_dtype_from_config(cls, vllm_config):
|
||||
from vllm.model_executor.models.nemotron_h import NemotronHForCausalLM
|
||||
|
||||
return NemotronHForCausalLM.get_mamba_state_dtype_from_config(vllm_config)
|
||||
|
||||
@classmethod
|
||||
def get_mamba_state_copy_func(cls):
|
||||
from vllm.model_executor.models.nemotron_h import NemotronHForCausalLM
|
||||
|
||||
return NemotronHForCausalLM.get_mamba_state_copy_func()
|
||||
@@ -0,0 +1,260 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
#
|
||||
# Adapted from nvidia/Nemotron-Labs-Audex-2B (Apache-2.0):
|
||||
# inference_scripts_vllm/audioqa_scripts/audex_2b_vllm/audio_features.py
|
||||
# inference_scripts_vllm/audioqa_scripts/audex_2b_vllm/processing_audex_vllm.py
|
||||
"""Multimodal processing for Audex audio understanding (S2T / audio QA).
|
||||
|
||||
No unified HF ``ProcessorMixin`` exists for Audex (only a
|
||||
``WhisperFeatureExtractor`` under ``audio_preprocessor/``), so
|
||||
``_call_hf_processor`` is fully overridden: it tokenizes the prompt and
|
||||
builds NV-Whisper features with 30 s non-overlapping windows (last clip
|
||||
zero-padded), exactly mirroring the release HF path.
|
||||
|
||||
Placeholder expansion uses vLLM's ``PromptReplacement``: a single
|
||||
``<so_embedding>`` per audio expands to ``<so_start>`` + N*``<so_embedding>``
|
||||
+ ``<so_end>`` where N = num_clips * 750.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from transformers import BatchFeature
|
||||
from vllm.config.multimodal import BaseDummyOptions
|
||||
from vllm.inputs import MultiModalDataDict
|
||||
from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems
|
||||
from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser
|
||||
from vllm.multimodal.processing import (
|
||||
BaseDummyInputsBuilder,
|
||||
BaseMultiModalProcessor,
|
||||
BaseProcessingInfo,
|
||||
PromptReplacement,
|
||||
PromptUpdate,
|
||||
PromptUpdateDetails,
|
||||
)
|
||||
|
||||
SOUND_TOKEN = "<so_embedding>"
|
||||
SOUND_START_TOKEN = "<so_start>"
|
||||
SOUND_END_TOKEN = "<so_end>"
|
||||
|
||||
SOUND_EMBEDDING_SIZE = 750
|
||||
SOUND_CLIP_DURATION = 30.0
|
||||
SOUND_TARGET_RATE = 16000
|
||||
|
||||
MAX_AUDIO_SECONDS = 900
|
||||
MAX_AUDIO_CLIPS = 30
|
||||
MAX_AUDIO_TOKENS = MAX_AUDIO_CLIPS * SOUND_EMBEDDING_SIZE
|
||||
|
||||
AUDIO_ENCODER_MICRO_BATCH = 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- features
|
||||
|
||||
|
||||
def normalize_audio(audio: np.ndarray) -> np.ndarray:
|
||||
"""Return mono float32 audio in [-1, 1], matching the Megatron/HF path."""
|
||||
audio = np.asarray(audio)
|
||||
if audio.ndim == 2:
|
||||
if audio.shape[1] <= 2:
|
||||
audio = audio.mean(axis=1)
|
||||
elif audio.shape[0] <= 2:
|
||||
audio = audio.mean(axis=0)
|
||||
else:
|
||||
raise ValueError(f"Unsupported audio shape: {audio.shape}")
|
||||
|
||||
if audio.dtype == np.int16:
|
||||
audio = audio.astype(np.float32) / 32768.0
|
||||
elif audio.dtype != np.float32:
|
||||
audio = audio.astype(np.float32)
|
||||
|
||||
max_abs = float(np.abs(audio).max()) if audio.size else 0.0
|
||||
if max_abs > 1.0:
|
||||
audio = audio / max_abs
|
||||
return audio.astype(np.float32, copy=False)
|
||||
|
||||
|
||||
def split_audio_into_clips(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int = SOUND_TARGET_RATE,
|
||||
clip_duration: float = SOUND_CLIP_DURATION,
|
||||
) -> list[np.ndarray]:
|
||||
"""Split audio into fixed clips; pad the final clip to full length."""
|
||||
audio = normalize_audio(audio)
|
||||
clip_samples = int(round(sample_rate * clip_duration))
|
||||
if clip_samples <= 0:
|
||||
raise ValueError(f"Invalid clip_samples: {clip_samples}")
|
||||
if audio.size == 0:
|
||||
audio = np.zeros(1, dtype=np.float32)
|
||||
|
||||
num_clips = max(1, math.ceil(audio.shape[0] / clip_samples))
|
||||
clips: list[np.ndarray] = []
|
||||
for idx in range(num_clips):
|
||||
start = idx * clip_samples
|
||||
clip = audio[start : start + clip_samples]
|
||||
if clip.shape[0] < clip_samples:
|
||||
clip = np.pad(clip, (0, clip_samples - clip.shape[0]))
|
||||
clips.append(clip.astype(np.float32, copy=False))
|
||||
return clips
|
||||
|
||||
|
||||
def extract_whisper_features(
|
||||
feature_extractor,
|
||||
audio: np.ndarray,
|
||||
sample_rate: int = SOUND_TARGET_RATE,
|
||||
clip_duration: float = SOUND_CLIP_DURATION,
|
||||
) -> torch.Tensor:
|
||||
"""Return NV-Whisper input features shaped (num_clips, 128, 3000)."""
|
||||
clips = split_audio_into_clips(audio, sample_rate=sample_rate, clip_duration=clip_duration)
|
||||
features = feature_extractor(
|
||||
clips,
|
||||
sampling_rate=sample_rate,
|
||||
return_tensors="pt",
|
||||
padding="max_length",
|
||||
return_attention_mask=False,
|
||||
)
|
||||
input_features = features.input_features
|
||||
if input_features.ndim != 3:
|
||||
raise ValueError(f"Expected 3D Whisper features, got {tuple(input_features.shape)}")
|
||||
return input_features
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- processor
|
||||
|
||||
|
||||
class AudexProcessingInfo(BaseProcessingInfo):
|
||||
def get_hf_config(self):
|
||||
return self.ctx.model_config.hf_config
|
||||
|
||||
def get_feature_extractor(self, **kwargs: object):
|
||||
from transformers import AutoFeatureExtractor
|
||||
|
||||
config = self.get_hf_config()
|
||||
path = getattr(config, "audio_preprocessor_path", None) or "audio_preprocessor"
|
||||
candidate = Path(path)
|
||||
if not candidate.is_absolute():
|
||||
candidate = Path(self.ctx.model_config.model) / candidate
|
||||
return AutoFeatureExtractor.from_pretrained(str(candidate))
|
||||
|
||||
def get_data_parser(self):
|
||||
return MultiModalDataParser(target_sr=SOUND_TARGET_RATE, target_channels=1)
|
||||
|
||||
def get_supported_mm_limits(self) -> Mapping[str, int | None]:
|
||||
return {"audio": 1}
|
||||
|
||||
def get_mm_max_tokens_per_item(self, seq_len: int, mm_counts: Mapping[str, int] | None = None) -> Mapping[str, int]:
|
||||
return {"audio": MAX_AUDIO_TOKENS}
|
||||
|
||||
def sound_token_ids(self) -> tuple[int, int, int]:
|
||||
cfg = self.get_hf_config()
|
||||
return (
|
||||
int(cfg.sound_start_token_id),
|
||||
int(cfg.sound_token_id),
|
||||
int(cfg.sound_end_token_id),
|
||||
)
|
||||
|
||||
|
||||
class AudexDummyInputsBuilder(BaseDummyInputsBuilder[AudexProcessingInfo]):
|
||||
def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
|
||||
return SOUND_TOKEN * mm_counts.get("audio", 0)
|
||||
|
||||
def get_dummy_mm_data(
|
||||
self,
|
||||
seq_len: int,
|
||||
mm_counts: Mapping[str, int],
|
||||
mm_options: Mapping[str, BaseDummyOptions] | None = None,
|
||||
) -> MultiModalDataDict:
|
||||
num_audios = mm_counts.get("audio", 0)
|
||||
audio_len = int(SOUND_CLIP_DURATION * SOUND_TARGET_RATE)
|
||||
overrides = mm_options.get("audio") if mm_options else None
|
||||
return {"audio": self._get_dummy_audios(length=audio_len, num_audios=num_audios, overrides=overrides)}
|
||||
|
||||
|
||||
class AudexMultiModalProcessor(BaseMultiModalProcessor[AudexProcessingInfo]):
|
||||
def _call_hf_processor(
|
||||
self,
|
||||
prompt: str,
|
||||
mm_data: Mapping[str, object],
|
||||
mm_kwargs: Mapping[str, Any],
|
||||
tok_kwargs: Mapping[str, object],
|
||||
) -> BatchFeature:
|
||||
tokenizer = self.info.get_tokenizer()
|
||||
prompt_ids = tokenizer(prompt, add_special_tokens=False).input_ids
|
||||
|
||||
audios = mm_data.get("audios", []) or mm_data.get("audio", [])
|
||||
if not audios:
|
||||
return BatchFeature(dict(input_ids=[prompt_ids]), tensor_type="pt")
|
||||
|
||||
feature_extractor = self.info.get_feature_extractor()
|
||||
all_features: list[torch.Tensor] = []
|
||||
audio_num_clips: list[int] = []
|
||||
for item in audios:
|
||||
audio = item[0] if isinstance(item, tuple) else item
|
||||
audio = normalize_audio(np.asarray(audio))
|
||||
duration = len(audio) / SOUND_TARGET_RATE
|
||||
if duration > MAX_AUDIO_SECONDS:
|
||||
raise ValueError(f"Audio duration {duration:.1f}s exceeds MAX_AUDIO_SECONDS={MAX_AUDIO_SECONDS}s.")
|
||||
clips = split_audio_into_clips(audio, sample_rate=SOUND_TARGET_RATE, clip_duration=SOUND_CLIP_DURATION)
|
||||
if len(clips) > MAX_AUDIO_CLIPS:
|
||||
raise ValueError(f"Audio needs {len(clips)} clips > MAX_AUDIO_CLIPS={MAX_AUDIO_CLIPS}.")
|
||||
feats = extract_whisper_features(
|
||||
feature_extractor,
|
||||
audio,
|
||||
sample_rate=SOUND_TARGET_RATE,
|
||||
clip_duration=SOUND_CLIP_DURATION,
|
||||
)
|
||||
all_features.append(feats)
|
||||
audio_num_clips.append(feats.shape[0])
|
||||
|
||||
input_features = torch.cat(all_features, dim=0)
|
||||
return BatchFeature(
|
||||
dict(
|
||||
input_ids=[prompt_ids],
|
||||
input_features=input_features,
|
||||
audio_num_clips=torch.tensor(audio_num_clips, dtype=torch.long),
|
||||
),
|
||||
tensor_type="pt",
|
||||
)
|
||||
|
||||
def _get_mm_fields_config(
|
||||
self,
|
||||
hf_inputs: BatchFeature,
|
||||
hf_processor_mm_kwargs: Mapping[str, object],
|
||||
) -> Mapping[str, MultiModalFieldConfig]:
|
||||
audio_num_clips = hf_inputs.get("audio_num_clips", torch.zeros(0, dtype=torch.long))
|
||||
return dict(
|
||||
input_features=MultiModalFieldConfig.flat_from_sizes("audio", audio_num_clips),
|
||||
audio_num_clips=MultiModalFieldConfig.batched("audio"),
|
||||
)
|
||||
|
||||
def _get_prompt_updates(
|
||||
self,
|
||||
mm_items: MultiModalDataItems,
|
||||
hf_processor_mm_kwargs: Mapping[str, Any],
|
||||
out_mm_kwargs: MultiModalKwargsItems,
|
||||
) -> Sequence[PromptUpdate]:
|
||||
so_start, so_embed, so_end = self.info.sound_token_ids()
|
||||
out_mm_data = out_mm_kwargs.get_data()
|
||||
num_clips = out_mm_data.get("audio_num_clips", torch.zeros(0, dtype=torch.long))
|
||||
num_clips_list = num_clips.flatten().tolist() if hasattr(num_clips, "flatten") else list(num_clips)
|
||||
|
||||
def get_replacement_audex(item_idx: int):
|
||||
n = int(num_clips_list[item_idx]) * SOUND_EMBEDDING_SIZE
|
||||
if n < 1:
|
||||
raise ValueError(f"Audio item {item_idx} produced 0 embeddings.")
|
||||
full = [so_start] + [so_embed] * n + [so_end]
|
||||
return PromptUpdateDetails.select_token_id(full, so_embed)
|
||||
|
||||
return [
|
||||
PromptReplacement(
|
||||
modality="audio",
|
||||
target=[so_embed],
|
||||
replacement=get_replacement_audex,
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,414 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# Adapted from the NemotronDense vLLM plugin bundled with
|
||||
# nvidia/Nemotron-Labs-Audex-2B (nemotron_dense_vllm_plugin), which is not
|
||||
# installed as a package; the model code is carried here instead.
|
||||
"""Audex thinker: the Nemotron dense decoder-only LM that autoregressively
|
||||
emits ``<speechcodec_N>`` tokens for TTS.
|
||||
|
||||
The thinker is a plain token-in/token-out vLLM model. Speech-codec token
|
||||
extraction happens in the stage input processors
|
||||
(``vllm_omni/model_executor/stage_input_processors/audex.py``), which read the
|
||||
sampled token stream directly, so no omni-specific output plumbing is needed
|
||||
here.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from itertools import islice
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from vllm.compilation.decorators import support_torch_compile
|
||||
from vllm.config import CacheConfig, VllmConfig
|
||||
from vllm.distributed import get_pp_group, get_tensor_model_parallel_world_size
|
||||
from vllm.model_executor.layers.activation import get_act_fn
|
||||
from vllm.model_executor.layers.attention import Attention
|
||||
from vllm.model_executor.layers.linear import (
|
||||
ColumnParallelLinear,
|
||||
QKVParallelLinear,
|
||||
RowParallelLinear,
|
||||
)
|
||||
from vllm.model_executor.layers.logits_processor import LogitsProcessor
|
||||
from vllm.model_executor.layers.quantization import QuantizationConfig
|
||||
from vllm.model_executor.layers.rotary_embedding import get_rope
|
||||
from vllm.model_executor.layers.vocab_parallel_embedding import (
|
||||
ParallelLMHead,
|
||||
VocabParallelEmbedding,
|
||||
)
|
||||
from vllm.model_executor.model_loader.weight_utils import (
|
||||
default_weight_loader,
|
||||
maybe_remap_kv_scale_name,
|
||||
)
|
||||
from vllm.model_executor.models.interfaces import SupportsLoRA, SupportsPP
|
||||
from vllm.model_executor.models.utils import (
|
||||
AutoWeightsLoader,
|
||||
PPMissingLayer,
|
||||
is_pp_missing_parameter,
|
||||
make_empty_intermediate_tensors_factory,
|
||||
make_layers,
|
||||
maybe_prefix,
|
||||
)
|
||||
from vllm.sequence import IntermediateTensors
|
||||
|
||||
|
||||
class NemotronDenseRMSNorm(nn.Module):
|
||||
def __init__(self, hidden_size: int, eps: float = 1e-5):
|
||||
super().__init__()
|
||||
self.weight = nn.Parameter(torch.ones(hidden_size))
|
||||
self.variance_epsilon = eps
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
input_dtype = hidden_states.dtype
|
||||
hidden_states = hidden_states.to(torch.float32)
|
||||
variance = hidden_states.pow(2).mean(-1, keepdim=True)
|
||||
hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon)
|
||||
return self.weight * hidden_states.to(input_dtype)
|
||||
|
||||
|
||||
class NemotronDenseMLP(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
intermediate_size: int,
|
||||
hidden_act: str,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
bias: bool = False,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.up_proj = ColumnParallelLinear(
|
||||
input_size=hidden_size,
|
||||
output_size=intermediate_size,
|
||||
bias=bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.up_proj",
|
||||
)
|
||||
self.down_proj = RowParallelLinear(
|
||||
input_size=intermediate_size,
|
||||
output_size=hidden_size,
|
||||
bias=bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.down_proj",
|
||||
)
|
||||
self.act_fn = get_act_fn(hidden_act)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
up, _ = self.up_proj(x)
|
||||
x = self.act_fn(up)
|
||||
x, _ = self.down_proj(x)
|
||||
return x
|
||||
|
||||
|
||||
class NemotronDenseAttention(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
config: Any,
|
||||
hidden_size: int,
|
||||
num_heads: int,
|
||||
num_kv_heads: int,
|
||||
max_position_embeddings: int = 8192,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
bias: bool = False,
|
||||
cache_config: CacheConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = hidden_size
|
||||
tp_size = get_tensor_model_parallel_world_size()
|
||||
self.total_num_heads = num_heads
|
||||
assert self.total_num_heads % tp_size == 0
|
||||
self.num_heads = self.total_num_heads // tp_size
|
||||
self.total_num_kv_heads = num_kv_heads
|
||||
if self.total_num_kv_heads >= tp_size:
|
||||
assert self.total_num_kv_heads % tp_size == 0
|
||||
else:
|
||||
assert tp_size % self.total_num_kv_heads == 0
|
||||
self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
|
||||
self.head_dim = getattr(config, "head_dim", None)
|
||||
if self.head_dim is None:
|
||||
self.head_dim = self.hidden_size // self.total_num_heads
|
||||
self.q_size = self.num_heads * self.head_dim
|
||||
self.kv_size = self.num_kv_heads * self.head_dim
|
||||
self.scaling = self.head_dim**-0.5
|
||||
self.max_position_embeddings = max_position_embeddings
|
||||
|
||||
self.qkv_proj = QKVParallelLinear(
|
||||
hidden_size=hidden_size,
|
||||
head_size=self.head_dim,
|
||||
total_num_heads=self.total_num_heads,
|
||||
total_num_kv_heads=self.total_num_kv_heads,
|
||||
bias=bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.qkv_proj",
|
||||
)
|
||||
self.o_proj = RowParallelLinear(
|
||||
input_size=self.total_num_heads * self.head_dim,
|
||||
output_size=hidden_size,
|
||||
bias=bias,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.o_proj",
|
||||
)
|
||||
|
||||
self.rotary_emb = get_rope(
|
||||
self.head_dim,
|
||||
max_position=max_position_embeddings,
|
||||
rope_parameters=config.rope_parameters,
|
||||
)
|
||||
self.attn = Attention(
|
||||
self.num_heads,
|
||||
self.head_dim,
|
||||
self.scaling,
|
||||
num_kv_heads=self.num_kv_heads,
|
||||
cache_config=cache_config,
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.attn",
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
positions: torch.Tensor,
|
||||
hidden_states: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
qkv, _ = self.qkv_proj(hidden_states)
|
||||
q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
|
||||
q, k = self.rotary_emb(positions, q, k)
|
||||
attn_output = self.attn(q, k, v)
|
||||
output, _ = self.o_proj(attn_output)
|
||||
return output
|
||||
|
||||
|
||||
class NemotronDenseDecoderLayer(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
config: Any,
|
||||
cache_config: CacheConfig | None = None,
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = config.hidden_size
|
||||
max_position_embeddings = getattr(config, "max_position_embeddings", 8192)
|
||||
attention_bias = getattr(config, "attention_bias", False) or getattr(config, "bias", False)
|
||||
self.self_attn = NemotronDenseAttention(
|
||||
config=config,
|
||||
hidden_size=self.hidden_size,
|
||||
num_heads=config.num_attention_heads,
|
||||
num_kv_heads=getattr(config, "num_key_value_heads", config.num_attention_heads),
|
||||
max_position_embeddings=max_position_embeddings,
|
||||
quant_config=quant_config,
|
||||
bias=attention_bias,
|
||||
cache_config=cache_config,
|
||||
prefix=f"{prefix}.self_attn",
|
||||
)
|
||||
self.mlp = NemotronDenseMLP(
|
||||
hidden_size=self.hidden_size,
|
||||
intermediate_size=config.intermediate_size,
|
||||
hidden_act=config.hidden_act,
|
||||
quant_config=quant_config,
|
||||
bias=getattr(config, "mlp_bias", False),
|
||||
prefix=f"{prefix}.mlp",
|
||||
)
|
||||
self.input_layernorm = NemotronDenseRMSNorm(config.hidden_size, eps=config.norm_eps)
|
||||
self.post_attention_layernorm = NemotronDenseRMSNorm(config.hidden_size, eps=config.norm_eps)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
positions: torch.Tensor,
|
||||
hidden_states: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
residual = hidden_states
|
||||
hidden_states = self.input_layernorm(hidden_states)
|
||||
hidden_states = self.self_attn(positions=positions, hidden_states=hidden_states)
|
||||
hidden_states = residual + hidden_states
|
||||
|
||||
residual = hidden_states
|
||||
hidden_states = self.post_attention_layernorm(hidden_states)
|
||||
hidden_states = self.mlp(hidden_states)
|
||||
hidden_states = residual + hidden_states
|
||||
return hidden_states
|
||||
|
||||
|
||||
@support_torch_compile
|
||||
class NemotronDenseModel(nn.Module):
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
|
||||
config = vllm_config.model_config.hf_config
|
||||
cache_config = vllm_config.cache_config
|
||||
quant_config = vllm_config.quant_config
|
||||
|
||||
self.config = config
|
||||
self.quant_config = quant_config
|
||||
|
||||
self.vocab_size = config.vocab_size
|
||||
|
||||
if get_pp_group().is_first_rank or (config.tie_word_embeddings and get_pp_group().is_last_rank):
|
||||
self.embed_tokens = VocabParallelEmbedding(
|
||||
self.vocab_size,
|
||||
config.hidden_size,
|
||||
)
|
||||
else:
|
||||
self.embed_tokens = PPMissingLayer()
|
||||
self.start_layer, self.end_layer, self.layers = make_layers(
|
||||
config.num_hidden_layers,
|
||||
lambda prefix: NemotronDenseDecoderLayer(
|
||||
config=config,
|
||||
cache_config=cache_config,
|
||||
quant_config=quant_config,
|
||||
prefix=prefix,
|
||||
),
|
||||
prefix=f"{prefix}.layers",
|
||||
)
|
||||
if get_pp_group().is_last_rank:
|
||||
self.norm = NemotronDenseRMSNorm(config.hidden_size, eps=config.norm_eps)
|
||||
else:
|
||||
self.norm = PPMissingLayer()
|
||||
|
||||
self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
|
||||
["hidden_states"], config.hidden_size
|
||||
)
|
||||
|
||||
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
|
||||
return self.embed_tokens(input_ids)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None,
|
||||
positions: torch.Tensor,
|
||||
intermediate_tensors: IntermediateTensors | None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
) -> torch.Tensor | IntermediateTensors:
|
||||
if get_pp_group().is_first_rank:
|
||||
if inputs_embeds is not None:
|
||||
hidden_states = inputs_embeds
|
||||
else:
|
||||
hidden_states = self.embed_input_ids(input_ids)
|
||||
else:
|
||||
assert intermediate_tensors is not None
|
||||
hidden_states = intermediate_tensors["hidden_states"]
|
||||
|
||||
for layer in islice(self.layers, self.start_layer, self.end_layer):
|
||||
hidden_states = layer(positions, hidden_states)
|
||||
|
||||
if not get_pp_group().is_last_rank:
|
||||
return IntermediateTensors({"hidden_states": hidden_states})
|
||||
|
||||
hidden_states = self.norm(hidden_states)
|
||||
return hidden_states
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
stacked_params_mapping = [
|
||||
(".qkv_proj", ".q_proj", "q"),
|
||||
(".qkv_proj", ".k_proj", "k"),
|
||||
(".qkv_proj", ".v_proj", "v"),
|
||||
]
|
||||
params_dict = dict(self.named_parameters())
|
||||
loaded_params: set[str] = set()
|
||||
for name, loaded_weight in weights:
|
||||
if self.quant_config is not None and (scale_name := self.quant_config.get_cache_scale(name)):
|
||||
param = params_dict[scale_name]
|
||||
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
loaded_weight = loaded_weight if loaded_weight.dim() == 0 else loaded_weight[0]
|
||||
weight_loader(param, loaded_weight)
|
||||
loaded_params.add(scale_name)
|
||||
continue
|
||||
for param_name, weight_name, shard_id in stacked_params_mapping:
|
||||
if weight_name not in name:
|
||||
continue
|
||||
name = name.replace(weight_name, param_name)
|
||||
if name.endswith(".bias") and name not in params_dict:
|
||||
continue
|
||||
|
||||
if is_pp_missing_parameter(name, self):
|
||||
continue
|
||||
|
||||
param = params_dict[name]
|
||||
weight_loader = param.weight_loader
|
||||
weight_loader(param, loaded_weight, shard_id)
|
||||
|
||||
break
|
||||
else:
|
||||
if name.endswith(".bias") and name not in params_dict:
|
||||
continue
|
||||
name = maybe_remap_kv_scale_name(name, params_dict)
|
||||
if name is None:
|
||||
continue
|
||||
|
||||
if is_pp_missing_parameter(name, self):
|
||||
continue
|
||||
|
||||
param = params_dict[name]
|
||||
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
weight_loader(param, loaded_weight)
|
||||
loaded_params.add(name)
|
||||
return loaded_params
|
||||
|
||||
|
||||
class NemotronDenseForCausalLM(nn.Module, SupportsLoRA, SupportsPP):
|
||||
packed_modules_mapping = {
|
||||
"qkv_proj": [
|
||||
"q_proj",
|
||||
"k_proj",
|
||||
"v_proj",
|
||||
],
|
||||
}
|
||||
|
||||
embedding_modules = {
|
||||
"embed_tokens": "input_embeddings",
|
||||
"lm_head": "output_embeddings",
|
||||
}
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
config = vllm_config.model_config.hf_config
|
||||
quant_config = vllm_config.quant_config
|
||||
|
||||
assert hasattr(config, "norm_eps") and hasattr(config, "num_hidden_layers")
|
||||
|
||||
self.config = config
|
||||
self.quant_config = quant_config
|
||||
|
||||
self.model = NemotronDenseModel(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model"))
|
||||
if get_pp_group().is_last_rank:
|
||||
self.lm_head = ParallelLMHead(
|
||||
config.vocab_size,
|
||||
config.hidden_size,
|
||||
quant_config=quant_config,
|
||||
prefix=maybe_prefix(prefix, "lm_head"),
|
||||
)
|
||||
if config.tie_word_embeddings:
|
||||
self.lm_head.weight = self.model.embed_tokens.weight
|
||||
|
||||
logit_scale = getattr(config, "logit_scale", 1.0)
|
||||
self.logits_processor = LogitsProcessor(config.vocab_size, scale=logit_scale)
|
||||
else:
|
||||
self.lm_head = PPMissingLayer()
|
||||
|
||||
self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors
|
||||
|
||||
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
|
||||
return self.model.embed_input_ids(input_ids)
|
||||
|
||||
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 | IntermediateTensors:
|
||||
# The omni AR runner forwards extra bookkeeping kwargs (e.g.
|
||||
# sampling_metadata); this plain LM ignores them.
|
||||
return self.model(input_ids, positions, intermediate_tensors, inputs_embeds)
|
||||
|
||||
def compute_logits(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
sampling_metadata: Any = None,
|
||||
) -> torch.Tensor | None:
|
||||
return self.logits_processor(self.lm_head, hidden_states)
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
loader = AutoWeightsLoader(self)
|
||||
return loader.load_weights(weights)
|
||||
@@ -0,0 +1,178 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Audex TTA stage 1: XCodec1 RVQ codes → waveform.
|
||||
|
||||
Unlike the streaming causal speech decoder used for TTS, XCodec1 is a CNN
|
||||
codec that the official flow decodes over the full sequence, so this stage
|
||||
runs sync full-payload only: each request delivers its complete
|
||||
de-interleaved ``[frames, 4]`` codec-id payload once at finish and the
|
||||
decode happens in a single call.
|
||||
|
||||
The checkpoint (``hf-audio/xcodec-hubert-general-balanced``) is external to
|
||||
the Audex repo and loads through transformers remote code; the model
|
||||
skeleton is built from config at startup for vLLM's memory profiler and the
|
||||
weights arrive via the standard ``load_weights`` iteration over the
|
||||
snapshot's safetensors.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.model_executor.models.audex.tta import (
|
||||
XCODEC1_CODEBOOK_SIZE,
|
||||
XCODEC1_NUM_CODEBOOKS,
|
||||
)
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Official cap on decoded frames (~10 s of audio at the codec frame rate).
|
||||
_DEFAULT_MAX_TTA_FRAMES = 500
|
||||
|
||||
|
||||
def _meta_str(value: Any) -> str | None:
|
||||
if isinstance(value, list | tuple):
|
||||
return _meta_str(value[0]) if value else None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _codes_from_runtime_info(runtime_info: dict[str, Any] | None) -> torch.Tensor | None:
|
||||
"""Extract the ``[frames, 4]`` codec-id payload from the connector info."""
|
||||
if not isinstance(runtime_info, dict):
|
||||
return None
|
||||
codes = runtime_info.get("codes")
|
||||
if not isinstance(codes, dict):
|
||||
return None
|
||||
audio = codes.get("audio")
|
||||
if isinstance(audio, torch.Tensor):
|
||||
return audio
|
||||
if isinstance(audio, list | tuple) and audio:
|
||||
return torch.as_tensor(audio, dtype=torch.long)
|
||||
return None
|
||||
|
||||
|
||||
class AudexXCodec1(nn.Module):
|
||||
"""Stage-1 model for Audex TTA (GenerationModelRunner)."""
|
||||
|
||||
input_modalities = "audio"
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
self.vllm_config = vllm_config
|
||||
self.model_path = vllm_config.model_config.model
|
||||
|
||||
self.have_multimodal_outputs = True
|
||||
self.has_preprocess = False
|
||||
self.has_postprocess = False
|
||||
self.enable_update_additional_information = True
|
||||
self.requires_raw_input_tokens = True
|
||||
|
||||
from transformers import AutoConfig, AutoModel
|
||||
|
||||
config = AutoConfig.from_pretrained(self.model_path, trust_remote_code=True)
|
||||
self.codec = AutoModel.from_config(config, trust_remote_code=True)
|
||||
self.codec.eval()
|
||||
self._sample_rate = int(getattr(config, "sampling_rate", 16000))
|
||||
self._max_frames = int(getattr(config, "max_tta_frames", _DEFAULT_MAX_TTA_FRAMES))
|
||||
|
||||
# -------------------- vLLM model interface --------------------
|
||||
|
||||
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
|
||||
# This stage ignores token embeddings; keep a stable dummy for the runner.
|
||||
if input_ids.numel() == 0:
|
||||
return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32)
|
||||
return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32)
|
||||
|
||||
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> None:
|
||||
return None
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
state_dict = dict(weights)
|
||||
self.codec.load_state_dict(state_dict, strict=True)
|
||||
device = self.vllm_config.device_config.device
|
||||
self.codec.to(device=device, dtype=torch.float32)
|
||||
return {f"codec.{name}" for name in state_dict}
|
||||
|
||||
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput | tuple, **kwargs: Any) -> OmniOutput:
|
||||
if isinstance(model_outputs, OmniOutput):
|
||||
return model_outputs
|
||||
# The runner may unpack the NamedTuple in transit; rebuild it.
|
||||
if isinstance(model_outputs, tuple) and len(model_outputs) == len(OmniOutput._fields):
|
||||
return OmniOutput(*model_outputs)
|
||||
raise TypeError(f"AudexXCodec1 expected OmniOutput, got {type(model_outputs)}")
|
||||
|
||||
# -------------------- decoding --------------------
|
||||
|
||||
def _decode_codes(self, req_id: str | None, codes: torch.Tensor) -> torch.Tensor:
|
||||
"""Decode a ``[frames, 4]`` codec-id payload to a mono waveform."""
|
||||
if codes.numel() == 0:
|
||||
# Zero codec tokens for the whole request. Do NOT raise: an
|
||||
# exception in forward() kills the engine core for every request.
|
||||
# Returning empty audio lets the serving layer fail this request.
|
||||
logger.error("Audex TTA: request %s finished with no codec tokens; no audio generated", req_id)
|
||||
return torch.empty(0, dtype=torch.float32)
|
||||
if codes.ndim != 2 or codes.shape[1] != XCODEC1_NUM_CODEBOOKS:
|
||||
logger.error(
|
||||
"Audex TTA: request %s payload has shape %s, expected [frames, %d]; no audio generated",
|
||||
req_id,
|
||||
tuple(codes.shape),
|
||||
XCODEC1_NUM_CODEBOOKS,
|
||||
)
|
||||
return torch.empty(0, dtype=torch.float32)
|
||||
|
||||
device = self.vllm_config.device_config.device
|
||||
with torch.inference_mode():
|
||||
frames = codes[: self._max_frames].to(device=device, dtype=torch.long)
|
||||
# [frames, 4] codec ids -> [1, 4, frames] per-layer indices.
|
||||
layer_offsets = (
|
||||
torch.arange(XCODEC1_NUM_CODEBOOKS, device=frames.device, dtype=frames.dtype) * XCODEC1_CODEBOOK_SIZE
|
||||
)
|
||||
per_layer = (frames - layer_offsets.view(1, -1)).clamp(0, XCODEC1_CODEBOOK_SIZE - 1)
|
||||
stacked = per_layer.transpose(0, 1).unsqueeze(0)
|
||||
decoded = self.codec.decode(stacked)
|
||||
audio = decoded.audio_values if hasattr(decoded, "audio_values") else decoded[0]
|
||||
return audio.reshape(-1).to(dtype=torch.float32, device="cpu")
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None = None,
|
||||
positions: torch.Tensor | None = None,
|
||||
intermediate_tensors: Any = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
runtime_additional_information: list[dict[str, Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> OmniOutput:
|
||||
runtime_infos = runtime_additional_information or []
|
||||
seq_token_counts = kwargs.get("seq_token_counts")
|
||||
if seq_token_counts is not None:
|
||||
num_reqs = len(seq_token_counts)
|
||||
elif input_ids is not None and input_ids.numel() > 0 and not runtime_infos:
|
||||
num_reqs = 1
|
||||
else:
|
||||
num_reqs = len(runtime_infos)
|
||||
|
||||
audios: list[torch.Tensor] = []
|
||||
sr_tensor = torch.tensor(self._sample_rate, dtype=torch.int32)
|
||||
for i in range(num_reqs):
|
||||
info = runtime_infos[i] if i < len(runtime_infos) else {}
|
||||
info = info if isinstance(info, dict) else {}
|
||||
meta = info.get("meta", {})
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
req_id = _meta_str(meta.get("req_id"))
|
||||
codes = _codes_from_runtime_info(info)
|
||||
if codes is None:
|
||||
audios.append(torch.empty(0, dtype=torch.float32))
|
||||
continue
|
||||
audios.append(self._decode_codes(req_id, codes))
|
||||
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": audios, "sr": [sr_tensor] * num_reqs},
|
||||
)
|
||||
@@ -0,0 +1,567 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
#
|
||||
# Adapted from nvidia/Nemotron-Labs-Audex-2B (Apache-2.0):
|
||||
# inference_scripts_vllm/audiogen_scripts/cfg_logits_processor.py
|
||||
# inference_scripts_vllm/audiogen_scripts/vllm_cfg_patch.py
|
||||
# Divergences from the official files are commented at the divergence site.
|
||||
"""Classifier-free guidance for the Audex thinker stage.
|
||||
|
||||
CFG pairs a conditional request with an unconditional (null-prompt)
|
||||
companion in the same engine and blends their logits every step:
|
||||
|
||||
blended = uncond + cfg_scale * (cond - uncond)
|
||||
|
||||
Both rows receive the blended logits and the sampled token is copied from
|
||||
the cond row to the uncond row, so the two sequences stay token-identical.
|
||||
Requests opt in via ``SamplingParams.extra_args``:
|
||||
|
||||
cond: {"cfg_scale": 1.5, "cfg_role": "cond", "cfg_pair_id": <id>}
|
||||
uncond: {"cfg_scale": 1.5, "cfg_role": "uncond", "cfg_pair_id": <id>}
|
||||
|
||||
Blending is only correct when both pair members decode the same position in
|
||||
the same engine step, so ``apply_cfg_patches()`` makes the vLLM scheduler
|
||||
pair-aware: a lone member waits for its partner, partners stay adjacent,
|
||||
their ``num_computed_tokens`` are equalized after every schedule, and they
|
||||
finish together. The patch is a no-op for engines that never see a CFG
|
||||
request (the pair registry stays empty).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.v1.sample.logits_processor import (
|
||||
BatchUpdate,
|
||||
LogitsProcessor,
|
||||
MoveDirectionality,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_COND = "cond"
|
||||
_UNCOND = "uncond"
|
||||
|
||||
|
||||
def _cfg_extra(request: Any, key: str) -> Any:
|
||||
sampling_params = getattr(request, "sampling_params", None)
|
||||
extra_args = getattr(sampling_params, "extra_args", None)
|
||||
if extra_args:
|
||||
return extra_args.get(key)
|
||||
return None
|
||||
|
||||
|
||||
class AudexCFGLogitsProcessor(LogitsProcessor):
|
||||
"""Blend cond/uncond logits for classifier-free guidance.
|
||||
|
||||
Pairs are matched by ``cfg_pair_id``. For each pair the blended logits
|
||||
are written to *both* rows so the sampler picks the same token; the
|
||||
post-sampling copy of the cond token into the uncond slot is installed
|
||||
by :meth:`_ensure_sample_patched` (which must run inside each worker
|
||||
process, hence from ``__init__`` rather than a main-process patch).
|
||||
"""
|
||||
|
||||
_sample_patched = False
|
||||
|
||||
@classmethod
|
||||
def validate_params(cls, params: SamplingParams) -> None:
|
||||
extra_args = params.extra_args
|
||||
if not extra_args:
|
||||
return
|
||||
role = extra_args.get("cfg_role")
|
||||
if role is not None and role not in (_COND, _UNCOND):
|
||||
raise ValueError(f"cfg_role must be 'cond' or 'uncond', got '{role}'")
|
||||
scale = extra_args.get("cfg_scale")
|
||||
if scale is not None and (not isinstance(scale, int | float) or scale < 1.0):
|
||||
raise ValueError(f"cfg_scale must be >= 1.0, got {scale}")
|
||||
|
||||
def __init__(self, vllm_config: VllmConfig, device: torch.device, is_pin_memory: bool) -> None:
|
||||
self._info: dict[int, dict[str, Any]] = {}
|
||||
self._output_tokens: dict[int, list[int]] = {}
|
||||
self._pairs: list[tuple[int, int, float]] = []
|
||||
self._dirty = True
|
||||
self._ensure_sample_patched()
|
||||
|
||||
@classmethod
|
||||
def _ensure_sample_patched(cls) -> None:
|
||||
"""Install the post-sampling cond→uncond token copy (once per process)."""
|
||||
if cls._sample_patched:
|
||||
return
|
||||
cls._sample_patched = True
|
||||
|
||||
# Divergence from the official file: it patches vLLM's
|
||||
# ``GPUModelRunner._sample``, but vllm-omni's AR runner overrides
|
||||
# ``_sample``, so the base-class patch would never run for the Audex
|
||||
# thinker stage. Patch the omni AR runner instead.
|
||||
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
|
||||
|
||||
orig_sample = GPUARModelRunner._sample
|
||||
|
||||
def _sample_with_cfg_sync(self, logits, spec_decode_metadata):
|
||||
sampler_output = orig_sample(self, logits, spec_decode_metadata)
|
||||
for proc in self.input_batch.logitsprocs.all:
|
||||
if isinstance(proc, AudexCFGLogitsProcessor) and proc._pairs:
|
||||
sampled = sampler_output.sampled_token_ids
|
||||
num_rows = sampled.shape[0] if hasattr(sampled, "shape") else len(sampled)
|
||||
for cond_idx, uncond_idx, _ in proc._pairs:
|
||||
# Steps that sample fewer rows than the persistent
|
||||
# batch (pure-prefill / partially scheduled steps)
|
||||
# have nothing to sync for this pair; the scheduler
|
||||
# pair patches keep both members step-locked on the
|
||||
# decode steps that matter.
|
||||
if cond_idx < num_rows and uncond_idx < num_rows:
|
||||
sampled[uncond_idx] = sampled[cond_idx]
|
||||
break
|
||||
return sampler_output
|
||||
|
||||
_sample_with_cfg_sync._audex_cfg_sync = True # type: ignore[attr-defined]
|
||||
GPUARModelRunner._sample = _sample_with_cfg_sync
|
||||
logger.info("AudexCFGLogitsProcessor: patched GPUARModelRunner._sample for CFG token sync")
|
||||
|
||||
def is_argmax_invariant(self) -> bool:
|
||||
return False
|
||||
|
||||
def _reset(self) -> None:
|
||||
self._info.clear()
|
||||
self._output_tokens.clear()
|
||||
self._pairs.clear()
|
||||
self._dirty = True
|
||||
|
||||
def update_state(self, batch_update: BatchUpdate | None) -> None:
|
||||
if batch_update is None:
|
||||
return
|
||||
|
||||
for idx in batch_update.removed:
|
||||
self._info.pop(idx, None)
|
||||
self._output_tokens.pop(idx, None)
|
||||
|
||||
if not self._info and batch_update.added:
|
||||
self._reset()
|
||||
|
||||
for idx, params, _, output_token_ids in batch_update.added:
|
||||
extra_args = params.extra_args if params else None
|
||||
if extra_args and extra_args.get("cfg_role") in (_COND, _UNCOND):
|
||||
self._info[idx] = {
|
||||
"role": extra_args["cfg_role"],
|
||||
"cfg_scale": float(extra_args.get("cfg_scale", 1.0)),
|
||||
"pair_id": extra_args.get("cfg_pair_id"),
|
||||
}
|
||||
self._output_tokens[idx] = output_token_ids
|
||||
else:
|
||||
self._info.pop(idx, None)
|
||||
self._output_tokens.pop(idx, None)
|
||||
self._dirty = True
|
||||
|
||||
if self._info:
|
||||
for src, dst, direction in batch_update.moved:
|
||||
src_info = self._info.pop(src, None)
|
||||
dst_info = self._info.pop(dst, None)
|
||||
src_tokens = self._output_tokens.pop(src, None)
|
||||
dst_tokens = self._output_tokens.pop(dst, None)
|
||||
if src_info is not None:
|
||||
self._info[dst] = src_info
|
||||
if src_tokens is not None:
|
||||
self._output_tokens[dst] = src_tokens
|
||||
if direction == MoveDirectionality.SWAP:
|
||||
if dst_info is not None:
|
||||
self._info[src] = dst_info
|
||||
if dst_tokens is not None:
|
||||
self._output_tokens[src] = dst_tokens
|
||||
self._dirty = True
|
||||
|
||||
def _rebuild_pairs(self) -> None:
|
||||
by_pair: dict[str, dict[str, tuple[int, float]]] = {}
|
||||
for idx, info in self._info.items():
|
||||
pair_id = info.get("pair_id")
|
||||
if pair_id is None:
|
||||
continue
|
||||
by_pair.setdefault(pair_id, {})[info["role"]] = (idx, info["cfg_scale"])
|
||||
|
||||
self._pairs = [
|
||||
(roles[_COND][0], roles[_UNCOND][0], roles[_COND][1])
|
||||
for roles in by_pair.values()
|
||||
if _COND in roles and _UNCOND in roles
|
||||
]
|
||||
self._dirty = False
|
||||
|
||||
def apply(self, logits: torch.Tensor) -> torch.Tensor:
|
||||
if not self._info:
|
||||
return logits
|
||||
|
||||
if self._dirty:
|
||||
self._rebuild_pairs()
|
||||
|
||||
num_rows = logits.shape[0]
|
||||
for cond_idx, uncond_idx, cfg_scale in self._pairs:
|
||||
# Rows beyond this step's logits (pure-prefill / partially
|
||||
# scheduled steps) have nothing to blend; decode steps carry
|
||||
# both pair rows thanks to the scheduler pair patches.
|
||||
if cond_idx >= num_rows or uncond_idx >= num_rows:
|
||||
continue
|
||||
blended = logits[uncond_idx] + cfg_scale * (logits[cond_idx] - logits[uncond_idx])
|
||||
logits[cond_idx] = blended
|
||||
logits[uncond_idx] = blended
|
||||
|
||||
return logits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler pair synchronization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _wait_queues(scheduler: Any) -> list[Any]:
|
||||
# Divergence from the official (vLLM 0.20-era) file: vLLM 0.24 parks
|
||||
# structurally not-ready requests in a second ``skipped_waiting`` queue;
|
||||
# pair-hold must cover both queues or a lone member parked there would
|
||||
# be admitted without its partner.
|
||||
queues = [scheduler.waiting]
|
||||
skipped = getattr(scheduler, "skipped_waiting", None)
|
||||
if skipped is not None:
|
||||
queues.append(skipped)
|
||||
return queues
|
||||
|
||||
|
||||
def _pair_complete(scheduler: Any, request_id: str, blocked_ids: set[str] | None = None) -> bool:
|
||||
pair_id = scheduler._cfg_req_to_pair.get(request_id)
|
||||
if pair_id is None:
|
||||
return True
|
||||
roles = scheduler._cfg_pairs.get(pair_id, {})
|
||||
if len(roles) != 2 or not all(rid in scheduler.requests for rid in roles.values()):
|
||||
return False
|
||||
if blocked_ids:
|
||||
# A partner parked in skipped_waiting (e.g. waiting for remote KVs)
|
||||
# may fail promotion this step while this member gets scheduled from
|
||||
# waiting — hold this member until the partner is schedulable.
|
||||
for rid in roles.values():
|
||||
if rid != request_id and rid in blocked_ids:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Schedule steps a lone pair member may wait for its partner. Partners arrive
|
||||
# within a handful of steps in practice; exhausting this budget means the
|
||||
# companion was never created (e.g. prompt expansion failed), and the request
|
||||
# is released to decode unguided instead of hanging forever.
|
||||
_MAX_PAIR_HOLD_STEPS = 512
|
||||
|
||||
|
||||
def _drop_stale_pair(scheduler: Any, request_id: str) -> None:
|
||||
pair_id = scheduler._cfg_req_to_pair.get(request_id)
|
||||
if pair_id is None:
|
||||
return
|
||||
for rid in scheduler._cfg_pairs.pop(pair_id, {}).values():
|
||||
scheduler._cfg_req_to_pair.pop(rid, None)
|
||||
logger.warning(
|
||||
"CFG pair %s: partner of %s never arrived after %d schedule steps; releasing the request unguided",
|
||||
pair_id,
|
||||
request_id,
|
||||
_MAX_PAIR_HOLD_STEPS,
|
||||
)
|
||||
|
||||
|
||||
def _hold_incomplete_pairs(scheduler: Any) -> list[tuple[Any, Any]]:
|
||||
"""Pull CFG requests whose partner is not yet admitted out of the wait queues.
|
||||
|
||||
The engine schedules as soon as the first member of a pair arrives over
|
||||
IPC; without the hold, the lone member prefills one step early and the
|
||||
pair is permanently offset by one token. Held requests are re-prepended
|
||||
after the schedule step.
|
||||
"""
|
||||
hold_counts: dict[str, int] = getattr(scheduler, "_cfg_hold_counts", None) or {}
|
||||
scheduler._cfg_hold_counts = hold_counts
|
||||
|
||||
skipped = getattr(scheduler, "skipped_waiting", None)
|
||||
blocked_ids = {req.request_id for req in list(skipped)} if skipped is not None else set()
|
||||
|
||||
held: list[tuple[Any, Any]] = []
|
||||
for queue in _wait_queues(scheduler):
|
||||
# A member is only "blocked" for its partner's sake when it sits in
|
||||
# the OTHER queue; members of the queue being scanned move together.
|
||||
partner_blockers = blocked_ids if queue is not skipped else set()
|
||||
held_here = []
|
||||
for request in list(queue):
|
||||
if _pair_complete(scheduler, request.request_id, partner_blockers):
|
||||
hold_counts.pop(request.request_id, None)
|
||||
continue
|
||||
count = hold_counts.get(request.request_id, 0) + 1
|
||||
if count > _MAX_PAIR_HOLD_STEPS:
|
||||
hold_counts.pop(request.request_id, None)
|
||||
_drop_stale_pair(scheduler, request.request_id)
|
||||
continue
|
||||
hold_counts[request.request_id] = count
|
||||
held_here.append(request)
|
||||
if held_here:
|
||||
queue.remove_requests(held_here)
|
||||
held.extend((queue, req) for req in held_here)
|
||||
return held
|
||||
|
||||
|
||||
def _release_held(held: list[tuple[Any, Any]]) -> None:
|
||||
for queue, request in reversed(held):
|
||||
queue.prepend_request(request)
|
||||
|
||||
|
||||
def _reorder_waiting_for_cfg(scheduler: Any) -> None:
|
||||
"""Move CFG pair partners adjacent in the FCFS waiting queue."""
|
||||
waiting = scheduler.waiting
|
||||
# Only the FCFS queue (a deque) has caller-controlled ordering; priority
|
||||
# queues order themselves and pair sync then relies on hold + equalize.
|
||||
if not isinstance(waiting, deque) or len(waiting) < 2:
|
||||
return
|
||||
|
||||
requests = list(waiting)
|
||||
waiting.clear()
|
||||
|
||||
seen: set[str] = set()
|
||||
result: list[Any] = []
|
||||
for request in requests:
|
||||
rid = request.request_id
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
result.append(request)
|
||||
|
||||
pair_id = scheduler._cfg_req_to_pair.get(rid)
|
||||
if pair_id is None:
|
||||
continue
|
||||
for partner_id in scheduler._cfg_pairs.get(pair_id, {}).values():
|
||||
if partner_id != rid and partner_id not in seen:
|
||||
for candidate in requests:
|
||||
if candidate.request_id == partner_id:
|
||||
seen.add(partner_id)
|
||||
result.append(candidate)
|
||||
break
|
||||
|
||||
waiting.extend(result)
|
||||
|
||||
|
||||
def _equalize_cfg_pair_progress(scheduler: Any, scheduler_output: Any) -> None:
|
||||
"""Bring both members of every CFG pair to the same ``num_computed_tokens``.
|
||||
|
||||
Prefix-cache hits or unequal chunked-prefill budget can leave one member
|
||||
ahead after a schedule step; reduce the faster member's allocation so
|
||||
both land on the same position. Over-allocated KV blocks remain and are
|
||||
consumed next step — nothing leaks.
|
||||
"""
|
||||
for roles in scheduler._cfg_pairs.values():
|
||||
cond_id = roles.get(_COND)
|
||||
uncond_id = roles.get(_UNCOND)
|
||||
if not cond_id or not uncond_id:
|
||||
continue
|
||||
|
||||
cond_sched = scheduler_output.num_scheduled_tokens.get(cond_id, 0)
|
||||
uncond_sched = scheduler_output.num_scheduled_tokens.get(uncond_id, 0)
|
||||
if cond_sched == 0 or uncond_sched == 0:
|
||||
continue
|
||||
|
||||
cond_req = scheduler.requests.get(cond_id)
|
||||
uncond_req = scheduler.requests.get(uncond_id)
|
||||
if not cond_req or not uncond_req:
|
||||
continue
|
||||
|
||||
if cond_req.num_computed_tokens == uncond_req.num_computed_tokens:
|
||||
continue
|
||||
|
||||
target = min(cond_req.num_computed_tokens, uncond_req.num_computed_tokens)
|
||||
|
||||
feasible = all(
|
||||
req.num_computed_tokens - target < sched
|
||||
for req, sched in ((cond_req, cond_sched), (uncond_req, uncond_sched))
|
||||
)
|
||||
if not feasible:
|
||||
continue
|
||||
|
||||
for req_id, req, orig_sched in (
|
||||
(cond_id, cond_req, cond_sched),
|
||||
(uncond_id, uncond_req, uncond_sched),
|
||||
):
|
||||
diff = req.num_computed_tokens - target
|
||||
if diff > 0:
|
||||
req.num_computed_tokens = target
|
||||
scheduler_output.num_scheduled_tokens[req_id] = orig_sched - diff
|
||||
scheduler_output.total_num_scheduled_tokens -= diff
|
||||
logger.debug(
|
||||
"CFG equalize %s: %d -> %d scheduled, computed -> %d",
|
||||
req_id,
|
||||
orig_sched,
|
||||
orig_sched - diff,
|
||||
target,
|
||||
)
|
||||
|
||||
|
||||
def _drop_split_pairs(scheduler: Any) -> None:
|
||||
"""Deregister a desynced CFG pair (one member preempted / left behind).
|
||||
|
||||
Manual re-preemption surgery is unsafe against the upstream scheduler.
|
||||
Instead, a pair that split anyway (KV-pressure preemption of exactly one
|
||||
member, or unrecoverable progress skew) is dropped from the registry:
|
||||
the cond request continues UNGUIDED (its logits are never blended again)
|
||||
rather than being re-paired against a desynced partner, and the
|
||||
companion's discarded output no longer matters.
|
||||
"""
|
||||
if not scheduler._cfg_pairs:
|
||||
return
|
||||
|
||||
running_ids = {req.request_id for req in scheduler.running}
|
||||
to_drop: list[tuple[str, str, str, str]] = []
|
||||
for pair_id, roles in scheduler._cfg_pairs.items():
|
||||
cond_id = roles.get(_COND)
|
||||
uncond_id = roles.get(_UNCOND)
|
||||
if cond_id is None or uncond_id is None:
|
||||
continue
|
||||
if cond_id not in scheduler.requests or uncond_id not in scheduler.requests:
|
||||
continue
|
||||
|
||||
cond_running = cond_id in running_ids
|
||||
uncond_running = uncond_id in running_ids
|
||||
if cond_running != uncond_running:
|
||||
# Split only counts once decoding started: a lone member that has
|
||||
# computed tokens while its partner was pushed back is desynced.
|
||||
runner = scheduler.requests.get(cond_id if cond_running else uncond_id)
|
||||
if runner is not None and runner.num_computed_tokens > 0:
|
||||
to_drop.append((pair_id, cond_id, uncond_id, "one member preempted"))
|
||||
continue
|
||||
|
||||
if cond_running and uncond_running:
|
||||
cond_req = scheduler.requests.get(cond_id)
|
||||
uncond_req = scheduler.requests.get(uncond_id)
|
||||
if cond_req and uncond_req and cond_req.num_computed_tokens != uncond_req.num_computed_tokens:
|
||||
logger.warning(
|
||||
"CFG pair progress mismatch (equalize will retry): %s@%d vs %s@%d",
|
||||
cond_id,
|
||||
cond_req.num_computed_tokens,
|
||||
uncond_id,
|
||||
uncond_req.num_computed_tokens,
|
||||
)
|
||||
|
||||
for pair_id, cond_id, uncond_id, reason in to_drop:
|
||||
scheduler._cfg_pairs.pop(pair_id, None)
|
||||
scheduler._cfg_req_to_pair.pop(cond_id, None)
|
||||
scheduler._cfg_req_to_pair.pop(uncond_id, None)
|
||||
logger.warning(
|
||||
"CFG pair %s split (%s); releasing cond=%s unguided, uncond=%s output is discarded",
|
||||
pair_id,
|
||||
reason,
|
||||
cond_id,
|
||||
uncond_id,
|
||||
)
|
||||
|
||||
|
||||
_patches_applied = False
|
||||
|
||||
|
||||
def cfg_patches_applied() -> bool:
|
||||
"""True once :func:`apply_cfg_patches` has patched the scheduler in this process."""
|
||||
return _patches_applied
|
||||
|
||||
|
||||
def _patch_scheduler() -> None:
|
||||
from vllm.v1.core.sched.scheduler import Scheduler
|
||||
|
||||
orig_init = Scheduler.__init__
|
||||
orig_add = Scheduler.add_request
|
||||
orig_finish = Scheduler.finish_requests
|
||||
orig_schedule = Scheduler.schedule
|
||||
|
||||
def _init(self, *args: Any, **kwargs: Any) -> None:
|
||||
orig_init(self, *args, **kwargs)
|
||||
self._cfg_pairs: dict[str, dict[str, str]] = {}
|
||||
self._cfg_req_to_pair: dict[str, str] = {}
|
||||
|
||||
def _add(self, request: Any) -> None:
|
||||
orig_add(self, request)
|
||||
pair_id = _cfg_extra(request, "cfg_pair_id")
|
||||
role = _cfg_extra(request, "cfg_role")
|
||||
if pair_id and role:
|
||||
self._cfg_pairs.setdefault(pair_id, {})[role] = request.request_id
|
||||
self._cfg_req_to_pair[request.request_id] = pair_id
|
||||
# An asymmetric prefix-cache hit (the cond prompt shares cached
|
||||
# branches; the <unk> null prompt does not) can advance one
|
||||
# member past what post-schedule equalization can claw back.
|
||||
# Skip prefix-cache reads for CFG requests entirely.
|
||||
if hasattr(request, "skip_reading_prefix_cache"):
|
||||
request.skip_reading_prefix_cache = True
|
||||
|
||||
def _finish(self, request_ids: Any, finished_status: Any) -> Any:
|
||||
if request_ids is None:
|
||||
result = orig_finish(self, request_ids, finished_status)
|
||||
self._cfg_pairs.clear()
|
||||
self._cfg_req_to_pair.clear()
|
||||
return result
|
||||
|
||||
if isinstance(request_ids, str):
|
||||
request_ids = {request_ids}
|
||||
else:
|
||||
request_ids = set(request_ids)
|
||||
|
||||
# Finish both members together: a surviving partner would decode
|
||||
# unpaired garbage (its logits are never blended again).
|
||||
partner_ids: set[str] = set()
|
||||
for req_id in request_ids:
|
||||
pair_id = self._cfg_req_to_pair.get(req_id)
|
||||
if pair_id is None:
|
||||
continue
|
||||
for rid in self._cfg_pairs.get(pair_id, {}).values():
|
||||
if rid != req_id and rid in self.requests:
|
||||
partner_ids.add(rid)
|
||||
|
||||
all_ids = request_ids | partner_ids
|
||||
result = orig_finish(self, all_ids, finished_status)
|
||||
|
||||
for req_id in all_ids:
|
||||
pair_id = self._cfg_req_to_pair.pop(req_id, None)
|
||||
if pair_id:
|
||||
self._cfg_pairs.pop(pair_id, None)
|
||||
return result
|
||||
|
||||
def _schedule(self, *args: Any, **kwargs: Any) -> Any:
|
||||
if not self._cfg_pairs:
|
||||
return orig_schedule(self, *args, **kwargs)
|
||||
|
||||
_reorder_waiting_for_cfg(self)
|
||||
held = _hold_incomplete_pairs(self)
|
||||
|
||||
# Chunked prefill can split one pair member's prompt across steps
|
||||
# while the other fits in a single step; halving the long-prefill
|
||||
# threshold makes both chunk, and equalize aligns the remainder.
|
||||
scheduler_config = getattr(self, "scheduler_config", None)
|
||||
orig_threshold = getattr(scheduler_config, "long_prefill_token_threshold", None)
|
||||
if scheduler_config is not None and orig_threshold is not None:
|
||||
scheduler_config.long_prefill_token_threshold = self.max_num_scheduled_tokens // 2
|
||||
try:
|
||||
scheduler_output = orig_schedule(self, *args, **kwargs)
|
||||
finally:
|
||||
if scheduler_config is not None and orig_threshold is not None:
|
||||
scheduler_config.long_prefill_token_threshold = orig_threshold
|
||||
_release_held(held)
|
||||
|
||||
_equalize_cfg_pair_progress(self, scheduler_output)
|
||||
_drop_split_pairs(self)
|
||||
return scheduler_output
|
||||
|
||||
_schedule._audex_cfg_patched = True # type: ignore[attr-defined]
|
||||
|
||||
Scheduler.__init__ = _init
|
||||
Scheduler.add_request = _add
|
||||
Scheduler.finish_requests = _finish
|
||||
Scheduler.schedule = _schedule
|
||||
|
||||
|
||||
def apply_cfg_patches() -> None:
|
||||
"""Make the vLLM v1 scheduler CFG-pair-aware. Idempotent, per process.
|
||||
|
||||
Must run in the engine-core process before ``Scheduler`` is constructed
|
||||
(the ``__init__`` wrapper installs the pair registry).
|
||||
"""
|
||||
global _patches_applied
|
||||
if _patches_applied:
|
||||
return
|
||||
_patches_applied = True
|
||||
logger.info("Applying Audex CFG scheduler patches to vLLM v1")
|
||||
_patch_scheduler()
|
||||
@@ -0,0 +1,186 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Audex checkpoint layout preparation.
|
||||
|
||||
The nvidia/Nemotron-Labs-Audex-2B repo deduplicates weights: the
|
||||
``checkpoint_folder_audiogen`` (and ``checkpoint_folder_textonly``) index
|
||||
files reference safetensors shards that physically live only under
|
||||
``checkpoint_folder_full``. The official repo ships a prepare script that
|
||||
symlinks them; this replicates that step automatically so users can pass the
|
||||
repo root without any manual preparation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_WEIGHT_SOURCE_FOLDER = "checkpoint_folder_full"
|
||||
|
||||
# Download profiles: each deployment pulls only the repo subset its stages
|
||||
# need. The dedup weight shards are appended per-model at call time from
|
||||
# the audiogen index (see _dedup_shard_patterns); the static lists here
|
||||
# hold only the model-independent folders.
|
||||
_SNAPSHOT_PROFILE_PATTERNS = {
|
||||
"tts": [
|
||||
"config.json",
|
||||
"checkpoint_folder_audiogen/*",
|
||||
"audex_causal_speech_decoder/*",
|
||||
],
|
||||
# TTA reuses the audiogen thinker; waveform decode happens in the
|
||||
# external XCodec1 checkpoint (see ensure_xcodec1_snapshot), so the
|
||||
# bundled speech decoder is not pulled.
|
||||
"tta": [
|
||||
"config.json",
|
||||
"checkpoint_folder_audiogen/*",
|
||||
],
|
||||
# The audio-capable full checkpoint: both weight shards, audio
|
||||
# preprocessor assets, chat template, and remote-code modeling files.
|
||||
"full": [
|
||||
"config.json",
|
||||
f"{_WEIGHT_SOURCE_FOLDER}/*",
|
||||
],
|
||||
}
|
||||
|
||||
XCODEC1_DEFAULT_REPO = "hf-audio/xcodec-hubert-general-balanced"
|
||||
|
||||
_AUDIOGEN_INDEX_FILE = "checkpoint_folder_audiogen/model.safetensors.index.json"
|
||||
|
||||
|
||||
def _download_audiogen_index(model: str) -> str:
|
||||
"""Resolve the audiogen dedup index to a local path (tiny, cache-first).
|
||||
|
||||
Cache-first keeps warm-cache boots offline-safe and avoids a Hub
|
||||
metadata round trip per stage-engine boot; a cache miss falls back to
|
||||
the network.
|
||||
"""
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
try:
|
||||
return hf_hub_download(model, _AUDIOGEN_INDEX_FILE, local_files_only=True)
|
||||
except Exception:
|
||||
return hf_hub_download(model, _AUDIOGEN_INDEX_FILE)
|
||||
|
||||
|
||||
def _dedup_shard_patterns(model: str) -> list[str]:
|
||||
"""Shards under the full checkpoint referenced by the audiogen index.
|
||||
|
||||
The audiogen folder deduplicates its LM weights into
|
||||
``checkpoint_folder_full`` shards (1 of 2 on the 2B, a subset of 14 on
|
||||
the 30B-A3B); the ``tts``/``tta`` profiles must download exactly those.
|
||||
Falls back to every full-checkpoint shard when the index is unreadable.
|
||||
"""
|
||||
try:
|
||||
with open(_download_audiogen_index(model)) as f:
|
||||
weight_map = json.load(f).get("weight_map", {})
|
||||
shards = sorted(set(weight_map.values()))
|
||||
if shards:
|
||||
return [f"{_WEIGHT_SOURCE_FOLDER}/{shard}" for shard in shards]
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not resolve the audiogen dedup shards for %s from %s (%s); downloading all full-checkpoint shards",
|
||||
model,
|
||||
_AUDIOGEN_INDEX_FILE,
|
||||
exc,
|
||||
)
|
||||
return [f"{_WEIGHT_SOURCE_FOLDER}/model-*.safetensors"]
|
||||
|
||||
|
||||
def ensure_audex_snapshot(model: str, profile: str = "tts") -> str:
|
||||
"""Resolve ``model`` to a local repo-root directory, downloading if needed.
|
||||
|
||||
Local paths pass through untouched. For an HF repo id, download (or reuse
|
||||
from cache) exactly the subset the requesting stage's ``profile`` needs,
|
||||
so per-stage ``model_subdir`` joins land on a real snapshot instead of
|
||||
the repo-id string on a fresh cache. Falls back to a cached snapshot
|
||||
when offline.
|
||||
"""
|
||||
if os.path.isdir(model):
|
||||
return model
|
||||
|
||||
patterns = _SNAPSHOT_PROFILE_PATTERNS.get(profile)
|
||||
if patterns is None:
|
||||
raise ValueError(
|
||||
f"Unknown Audex snapshot profile {profile!r}; expected one of {sorted(_SNAPSHOT_PROFILE_PATTERNS)}"
|
||||
)
|
||||
|
||||
patterns = list(patterns)
|
||||
if profile in ("tts", "tta"):
|
||||
patterns += _dedup_shard_patterns(model)
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
try:
|
||||
return snapshot_download(model, allow_patterns=patterns)
|
||||
except Exception as download_exc:
|
||||
try:
|
||||
return snapshot_download(model, allow_patterns=patterns, local_files_only=True)
|
||||
except Exception:
|
||||
raise RuntimeError(
|
||||
f"Could not resolve the Audex repo {model!r} (profile {profile!r}): the download "
|
||||
"failed and no cached snapshot with the required folders was found."
|
||||
) from download_exc
|
||||
|
||||
|
||||
def ensure_xcodec1_snapshot(model: str | None) -> str:
|
||||
"""Resolve the external XCodec1 checkpoint (TTA waveform decoder).
|
||||
|
||||
Accepts a local directory or an HF repo id; ``None``/empty falls back to
|
||||
the official ``hf-audio/xcodec-hubert-general-balanced``.
|
||||
"""
|
||||
model = model or XCODEC1_DEFAULT_REPO
|
||||
if os.path.isdir(model):
|
||||
return model
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
try:
|
||||
return snapshot_download(model)
|
||||
except Exception as download_exc:
|
||||
try:
|
||||
return snapshot_download(model, local_files_only=True)
|
||||
except Exception:
|
||||
raise RuntimeError(
|
||||
f"Could not resolve the XCodec1 checkpoint {model!r}: the download failed and "
|
||||
"no cached snapshot was found. Download it manually or point the TTA deploy "
|
||||
"yaml's stage-1 model at a local copy."
|
||||
) from download_exc
|
||||
|
||||
|
||||
def ensure_audiogen_weights(model_dir: str) -> None:
|
||||
"""Link index-referenced shards missing from ``model_dir`` from the full checkpoint.
|
||||
|
||||
No-op when the shards are already present (or the layout is unexpected);
|
||||
raises only if a referenced shard exists nowhere.
|
||||
"""
|
||||
index_path = os.path.join(model_dir, "model.safetensors.index.json")
|
||||
if not os.path.isfile(index_path):
|
||||
return
|
||||
try:
|
||||
with open(index_path) as f:
|
||||
weight_map = json.load(f).get("weight_map", {})
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("Could not read %s: %s", index_path, exc)
|
||||
return
|
||||
|
||||
source_dir = os.path.join(os.path.dirname(os.path.abspath(model_dir)), _WEIGHT_SOURCE_FOLDER)
|
||||
for shard in sorted(set(weight_map.values())):
|
||||
dst = os.path.join(model_dir, shard)
|
||||
if os.path.exists(dst):
|
||||
continue
|
||||
src = os.path.join(source_dir, shard)
|
||||
if not os.path.exists(src):
|
||||
raise FileNotFoundError(
|
||||
f"Audex checkpoint shard {shard!r} is missing from {model_dir} and "
|
||||
f"{source_dir}. Download the repo's {_WEIGHT_SOURCE_FOLDER}/ folder "
|
||||
"(it holds the deduplicated weight shards)."
|
||||
)
|
||||
try:
|
||||
os.symlink(os.path.relpath(src, model_dir), dst)
|
||||
logger.info("Linked Audex weight shard %s -> %s", dst, src)
|
||||
except OSError:
|
||||
shutil.copy2(src, dst)
|
||||
logger.info("Copied Audex weight shard %s -> %s", src, dst)
|
||||
@@ -0,0 +1,195 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Audex (Nemotron-Labs-Audex-2B) TTS pipeline topology.
|
||||
|
||||
Stage 0: Thinker — ChatML text prompt → <speechcodec_N> tokens (LLM AR).
|
||||
Stage 1: Code2Wav — streaming causal speech decoder → 16 kHz waveform.
|
||||
|
||||
Users pass the HF repo ROOT (its manifest config.json carries
|
||||
``model_type: nemotron_labs_audex``); per-stage checkpoints resolve through
|
||||
``model_subdir``/``tokenizer_subdir``. The decoder subfolder has no tokenizer
|
||||
files, so stage 1's tokenizer also points at the thinker checkpoint.
|
||||
"""
|
||||
|
||||
from vllm_omni.config.stage_config import (
|
||||
PipelineConfig,
|
||||
StageExecutionType,
|
||||
StagePipelineConfig,
|
||||
)
|
||||
|
||||
_PROC = "vllm_omni.model_executor.stage_input_processors.audex"
|
||||
|
||||
# <speechgen_end> token id in checkpoint_folder_audiogen's tokenizer; pinned
|
||||
# by a unit test against the real tokenizer fixture.
|
||||
AUDEX_SPEECHGEN_END_TOKEN_ID = 131076
|
||||
|
||||
# <audiogen_end> stops TTA generation (pinned in tests via models/audex/tta.py).
|
||||
AUDEX_AUDIOGEN_END_TOKEN_ID = 131074
|
||||
|
||||
# No pipeline-level model_arch: stage-0 classes resolve from each
|
||||
# checkpoint's own ``architectures`` (dense on the 2B, NemotronH on the
|
||||
# 30B-A3B); forcing one name via hf_overrides would misbind the other
|
||||
# size. Decoder stages keep their explicit per-stage model_arch.
|
||||
AUDEX_TTS_PIPELINE = PipelineConfig(
|
||||
model_type="audex_tts",
|
||||
default_deploy_config_name="audex_tts.yaml",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="audex_thinker",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(),
|
||||
owns_tokenizer=True,
|
||||
engine_output_type="latent",
|
||||
model_subdir="checkpoint_folder_audiogen",
|
||||
tokenizer_subdir="checkpoint_folder_audiogen",
|
||||
# Emits the unconditional CFG companion for cfg_scale > 1.0
|
||||
# requests; a no-op for unguided requests.
|
||||
prompt_expand_func=f"{_PROC}.expand_cfg_prompts",
|
||||
async_chunk_process_next_stage_input_func=(f"{_PROC}.thinker2code2wav_async_chunk"),
|
||||
custom_process_next_stage_input_func=f"{_PROC}.thinker2code2wav_full_payload",
|
||||
sampling_constraints={
|
||||
"detokenize": False,
|
||||
"stop_token_ids": [AUDEX_SPEECHGEN_END_TOKEN_ID],
|
||||
},
|
||||
),
|
||||
StagePipelineConfig(
|
||||
stage_id=1,
|
||||
model_stage="audex_code2wav",
|
||||
execution_type=StageExecutionType.LLM_GENERATION,
|
||||
input_sources=(0,),
|
||||
final_output=True,
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
model_arch="AudexCode2Wav",
|
||||
model_subdir="audex_causal_speech_decoder",
|
||||
tokenizer_subdir="checkpoint_folder_audiogen",
|
||||
sync_process_input_func=f"{_PROC}.thinker2code2wav_token_only",
|
||||
sampling_constraints={"detokenize": True},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Text-to-audio (caption → general audio): same audiogen thinker checkpoint,
|
||||
# but the output rides the interleaved 4-codebook <audiocodec_N> RVQ block
|
||||
# and decodes through the EXTERNAL XCodec1 checkpoint. XCodec1 is a CNN codec
|
||||
# decoded over the full sequence, so this pipeline is sync full-payload only
|
||||
# (deploy yaml sets async_chunk: false). CFG is effectively mandatory for TTA
|
||||
# quality (official default scale 3.0).
|
||||
# Cascaded speech-to-speech: the audio-capable full checkpoint serves all
|
||||
# three official passes (ASR → chat → TTS) in one deployment; only the TTS
|
||||
# pass (output modality "audio", final_stage_id 1) traverses the streaming
|
||||
# speech decoder — text-modality passes finish at stage 0 and never reach
|
||||
# the codec path. The cascade itself is orchestrated by the client/example.
|
||||
AUDEX_S2S_PIPELINE = PipelineConfig(
|
||||
model_type="audex_s2s",
|
||||
default_deploy_config_name="audex_s2s.yaml",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="audex_omni",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(),
|
||||
final_output=True,
|
||||
final_output_type="text",
|
||||
owns_tokenizer=True,
|
||||
requires_multimodal_data=True,
|
||||
engine_output_type="latent",
|
||||
model_subdir="checkpoint_folder_full",
|
||||
tokenizer_subdir="checkpoint_folder_full",
|
||||
prompt_expand_func=f"{_PROC}.expand_cfg_prompts",
|
||||
async_chunk_process_next_stage_input_func=(f"{_PROC}.thinker2code2wav_async_chunk"),
|
||||
custom_process_next_stage_input_func=f"{_PROC}.thinker2code2wav_full_payload",
|
||||
# <speechgen_end> additionally stops the TTS pass; text passes
|
||||
# stop at their natural eos first. detokenize stays on for the
|
||||
# text-final passes.
|
||||
sampling_constraints={
|
||||
"detokenize": True,
|
||||
"stop_token_ids": [AUDEX_SPEECHGEN_END_TOKEN_ID],
|
||||
},
|
||||
),
|
||||
StagePipelineConfig(
|
||||
stage_id=1,
|
||||
model_stage="audex_code2wav",
|
||||
execution_type=StageExecutionType.LLM_GENERATION,
|
||||
input_sources=(0,),
|
||||
final_output=True,
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
model_arch="AudexCode2Wav",
|
||||
model_subdir="audex_causal_speech_decoder",
|
||||
# The decoder folder has no tokenizer files. Borrow the audiogen
|
||||
# tokenizer (not checkpoint_folder_full): this stage resolves its
|
||||
# snapshot with the "tts" download profile, which guarantees
|
||||
# checkpoint_folder_audiogen but not the full folder's tokenizer,
|
||||
# so per-stage / clean-cache launches would otherwise fail.
|
||||
tokenizer_subdir="checkpoint_folder_audiogen",
|
||||
sync_process_input_func=f"{_PROC}.thinker2code2wav_token_only",
|
||||
sampling_constraints={"detokenize": True},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Audio understanding (ASR / audio QA): single-stage deployment of the
|
||||
# audio-capable full checkpoint — speech (+ text instruction) in, text out.
|
||||
# Modeled on the ming_flash_omni / qwen2_5_omni thinker-only pipelines.
|
||||
AUDEX_THINKER_ONLY_PIPELINE = PipelineConfig(
|
||||
model_type="audex_thinker_only",
|
||||
default_deploy_config_name="audex_thinker_only.yaml",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="audex_omni",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(),
|
||||
final_output=True,
|
||||
final_output_type="text",
|
||||
owns_tokenizer=True,
|
||||
requires_multimodal_data=True,
|
||||
engine_output_type="text",
|
||||
model_subdir="checkpoint_folder_full",
|
||||
tokenizer_subdir="checkpoint_folder_full",
|
||||
sampling_constraints={"detokenize": True},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AUDEX_TTA_PIPELINE = PipelineConfig(
|
||||
model_type="audex_tta",
|
||||
default_deploy_config_name="audex_tta.yaml",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="audex_tta_thinker",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(),
|
||||
owns_tokenizer=True,
|
||||
engine_output_type="latent",
|
||||
model_subdir="checkpoint_folder_audiogen",
|
||||
tokenizer_subdir="checkpoint_folder_audiogen",
|
||||
prompt_expand_func=f"{_PROC}.expand_cfg_prompts",
|
||||
custom_process_next_stage_input_func=f"{_PROC}.thinker2xcodec_full_payload",
|
||||
sampling_constraints={
|
||||
"detokenize": False,
|
||||
"stop_token_ids": [AUDEX_AUDIOGEN_END_TOKEN_ID],
|
||||
},
|
||||
),
|
||||
StagePipelineConfig(
|
||||
stage_id=1,
|
||||
model_stage="audex_xcodec",
|
||||
execution_type=StageExecutionType.LLM_GENERATION,
|
||||
input_sources=(0,),
|
||||
final_output=True,
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
model_arch="AudexXCodec1",
|
||||
# Length placeholder for the stage-1 request; the bulk codec
|
||||
# payload ships via thinker2xcodec_full_payload.
|
||||
sync_process_input_func=f"{_PROC}.thinker2code2wav_token_only",
|
||||
sampling_constraints={"detokenize": True},
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Audex TTS/TTA prompt construction.
|
||||
|
||||
The Audex thinker consumes the exact ChatML prompt used by the official
|
||||
inference script (``inference_scripts_vllm/audiogen_scripts/run_audio_gen_vllm.py``
|
||||
in the nvidia/Nemotron-Labs-Audex-2B repo). The checkpoint's bundled chat
|
||||
template opens a thinking block (``<think>\\n``) instead of the closed
|
||||
``<think></think>`` + ``<speechgen_start>`` priming that TTS generation
|
||||
requires, so the prompt is built from a literal template here; a unit test
|
||||
pins it byte-for-byte against the official format.
|
||||
|
||||
``build_null_prompt`` is the unconditional-prompt counterpart used by
|
||||
classifier-free guidance: the transcription is replaced with repeated
|
||||
``<unk>`` tokens, iteratively adjusted so the tokenized null prompt is
|
||||
exactly as long as the conditional prompt (the CFG pair must decode the
|
||||
same positions).
|
||||
"""
|
||||
|
||||
AUDEX_SYSTEM_PROMPT = "You are a helpful and harmless assistant.\n\nYou are not allowed to use any tools."
|
||||
|
||||
# ``{text}`` carries its own leading whitespace: the conditional prompt uses
|
||||
# " {transcription}" (matching the official script byte-for-byte), while the
|
||||
# CFG null prompt inserts the bare ``<unk>`` run. ``<unk>`` is a special
|
||||
# token, so a leading space would tokenize as a standalone space token and
|
||||
# make some conditional lengths unreachable by whole-``<unk>`` steps.
|
||||
_TTS_PROMPT_TEMPLATE = (
|
||||
"<|im_start|>system\n{system_prompt}<|im_end|>\n"
|
||||
"<|im_start|>user\n<|text to speech|> Generate speech for this transcription.{text}<|im_end|>\n"
|
||||
"<|im_start|>assistant\n<think></think><speechgen_start>"
|
||||
)
|
||||
|
||||
_TTA_PROMPT_TEMPLATE = (
|
||||
"<|im_start|>system\n{system_prompt}<|im_end|>\n"
|
||||
"<|im_start|>user\n<|text to audio|> Generate audio for this caption.{text}<|im_end|>\n"
|
||||
"<|im_start|>assistant\n<think></think><audiogen_start>"
|
||||
)
|
||||
|
||||
|
||||
def build_cond_prompt(text: str) -> str:
|
||||
"""Build the conditional TTS prompt for one transcription."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("Audex TTS requires non-empty input text")
|
||||
return _TTS_PROMPT_TEMPLATE.format(system_prompt=AUDEX_SYSTEM_PROMPT, text=f" {text}")
|
||||
|
||||
|
||||
def build_tta_cond_prompt(caption: str) -> str:
|
||||
"""Build the conditional TTA prompt for one audio caption."""
|
||||
caption = caption.strip()
|
||||
if not caption:
|
||||
raise ValueError("Audex TTA requires a non-empty caption")
|
||||
return _TTA_PROMPT_TEMPLATE.format(system_prompt=AUDEX_SYSTEM_PROMPT, text=f" {caption}")
|
||||
|
||||
|
||||
def _length_matched_null(cond_prompt: str, tokenizer, template: str, max_iters: int) -> str:
|
||||
target_len = len(tokenizer.encode(cond_prompt))
|
||||
n_unk = 1
|
||||
for _ in range(max_iters):
|
||||
prompt = template.format(system_prompt=AUDEX_SYSTEM_PROMPT, text="<unk>" * n_unk)
|
||||
diff = target_len - len(tokenizer.encode(prompt))
|
||||
if diff == 0:
|
||||
return prompt
|
||||
n_unk = max(1, n_unk + diff)
|
||||
if diff < 0 and n_unk == 1:
|
||||
break
|
||||
raise ValueError(f"Could not length-match the CFG null prompt to {target_len} tokens")
|
||||
|
||||
|
||||
def build_null_prompt(cond_prompt: str, tokenizer, max_iters: int = 64) -> str:
|
||||
"""Unconditional TTS prompt for CFG (length-matched ``<unk>`` padding).
|
||||
|
||||
Replaces the transcription with ``<unk>`` repeated, adjusting the count
|
||||
until ``tokenizer.encode`` yields exactly the conditional prompt's
|
||||
length. Raises when no count matches so callers can fail the request
|
||||
instead of submitting a misaligned pair.
|
||||
"""
|
||||
return _length_matched_null(cond_prompt, tokenizer, _TTS_PROMPT_TEMPLATE, max_iters)
|
||||
|
||||
|
||||
def build_tta_null_prompt(cond_prompt: str, tokenizer, max_iters: int = 64) -> str:
|
||||
"""Unconditional TTA prompt for CFG (length-matched ``<unk>`` padding)."""
|
||||
return _length_matched_null(cond_prompt, tokenizer, _TTA_PROMPT_TEMPLATE, max_iters)
|
||||
@@ -0,0 +1,22 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
|
||||
"""Vendored Audex causal speech decoder (Apache-2.0).
|
||||
|
||||
Verbatim copy of the ``audex_causal_speech_decoder`` remote-code package from
|
||||
nvidia/Nemotron-Labs-Audex-2B. Vendored because transformers' dynamic module
|
||||
loader resolves HuggingFace-cache blob symlinks to their real paths, which
|
||||
breaks the package's relative imports when loading with trust_remote_code
|
||||
directly from a hub snapshot.
|
||||
"""
|
||||
|
||||
from .configuration_audex_causal_speech_decoder import AudexCausalSpeechDecoderConfig
|
||||
from .modeling_audex_causal_speech_decoder import (
|
||||
AudexCausalSpeechDecoderModel,
|
||||
AudexCausalSpeechDecoderSession,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AudexCausalSpeechDecoderConfig",
|
||||
"AudexCausalSpeechDecoderModel",
|
||||
"AudexCausalSpeechDecoderSession",
|
||||
]
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
from transformers import PretrainedConfig
|
||||
|
||||
|
||||
class AudexCausalSpeechDecoderConfig(PretrainedConfig):
|
||||
model_type = "audex_causal_speech_decoder"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hidden_dim: int = 2048,
|
||||
depth: int = 12,
|
||||
heads: int = 32,
|
||||
pos_meb_dim: int = 64,
|
||||
hop_length: int = 320,
|
||||
vq_dim: int = 2048,
|
||||
lookahead_steps: int = 4,
|
||||
sample_rate: int = 16000,
|
||||
codebook_levels: list[int] | None = None,
|
||||
codebook_size: int = 65536,
|
||||
token_embed_dim: int = 8,
|
||||
embed_tokens_from_codes: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.hidden_dim = hidden_dim
|
||||
self.depth = depth
|
||||
self.heads = heads
|
||||
self.pos_meb_dim = pos_meb_dim
|
||||
self.hop_length = hop_length
|
||||
self.vq_dim = vq_dim
|
||||
self.lookahead_steps = lookahead_steps
|
||||
self.sample_rate = sample_rate
|
||||
self.codebook_levels = codebook_levels or [4, 4, 4, 4, 4, 4, 4, 4]
|
||||
self.codebook_size = codebook_size
|
||||
self.token_embed_dim = token_embed_dim
|
||||
self.embed_tokens_from_codes = embed_tokens_from_codes
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
from transformers import PreTrainedModel
|
||||
|
||||
from .configuration_audex_causal_speech_decoder import AudexCausalSpeechDecoderConfig
|
||||
from .streaming_utils import load_audex_causal_speech_decoder as _load_decoder_for_remote_code
|
||||
|
||||
REMOTE_CODE_IMPORTS = (_load_decoder_for_remote_code,)
|
||||
|
||||
|
||||
class RotaryPositionalEmbeddings(nn.Module):
|
||||
def __init__(self, dim: int, max_seq_len: int = 4096, base: int = 10_000) -> None:
|
||||
super().__init__()
|
||||
self.dim = dim
|
||||
self.base = base
|
||||
self.max_seq_len = max_seq_len
|
||||
self.rope_init()
|
||||
self._rope_ready = False
|
||||
|
||||
def rope_init(self, device: torch.device | None = None) -> None:
|
||||
theta = 1.0 / (self.base ** (torch.arange(0, self.dim, 2, device=device)[: (self.dim // 2)].float() / self.dim))
|
||||
self.register_buffer("theta", theta, persistent=False)
|
||||
self.build_rope_cache(self.max_seq_len)
|
||||
|
||||
def build_rope_cache(self, max_seq_len: int = 4096) -> None:
|
||||
self.max_seq_len = max_seq_len
|
||||
seq_idx = torch.arange(max_seq_len, dtype=self.theta.dtype, device=self.theta.device)
|
||||
idx_theta = torch.einsum("i, j -> ij", seq_idx, self.theta).float()
|
||||
cache = torch.stack([torch.cos(idx_theta), torch.sin(idx_theta)], dim=-1)
|
||||
self.register_buffer("cache", cache, persistent=False)
|
||||
|
||||
def forward(self, x: torch.Tensor, *, input_pos: torch.Tensor | None = None) -> torch.Tensor:
|
||||
seq_len = x.size(1)
|
||||
needed_seq_len = seq_len if input_pos is None else int(input_pos.max().item()) + 1
|
||||
if (
|
||||
not getattr(self, "_rope_ready", False)
|
||||
or self.theta.device != x.device
|
||||
or needed_seq_len > self.cache.size(0)
|
||||
):
|
||||
self.rope_init(device=x.device)
|
||||
if needed_seq_len > self.cache.size(0):
|
||||
self.build_rope_cache(max(needed_seq_len, self.cache.size(0) * 2))
|
||||
self._rope_ready = True
|
||||
|
||||
rope_cache = self.cache[:seq_len] if input_pos is None else self.cache[input_pos]
|
||||
xshaped = x.float().reshape(*x.shape[:-1], -1, 2)
|
||||
rope_cache = rope_cache.view(-1, xshaped.size(1), 1, xshaped.size(3), 2)
|
||||
x_out = torch.stack(
|
||||
[
|
||||
xshaped[..., 0] * rope_cache[..., 0] - xshaped[..., 1] * rope_cache[..., 1],
|
||||
xshaped[..., 1] * rope_cache[..., 0] + xshaped[..., 0] * rope_cache[..., 1],
|
||||
],
|
||||
-1,
|
||||
)
|
||||
return x_out.flatten(3).type_as(x)
|
||||
|
||||
|
||||
class CausalCodecDecoderCache:
|
||||
def __init__(self) -> None:
|
||||
self.key_values: dict[int, tuple[Tensor, Tensor]] = {}
|
||||
self.position = 0
|
||||
|
||||
def input_positions(self, length: int, device: torch.device) -> Tensor:
|
||||
return torch.arange(self.position, self.position + length, device=device).unsqueeze(0)
|
||||
|
||||
def update(self, layer_idx: int, key: Tensor, value: Tensor) -> tuple[Tensor, Tensor]:
|
||||
if layer_idx in self.key_values:
|
||||
prev_key, prev_value = self.key_values[layer_idx]
|
||||
key = torch.cat([prev_key, key], dim=2)
|
||||
value = torch.cat([prev_value, value], dim=2)
|
||||
self.key_values[layer_idx] = (key, value)
|
||||
return key, value
|
||||
|
||||
def advance(self, length: int) -> None:
|
||||
self.position += length
|
||||
|
||||
def reset(self) -> None:
|
||||
self.key_values.clear()
|
||||
self.position = 0
|
||||
|
||||
|
||||
class RMSNorm(nn.Module):
|
||||
def __init__(self, dim: int, eps: float = 1e-6):
|
||||
super().__init__()
|
||||
self.eps = eps
|
||||
self.weight = nn.Parameter(torch.ones(dim))
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
norm_x = torch.mean(x**2, dim=-1, keepdim=True)
|
||||
return x * torch.rsqrt(norm_x + self.eps) * self.weight
|
||||
|
||||
|
||||
class MLP(nn.Module):
|
||||
def __init__(self, dim: int) -> None:
|
||||
super().__init__()
|
||||
self.fc1 = nn.Linear(dim, 4 * dim, bias=False)
|
||||
self.silu = nn.SiLU()
|
||||
self.fc2 = nn.Linear(4 * dim, dim, bias=False)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return self.fc2(self.silu(self.fc1(x)))
|
||||
|
||||
|
||||
class Attention(nn.Module):
|
||||
def __init__(self, dim: int, n_heads: int, rotary_embed: RotaryPositionalEmbeddings, layer_idx: int):
|
||||
super().__init__()
|
||||
if dim % n_heads != 0:
|
||||
raise ValueError(f"dim must be divisible by n_heads, got dim={dim}, n_heads={n_heads}")
|
||||
self.n_heads = n_heads
|
||||
self.layer_idx = layer_idx
|
||||
self.rotary_embed = rotary_embed
|
||||
self.c_attn = nn.Linear(dim, 3 * dim, bias=False)
|
||||
self.c_proj = nn.Linear(dim, dim, bias=False)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
cache: CausalCodecDecoderCache | None = None,
|
||||
input_pos: Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
batch_size, seq_len, _ = x.shape
|
||||
qkv = self.c_attn(x)
|
||||
head_dim = qkv.size(-1) // (3 * self.n_heads)
|
||||
qkv = qkv.view(batch_size, seq_len, 3, self.n_heads, head_dim).permute(2, 0, 3, 1, 4)
|
||||
q, k, v = qkv.unbind(0)
|
||||
|
||||
q = self.rotary_embed(q.transpose(1, 2), input_pos=input_pos).transpose(1, 2)
|
||||
k = self.rotary_embed(k.transpose(1, 2), input_pos=input_pos).transpose(1, 2)
|
||||
if cache is None:
|
||||
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
|
||||
else:
|
||||
if input_pos is None:
|
||||
raise ValueError("input_pos is required when cache is set")
|
||||
k, v = cache.update(self.layer_idx, k, v)
|
||||
key_pos = torch.arange(k.size(2), device=x.device).view(1, 1, 1, -1)
|
||||
attn_mask = key_pos <= input_pos.view(input_pos.size(0), 1, -1, 1)
|
||||
y = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask)
|
||||
return self.c_proj(y.transpose(1, 2).contiguous().view(batch_size, seq_len, -1))
|
||||
|
||||
|
||||
class TransformerBlock(nn.Module):
|
||||
def __init__(self, dim: int, n_heads: int, rotary_embed: RotaryPositionalEmbeddings, layer_idx: int):
|
||||
super().__init__()
|
||||
self.att_norm = RMSNorm(dim)
|
||||
self.ffn_norm = RMSNorm(dim)
|
||||
self.att = Attention(dim=dim, n_heads=n_heads, rotary_embed=rotary_embed, layer_idx=layer_idx)
|
||||
self.mlp = MLP(dim=dim)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
cache: CausalCodecDecoderCache | None = None,
|
||||
input_pos: Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
x = x + self.att(self.att_norm(x), cache=cache, input_pos=input_pos)
|
||||
return x + self.mlp(self.ffn_norm(x))
|
||||
|
||||
|
||||
class PatchHead(nn.Module):
|
||||
def __init__(self, dim: int, hop_length: int = 320):
|
||||
super().__init__()
|
||||
self.proj = nn.Linear(dim, hop_length, bias=False)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
x = torch.tanh(self.proj(x))
|
||||
return x.reshape(x.size(0), 1, -1)
|
||||
|
||||
|
||||
class CausalVocosBackbone(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
hidden_dim: int = 2048,
|
||||
depth: int = 12,
|
||||
heads: int = 32,
|
||||
pos_meb_dim: int = 64,
|
||||
):
|
||||
super().__init__()
|
||||
rotary_embed = RotaryPositionalEmbeddings(dim=pos_meb_dim)
|
||||
self.transformers = nn.ModuleList(
|
||||
[
|
||||
TransformerBlock(dim=hidden_dim, n_heads=heads, rotary_embed=rotary_embed, layer_idx=idx)
|
||||
for idx in range(depth)
|
||||
]
|
||||
)
|
||||
self.final_layer_norm = RMSNorm(hidden_dim)
|
||||
|
||||
def forward(self, x: torch.Tensor, cache: CausalCodecDecoderCache | None = None) -> torch.Tensor:
|
||||
input_pos = None
|
||||
if cache is not None:
|
||||
input_pos = cache.input_positions(x.size(1), x.device).expand(x.size(0), -1)
|
||||
for block in self.transformers:
|
||||
x = block(x, cache=cache, input_pos=input_pos)
|
||||
if cache is not None:
|
||||
cache.advance(x.size(1))
|
||||
return self.final_layer_norm(x)
|
||||
|
||||
|
||||
class CausalCodecDecoderVocos(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
hidden_dim: int = 2048,
|
||||
depth: int = 12,
|
||||
heads: int = 32,
|
||||
pos_meb_dim: int = 64,
|
||||
hop_length: int = 320,
|
||||
vq_dim: int = 2048,
|
||||
lookahead_steps: int = 0,
|
||||
):
|
||||
super().__init__()
|
||||
if lookahead_steps < 0:
|
||||
raise ValueError(f"lookahead_steps must be >= 0, got {lookahead_steps}")
|
||||
|
||||
self.wav_proj = nn.Linear(hop_length, hidden_dim, bias=False)
|
||||
self.fc_post_a = nn.Linear(vq_dim, hidden_dim, bias=False)
|
||||
self.lookahead_steps = lookahead_steps
|
||||
if lookahead_steps > 0:
|
||||
self.lookahead_conv = nn.Conv1d(
|
||||
hidden_dim,
|
||||
hidden_dim,
|
||||
kernel_size=lookahead_steps + 1,
|
||||
padding=0,
|
||||
groups=hidden_dim,
|
||||
bias=False,
|
||||
)
|
||||
self.lookahead_act = nn.SiLU()
|
||||
self.lookahead_proj = nn.Conv1d(hidden_dim, hidden_dim, kernel_size=1, bias=False)
|
||||
nn.init.zeros_(self.lookahead_proj.weight)
|
||||
else:
|
||||
self.lookahead_conv = None
|
||||
self.lookahead_act = None
|
||||
self.lookahead_proj = None
|
||||
self.backbone = CausalVocosBackbone(hidden_dim, depth, heads, pos_meb_dim)
|
||||
self.head = PatchHead(hidden_dim, hop_length)
|
||||
|
||||
def _project_tokens(self, vq_emb: torch.Tensor) -> torch.Tensor:
|
||||
return self.fc_post_a(vq_emb)
|
||||
|
||||
def _apply_lookahead(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if self.lookahead_conv is None:
|
||||
return x
|
||||
if self.lookahead_act is None or self.lookahead_proj is None:
|
||||
raise RuntimeError("lookahead modules are not initialized")
|
||||
h = F.pad(x.transpose(1, 2), (0, self.lookahead_steps))
|
||||
h = self.lookahead_proj(self.lookahead_act(self.lookahead_conv(h)))
|
||||
return x + h.transpose(1, 2)
|
||||
|
||||
def _apply_lookahead_window(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if self.lookahead_conv is None:
|
||||
return x
|
||||
if self.lookahead_act is None or self.lookahead_proj is None:
|
||||
raise RuntimeError("lookahead modules are not initialized")
|
||||
if x.size(1) <= self.lookahead_steps:
|
||||
raise ValueError(f"lookahead window needs more than {self.lookahead_steps} frames, got {x.size(1)}")
|
||||
h = self.lookahead_proj(self.lookahead_act(self.lookahead_conv(x.transpose(1, 2))))
|
||||
return x[:, : h.size(2)] + h.transpose(1, 2)
|
||||
|
||||
def decode_cached(
|
||||
self,
|
||||
vq_emb: torch.Tensor,
|
||||
cache: CausalCodecDecoderCache,
|
||||
lookahead_vq_emb: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
x = self._project_tokens(vq_emb)
|
||||
if self.lookahead_steps > 0:
|
||||
if lookahead_vq_emb is None:
|
||||
lookahead_vq_emb = vq_emb.new_zeros(vq_emb.size(0), self.lookahead_steps, vq_emb.size(-1))
|
||||
if lookahead_vq_emb.size(1) != self.lookahead_steps:
|
||||
raise ValueError(
|
||||
f"lookahead_vq_emb must have {self.lookahead_steps} frames, got {lookahead_vq_emb.size(1)}"
|
||||
)
|
||||
lookahead_x = self._project_tokens(lookahead_vq_emb)
|
||||
x = self._apply_lookahead_window(torch.cat([x, lookahead_x], dim=1))
|
||||
x = self.backbone(x, cache=cache)
|
||||
return self.head(x)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
vq_emb: torch.Tensor,
|
||||
patched_wav: torch.Tensor | None = None,
|
||||
alpha: float = 0.0,
|
||||
) -> torch.Tensor:
|
||||
x = self._project_tokens(vq_emb)
|
||||
x = self._apply_lookahead(x)
|
||||
if patched_wav is not None:
|
||||
h = self.wav_proj(patched_wav)
|
||||
mask = torch.bernoulli(
|
||||
torch.full(
|
||||
(x.size(0), x.size(1), 1),
|
||||
min(max(alpha, 0.0), 1.0),
|
||||
device=x.device,
|
||||
dtype=x.dtype,
|
||||
)
|
||||
)
|
||||
x = x + h * mask
|
||||
return self.head(self.backbone(x))
|
||||
|
||||
|
||||
class AudexSpeechTokenEmbedder(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
output_dim: int,
|
||||
token_embed_dim: int,
|
||||
codebook_levels: Sequence[int],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
if len(codebook_levels) != token_embed_dim:
|
||||
raise ValueError(
|
||||
f"token_embed_dim={token_embed_dim} must match codebook_levels length={len(codebook_levels)}"
|
||||
)
|
||||
self.codebook_levels = tuple(int(level) for level in codebook_levels)
|
||||
self.project_out = nn.Linear(token_embed_dim, output_dim)
|
||||
|
||||
def forward(self, indices: torch.Tensor) -> torch.Tensor:
|
||||
if indices.size(-1) != 1:
|
||||
raise ValueError(f"indices last dimension must be 1, got {indices.size(-1)}")
|
||||
levels = torch.tensor(self.codebook_levels, dtype=torch.long, device=indices.device)
|
||||
basis = torch.cumprod(torch.cat([levels.new_ones(1), levels[:-1]]), dim=0)
|
||||
level_indices = (indices.long() // basis) % levels
|
||||
dtype = self.project_out.weight.dtype
|
||||
codes = level_indices.to(dtype=dtype)
|
||||
levels = levels.to(dtype=dtype)
|
||||
codes = codes * (2.0 / (levels - 1.0)) - 1.0
|
||||
return self.project_out(codes)
|
||||
|
||||
def get_output_from_indices(self, indices: torch.Tensor) -> torch.Tensor:
|
||||
return self(indices)
|
||||
|
||||
|
||||
class AudexCausalSpeechDecoderModel(PreTrainedModel):
|
||||
config_class = AudexCausalSpeechDecoderConfig
|
||||
base_model_prefix = "module"
|
||||
all_tied_weights_keys: dict[str, Any] = {}
|
||||
Cache = CausalCodecDecoderCache
|
||||
|
||||
def __init__(self, config: AudexCausalSpeechDecoderConfig):
|
||||
super().__init__(config)
|
||||
self.audex_speech_token_embedder = AudexSpeechTokenEmbedder(
|
||||
output_dim=config.vq_dim,
|
||||
token_embed_dim=config.token_embed_dim,
|
||||
codebook_levels=config.codebook_levels,
|
||||
)
|
||||
self.module = CausalCodecDecoderVocos(
|
||||
hidden_dim=config.hidden_dim,
|
||||
depth=config.depth,
|
||||
heads=config.heads,
|
||||
pos_meb_dim=config.pos_meb_dim,
|
||||
hop_length=config.hop_length,
|
||||
vq_dim=config.vq_dim,
|
||||
lookahead_steps=config.lookahead_steps,
|
||||
)
|
||||
|
||||
@property
|
||||
def lookahead_steps(self) -> int:
|
||||
return self.module.lookahead_steps
|
||||
|
||||
def create_cache(self) -> CausalCodecDecoderCache:
|
||||
return CausalCodecDecoderCache()
|
||||
|
||||
def decode_cached(
|
||||
self,
|
||||
vq_emb: torch.Tensor,
|
||||
cache: CausalCodecDecoderCache,
|
||||
lookahead_vq_emb: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
return self.module.decode_cached(vq_emb, cache, lookahead_vq_emb=lookahead_vq_emb)
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
*,
|
||||
chunk_frames: int = 1,
|
||||
sample_rate: int | None = None,
|
||||
return_numpy: bool = True,
|
||||
) -> AudexCausalSpeechDecoderSession:
|
||||
return AudexCausalSpeechDecoderSession(
|
||||
decoder=self,
|
||||
chunk_frames=chunk_frames,
|
||||
sample_rate=sample_rate or self.config.sample_rate,
|
||||
return_numpy=return_numpy,
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
vq_emb: torch.Tensor,
|
||||
patched_wav: torch.Tensor | None = None,
|
||||
alpha: float = 0.0,
|
||||
) -> torch.Tensor:
|
||||
return self.module(vq_emb, patched_wav=patched_wav, alpha=alpha)
|
||||
|
||||
|
||||
class AudexCausalSpeechDecoderSession:
|
||||
def __init__(
|
||||
self,
|
||||
decoder: AudexCausalSpeechDecoderModel,
|
||||
*,
|
||||
chunk_frames: int,
|
||||
sample_rate: int,
|
||||
return_numpy: bool,
|
||||
):
|
||||
if chunk_frames <= 0:
|
||||
raise ValueError(f"chunk_frames must be positive, got {chunk_frames}")
|
||||
self.decoder = decoder
|
||||
self.chunk_frames = chunk_frames
|
||||
self.sample_rate = sample_rate
|
||||
self.return_numpy = return_numpy
|
||||
self.cache = decoder.create_cache()
|
||||
self.buffer: list[list[int]] = []
|
||||
|
||||
@property
|
||||
def device(self) -> torch.device:
|
||||
return next(self.decoder.parameters()).device
|
||||
|
||||
def reset(self) -> None:
|
||||
self.cache = self.decoder.create_cache()
|
||||
self.buffer.clear()
|
||||
|
||||
def push(self, token_frames: Sequence[Sequence[int]]) -> Iterator[tuple[int, Any]]:
|
||||
self.buffer.extend(list(frame) for frame in token_frames)
|
||||
yield from self._drain(flush=False)
|
||||
|
||||
def flush(self) -> Iterator[tuple[int, Any]]:
|
||||
yield from self._drain(flush=True)
|
||||
|
||||
def _drain(self, *, flush: bool) -> Iterator[tuple[int, Any]]:
|
||||
ready_frames = len(self.buffer) - self.decoder.lookahead_steps
|
||||
while self.buffer and (flush or ready_frames >= self.chunk_frames):
|
||||
emit_frames = min(self.chunk_frames, len(self.buffer)) if flush else self.chunk_frames
|
||||
wav = self._decode_buffered_frames(emit_frames, flush=flush)
|
||||
del self.buffer[:emit_frames]
|
||||
ready_frames = len(self.buffer) - self.decoder.lookahead_steps
|
||||
yield self.sample_rate, self._format_chunk(wav)
|
||||
|
||||
def _embed_speech_token_frames(self, token_frames: Sequence[Sequence[int]]) -> torch.Tensor:
|
||||
indices = torch.tensor(token_frames, dtype=torch.long, device=self.device).unsqueeze(0)
|
||||
return self.decoder.audex_speech_token_embedder.get_output_from_indices(indices)
|
||||
|
||||
def _decode_buffered_frames(self, emit_frames: int, *, flush: bool) -> torch.Tensor:
|
||||
with torch.inference_mode():
|
||||
vq_emb = self._embed_speech_token_frames(self.buffer[:emit_frames])
|
||||
lookahead_vq_emb = None
|
||||
if self.decoder.lookahead_steps > 0:
|
||||
future_frames = self.buffer[emit_frames : emit_frames + self.decoder.lookahead_steps]
|
||||
future_parts = []
|
||||
if future_frames:
|
||||
future_parts.append(self._embed_speech_token_frames(future_frames))
|
||||
missing_frames = self.decoder.lookahead_steps - len(future_frames) if flush else 0
|
||||
if missing_frames > 0:
|
||||
future_parts.append(vq_emb.new_zeros(vq_emb.size(0), missing_frames, vq_emb.size(-1)))
|
||||
lookahead_vq_emb = torch.cat(future_parts, dim=1) if future_parts else None
|
||||
return self.decoder.decode_cached(vq_emb, self.cache, lookahead_vq_emb=lookahead_vq_emb)
|
||||
|
||||
def _format_chunk(self, wav: torch.Tensor) -> Any:
|
||||
chunk = wav.squeeze().float().detach().cpu()
|
||||
if not self.return_numpy:
|
||||
return chunk
|
||||
|
||||
import numpy as np
|
||||
|
||||
return chunk.numpy().astype(np.float32, copy=False)
|
||||
@@ -0,0 +1,140 @@
|
||||
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
from transformers import AutoModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .modeling_audex_causal_speech_decoder import AudexCausalSpeechDecoderModel
|
||||
|
||||
|
||||
def freeze_model(model: torch.nn.Module) -> torch.nn.Module:
|
||||
model.eval()
|
||||
for param in model.parameters():
|
||||
param.requires_grad = False
|
||||
return model
|
||||
|
||||
|
||||
def load_audex_causal_speech_decoder(
|
||||
model_path: str,
|
||||
*,
|
||||
device: torch.device | str = "cuda",
|
||||
dtype: torch.dtype | str | None = None,
|
||||
) -> AudexCausalSpeechDecoderModel:
|
||||
kwargs = {"trust_remote_code": True}
|
||||
if dtype is not None:
|
||||
kwargs["torch_dtype"] = dtype
|
||||
model = AutoModel.from_pretrained(model_path, **kwargs)
|
||||
return freeze_model(model.to(device))
|
||||
|
||||
|
||||
def _decoder_device(decoder: AudexCausalSpeechDecoderModel) -> torch.device:
|
||||
return next(decoder.parameters()).device
|
||||
|
||||
|
||||
def _embed_speech_token_frames(
|
||||
decoder: AudexCausalSpeechDecoderModel,
|
||||
token_frames: Sequence[Sequence[int]],
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
indices = torch.tensor(token_frames, dtype=torch.long, device=device).unsqueeze(0)
|
||||
return decoder.audex_speech_token_embedder.get_output_from_indices(indices)
|
||||
|
||||
|
||||
def decode_speech_token_frames(
|
||||
token_frames: Iterable[Sequence[int]],
|
||||
decoder: AudexCausalSpeechDecoderModel,
|
||||
*,
|
||||
device: torch.device | str | None = None,
|
||||
chunk_frames: int = 1,
|
||||
) -> Iterator[torch.Tensor]:
|
||||
if chunk_frames <= 0:
|
||||
raise ValueError(f"chunk_frames must be positive, got {chunk_frames}")
|
||||
|
||||
device = torch.device(device) if device is not None else _decoder_device(decoder)
|
||||
cache = decoder.create_cache()
|
||||
buffer: list[list[int]] = []
|
||||
lookahead_steps = decoder.lookahead_steps
|
||||
|
||||
for token_frame in token_frames:
|
||||
buffer.append(list(token_frame))
|
||||
while len(buffer) > lookahead_steps:
|
||||
emit_frames = min(chunk_frames, len(buffer) - lookahead_steps)
|
||||
wav = _decode_buffered_frames(decoder, cache, buffer, emit_frames, device, flush=False)
|
||||
del buffer[:emit_frames]
|
||||
yield wav
|
||||
|
||||
while buffer:
|
||||
emit_frames = min(chunk_frames, len(buffer))
|
||||
wav = _decode_buffered_frames(decoder, cache, buffer, emit_frames, device, flush=True)
|
||||
del buffer[:emit_frames]
|
||||
yield wav
|
||||
|
||||
|
||||
async def decode_speech_token_stream(
|
||||
token_stream: AsyncIterable[Sequence[int]],
|
||||
decoder: AudexCausalSpeechDecoderModel,
|
||||
*,
|
||||
device: torch.device | str | None = None,
|
||||
chunk_frames: int = 1,
|
||||
) -> AsyncIterator[torch.Tensor]:
|
||||
if chunk_frames <= 0:
|
||||
raise ValueError(f"chunk_frames must be positive, got {chunk_frames}")
|
||||
|
||||
device = torch.device(device) if device is not None else _decoder_device(decoder)
|
||||
cache = decoder.create_cache()
|
||||
buffer: list[list[int]] = []
|
||||
lookahead_steps = decoder.lookahead_steps
|
||||
|
||||
async for token_frame in token_stream:
|
||||
buffer.append(list(token_frame))
|
||||
while len(buffer) > lookahead_steps:
|
||||
emit_frames = min(chunk_frames, len(buffer) - lookahead_steps)
|
||||
wav = _decode_buffered_frames(decoder, cache, buffer, emit_frames, device, flush=False)
|
||||
del buffer[:emit_frames]
|
||||
yield wav
|
||||
|
||||
while buffer:
|
||||
emit_frames = min(chunk_frames, len(buffer))
|
||||
wav = _decode_buffered_frames(decoder, cache, buffer, emit_frames, device, flush=True)
|
||||
del buffer[:emit_frames]
|
||||
yield wav
|
||||
|
||||
|
||||
def _decode_buffered_frames(
|
||||
decoder: AudexCausalSpeechDecoderModel,
|
||||
cache: Any,
|
||||
buffer: Sequence[Sequence[int]],
|
||||
emit_frames: int,
|
||||
device: torch.device,
|
||||
*,
|
||||
flush: bool,
|
||||
) -> torch.Tensor:
|
||||
with torch.inference_mode():
|
||||
vq_emb = _embed_speech_token_frames(decoder, buffer[:emit_frames], device)
|
||||
lookahead_vq_emb = None
|
||||
if decoder.lookahead_steps > 0:
|
||||
future_frames = buffer[emit_frames : emit_frames + decoder.lookahead_steps]
|
||||
future_parts = []
|
||||
if future_frames:
|
||||
future_parts.append(_embed_speech_token_frames(decoder, future_frames, device))
|
||||
missing_frames = decoder.lookahead_steps - len(future_frames) if flush else 0
|
||||
if missing_frames > 0:
|
||||
future_parts.append(vq_emb.new_zeros(vq_emb.size(0), missing_frames, vq_emb.size(-1)))
|
||||
lookahead_vq_emb = torch.cat(future_parts, dim=1) if future_parts else None
|
||||
return decoder.decode_cached(vq_emb, cache, lookahead_vq_emb=lookahead_vq_emb)
|
||||
@@ -0,0 +1,293 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
#
|
||||
# Adapted from nvidia/Nemotron-Labs-Audex-2B (Apache-2.0):
|
||||
# inference_scripts_vllm/audiogen_scripts/tta_rvq_logits_processor.py
|
||||
# inference_scripts_vllm/audiogen_scripts/run_audio_gen_vllm_rvq_logit_mask.py
|
||||
# Divergences from the official files are commented at the divergence site.
|
||||
"""Audex text-to-audio (TTA) token space: RVQ phase masking and helpers.
|
||||
|
||||
TTA output is an interleaved 4-codebook RVQ stream over ``<audiocodec_N>``
|
||||
tokens: generated position ``p`` (counted from ``<audiogen_start>``) must
|
||||
come from codebook ``p % 4``, whose codec-id range is
|
||||
``[phase * 1024, (phase + 1) * 1024)``. The tokenizer defines 8192
|
||||
``<audiocodec_*>`` symbols; only codec ids 0..4095 (the 4 quantizers XCodec1
|
||||
decodes) are generated — the upper half stays disallowed.
|
||||
|
||||
Unguided/unmasked sampling emits phase-invalid sequences that XCodec1 cannot
|
||||
decode, so :class:`TTARVQPhaseMaskLogitsProcessor` is required for validity,
|
||||
driven per request by ``SamplingParams.extra_args["tta_rvq"]``:
|
||||
|
||||
{
|
||||
"phase_token_ids": [[...1024 ids...] x 4],
|
||||
"start_tid": <audiogen_start id>,
|
||||
"end_tid": <audiogen_end id>,
|
||||
"codec_cap": <max codec tokens or None>,
|
||||
"start_in_prompt": <bool>,
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.sample.logits_processor import (
|
||||
BatchUpdate,
|
||||
LogitsProcessor,
|
||||
MoveDirectionality,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
NEG_INF = float("-inf")
|
||||
XCODEC1_NUM_CODEBOOKS = 4
|
||||
XCODEC1_CODEBOOK_SIZE = 1024
|
||||
|
||||
# Pinned by a unit test against the real checkpoint tokenizer.
|
||||
AUDEX_AUDIOGEN_START_TOKEN_ID = 131073
|
||||
AUDEX_AUDIOGEN_END_TOKEN_ID = 131074
|
||||
AUDEX_AUDIOCODEC_TOKEN_OFFSET = 196613
|
||||
AUDEX_AUDIOCODEC_VOCAB_SIZE = 8192
|
||||
|
||||
|
||||
def build_tta_phase_token_ids(tokenizer: Any) -> tuple[list[list[int]], int, int]:
|
||||
"""Group tokenizer ids of ``<audiocodec_N>`` into the 4 RVQ phases.
|
||||
|
||||
Phase ``p`` collects the tokenizer ids for codec ids
|
||||
``[p*1024, (p+1)*1024)``. Fails loudly on missing markers or an
|
||||
incomplete codec vocab.
|
||||
|
||||
Returns ``(phase_token_ids, audiogen_start_tid, audiogen_end_tid)``.
|
||||
"""
|
||||
vocab = tokenizer.get_vocab()
|
||||
start_tid = vocab.get("<audiogen_start>")
|
||||
end_tid = vocab.get("<audiogen_end>")
|
||||
if start_tid is None or end_tid is None:
|
||||
raise ValueError(f"Missing marker token ids: audiogen_start={start_tid}, audiogen_end={end_tid}")
|
||||
|
||||
phase_token_ids: list[list[int]] = []
|
||||
for phase in range(XCODEC1_NUM_CODEBOOKS):
|
||||
ids: list[int] = []
|
||||
missing: list[int] = []
|
||||
for codec_id in range(phase * XCODEC1_CODEBOOK_SIZE, (phase + 1) * XCODEC1_CODEBOOK_SIZE):
|
||||
tok = vocab.get(f"<audiocodec_{codec_id}>")
|
||||
if tok is None:
|
||||
missing.append(codec_id)
|
||||
else:
|
||||
ids.append(tok)
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Incomplete codec vocab for RVQ phase {phase}: missing {len(missing)} codec ids (e.g. {missing[:5]})"
|
||||
)
|
||||
phase_token_ids.append(ids)
|
||||
|
||||
return phase_token_ids, int(start_tid), int(end_tid)
|
||||
|
||||
|
||||
def validate_rvq_phase(
|
||||
codec_ids: list[int],
|
||||
codebook_size: int = XCODEC1_CODEBOOK_SIZE,
|
||||
num_codebooks: int = XCODEC1_NUM_CODEBOOKS,
|
||||
) -> dict[str, Any]:
|
||||
"""Check that ``codec_ids[i]`` falls in RVQ phase ``i % 4``'s codec range."""
|
||||
mismatch_count = 0
|
||||
first_mismatch = None
|
||||
for i, codec_id in enumerate(codec_ids):
|
||||
expected = i % num_codebooks
|
||||
lo = expected * codebook_size
|
||||
if not (lo <= codec_id < lo + codebook_size):
|
||||
mismatch_count += 1
|
||||
if first_mismatch is None:
|
||||
first_mismatch = [i, int(codec_id), int(codec_id // codebook_size), int(expected)]
|
||||
return {
|
||||
"phase_valid": mismatch_count == 0,
|
||||
"mismatch_count": mismatch_count,
|
||||
"first_mismatch": first_mismatch,
|
||||
"codec_count": len(codec_ids),
|
||||
}
|
||||
|
||||
|
||||
class TTARVQPhaseMaskLogitsProcessor(LogitsProcessor):
|
||||
"""Mask logits to the RVQ-phase-valid token set for Audex TTA requests.
|
||||
|
||||
Per batch row:
|
||||
``_req[idx]`` -> {"start_tid", "end_tid", "codec_cap", "start_in_prompt"}
|
||||
``_output_tokens[idx]`` -> live reference to the request's output ids
|
||||
``_start_pos[idx]`` -> cached index of <audiogen_start> once seen;
|
||||
-1 means the prompt already ended with it.
|
||||
|
||||
The disallow masks are vocab-sized boolean tensors built once on the
|
||||
first ``apply`` (using the real ``logits.shape[-1]`` so vocab padding is
|
||||
also forbidden).
|
||||
"""
|
||||
|
||||
def __init__(self, vllm_config: VllmConfig, device: torch.device, is_pin_memory: bool) -> None:
|
||||
self.device = device
|
||||
self._req: dict[int, dict[str, Any]] = {}
|
||||
self._output_tokens: dict[int, list[int]] = {}
|
||||
self._start_pos: dict[int, int] = {}
|
||||
|
||||
self._phase_token_ids: list[list[int]] | None = None
|
||||
self._mask_end_tid: int | None = None
|
||||
|
||||
self._disallow_phase: torch.Tensor | None = None
|
||||
self._disallow_phase0_end: torch.Tensor | None = None
|
||||
self._disallow_except_end: torch.Tensor | None = None
|
||||
|
||||
def is_argmax_invariant(self) -> bool:
|
||||
return False
|
||||
|
||||
def _reset(self) -> None:
|
||||
self._req.clear()
|
||||
self._output_tokens.clear()
|
||||
self._start_pos.clear()
|
||||
|
||||
def update_state(self, batch_update: BatchUpdate | None) -> None:
|
||||
if batch_update is None:
|
||||
return
|
||||
|
||||
for idx in batch_update.removed:
|
||||
self._req.pop(idx, None)
|
||||
self._output_tokens.pop(idx, None)
|
||||
self._start_pos.pop(idx, None)
|
||||
|
||||
if not self._req and batch_update.added:
|
||||
self._reset()
|
||||
|
||||
for idx, params, _, output_token_ids in batch_update.added:
|
||||
extra_args = params.extra_args if params else None
|
||||
tta = extra_args.get("tta_rvq") if extra_args else None
|
||||
if tta:
|
||||
self._req[idx] = {
|
||||
"start_tid": tta["start_tid"],
|
||||
"end_tid": tta["end_tid"],
|
||||
"codec_cap": tta.get("codec_cap"),
|
||||
"start_in_prompt": bool(tta.get("start_in_prompt", False)),
|
||||
}
|
||||
self._output_tokens[idx] = output_token_ids
|
||||
if self._req[idx]["start_in_prompt"]:
|
||||
self._start_pos[idx] = -1
|
||||
else:
|
||||
self._start_pos.pop(idx, None)
|
||||
if self._phase_token_ids is None:
|
||||
self._phase_token_ids = tta["phase_token_ids"]
|
||||
self._mask_end_tid = tta["end_tid"]
|
||||
else:
|
||||
self._req.pop(idx, None)
|
||||
self._output_tokens.pop(idx, None)
|
||||
self._start_pos.pop(idx, None)
|
||||
|
||||
if self._req:
|
||||
for src, dst, direction in batch_update.moved:
|
||||
src_req = self._req.pop(src, None)
|
||||
dst_req = self._req.pop(dst, None)
|
||||
src_tokens = self._output_tokens.pop(src, None)
|
||||
dst_tokens = self._output_tokens.pop(dst, None)
|
||||
src_pos = self._start_pos.pop(src, None)
|
||||
dst_pos = self._start_pos.pop(dst, None)
|
||||
if src_req is not None:
|
||||
self._req[dst] = src_req
|
||||
if src_tokens is not None:
|
||||
self._output_tokens[dst] = src_tokens
|
||||
if src_pos is not None:
|
||||
self._start_pos[dst] = src_pos
|
||||
if direction == MoveDirectionality.SWAP:
|
||||
if dst_req is not None:
|
||||
self._req[src] = dst_req
|
||||
if dst_tokens is not None:
|
||||
self._output_tokens[src] = dst_tokens
|
||||
if dst_pos is not None:
|
||||
self._start_pos[src] = dst_pos
|
||||
|
||||
def _build_masks(self, vocab_size: int) -> None:
|
||||
if self._phase_token_ids is None or self._mask_end_tid is None:
|
||||
raise RuntimeError(
|
||||
"TTARVQPhaseMaskLogitsProcessor: phase token ids missing; "
|
||||
"extra_args['tta_rvq'] was not provided for a TTA request."
|
||||
)
|
||||
if len(self._phase_token_ids) != XCODEC1_NUM_CODEBOOKS:
|
||||
raise RuntimeError(
|
||||
f"TTARVQPhaseMaskLogitsProcessor: expected {XCODEC1_NUM_CODEBOOKS} phase id "
|
||||
f"lists, got {len(self._phase_token_ids)}."
|
||||
)
|
||||
|
||||
allow = torch.zeros((XCODEC1_NUM_CODEBOOKS, vocab_size), dtype=torch.bool, device=self.device)
|
||||
for phase, ids in enumerate(self._phase_token_ids):
|
||||
if not ids:
|
||||
raise RuntimeError(f"TTARVQPhaseMaskLogitsProcessor: empty token id list for RVQ phase {phase}.")
|
||||
idx = torch.as_tensor(ids, dtype=torch.long, device=self.device)
|
||||
if int(idx.max()) >= vocab_size or int(idx.min()) < 0:
|
||||
raise RuntimeError(
|
||||
f"TTARVQPhaseMaskLogitsProcessor: phase {phase} token id out of range for vocab_size={vocab_size}."
|
||||
)
|
||||
allow[phase].index_fill_(0, idx, True)
|
||||
|
||||
end_onehot = torch.zeros(vocab_size, dtype=torch.bool, device=self.device)
|
||||
if self._mask_end_tid < 0 or self._mask_end_tid >= vocab_size:
|
||||
raise RuntimeError(
|
||||
f"TTARVQPhaseMaskLogitsProcessor: end token id out of range "
|
||||
f"for vocab_size={vocab_size}: {self._mask_end_tid}."
|
||||
)
|
||||
end_onehot[self._mask_end_tid] = True
|
||||
|
||||
self._disallow_phase = ~allow
|
||||
self._disallow_phase0_end = ~(allow[0] | end_onehot)
|
||||
self._disallow_except_end = ~end_onehot
|
||||
logger.info(
|
||||
"TTARVQPhaseMaskLogitsProcessor: built masks vocab=%d end_tid=%d (pid=%d)",
|
||||
vocab_size,
|
||||
self._mask_end_tid,
|
||||
os.getpid(),
|
||||
)
|
||||
|
||||
def apply(self, logits: torch.Tensor) -> torch.Tensor:
|
||||
if not self._req:
|
||||
return logits
|
||||
|
||||
if self._disallow_phase is None:
|
||||
self._build_masks(logits.shape[-1])
|
||||
|
||||
num_rows = logits.shape[0]
|
||||
for idx, state in self._req.items():
|
||||
# Rows beyond this step's logits (pure-prefill / partially
|
||||
# scheduled steps) have nothing to mask; the persistent-batch
|
||||
# index re-enters range on the decode steps that sample.
|
||||
if idx >= num_rows:
|
||||
continue
|
||||
tokens = self._output_tokens.get(idx)
|
||||
if tokens is None:
|
||||
continue
|
||||
n = len(tokens)
|
||||
|
||||
start_pos = self._start_pos.get(idx)
|
||||
if start_pos is None:
|
||||
start_tid = state["start_tid"]
|
||||
for i in range(n):
|
||||
if tokens[i] == start_tid:
|
||||
start_pos = i
|
||||
self._start_pos[idx] = i
|
||||
break
|
||||
if start_pos is None:
|
||||
continue
|
||||
|
||||
position = n - start_pos - 1
|
||||
if position < 0:
|
||||
continue
|
||||
|
||||
cap = state["codec_cap"]
|
||||
if cap is not None and position >= cap:
|
||||
disallow = self._disallow_except_end
|
||||
else:
|
||||
phase = position & (XCODEC1_NUM_CODEBOOKS - 1)
|
||||
if phase == 0:
|
||||
disallow = self._disallow_phase0_end if position > 0 else self._disallow_phase[0]
|
||||
else:
|
||||
disallow = self._disallow_phase[phase]
|
||||
|
||||
logits[idx].masked_fill_(disallow, NEG_INF)
|
||||
|
||||
return logits
|
||||
@@ -84,6 +84,31 @@ _OMNI_MODELS = {
|
||||
"cosyvoice3",
|
||||
"CosyVoice3Model",
|
||||
),
|
||||
"NemotronDenseForCausalLM": (
|
||||
"audex",
|
||||
"audex_thinker",
|
||||
"NemotronDenseForCausalLM",
|
||||
),
|
||||
"AudexCode2Wav": (
|
||||
"audex",
|
||||
"audex_code2wav",
|
||||
"AudexCode2Wav",
|
||||
),
|
||||
"AudexXCodec1": (
|
||||
"audex",
|
||||
"audex_xcodec",
|
||||
"AudexXCodec1",
|
||||
),
|
||||
"NemotronDenseAudexForConditionalGeneration": (
|
||||
"audex",
|
||||
"audex_omni",
|
||||
"NemotronDenseAudexForConditionalGeneration",
|
||||
),
|
||||
"NemotronHAudexForConditionalGeneration": (
|
||||
"audex",
|
||||
"audex_omni",
|
||||
"NemotronHAudexForConditionalGeneration",
|
||||
),
|
||||
"OmniVoiceModel": (
|
||||
"omnivoice",
|
||||
"omnivoice",
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Audex thinker → code2wav stage input processors.
|
||||
|
||||
The thinker is a plain token-autoregressive LM whose sampled stream contains
|
||||
``<speechcodec_N>`` tokens in a contiguous vocab block. These processors read
|
||||
the sampled token ids directly (no tokenizer, no multimodal outputs), map them
|
||||
to codec frame ids by subtracting the vocab offset, and ship single-codebook
|
||||
frame payloads to the code2wav stage.
|
||||
|
||||
The downstream decoder is stateful-streaming (one session per request), so
|
||||
chunks carry ONLY new frames — no left-context replay — plus a ``finished``
|
||||
flag whose final (possibly empty) payload triggers the decoder's lookahead
|
||||
flush.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayloadStruct
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
from vllm_omni.model_executor.stage_input_processors.bagel import ExpandedPrompt
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
CFG_UNCOND_SUFFIX = "__cfg_uncond"
|
||||
|
||||
# Vocab layout of nvidia/Nemotron-Labs-Audex-2B checkpoint_folder_audiogen:
|
||||
# <speechcodec_0> .. <speechcodec_65535> occupy a contiguous id block. The
|
||||
# deploy yaml can override both values; a unit test pins these defaults
|
||||
# against the real tokenizer.
|
||||
_DEFAULT_CODEC_TOKEN_OFFSET = 131077
|
||||
_DEFAULT_CODEC_VOCAB_SIZE = 65536
|
||||
|
||||
# TTA <audiocodec_N> block (see models/audex/tta.py; pinned against the real
|
||||
# tokenizer there). Interleaved 4-codebook RVQ.
|
||||
_DEFAULT_AUDIOCODEC_TOKEN_OFFSET = 196613
|
||||
_DEFAULT_AUDIOCODEC_VOCAB_SIZE = 8192
|
||||
_TTA_NUM_CODEBOOKS = 4
|
||||
|
||||
_STATE_KEY = "_audex_async_state"
|
||||
|
||||
|
||||
def _ensure_list(value: Any) -> list[int]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, torch.Tensor):
|
||||
return value.reshape(-1).tolist()
|
||||
return list(value)
|
||||
|
||||
|
||||
def _connector_extra(transfer_manager: Any) -> dict[str, Any]:
|
||||
connector = getattr(transfer_manager, "connector", None)
|
||||
raw_cfg = getattr(connector, "config", {}) or {}
|
||||
if isinstance(raw_cfg, dict):
|
||||
extra = raw_cfg.get("extra", raw_cfg)
|
||||
return extra if isinstance(extra, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _codec_frames(tokens: list[int], codec_offset: int, codec_size: int) -> list[int]:
|
||||
"""Map ``<speechcodec_N>`` token ids to codec frame ids, dropping all other tokens."""
|
||||
return [int(t) - codec_offset for t in tokens if codec_offset <= int(t) < codec_offset + codec_size]
|
||||
|
||||
|
||||
def _finished_meta(request_id: Any) -> dict[str, Any]:
|
||||
"""Terminal-payload meta shared by the sync full-payload producers."""
|
||||
return {
|
||||
"finished": torch.tensor(True, dtype=torch.bool),
|
||||
"req_id": [request_id],
|
||||
}
|
||||
|
||||
|
||||
def _finished_request_codes(
|
||||
transfer_manager: Any,
|
||||
request: Any,
|
||||
offset_key: str,
|
||||
size_key: str,
|
||||
default_offset: int,
|
||||
default_size: int,
|
||||
) -> tuple[list[int], Any]:
|
||||
"""Codec frame ids from a finished request's full token stream, plus its id."""
|
||||
cfg = _connector_extra(transfer_manager)
|
||||
codec_offset = int(cfg.get(offset_key, default_offset))
|
||||
codec_size = int(cfg.get(size_key, default_size))
|
||||
output_token_ids = _ensure_list(getattr(request, "output_token_ids", []))
|
||||
codes = _codec_frames(output_token_ids, codec_offset, codec_size)
|
||||
request_id = getattr(request, "external_req_id", None) or getattr(request, "request_id", None)
|
||||
return codes, request_id
|
||||
|
||||
|
||||
def _empty_finished_payload(request_id: Any, codebooks: int) -> dict[str, Any]:
|
||||
"""Terminal payload for a failed request on the sync full-payload path.
|
||||
|
||||
Stage 1 decodes it to empty audio, which the serving layer's no-audio
|
||||
guard turns into a request-level error. Raising here instead would be
|
||||
swallowed by the payload hook (no terminal payload -> stage-1 stall).
|
||||
"""
|
||||
return {
|
||||
"codes": {"audio": torch.empty((0, codebooks), dtype=torch.long)},
|
||||
"meta": _finished_meta(request_id),
|
||||
}
|
||||
|
||||
|
||||
def expand_cfg_prompts(prompt: dict[str, Any] | str, sampling_params: Any) -> list[ExpandedPrompt]:
|
||||
"""Emit the unconditional CFG companion for a guided Audex TTS request.
|
||||
|
||||
A request opts into CFG by carrying ``extra_args`` with
|
||||
``cfg_scale > 1.0``, ``cfg_role="cond"``, ``cfg_pair_id`` and the
|
||||
pre-built length-matched ``cfg_null_prompt`` (constructed where a
|
||||
tokenizer is available: the serving layer or the offline example).
|
||||
Unguided requests (no/1.0 scale) expand to nothing, keeping the
|
||||
disabled path identical to the non-CFG flow.
|
||||
"""
|
||||
extra_args = getattr(sampling_params, "extra_args", None) or {}
|
||||
try:
|
||||
cfg_scale = float(extra_args.get("cfg_scale", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if cfg_scale <= 1.0 or extra_args.get("cfg_role") != "cond":
|
||||
return []
|
||||
|
||||
pair_id = extra_args.get("cfg_pair_id")
|
||||
null_prompt = extra_args.get("cfg_null_prompt")
|
||||
if not pair_id or not isinstance(null_prompt, str) or not null_prompt:
|
||||
raise ValueError(
|
||||
"Audex CFG request is missing cfg_pair_id/cfg_null_prompt in extra_args; "
|
||||
"the caller must build the length-matched null prompt before submission"
|
||||
)
|
||||
|
||||
if isinstance(prompt, dict):
|
||||
companion_prompt: dict[str, Any] | str = {**prompt, "prompt": null_prompt}
|
||||
companion_prompt.pop("prompt_token_ids", None)
|
||||
else:
|
||||
companion_prompt = null_prompt
|
||||
|
||||
return [
|
||||
ExpandedPrompt(
|
||||
prompt=companion_prompt,
|
||||
role="uncond",
|
||||
request_id_suffix=CFG_UNCOND_SUFFIX,
|
||||
sampling_params_override={
|
||||
"extra_args": {
|
||||
"cfg_scale": cfg_scale,
|
||||
"cfg_role": "uncond",
|
||||
"cfg_pair_id": pair_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _extract_new_codes(
|
||||
request: Any,
|
||||
state: dict[str, Any],
|
||||
codec_offset: int,
|
||||
codec_size: int,
|
||||
) -> list[int]:
|
||||
"""Return codec frame ids from tokens sampled since the last call."""
|
||||
output_token_ids = _ensure_list(getattr(request, "output_token_ids", []))
|
||||
new_tokens = output_token_ids[int(state.get("seen_len", 0)) :]
|
||||
state["seen_len"] = len(output_token_ids)
|
||||
return _codec_frames(new_tokens, codec_offset, codec_size)
|
||||
|
||||
|
||||
def thinker2code2wav_async_chunk(
|
||||
transfer_manager: Any,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
"""Async-chunk producer: sampled token stream → per-chunk codec frames."""
|
||||
del multimodal_output # thinker emits none; codes come from the token stream
|
||||
request_id = request.external_req_id
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
|
||||
cfg = _connector_extra(transfer_manager)
|
||||
chunk_frames = int(cfg.get("codec_chunk_frames", 25))
|
||||
initial_chunk_frames = int(cfg.get("initial_codec_chunk_frames", chunk_frames))
|
||||
codec_offset = int(cfg.get("codec_token_offset", _DEFAULT_CODEC_TOKEN_OFFSET))
|
||||
codec_size = int(cfg.get("codec_vocab_size", _DEFAULT_CODEC_VOCAB_SIZE))
|
||||
if chunk_frames <= 0 or initial_chunk_frames <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid codec chunk config: codec_chunk_frames={chunk_frames}, "
|
||||
f"initial_codec_chunk_frames={initial_chunk_frames}"
|
||||
)
|
||||
|
||||
request_state = transfer_manager.request_payload.get(request_id)
|
||||
if not isinstance(request_state, dict) or _STATE_KEY not in request_state:
|
||||
request_state = {
|
||||
_STATE_KEY: {
|
||||
"seen_len": 0,
|
||||
"pending": [],
|
||||
"emitted_chunks": 0,
|
||||
"total_frames": 0,
|
||||
"terminal_sent": False,
|
||||
}
|
||||
}
|
||||
transfer_manager.request_payload[request_id] = request_state
|
||||
state = request_state[_STATE_KEY]
|
||||
if state.get("terminal_sent", False):
|
||||
return None
|
||||
|
||||
pending: list[int] = state["pending"]
|
||||
new_codes = _extract_new_codes(request, state, codec_offset, codec_size)
|
||||
pending.extend(new_codes)
|
||||
state["total_frames"] = int(state.get("total_frames", 0)) + len(new_codes)
|
||||
|
||||
if finished and state["total_frames"] == 0:
|
||||
# Fail fast: the transfer adapter logs this and still delivers the
|
||||
# terminal marker, and the serving layer turns the audio-less request
|
||||
# into a request-level error instead of a silent empty stream.
|
||||
state["terminal_sent"] = True
|
||||
raise ValueError(f"Audex thinker produced no codec tokens for request {request_id}")
|
||||
|
||||
# Hold back at least one frame on non-terminal emissions so the terminal
|
||||
# chunk always carries codes: the generation scheduler never runs a final
|
||||
# forward for an empty terminal chunk, and the decoder's lookahead tail is
|
||||
# only flushed inside a forward that sees the finished flag.
|
||||
target = initial_chunk_frames if state["emitted_chunks"] == 0 else chunk_frames
|
||||
if not finished and len(pending) <= target:
|
||||
return None
|
||||
|
||||
if finished:
|
||||
emit, state["pending"] = pending, []
|
||||
state["terminal_sent"] = True
|
||||
else:
|
||||
emit, state["pending"] = pending[:target], pending[target:]
|
||||
|
||||
state["emitted_chunks"] += 1
|
||||
finished_flag = torch.tensor(finished, dtype=torch.bool)
|
||||
# Shape [frames, 1]: 2-D codec payloads ride the connector's payload
|
||||
# channel into ``runtime_additional_information`` (1-D tensors are
|
||||
# rerouted onto the token path and never reach the model's payload view).
|
||||
return OmniPayloadStruct(
|
||||
codes=CodesStruct(audio=torch.tensor(emit, dtype=torch.long).reshape(-1, 1)),
|
||||
meta=MetaStruct(
|
||||
finished=finished_flag,
|
||||
# The chunk adapter strips ``meta.finished`` before it reaches the
|
||||
# stage-1 model; ``stream_finished`` survives and is what
|
||||
# AudexCode2Wav uses to trigger the lookahead flush.
|
||||
stream_finished=finished_flag,
|
||||
req_id=[request_id],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def thinker2code2wav_full_payload(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Sync-path producer: ship the full codec sequence once at finish."""
|
||||
del pooling_output # thinker emits none; codes come from the token stream
|
||||
codes, request_id = _finished_request_codes(
|
||||
transfer_manager,
|
||||
request,
|
||||
"codec_token_offset",
|
||||
"codec_vocab_size",
|
||||
_DEFAULT_CODEC_TOKEN_OFFSET,
|
||||
_DEFAULT_CODEC_VOCAB_SIZE,
|
||||
)
|
||||
if not codes:
|
||||
# Do NOT raise: the sync payload hook swallows exceptions and returns
|
||||
# None, so no terminal payload would reach stage 1 and the request
|
||||
# would stall. An empty FINISHED payload lets stage 1 complete with
|
||||
# empty audio and the serving-layer no-audio guard fail the request.
|
||||
logger.error(
|
||||
"Audex thinker produced no codec tokens for request %s; sending empty terminal payload", request_id
|
||||
)
|
||||
return _empty_finished_payload(request_id, codebooks=1)
|
||||
return {"codes": {"audio": codes}, "meta": _finished_meta(request_id)}
|
||||
|
||||
|
||||
def thinker2xcodec_full_payload(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
"""TTA sync-path producer: ship the de-interleaved RVQ payload at finish.
|
||||
|
||||
Filters ``<audiocodec_N>`` ids from the sampled stream, truncates to whole
|
||||
4-token frames, and ships a ``[frames, 4]`` codec-id payload (offsets NOT
|
||||
subtracted; the XCodec1 stage removes the per-layer offset).
|
||||
"""
|
||||
del pooling_output # thinker emits none; codes come from the token stream
|
||||
codes, request_id = _finished_request_codes(
|
||||
transfer_manager,
|
||||
request,
|
||||
"audiocodec_token_offset",
|
||||
"audiocodec_vocab_size",
|
||||
_DEFAULT_AUDIOCODEC_TOKEN_OFFSET,
|
||||
_DEFAULT_AUDIOCODEC_VOCAB_SIZE,
|
||||
)
|
||||
# Error paths do NOT raise: the sync payload hook swallows exceptions and
|
||||
# returns None, so no terminal payload would reach stage 1 and the
|
||||
# request would stall. An empty FINISHED payload lets stage 1 complete
|
||||
# with empty audio and the serving-layer no-audio guard fail the request.
|
||||
if not codes:
|
||||
logger.error(
|
||||
"Audex TTA thinker produced no codec tokens for request %s; sending empty terminal payload", request_id
|
||||
)
|
||||
return _empty_finished_payload(request_id, codebooks=_TTA_NUM_CODEBOOKS)
|
||||
usable = len(codes) - (len(codes) % _TTA_NUM_CODEBOOKS)
|
||||
if usable == 0:
|
||||
logger.error(
|
||||
"Audex TTA thinker produced fewer than one full RVQ frame for request %s; sending empty terminal payload",
|
||||
request_id,
|
||||
)
|
||||
return _empty_finished_payload(request_id, codebooks=_TTA_NUM_CODEBOOKS)
|
||||
|
||||
# Validate the ACTUAL generated stream against the 4-codebook phase
|
||||
# contract before decode: XCodec's per-layer offset removal clamps
|
||||
# invalid ids into range, which would silently mask a phase violation.
|
||||
from vllm_omni.model_executor.models.audex.tta import validate_rvq_phase
|
||||
|
||||
validity = validate_rvq_phase(codes[:usable])
|
||||
if not validity["phase_valid"]:
|
||||
logger.error(
|
||||
"Audex TTA thinker produced a phase-invalid RVQ stream for request %s: "
|
||||
"%d mismatched positions (first mismatch [index, codec_id, got_phase, expected_phase] = %s); "
|
||||
"sending empty terminal payload",
|
||||
request_id,
|
||||
validity["mismatch_count"],
|
||||
validity["first_mismatch"],
|
||||
)
|
||||
return _empty_finished_payload(request_id, codebooks=_TTA_NUM_CODEBOOKS)
|
||||
|
||||
frames = torch.tensor(codes[:usable], dtype=torch.long).reshape(-1, _TTA_NUM_CODEBOOKS)
|
||||
return {"codes": {"audio": frames}, "meta": _finished_meta(request_id)}
|
||||
|
||||
|
||||
def thinker2code2wav_token_only(
|
||||
source_outputs: list[Any],
|
||||
prompt: Any = None,
|
||||
_requires_multimodal_data: bool = False,
|
||||
):
|
||||
"""Sync-side stage-1 engine-input builder (length placeholder).
|
||||
|
||||
The bulk codec payload ships via the worker connector from
|
||||
``thinker2code2wav_full_payload``; this only sizes the stage-1 request.
|
||||
"""
|
||||
del prompt
|
||||
engine_inputs: list[OmniTokensPrompt] = []
|
||||
for source_output in source_outputs:
|
||||
if not source_output.finished:
|
||||
continue
|
||||
output = source_output.outputs[0]
|
||||
prompt_ids = _ensure_list(source_output.prompt_token_ids)
|
||||
raw_output_ids = _ensure_list(output.cumulative_token_ids)
|
||||
if raw_output_ids[: len(prompt_ids)] == prompt_ids:
|
||||
raw_output_ids = raw_output_ids[len(prompt_ids) :]
|
||||
engine_inputs.append(OmniTokensPrompt(prompt_token_ids=raw_output_ids))
|
||||
return engine_inputs
|
||||
@@ -321,13 +321,23 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
"Qwen3TTSTalkerForConditionalGeneration",
|
||||
"Qwen3TTSCode2Wav",
|
||||
"CosyVoice3Model",
|
||||
"NemotronDenseForCausalLM",
|
||||
"NemotronHForCausalLM",
|
||||
"DyninOmniForConditionalGeneration",
|
||||
"IndexTTS2TalkerForConditionalGeneration",
|
||||
}
|
||||
if (
|
||||
getattr(self.model_config, "model_arch", None) in _OMNI_CONNECTOR_INIT_ARCHS
|
||||
or get_stage_connector_role(self.model_config) is not None
|
||||
):
|
||||
# The stage-level ``model_arch`` override may be blank so the class
|
||||
# resolves from the checkpoint's own ``architectures`` (e.g. the Audex
|
||||
# TTA/TTS thinker stages, which bind dense on the 2B and NemotronH on
|
||||
# the 30B-A3B). Gate on the RESOLVED architectures, not only the raw
|
||||
# override — otherwise a blank-override producer stage never creates
|
||||
# its worker connector and the sync full-payload flush silently never
|
||||
# runs (downstream stage starves on connector input).
|
||||
stage_archs = set(getattr(self.model_config, "architectures", None) or ())
|
||||
model_arch_override = getattr(self.model_config, "model_arch", None)
|
||||
if model_arch_override:
|
||||
stage_archs.add(model_arch_override)
|
||||
if stage_archs & _OMNI_CONNECTOR_INIT_ARCHS or get_stage_connector_role(self.model_config) is not None:
|
||||
self.init_omni_connectors(
|
||||
model_config=self.model_config,
|
||||
kv_transfer_manager=self.kv_transfer_manager,
|
||||
|
||||
@@ -64,6 +64,8 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin
|
||||
"MiMoAudioModel",
|
||||
"Qwen3TTSTalkerForConditionalGeneration",
|
||||
"Qwen3TTSCode2Wav",
|
||||
"AudexCode2Wav",
|
||||
"AudexXCodec1",
|
||||
"CosyVoice3Model",
|
||||
"DyninOmniForConditionalGeneration",
|
||||
"IndexTTS2S2MelDecoder",
|
||||
|
||||
Reference in new issue
Block a user