From 28ce618f0e61a564447d9a97323edfc55d5dc585 Mon Sep 17 00:00:00 2001 From: BeatSeat <137616995+BeatSeat@users.noreply.github.com> Date: Sun, 24 May 2026 05:42:26 +0800 Subject: [PATCH] [Model] Add GLM-TTS text-to-speech model support (#3141) Signed-off-by: BeatSeat Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> --- .buildkite/test-merge.yml | 41 + docs/models/supported_models.md | 1 + .../offline_inference/text_to_speech.md | 34 + .../examples/online_serving/text_to_speech.md | 56 + .../text_to_speech/README.md | 30 + .../text_to_speech/glm_tts/end2end.py | 194 +++ .../online_serving/text_to_speech/README.md | 40 + .../text_to_speech/glm_tts/gradio_demo.py | 291 ++++ .../glm_tts/openai_speech_client.py | 173 ++ .../text_to_speech/glm_tts/run_gradio_demo.sh | 48 + .../text_to_speech/glm_tts/run_server.sh | 38 + recipes/README.md | 1 + recipes/zai-org/GLM-TTS.md | 105 ++ tests/assets/glm_tts/jiayan_zh.wav | Bin 0 -> 356734 bytes tests/e2e/offline_inference/test_glm_tts.py | 148 ++ tests/e2e/online_serving/test_glm_tts.py | 131 ++ .../openai_api/test_serving_speech.py | 78 + .../models/test_glm_tts_prompt_len.py | 351 ++++ .../test_glm_tts_async_chunk.py | 278 ++++ vllm_omni/config/pipeline_registry.py | 4 + vllm_omni/deploy/glm_tts.yaml | 83 + vllm_omni/engine/arg_utils.py | 11 + .../entrypoints/openai/serving_speech.py | 192 ++- vllm_omni/entrypoints/utils.py | 15 +- .../models/common/nucleus_ras_sampling.py | 118 ++ .../models/common/whisper_vq.py | 291 ++++ .../model_executor/models/glm_tts/__init__.py | 18 + .../model_executor/models/glm_tts/glm_tts.py | 1423 +++++++++++++++++ .../models/glm_tts/glm_tts_dit.py | 650 ++++++++ .../models/glm_tts/glm_tts_dit_wrapper.py | 1301 +++++++++++++++ .../model_executor/models/glm_tts/pipeline.py | 46 + .../models/glm_tts/text_frontend.py | 393 +++++ .../model_executor/models/glm_tts/vocoder.py | 228 +++ .../models/glm_tts/voice_clone.py | 299 ++++ vllm_omni/model_executor/models/registry.py | 6 + .../stage_input_processors/glm_tts.py | 362 +++++ .../transformers_utils/configs/__init__.py | 3 + .../transformers_utils/configs/glm_tts.py | 94 ++ 38 files changed, 7568 insertions(+), 7 deletions(-) create mode 100644 examples/offline_inference/text_to_speech/glm_tts/end2end.py create mode 100644 examples/online_serving/text_to_speech/glm_tts/gradio_demo.py create mode 100644 examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py create mode 100644 examples/online_serving/text_to_speech/glm_tts/run_gradio_demo.sh create mode 100644 examples/online_serving/text_to_speech/glm_tts/run_server.sh create mode 100644 recipes/zai-org/GLM-TTS.md create mode 100644 tests/assets/glm_tts/jiayan_zh.wav create mode 100644 tests/e2e/offline_inference/test_glm_tts.py create mode 100644 tests/e2e/online_serving/test_glm_tts.py create mode 100644 tests/model_executor/models/test_glm_tts_prompt_len.py create mode 100644 tests/model_executor/stage_input_processors/test_glm_tts_async_chunk.py create mode 100644 vllm_omni/deploy/glm_tts.yaml create mode 100644 vllm_omni/model_executor/models/common/nucleus_ras_sampling.py create mode 100644 vllm_omni/model_executor/models/common/whisper_vq.py create mode 100644 vllm_omni/model_executor/models/glm_tts/__init__.py create mode 100644 vllm_omni/model_executor/models/glm_tts/glm_tts.py create mode 100644 vllm_omni/model_executor/models/glm_tts/glm_tts_dit.py create mode 100644 vllm_omni/model_executor/models/glm_tts/glm_tts_dit_wrapper.py create mode 100644 vllm_omni/model_executor/models/glm_tts/pipeline.py create mode 100644 vllm_omni/model_executor/models/glm_tts/text_frontend.py create mode 100644 vllm_omni/model_executor/models/glm_tts/vocoder.py create mode 100644 vllm_omni/model_executor/models/glm_tts/voice_clone.py create mode 100644 vllm_omni/model_executor/stage_input_processors/glm_tts.py create mode 100644 vllm_omni/transformers_utils/configs/glm_tts.py diff --git a/.buildkite/test-merge.yml b/.buildkite/test-merge.yml index 9e5027272..5b8d7e104 100644 --- a/.buildkite/test-merge.yml +++ b/.buildkite/test-merge.yml @@ -248,6 +248,47 @@ steps: path: /mnt/hf-cache type: DirectoryOrCreate + - label: "TTS · GLM-TTS Test" + timeout_in_minutes: 20 + commands: + - | + timeout 20m bash -c " + export VLLM_LOGGING_LEVEL=DEBUG + pytest -s -v tests/e2e/online_serving/test_glm_tts.py tests/e2e/offline_inference/test_glm_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model' + " + agents: + queue: "mithril-h100-pool" + plugins: + - kubernetes: + podSpec: + containers: + - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + resources: + limits: + nvidia.com/gpu: 1 + volumeMounts: + - name: devshm + mountPath: /dev/shm + - name: hf-cache + mountPath: /root/.cache/huggingface + env: + - name: HF_HOME + value: /root/.cache/huggingface + - name: HF_TOKEN + valueFrom: + secretKeyRef: + name: hf-token-secret + key: token + nodeSelector: + node.kubernetes.io/instance-type: gpu-h100-sxm + volumes: + - name: devshm + emptyDir: + medium: Memory + - name: hf-cache + hostPath: + path: /mnt/hf-cache + type: DirectoryOrCreate - label: "Omni · Qwen3-Omni Test" timeout_in_minutes: 30 diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index b0637a5d7..d26a579ed 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -58,6 +58,7 @@ th { | `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | +| `GLMTTSForConditionalGeneration` | GLM-TTS | `zai-org/GLM-TTS` | ✅︎ | | | | | `NextStep11Pipeline` | NextStep-1.1 | `stepfun-ai/NextStep-1.1` | ✅︎ | ✅︎ | | ✅︎ | | `MiMoAudioModel` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | ✅︎ | ✅︎ | | | | `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | diff --git a/docs/user_guide/examples/offline_inference/text_to_speech.md b/docs/user_guide/examples/offline_inference/text_to_speech.md index 7ad3d2d40..7937b1180 100644 --- a/docs/user_guide/examples/offline_inference/text_to_speech.md +++ b/docs/user_guide/examples/offline_inference/text_to_speech.md @@ -19,6 +19,7 @@ list of supported architectures across all modalities, see | VoxCPM2 | `openbmb/VoxCPM2` | single (native AR) | ✓ | — | continuation (`--ref-audio` + `--ref-text`) | 48 kHz | | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ (`async_chunk: true` default) | — | 22.05 kHz | | Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ (`--streaming`) | — | 44.1 kHz | +| GLM-TTS | `zai-org/GLM-TTS` | 2 (AR + DiT) | ✓ (required) | ✓ | — | 24 kHz | | OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design (`--instruct`), language (`--lang`) | 24 kHz | | Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | 2 (talker + code2wav) | ✓ (Base) | ✓ | 3 task variants (`--query-type`) | 24 kHz | | Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | varies | ✓ | ✓ | voice presets (`--voice`) | 24 kHz | @@ -158,6 +159,35 @@ Streaming requires `async_chunk: true` in the stage config. --- +## GLM-TTS + +2-stage TTS pipeline (AR + DiT flow-matching) at 24 kHz. Every request requires reference audio and its transcript for zero-shot voice cloning. + +### Quick start +```bash +python examples/offline_inference/text_to_speech/glm_tts/end2end.py \ + --model zai-org/GLM-TTS \ + --text "你好,这是语音合成测试。" \ + --ref-audio /path/to/reference.wav \ + --ref-text "这是参考音频的文本内容。" \ + --output-dir ./output +``` + +### Architecture +``` +Text → [Stage 0: AR] → Speech Tokens → [Stage 1: DiT + HiFT] → Audio (24 kHz) + (Llama-based) (32k vocab) (Flow Matching) +``` + +### Notes +- `--ref-audio` and `--ref-text` are **required** together; GLM-TTS does not support text-only synthesis. +- Reference audio should be 3-10 seconds. +- First run may be slow due to lazy loading of WhisperVQ tokenizer and CampPlus ONNX speaker embedder. +- Default sampling: temperature=1.0, top_k=25, top_p=0.8 (RAS method). +- The `--model` path should point to the repository root (not `llm/` subdirectory). + +--- + ## OmniVoice Zero-shot multilingual TTS supporting 600+ languages, with three modes (auto / clone / design). @@ -313,6 +343,10 @@ Available voice presets are listed on the HF model card (`mistralai/Voxtral-4B-T ``````py --8<-- "examples/offline_inference/text_to_speech/fish_speech/end2end.py" `````` +??? abstract "glm_tts/end2end.py" + ``````py + --8<-- "examples/offline_inference/text_to_speech/glm_tts/end2end.py" + `````` ??? abstract "omnivoice/end2end.py" ``````py --8<-- "examples/offline_inference/text_to_speech/omnivoice/end2end.py" diff --git a/docs/user_guide/examples/online_serving/text_to_speech.md b/docs/user_guide/examples/online_serving/text_to_speech.md index ff22dd0fd..37bfde7ad 100644 --- a/docs/user_guide/examples/online_serving/text_to_speech.md +++ b/docs/user_guide/examples/online_serving/text_to_speech.md @@ -19,6 +19,7 @@ For the full list of supported architectures across all modalities, see |---|---|---|---|---|---| | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | — | | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | ✓ | +| GLM-TTS | `zai-org/GLM-TTS` | ✓ (`ref_audio`+`ref_text`, required) | ✓ (PCM stream) | — | ✓ | | OmniVoice | `k2-fsa/OmniVoice` | (offline only) | — | — | — | | Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | ✓ (Base) | ✓ (PCM + WebSocket) | ✓ (presets + `/v1/audio/voices` upload) | ✓ (standard + FastRTC) | | VoxCPM2 | `openbmb/VoxCPM2` | ✓ | ✓ (AudioWorklet via gradio) | — | ✓ | @@ -192,6 +193,45 @@ python fish_speech/gradio_demo.py --api-base http://localhost:8091 # if server --- +## GLM-TTS + +2-stage TTS (AR + DiT flow-matching) at 24 kHz. Every request requires `ref_audio` + `ref_text`. + +### Launch +```bash +vllm serve zai-org/GLM-TTS --omni --trust-remote-code --port 8091 +# or: +bash examples/online_serving/text_to_speech/glm_tts/run_server.sh /path/to/GLM-TTS +``` + +### Sending requests +```bash +# Voice cloning (required) +python examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py \ + --text "你好,这是语音克隆测试。" \ + --ref-audio file:///path/to/ref.wav \ + --ref-text "这是参考音频的文本内容。" + +# Custom format +python examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py \ + --text "Hello, this is a voice cloning test." \ + --ref-audio file:///path/to/ref.wav \ + --ref-text "Transcript of the reference audio." \ + --response-format mp3 -o output.mp3 +``` + +### Gradio demo +```bash +bash examples/online_serving/text_to_speech/glm_tts/run_gradio_demo.sh +``` + +### Notes +- Output: 24 kHz mono WAV via HiFT vocoder. +- `ref_audio` + `ref_text` are **required** together on every request. Reference audio should be 3-10 seconds. +- Voice cloning feature extraction (WhisperVQ, CampPlus, mel) runs on the model side — no external dependency on the serving layer. + +--- + ## OmniVoice Zero-shot multilingual TTS (600+ languages). Online serving currently exposes **auto voice** only; voice cloning and voice design are available offline. @@ -419,6 +459,22 @@ The demo handles voice-preset selection and reference-audio upload. `voxtral_tts ``````py --8<-- "examples/online_serving/text_to_speech/fish_speech/speech_client.py" `````` +??? abstract "glm_tts/gradio_demo.py" + ``````py + --8<-- "examples/online_serving/text_to_speech/glm_tts/gradio_demo.py" + `````` +??? abstract "glm_tts/openai_speech_client.py" + ``````py + --8<-- "examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py" + `````` +??? abstract "glm_tts/run_gradio_demo.sh" + ``````sh + --8<-- "examples/online_serving/text_to_speech/glm_tts/run_gradio_demo.sh" + `````` +??? abstract "glm_tts/run_server.sh" + ``````sh + --8<-- "examples/online_serving/text_to_speech/glm_tts/run_server.sh" + `````` ??? abstract "omnivoice/run_server.sh" ``````sh --8<-- "examples/online_serving/text_to_speech/omnivoice/run_server.sh" diff --git a/examples/offline_inference/text_to_speech/README.md b/examples/offline_inference/text_to_speech/README.md index 722b9eeb3..0bf0efc4e 100644 --- a/examples/offline_inference/text_to_speech/README.md +++ b/examples/offline_inference/text_to_speech/README.md @@ -16,6 +16,7 @@ list of supported architectures across all modalities, see |---|---|---|---|---|---|---| | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 24 kHz | | Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ | — | 44.1 kHz | +| GLM-TTS | `zai-org/GLM-TTS` | 2 (AR + DiT) | ✓ (required) | ✓ | — | 24 kHz | | Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | single (talker only) | — (caption-controlled) | — | style / IP / basic captions | 44.1 kHz | | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | single (AR + codec) | ✓ (required) | ✓ | voice_clone, continuation | 48 kHz | | OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design, language hint | 24 kHz | @@ -92,6 +93,35 @@ Streaming is enabled by default via `async_chunk: true` in `vllm_omni/deploy/cos --- +## GLM-TTS + +2-stage TTS pipeline (AR + DiT flow-matching) at 24 kHz. Every request requires reference audio and its transcript for zero-shot voice cloning. + +### Quick start +```bash +python examples/offline_inference/text_to_speech/glm_tts/end2end.py \ + --model zai-org/GLM-TTS \ + --text "你好,这是语音合成测试。" \ + --ref-audio /path/to/reference.wav \ + --ref-text "这是参考音频的文本内容。" \ + --output-dir ./output +``` + +### Architecture +``` +Text → [Stage 0: AR] → Speech Tokens → [Stage 1: DiT + HiFT] → Audio (24 kHz) + (Llama-based) (32k vocab) (Flow Matching) +``` + +### Notes +- `--ref-audio` and `--ref-text` are **required** together; GLM-TTS does not support text-only synthesis. +- Reference audio should be 3-10 seconds. +- First run may be slow due to lazy loading of WhisperVQ tokenizer and CampPlus ONNX speaker embedder. +- Default sampling: temperature=1.0, top_k=25, top_p=0.8 (RAS method). +- The `--model` path should point to the repository root (not `llm/` subdirectory). + +--- + ## Fish Speech S2 Pro 4B dual-AR text-to-speech model from FishAudio with the DAC codec at 44.1 kHz. diff --git a/examples/offline_inference/text_to_speech/glm_tts/end2end.py b/examples/offline_inference/text_to_speech/glm_tts/end2end.py new file mode 100644 index 000000000..d1f19f8a5 --- /dev/null +++ b/examples/offline_inference/text_to_speech/glm_tts/end2end.py @@ -0,0 +1,194 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GLM-TTS End-to-End Offline Inference Example. + +GLM-TTS is a two-stage TTS system: + - Stage 0 (AR): Llama-based model generates speech tokens from text + - Stage 1 (DiT): Flow matching model converts speech tokens to audio + +Usage: + # Sync two-stage (default) + python examples/offline_inference/text_to_speech/glm_tts/end2end.py \ + --model /path/to/GLM-TTS \ + --text "你好,这是一个语音合成测试。" \ + --ref-audio /path/to/reference.wav \ + --ref-text "参考音频的转录文本。" \ + --output-dir ./output + + # Async chunk mode (streaming DiT) + python examples/offline_inference/text_to_speech/glm_tts/end2end.py \ + --model /path/to/GLM-TTS --async-chunk \ + --text "你好,这是一个语音合成测试。" \ + --ref-audio /path/to/reference.wav \ + --ref-text "参考音频的转录文本。" \ + --output-dir ./output +""" + +import base64 +import io +import logging +import os +import tempfile +import time +from typing import Any +from urllib.request import urlopen + +import numpy as np +import soundfile as sf +import torch +import yaml + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +from vllm.utils.argparse_utils import FlexibleArgumentParser + +from vllm_omni import Omni +from vllm_omni.model_executor.models.glm_tts.glm_tts import build_glm_tts_prefill_metadata + +logger = logging.getLogger(__name__) + +DEFAULT_DEPLOY_CONFIG = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "..", + "vllm_omni", + "deploy", + "glm_tts.yaml", +) +SAMPLE_RATE = 24000 + + +def _load_ref_audio(ref_audio: str) -> tuple[torch.Tensor, int]: + """Load reference audio from file path, URL, or data URI.""" + if ref_audio.startswith(("http://", "https://")): + with urlopen(ref_audio, timeout=60) as response: + audio_obj: Any = io.BytesIO(response.read()) + elif ref_audio.startswith("data:"): + _, _, encoded = ref_audio.partition(",") + audio_obj = io.BytesIO(base64.b64decode(encoded)) + else: + audio_obj = ref_audio + wav_np, sr = sf.read(audio_obj, dtype="float32") + if wav_np.ndim > 1: + wav_np = wav_np.mean(axis=1) + return torch.from_numpy(wav_np), int(sr) + + +def _concat_audio(audio_val: Any) -> np.ndarray: + """Concatenate audio tensors from multimodal output.""" + if isinstance(audio_val, list): + tensors = [torch.as_tensor(t).float().reshape(-1) for t in audio_val if t is not None] + if not tensors: + return np.zeros((0,), dtype=np.float32) + return torch.cat(tensors, dim=-1).cpu().numpy().astype(np.float32, copy=False) + if isinstance(audio_val, torch.Tensor): + return audio_val.float().cpu().numpy().reshape(-1) + return np.asarray(audio_val, dtype=np.float32).reshape(-1) + + +def _extract_sample_rate(audio_mm: dict) -> int: + """Extract sample rate from multimodal output dict.""" + sr_raw = audio_mm.get("sr", SAMPLE_RATE) + if isinstance(sr_raw, list): + sr_raw = sr_raw[-1] if sr_raw else SAMPLE_RATE + if hasattr(sr_raw, "item"): + return int(sr_raw.item()) + return int(sr_raw) + + +def _modify_deploy_config(base_path: str, async_chunk: bool) -> str: + """Build deploy config with explicit sync/async mode and eager execution. + + Mirrors the logic in ``tests/e2e/offline_inference/test_glm_tts.py`` + (``_get_deploy_config``) so that example runs match CI behavior. + """ + with open(base_path) as f: + cfg = yaml.safe_load(f) + cfg["async_chunk"] = async_chunk + for stage in cfg.get("stages", []): + stage["enforce_eager"] = True + if stage.get("stage_id") == 0: + stage["async_scheduling"] = bool(async_chunk) + tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, prefix="glm_tts_") + yaml.dump(cfg, tmp) + tmp.close() + return tmp.name + + +def main(args): + """Run offline GLM-TTS inference.""" + os.makedirs(args.output_dir, exist_ok=True) + base_deploy_config = args.deploy_config or DEFAULT_DEPLOY_CONFIG + deploy_config_path = _modify_deploy_config(base_deploy_config, args.async_chunk) + + ref_audio_wav, ref_audio_sr = _load_ref_audio(args.ref_audio) + if not args.ref_text: + raise ValueError("GLM-TTS requires --ref-audio and --ref-text.") + + inputs = [ + { + "prompt": args.text, + "multi_modal_data": { + "audio": (ref_audio_wav.float().cpu().numpy(), ref_audio_sr), + }, + "modalities": ["audio"], + "mm_processor_kwargs": {"prompt_text": args.ref_text}, + "additional_information": build_glm_tts_prefill_metadata( + args.model, + args.text, + args.ref_text, + ), + } + ] + + omni = Omni( + model=args.model, + stage_configs_path=deploy_config_path, + log_stats=args.log_stats, + stage_init_timeout=args.stage_init_timeout, + ) + + t_start = time.perf_counter() + outputs = omni.generate(inputs) + elapsed = (time.perf_counter() - t_start) * 1000 + + assert outputs, "No outputs returned" + audio_mm = outputs[0].multimodal_output + assert "audio" in audio_mm, "No audio output found" + + audio = _concat_audio(audio_mm["audio"]) + sr = _extract_sample_rate(audio_mm) + out_path = os.path.join(args.output_dir, "output.wav") + sf.write(out_path, audio, samplerate=sr, format="WAV") + + logger.info("Saved %s (%.2fs @ %dHz)", out_path, len(audio) / sr, sr) + logger.info("Total inference: %.1f ms", elapsed) + + +def parse_args(): + parser = FlexibleArgumentParser(description="GLM-TTS Text-to-Speech Example") + parser.add_argument("--model", type=str, required=True, help="Model path") + parser.add_argument("--text", type=str, default="你好,这是一个语音合成测试。") + parser.add_argument("--output-dir", type=str, default="./output") + parser.add_argument("--ref-audio", type=str, required=True, help="Reference WAV path/URL") + parser.add_argument("--ref-text", type=str, required=True, help="Transcript of ref audio") + parser.add_argument("--deploy-config", type=str, default=None) + parser.add_argument( + "--async-chunk", + action="store_true", + default=False, + help="Enable async_chunk mode (streaming DiT). Default: sync two-stage.", + ) + parser.add_argument("--log-stats", action="store_true") + parser.add_argument("--stage-init-timeout", type=int, default=600) + return parser.parse_args() + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + main(parse_args()) diff --git a/examples/online_serving/text_to_speech/README.md b/examples/online_serving/text_to_speech/README.md index 75b85580d..922156d4c 100644 --- a/examples/online_serving/text_to_speech/README.md +++ b/examples/online_serving/text_to_speech/README.md @@ -15,6 +15,7 @@ For the full list of supported architectures across all modalities, see | Model | HuggingFace repo | Voice cloning | Streaming | Voice presets / upload | Gradio demo | |---|---|---|---|---|---| | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | ✓ | +| GLM-TTS | `zai-org/GLM-TTS` | ✓ (`ref_audio`+`ref_text`, required) | ✓ (PCM stream) | — | ✓ | | Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | — (caption-controlled) | — | caption fields (`instructions`) | — | | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✓ (`ref_audio` required) | ✓ (PCM stream) | — | ✓ | | OmniVoice | `k2-fsa/OmniVoice` | (offline only) | — | — | — | @@ -94,6 +95,45 @@ For full request-shape documentation (all parameters, response formats, error co --- +## GLM-TTS + +2-stage TTS (AR + DiT flow-matching) at 24 kHz. Every request requires `ref_audio` + `ref_text`. + +### Launch +```bash +vllm serve zai-org/GLM-TTS --omni --trust-remote-code --port 8091 +# or: +bash examples/online_serving/text_to_speech/glm_tts/run_server.sh /path/to/GLM-TTS +``` + +### Sending requests +```bash +# Voice cloning (required) +python examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py \ + --text "你好,这是语音克隆测试。" \ + --ref-audio file:///path/to/ref.wav \ + --ref-text "这是参考音频的文本内容。" + +# Custom format +python examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py \ + --text "Hello, this is a voice cloning test." \ + --ref-audio file:///path/to/ref.wav \ + --ref-text "Transcript of the reference audio." \ + --response-format mp3 -o output.mp3 +``` + +### Gradio demo +```bash +bash examples/online_serving/text_to_speech/glm_tts/run_gradio_demo.sh +``` + +### Notes +- Output: 24 kHz mono WAV via HiFT vocoder. +- `ref_audio` + `ref_text` are **required** together on every request. Reference audio should be 3-10 seconds. +- Voice cloning feature extraction (WhisperVQ, CampPlus, mel) runs on the model side — no external dependency on the serving layer. + +--- + ## Fish Speech S2 Pro 4B dual-AR TTS at 44.1 kHz. Server uses the DAC codec. diff --git a/examples/online_serving/text_to_speech/glm_tts/gradio_demo.py b/examples/online_serving/text_to_speech/glm_tts/gradio_demo.py new file mode 100644 index 000000000..12e5d4b11 --- /dev/null +++ b/examples/online_serving/text_to_speech/glm_tts/gradio_demo.py @@ -0,0 +1,291 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Gradio demo for GLM-TTS online serving via /v1/audio/speech API. + +Supports: + - Voice-cloned text-to-speech synthesis from reference audio (upload or URL) + - Streaming (progressive PCM chunks) and non-streaming modes + +Usage: + # Start the server first (see run_server.sh), then: + python gradio_demo.py --api-base http://localhost:8091 + + # Or use run_gradio_demo.sh to start both server and demo together. +""" + +import argparse +import base64 +import io + +try: + import gradio as gr +except ImportError: + raise ImportError("gradio is required to run this demo. Install it with: pip install 'vllm-omni[demo]'") from None +import httpx +import numpy as np +import soundfile as sf + +PCM_SAMPLE_RATE = 24000 +DEFAULT_API_BASE = "http://localhost:8091" + + +def encode_audio_to_base64(audio_data: tuple) -> str: + """Encode Gradio audio input (sample_rate, numpy_array) to base64 data URL.""" + sample_rate, audio_np = audio_data + if audio_np.dtype != np.int16: + if audio_np.dtype in (np.float32, np.float64): + audio_np = np.clip(audio_np, -1.0, 1.0) + audio_np = (audio_np * 32767).astype(np.int16) + else: + audio_np = audio_np.astype(np.int16) + buf = io.BytesIO() + sf.write(buf, audio_np, sample_rate, format="WAV") + wav_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + return f"data:audio/wav;base64,{wav_b64}" + + +def build_payload( + text: str, + ref_audio: tuple | None, + ref_audio_url: str, + ref_text: str, + response_format: str = "wav", + stream: bool = False, + max_new_tokens: int = 4096, +) -> dict: + """Build the /v1/audio/speech request payload.""" + if not text or not text.strip(): + raise gr.Error("Please enter text to synthesize.") + + payload: dict = { + "input": text.strip(), + "voice": "default", + "response_format": "pcm" if stream else response_format, + "stream": stream, + "max_new_tokens": max_new_tokens, + } + + # Voice cloning: ref_audio takes priority over URL. + ref_url_stripped = ref_audio_url.strip() if ref_audio_url else "" + if ref_audio is not None: + payload["ref_audio"] = encode_audio_to_base64(ref_audio) + elif ref_url_stripped: + payload["ref_audio"] = ref_url_stripped + else: + raise gr.Error("GLM-TTS requires a reference audio file or URL.") + + if not ref_text or not ref_text.strip(): + raise gr.Error("GLM-TTS requires a transcript of the reference audio.") + payload["ref_text"] = ref_text.strip() + + return payload + + +def generate_speech(api_base: str, text: str, ref_audio, ref_audio_url, ref_text, response_format, max_new_tokens): + """Non-streaming: call /v1/audio/speech and return full audio.""" + payload = build_payload( + text, ref_audio, ref_audio_url, ref_text, response_format, stream=False, max_new_tokens=max_new_tokens + ) + + try: + with httpx.Client(timeout=300.0) as client: + resp = client.post( + f"{api_base}/v1/audio/speech", + json=payload, + headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"}, + ) + except httpx.TimeoutException: + raise gr.Error("Request timed out. The server may be busy.") + except httpx.ConnectError: + raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?") + + if resp.status_code != 200: + raise gr.Error(f"Server error ({resp.status_code}): {resp.text}") + + content_type = resp.headers.get("content-type", "") + if "application/json" in content_type: + try: + raise gr.Error(f"Server error: {resp.json()}") + except ValueError: + pass + + try: + if response_format == "pcm": + audio_np = np.frombuffer(resp.content, dtype=np.int16).astype(np.float32) / 32767.0 + return (PCM_SAMPLE_RATE, audio_np) + audio_np, sample_rate = sf.read(io.BytesIO(resp.content)) + if audio_np.ndim > 1: + audio_np = audio_np[:, 0] + return (sample_rate, audio_np.astype(np.float32)) + except Exception as e: + raise gr.Error(f"Failed to decode audio: {e}") + + +def generate_speech_stream( + api_base, text, ref_audio, ref_audio_url, ref_text, response_format, max_new_tokens, chunk_seconds=0.5 +): + """Streaming: yield PCM chunks for progressive playback.""" + payload = build_payload( + text, ref_audio, ref_audio_url, ref_text, response_format, stream=True, max_new_tokens=max_new_tokens + ) + + min_chunk_samples = int(PCM_SAMPLE_RATE * chunk_seconds) + prebuffer_chunks = 2 + pending: list[np.ndarray] = [] + pending_len = 0 + chunks_yielded = 0 + prebuffer: list[np.ndarray] = [] + + try: + leftover = b"" + with httpx.Client(timeout=300.0) as client: + with client.stream( + "POST", + f"{api_base}/v1/audio/speech", + json=payload, + headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"}, + ) as resp: + if resp.status_code != 200: + resp.read() + raise gr.Error(f"Server error ({resp.status_code}): {resp.text}") + for chunk in resp.iter_bytes(): + if not chunk: + continue + raw = leftover + chunk + usable = len(raw) - (len(raw) % 2) + leftover = raw[usable:] + if usable == 0: + continue + float_samples = np.frombuffer(raw[:usable], dtype=np.int16).copy().astype(np.float32) / 32767.0 + pending.append(float_samples) + pending_len += len(float_samples) + if pending_len >= min_chunk_samples: + audio_chunk = np.concatenate(pending) + pending.clear() + pending_len = 0 + if chunks_yielded < prebuffer_chunks: + prebuffer.append(audio_chunk) + chunks_yielded += 1 + if chunks_yielded == prebuffer_chunks: + yield (PCM_SAMPLE_RATE, np.concatenate(prebuffer)) + prebuffer.clear() + else: + yield (PCM_SAMPLE_RATE, audio_chunk) + remaining = prebuffer + pending + if remaining: + yield (PCM_SAMPLE_RATE, np.concatenate(remaining)) + except httpx.TimeoutException: + raise gr.Error("Request timed out. The server may be busy.") + except httpx.ConnectError: + raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?") + + +def on_stream_change(stream: bool): + """Lock format to PCM when streaming.""" + if stream: + return gr.update(value="pcm", interactive=False) + return gr.update(value="wav", interactive=True) + + +def build_interface(api_base: str, stream_chunk_seconds: float = 0.5): + """Build the Gradio interface.""" + with gr.Blocks(title="GLM-TTS Demo") as demo: + gr.Markdown("# GLM-TTS - Voice Cloning") + gr.Markdown(f"**Server:** `{api_base}` | **Model:** GLM-TTS | **Output:** 24kHz") + + with gr.Row(): + with gr.Column(scale=3): + text_input = gr.Textbox( + label="Text to Synthesize", + placeholder="Enter text here (supports Chinese and English)...", + lines=4, + ) + + with gr.Accordion("Reference Voice (required)", open=True): + gr.Markdown("Upload or link a short reference audio (3-10s) and provide its transcript.") + ref_audio = gr.Audio( + label="Reference Audio", + type="numpy", + sources=["upload", "microphone"], + ) + ref_audio_url = gr.Textbox( + label="Reference Audio URL (alternative to upload)", + placeholder="https://example.com/reference.wav", + lines=1, + ) + ref_text = gr.Textbox( + label="Reference Audio Transcript (required for cloning)", + placeholder="Exact transcript of the reference audio...", + lines=2, + ) + + with gr.Row(): + response_format = gr.Dropdown( + choices=["wav", "mp3", "flac", "pcm"], + value="wav", + label="Audio Format", + scale=1, + ) + stream_checkbox = gr.Checkbox( + label="Stream output", + value=False, + info="Progressive PCM streaming", + scale=1, + ) + max_new_tokens = gr.Slider( + minimum=256, + maximum=8192, + value=4096, + step=256, + label="Max New Tokens", + scale=1, + ) + + generate_btn = gr.Button("Generate Speech", variant="primary", size="lg") + + with gr.Column(scale=2): + audio_output = gr.Audio( + label="Generated Audio", + interactive=False, + streaming=True, + autoplay=True, + ) + gr.Markdown( + "### About\n" + "- **GLM-TTS** by Zhipu AI: Llama-based AR + Flow Matching DiT\n" + "- **Voice cloning**: upload 3-10s reference + transcript\n" + "- **Streaming**: real-time PCM output\n" + "- **24kHz** output via Vocos vocoder" + ) + + stream_checkbox.change(fn=on_stream_change, inputs=[stream_checkbox], outputs=[response_format]) + + all_inputs = [text_input, ref_audio, ref_audio_url, ref_text, response_format, max_new_tokens] + + def dispatch(stream_enabled, *args): + if stream_enabled: + yield from generate_speech_stream(api_base, *args, chunk_seconds=stream_chunk_seconds) + else: + yield generate_speech(api_base, *args) + + generate_btn.click(fn=dispatch, inputs=[stream_checkbox] + all_inputs, outputs=[audio_output]) + demo.queue() + return demo + + +def main(): + parser = argparse.ArgumentParser(description="Gradio demo for GLM-TTS") + parser.add_argument("--api-base", default=DEFAULT_API_BASE, help=f"API base URL (default: {DEFAULT_API_BASE})") + parser.add_argument("--host", default="0.0.0.0", help="Gradio host (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=7860, help="Gradio port (default: 7860)") + parser.add_argument("--share", action="store_true", help="Share publicly") + parser.add_argument("--stream-chunk-seconds", type=float, default=0.5, help="Seconds per streaming chunk") + args = parser.parse_args() + + print(f"Connecting to vLLM server at: {args.api_base}") + demo = build_interface(args.api_base, stream_chunk_seconds=args.stream_chunk_seconds) + demo.launch(server_name=args.host, server_port=args.port, share=args.share) + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py b/examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py new file mode 100644 index 000000000..a0ff181c3 --- /dev/null +++ b/examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""OpenAI-compatible client for GLM-TTS via /v1/audio/speech endpoint. + +GLM-TTS is a two-stage TTS system (AR + DiT) that generates audio from text +conditioned on reference speech. Each request requires ref_audio + ref_text. + +Usage: + # Voice cloning + python openai_speech_client.py --text "你好" --ref-audio file:///path/to/ref.wav --ref-text "参考文本" + + # Streaming response, for async_chunk server mode + python openai_speech_client.py --text "你好" --stream --ref-audio file:///path/to/ref.wav --ref-text "参考文本" + + # Specify output format + python openai_speech_client.py --text "你好" --ref-audio file:///path/to/ref.wav \ + --ref-text "参考文本" --response-format mp3 -o output.mp3 +""" + +import argparse + +import httpx + +# Default server configuration +DEFAULT_API_BASE = "http://localhost:8091" +DEFAULT_API_KEY = "EMPTY" + + +def run_tts_generation(args) -> None: + """Run TTS generation via OpenAI-compatible /v1/audio/speech API.""" + if not args.ref_audio or not args.ref_text: + raise ValueError("GLM-TTS requires --ref-audio and --ref-text for voice cloning.") + + payload = { + "model": args.model, + "voice": "default", + "input": args.text, + "response_format": args.response_format, + "stream": bool(args.stream), + "ref_audio": args.ref_audio, + "ref_text": args.ref_text, + } + if args.max_new_tokens: + payload["max_new_tokens"] = args.max_new_tokens + + print(f"Model: {args.model}") + print(f"Text: {args.text}") + print(f"Voice cloning: ref_audio={args.ref_audio}, ref_text={args.ref_text}") + print(f"Stream: {args.stream}") + print("Generating audio...") + + api_url = f"{args.api_base}/v1/audio/speech" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {args.api_key}", + } + + if args.stream: + output_path = args.output or "tts_output.pcm" + with httpx.Client(timeout=300.0) as client, open(output_path, "wb") as f: + with client.stream("POST", api_url, json=payload, headers=headers) as response: + if response.status_code != 200: + print(f"Error: {response.status_code}") + response.read() + print(response.text) + return + for chunk in response.iter_bytes(): + f.write(chunk) + print(f"Streaming audio saved to: {output_path}") + else: + 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 f"tts_output.{args.response_format}" + with open(output_path, "wb") as f: + f.write(response.content) + print(f"Audio saved to: {output_path}") + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="OpenAI-compatible client for GLM-TTS via /v1/audio/speech", + ) + + # Server configuration + parser.add_argument( + "--api-base", + type=str, + default=DEFAULT_API_BASE, + help=f"API base URL (default: {DEFAULT_API_BASE})", + ) + parser.add_argument( + "--api-key", + type=str, + default=DEFAULT_API_KEY, + help="API key (default: EMPTY)", + ) + parser.add_argument( + "--model", + "-m", + type=str, + default="glm-tts", + help="Model name/path", + ) + + # Input text + parser.add_argument( + "--text", + type=str, + required=True, + help="Text to synthesize", + ) + + # Generation parameters + parser.add_argument( + "--max-new-tokens", + type=int, + default=None, + help="Maximum new tokens to generate (default: model default)", + ) + + # Output + parser.add_argument( + "--response-format", + type=str, + default="wav", + choices=["wav", "mp3", "flac", "pcm", "aac", "opus"], + help="Audio output format (default: wav)", + ) + parser.add_argument( + "--stream", + action="store_true", + help="Request a streaming audio response (use with async_chunk server mode).", + ) + parser.add_argument( + "--output", + "-o", + type=str, + default=None, + help="Output audio file path (default: tts_output.)", + ) + + # Voice cloning parameters + parser.add_argument( + "--ref-audio", + type=str, + default=None, + help="Reference audio URL, file:// URI, or base64 data URL for voice cloning", + ) + parser.add_argument( + "--ref-text", + type=str, + default=None, + help="Transcript of the reference audio (required with --ref-audio)", + ) + + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + run_tts_generation(args) diff --git a/examples/online_serving/text_to_speech/glm_tts/run_gradio_demo.sh b/examples/online_serving/text_to_speech/glm_tts/run_gradio_demo.sh new file mode 100644 index 000000000..75cecdf9e --- /dev/null +++ b/examples/online_serving/text_to_speech/glm_tts/run_gradio_demo.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Launch GLM-TTS server + Gradio demo together. +# +# Usage: +# ./run_gradio_demo.sh +# CUDA_VISIBLE_DEVICES=0 PORT=8091 GRADIO_PORT=7860 ./run_gradio_demo.sh + +set -e + +MODEL="${MODEL:-zai-org/GLM-TTS}" +PORT="${PORT:-8091}" +GRADIO_PORT="${GRADIO_PORT:-7860}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +echo "Starting GLM-TTS server (port $PORT)..." +FLASHINFER_DISABLE_VERSION_CHECK=1 \ +vllm-omni serve "$MODEL" \ + --deploy-config "$REPO_ROOT/vllm_omni/deploy/glm_tts.yaml" \ + --host 0.0.0.0 \ + --port "$PORT" \ + --gpu-memory-utilization 0.9 \ + --trust-remote-code \ + --enforce-eager \ + --omni & +SERVER_PID=$! + +cleanup() { + echo "Stopping server (PID $SERVER_PID)..." + kill $SERVER_PID 2>/dev/null + wait $SERVER_PID 2>/dev/null +} +trap cleanup EXIT + +# Wait for server to be ready. +echo "Waiting for server to start..." +for i in $(seq 1 120); do + if curl -s "http://localhost:$PORT/health" > /dev/null 2>&1; then + echo "Server ready." + break + fi + sleep 2 +done + +echo "Starting Gradio demo (port $GRADIO_PORT)..." +python "$SCRIPT_DIR/gradio_demo.py" \ + --api-base "http://localhost:$PORT" \ + --port "$GRADIO_PORT" diff --git a/examples/online_serving/text_to_speech/glm_tts/run_server.sh b/examples/online_serving/text_to_speech/glm_tts/run_server.sh new file mode 100644 index 000000000..f5cc3a1f8 --- /dev/null +++ b/examples/online_serving/text_to_speech/glm_tts/run_server.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Launch vLLM-Omni server for GLM-TTS models +# +# Usage: +# ./run_server.sh # Default model path, async_chunk mode +# ./run_server.sh /path/to/GLM-TTS # Custom model path, async_chunk mode +# ./run_server.sh /path/to/GLM-TTS sync # Sync two-stage mode +# +# NOTE: The model path should point to the repo ROOT (not llm/ subdirectory). +# model_subdir/tokenizer_subdir in the pipeline config resolve subdirectories. + +set -e + +MODEL="${1:-zai-org/GLM-TTS}" +MODE="${2:-async}" + +EXTRA_ARGS=() +case "$MODE" in + async|async_chunk) + ;; + sync|no_async_chunk) + EXTRA_ARGS+=("--no-async-chunk") + ;; + *) + echo "Unknown mode: $MODE (expected async or sync)" >&2 + exit 1 + ;; +esac + +echo "Starting GLM-TTS server with model: $MODEL (mode: $MODE)" + +vllm-omni serve "$MODEL" \ + --deploy-config vllm_omni/deploy/glm_tts.yaml \ + --host 0.0.0.0 \ + --port 8091 \ + --trust-remote-code \ + --omni \ + "${EXTRA_ARGS[@]}" diff --git a/recipes/README.md b/recipes/README.md index 8ddb72943..d2e1eeff3 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -43,6 +43,7 @@ recipes/ | [`Tencent/HunyuanImage-3.0-Instruct.md`](./Tencent/HunyuanImage-3.0-Instruct.md) | DiT-only text-to-image serving and benchmark, including ModelOpt mixed FP8/NVFP4 | 4x H100/H800 80GB / 2x B200 | | [`Wan-AI/Wan2.2-I2V.md`](./Wan-AI/Wan2.2-I2V.md) | Image-to-video serving (Wan2.2 14B) | 8x Ascend NPU (A2/A3) | | [`Wan-AI/Wan2.2-S2V.md`](./Wan-AI/Wan2.2-S2V.md) | Speech-to-video serving (Wan2.2 14B) | 2x A100/H100 80GB | +| [`zai-org/GLM-TTS.md`](./zai-org/GLM-TTS.md) | Online serving for Chinese/English zero-shot voice-cloned TTS | 1x A40 48GB | Within a single recipe file, include different hardware support sections such as `GPU`, `ROCm`, and `NPU`, and add concrete tested configurations like diff --git a/recipes/zai-org/GLM-TTS.md b/recipes/zai-org/GLM-TTS.md new file mode 100644 index 000000000..2bf1a202a --- /dev/null +++ b/recipes/zai-org/GLM-TTS.md @@ -0,0 +1,105 @@ +# GLM-TTS for Chinese/English TTS on 1x GPU + +## Summary + +- Vendor: zai-org +- Model: `zai-org/GLM-TTS` +- Task: Zero-shot voice-cloned text-to-speech synthesis +- Mode: Online serving with the OpenAI-compatible `/v1/audio/speech` API +- Maintainer: Community + +## When to use this recipe + +Use this recipe to serve GLM-TTS as a two-stage TTS system (AR + DiT +flow-matching) for Chinese and English speech synthesis. Every request is +conditioned on reference audio and its transcript. + +## References + +- Upstream or canonical docs: + [zai-org/GLM-TTS on HuggingFace](https://huggingface.co/zai-org/GLM-TTS) +- GitHub repository: + [zai-org/GLM-TTS](https://github.com/zai-org/GLM-TTS) +- Related example under `examples/`: + [`examples/online_serving/text_to_speech/README.md#glm-tts`](../../examples/online_serving/text_to_speech/README.md#glm-tts) +- Offline inference example: + [`examples/offline_inference/text_to_speech/README.md#glm-tts`](../../examples/offline_inference/text_to_speech/README.md#glm-tts) + +## Hardware Support + +### GPU + +### 1x A40 48GB + +#### Environment + +- OS: Linux +- Python: 3.10+ +- Driver / runtime: NVIDIA CUDA environment with A40 48GB GPU +- vLLM version: Match the repository requirements for your checkout +- vLLM-Omni version or commit: Use the commit you are deploying from + +#### Command + +Start the server from the repository root: + +```bash +vllm-omni serve zai-org/GLM-TTS --omni --trust-remote-code --port 8091 +``` + +Async chunking is enabled by default in the bundled deployment config. For +the sync (non-streaming) path: + +```bash +vllm-omni serve zai-org/GLM-TTS --omni --trust-remote-code --port 8091 --no-async-chunk +``` + +Use a custom deploy config for advanced cases: + +```bash +vllm-omni serve zai-org/GLM-TTS --omni --trust-remote-code --port 8091 \ + --deploy-config /path/to/your_glm_tts_overrides.yaml +``` + +#### Verification + +Run the bundled OpenAI-compatible client with reference audio: + +```bash +python examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py \ + --text "你好,这是一个语音合成测试。" \ + --ref-audio file:///path/to/ref.wav \ + --ref-text "这是参考音频的文本内容。" +``` + +For a quick API smoke test: + +```bash +curl http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "model": "zai-org/GLM-TTS", + "input": "你好,这是一个语音合成测试。", + "response_format": "wav", + "ref_audio": "file:///path/to/ref.wav", + "ref_text": "这是参考音频的文本内容。" + }' --output test.wav +``` + +Voice cloning with reference audio: + +```bash +python examples/online_serving/text_to_speech/glm_tts/openai_speech_client.py \ + --text "你好,这是语音克隆测试。" \ + --ref-audio file:///path/to/ref.wav \ + --ref-text "这是参考音频的文本内容。" +``` + +#### Notes + +- Hardware scope: the default bundled config is CUDA-only and verified on 1x A40 48GB (~16.6 GiB peak); fits 24GB cards. Split stages across GPUs for higher concurrency. +- Memory usage: ~18-20GB total (AR ~10GB, DiT ~8GB); both stages share GPU 0 by default. +- Audio output: 24kHz mono WAV via HiFT vocoder (Vocos2D 32kHz fallback with resampling). +- Key flags: `--omni` is required; `--trust-remote-code` is needed for the GLM-TTS phoneme tokenizer; the DiT stage enables bucketed model-internal CUDA graphs with eager fallback. +- Voice cloning: requires `ref_audio` + `ref_text` together. Reference audio should be 3-10 seconds. Feature extraction (WhisperVQ tokenizer, CampPlus ONNX, mel) runs on the model side. +- Known limitations: First request may be slow due to lazy model loading (WhisperVQ, CampPlus ONNX). Warm-cache RTF is approximately 0.6-0.7x on A40. diff --git a/tests/assets/glm_tts/jiayan_zh.wav b/tests/assets/glm_tts/jiayan_zh.wav new file mode 100644 index 0000000000000000000000000000000000000000..1dd5287471534ccf19eedf3b6c95af32024c46a2 GIT binary patch literal 356734 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%0Dis2 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 tuple[np.ndarray, int]: + audio, sr = sf.read(str(REFERENCE_PROMPT_WAV_PATH), dtype="float32", always_2d=False) + if isinstance(audio, np.ndarray) and audio.ndim > 1: + audio = np.mean(audio, axis=-1) + return np.asarray(audio, dtype=np.float32), int(sr) + + +def _concat_audio(audio_val) -> np.ndarray: + import torch + + if isinstance(audio_val, list): + tensors = [torch.as_tensor(t).float().reshape(-1) for t in audio_val if t is not None] + if not tensors: + return np.zeros((0,), dtype=np.float32) + return torch.cat(tensors, dim=-1).cpu().numpy().astype(np.float32, copy=False) + if isinstance(audio_val, torch.Tensor): + return audio_val.float().cpu().numpy().reshape(-1) + return np.asarray(audio_val, dtype=np.float32).reshape(-1) + + +def _extract_sample_rate(audio_mm: dict) -> int: + sr_raw = audio_mm.get("sr", 24000) + if isinstance(sr_raw, list): + sr_raw = sr_raw[-1] if sr_raw else 24000 + if hasattr(sr_raw, "item"): + return int(sr_raw.item()) + return int(sr_raw) + + +def _get_deploy_config(*, async_chunk: bool) -> str: + """Build deploy config with explicit sync/async mode and eager execution.""" + return modify_stage_config( + DEPLOY_CONFIG, + updates={ + "async_chunk": async_chunk, + "stages": { + 0: { + "enforce_eager": True, + "async_scheduling": bool(async_chunk), + }, + 1: { + "enforce_eager": True, + }, + }, + }, + ) + + +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("async_chunk", ASYNC_CHUNK_MODES) +def test_offline_voice_clone_zh(async_chunk: bool) -> None: + """ + Test voice cloning offline inference. + Deploy Setting: glm_tts.yaml with explicit sync/async mode, enforce_eager + Input Modal: text + ref_audio + ref_text + Output Modal: audio + + Uses the official jiayan_zh.wav reference audio from the upstream + GLM-TTS repository (real human speech matching REF_TEXT). + """ + synth_text = "今天天气真不错,适合出去散散步。" + prompt_audio = _load_ref_audio() + from tests.helpers.runtime import OmniRunner + + with OmniRunner( + MODEL, + stage_configs_path=_get_deploy_config(async_chunk=async_chunk), + stage_init_timeout=600, + ) as omni_runner: + outputs = omni_runner.omni.generate( + [ + { + "prompt": synth_text, + "multi_modal_data": {"audio": prompt_audio}, + "modalities": ["audio"], + "mm_processor_kwargs": {"prompt_text": REF_TEXT}, + "additional_information": build_glm_tts_prefill_metadata( + MODEL, + synth_text, + REF_TEXT, + trust_remote_code=True, + ), + } + ], + omni_runner.get_default_sampling_params_list(), + ) + + assert outputs, "No outputs returned" + audio_mm = outputs[0].multimodal_output + assert "audio" in audio_mm, "No audio output found" + audio = _concat_audio(audio_mm["audio"]) + assert audio.size > 0, "Generated audio is empty" + + with tempfile.NamedTemporaryFile(suffix=".wav") as out_file: + sf.write(out_file.name, audio, samplerate=_extract_sample_rate(audio_mm), format="WAV") + transcript = convert_audio_file_to_text(out_file.name) + audio_bytes = open(out_file.name, "rb").read() + + assert_audio_speech_response( + OmniResponse( + success=True, + audio_bytes=audio_bytes, + audio_content=transcript, + audio_format="audio/wav", + ), + {"input": synth_text, "response_format": "wav"}, + run_level="advanced_model", + ) diff --git a/tests/e2e/online_serving/test_glm_tts.py b/tests/e2e/online_serving/test_glm_tts.py new file mode 100644 index 000000000..7c6ad9764 --- /dev/null +++ b/tests/e2e/online_serving/test_glm_tts.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +E2E Online tests for GLM-TTS model with text input and audio output. + +These tests verify the /v1/audio/speech endpoint works correctly with +the GLM-TTS two-stage pipeline (AR + DiT). +""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" +os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0" + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import load_test_audio_data_url +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path + +MODEL = os.environ.get("GLM_TTS_MODEL_PATH", "zai-org/GLM-TTS") +REF_TEXT = "他当时还跟线下其他的站姐吵架,然后,打架进局子了。" + +DEPLOY_CONFIG = get_deploy_config_path("glm_tts.yaml") + +# Official GLM-TTS reference audio and transcript. Vendored under tests/assets/ +# so Buildkite does not depend on raw.githubusercontent.com during E2E. +REF_AUDIO_URL = load_test_audio_data_url("glm_tts/jiayan_zh.wav") + + +SYNC_EXTRA_ARGS = [ + "--trust-remote-code", + "--disable-log-stats", + "--no-async-chunk", +] + +ASYNC_EXTRA_ARGS = [ + "--trust-remote-code", + "--disable-log-stats", +] + +tts_sync_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=DEPLOY_CONFIG, + server_args=SYNC_EXTRA_ARGS, + stage_init_timeout=600, + ), + id="glm_tts_sync", + ) +] + +tts_async_chunk_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=DEPLOY_CONFIG, + server_args=ASYNC_EXTRA_ARGS, + stage_init_timeout=600, + ), + id="glm_tts_async_chunk", + ) +] + + +# GLM-TTS is a zero-shot voice cloning model (always requires ref_audio). +# There is no text-only / non-clone path — the official implementation +# asserts prompt_feat.shape[1] != 0 in token2wav_with_cache and every +# example prompt includes a reference audio. CosyVoice3 follows the same +# pattern: all tests are voice_clone_*. + + +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_sync_server_params, indirect=True) +def test_voice_clone_zh_sync(omni_server, openai_client) -> None: + """ + Test voice cloning TTS with Chinese text. + Deploy Setting: glm_tts.yaml with ``--no-async-chunk`` (sync two-stage) + Input Modal: text + ref_audio + ref_text + Output Modal: audio + Input Setting: stream=False + + Uses the official jiayan_zh.wav reference audio from the upstream + GLM-TTS repository (real human speech matching REF_TEXT). + """ + request_config = { + "model": omni_server.model, + "input": "今天天气真不错,适合出去散散步。", + "stream": False, + "response_format": "wav", + "ref_audio": REF_AUDIO_URL, + "ref_text": REF_TEXT, + } + openai_client.send_audio_speech_request(request_config) + + +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_async_chunk_server_params, indirect=True) +def test_voice_clone_zh_async_chunk(omni_server, openai_client) -> None: + """ + Test voice cloning TTS with Chinese text via async_chunk streaming. + Deploy Setting: glm_tts.yaml default ``async_chunk: true`` + Input Modal: text + ref_audio + ref_text + Output Modal: audio + Input Setting: stream=True + """ + request_config = { + "model": omni_server.model, + "input": "今天天气真不错,适合出去散散步。", + "stream": True, + "response_format": "wav", + "ref_audio": REF_AUDIO_URL, + "ref_text": REF_TEXT, + } + openai_client.send_audio_speech_request(request_config) + + +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_sync_server_params, indirect=True) +def test_models_endpoint(omni_server, openai_client) -> None: + """Test the /v1/models endpoint returns loaded model.""" + models = openai_client.client.models.list() + assert len(models.data) > 0 diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 2440fe586..872410aaa 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -2335,6 +2335,84 @@ class TestCosyVoice3Serving: cosyvoice3_server._build_cosyvoice3_prompt.assert_awaited_once() +# ---- GLM-TTS Serving Tests ---- + + +@pytest.fixture +def glm_tts_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="zai-org/GLM-TTS", + hf_config=SimpleNamespace(min_token_text_ratio=2, max_token_text_ratio=20), + ) + mock_engine_client.default_sampling_params_list = [ + SimpleNamespace(max_tokens=2048, min_tokens=None, extra_args=None) + ] + 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="glm_tts"), + 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 TestGLMTTSServing: + def test_validate_glm_tts_requires_ref_audio(self, glm_tts_server): + request = OpenAICreateSpeechRequest(input="Hello", ref_text="Reference transcript") + error = glm_tts_server._validate_glm_tts_request(request) + assert error is not None + assert "ref_audio" in error + + def test_validate_glm_tts_requires_ref_text(self, glm_tts_server): + request = OpenAICreateSpeechRequest(input="Hello", ref_audio="data:audio/wav;base64,abc") + error = glm_tts_server._validate_glm_tts_request(request) + assert error is not None + assert "ref_text" in error + + def test_estimate_glm_tts_target_text_len_uses_tokenizer_tokens( + self, + glm_tts_server, + mocker: MockerFixture, + ): + class FakeTokenizer: + def encode(self, text): + assert text == "normalized target" + return [10, 20, 30] + + mocker.patch( + "vllm_omni.model_executor.models.glm_tts.glm_tts.resolve_glm_tts_tokenizer_path", + return_value="resolved-tokenizer", + ) + load_tokenizer = mocker.patch( + "vllm_omni.model_executor.models.glm_tts.glm_tts.load_glm_tts_tokenizer", + return_value=FakeTokenizer(), + ) + mocker.patch( + "vllm_omni.model_executor.models.glm_tts.text_frontend.GLMTTSTextFrontend.text_normalize", + return_value="normalized target", + ) + + text_token_len = glm_tts_server._estimate_glm_tts_text_token_len("abcdef") + + assert text_token_len == 3 + load_tokenizer.assert_called_once() + + class TestTTSAsyncOffloading: """Tests for event-loop-safe offloading of blocking TTS operations.""" diff --git a/tests/model_executor/models/test_glm_tts_prompt_len.py b/tests/model_executor/models/test_glm_tts_prompt_len.py new file mode 100644 index 000000000..df12078b4 --- /dev/null +++ b/tests/model_executor/models/test_glm_tts_prompt_len.py @@ -0,0 +1,351 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.model_executor.models.common.nucleus_ras_sampling import ras_sample_one +from vllm_omni.model_executor.models.glm_tts import glm_tts as glm_tts_module +from vllm_omni.model_executor.models.glm_tts.glm_tts import GLMTTSForConditionalGeneration +from vllm_omni.model_executor.models.glm_tts.text_frontend import GLMTTSTextFrontend + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_glm_tts_ras_fallback_matches_official_random_resample() -> None: + sampled = ras_sample_one( + torch.tensor([0.0, float("-inf")]), + decoded_tokens=[0] * 10, + top_p=1.0, + top_k=1, + win_size=10, + tau_r=0.1, + generator=None, + ) + + assert sampled == 0 + + +def test_glm_tts_text_frontend_applies_official_symbol_replacements() -> None: + frontend = GLMTTSTextFrontend() + + assert frontend.pre_replace("3-2 咯,") == "3减2 喽," + assert frontend.post_replace("α②∈A/B~C²") == "阿尔法二属于A每B到C平方" + + +def test_glm_tts_call_hf_processor_builds_prompt_text_text_boa( + monkeypatch: pytest.MonkeyPatch, +) -> None: + processor = object.__new__(glm_tts_module.GLMTTSMultiModalProcessor) + processor.info = SimpleNamespace( + ctx=SimpleNamespace( + get_hf_config=lambda *_args, **_kwargs: SimpleNamespace(), + model_config=SimpleNamespace(model="fake-model"), + ) + ) + processor.text_frontend = object() + processor.special_ids = {"boa": 99} + processor.speech_tokenizer = object() + processor.processor_device = torch.device("cpu") + processor.campplus_session = object() + + monkeypatch.setattr( + processor, + "_ensure_cached_runtime_components", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + processor, + "_encode_text", + lambda text: torch.tensor([[11, 12]]) if text.endswith(" ") else torch.tensor([[21, 22, 23]]), + ) + monkeypatch.setattr(processor, "_get_audio", lambda _mm_data: (torch.zeros(8), 24000)) + monkeypatch.setattr( + glm_tts_module, + "_normalize_glm_tts_processor_text", + lambda _frontend, text, add_trailing_space=False: text + (" " if add_trailing_space else ""), + ) + monkeypatch.setattr(glm_tts_module, "extract_prompt_speech_token", lambda *_args, **_kwargs: [7, 8, 9, 10]) + monkeypatch.setattr(glm_tts_module, "extract_prompt_feat", lambda *_args, **_kwargs: torch.ones(4, 3)) + monkeypatch.setattr(glm_tts_module, "extract_spk_embedding", lambda *_args, **_kwargs: [0.1, 0.2, 0.3]) + + out = processor._call_hf_processor( + prompt="target text", + mm_data={"audio": object()}, + mm_kwargs={"prompt_text": "reference text"}, + tok_kwargs={}, + ) + + assert out["input_ids"].tolist() == [[11, 12, 21, 22, 23, 99]] + assert out["prompt_speech_token"].tolist() == [[7, 8, 9, 10]] + assert out["prompt_speech_token_len"][0].item() == 4 + assert out["glm_tts_text_token_len"][0].item() == 3 + + +def test_glm_tts_prompt_updates_append_single_audio_suffix() -> None: + processor = object.__new__(glm_tts_module.GLMTTSMultiModalProcessor) + out_mm_kwargs = { + "audio": [{"prompt_speech_token_len": SimpleNamespace(data=[torch.tensor([5], dtype=torch.long)])}] + } + + updates = processor._get_prompt_updates( + mm_items=object(), + hf_processor_mm_kwargs={}, + out_mm_kwargs=out_mm_kwargs, + ) + + assert len(updates) == 1 + assert updates[0].modality == "audio" + assert updates[0].insertion(0) == [1, 1, 1, 1, 1] + + +def test_glm_tts_dummy_inputs_forbid_dummy_wav(monkeypatch: pytest.MonkeyPatch) -> None: + builder = object.__new__(glm_tts_module.GLMTTSDummyInputsBuilder) + monkeypatch.setattr( + builder, + "_get_dummy_audios", + lambda *_args, **_kwargs: pytest.fail("GLM-TTS dummy inputs must not fabricate wav audio"), + ) + + assert builder.get_dummy_mm_data(seq_len=16, mm_counts={"audio": 1}) == {} + + +def test_glm_tts_processing_info_declares_audio_token_budget() -> None: + info = object.__new__(glm_tts_module.GLMTTSMultiModalProcessingInfo) + + assert info.get_mm_max_tokens_per_item(seq_len=4096, mm_counts={"audio": 1}) == {"audio": 1024} + assert info.get_mm_max_tokens_per_item(seq_len=512, mm_counts={"audio": 1}) == {"audio": 512} + assert info.get_mm_max_tokens_per_item(seq_len=4096, mm_counts={}) == {} + + +def test_glm_tts_text_only_processor_skips_voice_clone(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeTokenizer: + def encode(self, text: str) -> list[int]: + return [11, 12, 13] + + processor = object.__new__(glm_tts_module.GLMTTSMultiModalProcessor) + processor.info = SimpleNamespace( + ctx=SimpleNamespace( + get_hf_config=lambda *_args, **_kwargs: SimpleNamespace(), + model_config=SimpleNamespace(model="fake-model", tokenizer=None, trust_remote_code=True), + ) + ) + + monkeypatch.setattr(glm_tts_module, "resolve_glm_tts_model_dir", lambda *_args, **_kwargs: "/fake-root") + monkeypatch.setattr(glm_tts_module, "load_glm_tts_tokenizer", lambda *_args, **_kwargs: FakeTokenizer()) + monkeypatch.setattr(glm_tts_module, "get_glm_tts_special_token_ids", lambda _tokenizer: {"boa": 99}) + monkeypatch.setattr(glm_tts_module, "GLMTTSTextFrontend", lambda: object()) + monkeypatch.setattr( + glm_tts_module, + "_normalize_glm_tts_processor_text", + lambda _frontend, text, add_trailing_space=False: text, + ) + monkeypatch.setattr( + glm_tts_module, + "load_voice_clone_frontend", + lambda *_args, **_kwargs: pytest.fail("text-only dummy path should not load voice clone frontend"), + ) + + out = processor._call_hf_processor(prompt="target text", mm_data={}, mm_kwargs={}, tok_kwargs={}) + + assert out["input_ids"].tolist() == [[11, 12, 13, 99]] + assert out["prompt_speech_token"].shape == (1, 1024) + assert out["prompt_speech_token_len"][0].item() == 1024 + assert out["glm_tts_prompt_text_token_len"][0].item() == 0 + assert out["glm_tts_text_token_len"][0].item() == 3 + + fields = processor._get_mm_fields_config(out, {}) + assert {key: value.modality for key, value in fields.items()} == { + "prompt_speech_token": "audio", + "prompt_speech_token_len": "audio", + "glm_tts_prompt_text_token_len": "audio", + "glm_tts_text_token_len": "audio", + } + + updates = processor._get_prompt_updates( + mm_items=object(), + hf_processor_mm_kwargs={}, + out_mm_kwargs={ + "audio": [{"prompt_speech_token_len": SimpleNamespace(data=[out["prompt_speech_token_len"][0]])}] + }, + ) + assert len(updates) == 1 + assert len(updates[0].insertion(0)) == 1024 + + +def test_glm_tts_embed_replaces_multimodal_positions_per_request() -> None: + class FakeModel(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.marker = torch.nn.Parameter(torch.zeros(())) + + def embed_tokens(self, input_ids: torch.Tensor) -> torch.Tensor: + return input_ids.to(torch.float32).reshape(-1, 1) + + model = object.__new__(GLMTTSForConditionalGeneration) + torch.nn.Module.__init__(model) + model.model_stage = "glm_tts" + model.model = FakeModel() + + input_ids = torch.tensor([101, 102, 1, 1, 201, 1, 1, 1]) + is_multimodal = torch.tensor([False, False, True, True, False, True, True, True]) + mm_embeds = [ + torch.tensor([[1001.0], [1002.0]]), + torch.tensor([[2001.0], [2002.0], [2003.0]]), + ] + + out = model.embed_input_ids(input_ids, multimodal_embeddings=mm_embeds, is_multimodal=is_multimodal) + + assert out.squeeze(-1).tolist() == [ + 101.0, + 102.0, + 1001.0, + 1002.0, + 201.0, + 2001.0, + 2002.0, + 2003.0, + ] + + +def test_glm_tts_embed_multimodal_keeps_all_batched_requests() -> None: + class FakeModel(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.marker = torch.nn.Parameter(torch.zeros(())) + + def embed_tokens(self, input_ids: torch.Tensor) -> torch.Tensor: + return input_ids.to(torch.float32).reshape(*input_ids.shape, 1) + + model = object.__new__(GLMTTSForConditionalGeneration) + torch.nn.Module.__init__(model) + model.model_stage = "glm_tts" + model.model = FakeModel() + model._ats = 1000 + + out = model.embed_multimodal( + prompt_speech_token=[ + torch.tensor([[1, 2]]), + torch.tensor([[3, 4, 5]]), + ] + ) + + assert out is not None + assert len(out) == 2 + assert out[0].squeeze(-1).tolist() == [1001.0, 1002.0] + assert out[1].squeeze(-1).tolist() == [1003.0, 1004.0, 1005.0] + + +def test_glm_tts_embed_multimodal_returns_one_item_for_single_request() -> None: + class FakeModel(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.marker = torch.nn.Parameter(torch.zeros(())) + + def embed_tokens(self, input_ids: torch.Tensor) -> torch.Tensor: + return input_ids.to(torch.float32).reshape(*input_ids.shape, 1) + + model = object.__new__(GLMTTSForConditionalGeneration) + torch.nn.Module.__init__(model) + model.model_stage = "glm_tts" + model.model = FakeModel() + model._ats = 1000 + + out = model.embed_multimodal(prompt_speech_token=torch.tensor([[1, 2, 3]])) + + assert out is not None + assert len(out) == 1 + assert out[0].squeeze(-1).tolist() == [1001.0, 1002.0, 1003.0] + + +def test_glm_tts_embed_multimodal_treats_scalar_tensor_list_as_one_item() -> None: + class FakeModel(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.marker = torch.nn.Parameter(torch.zeros(())) + + def embed_tokens(self, input_ids: torch.Tensor) -> torch.Tensor: + return input_ids.to(torch.float32).reshape(*input_ids.shape, 1) + + model = object.__new__(GLMTTSForConditionalGeneration) + torch.nn.Module.__init__(model) + model.model_stage = "glm_tts" + model.model = FakeModel() + model._ats = 1000 + + out = model.embed_multimodal(prompt_speech_token=[torch.tensor(1), torch.tensor(2)]) + + assert out is not None + assert len(out) == 1 + assert out[0].squeeze(-1).tolist() == [1001.0, 1002.0] + + +def test_glm_tts_preprocess_treats_one_token_text_tail_as_prefill() -> None: + model = object.__new__(GLMTTSForConditionalGeneration) + model._pad = 0 + model._ats = 1000 + model._ate = 2000 + model._eoa = 3000 + model._model_dtype = lambda: torch.float32 + + input_ids, input_embeds, info = model.preprocess( + torch.tensor([123]), + torch.tensor([[1.0, 2.0]]), + glm_tts_mm_prefill_done=True, + ) + + assert input_ids.tolist() == [0] + assert input_embeds.tolist() == [[1.0, 2.0]] + assert info["glm_tts_mm_prefill_done"] is True + assert info["speech_tokens"].tolist() == [[-1]] + + +def test_glm_tts_preprocess_initializes_generation_bounds_before_sampling() -> None: + model = object.__new__(GLMTTSForConditionalGeneration) + model._pad = 0 + model._ats = 1000 + model._ate = 2000 + model._eoa = 3000 + model._model_dtype = lambda: torch.float32 + model.config = SimpleNamespace(min_token_text_ratio=2.0, max_token_text_ratio=20.0) + + _, _, info = model.preprocess( + torch.tensor([11, 12]), + torch.tensor([[1.0], [2.0]]), + glm_tts_text_token_len=[torch.tensor([27])], + ) + + assert info["glm_tts_text_token_len"][0].item() == 27 + + +def test_glm_tts_make_omni_output_emits_prompt_speech_len_once() -> None: + model = object.__new__(GLMTTSForConditionalGeneration) + + out = model.make_omni_output( + torch.randn(2, 4), + model_intermediate_buffer=[{}], + prompt_speech_token_len=[torch.tensor([9])], + glm_tts_text_token_len=[torch.tensor([5])], + ) + + assert out.multimodal_outputs is not None + assert out.multimodal_outputs["prompt_speech_token_len"][0].item() == 9 + assert out.multimodal_outputs["glm_tts_text_token_len"][0].item() == 5 + + +def test_glm_tts_postprocess_caches_prompt_speech_token_len() -> None: + model = object.__new__(GLMTTSForConditionalGeneration) + model.config = SimpleNamespace(min_token_text_ratio=2.0, max_token_text_ratio=20.0) + + update = model.postprocess( + torch.randn(2, 4), + multimodal_outputs={ + "prompt_speech_token_len": [torch.tensor([13])], + "glm_tts_text_token_len": [torch.tensor([6])], + }, + ) + + assert update["prompt_speech_token_len"].item() == 13 + assert update["glm_tts_text_token_len"].item() == 6 diff --git a/tests/model_executor/stage_input_processors/test_glm_tts_async_chunk.py b/tests/model_executor/stage_input_processors/test_glm_tts_async_chunk.py new file mode 100644 index 000000000..19bdaf7fe --- /dev/null +++ b/tests/model_executor/stage_input_processors/test_glm_tts_async_chunk.py @@ -0,0 +1,278 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.engine.serialization import serialize_additional_information +from vllm_omni.model_executor.models.glm_tts.glm_tts_dit_wrapper import ( + split_request_ids, + valid_speech_tokens, +) +from vllm_omni.model_executor.stage_input_processors.glm_tts import ( + ar_to_dit, + ar_to_dit_async_chunk, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _Request: + external_req_id = "req-1" + additional_information = None + + def is_finished(self) -> bool: + return False + + +def _transfer_manager() -> SimpleNamespace: + connector = SimpleNamespace( + config={ + "extra": { + "codec_chunk_frames": 25, + "codec_left_context_frames": 25, + } + } + ) + return SimpleNamespace(connector=connector) + + +def _progressive_transfer_manager() -> SimpleNamespace: + connector = SimpleNamespace( + config={ + "extra": { + "codec_chunk_frames": [25, 50, 200], + "codec_left_context_frames": 25, + } + } + ) + return SimpleNamespace(connector=connector) + + +def _source_output(mm: dict, request_id: str = "req-0") -> SimpleNamespace: + return SimpleNamespace( + request_id=request_id, + outputs=[ + SimpleNamespace( + multimodal_output=mm, + ) + ], + ) + + +def _audio_codes(payload) -> list[int]: + assert payload.codes is not None + assert payload.codes.audio is not None + return payload.codes.audio.tolist() + + +def test_ar_to_dit_filters_invalid_tokens_and_preserves_conditioning() -> None: + prompt_token = torch.tensor([[1, 2, 3]]) + prompt_feat = torch.randn(1, 4, 80) + embedding = torch.randn(1, 192) + source_outputs = [ + _source_output( + { + "speech_tokens": torch.tensor([[-1, 0, 7, -1, 32767]]), + "prompt_token": prompt_token, + "prompt_feat": prompt_feat, + "embedding": embedding, + } + ) + ] + + outputs = ar_to_dit(source_outputs) + + assert len(outputs) == 1 + assert outputs[0]["prompt_token_ids"] == [0, 7, 32767] + additional_info = outputs[0]["additional_information"] + assert additional_info["speech_tokens"] == [0, 7, 32767] + # Voice clone data stored in nested embed dict (matches async_chunk path) + embed = additional_info["embed"] + assert embed["speech_token"] is prompt_token + assert embed["speech_feat"] is prompt_feat + assert embed["embedding"] is embedding + # Meta dict enables block-causal attention in DiT wrapper + meta = additional_info["meta"] + assert meta["req_id"] == ["req-0"] + assert meta["left_context_size"] == 0 + assert meta["stream_finished"] is True + private = additional_info["kv_metadata"]["glm_tts"] + assert private["block_pattern"] == [3] # len([0, 7, 32767]) + assert private["chunk_sizes_history"] == [3] + + +def test_dit_explicit_empty_speech_tokens_override_scheduler_placeholder() -> None: + placeholder = torch.tensor([0], dtype=torch.long) + + token = valid_speech_tokens([], device=placeholder.device, fallback=placeholder) + + assert token.numel() == 0 + + +def test_ar_to_dit_async_chunk_restores_serialized_conditioning_payload() -> None: + transfer = _transfer_manager() + request = _Request() + prompt_token = torch.tensor([[1, 2, 3]]) + prompt_feat = torch.randn(1, 4, 80) + embedding = torch.randn(1, 192) + request.additional_information = serialize_additional_information( + { + "prompt_speech_token": prompt_token, + "prompt_feat": prompt_feat, + "embedding": embedding, + } + ) + + payload = ar_to_dit_async_chunk( + transfer, + {"speech_tokens": torch.tensor([[0]])}, + request, + is_finished=False, + ) + + assert payload is None + state = transfer.request_payload["req-1"]["_glm_tts_async_state"] + prompt_payload = state["prompt_payload"] + assert torch.equal(prompt_payload["prompt_speech_token"], prompt_token) + assert torch.equal(prompt_payload["prompt_feat"], prompt_feat) + assert torch.equal(prompt_payload["embedding"], embedding) + + +def test_split_request_ids_uses_seq_token_counts() -> None: + ids = torch.tensor([1, 2, 3, 4, 5, 6], dtype=torch.long) + + split = split_request_ids(ids, seq_token_counts=[2, 3, 1]) + + assert [part.tolist() for part in split] == [[1, 2], [3, 4, 5], [6]] + + +def test_ar_to_dit_async_chunk_emits_cumulative_prefixes() -> None: + transfer = _transfer_manager() + request = _Request() + payloads = [] + + for token in range(75): + payload = ar_to_dit_async_chunk( + transfer, + {"speech_tokens": torch.tensor([[token]])}, + request, + is_finished=False, + ) + if payload is not None: + payloads.append(payload) + + assert len(payloads) == 3 + assert _audio_codes(payloads[0]) == list(range(25)) + assert payloads[0].meta.left_context_size == 0 + assert payloads[0].kv_metadata["glm_tts"]["block_pattern"] == [25] + assert _audio_codes(payloads[1]) == list(range(50)) + assert payloads[1].meta.left_context_size == 25 + assert payloads[1].kv_metadata["glm_tts"]["block_pattern"] == [25] + assert _audio_codes(payloads[2]) == list(range(75)) + assert payloads[2].meta.left_context_size == 50 + assert payloads[2].kv_metadata["glm_tts"]["block_pattern"] == [25] + + +def test_ar_to_dit_async_chunk_keeps_progressive_block_pattern_fixed() -> None: + transfer = _progressive_transfer_manager() + request = _Request() + payloads = [] + + for token in range(30): + payload = ar_to_dit_async_chunk( + transfer, + {"speech_tokens": torch.tensor([[token]])}, + request, + is_finished=False, + ) + if payload is not None: + payloads.append(payload) + + final_payload = ar_to_dit_async_chunk( + transfer, + None, + request, + is_finished=True, + ) + + assert len(payloads) == 1 + assert payloads[0].kv_metadata["glm_tts"]["chunk_sizes_history"] == [25] + assert payloads[0].kv_metadata["glm_tts"]["block_pattern"] == [25, 50, 200] + assert final_payload is not None + assert final_payload.kv_metadata["glm_tts"]["chunk_sizes_history"] == [25, 5] + assert final_payload.kv_metadata["glm_tts"]["block_pattern"] == [25, 50, 200] + + +def test_ar_to_dit_async_chunk_terminal_boundary_is_metadata_only() -> None: + transfer = _transfer_manager() + request = _Request() + payloads = [] + + for token in range(50): + payload = ar_to_dit_async_chunk( + transfer, + {"speech_tokens": torch.tensor([[token]])}, + request, + is_finished=False, + ) + if payload is not None: + payloads.append(payload) + + final_payload = ar_to_dit_async_chunk( + transfer, + None, + request, + is_finished=True, + ) + + assert len(payloads) == 2 + assert final_payload is not None + assert _audio_codes(final_payload) == [] + assert final_payload.meta.left_context_size == 50 + assert final_payload.meta.req_id == ["req-1"] + assert bool(final_payload.meta.stream_finished.item()) + + +def test_ar_to_dit_async_chunk_finished_flushes_partial_unsent_tail() -> None: + transfer = _transfer_manager() + request = _Request() + payloads = [] + + for token in range(30): + payload = ar_to_dit_async_chunk( + transfer, + {"speech_tokens": torch.tensor([[token]])}, + request, + is_finished=False, + ) + if payload is not None: + payloads.append(payload) + + final_payload = ar_to_dit_async_chunk( + transfer, + None, + request, + is_finished=True, + ) + + assert len(payloads) == 1 + assert _audio_codes(payloads[0]) == list(range(25)) + assert payloads[0].meta.left_context_size == 0 + assert final_payload is not None + assert _audio_codes(final_payload) == list(range(30)) + assert final_payload.meta.left_context_size == 25 + assert final_payload.meta.req_id == ["req-1"] + assert bool(final_payload.meta.finished.item()) + assert bool(final_payload.meta.stream_finished.item()) + assert ( + ar_to_dit_async_chunk( + transfer, + None, + request, + is_finished=True, + ) + is None + ) diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py index 555f35e17..1474e325c 100644 --- a/vllm_omni/config/pipeline_registry.py +++ b/vllm_omni/config/pipeline_registry.py @@ -97,6 +97,10 @@ _OMNI_PIPELINES: dict[str, tuple[str, str]] = { "vllm_omni.model_executor.models.voxtral_tts.pipeline", "VOXTRAL_TTS_PIPELINE", ), + "glm_tts": ( + "vllm_omni.model_executor.models.glm_tts.pipeline", + "GLM_TTS_PIPELINE", + ), "fish_qwen3_omni": ( "vllm_omni.model_executor.models.fish_speech.pipeline", "FISH_SPEECH_PIPELINE", diff --git a/vllm_omni/deploy/glm_tts.yaml b/vllm_omni/deploy/glm_tts.yaml new file mode 100644 index 000000000..1600b6152 --- /dev/null +++ b/vllm_omni/deploy/glm_tts.yaml @@ -0,0 +1,83 @@ +# GLM-TTS deploy: 2-stage AR -> DiT flow-matching. +# +# Default mode is async-chunk streaming through SharedMemoryConnector. +# Stage 0 (AR LLM) streams speech token chunks to Stage 1 (DiT + vocoder) +# via the shared-memory connector. Stage 1 runs as LLM_GENERATION type +# to support chunk_transfer_adapter consumption. +# +# Stage 1 keeps vLLM eager scheduling but enables the GLM-TTS bucketed DiT +# CUDA graph wrapper via hf_overrides.use_dit_cuda_graphs. Graph capture falls +# back to eager if a dynamic shape exceeds the captured buckets. +# +# Verified on 1x A40 48GB (both stages share GPU 0). +# Memory footprint ~16.6 GiB peak; fits 24GB cards with the settings below. +async_chunk: true +trust_remote_code: true + +connectors: + glm_tts_shm: + name: SharedMemoryConnector + extra: + codec_streaming: true + connector_get_sleep_s: 0.002 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + # Progressive chunk sizes matching original GLM-TTS defaults. + codec_chunk_frames: [25, 50, 200] + codec_left_context_frames: 25 + crossfade_sec: 0.1 + +stages: + - stage_id: 0 + max_num_seqs: 1 + gpu_memory_utilization: 0.55 + # BF16 for AR decode: memory-bandwidth bound on single-batch decode, + # bf16 halves HBM reads per step (~1.7x throughput on L4/A40). + dtype: bfloat16 + enforce_eager: false + async_scheduling: true + enable_prefix_caching: false + max_num_batched_tokens: 512 + max_model_len: 2048 + devices: "0" + output_connectors: + to_stage_1: glm_tts_shm + default_sampling_params: + temperature: 1.0 + top_k: 25 + top_p: 0.8 + max_tokens: 2048 + min_tokens: 1 + seed: 42 + detokenize: false + hf_overrides: + sample_method: ras + ras_top_p: 0.8 + ras_top_k: 25 + ras_win_size: 10 + ras_tau_r: 0.1 + min_token_text_ratio: 2.0 + max_token_text_ratio: 20.0 + skip_mm_profiling: true + + - stage_id: 1 + max_num_seqs: 1 + gpu_memory_utilization: 0.25 + enforce_eager: true + max_model_len: 8192 + max_num_batched_tokens: 2048 + # Force float32 for the flow-matching DiT stage. The Euler ODE solver + # (10 steps × 2 forward passes each) accumulates rounding errors in half + # precision that manifest as audible hiss on H20/H100. float32 matches + # the reference implementation and eliminates the noise floor. + dtype: float32 + devices: "0" + enable_prefix_caching: false + input_connectors: + from_stage_0: glm_tts_shm + default_sampling_params: + max_tokens: 2048 + hf_overrides: + use_dit_cuda_graphs: true + disable_hybrid_kv_cache_manager: true + skip_mm_profiling: true diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index 12a5a450a..c46222899 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -20,6 +20,7 @@ logger = init_logger(__name__) # Used when auto-injecting hf_overrides for models with missing config.json. _ARCH_TO_MODEL_TYPE: dict[str, str] = { "CosyVoice3Model": "cosyvoice3", + "GLMTTSForConditionalGeneration": "glm_tts", "OmniVoiceModel": "omnivoice", "VoxCPM2TalkerForConditionalGeneration": "voxcpm2", } @@ -27,6 +28,7 @@ _ARCH_TO_MODEL_TYPE: dict[str, str] = { # Maps model architecture names to tokenizer subfolder paths within HF repos. _TOKENIZER_SUBFOLDER_MAP: dict[str, str] = { "CosyVoice3Model": "CosyVoice-BlankEN", + "GLMTTSForConditionalGeneration": "vq32k-phoneme-tokenizer", } @@ -37,6 +39,10 @@ def _register_omni_hf_configs() -> None: from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import ( Qwen3TTSConfig, ) + from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config + from vllm_omni.transformers_utils.configs.glm_tts import GLMTTSConfig + from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig + 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) return @@ -51,6 +57,10 @@ def _register_omni_hf_configs() -> None: for model_type, config_cls in [ ("qwen3_tts", Qwen3TTSConfig), + ("cosyvoice3", CosyVoice3Config), + ("glm_tts", GLMTTSConfig), + ("omnivoice", OmniVoiceConfig), + ("voxcpm2", VoxCPM2Config), ]: try: AutoConfig.register(model_type, config_cls) @@ -298,6 +308,7 @@ class OmniEngineArgs(EngineArgs): model_path, allow_patterns=[ f"{subfolder}/tokenizer*", + f"{subfolder}/tokenization_*", f"{subfolder}/special_tokens*", f"{subfolder}/vocab*", f"{subfolder}/merges*", diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 3b7a2e747..10036978e 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -68,6 +68,7 @@ _COVO_AUDIO_MODEL_STAGES = {"fused_thinker_talker"} _VOXCPM2_TTS_MODEL_STAGES = {"latent_generator"} _MING_TTS_MODEL_STAGES = {"ming_tts"} _MOSS_TTS_MODEL_STAGES = {"moss_tts_nano"} +_GLM_TTS_MODEL_STAGES = {"glm_tts"} _TTS_MODEL_STAGES: set[str] = ( _VOXTRAL_TTS_MODEL_STAGES | _QWEN3_TTS_MODEL_STAGES @@ -78,6 +79,7 @@ _TTS_MODEL_STAGES: set[str] = ( | _VOXCPM2_TTS_MODEL_STAGES | _MING_TTS_MODEL_STAGES | _MOSS_TTS_MODEL_STAGES + | _GLM_TTS_MODEL_STAGES ) _SAMPLING_MAX_TOKENS_TTS_MODEL_TYPES = {"fish_tts", "qwen3_tts", "voxtral_tts", "cosyvoice3", "voxcpm2"} _TTS_LANGUAGES: set[str] = { @@ -335,6 +337,10 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # Determine TTS model type or None self._tts_model_type = self._detect_tts_model_type() + # GLM-TTS lazy-cached resources (populated on first GLM-TTS request) + self._glm_tts_text_tokenizer: object | None = None + self._glm_tts_text_frontend: object | None = None + # Cache TTS configuration values (computed once, reused per request) self._max_instructions_length = self._compute_max_instructions_length() @@ -499,6 +505,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return "ming_flash_omni_tts" if model_stage in _MOSS_TTS_MODEL_STAGES: return "moss_tts_nano" + if model_stage in _GLM_TTS_MODEL_STAGES: + return "glm_tts" return None def _compute_max_instructions_length(self) -> int: @@ -528,6 +536,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # is no static speaker list to surface here. return set() try: + if self._tts_model_type == "glm_tts": + return set() if self._tts_model_type == "voxcpm2": return {"default"} if self._tts_model_type == "voxtral_tts": @@ -785,12 +795,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): voice_lower = request.voice.lower() if voice_lower not in self.uploaded_speakers: - if self._tts_model_type in ("cosyvoice3", "fish_tts", "omnivoice", "moss_tts_nano"): + if self._tts_model_type in ("cosyvoice3", "fish_tts", "omnivoice", "moss_tts_nano", "glm_tts"): label = { "cosyvoice3": "CosyVoice3", "fish_tts": "Fish Speech", "omnivoice": "OmniVoice", "moss_tts_nano": "MOSS-TTS-Nano", + "glm_tts": "GLM-TTS", }.get(self._tts_model_type, self._tts_model_type) return ( f"Unknown voice '{request.voice}'. {label} has no " @@ -1135,6 +1146,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return self._validate_ming_tts_request(request) if self._tts_model_type == "moss_tts_nano": return self._validate_moss_tts_request(request) + if self._tts_model_type == "glm_tts": + return self._validate_glm_tts_request(request) return self._validate_qwen_tts_request(request) def _voxcpm2_encode(self, text: str) -> list[int]: @@ -1383,6 +1396,26 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): params["prompt_audio_array"] = [[wav_list, sr]] return params + def _validate_glm_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None: + """Validate GLM-TTS request — requires ref_audio for voice cloning.""" + if not request.input or not request.input.strip(): + return "Input text cannot be empty" + + if request.ref_audio is None: + return "GLM-TTS requires 'ref_audio' for zero-shot voice cloning" + 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 "GLM-TTS voice cloning requires 'ref_text' (transcript of the reference audio)" + + 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 >= {_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_fish_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None: """Validate Fish Speech request parameters. Returns error message or None.""" if not request.input or not request.input.strip(): @@ -1898,6 +1931,105 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): ) return sampling_params_list + # ---- GLM-TTS helpers ---- + + async def _build_glm_tts_prompt( + self, + request: OpenAICreateSpeechRequest, + *, + has_inline_ref_audio: bool = False, + ) -> dict[str, Any]: + """Build prompt for GLM-TTS. + + Uses the multimodal processor path (same as CosyVoice3): + - prompt: synthesis text + - multi_modal_data["audio"]: (wav_samples, sr) reference audio + - mm_processor_kwargs["prompt_text"]: reference text transcript + + AR preprocess() builds [PromptText | Text | BOA | PromptSpeechTokens + ATS]. + DiT receives prompt_token, prompt_feat, embedding for conditioning. + """ + # Voice cloning requires ref_audio + ref_text + if request.ref_audio is not None and request.ref_text: + wav_samples, sr = await self._resolve_ref_audio(request.ref_audio) + audio_data = (np.asarray(wav_samples, dtype=np.float32), int(sr)) + + mm_kwargs: dict[str, Any] = { + "prompt_text": request.ref_text, + } + if request.voice: + voice_lower = request.voice.lower() + if voice_lower in self.uploaded_speakers and not has_inline_ref_audio: + mm_kwargs["voice_name"] = voice_lower + mm_kwargs["voice_created_at"] = self._voice_created_at(voice_lower) + + return { + "prompt": request.input, + "multi_modal_data": { + "audio": audio_data, + }, + "mm_processor_kwargs": mm_kwargs, + "additional_information": self._build_glm_tts_prefill_metadata( + request.input, + request.ref_text, + ), + } + + raise ValueError("GLM-TTS requires ref_audio and ref_text for voice cloning.") + + def _glm_tts_text_tokenizer_and_frontend(self): + from vllm_omni.model_executor.models.glm_tts.glm_tts import ( + load_glm_tts_tokenizer, + resolve_glm_tts_tokenizer_path, + ) + from vllm_omni.model_executor.models.glm_tts.text_frontend import GLMTTSTextFrontend + + cached = self._glm_tts_text_tokenizer + if cached is None: + model_name_or_path = self.engine_client.model_config.model + tokenizer_path = getattr(self.engine_client.model_config, "tokenizer", None) + if tokenizer_path is None: + tokenizer_path = resolve_glm_tts_tokenizer_path(model_name_or_path) + cached = load_glm_tts_tokenizer( + tokenizer_path, + model_name_or_path=model_name_or_path, + trust_remote_code=bool(getattr(self.engine_client.model_config, "trust_remote_code", False)), + ) + self._glm_tts_text_tokenizer = cached + + frontend = self._glm_tts_text_frontend + if frontend is None: + frontend = GLMTTSTextFrontend() + self._glm_tts_text_frontend = frontend + return cached, frontend + + def _estimate_glm_tts_text_token_len(self, text: str | None, *, add_trailing_space: bool = False) -> int: + """Estimate GLM-TTS normalized text length with the model tokenizer.""" + cached, frontend = self._glm_tts_text_tokenizer_and_frontend() + text = text or "" + normalized = frontend.text_normalize(text) or text + normalized = normalized.strip() + if add_trailing_space and normalized: + normalized = f"{normalized} " + return max(1, len(cached.encode(normalized))) + + def _build_glm_tts_prefill_metadata(self, text: str, prompt_text: str | None) -> dict[str, Any]: + """Build GLM-TTS processor length metadata for additional_information. + + The model preprocess hook runs before postprocess can mirror MM kwargs + into additional_information, so these scalar fields must originate from + the request payload rather than runner-level mm_features. + """ + text_len = self._estimate_glm_tts_text_token_len(text) + prompt_text_len = ( + self._estimate_glm_tts_text_token_len(prompt_text, add_trailing_space=True) if prompt_text else 0 + ) + return { + "glm_tts_text_token_len": [text_len], + "glm_tts_prompt_text_token_len": [prompt_text_len], + "input_len": [prompt_text_len + text_len + 1], + } + # ---- Ming-flash-omni standalone-talker (TTS) helpers ---- def _build_ming_prompt(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]: @@ -1963,7 +2095,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # Resolve uploaded voice for non-Qwen3 models. # Qwen3 TTS has its own uploaded voice handling in _build_tts_params(). has_inline_ref_audio = request.ref_audio is not None - if self._tts_model_type in ("fish_tts", "cosyvoice3", "moss_tts_nano"): + if self._tts_model_type in ("fish_tts", "cosyvoice3", "moss_tts_nano", "glm_tts"): err = self._apply_uploaded_speaker(request) if err: raise ValueError(err) @@ -2041,6 +2173,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): elif self._tts_model_type == "cosyvoice3": prompt = await self._build_cosyvoice3_prompt(request, has_inline_ref_audio=has_inline_ref_audio) tts_params = {} + elif self._tts_model_type == "glm_tts": + prompt = await self._build_glm_tts_prompt(request, has_inline_ref_audio=has_inline_ref_audio) + tts_params = {} elif self._tts_model_type == "ming_flash_omni_tts": prompt = self._build_ming_prompt(request) tts_params = {} @@ -2112,6 +2247,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): model_type = "ming_flash_omni_tts" elif self._tts_model_type == "moss_tts_nano": model_type = "moss_tts_nano" + elif self._tts_model_type == "glm_tts": + model_type = "glm_tts" elif self._is_tts: model_type = tts_params.get("task_type", ["unknown"])[0] else: @@ -2129,6 +2266,57 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): if self._tts_model_type == "cosyvoice3" and sampling_params_list: sampling_params_list = self._apply_cosyvoice3_dynamic_tokens(sampling_params_list, request) + # GLM-TTS: set dynamic min/max tokens based on text length. + if self._tts_model_type == "glm_tts" and sampling_params_list: + import copy + + sampling_params_list = copy.deepcopy(sampling_params_list) + glm_metadata = prompt.get("additional_information") if isinstance(prompt, dict) else None + text_len_value = None + if isinstance(glm_metadata, dict): + text_len_value = glm_metadata.get("glm_tts_text_token_len") + if isinstance(text_len_value, list) and text_len_value: + text_len_value = text_len_value[0] + text_token_len = ( + int(text_len_value) + if text_len_value is not None + else self._estimate_glm_tts_text_token_len(request.input) + ) + hf_cfg = self.model_config.hf_config + min_ratio = getattr(hf_cfg, "min_token_text_ratio", 2) + max_ratio = getattr(hf_cfg, "max_token_text_ratio", 20) + stage_min_tokens = getattr(sampling_params_list[0], "min_tokens", None) + stage_max_tokens = getattr(sampling_params_list[0], "max_tokens", None) + cap_candidates = [int(cap) for cap in (stage_max_tokens, request.max_new_tokens) if cap is not None] + hard_cap = min(cap_candidates) if cap_candidates else None + + min_tokens = max(1, int(text_token_len * min_ratio)) + if stage_min_tokens is not None: + min_tokens = max(min_tokens, int(stage_min_tokens)) + if hard_cap is not None: + min_tokens = min(min_tokens, hard_cap) + + max_tokens = max(min_tokens, int(text_token_len * max_ratio)) + if hard_cap is not None: + max_tokens = min(max_tokens, hard_cap) + sampling_params_list[0].min_tokens = min_tokens + sampling_params_list[0].max_tokens = max_tokens + seed = getattr(request, "seed", None) + if seed is not None: + sampling_params_list[0].seed = seed + logger.info( + "GLM-TTS dynamic tokens: text_tokens=%d, min_ratio=%s, max_ratio=%s, " + "stage_min=%s, stage_max=%s, request_max=%s, min_tokens=%d, max_tokens=%d", + text_token_len, + min_ratio, + max_ratio, + stage_min_tokens, + stage_max_tokens, + request.max_new_tokens, + min_tokens, + max_tokens, + ) + # Apply model-specific extra parameters if request.extra_params is not None and sampling_params_list: if not isinstance(request.extra_params, dict): diff --git a/vllm_omni/entrypoints/utils.py b/vllm_omni/entrypoints/utils.py index de7eb5f4c..86a0d8182 100644 --- a/vllm_omni/entrypoints/utils.py +++ b/vllm_omni/entrypoints/utils.py @@ -329,11 +329,16 @@ def resolve_model_config_path(model: str) -> str: except Exception as e: raise ValueError(f"Failed to read config.json for model: {model}. Error: {e}") from e else: - raise ValueError( - f"Could not determine model_type for model: {model}. " - f"Model is not in standard transformers format and does not have model_index.json. " - f"Please ensure the model has proper configuration files with 'model_type' field" - ) + # No config.json at repo root (e.g. GLM-TTS stores configs in + # subdirectories only). Try matching against registered deploy + # YAML filenames before giving up. + model_type = _try_resolve_omni_model_type(model) + if model_type is None: + raise ValueError( + f"Could not determine model_type for model: {model}. " + f"Model is not in standard transformers format and does not have model_index.json. " + f"Please ensure the model has proper configuration files with 'model_type' field" + ) default_config_path = current_omni_platform.get_default_stage_config_path() if model_type in _DIFFUSERS_CLASS_TO_CONFIG: diff --git a/vllm_omni/model_executor/models/common/nucleus_ras_sampling.py b/vllm_omni/model_executor/models/common/nucleus_ras_sampling.py new file mode 100644 index 000000000..34573ae7d --- /dev/null +++ b/vllm_omni/model_executor/models/common/nucleus_ras_sampling.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Shared TTS sampling primitives: nucleus (top-p/top-k) and RAS. + +Used by CosyVoice3 and GLM-TTS (and any future TTS model with RAS). +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import torch + + +def multinomial_sample( + probs: torch.Tensor, + generator: torch.Generator | None = None, +) -> torch.Tensor: + """Draw one sample from a categorical distribution.""" + return torch.multinomial(probs, 1, replacement=True, generator=generator).reshape(()) + + +def nucleus_sample_one( + weighted_scores: torch.Tensor, + *, + top_p: float, + top_k: int, + generator: torch.Generator | None = None, +) -> int: + """Sample one token using nucleus (top-p + top-k) filtering. + + ``weighted_scores`` should be log-softmax-ed logits (for RAS callers) + or raw logits (softmax is applied internally). + + Vectorized via ``torch.cumsum`` — no Python loop over the vocabulary. + """ + # Apply top-k before sort. GLM-TTS and CosyVoice3 usually run with + # top_k=25, so sorting the whole audio vocabulary on every AR step is + # avoidable work in the hottest path. Keep probabilities normalized over + # the full vocabulary so the top-p cumulative mass matches the old path. + if top_k > 0 and int(top_k) < int(weighted_scores.numel()): + top_scores, sorted_idx = torch.topk( + weighted_scores, + k=int(top_k), + dim=0, + largest=True, + sorted=True, + ) + sorted_prob = (top_scores - torch.logsumexp(top_scores, dim=0)).exp() + else: + probs = weighted_scores.softmax(dim=0) + sorted_prob, sorted_idx = probs.sort(descending=True, stable=True) + + # Apply top-p (nucleus) filtering: keep the smallest prefix whose + # cumulative probability exceeds top_p, always including the first token. + # Use float32 for cumulative sum to match the original Python float64 + # accumulation loop. Low-precision (fp16/bf16) cumsum over ~32K vocab + # causes rounding drift that shifts the top-p boundary, changing which + # tokens are kept and ultimately producing different AR sequences. + sorted_prob_f32 = sorted_prob.float() + cum_prob = sorted_prob_f32.cumsum(dim=0) + # Mask: include tokens where cumsum *before* this token is still < top_p + mask = (cum_prob - sorted_prob_f32) < top_p + # Safety: ensure top token is always kept. With sorted descending + # probabilities, mask[0] is always True when top_p > 0 (since the + # cumsum-before-self of the first element is 0 < top_p). Setting it + # unconditionally avoids a GPU→CPU sync from the old `mask.any()` check. + mask[0] = True + + kept_probs = torch.nan_to_num(sorted_prob[mask], nan=0.0, posinf=0.0, neginf=0.0).clamp_min_(0.0) + kept_indices = sorted_idx[mask] + # ``torch.multinomial`` accepts unnormalized weights, but not an all-zero + # vector. Handle the extreme underflow case on-device by forcing the top + # retained token to be selectable without adding another CPU sync. + kept_probs[0] = torch.where( + kept_probs.sum() > 0, + kept_probs[0], + torch.ones_like(kept_probs[0]), + ) + + sample_pos = multinomial_sample(kept_probs, generator=generator) + return int(kept_indices[sample_pos].item()) + + +def ras_sample_one( + weighted_scores: torch.Tensor, + decoded_tokens: Sequence[int], + *, + top_p: float, + top_k: int, + win_size: int, + tau_r: float, + generator: torch.Generator | None = None, +) -> int: + """Repetition-Aware Sampling following GLM-TTS/CosyVoice. + + If the nucleus-sampled token appears too often in the recent window, + upstream samples once from the original full distribution instead of + masking the repeated token. This matters for GLM-TTS because masking the + dominant repeated token can artificially raise EOA probability right after + the minimum length guard is lifted. + """ + + def _random_sample_one() -> int: + return int(multinomial_sample(weighted_scores.softmax(dim=0), generator=generator).item()) + + top_id = nucleus_sample_one( + weighted_scores, + top_p=top_p, + top_k=top_k, + generator=generator, + ) + if win_size > 0 and decoded_tokens: + recent = decoded_tokens[-win_size:] + rep_num = sum(1 for token in recent if int(token) == top_id) + if rep_num >= win_size * tau_r: + top_id = _random_sample_one() + return top_id diff --git a/vllm_omni/model_executor/models/common/whisper_vq.py b/vllm_omni/model_executor/models/common/whisper_vq.py new file mode 100644 index 000000000..9d7af76b5 --- /dev/null +++ b/vllm_omni/model_executor/models/common/whisper_vq.py @@ -0,0 +1,291 @@ +# Copyright 2022 The OpenAI Authors and The HuggingFace Inc. team. All rights reserved. +# 2025 Zhipu AI Inc (authors: CogAudio Group Members) +# SPDX-License-Identifier: Apache-2.0 +"""WhisperVQEncoder: HF WhisperEncoder + VQ codebook + inter-layer pooling. + +Built on standard ``WhisperConfig``. VQ-specific parameters are patched onto +the config object after ``from_pretrained`` so that callers never need a +separate config class. GLM-TTS (and future VQ-based TTS models) only need:: + + from transformers import WhisperConfig + from vllm_omni.model_executor.models.common.whisper_vq import WhisperVQEncoder + + cfg = WhisperConfig.from_pretrained(checkpoint_dir) + cfg.pooling_kernel_size = None # passed through from checkpoint + cfg.pooling_type = "max" # or set by caller + cfg.pooling_position = 0 + cfg.quantize_vocab_size = 32768 + cfg.quantize_position = 16 + cfg.quantize_encoder_only = True + model = WhisperVQEncoder(cfg) +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from typing import Any + +import torch +import torch.nn.functional as F +from torch import Tensor, nn +from transformers import WhisperConfig +from transformers.modeling_outputs import BaseModelOutput +from transformers.models.whisper.modeling_whisper import WhisperEncoder + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +@dataclass +class QuantizedBaseModelOutput(BaseModelOutput): + quantized_token_ids: torch.LongTensor | None = None + + +def vector_quantize(inputs: Tensor, codebook: Tensor) -> tuple[Tensor, Tensor, Tensor]: + """Nearest-neighbour codebook lookup.""" + embedding_size = codebook.size(1) + inputs_flatten = inputs.reshape(-1, embedding_size) + codebook_sqr = torch.sum(codebook**2, dim=1) + inputs_sqr = torch.sum(inputs_flatten**2, dim=1, keepdim=True) + distances = torch.addmm( + codebook_sqr + inputs_sqr, + inputs_flatten, + codebook.t(), + alpha=-2.0, + beta=1.0, + ) + _, indices_flatten = torch.min(distances, dim=1) + codes_flatten = torch.index_select(codebook, dim=0, index=indices_flatten) + codes = codes_flatten.view_as(inputs) + return codes, indices_flatten, distances + + +_LEGACY_LAYER_REMAP = ( + (re.compile(r"^layers\.(\d+)\.attn\.query\."), r"layers.\1.self_attn.q_proj."), + (re.compile(r"^layers\.(\d+)\.attn\.key\."), r"layers.\1.self_attn.k_proj."), + (re.compile(r"^layers\.(\d+)\.attn\.value\."), r"layers.\1.self_attn.v_proj."), + (re.compile(r"^layers\.(\d+)\.attn\.out\."), r"layers.\1.self_attn.out_proj."), + (re.compile(r"^layers\.(\d+)\.attn_ln\."), r"layers.\1.self_attn_layer_norm."), + (re.compile(r"^layers\.(\d+)\.mlp_ln\."), r"layers.\1.final_layer_norm."), + (re.compile(r"^layers\.(\d+)\.mlp\.0\."), r"layers.\1.fc1."), + (re.compile(r"^layers\.(\d+)\.mlp\.2\."), r"layers.\1.fc2."), +) + + +def remap_legacy_whisper_vq_state_dict(state_dict: dict[str, Tensor]) -> dict[str, Tensor]: + """Map CogAudio/GLM-TTS fork parameter names onto HF WhisperEncoder names.""" + remapped: dict[str, Tensor] = {} + for name, tensor in state_dict.items(): + new_name = name + if new_name == "embed_positions": + new_name = "embed_positions.weight" + for pattern, replacement in _LEGACY_LAYER_REMAP: + new_name = pattern.sub(replacement, new_name) + remapped[new_name] = tensor + return remapped + + +# --------------------------------------------------------------------------- +# main encoder class +# --------------------------------------------------------------------------- + + +class WhisperVQEncoder(WhisperEncoder): + """HF Whisper encoder with optional VQ codebook and pooling. + + Uses a standard ``WhisperConfig`` with the following VQ-specific attrs + patched on by the caller (or loaded from the checkpoint's config.json): + + * ``pooling_kernel_size`` -- int | None + * ``pooling_type`` -- "max" | "avg" + * ``pooling_position`` -- int (0-based layer index) + * ``quantize_vocab_size`` -- int | None + * ``quantize_position`` -- int (0-based layer index) + * ``quantize_encoder_only`` -- bool + """ + + def __init__(self, config: WhisperConfig): + super().__init__(config) + embed_dim = config.d_model + max_source_positions = config.max_source_positions + + # Truncate layers when quantize_encoder_only is set. + qpos = int(getattr(config, "quantize_position", 0) or 0) + if getattr(config, "quantize_encoder_only", False): + self.layers = nn.ModuleList(list(self.layers[:qpos])) + self.layer_norm = None + else: + qpos = max(qpos, int(config.encoder_layers)) # never exceed total + + # Optional inter-layer pooling. + self.pooling_layer: nn.Module | None = None + ksize = getattr(config, "pooling_kernel_size", None) + if ksize is not None: + ptype = str(getattr(config, "pooling_type", "max") or "max") + self.pooling_layer = { + "max": nn.MaxPool1d(kernel_size=int(ksize)), + "avg": nn.AvgPool1d(kernel_size=int(ksize)), + }[ptype] + + # Optional vector-quantization codebook. + self.codebook: nn.Embedding | None = None + self.embed_positions2: nn.Embedding | None = None + vq_sz = getattr(config, "quantize_vocab_size", None) + if vq_sz is not None: + self.codebook = nn.Embedding(int(vq_sz), embed_dim) + pos2_len = max_source_positions + if ksize is not None: + pos2_len = math.ceil(max_source_positions / int(ksize)) + self.embed_positions2 = nn.Embedding(pos2_len, embed_dim) + + # cached for forward() + self._pooling_position = int(getattr(config, "pooling_position", 0) or 0) + self._quantize_position = qpos if vq_sz is not None else -1 + + # ------------------------------------------------------------------ + # forward / helpers + # ------------------------------------------------------------------ + + @property + def device(self) -> torch.device: # type: ignore[override] + return self.conv1.weight.device + + @property + def dtype(self) -> torch.dtype: # type: ignore[override] + return self.conv1.weight.dtype + + def load_state_dict( + self, + state_dict: dict[str, Tensor], + strict: bool = True, + assign: bool = False, + ): # type: ignore[override] + remapped = remap_legacy_whisper_vq_state_dict(state_dict) + try: + return super().load_state_dict(remapped, strict=strict, assign=assign) + except TypeError: + return super().load_state_dict(remapped, strict=strict) + + def forward( + self, + input_features: Tensor, + attention_mask: Tensor | None = None, + **_: Any, + ) -> QuantizedBaseModelOutput: + batch_size, _, _ = input_features.shape + hidden_states = F.gelu(self.conv1(input_features)) + hidden_states = F.gelu(self.conv2(hidden_states)) + hidden_states = hidden_states.permute(0, 2, 1) + seq_len = int(hidden_states.shape[1]) + + pos_embed = self._position_embeddings(seq_len, hidden_states.device, hidden_states.dtype) + hidden_states = hidden_states + pos_embed + + valid_mask = self._downsample_attention_mask(attention_mask, batch_size, seq_len, hidden_states.device) + hidden_states = hidden_states.masked_fill(~valid_mask.unsqueeze(-1), 0) + + quantized_token_ids = None + for idx, layer in enumerate(self.layers): + layer_mask = self._make_layer_attention_mask(valid_mask, hidden_states.dtype) + layer_outputs = layer( + hidden_states, + attention_mask=layer_mask, + layer_head_mask=None, + output_attentions=False, + ) + hidden_states = layer_outputs[0] if isinstance(layer_outputs, tuple) else layer_outputs + hidden_states = hidden_states.masked_fill(~valid_mask.unsqueeze(-1), 0) + + if idx + 1 == self._pooling_position and self.pooling_layer is not None: + hidden_states, valid_mask = self._apply_pooling(hidden_states, valid_mask) + + if idx + 1 == self._quantize_position and self.codebook is not None: + hidden_states, quantized_token_ids = self._apply_vq(hidden_states, valid_mask) + + if self.layer_norm is not None: + hidden_states = self.layer_norm(hidden_states) + hidden_states = hidden_states.masked_fill(~valid_mask.unsqueeze(-1), 0) + + return QuantizedBaseModelOutput( + last_hidden_state=hidden_states, + quantized_token_ids=quantized_token_ids, + ) + + def _position_embeddings(self, seq_len: int, device: torch.device, dtype: torch.dtype) -> Tensor: + ep = self.embed_positions + if isinstance(ep, nn.Embedding): + positions = torch.arange(seq_len, device=device) + return ep(positions).to(dtype=dtype).unsqueeze(0) + return ep[:seq_len].to(device=device, dtype=dtype).unsqueeze(0) + + def _downsample_attention_mask( + self, + attention_mask: Tensor | None, + batch_size: int, + seq_len: int, + device: torch.device, + ) -> Tensor: + if attention_mask is None: + return torch.ones(batch_size, seq_len, dtype=torch.bool, device=device) + conv_stride = int(self.conv1.stride[0]) * int(self.conv2.stride[0]) + downsampled = attention_mask[:, ::conv_stride].to(device=device, dtype=torch.bool) + if downsampled.shape[1] < seq_len: + pad = seq_len - int(downsampled.shape[1]) + downsampled = F.pad(downsampled, (0, pad), value=False) + return downsampled[:, :seq_len] + + @staticmethod + def _make_layer_attention_mask(valid_mask: Tensor, dtype: torch.dtype) -> Tensor: + min_value = torch.finfo(dtype).min + additive = torch.zeros(valid_mask.shape, dtype=dtype, device=valid_mask.device) + additive = additive.masked_fill(~valid_mask, min_value) + return additive[:, None, None, :] + + def _apply_pooling(self, hidden_states: Tensor, valid_mask: Tensor) -> tuple[Tensor, Tensor]: + k = int(self.config.pooling_kernel_size) # type: ignore[attr-defined] + pooled_segments: list[Tensor] = [] + pooled_masks: list[Tensor] = [] + for batch_idx in range(int(hidden_states.shape[0])): + length = int(valid_mask[batch_idx].sum().item()) + segment = hidden_states[batch_idx, :length] + if segment.numel() == 0: + pooled = hidden_states.new_zeros((0, hidden_states.shape[-1])) + else: + segment_t = segment.unsqueeze(0).transpose(1, 2) + if int(segment_t.shape[-1]) % k != 0: + segment_t = F.pad(segment_t, (0, k - int(segment_t.shape[-1]) % k)) + pooled = self.pooling_layer(segment_t).transpose(1, 2).squeeze(0) # type: ignore[misc] + pooled_segments.append(pooled) + pooled_masks.append(torch.ones(pooled.shape[0], dtype=torch.bool, device=hidden_states.device)) + + max_len = max((int(seg.shape[0]) for seg in pooled_segments), default=0) + output = hidden_states.new_zeros((hidden_states.shape[0], max_len, hidden_states.shape[-1])) + output_mask = torch.zeros(hidden_states.shape[0], max_len, dtype=torch.bool, device=hidden_states.device) + for i, segment in enumerate(pooled_segments): + if segment.shape[0] > 0: + output[i, : segment.shape[0]] = segment + output_mask[i, : segment.shape[0]] = pooled_masks[i] + return output, output_mask + + def _apply_vq(self, hidden_states: Tensor, valid_mask: Tensor) -> tuple[Tensor, Tensor | None]: + output = hidden_states.clone() + token_ids = torch.full( + (hidden_states.shape[0], hidden_states.shape[1]), + -1, + dtype=torch.long, + device=hidden_states.device, + ) + for batch_idx in range(int(hidden_states.shape[0])): + length = int(valid_mask[batch_idx].sum().item()) + if length <= 0: + continue + segment = hidden_states[batch_idx : batch_idx + 1, :length] + quantized, indices, _ = vector_quantize(segment, self.codebook.weight) # type: ignore[union-attr] + if self.embed_positions2 is not None: + quantized = quantized + self.embed_positions2.weight[:length].to(dtype=quantized.dtype).unsqueeze(0) + output[batch_idx, :length] = quantized.squeeze(0) + token_ids[batch_idx, :length] = indices.reshape(-1) + return output.masked_fill(~valid_mask.unsqueeze(-1), 0), token_ids diff --git a/vllm_omni/model_executor/models/glm_tts/__init__.py b/vllm_omni/model_executor/models/glm_tts/__init__.py new file mode 100644 index 000000000..9618227ca --- /dev/null +++ b/vllm_omni/model_executor/models/glm_tts/__init__.py @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GLM-TTS model support for vLLM-Omni. + +GLM-TTS is a two-stage text-to-speech system: + - Stage 0 (LLM): Llama-based AR model generates speech tokens from text + - Stage 1 (DiT): Flow matching model converts speech tokens to mel-spectrogram + +Reference: https://github.com/zai-org/GLM-TTS +""" + +from vllm_omni.model_executor.models.glm_tts.glm_tts import ( + GLMTTSForConditionalGeneration, +) + +__all__ = [ + "GLMTTSForConditionalGeneration", +] diff --git a/vllm_omni/model_executor/models/glm_tts/glm_tts.py b/vllm_omni/model_executor/models/glm_tts/glm_tts.py new file mode 100644 index 000000000..6e14840e2 --- /dev/null +++ b/vllm_omni/model_executor/models/glm_tts/glm_tts.py @@ -0,0 +1,1423 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GLM-TTS AR Model (Stage 0): Text → Speech Tokens. + +Based on Llama architecture, generates speech token sequences from input text. +Analogous to Fish Speech Slow AR and Qwen3-TTS Talker models. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import replace +from typing import Any + +import torch +import torch.nn as nn +from transformers import AutoTokenizer +from transformers.feature_extraction_utils import BatchFeature +from transformers.utils.hub import cached_file +from vllm.config import VllmConfig +from vllm.config.multimodal import BaseDummyOptions +from vllm.distributed import get_pp_group +from vllm.inputs import MultiModalDataDict +from vllm.logger import init_logger +from vllm.model_executor.layers.logits_processor import LogitsProcessor +from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead +from vllm.model_executor.model_loader.weight_utils import default_weight_loader +from vllm.model_executor.models.interfaces import SupportsMultiModal +from vllm.model_executor.models.llama import LlamaModel +from vllm.model_executor.models.utils import ( + AutoWeightsLoader, + PPMissingLayer, + WeightsMapper, + maybe_prefix, +) +from vllm.multimodal import MULTIMODAL_REGISTRY +from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems +from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser +from vllm.multimodal.processing import ( + BaseDummyInputsBuilder, + BaseMultiModalProcessor, + BaseProcessingInfo, + ProcessorInputs, + PromptIndexTargets, + PromptInsertion, + PromptUpdate, +) +from vllm.sequence import IntermediateTensors +from vllm.v1.outputs import SamplerOutput +from vllm.v1.sample.metadata import SamplingMetadata +from vllm.v1.sample.sampler import Sampler + +from vllm_omni.model_executor.models.common.nucleus_ras_sampling import ras_sample_one as _ras_sample_one +from vllm_omni.model_executor.models.output_templates import OmniOutput +from vllm_omni.transformers_utils.configs.glm_tts import GLMTTSConfig +from vllm_omni.utils.speaker_cache import get_speaker_cache + +from .text_frontend import GLMTTSTextFrontend +from .voice_clone import ( + extract_prompt_feat, + extract_prompt_speech_token, + extract_spk_embedding, + load_voice_clone_frontend, +) + +logger = init_logger(__name__) + +_GLM_TTS_DEFAULT_REPO_ID = "zai-org/GLM-TTS" +_GLM_TTS_TOKENIZER_SUBDIR = "vq32k-phoneme-tokenizer" +_GLM_TTS_MAX_PROMPT_SPEECH_TOKENS = 1024 + + +def _req_float(param: torch.Tensor | None, req_idx: int, default: float) -> float: + if param is None or param.numel() == 0: + return default + index = min(req_idx, int(param.numel()) - 1) + return float(param.reshape(-1)[index].item()) + + +def _tensor_param_values( + param: torch.Tensor | None, + count: int, + default: float, +) -> list[float]: + if param is None or param.numel() == 0: + return [default] * count + values = param.reshape(-1).detach().cpu().tolist() + if not values: + return [default] * count + if len(values) < count: + values.extend([values[-1]] * (count - len(values))) + return [float(value) for value in values[:count]] + + +def resolve_glm_tts_tokenizer_path(model_name_or_path: Any) -> str: + """Resolve vq32k-phoneme-tokenizer directory to a local path. + + For local model dirs, scans for the tokenizer subfolder on disk. + For HF hub names, downloads the tokenizer subfolder via + ``snapshot_download`` (same pattern as CosyVoice3's + ``_ensure_cached_runtime_components`` and + ``arg_utils._TOKENIZER_SUBFOLDER_MAP``). + """ + model_path = os.fspath(model_name_or_path) + if os.path.exists(model_path): + for candidate in [ + os.path.join(model_path, _GLM_TTS_TOKENIZER_SUBDIR), + os.path.join(os.path.dirname(model_path), _GLM_TTS_TOKENIZER_SUBDIR), + ]: + if os.path.isdir(candidate): + return candidate + return model_path + + from huggingface_hub import snapshot_download + + local_dir = snapshot_download( + model_path, + allow_patterns=[ + f"{_GLM_TTS_TOKENIZER_SUBDIR}/tokenizer*", + f"{_GLM_TTS_TOKENIZER_SUBDIR}/tokenization_*", + f"{_GLM_TTS_TOKENIZER_SUBDIR}/special_tokens*", + f"{_GLM_TTS_TOKENIZER_SUBDIR}/vocab*", + f"{_GLM_TTS_TOKENIZER_SUBDIR}/merges*", + f"{_GLM_TTS_TOKENIZER_SUBDIR}/added_tokens*", + ], + ) + candidate = os.path.join(local_dir, _GLM_TTS_TOKENIZER_SUBDIR) + if os.path.isdir(candidate): + return candidate + return model_path + + +def resolve_glm_tts_model_dir( + model_name_or_path: Any, + *, + tokenizer_path: Any | None = None, + required_files: Sequence[str] = (), + optional_files: Sequence[str] = (), +) -> str: + """Resolve GLM-TTS repo root: local dir → tokenizer parent → cached_file → snapshot.""" + model_path = os.fspath(model_name_or_path) + if os.path.isdir(model_path): + return os.path.abspath(model_path) + + def _has_files(root: str) -> bool: + has_req = all(os.path.isfile(os.path.join(root, *f.split("/"))) for f in required_files) + has_opt = not optional_files or any(os.path.isfile(os.path.join(root, *f.split("/"))) for f in optional_files) + return has_req and has_opt + + # Try tokenizer parent directory + if tokenizer_path: + tp = os.path.abspath(os.path.normpath(os.fspath(tokenizer_path))) + root = os.path.dirname(tp) if os.path.basename(tp) == _GLM_TTS_TOKENIZER_SUBDIR else tp + if os.path.isdir(root) and _has_files(root): + return root + + # Try cached_file to locate individual files without full snapshot + for filename in list(required_files) + list(optional_files): + try: + resolved = cached_file(model_name_or_path, filename) + if resolved: + root = os.path.abspath(resolved) + for _ in filename.split("/"): + root = os.path.dirname(root) + if _has_files(root): + return root + except Exception: + pass + + from huggingface_hub import snapshot_download + + return snapshot_download(model_name_or_path) + + +def _first_glm_tts_value(value: Any) -> Any: + return value[0] if isinstance(value, list) and value else value + + +def _glm_tts_int_value(value: Any) -> int | None: + """Extract a scalar integer from vLLM multimodal/runtime wrappers.""" + value = _first_glm_tts_value(value) + if isinstance(value, torch.Tensor): + if value.numel() == 0: + return None + return int(value.reshape(-1)[0].item()) + if isinstance(value, (int, float)): + return int(value) + return None + + +def _decode_glm_tts_audio_data(value: Any) -> tuple[torch.Tensor | None, int | None]: + """Extract (waveform_tensor, sample_rate) from multimodal input.""" + if isinstance(value, list) and len(value) == 1: + value = value[0] + if isinstance(value, torch.Tensor): + return value, None + if isinstance(value, (tuple, list)) and len(value) == 2 and isinstance(value[1], (int, float)): + return torch.as_tensor(value[0]), int(value[1]) + if hasattr(value, "shape"): # numpy array + return torch.as_tensor(value), None + return None, None + + +_glm_tts_tokenizer_cache: dict[str, Any] = {} + + +def load_glm_tts_tokenizer( + tokenizer_path: Any, + *, + model_name_or_path: Any | None = None, + trust_remote_code: bool = False, + **kwargs: Any, +) -> Any: + """Load GLM-TTS phoneme tokenizer (ChatGLM4Tokenizer, slow-only). + + Expects a resolved local directory (use ``resolve_glm_tts_tokenizer_path`` + to handle HF hub names first). Results are cached by *tokenizer_path*. + """ + cache_key = str(tokenizer_path) + cached = _glm_tts_tokenizer_cache.get(cache_key) + if cached is not None: + return cached + + tokenizer = AutoTokenizer.from_pretrained( + tokenizer_path, + use_fast=False, + trust_remote_code=trust_remote_code, + **kwargs, + ) + _glm_tts_tokenizer_cache[cache_key] = tokenizer + return tokenizer + + +def get_glm_tts_special_token_ids(tokenizer: Any) -> dict[str, int]: + """Resolve GLM-TTS special IDs from the phoneme/audio tokenizer.""" + special_tokens = { + "ats": "<|audio_0|>", + "ate": "<|audio_32767|>", + "boa": "<|begin_of_audio|>", + "eoa": "<|user|>", + "pad": "<|endoftext|>", + } + + result: dict[str, int] = {} + for key, token_str in special_tokens.items(): + token_ids = tokenizer.encode(token_str, add_special_tokens=False) + if len(token_ids) != 1: + raise ValueError(f"Token '{key}' ({token_str}) should encode to single ID, got: {token_ids}") + result[key] = int(token_ids[0]) + return result + + +def resolve_glm_tts_campplus_path(model_dir: str) -> str | None: + """Resolve ``campplus.onnx`` for GLM-TTS voice cloning.""" + local = os.path.join(model_dir, "frontend", "campplus.onnx") + if os.path.isfile(local): + return local + + try: + resolved = cached_file("FunAudioLLM/CosyVoice-300M", "campplus.onnx") + if resolved is not None: + logger.info("Resolved campplus.onnx from FunAudioLLM/CosyVoice-300M: %s", resolved) + return resolved + except Exception: + logger.debug("cached_file could not fetch campplus.onnx", exc_info=True) + + logger.warning( + "campplus.onnx not found locally or could not be downloaded. " + "Voice cloning speaker embedding will not be available.", + ) + return None + + +def _normalize_glm_tts_processor_text( + frontend: GLMTTSTextFrontend, + value: str | None, + *, + add_trailing_space: bool = False, +) -> str: + if value is None: + return "" + normalized = frontend.text_normalize(value) + normalized = (normalized or value).strip() + if add_trailing_space and normalized: + normalized = f"{normalized} " + return normalized + + +_glm_tts_text_frontend: GLMTTSTextFrontend | None = None + + +def _get_text_frontend() -> GLMTTSTextFrontend: + global _glm_tts_text_frontend + if _glm_tts_text_frontend is None: + _glm_tts_text_frontend = GLMTTSTextFrontend() + return _glm_tts_text_frontend + + +def build_glm_tts_prefill_metadata( + model_name_or_path: Any, + text: str, + prompt_text: str | None, + *, + tokenizer_path: Any | None = None, + trust_remote_code: bool = False, +) -> dict[str, list[int]]: + """Build request-local GLM-TTS length metadata for AR prefill. + + These scalars are mirrored into ``model_intermediate_buffer`` so the model + preprocess hook can initialize request state from the metadata alone. + """ + if tokenizer_path is None: + tokenizer_path = resolve_glm_tts_tokenizer_path(model_name_or_path) + tokenizer = load_glm_tts_tokenizer( + tokenizer_path, + model_name_or_path=model_name_or_path, + trust_remote_code=trust_remote_code, + ) + frontend = _get_text_frontend() + text_len = max(1, len(tokenizer.encode(_normalize_glm_tts_processor_text(frontend, text)))) + prompt_text_len = 0 + if prompt_text: + prompt_text_len = max( + 1, + len( + tokenizer.encode( + _normalize_glm_tts_processor_text( + frontend, + prompt_text, + add_trailing_space=True, + ) + ) + ), + ) + return { + "glm_tts_text_token_len": [text_len], + "glm_tts_prompt_text_token_len": [prompt_text_len], + "input_len": [prompt_text_len + text_len + 1], + } + + +class GLMTTSMultiModalProcessingInfo(BaseProcessingInfo): + def get_hf_config(self): + return self.ctx.get_hf_config(GLMTTSConfig) + + def get_supported_mm_limits(self) -> Mapping[str, int | None]: + return {"audio": 1} + + def get_mm_max_tokens_per_item( + self, + seq_len: int, + mm_counts: Mapping[str, int], + ) -> Mapping[str, int] | None: + if mm_counts.get("audio", 0) <= 0: + return {} + return {"audio": min(seq_len, _GLM_TTS_MAX_PROMPT_SPEECH_TOKENS)} + + def get_data_parser(self): + return MultiModalDataParser( + target_sr=24000, + expected_hidden_size=self._get_expected_hidden_size(), + ) + + +class GLMTTSMultiModalProcessor(BaseMultiModalProcessor[GLMTTSMultiModalProcessingInfo]): + """GLM-TTS voice-clone processor. + + Unlike CosyVoice3, GLM-TTS prompt speech tokens are normal Llama vocab IDs + (``<|audio_N|>``). The processor exposes them as multimodal embeddings for + the AR prompt and also carries WhisperVQ/CampPlus outputs to the AR->DiT + handoff. + """ + + def _ensure_cached_runtime_components( + self, + model_dir: str, + config: GLMTTSConfig, + *, + load_voice_clone: bool = True, + ) -> None: + # Skip if already loaded for this model_dir + mode + cache_key = (model_dir, load_voice_clone) + if getattr(self, "_loaded_cache_key", None) == cache_key: + return + + tokenizer_path = getattr(self.info.ctx.model_config, "tokenizer", None) + required = ("speech_tokenizer/config.json", "speech_tokenizer/model.safetensors") if load_voice_clone else () + model_dir = resolve_glm_tts_model_dir( + model_dir, + tokenizer_path=tokenizer_path, + required_files=required, + ) + + # Load text tokenizer + frontend (always needed) + if getattr(self, "_text_model_dir", None) != model_dir: + tok_path = tokenizer_path + if not tok_path or not os.path.isdir(os.fspath(tok_path)): + tok_path = os.path.join(model_dir, _GLM_TTS_TOKENIZER_SUBDIR) + if not os.path.isdir(tok_path): + tok_path = model_dir + trust_remote_code = bool(getattr(self.info.ctx.model_config, "trust_remote_code", False)) + self.tokenizer = load_glm_tts_tokenizer( + tok_path, + model_name_or_path=self.info.ctx.model_config.model, + trust_remote_code=trust_remote_code, + ) + self.special_ids = get_glm_tts_special_token_ids(self.tokenizer) + self.text_frontend = GLMTTSTextFrontend() + self._text_model_dir = model_dir + + # Load voice clone components (speech tokenizer + CampPlus) + if load_voice_clone: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self.processor_device = device + campplus_path = getattr(self, "_campplus_path", None) or resolve_glm_tts_campplus_path(model_dir) + self._campplus_path = campplus_path + self.speech_tokenizer, self.campplus_session = load_voice_clone_frontend( + model_dir, + device, + speech_tokenizer_cache=getattr(self, "speech_tokenizer", None), + campplus_cache=getattr(self, "campplus_session", None), + campplus_path=campplus_path, + ) + + if not hasattr(self, "_speaker_cache"): + self._speaker_cache = get_speaker_cache() + + self._loaded_cache_key = cache_key + + def _encode_text(self, text: str) -> torch.Tensor: + token_ids = self.tokenizer.encode(text) + return torch.tensor([token_ids], dtype=torch.long) + + def _get_audio(self, mm_data: Mapping[str, object]) -> tuple[torch.Tensor | None, int | None]: + audio = mm_data.get("audio") + if audio is None: + audios = mm_data.get("audios") + if isinstance(audios, (list, tuple)) and audios: + # Match CosyVoice3's compatibility behavior and accept the + # first item from the legacy plural field name. + audio = audios[0] + else: + audio = audios + wav, sr = _decode_glm_tts_audio_data(audio) + if wav is not None and wav.ndim > 1: + wav = wav.float() + wav = wav.mean(dim=0) if wav.shape[0] <= wav.shape[-1] else wav.mean(dim=-1) + return wav, int(sr or 24000) if wav is not None else sr + + def _call_hf_processor( + self, + prompt: str, + mm_data: Mapping[str, object], + mm_kwargs: Mapping[str, object], + tok_kwargs: Mapping[str, object], + ) -> BatchFeature: + config = self.info.ctx.get_hf_config() + model_dir = self.info.ctx.model_config.model + wav, sr = self._get_audio(mm_data) + if wav is None: + audio_supplied = mm_data.get("audio") is not None or mm_data.get("audios") is not None + assert not audio_supplied, ( + "GLM-TTS received an audio multimodal payload that could not be decoded. " + "Runtime voice cloning must provide decodable multi_modal_data['audio']; " + "the text-only processor path is reserved for profiling/cache." + ) + self._ensure_cached_runtime_components(model_dir, config, load_voice_clone=False) + normalized_text = _normalize_glm_tts_processor_text(self.text_frontend, prompt) + text_ids = self._encode_text(normalized_text) + dummy_audio_len = min( + _GLM_TTS_MAX_PROMPT_SPEECH_TOKENS, + max(1, int(getattr(config, "max_position_embeddings", _GLM_TTS_MAX_PROMPT_SPEECH_TOKENS))), + ) + boa_tensor = torch.tensor([[int(self.special_ids["boa"])]], dtype=torch.long) + input_ids = torch.cat([text_ids, boa_tensor], dim=1) + # vLLM profiling/cache setup may call the processor without an + # audio item. Do not fabricate a dummy wav here: for GLM-TTS that + # would exercise the real voice-clone frontend during profiling. + # This shape-only branch is not a supported runtime inference mode. + return BatchFeature( + { + "input_ids": input_ids, + "prompt_speech_token": torch.zeros((1, dummy_audio_len), dtype=torch.long), + "prompt_speech_token_len": [torch.tensor([dummy_audio_len], dtype=torch.long)], + "glm_tts_prompt_text_token_len": [torch.tensor([0], dtype=torch.long)], + "glm_tts_text_token_len": [torch.tensor([int(text_ids.shape[1])], dtype=torch.long)], + } + ) + + self._ensure_cached_runtime_components(model_dir, config) + normalized_text = _normalize_glm_tts_processor_text(self.text_frontend, prompt) + text_ids = self._encode_text(normalized_text) + prompt_text = mm_kwargs.get("prompt_text") + if not isinstance(prompt_text, str) or not prompt_text.strip(): + raise ValueError("GLM-TTS voice cloning requires mm_processor_kwargs['prompt_text'].") + + normalized_prompt_text = _normalize_glm_tts_processor_text( + self.text_frontend, + prompt_text, + add_trailing_space=True, + ) + prompt_text_ids = self._encode_text(normalized_prompt_text) + + # Speaker cache: skip WhisperVQ + mel + CampPlus on hit + voice_name = mm_kwargs.get("voice_name") + cache_key = None + if voice_name and isinstance(voice_name, str): + cache_key = self._speaker_cache.make_cache_key( + voice_name, + model_type="glm_tts", + created_at=int(mm_kwargs.get("voice_created_at") or 0), + ) + cached = self._speaker_cache.get(cache_key) + if cached is not None: + boa_tensor = torch.tensor([[int(self.special_ids["boa"])]], dtype=torch.long) + input_ids = torch.cat([prompt_text_ids, text_ids, boa_tensor], dim=1) + return BatchFeature( + { + "input_ids": input_ids, + "prompt_speech_token": cached["prompt_speech_token"].clone(), + "prompt_speech_token_len": [cached["prompt_speech_token_len"].clone()], + "glm_tts_prompt_text_token_len": [ + torch.tensor([int(prompt_text_ids.shape[1])], dtype=torch.long) + ], + "prompt_feat": cached["prompt_feat"].clone(), + "embedding": cached["embedding"].clone(), + "glm_tts_text_token_len": [torch.tensor([int(text_ids.shape[1])], dtype=torch.long)], + } + ) + + prompt_speech_token = extract_prompt_speech_token(wav, int(sr or 24000), self.speech_tokenizer) + if not prompt_speech_token: + raise RuntimeError("GLM-TTS failed to extract WhisperVQ prompt speech tokens from ref_audio.") + + prompt_speech_token_tensor = torch.tensor([prompt_speech_token], dtype=torch.long) + boa_tensor = torch.tensor([[int(self.special_ids["boa"])]], dtype=torch.long) + input_ids = torch.cat([prompt_text_ids, text_ids, boa_tensor], dim=1) + logger.info( + "GLM-TTS processor prompt: prompt_text_tokens=%d text_tokens=%d " + "prompt_speech_tokens=%d input_tokens_before_audio=%d expected_prefill_tokens=%d", + int(prompt_text_ids.shape[1]), + int(text_ids.shape[1]), + len(prompt_speech_token), + int(input_ids.shape[1]), + int(input_ids.shape[1]) + len(prompt_speech_token), + ) + + prompt_feat = extract_prompt_feat(wav, int(sr or 24000), self.processor_device) + if prompt_feat is None: + raise RuntimeError("GLM-TTS failed to extract prompt mel features from ref_audio.") + embedding = extract_spk_embedding(wav, int(sr or 24000), self.campplus_session) + if embedding is None: + raise RuntimeError("GLM-TTS failed to extract CampPlus speaker embedding from ref_audio.") + + prompt_speech_token_len = torch.tensor([len(prompt_speech_token)], dtype=torch.long) + prompt_feat_cpu = prompt_feat.detach().to("cpu").unsqueeze(0).contiguous() + embedding_tensor = torch.tensor([embedding], dtype=torch.float32) + + if cache_key is not None: + self._speaker_cache.put( + cache_key, + { + "prompt_speech_token": prompt_speech_token_tensor.detach().cpu(), + "prompt_speech_token_len": prompt_speech_token_len.detach().cpu(), + "prompt_feat": prompt_feat_cpu.detach().cpu(), + "embedding": embedding_tensor.detach().cpu(), + }, + ) + + return BatchFeature( + { + "input_ids": input_ids, + "prompt_speech_token": prompt_speech_token_tensor, + "prompt_speech_token_len": [prompt_speech_token_len], + "glm_tts_prompt_text_token_len": [torch.tensor([int(prompt_text_ids.shape[1])], dtype=torch.long)], + "prompt_feat": prompt_feat_cpu, + "embedding": embedding_tensor, + "glm_tts_text_token_len": [torch.tensor([int(text_ids.shape[1])], dtype=torch.long)], + } + ) + + def _get_mm_fields_config( + self, + hf_inputs: BatchFeature, + hf_processor_mm_kwargs: Mapping[str, object], + ) -> Mapping[str, MultiModalFieldConfig]: + return { + key: MultiModalFieldConfig.batched("audio") + for key in ( + "prompt_speech_token", + "prompt_speech_token_len", + "glm_tts_prompt_text_token_len", + "prompt_feat", + "embedding", + "glm_tts_text_token_len", + ) + if key in hf_inputs + } + + def _hf_processor_applies_updates( + self, + prompt_text: str, + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, object], + tokenization_kwargs: Mapping[str, object], + ) -> bool: + return False + + def _cached_apply_hf_processor(self, inputs: ProcessorInputs, timing_ctx: Any): + # GLM-TTS builds the actual AR prompt from both the request text and + # mm_processor_kwargs["prompt_text"]. The base cache path separates text + # processing from audio processing, which drops the reference transcript + # and BOA token from the final prompt. + return self._apply_hf_processor(inputs, timing_ctx) + + def _get_prompt_updates( + self, + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, object], + out_mm_kwargs: MultiModalKwargsItems, + ) -> Sequence[PromptUpdate]: + def insertion_end(item_idx: int) -> list[int]: + audio_items = out_mm_kwargs["audio"] + item = audio_items[item_idx] if item_idx < len(audio_items) else audio_items[0] + token_len = item["prompt_speech_token_len"].data[0].item() + return [1] * int(token_len) + + return [ + PromptInsertion( + modality="audio", + target=PromptIndexTargets.end(), + insertion=insertion_end, + ) + ] + + +class GLMTTSDummyInputsBuilder(BaseDummyInputsBuilder[GLMTTSMultiModalProcessingInfo]): + def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str: + return "This is a test of the GLM-TTS voice cloning system." + + def get_dummy_mm_data( + self, + seq_len: int, + mm_counts: Mapping[str, int], + mm_options: Mapping[str, BaseDummyOptions] | None = None, + ) -> MultiModalDataDict: + return {} + + def get_dummy_processor_inputs( + self, + seq_len: int, + mm_counts: Mapping[str, int], + mm_options: Mapping[str, BaseDummyOptions] | None = None, + ) -> ProcessorInputs: + inputs = super().get_dummy_processor_inputs(seq_len, mm_counts, mm_options) + inputs.hf_processor_mm_kwargs = {"prompt_text": "This is the reference voice."} + return inputs + + +@MULTIMODAL_REGISTRY.register_processor( + GLMTTSMultiModalProcessor, + info=GLMTTSMultiModalProcessingInfo, + dummy_inputs=GLMTTSDummyInputsBuilder, +) +class GLMTTSForConditionalGeneration(nn.Module, SupportsMultiModal): + """vLLM model for GLM-TTS. + + Handles both stages via model_stage branching: + - ``glm_tts`` (Stage 0): Text → Speech tokens (LLM AR, Llama backbone). + - ``glm_tts_dit`` (Stage 1): Speech tokens → Audio (DiT flow-matching + vocoder). + + Attributes: + have_multimodal_outputs: Signals scheduler to collect multimodal outputs. + has_preprocess: Model has preprocess hook for input preparation (stage 0 only). + has_postprocess: Model has postprocess hook for hidden state caching (stage 0 only). + """ + + supports_multimodal_raw_input_only = True + supports_multimodal = True + requires_raw_input_tokens = True + prefer_model_sampler = True + _sampling_eps = 1e-5 + hf_to_vllm_mapper = WeightsMapper( + orig_to_new_prefix={ + "llama_embedding.": "model.embed_tokens.", + "llama.model.": "model.", + "llama.": "model.", + } + ) + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + self.vllm_config = vllm_config + self.model_path = vllm_config.model_config.model + self.model_dir = self.model_path + self.model_stage = getattr(vllm_config.model_config, "model_stage", "glm_tts") + + # Load configuration + config: GLMTTSConfig = vllm_config.model_config.hf_config # type: ignore[assignment] + self.config = config + + # ---- Stage 1 (DiT): delegate to GLMTTSDiTForGeneration ---- + if self.model_stage == "glm_tts_dit": + from .glm_tts_dit_wrapper import GLMTTSDiTForGeneration + + self._dit_gen = GLMTTSDiTForGeneration(vllm_config=vllm_config, prefix=prefix) + # Expose the DiT as self.model for weight loading routing + self.model = self._dit_gen + self.have_multimodal_outputs = True + self.enable_update_additional_information = True + # DiT stage does not use preprocess/postprocess/sample + self.has_preprocess = False + self.has_postprocess = False + # DiT loads all weights internally from flow/flow.pt etc. + # Point DefaultModelLoader to flow/flow.pt (a .pt file) so it can + # find and load it without triggering the vLLM#39699 bug where + # subdirectory safetensors patterns cause pt_weights_iterator + # to be used on .safetensors files. load_weights() ignores the + # yielded weights and loads flow/flow.pt itself. + self.allow_patterns_overrides = ["flow/flow.pt"] + self.fall_back_to_pt_during_load = False + return + sample_method = str(getattr(config, "sample_method", "ras")).lower() + if sample_method != "ras": + raise ValueError(f"Unsupported GLM-TTS sample_method={sample_method!r}; expected 'ras'.") + + # Resolve repo root to local path (CosyVoice3 pattern). + # Model weights are in llm/ subdirectory; tokenizer and other resources + # are siblings of llm/ under the repo root. + self.model_dir = resolve_glm_tts_model_dir( + self.model_dir, + tokenizer_path=getattr(vllm_config.model_config, "tokenizer", None), + ) + + # Stage 0 weights live under llm/model-*.safetensors and are loaded + # explicitly by _iter_llm_safetensors() during load_weights(). + # + # We still point DefaultModelLoader at flow/flow.pt as a bootstrap + # sentinel because the current upstream loader path does not reliably + # target subdirectory safetensors patterns here without hitting the + # vLLM#39699 iterator bug. Keep the real stage-0 source of truth in + # _iter_llm_safetensors(); this override is only to get through the + # generic loader bootstrap step. + self.allow_patterns_overrides = ["flow/flow.pt"] + self.fall_back_to_pt_during_load = False + + # Load tokenizer for special token ID resolution. + # Prefer the tokenizer path auto-detected by arg_utils + # (_TOKENIZER_SUBFOLDER_MAP). Fall back to vq32k-phoneme-tokenizer/ + # under model_dir. + tokenizer_path = getattr(vllm_config.model_config, "tokenizer", None) + if not (tokenizer_path and os.path.isdir(tokenizer_path)): + tokenizer_path = os.path.join(self.model_dir, _GLM_TTS_TOKENIZER_SUBDIR) + if not os.path.exists(tokenizer_path): + tokenizer_path = self.model_dir + trust_remote_code = bool(getattr(vllm_config.model_config, "trust_remote_code", False)) + self._tokenizer = load_glm_tts_tokenizer( + tokenizer_path, + model_name_or_path=self.model_path, + trust_remote_code=trust_remote_code, + ) + special_ids = get_glm_tts_special_token_ids(self._tokenizer) + self._ats = special_ids["ats"] + self._ate = special_ids["ate"] + self._boa = special_ids["boa"] + self._eoa = special_ids["eoa"] + self._pad = special_ids["pad"] + self._bos = self._tokenizer.bos_token_id or self._pad + + logger.info( + "GLM-TTS token IDs: ATS=%d, ATE=%d, BOA=%d, EOA=%d, PAD=%d, vocab_size=%d", + self._ats, + self._ate, + self._boa, + self._eoa, + self._pad, + config.vocab_size, + ) + + # Validate special token sanity with runtime exceptions (asserts can be + # stripped under python -O). + if self._ate - self._ats != 32767: + raise ValueError(f"Audio token range should be 32768, got {self._ate - self._ats + 1}") + if self._boa >= self._ats: + raise ValueError(f"BOA={self._boa} should be < ATS={self._ats} (BOA is text token)") + + # Validate vocab_size covers all special tokens + max_token = max(self._ats, self._ate, self._boa, self._eoa, self._pad) + if max_token >= config.vocab_size: + raise ValueError( + f"vocab_size ({config.vocab_size}) must be > max token ID ({max_token}). " + f"Check model's config.json has correct vocab_size." + ) + + # Update config with dynamic token IDs so vLLM uses correct eos_token + # This enables proper stop detection when EOA is sampled + config.eos_token_id = self._eoa + config.eoa_token_id = self._eoa + config.audio_token_start = self._ats + config.audio_token_end = self._ate + config.boa_token_id = self._boa + config.pad_token_id = self._pad + config.bos_token_id = self._bos + + # Model flags for AR scheduler + self.have_multimodal_outputs = True + self.has_preprocess = True + self.has_postprocess = True + # GLM-TTS async chunk transfer consumes speech_tokens plus voice-clone + self.gpu_resident_buffer_keys: set[tuple[str, str]] = {("last_hidden", "last")} + + self.model = LlamaModel(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")) + + # LM head for speech token prediction + if get_pp_group().is_last_rank: + self.lm_head = ParallelLMHead( + config.vocab_size, + config.hidden_size, + quant_config=vllm_config.quant_config, + prefix=maybe_prefix(prefix, "lm_head"), + ) + else: + self.lm_head = PPMissingLayer() + + self.logits_processor = LogitsProcessor(config.vocab_size) + self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors + + # Runtime validation: dynamic EOA must match the hardcoded pipeline + # stop_token_ids constant (59253). A mismatch means the upstream + # tokenizer vocabulary has changed and pipeline.py needs updating. + _PIPELINE_EOA = 59253 + if self._eoa != _PIPELINE_EOA: + logger.warning( + "GLM-TTS EOA token mismatch: tokenizer resolved %d but " + "pipeline.py hardcodes stop_token_ids=[%d]. Update " + "pipeline.py to match the current checkpoint.", + self._eoa, + _PIPELINE_EOA, + ) + + # RAS sampling config — stored as attributes for sample() + self._ras_win_size = int(getattr(config, "ras_win_size", 10)) + self._ras_tau_r = float(getattr(config, "ras_tau_r", 0.1)) + self._ras_top_p = float(getattr(config, "ras_top_p", 0.8)) + self._ras_top_k = int(getattr(config, "ras_top_k", 25)) + + def _model_dtype(self) -> torch.dtype: + """Return the active parameter dtype for locally-created embeddings.""" + try: + return next(self.model.parameters()).dtype + except StopIteration: + dtype = getattr(self.vllm_config.model_config, "dtype", None) + if isinstance(dtype, torch.dtype): + return dtype + if isinstance(dtype, str): + _aliases = {"bf16": "bfloat16", "fp16": "float16", "half": "float16", "fp32": "float32"} + resolved = getattr(torch, _aliases.get(dtype.lower(), dtype.lower()), None) + if resolved is not None: + return resolved + return torch.get_default_dtype() + + def _resolve_prefill_target_text_len( + self, + span_len: int, + info_dict: Mapping[str, Any], + ) -> int | None: + """Resolve the target-text token count before the first AR sample. + + The request builder mirrors the processor's length metadata into + model_intermediate_buffer before the first prefill. Prefer lengths + inferred from that metadata, then fall back to the explicit target + scalar. Never silently use a prompt-text length as the target length. + """ + + provided_text_len = _glm_tts_int_value(info_dict.get("glm_tts_text_token_len")) + prompt_text_len = _glm_tts_int_value(info_dict.get("glm_tts_prompt_text_token_len")) + prompt_speech_len = _glm_tts_int_value(info_dict.get("prompt_speech_token_len")) + input_len = _glm_tts_int_value(info_dict.get("input_len")) + + inferred_text_len: int | None = None + if input_len is not None and prompt_text_len is not None: + inferred_text_len = input_len - prompt_text_len - 1 # strip prompt text and BOA. + elif prompt_text_len is not None and prompt_speech_len is not None: + inferred_text_len = span_len - prompt_text_len - prompt_speech_len - 1 + + if inferred_text_len is not None and inferred_text_len > 0: + if provided_text_len is not None and provided_text_len != inferred_text_len: + logger.warning( + "GLM-TTS target text token length mismatch: processor=%d inferred=%d; using inferred length.", + provided_text_len, + inferred_text_len, + ) + return inferred_text_len + + return provided_text_len if provided_text_len is not None and provided_text_len > 0 else None + + def embed_input_ids( + self, + input_ids: torch.Tensor, + multimodal_embeddings: Any | None = None, + is_multimodal: Any | None = None, + **kwargs: Any, + ) -> torch.Tensor: + if self.model_stage == "glm_tts_dit": + return self._dit_gen.embed_input_ids(input_ids, **kwargs) + embed_tokens = self.model.embed_tokens(input_ids) + if multimodal_embeddings is None or is_multimodal is None: + return embed_tokens + + mask = torch.as_tensor(is_multimodal, device=embed_tokens.device, dtype=torch.bool).reshape(-1) + if not bool(mask.any()): + return embed_tokens + + if isinstance(multimodal_embeddings, (list, tuple)): + tensors = [ + torch.as_tensor(item, device=embed_tokens.device, dtype=embed_tokens.dtype) + for item in multimodal_embeddings + if item is not None + ] + if not tensors: + return embed_tokens + mm_embeds = torch.cat([item.reshape(-1, item.shape[-1]) for item in tensors], dim=0) + else: + mm_embeds = multimodal_embeddings + if mm_embeds is None: + return embed_tokens + mm_embeds = torch.as_tensor(mm_embeds, device=embed_tokens.device, dtype=embed_tokens.dtype) + if mm_embeds.ndim == 3 and int(mm_embeds.shape[0]) == 1: + mm_embeds = mm_embeds.squeeze(0) + if mm_embeds.ndim != 2: + shape = tuple(mm_embeds.shape) + raise ValueError(f"GLM-TTS multimodal embeddings should be 2D, got shape={shape}") + + flat_tokens = embed_tokens.reshape(-1, embed_tokens.shape[-1]) + if int(flat_tokens.shape[0]) != int(mask.numel()): + raise ValueError( + "GLM-TTS multimodal mask/token length mismatch: " + f"mask={int(mask.numel())}, tokens={int(flat_tokens.shape[0])}" + ) + mm_len = int(mask.sum().item()) + if int(mm_embeds.shape[0]) < mm_len: + raise ValueError( + "GLM-TTS multimodal embedding length mismatch: " + f"embeddings={int(mm_embeds.shape[0])}, placeholders={mm_len}" + ) + + flat_tokens = flat_tokens.clone() + flat_tokens[mask] = mm_embeds[:mm_len] + return flat_tokens.reshape_as(embed_tokens) + + def embed_multimodal(self, **kwargs: Any) -> list[torch.Tensor] | None: + if self.model_stage != "glm_tts": + return None + prompt_speech_token = kwargs.get("prompt_speech_token") + if prompt_speech_token is None: + return None + + def embed_one(value: Any) -> torch.Tensor | None: + if value is None: + return None + speech_token = torch.as_tensor(value, device=next(self.model.parameters()).device) + if speech_token.numel() == 0: + return None + speech_token = speech_token.to(dtype=torch.long) + if speech_token.ndim == 0: + speech_token = speech_token.reshape(1, 1) + elif speech_token.ndim == 1: + speech_token = speech_token.unsqueeze(0) + elif speech_token.ndim > 2: + speech_token = speech_token.reshape(int(speech_token.shape[0]), -1) + if int(speech_token.min().item()) >= self._ats: + speech_ids = speech_token + else: + speech_ids = speech_token + self._ats + speech_embeds = self.model.embed_tokens(speech_ids) + return speech_embeds.reshape(-1, speech_embeds.shape[-1]) + + def is_flat_token_sequence(value: list[Any] | tuple[Any, ...]) -> bool: + if not value: + return False + try: + return all(torch.as_tensor(item).ndim == 0 for item in value) + except (TypeError, ValueError): + return False + + if isinstance(prompt_speech_token, (list, tuple)): + if is_flat_token_sequence(prompt_speech_token): + one_embed = embed_one(prompt_speech_token) + return [one_embed] if one_embed is not None else None + embeds = [embed_one(item) for item in prompt_speech_token] + embeds = [item for item in embeds if item is not None] + if not embeds: + return None + return embeds + + one_embed = embed_one(prompt_speech_token) + return [one_embed] if one_embed is not None else None + + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + intermediate_tensors: IntermediateTensors | None = None, + inputs_embeds: torch.Tensor | None = None, + **kwargs: Any, + ) -> torch.Tensor | IntermediateTensors | OmniOutput: + if self.model_stage == "glm_tts_dit": + return self._dit_gen.forward(input_ids, positions, **kwargs) + return self.model(input_ids, positions, intermediate_tensors, inputs_embeds) + + def compute_logits( + self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None + ) -> torch.Tensor | None: + if self.model_stage == "glm_tts_dit": + return self._dit_gen.compute_logits(hidden_states, sampling_metadata) + if isinstance(hidden_states, OmniOutput): + hidden_states = hidden_states.text_hidden_states + if hidden_states is None: + return None + logits = self.logits_processor(self.lm_head, hidden_states) + if logits is None: + return None + + return logits + + def _glm_tts_ras_enabled(self, sampling_metadata: SamplingMetadata) -> bool: + if self.model_stage != "glm_tts": + return False + if sampling_metadata.max_num_logprobs is not None: + return False + if sampling_metadata.temperature is None: + return False + if bool(sampling_metadata.bad_words_token_ids): + return False + if torch.any(sampling_metadata.frequency_penalties != 0): + return False + if torch.any(sampling_metadata.presence_penalties != 0): + return False + return True + + def _apply_allowed_mask(self, logits: torch.Tensor) -> torch.Tensor: + """Restrict logits to valid audio tokens [ATS..ATE] ∪ {EOA}.""" + allowed_mask = getattr(self, "_ar_allowed_mask", None) + if allowed_mask is None or allowed_mask.device != logits.device or allowed_mask.shape[0] != logits.shape[-1]: + allowed_mask = torch.full((logits.shape[-1],), float("-inf"), device=logits.device) + allowed_mask[self._ats : self._ate + 1] = 0.0 + allowed_mask[self._eoa] = 0.0 + self._ar_allowed_mask = allowed_mask + return logits + allowed_mask + + def _select_allowed_audio_logits(self, logits: torch.Tensor) -> torch.Tensor: + """Return compact logits for [audio tokens] plus EOA. + + The RAS path samples only from GLM-TTS audio tokens and EOA. Keeping a + compact view avoids top-k/logsumexp over the full LLM vocabulary on + every AR step; this is equivalent to sampling after ``_apply_allowed_mask``. + """ + audio_logits = logits[:, self._ats : self._ate + 1] + if self._ats <= self._eoa <= self._ate: + return audio_logits + return torch.cat((audio_logits, logits[:, self._eoa : self._eoa + 1]), dim=-1) + + def _allowed_local_to_token_id(self, local_id: int) -> int: + audio_vocab = self._ate - self._ats + 1 + return self._ats + local_id if local_id < audio_vocab else self._eoa + + def _recent_allowed_local_ids(self, decoded_tokens: Sequence[int], win_size: int) -> list[int]: + if win_size <= 0 or not decoded_tokens: + return [] + audio_vocab = self._ate - self._ats + 1 + recent: list[int] = [] + for token in decoded_tokens[-win_size:]: + token_id = int(token) + if self._ats <= token_id <= self._ate: + recent.append(token_id - self._ats) + elif token_id == self._eoa: + recent.append(audio_vocab) + else: + recent.append(-1) + return recent + + def sample( + self, + logits: torch.Tensor, + sampling_metadata: SamplingMetadata, + ) -> SamplerOutput | None: + """RAS sampler following CosyVoice3 pattern. + + Uses vLLM Sampler for logits processing (logit_bias_state handles + min_tokens/max_tokens/stop_token_ids). When RAS is enabled, applies + per-request nucleus+repetition-aware sampling; otherwise falls back + to standard vLLM sampling. + """ + if logits is None or logits.numel() == 0: + return None + if self.model_stage != "glm_tts": + return None + + sampler = getattr(self, "_ar_sampler", None) + if sampler is None: + sampler = Sampler() + self._ar_sampler = sampler + + if not self._glm_tts_ras_enabled(sampling_metadata): + logits = self._apply_allowed_mask(logits) + return sampler(logits=logits, sampling_metadata=sampling_metadata) + + logits = logits.to(torch.float32) + sampling_for_processors = replace(sampling_metadata, no_penalties=True) + logits = sampler.apply_logits_processors(logits, sampling_for_processors, predict_bonus_token=False) + allowed_logits = self._select_allowed_audio_logits(logits) + + default_top_p = self._ras_top_p + default_top_k = self._ras_top_k + ws = self._ras_win_size + tr = self._ras_tau_r + + num_reqs = int(allowed_logits.shape[0]) + t_data = sampling_metadata.temperature + tp_data = sampling_metadata.top_p + tk_data = sampling_metadata.top_k + cache_key = ( + t_data.data_ptr() if t_data is not None else 0, + tp_data.data_ptr() if tp_data is not None else 0, + tk_data.data_ptr() if tk_data is not None else 0, + num_reqs, + ) + _sp_cache = getattr(self, "_sampling_param_cache", None) + if _sp_cache is not None and _sp_cache[0] == cache_key: + temperatures, top_ps, top_ks = _sp_cache[1] + else: + temperatures = _tensor_param_values(t_data, num_reqs, 1.0) + top_ps = _tensor_param_values(tp_data, num_reqs, default_top_p) + top_ks = _tensor_param_values(tk_data, num_reqs, default_top_k) + self._sampling_param_cache = (cache_key, (temperatures, top_ps, top_ks)) + + sampled_ids: list[int] = [] + for req_idx in range(num_reqs): + row_logits = allowed_logits[req_idx] + temperature = temperatures[req_idx] + if temperature < self._sampling_eps: + local_id = int(torch.argmax(row_logits).item()) + sampled_ids.append(self._allowed_local_to_token_id(local_id)) + continue + + top_p = top_ps[req_idx] + top_k = int(top_ks[req_idx]) + generator = sampling_metadata.generators.get(req_idx) + weighted_scores = row_logits / max(temperature, self._sampling_eps) + decoded_tokens = ( + sampling_metadata.output_token_ids[req_idx] if req_idx < len(sampling_metadata.output_token_ids) else [] + ) + local_id = _ras_sample_one( + weighted_scores, + self._recent_allowed_local_ids(decoded_tokens, ws), + top_p=top_p, + top_k=top_k, + win_size=ws, + tau_r=tr, + generator=generator, + ) + sampled_ids.append(self._allowed_local_to_token_id(local_id)) + + sampled = torch.tensor(sampled_ids, device=logits.device, dtype=torch.int32) + return SamplerOutput(sampled_token_ids=sampled.unsqueeze(-1), logprobs_tensors=None) + + def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput: + """Package hidden states, speech tokens, and voice clone data into OmniOutput. + + Streaming contract: **delta**. Each decode step emits exactly one + speech token (or a prefill placeholder). The engine's output + processor concatenates per-step deltas into the final tensor. + """ + if isinstance(model_outputs, OmniOutput): + return model_outputs + + hidden = model_outputs + info_dicts = kwargs.get("model_intermediate_buffer") or [] + if isinstance(info_dicts, dict): + info_dicts = [info_dicts] + + speech_tokens_list: list[torch.Tensor] = [] + multimodal_extras: dict[str, Any] = {} + mm_conditioning_keys = ( + "prompt_speech_token", + "prompt_speech_token_len", + "glm_tts_prompt_text_token_len", + "prompt_feat", + "embedding", + "glm_tts_text_token_len", + ) + info_for_flags = next((info for info in info_dicts if isinstance(info, dict)), None) + if info_for_flags is None or not info_for_flags.get("_voice_clone_emitted"): + copied_from_kwargs = False + for key in mm_conditioning_keys: + val = kwargs.get(key) + if val is not None: + multimodal_extras[key] = val + copied_from_kwargs = True + if copied_from_kwargs and info_for_flags is not None: + info_for_flags["_voice_clone_emitted"] = True + + for info in info_dicts: + if not isinstance(info, dict): + continue + tokens = info.get("speech_tokens") + if isinstance(tokens, torch.Tensor) and tokens.numel() > 0: + speech_tokens_list.append(tokens) + # Propagate voice cloning features from preprocess info_update. + # IMPORTANT: Only emit once — the output processor accumulates + # every decode step and concatenates tensors, which would corrupt + # constant data (e.g. embedding [192] → [N*192]). + if not info.get("_voice_clone_emitted"): + for key in mm_conditioning_keys: + val = info.get(key) + if val is not None and key not in multimodal_extras: + multimodal_extras[key] = val + if any(info.get(k) is not None for k in mm_conditioning_keys): + info["_voice_clone_emitted"] = True + + if not speech_tokens_list: + return OmniOutput(text_hidden_states=hidden, multimodal_outputs=multimodal_extras) + + speech_tokens = torch.cat(speech_tokens_list, dim=0) + span_len = int(speech_tokens.shape[0]) + hidden = hidden[:span_len] + multimodal_extras["speech_tokens"] = speech_tokens + return OmniOutput( + text_hidden_states=hidden, + multimodal_outputs=multimodal_extras, + ) + + def preprocess( + self, + input_ids: torch.Tensor, + input_embeds: torch.Tensor | None, + **info_dict: Any, + ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: + """Prepare inputs for GLM-TTS AR model. + + GLM-TTS only supports the multimodal processor path: + text prompt + ``multi_modal_data["audio"]`` + ``mm_processor_kwargs["prompt_text"]``. + Legacy placeholder prompts via ``additional_information`` are rejected. + """ + if info_dict.get("additional_information") is not None: + raise ValueError( + "GLM-TTS no longer accepts legacy additional_information prompts; " + "use prompt + multi_modal_data['audio'] + mm_processor_kwargs['prompt_text']." + ) + + span_len = int(input_ids.shape[0]) + logger.debug("preprocess: span_len=%d, input_ids.shape=%s", span_len, input_ids.shape) + if span_len <= 0: + return input_ids, input_embeds if input_embeds is not None else self.embed_input_ids(input_ids), {} + + device = input_ids.device + + if isinstance(info_dict.get("text"), list) and info_dict["text"]: + raise ValueError( + "GLM-TTS no longer accepts legacy text/additional_information prefill payloads; " + "use the multimodal processor path." + ) + + mm_prefill_done = bool(info_dict.get("glm_tts_mm_prefill_done")) + sampled_token: int | None = None + if input_embeds is not None and span_len == 1: + sampled_token = int(input_ids[0].item()) + one_token_prefill_tail = ( + input_embeds is not None + and span_len == 1 + and mm_prefill_done + and sampled_token is not None + and sampled_token != self._eoa + and not (self._ats <= sampled_token <= self._ate) + ) + is_prefill_span = span_len > 1 or not mm_prefill_done or one_token_prefill_tail + if input_embeds is None and is_prefill_span: + raise ValueError("Missing GLM-TTS multimodal input embeddings.") + if input_embeds is not None and is_prefill_span: + input_ids_out = input_ids.clone() + input_ids_out[:] = self._pad + info_update: dict[str, Any] = { + "glm_tts_mm_prefill_done": True, + "speech_tokens": torch.full((span_len, 1), -1, device=device, dtype=torch.long), + } + # One-token prefill tails re-enter the prefill branch but + # text_token_len was already resolved during the initial prefill. + if not one_token_prefill_tail: + text_token_len = self._resolve_prefill_target_text_len(span_len, info_dict) + if text_token_len is not None: + info_update["glm_tts_text_token_len"] = torch.tensor( + [text_token_len], device=device, dtype=torch.long + ) + prompt_text_len = info_dict.get("glm_tts_prompt_text_token_len") + if prompt_text_len is not None: + info_update["glm_tts_prompt_text_token_len"] = prompt_text_len + prompt_speech_len = info_dict.get("prompt_speech_token_len") + if prompt_speech_len is None: + input_len = _glm_tts_int_value(info_dict.get("input_len")) + if input_len is not None: + prompt_speech_len = torch.tensor( + [max(0, span_len - input_len)], device=device, dtype=torch.long + ) + if prompt_speech_len is not None: + info_update["prompt_speech_token_len"] = prompt_speech_len + return input_ids_out, input_embeds.to(dtype=self._model_dtype()), info_update + + # Decode: span_len == 1 + # Standard autoregressive decode - use input_ids directly + if input_embeds is not None and int(input_embeds.shape[0]) == 1: + inputs_embeds_out = input_embeds.reshape(1, -1).to(dtype=self._model_dtype()) + else: + inputs_embeds_out = self.embed_input_ids(input_ids.reshape(1, 1).to(torch.long)) + inputs_embeds_out = inputs_embeds_out.reshape(1, -1).to(dtype=self._model_dtype()) + + # Convert sampled token to speech token (relative to ATS) + # -1 = invalid/EOA, valid range = [0, ATE-ATS] + if sampled_token is None: + sampled_token = int(input_ids[0].item()) + if self._ats <= sampled_token <= self._ate: + speech_token = sampled_token - self._ats + else: + # EOA or other non-audio token → mark as invalid (-1) + speech_token = -1 + logger.debug("GLM-TTS decode: non-audio token %d (EOA=%d)", sampled_token, self._eoa) + speech_tokens = torch.tensor([[speech_token]], device=device, dtype=torch.long) + + info_update = {"speech_tokens": speech_tokens} + return input_ids, inputs_embeds_out, info_update + + def postprocess(self, hidden_states: torch.Tensor, **kwargs: Any) -> dict[str, Any]: + """Cache last hidden state for next decode step.""" + if hidden_states.numel() == 0: + return {} + last = hidden_states[-1, :].detach() + update: dict[str, Any] = {"last_hidden": {"last": last}} + + multimodal_outputs = kwargs.get("multimodal_outputs") + if isinstance(multimodal_outputs, dict): + copied_conditioning = False + for key in ("prompt_speech_token", "prompt_speech_token_len", "prompt_feat", "embedding"): + val = multimodal_outputs.get(key) + if val is not None: + update[key] = _first_glm_tts_value(val) + copied_conditioning = True + + prompt_text_len = multimodal_outputs.get("glm_tts_prompt_text_token_len") + if prompt_text_len is not None: + update["glm_tts_prompt_text_token_len"] = _first_glm_tts_value(prompt_text_len) + + text_token_len = _glm_tts_int_value(multimodal_outputs.get("glm_tts_text_token_len")) + if text_token_len is not None: + update["glm_tts_text_token_len"] = torch.tensor([int(text_token_len)], dtype=torch.long) + + if copied_conditioning: + update["_voice_clone_emitted"] = True + + return update + + def _iter_llm_safetensors(self) -> Iterable[tuple[str, torch.Tensor]]: + """Yield (name, tensor) pairs from llm/model-*.safetensors.""" + import glob as glob_module + + from safetensors.torch import load_file + + llm_dir = os.path.join(self.model_dir, "llm") + if os.path.isdir(llm_dir): + sf_files = sorted(glob_module.glob(os.path.join(llm_dir, "model-*.safetensors"))) + if sf_files: + for sf_path in sf_files: + yield from load_file(sf_path, device="cpu").items() + return + + from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific + + model_root = download_weights_from_hf_specific( + self.model_path, + self.vllm_config.load_config.download_dir, + allow_patterns=["llm/model-*.safetensors"], + ) + llm_dir = os.path.join(model_root, "llm") + sf_files = sorted(glob_module.glob(os.path.join(llm_dir, "model-*.safetensors"))) + if not sf_files: + raise RuntimeError(f"No LLM safetensors found under {model_root}. Expected llm/model-*.safetensors.") + for sf_path in sf_files: + yield from load_file(sf_path, device="cpu").items() + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + """Load weights from checkpoint. + + Stage 0 (glm_tts): HuggingFace Llama-format checkpoint from llm/ subdir. + Stage 1 (glm_tts_dit): DiT flow.pt + vocoder. + """ + if self.model_stage == "glm_tts_dit": + return self._dit_gen.load_weights(weights) + + def _glm_tts_weights() -> Iterable[tuple[str, torch.Tensor]]: + for name, loaded_weight in self._iter_llm_safetensors(): + if "rotary_emb.inv_freq" in name: + continue + if "rotary_emb.cos_cached" in name or "rotary_emb.sin_cached" in name: + continue + yield name, loaded_weight + + loader = AutoWeightsLoader(self) + loaded_params = loader.load_weights(_glm_tts_weights(), mapper=self.hf_to_vllm_mapper) + + params_dict = dict(self.named_parameters()) + # Handle tie_word_embeddings: copy embed_tokens to lm_head if not loaded. + lm_head_key = "lm_head.weight" + embed_key = "model.embed_tokens.weight" + if lm_head_key not in loaded_params and embed_key in loaded_params: + if lm_head_key in params_dict and embed_key in params_dict: + lm_head_param = params_dict[lm_head_key] + embed_param = params_dict[embed_key] + weight_loader = getattr(lm_head_param, "weight_loader", default_weight_loader) + weight_loader(lm_head_param, embed_param.data) + loaded_params.add(lm_head_key) + logger.info("Tied lm_head.weight to embed_tokens.weight") + + logger.info("Loaded %d weights for GLMTTSForConditionalGeneration", len(loaded_params)) + return loaded_params diff --git a/vllm_omni/model_executor/models/glm_tts/glm_tts_dit.py b/vllm_omni/model_executor/models/glm_tts/glm_tts_dit.py new file mode 100644 index 000000000..5cda6ec45 --- /dev/null +++ b/vllm_omni/model_executor/models/glm_tts/glm_tts_dit.py @@ -0,0 +1,650 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GLM-TTS DiT (Diffusion Transformer) Model. + +Flow Matching model that converts speech tokens to mel-spectrogram. +Ported from: https://github.com/zai-org/GLM-TTS/blob/main/flow/dit.py +""" + +from __future__ import annotations + +import math + +import torch +import torch.nn as nn +import torch.nn.functional as F +from einops import rearrange +from x_transformers.x_transformers import RotaryEmbedding, apply_rotary_pos_emb + +from vllm_omni.diffusion.attention.layer import Attention as DiffusionAttention + + +def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0, theta_rescale_factor: float = 1.0): + """Precompute Rotary Positional Embeddings (RoPE).""" + theta *= theta_rescale_factor ** (dim / (dim - 2)) + freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim)) + t = torch.arange(end, device=freqs.device) + freqs = torch.outer(t, freqs).float() + freqs_cos = torch.cos(freqs) + freqs_sin = torch.sin(freqs) + return torch.cat([freqs_cos, freqs_sin], dim=-1) + + +def get_pos_embed_indices(start, length, max_pos, scale=1.0): + """Get position embedding indices.""" + scale = scale * torch.ones_like(start, dtype=torch.float32) + pos = ( + start.unsqueeze(1) + + (torch.arange(length, device=start.device, dtype=torch.float32).unsqueeze(0) * scale.unsqueeze(1)).long() + ) + pos = torch.where(pos < max_pos, pos, max_pos - 1) + return pos + + +class SinusPositionEmbedding(nn.Module): + """Sinusoidal position embedding for timesteps.""" + + def __init__(self, dim: int): + super().__init__() + self.dim = dim + + def forward(self, x: torch.Tensor, scale: float = 1000) -> torch.Tensor: + device = x.device + half_dim = self.dim // 2 + emb = math.log(10000) / (half_dim - 1) + emb = torch.exp(torch.arange(half_dim, device=device).float() * -emb) + emb = scale * x.unsqueeze(1) * emb.unsqueeze(0) + emb = torch.cat((emb.sin(), emb.cos()), dim=-1) + return emb + + +class TimestepEmbedding(nn.Module): + """Timestep embedding with MLP.""" + + def __init__(self, dim: int, freq_embed_dim: int = 256): + super().__init__() + self.time_embed = SinusPositionEmbedding(freq_embed_dim) + self.time_mlp = nn.Sequential(nn.Linear(freq_embed_dim, dim), nn.SiLU(), nn.Linear(dim, dim)) + + def forward(self, timestep: torch.Tensor) -> torch.Tensor: + time_hidden = self.time_embed(timestep) + # Cast to model weight dtype (bfloat16) not input dtype (may be float32) + weight_dtype = self.time_mlp[0].weight.dtype + time_hidden = time_hidden.to(weight_dtype) + return self.time_mlp(time_hidden) + + +class GRN(nn.Module): + """Global Response Normalization layer.""" + + def __init__(self, dim: int): + super().__init__() + self.gamma = nn.Parameter(torch.zeros(1, 1, dim)) + self.beta = nn.Parameter(torch.zeros(1, 1, dim)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + gx = torch.norm(x, p=2, dim=1, keepdim=True) + nx = gx / (gx.mean(dim=-1, keepdim=True) + 1e-6) + return self.gamma * (x * nx) + self.beta + x + + +class ConvNeXtV2Block(nn.Module): + """ConvNeXt-V2 Block for text embedding.""" + + def __init__(self, dim: int, intermediate_dim: int, dilation: int = 1): + super().__init__() + padding = (dilation * (7 - 1)) // 2 + self.dwconv = nn.Conv1d(dim, dim, kernel_size=7, padding=padding, groups=dim, dilation=dilation) + self.norm = nn.LayerNorm(dim, eps=1e-6) + self.pwconv1 = nn.Linear(dim, intermediate_dim) + self.act = nn.GELU() + self.grn = GRN(intermediate_dim) + self.pwconv2 = nn.Linear(intermediate_dim, dim) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + residual = x + x = x.transpose(1, 2) + x = self.dwconv(x) + x = x.transpose(1, 2) + x = self.norm(x) + x = self.pwconv1(x) + x = self.act(x) + x = self.grn(x) + x = self.pwconv2(x) + return residual + x + + +class ConvPositionEmbedding(nn.Module): + """Convolutional position embedding.""" + + def __init__(self, dim: int, kernel_size: int = 31, groups: int = 16): + super().__init__() + if kernel_size % 2 == 0: + raise ValueError(f"kernel_size must be odd, got {kernel_size}") + self.conv1d = nn.Sequential( + nn.Conv1d(dim, dim, kernel_size, groups=groups, padding=kernel_size // 2), + nn.Mish(), + nn.Conv1d(dim, dim, kernel_size, groups=groups, padding=kernel_size // 2), + nn.Mish(), + ) + + def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor: + if mask is not None: + mask = mask[..., None] + x = x.masked_fill(~mask, 0.0) + + x = rearrange(x, "b n d -> b d n") + x = self.conv1d(x) + out = rearrange(x, "b d n -> b n d") + + if mask is not None: + out = out.masked_fill(~mask, 0.0) + return out + + +class TextEmbedding(nn.Module): + """Text/speech token embedding with optional ConvNeXt modeling.""" + + def __init__( + self, + text_num_embeds: int, + output_dim: int, + conv_layers: int = 0, + conv_mult: int = 2, + length_align: str = "fill", + ): + super().__init__() + self.pad_id = int(text_num_embeds) + self.text_embed = nn.Embedding(text_num_embeds + 1, output_dim, padding_idx=self.pad_id) + self.length_align = length_align + + if conv_layers > 0: + self.extra_modeling = True + self.precompute_max_pos = 4096 + self.register_buffer( + "freqs_cis", precompute_freqs_cis(output_dim, self.precompute_max_pos), persistent=False + ) + self.text_blocks = nn.Sequential( + *[ConvNeXtV2Block(output_dim, output_dim * conv_mult) for _ in range(conv_layers)] + ) + else: + self.extra_modeling = False + + def forward( + self, + text: torch.Tensor, + aim_seq_len: int, + text_lens: torch.Tensor | None = None, + ) -> torch.Tensor: + batch, text_len = text.shape[0], text.shape[1] + if self.length_align == "fill": + text = text[:, :aim_seq_len] + text = F.pad(text, (0, aim_seq_len - min(text_len, aim_seq_len)), value=self.pad_id) + elif self.length_align == "interpolate_token": + if text_lens is None: + text = ( + F.interpolate( + text.unsqueeze(1).float(), + size=aim_seq_len, + mode="nearest", + ) + .squeeze(1) + .long() + ) + else: + text_lens = text_lens.to(device=text.device, dtype=torch.long) + text_lens = torch.clamp(text_lens, min=1, max=text_len) + positions = torch.arange(aim_seq_len, device=text.device, dtype=torch.long) + src_idx = positions.unsqueeze(0) * text_lens.unsqueeze(1) // int(aim_seq_len) + src_idx = torch.minimum(src_idx, (text_lens - 1).unsqueeze(1)) + text = torch.gather(text, dim=1, index=src_idx) + elif self.length_align != "interpolate_feature": + raise ValueError(f"Unsupported length_align={self.length_align!r}") + + hidden = self.text_embed(text) + + if self.length_align == "interpolate_feature": + hidden = F.interpolate( + hidden.permute(0, 2, 1), + size=aim_seq_len, + mode="nearest", + ).permute(0, 2, 1) + + if self.extra_modeling: + # Get model dtype from text_blocks weights + model_dtype = self.text_blocks[0].dwconv.weight.dtype + hidden = hidden.to(model_dtype) + + batch_start = torch.zeros((batch,), dtype=torch.long, device=text.device) + pos_idx = get_pos_embed_indices(batch_start, aim_seq_len, max_pos=self.precompute_max_pos) + text_pos_embed = self.freqs_cis[pos_idx].to(model_dtype) + hidden = hidden + text_pos_embed + hidden = self.text_blocks(hidden) + + return hidden + + +class EmbeddingConcater(nn.Module): + """Concatenates and projects noisy audio, condition, and text embeddings.""" + + def __init__(self, in_dim: int, out_dim: int): + super().__init__() + self.proj = nn.Linear(in_dim, out_dim) + self.conv_pos_embed = ConvPositionEmbedding(dim=out_dim) + + def forward( + self, + x: torch.Tensor, + cond: torch.Tensor, + text_embed: torch.Tensor, + drop_audio_cond: bool = False, + ) -> torch.Tensor: + if drop_audio_cond: + cond = torch.zeros_like(cond) + x = self.proj(torch.cat((x, cond, text_embed), dim=-1)) + x = self.conv_pos_embed(x) + x + return x + + +class AdaLayerNormZero(nn.Module): + """Adaptive LayerNorm with modulation from time/condition embedding.""" + + def __init__(self, dim: int, additional_dim: int = 0): + super().__init__() + self.silu = nn.SiLU() + self.linear = nn.Linear(dim + additional_dim, dim * 6) + self.norm = nn.LayerNorm(dim, elementwise_affine=False, eps=1e-6) + + def forward(self, x: torch.Tensor, emb: torch.Tensor): + emb = self.linear(self.silu(emb)) + shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = torch.chunk(emb, 6, dim=1) + x = self.norm(x) * (1 + scale_msa[:, None]) + shift_msa[:, None] + return x, gate_msa, shift_mlp, scale_mlp, gate_mlp + + +class AdaLayerNormZeroFinal(nn.Module): + """Adaptive LayerNorm for final layer.""" + + def __init__(self, dim: int, additional_dim: int = 0): + super().__init__() + self.silu = nn.SiLU() + self.linear = nn.Linear(dim + additional_dim, dim * 2) + self.norm = nn.LayerNorm(dim, elementwise_affine=False, eps=1e-6) + + def forward(self, x: torch.Tensor, emb: torch.Tensor) -> torch.Tensor: + emb = self.linear(self.silu(emb)) + scale, shift = torch.chunk(emb, 2, dim=1) + x = self.norm(x) * (1 + scale)[:, None, :] + shift[:, None, :] + return x + + +class FeedForward(nn.Module): + """Feed-forward network with GELU activation. + + Matches original GLM-TTS structure with nested Sequential for checkpoint compatibility. + Weight paths: ff.ff.0.0.* (first Linear), ff.ff.2.* (second Linear) + """ + + def __init__(self, dim: int, dim_out: int | None = None, mult: int = 4, dropout: float = 0.0): + super().__init__() + inner_dim = int(dim * mult) + dim_out = dim_out if dim_out is not None else dim + + # Nested structure to match original checkpoint paths + project_in = nn.Sequential( + nn.Linear(dim, inner_dim), + nn.GELU(approximate="tanh"), + ) + self.ff = nn.Sequential( + project_in, + nn.Dropout(dropout), + nn.Linear(inner_dim, dim_out), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.ff(x) + + +class DiTAttention(nn.Module): + """Attention module using diffusion infrastructure.""" + + def __init__(self, dim: int, heads: int = 8, dim_head: int = 64, dropout: float = 0.0): + super().__init__() + self.dim = dim + self.heads = heads + self.dim_head = dim_head + self.inner_dim = dim_head * heads + self.scale = 1.0 / math.sqrt(dim_head) + + self.to_q = nn.Linear(dim, self.inner_dim) + self.to_k = nn.Linear(dim, self.inner_dim) + self.to_v = nn.Linear(dim, self.inner_dim) + self.to_out = nn.Sequential(nn.Linear(self.inner_dim, dim), nn.Dropout(dropout)) + + self.attn = DiffusionAttention( + num_heads=heads, + head_size=dim_head, + softmax_scale=self.scale, + causal=False, + ) + + def forward( + self, + x: torch.Tensor, + padding_mask: torch.Tensor | None = None, + rope=None, + attn_mask: torch.Tensor | None = None, + ) -> torch.Tensor: + batch_size, seq_len = x.shape[0], x.shape[1] + + query = self.to_q(x) + key = self.to_k(x) + value = self.to_v(x) + + # Apply RoPE + if rope is not None: + freqs, xpos_scale = rope + q_xpos_scale, k_xpos_scale = (xpos_scale, xpos_scale**-1.0) if xpos_scale is not None else (1.0, 1.0) + query = apply_rotary_pos_emb(query, freqs, q_xpos_scale) + key = apply_rotary_pos_emb(key, freqs, k_xpos_scale) + + # Reshape for attention + query = query.view(batch_size, seq_len, self.heads, self.dim_head) + key = key.view(batch_size, seq_len, self.heads, self.dim_head) + value = value.view(batch_size, seq_len, self.heads, self.dim_head) + + # Block-causal mask requires arbitrary 2D attention mask which + # FlashAttention does not support. Fall back to PyTorch SDPA + # (matches official GLM-TTS flow/modules.py:272). + if attn_mask is not None: + q = query.permute(0, 2, 1, 3) # [B, H, T, D] + k = key.permute(0, 2, 1, 3) + v = value.permute(0, 2, 1, 3) + out = F.scaled_dot_product_attention( + q, + k, + v, + attn_mask=attn_mask, + dropout_p=0.0, + is_causal=False, + ) + out = out.permute(0, 2, 1, 3) # [B, T, H, D] + else: + out = self.attn(query, key, value, attn_metadata=None) + out = out.view(batch_size, seq_len, self.inner_dim) + out = out.to(query.dtype) + out = self.to_out(out) + + # Apply padding mask + if padding_mask is not None: + padding_mask = rearrange(padding_mask, "b 1 n -> b n 1").bool() + out = out.masked_fill(~padding_mask, 0.0) + + return out + + +class DiTBlock(nn.Module): + """DiT block with AdaLayerNorm modulation.""" + + def __init__( + self, + dim: int, + heads: int, + dim_head: int, + ff_mult: int = 4, + dropout: float = 0.1, + additional_condition_dim: int = 0, + ): + super().__init__() + self.attn_norm = AdaLayerNormZero(dim, additional_condition_dim) + self.attn = DiTAttention(dim=dim, heads=heads, dim_head=dim_head, dropout=dropout) + self.ff_norm = nn.LayerNorm(dim, elementwise_affine=False, eps=1e-6) + self.ff = FeedForward(dim=dim, mult=ff_mult, dropout=dropout) + + def forward( + self, + x: torch.Tensor, + t: torch.Tensor, + padding_mask: torch.Tensor | None = None, + rope=None, + attn_mask: torch.Tensor | None = None, + ) -> torch.Tensor: + # Pre-norm & modulation for attention + norm, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.attn_norm(x, emb=t) + + # Attention + attn_output = self.attn(x=norm, padding_mask=padding_mask, rope=rope, attn_mask=attn_mask) + x = x + gate_msa.unsqueeze(1) * attn_output + + # FeedForward + norm = self.ff_norm(x) * (1 + scale_mlp[:, None]) + shift_mlp[:, None] + ff_output = self.ff(norm) + x = x + gate_mlp.unsqueeze(1) * ff_output + + return x + + +class GLMTTSDiT(nn.Module): + """GLM-TTS Diffusion Transformer. + + Converts speech tokens to mel-spectrogram via flow matching. + """ + + _repeated_blocks = ["DiTBlock"] + _layerwise_offload_blocks_attrs = ["transformer_blocks"] + + def __init__( + self, + *, + trans_dim: int = 768, + depth: int = 18, + heads: int = 12, + dim_head: int = 64, + ff_mult: int = 2, + dropout: float = 0.1, + mel_dim: int = 80, + text_vocab_size: int = 100000, + text_emb_dim: int = 512, + conv_layers: int = 4, + condition_dim: int = 160, # mel_dim + spk_embed_dim + spkr_emb_adaln: bool = False, + spkr_dim: int = 192, + use_wavlm_emb: bool = False, + long_skip_connection: bool = False, + ): + super().__init__() + + self.time_embed = TimestepEmbedding(trans_dim) + self.text_emb_layer = TextEmbedding( + text_vocab_size, + text_emb_dim, + conv_layers=conv_layers, + length_align="interpolate_token", + ) + + in_dim = mel_dim + text_emb_dim + condition_dim + self.emb_concator = EmbeddingConcater(in_dim, trans_dim) + + self.rotary_embed = RotaryEmbedding(dim_head) + + self.dim = trans_dim + self.depth = depth + self.num_heads = heads + self.spkr_emb_adaln = spkr_emb_adaln + + # Frame rates for block-causal mask token→mel conversion + self._input_frame_rate: float = 25.0 + self._mel_framerate: float = 50.0 + + # Speaker embedding dimension for AdaLN + if spkr_emb_adaln: + additional_dim = spkr_dim + 256 if use_wavlm_emb else spkr_dim + else: + additional_dim = 0 + + self.transformer_blocks = nn.ModuleList( + [ + DiTBlock( + dim=trans_dim, + heads=heads, + dim_head=dim_head, + ff_mult=ff_mult, + dropout=dropout, + additional_condition_dim=additional_dim, + ) + for _ in range(depth) + ] + ) + + self.long_skip_connection = nn.Linear(trans_dim * 2, trans_dim, bias=False) if long_skip_connection else None + + self.norm_out = AdaLayerNormZeroFinal(trans_dim, additional_dim=additional_dim) + self.proj_out = nn.Linear(trans_dim, mel_dim) + + # ------------------------------------------------------------------ + # Block-causal attention mask (ported from GLM-TTS block_mask_util) + # ------------------------------------------------------------------ + + def _token_to_mel_size(self, token_count: int) -> int: + """Convert token block size to mel-frame block size for streaming mask. + + Matches upstream GLM-TTS ``flow/dit.py`` block-mask conversion. + This is intentionally separate from normal ``feat_len`` calculation, + which still follows ``input_frame_rate`` and ``mel_framerate``. + """ + return int(token_count / 12.5 * 22050 / 256) + + def _create_block_causal_mask( + self, + block_pattern: list[int], + seq_len: int, + device: torch.device, + dtype: torch.dtype, + ) -> torch.Tensor: + """Build block-causal attention mask [1, 1, T, T]. + + Ported from GLM-TTS utils/block_mask_util.py ``create()``. + Each position can attend to its own block plus a look-ahead into + the next block (50% for small blocks, 66% for large blocks). + + Vectorized via ``torch.searchsorted`` — no Python loop over T. + """ + import itertools + + mel_blocks = [self._token_to_mel_size(b) for b in block_pattern] + # Extend with copies of the last block (enough for any seq_len) + extended = mel_blocks + [mel_blocks[-1]] * ((seq_len // max(mel_blocks[-1], 1)) + 2) + accum = list(itertools.accumulate(extended)) + + # Vectorized: map each position to its block index via searchsorted + accum_t = torch.tensor(accum, dtype=torch.long) + extended_t = torch.tensor(extended, dtype=torch.long) + positions = torch.arange(seq_len, dtype=torch.long) + + # searchsorted(accum, positions, right=True) gives the block index + # for each position (accum is strictly increasing). + block_idx = torch.searchsorted(accum_t, positions, right=True) + + # Per-position: block start, block size, position within block + block_start = torch.where(block_idx > 0, accum_t[block_idx - 1], torch.zeros_like(block_idx)) + cur_block_size = extended_t[block_idx] + idx_in_block = positions - block_start + + # Look-ahead: positions near the end of a block can see into the next + look_future = cur_block_size - 1 - idx_in_block + min_future = torch.where( + cur_block_size <= 172, + cur_block_size // 2, + (cur_block_size.float() / 1.5).long(), + ) + delta = torch.clamp(min_future - look_future, min=0) + aim_len = torch.clamp(block_start + cur_block_size + delta, max=seq_len) + + # Build mask: position i can attend to columns [0, aim_len[i]) + col_idx = torch.arange(seq_len, device=device) + mask = col_idx.unsqueeze(0) < aim_len.unsqueeze(1).to(device) + + # [1, 1, T, T] for broadcast across batch and heads + return mask.unsqueeze(0).unsqueeze(0) + + def forward( + self, + middle_point: torch.Tensor, + condition: torch.Tensor, + text: torch.Tensor, + time_step: torch.Tensor, + padding_mask: torch.Tensor, + spkr_emb: torch.Tensor | None = None, + text_lens: torch.Tensor | None = None, + is_causal: bool = False, + attn_mask: torch.Tensor | None = None, + block_pattern: list[int] | None = None, + ) -> torch.Tensor: + """Forward pass. + + Args: + middle_point: Noised audio tensor [B, T, mel_dim] + condition: Masked audio condition [B, T, condition_dim] + text: Speech token tensor [B, T_text] + time_step: Timestep tensor [B] + padding_mask: Padding mask [B, T] + spkr_emb: Speaker embedding [B, spkr_dim] + is_causal: Use block-causal attention + attn_mask: Custom attention mask (overrides is_causal) + block_pattern: Token-level block sizes for block-causal mask + + Returns: + Predicted mel-spectrogram [B, T, mel_dim] + """ + # Cast floating inputs to model dtype (weights may be bfloat16) + model_dtype = self.proj_out.weight.dtype + middle_point = middle_point.to(model_dtype) + condition = condition.to(model_dtype) + time_step = time_step.to(model_dtype) + if spkr_emb is not None: + spkr_emb = spkr_emb.to(model_dtype) + + _, seq_len = middle_point.shape[0], middle_point.shape[1] + + # Time embedding + time_emb = self.time_embed(time_step) + + # Concatenate speaker embedding if using AdaLN + if self.spkr_emb_adaln and spkr_emb is not None: + time_emb = torch.cat([time_emb, spkr_emb], dim=-1) + + # Text embedding - ensure cast to model dtype + text_embed = self.text_emb_layer(text, seq_len, text_lens=text_lens).to(model_dtype) + + # Input projection + x = self.emb_concator(middle_point, condition, text_embed, drop_audio_cond=False) + + # Rotary embeddings + rope = self.rotary_embed.forward_from_seq_len(seq_len) + + # Build block-causal mask when is_causal and no explicit attn_mask + if attn_mask is None and is_causal and block_pattern is not None: + attn_mask = self._create_block_causal_mask( + block_pattern, + seq_len, + middle_point.device, + model_dtype, + ) + + # Long skip connection + if self.long_skip_connection is not None: + residual = x + + # Transformer blocks + for block in self.transformer_blocks: + x = block(x, time_emb, padding_mask=padding_mask.unsqueeze(1), rope=rope, attn_mask=attn_mask) + + # Long skip connection + if self.long_skip_connection is not None: + x = self.long_skip_connection(torch.cat((x, residual), dim=-1)) + + # Output projection + x = self.norm_out(x, time_emb) + output = self.proj_out(x) + + return output diff --git a/vllm_omni/model_executor/models/glm_tts/glm_tts_dit_wrapper.py b/vllm_omni/model_executor/models/glm_tts/glm_tts_dit_wrapper.py new file mode 100644 index 000000000..a7c26af30 --- /dev/null +++ b/vllm_omni/model_executor/models/glm_tts/glm_tts_dit_wrapper.py @@ -0,0 +1,1301 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GLM-TTS DiT wrapper: LLM_GENERATION-compatible flow-matching + vocoder.""" + +from __future__ import annotations + +import dataclasses +import json +import math +import os +import threading +from collections.abc import Iterable +from typing import Any + +import numpy as np +import torch +import torch.nn as nn +from torch.cuda import CUDAGraph +from vllm.config import VllmConfig +from vllm.logger import init_logger +from vllm.platforms import current_platform + +from vllm_omni.model_executor.models.glm_tts.glm_tts import ( + resolve_glm_tts_model_dir, +) +from vllm_omni.model_executor.models.glm_tts.vocoder import ( + load_vocoder, + mel_to_audio, +) +from vllm_omni.model_executor.models.output_templates import OmniOutput + +logger = init_logger(__name__) + + +@dataclasses.dataclass +class _StreamRequestState: + """Per-request streaming state (replaces 5 separate dicts).""" + + diff_cache: dict[int | str, Any] = dataclasses.field(default_factory=dict) + prompt_payload: dict[str, torch.Tensor] = dataclasses.field(default_factory=dict) + audio_tail: torch.Tensor | None = None + wav_pointer: int = 0 + fade_tail: torch.Tensor | None = None + + +_GLM_TTS_OFFICIAL_FLOW_FALLBACK: dict[str, Any] = { + "spkr_emb_adaLN": True, + "speech_token_cfg": False, + "remove_spkr_concat_condition": True, + "mel_dim": 80, + "mel_framerate": 50, + "input_frame_rate": 25, +} + +_GLM_TTS_RUNTIME_FLOW_DEFAULTS: dict[str, Any] = { + "sample_rate": 24000, + "n_timesteps": 10, + "t_scheduler": "cosine", + "inference_cfg_rate": 0.7, + "spk_embed_dim": 80, + "trans_dim": 768, + "depth": 18, + "heads": 12, +} + + +def _load_glm_tts_flow_config(flow_dir: str) -> dict[str, Any]: + """Load GLM-TTS flow config from JSON/YAML, falling back to official defaults.""" + config: dict[str, Any] = dict(_GLM_TTS_OFFICIAL_FLOW_FALLBACK) + + # Try config.json first (standard HuggingFace layout) + json_path = os.path.join(flow_dir, "config.json") + if os.path.exists(json_path): + try: + with open(json_path) as f: + raw = json.load(f) + flow = raw.get("flow", raw) if isinstance(raw, dict) else {} + if isinstance(flow, dict): + config.update(flow) + except Exception as e: + logger.warning("Failed to parse GLM-TTS flow config.json: %s", e) + + # Try config.yaml (HyperPyYAML — strip tags, only extract kwargs) + yaml_path = os.path.join(flow_dir, "config.yaml") + if os.path.exists(yaml_path): + try: + import yaml + + class _TagStripper(yaml.SafeLoader): + pass + + _TagStripper.add_multi_constructor( + "!", + lambda loader, _tag, node: ( + loader.construct_mapping(node, deep=True) + if isinstance(node, yaml.MappingNode) + else loader.construct_sequence(node, deep=True) + if isinstance(node, yaml.SequenceNode) + else loader.construct_scalar(node) + ), + ) + with open(yaml_path) as f: + raw = yaml.load(f, Loader=_TagStripper) + flow = raw.get("flow", raw) if isinstance(raw, dict) else {} + if isinstance(flow, dict): + config.update(flow) + except Exception as e: + logger.warning("Failed to parse GLM-TTS flow config.yaml: %s", e) + + return config + + +def make_pad_mask(lengths: torch.Tensor, max_len: int | None = None) -> torch.Tensor: + if max_len is None: + max_len = int(lengths.max().item()) + seq_range = torch.arange(0, max_len, device=lengths.device) + return seq_range.unsqueeze(0).expand(lengths.shape[0], max_len) >= lengths.unsqueeze(1) + + +def _unwrap(value: object) -> object: + """Unwrap single-element lists (common runtime_info wrapping).""" + return value[0] if isinstance(value, list) and value else value + + +def nested_get(info: dict[str, Any], section: str, key: str) -> Any: + nested = info.get(section) + return nested[key] if isinstance(nested, dict) and key in nested else info.get(key) + + +def _glm_tts_private_info(info: dict[str, Any]) -> dict[str, Any]: + private = info.get("glm_tts") + if isinstance(private, dict): + return private + kv_metadata = info.get("kv_metadata") + if isinstance(kv_metadata, dict): + private = kv_metadata.get("glm_tts") + if isinstance(private, dict): + return private + return {} + + +def as_tensor(value: object) -> torch.Tensor | None: + value = _unwrap(value) + return value if isinstance(value, torch.Tensor) else None + + +def as_bool(value: object) -> bool: + value = _unwrap(value) + if isinstance(value, torch.Tensor) and value.numel() > 0: + return bool(value.reshape(-1)[0].item()) + return bool(value) if value is not None else False + + +def as_str(value: object) -> str | None: + value = _unwrap(value) + return str(value) if value is not None else None + + +def _as_scalar(value: object, typ: type, default: int | float = 0): + """Extract scalar int/float from runtime_info value.""" + value = _unwrap(value) + if isinstance(value, torch.Tensor) and value.numel() > 0: + return typ(value.reshape(-1)[0].item()) + if value is None: + return default + try: + return typ(value) + except (TypeError, ValueError): + return default + + +def as_int(value: object, default: int = 0) -> int: + return _as_scalar(value, int, default) + + +def as_float(value: object, default: float = 0.0) -> float: + return _as_scalar(value, float, default) + + +def as_int_list(value: object) -> list[int] | None: + if value is None: + return None + if isinstance(value, torch.Tensor): + return [int(x) for x in value.reshape(-1).tolist()] if value.numel() else [] + if isinstance(value, list): + return [int(x.item() if hasattr(x, "item") else x) for x in value] + return None + + +def valid_speech_tokens( + value: object, + *, + device: torch.device, + fallback: torch.Tensor | None = None, +) -> torch.Tensor: + """Return GLM-TTS speech tokens, treating an explicit empty list as empty.""" + if value is None: + token = fallback if fallback is not None else torch.empty(0, device=device, dtype=torch.long) + elif isinstance(value, torch.Tensor): + token = value.to(device=device, dtype=torch.long) + elif isinstance(value, np.ndarray): + token = torch.as_tensor(value, device=device, dtype=torch.long) + elif isinstance(value, list): + if len(value) == 1 and isinstance(value[0], torch.Tensor): + token = value[0].to(device=device, dtype=torch.long) + elif value and all(isinstance(item, torch.Tensor) for item in value): + token = torch.cat([item.reshape(-1).to(device=device, dtype=torch.long) for item in value]) + else: + token = torch.tensor(value, device=device, dtype=torch.long) + else: + token = fallback if fallback is not None else torch.empty(0, device=device, dtype=torch.long) + + token = token.reshape(-1).to(device=device, dtype=torch.long) + return token[(token >= 0) & (token < 32768)] + + +def split_request_ids(ids: torch.Tensor, seq_token_counts: list[int] | None = None) -> list[torch.Tensor]: + if seq_token_counts is not None: + boundaries, total = [0], ids.numel() + for count in seq_token_counts: + boundaries.append(boundaries[-1] + int(count)) + return [ids[boundaries[i] : min(boundaries[i + 1], total)] for i in range(len(seq_token_counts))] + try: + from vllm.forward_context import get_forward_context, is_forward_context_available + + if is_forward_context_available(): + slices = get_forward_context().ubatch_slices + if slices is not None and len(slices) > 1 and not any(hasattr(s, "token_slice") for s in slices): + boundaries = [0] + for s in slices: + boundaries.append(boundaries[-1] + int(s)) + return [ids[boundaries[i] : boundaries[i + 1]] for i in range(len(boundaries) - 1)] + except ImportError: + pass + return [ids] + + +class GLMTTSDiTForGeneration(nn.Module): + """GLM-TTS DiT flow-matching stage wrapped as LLM_GENERATION.""" + + have_multimodal_outputs = True + enable_update_additional_information = True + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + config = vllm_config.model_config.hf_config + self.config = config + self.model_stage = "glm_tts_dit" + self.max_num_seqs = int(getattr(vllm_config.scheduler_config, "max_num_seqs", 1)) + self._use_dit_cuda_graphs = bool(getattr(config, "use_dit_cuda_graphs", False)) + self._dit_cudagraph: CUDAGraphGLMTTSDiTWrapper | None = None + self._codec_chunk_frames, self._codec_left_context_frames = self._connector_chunk_config(vllm_config) + + model_path = vllm_config.model_config.model + model_path = resolve_glm_tts_model_dir( + model_path, + tokenizer_path=getattr(vllm_config.model_config, "tokenizer", None), + required_files=("flow/flow.pt",), + optional_files=("hift/hift.pt", "vocos2d/generator_jit.ckpt"), + ) + self.model_dir = model_path + flow_dir = os.path.join(self.model_dir, "flow") + self.hf_flow_config = _load_glm_tts_flow_config(flow_dir) + logger.info("GLM-TTS flow config: %s", self.hf_flow_config) + + self.mel_dim = self.hf_flow_config.get("mel_dim", 80) + self.input_frame_rate = self.hf_flow_config.get("input_frame_rate", 25.0) + self.mel_framerate = self.hf_flow_config.get("mel_framerate", 50) + self.sample_rate = self.hf_flow_config.get("sample_rate", _GLM_TTS_RUNTIME_FLOW_DEFAULTS["sample_rate"]) + + self.n_timesteps = self.hf_flow_config.get("n_timesteps", _GLM_TTS_RUNTIME_FLOW_DEFAULTS["n_timesteps"]) + config_n_ts = getattr(config, "n_timesteps", None) + if config_n_ts is not None: + self.n_timesteps = int(config_n_ts) + self.t_scheduler = self.hf_flow_config.get("t_scheduler", _GLM_TTS_RUNTIME_FLOW_DEFAULTS["t_scheduler"]) + self.inference_cfg_rate = self.hf_flow_config.get( + "inference_cfg_rate", _GLM_TTS_RUNTIME_FLOW_DEFAULTS["inference_cfg_rate"] + ) + self.speech_token_cfg = self.hf_flow_config.get("speech_token_cfg", False) + + self.spk_embed_dim = self.hf_flow_config.get("spk_embed_dim", _GLM_TTS_RUNTIME_FLOW_DEFAULTS["spk_embed_dim"]) + self.spkr_emb_adaln = self.hf_flow_config.get("spkr_emb_adaLN", True) + self.remove_spkr_concat_condition = self.hf_flow_config.get("remove_spkr_concat_condition", True) + + from vllm_omni.model_executor.models.glm_tts.glm_tts_dit import GLMTTSDiT + + trans_dim = self.hf_flow_config.get("trans_dim", _GLM_TTS_RUNTIME_FLOW_DEFAULTS["trans_dim"]) + depth = self.hf_flow_config.get("depth", _GLM_TTS_RUNTIME_FLOW_DEFAULTS["depth"]) + heads = self.hf_flow_config.get("heads", _GLM_TTS_RUNTIME_FLOW_DEFAULTS["heads"]) + condition_dim = self.mel_dim if self.remove_spkr_concat_condition else self.mel_dim + self.spk_embed_dim + + self.dit = GLMTTSDiT( + trans_dim=trans_dim, + depth=depth, + heads=heads, + dim_head=64, + ff_mult=2, + dropout=0.0, + mel_dim=self.mel_dim, + text_vocab_size=100000, + text_emb_dim=512, + conv_layers=4, + condition_dim=condition_dim, + spkr_emb_adaln=self.spkr_emb_adaln, + spkr_dim=192, + use_wavlm_emb=False, + ) + self._model_dtype = self._resolve_model_dtype(vllm_config) + if self._model_dtype is not None: + self.dit.to(dtype=self._model_dtype) + + if not self.remove_spkr_concat_condition: + self.spk_embed_affine_layer = nn.Linear(192, self.spk_embed_dim) + if self._model_dtype is not None: + self.spk_embed_affine_layer.to(dtype=self._model_dtype) + + self._vocoder = None + self._vocoder_sample_rate: int | None = None + + self._stream_cache_lock = threading.Lock() + self._stream_state_by_req: dict[str, _StreamRequestState] = {} + # Track recently-seen req_ids so stale entries (aborted requests) + # can be evicted without modifying the shared model runner. + self._stream_last_seen: dict[str, int] = {} + self._stream_forward_count: int = 0 + + self._flow_weights_path = os.path.join(self.model_dir, "flow", "flow.pt") + self._hift_weights_path = os.path.join(self.model_dir, "hift", "hift.pt") + self._vocos_jit_path = os.path.join(self.model_dir, "vocos2d", "generator_jit.ckpt") + + @staticmethod + def _connector_chunk_config(vllm_config: VllmConfig) -> tuple[int, int]: + cc = getattr(vllm_config.model_config, "stage_connector_config", None) + extra = (cc or {}).get("extra") if isinstance(cc, dict) else getattr(cc, "extra", None) + if not isinstance(extra, dict): + return 25, 25 + cf = extra.get("codec_chunk_frames", 25) + return (int(cf[0]) if isinstance(cf, list) else int(cf)), int(extra.get("codec_left_context_frames", 25)) + + @staticmethod + def _resolve_model_dtype(vllm_config: VllmConfig) -> torch.dtype | None: + dtype = getattr(vllm_config.model_config, "dtype", None) + if isinstance(dtype, torch.dtype): + return dtype + if isinstance(dtype, str): + _aliases = {"bf16": "bfloat16", "fp16": "float16", "half": "float16", "fp32": "float32"} + return getattr(torch, _aliases.get(dtype.lower(), dtype.lower()), None) + return None + + def _ensure_vocoder_loaded(self) -> None: + if self._vocoder is not None: + return + + device = next(self.dit.parameters()).device + self._vocoder, self._vocoder_sample_rate = load_vocoder(self.model_dir, device, self.sample_rate) + if self._vocoder is None: + raise RuntimeError( + "GLM-TTS stage 1 could not load a vocoder. " + f"Expected either {self._hift_weights_path} or {self._vocos_jit_path}." + ) + + def embed_input_ids( + self, + input_ids: torch.Tensor, + **_: Any, + ) -> torch.Tensor: + """Return zero embeddings (DiT does not use token embeddings).""" + if input_ids.dim() != 1: + raise ValueError(f"GLM-TTS DiT input_ids must be 1D, got shape={tuple(input_ids.shape)}") + hidden = int(self.config.hidden_size) + return torch.zeros( + (input_ids.shape[0], hidden), + device=input_ids.device, + dtype=self._model_dtype or torch.float32, + ) + + def compute_logits(self, hidden_states: Any, sampling_metadata: Any = None) -> None: + """DiT does not produce logits for sampling.""" + return None + + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + **kwargs: Any, + ) -> OmniOutput: + """Run DiT flow-matching + vocoder on speech tokens.""" + # Evict stale streaming caches (aborted requests whose stream_finished + # was never sent). Entries not seen for 64 forward calls are dropped. + self._evict_stale_stream_caches() + + runtime_info = kwargs.get("model_intermediate_buffer") or [] + + seq_token_counts = kwargs.get("seq_token_counts") + flat_ids = input_ids.reshape(-1).to(dtype=torch.long) + if seq_token_counts is None and self.max_num_seqs > 1: + raise RuntimeError( + "GLM-TTS DiT stage requires seq_token_counts when max_num_seqs > 1; " + "otherwise concatenated speech tokens cannot be split per request." + ) + request_ids_list = split_request_ids(flat_ids, seq_token_counts) + + num_reqs = max(1, len(request_ids_list)) + sample_rate = torch.tensor(int(self.sample_rate), dtype=torch.int32) + empty_audio = torch.zeros((0,), dtype=torch.float32, device=input_ids.device) + audios: list[torch.Tensor] = [empty_audio] * num_reqs + srs: list[torch.Tensor] = [sample_rate] * num_reqs + + if not isinstance(runtime_info, list): + runtime_info = [] + + for idx, req_ids in enumerate(request_ids_list): + info = runtime_info[idx] if idx < len(runtime_info) and isinstance(runtime_info[idx], dict) else {} + req_id = as_str(nested_get(info, "meta", "req_id")) if info else None + if req_id is not None: + with self._stream_cache_lock: + self._stream_last_seen[req_id] = self._stream_forward_count + stream_finished_raw = nested_get(info, "meta", "stream_finished") if info else None + if stream_finished_raw is None and info: + stream_finished_raw = info.get("stream_finished") + stream_finished = as_bool(stream_finished_raw) if info else False + prompt_token = as_tensor(nested_get(info, "embed", "speech_token")) if info else None + if prompt_token is None and info: + prompt_token = as_tensor(info.get("prompt_token")) # backward compat + if prompt_token is None and info: + prompt_token = as_tensor(info.get("prompt_speech_token")) # backward compat + prompt_feat = as_tensor(nested_get(info, "embed", "speech_feat")) if info else None + if prompt_feat is None and info: + prompt_feat = as_tensor(info.get("prompt_feat")) # backward compat + embedding = as_tensor(nested_get(info, "embed", "embedding")) if info else None + if embedding is None and info: + embedding = as_tensor(info.get("embedding")) # backward compat + + speech_tokens_raw = info.get("speech_tokens") if info else None + has_meta = isinstance(info.get("meta"), dict) if info else False + uses_streaming = bool(info) and ( + has_meta + or "stream_finished" in info + or "token_offset" in info + or "left_context_size" in info + or "glm_tts" in info + or "kv_metadata" in info + ) + if uses_streaming and req_id is not None: + with self._stream_cache_lock: + state = self._stream_state_by_req.setdefault(req_id, _StreamRequestState()) + prompt_payload = state.prompt_payload + if prompt_token is not None: + prompt_payload["prompt_speech_token"] = prompt_token + elif "prompt_speech_token" in prompt_payload: + prompt_token = prompt_payload["prompt_speech_token"] + if prompt_feat is not None: + prompt_payload["prompt_feat"] = prompt_feat + elif "prompt_feat" in prompt_payload: + prompt_feat = prompt_payload["prompt_feat"] + if embedding is not None: + prompt_payload["embedding"] = embedding + elif "embedding" in prompt_payload: + embedding = prompt_payload["embedding"] + + fallback_token = req_ids.reshape(-1).to(device=input_ids.device, dtype=torch.long) + if uses_streaming: + token = valid_speech_tokens(None, device=input_ids.device, fallback=fallback_token) + else: + token = valid_speech_tokens( + speech_tokens_raw, + device=input_ids.device, + fallback=fallback_token, + ) + + if token.numel() == 0: + if stream_finished and req_id is not None: + with self._stream_cache_lock: + self._stream_state_by_req.pop(req_id, None) + self._stream_last_seen.pop(req_id, None) + audios[idx] = empty_audio + continue + + if uses_streaming: + private_info = _glm_tts_private_info(info) + token_offset_raw = nested_get(info, "meta", "left_context_size") + if token_offset_raw is None: + token_offset_raw = info.get("left_context_size") + if token_offset_raw is None: + token_offset_raw = nested_get(info, "meta", "token_offset") + if token_offset_raw is None: + token_offset_raw = info.get("token_offset") + token_offset = max( + 0, + as_int(token_offset_raw, 0), + ) + + chunk_sizes_raw = private_info.get("chunk_sizes_history") + if chunk_sizes_raw is None: + chunk_sizes_raw = nested_get(info, "meta", "chunk_sizes_history") + if chunk_sizes_raw is None: + chunk_sizes_raw = info.get("chunk_sizes_history") + block_pattern_raw = private_info.get("block_pattern") + if block_pattern_raw is None: + block_pattern_raw = nested_get(info, "meta", "block_pattern") + if block_pattern_raw is None: + block_pattern_raw = info.get("block_pattern") + crossfade_raw = private_info.get("crossfade_sec") + if crossfade_raw is None: + crossfade_raw = nested_get(info, "meta", "crossfade_sec") + if crossfade_raw is None: + crossfade_raw = info.get("crossfade_sec") + chunk_sizes_history = as_int_list(chunk_sizes_raw) + block_pattern = as_int_list(block_pattern_raw) + crossfade_sec = as_float(crossfade_raw, 0.1) + + audio = self._forward_full( + speech_tokens=token, + prompt_token=prompt_token, + prompt_feat=prompt_feat, + embedding=embedding, + token_offset=token_offset, + req_id=req_id, + stream_finished=stream_finished, + chunk_sizes_history=chunk_sizes_history, + block_pattern=block_pattern, + crossfade_sec=crossfade_sec, + ) + if stream_finished and req_id is not None: + with self._stream_cache_lock: + self._stream_state_by_req.pop(req_id, None) + self._stream_last_seen.pop(req_id, None) + else: + audio = self._forward_full( + speech_tokens=token, + prompt_token=prompt_token, + prompt_feat=prompt_feat, + embedding=embedding, + ) + + audios[idx] = audio.reshape(-1).to(dtype=torch.float32) + + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={"audio": audios, "sr": srs}, + ) + + @staticmethod + def _to_2d(val: torch.Tensor | np.ndarray | None, device: torch.device, dtype: torch.dtype) -> torch.Tensor | None: + """Convert numpy/tensor to 2D+ tensor on device, or return None.""" + if val is None: + return None + if isinstance(val, np.ndarray): + val = torch.from_numpy(val) + val = val.to(device=device, dtype=dtype) + if val.ndim == 1: + val = val.unsqueeze(0) + return val + + @staticmethod + def _validate_voice_clone(prompt_token, prompt_feat, embedding) -> None: + """Raise if voice clone payload is incomplete.""" + fields = {"prompt_speech_token": prompt_token, "prompt_feat": prompt_feat, "embedding": embedding} + present = [k for k, v in fields.items() if v is not None] + missing = [k for k, v in fields.items() if v is None] + if present and missing: + raise RuntimeError(f"GLM-TTS voice clone payload incomplete: present={present}, missing={missing}.") + + @torch.inference_mode() + def _forward_full( + self, + speech_tokens: torch.Tensor, + prompt_token: torch.Tensor | None = None, + prompt_feat: torch.Tensor | None = None, + embedding: torch.Tensor | None = None, + token_offset: int = 0, + req_id: str | None = None, + stream_finished: bool = False, + chunk_sizes_history: list[int] | None = None, + block_pattern: list[int] | None = None, + crossfade_sec: float = 0.1, + ) -> torch.Tensor: + """DiT flow-matching + vocoder pipeline. Returns audio waveform.""" + device = speech_tokens.device + self._validate_voice_clone(prompt_token, prompt_feat, embedding) + + if speech_tokens.ndim == 1: + speech_tokens = speech_tokens.unsqueeze(0) + generated_token_len = int(speech_tokens.shape[1]) + + has_voice_clone = prompt_token is not None or embedding is not None + prompt_token = self._to_2d(prompt_token, device, torch.long) + if prompt_token is not None: + prompt_token_len = prompt_token.shape[1] + speech_tokens = torch.cat([prompt_token, speech_tokens], dim=1) + else: + prompt_token_len = 0 + has_prompt_audio_context = prompt_token_len > 0 + + token_len = speech_tokens.shape[1] + if token_len == 0: + return torch.zeros(0, device=device) + + text_lens = torch.tensor([token_len], device=device, dtype=torch.long) + feat_len = max(1, int(token_len / self.input_frame_rate * self.mel_framerate)) + + padding_mask = ~make_pad_mask(torch.tensor([feat_len], device=device), max_len=feat_len) + mel_cond = torch.zeros([1, feat_len, self.mel_dim], device=device) + + prompt_feat_len = 0 + prompt_feat = self._to_2d(prompt_feat, device, mel_cond.dtype) + if prompt_feat is not None: + if prompt_feat.ndim == 2: + prompt_feat = prompt_feat.unsqueeze(0) + copy_len = min(prompt_feat.shape[1], feat_len) + mel_cond[:, :copy_len, :] = prompt_feat[:, -copy_len:, :] + prompt_feat_len = copy_len + has_prompt_audio_context = has_prompt_audio_context and prompt_feat_len > 0 + + embedding = self._to_2d(embedding, device, mel_cond.dtype) + spkr_embedding = None + if not self.remove_spkr_concat_condition: + if has_voice_clone and embedding is not None: + spk_embedding_normed = torch.nn.functional.normalize(embedding, dim=1) + spk_embedding = self.spk_embed_affine_layer(spk_embedding_normed) + spk_embedding_expanded = spk_embedding.unsqueeze(1).expand(-1, feat_len, -1) + condition = torch.cat([mel_cond, spk_embedding_expanded], dim=-1) + spkr_embedding = spk_embedding_normed + else: + spk_zeros = torch.zeros([1, feat_len, self.spk_embed_dim], device=device) + condition = torch.cat([mel_cond, spk_zeros], dim=-1) + else: + condition = mel_cond + + if self.spkr_emb_adaln: + if has_voice_clone and embedding is not None: + if spkr_embedding is None: + spkr_embedding = torch.nn.functional.normalize(embedding, dim=1) + else: + spkr_embedding = torch.zeros([1, 192], device=device, dtype=mel_cond.dtype) + + with self._stream_cache_lock: + _rs = self._stream_state_by_req.get(req_id) if req_id is not None else None + last_step_cache = _rs.diff_cache if _rs is not None else None + + dit_block_pattern: list[int] | None = None + is_streaming = req_id is not None and token_offset >= 0 + if is_streaming: + pattern_source = block_pattern or chunk_sizes_history + if pattern_source is not None and len(pattern_source) > 0: + if prompt_token_len > 0: + dit_block_pattern = [prompt_token_len] + pattern_source + else: + dit_block_pattern = list(pattern_source) + + lookahead_mel_len = 0 + if is_streaming and chunk_sizes_history is not None and len(chunk_sizes_history) > 0: + last_chunk_tokens = chunk_sizes_history[-1] + lookahead_tokens = last_chunk_tokens // 2 + lookahead_mel_len = int(lookahead_tokens / self.input_frame_rate * self.mel_framerate) + + # Compute cache_len: only cache the stable region for streaming + stream_cache_len: int | None = None + if req_id is not None: + stable_mel_len = int(generated_token_len / self.input_frame_rate * self.mel_framerate) + override_mel = max(0, stable_mel_len - lookahead_mel_len) + stream_cache_len = min(feat_len, prompt_feat_len + override_mel + 1) + + mel, current_step_cache = self._do_sample( + speech_tokens=speech_tokens, + mel_cond=mel_cond, + condition=condition, + padding_mask=padding_mask, + spkr_embedding=spkr_embedding, + n_timesteps=self.n_timesteps, + last_step_cache=last_step_cache, + block_pattern=dit_block_pattern, + cache_len=stream_cache_len, + text_lens=text_lens, + ) + + if req_id is not None: + current_step_cache["override_len"] = int(stream_cache_len or 0) + with self._stream_cache_lock: + rs = self._stream_state_by_req.setdefault(req_id, _StreamRequestState()) + rs.diff_cache = current_step_cache + + if has_prompt_audio_context: + crop_len = min(prompt_feat_len, max(0, int(mel.shape[1]) - 1)) + if crop_len > 0: + mel = mel[:, crop_len:, :] + + self._ensure_vocoder_loaded() + + audio = mel_to_audio(self._vocoder, mel) + + effective_sr = self._vocoder_sample_rate or self.sample_rate + if effective_sr != self.sample_rate and audio is not None and audio.numel() > 0: + try: + import torchaudio + + audio = torchaudio.functional.resample(audio, effective_sr, self.sample_rate) + except Exception as e: + logger.warning( + "Failed to resample audio from %d to %d: %s", + effective_sr, + self.sample_rate, + e, + ) + + audio = audio.reshape(-1) + if is_streaming and req_id is not None: + audio = self._slice_streaming_audio( + audio, + req_id=req_id, + stream_finished=stream_finished, + lookahead_mel_len=lookahead_mel_len, + crossfade_sec=crossfade_sec, + ) + + return audio + + def _slice_streaming_audio( + self, + audio: torch.Tensor, + *, + req_id: str, + stream_finished: bool, + lookahead_mel_len: int, + crossfade_sec: float, + ) -> torch.Tensor: + """Slice cumulative streaming audio using the official wav pointer. + + Official GLM-TTS runs the flow/vocoder on the cumulative token prefix + every chunk, then drops the waveform samples that were already emitted. + Non-final chunks additionally keep a lookahead plus fade tail for the + next chunk; the final full-prefix pass flushes that tail. + """ + look_back_len = max(0, int(lookahead_mel_len * self.sample_rate / self.mel_framerate)) + fade_len = max(0, int(crossfade_sec * self.sample_rate)) + overlap_len = look_back_len + fade_len + + with self._stream_cache_lock: + _rs = self._stream_state_by_req.get(req_id) + wav_pointer = _rs.wav_pointer if _rs is not None else 0 + fade_tail = _rs.fade_tail if _rs is not None else None + + current = audio[min(wav_pointer, int(audio.numel())) :].clone() + if fade_tail is not None and fade_tail.numel() > 0 and current.numel() > 0: + blend_len = min(int(fade_tail.numel()), int(current.numel())) + fade_in = torch.linspace(0.0, 1.0, blend_len, dtype=current.dtype, device=current.device) + fade_out = 1.0 - fade_in + current[:blend_len] = ( + fade_tail[:blend_len].to(device=current.device, dtype=current.dtype) * fade_out + + current[:blend_len] * fade_in + ) + + if stream_finished: + with self._stream_cache_lock: + _rs = self._stream_state_by_req.get(req_id) + if _rs is not None: + _rs.wav_pointer = 0 + _rs.fade_tail = None + return current + + if overlap_len <= 0 or current.numel() <= overlap_len: + emit = torch.zeros(0, dtype=current.dtype, device=current.device) + next_tail = current.detach().clone() + else: + emit = current[:-overlap_len] + tail_end = -look_back_len if look_back_len > 0 else None + next_tail = current[-overlap_len:tail_end].detach().clone() + + with self._stream_cache_lock: + rs = self._stream_state_by_req.setdefault(req_id, _StreamRequestState()) + rs.wav_pointer = wav_pointer + int(emit.numel()) + rs.fade_tail = next_tail + return emit + + # Maximum number of forward calls before a cache entry is considered stale. + _STALE_THRESHOLD = 64 + + def _evict_stale_stream_caches(self) -> None: + """Evict streaming cache entries for requests not seen recently. + + This is a self-contained fallback: if ``stream_finished`` is never + sent (e.g. client disconnect), the entry would otherwise leak forever. + Called at the top of every ``forward`` so no changes to the shared + model runner are needed. + """ + self._stream_forward_count += 1 + # Only check every 16 forward calls to amortize the lock overhead. + if self._stream_forward_count % 16 != 0: + return + with self._stream_cache_lock: + if not self._stream_last_seen: + return + stale_ids = [ + rid + for rid, last_seen in self._stream_last_seen.items() + if self._stream_forward_count - last_seen > self._STALE_THRESHOLD + ] + for rid in stale_ids: + self._stream_state_by_req.pop(rid, None) + self._stream_last_seen.pop(rid, None) + + def _do_sample( + self, + speech_tokens: torch.Tensor, + mel_cond: torch.Tensor, + condition: torch.Tensor, + padding_mask: torch.Tensor, + spkr_embedding: torch.Tensor | None, + n_timesteps: int, + last_step_cache: dict[int | str, Any] | None = None, + block_pattern: list[int] | None = None, + cache_len: int | None = None, + text_lens: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, dict[int | str, Any]]: + """Euler ODE sampling for flow matching.""" + # CUDA graph path: the captured graph does NOT pass is_causal / + # block_pattern to the DiT, so it always uses bidirectional attention. + # That is correct for non-streaming (full-sequence) inference but + # wrong for streaming where block-causal attention is required. + # Skip the graph when block_pattern is set so we fall through to the + # eager path that correctly forwards these arguments. + use_cudagraph = block_pattern is None + graph_result = ( + self._try_cudagraph_sample( + speech_tokens=speech_tokens, + mel_cond=mel_cond, + condition=condition, + padding_mask=padding_mask, + spkr_embedding=spkr_embedding, + n_timesteps=n_timesteps, + last_step_cache=last_step_cache, + cache_len=cache_len, + text_lens=text_lens, + ) + if use_cudagraph + else None + ) + if graph_result is not None: + return graph_result + + device = speech_tokens.device + # Always accumulate the Euler ODE state in float32 regardless of the + # DiT model dtype. Half-precision rounding errors across 10 steps (×2 + # CFG passes each) manifest as audible hiss on H20/H100. + x = torch.randn(mel_cond.shape, device=device, dtype=torch.float32) + # Cast conditioning tensors to float32 for the ODE loop. + condition_f32 = condition.to(torch.float32) + spkr_embedding_f32 = spkr_embedding.to(torch.float32) if spkr_embedding is not None else None + # Pre-cast invariant conditioning tensors to DiT dtype once (avoids + # repeated .to() calls inside the inner Euler loop). + condition_dit = condition_f32.to(mel_cond.dtype) + spkr_embedding_dit = spkr_embedding_f32.to(mel_cond.dtype) if spkr_embedding_f32 is not None else None + current_step_cache: dict[int | str, Any] = {} + effective_cache_len = min(int(cache_len or 0), int(mel_cond.shape[1])) + should_cache_steps = effective_cache_len > 0 + t_span = torch.linspace(0, 1, n_timesteps + 1, device=device, dtype=torch.float32) + if self.t_scheduler == "cosine": + t_span = 1 - torch.cos(t_span * 0.5 * torch.pi) + + for step in range(n_timesteps): + cache_step = step + 1 + if last_step_cache is not None and cache_step in last_step_cache: + x_cache = last_step_cache[cache_step].get("x") + if isinstance(x_cache, torch.Tensor): + override_len = int(last_step_cache.get("override_len", x_cache.shape[1])) + safe_len = min(int(x.shape[1]), int(x_cache.shape[1]), max(0, override_len)) + if safe_len > 0: + x[:, :safe_len, :] = x_cache[:, :safe_len, :].to(device=device, dtype=torch.float32) + if should_cache_steps: + current_step_cache[cache_step] = {"x": x[:, :effective_cache_len, :].detach().clone()} + + t_current = t_span[step] + dt = t_span[step + 1] - t_current + + use_causal = block_pattern is not None + x_dit = x.to(mel_cond.dtype) + + if self.inference_cfg_rate > 0: + # Batch conditional + unconditional into a single forward + middle_b = x_dit.expand(2, -1, -1) + condition_b = torch.cat([condition_dit, torch.zeros_like(condition_dit)], dim=0) + if self.speech_token_cfg: + text_b = torch.cat([speech_tokens, torch.zeros_like(speech_tokens)], dim=0) + else: + text_b = speech_tokens.expand(2, -1) + time_b = t_current.unsqueeze(0).expand(2) + padding_b = padding_mask.expand(2, -1) + if spkr_embedding_dit is not None and self.spkr_emb_adaln: + spkr_b = torch.cat([spkr_embedding_dit, torch.zeros_like(spkr_embedding_dit)], dim=0) + else: + spkr_b = None + text_lens_b = text_lens.expand(2) if text_lens is not None else None + pred_b = self.dit( + middle_point=middle_b, + condition=condition_b, + text=text_b, + time_step=time_b, + padding_mask=padding_b, + spkr_emb=spkr_b, + is_causal=use_causal, + block_pattern=block_pattern, + text_lens=text_lens_b, + ).to(torch.float32) + dphi_dt, cfg_dphi_dt = pred_b.chunk(2, dim=0) + dphi_dt = (1.0 + self.inference_cfg_rate) * dphi_dt - self.inference_cfg_rate * cfg_dphi_dt + else: + dphi_dt = self.dit( + middle_point=x_dit, + condition=condition_dit, + text=speech_tokens, + time_step=t_current.unsqueeze(0), + padding_mask=padding_mask, + spkr_emb=spkr_embedding_dit if spkr_embedding_dit is not None and self.spkr_emb_adaln else None, + is_causal=use_causal, + block_pattern=block_pattern, + text_lens=text_lens, + ).to(torch.float32) + + x = x + dt * dphi_dt + + return x.to(mel_cond.dtype), current_step_cache + + def _try_cudagraph_sample( + self, + *, + speech_tokens: torch.Tensor, + mel_cond: torch.Tensor, + condition: torch.Tensor, + padding_mask: torch.Tensor, + spkr_embedding: torch.Tensor | None, + n_timesteps: int, + last_step_cache: dict[int | str, Any] | None, + cache_len: int | None = None, + text_lens: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, dict[int | str, Any]] | None: + if not self._use_dit_cuda_graphs or mel_cond.device.type != "cuda": + return None + if self._dit_cudagraph is None: + condition_dim = self.mel_dim if self.remove_spkr_concat_condition else self.mel_dim + self.spk_embed_dim + self._dit_cudagraph = CUDAGraphGLMTTSDiTWrapper( + self.dit, + mel_dim=self.mel_dim, + condition_dim=condition_dim, + input_frame_rate=self.input_frame_rate, + mel_framerate=self.mel_framerate, + inference_cfg_rate=self.inference_cfg_rate, + speech_token_cfg=self.speech_token_cfg, + spkr_emb_adaln=self.spkr_emb_adaln, + t_scheduler=self.t_scheduler, + enabled=True, + ) + try: + self._dit_cudagraph.warmup( + device=mel_cond.device, + dtype=mel_cond.dtype, + n_timesteps=n_timesteps, + codec_chunk_frames=self._codec_chunk_frames, + codec_left_context_frames=self._codec_left_context_frames, + ) + except Exception: + logger.warning("Disabling GLM-TTS DiT CUDA graphs after warmup failure", exc_info=True) + self._dit_cudagraph.enabled = False + return None + + try: + return self._dit_cudagraph.sample( + speech_tokens=speech_tokens, + mel_cond=mel_cond, + condition=condition, + padding_mask=padding_mask, + spkr_embedding=spkr_embedding, + n_timesteps=n_timesteps, + last_step_cache=last_step_cache, + cache_len=cache_len, + text_lens=text_lens, + ) + except Exception: + logger.warning("GLM-TTS DiT CUDA graph replay failed; falling back to eager", exc_info=True) + return None + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + """Load DiT + vocoder weights from {model_dir}/flow/flow.pt.""" + for _ in weights: + pass + + weight_dict: dict[str, torch.Tensor] = {} + if not os.path.exists(self._flow_weights_path): + raise FileNotFoundError(f"GLM-TTS flow weights not found at {self._flow_weights_path}") + + logger.info("Loading GLM-TTS flow weights from %s", self._flow_weights_path) + checkpoint = torch.load(self._flow_weights_path, map_location="cpu", weights_only=True) + if "model" in checkpoint: + weight_dict = checkpoint["model"] + else: + weight_dict = checkpoint + + dit_state = { + (name.removeprefix("estimator.") if name.startswith("estimator.") else name.removeprefix("dit.")): tensor + for name, tensor in weight_dict.items() + if (name.startswith("estimator.") or name.startswith("dit.")) + and "inv_freq" not in name + and "rotary_embed" not in name + } + if not dit_state: + raise RuntimeError( + "GLM-TTS flow checkpoint did not contain any DiT weights under 'estimator.*' or 'dit.*'." + ) + missing, unexpected = self.dit.load_state_dict(dit_state, strict=False) + missing = [k for k in missing if "inv_freq" not in k] + if missing: + logger.warning("Missing DiT weights: %s", missing[:10]) + if unexpected: + logger.warning("Unexpected DiT weights: %s", unexpected[:10]) + + loaded_spk: set[str] = set() + if hasattr(self, "spk_embed_affine_layer"): + for suffix in ("weight", "bias"): + key = f"spk_embed_affine_layer.{suffix}" + if key in weight_dict: + param = getattr(self.spk_embed_affine_layer, suffix) + param.data.copy_(weight_dict[key].to(device=param.device, dtype=param.dtype)) + loaded_spk.add(key) + self.spk_embed_affine_layer.eval() + self.dit.eval() + self._ensure_vocoder_loaded() + + # Return both prefixed and unprefixed param names for nested module compat + loaded_params: set[str] = set() + for k in dit_state: + loaded_params.update((f"dit.{k}", f"_dit_gen.dit.{k}")) + for k in loaded_spk: + loaded_params.update((k, f"_dit_gen.{k}")) + logger.info("Loaded GLMTTSDiTForGeneration: %d params", len(loaded_params)) + return loaded_params + + +class CUDAGraphGLMTTSDiTWrapper: + """Capture GLM-TTS DiT Euler sampling for fixed mel-frame buckets. + + The wrapper owns static buffers for a single-request DiT stage. Dynamic + requests are right-padded to the nearest captured mel-frame bucket and + trimmed back to the actual mel length after graph replay. + """ + + def __init__( + self, + dit: torch.nn.Module, + *, + mel_dim: int, + condition_dim: int, + input_frame_rate: float, + mel_framerate: float, + inference_cfg_rate: float, + speech_token_cfg: bool, + spkr_emb_adaln: bool, + t_scheduler: str, + enabled: bool = True, + capture_sizes: list[int] | None = None, + ) -> None: + self.dit = dit + self.mel_dim = int(mel_dim) + self.condition_dim = int(condition_dim) + self.input_frame_rate = float(input_frame_rate) + self.mel_framerate = float(mel_framerate) + self.inference_cfg_rate = float(inference_cfg_rate) + self.speech_token_cfg = bool(speech_token_cfg) + self.spkr_emb_adaln = bool(spkr_emb_adaln) + self.t_scheduler = str(t_scheduler) + self.enabled = bool(enabled) + self.capture_sizes = sorted(set(capture_sizes or [])) + + self.graphs: dict[int, CUDAGraph] = {} + self.static_x: dict[int, torch.Tensor] = {} + self.static_condition: dict[int, torch.Tensor] = {} + self.static_text: dict[int, torch.Tensor] = {} + self.static_text_lens: dict[int, torch.Tensor] = {} + self.static_padding_mask: dict[int, torch.Tensor] = {} + self.static_spkr_emb: dict[int, torch.Tensor] = {} + self.static_output: dict[int, torch.Tensor] = {} + self.static_step_cache: dict[int, list[torch.Tensor]] = {} + + self._warmed_up = False + self._n_timesteps: int | None = None + + @staticmethod + def compute_capture_sizes( + *, + codec_chunk_frames: int = 25, + codec_left_context_frames: int = 25, + input_frame_rate: float = 25.0, + mel_framerate: float = 50.0, + max_bucket: int = 1024, + ) -> list[int]: + sizes: set[int] = set() + + def token_to_mel(tokens: int) -> int: + return max(1, int(math.ceil(float(tokens) / input_frame_rate * mel_framerate))) + + if codec_chunk_frames > 0: + sizes.add(token_to_mel(codec_chunk_frames)) + if codec_left_context_frames > 0: + sizes.add(token_to_mel(codec_chunk_frames + codec_left_context_frames)) + + for size in (32, 64, 128, 256, 512, 1024): + if size <= max_bucket: + sizes.add(size) + return sorted(sizes) + + def _get_padded_size(self, actual_feat_len: int) -> int | None: + for size in self.capture_sizes: + if actual_feat_len <= size: + return size + return None + + def _token_bucket_len(self, bucket_feat_len: int) -> int: + return max(1, int(math.ceil(float(bucket_feat_len) * self.input_frame_rate / self.mel_framerate))) + + def warmup( + self, + *, + device: torch.device, + dtype: torch.dtype, + n_timesteps: int, + codec_chunk_frames: int = 25, + codec_left_context_frames: int = 25, + ) -> None: + if not self.enabled or self._warmed_up or device.type != "cuda" or torch.cuda.is_current_stream_capturing(): + return + self._n_timesteps = int(n_timesteps) + if not self.capture_sizes: + self.capture_sizes = self.compute_capture_sizes( + codec_chunk_frames=codec_chunk_frames, + codec_left_context_frames=codec_left_context_frames, + input_frame_rate=self.input_frame_rate, + mel_framerate=self.mel_framerate, + ) + + self.dit.eval() + logger.info("Starting GLM-TTS DiT CUDA graph warmup for sizes: %s", self.capture_sizes) + for size in self.capture_sizes: + try: + self._allocate_static_buffers(size, device, dtype) + self._capture(size, device) + logger.info("Captured GLM-TTS DiT CUDA graph for mel frames=%d", size) + except Exception: + logger.warning("Failed to capture GLM-TTS DiT CUDA graph for size=%d", size, exc_info=True) + self._warmed_up = True + + def _allocate_static_buffers(self, size: int, device: torch.device, dtype: torch.dtype) -> None: + token_len = self._token_bucket_len(size) + self.static_x[size] = torch.zeros(1, size, self.mel_dim, device=device, dtype=dtype) + self.static_condition[size] = torch.zeros(1, size, self.condition_dim, device=device, dtype=dtype) + self.static_text[size] = torch.zeros(1, token_len, device=device, dtype=torch.long) + self.static_text_lens[size] = torch.full((1,), token_len, device=device, dtype=torch.long) + self.static_padding_mask[size] = torch.zeros(1, size, device=device, dtype=torch.bool) + self.static_spkr_emb[size] = torch.zeros(1, 192, device=device, dtype=dtype) + self.static_output[size] = torch.zeros(1, size, self.mel_dim, device=device, dtype=dtype) + self.static_step_cache[size] = [ + torch.zeros(1, size, self.mel_dim, device=device, dtype=dtype) for _ in range(int(self._n_timesteps or 0)) + ] + + def _time_schedule(self, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + n_timesteps = int(self._n_timesteps or 0) + t_span = torch.linspace(0, 1, n_timesteps + 1, device=device, dtype=dtype) + if self.t_scheduler == "cosine": + t_span = 1 - torch.cos(t_span * 0.5 * torch.pi) + return t_span + + def _run_static(self, size: int) -> torch.Tensor: + x = self.static_x[size] + condition = self.static_condition[size] + text = self.static_text[size] + text_lens = self.static_text_lens[size] + padding_mask = self.static_padding_mask[size] + spkr_emb = self.static_spkr_emb[size] if self.spkr_emb_adaln else None + t_span = self._time_schedule(x.device, x.dtype) + + for step in range(int(self._n_timesteps or 0)): + self.static_step_cache[size][step].copy_(x) + t_current = t_span[step] + dt = t_span[step + 1] - t_current + if self.inference_cfg_rate > 0: + middle_b = x.expand(2, -1, -1) + condition_b = torch.cat([condition, torch.zeros_like(condition)], dim=0) + if self.speech_token_cfg: + text_b = torch.cat([text, torch.zeros_like(text)], dim=0) + else: + text_b = text.expand(2, -1) + time_b = t_current.unsqueeze(0).expand(2) + padding_b = padding_mask.expand(2, -1) + text_lens_b = text_lens.expand(2) + if spkr_emb is not None: + spkr_b = torch.cat([spkr_emb, torch.zeros_like(spkr_emb)], dim=0) + else: + spkr_b = None + pred_b = self.dit( + middle_point=middle_b, + condition=condition_b, + text=text_b, + text_lens=text_lens_b, + time_step=time_b, + padding_mask=padding_b, + spkr_emb=spkr_b, + ) + dphi_dt, cfg_dphi_dt = pred_b.chunk(2, dim=0) + dphi_dt = (1.0 + self.inference_cfg_rate) * dphi_dt - self.inference_cfg_rate * cfg_dphi_dt + else: + dphi_dt = self.dit( + middle_point=x, + condition=condition, + text=text, + text_lens=text_lens, + time_step=t_current.unsqueeze(0), + padding_mask=padding_mask, + spkr_emb=spkr_emb, + ) + x = x + dt * dphi_dt + self.static_output[size].copy_(x) + return self.static_output[size] + + def _capture(self, size: int, device: torch.device) -> None: + with torch.no_grad(): + self.static_x[size].normal_() + _ = self._run_static(size) + torch.accelerator.synchronize(device) + + graph = CUDAGraph() + with torch.no_grad(): + with torch.cuda.graph(graph, pool=current_platform.get_global_graph_pool()): + self._run_static(size) + self.graphs[size] = graph + + def sample( + self, + *, + speech_tokens: torch.Tensor, + mel_cond: torch.Tensor, + condition: torch.Tensor, + padding_mask: torch.Tensor, + spkr_embedding: torch.Tensor | None, + n_timesteps: int, + last_step_cache: dict[int | str, Any] | None = None, + cache_len: int | None = None, + text_lens: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, dict[int | str, Any]] | None: + if ( + not self.enabled + or not self._warmed_up + or int(n_timesteps) != int(self._n_timesteps or -1) + or speech_tokens.shape[0] != 1 + or mel_cond.shape[0] != 1 + or mel_cond.device.type != "cuda" + or torch.cuda.is_current_stream_capturing() + ): + return None + + actual_feat_len = int(mel_cond.shape[1]) + size = self._get_padded_size(actual_feat_len) + if size is None or size not in self.graphs: + return None + + token_bucket_len = int(self.static_text[size].shape[1]) + if int(speech_tokens.shape[1]) > token_bucket_len: + return None + + self.static_x[size].normal_() + self.static_condition[size].zero_() + self.static_text[size].zero_() + self.static_padding_mask[size].zero_() + self.static_spkr_emb[size].zero_() + + # Warm-start: when streaming, pre-fill the stable region of the + # initial noise from the step-1 cache of the previous chunk. + # The graph's Euler loop then evolves from this warm-started state. + # New (lookahead) region keeps random noise — no per-step overlay. + if last_step_cache is not None and 1 in last_step_cache: + cached_x = last_step_cache[1]["x"] + override_len = int(last_step_cache.get("override_len", cached_x.shape[1])) + safe = min(size, int(cached_x.shape[1]), max(0, override_len)) + if safe > 0: + self.static_x[size][:, :safe, :].copy_(cached_x[:, :safe, :].to(self.static_x[size])) + + self.static_condition[size][:, :actual_feat_len, : condition.shape[-1]] = condition + self.static_text[size][:, : speech_tokens.shape[1]] = speech_tokens + actual_text_len = int(speech_tokens.shape[1]) + if text_lens is not None: + self.static_text_lens[size].copy_(text_lens) + else: + self.static_text_lens[size].fill_(actual_text_len) + self.static_padding_mask[size][:, :actual_feat_len] = padding_mask + if spkr_embedding is not None: + self.static_spkr_emb[size].copy_(spkr_embedding.to(self.static_spkr_emb[size])) + + self.graphs[size].replay() + mel = self.static_output[size][:, :actual_feat_len, :].clone() + current_step_cache: dict[int | str, Any] = {} + cache_len = min(int(cache_len or 0), actual_feat_len) + if cache_len > 0: + for idx, cache in enumerate(self.static_step_cache[size], start=1): + current_step_cache[idx] = {"x": cache[:, :cache_len, :].detach().clone()} + return mel, current_step_cache diff --git a/vllm_omni/model_executor/models/glm_tts/pipeline.py b/vllm_omni/model_executor/models/glm_tts/pipeline.py new file mode 100644 index 000000000..f016a993e --- /dev/null +++ b/vllm_omni/model_executor/models/glm_tts/pipeline.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GLM-TTS pipeline: Stage 0 (AR) → Stage 1 (DiT).""" + +from vllm_omni.config.stage_config import ( + PipelineConfig, + StageExecutionType, + StagePipelineConfig, +) + +_PROC = "vllm_omni.model_executor.stage_input_processors.glm_tts" + +GLM_TTS_PIPELINE = PipelineConfig( + model_type="glm_tts", + model_arch="GLMTTSForConditionalGeneration", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="glm_tts", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + owns_tokenizer=True, + engine_output_type="latent", + async_chunk_process_next_stage_input_func=(f"{_PROC}.ar_to_dit_async_chunk"), + sampling_constraints={ + # GLM-TTS uses 👂 (token string "👂", ID 59253) as + # end-of-audio marker. The ID is resolved dynamically by + # GLMTTSForConditionalGeneration.__init__ from the tokenizer and + # validated at runtime against this hardcoded value. If the + # upstream checkpoint changes the mapping, the model will log a + # warning so the constant here can be updated. + "stop_token_ids": [59253], + }, + ), + StagePipelineConfig( + stage_id=1, + model_stage="glm_tts_dit", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(0,), + final_output=True, + final_output_type="audio", + engine_output_type="latent", + sync_process_input_func=f"{_PROC}.ar_to_dit", + ), + ), +) diff --git a/vllm_omni/model_executor/models/glm_tts/text_frontend.py b/vllm_omni/model_executor/models/glm_tts/text_frontend.py new file mode 100644 index 000000000..8cfc0e5c8 --- /dev/null +++ b/vllm_omni/model_executor/models/glm_tts/text_frontend.py @@ -0,0 +1,393 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Lightweight text frontend aligned with the official GLM-TTS preprocessing.""" + +from __future__ import annotations + +import json +import os +import re + +try: + import emoji +except ImportError: + emoji = None # type: ignore[assignment] + +try: + import inflect +except ImportError: + inflect = None # type: ignore[assignment] + +try: + from tn.chinese.normalizer import Normalizer as ZhNormalizer +except ImportError: + ZhNormalizer = None + +try: + from tn.english.normalizer import Normalizer as EnNormalizer +except ImportError: + EnNormalizer = None + + +CHINESE_CHAR_PATTERN = re.compile(r"[\u4e00-\u9fff]+") +PUNCTUATION_CHARS = r"。?!;:、.?!;:,," + + +def contains_chinese(text: str) -> bool: + return bool(CHINESE_CHAR_PATTERN.search(text)) + + +def markdown_norm(markdown_text: str) -> str: + markdown_text = re.sub(r"^(\d+)\. ", r"\1。", markdown_text) + return markdown_text.replace("\\n", "\n") + + +def multi_line_process(plain_text: str) -> str: + lines: list[str] = [] + for line in plain_text.split("\n"): + line = line.strip() + if not line: + continue + if line[-1] not in {".", "!", "?", ";", ":", ":", "。", "!", "?", ";", ","}: + line = f"{line}{'。' if contains_chinese(line) else '. '}" + lines.append(line) + return "".join(lines) if contains_chinese("".join(lines)) else " ".join(lines) + + +def remove_bracket(text: str, lang: str = "zh") -> str: + brackets_to_remove = [ + ("(", ")"), + ("(", ")"), + ("【", "】"), + ("「", "」"), + ("`", "`"), + ("《", "》"), + ("『", "』"), + ("{", "}"), + ("[", "]"), + ] + if lang != "en": + brackets_to_remove.append(("‘", "’")) + for left, right in brackets_to_remove: + text = text.replace(left, "").replace(right, "") + return text + + +def number_to_chinese(number: int | float | str) -> str: + units = ["", "十", "百", "千", "万", "十万", "百万", "千万", "亿", "十亿", "百亿", "千亿"] + nums = "零一二三四五六七八九" + if isinstance(number, (float, str)): + str_num = str(float(number)) + if "." in str_num: + int_part, decimal_part = str_num.split(".") + if decimal_part.strip("0") == "": + return number_to_chinese(int(int_part)) + chinese_int = number_to_chinese(int(int_part)) if int(int_part) != 0 else "零" + chinese_decimal = "".join(nums[int(d)] for d in decimal_part) + return f"{chinese_int}点{chinese_decimal}" + + number = int(number) + if number == 0: + return "零" + if number < 0: + return "负" + number_to_chinese(abs(number)) + + result: list[str] = [] + unit_position = 0 + zero_flag = False + last_unit = "" + while number > 0: + num = number % 10 + current_unit = units[unit_position] if unit_position < len(units) else "" + if num == 0: + if not zero_flag and result and last_unit not in ["万", "亿"]: + result.append(nums[num]) + zero_flag = True + else: + if current_unit: + result.append(current_unit) + result.append(nums[num]) + zero_flag = False + last_unit = current_unit + unit_position += 1 + number //= 10 + + result.reverse() + text = "".join(result) + text = re.sub(r"^一十", "十", text) + text = re.sub(r"零+", "零", text) + text = re.sub(r"零$", "", text) + return text + + +def tn_scientific_notation(text: str) -> str: + pattern = re.compile(r"(.*?)(-?\d+(?:\.\d+)?)[*x×](\d+(?:\.\d+)?)\^(-?\d+(?:\.\d+)?)(.*?)$") + match = pattern.match(text) + if not match: + return text + prefix, base, multiplier, exponent, suffix = match.groups() + description = f"{number_to_chinese(base)}乘{number_to_chinese(multiplier)}的{number_to_chinese(exponent)}次方" + return f"{prefix}{description}{suffix}" + + +def replace_asterisk_with_multiply(text: str, lang: str) -> str: + if lang == "en": + replace_name = "multiply" + replace_number = "0-9" + else: + replace_name = "乘" + replace_number = "一二三四五六七八九十百千万亿" + + rule0 = rf"(?<=[{replace_number}a-zA-Z])\s*\*\s*(?=[{replace_number}a-zA-Z])" + rule1 = rf"(?<=[{replace_number}a-zA-Z]\))\s*\*\s*(?=[{replace_number}a-zA-Z])" + rule2 = rf"(?<=[{replace_number}a-zA-Z])\s*\*\s*(?=\([{replace_number}a-zA-Z])" + rule3 = rf"(?<=[{replace_number}a-zA-Z]\))\s*\*\s*(?=\([{replace_number}a-zA-Z])" + return re.sub(f"{rule0}|{rule1}|{rule2}|{rule3}", replace_name, text).replace("*", "") + + +def special_replace(text: str) -> str: + text = text.replace("\\", "") + text = f" {text} " + text = re.sub(r"(?<=\W)额(?=\W)", "呃", text) + text = re.sub(r"[~~]+", "。", text) + text = replace_asterisk_with_multiply(text, "zh") + return text.strip() + + +def spell_out_number(text: str, inflect_parser: object | None) -> str: + if inflect_parser is None: + return text + new_text: list[str] = [] + start = None + for idx, char in enumerate(text): + if not char.isdigit(): + if start is not None: + new_text.append(inflect_parser.number_to_words(text[start:idx])) + start = None + new_text.append(char) + elif start is None: + start = idx + if start is not None and start < len(text): + new_text.append(inflect_parser.number_to_words(text[start:])) + return "".join(new_text) + + +def replace_space(text: str) -> str: + alphanumeric_pattern = r"[a-zA-Z0-9]" + punctuation_pattern = r"[.,!?;:]" + text = re.sub(r" +", " ", text) + result = "" + idx = 0 + while idx < len(text): + current_char = text[idx] + if current_char != " ": + result += current_char + idx += 1 + continue + prev_char = text[idx - 1] if idx > 0 else "" + next_char = text[idx + 1] if idx + 1 < len(text) else "" + if re.match(alphanumeric_pattern, prev_char) and re.match(alphanumeric_pattern, next_char): + result += " " + elif re.match(punctuation_pattern, prev_char) and re.match(alphanumeric_pattern, next_char): + result += " " + idx += 1 + return result + + +def normalize_punctuation(text: str, punctuation_chars: str) -> str: + text = replace_space(text) + text = re.sub(rf"([{punctuation_chars}])\1+", r"\1", text) + text = re.sub(rf"([{punctuation_chars}])(?=[{punctuation_chars}])", "", text) + text = text.replace("#", "") + text = text.replace("!", "。") + text = text.replace("!", ".") + return text + + +def ensure_proper_ending(text: str) -> str: + if not text: + return text + if text[-1] in "??": + return text + if text[-1] in PUNCTUATION_CHARS: + return f"{text[:-1]}{'。' if contains_chinese(text) else '.'}" + return f"{text}{'。' if contains_chinese(text) else '.'}" + + +class GLMTTSTextFrontend: + """Subset of the official GLM-TTS text frontend used by the adapter.""" + + def __init__(self) -> None: + self.inflect_parser = inflect.engine() if inflect is not None else None + self.zh_tn_model = ( + ZhNormalizer( + remove_erhua=False, + full_to_half=True, + remove_interjections=False, + overwrite_cache=True, + ) + if ZhNormalizer is not None + else None + ) + self.en_tn_model = EnNormalizer() if EnNormalizer is not None else None + self._custom_replacements = self._load_custom_replacements() + + @staticmethod + def _load_custom_replacements() -> list[tuple[str, str]]: + custom_replace_path = os.path.join(os.path.dirname(__file__), "configs", "custom_replace.jsonl") + if not os.path.exists(custom_replace_path): + return [] + replacements: list[tuple[str, str]] = [] + with open(custom_replace_path, encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + item = json.loads(line) + replacements.append((item["origin"], item["new"])) + return replacements + + def pre_replace(self, sentence: str) -> str: + sentence = tn_scientific_notation(sentence) + sentence = re.sub(r"(?<=\d)\s*-\s*(?=\d)", "减", sentence) + sentence = sentence.replace("-", "") + sentence = re.sub(r"咯([" + re.escape(PUNCTUATION_CHARS) + r"])", r"喽\1", sentence) + for origin, new in self._custom_replacements: + sentence = sentence.replace(origin, new) + return sentence + + def post_replace(self, sentence: str) -> str: + sentence = remove_bracket(sentence) + sentence = sentence.replace(" - ", ",") + sentence = sentence.replace("——", ",") + sentence = re.sub(r"[,::;;、]+", ",", sentence) + sentence = re.sub(r"[.…]+", "。", sentence) + sentence = re.sub(r"[_·]+", "", sentence) + sentence = re.sub(r"""['"‘’“”|]+""", "", sentence) + + symbol_map = { + "†": ",", + "²": "平方", + "³": "立方", + "/": "每", + "~": "到", + "~": "到", + "①": "一", + "②": "二", + "③": "三", + "④": "四", + "⑤": "五", + "⑥": "六", + "⑦": "七", + "⑧": "八", + "⑨": "九", + "⑩": "十", + "α": "阿尔法", + "β": "贝塔", + "γ": "伽玛", + "Γ": "伽玛", + "δ": "德尔塔", + "Δ": "德尔塔", + "△": "德尔塔", + "ε": "艾普西龙", + "ζ": "捷塔", + "η": "依塔", + "θ": "西塔", + "Θ": "西塔", + "ι": "艾欧塔", + "κ": "喀帕", + "λ": "拉姆达", + "Λ": "拉姆达", + "μ": "缪", + "ν": "拗", + "ξ": "克西", + "Ξ": "克西", + "ο": "欧米克伦", + "π": "派", + "Π": "派", + "ρ": "肉", + "ς": "西格玛", + "Σ": "西格玛", + "σ": "西格玛", + "τ": "套", + "υ": "宇普西龙", + "φ": "服艾", + "Φ": "服艾", + "χ": "器", + "ψ": "普赛", + "Ψ": "普赛", + "ω": "欧米伽", + "Ω": "欧米伽", + "□": "方框", + ">": "大于", + "<": "小于", + "∈": "属于", + "∉": "不属于", + "∪": "并", + "∩": "交", + "⊥": "垂直", + "∥": "平行", + "≠": "不等于", + "∵": "因为", + "∴": "所以", + "∅": "空集", + "⊂": "真包含于", + "⊃": "包含", + "⊆": "包含于", + "⊇": "真包含", + "⊄": "不属于", + "⊅": "非超集", + "⊈": "不属于", + "⊉": "非超集", + } + for old, new in symbol_map.items(): + sentence = sentence.replace(old, new) + return sentence + + def text_normalize(self, text: str | None) -> str | None: + if text is None: + return None + text = self._preprocess_text(text) + if contains_chinese(text): + text = self._normalize_chinese_text(text).lower() + else: + text = self._normalize_english_text(text) + text = normalize_punctuation(text, PUNCTUATION_CHARS) + return ensure_proper_ending(text) + + def _preprocess_text(self, text: str) -> str: + text = markdown_norm(text) + text = multi_line_process(text) + if emoji is not None: + text = emoji.replace_emoji(text, replace="") + return re.sub(r"(?<=[a-zA-Z])-(?=[a-zA-Z])", " ", text) + + def _normalize_chinese_text(self, text: str) -> str: + text = self.pre_replace(text) + if self.zh_tn_model is not None: + text = self.zh_tn_model.normalize(text) + text = special_replace(text) + return self.post_replace(text).strip() + + def _normalize_english_text(self, text: str) -> str: + text = text.replace("'", "’") + if self.en_tn_model is not None: + text = self.en_tn_model.normalize(text) + text = remove_bracket(text, "en") + text = replace_asterisk_with_multiply(text, "en") + text = text.replace("—", " ") + text = text.replace("’", "'") + text = spell_out_number(text, self.inflect_parser) + text = re.sub(r"\s+", " ", text) + keep_punctuation = r"\.,!\?'\:;" + pattern = rf"[^\w\s{keep_punctuation}]" + text = re.sub(pattern, "", text) + text = text.lower() + text = re.sub(r"\.+", ".", text) + text = re.sub(r"\,+", ",", text) + text = re.sub(r"!+", "!", text) + text = re.sub(r"\?+", "?", text) + text = re.sub(r"'+", "'", text) + text = re.sub(r":+", ":", text) + text = re.sub(r";+", ";", text) + text = re.sub(r"\s*([.,?!':;])\s*", r"\1 ", text) + return text.strip() diff --git a/vllm_omni/model_executor/models/glm_tts/vocoder.py b/vllm_omni/model_executor/models/glm_tts/vocoder.py new file mode 100644 index 000000000..77c61c0de --- /dev/null +++ b/vllm_omni/model_executor/models/glm_tts/vocoder.py @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Vocoder loading and mel-to-audio conversion for GLM-TTS. + +Supports: + - HiFT (24kHz) — reuses CosyVoice3's vendored HiFTGenerator + - Vocos2D JIT (32kHz) from TorchScript checkpoint + +Extracted from glm_tts_dit_wrapper.py to keep file sizes under 800 lines. +""" + +from __future__ import annotations + +import os +from typing import Any + +import torch +import torch.nn as nn +from vllm.logger import init_logger + +try: + from torch.nn.utils.parametrizations import weight_norm +except ImportError: + from torch.nn.utils import weight_norm + +logger = init_logger(__name__) + + +# --------------------------------------------------------------------------- +# HiFT vocoder (24kHz) — reuses CosyVoice3's vendored HiFTGenerator +# --------------------------------------------------------------------------- + + +class ConvRNNF0Predictor(nn.Module): + """Non-causal F0 predictor for GLM-TTS HiFT vocoder. + + GLM-TTS ships a non-causal HiFT checkpoint whose F0 predictor uses + standard ``nn.Conv1d`` layers (kernel_size=3, padding=1). CosyVoice3's + vendored hifigan only includes the *causal* variant + (``CausalConvRNNF0Predictor``), so we provide the non-causal version + here. The network structure and state_dict keys are identical to the + original ``cosyvoice.hifigan_cosy2.f0_predictor.ConvRNNF0Predictor``. + """ + + def __init__( + self, + num_class: int = 1, + in_channels: int = 80, + cond_channels: int = 512, + ): + super().__init__() + self.num_class = num_class + self.condnet = nn.Sequential( + weight_norm(nn.Conv1d(in_channels, cond_channels, kernel_size=3, padding=1)), + nn.ELU(), + weight_norm(nn.Conv1d(cond_channels, cond_channels, kernel_size=3, padding=1)), + nn.ELU(), + weight_norm(nn.Conv1d(cond_channels, cond_channels, kernel_size=3, padding=1)), + nn.ELU(), + weight_norm(nn.Conv1d(cond_channels, cond_channels, kernel_size=3, padding=1)), + nn.ELU(), + weight_norm(nn.Conv1d(cond_channels, cond_channels, kernel_size=3, padding=1)), + nn.ELU(), + ) + self.classifier = nn.Linear(in_features=cond_channels, out_features=self.num_class) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.condnet(x) + x = x.transpose(1, 2) + return torch.abs(self.classifier(x).squeeze(-1)) + + +class HiFTWrapper: + """Thin wrapper around CosyVoice3's vendored HiFTGenerator.""" + + def __init__(self, state_dict: dict[str, Any], device: torch.device): + self.device = device + self.sample_rate = 24000 + from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.hifigan import ( + HiFTGenerator, + ) + + f0_predictor = ConvRNNF0Predictor(num_class=1, in_channels=80, cond_channels=512) + self.model = HiFTGenerator( + in_channels=80, + base_channels=512, + nb_harmonics=8, + sampling_rate=24000, + nsf_alpha=0.1, + nsf_sigma=0.003, + nsf_voiced_threshold=10, + upsample_rates=[8, 5, 3], + upsample_kernel_sizes=[16, 11, 7], + istft_params={"n_fft": 16, "hop_len": 4}, + resblock_kernel_sizes=[3, 7, 11], + resblock_dilation_sizes=[[1, 3, 5], [1, 3, 5], [1, 3, 5]], + source_resblock_kernel_sizes=[7, 7, 11], + source_resblock_dilation_sizes=[ + [1, 3, 5], + [1, 3, 5], + [1, 3, 5], + ], + lrelu_slope=0.1, + audio_limit=0.99, + f0_predictor=f0_predictor, + ).to(device) + self.model.load_state_dict(state_dict) + self.model.eval() + self.model.float() + + def __call__(self, mel: torch.Tensor) -> torch.Tensor: + mel = mel.to(self.device) + with torch.no_grad(): + audio, _ = self.model.inference(mel) + return audio + + +def load_hift(ckpt_path: str, device: torch.device) -> HiFTWrapper: + """Load HiFT vocoder from checkpoint.""" + state_dict = torch.load(ckpt_path, map_location=device, weights_only=True) + if isinstance(state_dict, dict): + for key in ("state_dict", "model", "generator"): + nested = state_dict.get(key) + if isinstance(nested, dict): + state_dict = nested + break + return HiFTWrapper(state_dict, device) + + +# --------------------------------------------------------------------------- +# Vocos2D JIT vocoder (32kHz) +# --------------------------------------------------------------------------- + +_MEL_LOGDIFF = -7.847762537473608 + + +class Vocos2DWrapper: + """Thin wrapper around Vocos2D TorchScript model.""" + + def __init__(self, ckpt_path: str, device: torch.device): + self.device = device + self.gen_model = torch.jit.load(ckpt_path, map_location=device) + self.gen_model.eval() + self._mel_logdiff = _MEL_LOGDIFF + + def __call__(self, mel: torch.Tensor) -> torch.Tensor: + if mel.size(-1) == 1: + mel = torch.cat([mel, mel], dim=-1) + xs_mel = mel.transpose(-1, -2) + self._mel_logdiff + xs = self.gen_model(xs_mel.to(self.device)) + if xs.ndim == 2: + xs = xs.unsqueeze(1) + return xs + + +def load_vocos2d_jit(ckpt_path: str, device: torch.device) -> Vocos2DWrapper: + """Load Vocos2D JIT vocoder from TorchScript checkpoint.""" + return Vocos2DWrapper(ckpt_path, device) + + +# --------------------------------------------------------------------------- +# Unified loader +# --------------------------------------------------------------------------- + + +def load_vocoder( + model_root: str, + device: torch.device, + sample_rate: int = 24000, +) -> tuple[Any | None, int]: + """Try to load the best available vocoder. + + Returns: + (vocoder, actual_sample_rate) or (None, sample_rate) on failure. + """ + # Try HiFT first for 24kHz + if sample_rate == 24000: + hift_path = os.path.join(model_root, "hift", "hift.pt") + if os.path.isfile(hift_path): + try: + vocoder = load_hift(hift_path, device) + logger.info("Loaded HiFT vocoder from %s", hift_path) + return vocoder, 24000 + except Exception as e: + logger.warning("Failed to load HiFT: %s", e) + + # Try Vocos2D JIT + vocos_jit_path = os.path.join(model_root, "vocos2d", "generator_jit.ckpt") + if os.path.isfile(vocos_jit_path): + try: + vocoder = load_vocos2d_jit(vocos_jit_path, device) + logger.info("Loaded Vocos2D JIT vocoder (32kHz)") + return vocoder, 32000 + except Exception as e: + logger.warning("Failed to load Vocos2D JIT: %s", e) + + logger.warning("No vocoder available") + return None, sample_rate + + +def mel_to_audio( + vocoder: Any | None, + mel: torch.Tensor, +) -> torch.Tensor: + """Convert mel-spectrogram to audio waveform. + + Args: + vocoder: Vocoder instance (HiFTWrapper, Vocos2DWrapper, or None). + mel: Mel-spectrogram [B, T, mel_dim]. + + Returns: + Audio waveform [B, 1, samples]. + """ + # Transpose to [B, mel_dim, T] for vocoder. + # HiFT / Vocos2D are precision-sensitive; always feed float32. + mel = mel.to(torch.float32).transpose(1, 2) + + if vocoder is not None: + if callable(vocoder) and not hasattr(vocoder, "decode"): + audio = vocoder(mel) + else: + audio = vocoder.decode(mel) + if audio.ndim == 2: + audio = audio.unsqueeze(1) + else: + raise RuntimeError("GLM-TTS vocoder is not loaded.") + + return audio diff --git a/vllm_omni/model_executor/models/glm_tts/voice_clone.py b/vllm_omni/model_executor/models/glm_tts/voice_clone.py new file mode 100644 index 000000000..d4d32f4f6 --- /dev/null +++ b/vllm_omni/model_executor/models/glm_tts/voice_clone.py @@ -0,0 +1,299 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GLM-TTS voice cloning frontend: speech tokenizer, speaker embedding, mel features. + +Lazy-loads external models (WhisperVQ, CampPlus ONNX) and provides extraction +helpers consumed by the GLM-TTS multimodal processor. +""" + +from __future__ import annotations + +import os + +import torch +import torchaudio.compliance.kaldi as kaldi +import torchaudio.transforms as T +from vllm.logger import init_logger + +logger = init_logger(__name__) + +_MEL_BASIS_CACHE: dict[tuple[int, int, int, int, int, str], torch.Tensor] = {} +_HANN_WINDOW_CACHE: dict[tuple[int, str], torch.Tensor] = {} + + +def _spectral_normalize_torch(magnitudes: torch.Tensor) -> torch.Tensor: + return torch.log(torch.clamp(magnitudes, min=1e-5)) + + +def _get_glm_tts_mel_basis( + *, + device: torch.device, + sample_rate: int, + n_fft: int, + n_mels: int, + f_min: int, + f_max: int, +) -> torch.Tensor: + """Compute or retrieve cached GLM-TTS mel filter bank. + + Uses torchaudio melscale_fbanks with Slaney parameters (norm="slaney", + mel_scale="slaney") to match the official GLM-TTS ``utils/audio.py`` + librosa.filters.mel output. The previous HTK / norm=None config + produced a numerically different filter bank that shifted the + prompt_feat distribution and degraded voice-clone conditioning. + """ + cache_key = (sample_rate, n_fft, n_mels, f_min, f_max, str(device)) + mel_basis = _MEL_BASIS_CACHE.get(cache_key) + if mel_basis is not None: + return mel_basis + + import torchaudio.functional as AF + + mel_basis = ( + AF.melscale_fbanks( + n_freqs=(n_fft // 2) + 1, + f_min=float(f_min), + f_max=float(f_max), + n_mels=n_mels, + sample_rate=sample_rate, + norm="slaney", + mel_scale="slaney", + ) + .transpose(0, 1) + .contiguous() + .float() + .to(device) + ) + + _MEL_BASIS_CACHE[cache_key] = mel_basis + return mel_basis + + +def _extract_glm_tts_mel_feature( + audio: torch.Tensor, + *, + sample_rate: int, + n_fft: int, + win_length: int, + hop_length: int, + n_mels: int, + f_min: int, + f_max: int, +) -> torch.Tensor: + """Extract time-major log-mel spectrogram features from audio waveform.""" + device = audio.device + mel_basis = _get_glm_tts_mel_basis( + device=device, + sample_rate=sample_rate, + n_fft=n_fft, + n_mels=n_mels, + f_min=f_min, + f_max=f_max, + ) + + hann_key = (win_length, str(device)) + window = _HANN_WINDOW_CACHE.get(hann_key) + if window is None: + window = torch.hann_window(win_length, device=device) + _HANN_WINDOW_CACHE[hann_key] = window + + if audio.ndim == 1: + audio = audio.unsqueeze(0) + + pad = int((n_fft - hop_length) / 2) + audio = torch.nn.functional.pad( + audio.unsqueeze(1), + (pad, pad), + mode="reflect", + ).squeeze(1) + + spec = torch.stft( + audio, + n_fft, + hop_length=hop_length, + win_length=win_length, + window=window, + center=False, + pad_mode="reflect", + normalized=False, + onesided=True, + return_complex=True, + ) + spec = torch.sqrt(spec.real.pow(2) + spec.imag.pow(2) + 1e-9) + spec = torch.matmul(mel_basis, spec) + spec = _spectral_normalize_torch(spec) + return spec.squeeze(0).transpose(0, 1).contiguous() + + +def load_voice_clone_frontend( + model_root: str, + model_device: torch.device, + *, + speech_tokenizer_cache: tuple | None, + campplus_cache: object | None, + campplus_path: str | None = None, +) -> tuple[tuple | None, object | None]: + """Lazy-load voice cloning frontend models. + + Args: + campplus_path: Pre-resolved path to ``campplus.onnx``. When provided + this takes priority over the default ``model_root/frontend/`` + lookup. The caller (``GLMTTSForConditionalGeneration``) resolves + the path via ``transformers.utils.hub.cached_file`` during + ``__init__`` so the file is already downloaded. + + Returns: + ``(speech_tokenizer, campplus_session)`` — guaranteed non-None + on success. Raises ``RuntimeError`` when a required component + cannot be loaded. + """ + speech_tokenizer = speech_tokenizer_cache + campplus_session = campplus_cache + + if speech_tokenizer is None: + speech_tokenizer_paths = ( + os.path.join(model_root, "speech_tokenizer"), + os.path.join(model_root, "ckpt", "speech_tokenizer"), + ) + speech_tokenizer_path = next((path for path in speech_tokenizer_paths if os.path.isdir(path)), None) + if speech_tokenizer_path is None: + raise RuntimeError( + f"GLM-TTS speech tokenizer directory not found at {speech_tokenizer_paths[0]} " + f"or {speech_tokenizer_paths[1]}. Voice cloning requires the WhisperVQ " + f"speech tokenizer from the model snapshot." + ) + try: + from safetensors.torch import load_file + from transformers import AutoFeatureExtractor, WhisperConfig + + from vllm_omni.model_executor.models.common.whisper_vq import ( + WhisperVQEncoder, + ) + + _config = WhisperConfig.from_pretrained(speech_tokenizer_path) + _config.quantize_encoder_only = True + _model = WhisperVQEncoder(_config) + _sd = load_file(os.path.join(speech_tokenizer_path, "model.safetensors")) + _prefix, _cleaned = "model.encoder.", {} + for k, v in _sd.items(): + _cleaned[k[len(_prefix) :] if k.startswith(_prefix) else k] = v + _model.load_state_dict(_cleaned, strict=False) + _model = _model.to(model_device).eval() + _fe = AutoFeatureExtractor.from_pretrained(speech_tokenizer_path) + speech_tokenizer = (_model, _fe) + logger.info("Loaded GLM-TTS speech tokenizer from %s", speech_tokenizer_path) + except Exception as exc: + raise RuntimeError(f"Failed to load GLM-TTS speech tokenizer from {speech_tokenizer_path}") from exc + + if campplus_session is None: + cp = campplus_path or os.path.join(model_root, "frontend", "campplus.onnx") + if not os.path.isfile(cp): + raise RuntimeError( + f"GLM-TTS campplus.onnx not found at {cp}. " + f"Voice cloning requires the CampPlus speaker embedding model." + ) + try: + import onnxruntime + + cuda_avail = "CUDAExecutionProvider" in onnxruntime.get_available_providers() + cuda_ok = model_device.type == "cuda" and cuda_avail + providers = (["CUDAExecutionProvider"] if cuda_ok else []) + ["CPUExecutionProvider"] + campplus_session = onnxruntime.InferenceSession(cp, providers=providers) + logger.info("Loaded GLM-TTS campplus ONNX from %s", cp) + except Exception as exc: + raise RuntimeError(f"Failed to load GLM-TTS campplus ONNX from {cp}") from exc + + return speech_tokenizer, campplus_session + + +def extract_prompt_speech_token( + ref_audio_wav: torch.Tensor, + ref_audio_sr: int, + speech_tokenizer: tuple, +) -> list[int] | None: + """Extract prompt speech tokens from reference audio using WhisperVQ.""" + + model, feature_extractor = speech_tokenizer + device = model.device + + audio = ref_audio_wav.float().to(device) + if ref_audio_sr != 16000: + resampler = T.Resample(orig_freq=ref_audio_sr, new_freq=16000).to(device) + audio = resampler(audio) + + if audio.ndim > 1: + audio = audio[0] + + audio_np = audio.cpu().numpy() + pooling_kernel_size = getattr(model.config, "pooling_kernel_size", 1) + stride = model.conv1.stride[0] * model.conv2.stride[0] * pooling_kernel_size * feature_extractor.hop_length + + all_tokens: list[int] = [] + time_step = 0 + while time_step * 16000 < audio_np.shape[0]: + segment = audio_np[time_step * 16000 : (time_step + 30) * 16000] + features = feature_extractor( + [segment], + sampling_rate=16000, + return_attention_mask=True, + return_tensors="pt", + padding="longest", + pad_to_multiple_of=stride, + ).to(device) + with torch.no_grad(): + outputs = model(**features) + tokens = outputs.quantized_token_ids + attn = features.attention_mask[:, :: model.conv1.stride[0] * model.conv2.stride[0]][:, ::pooling_kernel_size] + all_tokens.extend(tokens[0][attn[0].bool()].tolist()) + time_step += 30 + + return all_tokens if all_tokens else None + + +def extract_spk_embedding( + ref_audio_wav: torch.Tensor, + ref_audio_sr: int, + campplus_session: object, +) -> list[float] | None: + """Extract speaker embedding from reference audio using CampPlus ONNX.""" + + audio = ref_audio_wav.float().cpu() + if ref_audio_sr != 16000: + resampler = T.Resample(orig_freq=ref_audio_sr, new_freq=16000) + audio = resampler(audio) + if audio.ndim > 1: + audio = audio[0] + + feat = kaldi.fbank(audio.unsqueeze(0), num_mel_bins=80, dither=0, sample_frequency=16000) + feat = feat - feat.mean(dim=0, keepdim=True) + + input_name = campplus_session.get_inputs()[0].name + embedding = campplus_session.run(None, {input_name: feat.unsqueeze(dim=0).cpu().numpy()})[0].flatten().tolist() + return embedding + + +def extract_prompt_feat( + ref_audio_wav: torch.Tensor, + ref_audio_sr: int, + model_device: torch.device, +) -> torch.Tensor | None: + """Extract mel features from reference audio for DiT conditioning.""" + audio = ref_audio_wav.float().to(model_device) + if ref_audio_sr != 24000: + resampler = T.Resample(orig_freq=ref_audio_sr, new_freq=24000).to(model_device) + audio = resampler(audio) + if audio.ndim > 1: + audio = audio[0] + + with torch.no_grad(): + feat = _extract_glm_tts_mel_feature( + audio, + sample_rate=24000, + n_fft=1920, + win_length=1920, + hop_length=480, + n_mels=80, + f_min=0, + f_max=8000, + ) + return feat # [T_mel, mel_dim] diff --git a/vllm_omni/model_executor/models/registry.py b/vllm_omni/model_executor/models/registry.py index 4f29c0ae3..4c49afc36 100644 --- a/vllm_omni/model_executor/models/registry.py +++ b/vllm_omni/model_executor/models/registry.py @@ -129,6 +129,12 @@ _OMNI_MODELS = { "glm_image_ar", "GlmImageForConditionalGeneration", ), + ## glm_tts + "GLMTTSForConditionalGeneration": ( + "glm_tts", + "glm_tts", + "GLMTTSForConditionalGeneration", + ), "OmniBagelForConditionalGeneration": ( "bagel", "bagel", diff --git a/vllm_omni/model_executor/stage_input_processors/glm_tts.py b/vllm_omni/model_executor/stage_input_processors/glm_tts.py new file mode 100644 index 000000000..caafbf86c --- /dev/null +++ b/vllm_omni/model_executor/stage_input_processors/glm_tts.py @@ -0,0 +1,362 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Stage input processor for GLM-TTS: AR → DiT Pipeline. + +Supports both sync (non-streaming) and async_chunk (streaming) modes. +Adapted for LLM_GENERATION execution type on stage 1 (DiT). +""" + +from typing import Any + +import torch +from vllm.logger import init_logger + +from vllm_omni.data_entry_keys import ( + CodesStruct, + EmbeddingsStruct, + MetaStruct, + OmniPayloadStruct, +) +from vllm_omni.engine.serialization import deserialize_additional_information + +logger = init_logger(__name__) + +_GLM_TTS_PRIVATE_KEY = "glm_tts" + + +def _copy_voice_clone_payload( + src: dict[str, Any], + dst: dict[str, Any], + *, + to_cpu: bool = False, + skip_existing: bool = False, +) -> None: + if not skip_existing or "prompt_speech_token" not in dst: + prompt = src.get("prompt_speech_token") + if prompt is None: + prompt = src.get("prompt_token") + if prompt is not None: + if to_cpu: + prompt = _to_cpu_tensor(prompt) + if prompt is not None: + dst["prompt_speech_token"] = prompt + + for key in ("prompt_feat", "embedding"): + if skip_existing and key in dst: + continue + val = src.get(key) + if val is not None: + if to_cpu: + val = _to_cpu_tensor(val) + if val is not None: + dst[key] = val + + +def _build_voice_clone_embed_struct(payload: dict[str, Any]) -> EmbeddingsStruct | None: + prompt_token = payload.get("prompt_speech_token") + if prompt_token is None: + prompt_token = payload.get("prompt_token") + prompt_feat = payload.get("prompt_feat") + embedding = payload.get("embedding") + if prompt_token is None and prompt_feat is None and embedding is None: + return None + return EmbeddingsStruct( + speech_token=prompt_token, + speech_feat=prompt_feat, + embedding=embedding, + ) + + +# --------------------------------------------------------------------------- +# Sync processor: collect all speech tokens from AR, pass to DiT +# --------------------------------------------------------------------------- + + +def ar_to_dit( + source_outputs: list[Any], + prompt: Any = None, + _requires_multimodal_data: bool = False, +) -> list[Any]: + """Non-streaming: collect speech tokens from AR, pass to DiT. + + Propagates voice cloning data (prompt_token, prompt_feat, embedding) + from the AR model's multimodal_output to the DiT stage using the same + nested ``embed`` dict format as the async_chunk path. This ensures + ``nested_get(info, "embed", ...)`` in the DiT wrapper reads all three + fields without relying on backward-compat flat-key fallbacks. + """ + from vllm_omni.inputs.data import OmniTokensPrompt + + dit_inputs: list[OmniTokensPrompt] = [] + + for output in source_outputs: + mm = output.outputs[0].multimodal_output + speech_tokens = mm.get("speech_tokens") + + # Extract valid token list (filter -1 placeholder markers from prefill) + if isinstance(speech_tokens, torch.Tensor): + valid = speech_tokens.to(torch.long).reshape(-1) + token_list = valid[valid >= 0].cpu().tolist() + elif speech_tokens is not None: + token_list = [t for t in speech_tokens if t >= 0] + else: + token_list = [] + + if not token_list: + logger.warning("No valid speech tokens for request %s", output.request_id) + + additional_info: dict[str, Any] = {"speech_tokens": token_list} + + # Build nested embed dict (unified with async_chunk path). + # _build_voice_clone_embed_struct reads prompt_speech_token/prompt_feat/ + # embedding from *mm* and returns an EmbeddingsStruct; we convert to a + # plain dict so flatten_payload/unflatten_payload round-trips correctly + # (key "embed" is in _NESTED_KEYS). + embed = _build_voice_clone_embed_struct(mm) + if embed is not None: + additional_info["embed"] = { + "speech_token": embed.speech_token, + "speech_feat": embed.speech_feat, + "embedding": embed.embedding, + } + + # Populate meta dict so DiT wrapper uses block-causal attention. + # Without meta, forward() sets uses_streaming=False → bidirectional + # attention → garbage audio. The full token sequence is treated as + # a single "chunk" with stream_finished=True. + n_tokens = len(token_list) if token_list else 1 + additional_info["meta"] = { + "req_id": [output.request_id], + "left_context_size": 0, + "stream_finished": True, + } + additional_info["kv_metadata"] = { + _GLM_TTS_PRIVATE_KEY: { + "chunk_sizes_history": [n_tokens], + "block_pattern": [n_tokens], + } + } + + dit_inputs.append( + OmniTokensPrompt( + # LLM_GENERATION scheduler needs at least 1 token + prompt_token_ids=token_list or [0], + additional_information=additional_info, + ) + ) + + return dit_inputs + + +# --------------------------------------------------------------------------- +# Helper: extract last speech token from AR model pooling output +# --------------------------------------------------------------------------- + + +def _extract_last_speech_token(pooling_output: dict[str, Any]) -> int | None: + """Extract the last valid speech token from AR model output. + + GLM-TTS AR produces one speech token per decode step. + Returns the token ID (relative to ATS, i.e. 0-based), or None. + """ + speech_tokens = pooling_output.get("speech_tokens") + if not isinstance(speech_tokens, torch.Tensor) or speech_tokens.numel() == 0: + return None + token_val = int(speech_tokens.reshape(-1).to(torch.long)[-1].item()) + # -1 = invalid/EOA marker + if token_val < 0: + return None + return token_val + + +def _to_cpu_tensor(value: Any) -> torch.Tensor | None: + """Convert value to CPU tensor if possible.""" + if isinstance(value, torch.Tensor): + return value.detach().cpu() + if isinstance(value, list): + if not value: + return None + if isinstance(value[0], torch.Tensor): + return value[0].detach().cpu() + return None + + +# --------------------------------------------------------------------------- +# Async streaming processor: emit speech token chunks as AR produces them +# --------------------------------------------------------------------------- + + +def ar_to_dit_async_chunk( + transfer_manager: Any, + pooling_output: dict[str, Any] | None, + request: Any, + is_finished: bool = False, +) -> OmniPayloadStruct | None: + """Async streaming processor: emit speech token chunks as AR produces them. + + Accumulates per-step speech tokens and emits fixed-size cumulative prefixes + for GLM-TTS flow-cache streaming. + + Follows the CosyVoice3 talker2code2wav_async_chunk transfer pattern: + - Per-request state tracking (seen tokens, prompt sent flag) + - First chunk carries voice clone conditioning payload + - Each chunk includes left_context_size, stream_finished, req_id + - code_predictor_codes for chunk_transfer_adapter consumption + + Unlike codec left-context decoders, official GLM-TTS sends the cumulative + AR token prefix to the flow stage on every chunk and reuses diffusion + latents internally. Here left_context_size means the stable token prefix + that has already been emitted, so DiT can crop regenerated audio after + sampling. + + GLM-TTS produces single-token-per-step (no multi-codebook), so each entry + in code_prompt_token_ids is a plain int, not a list of codebook values. + """ + request_id = getattr(request, "external_req_id", None) or getattr(request, "request_id", None) + if request_id is None: + raise ValueError("GLM-TTS async chunk request is missing request id") + finished = bool(is_finished or request.is_finished()) + + # Read connector chunk config (supports progressive list or single int) + connector = getattr(transfer_manager, "connector", None) + raw_cfg = getattr(connector, "config", {}) or {} + cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {} + chunk_frames_cfg = cfg.get("codec_chunk_frames", 25) + if isinstance(chunk_frames_cfg, list): + progressive_chunk_sizes = [int(c) for c in chunk_frames_cfg] + else: + progressive_chunk_sizes = [int(chunk_frames_cfg)] + left_context_size_config = int(cfg.get("codec_left_context_frames", 25)) + crossfade_sec = float(cfg.get("crossfade_sec", 0.1)) + + if not progressive_chunk_sizes or any(c <= 0 for c in progressive_chunk_sizes) or left_context_size_config < 0: + raise ValueError( + f"Invalid codec chunk config: codec_chunk_frames={chunk_frames_cfg}, " + f"codec_left_context_frames={left_context_size_config}" + ) + + # Initialize per-request state (like CosyVoice3) + request_payload = getattr(transfer_manager, "request_payload", None) + if request_payload is None: + request_payload = {} + transfer_manager.request_payload = request_payload + code_prompt_token_ids = getattr(transfer_manager, "code_prompt_token_ids", None) + if code_prompt_token_ids is None: + code_prompt_token_ids = {} + transfer_manager.code_prompt_token_ids = code_prompt_token_ids + code_prompt_token_ids.setdefault(request_id, []) + request_state = request_payload.get(request_id) + if not isinstance(request_state, dict) or "_glm_tts_async_state" not in request_state: + # Extract voice clone conditioning from request additional_information + info = deserialize_additional_information(getattr(request, "additional_information", None)) + prompt_payload: dict[str, Any] = {} + _copy_voice_clone_payload(info, prompt_payload, to_cpu=True) + + # Also try to extract from pooling_output (first call) + if isinstance(pooling_output, dict): + _copy_voice_clone_payload(pooling_output, prompt_payload, to_cpu=True, skip_existing=True) + + request_state = { + "_glm_tts_async_state": { + "seen_len": 0, + "sent_prompt": False, + "emitted_chunks": 0, + "emitted_token_len": 0, + "terminal_sent": False, + "prompt_payload": prompt_payload, + "chunk_sizes_history": [], + "block_pattern": progressive_chunk_sizes, + } + } + request_payload[request_id] = request_state + + state = request_state["_glm_tts_async_state"] + if state.get("terminal_sent", False): + return None + + # Helper: get voice clone embed (once per request) + def _pop_embed() -> EmbeddingsStruct | None: + if state.get("sent_prompt", False): + return None + state["sent_prompt"] = True + return _build_voice_clone_embed_struct(state.get("prompt_payload", {})) + + # Helper: build terminal or chunk payload + def _make_payload( + codes: list[int], + *, + tok_offset: int, + is_terminal: bool, + ) -> OmniPayloadStruct: + return OmniPayloadStruct( + codes=CodesStruct( + audio=(torch.tensor(codes, dtype=torch.long) if codes else torch.empty(0, dtype=torch.long)) + ), + meta=MetaStruct( + finished=torch.tensor(is_terminal, dtype=torch.bool), + stream_finished=torch.tensor(is_terminal, dtype=torch.bool), + req_id=[request_id], + left_context_size=tok_offset, + ), + kv_metadata={ + _GLM_TTS_PRIVATE_KEY: { + "chunk_sizes_history": list(state.get("chunk_sizes_history", [])), + "block_pattern": list(state.get("block_pattern", progressive_chunk_sizes)), + "crossfade_sec": crossfade_sec, + } + }, + embed=_pop_embed(), + ) + + # Accumulate new speech token from this step + if isinstance(pooling_output, dict): + token = _extract_last_speech_token(pooling_output) + if token is not None: + code_prompt_token_ids[request_id].append(token) + elif not finished: + return None + + token_frames = code_prompt_token_ids[request_id] + length = len(token_frames) + emitted_token_len = int(state.get("emitted_token_len", 0)) + + # Terminal: no tokens, or finished on/before chunk boundary + if finished and length <= emitted_token_len: + state["terminal_sent"] = True + # Boundary finish is metadata-only: the cumulative prefix has already + # been emitted, so resending it would make the DiT/vocoder redo work + # and can duplicate audio. + return _make_payload([], tok_offset=emitted_token_len, is_terminal=True) + + if length <= 0: + return None + + # Progressive chunk size: 25 → 50 → 200 (official GLM-TTS pattern) + chunk_count = int(state.get("emitted_chunks", 0)) + idx = min(chunk_count, len(progressive_chunk_sizes) - 1) + current_chunk_size = progressive_chunk_sizes[idx] + + # Check if enough tokens accumulated for next chunk + if not finished and (length - emitted_token_len) < current_chunk_size: + return None + + # Determine end index for cumulative prefix + if emitted_token_len == 0: + end_index = min(length, current_chunk_size) + token_offset = 0 + else: + end_index = length if finished else min(length, emitted_token_len + current_chunk_size) + token_offset = emitted_token_len + + # Track chunk sizes for cache slicing + chunk_sizes_history: list[int] = list(state.get("chunk_sizes_history", [])) + chunk_sizes_history.append(end_index - emitted_token_len) + state["chunk_sizes_history"] = chunk_sizes_history + state["emitted_chunks"] = chunk_count + 1 + + if finished: + state["terminal_sent"] = True + else: + state["emitted_token_len"] = max(emitted_token_len, end_index) + + return _make_payload(list(token_frames[:end_index]), tok_offset=token_offset, is_terminal=finished) diff --git a/vllm_omni/transformers_utils/configs/__init__.py b/vllm_omni/transformers_utils/configs/__init__.py index 708d9c2b6..1a806cc46 100644 --- a/vllm_omni/transformers_utils/configs/__init__.py +++ b/vllm_omni/transformers_utils/configs/__init__.py @@ -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", + "GLMTTSConfig": "vllm_omni.transformers_utils.configs.glm_tts", "VoxCPM2Config": "vllm_omni.transformers_utils.configs.voxcpm2", "VoxtralTTSConfig": "vllm_omni.transformers_utils.configs.voxtral_tts", "CosyVoice3Config": "vllm_omni.transformers_utils.configs.cosyvoice3", @@ -36,6 +37,7 @@ __all__ = [ "FishSpeechConfig", "FishSpeechSlowARConfig", "FishSpeechFastARConfig", + "GLMTTSConfig", "VoxCPM2Config", "VoxtralTTSConfig", "CosyVoice3Config", @@ -65,6 +67,7 @@ def __dir__(): # run as soon as `vllm_omni.transformers_utils.configs` is imported. from vllm_omni.transformers_utils.configs import cosyvoice3 as _cosyvoice3 # noqa: F401, E402 from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402 +from vllm_omni.transformers_utils.configs import glm_tts as _glm_tts # 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 ming_flash_omni as _ming_flash_omni # noqa: F401, E402 from vllm_omni.transformers_utils.configs import omnivoice as _omnivoice # noqa: F401, E402 diff --git a/vllm_omni/transformers_utils/configs/glm_tts.py b/vllm_omni/transformers_utils/configs/glm_tts.py new file mode 100644 index 000000000..9a7f8dfbf --- /dev/null +++ b/vllm_omni/transformers_utils/configs/glm_tts.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""GLM-TTS config registration with transformers AutoConfig. + +Registers GLMTTSConfig (model_type="glm_tts") so that +``AutoConfig.from_pretrained("path/to/glm-tts")`` returns the correct config class. + +Note: GLM-TTS uses a Llama backbone, but we register a custom config +to handle the special token IDs and flow model parameters. +""" + +from __future__ import annotations + +from typing import Any + +from transformers import AutoConfig, PretrainedConfig + + +class GLMTTSConfig(PretrainedConfig): + """Llama-based AR model for text-to-speech token generation. + + Special token IDs are loaded dynamically from the tokenizer at init time. + """ + + model_type: str = "glm_tts" + + def __init__( + self, + vocab_size: int = 98304, + hidden_size: int = 2048, + intermediate_size: int = 6144, + num_hidden_layers: int = 28, + num_attention_heads: int = 16, + num_key_value_heads: int = 4, + hidden_act: str = "silu", + max_position_embeddings: int = 8192, + rms_norm_eps: float = 1e-5, + rope_theta: float = 10000.0, + audio_token_start: int = -1, + audio_token_end: int = -1, + boa_token_id: int = -1, + eoa_token_id: int = -1, + pad_token_id: int = -1, + bos_token_id: int = -1, + speech_token_vocab_size: int = 32768, + speech_token_dim: int = 512, + spk_embed_dim: int = 192, + mel_dim: int = 80, + input_frame_rate: float = 25.0, + mel_framerate: int = 50, + max_token_text_ratio: float = 20.0, + min_token_text_ratio: float = 2.0, + sample_method: str = "ras", + ras_top_p: float = 0.8, + ras_top_k: int = 25, + ras_win_size: int = 10, + ras_tau_r: float = 0.1, + **kwargs: Any, + ): + self.vocab_size = vocab_size + self.hidden_size = hidden_size + self.intermediate_size = intermediate_size + self.num_hidden_layers = num_hidden_layers + self.num_attention_heads = num_attention_heads + self.num_key_value_heads = num_key_value_heads + self.hidden_act = hidden_act + self.max_position_embeddings = max_position_embeddings + self.rms_norm_eps = rms_norm_eps + self.rope_theta = rope_theta + self.audio_token_start = audio_token_start + self.audio_token_end = audio_token_end + self.boa_token_id = boa_token_id + self.eoa_token_id = eoa_token_id + self.speech_token_vocab_size = speech_token_vocab_size + self.speech_token_dim = speech_token_dim + self.spk_embed_dim = spk_embed_dim + self.mel_dim = mel_dim + self.input_frame_rate = input_frame_rate + self.mel_framerate = mel_framerate + self.max_token_text_ratio = max_token_text_ratio + self.min_token_text_ratio = min_token_text_ratio + self.sample_method = sample_method + self.ras_top_p = ras_top_p + self.ras_top_k = ras_top_k + self.ras_win_size = ras_win_size + self.ras_tau_r = ras_tau_r + super().__init__(pad_token_id=pad_token_id, bos_token_id=bos_token_id, **kwargs) + + +AutoConfig.register("glm_tts", GLMTTSConfig) + +__all__ = [ + "GLMTTSConfig", +]