Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions benchmarks/tts/model_configs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ models:
task_extra_body:
voice_clone: {}

# MOSS-TTS-Local-Transformer-v1.5: Qwen3 backbone + 1-layer GPT2-style local
# depth transformer (n_vq=12), paired with the 48 kHz stereo
# MOSS-Audio-Tokenizer-v2 codec. Same voice-clone request shape as the
# other MOSS-TTS variants.
OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5:
supported_tasks: [voice_clone]
backend: openai-audio-speech
endpoint: /v1/audio/speech
task_extra_body:
voice_clone: {}

# MOSS-TTSD synthesizes [S1]/[S2] dialogue. The seed-tts voice_clone dataset
# has single-speaker rows; bench piggybacks on it (both speakers share the
# same ref_audio). For real dialogue evaluation use the ttsd dataset.
Expand Down
37 changes: 25 additions & 12 deletions examples/offline_inference/text_to_speech/moss_tts/end2end.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Offline inference example for MOSS-TTS variants.

Covers all five models:
- OpenMOSS-Team/MOSS-TTS (8B, general TTS)
- OpenMOSS-Team/MOSS-TTSD-v1.0 (8B, dialogue TTS)
- OpenMOSS-Team/MOSS-SoundEffect (8B, sound effect synthesis)
- OpenMOSS-Team/MOSS-TTS-Realtime (1.7B, low-latency streaming)
- OpenMOSS-Team/MOSS-VoiceGenerator(1.7B, voice design)
Covers six models:
- OpenMOSS-Team/MOSS-TTS (8B, general TTS)
- OpenMOSS-Team/MOSS-TTSD-v1.0 (8B, dialogue TTS)
- OpenMOSS-Team/MOSS-SoundEffect (8B, sound effect synthesis)
- OpenMOSS-Team/MOSS-TTS-Realtime (1.7B, low-latency streaming)
- OpenMOSS-Team/MOSS-VoiceGenerator (1.7B, voice design)
- OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5 (n_vq=12, 48 kHz stereo)

Usage
-----
Expand Down Expand Up @@ -210,12 +211,18 @@ def run_tts(args: argparse.Namespace) -> None:
"OpenMOSS-Team/MOSS-SoundEffect": "moss_sound_effect",
"OpenMOSS-Team/MOSS-TTS-Realtime": "moss_tts_realtime",
"OpenMOSS-Team/MOSS-VoiceGenerator": "moss_voice_generator",
"OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5": "moss_tts_local",
}
deploy_config = deploy_map.get(args.model, "moss_tts")

is_sound_effect = "SoundEffect" in args.model
is_voice_gen = "VoiceGenerator" in args.model
is_realtime = "Realtime" in args.model
# Local-v1.5 ships its own AutoProcessor (processor_config.json +
# processing_moss_tts.py), so it reuses the same _build_unified_codes
# path as the Delay variants below — just a different n_vq/codec.
is_local = "Local" in args.model
output_sr = 48000 if is_local else 24000

ref_audio = None
builder_kwargs: dict = {}
Expand Down Expand Up @@ -314,27 +321,33 @@ def run_tts(args: argparse.Namespace) -> None:
wav = mm.get("model_outputs")
if wav is None:
continue
# Local-v1.5's codec emits stereo chunks shaped (C, T); keep the
# channel axis intact (don't flatten) so concatenation below stays
# along the time axis. Mono variants stay (T,) as before.
if isinstance(wav, list):
non_empty = [w.reshape(-1) for w in wav if isinstance(w, torch.Tensor) and w.numel() > 0]
non_empty = [w for w in wav if isinstance(w, torch.Tensor) and w.numel() > 0]
if non_empty and t_first_audio is None:
t_first_audio = _time.monotonic()
chunks.extend(non_empty)
elif isinstance(wav, torch.Tensor) and wav.numel() > 0:
if t_first_audio is None:
t_first_audio = _time.monotonic()
chunks.append(wav.reshape(-1))
chunks.append(wav)

