Add voxcpm model support. (#2467)

Signed-off-by: Celeste-jq <591998922@qq.com>
Signed-off-by: lyj-jjj <liuyingjun5@huawei.com>
Signed-off-by: IsleOfDawnlight <stellamou@qq.com>
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Celeste-jq <591998922@qq.com>
Co-authored-by: lyj-jjj <liuyingjun5@huawei.com>
Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-04-15 15:04:58 +08:00
1 parent 38d5f2d530
commit 4bf4c63147
31 files changed
+4727 -2

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
# VoxCPM Benchmark
This directory contains both:
- online serving benchmark through the OpenAI-compatible `/v1/audio/speech` API
- offline benchmark for `Omni` / `AsyncOmni`
- full offline smoke-matrix orchestration
Both benchmark paths report:
- TTFP: time to first PCM packet
- E2E latency
- RTF: real-time factor (`e2e / audio_duration`)
## Offline Benchmark
Single offline benchmark run:
```bash
python benchmarks/voxcpm/vllm_omni/bench_tts_offline.py \
--model /path/to/voxcpm-model \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm.yaml \
--text "This is a split-stage VoxCPM synthesis example running on vLLM Omni." \
--warmup-runs 1 \
--output-dir benchmarks/voxcpm/results/offline_single
```
Streaming offline benchmark:
```bash
python benchmarks/voxcpm/vllm_omni/bench_tts_offline.py \
--model /path/to/voxcpm-model \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
--text "This is a split-stage VoxCPM streaming example running on vLLM Omni." \
--warmup-runs 1 \
--output-dir benchmarks/voxcpm/results/offline_streaming
```
Full fixed offline matrix, equivalent to the old `examples/offline_inference/voxcpm/test.py`:
```bash
python benchmarks/voxcpm/vllm_omni/run_offline_matrix.py \
--model /path/to/voxcpm-model \
--ref-audio /path/to/reference.wav \
--ref-text "The exact transcript spoken in reference.wav." \
--output-root benchmarks/voxcpm/results/offline_matrix
```
The full matrix covers both routes:
- streaming: `voxcpm_async_chunk.yaml`
- sync: `voxcpm.yaml`
And these six scenarios under each route:
- warmup + single TTS
- warmup + single voice cloning
- warmup + batch TTS
- warmup + batch voice cloning
- cold single TTS
- cold single voice cloning
`bench_tts_offline.py` itself no longer writes `summary.json` / `results.json`; it prints TTFP / RTF inline and saves generated WAV files only. The matrix runner keeps only per-case `run.log`.
## Start the Server
Async-chunk:
```bash
vllm serve /path/to/voxcpm-model \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
--trust-remote-code \
--enforce-eager \
--omni \
--port 8091
```
Non-streaming:
```bash
vllm serve /path/to/voxcpm-model \
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm.yaml \
--trust-remote-code \
--enforce-eager \
--omni \
--port 8091
```
## Run the Benchmark
```bash
python benchmarks/voxcpm/vllm_omni/bench_tts_serve.py \
--host 127.0.0.1 \
--port 8091 \
--num-prompts 20 \
--max-concurrency 1 \
--result-dir /tmp/voxcpm_bench
```
Voice cloning benchmark:
```bash
python benchmarks/voxcpm/vllm_omni/bench_tts_serve.py \
--host 127.0.0.1 \
--port 8091 \
--num-prompts 10 \
--max-concurrency 1 \
--ref-audio https://example.com/reference.wav \
--ref-text "The exact transcript spoken in the reference audio." \
--result-dir /tmp/voxcpm_clone_bench
```
## Notes
- The benchmark uses `stream=true` and `response_format=pcm` so TTFP is measured from the first audio packet.
- `RTF < 1.0` means the server generates audio faster than real time.
- For `voxcpm_async_chunk.yaml`, keep concurrency at `1`. This matches native VoxCPM streaming more closely.
- Do not benchmark concurrent online streaming on `voxcpm_async_chunk.yaml`; use `voxcpm.yaml` for multi-request throughput runs.
- For the offline matrix mode, `--ref-audio` and `--ref-text` are required because clone cases are part of the fixed coverage set.
@@ -0,0 +1,890 @@
"""Offline VoxCPM benchmark for vLLM Omni.
Supports both:
- sync one-shot (Omni.generate)
- streaming (AsyncOmni.generate with async_chunk config)
- text-only synthesis
- voice cloning
- text/clone batch inputs from txt or jsonl
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import tempfile
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import torch
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni import AsyncOmni, Omni
REPO_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_STAGE_ASYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm_async_chunk.yaml"
DEFAULT_STAGE_SYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm.yaml"
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class PromptSpec:
text: str
label: str
ref_audio: str | None = None
ref_text: str | None = None
def _require_soundfile():
try:
import soundfile as sf # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"soundfile is required to write VoxCPM benchmark WAV outputs. Install it with: pip install soundfile"
) from exc
return sf
def _build_prompt(
args,
*,
text: str,
ref_audio: str | None = None,
ref_text: str | None = None,
global_request_id: str | None = None,
) -> dict[str, Any]:
additional_information: dict[str, list[Any]] = {
"text": [text],
"cfg_value": [args.cfg_value],
"inference_timesteps": [args.inference_timesteps],
"min_len": [args.min_len],
"max_new_tokens": [args.max_new_tokens],
}
if args.streaming_prefix_len is not None:
additional_information["streaming_prefix_len"] = [args.streaming_prefix_len]
if ref_audio:
additional_information["ref_audio"] = [ref_audio]
if ref_text:
additional_information["ref_text"] = [ref_text]
if global_request_id is not None:
additional_information["global_request_id"] = [global_request_id]
return {
"prompt_token_ids": [1],
"additional_information": additional_information,
}
def _extract_audio_tensor(mm: dict[str, Any]) -> torch.Tensor:
audio = mm.get("audio", mm.get("model_outputs"))
if audio is None:
raise ValueError("No audio output found in multimodal output.")
if isinstance(audio, list):
parts = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio]
audio = torch.cat(parts, dim=-1) if parts else torch.zeros(0)
if not isinstance(audio, torch.Tensor):
audio = torch.as_tensor(audio)
return audio.float().cpu().reshape(-1)
def _extract_sample_rate(mm: dict[str, Any]) -> int:
sr_raw = mm.get("sr", 24000)
if isinstance(sr_raw, list) and sr_raw:
sr_raw = sr_raw[-1]
if hasattr(sr_raw, "item"):
return int(sr_raw.item())
return int(sr_raw)
def _emit_offline_metrics(
*,
request_id: str,
elapsed_s: float,
first_audio_elapsed: float | None,
audio_duration_s: float,
) -> None:
metrics = {
"request_id": request_id,
"ttfp_ms": round(first_audio_elapsed * 1000.0, 3) if first_audio_elapsed is not None else None,
"audio_duration_s": round(audio_duration_s, 6),
"rtf": round(elapsed_s / audio_duration_s, 6) if audio_duration_s > 0 else None,
}
print(f"[OfflineMetrics] {metrics}")
def _write_audio_tensor(output_path: Path, audio_tensor: Any, sample_rate: int) -> None:
sf = _require_soundfile()
if isinstance(audio_tensor, torch.Tensor):
audio_np = audio_tensor.float().cpu().clamp(-1.0, 1.0).numpy()
else:
audio_np = torch.as_tensor(audio_tensor).float().cpu().clamp(-1.0, 1.0).numpy()
sf.write(
output_path,
audio_np,
sample_rate,
format="WAV",
subtype="PCM_16",
)
def _save_wav(mm: dict[str, Any], output_dir: Path, request_id: str) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"output_{request_id}.wav"
_write_audio_tensor(output_path, _extract_audio_tensor(mm), _extract_sample_rate(mm))
return output_path
def _iter_request_multimodal_outputs(request_output: Any):
outputs = getattr(request_output, "outputs", None)
if outputs:
for output in outputs:
mm = getattr(output, "multimodal_output", None)
if isinstance(mm, dict):
yield mm
mm = getattr(request_output, "multimodal_output", None)
if isinstance(mm, dict):
yield mm
def _read_non_empty_lines(path: str) -> list[str]:
with open(path, encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
def _load_prompt_specs(args) -> list[PromptSpec]:
specs: list[PromptSpec] = []
if args.txt_prompts is not None:
texts = _read_non_empty_lines(args.txt_prompts)
if not texts:
raise ValueError(f"No prompts found in {args.txt_prompts}")
for idx, text in enumerate(texts, start=1):
specs.append(
PromptSpec(
text=text,
label=f"item{idx:03d}",
ref_audio=args.ref_audio,
ref_text=args.ref_text,
)
)
return specs
if args.jsonl_prompts is not None:
with open(args.jsonl_prompts, encoding="utf-8") as f:
for line_no, raw_line in enumerate(f, start=1):
line = raw_line.strip()
if not line:
continue
try:
item = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"{args.jsonl_prompts}:{line_no} is not valid JSON: {exc}") from exc
if not isinstance(item, dict):
raise ValueError(f"{args.jsonl_prompts}:{line_no} must be a JSON object")
text = item.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError(f"{args.jsonl_prompts}:{line_no} requires non-empty string field 'text'")
ref_audio = item.get("ref_audio", args.ref_audio)
ref_text = item.get("ref_text", args.ref_text)
if (ref_audio is None) != (ref_text is None):
raise ValueError(
f"{args.jsonl_prompts}:{line_no} must provide both 'ref_audio' and 'ref_text' together"
)
specs.append(
PromptSpec(
text=text.strip(),
label=f"item{len(specs) + 1:03d}",
ref_audio=ref_audio,
ref_text=ref_text,
)
)
if not specs:
raise ValueError(f"No prompts found in {args.jsonl_prompts}")
return specs
specs.append(
PromptSpec(
text=args.text,
label="item001",
ref_audio=args.ref_audio,
ref_text=args.ref_text,
)
)
return specs
def _build_prompt_for_spec(args, spec: PromptSpec, *, global_request_id: str | None = None) -> dict[str, Any]:
return _build_prompt(
args,
text=spec.text,
ref_audio=spec.ref_audio,
ref_text=spec.ref_text,
global_request_id=global_request_id,
)
def _count_voice_clone_prompts(prompt_specs: list[PromptSpec]) -> int:
return sum(1 for spec in prompt_specs if spec.ref_audio is not None)
def _get_warmup_specs(prompt_specs: list[PromptSpec]) -> list[PromptSpec]:
return prompt_specs[:1]
def _extract_stream_finished(stage_output: Any) -> bool:
request_output = getattr(stage_output, "request_output", None)
request_finished = getattr(request_output, "finished", None)
if request_finished is not None:
return bool(request_finished)
return bool(getattr(stage_output, "finished", False))
def _build_profiled_stage_config(
stage_configs_path: str,
profiler_dir: str,
) -> str:
stage_config_path = Path(stage_configs_path)
yaml_text = stage_config_path.read_text(encoding="utf-8")
injected_lines: list[str] = []
injected_count = 0
for line in yaml_text.splitlines():
injected_lines.append(line)
if line.strip() != "engine_args:":
continue
indent = line[: len(line) - len(line.lstrip())]
child_indent = indent + " "
grandchild_indent = child_indent + " "
injected_lines.extend(
[
f"{child_indent}profiler_config:",
f'{grandchild_indent}profiler: "torch"',
f'{grandchild_indent}torch_profiler_dir: "{profiler_dir}"',
f"{grandchild_indent}torch_profiler_with_stack: true",
]
)
injected_count += 1
if injected_count == 0:
raise ValueError(f"No engine_args block found in stage config: {stage_configs_path}")
tmp = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
delete=False,
suffix=".yaml",
prefix=f"{stage_config_path.stem}_profile_",
)
tmp.write("\n".join(injected_lines) + "\n")
tmp.close()
return tmp.name
def parse_args():
parser = FlexibleArgumentParser(
description="Offline split-stage VoxCPM inference with vLLM Omni (auto sync/streaming by stage config)"
)
parser.add_argument(
"--model",
type=str,
default=os.environ.get("VOXCPM_MODEL"),
help="Local VoxCPM model directory. Defaults to $VOXCPM_MODEL.",
)
parser.add_argument(
"--text",
type=str,
default="This is a split-stage VoxCPM synthesis example running on vLLM Omni.",
help="Text to synthesize. Ignored when --txt-prompts or --jsonl-prompts is used.",
)
parser.add_argument(
"--txt-prompts",
type=str,
default=None,
help="Path to a .txt file with one synthesis text per line.",
)
parser.add_argument(
"--jsonl-prompts",
type=str,
default=None,
help=(
"Path to a .jsonl file. Each line must contain at least {'text': ...}; "
"clone rows can also set ref_audio/ref_text, and ref_text must be the "
"real transcript of ref_audio."
),
)
parser.add_argument(
"--ref-audio",
type=str,
default=None,
help=(
"Optional reference audio path for voice cloning. With --txt-prompts, "
"the same reference is applied to every line."
),
)
parser.add_argument(
"--ref-text",
type=str,
default=None,
help=(
"Real transcript of the reference audio. Placeholder text or mismatched "
"text will usually produce noisy/electronic clone audio."
),
)
parser.add_argument(
"--stage-configs-path",
type=str,
default=str(DEFAULT_STAGE_SYNC),
help="Stage config YAML path. Routing is selected only from this path.",
)
parser.add_argument(
"--cfg-value",
type=float,
default=2.0,
help="Classifier-free guidance value for VoxCPM.",
)
parser.add_argument(
"--inference-timesteps",
type=int,
default=10,
help="Number of inference timesteps.",
)
parser.add_argument(
"--min-len",
type=int,
default=2,
help="Minimum generated token length.",
)
parser.add_argument(
"--max-new-tokens",
type=int,
default=4096,
help="Maximum generated token length.",
)
parser.add_argument(
"--streaming-prefix-len",
type=int,
default=None,
help="VoxCPM streaming window (optional, streaming mode only).",
)
parser.add_argument(
"--output-dir",
type=str,
default=None,
help="Directory for output WAV files.",
)
parser.add_argument(
"--stage-init-timeout",
type=int,
default=600,
help="Stage initialization timeout in seconds.",
)
parser.add_argument(
"--log-stats",
dest="log_stats",
action="store_true",
help="Enable vLLM Omni stats logging.",
)
parser.add_argument(
"--no-log-stats",
dest="log_stats",
action="store_false",
help="Disable vLLM Omni stats logging.",
)
parser.set_defaults(log_stats=True)
parser.add_argument(
"--num-runs",
type=int,
default=1,
help="Number of full inference runs (same prompt each time). Default 1.",
)
parser.add_argument(
"--warmup-runs",
type=int,
default=0,
help=(
"Optional number of warmup passes before measured runs. Warmup uses only "
"the first prompt and does not save outputs."
),
)
parser.add_argument(
"--enable-profiler",
action="store_true",
help=(
"Enable torch profiler for the configured stages. A temporary profiled "
"stage config is generated automatically."
),
)
parser.add_argument(
"--profiler-dir",
type=str,
default=None,
help="Directory for profiler traces. Defaults to <output-dir>/profiler when profiling is enabled.",
)
parser.add_argument(
"--profiler-stages",
type=int,
nargs="*",
default=None,
help="Optional stage ids to profile. Defaults to all stages that have profiler_config.",
)
parser.add_argument(
"--profiler-wait-seconds",
type=float,
default=30.0,
help="Seconds to wait after stop_profile for trace files to flush.",
)
args = parser.parse_args()
if not args.model:
parser.error("--model is required unless $VOXCPM_MODEL is set")
if args.txt_prompts is not None and args.jsonl_prompts is not None:
parser.error("--txt-prompts and --jsonl-prompts are mutually exclusive")
if (args.ref_audio is None) != (args.ref_text is None):
parser.error("--ref-audio and --ref-text must be provided together")
if args.num_runs < 1:
parser.error("--num-runs must be >= 1")
if args.warmup_runs < 0:
parser.error("--warmup-runs must be >= 0")
if args.output_dir is None:
args.output_dir = (
"output_audio_streaming" if _is_streaming_stage_config(args.stage_configs_path) else "output_audio"
)
if args.enable_profiler and args.profiler_dir is None:
args.profiler_dir = str(Path(args.output_dir) / "profiler")
try:
args.prompt_specs = _load_prompt_specs(args)
except ValueError as exc:
parser.error(str(exc))
return args
def _is_streaming_stage_config(stage_configs_path: str) -> bool:
cfg_name = Path(stage_configs_path).name.lower()
# Keep routing purely config-path based:
# - voxcpm.yaml => sync
# - voxcpm_async_chunk.yaml => streaming
return "async_chunk" in cfg_name
async def _collect_streaming_audio(
omni: AsyncOmni,
args: Any,
spec: PromptSpec,
request_id: str,
*,
phase_label: str,
prompt_index: int,
prompt_count: int,
print_prompt: bool = False,
) -> tuple[torch.Tensor, int, float, float | None]:
prompt = _build_prompt_for_spec(args, spec, global_request_id=request_id)
delta_chunks: list[torch.Tensor] = []
sample_rate = 24000
chunk_i = 0
prev_total_samples = 0
t_start = time.perf_counter()
first_audio_elapsed: float | None = None
if print_prompt:
print(f"---prompt---:{prompt}")
async for stage_output in omni.generate(prompt, request_id=request_id):
mm = getattr(stage_output, "multimodal_output", None)
if not isinstance(mm, dict):
ro = getattr(stage_output, "request_output", None)
if ro is None:
continue
mm = getattr(ro, "multimodal_output", None)
if not isinstance(mm, dict) and getattr(ro, "outputs", None):
seq = ro.outputs[0]
mm = getattr(seq, "multimodal_output", None)
if not isinstance(mm, dict):
continue
sample_rate = _extract_sample_rate(mm)
try:
w = _extract_audio_tensor(mm)
n = int(w.numel())
if n == 0:
continue
finished = _extract_stream_finished(stage_output)
if n > prev_total_samples:
delta = w.reshape(-1)[prev_total_samples:]
prev_total_samples = n
elif finished and n == prev_total_samples:
delta = w.reshape(-1)[:0]
else:
delta = w.reshape(-1)
prev_total_samples += int(delta.numel())
if int(delta.numel()) > 0:
delta_chunks.append(delta)
if first_audio_elapsed is None and int(delta.numel()) > 0:
first_audio_elapsed = time.perf_counter() - t_start
logger.info(
"%s prompt=%d/%d chunk=%d delta_samples=%d buf_len=%d finished=%s",
phase_label,
prompt_index + 1,
prompt_count,
chunk_i,
int(delta.numel()),
n,
finished,
)
chunk_i += 1
except ValueError:
if not _extract_stream_finished(stage_output):
logger.debug("skip non-audio partial output chunk=%d", chunk_i)
if not delta_chunks:
raise RuntimeError("No audio chunks received; check stage config and logs.")
audio_cat = torch.cat([c.reshape(-1) for c in delta_chunks], dim=0)
elapsed = time.perf_counter() - t_start
return audio_cat, sample_rate, elapsed, first_audio_elapsed
async def _abort_streaming_residual_work(
omni: AsyncOmni,
request_id: str,
*,
settle_seconds: float = 0.1,
) -> None:
"""Stop any late stage-0 work once the final audio has been collected."""
await omni.engine.abort_async([request_id])
if settle_seconds > 0:
await asyncio.sleep(settle_seconds)
async def _run_streaming_single(
omni: AsyncOmni,
args: Any,
spec: PromptSpec,
output_dir: Path,
request_id: str,
*,
run_index: int,
num_runs: int,
prompt_index: int,
prompt_count: int,
) -> Path:
audio_cat, sample_rate, elapsed, first_audio_elapsed = await _collect_streaming_audio(
omni,
args,
spec,
request_id,
phase_label=f"run={run_index + 1}/{num_runs}",
prompt_index=prompt_index,
prompt_count=prompt_count,
print_prompt=(run_index == 0 and prompt_index == 0),
)
await _abort_streaming_residual_work(omni, request_id)
output_path = output_dir / f"output_run{run_index + 1}_{spec.label}.wav"
_write_audio_tensor(output_path, audio_cat, sample_rate)
audio_duration_s = float(audio_cat.numel()) / float(sample_rate) if sample_rate > 0 else 0.0
ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
rtf_text = f", rtf={elapsed / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
print(
f"Saved (streaming) run {run_index + 1}/{num_runs}, "
f"prompt {prompt_index + 1}/{prompt_count}: {output_path} ({elapsed:.2f}s{ttfp_text}{rtf_text})"
)
_emit_offline_metrics(
request_id=request_id,
elapsed_s=elapsed,
first_audio_elapsed=first_audio_elapsed,
audio_duration_s=audio_duration_s,
)
return output_path
async def _run_streaming_warmup(args, omni: AsyncOmni) -> None:
if args.warmup_runs == 0:
return
warmup_specs = _get_warmup_specs(args.prompt_specs)
print(
f"Warmup: {args.warmup_runs} run(s) using the first prompt "
f"({len(warmup_specs)} prompt(s)); outputs will be discarded."
)
for warmup_index in range(args.warmup_runs):
t_warmup = time.perf_counter()
tasks = []
request_ids: list[str] = []
for prompt_index, spec in enumerate(warmup_specs):
request_id = f"warmup_stream_{warmup_index + 1}_{spec.label}_{uuid.uuid4().hex[:8]}"
request_ids.append(request_id)
tasks.append(
_collect_streaming_audio(
omni,
args,
spec,
request_id,
phase_label=f"warmup={warmup_index + 1}/{args.warmup_runs}",
prompt_index=prompt_index,
prompt_count=len(warmup_specs),
)
)
results = await asyncio.gather(*tasks)
for request_id in request_ids:
await _abort_streaming_residual_work(omni, request_id)
total_samples = sum(int(audio.numel()) for audio, _, _, _ in results)
warmup_ttfps = [ttfp for _, _, _, ttfp in results if ttfp is not None]
ttfp_text = f", ttfp={min(warmup_ttfps):.2f}s" if warmup_ttfps else ""
print(
f"Warmup (streaming) {warmup_index + 1}/{args.warmup_runs} finished: "
f"{len(results)} prompt(s), {total_samples} sample(s) "
f"({time.perf_counter() - t_warmup:.2f}s{ttfp_text})"
)
async def _run_streaming(args) -> list[Path]:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
omni = AsyncOmni(
model=args.model,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
)
await _run_streaming_warmup(args, omni)
profiler_started = False
if args.enable_profiler:
profile_prefix = f"voxcpm_streaming_{int(time.time())}"
stages_text = args.profiler_stages if args.profiler_stages is not None else "all-configured"
print(f"Starting profiler (streaming): stages={stages_text}, dir={args.profiler_dir}")
await omni.start_profile(profile_prefix=profile_prefix, stages=args.profiler_stages)
profiler_started = True
t_total = time.perf_counter()
total_elapsed = 0.0
paths: list[Path] = []
prompt_specs: list[PromptSpec] = args.prompt_specs
try:
for run in range(args.num_runs):
for prompt_index, spec in enumerate(prompt_specs):
request_id = f"stream_{run + 1}_{spec.label}_{uuid.uuid4().hex[:8]}"
paths.append(
await _run_streaming_single(
omni,
args,
spec,
output_dir,
request_id,
run_index=run,
num_runs=args.num_runs,
prompt_index=prompt_index,
prompt_count=len(prompt_specs),
)
)
total_elapsed = time.perf_counter() - t_total
finally:
if profiler_started:
print("Stopping profiler (streaming)...")
await omni.stop_profile(stages=args.profiler_stages)
if args.profiler_wait_seconds > 0:
print(f"Waiting {args.profiler_wait_seconds:.1f}s for profiler traces to flush...")
await asyncio.sleep(args.profiler_wait_seconds)
print(
f"All streaming runs finished: {args.num_runs} run(s), "
f"{len(prompt_specs)} prompt(s), {len(paths)} file(s) in {total_elapsed:.2f}s total"
)
return paths
def _run_sync(args) -> list[Path]:
output_dir = Path(args.output_dir)
omni = Omni(
model=args.model,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
)
def _run_sync_single(
spec: PromptSpec,
*,
request_prefix: str,
save_outputs: bool,
run_index: int | None = None,
) -> tuple[list[Path], int, float | None, float, float, str]:
global_request_id = f"{request_prefix}_{spec.label}"
prompt = _build_prompt_for_spec(args, spec, global_request_id=global_request_id)
if save_outputs and run_index == 0 and spec.label == "item001":
print(f"---prompt---:{prompt}")
saved_paths: list[Path] = []
output_count = 0
first_audio_elapsed: float | None = None
total_audio_duration_s = 0.0
metrics_request_id = global_request_id
t_start = time.perf_counter()
for stage_outputs in omni.generate(prompt):
request_output = stage_outputs.request_output
if request_output is None:
continue
request_output_id = getattr(request_output, "request_id", None)
if isinstance(request_output_id, str) and request_output_id:
metrics_request_id = request_output_id
for j, mm in enumerate(_iter_request_multimodal_outputs(request_output)):
output_count += 1
if first_audio_elapsed is None:
try:
audio_tensor = _extract_audio_tensor(mm)
if int(audio_tensor.numel()) > 0:
first_audio_elapsed = time.perf_counter() - t_start
total_audio_duration_s += float(audio_tensor.numel()) / float(_extract_sample_rate(mm))
except ValueError:
pass
else:
try:
audio_tensor = _extract_audio_tensor(mm)
total_audio_duration_s += float(audio_tensor.numel()) / float(_extract_sample_rate(mm))
except ValueError:
pass
if not save_outputs:
continue
save_stem = f"run{run_index + 1}_{spec.label}" if j == 0 else f"run{run_index + 1}_{spec.label}_{j}"
saved_paths.append(_save_wav(mm, output_dir, save_stem))
if output_count == 0:
raise RuntimeError("No output from Omni.generate")
elapsed_s = time.perf_counter() - t_start
return saved_paths, output_count, first_audio_elapsed, elapsed_s, total_audio_duration_s, metrics_request_id
if args.warmup_runs:
warmup_specs = _get_warmup_specs(args.prompt_specs)
print(
f"Warmup: {args.warmup_runs} run(s) using the first prompt "
f"({len(warmup_specs)} prompt(s)); outputs will be discarded."
)
for warmup_index in range(args.warmup_runs):
t_warmup = time.perf_counter()
_, output_count, first_audio_elapsed, elapsed_s, audio_duration_s, _ = _run_sync_single(
warmup_specs[0],
request_prefix=f"warmup_sync{warmup_index + 1}",
save_outputs=False,
)
ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
rtf_text = f", rtf={elapsed_s / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
print(
f"Warmup (sync) {warmup_index + 1}/{args.warmup_runs} finished: "
f"{output_count} output(s) ({time.perf_counter() - t_warmup:.2f}s{ttfp_text}{rtf_text})"
)
profiler_started = False
if args.enable_profiler:
profile_prefix = f"voxcpm_sync_{int(time.time())}"
stages_text = args.profiler_stages if args.profiler_stages is not None else "all-configured"
print(f"Starting profiler (sync): stages={stages_text}, dir={args.profiler_dir}")
omni.start_profile(profile_prefix=profile_prefix, stages=args.profiler_stages)
profiler_started = True
t_total = time.perf_counter()
total_elapsed = 0.0
saved_paths: list[Path] = []
prompt_specs: list[PromptSpec] = args.prompt_specs
try:
for run in range(args.num_runs):
t_run = time.perf_counter()
run_paths: list[Path] = []
for prompt_index, spec in enumerate(prompt_specs):
prompt_paths, _, first_audio_elapsed, elapsed_s, audio_duration_s, metrics_request_id = (
_run_sync_single(
spec,
request_prefix=f"sync_run{run + 1}_{prompt_index + 1:03d}",
save_outputs=True,
run_index=run,
)
)
run_paths.extend(prompt_paths)
ttfp_text = f", ttfp={first_audio_elapsed:.2f}s" if first_audio_elapsed is not None else ""
rtf_text = f", rtf={elapsed_s / audio_duration_s:.3f}" if audio_duration_s > 0 else ""
print(
f"Saved (sync) run {run + 1}/{args.num_runs}, "
f"prompt {prompt_index + 1}/{len(prompt_specs)}: {len(prompt_paths)} file(s){ttfp_text}{rtf_text}"
)
_emit_offline_metrics(
request_id=metrics_request_id,
elapsed_s=elapsed_s,
first_audio_elapsed=first_audio_elapsed,
audio_duration_s=audio_duration_s,
)
saved_paths.extend(run_paths)
print(
f"Run {run + 1}/{args.num_runs} finished: {len(run_paths)} file(s) ({time.perf_counter() - t_run:.2f}s)"
)
for path in run_paths:
print(f" {path}")
total_elapsed = time.perf_counter() - t_total
finally:
if profiler_started:
print("Stopping profiler (sync)...")
omni.stop_profile(stages=args.profiler_stages)
if args.profiler_wait_seconds > 0:
print(f"Waiting {args.profiler_wait_seconds:.1f}s for profiler traces to flush...")
time.sleep(args.profiler_wait_seconds)
print(
f"All sync runs finished: {args.num_runs} run(s), "
f"{len(prompt_specs)} prompt(s), {len(saved_paths)} file(s) in {total_elapsed:.2f}s total"
)
return saved_paths
def main(args) -> int:
logging.basicConfig(level=logging.INFO)
profiled_stage_config_path: str | None = None
original_stage_config_path = args.stage_configs_path
if args.enable_profiler:
Path(args.profiler_dir).mkdir(parents=True, exist_ok=True)
profiled_stage_config_path = _build_profiled_stage_config(
args.stage_configs_path,
str(Path(args.profiler_dir).resolve()),
)
args.stage_configs_path = profiled_stage_config_path
is_streaming = _is_streaming_stage_config(args.stage_configs_path)
voice_clone_count = _count_voice_clone_prompts(args.prompt_specs)
print(f"Model: {args.model}")
print(f"Stage config: {original_stage_config_path}")
print(f"Route: {'streaming' if is_streaming else 'sync'} (from stage-configs-path)")
print(f"Prompt count: {len(args.prompt_specs)}")
print("Batch mode: sequential (aligned with native VoxCPM)")
print(f"Warmup runs: {args.warmup_runs}")
print(f"Voice cloning prompts: {voice_clone_count}/{len(args.prompt_specs)}")
if args.enable_profiler:
print(f"Profiler: enabled (dir={args.profiler_dir}, stages={args.profiler_stages or 'all-configured'})")
print(f"Profiled stage config: {args.stage_configs_path}")
if voice_clone_count:
print("Voice cloning note: --ref-text/ref_text must match the spoken content of the reference audio.")
print(f"Num runs: {args.num_runs}")
try:
if is_streaming:
asyncio.run(_run_streaming(args))
else:
_run_sync(args)
finally:
if profiled_stage_config_path is not None and os.path.exists(profiled_stage_config_path):
os.unlink(profiled_stage_config_path)
return 0
if __name__ == "__main__":
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
raise SystemExit(main(parse_args()))
@@ -0,0 +1,283 @@
"""Benchmark VoxCPM via /v1/audio/speech.
Reports TTFP (time to first packet), E2E latency, and RTF (real-time factor).
"""
from __future__ import annotations
import argparse
import asyncio
import json
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
import aiohttp
import numpy as np
from tqdm.asyncio import tqdm
DEFAULT_MODEL = "OpenBMB/VoxCPM1.5"
DEFAULT_SAMPLE_RATE = 24000
PROMPTS = [
"Hello, welcome to the VoxCPM speech benchmark.",
"This is a short benchmark prompt for online text-to-speech generation.",
"The quick brown fox jumps over the lazy dog near the riverbank.",
"Please remember to bring your identification documents tomorrow morning.",
"Learning a new language takes patience, practice, and curiosity.",
"This benchmark reports TTFP and RTF for the VoxCPM online serving path.",
]
@dataclass
class RequestResult:
success: bool = False
ttfp: float = 0.0
e2e: float = 0.0
audio_bytes: int = 0
audio_duration: float = 0.0
rtf: float = 0.0
prompt: str = ""
error: str = ""
@dataclass
class BenchmarkResult:
concurrency: int = 0
num_prompts: int = 0
completed: int = 0
failed: int = 0
duration_s: float = 0.0
mean_ttfp_ms: float = 0.0
median_ttfp_ms: float = 0.0
p95_ttfp_ms: float = 0.0
mean_e2e_ms: float = 0.0
median_e2e_ms: float = 0.0
p95_e2e_ms: float = 0.0
mean_rtf: float = 0.0
median_rtf: float = 0.0
p95_rtf: float = 0.0
total_audio_duration_s: float = 0.0
request_throughput: float = 0.0
per_request: list[dict[str, float | str]] = field(default_factory=list)
def pcm_bytes_to_duration(num_bytes: int, sample_rate: int = DEFAULT_SAMPLE_RATE, sample_width: int = 2) -> float:
num_samples = num_bytes / sample_width
return num_samples / sample_rate
async def send_tts_request(
session: aiohttp.ClientSession,
api_url: str,
*,
model: str,
prompt: str,
ref_audio: str | None,
ref_text: str | None,
pbar: tqdm | None = None,
) -> RequestResult:
payload: dict[str, object] = {
"model": model,
"input": prompt,
"stream": True,
"response_format": "pcm",
}
if ref_audio is not None:
payload["ref_audio"] = ref_audio
if ref_text is not None:
payload["ref_text"] = ref_text
result = RequestResult(prompt=prompt)
started_at = time.perf_counter()
try:
async with session.post(api_url, json=payload) as response:
if response.status != 200:
result.error = f"HTTP {response.status}: {await response.text()}"
return result
first_chunk = True
total_bytes = 0
async for chunk in response.content.iter_any():
if not chunk:
continue
if first_chunk:
result.ttfp = time.perf_counter() - started_at
first_chunk = False
total_bytes += len(chunk)
result.e2e = time.perf_counter() - started_at
result.audio_bytes = total_bytes
result.audio_duration = pcm_bytes_to_duration(total_bytes)
if result.audio_duration > 0:
result.rtf = result.e2e / result.audio_duration
result.success = True
except Exception as e:
result.error = str(e)
result.e2e = time.perf_counter() - started_at
if pbar is not None:
pbar.update(1)
return result
async def run_benchmark(
*,
host: str,
port: int,
model: str,
num_prompts: int,
max_concurrency: int,
num_warmups: int,
ref_audio: str | None,
ref_text: str | None,
) -> BenchmarkResult:
api_url = f"http://{host}:{port}/v1/audio/speech"
connector = aiohttp.TCPConnector(limit=max_concurrency, limit_per_host=max_concurrency, keepalive_timeout=60)
timeout = aiohttp.ClientTimeout(total=600)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
if num_warmups > 0:
print(f" Warming up with {num_warmups} requests...")
warmup_tasks = [
send_tts_request(
session,
api_url,
model=model,
prompt=PROMPTS[i % len(PROMPTS)],
ref_audio=ref_audio,
ref_text=ref_text,
)
for i in range(num_warmups)
]
await asyncio.gather(*warmup_tasks)
print(" Warmup done.")
request_prompts = [PROMPTS[i % len(PROMPTS)] for i in range(num_prompts)]
semaphore = asyncio.Semaphore(max_concurrency)
pbar = tqdm(total=num_prompts, desc=f" concurrency={max_concurrency}")
async def limited_request(prompt: str) -> RequestResult:
async with semaphore:
return await send_tts_request(
session,
api_url,
model=model,
prompt=prompt,
ref_audio=ref_audio,
ref_text=ref_text,
pbar=pbar,
)
started_at = time.perf_counter()
results = await asyncio.gather(*[asyncio.create_task(limited_request(prompt)) for prompt in request_prompts])
duration = time.perf_counter() - started_at
pbar.close()
succeeded = [result for result in results if result.success]
bench = BenchmarkResult(
concurrency=max_concurrency,
num_prompts=num_prompts,
completed=len(succeeded),
failed=len(results) - len(succeeded),
duration_s=duration,
)
if not succeeded:
return bench
ttfps = np.array([result.ttfp * 1000 for result in succeeded], dtype=np.float64)
e2es = np.array([result.e2e * 1000 for result in succeeded], dtype=np.float64)
rtfs = np.array([result.rtf for result in succeeded], dtype=np.float64)
audio_durations = np.array([result.audio_duration for result in succeeded], dtype=np.float64)
bench.mean_ttfp_ms = float(np.mean(ttfps))
bench.median_ttfp_ms = float(np.median(ttfps))
bench.p95_ttfp_ms = float(np.percentile(ttfps, 95))
bench.mean_e2e_ms = float(np.mean(e2es))
bench.median_e2e_ms = float(np.median(e2es))
bench.p95_e2e_ms = float(np.percentile(e2es, 95))
bench.mean_rtf = float(np.mean(rtfs))
bench.median_rtf = float(np.median(rtfs))
bench.p95_rtf = float(np.percentile(rtfs, 95))
bench.total_audio_duration_s = float(np.sum(audio_durations))
bench.request_throughput = len(succeeded) / duration if duration > 0 else 0.0
bench.per_request = [
{
"prompt": result.prompt,
"ttfp_ms": result.ttfp * 1000,
"e2e_ms": result.e2e * 1000,
"rtf": result.rtf,
"audio_duration_s": result.audio_duration,
}
for result in succeeded
]
return bench
def print_summary(result: BenchmarkResult) -> None:
width = 54
print("")
print("=" * width)
print(f"{'VoxCPM Serving Benchmark':^{width}}")
print("=" * width)
print(f"concurrency : {result.concurrency}")
print(f"requests : {result.completed}/{result.num_prompts} succeeded")
print(f"wall time (s) : {result.duration_s:.3f}")
print(f"mean TTFP (ms) : {result.mean_ttfp_ms:.2f}")
print(f"p95 TTFP (ms) : {result.p95_ttfp_ms:.2f}")
print(f"mean E2E (ms) : {result.mean_e2e_ms:.2f}")
print(f"p95 E2E (ms) : {result.p95_e2e_ms:.2f}")
print(f"mean RTF : {result.mean_rtf:.3f}")
print(f"p95 RTF : {result.p95_rtf:.3f}")
print(f"request throughput : {result.request_throughput:.2f} req/s")
print("=" * width)
async def main_async(args) -> None:
result_dir = Path(args.result_dir)
result_dir.mkdir(parents=True, exist_ok=True)
all_results: list[BenchmarkResult] = []
for concurrency in args.max_concurrency:
result = await run_benchmark(
host=args.host,
port=args.port,
model=args.model,
num_prompts=args.num_prompts,
max_concurrency=concurrency,
num_warmups=args.num_warmups,
ref_audio=args.ref_audio,
ref_text=args.ref_text,
)
print_summary(result)
all_results.append(result)
payload = {
"model": args.model,
"created_at": datetime.utcnow().isoformat() + "Z",
"results": [asdict(result) for result in all_results],
}
result_path = result_dir / "bench_tts_serve.json"
result_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(f"Saved results to: {result_path}")
def parse_args():
parser = argparse.ArgumentParser(description="Benchmark VoxCPM via /v1/audio/speech")
parser.add_argument("--host", default="127.0.0.1", help="Server host")
parser.add_argument("--port", type=int, default=8091, help="Server port")
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name or path")
parser.add_argument("--num-prompts", type=int, default=20, help="Number of prompts to send")
parser.add_argument("--max-concurrency", type=int, nargs="+", default=[1], help="Concurrency levels to benchmark")
parser.add_argument("--num-warmups", type=int, default=3, help="Warmup request count")
parser.add_argument("--ref-audio", default=None, help="Reference audio URL or data URL for voice cloning")
parser.add_argument("--ref-text", default=None, help="Reference audio transcript for voice cloning")
parser.add_argument("--result-dir", default="results", help="Directory to save benchmark JSON")
return parser.parse_args()
if __name__ == "__main__":
asyncio.run(main_async(parse_args()))
@@ -0,0 +1,303 @@
"""Run the full offline VoxCPM smoke matrix.
This script keeps the old `test.py` coverage, but delegates each case to
`bench_tts_offline.py` so the benchmark runner itself stays focused on a
single execution path.
"""
from __future__ import annotations
import shlex
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from vllm.utils.argparse_utils import FlexibleArgumentParser
REPO_ROOT = Path(__file__).resolve().parents[3]
BENCH_SCRIPT = Path(__file__).with_name("bench_tts_offline.py")
DEFAULT_STAGE_ASYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm_async_chunk.yaml"
DEFAULT_STAGE_SYNC = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm.yaml"
DEFAULT_OUTPUT_ROOT = BENCH_SCRIPT.parents[1] / "results" / "offline_matrix"
SINGLE_TTS_TEXT = "This is a single text-to-speech smoke test for VoxCPM on vLLM Omni."
SINGLE_CLONE_TEXT = "This sentence is synthesized with the cloned voice for validation."
BATCH_TTS_TEXTS = [
"The first batch text-to-speech sample validates sequential batch execution.",
"The second batch text-to-speech sample checks another prompt in the same file.",
"The third batch text-to-speech sample completes the sequential batch path.",
]
BATCH_CLONE_TEXTS = [
"The first cloned sample validates sequential batch voice cloning.",
"The second cloned sample checks the same reference voice on another prompt.",
"The third cloned sample finishes the shared-reference clone batch path.",
]
@dataclass(frozen=True, slots=True)
class ModeSpec:
name: str
stage_config: Path
@dataclass(frozen=True, slots=True)
class CaseSpec:
name: str
warmup_runs: int
prompt_kind: str
voice_clone: bool
@dataclass(frozen=True, slots=True)
class CaseResult:
mode: str
case: str
returncode: int
elapsed_s: float
output_dir: Path
log_path: Path
@property
def ok(self) -> bool:
return self.returncode == 0
MODE_SPECS = [
ModeSpec(name="streaming", stage_config=DEFAULT_STAGE_ASYNC),
ModeSpec(name="sync", stage_config=DEFAULT_STAGE_SYNC),
]
CASE_SPECS = [
CaseSpec(name="warmup_single_tts", warmup_runs=1, prompt_kind="single", voice_clone=False),
CaseSpec(name="warmup_single_clone", warmup_runs=1, prompt_kind="single", voice_clone=True),
CaseSpec(name="warmup_batch_tts", warmup_runs=1, prompt_kind="batch", voice_clone=False),
CaseSpec(name="warmup_batch_clone", warmup_runs=1, prompt_kind="batch", voice_clone=True),
CaseSpec(name="cold_single_tts", warmup_runs=0, prompt_kind="single", voice_clone=False),
CaseSpec(name="cold_single_clone", warmup_runs=0, prompt_kind="single", voice_clone=True),
]
def _write_lines(path: Path, lines: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _prepare_batch_inputs(output_root: Path) -> tuple[Path, Path]:
input_dir = output_root / "inputs"
batch_tts_path = input_dir / "batch_tts_prompts.txt"
batch_clone_path = input_dir / "batch_clone_prompts.txt"
_write_lines(batch_tts_path, BATCH_TTS_TEXTS)
_write_lines(batch_clone_path, BATCH_CLONE_TEXTS)
return batch_tts_path, batch_clone_path
def _base_command(args, mode: ModeSpec, output_dir: Path) -> list[str]:
cmd = [
args.python,
str(BENCH_SCRIPT),
"--model",
args.model,
"--stage-configs-path",
str(mode.stage_config),
"--output-dir",
str(output_dir),
"--num-runs",
str(args.num_runs),
"--stage-init-timeout",
str(args.stage_init_timeout),
]
cmd.append("--log-stats" if args.log_stats else "--no-log-stats")
cmd.extend(["--cfg-value", str(args.cfg_value)])
cmd.extend(["--inference-timesteps", str(args.inference_timesteps)])
cmd.extend(["--min-len", str(args.min_len)])
cmd.extend(["--max-new-tokens", str(args.max_new_tokens)])
if args.streaming_prefix_len is not None:
cmd.extend(["--streaming-prefix-len", str(args.streaming_prefix_len)])
if args.enable_profiler:
profiler_dir = Path(args.profiler_dir) if args.profiler_dir is not None else (output_dir / "profiler")
cmd.append("--enable-profiler")
cmd.extend(["--profiler-dir", str(profiler_dir)])
cmd.extend(["--profiler-wait-seconds", str(args.profiler_wait_seconds)])
if args.profiler_stages is not None:
cmd.append("--profiler-stages")
cmd.extend(str(stage_id) for stage_id in args.profiler_stages)
return cmd
def _build_case_command(
args,
mode: ModeSpec,
case: CaseSpec,
*,
batch_tts_path: Path,
batch_clone_path: Path,
output_dir: Path,
) -> list[str]:
cmd = _base_command(args, mode, output_dir)
cmd.extend(["--warmup-runs", str(case.warmup_runs)])
if case.prompt_kind == "single":
cmd.extend(["--text", SINGLE_CLONE_TEXT if case.voice_clone else SINGLE_TTS_TEXT])
else:
cmd.extend(["--txt-prompts", str(batch_clone_path if case.voice_clone else batch_tts_path)])
if case.voice_clone:
cmd.extend(["--ref-audio", args.ref_audio, "--ref-text", args.ref_text])
return cmd
def _run_case(
args,
mode: ModeSpec,
case: CaseSpec,
*,
batch_tts_path: Path,
batch_clone_path: Path,
output_root: Path,
) -> CaseResult:
case_output_dir = output_root / mode.name / case.name
case_output_dir.mkdir(parents=True, exist_ok=True)
case_log_path = case_output_dir / "run.log"
cmd = _build_case_command(
args,
mode,
case,
batch_tts_path=batch_tts_path,
batch_clone_path=batch_clone_path,
output_dir=case_output_dir,
)
print()
print("=" * 80)
print(f"[{mode.name}] {case.name}")
print(f"Output directory: {case_output_dir}")
print(shlex.join(cmd))
start = time.perf_counter()
with case_log_path.open("w", encoding="utf-8") as log_fp:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert process.stdout is not None
for line in process.stdout:
print(line, end="")
log_fp.write(line)
process.wait()
elapsed_s = time.perf_counter() - start
status = "PASS" if (process.returncode or 0) == 0 else f"FAIL({process.returncode})"
print(f"[{mode.name}] {case.name} -> {status} ({elapsed_s:.2f}s)")
return CaseResult(
mode=mode.name,
case=case.name,
returncode=int(process.returncode or 0),
elapsed_s=elapsed_s,
output_dir=case_output_dir,
log_path=case_log_path,
)
def parse_args():
parser = FlexibleArgumentParser(description="Run the full offline VoxCPM smoke matrix.")
parser.add_argument("--model", type=str, required=True, help="Local VoxCPM model directory.")
parser.add_argument("--ref-audio", type=str, required=True, help="Reference audio path for clone cases.")
parser.add_argument("--ref-text", type=str, required=True, help="Exact transcript spoken in --ref-audio.")
parser.add_argument("--output-root", type=str, default=str(DEFAULT_OUTPUT_ROOT), help="Root directory for outputs.")
parser.add_argument("--python", type=str, default=sys.executable, help="Python executable used to launch cases.")
parser.add_argument("--stage-init-timeout", type=int, default=600, help="Stage initialization timeout in seconds.")
parser.add_argument("--log-stats", dest="log_stats", action="store_true", help="Enable vLLM Omni stats logging.")
parser.add_argument(
"--no-log-stats",
dest="log_stats",
action="store_false",
help="Disable vLLM Omni stats logging.",
)
parser.set_defaults(log_stats=True)
parser.add_argument("--num-runs", type=int, default=1, help="Number of measured runs per case.")
parser.add_argument("--cfg-value", type=float, default=2.0, help="Classifier-free guidance value for VoxCPM.")
parser.add_argument("--inference-timesteps", type=int, default=10, help="Number of inference timesteps.")
parser.add_argument("--min-len", type=int, default=2, help="Minimum generated token length.")
parser.add_argument("--max-new-tokens", type=int, default=4096, help="Maximum generated token length.")
parser.add_argument(
"--streaming-prefix-len",
type=int,
default=None,
help="Optional VoxCPM streaming window passed to streaming cases.",
)
parser.add_argument("--enable-profiler", action="store_true", help="Enable torch profiler for each case.")
parser.add_argument(
"--profiler-dir",
type=str,
default=None,
help="Profiler output root. Defaults to <case-output-dir>/profiler.",
)
parser.add_argument(
"--profiler-stages",
type=int,
nargs="*",
default=None,
help="Optional stage ids to profile. Defaults to all configured stages.",
)
parser.add_argument(
"--profiler-wait-seconds",
type=float,
default=30.0,
help="Seconds to wait after stopping profiler for traces to flush.",
)
args = parser.parse_args()
if args.num_runs < 1:
parser.error("--num-runs must be >= 1")
return args
def main(args) -> int:
output_root = Path(args.output_root)
output_root.mkdir(parents=True, exist_ok=True)
batch_tts_path, batch_clone_path = _prepare_batch_inputs(output_root)
print(f"Model: {args.model}")
print(f"Reference audio: {args.ref_audio}")
print(f"Reference text: {args.ref_text}")
print(f"Python: {args.python}")
print(f"Output root: {output_root}")
print(f"Cases: {len(MODE_SPECS) * len(CASE_SPECS)}")
results: list[CaseResult] = []
for mode in MODE_SPECS:
for case in CASE_SPECS:
results.append(
_run_case(
args,
mode,
case,
batch_tts_path=batch_tts_path,
batch_clone_path=batch_clone_path,
output_root=output_root,
)
)
failed = [result for result in results if not result.ok]
print()
print("=" * 80)
print("Summary:")
for result in results:
status = "PASS" if result.ok else f"FAIL({result.returncode})"
print(f"- [{result.mode}] {result.case}: {status} ({result.elapsed_s:.2f}s)")
print(f" output_dir={result.output_dir}")
print(f" log={result.log_path}")
print(f"Passed: {len(results) - len(failed)}/{len(results)}")
if failed:
print("Failed cases:")
for result in failed:
print(f"- [{result.mode}] {result.case}: see {result.log_path}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(parse_args()))