Files
vllm-omni/tests/e2e/offline_inference/test_audex_s2s.py
T
5014fea4a0 [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>
2026-07-28 03:58:22 +00:00

263 lines
12 KiB
Python

# 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")