t_gen_end = _time.monotonic()
if not chunks:
print("No audio in output — check model loading logs.")
return

wav_t = torch.cat(chunks).float().cpu()
sf.write(args.output, wav_t.numpy(), 24000)
print(f"Saved {len(wav_t) / 24000:.2f}s of audio to {args.output}")
cat_dim = -1 if chunks[0].dim() == 2 else 0
wav_t = torch.cat(chunks, dim=cat_dim).float().cpu()
n_samples = wav_t.shape[-1]
# soundfile wants (T,) mono or (T, C) multi-channel.
sf.write(args.output, (wav_t.transpose(0, 1) if wav_t.dim() == 2 else wav_t).numpy(), output_sr)
print(f"Saved {n_samples / output_sr:.2f}s of audio to {args.output}")
ttfa_ms = (t_first_audio - t_gen_start) * 1000.0 if t_first_audio is not None else float("nan")
gen_total_ms = (t_gen_end - t_gen_start) * 1000.0
print(f"TTFA_MS={ttfa_ms:.1f} GEN_TOTAL_MS={gen_total_ms:.1f} AUDIO_S={len(wav_t) / 24000:.2f}")
print(f"TTFA_MS={ttfa_ms:.1f} GEN_TOTAL_MS={gen_total_ms:.1f} AUDIO_S={n_samples / output_sr:.2f}")


def main() -> None:
Expand Down
34 changes: 31 additions & 3 deletions vllm_omni/benchmarks/patch/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
from vllm_omni.metrics import definitions as defs

_AUDIO_CONTINUITY_THRESHOLD_ENV = "VLLM_OMNI_BENCH_AUDIO_CONTINUITY_THRESHOLD_S"
_AUDIO_SAMPLE_RATE_ENV = "VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE"
_AUDIO_CHANNELS_ENV = "VLLM_OMNI_BENCH_AUDIO_CHANNELS"
RETURN_STAGE_METRICS_FIELD = "return_stage_metrics"
_IMAGE_STAGE_METRICS_BACKENDS = frozenset({"openai-image-edits-omni"})
_PRINT_STAGE = False
Expand Down Expand Up @@ -109,6 +111,32 @@ def _audio_continuity_threshold_s() -> float:
return max(value, 0.0)


def _audio_pcm_format() -> tuple[int, int]:
"""Return ``(sample_rate, channels)`` of the streamed PCM response.

Defaults to 24 kHz mono (Qwen3-TTS / VoxCPM2 / most MOSS-TTS variants).
Override for models with a different codec output format (e.g.
MOSS-TTS-Local-Transformer-v1.5, which streams 48 kHz stereo PCM) via
``VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE`` / ``VLLM_OMNI_BENCH_AUDIO_CHANNELS`` -
getting this wrong silently skews audio_duration/audio_rtf/underrun stats.
"""
sample_rate = 24000
channels = 1
raw_sr = os.environ.get(_AUDIO_SAMPLE_RATE_ENV)
if raw_sr:
try:
sample_rate = int(raw_sr)
except ValueError:
logger.warning("Invalid %s=%r; using default %d", _AUDIO_SAMPLE_RATE_ENV, raw_sr, sample_rate)
raw_ch = os.environ.get(_AUDIO_CHANNELS_ENV)
if raw_ch:
try:
channels = int(raw_ch)
except ValueError:
logger.warning("Invalid %s=%r; using default %d", _AUDIO_CHANNELS_ENV, raw_ch, channels)
return sample_rate, channels


get_samples_old = datasets.get_samples

