Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Co-authored-by: Sy03 <1370724210@qq.com>
122 lines
5.0 KiB
Python
122 lines
5.0 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Offline E2E smoke test for AuK (encoder stage -> diffusion stage).
|
|
|
|
AuK ships as ``config.yaml`` plus loose safetensors and needs the Qwen2.5-Omni-3B
|
|
snapshot next to it, so the test expects an assembled directory produced by
|
|
``tools/prepare_auk_checkpoint.py`` and skips when it is not available:
|
|
|
|
VLLM_OMNI_AUK_MODEL_DIR=/path/to/auk-omni python -m pytest tests/e2e/offline_inference/test_auk.py
|
|
|
|
Two requests are exercised: zero-shot TTS from a reference clip and text-only
|
|
instruct TTS. The checks are structural (duration, sample rate, level,
|
|
finiteness); fidelity against the upstream implementation is covered by the
|
|
parity suites under ``tests/diffusion/models/auk``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import soundfile as sf
|
|
|
|
from tests.helpers.mark import hardware_test
|
|
from tests.helpers.media import get_asset_path
|
|
from tests.helpers.runtime import OmniRunner
|
|
from tests.helpers.stage_config import get_deploy_config_path
|
|
from vllm_omni.model_extras.auk import HOP, SAMPLE_RATE, auk_prompt, auk_sampling_params
|
|
|
|
MODEL_DIR_ENV = "VLLM_OMNI_AUK_MODEL_DIR"
|
|
|
|
# Any clean speech clip works as the cloning reference; reuse the vendored
|
|
# CosyVoice3 prompt so no new binary asset is needed.
|
|
REFERENCE_WAV_PATH = get_asset_path("cosyvoice3/zero_shot_prompt.wav")
|
|
ZERO_SHOT_TEXT = "The quick brown fox jumps over the lazy dog."
|
|
ZERO_SHOT_SECONDS = 3.0
|
|
# The upstream cookbook template: description first, then the content.
|
|
INSTRUCT_TEXT = (
|
|
'Generate speech based on the following description: "A calm young woman speaking warmly and slowly.". '
|
|
'The content to speak is: "Welcome back, how was your day?".'
|
|
)
|
|
INSTRUCT_SECONDS = 2.5
|
|
# 8 Euler steps keep the test short; the base recipe is 32.
|
|
TEST_NFE = 8
|
|
|
|
_model_dir = os.environ.get(MODEL_DIR_ENV)
|
|
pytestmark = [
|
|
pytest.mark.slow,
|
|
pytest.mark.tts,
|
|
pytest.mark.skipif(not _model_dir, reason=f"set {MODEL_DIR_ENV} to an assembled AuK directory"),
|
|
pytest.mark.parametrize(
|
|
"omni_runner",
|
|
[pytest.param((str(Path(_model_dir or ".").resolve()), get_deploy_config_path("auk.yaml"), {}), id="auk")],
|
|
indirect=True,
|
|
),
|
|
]
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _load_reference_wav() -> tuple[np.ndarray, int]:
|
|
audio, sr = sf.read(str(REFERENCE_WAV_PATH), dtype="float32", always_2d=False)
|
|
if isinstance(audio, np.ndarray) and audio.ndim > 1:
|
|
audio = np.mean(audio, axis=-1)
|
|
return np.asarray(audio, dtype=np.float32), int(sr)
|
|
|
|
|
|
def _audio_from_output(output) -> tuple[np.ndarray, int]:
|
|
mm = getattr(output, "multimodal_output", None) or {}
|
|
assert "audio" in mm, f"no audio in the final output; keys={sorted(mm)}"
|
|
audio = mm["audio"]
|
|
if isinstance(audio, list):
|
|
audio = np.concatenate([np.asarray(a, dtype=np.float32).reshape(-1) for a in audio])
|
|
if hasattr(audio, "detach"):
|
|
audio = audio.detach().cpu().float().numpy()
|
|
audio = np.asarray(audio, dtype=np.float32).reshape(-1)
|
|
sr = mm.get("audio_sample_rate", SAMPLE_RATE)
|
|
if isinstance(sr, list) and sr:
|
|
sr = sr[-1]
|
|
if hasattr(sr, "item"):
|
|
sr = sr.item()
|
|
return audio, int(sr)
|
|
|
|
|
|
def _check_waveform(audio: np.ndarray, sr: int, *, gen_seconds: float) -> None:
|
|
assert sr == SAMPLE_RATE
|
|
expected = math.ceil(gen_seconds * SAMPLE_RATE / HOP) * HOP
|
|
assert audio.size == expected, f"expected {expected} samples for {gen_seconds}s, got {audio.size}"
|
|
assert np.isfinite(audio).all(), "non-finite samples"
|
|
assert np.abs(audio).max() <= 1.0 + 1e-6, "audio exceeds full scale"
|
|
assert float(np.sqrt(np.mean(audio**2))) > 1e-3, "audio is silent"
|
|
|
|
|
|
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
|
def test_auk_zero_shot_tts(omni_runner: OmniRunner) -> None:
|
|
"""A reference clip plus an instruction yields speech of the requested length."""
|
|
wav, sr = _load_reference_wav()
|
|
prompt = auk_prompt(
|
|
f"Say the following with the same voice: '{ZERO_SHOT_TEXT}'",
|
|
(wav, sr),
|
|
gen_seconds=ZERO_SHOT_SECONDS,
|
|
)
|
|
outputs = omni_runner.omni.generate(prompt, auk_sampling_params(nfe=TEST_NFE, cfg=2.0, seed=0))
|
|
assert outputs, "no outputs returned"
|
|
audio, out_sr = _audio_from_output(outputs[0])
|
|
_check_waveform(audio, out_sr, gen_seconds=ZERO_SHOT_SECONDS)
|
|
|
|
|
|
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
|
def test_auk_instruct_tts_text_only(omni_runner: OmniRunner) -> None:
|
|
"""A text-only request (no reference audio) takes the no-prompt-audio path."""
|
|
prompt = auk_prompt(INSTRUCT_TEXT, None, gen_seconds=INSTRUCT_SECONDS)
|
|
assert prompt["prompt"].rstrip().endswith("<|im_start|>assistant\n".rstrip())
|
|
assert "|<no_prompt_audio>|" in prompt["prompt"]
|
|
outputs = omni_runner.omni.generate(prompt, auk_sampling_params(nfe=TEST_NFE, cfg=2.0, seed=0))
|
|
assert outputs, "no outputs returned"
|
|
audio, out_sr = _audio_from_output(outputs[0])
|
|
_check_waveform(audio, out_sr, gen_seconds=INSTRUCT_SECONDS)
|