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:
31 files changed
+4727
-2
No files matched your search
@@ -295,6 +295,31 @@ steps:
|
||||
volumes:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "VoxCPM E2E Test"
|
||||
timeout_in_minutes: 20
|
||||
depends_on: upload-ready-pipeline
|
||||
commands:
|
||||
- |
|
||||
timeout 20m bash -c '
|
||||
pip install voxcpm
|
||||
export VLLM_LOGGING_LEVEL=DEBUG
|
||||
export VLLM_WORKER_MULTIPROC_METHOD=spawn
|
||||
pytest -s -v tests/e2e/offline_inference/test_voxcpm.py -m "core_model" --run-level "core_model"
|
||||
'
|
||||
agents:
|
||||
queue: "gpu_1_queue"
|
||||
plugins:
|
||||
- docker#v5.2.0:
|
||||
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
|
||||
always-pull: true
|
||||
propagate-environment: true
|
||||
shm-size: "8gb"
|
||||
environment:
|
||||
- "HF_HOME=/fsx/hf_cache"
|
||||
- "HF_TOKEN"
|
||||
volumes:
|
||||
- "/fsx/hf_cache:/fsx/hf_cache"
|
||||
|
||||
- label: "VoxCPM2 Native AR E2E Test"
|
||||
timeout_in_minutes: 20
|
||||
depends_on: upload-ready-pipeline
|
||||
|
||||
@@ -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()))
|
||||
@@ -0,0 +1,123 @@
|
||||
# VoxCPM Offline Example
|
||||
|
||||
This directory contains the minimal offline VoxCPM example for vLLM Omni.
|
||||
|
||||
`end2end.py` is intentionally small and only covers:
|
||||
|
||||
- single text-to-speech
|
||||
- single voice cloning with `ref_audio` + `ref_text`
|
||||
- non-streaming with `vllm_omni/model_executor/stage_configs/voxcpm.yaml`
|
||||
- streaming with `vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml`
|
||||
|
||||
Advanced workflows were moved out of the getting-started example:
|
||||
|
||||
- `benchmarks/voxcpm/vllm_omni/bench_tts_offline.py`: warmup, batch prompts, profiler, offline TTFP / RTF
|
||||
- `benchmarks/voxcpm/vllm_omni/run_offline_matrix.py`: fixed offline smoke matrix
|
||||
- `benchmarks/voxcpm/`: benchmark scripts and benchmark docs
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install VoxCPM in one of these ways:
|
||||
|
||||
```bash
|
||||
pip install voxcpm
|
||||
```
|
||||
|
||||
or point vLLM Omni to the local VoxCPM source tree:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
|
||||
```
|
||||
|
||||
The example writes WAV files with `soundfile`:
|
||||
|
||||
```bash
|
||||
pip install soundfile
|
||||
```
|
||||
|
||||
## Model Path
|
||||
|
||||
Pass the native VoxCPM model directory directly:
|
||||
|
||||
```bash
|
||||
export VOXCPM_MODEL=/path/to/voxcpm-model
|
||||
```
|
||||
|
||||
If the native VoxCPM `config.json` does not contain HuggingFace metadata such as
|
||||
`model_type`, prepare a persistent HF-compatible config directory and point the
|
||||
stage configs to it with `VLLM_OMNI_VOXCPM_HF_CONFIG_PATH`:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_VOXCPM_HF_CONFIG_PATH=/tmp/voxcpm_hf_config
|
||||
mkdir -p "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"
|
||||
cp "$VOXCPM_MODEL/config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/config.json"
|
||||
cp "$VOXCPM_MODEL/generation_config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/generation_config.json" 2>/dev/null || true
|
||||
python3 -c 'import json, os; p=os.path.join(os.environ["VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"], "config.json"); cfg=json.load(open(p, "r", encoding="utf-8")); cfg["model_type"]="voxcpm"; cfg.setdefault("architectures", ["VoxCPMForConditionalGeneration"]); json.dump(cfg, open(p, "w", encoding="utf-8"), indent=2, ensure_ascii=False)'
|
||||
```
|
||||
|
||||
If the model directory itself already has `model_type`, this extra directory is
|
||||
not required.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Single text-to-speech, non-streaming:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/voxcpm/end2end.py \
|
||||
--model "$VOXCPM_MODEL" \
|
||||
--text "This is a split-stage VoxCPM synthesis example running on vLLM Omni."
|
||||
```
|
||||
|
||||
Single voice cloning, non-streaming:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/voxcpm/end2end.py \
|
||||
--model "$VOXCPM_MODEL" \
|
||||
--text "This sentence is synthesized with a cloned voice." \
|
||||
--ref-audio /path/to/reference.wav \
|
||||
--ref-text "The exact transcript spoken in reference.wav."
|
||||
```
|
||||
|
||||
Streaming:
|
||||
|
||||
```bash
|
||||
python examples/offline_inference/voxcpm/end2end.py \
|
||||
--model "$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."
|
||||
```
|
||||
|
||||
By default, `end2end.py` writes to `output_audio/` for non-streaming and
|
||||
`output_audio_streaming/` for streaming.
|
||||
|
||||
## Advanced Workflows
|
||||
|
||||
Use `benchmarks/voxcpm/vllm_omni/bench_tts_offline.py` when you need:
|
||||
|
||||
- warmup runs
|
||||
- prompt files
|
||||
- batch JSONL inputs
|
||||
- profiler injection
|
||||
- offline TTFP / RTF emission
|
||||
|
||||
Use `benchmarks/voxcpm/vllm_omni/run_offline_matrix.py` when you need the fixed offline smoke matrix that previously lived in `test.py`.
|
||||
|
||||
Full matrix benchmark example:
|
||||
|
||||
```bash
|
||||
python benchmarks/voxcpm/vllm_omni/run_offline_matrix.py \
|
||||
--model "$VOXCPM_MODEL" \
|
||||
--ref-audio /path/to/reference.wav \
|
||||
--ref-text "The exact transcript spoken in reference.wav."
|
||||
```
|
||||
|
||||
For online serving examples, see [examples/online_serving/voxcpm](../../online_serving/voxcpm/README.md).
|
||||
|
||||
For benchmark reporting, see [benchmarks/voxcpm](../../../benchmarks/voxcpm/README.md).
|
||||
|
||||
## Notes
|
||||
|
||||
- `voxcpm.yaml` is the default non-streaming stage config.
|
||||
- `voxcpm_async_chunk.yaml` is the streaming stage config.
|
||||
- Streaming is currently single-request oriented; the fixed smoke matrix now lives in `benchmarks/voxcpm/vllm_omni/run_offline_matrix.py`.
|
||||
- `ref_text` must be the real transcript of the reference audio. Mismatched text usually causes obvious quality degradation.
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Minimal offline VoxCPM example for vLLM Omni."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||
|
||||
from vllm_omni import AsyncOmni, Omni
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
DEFAULT_SYNC_STAGE_CONFIG = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm.yaml"
|
||||
|
||||
|
||||
def _build_prompt(args) -> dict[str, Any]:
|
||||
additional_information: dict[str, list[Any]] = {
|
||||
"text": [args.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 args.ref_audio is not None:
|
||||
additional_information["ref_audio"] = [args.ref_audio]
|
||||
if args.ref_text is not None:
|
||||
additional_information["ref_text"] = [args.ref_text]
|
||||
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(item).float().cpu().reshape(-1) for item 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 _is_streaming_stage_config(stage_config_path: str) -> bool:
|
||||
return "async_chunk" in Path(stage_config_path).stem
|
||||
|
||||
|
||||
def _save_audio(audio: torch.Tensor, sample_rate: int, output_dir: Path, request_id: str) -> Path:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / f"output_{request_id}.wav"
|
||||
sf.write(
|
||||
output_path,
|
||||
audio.float().cpu().clamp(-1.0, 1.0).numpy(),
|
||||
sample_rate,
|
||||
format="WAV",
|
||||
subtype="PCM_16",
|
||||
)
|
||||
return output_path
|
||||
|
||||
|
||||
async def _run_streaming(args) -> Path:
|
||||
prompt = _build_prompt(args)
|
||||
output_dir = Path(args.output_dir) if args.output_dir is not None else Path("output_audio_streaming")
|
||||
request_id = "streaming_example"
|
||||
sample_rate = 24000
|
||||
buffered_samples = 0
|
||||
chunks: list[torch.Tensor] = []
|
||||
started = time.perf_counter()
|
||||
omni = AsyncOmni(
|
||||
model=args.model,
|
||||
stage_configs_path=args.stage_configs_path,
|
||||
log_stats=args.log_stats,
|
||||
stage_init_timeout=args.stage_init_timeout,
|
||||
)
|
||||
try:
|
||||
async for stage_output in omni.generate(prompt, request_id=request_id):
|
||||
mm = getattr(stage_output, "multimodal_output", None)
|
||||
if not isinstance(mm, dict):
|
||||
request_output = getattr(stage_output, "request_output", None)
|
||||
if request_output is None:
|
||||
continue
|
||||
mm = getattr(request_output, "multimodal_output", None)
|
||||
if not isinstance(mm, dict) and getattr(request_output, "outputs", None):
|
||||
mm = getattr(request_output.outputs[0], "multimodal_output", None)
|
||||
if not isinstance(mm, dict):
|
||||
continue
|
||||
audio = _extract_audio_tensor(mm)
|
||||
if audio.numel() == 0:
|
||||
continue
|
||||
sample_rate = _extract_sample_rate(mm)
|
||||
if audio.numel() > buffered_samples:
|
||||
delta = audio[buffered_samples:]
|
||||
buffered_samples = int(audio.numel())
|
||||
else:
|
||||
delta = audio
|
||||
buffered_samples += int(delta.numel())
|
||||
if delta.numel() > 0:
|
||||
chunks.append(delta)
|
||||
if not chunks:
|
||||
raise RuntimeError("No streaming audio chunks received from VoxCPM.")
|
||||
output_audio = torch.cat(chunks, dim=0)
|
||||
output_path = _save_audio(output_audio, sample_rate, output_dir, request_id)
|
||||
print(f"Saved streaming audio to: {output_path} ({time.perf_counter() - started:.2f}s)")
|
||||
return output_path
|
||||
finally:
|
||||
omni.shutdown()
|
||||
|
||||
|
||||
def _run_sync(args) -> Path:
|
||||
prompt = _build_prompt(args)
|
||||
output_dir = Path(args.output_dir) if args.output_dir is not None else Path("output_audio")
|
||||
request_id = "sync_example"
|
||||
started = time.perf_counter()
|
||||
last_mm: dict[str, Any] | None = None
|
||||
omni = Omni(
|
||||
model=args.model,
|
||||
stage_configs_path=args.stage_configs_path,
|
||||
log_stats=args.log_stats,
|
||||
stage_init_timeout=args.stage_init_timeout,
|
||||
)
|
||||
for stage_outputs in omni.generate(prompt):
|
||||
request_output = getattr(stage_outputs, "request_output", None)
|
||||
if request_output is None:
|
||||
continue
|
||||
outputs = getattr(request_output, "outputs", None)
|
||||
if outputs:
|
||||
for output in outputs:
|
||||
mm = getattr(output, "multimodal_output", None)
|
||||
if isinstance(mm, dict):
|
||||
last_mm = mm
|
||||
mm = getattr(request_output, "multimodal_output", None)
|
||||
if isinstance(mm, dict):
|
||||
last_mm = mm
|
||||
if last_mm is None:
|
||||
raise RuntimeError("No audio output received from VoxCPM.")
|
||||
output_path = _save_audio(
|
||||
_extract_audio_tensor(last_mm),
|
||||
_extract_sample_rate(last_mm),
|
||||
output_dir,
|
||||
request_id,
|
||||
)
|
||||
print(f"Saved audio to: {output_path} ({time.perf_counter() - started:.2f}s)")
|
||||
return output_path
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = FlexibleArgumentParser(description="Minimal offline VoxCPM example for vLLM Omni.")
|
||||
parser.add_argument("--model", type=str, required=True, help="Local VoxCPM model directory.")
|
||||
parser.add_argument(
|
||||
"--stage-configs-path",
|
||||
type=str,
|
||||
default=str(DEFAULT_SYNC_STAGE_CONFIG),
|
||||
help=("Stage config path. Use voxcpm.yaml for non-streaming or voxcpm_async_chunk.yaml for streaming."),
|
||||
)
|
||||
parser.add_argument("--text", type=str, required=True, help="Input text for synthesis.")
|
||||
parser.add_argument("--ref-audio", type=str, default=None, help="Reference audio path for voice cloning.")
|
||||
parser.add_argument("--ref-text", type=str, default=None, help="Transcript of the reference audio.")
|
||||
parser.add_argument("--output-dir", type=str, default=None, help="Output directory for generated wav files.")
|
||||
parser.add_argument("--cfg-value", type=float, default=2.0, help="Guidance value passed to VoxCPM.")
|
||||
parser.add_argument("--inference-timesteps", type=int, default=10, help="Number of diffusion timesteps.")
|
||||
parser.add_argument("--min-len", type=int, default=2, help="Minimum latent length.")
|
||||
parser.add_argument("--max-new-tokens", type=int, default=4096, help="Maximum latent length.")
|
||||
parser.add_argument(
|
||||
"--streaming-prefix-len",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Streaming prefix length used by voxcpm_async_chunk.yaml.",
|
||||
)
|
||||
parser.add_argument("--stage-init-timeout", type=int, default=600, help="Stage initialization timeout in seconds.")
|
||||
parser.add_argument("--log-stats", action="store_true", help="Enable vLLM Omni stats logging.")
|
||||
args = parser.parse_args()
|
||||
if (args.ref_audio is None) != (args.ref_text is None):
|
||||
raise ValueError("Voice cloning requires --ref-audio and --ref-text together.")
|
||||
return args
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
route = "streaming" if _is_streaming_stage_config(args.stage_configs_path) else "sync"
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Stage config: {args.stage_configs_path}")
|
||||
print(f"Route: {route}")
|
||||
if route == "streaming":
|
||||
asyncio.run(_run_streaming(args))
|
||||
else:
|
||||
_run_sync(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(parse_args())
|
||||
@@ -0,0 +1,166 @@
|
||||
# VoxCPM
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install VoxCPM in one of these ways:
|
||||
|
||||
```bash
|
||||
pip install voxcpm
|
||||
```
|
||||
|
||||
or point vLLM-Omni to a local VoxCPM source tree:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_VOXCPM_CODE_PATH=/path/to/VoxCPM/src
|
||||
```
|
||||
|
||||
If the native VoxCPM `config.json` lacks HF metadata such as `model_type`,
|
||||
prepare a persistent HF-compatible config directory and export:
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_VOXCPM_HF_CONFIG_PATH=/tmp/voxcpm_hf_config
|
||||
mkdir -p "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"
|
||||
cp "$VOXCPM_MODEL/config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/config.json"
|
||||
cp "$VOXCPM_MODEL/generation_config.json" "$VLLM_OMNI_VOXCPM_HF_CONFIG_PATH/generation_config.json" 2>/dev/null || true
|
||||
python3 -c 'import json, os; p=os.path.join(os.environ["VLLM_OMNI_VOXCPM_HF_CONFIG_PATH"], "config.json"); cfg=json.load(open(p, "r", encoding="utf-8")); cfg["model_type"]="voxcpm"; cfg.setdefault("architectures", ["VoxCPMForConditionalGeneration"]); json.dump(cfg, open(p, "w", encoding="utf-8"), indent=2, ensure_ascii=False)'
|
||||
```
|
||||
|
||||
The VoxCPM stage configs read `VLLM_OMNI_VOXCPM_HF_CONFIG_PATH` directly. The `python3 -c` form above avoids heredoc/indentation issues in interactive shells.
|
||||
|
||||
## Launch the Server
|
||||
|
||||
Use the async-chunk stage config by default:
|
||||
|
||||
```bash
|
||||
export VOXCPM_MODEL=/path/to/voxcpm-model
|
||||
cd examples/online_serving/voxcpm
|
||||
./run_server.sh
|
||||
```
|
||||
|
||||
Use the non-streaming stage config:
|
||||
|
||||
```bash
|
||||
./run_server.sh sync
|
||||
```
|
||||
|
||||
You can also launch the server directly:
|
||||
|
||||
```bash
|
||||
vllm serve "$VOXCPM_MODEL" \
|
||||
--stage-configs-path vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml \
|
||||
--trust-remote-code \
|
||||
--enforce-eager \
|
||||
--omni \
|
||||
--port 8091
|
||||
```
|
||||
|
||||
## Send Requests
|
||||
|
||||
### Basic text-to-speech
|
||||
|
||||
```bash
|
||||
python openai_speech_client.py \
|
||||
--model "$VOXCPM_MODEL" \
|
||||
--text "This is a VoxCPM online text-to-speech example."
|
||||
```
|
||||
|
||||
### Voice cloning
|
||||
|
||||
```bash
|
||||
python openai_speech_client.py \
|
||||
--model "$VOXCPM_MODEL" \
|
||||
--text "This sentence is synthesized with a cloned voice." \
|
||||
--ref-audio /path/to/reference.wav \
|
||||
--ref-text "The exact transcript spoken in reference.wav."
|
||||
```
|
||||
|
||||
`ref_text` must be the real transcript of the reference audio. Placeholder text or mismatched text will usually degrade quality badly.
|
||||
|
||||
### Streaming PCM output
|
||||
|
||||
```bash
|
||||
python openai_speech_client.py \
|
||||
--model "$VOXCPM_MODEL" \
|
||||
--text "This is a streaming VoxCPM request." \
|
||||
--stream \
|
||||
--output voxcpm_stream.pcm
|
||||
```
|
||||
|
||||
### Using curl
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "OpenBMB/VoxCPM1.5",
|
||||
"input": "Hello from VoxCPM online serving.",
|
||||
"response_format": "wav"
|
||||
}' --output output.wav
|
||||
```
|
||||
|
||||
Voice cloning:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "OpenBMB/VoxCPM1.5",
|
||||
"input": "This sentence uses a cloned voice.",
|
||||
"ref_audio": "https://example.com/reference.wav",
|
||||
"ref_text": "The exact transcript spoken in the reference audio.",
|
||||
"response_format": "wav"
|
||||
}' --output cloned.wav
|
||||
```
|
||||
|
||||
Streaming PCM:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "OpenBMB/VoxCPM1.5",
|
||||
"input": "This is a streaming VoxCPM request.",
|
||||
"stream": true,
|
||||
"response_format": "pcm"
|
||||
}' --output output.pcm
|
||||
```
|
||||
|
||||
## Supported Request Shape
|
||||
|
||||
VoxCPM online serving currently supports:
|
||||
|
||||
- plain text-to-speech
|
||||
- voice cloning with `ref_audio` + `ref_text`
|
||||
- `stream=true` with `response_format=pcm` or `wav`
|
||||
|
||||
VoxCPM online serving does not use these generic TTS fields:
|
||||
|
||||
- `voice`
|
||||
- `instructions`
|
||||
- `language`
|
||||
- `speaker_embedding`
|
||||
- `x_vector_only_mode`
|
||||
|
||||
## Streaming vs Non-Streaming
|
||||
|
||||
- `voxcpm_async_chunk.yaml` enables async-chunk streaming and is best for single-request streaming latency.
|
||||
- `voxcpm.yaml` performs one-shot latent generation then VAE decode.
|
||||
|
||||
Like native VoxCPM, the async streaming path should be treated as single-request. If you need stable throughput benchmarking, prefer `voxcpm.yaml`.
|
||||
|
||||
Do not use `voxcpm_async_chunk.yaml` for concurrent online streaming or `/v1/audio/speech/batch`. For multiple requests, prefer `voxcpm.yaml`.
|
||||
|
||||
## Benchmark
|
||||
|
||||
The serving benchmark reports TTFP and RTF:
|
||||
|
||||
```bash
|
||||
python benchmarks/voxcpm/vllm_omni/bench_tts_serve.py \
|
||||
--host 127.0.0.1 \
|
||||
--port 8091 \
|
||||
--num-prompts 10 \
|
||||
--max-concurrency 1 \
|
||||
--result-dir /tmp/voxcpm_bench
|
||||
```
|
||||
|
||||
For the async-chunk server, keep `--max-concurrency 1`.
|
||||
@@ -0,0 +1,155 @@
|
||||
"""OpenAI-compatible client for VoxCPM via /v1/audio/speech.
|
||||
|
||||
Examples:
|
||||
# Basic text-to-speech
|
||||
python openai_speech_client.py --text "Hello from VoxCPM"
|
||||
|
||||
# Voice cloning
|
||||
python openai_speech_client.py \
|
||||
--text "This sentence uses the cloned voice." \
|
||||
--ref-audio /path/to/reference.wav \
|
||||
--ref-text "The exact transcript spoken in the reference audio."
|
||||
|
||||
# Streaming PCM output
|
||||
python openai_speech_client.py \
|
||||
--text "This is a streaming VoxCPM request." \
|
||||
--stream \
|
||||
--output output.pcm
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_API_BASE = "http://localhost:8091"
|
||||
DEFAULT_API_KEY = "EMPTY"
|
||||
DEFAULT_MODEL = "OpenBMB/VoxCPM1.5"
|
||||
|
||||
|
||||
def encode_audio_to_base64(audio_path: str) -> str:
|
||||
"""Encode a local audio file to base64 data URL."""
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
|
||||
ext = audio_path.lower().rsplit(".", 1)[-1]
|
||||
mime_map = {
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"flac": "audio/flac",
|
||||
"ogg": "audio/ogg",
|
||||
}
|
||||
mime_type = mime_map.get(ext, "audio/wav")
|
||||
|
||||
with open(audio_path, "rb") as f:
|
||||
audio_b64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{audio_b64}"
|
||||
|
||||
|
||||
def build_payload(args) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"model": args.model,
|
||||
"input": args.text,
|
||||
"response_format": "pcm" if args.stream else args.response_format,
|
||||
}
|
||||
|
||||
if args.ref_audio:
|
||||
if args.ref_audio.startswith(("http://", "https://", "data:")):
|
||||
payload["ref_audio"] = args.ref_audio
|
||||
else:
|
||||
payload["ref_audio"] = encode_audio_to_base64(args.ref_audio)
|
||||
if args.ref_text:
|
||||
payload["ref_text"] = args.ref_text
|
||||
if args.max_new_tokens is not None:
|
||||
payload["max_new_tokens"] = args.max_new_tokens
|
||||
if args.stream:
|
||||
payload["stream"] = True
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def run_tts(args) -> None:
|
||||
payload = build_payload(args)
|
||||
api_url = f"{args.api_base}/v1/audio/speech"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {args.api_key}",
|
||||
}
|
||||
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Text: {args.text}")
|
||||
if args.ref_audio:
|
||||
print("Mode: voice cloning")
|
||||
print(f"Reference audio: {args.ref_audio}")
|
||||
else:
|
||||
print("Mode: text-to-speech")
|
||||
|
||||
if args.stream:
|
||||
output_path = args.output or "voxcpm_output.pcm"
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
with client.stream("POST", api_url, json=payload, headers=headers) as response:
|
||||
if response.status_code != 200:
|
||||
print(f"Error: {response.status_code}")
|
||||
print(response.read().decode("utf-8", errors="ignore"))
|
||||
return
|
||||
|
||||
total_bytes = 0
|
||||
with open(output_path, "wb") as f:
|
||||
for chunk in response.iter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
f.write(chunk)
|
||||
total_bytes += len(chunk)
|
||||
print(f"Streamed {total_bytes} bytes to: {output_path}")
|
||||
return
|
||||
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
response = client.post(api_url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: {response.status_code}")
|
||||
print(response.text)
|
||||
return
|
||||
|
||||
try:
|
||||
text = response.content.decode("utf-8")
|
||||
if text.startswith('{"error"'):
|
||||
print(f"Error: {text}")
|
||||
return
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
output_path = args.output or "voxcpm_output.wav"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
print(f"Audio saved to: {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="VoxCPM OpenAI-compatible speech client")
|
||||
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL")
|
||||
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
|
||||
parser.add_argument("--model", "-m", default=DEFAULT_MODEL, help="Model name or path")
|
||||
parser.add_argument("--text", required=True, help="Text to synthesize")
|
||||
parser.add_argument("--ref-audio", default=None, help="Reference audio path, URL, or data URL")
|
||||
parser.add_argument(
|
||||
"--ref-text",
|
||||
default=None,
|
||||
help="The exact transcript spoken in the reference audio",
|
||||
)
|
||||
parser.add_argument("--stream", action="store_true", help="Enable streaming PCM output")
|
||||
parser.add_argument(
|
||||
"--response-format",
|
||||
default="wav",
|
||||
choices=["wav", "pcm", "flac", "mp3", "aac", "opus"],
|
||||
help="Audio format for non-streaming mode (default: wav)",
|
||||
)
|
||||
parser.add_argument("--max-new-tokens", type=int, default=None, help="Maximum tokens to generate")
|
||||
parser.add_argument("--output", "-o", default=None, help="Output file path")
|
||||
args = parser.parse_args()
|
||||
run_tts(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Launch vLLM-Omni server for VoxCPM online speech serving.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_server.sh # default: async_chunk stage config
|
||||
# ./run_server.sh async # async_chunk stage config
|
||||
# ./run_server.sh sync # no-async-chunk stage config
|
||||
# VOXCPM_MODEL=/path/to/model ./run_server.sh
|
||||
|
||||
set -e
|
||||
|
||||
MODE="${1:-async}"
|
||||
MODEL="${VOXCPM_MODEL:-OpenBMB/VoxCPM1.5}"
|
||||
|
||||
case "$MODE" in
|
||||
async)
|
||||
STAGE_CONFIG="vllm_omni/model_executor/stage_configs/voxcpm_async_chunk.yaml"
|
||||
;;
|
||||
sync)
|
||||
STAGE_CONFIG="vllm_omni/model_executor/stage_configs/voxcpm.yaml"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown mode: $MODE"
|
||||
echo "Supported: async, sync"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Starting VoxCPM server with model: $MODEL"
|
||||
echo "Stage config: $STAGE_CONFIG"
|
||||
|
||||
vllm serve "$MODEL" \
|
||||
--stage-configs-path "$STAGE_CONFIG" \
|
||||
--host 0.0.0.0 \
|
||||
--port 8091 \
|
||||
--trust-remote-code \
|
||||
--enforce-eager \
|
||||
--omni
|
||||
@@ -0,0 +1,156 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""E2E test for VoxCPM offline inference."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import tests.conftest as omni_test_conftest
|
||||
from tests.conftest import OmniRunner
|
||||
from tests.utils import hardware_test
|
||||
from vllm_omni.model_executor.models.voxcpm.voxcpm_runtime_utils import (
|
||||
prepare_voxcpm_hf_config_dir,
|
||||
resolve_voxcpm_model_dir,
|
||||
)
|
||||
|
||||
VOXCPM_MODEL = os.environ.get("VOXCPM_MODEL", "OpenBMB/VoxCPM1.5")
|
||||
STAGE_CONFIG = str(
|
||||
Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "voxcpm.yaml"
|
||||
)
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_npu_cleanup_for_voxcpm(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Limit the NPU cleanup workaround to this VoxCPM test module only."""
|
||||
original_cleanup = omni_test_conftest.cleanup_dist_env_and_memory
|
||||
|
||||
def _safe_cleanup() -> None:
|
||||
try:
|
||||
original_cleanup()
|
||||
except RuntimeError as exc:
|
||||
if "Allocator for npu is not a DeviceAllocator" in str(exc):
|
||||
return
|
||||
raise
|
||||
|
||||
monkeypatch.setattr(omni_test_conftest, "cleanup_dist_env_and_memory", _safe_cleanup)
|
||||
|
||||
|
||||
def _build_prompt(text: str) -> dict[str, Any]:
|
||||
return {
|
||||
"prompt_token_ids": [1],
|
||||
"additional_information": {
|
||||
"text": [text],
|
||||
"cfg_value": [2.0],
|
||||
"inference_timesteps": [10],
|
||||
"min_len": [2],
|
||||
"max_new_tokens": [1024],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _extract_audio_tensor(multimodal_output: dict[str, Any]) -> torch.Tensor:
|
||||
audio = multimodal_output.get("audio", multimodal_output.get("model_outputs"))
|
||||
assert audio is not None, f"No audio output found, keys={list(multimodal_output.keys())}"
|
||||
|
||||
if isinstance(audio, list):
|
||||
parts: list[torch.Tensor] = []
|
||||
for item in audio:
|
||||
if item is None:
|
||||
continue
|
||||
tensor = torch.as_tensor(item)
|
||||
if tensor.numel() == 0:
|
||||
continue
|
||||
parts.append(tensor.float().cpu().reshape(-1))
|
||||
return torch.cat(parts, dim=-1) if parts else torch.zeros((0,), dtype=torch.float32)
|
||||
|
||||
return torch.as_tensor(audio).float().cpu().reshape(-1)
|
||||
|
||||
|
||||
def _extract_final_multimodal_output(outputs) -> dict[str, Any]:
|
||||
for item in reversed(outputs):
|
||||
request_output = getattr(item, "request_output", None)
|
||||
if request_output is not None:
|
||||
multimodal_output = getattr(request_output, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
return multimodal_output
|
||||
completions = getattr(request_output, "outputs", None) or []
|
||||
for completion in completions:
|
||||
multimodal_output = getattr(completion, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
return multimodal_output
|
||||
|
||||
multimodal_output = getattr(item, "multimodal_output", None)
|
||||
if isinstance(multimodal_output, dict):
|
||||
return multimodal_output
|
||||
|
||||
raise AssertionError("No multimodal audio output found in VoxCPM generate results")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voxcpm_model_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> str:
|
||||
model_dir = resolve_voxcpm_model_dir(VOXCPM_MODEL)
|
||||
|
||||
hf_config_env = os.environ.get("VLLM_OMNI_VOXCPM_HF_CONFIG_PATH")
|
||||
if hf_config_env:
|
||||
hf_config_dir = Path(hf_config_env).expanduser()
|
||||
else:
|
||||
hf_config_dir = tmp_path / "voxcpm_hf_config"
|
||||
|
||||
if not (hf_config_dir / "config.json").exists():
|
||||
prepare_voxcpm_hf_config_dir(model_dir, hf_config_dir)
|
||||
|
||||
monkeypatch.setenv("VLLM_OMNI_VOXCPM_HF_CONFIG_PATH", str(hf_config_dir))
|
||||
return str(model_dir)
|
||||
|
||||
|
||||
def test_prepare_voxcpm_hf_config_dir(tmp_path: Path):
|
||||
model_dir = tmp_path / "model"
|
||||
model_dir.mkdir()
|
||||
(model_dir / "config.json").write_text(json.dumps({"hidden_size": 1024}), encoding="utf-8")
|
||||
(model_dir / "generation_config.json").write_text(json.dumps({"do_sample": False}), encoding="utf-8")
|
||||
|
||||
hf_config_dir = prepare_voxcpm_hf_config_dir(model_dir, tmp_path / "voxcpm_hf_config")
|
||||
|
||||
prepared_config = json.loads((hf_config_dir / "config.json").read_text(encoding="utf-8"))
|
||||
assert prepared_config["model_type"] == "voxcpm"
|
||||
assert prepared_config["architectures"] == ["VoxCPMForConditionalGeneration"]
|
||||
assert (hf_config_dir / "generation_config.json").exists()
|
||||
|
||||
|
||||
def test_resolve_voxcpm_model_dir_local_path(tmp_path: Path):
|
||||
model_dir = tmp_path / "OpenBMB" / "VoxCPM1.5"
|
||||
model_dir.mkdir(parents=True)
|
||||
|
||||
assert resolve_voxcpm_model_dir(str(model_dir)) == model_dir
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.omni
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_voxcpm_zero_shot_001(voxcpm_model_path: str):
|
||||
with OmniRunner(voxcpm_model_path, stage_configs_path=STAGE_CONFIG) as runner:
|
||||
outputs = list(runner.omni.generate(_build_prompt("Hello, this is a VoxCPM offline inference test.")))
|
||||
|
||||
assert outputs, "No outputs returned"
|
||||
|
||||
multimodal_output = _extract_final_multimodal_output(outputs)
|
||||
audio = _extract_audio_tensor(multimodal_output)
|
||||
assert audio.numel() > SAMPLE_RATE // 2, f"Audio too short: {audio.numel()} samples"
|
||||
|
||||
duration_s = audio.shape[0] / SAMPLE_RATE
|
||||
assert 0.5 < duration_s < 30.0, f"Audio duration out of range: {duration_s:.2f}s"
|
||||
|
||||
peak = float(torch.max(torch.abs(audio)).item()) if audio.numel() > 0 else 0.0
|
||||
assert peak > 0.01, "Generated audio appears to be silence"
|
||||
|
||||
audio_np = audio.numpy()
|
||||
rms = float(np.sqrt(np.mean(np.square(audio_np)))) if audio_np.size else 0.0
|
||||
assert rms > 1e-4, "Generated audio RMS too low"
|
||||
@@ -7,6 +7,7 @@ explicitly patch values that differ from vLLM.
|
||||
import argparse
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
@@ -166,6 +167,24 @@ def test_stage_configs_path_field():
|
||||
assert args.stage_configs_path == "/some/path.yaml"
|
||||
|
||||
|
||||
def test_voxcpm_model_arch_injects_model_type_override(mocker):
|
||||
"""Ensure VoxCPM model_arch injects hf_overrides for config resolution."""
|
||||
mocker.patch.object(OmniEngineArgs, "_ensure_omni_models_registered", return_value=True)
|
||||
mocker.patch.object(OmniEngineArgs, "_patch_empty_hf_config")
|
||||
mocker.patch.object(EngineArgs, "create_model_config", return_value=Mock())
|
||||
mocker.patch.object(OmniModelConfig, "from_vllm_model_config", return_value=Mock())
|
||||
|
||||
args = OmniEngineArgs(
|
||||
model="OpenBMB/VoxCPM1.5",
|
||||
model_arch="VoxCPMForConditionalGeneration",
|
||||
)
|
||||
args.create_model_config()
|
||||
|
||||
assert args.hf_overrides["architectures"] == ["VoxCPMForConditionalGeneration"]
|
||||
assert args.hf_overrides["model_type"] == "voxcpm"
|
||||
args._patch_empty_hf_config.assert_called_once_with("voxcpm")
|
||||
|
||||
|
||||
def test_strip_single_engine_args():
|
||||
"""_strip_single_engine_args should remove EngineArgs fields but keep omni fields."""
|
||||
kwargs = {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""UTs for VoxCPM OpenAI speech serving behavior."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voxcpm_server(mocker: MockerFixture):
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_supported_speakers", return_value=set())
|
||||
mocker.patch.object(OmniOpenAIServingSpeech, "_load_codec_frame_rate", return_value=None)
|
||||
|
||||
mock_engine_client = mocker.MagicMock()
|
||||
mock_engine_client.errored = False
|
||||
mock_engine_client.model_config = mocker.MagicMock(model="OpenBMB/VoxCPM1.5")
|
||||
mock_engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)]
|
||||
mock_engine_client.tts_batch_max_items = 32
|
||||
mock_engine_client.generate = mocker.MagicMock(return_value="generator")
|
||||
mock_engine_client.stage_configs = [
|
||||
SimpleNamespace(
|
||||
engine_args=SimpleNamespace(
|
||||
model_stage="latent_generator",
|
||||
model_arch="VoxCPMForConditionalGeneration",
|
||||
),
|
||||
tts_args={},
|
||||
),
|
||||
SimpleNamespace(
|
||||
engine_args=SimpleNamespace(model_stage="vae"),
|
||||
tts_args={},
|
||||
),
|
||||
]
|
||||
|
||||
mock_models = mocker.MagicMock()
|
||||
mock_models.is_base_model.return_value = True
|
||||
|
||||
return OmniOpenAIServingSpeech(
|
||||
engine_client=mock_engine_client,
|
||||
models=mock_models,
|
||||
request_logger=mocker.MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
class TestVoxCPMServing:
|
||||
def test_voxcpm_model_type_detection(self, voxcpm_server):
|
||||
assert voxcpm_server._tts_model_type == "voxcpm"
|
||||
assert voxcpm_server._is_tts is True
|
||||
assert voxcpm_server.supported_speakers == set()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_kwargs", "expected_substring"),
|
||||
[
|
||||
({"voice": "alice"}, "voice"),
|
||||
({"instructions": "whisper"}, "instructions"),
|
||||
({"language": "en"}, "language"),
|
||||
({"task_type": "CustomVoice"}, "plain tts"),
|
||||
({"x_vector_only_mode": True}, "x_vector_only_mode"),
|
||||
({"speaker_embedding": [0.1, 0.2]}, "speaker_embedding"),
|
||||
({"initial_codec_chunk_frames": 4}, "initial_codec_chunk_frames"),
|
||||
({"ref_text": "reference"}, "ref_audio"),
|
||||
],
|
||||
)
|
||||
def test_validate_voxcpm_rejects_unsupported_fields(self, voxcpm_server, request_kwargs, expected_substring):
|
||||
request = OpenAICreateSpeechRequest(input="hello voxcpm", **request_kwargs)
|
||||
error = voxcpm_server._validate_voxcpm_request(request)
|
||||
assert error is not None
|
||||
assert expected_substring in error.lower()
|
||||
|
||||
def test_validate_voxcpm_accepts_plain_tts_request(self, voxcpm_server):
|
||||
request = OpenAICreateSpeechRequest(input="hello voxcpm", max_new_tokens=256)
|
||||
assert voxcpm_server._validate_voxcpm_request(request) is None
|
||||
|
||||
def test_validate_voxcpm_accepts_voice_clone_request(self, voxcpm_server):
|
||||
request = OpenAICreateSpeechRequest(
|
||||
input="clone this voice",
|
||||
ref_audio="data:audio/wav;base64,QUJD",
|
||||
ref_text="reference transcript",
|
||||
max_new_tokens=256,
|
||||
)
|
||||
assert voxcpm_server._validate_voxcpm_request(request) is None
|
||||
|
||||
def test_prepare_speech_generation_voxcpm_text_only(self, voxcpm_server):
|
||||
request = OpenAICreateSpeechRequest(input="hello voxcpm", max_new_tokens=321)
|
||||
|
||||
request_id, generator, tts_params = asyncio.run(voxcpm_server._prepare_speech_generation(request))
|
||||
|
||||
assert request_id.startswith("speech-")
|
||||
assert generator == "generator"
|
||||
assert tts_params == {
|
||||
"text": ["hello voxcpm"],
|
||||
"cfg_value": [2.0],
|
||||
"inference_timesteps": [10],
|
||||
"min_len": [2],
|
||||
"max_new_tokens": [321],
|
||||
}
|
||||
|
||||
voxcpm_server.engine_client.generate.assert_called_once()
|
||||
call = voxcpm_server.engine_client.generate.call_args
|
||||
assert call.kwargs["prompt"] == {
|
||||
"prompt_token_ids": [1],
|
||||
"additional_information": tts_params,
|
||||
}
|
||||
assert call.kwargs["output_modalities"] == ["audio"]
|
||||
|
||||
def test_prepare_speech_generation_voxcpm_voice_clone_resolves_ref_audio(self, voxcpm_server):
|
||||
voxcpm_server._resolve_ref_audio = AsyncMock(return_value=([0.1, -0.1, 0.2], 16000))
|
||||
request = OpenAICreateSpeechRequest(
|
||||
input="clone this voice",
|
||||
ref_audio="data:audio/wav;base64,QUJD",
|
||||
ref_text="reference transcript",
|
||||
max_new_tokens=512,
|
||||
)
|
||||
|
||||
request_id, generator, tts_params = asyncio.run(voxcpm_server._prepare_speech_generation(request))
|
||||
|
||||
assert request_id.startswith("speech-")
|
||||
assert generator == "generator"
|
||||
assert tts_params == {
|
||||
"text": ["clone this voice"],
|
||||
"cfg_value": [2.0],
|
||||
"inference_timesteps": [10],
|
||||
"min_len": [2],
|
||||
"max_new_tokens": [512],
|
||||
"ref_text": ["reference transcript"],
|
||||
"ref_audio": [[[0.1, -0.1, 0.2], 16000]],
|
||||
}
|
||||
|
||||
voxcpm_server._resolve_ref_audio.assert_awaited_once_with("data:audio/wav;base64,QUJD")
|
||||
call = voxcpm_server.engine_client.generate.call_args
|
||||
assert call.kwargs["prompt"] == {
|
||||
"prompt_token_ids": [1],
|
||||
"additional_information": tts_params,
|
||||
}
|
||||
@@ -310,6 +310,39 @@ class TestResolveModelConfigPath:
|
||||
assert result is not None
|
||||
assert "glm_image.yaml" in result
|
||||
|
||||
def test_voxcpm_transformers_format_resolution(self, mocker: MockerFixture):
|
||||
"""Test VoxCPM transformers config resolves to the voxcpm stage config."""
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.utils.get_config",
|
||||
side_effect=ValueError("missing transformers config"),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.utils.file_or_path_exists",
|
||||
side_effect=lambda _model, filename, revision=None: filename == "config.json",
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.utils.get_hf_file_to_dict",
|
||||
return_value={"model_type": "voxcpm"},
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.entrypoints.utils.current_omni_platform.get_default_stage_config_path",
|
||||
return_value="vllm_omni/model_executor/stage_configs",
|
||||
)
|
||||
|
||||
original_exists = os.path.exists
|
||||
|
||||
def mock_exists(path):
|
||||
if "voxcpm.yaml" in str(path):
|
||||
return True
|
||||
return original_exists(path)
|
||||
|
||||
mocker.patch("os.path.exists", side_effect=mock_exists)
|
||||
|
||||
result = resolve_model_config_path("OpenBMB/VoxCPM1.5")
|
||||
|
||||
assert result is not None
|
||||
assert "voxcpm.yaml" in result
|
||||
|
||||
|
||||
class TestLoadAndResolveStageConfigs:
|
||||
def test_load_and_resolve_with_kwargs(self):
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""UTs for VoxCPM async-chunk stage input processing."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.voxcpm import (
|
||||
_VOXCPM_LATENT_MAGIC,
|
||||
_coerce_finished_flag,
|
||||
latent2vae_async_chunk,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _request(*, finished):
|
||||
return SimpleNamespace(is_finished=lambda: finished)
|
||||
|
||||
|
||||
def _decode_serialized_latent(codes: list[int]) -> torch.Tensor:
|
||||
assert codes[0] == _VOXCPM_LATENT_MAGIC
|
||||
latent_dim = codes[1]
|
||||
time_dim = codes[2]
|
||||
payload = torch.tensor(codes[3:], dtype=torch.int32).to(torch.uint16)
|
||||
return payload.view(torch.bfloat16).to(torch.float32).reshape(1, latent_dim, time_dim)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, False),
|
||||
(False, False),
|
||||
(True, True),
|
||||
(torch.tensor(False), False),
|
||||
(torch.tensor(True), True),
|
||||
([torch.tensor(True)], True),
|
||||
(([True],), True),
|
||||
([], False),
|
||||
],
|
||||
)
|
||||
def test_coerce_finished_flag(value, expected):
|
||||
assert _coerce_finished_flag(value) is expected
|
||||
|
||||
|
||||
def test_latent2vae_async_chunk_serializes_latent_payload():
|
||||
latent = torch.arange(6, dtype=torch.float32).reshape(2, 3)
|
||||
|
||||
payload = latent2vae_async_chunk(
|
||||
transfer_manager=None,
|
||||
pooling_output={"latent_audio_feat": latent},
|
||||
request=_request(finished=False),
|
||||
is_finished=torch.tensor(False),
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert torch.equal(payload["finished"], torch.tensor(False, dtype=torch.bool))
|
||||
recovered = _decode_serialized_latent(payload["code_predictor_codes"])
|
||||
torch.testing.assert_close(recovered, latent.to(torch.bfloat16).to(torch.float32).unsqueeze(0))
|
||||
|
||||
|
||||
def test_latent2vae_async_chunk_returns_terminal_marker_without_latent():
|
||||
payload = latent2vae_async_chunk(
|
||||
transfer_manager=None,
|
||||
pooling_output=None,
|
||||
request=_request(finished=[torch.tensor(True)]),
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"code_predictor_codes": [],
|
||||
"finished": torch.tensor(True, dtype=torch.bool),
|
||||
}
|
||||
|
||||
|
||||
def test_latent2vae_async_chunk_returns_none_for_nonterminal_empty_chunk():
|
||||
payload = latent2vae_async_chunk(
|
||||
transfer_manager=None,
|
||||
pooling_output={"latent_audio_feat": torch.zeros((0,), dtype=torch.float32)},
|
||||
request=_request(finished=False),
|
||||
is_finished=False,
|
||||
)
|
||||
|
||||
assert payload is None
|
||||
@@ -21,6 +21,7 @@ _ARCH_TO_MODEL_TYPE: dict[str, str] = {
|
||||
"CosyVoice3Model": "cosyvoice3",
|
||||
"OmniVoiceModel": "omnivoice",
|
||||
"VoxCPM2TalkerForConditionalGeneration": "voxcpm2",
|
||||
"VoxCPMForConditionalGeneration": "voxcpm",
|
||||
}
|
||||
|
||||
# Maps model architecture names to tokenizer subfolder paths within HF repos.
|
||||
@@ -41,6 +42,7 @@ def _register_omni_hf_configs() -> None:
|
||||
from vllm_omni.model_executor.models.voxtral_tts.configuration_voxtral_tts import (
|
||||
VoxtralTTSConfig,
|
||||
)
|
||||
from vllm_omni.transformers_utils.configs.voxcpm import VoxCPMConfig
|
||||
from vllm_omni.transformers_utils.configs.voxcpm2 import VoxCPM2Config
|
||||
except Exception as exc: # pragma: no cover - best-effort optional registration
|
||||
logger.warning("Skipping omni HF config registration due to import error: %s", exc)
|
||||
@@ -59,6 +61,7 @@ def _register_omni_hf_configs() -> None:
|
||||
("cosyvoice3", CosyVoice3Config),
|
||||
("omnivoice", OmniVoiceConfig),
|
||||
("voxtral_tts", VoxtralTTSConfig),
|
||||
("voxcpm", VoxCPMConfig),
|
||||
("voxcpm2", VoxCPM2Config),
|
||||
]:
|
||||
try:
|
||||
|
||||
@@ -49,6 +49,7 @@ _QWEN3_TTS_MODEL_STAGES = {"qwen3_tts"}
|
||||
_FISH_TTS_MODEL_STAGES = {"fish_speech_slow_ar"}
|
||||
_COSYVOICE3_TTS_MODEL_STAGES = {"cosyvoice3_talker"}
|
||||
_OMNIVOICE_TTS_MODEL_STAGES = {"omnivoice_generator"}
|
||||
_VOXCPM_TTS_MODEL_STAGES = {"latent_generator", "vae"}
|
||||
_VOXCPM2_TTS_MODEL_STAGES = {"latent_generator"}
|
||||
_TTS_MODEL_STAGES: set[str] = (
|
||||
_VOXTRAL_TTS_MODEL_STAGES
|
||||
@@ -56,6 +57,7 @@ _TTS_MODEL_STAGES: set[str] = (
|
||||
| _FISH_TTS_MODEL_STAGES
|
||||
| _COSYVOICE3_TTS_MODEL_STAGES
|
||||
| _OMNIVOICE_TTS_MODEL_STAGES
|
||||
| _VOXCPM_TTS_MODEL_STAGES
|
||||
| _VOXCPM2_TTS_MODEL_STAGES
|
||||
)
|
||||
_TTS_LANGUAGES: set[str] = {
|
||||
@@ -282,6 +284,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
if self._tts_stage is None:
|
||||
return None
|
||||
model_stage = getattr(self._tts_stage.engine_args, "model_stage", None)
|
||||
model_arch = getattr(self._tts_stage.engine_args, "model_arch", None)
|
||||
if model_arch == "VoxCPM2TalkerForConditionalGeneration":
|
||||
return "voxcpm2"
|
||||
if model_arch == "VoxCPMForConditionalGeneration":
|
||||
return "voxcpm"
|
||||
if model_stage in _QWEN3_TTS_MODEL_STAGES:
|
||||
return "qwen3_tts"
|
||||
if model_stage in _VOXTRAL_TTS_MODEL_STAGES:
|
||||
@@ -292,8 +299,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
return "cosyvoice3"
|
||||
if model_stage in _OMNIVOICE_TTS_MODEL_STAGES:
|
||||
return "omnivoice"
|
||||
if model_stage in _VOXCPM2_TTS_MODEL_STAGES:
|
||||
return "voxcpm2"
|
||||
if model_stage in (_VOXCPM_TTS_MODEL_STAGES | _VOXCPM2_TTS_MODEL_STAGES):
|
||||
has_vae_stage = any(
|
||||
getattr(getattr(stage, "engine_args", None), "model_stage", None) == "vae"
|
||||
for stage in self.engine_client.stage_configs
|
||||
)
|
||||
return "voxcpm" if has_vae_stage or model_stage == "vae" else "voxcpm2"
|
||||
return None
|
||||
|
||||
def _compute_max_instructions_length(self) -> int:
|
||||
@@ -318,6 +329,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
def _load_supported_speakers(self) -> set[str]:
|
||||
"""Load supported speakers (case-insensitive) from the model configuration."""
|
||||
try:
|
||||
if self._tts_model_type == "voxcpm":
|
||||
return set()
|
||||
if self._tts_model_type == "voxtral_tts":
|
||||
config = self.engine_client.model_config.hf_config.audio_config
|
||||
else:
|
||||
@@ -377,6 +390,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
def _estimate_prompt_len(self, tts_params: dict[str, Any]) -> int:
|
||||
"""Estimate prompt length so the placeholder matches model-side embeddings."""
|
||||
try:
|
||||
if self._tts_model_type == "voxcpm":
|
||||
return 1
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
|
||||
Qwen3TTSTalkerForConditionalGeneration,
|
||||
)
|
||||
@@ -791,6 +806,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
return self._validate_fish_tts_request(request)
|
||||
if self._tts_model_type == "cosyvoice3":
|
||||
return self._validate_cosyvoice3_request(request)
|
||||
if self._tts_model_type == "voxcpm":
|
||||
return self._validate_voxcpm_request(request)
|
||||
if self._tts_model_type == "voxcpm2":
|
||||
return None # VoxCPM2 accepts any text input
|
||||
return self._validate_qwen_tts_request(request)
|
||||
@@ -832,6 +849,43 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
|
||||
return None
|
||||
|
||||
def _validate_voxcpm_request(self, request: OpenAICreateSpeechRequest) -> str | None:
|
||||
"""Validate VoxCPM request parameters. Returns error message or None."""
|
||||
if not request.input or not request.input.strip():
|
||||
return "Input text cannot be empty"
|
||||
|
||||
if request.voice is not None:
|
||||
return "'voice' is not supported for VoxCPM"
|
||||
if request.instructions is not None:
|
||||
return "'instructions' is not supported for VoxCPM"
|
||||
if request.language is not None:
|
||||
return "'language' is not supported for VoxCPM"
|
||||
if request.task_type not in (None, "Base"):
|
||||
return "VoxCPM only supports plain TTS or voice cloning with ref_audio/ref_text"
|
||||
if request.x_vector_only_mode is not None:
|
||||
return "'x_vector_only_mode' is not supported for VoxCPM"
|
||||
if request.speaker_embedding is not None:
|
||||
return "'speaker_embedding' is not supported for VoxCPM"
|
||||
if request.initial_codec_chunk_frames is not None:
|
||||
return "'initial_codec_chunk_frames' is not supported for VoxCPM"
|
||||
|
||||
if request.ref_audio is not None:
|
||||
fmt_err = self._validate_ref_audio_format(request.ref_audio)
|
||||
if fmt_err:
|
||||
return fmt_err
|
||||
if not request.ref_text or not request.ref_text.strip():
|
||||
return "Voice cloning requires 'ref_text' (transcript of the reference audio)"
|
||||
elif request.ref_text is not None:
|
||||
return "'ref_text' requires 'ref_audio' for VoxCPM voice cloning"
|
||||
|
||||
if request.max_new_tokens is not None:
|
||||
if request.max_new_tokens < _TTS_MAX_NEW_TOKENS_MIN:
|
||||
return f"max_new_tokens must be at least {_TTS_MAX_NEW_TOKENS_MIN}"
|
||||
if request.max_new_tokens > _TTS_MAX_NEW_TOKENS_MAX:
|
||||
return f"max_new_tokens cannot exceed {_TTS_MAX_NEW_TOKENS_MAX}"
|
||||
|
||||
return None
|
||||
|
||||
def _validate_qwen_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None:
|
||||
"""Validate Qwen TTS request parameters. Returns error message or None."""
|
||||
# Infer Base task when ref_audio or ref_text is provided without explicit task_type.
|
||||
@@ -1169,6 +1223,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
Processes each parameter if present, skips if not.
|
||||
Values are wrapped in lists as required by the model.
|
||||
"""
|
||||
if self._tts_model_type == "voxcpm":
|
||||
params: dict[str, Any] = {
|
||||
"text": [request.input],
|
||||
"cfg_value": [2.0],
|
||||
"inference_timesteps": [10],
|
||||
"min_len": [2],
|
||||
"max_new_tokens": [request.max_new_tokens or 4096],
|
||||
}
|
||||
if request.ref_text is not None:
|
||||
params["ref_text"] = [request.ref_text]
|
||||
return params
|
||||
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
# Text content (always required)
|
||||
@@ -1499,6 +1565,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
model_type = "voxtral_tts"
|
||||
elif self._tts_model_type == "cosyvoice3":
|
||||
model_type = "cosyvoice3"
|
||||
elif self._tts_model_type == "voxcpm":
|
||||
model_type = "voxcpm"
|
||||
elif self._tts_model_type == "voxcpm2":
|
||||
model_type = "voxcpm2"
|
||||
elif self._is_tts:
|
||||
|
||||
@@ -145,6 +145,12 @@ _OMNI_MODELS = {
|
||||
"fish_speech_dac_decoder",
|
||||
"FishSpeechDACDecoder",
|
||||
),
|
||||
## VoxCPM
|
||||
"VoxCPMForConditionalGeneration": (
|
||||
"voxcpm",
|
||||
"voxcpm",
|
||||
"VoxCPMForConditionalGeneration",
|
||||
),
|
||||
## VoxCPM2
|
||||
"VoxCPM2TalkerForConditionalGeneration": (
|
||||
"voxcpm2",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from .configuration_voxcpm import VoxCPMConfig
|
||||
from .voxcpm import VoxCPMForConditionalGeneration
|
||||
|
||||
__all__ = [
|
||||
"VoxCPMConfig",
|
||||
"VoxCPMForConditionalGeneration",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
from vllm_omni.transformers_utils.configs.voxcpm import VoxCPMConfig
|
||||
|
||||
__all__ = ["VoxCPMConfig"]
|
||||
@@ -0,0 +1,886 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
import wave
|
||||
from collections.abc import Callable, Generator, Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from einops import rearrange
|
||||
from tqdm import tqdm
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
from vllm.sequence import IntermediateTensors
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
|
||||
from .voxcpm_loader import (
|
||||
_build_prompt_cache_with_soundfile,
|
||||
_device_to_string,
|
||||
_force_cuda_available_for_npu,
|
||||
_import_voxcpm_audio_vae_classes,
|
||||
_import_voxcpm_base_model_class,
|
||||
_is_torchcodec_load_error,
|
||||
_normalize_dtype_name,
|
||||
_prepare_runtime_model_dir,
|
||||
_resolve_runtime_device,
|
||||
)
|
||||
from .voxcpm_runtime_utils import resolve_voxcpm_model_dir
|
||||
from .voxcpm_stage_wrappers import _DirectVoxCPMAudioVAE, _DirectVoxCPMLatentGenerator
|
||||
|
||||
logger = init_logger(__name__)
|
||||
_VOXCPM_LATENT_MAGIC = 131071
|
||||
|
||||
|
||||
def _make_voxcpm_model_for_omni(base: type[Any]) -> type[Any]:
|
||||
"""Subclass upstream VoxCPMModel: local ``_inference`` + ``latents_only`` prompt-cache generation."""
|
||||
|
||||
from voxcpm.model.utils import get_dtype
|
||||
|
||||
class VoxCPMModelForOmni(base):
|
||||
@torch.inference_mode()
|
||||
def build_prompt_cache(self, *args: Any, **kwargs: Any):
|
||||
try:
|
||||
return super().build_prompt_cache(*args, **kwargs)
|
||||
except (ImportError, ModuleNotFoundError, RuntimeError) as exc:
|
||||
if not _is_torchcodec_load_error(exc):
|
||||
raise
|
||||
return _build_prompt_cache_with_soundfile(self, *args, **kwargs)
|
||||
|
||||
@torch.inference_mode()
|
||||
def _inference(
|
||||
self,
|
||||
text: torch.Tensor,
|
||||
text_mask: torch.Tensor,
|
||||
feat: torch.Tensor,
|
||||
feat_mask: torch.Tensor,
|
||||
min_len: int = 2,
|
||||
max_len: int = 2000,
|
||||
inference_timesteps: int = 10,
|
||||
cfg_value: float = 2.0,
|
||||
streaming: bool = False,
|
||||
streaming_prefix_len: int = 3,
|
||||
) -> Generator[tuple[torch.Tensor, torch.Tensor | list[torch.Tensor]], None, None]:
|
||||
B, _, _, _ = feat.shape
|
||||
|
||||
feat_embed = self.feat_encoder(feat)
|
||||
feat_embed = self.enc_to_lm_proj(feat_embed)
|
||||
|
||||
scale_emb = self.config.lm_config.scale_emb if self.config.lm_config.use_mup else 1.0
|
||||
text_embed = self.base_lm.embed_tokens(text) * scale_emb
|
||||
combined_embed = text_mask.unsqueeze(-1) * text_embed + feat_mask.unsqueeze(-1) * feat_embed
|
||||
|
||||
prefix_feat_cond = feat[:, -1, ...]
|
||||
pred_feat_seq: list[torch.Tensor] = []
|
||||
|
||||
audio_patch_count = int(feat_mask.sum().item())
|
||||
if audio_patch_count > 0:
|
||||
context_len = min(streaming_prefix_len - 1, audio_patch_count)
|
||||
prompt_context_patches = list(feat[:, -context_len:, :, :].split(1, dim=1))
|
||||
pred_feat_seq = prompt_context_patches + pred_feat_seq
|
||||
|
||||
enc_outputs, kv_cache_tuple = self.base_lm(
|
||||
inputs_embeds=combined_embed,
|
||||
is_causal=True,
|
||||
)
|
||||
self.base_lm.kv_cache.fill_caches(kv_cache_tuple)
|
||||
|
||||
enc_outputs = self.fsq_layer(enc_outputs) * feat_mask.unsqueeze(-1) + enc_outputs * text_mask.unsqueeze(-1)
|
||||
lm_hidden = enc_outputs[:, -1, :]
|
||||
|
||||
residual_enc_outputs, residual_kv_cache_tuple = self.residual_lm(
|
||||
inputs_embeds=enc_outputs + feat_mask.unsqueeze(-1) * feat_embed,
|
||||
is_causal=True,
|
||||
)
|
||||
self.residual_lm.kv_cache.fill_caches(residual_kv_cache_tuple)
|
||||
residual_hidden = residual_enc_outputs[:, -1, :]
|
||||
|
||||
for step_idx in tqdm(range(max_len)):
|
||||
dit_hidden = self.lm_to_dit_proj(lm_hidden) + self.res_to_dit_proj(residual_hidden)
|
||||
pred_feat = self.feat_decoder(
|
||||
mu=dit_hidden,
|
||||
patch_size=self.patch_size,
|
||||
cond=prefix_feat_cond.transpose(1, 2).contiguous(),
|
||||
n_timesteps=inference_timesteps,
|
||||
cfg_value=cfg_value,
|
||||
).transpose(1, 2)
|
||||
|
||||
curr_embed = self.enc_to_lm_proj(self.feat_encoder(pred_feat.unsqueeze(1)))
|
||||
pred_feat_seq.append(pred_feat.unsqueeze(1))
|
||||
prefix_feat_cond = pred_feat
|
||||
|
||||
if streaming:
|
||||
pred_feat_chunk = torch.cat(pred_feat_seq[-streaming_prefix_len:], dim=1)
|
||||
feat_pred = rearrange(pred_feat_chunk, "b t p d -> b d (t p)", b=B, p=self.patch_size)
|
||||
yield feat_pred, pred_feat_seq
|
||||
|
||||
stop_flag = self.stop_head(self.stop_actn(self.stop_proj(lm_hidden))).argmax(dim=-1)[0].cpu().item()
|
||||
if step_idx > min_len and stop_flag == 1:
|
||||
break
|
||||
|
||||
lm_hidden = self.base_lm.forward_step(
|
||||
curr_embed[:, 0, :],
|
||||
torch.tensor([self.base_lm.kv_cache.step()], device=curr_embed.device),
|
||||
).clone()
|
||||
lm_hidden = self.fsq_layer(lm_hidden)
|
||||
residual_hidden = self.residual_lm.forward_step(
|
||||
lm_hidden + curr_embed[:, 0, :],
|
||||
torch.tensor([self.residual_lm.kv_cache.step()], device=curr_embed.device),
|
||||
).clone()
|
||||
|
||||
if not streaming:
|
||||
pred_feat_seq_cat = torch.cat(pred_feat_seq, dim=1)
|
||||
feat_pred = rearrange(pred_feat_seq_cat, "b t p d -> b d (t p)", b=B, p=self.patch_size)
|
||||
yield feat_pred, pred_feat_seq_cat.squeeze(0).cpu()
|
||||
|
||||
@torch.inference_mode()
|
||||
def generate_latents_with_prompt_cache(
|
||||
self,
|
||||
target_text: str,
|
||||
prompt_cache: dict,
|
||||
min_len: int = 2,
|
||||
max_len: int = 2000,
|
||||
inference_timesteps: int = 10,
|
||||
cfg_value: float = 2.0,
|
||||
retry_badcase: bool = False,
|
||||
retry_badcase_max_times: int = 3,
|
||||
retry_badcase_ratio_threshold: float = 6.0,
|
||||
streaming_prefix_len: int = 3,
|
||||
) -> tuple[None, torch.Tensor, torch.Tensor]:
|
||||
return next(
|
||||
self._generate_with_prompt_cache(
|
||||
target_text=target_text,
|
||||
prompt_cache=prompt_cache,
|
||||
min_len=min_len,
|
||||
max_len=max_len,
|
||||
inference_timesteps=inference_timesteps,
|
||||
cfg_value=cfg_value,
|
||||
retry_badcase=retry_badcase,
|
||||
retry_badcase_max_times=retry_badcase_max_times,
|
||||
retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
|
||||
streaming=False,
|
||||
streaming_prefix_len=streaming_prefix_len,
|
||||
latents_only=True,
|
||||
)
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
def generate_latents_with_prompt_cache_streaming(
|
||||
self,
|
||||
target_text: str,
|
||||
prompt_cache: dict,
|
||||
min_len: int = 2,
|
||||
max_len: int = 2000,
|
||||
inference_timesteps: int = 10,
|
||||
cfg_value: float = 2.0,
|
||||
retry_badcase: bool = False,
|
||||
retry_badcase_max_times: int = 3,
|
||||
retry_badcase_ratio_threshold: float = 6.0,
|
||||
streaming_prefix_len: int = 3,
|
||||
) -> Generator[tuple[None, torch.Tensor, torch.Tensor], None, None]:
|
||||
return self._generate_with_prompt_cache(
|
||||
target_text=target_text,
|
||||
prompt_cache=prompt_cache,
|
||||
min_len=min_len,
|
||||
max_len=max_len,
|
||||
inference_timesteps=inference_timesteps,
|
||||
cfg_value=cfg_value,
|
||||
retry_badcase=retry_badcase,
|
||||
retry_badcase_max_times=retry_badcase_max_times,
|
||||
retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
|
||||
streaming=True,
|
||||
streaming_prefix_len=streaming_prefix_len,
|
||||
latents_only=True,
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
def _generate_with_prompt_cache(
|
||||
self,
|
||||
target_text: str,
|
||||
prompt_cache: dict,
|
||||
min_len: int = 2,
|
||||
max_len: int = 2000,
|
||||
inference_timesteps: int = 10,
|
||||
cfg_value: float = 2.0,
|
||||
retry_badcase: bool = False,
|
||||
retry_badcase_max_times: int = 3,
|
||||
retry_badcase_ratio_threshold: float = 6.0,
|
||||
streaming: bool = False,
|
||||
streaming_prefix_len: int = 3,
|
||||
latents_only: bool = False,
|
||||
) -> Generator[tuple[torch.Tensor | None, torch.Tensor, torch.Tensor | list[torch.Tensor]], None, None]:
|
||||
if retry_badcase and streaming:
|
||||
warnings.warn("Retry on bad cases is not supported in streaming mode, setting retry_badcase=False.")
|
||||
retry_badcase = False
|
||||
if prompt_cache is None:
|
||||
prompt_audio_feat = torch.empty((0, self.patch_size, self.audio_vae.latent_dim), dtype=torch.float32)
|
||||
text = target_text
|
||||
else:
|
||||
prompt_audio_feat = prompt_cache["audio_feat"]
|
||||
prompt_text = prompt_cache["prompt_text"]
|
||||
text = prompt_text + target_text
|
||||
|
||||
text_token = torch.LongTensor(self.text_tokenizer(text))
|
||||
text_token = torch.cat(
|
||||
[
|
||||
text_token,
|
||||
torch.tensor([self.audio_start_token], dtype=torch.int32, device=text_token.device),
|
||||
],
|
||||
dim=-1,
|
||||
)
|
||||
target_text_token = torch.LongTensor(self.text_tokenizer(target_text))
|
||||
|
||||
audio_length = prompt_audio_feat.size(0)
|
||||
text_length = text_token.shape[0]
|
||||
text_pad_token = torch.zeros(audio_length, dtype=torch.int32, device=text_token.device)
|
||||
audio_pad_feat = torch.zeros(
|
||||
(text_token.shape[0], self.patch_size, self.audio_vae.latent_dim),
|
||||
dtype=torch.float32,
|
||||
device=text_token.device,
|
||||
)
|
||||
text_token = torch.cat([text_token, text_pad_token])
|
||||
audio_feat = torch.cat([audio_pad_feat, prompt_audio_feat], dim=0)
|
||||
text_mask = (
|
||||
torch.cat([torch.ones(text_length), torch.zeros(audio_length)]).type(torch.int32).to(text_token.device)
|
||||
)
|
||||
audio_mask = (
|
||||
torch.cat([torch.zeros(text_length), torch.ones(audio_length)]).type(torch.int32).to(text_token.device)
|
||||
)
|
||||
|
||||
text_token = text_token.unsqueeze(0).to(self.device)
|
||||
text_mask = text_mask.unsqueeze(0).to(self.device)
|
||||
audio_feat = audio_feat.unsqueeze(0).to(self.device).to(get_dtype(self.config.dtype))
|
||||
audio_mask = audio_mask.unsqueeze(0).to(self.device)
|
||||
|
||||
target_text_length = len(self.text_tokenizer(target_text))
|
||||
retry_badcase_times = 0
|
||||
while retry_badcase_times < retry_badcase_max_times:
|
||||
inference_result = self._inference(
|
||||
text_token,
|
||||
text_mask,
|
||||
audio_feat,
|
||||
audio_mask,
|
||||
min_len=min_len,
|
||||
max_len=min(int(target_text_length * retry_badcase_ratio_threshold + 10), max_len),
|
||||
inference_timesteps=inference_timesteps,
|
||||
cfg_value=cfg_value,
|
||||
streaming=streaming,
|
||||
streaming_prefix_len=streaming_prefix_len,
|
||||
)
|
||||
if streaming:
|
||||
patch_len = self.patch_size * self.chunk_size
|
||||
for latent_pred, pred_audio_feat in inference_result:
|
||||
if latents_only:
|
||||
decode_audio = None
|
||||
yield (decode_audio, target_text_token, latent_pred)
|
||||
else:
|
||||
decode_audio = self.audio_vae.decode(latent_pred.to(torch.float32))
|
||||
decode_audio = decode_audio[..., -patch_len:].squeeze(1).cpu()
|
||||
yield (decode_audio, target_text_token, pred_audio_feat)
|
||||
break
|
||||
|
||||
latent_pred, pred_audio_feat = next(inference_result)
|
||||
if retry_badcase and pred_audio_feat.shape[0] >= target_text_length * retry_badcase_ratio_threshold:
|
||||
ratio = pred_audio_feat.shape[0] / target_text_length
|
||||
print(f" Badcase detected, audio_text_ratio={ratio}, retrying...", file=sys.stderr)
|
||||
retry_badcase_times += 1
|
||||
continue
|
||||
break
|
||||
|
||||
if not streaming:
|
||||
if latents_only:
|
||||
decode_audio = None
|
||||
else:
|
||||
decode_audio = self.audio_vae.decode(latent_pred.to(torch.float32))
|
||||
patch_len = self.patch_size * self.chunk_size
|
||||
if audio_mask.sum().item() > 0:
|
||||
decode_audio = decode_audio[..., patch_len * (streaming_prefix_len - 1) :].squeeze(1).cpu()
|
||||
else:
|
||||
decode_audio = decode_audio[..., :].squeeze(1).cpu()
|
||||
yield (decode_audio, target_text_token, pred_audio_feat)
|
||||
|
||||
VoxCPMModelForOmni.__name__ = "VoxCPMModelForOmni"
|
||||
VoxCPMModelForOmni.__qualname__ = "VoxCPMModelForOmni"
|
||||
return VoxCPMModelForOmni
|
||||
|
||||
|
||||
def _import_voxcpm_model_class() -> type[Any]:
|
||||
base = _import_voxcpm_base_model_class()
|
||||
return _make_voxcpm_model_for_omni(base)
|
||||
|
||||
|
||||
def _load_native_voxcpm_model(
|
||||
model_path: str,
|
||||
*,
|
||||
device: torch.device,
|
||||
dtype: str | None,
|
||||
):
|
||||
VoxCPMModel = _import_voxcpm_model_class()
|
||||
model_dir = resolve_voxcpm_model_dir(model_path)
|
||||
runtime_model_path = _prepare_runtime_model_dir(model_dir, target_device=device, target_dtype=dtype)
|
||||
|
||||
if device.type == "npu" and hasattr(torch, "npu"):
|
||||
torch.npu.set_device(device)
|
||||
|
||||
with _force_cuda_available_for_npu(device):
|
||||
return VoxCPMModel.from_local(
|
||||
runtime_model_path,
|
||||
optimize=device.type == "cuda",
|
||||
)
|
||||
|
||||
|
||||
def _load_native_voxcpm_latent_generator(
|
||||
model_path: str,
|
||||
*,
|
||||
device: torch.device,
|
||||
dtype: str | None,
|
||||
) -> _DirectVoxCPMLatentGenerator:
|
||||
return _DirectVoxCPMLatentGenerator(_load_native_voxcpm_model(model_path, device=device, dtype=dtype))
|
||||
|
||||
|
||||
def _load_native_voxcpm_audio_vae(
|
||||
model_path: str,
|
||||
*,
|
||||
device: torch.device,
|
||||
) -> _DirectVoxCPMAudioVAE:
|
||||
AudioVAE, AudioVAEConfig = _import_voxcpm_audio_vae_classes()
|
||||
model_dir = resolve_voxcpm_model_dir(model_path)
|
||||
runtime_model_path = _prepare_runtime_model_dir(model_dir, target_device=device, target_dtype="float32")
|
||||
config_dict = json.loads((Path(runtime_model_path) / "config.json").read_text())
|
||||
audio_vae_config = config_dict.get("audio_vae_config")
|
||||
audio_vae = AudioVAE(config=AudioVAEConfig(**audio_vae_config)) if audio_vae_config is not None else AudioVAE()
|
||||
|
||||
state_dict = torch.load(
|
||||
Path(runtime_model_path) / "audiovae.pth",
|
||||
map_location="cpu",
|
||||
weights_only=True,
|
||||
)["state_dict"]
|
||||
audio_vae.load_state_dict(state_dict, strict=True)
|
||||
audio_vae = audio_vae.to(device=device, dtype=torch.float32).eval()
|
||||
if device.type == "npu" and hasattr(torch, "npu"):
|
||||
torch.npu.set_device(device)
|
||||
patch_size = int(config_dict.get("patch_size", 2))
|
||||
return _DirectVoxCPMAudioVAE(audio_vae, patch_size=patch_size)
|
||||
|
||||
|
||||
class VoxCPMForConditionalGeneration(nn.Module):
|
||||
input_modalities = "audio"
|
||||
_LATENT_STAGES = {"latent_generator", "latent", "ar_dit"}
|
||||
_VAE_STAGES = {"vae", "audio_vae"}
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
del prefix
|
||||
self.vllm_config = vllm_config
|
||||
self.model_path = vllm_config.model_config.model
|
||||
self.model_stage = getattr(vllm_config.model_config, "model_stage", "latent_generator")
|
||||
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
|
||||
self.inject_omni_request_id_into_runtime_info = True
|
||||
self._pipeline = None
|
||||
self._latent_stream_gens: dict[str, Any] = {}
|
||||
self._latent_stream_terminal_pending: dict[str, int] = {}
|
||||
self._latent_stream_completed: set[str] = set()
|
||||
self._next_local_stream_key = 0
|
||||
self._ar_emit_stop_token = True
|
||||
|
||||
def _runner_hidden_device_dtype(self) -> tuple[torch.device, torch.dtype]:
|
||||
device = _resolve_runtime_device(self.vllm_config)
|
||||
model_config = getattr(self.vllm_config, "model_config", None)
|
||||
dtype = getattr(model_config, "dtype", torch.float32) if model_config is not None else torch.float32
|
||||
return device, dtype
|
||||
|
||||
def _ensure_model_loaded(self):
|
||||
if self._pipeline is not None:
|
||||
return
|
||||
|
||||
target_device = _resolve_runtime_device(self.vllm_config)
|
||||
model_dtype = getattr(self.vllm_config.model_config, "dtype", None)
|
||||
normalized_dtype = _normalize_dtype_name(model_dtype)
|
||||
if self.model_stage in self._LATENT_STAGES:
|
||||
self._pipeline = _load_native_voxcpm_latent_generator(
|
||||
self.model_path,
|
||||
device=target_device,
|
||||
dtype=normalized_dtype,
|
||||
)
|
||||
elif self.model_stage in self._VAE_STAGES:
|
||||
self._pipeline = _load_native_voxcpm_audio_vae(
|
||||
self.model_path,
|
||||
device=target_device,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported VoxCPM model_stage: {self.model_stage}. "
|
||||
"pure_voxcpm only supports split-stage latent_generator/vae inference."
|
||||
)
|
||||
|
||||
logger.info("Loaded VoxCPM stage '%s' on %s", self.model_stage, _device_to_string(target_device))
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
del weights
|
||||
self._ensure_model_loaded()
|
||||
return set()
|
||||
|
||||
@staticmethod
|
||||
def _extract_val(info: dict[str, Any], key: str, default: Any) -> Any:
|
||||
value = info.get(key, default)
|
||||
if isinstance(value, list):
|
||||
return value[0] if value else default
|
||||
return value
|
||||
|
||||
def _resolve_stream_request_key(self, info: dict[str, Any]) -> str:
|
||||
request_key = info.get("__voxcpm_stream_key")
|
||||
if request_key is not None:
|
||||
return str(request_key)
|
||||
|
||||
request_key = info.get("_omni_req_id")
|
||||
if request_key is not None:
|
||||
request_key = str(request_key)
|
||||
info["__voxcpm_stream_key"] = request_key
|
||||
return request_key
|
||||
|
||||
request_key = f"voxcpm-local-{self._next_local_stream_key}"
|
||||
self._next_local_stream_key += 1
|
||||
info["__voxcpm_stream_key"] = request_key
|
||||
return str(request_key)
|
||||
|
||||
def _recover_latent_from_input_ids(self, input_ids: torch.Tensor | None) -> torch.Tensor | None:
|
||||
if input_ids is None or input_ids.numel() == 0:
|
||||
return None
|
||||
flat_ids = input_ids.detach().reshape(-1).to("cpu")
|
||||
if flat_ids.numel() < 4 or int(flat_ids[0].item()) != _VOXCPM_LATENT_MAGIC:
|
||||
return None
|
||||
latent_dim = int(flat_ids[1].item())
|
||||
time_dim = int(flat_ids[2].item())
|
||||
payload = flat_ids[3:]
|
||||
expected = latent_dim * time_dim
|
||||
if latent_dim <= 0 or time_dim <= 0:
|
||||
raise ValueError(f"Invalid VoxCPM latent header: latent_dim={latent_dim}, time_dim={time_dim}")
|
||||
if int(payload.numel()) != expected:
|
||||
raise ValueError(
|
||||
"Invalid VoxCPM latent payload size: "
|
||||
f"expected={expected}, actual={int(payload.numel())}, "
|
||||
f"latent_dim={latent_dim}, time_dim={time_dim}"
|
||||
)
|
||||
packed = payload.to(dtype=torch.int32).to(torch.uint16)
|
||||
return packed.view(torch.bfloat16).to(torch.float32).reshape(1, latent_dim, time_dim)
|
||||
|
||||
def _maybe_recover_vae_infos(
|
||||
self,
|
||||
infos: list[dict[str, Any]],
|
||||
input_ids: torch.Tensor | None,
|
||||
*,
|
||||
async_chunk: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not async_chunk:
|
||||
return infos
|
||||
if any(self._extract_val(info, "latent_audio_feat", None) is not None for info in infos):
|
||||
return infos
|
||||
recovered = self._recover_latent_from_input_ids(input_ids)
|
||||
if recovered is None:
|
||||
return infos
|
||||
return [{"latent_audio_feat": recovered}]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_audio_samples(samples: Any) -> np.ndarray:
|
||||
if isinstance(samples, torch.Tensor):
|
||||
return samples.detach().cpu().float().reshape(-1).numpy()
|
||||
return np.asarray(samples, dtype=np.float32).reshape(-1)
|
||||
|
||||
@classmethod
|
||||
def _normalize_ref_audio(cls, ref_audio: Any) -> tuple[np.ndarray, int]:
|
||||
if isinstance(ref_audio, str):
|
||||
raise TypeError("String ref_audio should be handled as a path before waveform normalization.")
|
||||
|
||||
if isinstance(ref_audio, dict):
|
||||
sample_rate = ref_audio.get("sample_rate") or ref_audio.get("sampling_rate") or ref_audio.get("sr")
|
||||
samples = None
|
||||
for key in ("audio", "wav", "samples", "array", "waveform"):
|
||||
if key in ref_audio and ref_audio[key] is not None:
|
||||
samples = ref_audio[key]
|
||||
break
|
||||
if sample_rate is None or samples is None:
|
||||
raise ValueError("ref_audio dict must contain waveform data and sample rate.")
|
||||
return cls._normalize_audio_samples(samples), int(sample_rate)
|
||||
|
||||
if isinstance(ref_audio, (list, tuple)):
|
||||
if len(ref_audio) == 1:
|
||||
return cls._normalize_ref_audio(ref_audio[0])
|
||||
if len(ref_audio) == 2 and np.isscalar(ref_audio[1]):
|
||||
return cls._normalize_audio_samples(ref_audio[0]), int(ref_audio[1])
|
||||
|
||||
raise TypeError(f"Unsupported ref_audio format: {type(ref_audio)!r}")
|
||||
|
||||
@staticmethod
|
||||
def _write_temp_prompt_wav(waveform: np.ndarray, sample_rate: int) -> str:
|
||||
prompt_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
|
||||
prompt_file.close()
|
||||
|
||||
wav = np.asarray(waveform, dtype=np.float32).reshape(-1)
|
||||
wav = np.clip(wav, -1.0, 1.0)
|
||||
pcm16 = (wav * 32767.0).astype(np.int16)
|
||||
with wave.open(prompt_file.name, "wb") as wav_file:
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(int(sample_rate))
|
||||
wav_file.writeframes(pcm16.tobytes())
|
||||
|
||||
return prompt_file.name
|
||||
|
||||
@classmethod
|
||||
def _resolve_prompt_inputs(cls, info: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
|
||||
prompt_text = cls._extract_val(info, "prompt_text", None)
|
||||
prompt_wav_path = cls._extract_val(info, "prompt_wav_path", None)
|
||||
if prompt_wav_path:
|
||||
if prompt_text is None:
|
||||
prompt_text = cls._extract_val(info, "ref_text", None)
|
||||
return prompt_wav_path, prompt_text, None
|
||||
|
||||
ref_audio = cls._extract_val(info, "ref_audio", None)
|
||||
ref_text = cls._extract_val(info, "ref_text", None)
|
||||
if ref_audio is None or ref_text is None:
|
||||
return None, None, None
|
||||
if isinstance(ref_audio, str):
|
||||
return ref_audio, ref_text, None
|
||||
|
||||
waveform, sample_rate = cls._normalize_ref_audio(ref_audio)
|
||||
temp_prompt_wav = cls._write_temp_prompt_wav(waveform, sample_rate)
|
||||
return temp_prompt_wav, ref_text, temp_prompt_wav
|
||||
|
||||
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
|
||||
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 _get_vocab_size(self) -> int:
|
||||
model_config = getattr(self.vllm_config, "model_config", None)
|
||||
if model_config is not None:
|
||||
getter = getattr(model_config, "get_vocab_size", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return int(getter())
|
||||
except Exception:
|
||||
pass
|
||||
hf_config = getattr(model_config, "hf_text_config", None)
|
||||
if hf_config is not None and hasattr(hf_config, "vocab_size"):
|
||||
return int(hf_config.vocab_size)
|
||||
return 32000
|
||||
|
||||
def _make_empty_output(
|
||||
self,
|
||||
*,
|
||||
output_key: str,
|
||||
payload_factory: Callable[[], torch.Tensor],
|
||||
infos: list[dict[str, Any]],
|
||||
sample_rate: int,
|
||||
out_device: torch.device,
|
||||
out_dtype: torch.dtype,
|
||||
hidden_rows: int | None = None,
|
||||
) -> OmniOutput:
|
||||
if hidden_rows is None:
|
||||
hidden_rows = len(infos)
|
||||
return OmniOutput(
|
||||
text_hidden_states=torch.zeros((hidden_rows, 1), device=out_device, dtype=out_dtype),
|
||||
multimodal_outputs={
|
||||
output_key: [payload_factory() for _ in infos],
|
||||
"sr": [torch.tensor(sample_rate, dtype=torch.int32) for _ in infos],
|
||||
},
|
||||
)
|
||||
|
||||
def _finalize_stage_output(
|
||||
self,
|
||||
*,
|
||||
output_key: str,
|
||||
outputs: list[torch.Tensor],
|
||||
sample_rates: list[torch.Tensor],
|
||||
out_device: torch.device,
|
||||
out_dtype: torch.dtype,
|
||||
hidden_rows: int | None = None,
|
||||
) -> OmniOutput:
|
||||
multimodal_outputs: dict[str, Any] = {output_key: outputs, "sr": sample_rates}
|
||||
if hidden_rows is not None:
|
||||
text_hidden_states = torch.zeros((hidden_rows, 1), device=out_device, dtype=out_dtype)
|
||||
elif outputs:
|
||||
outputs_tensor = torch.stack(outputs)
|
||||
text_hidden_states = (
|
||||
outputs_tensor.unsqueeze(-1)
|
||||
if outputs_tensor.ndim == 1
|
||||
else outputs_tensor.reshape(-1, outputs_tensor.shape[-1])
|
||||
)
|
||||
else:
|
||||
text_hidden_states = torch.zeros((0, 1), device=out_device, dtype=out_dtype)
|
||||
text_hidden_states = text_hidden_states.to(device=out_device, dtype=out_dtype)
|
||||
return OmniOutput(
|
||||
text_hidden_states=text_hidden_states,
|
||||
multimodal_outputs=multimodal_outputs,
|
||||
)
|
||||
|
||||
def _forward_vae_stage(
|
||||
self,
|
||||
infos: list[dict[str, Any]],
|
||||
*,
|
||||
sample_rate: int,
|
||||
async_chunk: bool,
|
||||
out_device: torch.device,
|
||||
out_dtype: torch.dtype,
|
||||
) -> OmniOutput:
|
||||
if all(self._extract_val(info, "latent_audio_feat", None) is None for info in infos):
|
||||
self._ar_emit_stop_token = True
|
||||
return self._make_empty_output(
|
||||
output_key="model_outputs",
|
||||
payload_factory=lambda: torch.zeros((0,), dtype=torch.float32),
|
||||
infos=infos,
|
||||
sample_rate=sample_rate,
|
||||
out_device=out_device,
|
||||
out_dtype=out_dtype,
|
||||
)
|
||||
|
||||
outputs: list[torch.Tensor] = []
|
||||
sample_rates: list[torch.Tensor] = []
|
||||
for info in infos:
|
||||
latent_audio_feat = self._extract_val(info, "latent_audio_feat", None)
|
||||
audio_tensor = self._pipeline.decode(latent_audio_feat, trim_streaming_patch=async_chunk)
|
||||
outputs.append(audio_tensor.float().cpu())
|
||||
sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
|
||||
|
||||
self._ar_emit_stop_token = True
|
||||
return self._finalize_stage_output(
|
||||
output_key="model_outputs",
|
||||
outputs=outputs,
|
||||
sample_rates=sample_rates,
|
||||
out_device=out_device,
|
||||
out_dtype=out_dtype,
|
||||
)
|
||||
|
||||
def _forward_latent_stage(
|
||||
self,
|
||||
infos: list[dict[str, Any]],
|
||||
*,
|
||||
sample_rate: int,
|
||||
async_chunk: bool,
|
||||
out_device: torch.device,
|
||||
out_dtype: torch.dtype,
|
||||
hidden_rows: int,
|
||||
) -> OmniOutput:
|
||||
texts = [self._extract_val(info, "text", "") for info in infos]
|
||||
if all(not text for text in texts):
|
||||
self._ar_emit_stop_token = True
|
||||
return self._make_empty_output(
|
||||
output_key="latent_audio_feat",
|
||||
payload_factory=lambda: torch.zeros((0,), dtype=torch.float32),
|
||||
infos=infos,
|
||||
sample_rate=sample_rate,
|
||||
out_device=out_device,
|
||||
out_dtype=out_dtype,
|
||||
hidden_rows=hidden_rows,
|
||||
)
|
||||
|
||||
outputs: list[torch.Tensor] = []
|
||||
sample_rates: list[torch.Tensor] = []
|
||||
last_chunk_flags: list[bool] | None = [] if async_chunk else None
|
||||
payload_finished_flags: list[bool] | None = [] if async_chunk else None
|
||||
for info in infos:
|
||||
text = self._extract_val(info, "text", "")
|
||||
cfg_value = float(self._extract_val(info, "cfg_value", 2.0))
|
||||
inference_timesteps = int(self._extract_val(info, "inference_timesteps", 10))
|
||||
min_len = int(self._extract_val(info, "min_len", 2))
|
||||
max_len = int(self._extract_val(info, "max_len", self._extract_val(info, "max_new_tokens", 4096)))
|
||||
retry_badcase = bool(self._extract_val(info, "retry_badcase", True))
|
||||
retry_badcase_max_times = int(self._extract_val(info, "retry_badcase_max_times", 3))
|
||||
retry_badcase_ratio_threshold = float(self._extract_val(info, "retry_badcase_ratio_threshold", 6.0))
|
||||
streaming_prefix_len = int(self._extract_val(info, "streaming_prefix_len", 3))
|
||||
|
||||
request_key = self._resolve_stream_request_key(info)
|
||||
created_temp: str | None = None
|
||||
|
||||
if async_chunk:
|
||||
terminal_pending = self._latent_stream_terminal_pending.get(request_key, 0)
|
||||
if terminal_pending > 0:
|
||||
outputs.append(torch.zeros((0,), dtype=torch.float32))
|
||||
assert last_chunk_flags is not None
|
||||
last_chunk_flags.append(True)
|
||||
assert payload_finished_flags is not None
|
||||
payload_finished_flags.append(terminal_pending == 1)
|
||||
if terminal_pending == 1:
|
||||
self._latent_stream_terminal_pending.pop(request_key, None)
|
||||
else:
|
||||
self._latent_stream_terminal_pending[request_key] = terminal_pending - 1
|
||||
sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
|
||||
continue
|
||||
|
||||
if request_key in self._latent_stream_completed:
|
||||
outputs.append(torch.zeros((0,), dtype=torch.float32))
|
||||
assert last_chunk_flags is not None
|
||||
last_chunk_flags.append(True)
|
||||
assert payload_finished_flags is not None
|
||||
payload_finished_flags.append(False)
|
||||
sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
|
||||
continue
|
||||
|
||||
if request_key not in self._latent_stream_gens:
|
||||
prompt_wav_path, prompt_text, temp_prompt_wav = self._resolve_prompt_inputs(info)
|
||||
created_temp = temp_prompt_wav
|
||||
self._latent_stream_gens[request_key] = self._pipeline.iter_latent_chunks_streaming(
|
||||
text=text,
|
||||
prompt_wav_path=prompt_wav_path,
|
||||
prompt_text=prompt_text,
|
||||
cfg_value=cfg_value,
|
||||
inference_timesteps=inference_timesteps,
|
||||
min_len=min_len,
|
||||
max_len=max_len,
|
||||
streaming_prefix_len=streaming_prefix_len,
|
||||
retry_badcase=False,
|
||||
retry_badcase_max_times=retry_badcase_max_times,
|
||||
retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
|
||||
)
|
||||
generator = self._latent_stream_gens[request_key]
|
||||
try:
|
||||
chunk_latent, is_last = next(generator)
|
||||
except StopIteration:
|
||||
self._latent_stream_gens.pop(request_key, None)
|
||||
self._latent_stream_terminal_pending[request_key] = 1
|
||||
self._latent_stream_completed.add(request_key)
|
||||
outputs.append(torch.zeros((0,), dtype=torch.float32))
|
||||
assert last_chunk_flags is not None
|
||||
last_chunk_flags.append(True)
|
||||
assert payload_finished_flags is not None
|
||||
payload_finished_flags.append(True)
|
||||
else:
|
||||
if is_last:
|
||||
self._latent_stream_gens.pop(request_key, None)
|
||||
self._latent_stream_terminal_pending[request_key] = 1
|
||||
self._latent_stream_completed.add(request_key)
|
||||
outputs.append(chunk_latent.detach().float().cpu())
|
||||
assert last_chunk_flags is not None
|
||||
last_chunk_flags.append(bool(is_last))
|
||||
assert payload_finished_flags is not None
|
||||
payload_finished_flags.append(False)
|
||||
finally:
|
||||
if created_temp is not None and os.path.exists(created_temp):
|
||||
os.unlink(created_temp)
|
||||
sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
|
||||
continue
|
||||
|
||||
prompt_wav_path, prompt_text, temp_prompt_wav = self._resolve_prompt_inputs(info)
|
||||
try:
|
||||
latent_audio_feat = self._pipeline.generate_latents(
|
||||
text=text,
|
||||
prompt_wav_path=prompt_wav_path,
|
||||
prompt_text=prompt_text,
|
||||
cfg_value=cfg_value,
|
||||
inference_timesteps=inference_timesteps,
|
||||
min_len=min_len,
|
||||
max_len=max_len,
|
||||
retry_badcase=retry_badcase,
|
||||
retry_badcase_max_times=retry_badcase_max_times,
|
||||
retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
|
||||
)
|
||||
outputs.append(latent_audio_feat.float().cpu())
|
||||
finally:
|
||||
if temp_prompt_wav is not None and os.path.exists(temp_prompt_wav):
|
||||
os.unlink(temp_prompt_wav)
|
||||
|
||||
sample_rates.append(torch.tensor(sample_rate, dtype=torch.int32))
|
||||
|
||||
self._ar_emit_stop_token = all(last_chunk_flags) if async_chunk and last_chunk_flags else True
|
||||
output = self._finalize_stage_output(
|
||||
output_key="latent_audio_feat",
|
||||
outputs=outputs,
|
||||
sample_rates=sample_rates,
|
||||
out_device=out_device,
|
||||
out_dtype=out_dtype,
|
||||
hidden_rows=hidden_rows,
|
||||
)
|
||||
if async_chunk and payload_finished_flags is not None:
|
||||
output.multimodal_outputs["finished"] = [
|
||||
torch.tensor(flag, dtype=torch.bool) for flag in payload_finished_flags
|
||||
]
|
||||
return output
|
||||
|
||||
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> torch.Tensor:
|
||||
del sampling_metadata
|
||||
if isinstance(hidden_states, OmniOutput):
|
||||
hidden_states = hidden_states.text_hidden_states
|
||||
if hidden_states is None:
|
||||
device, dtype = self._runner_hidden_device_dtype()
|
||||
hidden_states = torch.zeros((0, 1), device=device, dtype=dtype)
|
||||
if hidden_states.ndim == 1:
|
||||
hidden_states = hidden_states.unsqueeze(-1)
|
||||
elif hidden_states.ndim > 2:
|
||||
hidden_states = hidden_states.reshape(-1, hidden_states.shape[-1])
|
||||
|
||||
vocab_size = self._get_vocab_size()
|
||||
num_rows = int(hidden_states.shape[0])
|
||||
logits = torch.zeros((num_rows, vocab_size), dtype=torch.float32, device=hidden_states.device)
|
||||
eos_id = 2 if vocab_size > 2 else 0
|
||||
safe_id = 1 if vocab_size > 1 and 1 != eos_id else 0
|
||||
emit_stop = getattr(self, "_ar_emit_stop_token", True)
|
||||
if num_rows > 0:
|
||||
if emit_stop:
|
||||
logits[:, eos_id] = 1.0e6
|
||||
else:
|
||||
logits[:, eos_id] = -1.0e9
|
||||
logits[:, safe_id] = 1.0e6
|
||||
return logits
|
||||
|
||||
@torch.no_grad()
|
||||
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,
|
||||
model_intermediate_buffer: list[dict[str, Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> OmniOutput:
|
||||
del positions, intermediate_tensors, inputs_embeds, kwargs
|
||||
self._ensure_model_loaded()
|
||||
out_device, out_dtype = self._runner_hidden_device_dtype()
|
||||
if input_ids is not None and input_ids.device.type == out_device.type:
|
||||
out_device = input_ids.device
|
||||
|
||||
infos = model_intermediate_buffer or runtime_additional_information or [{}]
|
||||
hidden_rows = len(infos)
|
||||
if input_ids is not None and len(input_ids.shape) > 0:
|
||||
hidden_rows = max(hidden_rows, int(input_ids.shape[0]))
|
||||
sample_rate = int(getattr(self._pipeline, "sample_rate", 24000))
|
||||
async_chunk = bool(getattr(self.vllm_config.model_config, "async_chunk", False))
|
||||
if self.model_stage in self._VAE_STAGES:
|
||||
infos = self._maybe_recover_vae_infos(infos, input_ids, async_chunk=async_chunk)
|
||||
return self._forward_vae_stage(
|
||||
infos,
|
||||
sample_rate=sample_rate,
|
||||
async_chunk=async_chunk,
|
||||
out_device=out_device,
|
||||
out_dtype=out_dtype,
|
||||
)
|
||||
if self.model_stage in self._LATENT_STAGES:
|
||||
return self._forward_latent_stage(
|
||||
infos,
|
||||
sample_rate=sample_rate,
|
||||
async_chunk=async_chunk,
|
||||
out_device=out_device,
|
||||
out_dtype=out_dtype,
|
||||
hidden_rows=hidden_rows,
|
||||
)
|
||||
raise ValueError(f"Unsupported VoxCPM model_stage at runtime: {self.model_stage}")
|
||||
|
||||
def make_empty_intermediate_tensors(
|
||||
self, batch_size: int, dtype: torch.dtype, device: torch.device
|
||||
) -> IntermediateTensors:
|
||||
del batch_size, dtype, device
|
||||
return {}
|
||||
|
||||
|
||||
__all__ = ["VoxCPMForConditionalGeneration"]
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _iter_voxcpm_src_candidates() -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
env_path = os.environ.get("VLLM_OMNI_VOXCPM_CODE_PATH")
|
||||
if env_path:
|
||||
candidates.append(Path(env_path).expanduser())
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[4]
|
||||
candidates.append(repo_root.parent / "VoxCPM" / "src")
|
||||
|
||||
unique_candidates: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
candidate_key = str(candidate)
|
||||
if candidate_key in seen:
|
||||
continue
|
||||
seen.add(candidate_key)
|
||||
unique_candidates.append(candidate)
|
||||
return unique_candidates
|
||||
|
||||
|
||||
def _prepend_voxcpm_src(candidate: Path) -> None:
|
||||
candidate_str = str(candidate)
|
||||
if candidate_str not in sys.path:
|
||||
sys.path.insert(0, candidate_str)
|
||||
|
||||
|
||||
def _import_voxcpm_attrs(module_name: str, *attr_names: str) -> tuple[Any, ...]:
|
||||
last_exc: ImportError | None = None
|
||||
for candidate in _iter_voxcpm_src_candidates():
|
||||
if not candidate.exists():
|
||||
continue
|
||||
_prepend_voxcpm_src(candidate)
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
return tuple(getattr(module, attr_name) for attr_name in attr_names)
|
||||
except ImportError as exc:
|
||||
last_exc = exc
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
return tuple(getattr(module, attr_name) for attr_name in attr_names)
|
||||
except ImportError as exc:
|
||||
last_exc = exc
|
||||
|
||||
raise ImportError(f"Failed to import {module_name}.") from last_exc
|
||||
|
||||
|
||||
def _import_voxcpm_base_model_class():
|
||||
"""Import upstream ``VoxCPMModel`` from ``VoxCPM/src/voxcpm`` (env, sibling tree, or pip)."""
|
||||
try:
|
||||
(VoxCPMModel,) = _import_voxcpm_attrs("voxcpm.model.voxcpm", "VoxCPMModel")
|
||||
return VoxCPMModel
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Failed to import VoxCPMModel. Install the `voxcpm` package or set "
|
||||
"`VLLM_OMNI_VOXCPM_CODE_PATH` to the VoxCPM repository `src` directory "
|
||||
"(the parent of the `voxcpm` package that contains `model/` and `modules/`)."
|
||||
) from exc
|
||||
|
||||
|
||||
def _import_voxcpm_audio_vae_classes():
|
||||
try:
|
||||
return _import_voxcpm_attrs("voxcpm.modules.audiovae", "AudioVAE", "AudioVAEConfig")
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Failed to import VoxCPM AudioVAE. Install the `voxcpm` package or set "
|
||||
"`VLLM_OMNI_VOXCPM_CODE_PATH` to the VoxCPM repository `src` directory."
|
||||
) from exc
|
||||
|
||||
|
||||
def _device_to_string(device: torch.device) -> str:
|
||||
if device.index is None:
|
||||
return device.type
|
||||
return f"{device.type}:{device.index}"
|
||||
|
||||
|
||||
def _normalize_dtype_name(dtype: Any) -> str | None:
|
||||
if dtype is None:
|
||||
return None
|
||||
if isinstance(dtype, torch.dtype):
|
||||
mapping = {
|
||||
torch.bfloat16: "bfloat16",
|
||||
torch.float16: "float16",
|
||||
torch.float32: "float32",
|
||||
}
|
||||
return mapping.get(dtype, str(dtype).removeprefix("torch."))
|
||||
dtype_str = str(dtype)
|
||||
return dtype_str.removeprefix("torch.")
|
||||
|
||||
|
||||
def _resolve_runtime_device(vllm_config: VllmConfig) -> torch.device:
|
||||
try:
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
return current_omni_platform.get_torch_device()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
device = getattr(getattr(vllm_config, "device_config", None), "device", None)
|
||||
if isinstance(device, torch.device):
|
||||
return device
|
||||
if device:
|
||||
return torch.device(device)
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
def _prepare_runtime_model_dir(
|
||||
model_path: str | Path,
|
||||
*,
|
||||
target_device: torch.device,
|
||||
target_dtype: str | None,
|
||||
) -> str:
|
||||
source_dir = Path(model_path)
|
||||
config_path = source_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
return str(source_dir)
|
||||
|
||||
config_text = config_path.read_text()
|
||||
config_dict = json.loads(config_text)
|
||||
desired_device = target_device.type
|
||||
desired_dtype = target_dtype or config_dict.get("dtype")
|
||||
|
||||
if config_dict.get("device") == desired_device and config_dict.get("dtype") == desired_dtype:
|
||||
return str(source_dir)
|
||||
|
||||
digest = sha256(f"{source_dir.resolve()}:{config_text}:{desired_device}:{desired_dtype}".encode()).hexdigest()[:16]
|
||||
runtime_dir = Path(tempfile.gettempdir()) / "vllm_omni_voxcpm_runtime" / digest
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for entry in source_dir.iterdir():
|
||||
target = runtime_dir / entry.name
|
||||
if entry.name == "config.json" or target.exists():
|
||||
continue
|
||||
try:
|
||||
target.symlink_to(entry, target_is_directory=entry.is_dir())
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Falling back to copying VoxCPM runtime artifact %s into %s because symlink creation failed: %s",
|
||||
entry,
|
||||
runtime_dir,
|
||||
exc,
|
||||
)
|
||||
if entry.is_dir():
|
||||
shutil.copytree(entry, target, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(entry, target)
|
||||
|
||||
patched_config = dict(config_dict)
|
||||
patched_config["device"] = desired_device
|
||||
if desired_dtype is not None:
|
||||
patched_config["dtype"] = desired_dtype
|
||||
(runtime_dir / "config.json").write_text(json.dumps(patched_config, indent=2, sort_keys=True))
|
||||
return str(runtime_dir)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _force_cuda_available_for_npu(device: torch.device):
|
||||
if device.type != "npu":
|
||||
yield
|
||||
return
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
def _is_torchcodec_load_error(exc: BaseException) -> bool:
|
||||
message = str(exc).lower()
|
||||
return "torchcodec" in message or "load_with_torchcodec" in message
|
||||
|
||||
|
||||
def _load_audio_with_soundfile(
|
||||
prompt_wav_path: str,
|
||||
*,
|
||||
sample_rate: int,
|
||||
) -> torch.Tensor:
|
||||
try:
|
||||
import soundfile as sf
|
||||
except ImportError:
|
||||
raise
|
||||
|
||||
audio_np, source_sr = sf.read(prompt_wav_path, dtype="float32", always_2d=True)
|
||||
audio = torch.from_numpy(np.ascontiguousarray(audio_np.T))
|
||||
|
||||
if audio.size(0) > 1:
|
||||
audio = audio.mean(dim=0, keepdim=True)
|
||||
|
||||
if int(source_sr) != int(sample_rate):
|
||||
try:
|
||||
import torchaudio
|
||||
except ImportError as exc:
|
||||
raise ImportError("torchaudio is required for resampling prompt audio.") from exc
|
||||
audio = torchaudio.functional.resample(audio, int(source_sr), int(sample_rate))
|
||||
|
||||
return audio
|
||||
|
||||
|
||||
def _build_prompt_cache_with_soundfile(model: Any, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
if args:
|
||||
prompt_text = args[0]
|
||||
prompt_wav_path = args[1] if len(args) > 1 else kwargs.get("prompt_wav_path")
|
||||
else:
|
||||
prompt_text = kwargs.get("prompt_text")
|
||||
prompt_wav_path = kwargs.get("prompt_wav_path")
|
||||
|
||||
if not prompt_text or not prompt_wav_path:
|
||||
raise ValueError("prompt_text and prompt_wav_path are required")
|
||||
|
||||
audio = _load_audio_with_soundfile(prompt_wav_path, sample_rate=int(model.sample_rate))
|
||||
|
||||
patch_len = model.patch_size * model.chunk_size
|
||||
if audio.size(1) % patch_len != 0:
|
||||
padding_size = patch_len - audio.size(1) % patch_len
|
||||
audio = torch.nn.functional.pad(audio, (padding_size, 0))
|
||||
|
||||
audio_feat = model.audio_vae.encode(audio.to(model.device), model.sample_rate).cpu()
|
||||
audio_feat = audio_feat.view(
|
||||
model.audio_vae.latent_dim,
|
||||
-1,
|
||||
model.patch_size,
|
||||
).permute(1, 2, 0)
|
||||
|
||||
return {
|
||||
"prompt_text": prompt_text,
|
||||
"audio_feat": audio_feat,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_voxcpm_model_dir(model: str) -> Path:
|
||||
model_path = Path(model).expanduser()
|
||||
if model_path.exists():
|
||||
return model_path
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
return Path(snapshot_download(repo_id=model))
|
||||
|
||||
|
||||
def prepare_voxcpm_hf_config_dir(model_dir: str | Path, hf_config_dir: str | Path) -> Path:
|
||||
model_dir = Path(model_dir).expanduser()
|
||||
hf_config_dir = Path(hf_config_dir).expanduser()
|
||||
hf_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_config_path = model_dir / "config.json"
|
||||
if not source_config_path.exists():
|
||||
raise FileNotFoundError(f"VoxCPM config.json not found under {model_dir}")
|
||||
|
||||
config_path = hf_config_dir / "config.json"
|
||||
shutil.copy2(source_config_path, config_path)
|
||||
|
||||
source_generation_config_path = model_dir / "generation_config.json"
|
||||
if source_generation_config_path.exists():
|
||||
shutil.copy2(source_generation_config_path, hf_config_dir / "generation_config.json")
|
||||
|
||||
config_dict = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config_dict["model_type"] = "voxcpm"
|
||||
config_dict.setdefault("architectures", ["VoxCPMForConditionalGeneration"])
|
||||
config_path.write_text(json.dumps(config_dict, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
return hf_config_dir
|
||||
|
||||
|
||||
__all__ = [
|
||||
"prepare_voxcpm_hf_config_dir",
|
||||
"resolve_voxcpm_model_dir",
|
||||
]
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from einops import rearrange
|
||||
|
||||
|
||||
class _DirectVoxCPMLatentGenerator:
|
||||
def __init__(self, tts_model: Any):
|
||||
self.tts_model = tts_model
|
||||
self.sample_rate = int(getattr(tts_model, "sample_rate", 24000))
|
||||
|
||||
def generate_latents(
|
||||
self,
|
||||
*,
|
||||
text: str,
|
||||
prompt_wav_path: str | None = None,
|
||||
prompt_text: str | None = None,
|
||||
cfg_value: float = 2.0,
|
||||
inference_timesteps: int = 10,
|
||||
min_len: int = 2,
|
||||
max_len: int = 4096,
|
||||
retry_badcase: bool = True,
|
||||
retry_badcase_max_times: int = 3,
|
||||
retry_badcase_ratio_threshold: float = 6.0,
|
||||
) -> torch.Tensor:
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("target text must be a non-empty string")
|
||||
if (prompt_wav_path is None) != (prompt_text is None):
|
||||
raise ValueError("prompt_wav_path and prompt_text must both be provided or both be None")
|
||||
if prompt_wav_path is not None and not os.path.exists(prompt_wav_path):
|
||||
raise FileNotFoundError(f"prompt_wav_path does not exist: {prompt_wav_path}")
|
||||
|
||||
prompt_cache = None
|
||||
if prompt_wav_path is not None and prompt_text is not None:
|
||||
prompt_cache = self.tts_model.build_prompt_cache(
|
||||
prompt_text=prompt_text,
|
||||
prompt_wav_path=prompt_wav_path,
|
||||
)
|
||||
|
||||
gen_kw = dict(
|
||||
target_text=" ".join(text.split()),
|
||||
prompt_cache=prompt_cache,
|
||||
min_len=min_len,
|
||||
max_len=max_len,
|
||||
inference_timesteps=inference_timesteps,
|
||||
cfg_value=cfg_value,
|
||||
retry_badcase=retry_badcase,
|
||||
retry_badcase_max_times=retry_badcase_max_times,
|
||||
retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
|
||||
)
|
||||
latent_entry = getattr(self.tts_model, "generate_latents_with_prompt_cache", None)
|
||||
if latent_entry is not None:
|
||||
_, _, pred_audio_feat = latent_entry(**gen_kw)
|
||||
else:
|
||||
try:
|
||||
_, _, pred_audio_feat = self.tts_model.generate_with_prompt_cache(
|
||||
**gen_kw,
|
||||
latents_only=True,
|
||||
)
|
||||
except TypeError:
|
||||
_, _, pred_audio_feat = self.tts_model.generate_with_prompt_cache(**gen_kw)
|
||||
return pred_audio_feat.detach().cpu().to(torch.float32)
|
||||
|
||||
def iter_latent_chunks_streaming(
|
||||
self,
|
||||
*,
|
||||
text: str,
|
||||
prompt_wav_path: str | None = None,
|
||||
prompt_text: str | None = None,
|
||||
cfg_value: float = 2.0,
|
||||
inference_timesteps: int = 10,
|
||||
min_len: int = 2,
|
||||
max_len: int = 4096,
|
||||
streaming_prefix_len: int = 3,
|
||||
retry_badcase: bool = False,
|
||||
retry_badcase_max_times: int = 3,
|
||||
retry_badcase_ratio_threshold: float = 6.0,
|
||||
) -> Generator[tuple[torch.Tensor, bool], None, None]:
|
||||
"""Yield ``(latent_window, is_last_chunk)`` for Omni async_chunk latent to VAE."""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError("target text must be a non-empty string")
|
||||
if (prompt_wav_path is None) != (prompt_text is None):
|
||||
raise ValueError("prompt_wav_path and prompt_text must both be provided or both be None")
|
||||
if prompt_wav_path is not None and not os.path.exists(prompt_wav_path):
|
||||
raise FileNotFoundError(f"prompt_wav_path does not exist: {prompt_wav_path}")
|
||||
|
||||
prompt_cache = None
|
||||
if prompt_wav_path is not None and prompt_text is not None:
|
||||
prompt_cache = self.tts_model.build_prompt_cache(
|
||||
prompt_text=prompt_text,
|
||||
prompt_wav_path=prompt_wav_path,
|
||||
)
|
||||
|
||||
gen_kw = dict(
|
||||
target_text=" ".join(text.split()),
|
||||
prompt_cache=prompt_cache,
|
||||
min_len=min_len,
|
||||
max_len=max_len,
|
||||
inference_timesteps=inference_timesteps,
|
||||
cfg_value=cfg_value,
|
||||
retry_badcase=retry_badcase,
|
||||
retry_badcase_max_times=retry_badcase_max_times,
|
||||
retry_badcase_ratio_threshold=retry_badcase_ratio_threshold,
|
||||
streaming_prefix_len=streaming_prefix_len,
|
||||
)
|
||||
stream_entry = getattr(self.tts_model, "generate_latents_with_prompt_cache_streaming", None)
|
||||
if stream_entry is not None:
|
||||
gen = stream_entry(**gen_kw)
|
||||
else:
|
||||
fallback_stream_entry = getattr(self.tts_model, "generate_with_prompt_cache_streaming", None)
|
||||
if fallback_stream_entry is not None:
|
||||
gen = fallback_stream_entry(**gen_kw, latents_only=True)
|
||||
else:
|
||||
gen = self.tts_model._generate_with_prompt_cache(streaming=True, latents_only=True, **gen_kw)
|
||||
|
||||
iterator = iter(gen)
|
||||
previous = next(iterator, None)
|
||||
while previous is not None:
|
||||
current = next(iterator, None)
|
||||
_, _target_tok, chunk_latent = previous
|
||||
if not isinstance(chunk_latent, torch.Tensor):
|
||||
chunk_latent = torch.as_tensor(chunk_latent)
|
||||
yield chunk_latent, current is None
|
||||
previous = current
|
||||
|
||||
|
||||
class _DirectVoxCPMAudioVAE:
|
||||
def __init__(self, audio_vae: nn.Module, *, patch_size: int = 2):
|
||||
self.audio_vae = audio_vae
|
||||
self.sample_rate = int(getattr(audio_vae, "sample_rate", 24000))
|
||||
self.latent_dim = int(getattr(audio_vae, "latent_dim", 64))
|
||||
self.patch_size = int(patch_size)
|
||||
self._chunk_size = int(getattr(audio_vae, "chunk_size", 1))
|
||||
self._stream_audio_patch_samples = max(1, self.patch_size * self._chunk_size)
|
||||
|
||||
def _prepare_latents_for_decode(self, latent_audio_feat: Any) -> torch.Tensor:
|
||||
latents = latent_audio_feat
|
||||
if not isinstance(latents, torch.Tensor):
|
||||
latents = torch.tensor(latents, dtype=torch.float32)
|
||||
latents = latents.detach().to(torch.float32)
|
||||
|
||||
if latents.ndim == 3:
|
||||
if latents.shape[-1] == self.latent_dim:
|
||||
latents = rearrange(latents, "t p d -> 1 d (t p)")
|
||||
elif latents.shape[1] == self.latent_dim:
|
||||
latents = latents.contiguous()
|
||||
else:
|
||||
raise ValueError(f"Unsupported latent_audio_feat shape: {tuple(latents.shape)}")
|
||||
elif latents.ndim == 2:
|
||||
if latents.shape[0] == self.latent_dim:
|
||||
latents = latents.unsqueeze(0)
|
||||
elif latents.shape[1] == self.latent_dim:
|
||||
latents = rearrange(latents, "t d -> 1 d t")
|
||||
else:
|
||||
raise ValueError(f"Unsupported latent_audio_feat shape: {tuple(latents.shape)}")
|
||||
else:
|
||||
raise ValueError(f"Unsupported latent_audio_feat ndim: {latents.ndim}")
|
||||
|
||||
return latents
|
||||
|
||||
@torch.no_grad()
|
||||
def decode(self, latent_audio_feat: Any, *, trim_streaming_patch: bool = False) -> torch.Tensor:
|
||||
latents = self._prepare_latents_for_decode(latent_audio_feat)
|
||||
device = next(self.audio_vae.parameters()).device
|
||||
raw = self.audio_vae.decode(latents.to(device=device, dtype=torch.float32))
|
||||
if isinstance(raw, dict):
|
||||
audio = raw.get("audio")
|
||||
if audio is None:
|
||||
audio = next(v for v in raw.values() if isinstance(v, torch.Tensor))
|
||||
else:
|
||||
audio = raw
|
||||
if audio.dim() == 3:
|
||||
stream = audio.squeeze(1)
|
||||
elif audio.dim() == 2:
|
||||
stream = audio
|
||||
else:
|
||||
stream = audio.reshape(audio.shape[0], -1)
|
||||
if trim_streaming_patch:
|
||||
stream = stream[..., -self._stream_audio_patch_samples :]
|
||||
return stream.reshape(-1).detach().cpu().to(torch.float32)
|
||||
@@ -0,0 +1,69 @@
|
||||
# VoxCPM two-stage (latent → VAE) without async_chunk: one-shot latent then decode.
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
is_comprehension: true
|
||||
runtime:
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
dtype: bfloat16
|
||||
model_stage: latent_generator
|
||||
model_arch: VoxCPMForConditionalGeneration
|
||||
# Optional persistent HF-compatible config dir for native VoxCPM models.
|
||||
hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
|
||||
worker_type: ar
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: latent
|
||||
gpu_memory_utilization: 0.7
|
||||
distributed_executor_backend: "mp"
|
||||
max_num_batched_tokens: 4096
|
||||
max_model_len: 4096
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 4096
|
||||
stop_token_ids: [2]
|
||||
seed: 42
|
||||
detokenize: false
|
||||
repetition_penalty: 1.0
|
||||
final_output: false
|
||||
|
||||
- stage_id: 1
|
||||
stage_type: llm
|
||||
runtime:
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
dtype: float32
|
||||
model_stage: vae
|
||||
model_arch: VoxCPMForConditionalGeneration
|
||||
hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: audio
|
||||
gpu_memory_utilization: 0.15
|
||||
distributed_executor_backend: "mp"
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 4096
|
||||
engine_input_source: [0]
|
||||
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.voxcpm.latent2vae
|
||||
final_output: true
|
||||
final_output_type: audio
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 1
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.0
|
||||
@@ -0,0 +1,102 @@
|
||||
# VoxCPM two-stage streaming (align with qwen3_tts.yaml async_chunk pattern).
|
||||
# Stage0 (latent_generator) emits latent in time chunks; Stage1 (VAE) decodes as chunks arrive.
|
||||
async_chunk: true
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
is_comprehension: true
|
||||
runtime:
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
dtype: bfloat16
|
||||
model_stage: latent_generator
|
||||
model_arch: VoxCPMForConditionalGeneration
|
||||
# Optional persistent HF-compatible config dir for native VoxCPM models.
|
||||
hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
|
||||
worker_type: ar
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: true
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: latent
|
||||
gpu_memory_utilization: 0.7
|
||||
distributed_executor_backend: "mp"
|
||||
max_num_batched_tokens: 4096
|
||||
max_model_len: 4096
|
||||
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.voxcpm.latent2vae_async_chunk
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 4096
|
||||
stop_token_ids: [2]
|
||||
seed: 42
|
||||
detokenize: false
|
||||
repetition_penalty: 1.0
|
||||
final_output: false
|
||||
output_connectors:
|
||||
to_stage_1: voxcpm_shm
|
||||
|
||||
- stage_id: 1
|
||||
stage_type: llm
|
||||
runtime:
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
dtype: float32
|
||||
model_stage: vae
|
||||
model_arch: VoxCPMForConditionalGeneration
|
||||
hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: audio
|
||||
gpu_memory_utilization: 0.15
|
||||
distributed_executor_backend: "mp"
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 4096
|
||||
engine_input_source: [0]
|
||||
final_output: true
|
||||
final_output_type: audio
|
||||
input_connectors:
|
||||
from_stage_0: voxcpm_shm
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 128
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.0
|
||||
|
||||
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1
|
||||
max_inflight: 1
|
||||
|
||||
connectors:
|
||||
voxcpm_shm:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
shm_threshold_bytes: 65536
|
||||
# Frame-aligned codec streaming transport.
|
||||
codec_streaming: true
|
||||
# Connector polling / timeout (unit: loop count, sleep interval in seconds).
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
# Align with Omni: small chunks with sufficient context overlap.
|
||||
codec_chunk_frames: 1
|
||||
codec_left_context_frames: 1
|
||||
|
||||
edges:
|
||||
- from: 0
|
||||
to: 1
|
||||
window_size: -1
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.inputs import TextPrompt
|
||||
|
||||
from vllm_omni.inputs.data import OmniTokensPrompt
|
||||
|
||||
_VOXCPM_LATENT_MAGIC = 131071
|
||||
|
||||
|
||||
def _serialize_latent_to_codes(latent: Any) -> list[int]:
|
||||
latent_tensor = latent if isinstance(latent, torch.Tensor) else torch.as_tensor(latent)
|
||||
latent_tensor = latent_tensor.detach().cpu().contiguous()
|
||||
if latent_tensor.ndim == 3:
|
||||
if latent_tensor.shape[0] != 1:
|
||||
raise ValueError(f"Expected batch=1 latent tensor, got shape={tuple(latent_tensor.shape)}")
|
||||
latent_tensor = latent_tensor.squeeze(0)
|
||||
if latent_tensor.ndim != 2:
|
||||
raise ValueError(f"Unsupported latent_audio_feat shape for async chunk: {tuple(latent_tensor.shape)}")
|
||||
latent_dim, time_dim = int(latent_tensor.shape[0]), int(latent_tensor.shape[1])
|
||||
packed = latent_tensor.to(torch.bfloat16).contiguous().view(torch.uint16).reshape(-1).to(torch.int32)
|
||||
return [_VOXCPM_LATENT_MAGIC, latent_dim, time_dim, *packed.tolist()]
|
||||
|
||||
|
||||
def _coerce_finished_flag(value: Any) -> bool:
|
||||
"""Normalize VoxCPM async-chunk finished markers to a Python bool."""
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, torch.Tensor):
|
||||
if value.numel() != 1:
|
||||
raise ValueError(f"finished tensor must be scalar, got shape={tuple(value.shape)}")
|
||||
return bool(value.detach().cpu().item())
|
||||
if isinstance(value, (list, tuple)):
|
||||
if not value:
|
||||
return False
|
||||
if len(value) != 1:
|
||||
raise ValueError(f"finished container must have one element, got len={len(value)}")
|
||||
return _coerce_finished_flag(value[0])
|
||||
return bool(value)
|
||||
|
||||
|
||||
def latent2vae(
|
||||
stage_list: list[Any],
|
||||
engine_input_source: list[int],
|
||||
prompt: OmniTokensPrompt | TextPrompt | None = None,
|
||||
requires_multimodal_data: bool = False,
|
||||
) -> list[OmniTokensPrompt]:
|
||||
del prompt, requires_multimodal_data
|
||||
|
||||
if not engine_input_source:
|
||||
raise ValueError("engine_input_source cannot be empty")
|
||||
|
||||
source_stage_id = engine_input_source[0]
|
||||
if source_stage_id >= len(stage_list):
|
||||
raise IndexError(f"Invalid stage_id: {source_stage_id}")
|
||||
|
||||
source_outputs = stage_list[source_stage_id].engine_outputs
|
||||
if source_outputs is None:
|
||||
raise RuntimeError(f"Stage {source_stage_id} has no outputs yet")
|
||||
|
||||
vae_inputs: list[OmniTokensPrompt] = []
|
||||
for source_output in source_outputs:
|
||||
output = source_output.outputs[0]
|
||||
multimodal_output = getattr(output, "multimodal_output", None)
|
||||
if not isinstance(multimodal_output, dict) or "latent_audio_feat" not in multimodal_output:
|
||||
raise ValueError(
|
||||
"VoxCPM latent stage output missing 'latent_audio_feat'. "
|
||||
f"request_id={getattr(source_output, 'request_id', None)}"
|
||||
)
|
||||
|
||||
additional_information = {
|
||||
"latent_audio_feat": multimodal_output["latent_audio_feat"],
|
||||
}
|
||||
if "sr" in multimodal_output:
|
||||
additional_information["sample_rate"] = [int(multimodal_output["sr"])]
|
||||
|
||||
vae_inputs.append(
|
||||
OmniTokensPrompt(
|
||||
prompt_token_ids=[0],
|
||||
additional_information=additional_information,
|
||||
multi_modal_data=None,
|
||||
mm_processor_kwargs=None,
|
||||
)
|
||||
)
|
||||
|
||||
return vae_inputs
|
||||
|
||||
|
||||
def latent2vae_async_chunk(
|
||||
transfer_manager: Any,
|
||||
pooling_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Stage-0 latent → stage-1 VAE under ``async_chunk`` (connector payload)."""
|
||||
# Kept for callback signature compatibility with OmniChunkTransferAdapter.
|
||||
_ = transfer_manager
|
||||
finished_request = _coerce_finished_flag(is_finished)
|
||||
if callable(getattr(request, "is_finished", None)):
|
||||
finished_request = finished_request or _coerce_finished_flag(request.is_finished())
|
||||
if not isinstance(pooling_output, dict):
|
||||
if finished_request:
|
||||
return {
|
||||
"code_predictor_codes": [],
|
||||
"finished": torch.tensor(True, dtype=torch.bool),
|
||||
}
|
||||
return None
|
||||
|
||||
latent = pooling_output.get("latent_audio_feat")
|
||||
if isinstance(latent, torch.Tensor) and latent.numel() == 0:
|
||||
latent = None
|
||||
|
||||
if latent is None:
|
||||
if finished_request:
|
||||
return {
|
||||
"code_predictor_codes": [],
|
||||
"finished": torch.tensor(True, dtype=torch.bool),
|
||||
}
|
||||
return None
|
||||
|
||||
serialized_codes = _serialize_latent_to_codes(latent)
|
||||
out: dict[str, Any] = {
|
||||
"code_predictor_codes": serialized_codes,
|
||||
"finished": torch.tensor(finished_request, dtype=torch.bool),
|
||||
}
|
||||
return out
|
||||
@@ -0,0 +1,67 @@
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
is_comprehension: true
|
||||
runtime:
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
dtype: bfloat16
|
||||
model_stage: latent_generator
|
||||
model_arch: VoxCPMForConditionalGeneration
|
||||
# Optional persistent HF-compatible config dir for native VoxCPM models.
|
||||
hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
|
||||
worker_type: ar
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: latent
|
||||
gpu_memory_utilization: 0.75
|
||||
distributed_executor_backend: "mp"
|
||||
max_num_batched_tokens: 4096
|
||||
max_model_len: 4096
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 4096
|
||||
seed: 42
|
||||
detokenize: false
|
||||
repetition_penalty: 1.0
|
||||
final_output: false
|
||||
|
||||
- stage_id: 1
|
||||
stage_type: llm
|
||||
runtime:
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
dtype: float32
|
||||
model_stage: vae
|
||||
model_arch: VoxCPMForConditionalGeneration
|
||||
hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: audio
|
||||
gpu_memory_utilization: 0.1
|
||||
distributed_executor_backend: "mp"
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 4096
|
||||
engine_input_source: [0]
|
||||
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.voxcpm.latent2vae
|
||||
final_output: true
|
||||
final_output_type: audio
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 1
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.0
|
||||
@@ -0,0 +1,93 @@
|
||||
async_chunk: true
|
||||
stage_args:
|
||||
- stage_id: 0
|
||||
stage_type: llm
|
||||
is_comprehension: true
|
||||
runtime:
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
dtype: bfloat16
|
||||
model_stage: latent_generator
|
||||
model_arch: VoxCPMForConditionalGeneration
|
||||
# Optional persistent HF-compatible config dir for native VoxCPM models.
|
||||
hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
|
||||
worker_type: ar
|
||||
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: latent
|
||||
gpu_memory_utilization: 0.75
|
||||
distributed_executor_backend: "mp"
|
||||
max_num_batched_tokens: 4096
|
||||
max_model_len: 4096
|
||||
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.voxcpm.latent2vae_async_chunk
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 4096
|
||||
seed: 42
|
||||
detokenize: false
|
||||
repetition_penalty: 1.0
|
||||
final_output: false
|
||||
|
||||
- stage_id: 1
|
||||
stage_type: llm
|
||||
runtime:
|
||||
devices: "0"
|
||||
max_batch_size: 1
|
||||
engine_args:
|
||||
dtype: float32
|
||||
model_stage: vae
|
||||
model_arch: VoxCPMForConditionalGeneration
|
||||
hf_config_path: ${oc.env:VLLM_OMNI_VOXCPM_HF_CONFIG_PATH,}
|
||||
worker_type: generation
|
||||
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
|
||||
enforce_eager: true
|
||||
trust_remote_code: true
|
||||
async_scheduling: false
|
||||
enable_prefix_caching: false
|
||||
engine_output_type: audio
|
||||
gpu_memory_utilization: 0.1
|
||||
distributed_executor_backend: "mp"
|
||||
max_num_batched_tokens: 8192
|
||||
max_model_len: 4096
|
||||
engine_input_source: [0]
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
final_output: true
|
||||
final_output_type: audio
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 1
|
||||
seed: 42
|
||||
detokenize: true
|
||||
repetition_penalty: 1.0
|
||||
|
||||
runtime:
|
||||
enabled: true
|
||||
defaults:
|
||||
window_size: -1
|
||||
max_inflight: 1
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
shm_threshold_bytes: 65536
|
||||
codec_streaming: false
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
|
||||
edges:
|
||||
- from: 0
|
||||
to: 1
|
||||
window_size: -1
|
||||
@@ -17,6 +17,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
|
||||
"FishSpeechConfig": "vllm_omni.transformers_utils.configs.fish_speech",
|
||||
"FishSpeechSlowARConfig": "vllm_omni.transformers_utils.configs.fish_speech",
|
||||
"FishSpeechFastARConfig": "vllm_omni.transformers_utils.configs.fish_speech",
|
||||
"VoxCPMConfig": "vllm_omni.transformers_utils.configs.voxcpm",
|
||||
"VoxCPM2Config": "vllm_omni.transformers_utils.configs.voxcpm2",
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ __all__ = [
|
||||
"FishSpeechConfig",
|
||||
"FishSpeechSlowARConfig",
|
||||
"FishSpeechFastARConfig",
|
||||
"VoxCPMConfig",
|
||||
"VoxCPM2Config",
|
||||
]
|
||||
|
||||
@@ -49,4 +51,5 @@ def __dir__():
|
||||
# run as soon as `vllm_omni.transformers_utils.configs` is imported.
|
||||
from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402
|
||||
from vllm_omni.transformers_utils.configs import mammoth_moda2 as _mammoth_moda2 # noqa: F401, E402
|
||||
from vllm_omni.transformers_utils.configs import voxcpm as _voxcpm # noqa: F401, E402
|
||||
from vllm_omni.transformers_utils.configs import voxcpm2 as _voxcpm2 # noqa: F401, E402
|
||||
@@ -0,0 +1,68 @@
|
||||
from transformers import AutoConfig
|
||||
from transformers.configuration_utils import PretrainedConfig
|
||||
|
||||
|
||||
class VoxCPMConfig(PretrainedConfig):
|
||||
model_type = "voxcpm"
|
||||
keys_to_ignore_at_inference = ["past_key_values"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bos_token_id: int = 1,
|
||||
eos_token_id: int = 2,
|
||||
vocab_size: int = 32000,
|
||||
hidden_size: int = 1024,
|
||||
intermediate_size: int = 4096,
|
||||
max_position_embeddings: int = 4096,
|
||||
num_attention_heads: int = 16,
|
||||
num_hidden_layers: int = 24,
|
||||
num_key_value_heads: int = 16,
|
||||
rms_norm_eps: float = 1e-6,
|
||||
rope_theta: float = 10000.0,
|
||||
rope_scaling: dict | None = None,
|
||||
lm_config: dict | None = None,
|
||||
encoder_config: dict | None = None,
|
||||
dit_config: dict | None = None,
|
||||
audio_vae_config: dict | None = None,
|
||||
patch_size: int = 2,
|
||||
feat_dim: int = 64,
|
||||
residual_lm_num_layers: int = 6,
|
||||
scalar_quantization_latent_dim: int = 256,
|
||||
scalar_quantization_scale: int = 9,
|
||||
max_length: int = 4096,
|
||||
device: str = "cuda",
|
||||
dtype: str = "bfloat16",
|
||||
dit_mean_mode: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(bos_token_id=bos_token_id, eos_token_id=eos_token_id, **kwargs)
|
||||
self.vocab_size = vocab_size
|
||||
self.hidden_size = hidden_size
|
||||
self.intermediate_size = intermediate_size
|
||||
self.max_position_embeddings = max_position_embeddings
|
||||
self.num_attention_heads = num_attention_heads
|
||||
self.num_hidden_layers = num_hidden_layers
|
||||
self.num_key_value_heads = num_key_value_heads
|
||||
self.rms_norm_eps = rms_norm_eps
|
||||
self.rope_theta = rope_theta
|
||||
self.rope_scaling = rope_scaling
|
||||
|
||||
self.lm_config = lm_config or {}
|
||||
self.encoder_config = encoder_config or {}
|
||||
self.dit_config = dit_config or {}
|
||||
self.audio_vae_config = audio_vae_config
|
||||
|
||||
self.patch_size = patch_size
|
||||
self.feat_dim = feat_dim
|
||||
self.residual_lm_num_layers = residual_lm_num_layers
|
||||
self.scalar_quantization_latent_dim = scalar_quantization_latent_dim
|
||||
self.scalar_quantization_scale = scalar_quantization_scale
|
||||
self.max_length = max_length
|
||||
self.device = device
|
||||
self.dtype = dtype
|
||||
self.dit_mean_mode = dit_mean_mode
|
||||
|
||||
|
||||
AutoConfig.register("voxcpm", VoxCPMConfig)
|
||||
|
||||
__all__ = ["VoxCPMConfig"]
|
||||
Reference in new issue
Block a user