_DEFAULT_DAILY_OMNI_REPO = "liarliar/Daily-Omni"
Expand Down Expand Up @@ -1133,10 +1161,10 @@ async def async_request_openai_audio_speech(
output = MixRequestFuncOutput()
output.prompt_len = request_func_input.prompt_len

# PCM format: 16-bit signed, 24 kHz, mono
sample_rate = 24000
# PCM format: 16-bit signed; sample_rate/channels are model-dependent
# (see _audio_pcm_format docstring).
sample_rate, channels = _audio_pcm_format()
sample_width = 2 # 16-bit = 2 bytes
channels = 1

st = time.perf_counter()
output.start_time = st
Expand Down
7 changes: 6 additions & 1 deletion vllm_omni/config/pipeline_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@
)
from vllm_omni.model_executor.models.ming_tts.pipeline import MING_TTS_PIPELINE
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO_4_5_PIPELINE
from vllm_omni.model_executor.models.moss_tts.pipeline import MOSS_TTS_PIPELINE, MOSS_TTS_REALTIME_PIPELINE
from vllm_omni.model_executor.models.moss_tts.pipeline import (
MOSS_TTS_LOCAL_PIPELINE,
MOSS_TTS_PIPELINE,
MOSS_TTS_REALTIME_PIPELINE,
)
from vllm_omni.model_executor.models.moss_tts_nano.pipeline import MOSS_TTS_NANO_PIPELINE
from vllm_omni.model_executor.models.qwen2_5_omni.pipeline import (
QWEN2_5_OMNI_PIPELINE,
Expand Down Expand Up @@ -112,6 +116,7 @@
"moss_tts_nano": MOSS_TTS_NANO_PIPELINE,
"moss_tts_delay": MOSS_TTS_PIPELINE,
"moss_tts_realtime": MOSS_TTS_REALTIME_PIPELINE,
"moss_tts_local": MOSS_TTS_LOCAL_PIPELINE,
"minicpmo_4_5": MINICPMO_4_5_PIPELINE,
"higgs_audio_v2": HIGGS_AUDIO_V2_PIPELINE,
"higgs_multimodal_qwen3": HIGGS_AUDIO_V3_PIPELINE,
Expand Down
56 changes: 56 additions & 0 deletions vllm_omni/deploy/moss_tts_local.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# MOSS-TTS-Local-Transformer-v1.5 (MossTTSLocalModel, n_vq=12, 48 kHz stereo)
# HF: OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5
# Codec: OpenMOSS-Team/MOSS-Audio-Tokenizer-v2 (48 kHz, stereo, channel-interleaved RVQ)
# Uses a 1-layer GPT2-style local depth transformer (no delay warm-up), reset every
# frame — same per-frame timing (80 ms/frame, 12.5 fps) as MOSS-TTS-Realtime.
async_chunk: true

connectors:
shm:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
codec_streaming: true
connector_get_sleep_s: 0.005
connector_get_max_wait_first_chunk: 1000
connector_get_max_wait: 300
codec_chunk_frames: 15 # 15 frames * 80 ms/frame = 1.2 s chunks
codec_left_context_frames: 0

stages:
- stage_id: 0
max_num_seqs: 8
gpu_memory_utilization: 0.60
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 4096
max_model_len: 4096
devices: "0"
output_connectors:
to_stage_1: shm
default_sampling_params:
temperature: 1.0
top_p: 0.95
top_k: 50
max_tokens: 4096
seed: 42
repetition_penalty: 1.1

- stage_id: 1
max_num_seqs: 1
gpu_memory_utilization: 0.15
enforce_eager: true
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 65536
max_model_len: 65536
devices: "0"
input_connectors:
from_stage_0: shm
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
repetition_penalty: 1.0
6 changes: 6 additions & 0 deletions vllm_omni/engine/arg_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def _register_omni_hf_configs() -> None:
IndexTTS2Config,
)
from vllm_omni.model_executor.models.ming_tts.config_ming_tts import MingDenseConfig
from vllm_omni.model_executor.models.moss_tts.configuration_moss_tts import (
MossTTSLocalConfig,
MossTTSRealtimeConfig,
)
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import (
Qwen3TTSConfig,
)
Expand All @@ -63,6 +67,8 @@ def _register_omni_hf_configs() -> None:
for model_type, config_cls in [
("dense", MingDenseConfig),
("indextts2", IndexTTS2Config),
("moss_tts_local", MossTTSLocalConfig),
("moss_tts_realtime", MossTTSRealtimeConfig),
("qwen3_tts", Qwen3TTSConfig),
("cosyvoice3", CosyVoice3Config),
("glm_tts", GLMTTSConfig),
Expand Down
60 changes: 53 additions & 7 deletions vllm_omni/entrypoints/openai/serving_speech.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
_MING_TTS_MODEL_STAGES = {"ming_tts"}
_MOSS_TTS_MODEL_STAGES = {"moss_tts_nano"}
_MOSS_TTS_FULL_MODEL_STAGES = {"moss_tts", "moss_tts_codec"}
_MOSS_TTS_LOCAL_MODEL_STAGES = {"moss_tts_local", "moss_tts_local_codec"}
_HIGGS_AUDIO_V2_TTS_MODEL_STAGES = {"higgs_audio_v2"}
_HIGGS_V3_TTS_MODEL_STAGES = {"higgs_audio_v3"}
_GLM_TTS_MODEL_STAGES = {"glm_tts"}
Expand All @@ -99,6 +100,7 @@
| _MING_TTS_MODEL_STAGES
| _MOSS_TTS_MODEL_STAGES
| _MOSS_TTS_FULL_MODEL_STAGES
| _MOSS_TTS_LOCAL_MODEL_STAGES
| _GLM_TTS_MODEL_STAGES
| _STEP_AUDIO2_TTS_MODEL_STAGES
| _INDEXTTS2_TTS_MODEL_STAGES
Expand Down Expand Up @@ -182,6 +184,18 @@ def _create_wav_header(sample_rate: int, num_channels: int = 1, bits_per_sample:
return header


def _infer_audio_num_channels(audio: np.ndarray) -> int:
"""Infer channel count before streaming PCM bytes are wrapped as WAV."""
if audio.ndim == 3 and audio.shape[0] == 1:
audio = audio[0]
if audio.ndim == 2:
if audio.shape[0] in (1, 2):
return int(audio.shape[0])
if audio.shape[1] in (1, 2):
return int(audio.shape[1])
return 1


def _sanitize_filename(filename: str) -> str:
"""Sanitize filename to prevent path traversal attacks.

Expand Down Expand Up @@ -688,6 +702,8 @@ def _detect_tts_model_type(self) -> str | None:
return "moss_tts_nano"
if model_stage in _MOSS_TTS_FULL_MODEL_STAGES:
return "moss_tts"
if model_stage in _MOSS_TTS_LOCAL_MODEL_STAGES:
return "moss_tts"
if model_stage in _HIGGS_AUDIO_V2_TTS_MODEL_STAGES:
return "higgs_audio_v2"
if model_stage in _HIGGS_V3_TTS_MODEL_STAGES:
Expand Down Expand Up @@ -1675,6 +1691,8 @@ def _detect_moss_variant(self) -> str:
name = ""
if "realtime" in name:
return "realtime"
if "local" in name:
return "local"
if "ttsd" in name:
return "ttsd"
if "soundeffect" in name:
Expand All @@ -1701,9 +1719,17 @@ def _validate_moss_tts_request(self, request: OpenAICreateSpeechRequest) -> str
return "Input text cannot be empty"

v = self._moss_variant
if v in (None, "tts", "realtime"):
if v in (None, "tts", "realtime", "local"):
if request.ref_audio is None:
label = "MOSS-TTS-Nano" if v is None else ("MOSS-TTS-Realtime" if v == "realtime" else "MOSS-TTS")
label = (
"MOSS-TTS-Nano"
if v is None
else (
"MOSS-TTS-Realtime"
if v == "realtime"
else ("MOSS-TTS-Local-Transformer" if v == "local" else "MOSS-TTS")
)
)
return f"{label} requires 'ref_audio' (reference audio for voice cloning)."
return self._validate_ref_audio_format(request.ref_audio)

Expand Down Expand Up @@ -1806,11 +1832,26 @@ async def _build_moss_tts_params(self, request: OpenAICreateSpeechRequest) -> di
params["prompt_audio_array"] = [[wav_list, sr]]
return params

# ---- MossTTSDelay family (tts/ttsd/sound_effect/voice_generator):
# call the upstream processor server-side to produce unified codes ----
# ---- MossTTSDelay family (tts/ttsd/sound_effect/voice_generator)
# and MOSS-TTS-Local-Transformer-v1.5: call the upstream processor
# server-side to produce unified codes. Local-v1.5 ships its own
# AutoProcessor (processor_config.json + processing_moss_tts.py) and
# reuses this exact build_user_message/encode_audios_from_wav path in
# the offline example (examples/.../moss_tts/end2end.py:
# _build_unified_codes) -- it is NOT in the same boat as Realtime
# (no processor_config.json there), so it must not fall back to the
# prompt_audio_array path above (which the talker's preprocess()
# never reads -- info_dict["codes"]["ref"] is the only thing it
# consumes, so skipping this path silently drops all voice-clone
# conditioning and produces unconditioned/garbage audio online). ----
proc = self._get_moss_processor()
n_vq = int(getattr(proc.model_config, "n_vq", 32))
sr_target = int(getattr(proc.model_config, "sampling_rate", 24000))
# Local-v1.5 encodes reference audio at a fixed 24 kHz working rate
# regardless of its 48 kHz stereo *output* codec -- mirrors the
# offline example's hardcoded encode_audios_from_wav(sampling_rate=24000)
# for this variant; proc.model_config.sampling_rate there is the
# output rate (48000), the wrong value to resample the reference into.
sr_target = 24000 if v == "local" else int(getattr(proc.model_config, "sampling_rate", 24000))

# Reference-audio encoding + speaker caching lives in the model package
# (moss_tts.reference_encoder), mirroring Fish Speech / CosyVoice3 /
Expand All @@ -1837,7 +1878,7 @@ async def _encode_ref(ref_str: str) -> torch.Tensor:
)

user_kwargs: dict[str, Any] = {"text": request.input or ""}
if v in ("tts", "realtime"):
if v in ("tts", "local"):
user_kwargs["reference"] = [await _encode_ref(request.ref_audio)]
elif v == "ttsd":
refs = [await _encode_ref(request.ref_audio)]
Expand Down Expand Up @@ -2654,7 +2695,12 @@ async def _generate_audio_chunks(
assert sr_raw is not None, (
"First audio chunk must include sample rate metadata for WAV streaming"
)
wav_header = _create_wav_header(sample_rate=sample_rate_val, num_channels=1, bits_per_sample=16)
num_channels = _infer_audio_num_channels(np.asarray(chunk_np))
wav_header = _create_wav_header(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, I think we really need to refactor the whole serving_speech.py. To be honest, it's hard to read and maintain now. And it has 4000+ lines now. @linyueqian @Sy0307 @hsliuustc0106 @alex-jw-brooks

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can not agree more XD

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe proposaling a new RFC can be a good start for making this file clear and easy to reuse. Thoughts? @linyueqian

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed. i try to refactor some but need more work

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcanlin completely agree, I have actually been meaning to refactor some of the entrypoints once I have some bandwidth. I think we need to decouple model specific code from individual entrypoints as much as possible. It will simplify this entrypoint a lot and make testing easier.

serving_chat is also quite hard to look at and 3.7k lines too 😬 I think we need to make sure tests are solid for current behavior and should simplify them both as soon as possible

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets create a new RFC for that

sample_rate=sample_rate_val,
num_channels=num_channels,
bits_per_sample=16,
)
yield wav_header
first_chunk = False

Expand Down
2 changes: 1 addition & 1 deletion vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,5 @@ class MossTTSNanoAdapter(_MossTTSAdapterBase):

@register_tts_adapter
class MossTTSAdapter(_MossTTSAdapterBase):
stage_keys = frozenset({"moss_tts", "moss_tts_codec"})
stage_keys = frozenset({"moss_tts", "moss_tts_codec", "moss_tts_local", "moss_tts_local_codec"})
name = "moss_tts"
Loading
Loading