[Model] Add Audio8 TTS Preview 0.6B (DualAR, 44.1 kHz codec) (#6157)
Signed-off-by: NancyFyong <NancyFyong@users.noreply.github.com> Co-authored-by: NancyFyong <NancyFyong@users.noreply.github.com>
This commit is contained in:
1 parent
cdf027874c
commit
e74fe3c6e8
41 files changed
+5398
-5
No files matched your search
@@ -85,6 +85,25 @@ vllm bench serve --omni \
|
||||
--save-result --result-dir ./results
|
||||
```
|
||||
|
||||
#### Set the sample rate for models that are not 24 kHz
|
||||
|
||||
Streamed PCM carries no header, so the harness derives `audio_duration` from the
|
||||
byte count and a **default 24000 Hz** assumption
|
||||
(`vllm_omni/metrics/definitions.py:DEFAULT_AUDIO_SAMPLE_RATE`). For a model that
|
||||
emits another rate, export the real one or every duration-derived metric is
|
||||
wrong by `real_sr / 24000`:
|
||||
|
||||
```bash
|
||||
# Audio8 TTS, Fish Speech S2 Pro, Ming-omni-tts, ... all decode at 44.1 kHz
|
||||
export VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE=44100
|
||||
```
|
||||
|
||||
Without it, a 44.1 kHz model reports `audio_duration` 1.84x too long, so
|
||||
`audio_rtf` looks 1.84x *better* than reality and `audio_underrun` /
|
||||
`Streaming continuity OK rate` are measured against inflated chunk budgets.
|
||||
A quick sanity check: compare `Mean AUDIO_DURATION (s)` against the actual
|
||||
length of a saved WAV for the same prompt.
|
||||
|
||||
#### Streaming continuity (`audio_underrun`)
|
||||
|
||||
`audio_underrun` (seconds) is the per-request worst-case buffer deficit
|
||||
|
||||
@@ -17,6 +17,17 @@ models:
|
||||
extra_params:
|
||||
lang: en
|
||||
text_normalization: true
|
||||
# Audio8 TTS Preview: DualAR, 44.1 kHz. trust_remote_code stays false.
|
||||
# Bench at 44.1 kHz: export VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE=44100, otherwise
|
||||
# the streamed-PCM duration defaults to 24 kHz and RTF looks 1.84x too good.
|
||||
Audio8/Audio8-TTS-Preview-0.6b:
|
||||
supported_tasks: [default_voice, voice_clone]
|
||||
backend: openai-audio-speech
|
||||
endpoint: /v1/audio/speech
|
||||
trust_remote_code: false
|
||||
task_extra_body:
|
||||
default_voice: {}
|
||||
voice_clone: {}
|
||||
|
||||
# -CustomVoice checkpoints lack speaker_encoder weights, so voice_clone is
|
||||
# NOT supported (an attempt raises ValueError from _extract_speaker_embedding
|
||||
|
||||
@@ -97,6 +97,7 @@ th {
|
||||
| `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | — |
|
||||
| `Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | ✅︎ | ✅︎ | | | — |
|
||||
| `FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✅︎ | ✅︎ | | ✅︎ | — |
|
||||
| `Audio8TTSSlowARForConditionalGeneration` | Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | ✅︎ | | | | — |
|
||||
| `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only), SenseNova-U1.5 | `SenseNova/SenseNova-U1-8B-MoT`, `sensenova/SenseNova-U1.5-8B-MoT` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SenseNova/SenseNova-U1.5.md) |
|
||||
| `LancePipeline` | Lance | `bytedance-research/Lance` | ✅︎ | | | | — |
|
||||
| `HunyuanVideo15Pipeline` | HunyuanVideo-1.5-T2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | ✅︎ | ✅︎ | | | — |
|
||||
|
||||
@@ -14,6 +14,7 @@ list of supported architectures across all modalities, see
|
||||
|
||||
| Model | HuggingFace repo | Stages | Voice cloning | Streaming | Special modes | Sample rate |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | dual-AR | ✓ | ✓ | 11 languages | 44.1 kHz |
|
||||
| CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 24 kHz |
|
||||
| Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ | — | 44.1 kHz |
|
||||
| Gepard-1.0 | `nineninesix/gepard-1.0` | single (native AR + NanoCodec) | — (zero-shot; cloning WIP) | — (serving WIP) | zero-shot | 22.05 kHz |
|
||||
@@ -44,6 +45,57 @@ python examples/offline_inference/text_to_speech/<model>/end2end.py \
|
||||
|
||||
---
|
||||
|
||||
## Audio8 TTS Preview
|
||||
|
||||
0.6B DualAR text-to-speech model (Audio8) with its own 44.1 kHz neural audio
|
||||
codec: a slow AR transformer predicts one semantic token per codec frame, and a
|
||||
4-layer fast AR predicts that frame's remaining 9 codebooks.
|
||||
|
||||
### Prerequisites
|
||||
No extra packages: the codec architecture ships in tree and its weights
|
||||
(`codec.pth`) come with the checkpoint.
|
||||
|
||||
### Quick start
|
||||
```bash
|
||||
python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \
|
||||
--text "Welcome to Audio8 TTS, a compact multilingual text to speech model."
|
||||
```
|
||||
|
||||
### Voice cloning
|
||||
```bash
|
||||
python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \
|
||||
--text "Welcome to Audio8 TTS." \
|
||||
--ref-audio /path/to/reference.wav \
|
||||
--ref-text "The exact transcript of the reference recording."
|
||||
```
|
||||
The reference transcript must match what is actually spoken in the clip;
|
||||
a mismatch degrades both stability and speaker similarity.
|
||||
|
||||
### Streaming
|
||||
```bash
|
||||
python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \
|
||||
--text "Welcome to Audio8 TTS." --streaming
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Output: 44.1 kHz mono WAV; the codec emits ~21.5 frames/s (2048 samples per frame).
|
||||
- Context is 2048 packed text+audio positions, so `max_model_len` is 2048.
|
||||
- Greedy decoding (`temperature: 0`) is degenerate for this checkpoint — it
|
||||
ends the utterance almost immediately. Keep the deploy defaults
|
||||
(`temperature 0.7`, `top_k 50`, `top_p 0.9`), which also match
|
||||
`generation_config.json`.
|
||||
- Repetition-Aware Sampling is applied to the semantic token: a repeat inside
|
||||
the last 10 frames is re-drawn from a flatter distribution instead of being
|
||||
masked out.
|
||||
- `--num-prompts 4` smoke-tests concurrency (stage 0 runs `max_num_seqs: 4`).
|
||||
|
||||
### Measured behaviour (1x H20, shared GPU)
|
||||
`--streaming` on a 58-character English sentence: TTFA ~0.74 s (4-frame first
|
||||
chunk), 7 chunks, 1.55 s wall for 3.16 s of audio (RTF ~0.49). Treat these as
|
||||
smoke-test magnitudes, not a benchmark.
|
||||
|
||||
---
|
||||
|
||||
## CosyVoice3
|
||||
|
||||
2-stage TTS pipeline (`talker` + `code2wav`) at 24 kHz.
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Offline inference demo for Audio8 TTS Preview 0.6B via vLLM Omni.
|
||||
|
||||
Usage:
|
||||
# Text-only synthesis
|
||||
python end2end.py --text "Welcome to Audio8 TTS."
|
||||
|
||||
# Zero-shot voice cloning (the transcript must match the reference audio)
|
||||
python end2end.py --text "Welcome to Audio8 TTS." \
|
||||
--ref-audio reference.wav --ref-text "The exact transcript of the reference recording."
|
||||
|
||||
# Streaming (per-chunk TTFA / inter-chunk timings)
|
||||
python end2end.py --text "Welcome to Audio8 TTS." --streaming
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
from vllm_omni import AsyncOmni, Omni
|
||||
from vllm_omni.model_executor.models.audio8_tts.codec_utils import estimate_reference_code_frames
|
||||
from vllm_omni.model_executor.models.audio8_tts.prompt_utils import (
|
||||
build_text_only_prompt_ids,
|
||||
estimate_voice_clone_prompt_len,
|
||||
normalize_text,
|
||||
)
|
||||
from vllm_omni.utils.tracking_parser import TrackingArgumentParser
|
||||
|
||||
DEFAULT_MODEL = "Audio8/Audio8-TTS-Preview-0.6b"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
text: str,
|
||||
ref_audio_path: str | None = None,
|
||||
ref_text: str | None = None,
|
||||
model_name: str = DEFAULT_MODEL,
|
||||
) -> dict:
|
||||
"""Build an engine prompt, using the same protocol as online serving."""
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
||||
if ref_audio_path is None and ref_text is None:
|
||||
prompt_ids, normalized_text = build_text_only_prompt_ids(tokenizer, text)
|
||||
return {
|
||||
"prompt_token_ids": prompt_ids,
|
||||
"additional_information": {"text": [normalized_text]},
|
||||
}
|
||||
|
||||
if not ref_audio_path or not ref_text:
|
||||
raise ValueError("Audio8 TTS voice cloning requires both --ref-audio and --ref-text")
|
||||
|
||||
normalized_text = normalize_text(text)
|
||||
normalized_ref_text = normalize_text(ref_text, add_default_speaker=True)
|
||||
ref_audio_wav, ref_audio_sr = sf.read(ref_audio_path, dtype="float32", always_2d=False)
|
||||
if ref_audio_wav.ndim > 1:
|
||||
ref_audio_wav = ref_audio_wav.mean(axis=-1)
|
||||
ref_frames = estimate_reference_code_frames(int(ref_audio_wav.shape[0]), int(ref_audio_sr))
|
||||
placeholder_len = estimate_voice_clone_prompt_len(tokenizer, normalized_text, normalized_ref_text, ref_frames)
|
||||
|
||||
# The clone prompt embeds the reference audio's own codec codes, so the
|
||||
# model builds it in preprocess(); we only reserve the exact length here.
|
||||
return {
|
||||
"prompt_token_ids": [1] * placeholder_len,
|
||||
"additional_information": {
|
||||
"text": normalized_text,
|
||||
"ref_text": normalized_ref_text,
|
||||
"ref_audio_wav": torch.from_numpy(np.asarray(ref_audio_wav, dtype=np.float32)),
|
||||
"ref_audio_sr": int(ref_audio_sr),
|
||||
"audio8_structured_voice_clone": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def extract_audio(multimodal_output: dict) -> tuple[torch.Tensor, int]:
|
||||
"""Pull the waveform and sample rate out of a multimodal output.
|
||||
|
||||
The output processor concatenates the per-step delta tensors under
|
||||
``model_outputs``; ``audio`` is the older alias. Never use ``a or b`` on
|
||||
these values -- truthiness on a tensor raises.
|
||||
"""
|
||||
audio = multimodal_output.get("model_outputs")
|
||||
if audio is None:
|
||||
audio = multimodal_output.get("audio")
|
||||
if audio is None:
|
||||
raise ValueError(f"No audio in multimodal_output: {sorted(multimodal_output)}")
|
||||
if isinstance(audio, list):
|
||||
audio = torch.cat([torch.as_tensor(chunk).reshape(-1) for chunk in audio], dim=0)
|
||||
audio = torch.as_tensor(audio).reshape(-1)
|
||||
|
||||
sr_raw = multimodal_output.get("sr")
|
||||
if isinstance(sr_raw, list):
|
||||
sr_raw = sr_raw[-1] if sr_raw else None
|
||||
if sr_raw is None:
|
||||
raise ValueError("Missing sample rate in multimodal_output")
|
||||
sample_rate = int(sr_raw.item()) if hasattr(sr_raw, "item") else int(sr_raw)
|
||||
return audio, sample_rate
|
||||
|
||||
|
||||
def _save_wav(output_dir: str, request_id: str, audio: torch.Tensor, sample_rate: int) -> None:
|
||||
out_wav = os.path.join(output_dir, f"output_{request_id}.wav")
|
||||
sf.write(out_wav, audio.float().cpu().numpy(), samplerate=sample_rate, format="WAV")
|
||||
duration = audio.numel() / sample_rate
|
||||
# print(), not logging: vLLM reconfigures logging on engine start and
|
||||
# disables loggers created before that, which would swallow these lines.
|
||||
print(f"Request {request_id}: saved {out_wav} (sr={sample_rate}, {duration:.2f}s)")
|
||||
|
||||
|
||||
def _omni_kwargs(args, model_name: str) -> dict:
|
||||
kwargs = {
|
||||
"model": model_name,
|
||||
"log_stats": args.log_stats,
|
||||
"stage_init_timeout": args.stage_init_timeout,
|
||||
}
|
||||
if args.deploy_config:
|
||||
kwargs["deploy_config"] = args.deploy_config
|
||||
return kwargs
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
model_name = args.model or DEFAULT_MODEL
|
||||
prompts = [
|
||||
build_prompt(text, args.ref_audio, args.ref_text, model_name) for text in ([args.text] * args.num_prompts)
|
||||
]
|
||||
|
||||
omni = Omni(**_omni_kwargs(args, model_name))
|
||||
t_start = time.perf_counter()
|
||||
total_audio_seconds = 0.0
|
||||
for stage_outputs in omni.generate(prompts):
|
||||
request_output = stage_outputs
|
||||
if request_output is None or not request_output.outputs:
|
||||
continue
|
||||
audio, sample_rate = extract_audio(request_output.outputs[0].multimodal_output)
|
||||
assert audio.numel() > 0, "Audio8 TTS produced an empty waveform"
|
||||
total_audio_seconds += audio.numel() / sample_rate
|
||||
_save_wav(args.output_dir, request_output.request_id, audio, sample_rate)
|
||||
elapsed = time.perf_counter() - t_start
|
||||
rtf = elapsed / max(total_audio_seconds, 1e-6)
|
||||
print(f"Total {elapsed * 1000:.1f} ms for {total_audio_seconds:.2f} s of audio (RTF={rtf:.3f})")
|
||||
|
||||
|
||||
async def main_streaming(args) -> None:
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
model_name = args.model or DEFAULT_MODEL
|
||||
prompt = build_prompt(args.text, args.ref_audio, args.ref_text, model_name)
|
||||
|
||||
omni = AsyncOmni(**_omni_kwargs(args, model_name))
|
||||
request_id = "0"
|
||||
chunks: list[torch.Tensor] = []
|
||||
sample_rate = None
|
||||
t_start = time.perf_counter()
|
||||
t_prev = t_start
|
||||
chunk_index = 0
|
||||
|
||||
async for stage_output in omni.generate(prompt, request_id=request_id):
|
||||
multimodal_output = stage_output.outputs[0].multimodal_output
|
||||
if not multimodal_output:
|
||||
continue
|
||||
audio = multimodal_output.get("model_outputs")
|
||||
if audio is None:
|
||||
audio = multimodal_output.get("audio")
|
||||
if audio is not None:
|
||||
if isinstance(audio, list):
|
||||
chunks.extend(torch.as_tensor(chunk).reshape(-1) for chunk in audio)
|
||||
else:
|
||||
chunks.append(torch.as_tensor(audio).reshape(-1))
|
||||
sr_raw = multimodal_output.get("sr")
|
||||
if isinstance(sr_raw, list):
|
||||
sr_raw = sr_raw[-1] if sr_raw else None
|
||||
if sr_raw is not None:
|
||||
sample_rate = int(sr_raw.item()) if hasattr(sr_raw, "item") else int(sr_raw)
|
||||
|
||||
if stage_output.finished:
|
||||
break
|
||||
now = time.perf_counter()
|
||||
if chunk_index == 0:
|
||||
print(f"chunk 0: TTFA={(now - t_start) * 1000:.1f} ms")
|
||||
else:
|
||||
print(f"chunk {chunk_index}: inter_chunk={(now - t_prev) * 1000:.1f} ms")
|
||||
t_prev = now
|
||||
chunk_index += 1
|
||||
|
||||
if not chunks or sample_rate is None:
|
||||
raise RuntimeError("Audio8 TTS streaming produced no audio")
|
||||
_save_wav(args.output_dir, request_id, torch.cat(chunks, dim=0), sample_rate)
|
||||
print(f"Streaming done: total={(time.perf_counter() - t_start) * 1000:.1f} ms chunks={chunk_index}")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = TrackingArgumentParser(description="Audio8 TTS Preview offline inference with vLLM Omni")
|
||||
parser.add_argument("--model", type=str, default=DEFAULT_MODEL, help="Model path or HuggingFace repo ID.")
|
||||
parser.add_argument(
|
||||
"--text",
|
||||
type=str,
|
||||
default="Welcome to Audio8 TTS, a compact multilingual text to speech model.",
|
||||
help="Text to synthesize.",
|
||||
)
|
||||
parser.add_argument("--ref-audio", type=str, default=None, help="Reference audio path for voice cloning.")
|
||||
parser.add_argument(
|
||||
"--ref-text",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Transcript of --ref-audio; required for voice cloning and must match the recording.",
|
||||
)
|
||||
parser.add_argument("--output-dir", type=str, default="output_audio", help="Directory for output WAV files.")
|
||||
parser.add_argument(
|
||||
"--num-prompts",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Duplicate the prompt N times to smoke-test concurrent requests.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deploy-config",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override the deploy config path; defaults to vllm_omni/deploy/audio8_tts.yaml via model_type.",
|
||||
)
|
||||
parser.add_argument("--streaming", action="store_true", help="Use AsyncOmni and report per-chunk latency.")
|
||||
parser.add_argument("--log-stats", action="store_true", help="Enable engine stats logging.")
|
||||
parser.add_argument("--stage-init-timeout", type=int, default=600, help="Per-stage init timeout in seconds.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parsed_args = parse_args()
|
||||
if parsed_args.streaming:
|
||||
asyncio.run(main_streaming(parsed_args))
|
||||
else:
|
||||
main(parsed_args)
|
||||
@@ -14,6 +14,7 @@ For the full list of supported architectures across all modalities, see
|
||||
|
||||
| Model | HuggingFace repo | Voice cloning | Streaming | Voice presets / upload | Gradio demo |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | uploaded audio voice only; no presets | ✓ |
|
||||
| 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) | — | ✓ |
|
||||
| IndexTTS-2 | `IndexTeam/IndexTTS-2` | ✓ (`ref_audio` or uploaded `voice`) | `stream=true` response, non-chunk | uploaded audio voice only; no presets | — |
|
||||
@@ -253,6 +254,138 @@ python examples/online_serving/text_to_speech/indextts2/speech_client.py \
|
||||
|
||||
---
|
||||
|
||||
## Audio8 TTS Preview
|
||||
|
||||
0.6B DualAR TTS at 44.1 kHz, 11 languages, zero-shot voice cloning.
|
||||
|
||||
### Prerequisites
|
||||
None beyond the base install: the neural audio codec is implemented in tree and
|
||||
its weights (`codec.pth`) ship with the checkpoint.
|
||||
|
||||
### Launch
|
||||
```bash
|
||||
vllm serve Audio8/Audio8-TTS-Preview-0.6b --omni --port 8092
|
||||
# or:
|
||||
./audio8_tts/run_server.sh
|
||||
```
|
||||
The deploy config auto-loads from `vllm_omni/deploy/audio8_tts.yaml` (HF
|
||||
`model_type` is `arktts`). Do **not** pass `--trust-remote-code`: vllm-omni
|
||||
registers its own `arktts` config, and transformers would otherwise prefer the
|
||||
checkpoint's remote code and bypass it.
|
||||
|
||||
### Text-only synthesis
|
||||
```bash
|
||||
curl -X POST http://localhost:8092/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "Welcome to Audio8 TTS.",
|
||||
"response_format": "wav"
|
||||
}' --output output.wav
|
||||
```
|
||||
|
||||
### Voice cloning
|
||||
```bash
|
||||
curl -X POST http://localhost:8092/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "Welcome to Audio8 TTS.",
|
||||
"ref_audio": "https://example.com/reference.wav",
|
||||
"ref_text": "The exact transcript of the reference recording."
|
||||
}' --output cloned.wav
|
||||
```
|
||||
`ref_audio` also accepts a `data:audio/wav;base64,...` URL. `ref_text` is
|
||||
mandatory whenever `ref_audio` is present and must match the recording.
|
||||
Uploading a voice via `POST /v1/audio/voices` lets the server reuse the encoded
|
||||
reference codes across requests (`voice: "<name>"`).
|
||||
|
||||
### Streaming
|
||||
```bash
|
||||
python audio8_tts/speech_client.py --text "Welcome to Audio8 TTS." --stream --output out.pcm
|
||||
ffplay -f s16le -ar 44100 -ac 1 out.pcm
|
||||
```
|
||||
|
||||
### Gradio demo
|
||||
```bash
|
||||
./audio8_tts/run_gradio_demo.sh # server + demo
|
||||
python audio8_tts/gradio_demo.py --api-base http://localhost:8092 # demo only
|
||||
```
|
||||
|
||||
### Benchmark
|
||||
|
||||
```bash
|
||||
export VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE=44100 # required: harness defaults to 24 kHz
|
||||
vllm bench serve --omni --host 127.0.0.1 --port 8092 \
|
||||
--model Audio8/Audio8-TTS-Preview-0.6b \
|
||||
--backend openai-audio-speech --endpoint /v1/audio/speech \
|
||||
--dataset-name seed-tts-text --dataset-path benchmarks/build_dataset/seed_tts_smoke \
|
||||
--seed-tts-locale en --num-prompts 20 --num-warmups 2 \
|
||||
--max-concurrency 1 --request-rate inf \
|
||||
--percentile-metrics ttft,e2el,audio_rtf,audio_ttfp,audio_duration,audio_underrun
|
||||
```
|
||||
|
||||
Measured on 1x H20 **shared with an unrelated job holding ~80% SM**, so treat
|
||||
these as a lower bound. 20 prompts from `seed_tts_smoke/en`, mean audio 4.5 s,
|
||||
deploy defaults:
|
||||
|
||||
| | HF reference (batch=1) | vllm-omni c=1 | c=4 | c=8 |
|
||||
|---|---|---|---|---|
|
||||
| RTF (mean) | 1.008 | **0.19** | 0.30 | 0.54 |
|
||||
| Throughput (req/s) | 0.23 | 1.18 | 2.84 | **2.93** |
|
||||
| E2E latency (mean, ms) | 4375 | 850 | 1349 | 2361 |
|
||||
| Time to first packet (mean, ms) | n/a (no streaming) | 63 | 105 | 1124 |
|
||||
| Underrun p99 / continuity OK | n/a | 0 s / 100% | 0 s / 100% | 0 s / 100% |
|
||||
|
||||
~5.3x lower per-request RTF than the upstream reference implementation at
|
||||
concurrency 1, ~12.8x aggregate throughput at concurrency 8.
|
||||
|
||||
#### Stage-0 `max_num_seqs`: first-packet vs throughput
|
||||
|
||||
This subsection is operator tuning guidance, not a claim this PR optimizes
|
||||
anything: the shipped deploy default is unchanged (`max_num_seqs: 4`). The
|
||||
numbers below are a single run on the same contended H20 as above (one unrelated
|
||||
job holding ~80% SM, no repeats), so read them as directional, not as measured
|
||||
speedups. Same setup, varying stage 0's `max_num_seqs` (stage 1 stays at 1):
|
||||
|
||||
| stage-0 `max_num_seqs` | client concurrency | req/s | RTF | TTFP mean / p99 (ms) | underrun p99 (s) |
|
||||
|---|---|---|---|---|---|
|
||||
| 4 (default) | 1 | 1.18 | 0.19 | 63 / 67 | 0.00 |
|
||||
| 4 | 4 | 2.84 | 0.30 | 105 / 148 | 0.00 |
|
||||
| 4 | 8 | 2.93 | 0.54 | 1124 / 1642 | 0.00 |
|
||||
| 8 | 1 | 1.16 | 0.19 | 64 / 70 | 0.00 |
|
||||
| 8 | 4 | 2.88 | 0.30 | 107 / 148 | 0.00 |
|
||||
| 8 | 8 | **3.72** | 0.40 | **247** / 733 | 0.22 |
|
||||
| 8 | 16 | 3.91 | 0.65 | 1366 / 2530 | 0.29 |
|
||||
|
||||
Reading of this:
|
||||
|
||||
- Below the limit (`concurrency <= max_num_seqs`) the setting is irrelevant, as
|
||||
expected — c=1 and c=4 are identical for both.
|
||||
- At c=8, raising the limit to 8 admits every request instead of queueing half
|
||||
of them: in this single run, roughly +27% throughput and ~4.5x lower
|
||||
first-packet latency.
|
||||
- Throughput saturates around 3.7-3.9 req/s (~17 s of audio per second). Past
|
||||
c=8 you only buy queueing delay.
|
||||
- The cost of admitting more streams is a 0.22-0.29 s worst-case buffer deficit
|
||||
in 1 of 20 requests (`Streaming continuity OK rate` 95%), because stage 1 runs
|
||||
`max_num_seqs: 1` and the codec window round-robins across streams. Client-side
|
||||
prebuffering (the bundled Gradio demo holds 2 chunks) absorbs that; raising
|
||||
stage 1's `max_num_seqs` is the untested knob if you need both.
|
||||
|
||||
The shipped default stays at 4 because every measured point there had zero
|
||||
underrun. Raise it to 8 if first-packet latency under load matters more than a
|
||||
rare sub-300 ms gap.
|
||||
|
||||
### Notes
|
||||
- Output: 44.1 kHz mono; ~21.5 codec frames per second.
|
||||
- No built-in speaker presets. Omit `voice` for a random timbre, or clone one.
|
||||
- `max_new_tokens` caps generated codec frames (1 frame ~= 46 ms).
|
||||
- Context is 2048 packed text+audio positions (`max_model_len: 2048`).
|
||||
- Do not send `temperature: 0`: greedy decoding is degenerate for this
|
||||
checkpoint and truncates the utterance. The deploy defaults
|
||||
(0.7 / top_k 50 / top_p 0.9) mirror `generation_config.json`.
|
||||
|
||||
---
|
||||
|
||||
## Fish Speech S2 Pro
|
||||
|
||||
4B dual-AR TTS at 44.1 kHz. Server uses the DAC codec, which is vendored in
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Gradio demo for Audio8 TTS Preview via the /v1/audio/speech API.
|
||||
|
||||
Supports:
|
||||
- text-to-speech synthesis in 11 languages
|
||||
- zero-shot voice cloning from reference audio (upload, microphone, 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:8092
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
#: Audio8 TTS decodes at 44.1 kHz; raw PCM must be interpreted at that rate.
|
||||
PCM_SAMPLE_RATE = 44100
|
||||
DEFAULT_API_BASE = "http://localhost:8092"
|
||||
|
||||
|
||||
def encode_audio_to_base64(audio_data: tuple) -> str:
|
||||
"""Encode a Gradio ``(sample_rate, numpy_array)`` input as a 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) * 32767).astype(np.int16)
|
||||
else:
|
||||
audio_np = audio_np.astype(np.int16)
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, audio_np, sample_rate, format="WAV")
|
||||
return f"data:audio/wav;base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}"
|
||||
|
||||
|
||||
def build_payload(
|
||||
text: str,
|
||||
ref_audio: tuple | None,
|
||||
ref_audio_url: str,
|
||||
ref_text: str,
|
||||
response_format: str = "wav",
|
||||
stream: bool = False,
|
||||
) -> dict:
|
||||
"""Build the ``/v1/audio/speech`` payload.
|
||||
|
||||
Audio8 TTS has no built-in speakers: omit ``voice`` for a random timbre, or
|
||||
supply ``ref_audio`` + ``ref_text`` to clone a voice.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
raise gr.Error("Please enter text to synthesize.")
|
||||
|
||||
payload: dict = {
|
||||
"input": text.strip(),
|
||||
"response_format": "pcm" if stream else response_format,
|
||||
"stream": stream,
|
||||
}
|
||||
if stream:
|
||||
payload["stream_format"] = "audio"
|
||||
|
||||
ref_url = 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:
|
||||
payload["ref_audio"] = ref_url
|
||||
|
||||
if "ref_audio" in payload:
|
||||
if not ref_text or not ref_text.strip():
|
||||
raise gr.Error("Voice cloning requires the 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):
|
||||
"""Non-streaming request; returns the whole waveform."""
|
||||
payload = build_payload(text, ref_audio, ref_audio_url, ref_text, response_format, stream=False)
|
||||
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.") from None
|
||||
except httpx.ConnectError:
|
||||
raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?") from None
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise gr.Error(f"Server error ({resp.status_code}): {resp.text}")
|
||||
if "application/json" in resp.headers.get("content-type", ""):
|
||||
raise gr.Error(f"Server error: {resp.text}")
|
||||
|
||||
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}") from None
|
||||
|
||||
|
||||
def generate_speech_stream(api_base, text, ref_audio, ref_audio_url, ref_text, response_format, chunk_seconds=0.5):
|
||||
"""Streaming request; yields PCM chunks for progressive playback."""
|
||||
payload = build_payload(text, ref_audio, ref_audio_url, ref_text, response_format, stream=True)
|
||||
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] = []
|
||||
leftover = b""
|
||||
|
||||
try:
|
||||
with (
|
||||
httpx.Client(timeout=300.0) as client,
|
||||
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
|
||||
# s16le: never split a sample across chunk boundaries.
|
||||
usable = len(raw) - (len(raw) % 2)
|
||||
leftover = raw[usable:]
|
||||
if usable == 0:
|
||||
continue
|
||||
samples = np.frombuffer(raw[:usable], dtype=np.int16).copy().astype(np.float32) / 32767.0
|
||||
pending.append(samples)
|
||||
pending_len += len(samples)
|
||||
if pending_len < min_chunk_samples:
|
||||
continue
|
||||
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.") from None
|
||||
except httpx.ConnectError:
|
||||
raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?") from None
|
||||
|
||||
|
||||
def on_stream_change(stream: bool):
|
||||
"""Streaming only makes sense with raw PCM."""
|
||||
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):
|
||||
with gr.Blocks(title="Audio8 TTS Preview Demo") as demo:
|
||||
gr.Markdown("# Audio8 TTS Preview 0.6B - Text to Speech")
|
||||
gr.Markdown(f"**Server:** `{api_base}` | **Model:** Audio8/Audio8-TTS-Preview-0.6b | **Output:** 44.1 kHz")
|
||||
|
||||
with gr.Row():
|
||||
with gr.Column(scale=3):
|
||||
text_input = gr.Textbox(label="Text to Synthesize", placeholder="Enter text here...", lines=4)
|
||||
|
||||
with gr.Accordion("Voice Cloning (optional)", open=False):
|
||||
gr.Markdown(
|
||||
"Upload or link a short reference recording (5-30 s) and paste its **exact** transcript. "
|
||||
"A transcript that does not match the audio degrades both stability and speaker similarity."
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
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"
|
||||
"- **Audio8 TTS Preview** — 0.6B DualAR model (slow AR + fast AR codebook decoder)\n"
|
||||
"- **Languages**: Cantonese, Chinese, Dutch, English, French, German, "
|
||||
"Italian, Japanese, Korean, Polish, Spanish\n"
|
||||
"- **Voice cloning**: zero-shot from a reference clip + transcript\n"
|
||||
"- **44.1 kHz** output, ~21.5 codec frames per second"
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
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() -> None:
|
||||
parser = argparse.ArgumentParser(description="Gradio demo for Audio8 TTS Preview")
|
||||
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")
|
||||
parser.add_argument("--port", type=int, default=7861, help="Gradio port")
|
||||
parser.add_argument("--share", action="store_true", help="Create a public share link")
|
||||
parser.add_argument("--stream-chunk-seconds", type=float, default=0.5, help="Seconds of audio per yielded chunk")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Connecting to vLLM server at: {args.api_base}")
|
||||
build_interface(args.api_base, stream_chunk_seconds=args.stream_chunk_seconds).launch(
|
||||
server_name=args.host, server_port=args.port, share=args.share
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
# Launch the Audio8 TTS Preview server + Gradio demo together.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_gradio_demo.sh
|
||||
# CUDA_VISIBLE_DEVICES=0 PORT=8092 GRADIO_PORT=7861 ./run_gradio_demo.sh
|
||||
|
||||
set -e
|
||||
|
||||
MODEL="${MODEL:-Audio8/Audio8-TTS-Preview-0.6b}"
|
||||
PORT="${PORT:-8092}"
|
||||
GRADIO_PORT="${GRADIO_PORT:-7861}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "Starting Audio8 TTS Preview server (port $PORT)..."
|
||||
vllm serve "$MODEL" \
|
||||
--omni \
|
||||
--host 0.0.0.0 \
|
||||
--port "$PORT" &
|
||||
SERVER_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping server (PID $SERVER_PID)..."
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
wait $SERVER_PID 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Waiting for server to start..."
|
||||
for _ 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"
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
# Launch a vLLM-Omni server for Audio8 TTS Preview 0.6B.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_server.sh
|
||||
# CUDA_VISIBLE_DEVICES=0 PORT=8092 ./run_server.sh
|
||||
|
||||
set -e
|
||||
|
||||
MODEL="${MODEL:-Audio8/Audio8-TTS-Preview-0.6b}"
|
||||
PORT="${PORT:-8092}"
|
||||
|
||||
echo "Starting Audio8 TTS Preview server with model: $MODEL"
|
||||
|
||||
# --trust-remote-code is deliberately NOT passed: vllm-omni registers its own
|
||||
# `arktts` config, and transformers would otherwise prefer the checkpoint's
|
||||
# remote code and bypass it.
|
||||
vllm serve "$MODEL" \
|
||||
--omni \
|
||||
--host 0.0.0.0 \
|
||||
--port "$PORT"
|
||||
@@ -0,0 +1,131 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Client for Audio8 TTS Preview via the /v1/audio/speech endpoint.
|
||||
|
||||
Examples:
|
||||
# Basic TTS
|
||||
python speech_client.py --text "Welcome to Audio8 TTS."
|
||||
|
||||
# Zero-shot voice cloning (the transcript must match the reference audio)
|
||||
python speech_client.py --text "Welcome to Audio8 TTS." \
|
||||
--ref-audio ref.wav --ref-text "The exact transcript of the reference recording."
|
||||
|
||||
# Streaming PCM output
|
||||
python speech_client.py --text "Welcome to Audio8 TTS." --stream --output output.pcm
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_API_BASE = "http://localhost:8092"
|
||||
DEFAULT_API_KEY = "EMPTY"
|
||||
DEFAULT_MODEL = "Audio8/Audio8-TTS-Preview-0.6b"
|
||||
#: The codec runs at 44.1 kHz, so raw PCM must be played back at that rate.
|
||||
PCM_SAMPLE_RATE = 44100
|
||||
|
||||
|
||||
def encode_audio_to_base64(audio_path: str) -> str:
|
||||
"""Encode a local audio file as a base64 data URL."""
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
ext = audio_path.lower().rsplit(".", 1)[-1]
|
||||
mime_type = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac", "ogg": "audio/ogg"}.get(
|
||||
ext, "audio/wav"
|
||||
)
|
||||
with open(audio_path, "rb") as handle:
|
||||
audio_b64 = base64.b64encode(handle.read()).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{audio_b64}"
|
||||
|
||||
|
||||
def build_payload(args) -> dict:
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"input": args.text,
|
||||
"response_format": args.response_format,
|
||||
}
|
||||
if args.ref_audio:
|
||||
if args.ref_audio.startswith(("http://", "https://")):
|
||||
payload["ref_audio"] = args.ref_audio
|
||||
else:
|
||||
payload["ref_audio"] = encode_audio_to_base64(args.ref_audio)
|
||||
if args.ref_text:
|
||||
payload["ref_text"] = args.ref_text
|
||||
if args.max_new_tokens is not None:
|
||||
payload["max_new_tokens"] = args.max_new_tokens
|
||||
if args.stream:
|
||||
payload["stream"] = True
|
||||
payload["stream_format"] = "audio"
|
||||
payload["response_format"] = "pcm"
|
||||
return payload
|
||||
|
||||
|
||||
def run_tts(args) -> None:
|
||||
payload = build_payload(args)
|
||||
api_url = f"{args.api_base}/v1/audio/speech"
|
||||
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {args.api_key}"}
|
||||
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Text: {args.text}")
|
||||
if args.ref_audio:
|
||||
print(f"Voice cloning: ref_audio={args.ref_audio!r} ref_text={args.ref_text!r}")
|
||||
|
||||
if args.stream:
|
||||
output_path = args.output or "output.pcm"
|
||||
with (
|
||||
httpx.Client(timeout=300.0) as client,
|
||||
client.stream("POST", api_url, json=payload, headers=headers) as resp,
|
||||
):
|
||||
if resp.status_code != 200:
|
||||
print(f"Error {resp.status_code}: {resp.read().decode()}")
|
||||
return
|
||||
total_bytes = 0
|
||||
with open(output_path, "wb") as handle:
|
||||
for chunk in resp.iter_bytes():
|
||||
handle.write(chunk)
|
||||
total_bytes += len(chunk)
|
||||
seconds = total_bytes / 2 / PCM_SAMPLE_RATE
|
||||
print(f"Streamed {total_bytes} bytes (~{seconds:.2f}s of s16le @ {PCM_SAMPLE_RATE} Hz) to {output_path}")
|
||||
print(f"Play with: ffplay -f s16le -ar {PCM_SAMPLE_RATE} -ac 1 {output_path}")
|
||||
return
|
||||
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
response = client.post(api_url, json=payload, headers=headers)
|
||||
if response.status_code != 200:
|
||||
print(f"Error {response.status_code}: {response.text}")
|
||||
return
|
||||
if response.headers.get("content-type", "").startswith("application/json"):
|
||||
print(f"Error: {response.text}")
|
||||
return
|
||||
|
||||
output_path = args.output or f"output.{args.response_format}"
|
||||
with open(output_path, "wb") as handle:
|
||||
handle.write(response.content)
|
||||
print(f"Audio saved to: {output_path} ({len(response.content)} bytes)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Audio8 TTS Preview client")
|
||||
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL")
|
||||
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key")
|
||||
parser.add_argument("--model", "-m", default=DEFAULT_MODEL, help="Model name")
|
||||
parser.add_argument("--text", required=True, help="Text to synthesize")
|
||||
parser.add_argument("--ref-audio", default=None, help="Reference audio for voice cloning (path or URL)")
|
||||
parser.add_argument("--ref-text", default=None, help="Transcript of the reference audio")
|
||||
parser.add_argument("--max-new-tokens", type=int, default=None, help="Cap the number of generated codec frames")
|
||||
parser.add_argument("--stream", action="store_true", help="Stream PCM instead of returning a whole file")
|
||||
parser.add_argument(
|
||||
"--response-format",
|
||||
default="wav",
|
||||
choices=["wav", "mp3", "flac", "pcm", "aac", "opus"],
|
||||
help="Audio format (default: wav)",
|
||||
)
|
||||
parser.add_argument("--output", "-o", default=None, help="Output file path")
|
||||
run_tts(parser.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""E2E offline tests for Audio8 TTS Preview (DualAR, 44.1 kHz codec).
|
||||
|
||||
The prompt is pre-tokenized here (not built from ``mm_processor_kwargs``)
|
||||
because Audio8 TTS shares Fish Speech's protocol: text-only prompts are plain
|
||||
token ids, and voice-clone prompts are a placeholder of the exact final length
|
||||
whose real embeddings are built model-side from the encoded reference audio.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniRunner, OmniRunnerHandler
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
MODEL = os.environ.get("AUDIO8_TTS_MODEL_PATH", "Audio8/Audio8-TTS-Preview-0.6b")
|
||||
DEPLOY_CONFIG = get_deploy_config_path("audio8_tts.yaml")
|
||||
SAMPLE_RATE = 44100
|
||||
TEXT = "The weather is nice today, perfect for a walk in the park."
|
||||
|
||||
# Four distinct inputs for the concurrency test: distinct text is required so a
|
||||
# leaked per-request codec buffer (which would make two requests decode to the
|
||||
# same audio) actually fails the pairwise-difference assertion.
|
||||
CONCURRENT_TEXTS = [
|
||||
"The weather is nice today, perfect for a walk in the park.",
|
||||
"She sold seashells by the seashore all through the summer.",
|
||||
"Our train departs at a quarter past nine tomorrow morning.",
|
||||
"He planted rows of tomatoes and basil behind the old house.",
|
||||
]
|
||||
|
||||
# The checkpoint registers its own `arktts` config in vllm-omni, so remote code
|
||||
# must stay disabled (transformers would otherwise prefer the checkpoint's
|
||||
# auto_map and bypass the Qwen2-shaped view Qwen2Model needs).
|
||||
_OMNI_RUNNER_PARAM = (MODEL, DEPLOY_CONFIG, {"trust_remote_code": False})
|
||||
|
||||
pytestmark = pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True)
|
||||
|
||||
|
||||
def _text_only_prompt(text: str = TEXT) -> dict:
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from vllm_omni.model_executor.models.audio8_tts.prompt_utils import build_text_only_prompt_ids
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(MODEL)
|
||||
prompt_ids, normalized_text = build_text_only_prompt_ids(tokenizer, text)
|
||||
return {"prompt_token_ids": prompt_ids, "additional_information": {"text": [normalized_text]}}
|
||||
|
||||
|
||||
# At ``core_model`` (L1/L2) the harness patches every stage to
|
||||
# ``load_format: dummy``, so the AR runs on random weights: it never emits EOS
|
||||
# and always generates to ``max_tokens`` (1024 frames ~= 47.5 s). Duration
|
||||
# bounds are therefore only meaningful once real weights load, at
|
||||
# ``advanced_model`` / ``full_model``.
|
||||
_REAL_WEIGHT_RUN_LEVELS = frozenset({"advanced_model", "full_model"})
|
||||
|
||||
|
||||
def _duration_bounds(run_level: str) -> dict[str, float]:
|
||||
if run_level not in _REAL_WEIGHT_RUN_LEVELS:
|
||||
return {}
|
||||
return {"min_duration_s": 0.5, "max_duration_s": 30.0}
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_text_to_audio_001(omni_runner: OmniRunner, run_level: str) -> None:
|
||||
"""Default deploy, single request, text-only synthesis.
|
||||
|
||||
Deploy Setting: audio8_tts.yaml
|
||||
Input Modal: text
|
||||
Output Modal: audio
|
||||
"""
|
||||
prompt = _text_only_prompt()
|
||||
request_config = {
|
||||
"input": TEXT,
|
||||
"prompt_token_ids": prompt["prompt_token_ids"],
|
||||
"additional_information": prompt["additional_information"],
|
||||
"response_format": "wav",
|
||||
"expected_sample_rate": SAMPLE_RATE,
|
||||
**_duration_bounds(run_level),
|
||||
}
|
||||
OmniRunnerHandler(omni_runner).send_tokenized_tts_request(request_config)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_text_to_audio_concurrent_002(omni_runner: OmniRunner, run_level: str) -> None:
|
||||
"""Four concurrent requests with distinct text: stage 0 runs ``max_num_seqs: 4``.
|
||||
|
||||
Per-request codec state is keyed by request id. The four inputs are
|
||||
deliberately distinct and the decoded outputs are asserted pairwise
|
||||
different, so leaked codec state (which would make two requests decode to
|
||||
identical audio) fails here, and only here.
|
||||
"""
|
||||
prompts = [_text_only_prompt(text) for text in CONCURRENT_TEXTS]
|
||||
request_config = {
|
||||
"input": CONCURRENT_TEXTS[0],
|
||||
"prompt_token_ids": [p["prompt_token_ids"] for p in prompts],
|
||||
"additional_information": [p["additional_information"] for p in prompts],
|
||||
"response_format": "wav",
|
||||
"expected_sample_rate": SAMPLE_RATE,
|
||||
"assert_distinct_outputs": True,
|
||||
**_duration_bounds(run_level),
|
||||
}
|
||||
OmniRunnerHandler(omni_runner).send_tokenized_tts_request(request_config)
|
||||
@@ -0,0 +1,166 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""E2E online tests for Audio8 TTS Preview via /v1/audio/speech.
|
||||
|
||||
Real model inference (no mocks): text-only synthesis, streaming, and zero-shot
|
||||
voice cloning. The reference audio is inlined as a base64 data URL because CI
|
||||
hosts cannot fetch external URLs.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.media import get_asset_path
|
||||
from tests.helpers.runtime import OmniServerParams
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
|
||||
MODEL = os.environ.get("AUDIO8_TTS_MODEL_PATH", "Audio8/Audio8-TTS-Preview-0.6b")
|
||||
DEFAULT_AUDIO_SPEECH_TIMEOUT_S = 300.0
|
||||
# The codec decodes at 44.1 kHz; raw PCM carries no header, so assertions that
|
||||
# depend on the rate (HNR pitch search) have to be told explicitly.
|
||||
SAMPLE_RATE = 44100
|
||||
MAX_CONCURRENT = 4
|
||||
|
||||
# ~0.5 s of 44.1 kHz mono PCM_16 WAV.
|
||||
_MIN_AUDIO_BYTES = 40_000
|
||||
|
||||
# Reused vendored asset (3.5 s, Chinese) plus its exact transcript: Audio8 TTS
|
||||
# requires the reference transcript to match the recording.
|
||||
REF_AUDIO_URL = get_asset_path("cosyvoice3/zero_shot_prompt.wav", as_data_url=True)
|
||||
REF_TEXT = "希望你以后能够做的比我还好呦。"
|
||||
|
||||
|
||||
def get_prompt(prompt_type: str = "en") -> str:
|
||||
prompts = {
|
||||
"en": "The weather is nice today, perfect for a walk in the park.",
|
||||
"zh": "今天天气很好,非常适合去公园散步。",
|
||||
}
|
||||
return prompts.get(prompt_type, prompts["en"])
|
||||
|
||||
|
||||
tts_server_params = [
|
||||
pytest.param(
|
||||
OmniServerParams(
|
||||
model=MODEL,
|
||||
stage_config_path=get_deploy_config_path("audio8_tts.yaml"),
|
||||
# No --trust-remote-code: vllm-omni owns the `arktts` config.
|
||||
server_args=["--disable-log-stats"],
|
||||
),
|
||||
id="audio8_tts",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_text_to_audio_001(omni_server, openai_client) -> None:
|
||||
"""Baseline smoke: default deploy, non-streaming WAV.
|
||||
|
||||
Deploy Setting: audio8_tts.yaml
|
||||
Input Modal: text
|
||||
Output Modal: audio
|
||||
Input Setting: stream=False
|
||||
Datasets: few requests (max_num_seqs=4)
|
||||
"""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"stream": False,
|
||||
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
|
||||
"response_format": "wav",
|
||||
"min_audio_bytes": _MIN_AUDIO_BYTES,
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config, request_num=MAX_CONCURRENT)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_text_to_audio_streaming_002(omni_server, openai_client) -> None:
|
||||
"""Streaming PCM: exercises the async_chunk path end to end.
|
||||
|
||||
Deploy Setting: audio8_tts.yaml (async_chunk: true)
|
||||
Input Setting: stream=True, response_format=pcm
|
||||
"""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
|
||||
"response_format": "pcm",
|
||||
"min_audio_bytes": _MIN_AUDIO_BYTES,
|
||||
# Raw PCM has no header: without this the harness assumes 24 kHz and the
|
||||
# HNR pitch search lands on 147-735 Hz instead of 80-400 Hz, scoring
|
||||
# clean 44.1 kHz speech ~1.9 dB too low.
|
||||
"expected_sample_rate": SAMPLE_RATE,
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_voice_clone_003(omni_server, openai_client) -> None:
|
||||
"""Zero-shot voice cloning: the reference audio is encoded model-side and
|
||||
its codec codes are spliced into the prompt.
|
||||
|
||||
Input Modal: text + ref_audio + ref_text
|
||||
"""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt("zh"),
|
||||
"ref_audio": REF_AUDIO_URL,
|
||||
"ref_text": REF_TEXT,
|
||||
"stream": False,
|
||||
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
|
||||
"response_format": "wav",
|
||||
"min_audio_bytes": _MIN_AUDIO_BYTES,
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_voice_clone_requires_ref_text_004(omni_server, openai_client) -> None:
|
||||
"""``ref_audio`` without ``ref_text`` must be rejected, not silently
|
||||
synthesized with an untethered voice."""
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"ref_audio": REF_AUDIO_URL,
|
||||
"stream": False,
|
||||
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
|
||||
"response_format": "wav",
|
||||
"status_code": (400, 422),
|
||||
"err_message": "ref_text",
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.tts
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||
def test_empty_input_is_rejected_005(omni_server, openai_client) -> None:
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
"input": " ",
|
||||
"stream": False,
|
||||
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
|
||||
"response_format": "wav",
|
||||
"status_code": (400, 422),
|
||||
"err_message": "Input text cannot be empty",
|
||||
}
|
||||
openai_client.send_audio_speech_request(request_config)
|
||||
@@ -0,0 +1,85 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Serving-side registration invariants for the Audio8 TTS adapter.
|
||||
|
||||
The engine never names ``audio8_tts`` directly: ``/v1/audio/speech`` resolves the
|
||||
adapter by matching the deployed stage's ``model_stage`` key against the
|
||||
``stage_keys`` each adapter declares. That makes the pipeline definition and the
|
||||
adapter two halves of one contract, with nothing in the type system holding them
|
||||
together -- rename either side and the endpoint silently stops recognising the
|
||||
model (falling through to "unknown TTS model") instead of failing at startup.
|
||||
|
||||
These tests pin the contract from both ends.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import (
|
||||
all_tts_model_types,
|
||||
all_tts_stage_keys,
|
||||
detect_tts_model_type,
|
||||
resolve_adapter,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.audio8_tts import Audio8TTSAdapter
|
||||
from vllm_omni.model_executor.models.audio8_tts.pipeline import AUDIO8_TTS_PIPELINE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
SLOW_AR_STAGE_KEY = "audio8_tts_slow_ar"
|
||||
|
||||
|
||||
def _pipeline_stage_keys() -> list[str]:
|
||||
return [stage.model_stage for stage in AUDIO8_TTS_PIPELINE.stages]
|
||||
|
||||
|
||||
def test_pipeline_stage_key_is_the_one_the_adapter_claims():
|
||||
"""The shipped pipeline must name the stage the adapter matches on."""
|
||||
assert SLOW_AR_STAGE_KEY in _pipeline_stage_keys()
|
||||
assert SLOW_AR_STAGE_KEY in Audio8TTSAdapter.stage_keys
|
||||
|
||||
|
||||
def test_detection_resolves_slow_ar_stage_to_the_audio8_adapter():
|
||||
assert detect_tts_model_type(SLOW_AR_STAGE_KEY, "Audio8TTSSlowARForConditionalGeneration") == "audio8_tts"
|
||||
assert resolve_adapter("audio8_tts") is Audio8TTSAdapter
|
||||
|
||||
|
||||
def test_adapter_is_registered_in_the_global_tables():
|
||||
"""Importing the package must register the adapter, not just define it."""
|
||||
assert "audio8_tts" in all_tts_model_types()
|
||||
assert SLOW_AR_STAGE_KEY in all_tts_stage_keys()
|
||||
|
||||
|
||||
def test_codec_decoder_stage_does_not_serve_speech():
|
||||
"""Only stage 0 answers /v1/audio/speech; the codec stage must not match."""
|
||||
codec_stage_key = _pipeline_stage_keys()[1]
|
||||
assert codec_stage_key != SLOW_AR_STAGE_KEY
|
||||
assert detect_tts_model_type(codec_stage_key, "Audio8TTSCodecDecoder") != "audio8_tts"
|
||||
|
||||
|
||||
def test_apply_sampling_overrides_defaults_tts_local_seed_for_fast_ar():
|
||||
"""The deploy seed must reach the Fast AR through ``tts_local_seed``.
|
||||
|
||||
Only the Slow AR is seeded via ``sampling_metadata.generators``; the residual
|
||||
codebooks read ``tts_local_seed`` and otherwise fall back to global RNG, which
|
||||
makes an unseeded request's audio depend on batch composition. The adapter
|
||||
defaults it from the stage-0 seed without clobbering an explicit value.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from vllm.sampling_params import SamplingParams
|
||||
|
||||
adapter = Audio8TTSAdapter.__new__(Audio8TTSAdapter)
|
||||
request = SimpleNamespace(max_new_tokens=None)
|
||||
|
||||
# Deploy default seed propagates to tts_local_seed.
|
||||
out = adapter.apply_sampling_overrides([SamplingParams(seed=42)], request)
|
||||
assert out[0].extra_args["tts_local_seed"] == 42
|
||||
|
||||
# An explicit tts_local_seed is left intact (setdefault, not overwrite).
|
||||
pinned = [SamplingParams(seed=42, extra_args={"tts_local_seed": 7})]
|
||||
assert adapter.apply_sampling_overrides(pinned, request)[0].extra_args["tts_local_seed"] == 7
|
||||
|
||||
# No seed at all -> nothing injected (non-deterministic by request contract).
|
||||
out_unseeded = adapter.apply_sampling_overrides([SamplingParams(seed=None)], request)
|
||||
assert (out_unseeded[0].extra_args or {}).get("tts_local_seed") is None
|
||||
@@ -132,6 +132,8 @@ _PIPELINE_STAGES = [
|
||||
"audex_thinker",
|
||||
"audex_tta_thinker",
|
||||
"audex_xcodec",
|
||||
"audio8_tts_codec_decoder",
|
||||
"audio8_tts_slow_ar",
|
||||
"audio_generation",
|
||||
"audio_tokenizer",
|
||||
"audio_vae",
|
||||
|
||||
@@ -659,7 +659,11 @@ def _compute_pcm_hnr_db(pcm_samples: np.ndarray, sr: int = _PCM_SPEECH_SAMPLE_RA
|
||||
return float(np.mean(hnr_values)) if hnr_values else 0.0
|
||||
|
||||
|
||||
def _assert_pcm_int16_speech_hnr(audio_bytes: bytes, min_hnr_db: float = _MIN_PCM_SPEECH_HNR_DB) -> None:
|
||||
def _assert_pcm_int16_speech_hnr(
|
||||
audio_bytes: bytes,
|
||||
min_hnr_db: float = _MIN_PCM_SPEECH_HNR_DB,
|
||||
sr: int = _PCM_SPEECH_SAMPLE_RATE_HZ,
|
||||
) -> None:
|
||||
"""Validate harmonic-to-noise ratio on raw int16 PCM from /v1/audio/speech.
|
||||
|
||||
min_hnr_db defaults to the global _MIN_PCM_SPEECH_HNR_DB (1.0 dB),
|
||||
@@ -668,12 +672,16 @@ def _assert_pcm_int16_speech_hnr(audio_bytes: bytes, min_hnr_db: float = _MIN_PC
|
||||
intrinsically around -2 dB) can pass a lower per-test threshold via
|
||||
request_config["min_hnr_db"] to keep the catastrophic-failure check
|
||||
while not gating CI on a model-intrinsic property.
|
||||
|
||||
``sr`` must be the stream's real sample rate: the autocorrelation lag window
|
||||
is derived from it, so a wrong rate detunes the 80-400 Hz pitch search and
|
||||
understates HNR for models that do not decode at 24 kHz.
|
||||
"""
|
||||
assert audio_bytes is not None and len(audio_bytes) >= 2, "missing PCM bytes"
|
||||
assert len(audio_bytes) % 2 == 0, "PCM byte length must be aligned to int16"
|
||||
pcm_samples = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
hnr = _compute_pcm_hnr_db(pcm_samples)
|
||||
print(f"PCM speech HNR: {hnr:.2f} dB (threshold: {min_hnr_db} dB)")
|
||||
hnr = _compute_pcm_hnr_db(pcm_samples, sr=sr)
|
||||
print(f"PCM speech HNR: {hnr:.2f} dB (threshold: {min_hnr_db} dB, sr={sr})")
|
||||
assert hnr >= min_hnr_db, (
|
||||
f"Audio distortion detected: HNR={hnr:.2f} dB < {min_hnr_db} dB. "
|
||||
"Voice clone decoder may be losing ref_code speaker context on later chunks."
|
||||
@@ -934,7 +942,15 @@ def assert_audio_speech_response(response: Any, request_config: dict[str, Any],
|
||||
if run_level in {"advanced_model", "full_model"}:
|
||||
if req_fmt == "pcm" and response.audio_bytes:
|
||||
min_hnr_db = float(request_config.get("min_hnr_db", _MIN_PCM_SPEECH_HNR_DB))
|
||||
_assert_pcm_int16_speech_hnr(response.audio_bytes, min_hnr_db=min_hnr_db)
|
||||
# Raw PCM carries no header, so the HNR pitch search has to be told
|
||||
# the rate. Defaulting to 24 kHz for a 44.1 kHz model shifts the
|
||||
# search window to 147-735 Hz and misses the fundamental entirely,
|
||||
# scoring clean speech ~1.9 dB lower than it is.
|
||||
_assert_pcm_int16_speech_hnr(
|
||||
response.audio_bytes,
|
||||
min_hnr_db=min_hnr_db,
|
||||
sr=int(request_config.get("expected_sample_rate") or _PCM_SPEECH_SAMPLE_RATE_HZ),
|
||||
)
|
||||
|
||||
transcript = _resolve_audio_transcript(response, request_config, run_level, speech_api=True)
|
||||
if transcript is not None:
|
||||
|
||||
@@ -12,6 +12,7 @@ import copy
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
@@ -2123,6 +2124,123 @@ class OfflineOmniClient:
|
||||
assert_audio_speech_response(result, request_config, run_level="core_model")
|
||||
return result
|
||||
|
||||
def send_tokenized_tts_request(self, request_config: dict[str, Any]) -> OmniResponse:
|
||||
"""Offline TTS for models whose engine prompt is *pre-tokenized*.
|
||||
|
||||
Fish-Speech-style DualAR models (and any model whose prompt embeds codec
|
||||
codes) cannot go through :meth:`send_audio_speech_request`: the prompt is
|
||||
built by the caller as ``prompt_token_ids`` plus per-request
|
||||
``additional_information``, not from ``mm_processor_kwargs``.
|
||||
|
||||
request_config keys:
|
||||
- ``prompt_token_ids``: ``list[int]`` or ``list[list[int]]`` (one entry
|
||||
per request).
|
||||
- ``additional_information``: dict, or list of dicts matching
|
||||
``prompt_token_ids``.
|
||||
- ``expected_sample_rate``: asserted against the reported ``sr``.
|
||||
- ``min_duration_s`` / ``max_duration_s``: bounds on decoded audio.
|
||||
- plus every key ``assert_audio_speech_response`` understands
|
||||
(``response_format``, ``min_audio_bytes``, ``input``, ...).
|
||||
"""
|
||||
token_ids = request_config.get("prompt_token_ids")
|
||||
if not token_ids:
|
||||
raise ValueError("request_config must contain 'prompt_token_ids'")
|
||||
batched_ids = token_ids if isinstance(token_ids[0], (list, tuple)) else [token_ids]
|
||||
|
||||
extra = request_config.get("additional_information")
|
||||
if extra is None:
|
||||
batched_extra: list[dict[str, Any] | None] = [None] * len(batched_ids)
|
||||
elif isinstance(extra, dict):
|
||||
batched_extra = [extra] * len(batched_ids)
|
||||
else:
|
||||
batched_extra = list(extra)
|
||||
if len(batched_extra) != len(batched_ids):
|
||||
raise ValueError("'additional_information' must match the number of prompts")
|
||||
|
||||
prompts: list[dict[str, Any]] = []
|
||||
for ids, info in zip(batched_ids, batched_extra, strict=True):
|
||||
prompt: dict[str, Any] = {"prompt_token_ids": list(ids)}
|
||||
if info is not None:
|
||||
prompt["additional_information"] = info
|
||||
prompts.append(prompt)
|
||||
|
||||
start_time = time.perf_counter()
|
||||
outputs = self.runner.omni.generate(prompts)
|
||||
e2e_latency = time.perf_counter() - start_time
|
||||
|
||||
assert len(outputs) == len(prompts), f"Expected {len(prompts)} outputs, got {len(outputs)}"
|
||||
expected_sr = request_config.get("expected_sample_rate")
|
||||
min_duration = request_config.get("min_duration_s")
|
||||
max_duration = request_config.get("max_duration_s")
|
||||
|
||||
result = OmniResponse()
|
||||
wavs: list[torch.Tensor] = []
|
||||
for index, stage_output in enumerate(outputs):
|
||||
assert stage_output.outputs, f"Request {index} produced no output"
|
||||
multimodal_output = stage_output.outputs[0].multimodal_output
|
||||
assert isinstance(multimodal_output, Mapping), (
|
||||
f"Request {index}: expected a mapping, got {type(multimodal_output)}"
|
||||
)
|
||||
|
||||
# Never use ``a or b`` here: truthiness on a tensor raises.
|
||||
audio = multimodal_output.get("model_outputs")
|
||||
if audio is None:
|
||||
audio = multimodal_output.get("audio")
|
||||
assert audio is not None, f"Request {index}: no audio key in {sorted(multimodal_output)}"
|
||||
if isinstance(audio, list):
|
||||
chunks = [torch.as_tensor(chunk).float().cpu().reshape(-1) for chunk in audio if chunk is not None]
|
||||
assert chunks, f"Request {index}: audio list contained no tensors"
|
||||
audio = torch.cat(chunks, dim=0)
|
||||
wav = torch.as_tensor(audio).float().cpu().reshape(-1)
|
||||
assert wav.numel() > 0, f"Request {index}: decoded audio is empty"
|
||||
wavs.append(wav)
|
||||
|
||||
sr_raw = multimodal_output.get("sr")
|
||||
if isinstance(sr_raw, list):
|
||||
sr_raw = sr_raw[-1] if sr_raw else None
|
||||
assert sr_raw is not None, f"Request {index}: missing sample rate"
|
||||
sample_rate = int(sr_raw.item() if hasattr(sr_raw, "item") else sr_raw)
|
||||
if expected_sr is not None:
|
||||
assert sample_rate == int(expected_sr), (
|
||||
f"Request {index}: expected sample rate {expected_sr}, got {sample_rate}"
|
||||
)
|
||||
|
||||
duration_s = wav.numel() / sample_rate
|
||||
if min_duration is not None:
|
||||
assert duration_s >= float(min_duration), (
|
||||
f"Request {index}: audio too short ({duration_s:.2f}s < {min_duration}s)"
|
||||
)
|
||||
if max_duration is not None:
|
||||
assert duration_s <= float(max_duration), (
|
||||
f"Request {index}: audio too long ({duration_s:.2f}s > {max_duration}s)"
|
||||
)
|
||||
|
||||
wav_buf = io.BytesIO()
|
||||
sf.write(wav_buf, wav.numpy(), samplerate=sample_rate, format="WAV", subtype="PCM_16")
|
||||
result = OmniResponse(
|
||||
success=True,
|
||||
audio_bytes=wav_buf.getvalue(),
|
||||
audio_format="audio/wav",
|
||||
e2e_latency=e2e_latency,
|
||||
)
|
||||
assert_audio_speech_response(result, request_config, run_level="core_model")
|
||||
|
||||
if request_config.get("assert_distinct_outputs") and len(wavs) > 1:
|
||||
# Distinct inputs must decode to distinct audio. Leaked per-request
|
||||
# codec state shows up as two requests producing (near-)identical
|
||||
# waveforms; different lengths already prove distinctness.
|
||||
for i in range(len(wavs)):
|
||||
for j in range(i + 1, len(wavs)):
|
||||
a, b = wavs[i], wavs[j]
|
||||
if a.numel() != b.numel():
|
||||
continue
|
||||
max_diff = (a - b).abs().max().item()
|
||||
assert max_diff > 1e-3, (
|
||||
f"Requests {i} and {j} produced near-identical audio "
|
||||
f"(max sample diff {max_diff:.2e}); possible codec-state crosstalk"
|
||||
)
|
||||
return result
|
||||
|
||||
def start_profile(self, profile_prefix: str | None = None, stages: list[int] | None = None) -> list[Any]:
|
||||
return self.runner.start_profile(profile_prefix=profile_prefix, stages=stages)
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,141 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import functools
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
NUM_CODEBOOKS = 10
|
||||
#: Samples the fake codec returns per frame, so trims are easy to assert.
|
||||
SAMPLES_PER_FRAME = 100
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _decoder_cls():
|
||||
from vllm_omni.model_executor.models.audio8_tts.audio8_tts_codec_decoder import Audio8TTSCodecDecoder
|
||||
|
||||
return Audio8TTSCodecDecoder
|
||||
|
||||
|
||||
class _FakeCodec:
|
||||
"""Returns ``SAMPLES_PER_FRAME`` ramp samples per frame."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[int, ...]] = []
|
||||
|
||||
def decode(self, codes_bqf: torch.Tensor) -> torch.Tensor:
|
||||
self.calls.append(tuple(codes_bqf.shape))
|
||||
frames = int(codes_bqf.shape[-1])
|
||||
total = frames * SAMPLES_PER_FRAME
|
||||
return torch.arange(total, dtype=torch.float32).view(1, 1, total)
|
||||
|
||||
|
||||
def _make_decoder(codec: _FakeCodec, num_codebooks: int = NUM_CODEBOOKS, splits=None):
|
||||
decoder = object.__new__(_decoder_cls())
|
||||
torch.nn.Module.__init__(decoder)
|
||||
decoder._codec = codec
|
||||
decoder._codec_device = torch.device("cpu")
|
||||
decoder._codec_dtype = torch.float32
|
||||
decoder._num_codebooks = num_codebooks
|
||||
decoder._sample_rate = 44100
|
||||
decoder._frame_size = 2048
|
||||
decoder._logged_stats = True
|
||||
decoder._ensure_codec_loaded = lambda: None
|
||||
if splits is not None:
|
||||
decoder._split_request_ids = lambda ids, seq_token_counts=None: splits
|
||||
return decoder
|
||||
|
||||
|
||||
def test_left_context_frames_are_trimmed_from_the_emitted_chunk():
|
||||
"""The decoder must emit *delta* audio: the left-context prefix exists only
|
||||
so the causal codec has history and must never reach the client twice."""
|
||||
codec = _FakeCodec()
|
||||
decoder = _make_decoder(codec)
|
||||
codes = torch.arange(NUM_CODEBOOKS * 4, dtype=torch.long).reshape(NUM_CODEBOOKS, 4)
|
||||
|
||||
out = decoder.forward(
|
||||
input_ids=torch.tensor([0], dtype=torch.long),
|
||||
runtime_additional_information=[{"codes": {"audio": codes}, "meta": {"left_context_size": 3}}],
|
||||
)
|
||||
|
||||
audios = out.multimodal_outputs["model_outputs"]
|
||||
assert len(audios) == 1
|
||||
# 4 frames decoded, 3 of context trimmed => 1 frame of new audio.
|
||||
assert audios[0].shape[0] == SAMPLES_PER_FRAME
|
||||
assert codec.calls == [(1, NUM_CODEBOOKS, 4)]
|
||||
|
||||
|
||||
def test_tensor_codes_from_runtime_info_win_over_input_ids():
|
||||
codec = _FakeCodec()
|
||||
decoder = _make_decoder(codec, num_codebooks=2)
|
||||
out = decoder.forward(
|
||||
input_ids=torch.zeros(64, dtype=torch.long),
|
||||
runtime_additional_information=[{"codes": {"audio": torch.tensor([[1, 2], [3, 4]], dtype=torch.long)}}],
|
||||
)
|
||||
assert codec.calls == [(1, 2, 2)]
|
||||
assert out.multimodal_outputs["model_outputs"][0].shape[0] == 2 * SAMPLES_PER_FRAME
|
||||
|
||||
|
||||
def test_flat_input_ids_are_reshaped_codebook_major():
|
||||
codec = _FakeCodec()
|
||||
decoder = _make_decoder(codec, num_codebooks=2)
|
||||
out = decoder.forward(input_ids=torch.arange(6, dtype=torch.long), runtime_additional_information=[{}])
|
||||
assert codec.calls == [(1, 2, 3)]
|
||||
assert out.multimodal_outputs["model_outputs"][0].shape[0] == 3 * SAMPLES_PER_FRAME
|
||||
|
||||
|
||||
def test_every_return_path_sets_model_outputs_and_sr():
|
||||
"""A branch that omits ``model_outputs`` makes the serving layer drop the
|
||||
chunk silently, so assert the key exists on all the early-exit paths."""
|
||||
codec = _FakeCodec()
|
||||
|
||||
empty = _make_decoder(codec).forward(input_ids=torch.zeros(0, dtype=torch.long))
|
||||
assert empty.multimodal_outputs["model_outputs"][0].numel() == 0
|
||||
assert empty.multimodal_outputs["sr"]
|
||||
|
||||
ragged = _make_decoder(codec, num_codebooks=4).forward(
|
||||
input_ids=torch.arange(6, dtype=torch.long), # 6 % 4 != 0
|
||||
runtime_additional_information=[{}],
|
||||
)
|
||||
assert ragged.multimodal_outputs["model_outputs"][0].numel() == 0
|
||||
|
||||
codes = torch.zeros((NUM_CODEBOOKS, 2), dtype=torch.long)
|
||||
no_new = _make_decoder(codec).forward(
|
||||
input_ids=torch.tensor([0], dtype=torch.long),
|
||||
runtime_additional_information=[{"codes": {"audio": codes}, "meta": {"left_context_size": 2}}],
|
||||
)
|
||||
assert no_new.multimodal_outputs["model_outputs"][0].numel() == 0
|
||||
assert codec.calls == [], "a context-only chunk must not be decoded"
|
||||
|
||||
|
||||
def test_mixed_batch_keeps_per_request_alignment():
|
||||
"""An empty request must not shift the following request's audio into its
|
||||
slot: the output list index is the request index."""
|
||||
codec = _FakeCodec()
|
||||
splits = [
|
||||
torch.empty((0,), dtype=torch.long),
|
||||
torch.arange(NUM_CODEBOOKS * 2, dtype=torch.long),
|
||||
]
|
||||
decoder = _make_decoder(codec, splits=splits)
|
||||
out = decoder.forward(
|
||||
input_ids=torch.arange(NUM_CODEBOOKS * 2, dtype=torch.long),
|
||||
runtime_additional_information=[{}, {"meta": {"left_context_size": 1}}],
|
||||
)
|
||||
audios = out.multimodal_outputs["model_outputs"]
|
||||
assert len(audios) == 2
|
||||
assert audios[0].numel() == 0
|
||||
assert audios[1].shape[0] == SAMPLES_PER_FRAME
|
||||
|
||||
|
||||
def test_codes_with_wrong_codebook_count_fall_back_to_input_ids():
|
||||
codec = _FakeCodec()
|
||||
decoder = _make_decoder(codec, num_codebooks=2)
|
||||
out = decoder.forward(
|
||||
input_ids=torch.arange(4, dtype=torch.long),
|
||||
runtime_additional_information=[{"codes": {"audio": torch.zeros((5, 3), dtype=torch.long)}}],
|
||||
)
|
||||
assert codec.calls == [(1, 2, 2)]
|
||||
assert out.multimodal_outputs["model_outputs"][0].shape[0] == 2 * SAMPLES_PER_FRAME
|
||||
@@ -0,0 +1,112 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.model_executor.models.audio8_tts.prompt_utils import (
|
||||
build_text_only_prompt_ids,
|
||||
build_voice_clone_prompt_ids,
|
||||
estimate_voice_clone_prompt_len,
|
||||
normalize_text,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _SegmentTokenizer:
|
||||
"""Tokenizer stub that maps each encoded string to one sentinel id.
|
||||
|
||||
Lets the tests assert the *segment sequence* the prompt builder emits, which
|
||||
is what must stay byte-identical to the reference ``ArkttsProcessor``.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.vocab: dict[str, int] = {}
|
||||
|
||||
def encode(self, text: str, add_special_tokens: bool = True):
|
||||
assert add_special_tokens is False, "Audio8 prompts must not add extra special tokens"
|
||||
return [self.vocab.setdefault(text, 1000 + len(self.vocab))]
|
||||
|
||||
def segment_of(self, token_id: int) -> str:
|
||||
return next(text for text, tid in self.vocab.items() if tid == token_id)
|
||||
|
||||
|
||||
def _segments(tokenizer: _SegmentTokenizer, ids: list[int]) -> list[str]:
|
||||
return [tokenizer.segment_of(i) for i in ids]
|
||||
|
||||
|
||||
def test_text_only_prompt_matches_reference_segment_order():
|
||||
tokenizer = _SegmentTokenizer()
|
||||
ids, normalized = build_text_only_prompt_ids(tokenizer, " Hello world ")
|
||||
|
||||
assert normalized == "Hello world"
|
||||
assert _segments(tokenizer, ids) == [
|
||||
"<|im_start|>system\n",
|
||||
"convert the provided text to speech",
|
||||
"<|im_end|>\n",
|
||||
"<|im_start|>user\n",
|
||||
"Hello world",
|
||||
"<|im_end|>\n",
|
||||
"<|im_start|>assistant\n<|voice|>",
|
||||
]
|
||||
|
||||
|
||||
def test_voice_clone_prompt_places_reference_codes_between_prefix_and_suffix():
|
||||
tokenizer = _SegmentTokenizer()
|
||||
semantic_ids = [151678, 151679, 151680]
|
||||
ids, ref_start, text, ref_text = build_voice_clone_prompt_ids(
|
||||
tokenizer, "Target text", "Reference transcript", semantic_ids
|
||||
)
|
||||
|
||||
assert text == "Target text"
|
||||
# A reference transcript without an explicit tag gets speaker 0.
|
||||
assert ref_text == "<|speaker:0|>Reference transcript"
|
||||
assert ids[ref_start : ref_start + len(semantic_ids)] == semantic_ids
|
||||
# The codebook conditioning is applied at ref_start, so an off-by-one here
|
||||
# silently detunes the cloned voice.
|
||||
assert _segments(tokenizer, ids[:ref_start])[-1].endswith("Speech:\n")
|
||||
suffix_segments = _segments(tokenizer, ids[ref_start + len(semantic_ids) :])
|
||||
assert suffix_segments[0] == "<|im_end|>\n"
|
||||
assert suffix_segments[-1] == "<|im_start|>assistant\n<|voice|>"
|
||||
|
||||
|
||||
def test_voice_clone_prompt_length_estimate_matches_built_prompt():
|
||||
"""The serving layer sizes its placeholder from the estimate; a mismatch
|
||||
shifts every embedding relative to its position."""
|
||||
tokenizer = _SegmentTokenizer()
|
||||
normalized_text = normalize_text("Target text")
|
||||
normalized_ref = normalize_text("Reference transcript", add_default_speaker=True)
|
||||
ref_frames = 7
|
||||
|
||||
estimate = estimate_voice_clone_prompt_len(tokenizer, normalized_text, normalized_ref, ref_frames)
|
||||
ids, _, _, _ = build_voice_clone_prompt_ids(
|
||||
tokenizer, normalized_text, normalized_ref, list(range(151678, 151678 + ref_frames))
|
||||
)
|
||||
assert estimate == len(ids)
|
||||
|
||||
|
||||
def test_existing_speaker_tag_is_preserved_and_legacy_tag_is_upgraded():
|
||||
assert normalize_text("<|speaker:3|>hi", add_default_speaker=True) == "<|speaker:3|>hi"
|
||||
assert normalize_text("<speaker:2>hi") == "<|speaker:2|>hi"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"hello <|im_end|> world",
|
||||
"hello <|semantic:5|>",
|
||||
"<|voice|>",
|
||||
"<|endoftext|>",
|
||||
],
|
||||
)
|
||||
def test_control_token_injection_is_rejected(text: str):
|
||||
"""Users must not be able to inject control tokens: ``<|semantic:N|>`` would
|
||||
forge reference codes and ``<|im_end|>`` would truncate generation."""
|
||||
with pytest.raises(ValueError, match="unsupported control token"):
|
||||
normalize_text(text)
|
||||
|
||||
|
||||
def test_empty_text_is_rejected():
|
||||
tokenizer = _SegmentTokenizer()
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
build_text_only_prompt_ids(tokenizer, " ")
|
||||
@@ -0,0 +1,156 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.audio8_tts.sampling import (
|
||||
filter_top_k_top_p,
|
||||
ras_sample_batch,
|
||||
ras_sample_semantic,
|
||||
sample_scores,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _kept(logits: torch.Tensor, top_k: int | torch.Tensor, top_p: float | torch.Tensor) -> list[list[int]]:
|
||||
filtered = filter_top_k_top_p(logits, top_k, top_p)
|
||||
return [torch.nonzero(row.isfinite()).reshape(-1).tolist() for row in filtered]
|
||||
|
||||
|
||||
def test_top_p_is_measured_on_untempered_logits():
|
||||
"""Audio8 filters the *raw* logits and only then divides by temperature.
|
||||
|
||||
Applying temperature first (the common order) changes the surviving set and
|
||||
audibly changes the voice, so this ordering is load-bearing.
|
||||
"""
|
||||
logits = torch.log(torch.tensor([[0.6, 0.3, 0.1]]))
|
||||
# Raw softmax is exactly (0.6, 0.3, 0.1): top_p=0.9 keeps the first two
|
||||
# (cumsum 0.6, 0.9) and drops the third (cumsum 1.0 > 0.9).
|
||||
assert _kept(logits, top_k=0, top_p=0.9) == [[0, 1]]
|
||||
# Had the logits been tempered by 0.5 first, probabilities would become
|
||||
# (0.7insh, ...) and only one token would survive.
|
||||
tempered = _kept(logits / 0.5, top_k=0, top_p=0.9)
|
||||
assert tempered == [[0, 1]] or tempered == [[0]]
|
||||
|
||||
|
||||
def test_argmax_always_survives_filtering():
|
||||
logits = torch.tensor([[10.0, -1.0, -2.0]])
|
||||
# top_p=0.0 would otherwise remove every candidate and produce NaNs.
|
||||
assert _kept(logits, top_k=1, top_p=0.0) == [[0]]
|
||||
|
||||
|
||||
def test_per_row_top_k_and_top_p_are_honoured():
|
||||
logits = torch.log(torch.tensor([[0.5, 0.3, 0.15, 0.05], [0.5, 0.3, 0.15, 0.05]]))
|
||||
kept = _kept(logits, top_k=torch.tensor([1, 3]), top_p=torch.tensor([1.0, 1.0]))
|
||||
assert kept == [[0], [0, 1, 2]]
|
||||
|
||||
|
||||
def test_zero_temperature_row_falls_back_to_argmax():
|
||||
logits = torch.tensor([[1.0, 5.0, 2.0], [1.0, 5.0, 2.0]])
|
||||
out = sample_scores(
|
||||
logits,
|
||||
temperature=torch.tensor([0.0, 1.0]),
|
||||
top_k=0,
|
||||
top_p=1.0,
|
||||
generator=torch.Generator().manual_seed(0),
|
||||
)
|
||||
assert int(out[0]) == 1
|
||||
|
||||
|
||||
def test_ras_substitutes_only_repeated_semantic_ids():
|
||||
"""RAS re-rolls a repeat from a flatter distribution; the EOS slot and
|
||||
non-repeats must pass through untouched, or utterances get truncated."""
|
||||
num_semantic = 2
|
||||
# Row 0 will draw id 0 (a repeat) -> substituted.
|
||||
# Row 1 will draw id 2 (the EOS slot) -> never substituted even if recent.
|
||||
logits = torch.tensor(
|
||||
[
|
||||
[10.0, -20.0, -20.0],
|
||||
[-20.0, -20.0, 10.0],
|
||||
]
|
||||
)
|
||||
recent = torch.tensor([[0, -1], [2, -1]])
|
||||
out = ras_sample_semantic(
|
||||
logits,
|
||||
recent,
|
||||
temperature=1e-6, # deterministic: argmax within each draw
|
||||
top_k=0,
|
||||
top_p=1.0,
|
||||
ras_temperature=1e-6,
|
||||
ras_top_p=1.0,
|
||||
num_semantic_ids=num_semantic,
|
||||
)
|
||||
# Both draws are argmax, so substitution is observable only through which
|
||||
# branch was taken; assert the EOS row is preserved verbatim.
|
||||
assert int(out[1]) == 2
|
||||
assert int(out[0]) == 0
|
||||
|
||||
|
||||
def test_ras_without_history_returns_the_plain_draw():
|
||||
logits = torch.tensor([[10.0, 0.0, -5.0]])
|
||||
out = ras_sample_semantic(
|
||||
logits,
|
||||
None,
|
||||
temperature=1e-6,
|
||||
top_k=0,
|
||||
top_p=1.0,
|
||||
ras_temperature=1.0,
|
||||
ras_top_p=0.9,
|
||||
num_semantic_ids=2,
|
||||
)
|
||||
assert int(out[0]) == 0
|
||||
|
||||
|
||||
def test_sampling_is_reproducible_for_a_seeded_generator():
|
||||
logits = torch.randn(4, 32, generator=torch.Generator().manual_seed(7))
|
||||
first = sample_scores(logits, temperature=0.7, top_k=8, top_p=0.9, generator=torch.Generator().manual_seed(1234))
|
||||
second = sample_scores(logits, temperature=0.7, top_k=8, top_p=0.9, generator=torch.Generator().manual_seed(1234))
|
||||
assert torch.equal(first, second)
|
||||
|
||||
|
||||
def test_batch_routes_each_row_to_its_own_generator():
|
||||
"""Regression for the shared slot-0 generator bug.
|
||||
|
||||
A shared generator over ``[num_reqs, vocab]`` made every row draw from slot
|
||||
0's RNG stream, so a request's audio depended on its batch neighbours and
|
||||
per-request ``seed`` was ignored for slots > 0. Flat logits make the draw
|
||||
purely noise-driven so the wrong generator changes the token.
|
||||
"""
|
||||
# Flat logits: the sampled id is determined only by the generator's noise,
|
||||
# so routing the generator to the wrong slot changes the result (coincidence ~1/256).
|
||||
logits = torch.zeros(2, 256)
|
||||
kw = dict(temperature=1.0, top_k=0, top_p=1.0, ras_temperature=1.0, ras_top_p=0.9, num_semantic_ids=200)
|
||||
|
||||
# (1) Each batched row equals sampling that row alone with its own generator.
|
||||
batched = ras_sample_batch(
|
||||
logits,
|
||||
None,
|
||||
generators={0: torch.Generator().manual_seed(11), 1: torch.Generator().manual_seed(22)},
|
||||
num_reqs=2,
|
||||
**kw,
|
||||
)
|
||||
row0 = ras_sample_semantic(logits[0:1], None, generator=torch.Generator().manual_seed(11), **kw)
|
||||
row1 = ras_sample_semantic(logits[1:2], None, generator=torch.Generator().manual_seed(22), **kw)
|
||||
assert int(batched[0]) == int(row0)
|
||||
assert int(batched[1]) == int(row1)
|
||||
|
||||
# (2) Row 1 must not change when only slot 0's seed changes.
|
||||
other = ras_sample_batch(
|
||||
logits,
|
||||
None,
|
||||
generators={0: torch.Generator().manual_seed(9999), 1: torch.Generator().manual_seed(22)},
|
||||
num_reqs=2,
|
||||
**kw,
|
||||
)
|
||||
assert int(other[1]) == int(batched[1])
|
||||
|
||||
|
||||
def test_single_request_batch_keeps_the_fast_path():
|
||||
"""``num_reqs == 1`` must match a direct single-row draw (no per-row loop)."""
|
||||
logits = torch.randn(1, 48, generator=torch.Generator().manual_seed(5))
|
||||
kw = dict(temperature=0.8, top_k=16, top_p=0.9, ras_temperature=1.0, ras_top_p=0.9, num_semantic_ids=40)
|
||||
batched = ras_sample_batch(logits, None, generators={0: torch.Generator().manual_seed(99)}, num_reqs=1, **kw)
|
||||
direct = ras_sample_semantic(logits, None, generator=torch.Generator().manual_seed(99), **kw)
|
||||
assert torch.equal(batched, direct)
|
||||
@@ -0,0 +1,163 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.audio8_tts import (
|
||||
extract_last_frame,
|
||||
slow_ar_to_codec_decoder_async_chunk,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
NUM_CODEBOOKS = 3
|
||||
|
||||
|
||||
def _make_transfer_manager(*, chunk_frames=2, left_context=1, initial_chunk_frames=0):
|
||||
return SimpleNamespace(
|
||||
code_prompt_token_ids=defaultdict(list),
|
||||
put_req_chunk=defaultdict(int),
|
||||
connector=SimpleNamespace(
|
||||
config={
|
||||
"extra": {
|
||||
"codec_chunk_frames": chunk_frames,
|
||||
"codec_left_context_frames": left_context,
|
||||
"initial_codec_chunk_frames": initial_chunk_frames,
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_request(req_id="req", *, finished=False):
|
||||
return SimpleNamespace(
|
||||
external_req_id=req_id,
|
||||
additional_information=None,
|
||||
is_finished=lambda: finished,
|
||||
)
|
||||
|
||||
|
||||
def _frame(value: int) -> torch.Tensor:
|
||||
return torch.full((NUM_CODEBOOKS,), value, dtype=torch.long)
|
||||
|
||||
|
||||
def _step(transfer_manager, request, frame_value: int | None, *, finished: bool = False):
|
||||
multimodal_output = {} if frame_value is None else {"audio_codes": _frame(frame_value).unsqueeze(0)}
|
||||
return slow_ar_to_codec_decoder_async_chunk(transfer_manager, multimodal_output, request, is_finished=finished)
|
||||
|
||||
|
||||
def test_codes_accumulate_across_steps_and_chunk_is_withheld_until_full():
|
||||
"""Codes must accumulate; emitting per-step would starve the codec of the
|
||||
context it needs and resetting would truncate the utterance."""
|
||||
transfer_manager = _make_transfer_manager(chunk_frames=2, left_context=1)
|
||||
request = _make_request()
|
||||
|
||||
assert _step(transfer_manager, request, 1) is None
|
||||
payload = _step(transfer_manager, request, 2)
|
||||
|
||||
assert payload is not None
|
||||
assert len(transfer_manager.code_prompt_token_ids["req"]) == 2
|
||||
# [num_codebooks, frames], codebook-major.
|
||||
assert tuple(payload.codes.audio.shape) == (NUM_CODEBOOKS, 2)
|
||||
assert payload.codes.audio[0].tolist() == [1, 2]
|
||||
# Nothing precedes the first chunk, so there is no left context yet.
|
||||
assert payload.meta.left_context_size == 0
|
||||
|
||||
|
||||
def test_second_chunk_carries_left_context_and_reports_its_size():
|
||||
transfer_manager = _make_transfer_manager(chunk_frames=2, left_context=1)
|
||||
request = _make_request()
|
||||
for value in (1, 2, 3):
|
||||
_step(transfer_manager, request, value)
|
||||
payload = _step(transfer_manager, request, 4)
|
||||
|
||||
assert payload is not None
|
||||
# 1 context frame + 2 new frames.
|
||||
assert tuple(payload.codes.audio.shape) == (NUM_CODEBOOKS, 3)
|
||||
assert payload.codes.audio[0].tolist() == [2, 3, 4]
|
||||
assert payload.meta.left_context_size == 1
|
||||
|
||||
|
||||
def test_partial_tail_is_flushed_when_the_request_finishes():
|
||||
transfer_manager = _make_transfer_manager(chunk_frames=4, left_context=2)
|
||||
request = _make_request()
|
||||
_step(transfer_manager, request, 1)
|
||||
_step(transfer_manager, request, 2)
|
||||
payload = _step(transfer_manager, request, 3, finished=True)
|
||||
|
||||
assert payload is not None
|
||||
assert payload.codes.audio[0].tolist() == [1, 2, 3]
|
||||
assert bool(payload.meta.finished) is True
|
||||
|
||||
|
||||
def test_finished_request_without_any_frame_emits_a_terminal_payload():
|
||||
transfer_manager = _make_transfer_manager()
|
||||
payload = slow_ar_to_codec_decoder_async_chunk(
|
||||
transfer_manager, None, _make_request(finished=True), is_finished=True
|
||||
)
|
||||
assert payload is not None
|
||||
assert payload.codes.audio.numel() == 0
|
||||
assert bool(payload.meta.finished) is True
|
||||
|
||||
|
||||
def test_terminal_call_on_a_boundary_does_not_re_emit_the_last_chunk():
|
||||
"""A finished call carrying no new frame, arriving when the stream sits
|
||||
exactly on a chunk boundary, must close the stream rather than re-ship the
|
||||
last window. Before the guard, the final chunk_frames frames shipped twice."""
|
||||
transfer_manager = _make_transfer_manager(chunk_frames=2, left_context=1)
|
||||
request = _make_request()
|
||||
for value in (1, 2, 3, 4): # length ends exactly on a chunk boundary
|
||||
_step(transfer_manager, request, value)
|
||||
# Separate terminal call, no new frame: the boundary chunk is already sent.
|
||||
payload = _step(transfer_manager, request, None, finished=True)
|
||||
assert payload is not None
|
||||
assert payload.codes.audio.numel() == 0
|
||||
assert bool(payload.meta.finished) is True
|
||||
|
||||
|
||||
def test_final_frame_on_a_boundary_still_emits_when_it_arrives_finished():
|
||||
"""If the last frame both lands on a boundary and carries finished, its chunk
|
||||
has not been shipped yet, so it must still be emitted (the guard must key on
|
||||
'no new frame this call', not merely on being finished at a boundary)."""
|
||||
transfer_manager = _make_transfer_manager(chunk_frames=2, left_context=1)
|
||||
request = _make_request()
|
||||
_step(transfer_manager, request, 1)
|
||||
payload = _step(transfer_manager, request, 2, finished=True)
|
||||
assert payload is not None
|
||||
assert payload.codes.audio[0].tolist() == [1, 2]
|
||||
assert bool(payload.meta.finished) is True
|
||||
|
||||
|
||||
def test_initial_chunk_shortens_time_to_first_audio():
|
||||
transfer_manager = _make_transfer_manager(chunk_frames=4, left_context=2, initial_chunk_frames=1)
|
||||
request = _make_request()
|
||||
payload = _step(transfer_manager, request, 1)
|
||||
|
||||
assert payload is not None
|
||||
assert payload.codes.audio[0].tolist() == [1]
|
||||
assert payload.meta.left_context_size == 0
|
||||
|
||||
|
||||
def test_prefill_placeholder_frames_are_not_forwarded():
|
||||
"""Prefill emits an all-zero placeholder frame per position; forwarding it
|
||||
would prepend silence and desynchronise the chunk boundaries."""
|
||||
assert extract_last_frame({"audio_codes": torch.zeros((3, NUM_CODEBOOKS), dtype=torch.long)}) is None
|
||||
assert extract_last_frame({}) is None
|
||||
assert extract_last_frame({"audio_codes": torch.zeros((0, NUM_CODEBOOKS), dtype=torch.long)}) is None
|
||||
|
||||
|
||||
def test_explicit_validity_flag_overrides_the_all_zero_heuristic():
|
||||
frame = torch.zeros((1, NUM_CODEBOOKS), dtype=torch.long)
|
||||
out = extract_last_frame({"audio_codes": frame, "audio_code_valid": torch.tensor([True])})
|
||||
assert out is not None and out.tolist() == [0] * NUM_CODEBOOKS
|
||||
|
||||
|
||||
def test_invalid_chunk_config_fails_loudly():
|
||||
transfer_manager = _make_transfer_manager(chunk_frames=0)
|
||||
request = _make_request()
|
||||
with pytest.raises(ValueError, match="codec chunk config"):
|
||||
_step(transfer_manager, request, 1)
|
||||
@@ -1829,6 +1829,7 @@ class TestPreferModelSamplerNoneFallback:
|
||||
if _declares_prefer_model_sampler(p.read_text(encoding="utf-8", errors="ignore"))
|
||||
}
|
||||
expected = {
|
||||
"audio8_tts",
|
||||
"cosyvoice3",
|
||||
"glm_tts",
|
||||
"higgs_audio_v2",
|
||||
|
||||
@@ -39,6 +39,7 @@ from vllm_omni.model_executor.models.audex.pipeline import (
|
||||
AUDEX_TTA_PIPELINE,
|
||||
AUDEX_TTS_PIPELINE,
|
||||
)
|
||||
from vllm_omni.model_executor.models.audio8_tts.pipeline import AUDIO8_TTS_PIPELINE
|
||||
from vllm_omni.model_executor.models.aura_omni.pipeline import AURA_OMNI_PIPELINE
|
||||
from vllm_omni.model_executor.models.bagel.pipeline import (
|
||||
BAGEL_PIPELINE,
|
||||
@@ -172,6 +173,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
|
||||
"voxtral_tts": VOXTRAL_TTS_PIPELINE,
|
||||
"glm_tts": GLM_TTS_PIPELINE,
|
||||
"fish_qwen3_omni": FISH_SPEECH_PIPELINE,
|
||||
"arktts": AUDIO8_TTS_PIPELINE,
|
||||
"ming_flash_omni": MING_FLASH_OMNI_PIPELINE,
|
||||
"ming_flash_omni_tts": MING_FLASH_OMNI_TTS_PIPELINE,
|
||||
"ming_flash_omni_thinker_only": MING_FLASH_OMNI_THINKER_ONLY_PIPELINE,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Audio8 TTS Preview 0.6B deploy: async-chunk streaming slow_ar -> codec_decoder.
|
||||
# Model: Audio8/Audio8-TTS-Preview-0.6b (model_type = "arktts").
|
||||
#
|
||||
# trust_remote_code is deliberately OFF: the checkpoint ships an `auto_map`, and
|
||||
# transformers prefers remote code over registered classes when it is enabled,
|
||||
# which would bypass vllm-omni's Audio8TTSConfig (the Qwen2-shaped view that
|
||||
# Qwen2Model needs). The tokenizer is a plain PreTrainedTokenizerFast and needs
|
||||
# no remote code either.
|
||||
async_chunk: true
|
||||
enable_chunked_prefill: false
|
||||
|
||||
connectors:
|
||||
connector_of_shared_memory:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
codec_streaming: true
|
||||
connector_get_sleep_s: 0.01
|
||||
connector_get_max_wait_first_chunk: 3000
|
||||
connector_get_max_wait: 300
|
||||
# ~21.5 Hz codec; 25 frames ~= 1.16 s of audio.
|
||||
codec_chunk_frames: 25
|
||||
codec_left_context_frames: 25
|
||||
initial_codec_chunk_frames: 4
|
||||
# float32 keeps the Snake activations / weight-normed convs clean.
|
||||
audio8_tts_codec_dtype: float32
|
||||
|
||||
stages:
|
||||
- stage_id: 0
|
||||
# >= 4 so concurrent requests pipeline; a default of 1 starves the codec
|
||||
# window and produces audible gaps under load.
|
||||
#
|
||||
# Measured on 1x H20 (20 prompts, mean audio 4.5 s): at client concurrency 8,
|
||||
# raising this to 8 buys +27% throughput and 4.5x lower first-packet latency
|
||||
# (1124 ms -> 247 ms) at the cost of a 0.22 s worst-case buffer deficit in
|
||||
# 1 of 20 requests. 4 is the default because every measured point here had
|
||||
# zero underrun; see examples/online_serving/text_to_speech/README.md.
|
||||
max_num_seqs: 4
|
||||
# 0.6B weights + a 2048-token KV cache need very little: sized for a small
|
||||
# slice of the GPU so the codec stage and other tenants still fit.
|
||||
gpu_memory_utilization: 0.2
|
||||
enforce_eager: false
|
||||
trust_remote_code: false
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: false
|
||||
# The checkpoint's max_seq_len is 2048 packed text+audio positions; with
|
||||
# chunked prefill off, max_num_batched_tokens must be >= max_model_len.
|
||||
max_model_len: 2048
|
||||
max_num_batched_tokens: 2048
|
||||
devices: "0"
|
||||
output_connectors:
|
||||
to_stage_1: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
# generation_config.json of the released checkpoint.
|
||||
temperature: 0.7
|
||||
top_k: 50
|
||||
top_p: 0.9
|
||||
max_tokens: 1024
|
||||
seed: 42
|
||||
repetition_penalty: 1.0
|
||||
|
||||
- stage_id: 1
|
||||
max_num_seqs: 1
|
||||
# The codec is ~1.4 GB of weights plus per-chunk activations.
|
||||
gpu_memory_utilization: 0.1
|
||||
enforce_eager: true
|
||||
trust_remote_code: false
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: false
|
||||
max_model_len: 2048
|
||||
max_num_batched_tokens: 2048
|
||||
devices: "0"
|
||||
input_connectors:
|
||||
from_stage_0: connector_of_shared_memory
|
||||
default_sampling_params:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
max_tokens: 65536
|
||||
seed: 42
|
||||
repetition_penalty: 1.0
|
||||
@@ -712,6 +712,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
if self._tts_model_type in (
|
||||
"cosyvoice3",
|
||||
"fish_tts",
|
||||
"audio8_tts",
|
||||
"omnivoice",
|
||||
"moss_tts_nano",
|
||||
"glm_tts",
|
||||
@@ -721,6 +722,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
label = {
|
||||
"cosyvoice3": "CosyVoice3",
|
||||
"fish_tts": "Fish Speech",
|
||||
"audio8_tts": "Audio8 TTS",
|
||||
"omnivoice": "OmniVoice",
|
||||
"moss_tts_nano": "MOSS-TTS-Nano",
|
||||
"higgs_audio_v2": "Higgs-Audio V2",
|
||||
|
||||
@@ -104,6 +104,7 @@ def tts_entry_stage_archs() -> frozenset[str]:
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import ( # noqa: E402,F401
|
||||
audex,
|
||||
audex_tta,
|
||||
audio8_tts,
|
||||
cosyvoice3,
|
||||
covo_audio,
|
||||
dots_tts,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS Preview serving adapter for ``/v1/audio/speech``."""
|
||||
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from vllm.inputs import tokens_input
|
||||
from vllm.utils.async_utils import make_async
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.base import (
|
||||
ARTTSAdapter,
|
||||
PreparedRequest,
|
||||
apply_max_new_tokens,
|
||||
conditioning_cache_salt,
|
||||
)
|
||||
from vllm_omni.model_executor.models.audio8_tts.codec_utils import (
|
||||
estimate_reference_code_frames,
|
||||
)
|
||||
from vllm_omni.model_executor.models.audio8_tts.prompt_utils import (
|
||||
build_text_only_prompt_ids,
|
||||
estimate_voice_clone_prompt_len,
|
||||
normalize_text,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
||||
|
||||
|
||||
@register_tts_adapter
|
||||
class Audio8TTSAdapter(ARTTSAdapter):
|
||||
"""Audio8 TTS Preview 0.6B: text-only synthesis and zero-shot voice cloning."""
|
||||
|
||||
stage_keys = frozenset({"audio8_tts_slow_ar"})
|
||||
name = "audio8_tts"
|
||||
|
||||
def __init__(self, ctx: Any) -> None:
|
||||
super().__init__(ctx)
|
||||
self._cached_tokenizer: Any = None
|
||||
|
||||
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
|
||||
server = self.ctx.server
|
||||
err = server._apply_uploaded_speaker(request)
|
||||
if err:
|
||||
return err
|
||||
if not request.input or not request.input.strip():
|
||||
return "Input text cannot be empty"
|
||||
|
||||
if request.ref_audio is not None:
|
||||
fmt_err = server._validate_ref_audio_format(request.ref_audio)
|
||||
if fmt_err:
|
||||
return fmt_err
|
||||
if not request.ref_text or not request.ref_text.strip():
|
||||
return "Voice cloning requires 'ref_text' (transcript of the reference audio)"
|
||||
|
||||
if request.max_new_tokens is not None:
|
||||
if request.max_new_tokens < self.max_new_tokens_min:
|
||||
return f"max_new_tokens must be at least {self.max_new_tokens_min}"
|
||||
if request.max_new_tokens > self.max_new_tokens_max:
|
||||
return f"max_new_tokens cannot exceed {self.max_new_tokens_max}"
|
||||
|
||||
return None
|
||||
|
||||
def apply_sampling_overrides(
|
||||
self,
|
||||
sampling_params_list: list,
|
||||
request: "OpenAICreateSpeechRequest",
|
||||
prompt: dict[str, Any] | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> list:
|
||||
sampling_params_list = apply_max_new_tokens(sampling_params_list, request)
|
||||
# Seed the Fast AR (talker_mtp) residual sampling per request. The Slow AR
|
||||
# is already seeded via sampling_metadata.generators, but the residual
|
||||
# codebooks read tts_local_seed; without it they fall back to global RNG,
|
||||
# so an unseeded request's audio would depend on batch composition. Default
|
||||
# it from the deploy seed; an explicit request.seed still overrides it
|
||||
# downstream. Mirrors how serving handles qwen3_tts.
|
||||
if sampling_params_list:
|
||||
default_seed = sampling_params_list[0].seed
|
||||
if default_seed is not None:
|
||||
sampling_params_list = copy.deepcopy(sampling_params_list)
|
||||
stage0 = sampling_params_list[0]
|
||||
if stage0.extra_args is None:
|
||||
stage0.extra_args = {}
|
||||
stage0.extra_args.setdefault("tts_local_seed", int(default_seed))
|
||||
return sampling_params_list
|
||||
|
||||
async def build(
|
||||
self,
|
||||
request: "OpenAICreateSpeechRequest",
|
||||
sampling_params_list: list,
|
||||
has_inline_ref_audio: bool,
|
||||
) -> PreparedRequest:
|
||||
del sampling_params_list
|
||||
server = self.ctx.server
|
||||
ref_audio_data = None
|
||||
if request.ref_audio is not None:
|
||||
wav_list, sample_rate = await server._resolve_ref_audio(request.ref_audio)
|
||||
ref_audio_data = (wav_list, sample_rate)
|
||||
# Prompt building tokenizes and, for voice clone, allocates tensors; keep
|
||||
# it off the event loop via the server's single-worker TTS executor.
|
||||
build_prompt = make_async(self._build_prompt, executor=server._tts_executor)
|
||||
prompt = await build_prompt(
|
||||
request,
|
||||
ref_audio_data=ref_audio_data,
|
||||
has_inline_ref_audio=has_inline_ref_audio,
|
||||
)
|
||||
tts_params: dict = {}
|
||||
prompt["cache_salt"] = conditioning_cache_salt(request, tts_params)
|
||||
return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type=self.name)
|
||||
|
||||
def _tokenizer(self) -> Any:
|
||||
"""The Audio8 tokenizer, loaded once per process."""
|
||||
if self._cached_tokenizer is None:
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
model_name = self.ctx.server.engine_client.model_config.model
|
||||
self._cached_tokenizer = AutoTokenizer.from_pretrained(model_name)
|
||||
return self._cached_tokenizer
|
||||
|
||||
def _estimate_prompt_len(self, text: str, ref_text: str, ref_audio: object) -> int:
|
||||
"""Exact clone-prompt length, without encoding the reference audio.
|
||||
|
||||
Deliberately not defensive: the placeholder this sizes must match the
|
||||
length ``preprocess()`` actually builds, so a wrong guess corrupts the
|
||||
request instead of failing it. Let errors surface to the caller.
|
||||
"""
|
||||
if not isinstance(ref_audio, (list, tuple)) or len(ref_audio) != 2:
|
||||
raise ValueError("Audio8 TTS reference audio must be a (samples, sample_rate) pair")
|
||||
wav, sample_rate = ref_audio
|
||||
ref_frames = estimate_reference_code_frames(len(wav), int(sample_rate))
|
||||
return estimate_voice_clone_prompt_len(self._tokenizer(), text, ref_text, ref_frames)
|
||||
|
||||
def _build_prompt(
|
||||
self,
|
||||
request: "OpenAICreateSpeechRequest",
|
||||
ref_audio_data: tuple[list[float], int] | None = None,
|
||||
*,
|
||||
has_inline_ref_audio: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the engine prompt for Audio8 TTS Preview.
|
||||
|
||||
Text-only prompts are tokenized here. Voice cloning cannot be: the
|
||||
prompt embeds the reference audio's own codec codes, so the model-side
|
||||
``preprocess`` builds it and this path only reserves a placeholder of the
|
||||
exact final length.
|
||||
"""
|
||||
server = self.ctx.server
|
||||
if ref_audio_data is None or not request.ref_text:
|
||||
prompt_ids, normalized_text = build_text_only_prompt_ids(self._tokenizer(), request.input)
|
||||
# Scalars are list-wrapped for the text-only path, matching the
|
||||
# other TTS entrypoints' additional_information shape.
|
||||
additional_information: dict[str, Any] = {"text": [normalized_text]}
|
||||
if request.max_new_tokens is not None:
|
||||
additional_information["max_new_tokens"] = [request.max_new_tokens]
|
||||
prompt = tokens_input(prompt_token_ids=prompt_ids)
|
||||
prompt["additional_information"] = additional_information
|
||||
return prompt
|
||||
|
||||
wav_samples, sample_rate = ref_audio_data
|
||||
normalized_text = normalize_text(request.input)
|
||||
normalized_ref_text = normalize_text(request.ref_text, add_default_speaker=True)
|
||||
placeholder_len = self._estimate_prompt_len(normalized_text, normalized_ref_text, ref_audio_data)
|
||||
|
||||
# Structured clone: scalars (not list-wrapped) because model-side
|
||||
# preprocess() consumes these fields directly.
|
||||
additional_information = {
|
||||
"text": normalized_text,
|
||||
"ref_text": normalized_ref_text,
|
||||
"ref_audio_wav": torch.from_numpy(np.asarray(wav_samples, dtype=np.float32)),
|
||||
"ref_audio_sr": int(sample_rate),
|
||||
"audio8_structured_voice_clone": True,
|
||||
}
|
||||
if request.voice is not None:
|
||||
voice_lower = request.voice.lower()
|
||||
if voice_lower in server.uploaded_speakers and not has_inline_ref_audio:
|
||||
# Lets the model cache this speaker's encoded codes.
|
||||
additional_information["voice_name"] = voice_lower
|
||||
additional_information["voice_created_at"] = server._voice_created_at(voice_lower)
|
||||
if request.max_new_tokens is not None:
|
||||
additional_information["max_new_tokens"] = request.max_new_tokens
|
||||
prompt = tokens_input(prompt_token_ids=[1] * placeholder_len)
|
||||
prompt["additional_information"] = additional_information
|
||||
return prompt
|
||||
|
||||
|
||||
__all__ = ["Audio8TTSAdapter"]
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS Preview (DualAR, 44.1 kHz codec) model implementation."""
|
||||
@@ -0,0 +1,284 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS Preview -- codec decoder (Stage 1).
|
||||
|
||||
Consumes frames of ``num_codebooks`` codec codes and decodes them to a 44.1 kHz
|
||||
waveform. Streaming contract is delta: with ``async_chunk`` on, each chunk is
|
||||
``[left_context | new_frames]``; the whole window is decoded so the causal
|
||||
codec keeps continuous context, then only the new tail is returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.forward_context import get_forward_context, is_forward_context_available
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.model_executor.models.audio8_tts.codec_utils import load_arktts_codec
|
||||
from vllm_omni.model_executor.models.audio8_tts.configuration_audio8_tts import (
|
||||
ARKTTS_CODEC_FRAME_SIZE,
|
||||
ARKTTS_CODEC_SAMPLE_RATE,
|
||||
)
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_DTYPES = {
|
||||
"fp16": torch.float16,
|
||||
"float16": torch.float16,
|
||||
"half": torch.float16,
|
||||
"bf16": torch.bfloat16,
|
||||
"bfloat16": torch.bfloat16,
|
||||
"fp32": torch.float32,
|
||||
"float32": torch.float32,
|
||||
"": torch.float32,
|
||||
}
|
||||
|
||||
|
||||
def _connector_extra_config(vllm_config: VllmConfig) -> dict[str, Any]:
|
||||
model_config = getattr(vllm_config, "model_config", None)
|
||||
connector_cfg = getattr(model_config, "stage_connector_config", None)
|
||||
if isinstance(connector_cfg, dict):
|
||||
return connector_cfg.get("extra", connector_cfg)
|
||||
extra = getattr(connector_cfg, "extra", None)
|
||||
return extra if isinstance(extra, dict) else {}
|
||||
|
||||
|
||||
def _resolve_dtype(extra_cfg: dict[str, Any]) -> torch.dtype:
|
||||
raw = str(extra_cfg.get("audio8_tts_codec_dtype", "float32")).strip().lower()
|
||||
if raw not in _DTYPES:
|
||||
raise ValueError(f"Invalid Audio8 TTS codec dtype: {raw!r}")
|
||||
return _DTYPES[raw]
|
||||
|
||||
|
||||
class Audio8TTSCodecDecoder(nn.Module):
|
||||
"""Stage 1: codec codes -> waveform (runs under the generation runner)."""
|
||||
|
||||
input_modalities = "audio"
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
del prefix
|
||||
self.vllm_config = vllm_config
|
||||
self.model_path = vllm_config.model_config.model
|
||||
config = vllm_config.model_config.hf_config
|
||||
|
||||
self.have_multimodal_outputs = True
|
||||
self.has_preprocess = False
|
||||
self.has_postprocess = False
|
||||
self.enable_update_additional_information = True
|
||||
self.requires_raw_input_tokens = True
|
||||
|
||||
self._num_codebooks = int(getattr(config, "num_codebooks", 10))
|
||||
self._sample_rate = int(getattr(config, "codec_sample_rate", ARKTTS_CODEC_SAMPLE_RATE))
|
||||
self._frame_size = int(getattr(config, "codec_frame_size", ARKTTS_CODEC_FRAME_SIZE))
|
||||
self._codec_kwargs = {
|
||||
"post_n_layer": int(getattr(config, "codec_post_n_layer", 8)),
|
||||
"post_n_head": int(getattr(config, "codec_post_n_head", 16)),
|
||||
"post_n_local_heads": int(getattr(config, "codec_post_n_local_heads", 8)),
|
||||
"post_intermediate_size": int(getattr(config, "codec_post_intermediate_size", 1216)),
|
||||
}
|
||||
# float32 by default: the codec's Snake activations and weight-normed
|
||||
# convs lose audible headroom in bf16.
|
||||
self._codec_dtype = _resolve_dtype(_connector_extra_config(vllm_config))
|
||||
self._codec: nn.Module | None = None
|
||||
self._codec_device: torch.device | None = None
|
||||
self._logged_stats = False
|
||||
|
||||
# -------------------- codec --------------------
|
||||
|
||||
def _ensure_codec_loaded(self) -> None:
|
||||
if self._codec is not None:
|
||||
return
|
||||
device = self.vllm_config.device_config.device
|
||||
self._codec = load_arktts_codec(
|
||||
self.model_path,
|
||||
device=device,
|
||||
dtype=self._codec_dtype,
|
||||
role="decode",
|
||||
**self._codec_kwargs,
|
||||
)
|
||||
self._codec_device = torch.device(device)
|
||||
|
||||
# -------------------- vLLM hooks --------------------
|
||||
|
||||
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
|
||||
if input_ids.numel() == 0:
|
||||
return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32)
|
||||
return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32)
|
||||
|
||||
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> None:
|
||||
return None
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
"""No-op: the codec comes from ``codec.pth``, not the main checkpoint."""
|
||||
del weights
|
||||
return set()
|
||||
|
||||
# -------------------- request splitting --------------------
|
||||
|
||||
def _split_request_ids(
|
||||
self,
|
||||
ids: torch.Tensor,
|
||||
seq_token_counts: list[int] | None = None,
|
||||
) -> list[torch.Tensor]:
|
||||
if seq_token_counts is not None and len(seq_token_counts) > 1:
|
||||
boundaries = [0]
|
||||
for count in seq_token_counts:
|
||||
boundaries.append(boundaries[-1] + count)
|
||||
total = ids.numel()
|
||||
return [ids[boundaries[i] : min(boundaries[i + 1], total)] for i in range(len(seq_token_counts))]
|
||||
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 size in slices:
|
||||
boundaries.append(boundaries[-1] + size)
|
||||
return [ids[boundaries[i] : boundaries[i + 1]] for i in range(len(boundaries) - 1)]
|
||||
return [ids]
|
||||
|
||||
def _codes_from_runtime_info(
|
||||
self,
|
||||
info: dict[str, Any] | None,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor | None:
|
||||
"""Prefer the tensor payload the connector carried over input_ids."""
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
codes = info.get("codes", {}).get("audio")
|
||||
if not isinstance(codes, torch.Tensor) or codes.numel() == 0:
|
||||
return None
|
||||
codes = codes.to(device=device, dtype=torch.long, non_blocking=True).contiguous()
|
||||
if codes.ndim != 2 or int(codes.shape[0]) != self._num_codebooks:
|
||||
logger.warning(
|
||||
"Audio8 TTS codec decoder expected codes of shape [%d, frames], got %s",
|
||||
self._num_codebooks,
|
||||
tuple(codes.shape),
|
||||
)
|
||||
return None
|
||||
return codes
|
||||
|
||||
# -------------------- decode --------------------
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None = None,
|
||||
positions: torch.Tensor | None = None,
|
||||
intermediate_tensors: Any = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
runtime_additional_information: list[dict[str, Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> OmniOutput:
|
||||
"""Decode one chunk per request.
|
||||
|
||||
``input_ids`` is codebook-major flat codes
|
||||
(``[cb0_f0 ... cb0_fN, cb1_f0 ...]``); tensor codes on the connector
|
||||
win over ``input_ids``. Every return path must set ``model_outputs`` or
|
||||
the serving layer silently drops a chunk of audio.
|
||||
"""
|
||||
del positions, intermediate_tensors, inputs_embeds
|
||||
sr_tensor = torch.tensor(self._sample_rate, dtype=torch.int32)
|
||||
empty = torch.zeros((0,), dtype=torch.float32)
|
||||
|
||||
self._ensure_codec_loaded()
|
||||
codec = self._codec
|
||||
assert codec is not None
|
||||
|
||||
if input_ids is None or input_ids.numel() == 0:
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": [empty], "sr": [sr_tensor]},
|
||||
)
|
||||
|
||||
ids = input_ids.reshape(-1).to(dtype=torch.long)
|
||||
per_request_ids = self._split_request_ids(ids, kwargs.get("seq_token_counts"))
|
||||
num_req = len(per_request_ids)
|
||||
|
||||
context_frames = [0] * num_req
|
||||
if runtime_additional_information is not None:
|
||||
for index, info in enumerate(runtime_additional_information[:num_req]):
|
||||
meta = info.get("meta", {}) if isinstance(info, dict) else {}
|
||||
context_frames[index] = int(meta.get("left_context_size", 0) or 0)
|
||||
|
||||
audios: list[torch.Tensor] = [empty] * num_req
|
||||
for index, request_ids in enumerate(per_request_ids):
|
||||
codes_qf = (
|
||||
self._codes_from_runtime_info(runtime_additional_information[index], ids.device)
|
||||
if runtime_additional_information is not None and index < len(runtime_additional_information)
|
||||
else None
|
||||
)
|
||||
if codes_qf is None:
|
||||
if request_ids.numel() < self._num_codebooks:
|
||||
continue
|
||||
if int(request_ids.numel()) % self._num_codebooks != 0:
|
||||
logger.warning(
|
||||
"Audio8 TTS codec decoder got %d ids, not divisible by num_codebooks=%d; skipping request",
|
||||
int(request_ids.numel()),
|
||||
self._num_codebooks,
|
||||
)
|
||||
continue
|
||||
codes_qf = request_ids.reshape(self._num_codebooks, -1)
|
||||
|
||||
total_frames = int(codes_qf.shape[1])
|
||||
if total_frames <= 0:
|
||||
continue
|
||||
new_frames = total_frames - context_frames[index]
|
||||
if new_frames <= 0:
|
||||
logger.warning(
|
||||
"Audio8 TTS codec decoder chunk has no new frames (total=%d, context=%d)",
|
||||
total_frames,
|
||||
context_frames[index],
|
||||
)
|
||||
continue
|
||||
|
||||
if not self._logged_stats:
|
||||
self._logged_stats = True
|
||||
logger.info(
|
||||
"Audio8 TTS codec decoder: frames=%d codebooks=%d dtype=%s sr=%d",
|
||||
total_frames,
|
||||
self._num_codebooks,
|
||||
self._codec_dtype,
|
||||
self._sample_rate,
|
||||
)
|
||||
|
||||
with torch.amp.autocast("cuda", enabled=False):
|
||||
# Move codes to the codec's device explicitly: a mismatch here
|
||||
# silently falls back to CPU or raises deep inside the decoder.
|
||||
waveform = codec.decode(codes_qf.unsqueeze(0).to(device=self._codec_device))
|
||||
waveform = waveform.reshape(-1).to(dtype=torch.float32)
|
||||
|
||||
# Trim the left-context prefix proportionally: the codec's padding /
|
||||
# rounding means the decoded length is not exactly
|
||||
# frames * frame_size.
|
||||
if context_frames[index] > 0:
|
||||
cut = int(round(context_frames[index] / total_frames * int(waveform.shape[0])))
|
||||
cut = max(0, min(cut, int(waveform.shape[0])))
|
||||
waveform = waveform[cut:]
|
||||
if waveform.numel() > 0:
|
||||
audios[index] = waveform.contiguous()
|
||||
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": audios, "sr": [sr_tensor] * num_req},
|
||||
)
|
||||
|
||||
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
|
||||
del kwargs
|
||||
if isinstance(model_outputs, OmniOutput):
|
||||
return model_outputs
|
||||
if not (isinstance(model_outputs, tuple) and len(model_outputs) == 2):
|
||||
raise TypeError(f"Audio8TTSCodecDecoder expected (audio_tensor, sr), got {type(model_outputs)}")
|
||||
audio_tensor, sample_rate = model_outputs
|
||||
return OmniOutput(
|
||||
text_hidden_states=None,
|
||||
multimodal_outputs={"model_outputs": audio_tensor, "sr": sample_rate},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Audio8TTSCodecDecoder"]
|
||||
@@ -0,0 +1,346 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS Fast AR: the residual-codebook predictor.
|
||||
|
||||
Runs once per Slow AR step over ``num_codebooks + 1`` positions: position 0 is
|
||||
the projected Slow AR hidden state (primes the KV cache, logits discarded),
|
||||
position 1 is the sampled semantic code (not predicted, only fed in), positions
|
||||
2.. are the previously sampled residual codes; only codes 1..num_codebooks-1
|
||||
are sampled here.
|
||||
|
||||
Not paged: at most 10 tokens per call, SDPA over a per-call pre-allocated KV
|
||||
cache. Matches the reference ``ArkttsModel._generate_codebooks``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.config.vllm import set_current_vllm_config
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.layernorm import RMSNorm
|
||||
from vllm.model_executor.layers.linear import (
|
||||
MergedColumnParallelLinear,
|
||||
QKVParallelLinear,
|
||||
RowParallelLinear,
|
||||
)
|
||||
from vllm.model_executor.layers.rotary_embedding import get_rope
|
||||
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
|
||||
|
||||
from .configuration_audio8_tts import Audio8TTSFastARConfig, Audio8TTSSlowARConfig
|
||||
from .sampling import sample_scores
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class _FastARAttention(nn.Module):
|
||||
"""GQA self-attention over the short codebook sequence.
|
||||
|
||||
``forward_one`` decodes a single position against a caller-owned KV cache;
|
||||
there is no batched-prefill path because the codebook loop is inherently
|
||||
sequential.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Audio8TTSFastARConfig, *, prefix: str = "") -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = config.hidden_size
|
||||
self.num_heads = config.num_attention_heads
|
||||
self.num_kv_heads = config.num_key_value_heads
|
||||
self.head_dim = config.head_dim
|
||||
self.q_size = self.num_heads * self.head_dim
|
||||
self.kv_size = self.num_kv_heads * self.head_dim
|
||||
self.scaling = self.head_dim**-0.5
|
||||
self._use_gqa = self.num_kv_heads != self.num_heads
|
||||
|
||||
self.qkv_proj = QKVParallelLinear(
|
||||
hidden_size=self.hidden_size,
|
||||
head_size=self.head_dim,
|
||||
total_num_heads=self.num_heads,
|
||||
total_num_kv_heads=self.num_kv_heads,
|
||||
bias=config.attention_qkv_bias,
|
||||
prefix=f"{prefix}.qkv_proj",
|
||||
disable_tp=True,
|
||||
)
|
||||
self.o_proj = RowParallelLinear(
|
||||
input_size=self.num_heads * self.head_dim,
|
||||
output_size=self.hidden_size,
|
||||
bias=False,
|
||||
prefix=f"{prefix}.o_proj",
|
||||
disable_tp=True,
|
||||
)
|
||||
self.rotary_emb = get_rope(
|
||||
self.head_dim,
|
||||
max_position=config.max_position_embeddings,
|
||||
# Audio8 TTS uses interleaved (GPT-J) RoPE, not NeoX.
|
||||
is_neox_style=False,
|
||||
rope_parameters={"rope_theta": config.rope_theta, "rope_type": "default"},
|
||||
)
|
||||
if config.attention_qk_norm:
|
||||
self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
|
||||
self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
|
||||
else:
|
||||
self.q_norm = None
|
||||
self.k_norm = None
|
||||
|
||||
def forward_one(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
position_ids: torch.Tensor,
|
||||
k_cache: torch.Tensor,
|
||||
v_cache: torch.Tensor,
|
||||
cache_pos: int,
|
||||
) -> torch.Tensor:
|
||||
bsz = int(hidden_states.shape[0])
|
||||
|
||||
qkv, _ = self.qkv_proj(hidden_states.reshape(bsz, -1))
|
||||
q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
|
||||
|
||||
if self.q_norm is not None:
|
||||
q = self.q_norm(q.view(-1, self.num_heads, self.head_dim)).view(q.shape)
|
||||
k = self.k_norm(k.view(-1, self.num_kv_heads, self.head_dim)).view(k.shape)
|
||||
|
||||
q, k = self.rotary_emb(position_ids.reshape(-1), q, k)
|
||||
|
||||
q = q.view(bsz, self.num_heads, self.head_dim).unsqueeze(2)
|
||||
k_cache[:bsz, :, cache_pos, :] = k.view(bsz, self.num_kv_heads, self.head_dim)
|
||||
v_cache[:bsz, :, cache_pos, :] = v.view(bsz, self.num_kv_heads, self.head_dim)
|
||||
|
||||
attn_out = F.scaled_dot_product_attention(
|
||||
q,
|
||||
k_cache[:bsz, :, : cache_pos + 1, :],
|
||||
v_cache[:bsz, :, : cache_pos + 1, :],
|
||||
scale=self.scaling,
|
||||
is_causal=False,
|
||||
enable_gqa=self._use_gqa,
|
||||
)
|
||||
attn_out = attn_out.transpose(1, 2).reshape(bsz, -1)
|
||||
output, _ = self.o_proj(attn_out)
|
||||
return output
|
||||
|
||||
|
||||
class _FastARMLP(nn.Module):
|
||||
"""SiLU-gated MLP (``w1``/``w3`` gate-up, ``w2`` down)."""
|
||||
|
||||
def __init__(self, config: Audio8TTSFastARConfig, *, prefix: str = "") -> None:
|
||||
super().__init__()
|
||||
self.gate_up_proj = MergedColumnParallelLinear(
|
||||
input_size=config.hidden_size,
|
||||
output_sizes=[config.intermediate_size] * 2,
|
||||
bias=False,
|
||||
prefix=f"{prefix}.gate_up_proj",
|
||||
disable_tp=True,
|
||||
)
|
||||
self.down_proj = RowParallelLinear(
|
||||
input_size=config.intermediate_size,
|
||||
output_size=config.hidden_size,
|
||||
bias=False,
|
||||
prefix=f"{prefix}.down_proj",
|
||||
disable_tp=True,
|
||||
)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
gate_up, _ = self.gate_up_proj(x)
|
||||
gate, up = gate_up.chunk(2, dim=-1)
|
||||
x, _ = self.down_proj(F.silu(gate) * up)
|
||||
return x
|
||||
|
||||
|
||||
class _FastARDecoderLayer(nn.Module):
|
||||
def __init__(self, config: Audio8TTSFastARConfig, *, prefix: str = "") -> None:
|
||||
super().__init__()
|
||||
self.self_attn = _FastARAttention(config, prefix=f"{prefix}.self_attn")
|
||||
self.mlp = _FastARMLP(config, prefix=f"{prefix}.mlp")
|
||||
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
|
||||
def forward_one(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
position_ids: torch.Tensor,
|
||||
k_cache: torch.Tensor,
|
||||
v_cache: torch.Tensor,
|
||||
cache_pos: int,
|
||||
) -> torch.Tensor:
|
||||
hidden_states = hidden_states + self.self_attn.forward_one(
|
||||
self.input_layernorm(hidden_states), position_ids, k_cache, v_cache, cache_pos
|
||||
)
|
||||
return hidden_states + self.mlp(self.post_attention_layernorm(hidden_states))
|
||||
|
||||
|
||||
class Audio8TTSFastAR(nn.Module):
|
||||
"""Residual-codebook predictor for Audio8 TTS.
|
||||
|
||||
``forward`` returns all ``num_codebooks`` codes for one frame, with code 0
|
||||
copied from the already-sampled semantic token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
vllm_config: VllmConfig,
|
||||
config: Audio8TTSFastARConfig,
|
||||
slow_ar_config: Audio8TTSSlowARConfig,
|
||||
prefix: str = "fast_ar",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._vllm_config = vllm_config
|
||||
self.config = config
|
||||
self.slow_ar_config = slow_ar_config
|
||||
|
||||
self.layers = nn.ModuleList(
|
||||
[_FastARDecoderLayer(config, prefix=f"{prefix}.layers.{i}") for i in range(config.num_hidden_layers)]
|
||||
)
|
||||
self.fast_embeddings = nn.Embedding(config.vocab_size, config.hidden_size)
|
||||
self.fast_output = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
|
||||
self.fast_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
if slow_ar_config.hidden_size != config.hidden_size:
|
||||
self.fast_project_in = nn.Linear(slow_ar_config.hidden_size, config.hidden_size, bias=True)
|
||||
else:
|
||||
# 0.6b: dim == fast_dim == 896, and the checkpoint has no
|
||||
# fast_project_in tensor.
|
||||
self.fast_project_in = nn.Identity()
|
||||
|
||||
self._num_codebooks = int(config.num_codebooks)
|
||||
self._fast_dim = int(config.hidden_size)
|
||||
self._k_cache: torch.Tensor | None = None
|
||||
self._v_cache: torch.Tensor | None = None
|
||||
self._pos_ids: torch.Tensor | None = None
|
||||
|
||||
def _ensure_buffers(self, bsz: int, device: torch.device, dtype: torch.dtype) -> None:
|
||||
max_seq = self._num_codebooks + 1
|
||||
if (
|
||||
self._k_cache is not None
|
||||
and self._pos_ids is not None
|
||||
and self._k_cache.shape[1] >= bsz
|
||||
and self._k_cache.device == device
|
||||
and self._k_cache.dtype == dtype
|
||||
and self._pos_ids.shape[0] >= bsz
|
||||
):
|
||||
return
|
||||
self._k_cache = torch.empty(
|
||||
self.config.num_hidden_layers,
|
||||
bsz,
|
||||
self.config.num_key_value_heads,
|
||||
max_seq,
|
||||
self.config.head_dim,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
self._v_cache = torch.empty_like(self._k_cache)
|
||||
self._pos_ids = torch.arange(max_seq, dtype=torch.long, device=device).unsqueeze(0).expand(bsz, -1).contiguous()
|
||||
|
||||
def _forward_one(self, embed: torch.Tensor, position_ids: torch.Tensor, cache_pos: int) -> torch.Tensor:
|
||||
assert self._k_cache is not None and self._v_cache is not None
|
||||
hidden = embed
|
||||
for layer_idx, layer in enumerate(self.layers):
|
||||
hidden = layer.forward_one(
|
||||
hidden,
|
||||
position_ids,
|
||||
self._k_cache[layer_idx],
|
||||
self._v_cache[layer_idx],
|
||||
cache_pos,
|
||||
)
|
||||
return hidden
|
||||
|
||||
@torch.inference_mode()
|
||||
def forward(
|
||||
self,
|
||||
slow_ar_hidden: torch.Tensor,
|
||||
semantic_token_id: torch.Tensor,
|
||||
*,
|
||||
do_sample: bool = True,
|
||||
temperature: float = 0.7,
|
||||
top_k: int = 50,
|
||||
top_p: float = 0.9,
|
||||
generator: torch.Generator | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Predict one frame of codec codes.
|
||||
|
||||
``slow_ar_hidden`` must already be post-final-norm when
|
||||
``norm_fastlayer_input`` is set (it is for the released checkpoint) --
|
||||
vLLM's Qwen2Model returns exactly that. Returns ``[B, num_codebooks]``
|
||||
int64 codes; code 0 is the semantic code, codes 1.. come from this module.
|
||||
"""
|
||||
bsz = int(slow_ar_hidden.shape[0])
|
||||
num_cb = self._num_codebooks
|
||||
device = slow_ar_hidden.device
|
||||
dtype = slow_ar_hidden.dtype
|
||||
|
||||
semantic_begin = int(self.slow_ar_config.semantic_begin_id)
|
||||
codebook_size = int(self.slow_ar_config.codebook_size)
|
||||
# Non-semantic tokens (EOS) clamp to code 0; those frames are dropped
|
||||
# downstream because the request finishes on that token.
|
||||
semantic_code = (semantic_token_id.reshape(bsz) - semantic_begin).clamp(min=0, max=codebook_size - 1)
|
||||
|
||||
codes = torch.empty(bsz, num_cb, dtype=torch.long, device=device)
|
||||
codes[:, 0] = semantic_code
|
||||
|
||||
self._ensure_buffers(bsz, device, dtype)
|
||||
pos_ids = self._pos_ids
|
||||
assert pos_ids is not None
|
||||
|
||||
# Position 0 primes the cache; its logits are unused.
|
||||
projected = self.fast_project_in(slow_ar_hidden.reshape(bsz, -1))
|
||||
self._forward_one(projected, pos_ids[:bsz, 0], 0)
|
||||
|
||||
embed = self.fast_embeddings(semantic_code)
|
||||
for step in range(1, num_cb):
|
||||
hidden = self._forward_one(embed, pos_ids[:bsz, step], step)
|
||||
logits = self.fast_output(self.fast_norm(hidden)).float()
|
||||
next_ids = sample_scores(
|
||||
logits,
|
||||
temperature=temperature,
|
||||
top_k=top_k,
|
||||
top_p=top_p,
|
||||
do_sample=do_sample,
|
||||
generator=generator,
|
||||
)
|
||||
codes[:, step] = next_ids
|
||||
if step < num_cb - 1:
|
||||
embed = self.fast_embeddings(next_ids)
|
||||
|
||||
return codes
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
"""Load already-remapped Fast AR weights (see the Slow AR remapper)."""
|
||||
with set_current_vllm_config(self._vllm_config):
|
||||
params_dict = dict(self.named_parameters(remove_duplicate=False))
|
||||
loaded: set[str] = set()
|
||||
stacked_params_mapping = [
|
||||
("qkv_proj", "q_proj", "q"),
|
||||
("qkv_proj", "k_proj", "k"),
|
||||
("qkv_proj", "v_proj", "v"),
|
||||
("gate_up_proj", "gate_proj", 0),
|
||||
("gate_up_proj", "up_proj", 1),
|
||||
]
|
||||
for name, loaded_weight in weights:
|
||||
handled = False
|
||||
for param_name, weight_name, shard_id in stacked_params_mapping:
|
||||
if weight_name not in name:
|
||||
continue
|
||||
mapped = name.replace(weight_name, param_name)
|
||||
if mapped not in params_dict:
|
||||
continue
|
||||
param = params_dict[mapped]
|
||||
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
if weight_loader == default_weight_loader:
|
||||
weight_loader(param, loaded_weight)
|
||||
else:
|
||||
weight_loader(param, loaded_weight, shard_id)
|
||||
loaded.add(mapped)
|
||||
handled = True
|
||||
break
|
||||
if handled or name not in params_dict:
|
||||
continue
|
||||
param = params_dict[name]
|
||||
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
weight_loader(param, loaded_weight)
|
||||
loaded.add(name)
|
||||
return loaded
|
||||
|
||||
|
||||
__all__ = ["Audio8TTSFastAR"]
|
||||
@@ -0,0 +1,755 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS Preview -- Slow AR model (Stage 0).
|
||||
|
||||
Text (+ optional reference voice) -> one semantic token per codec frame; the
|
||||
frame's remaining 9 codebooks are produced by the nested Fast AR.
|
||||
|
||||
Backbone is vLLM's ``Qwen2Model`` (qkv bias, no q/k norm), with three
|
||||
adjustments: interleaved (GPT-J) RoPE, multi-codebook input embedding at
|
||||
semantic positions (summed, unlike Fish Speech's ``1/sqrt(Q+1)`` rescale), and
|
||||
logits masked to the semantic range + ``<|im_end|>`` then Repetition-Aware
|
||||
sampled.
|
||||
|
||||
Streaming contract is delta: each decode step appends one frame of
|
||||
``num_codebooks`` codes to ``codes.audio``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from transformers import AutoTokenizer
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed import get_pp_group
|
||||
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.qwen2 import Qwen2Model
|
||||
from vllm.model_executor.models.utils import PPMissingLayer, maybe_prefix
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.v1.outputs import SamplerOutput
|
||||
|
||||
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||
from vllm_omni.utils.speaker_cache import get_speaker_cache
|
||||
|
||||
from .audio8_tts_fast_ar import Audio8TTSFastAR
|
||||
from .codec_utils import encode_reference_audio_codes
|
||||
from .configuration_audio8_tts import (
|
||||
Audio8TTSConfig,
|
||||
Audio8TTSFastARConfig,
|
||||
Audio8TTSSlowARConfig,
|
||||
)
|
||||
from .prompt_utils import build_voice_clone_prompt_ids
|
||||
from .sampling import SAMPLING_EPS, ras_sample_batch
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
#: Defaults from ``generation_config.json`` of the released checkpoint.
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
DEFAULT_TOP_K = 50
|
||||
DEFAULT_TOP_P = 0.9
|
||||
|
||||
|
||||
def _remap_audio8_tts_weights(
|
||||
weights: Iterable[tuple[str, torch.Tensor]],
|
||||
*,
|
||||
q_size: int,
|
||||
kv_size: int,
|
||||
fast_q_size: int,
|
||||
fast_kv_size: int,
|
||||
) -> Iterable[tuple[str, torch.Tensor]]:
|
||||
"""Rename Audio8 checkpoint tensors to vLLM / Qwen2 names.
|
||||
|
||||
The checkpoint is flat (no ``text_model.`` prefix). Slow AR tensors move
|
||||
under ``model.``, Fast AR tensors under ``fast_ar.``, and ``wqkv`` splits
|
||||
into ``{q,k,v}_proj``. See the body for the exact mapping table.
|
||||
"""
|
||||
|
||||
def rewrite_block(suffix: str) -> str:
|
||||
for old, new in (
|
||||
(".attention.wo.", ".self_attn.o_proj."),
|
||||
(".attention.q_norm.", ".self_attn.q_norm."),
|
||||
(".attention.k_norm.", ".self_attn.k_norm."),
|
||||
(".attention_norm.", ".input_layernorm."),
|
||||
(".feed_forward.w1.", ".mlp.gate_proj."),
|
||||
(".feed_forward.w3.", ".mlp.up_proj."),
|
||||
(".feed_forward.w2.", ".mlp.down_proj."),
|
||||
(".ffn_norm.", ".post_attention_layernorm."),
|
||||
):
|
||||
suffix = suffix.replace(old, new)
|
||||
return suffix
|
||||
|
||||
for name, tensor in weights:
|
||||
# RoPE tables are recomputed by vLLM.
|
||||
if name.endswith(("freqs_cis", "fast_freqs_cis")) or "rotary_emb.inv_freq" in name:
|
||||
continue
|
||||
|
||||
if name.startswith("fast_layers."):
|
||||
suffix = name[len("fast_layers.") :]
|
||||
if ".attention.wqkv." in suffix:
|
||||
layer_prefix, _, param = suffix.partition(".attention.wqkv.")
|
||||
yield f"fast_ar.layers.{layer_prefix}.self_attn.q_proj.{param}", tensor[:fast_q_size]
|
||||
yield (
|
||||
f"fast_ar.layers.{layer_prefix}.self_attn.k_proj.{param}",
|
||||
tensor[fast_q_size : fast_q_size + fast_kv_size],
|
||||
)
|
||||
yield (
|
||||
f"fast_ar.layers.{layer_prefix}.self_attn.v_proj.{param}",
|
||||
tensor[fast_q_size + fast_kv_size :],
|
||||
)
|
||||
continue
|
||||
yield f"fast_ar.layers.{rewrite_block(suffix)}", tensor
|
||||
continue
|
||||
|
||||
if name.startswith(("fast_embeddings.", "fast_output.", "fast_norm.", "fast_project_in.")):
|
||||
yield f"fast_ar.{name}", tensor
|
||||
continue
|
||||
|
||||
if name.startswith("layers."):
|
||||
suffix = name[len("layers.") :]
|
||||
if ".attention.wqkv." in suffix:
|
||||
layer_prefix, _, param = suffix.partition(".attention.wqkv.")
|
||||
yield f"model.layers.{layer_prefix}.self_attn.q_proj.{param}", tensor[:q_size]
|
||||
yield (
|
||||
f"model.layers.{layer_prefix}.self_attn.k_proj.{param}",
|
||||
tensor[q_size : q_size + kv_size],
|
||||
)
|
||||
yield f"model.layers.{layer_prefix}.self_attn.v_proj.{param}", tensor[q_size + kv_size :]
|
||||
continue
|
||||
yield f"model.layers.{rewrite_block(suffix)}", tensor
|
||||
continue
|
||||
|
||||
if name == "embeddings.weight":
|
||||
yield "model.embed_tokens.weight", tensor
|
||||
continue
|
||||
if name == "norm.weight":
|
||||
yield "model.norm.weight", tensor
|
||||
continue
|
||||
|
||||
yield name, tensor
|
||||
|
||||
|
||||
class Audio8TTSSlowARForConditionalGeneration(nn.Module):
|
||||
"""Stage 0: text -> semantic tokens + residual codec codes."""
|
||||
|
||||
prefer_model_sampler = True
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
super().__init__()
|
||||
self.vllm_config = vllm_config
|
||||
self.model_path = vllm_config.model_config.model
|
||||
|
||||
config: Audio8TTSConfig = vllm_config.model_config.hf_config # type: ignore[assignment]
|
||||
self.config = config
|
||||
self.text_config: Audio8TTSSlowARConfig = config.get_text_config()
|
||||
self.fast_ar_config: Audio8TTSFastARConfig = config.fast_ar_config
|
||||
|
||||
self._semantic_begin_id = int(self.text_config.semantic_begin_id)
|
||||
self._semantic_end_id = int(self.text_config.semantic_end_id)
|
||||
self._num_semantic_ids = self._semantic_end_id - self._semantic_begin_id + 1
|
||||
self._eos_token_id = int(self.text_config.eos_token_id)
|
||||
self._pad_token_id = int(self.text_config.pad_token_id)
|
||||
self._codebook_size = int(self.text_config.codebook_size)
|
||||
self._num_codebooks = int(self.text_config.num_codebooks)
|
||||
self._ras_window_size = int(config.ras_window_size)
|
||||
self._ras_temperature = float(config.ras_temperature)
|
||||
self._ras_top_p = float(config.ras_top_p)
|
||||
|
||||
self.have_multimodal_outputs = True
|
||||
self.has_preprocess = True
|
||||
self.has_postprocess = True
|
||||
self.mtp_hidden_size = int(self.text_config.hidden_size)
|
||||
self.talker_mtp_output_key = ("codes", "audio")
|
||||
self.gpu_resident_buffer_keys: set[tuple[str, str]] = {("hidden_states", "last")}
|
||||
self.talker_mtp_graph_safe = True
|
||||
|
||||
self.model = Qwen2Model(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model"))
|
||||
self._fix_rope_style()
|
||||
|
||||
if get_pp_group().is_last_rank:
|
||||
self.lm_head = ParallelLMHead(
|
||||
self.text_config.vocab_size,
|
||||
self.text_config.hidden_size,
|
||||
quant_config=vllm_config.quant_config,
|
||||
prefix=maybe_prefix(prefix, "lm_head"),
|
||||
)
|
||||
else:
|
||||
self.lm_head = PPMissingLayer()
|
||||
self.logits_processor = LogitsProcessor(self.text_config.vocab_size)
|
||||
self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors
|
||||
|
||||
# Summed multi-codebook input embedding table.
|
||||
self.codebook_embeddings = nn.Embedding(
|
||||
self._codebook_size * self._num_codebooks,
|
||||
self.text_config.hidden_size,
|
||||
)
|
||||
|
||||
# Fast AR shares the parent's VllmConfig but must not share the
|
||||
# compilation context: see the Fish Speech note on why this is
|
||||
# copy.copy and not dataclasses.replace (pydantic validators re-run and
|
||||
# reject an already-rebound compilation backend).
|
||||
fast_ar_compilation = copy.copy(vllm_config.compilation_config)
|
||||
fast_ar_compilation.static_forward_context = {}
|
||||
self._fast_ar_vllm_config = copy.copy(vllm_config)
|
||||
self._fast_ar_vllm_config.compilation_config = fast_ar_compilation
|
||||
from vllm.config.vllm import set_current_vllm_config
|
||||
|
||||
with set_current_vllm_config(self._fast_ar_vllm_config):
|
||||
self.fast_ar = Audio8TTSFastAR(
|
||||
vllm_config=self._fast_ar_vllm_config,
|
||||
config=self.fast_ar_config,
|
||||
slow_ar_config=self.text_config,
|
||||
prefix="fast_ar",
|
||||
)
|
||||
|
||||
# Constant logits mask: semantic codes plus <|im_end|>. Safe as a
|
||||
# non-persistent buffer under vLLM (module built on the target device),
|
||||
# unlike under ``PreTrainedModel.from_pretrained`` on transformers >= 5,
|
||||
# which meta-inits and then empty_like's every non-persistent buffer.
|
||||
vocab = int(self.text_config.vocab_size)
|
||||
allowed = torch.zeros((vocab,), dtype=torch.bool)
|
||||
allowed[self._semantic_begin_id : min(self._semantic_end_id + 1, vocab)] = True
|
||||
if self._eos_token_id < vocab:
|
||||
allowed[self._eos_token_id] = True
|
||||
self.register_buffer("_semantic_allowed_mask", allowed, persistent=False)
|
||||
|
||||
self._speaker_cache = get_speaker_cache()
|
||||
self._tokenizer = None
|
||||
|
||||
def _fix_rope_style(self) -> None:
|
||||
"""Rebuild RoPE as interleaved (GPT-J); vLLM's Qwen2 defaults to NeoX.
|
||||
|
||||
This also replaces the reference implementation's ``freqs_cis`` table
|
||||
entirely: ``get_rope`` computes its cos/sin cache in the constructor, so
|
||||
there is no checkpoint-absent buffer for a loader to leave uninitialised.
|
||||
"""
|
||||
from vllm.model_executor.layers.rotary_embedding import get_rope
|
||||
|
||||
rope_parameters = dict(self.text_config.rope_parameters or {})
|
||||
rope_parameters.setdefault("rope_type", "default")
|
||||
for layer in self.model.layers:
|
||||
attn = layer.self_attn
|
||||
attn.rotary_emb = get_rope(
|
||||
head_size=attn.head_dim,
|
||||
max_position=self.text_config.max_position_embeddings,
|
||||
is_neox_style=False,
|
||||
rope_parameters=rope_parameters,
|
||||
)
|
||||
logger.info("Audio8 TTS: switched %d layers to interleaved RoPE", len(self.model.layers))
|
||||
|
||||
# -------------------- vLLM hooks --------------------
|
||||
|
||||
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
|
||||
return self.model.embed_input_ids(input_ids)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
positions: torch.Tensor,
|
||||
intermediate_tensors: IntermediateTensors | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
**_: Any,
|
||||
) -> torch.Tensor | IntermediateTensors:
|
||||
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 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.masked_fill(~self._semantic_allowed_mask, float("-inf"))
|
||||
|
||||
# -------------------- Repetition-Aware Sampling --------------------
|
||||
|
||||
def _compact_semantic_logits(self, logits: torch.Tensor) -> torch.Tensor:
|
||||
"""Slice the allowed vocabulary into ``[B, num_semantic_ids + 1]``.
|
||||
|
||||
Local ids ``0..num_semantic_ids-1`` are semantic codes; the last column
|
||||
is ``<|im_end|>``. Sorting 4097 columns instead of the full 155776
|
||||
vocabulary is the difference between a cheap and an expensive AR step,
|
||||
and is exactly equivalent because everything else is already ``-inf``.
|
||||
"""
|
||||
semantic = logits[:, self._semantic_begin_id : self._semantic_end_id + 1]
|
||||
eos = logits[:, self._eos_token_id : self._eos_token_id + 1]
|
||||
return torch.cat((semantic, eos), dim=-1)
|
||||
|
||||
def _local_to_token_id(self, local_ids: torch.Tensor) -> torch.Tensor:
|
||||
is_eos = local_ids >= self._num_semantic_ids
|
||||
return torch.where(is_eos, torch.full_like(local_ids, self._eos_token_id), local_ids + self._semantic_begin_id)
|
||||
|
||||
def _recent_local_ids(self, output_token_ids: list[list[int]], num_reqs: int, device: torch.device):
|
||||
"""Build the ``[B, W]`` RAS window in compact id space.
|
||||
|
||||
Reads the host-side decoded-token history the AR runner already
|
||||
materialised, so this costs one small H2D copy and never syncs the GPU.
|
||||
Returns ``None`` when no request has history yet.
|
||||
"""
|
||||
window = self._ras_window_size
|
||||
if window <= 0:
|
||||
return None
|
||||
rows: list[list[int]] = []
|
||||
any_history = False
|
||||
for req_idx in range(num_reqs):
|
||||
history = output_token_ids[req_idx] if req_idx < len(output_token_ids) else []
|
||||
recent = [int(token) for token in history[-window:]]
|
||||
local = [
|
||||
token - self._semantic_begin_id if self._semantic_begin_id <= token <= self._semantic_end_id else -1
|
||||
for token in recent
|
||||
]
|
||||
any_history = any_history or bool(local)
|
||||
rows.append([-1] * (window - len(local)) + local)
|
||||
if not any_history:
|
||||
return None
|
||||
return torch.tensor(rows, dtype=torch.long, device=device)
|
||||
|
||||
def sample(self, logits: torch.Tensor, sampling_metadata: Any) -> SamplerOutput | None:
|
||||
"""Sample the semantic token with the reference filter order + RAS.
|
||||
|
||||
Returns ``None`` to fall back to vLLM's sampler when the request mix
|
||||
needs features RAS does not model (logprobs, penalties, bad words).
|
||||
"""
|
||||
if logits is None or logits.numel() == 0:
|
||||
return None
|
||||
if sampling_metadata.max_num_logprobs is not None:
|
||||
return None
|
||||
if not sampling_metadata.no_penalties:
|
||||
return None
|
||||
if sampling_metadata.bad_words_token_ids:
|
||||
return None
|
||||
|
||||
logits = logits.to(torch.float32)
|
||||
if sampling_metadata.allowed_token_ids_mask is not None:
|
||||
num_reqs = int(logits.shape[0])
|
||||
logits.masked_fill_(sampling_metadata.allowed_token_ids_mask[:num_reqs], float("-inf"))
|
||||
for processor in sampling_metadata.logitsprocs.non_argmax_invariant:
|
||||
logits = processor.apply(logits)
|
||||
|
||||
compact = self._compact_semantic_logits(logits)
|
||||
num_reqs = int(compact.shape[0])
|
||||
device = compact.device
|
||||
|
||||
temperature = sampling_metadata.temperature
|
||||
if sampling_metadata.all_greedy or temperature is None:
|
||||
local_ids = compact.argmax(dim=-1)
|
||||
else:
|
||||
temperature = temperature[:num_reqs]
|
||||
top_p = DEFAULT_TOP_P if sampling_metadata.top_p is None else sampling_metadata.top_p[:num_reqs]
|
||||
top_k = DEFAULT_TOP_K if sampling_metadata.top_k is None else sampling_metadata.top_k[:num_reqs]
|
||||
recent = self._recent_local_ids(sampling_metadata.output_token_ids, num_reqs, device)
|
||||
local_ids = ras_sample_batch(
|
||||
compact,
|
||||
recent,
|
||||
temperature=temperature,
|
||||
top_k=top_k,
|
||||
top_p=top_p,
|
||||
ras_temperature=self._ras_temperature,
|
||||
ras_top_p=self._ras_top_p,
|
||||
num_semantic_ids=self._num_semantic_ids,
|
||||
generators=sampling_metadata.generators,
|
||||
num_reqs=num_reqs,
|
||||
)
|
||||
|
||||
token_ids = self._local_to_token_id(local_ids).to(dtype=torch.int32)
|
||||
return SamplerOutput(sampled_token_ids=token_ids.unsqueeze(-1), logprobs_tensors=None)
|
||||
|
||||
# -------------------- Omni multimodal plumbing --------------------
|
||||
|
||||
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
|
||||
if isinstance(model_outputs, OmniOutput):
|
||||
return model_outputs
|
||||
|
||||
hidden = model_outputs
|
||||
info_dicts = kwargs.get("model_intermediate_buffer")
|
||||
if info_dicts is None:
|
||||
info_dicts = kwargs.get("runtime_additional_information") or []
|
||||
|
||||
audio_codes_list: list[torch.Tensor] = []
|
||||
for info in info_dicts:
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
codes = info.get("codes", {}).get("audio")
|
||||
if isinstance(codes, torch.Tensor):
|
||||
audio_codes_list.append(codes)
|
||||
|
||||
if not audio_codes_list:
|
||||
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
|
||||
|
||||
audio_codes = torch.cat(audio_codes_list, dim=0)
|
||||
hidden = hidden[: int(audio_codes.shape[0])]
|
||||
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={"audio_codes": audio_codes})
|
||||
|
||||
# -------------------- preprocess / postprocess --------------------
|
||||
|
||||
def preprocess(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
input_embeds: torch.Tensor | None,
|
||||
**info_dict: Any,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
|
||||
additional_information = info_dict.get("additional_information")
|
||||
if isinstance(additional_information, dict):
|
||||
merged: dict[str, Any] = {k: v for k, v in info_dict.items() if k != "additional_information"}
|
||||
for key, value in additional_information.items():
|
||||
merged.setdefault(key, value)
|
||||
info_dict = merged
|
||||
|
||||
span_len = int(input_ids.shape[0])
|
||||
if span_len <= 0:
|
||||
embeds = input_embeds if input_embeds is not None else self.embed_input_ids(input_ids)
|
||||
return input_ids, embeds, {}
|
||||
|
||||
if span_len > 1:
|
||||
return self._preprocess_prefill(input_ids, info_dict, span_len)
|
||||
return self._preprocess_decode(input_ids, info_dict)
|
||||
|
||||
def _prefill_chunk(
|
||||
self,
|
||||
prompt_embeds_buf: torch.Tensor,
|
||||
start: int,
|
||||
span_len: int,
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
"""Slice ``span_len`` rows out of the pinned CPU prompt-embed buffer."""
|
||||
total = int(prompt_embeds_buf.shape[0])
|
||||
begin = max(0, min(start, total))
|
||||
end = max(0, min(start + span_len, total))
|
||||
take = prompt_embeds_buf[begin:end]
|
||||
if int(take.shape[0]) < span_len:
|
||||
pad_rows = span_len - int(take.shape[0])
|
||||
pad_embed = self.embed_input_ids(
|
||||
torch.tensor([self._pad_token_id], device=device, dtype=torch.long)
|
||||
).reshape(1, -1)
|
||||
take = torch.cat([take.to(device=device, dtype=torch.bfloat16), pad_embed.expand(pad_rows, -1)], dim=0)
|
||||
return take.to(dtype=torch.bfloat16)
|
||||
return take.to(device=device, dtype=torch.bfloat16, non_blocking=True)
|
||||
|
||||
def _preprocess_prefill(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
info_dict: dict[str, Any],
|
||||
span_len: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
|
||||
device = input_ids.device
|
||||
embed = info_dict.get("embed", {})
|
||||
prompt_embeds_buf = embed.get("prefill")
|
||||
is_first_chunk = not isinstance(prompt_embeds_buf, torch.Tensor) or prompt_embeds_buf.ndim != 2
|
||||
|
||||
if is_first_chunk:
|
||||
if bool(info_dict.get("audio8_structured_voice_clone", False)):
|
||||
prompt_embeds = self._build_voice_clone_prefill_embeds(info_dict)
|
||||
else:
|
||||
prompt_embeds = self.embed_input_ids(input_ids.reshape(1, -1).to(torch.long)).squeeze(0)
|
||||
prompt_embeds_buf = prompt_embeds.detach().to("cpu", dtype=torch.bfloat16).contiguous()
|
||||
if not prompt_embeds_buf.is_pinned():
|
||||
prompt_embeds_buf = prompt_embeds_buf.pin_memory()
|
||||
offset = 0
|
||||
else:
|
||||
offset = int(info_dict.get("meta", {}).get("prefill_offset", 0) or 0)
|
||||
|
||||
total_prompt_len = int(prompt_embeds_buf.shape[0])
|
||||
chunk = self._prefill_chunk(prompt_embeds_buf, offset, span_len, device)
|
||||
next_offset = min(offset + span_len, total_prompt_len)
|
||||
|
||||
# No codes are emitted during prefill; a zero frame per position keeps
|
||||
# the codes buffer aligned with the hidden-state span.
|
||||
zeros = torch.zeros((chunk.shape[0], self._num_codebooks), device=device, dtype=torch.long)
|
||||
info_update = {
|
||||
"embed": {"prefill": prompt_embeds_buf if next_offset < total_prompt_len else None},
|
||||
"meta": {"prefill_offset": next_offset},
|
||||
"codes": {"audio": zeros},
|
||||
}
|
||||
return torch.full_like(input_ids, self._pad_token_id), chunk, info_update
|
||||
|
||||
def _preprocess_decode(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
info_dict: dict[str, Any],
|
||||
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
|
||||
device = input_ids.device
|
||||
token_embed = self.embed_input_ids(input_ids.reshape(1, 1).to(torch.long)).to(
|
||||
device=device, dtype=torch.bfloat16
|
||||
)
|
||||
last_hidden = info_dict.get("hidden_states", {}).get("last")
|
||||
if not isinstance(last_hidden, torch.Tensor):
|
||||
# First decode step right after prefill: no Fast AR input yet.
|
||||
logger.warning("Audio8 TTS preprocess: hidden_states.last missing; emitting text-only embedding")
|
||||
return input_ids, token_embed.reshape(1, -1), {}
|
||||
|
||||
# Codebook embeddings are added in talker_mtp, using this step's Fast AR
|
||||
# output; adding them here would use the previous step's codes.
|
||||
info_update = {
|
||||
"mtp_inputs": (
|
||||
last_hidden.to(device=device, dtype=torch.bfloat16).reshape(1, -1),
|
||||
torch.zeros(1, self.text_config.hidden_size, device=device, dtype=torch.bfloat16),
|
||||
),
|
||||
}
|
||||
return input_ids, token_embed.reshape(1, -1), info_update
|
||||
|
||||
def postprocess(self, hidden_states: torch.Tensor, **_: Any) -> dict[str, Any]:
|
||||
if hidden_states.numel() == 0:
|
||||
return {}
|
||||
return {"hidden_states": {"last": hidden_states[-1, :].detach().contiguous()}}
|
||||
|
||||
# -------------------- voice cloning --------------------
|
||||
|
||||
def _get_tokenizer(self):
|
||||
if self._tokenizer is None:
|
||||
self._tokenizer = AutoTokenizer.from_pretrained(self.model_path)
|
||||
return self._tokenizer
|
||||
|
||||
def _codec_kwargs(self) -> dict[str, int]:
|
||||
return {
|
||||
"post_n_layer": int(self.config.codec_post_n_layer),
|
||||
"post_n_head": int(self.config.codec_post_n_head),
|
||||
"post_n_local_heads": int(self.config.codec_post_n_local_heads),
|
||||
"post_intermediate_size": int(self.config.codec_post_intermediate_size),
|
||||
}
|
||||
|
||||
def _build_voice_clone_prefill_embeds(self, info_dict: dict[str, Any]) -> torch.Tensor:
|
||||
"""Encode the reference audio, build the clone prompt, embed the codes."""
|
||||
text = info_dict.get("text")
|
||||
ref_text = info_dict.get("ref_text")
|
||||
if not isinstance(text, str) or not isinstance(ref_text, str):
|
||||
raise ValueError("Audio8 TTS voice cloning requires string 'text' and 'ref_text'")
|
||||
|
||||
device = self.codebook_embeddings.weight.device
|
||||
cache_key = None
|
||||
voice_name = info_dict.get("voice_name")
|
||||
if isinstance(voice_name, str) and voice_name:
|
||||
cache_key = self._speaker_cache.make_cache_key(
|
||||
voice_name,
|
||||
model_type="audio8_tts",
|
||||
created_at=int(info_dict.get("voice_created_at") or 0),
|
||||
)
|
||||
cached = self._speaker_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
logger.debug("Speaker cache HIT for Audio8 TTS speaker '%s'", voice_name)
|
||||
return self._embed_voice_clone_prompt(
|
||||
text, ref_text, cached["ref_codes_fq"].to(device=device, dtype=torch.long)
|
||||
)
|
||||
|
||||
ref_audio_sr = info_dict.get("ref_audio_sr")
|
||||
if not isinstance(ref_audio_sr, int):
|
||||
raise ValueError("Audio8 TTS voice cloning requires integer 'ref_audio_sr'")
|
||||
ref_audio_wav = info_dict.get("ref_audio_wav")
|
||||
if ref_audio_wav is None:
|
||||
raise ValueError("Audio8 TTS voice cloning requires 'ref_audio_wav'")
|
||||
if not isinstance(ref_audio_wav, torch.Tensor):
|
||||
ref_audio_wav = torch.from_numpy(np.asarray(ref_audio_wav, dtype=np.float32))
|
||||
|
||||
ref_codes_fq = encode_reference_audio_codes(
|
||||
self.model_path,
|
||||
ref_audio_wav,
|
||||
ref_audio_sr,
|
||||
device=device,
|
||||
**self._codec_kwargs(),
|
||||
)
|
||||
if cache_key is not None:
|
||||
self._speaker_cache.put(cache_key, {"ref_codes_fq": ref_codes_fq.detach().cpu()})
|
||||
logger.debug("Speaker cache STORE for Audio8 TTS speaker '%s'", voice_name)
|
||||
return self._embed_voice_clone_prompt(text, ref_text, ref_codes_fq)
|
||||
|
||||
def _embed_voice_clone_prompt(
|
||||
self,
|
||||
text: str,
|
||||
ref_text: str,
|
||||
ref_codes_fq: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Embed the clone prompt, summing codebooks over the reference frames.
|
||||
|
||||
Args:
|
||||
ref_codes_fq: ``[frames, num_codebooks]`` reference codec codes.
|
||||
"""
|
||||
device = self.codebook_embeddings.weight.device
|
||||
ref_codes_fq = ref_codes_fq.to(device=device, dtype=torch.long)
|
||||
semantic_token_ids = (ref_codes_fq[:, 0] + self._semantic_begin_id).tolist()
|
||||
prompt_ids, ref_start, _, _ = build_voice_clone_prompt_ids(
|
||||
self._get_tokenizer(), text, ref_text, semantic_token_ids
|
||||
)
|
||||
prompt = torch.tensor(prompt_ids, dtype=torch.long, device=device)
|
||||
embeds = self.embed_input_ids(prompt.unsqueeze(0)).squeeze(0).to(dtype=torch.bfloat16)
|
||||
|
||||
frames = int(ref_codes_fq.shape[0])
|
||||
if frames <= 0:
|
||||
return embeds
|
||||
codebooks = min(int(ref_codes_fq.shape[1]), self._num_codebooks)
|
||||
offsets = (torch.arange(codebooks, device=device, dtype=torch.long) * self._codebook_size).unsqueeze(0)
|
||||
codes = ref_codes_fq[:, :codebooks].clamp(min=0, max=self._codebook_size - 1) + offsets
|
||||
codebook_sum = self.codebook_embeddings(codes).sum(dim=1).to(dtype=embeds.dtype)
|
||||
|
||||
result = embeds.clone()
|
||||
# Audio8 TTS sums the embeddings directly -- no 1/sqrt(Q+1) rescale.
|
||||
result[ref_start : ref_start + frames] += codebook_sum
|
||||
return result.to(dtype=torch.bfloat16)
|
||||
|
||||
# -------------------- GPU-side Fast AR fast path --------------------
|
||||
|
||||
@torch.inference_mode()
|
||||
def talker_mtp(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
input_embeds: torch.Tensor,
|
||||
last_talker_hidden: torch.Tensor,
|
||||
text_step: torch.Tensor,
|
||||
seed: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Run the Fast AR and fold its codes into this step's input embedding.
|
||||
|
||||
Returns:
|
||||
``(inputs_embeds, audio_codes)`` where ``audio_codes`` is
|
||||
``[B, num_codebooks]``.
|
||||
"""
|
||||
del text_step
|
||||
bsz = int(input_ids.shape[0])
|
||||
device = input_embeds.device
|
||||
|
||||
token_ids = input_ids.reshape(bsz).to(dtype=torch.long, device=device)
|
||||
past_hidden = last_talker_hidden.reshape(bsz, -1).to(dtype=torch.bfloat16, device=device)
|
||||
|
||||
temperature = kwargs.get("temperature")
|
||||
temperature = DEFAULT_TEMPERATURE if temperature is None else float(temperature)
|
||||
do_sample = kwargs.get("do_sample")
|
||||
generator = kwargs.get("generator")
|
||||
if generator is None and seed is not None:
|
||||
generator = torch.Generator(device=device)
|
||||
generator.manual_seed(int(seed))
|
||||
|
||||
_top_k = kwargs.get("top_k")
|
||||
_top_p = kwargs.get("top_p")
|
||||
audio_codes = self.fast_ar(
|
||||
slow_ar_hidden=past_hidden,
|
||||
semantic_token_id=token_ids,
|
||||
do_sample=(temperature >= SAMPLING_EPS) if do_sample is None else bool(do_sample),
|
||||
temperature=temperature,
|
||||
top_k=int(DEFAULT_TOP_K if _top_k is None else _top_k),
|
||||
top_p=float(DEFAULT_TOP_P if _top_p is None else _top_p),
|
||||
generator=generator,
|
||||
)
|
||||
|
||||
embeds = input_embeds.reshape(bsz, -1)
|
||||
offsets = (torch.arange(self._num_codebooks, device=device, dtype=torch.long) * self._codebook_size).unsqueeze(
|
||||
0
|
||||
)
|
||||
codebook_sum = self.codebook_embeddings(audio_codes + offsets).sum(dim=1).to(dtype=embeds.dtype)
|
||||
is_semantic = (token_ids >= self._semantic_begin_id) & (token_ids <= self._semantic_end_id)
|
||||
embeds = torch.where(is_semantic.unsqueeze(-1), embeds + codebook_sum, embeds)
|
||||
return embeds, audio_codes.to(dtype=torch.long)
|
||||
|
||||
# -------------------- prompt length estimation --------------------
|
||||
|
||||
@staticmethod
|
||||
def estimate_prompt_len_from_additional_information(
|
||||
additional_information: dict[str, Any] | None,
|
||||
**kwargs: Any,
|
||||
) -> int:
|
||||
"""Upper-bound the text-only prompt length for placeholder allocation."""
|
||||
del kwargs
|
||||
info = additional_information or {}
|
||||
text = info.get("text", "")
|
||||
if isinstance(text, list):
|
||||
text = text[0] if text else ""
|
||||
# ~1 token per character is a safe bound for CJK; +64 covers the
|
||||
# chat-template scaffolding.
|
||||
return max(2, len(str(text)) + 64)
|
||||
|
||||
# -------------------- weight loading --------------------
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
text_config = self.text_config
|
||||
fast_config = self.fast_ar_config
|
||||
remapped = _remap_audio8_tts_weights(
|
||||
weights,
|
||||
q_size=text_config.num_attention_heads * text_config.head_dim,
|
||||
kv_size=text_config.num_key_value_heads * text_config.head_dim,
|
||||
fast_q_size=fast_config.num_attention_heads * fast_config.head_dim,
|
||||
fast_kv_size=fast_config.num_key_value_heads * fast_config.head_dim,
|
||||
)
|
||||
|
||||
stacked_params_mapping = [
|
||||
("qkv_proj", "q_proj", "q"),
|
||||
("qkv_proj", "k_proj", "k"),
|
||||
("qkv_proj", "v_proj", "v"),
|
||||
("gate_up_proj", "gate_proj", 0),
|
||||
("gate_up_proj", "up_proj", 1),
|
||||
]
|
||||
params_dict = dict(self.named_parameters(remove_duplicate=False))
|
||||
loaded_params: set[str] = set()
|
||||
unexpected: list[str] = []
|
||||
|
||||
for name, loaded_weight in remapped:
|
||||
if name == "model.embed_tokens.weight" and self.text_config.tie_word_embeddings:
|
||||
lm_key = "lm_head.weight"
|
||||
if lm_key in params_dict:
|
||||
param = params_dict[lm_key]
|
||||
loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
loader(param, loaded_weight)
|
||||
loaded_params.add(lm_key)
|
||||
|
||||
handled = False
|
||||
for param_name, weight_name, shard_id in stacked_params_mapping:
|
||||
if weight_name not in name:
|
||||
continue
|
||||
mapped = name.replace(weight_name, param_name)
|
||||
if mapped not in params_dict:
|
||||
continue
|
||||
param = params_dict[mapped]
|
||||
loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
if loader == default_weight_loader:
|
||||
loader(param, loaded_weight)
|
||||
else:
|
||||
loader(param, loaded_weight, shard_id)
|
||||
loaded_params.add(mapped)
|
||||
handled = True
|
||||
break
|
||||
if handled:
|
||||
continue
|
||||
|
||||
if name in params_dict:
|
||||
param = params_dict[name]
|
||||
loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
loader(param, loaded_weight)
|
||||
loaded_params.add(name)
|
||||
else:
|
||||
unexpected.append(name)
|
||||
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
f"Audio8 TTS Slow AR received {len(unexpected)} unmapped checkpoint tensors, "
|
||||
f"e.g. {sorted(unexpected)[:5]}. The weight remapper is out of sync with the checkpoint."
|
||||
)
|
||||
missing = sorted(set(params_dict) - loaded_params)
|
||||
if missing:
|
||||
raise ValueError(f"Audio8 TTS Slow AR is missing weights for {missing[:5]} ({len(missing)} total)")
|
||||
logger.info("Loaded %d weights for Audio8TTSSlowARForConditionalGeneration", len(loaded_params))
|
||||
|
||||
# The reference precomputes RoPE in bf16; keeping fp32 cos/sin here
|
||||
# shifts the logits enough to trigger early EOS (same failure mode as
|
||||
# Fish Speech).
|
||||
truncated = 0
|
||||
for module in self.modules():
|
||||
cache = getattr(module, "cos_sin_cache", None)
|
||||
if isinstance(cache, torch.Tensor):
|
||||
module.cos_sin_cache = cache.to(torch.bfloat16).to(cache.dtype)
|
||||
truncated += 1
|
||||
if truncated:
|
||||
logger.info("Audio8 TTS: truncated %d RoPE cos_sin_cache buffers to bf16", truncated)
|
||||
|
||||
return loaded_params
|
||||
|
||||
|
||||
__all__ = ["Audio8TTSSlowARForConditionalGeneration"]
|
||||
@@ -0,0 +1,600 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS neural audio codec (44.1 kHz, 10 codebooks).
|
||||
|
||||
Vendored from the reference ``modeling_arktts_codec.py`` shipped with
|
||||
``Audio8/Audio8-TTS-Preview-0.6b`` (Apache-2.0). The module tree and attribute
|
||||
names are reproduced verbatim so ``codec.pth`` loads under ``strict=True``.
|
||||
|
||||
Inference-only deviations: no ``torch.jit.script`` on Snake (it fights
|
||||
``torch.compile`` / CUDA graphs), RoPE cached in a plain dict instead of a
|
||||
non-persistent buffer (the pattern that leaves the reference LM with
|
||||
uninitialised RoPE under ``from_pretrained`` on transformers >= 5), and
|
||||
:func:`build_arktts_codec` prunes the unused encoder or decoder half per stage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
from torch.nn.utils import weight_norm as legacy_weight_norm
|
||||
from torch.nn.utils.parametrizations import weight_norm
|
||||
|
||||
# The codec's own transformer blocks always use base 10000, independent of the
|
||||
# 1e6 rope_base of the DualAR language model.
|
||||
_CODEC_ROPE_BASE = 10000.0
|
||||
|
||||
|
||||
def _rope_table(length: int, head_dim: int, base: float, device: torch.device) -> Tensor:
|
||||
frequencies = 1.0 / (base ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim))
|
||||
phases = torch.outer(torch.arange(length, device=device), frequencies)
|
||||
values = torch.polar(torch.ones_like(phases), phases)
|
||||
return torch.stack((values.real, values.imag), dim=-1).to(torch.bfloat16)
|
||||
|
||||
|
||||
def _apply_rope(x: Tensor, values: Tensor) -> Tensor:
|
||||
shaped = x.float().reshape(*x.shape[:-1], -1, 2)
|
||||
values = values.view(1, shaped.shape[1], 1, shaped.shape[3], 2)
|
||||
output = torch.stack(
|
||||
(
|
||||
shaped[..., 0] * values[..., 0] - shaped[..., 1] * values[..., 1],
|
||||
shaped[..., 1] * values[..., 0] + shaped[..., 0] * values[..., 1],
|
||||
),
|
||||
dim=-1,
|
||||
)
|
||||
return output.flatten(3).to(x.dtype)
|
||||
|
||||
|
||||
class ArkttsCodecRMSNorm(nn.Module):
|
||||
def __init__(self, dim: int, eps: float = 1e-5) -> None:
|
||||
super().__init__()
|
||||
self.eps = float(eps)
|
||||
self.weight = nn.Parameter(torch.ones(dim))
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
output = x.float() * torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + self.eps)
|
||||
return output.to(x.dtype) * self.weight
|
||||
|
||||
|
||||
class ArkttsCodecLayerScale(nn.Module):
|
||||
def __init__(self, dim: int, init_values: float = 1e-2) -> None:
|
||||
super().__init__()
|
||||
self.gamma = nn.Parameter(init_values * torch.ones(dim))
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
return x * self.gamma
|
||||
|
||||
|
||||
class ArkttsCodecTransformerConfig:
|
||||
"""Plain option holder for the codec's transformer blocks.
|
||||
|
||||
Deliberately not a dataclass / msgspec Struct: it mirrors a reference
|
||||
dataclass whose only job is to carry constructor arguments, and it is
|
||||
instantiated from Python literals inside this module only.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
n_layer: int,
|
||||
n_head: int,
|
||||
dim: int,
|
||||
intermediate_size: int,
|
||||
n_local_heads: int = -1,
|
||||
head_dim: int = 64,
|
||||
rope_base: float = _CODEC_ROPE_BASE,
|
||||
norm_eps: float = 1e-5,
|
||||
channels_first: bool = True,
|
||||
) -> None:
|
||||
self.n_layer = int(n_layer)
|
||||
self.n_head = int(n_head)
|
||||
self.dim = int(dim)
|
||||
self.intermediate_size = int(intermediate_size)
|
||||
self.n_local_heads = int(n_head) if n_local_heads == -1 else int(n_local_heads)
|
||||
self.head_dim = int(head_dim)
|
||||
self.rope_base = float(rope_base)
|
||||
self.norm_eps = float(norm_eps)
|
||||
self.channels_first = bool(channels_first)
|
||||
|
||||
|
||||
class ArkttsCodecAttention(nn.Module):
|
||||
def __init__(self, config: ArkttsCodecTransformerConfig) -> None:
|
||||
super().__init__()
|
||||
total = (config.n_head + 2 * config.n_local_heads) * config.head_dim
|
||||
self.wqkv = nn.Linear(config.dim, total, bias=False)
|
||||
self.wo = nn.Linear(config.head_dim * config.n_head, config.dim, bias=False)
|
||||
self.n_head = config.n_head
|
||||
self.n_local_heads = config.n_local_heads
|
||||
self.head_dim = config.head_dim
|
||||
|
||||
def forward(self, x: Tensor, rope_values: Tensor, mask: Tensor) -> Tensor:
|
||||
batch, length, _ = x.shape
|
||||
query_size = self.n_head * self.head_dim
|
||||
kv_size = self.n_local_heads * self.head_dim
|
||||
query, key, value = self.wqkv(x).split((query_size, kv_size, kv_size), dim=-1)
|
||||
query = query.view(batch, length, self.n_head, self.head_dim)
|
||||
key = key.view(batch, length, self.n_local_heads, self.head_dim)
|
||||
value = value.view(batch, length, self.n_local_heads, self.head_dim)
|
||||
query = _apply_rope(query, rope_values).transpose(1, 2)
|
||||
key = _apply_rope(key, rope_values).transpose(1, 2)
|
||||
value = value.transpose(1, 2)
|
||||
output = F.scaled_dot_product_attention(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
attn_mask=mask,
|
||||
enable_gqa=self.n_local_heads != self.n_head,
|
||||
)
|
||||
output = output.transpose(1, 2).contiguous().view(batch, length, query_size)
|
||||
return self.wo(output)
|
||||
|
||||
|
||||
class ArkttsCodecFeedForward(nn.Module):
|
||||
def __init__(self, config: ArkttsCodecTransformerConfig) -> None:
|
||||
super().__init__()
|
||||
self.w1 = nn.Linear(config.dim, config.intermediate_size, bias=False)
|
||||
self.w3 = nn.Linear(config.dim, config.intermediate_size, bias=False)
|
||||
self.w2 = nn.Linear(config.intermediate_size, config.dim, bias=False)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
return self.w2(F.silu(self.w1(x)) * self.w3(x))
|
||||
|
||||
|
||||
class ArkttsCodecTransformerBlock(nn.Module):
|
||||
def __init__(self, config: ArkttsCodecTransformerConfig) -> None:
|
||||
super().__init__()
|
||||
self.attention = ArkttsCodecAttention(config)
|
||||
self.feed_forward = ArkttsCodecFeedForward(config)
|
||||
self.ffn_norm = ArkttsCodecRMSNorm(config.dim, config.norm_eps)
|
||||
self.attention_norm = ArkttsCodecRMSNorm(config.dim, config.norm_eps)
|
||||
self.attention_layer_scale = ArkttsCodecLayerScale(config.dim)
|
||||
self.ffn_layer_scale = ArkttsCodecLayerScale(config.dim)
|
||||
|
||||
def forward(self, x: Tensor, rope_values: Tensor, mask: Tensor) -> Tensor:
|
||||
hidden = x + self.attention_layer_scale(self.attention(self.attention_norm(x), rope_values, mask))
|
||||
return hidden + self.ffn_layer_scale(self.feed_forward(self.ffn_norm(hidden)))
|
||||
|
||||
|
||||
class ArkttsCodecWindowTransformer(nn.Module):
|
||||
"""Causal transformer with a sliding attention window."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ArkttsCodecTransformerConfig,
|
||||
input_dim: int,
|
||||
window_size: int | None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.layers = nn.ModuleList([ArkttsCodecTransformerBlock(config) for _ in range(config.n_layer)])
|
||||
self.norm = ArkttsCodecRMSNorm(config.dim, config.norm_eps)
|
||||
self.window_size = window_size
|
||||
self.channels_first = config.channels_first
|
||||
self.input_proj = nn.Linear(input_dim, config.dim) if input_dim != config.dim else nn.Identity()
|
||||
self.output_proj = nn.Linear(config.dim, input_dim) if input_dim != config.dim else nn.Identity()
|
||||
self.look_ahead_conv = nn.Identity()
|
||||
self.head_dim = config.head_dim
|
||||
self.rope_base = config.rope_base
|
||||
self._mask_cache: dict[tuple[int, str], Tensor] = {}
|
||||
self._rope_cache: dict[tuple[int, str], Tensor] = {}
|
||||
|
||||
def _mask(self, length: int, device: torch.device) -> Tensor:
|
||||
key = (length, str(device))
|
||||
cached = self._mask_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
row = torch.arange(length, device=device)[:, None]
|
||||
column = torch.arange(length, device=device)[None, :]
|
||||
mask = column <= row
|
||||
if self.window_size is not None:
|
||||
mask &= column >= (row - self.window_size + 1).clamp_min(0)
|
||||
mask = mask[None, None]
|
||||
self._mask_cache[key] = mask
|
||||
return mask
|
||||
|
||||
def _rope(self, length: int, device: torch.device) -> Tensor:
|
||||
key = (length, str(device))
|
||||
cached = self._rope_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
values = _rope_table(length, self.head_dim, self.rope_base, device)
|
||||
self._rope_cache[key] = values
|
||||
return values
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
if self.channels_first:
|
||||
x = x.transpose(1, 2)
|
||||
x = self.look_ahead_conv(self.input_proj(x))
|
||||
length = int(x.shape[1])
|
||||
mask = self._mask(length, x.device)
|
||||
rope_values = self._rope(length, x.device)
|
||||
for layer in self.layers:
|
||||
x = layer(x, rope_values, mask)
|
||||
x = self.output_proj(self.norm(x))
|
||||
return x.transpose(1, 2) if self.channels_first else x
|
||||
|
||||
|
||||
def _extra_padding(x: Tensor, kernel_size: int, stride: int, padding_total: int = 0) -> int:
|
||||
length = x.shape[-1]
|
||||
frames = (length - kernel_size + padding_total) / stride + 1
|
||||
ideal = (math.ceil(frames) - 1) * stride + kernel_size - padding_total
|
||||
return ideal - length
|
||||
|
||||
|
||||
class ArkttsCausalConv1d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
kernel_size: int,
|
||||
dilation: int = 1,
|
||||
stride: int = 1,
|
||||
groups: int = 1,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.conv = nn.Conv1d(in_channels, out_channels, kernel_size, stride=stride, dilation=dilation, groups=groups)
|
||||
self.stride = stride
|
||||
self.kernel_size = (kernel_size - 1) * dilation + 1
|
||||
self.padding = self.kernel_size - self.stride
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
right = _extra_padding(x, self.kernel_size, self.stride, self.padding)
|
||||
return self.conv(F.pad(x, (self.padding, right))).contiguous()
|
||||
|
||||
def apply_weight_norm(self) -> ArkttsCausalConv1d:
|
||||
self.conv = weight_norm(self.conv)
|
||||
return self
|
||||
|
||||
|
||||
class ArkttsCausalConvTranspose1d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels: int,
|
||||
out_channels: int,
|
||||
kernel_size: int,
|
||||
stride: int = 1,
|
||||
dilation: int = 1,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.conv = nn.ConvTranspose1d(in_channels, out_channels, kernel_size, stride=stride, dilation=dilation)
|
||||
self.stride = stride
|
||||
self.kernel_size = kernel_size
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
x = self.conv(x)
|
||||
crop = self.kernel_size - self.stride
|
||||
return x[..., : x.shape[-1] - crop].contiguous() if crop else x.contiguous()
|
||||
|
||||
def apply_weight_norm(self) -> ArkttsCausalConvTranspose1d:
|
||||
self.conv = weight_norm(self.conv)
|
||||
return self
|
||||
|
||||
|
||||
def _causal_wn_conv(*args: Any, **kwargs: Any) -> ArkttsCausalConv1d:
|
||||
return ArkttsCausalConv1d(*args, **kwargs).apply_weight_norm()
|
||||
|
||||
|
||||
def _causal_wn_transpose(*args: Any, **kwargs: Any) -> ArkttsCausalConvTranspose1d:
|
||||
return ArkttsCausalConvTranspose1d(*args, **kwargs).apply_weight_norm()
|
||||
|
||||
|
||||
class ArkttsSnake1d(nn.Module):
|
||||
def __init__(self, channels: int) -> None:
|
||||
super().__init__()
|
||||
self.alpha = nn.Parameter(torch.ones(1, channels, 1))
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
shape = x.shape
|
||||
x = x.reshape(shape[0], shape[1], -1)
|
||||
x = x + (self.alpha + 1e-9).reciprocal() * torch.sin(self.alpha * x).pow(2)
|
||||
return x.reshape(shape)
|
||||
|
||||
|
||||
class ArkttsResidualUnit(nn.Module):
|
||||
def __init__(self, dim: int, dilation: int) -> None:
|
||||
super().__init__()
|
||||
self.block = nn.Sequential(
|
||||
ArkttsSnake1d(dim),
|
||||
_causal_wn_conv(dim, dim, kernel_size=7, dilation=dilation),
|
||||
ArkttsSnake1d(dim),
|
||||
_causal_wn_conv(dim, dim, kernel_size=1),
|
||||
)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
output = self.block(x)
|
||||
difference = x.shape[-1] - output.shape[-1]
|
||||
if difference > 0:
|
||||
x = x[..., :-difference]
|
||||
return x + output
|
||||
|
||||
|
||||
class ArkttsEncoderBlock(nn.Module):
|
||||
def __init__(self, dim: int, stride: int, transformer_layers: int) -> None:
|
||||
super().__init__()
|
||||
modules: list[nn.Module] = [
|
||||
ArkttsResidualUnit(dim // 2, 1),
|
||||
ArkttsResidualUnit(dim // 2, 3),
|
||||
ArkttsResidualUnit(dim // 2, 9),
|
||||
ArkttsSnake1d(dim // 2),
|
||||
_causal_wn_conv(dim // 2, dim, kernel_size=2 * stride, stride=stride),
|
||||
]
|
||||
if transformer_layers:
|
||||
config = ArkttsCodecTransformerConfig(
|
||||
n_layer=transformer_layers,
|
||||
n_head=dim // 64,
|
||||
dim=dim,
|
||||
intermediate_size=dim * 3,
|
||||
)
|
||||
modules.append(ArkttsCodecWindowTransformer(config, dim, window_size=512))
|
||||
else:
|
||||
modules.append(nn.Identity())
|
||||
self.block = nn.Sequential(*modules)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
return self.block(x)
|
||||
|
||||
|
||||
class ArkttsEncoder(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
dim = 64
|
||||
modules: list[nn.Module] = [_causal_wn_conv(1, dim, kernel_size=7)]
|
||||
for stride, transformer_layers in zip((2, 4, 8, 8), (0, 0, 0, 4), strict=True):
|
||||
dim *= 2
|
||||
modules.append(ArkttsEncoderBlock(dim, stride, transformer_layers))
|
||||
modules.extend((ArkttsSnake1d(dim), _causal_wn_conv(dim, 1024, kernel_size=3)))
|
||||
self.block = nn.Sequential(*modules)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
return self.block(x)
|
||||
|
||||
|
||||
class ArkttsDecoderBlock(nn.Module):
|
||||
def __init__(self, input_dim: int, output_dim: int, stride: int) -> None:
|
||||
super().__init__()
|
||||
self.block = nn.Sequential(
|
||||
ArkttsSnake1d(input_dim),
|
||||
_causal_wn_transpose(input_dim, output_dim, kernel_size=2 * stride, stride=stride),
|
||||
ArkttsResidualUnit(output_dim, 1),
|
||||
ArkttsResidualUnit(output_dim, 3),
|
||||
ArkttsResidualUnit(output_dim, 9),
|
||||
)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
return self.block(x)
|
||||
|
||||
|
||||
class ArkttsDecoder(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
channels = 1536
|
||||
modules: list[nn.Module] = [_causal_wn_conv(1024, channels, kernel_size=7)]
|
||||
output_dim = channels
|
||||
for index, stride in enumerate((8, 8, 4, 2)):
|
||||
input_dim = channels // (2**index)
|
||||
output_dim = channels // (2 ** (index + 1))
|
||||
modules.append(ArkttsDecoderBlock(input_dim, output_dim, stride))
|
||||
modules.extend(
|
||||
(
|
||||
ArkttsSnake1d(output_dim),
|
||||
_causal_wn_conv(output_dim, 1, kernel_size=7),
|
||||
nn.Tanh(),
|
||||
)
|
||||
)
|
||||
self.model = nn.Sequential(*modules)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
return self.model(x)
|
||||
|
||||
|
||||
class ArkttsVectorQuantizer(nn.Module):
|
||||
def __init__(self, input_dim: int, codebook_size: int, codebook_dim: int) -> None:
|
||||
super().__init__()
|
||||
self.codebook_size = int(codebook_size)
|
||||
self.codebook_dim = int(codebook_dim)
|
||||
self.in_proj = legacy_weight_norm(nn.Conv1d(input_dim, codebook_dim, kernel_size=1))
|
||||
self.out_proj = legacy_weight_norm(nn.Conv1d(codebook_dim, input_dim, kernel_size=1))
|
||||
self.codebook = nn.Embedding(codebook_size, codebook_dim)
|
||||
|
||||
def decode_code(self, indices: Tensor) -> Tensor:
|
||||
return F.embedding(indices, self.codebook.weight).transpose(1, 2)
|
||||
|
||||
def decode_latents(self, latents: Tensor) -> tuple[Tensor, Tensor]:
|
||||
batch, _, length = latents.shape
|
||||
flattened = F.normalize(latents.transpose(1, 2).reshape(batch * length, -1))
|
||||
codebook = F.normalize(self.codebook.weight)
|
||||
distances = (
|
||||
flattened.pow(2).sum(1, keepdim=True)
|
||||
- 2 * flattened @ codebook.t()
|
||||
+ codebook.pow(2).sum(1, keepdim=True).t()
|
||||
)
|
||||
indices = (-distances).argmax(1).view(batch, length)
|
||||
return self.decode_code(indices), indices
|
||||
|
||||
def forward(self, z: Tensor) -> tuple[Tensor, Tensor]:
|
||||
projected = self.in_proj(z)
|
||||
quantized, indices = self.decode_latents(projected)
|
||||
return self.out_proj(quantized), indices
|
||||
|
||||
|
||||
class ArkttsResidualQuantizer(nn.Module):
|
||||
def __init__(self, input_dim: int, n_codebooks: int, codebook_size: int, codebook_dim: int) -> None:
|
||||
super().__init__()
|
||||
self.n_codebooks = int(n_codebooks)
|
||||
self.codebook_size = int(codebook_size)
|
||||
self.quantizers = nn.ModuleList(
|
||||
[ArkttsVectorQuantizer(input_dim, codebook_size, codebook_dim) for _ in range(n_codebooks)]
|
||||
)
|
||||
|
||||
def forward(self, z: Tensor) -> tuple[Tensor, Tensor]:
|
||||
quantized_sum: Tensor | float = 0.0
|
||||
residual = z
|
||||
codes: list[Tensor] = []
|
||||
for quantizer in self.quantizers:
|
||||
quantized, indices = quantizer(residual)
|
||||
quantized_sum = quantized_sum + quantized
|
||||
residual = residual - quantized
|
||||
codes.append(indices)
|
||||
return quantized_sum, torch.stack(codes, dim=1)
|
||||
|
||||
def from_codes(self, codes: Tensor) -> Tensor:
|
||||
output: Tensor | float = 0.0
|
||||
for index in range(codes.shape[1]):
|
||||
projected = self.quantizers[index].decode_code(codes[:, index])
|
||||
output = output + self.quantizers[index].out_proj(projected)
|
||||
return output
|
||||
|
||||
|
||||
class ArkttsConvNeXtBlock(nn.Module):
|
||||
def __init__(self, dim: int) -> None:
|
||||
super().__init__()
|
||||
self.dwconv = ArkttsCausalConv1d(dim, dim, kernel_size=7, groups=dim)
|
||||
self.norm = nn.LayerNorm(dim, eps=1e-6)
|
||||
self.pwconv1 = nn.Linear(dim, 4 * dim)
|
||||
self.act = nn.GELU()
|
||||
self.pwconv2 = nn.Linear(4 * dim, dim)
|
||||
self.gamma = nn.Parameter(1e-6 * torch.ones(dim))
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
residual = x
|
||||
x = self.dwconv(x).permute(0, 2, 1)
|
||||
x = self.pwconv2(self.act(self.pwconv1(self.norm(x))))
|
||||
x = (self.gamma * x).permute(0, 2, 1)
|
||||
return residual + x
|
||||
|
||||
|
||||
class ArkttsDownsampleQuantizer(nn.Module):
|
||||
"""1 semantic codebook (4096) + 9 residual codebooks (1024), 4x downsampled."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
post_n_layer: int = 8,
|
||||
post_n_head: int = 16,
|
||||
post_n_local_heads: int = 8,
|
||||
post_intermediate_size: int = 1216,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.semantic_quantizer = ArkttsResidualQuantizer(1024, 1, 4096, 8)
|
||||
self.quantizer = ArkttsResidualQuantizer(1024, 9, 1024, 8)
|
||||
self.downsample = nn.Sequential(
|
||||
nn.Sequential(ArkttsCausalConv1d(1024, 1024, kernel_size=2, stride=2), ArkttsConvNeXtBlock(1024)),
|
||||
nn.Sequential(ArkttsCausalConv1d(1024, 1024, kernel_size=2, stride=2), ArkttsConvNeXtBlock(1024)),
|
||||
)
|
||||
self.upsample = nn.Sequential(
|
||||
nn.Sequential(ArkttsCausalConvTranspose1d(1024, 1024, kernel_size=2, stride=2), ArkttsConvNeXtBlock(1024)),
|
||||
nn.Sequential(ArkttsCausalConvTranspose1d(1024, 1024, kernel_size=2, stride=2), ArkttsConvNeXtBlock(1024)),
|
||||
)
|
||||
self.pre_module = ArkttsCodecWindowTransformer(
|
||||
ArkttsCodecTransformerConfig(n_layer=8, n_head=16, dim=1024, intermediate_size=3072),
|
||||
1024,
|
||||
window_size=128,
|
||||
)
|
||||
self.post_module = ArkttsCodecWindowTransformer(
|
||||
ArkttsCodecTransformerConfig(
|
||||
n_layer=post_n_layer,
|
||||
n_head=post_n_head,
|
||||
n_local_heads=post_n_local_heads,
|
||||
dim=1024,
|
||||
intermediate_size=post_intermediate_size,
|
||||
),
|
||||
1024,
|
||||
window_size=128,
|
||||
)
|
||||
self.semantic_predictor_module = nn.Identity()
|
||||
|
||||
def forward(self, z: Tensor) -> tuple[Tensor, Tensor]:
|
||||
original_length = z.shape[-1]
|
||||
z = self.pre_module(self.downsample(z))
|
||||
semantic, semantic_codes = self.semantic_quantizer(z)
|
||||
residual, residual_codes = self.quantizer(z - semantic)
|
||||
z = self.upsample(self.post_module(semantic + residual))
|
||||
difference = original_length - z.shape[-1]
|
||||
if difference > 0:
|
||||
z = F.pad(z, (difference, 0))
|
||||
elif difference < 0:
|
||||
z = z[..., -difference:]
|
||||
return z, torch.cat((semantic_codes, residual_codes), dim=1)
|
||||
|
||||
def decode(self, indices: Tensor) -> Tensor:
|
||||
# Cloned because the clamps below are in-place and this is the caller's
|
||||
# codes buffer; once per decode chunk, not per AR step.
|
||||
indices = indices.clone()
|
||||
indices[:, 0].clamp_(0, self.semantic_quantizer.codebook_size - 1)
|
||||
indices[:, 1:].clamp_(0, self.quantizer.codebook_size - 1)
|
||||
semantic = self.semantic_quantizer.from_codes(indices[:, :1])
|
||||
residual = self.quantizer.from_codes(indices[:, 1:])
|
||||
return self.upsample(self.post_module(semantic + residual))
|
||||
|
||||
|
||||
class ArkttsCodec(nn.Module):
|
||||
"""Audio8 TTS codec: waveform <-> 10 codebooks at ~21.5 frames/s."""
|
||||
|
||||
sample_rate = 44100
|
||||
frame_length = 2048
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
post_n_layer: int = 8,
|
||||
post_n_head: int = 16,
|
||||
post_n_local_heads: int = 8,
|
||||
post_intermediate_size: int = 1216,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.encoder = ArkttsEncoder()
|
||||
self.quantizer = ArkttsDownsampleQuantizer(
|
||||
post_n_layer=post_n_layer,
|
||||
post_n_head=post_n_head,
|
||||
post_n_local_heads=post_n_local_heads,
|
||||
post_intermediate_size=post_intermediate_size,
|
||||
)
|
||||
self.decoder = ArkttsDecoder()
|
||||
|
||||
@torch.inference_mode()
|
||||
def encode(self, audio: Tensor, audio_lengths: Tensor | None = None) -> tuple[Tensor, Tensor]:
|
||||
"""Encode ``[B, 1, samples]`` (or ``[B, samples]``) to ``[B, 10, frames]``."""
|
||||
if audio.ndim == 2:
|
||||
audio = audio[:, None]
|
||||
if audio.ndim != 3 or audio.shape[1] != 1:
|
||||
raise ValueError(f"audio must have shape [B, 1, samples], got {tuple(audio.shape)}")
|
||||
original_length = int(audio.shape[-1])
|
||||
right = math.ceil(original_length / self.frame_length) * self.frame_length - original_length
|
||||
if right:
|
||||
audio = F.pad(audio, (0, right))
|
||||
if audio_lengths is None:
|
||||
audio_lengths = torch.full((audio.shape[0],), original_length, device=audio.device, dtype=torch.long)
|
||||
_, codes = self.quantizer(self.encoder(audio))
|
||||
code_lengths = torch.ceil(audio_lengths.float() / self.frame_length).long()
|
||||
max_codes = int(codes.shape[-1])
|
||||
code_lengths = code_lengths.clamp_max(max_codes)
|
||||
frame_index = torch.arange(max_codes, device=codes.device)
|
||||
valid = frame_index[None, None, :] < code_lengths[:, None, None]
|
||||
return torch.where(valid, codes, torch.full_like(codes, -1)), code_lengths
|
||||
|
||||
@torch.inference_mode()
|
||||
def decode(self, codes: Tensor) -> Tensor:
|
||||
"""Decode ``[B, 10, frames]`` to ``[B, 1, samples]``."""
|
||||
return self.decoder(self.quantizer.decode(codes.long()))
|
||||
|
||||
|
||||
def build_arktts_codec(
|
||||
*,
|
||||
post_n_layer: int = 8,
|
||||
post_n_head: int = 16,
|
||||
post_n_local_heads: int = 8,
|
||||
post_intermediate_size: int = 1216,
|
||||
) -> ArkttsCodec:
|
||||
"""Construct the codec with uninitialised weights, on CPU, in eval mode."""
|
||||
codec = ArkttsCodec(
|
||||
post_n_layer=post_n_layer,
|
||||
post_n_head=post_n_head,
|
||||
post_n_local_heads=post_n_local_heads,
|
||||
post_intermediate_size=post_intermediate_size,
|
||||
)
|
||||
codec.eval()
|
||||
return codec
|
||||
|
||||
|
||||
__all__ = ["ArkttsCodec", "build_arktts_codec"]
|
||||
@@ -0,0 +1,224 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Loading / caching helpers for the Audio8 TTS ``codec.pth`` checkpoint.
|
||||
|
||||
Both stages need the codec but for opposite halves: Stage 0 encodes reference
|
||||
audio for zero-shot voice cloning, Stage 1 decodes generated codes to waveform.
|
||||
The unused half is pruned before the module is moved to the device so a stage
|
||||
never pays GPU memory for weights it cannot reach.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.nn.utils.parametrize import remove_parametrizations
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.model_executor.models.audio8_tts.codec import ArkttsCodec, build_arktts_codec
|
||||
from vllm_omni.model_executor.models.audio8_tts.configuration_audio8_tts import (
|
||||
ARKTTS_CODEC_FRAME_SIZE,
|
||||
ARKTTS_CODEC_SAMPLE_RATE,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
CODEC_FILENAME = "codec.pth"
|
||||
|
||||
_codec_cache: dict[tuple[str, str, str, str], ArkttsCodec] = {}
|
||||
|
||||
|
||||
def resolve_codec_path(model_path: str, filename: str = CODEC_FILENAME) -> str:
|
||||
"""Return a local path to ``filename``, downloading from the Hub if needed."""
|
||||
local = os.path.join(model_path, filename)
|
||||
if os.path.exists(local):
|
||||
return local
|
||||
|
||||
from transformers.utils.hub import cached_file
|
||||
|
||||
cached = cached_file(model_path, filename)
|
||||
if cached is None or not os.path.exists(cached):
|
||||
raise FileNotFoundError(
|
||||
f"{filename} not found for {model_path}; the Audio8 TTS checkpoint must ship its neural audio codec."
|
||||
)
|
||||
return cached
|
||||
|
||||
|
||||
def bake_weight_norm(module: nn.Module) -> int:
|
||||
"""Fold weight-norm parametrisations into plain weights (inference only)."""
|
||||
baked = 0
|
||||
for submodule in module.modules():
|
||||
parametrizations = getattr(submodule, "parametrizations", None)
|
||||
if not parametrizations:
|
||||
continue
|
||||
for name in list(parametrizations.keys()):
|
||||
remove_parametrizations(submodule, name, leave_parametrized=True)
|
||||
baked += 1
|
||||
return baked
|
||||
|
||||
|
||||
def load_arktts_codec(
|
||||
model_path: str,
|
||||
*,
|
||||
device: torch.device | str = "cpu",
|
||||
dtype: torch.dtype = torch.float32,
|
||||
role: str = "decode",
|
||||
post_n_layer: int = 8,
|
||||
post_n_head: int = 16,
|
||||
post_n_local_heads: int = 8,
|
||||
post_intermediate_size: int = 1216,
|
||||
) -> ArkttsCodec:
|
||||
"""Load ``codec.pth`` for ``role`` in {"encode", "decode", "both"}.
|
||||
|
||||
Instances are cached per (path, device, dtype, role) because both the Stage-0
|
||||
voice-clone path and the Stage-1 decoder may ask for the same codec many
|
||||
times per request.
|
||||
"""
|
||||
if role not in {"encode", "decode", "both"}:
|
||||
raise ValueError(f"role must be encode/decode/both, got {role!r}")
|
||||
device = torch.device(device)
|
||||
cache_key = (model_path, str(device), str(dtype), role)
|
||||
cached_codec = _codec_cache.get(cache_key)
|
||||
if cached_codec is not None:
|
||||
return cached_codec
|
||||
|
||||
codec_path = resolve_codec_path(model_path)
|
||||
codec = build_arktts_codec(
|
||||
post_n_layer=post_n_layer,
|
||||
post_n_head=post_n_head,
|
||||
post_n_local_heads=post_n_local_heads,
|
||||
post_intermediate_size=post_intermediate_size,
|
||||
)
|
||||
|
||||
state = torch.load(codec_path, map_location="cpu", weights_only=True)
|
||||
if "state_dict" in state:
|
||||
state = state["state_dict"]
|
||||
if any("generator." in key for key in state):
|
||||
state = {key.replace("generator.", ""): value for key, value in state.items() if "generator." in key}
|
||||
state = {key: value for key, value in state.items() if not key.endswith(("freqs_cis", "causal_mask"))}
|
||||
codec.load_state_dict(state, strict=True)
|
||||
|
||||
baked = bake_weight_norm(codec)
|
||||
if role == "encode":
|
||||
codec.decoder = None
|
||||
elif role == "decode":
|
||||
codec.encoder = None
|
||||
codec.quantizer.pre_module = None
|
||||
codec.quantizer.downsample = None
|
||||
|
||||
codec = codec.to(device=device, dtype=dtype)
|
||||
codec.eval()
|
||||
_codec_cache[cache_key] = codec
|
||||
logger.info(
|
||||
"Loaded Audio8 TTS codec from %s (role=%s, device=%s, dtype=%s, baked_weight_norms=%d)",
|
||||
codec_path,
|
||||
role,
|
||||
device,
|
||||
dtype,
|
||||
baked,
|
||||
)
|
||||
return codec
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _resample_transform(source_sr: int, target_sr: int, device: torch.device, dtype: torch.dtype):
|
||||
import torchaudio
|
||||
|
||||
return torchaudio.transforms.Resample(source_sr, target_sr).to(device=device, dtype=dtype)
|
||||
|
||||
|
||||
def prepare_reference_waveform(
|
||||
wav_samples: list[float] | np.ndarray | torch.Tensor,
|
||||
sample_rate: int,
|
||||
*,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype = torch.float32,
|
||||
) -> torch.Tensor:
|
||||
"""Down-mix to mono and resample reference audio to the codec's rate.
|
||||
|
||||
Returns a 1-D waveform tensor.
|
||||
"""
|
||||
if isinstance(wav_samples, torch.Tensor):
|
||||
wav = wav_samples.detach()
|
||||
else:
|
||||
wav = torch.as_tensor(np.asarray(wav_samples))
|
||||
wav = wav.to(device=device, dtype=dtype)
|
||||
|
||||
if wav.ndim == 2:
|
||||
# Accept [channels, samples] and [samples, channels].
|
||||
if wav.shape[0] <= 8 and wav.shape[1] > wav.shape[0]:
|
||||
wav = wav.mean(dim=0)
|
||||
elif wav.shape[-1] <= 8 and wav.shape[0] > wav.shape[-1]:
|
||||
wav = wav.mean(dim=-1)
|
||||
else:
|
||||
wav = wav.mean(dim=0)
|
||||
elif wav.ndim > 2:
|
||||
wav = wav.reshape(-1, wav.shape[-1]).mean(dim=0)
|
||||
wav = wav.reshape(-1)
|
||||
|
||||
if wav.numel() == 0:
|
||||
raise ValueError("Reference audio must not be empty")
|
||||
if int(sample_rate) <= 0:
|
||||
raise ValueError(f"Reference audio sample rate must be positive, got {sample_rate}")
|
||||
if int(sample_rate) != ARKTTS_CODEC_SAMPLE_RATE:
|
||||
transform = _resample_transform(int(sample_rate), ARKTTS_CODEC_SAMPLE_RATE, wav.device, wav.dtype)
|
||||
wav = transform(wav.unsqueeze(0)).squeeze(0)
|
||||
return wav.contiguous()
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def encode_reference_audio_codes(
|
||||
model_path: str,
|
||||
wav_samples: list[float] | np.ndarray | torch.Tensor,
|
||||
sample_rate: int,
|
||||
*,
|
||||
device: torch.device | str,
|
||||
**codec_kwargs: int,
|
||||
) -> torch.Tensor:
|
||||
"""Encode reference audio into codec codes.
|
||||
|
||||
Returns:
|
||||
``[frames, num_codebooks]`` int64 codes on ``device``.
|
||||
"""
|
||||
device = torch.device(device)
|
||||
codec = load_arktts_codec(model_path, device=device, dtype=torch.float32, role="encode", **codec_kwargs)
|
||||
wav = prepare_reference_waveform(wav_samples, sample_rate, device=device)
|
||||
lengths = torch.tensor([wav.numel()], device=device, dtype=torch.long)
|
||||
codes, code_lengths = codec.encode(wav.reshape(1, 1, -1), lengths)
|
||||
frames = int(code_lengths[0].item())
|
||||
codes_fq = codes[0, :, :frames].transpose(0, 1).to(dtype=torch.long).contiguous()
|
||||
logger.info(
|
||||
"Encoded Audio8 TTS reference audio: %d samples @ %d Hz -> frames=%d codebooks=%d",
|
||||
int(wav.numel()),
|
||||
int(sample_rate),
|
||||
int(codes_fq.shape[0]),
|
||||
int(codes_fq.shape[1]),
|
||||
)
|
||||
return codes_fq
|
||||
|
||||
|
||||
def estimate_reference_code_frames(num_samples: int, sample_rate: int) -> int:
|
||||
"""Frames the codec will emit for ``num_samples`` at ``sample_rate``.
|
||||
|
||||
Used by the serving layer to size the prompt placeholder without paying for
|
||||
a codec encode on the API thread.
|
||||
"""
|
||||
if num_samples <= 0 or sample_rate <= 0:
|
||||
raise ValueError("Reference audio must have a positive length and sample rate")
|
||||
resampled = max(1, -(-num_samples * ARKTTS_CODEC_SAMPLE_RATE // sample_rate))
|
||||
return max(1, -(-resampled // ARKTTS_CODEC_FRAME_SIZE))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CODEC_FILENAME",
|
||||
"bake_weight_norm",
|
||||
"encode_reference_audio_codes",
|
||||
"estimate_reference_code_frames",
|
||||
"load_arktts_codec",
|
||||
"prepare_reference_waveform",
|
||||
"resolve_codec_path",
|
||||
]
|
||||
@@ -0,0 +1,267 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Configuration classes for Audio8 TTS Preview (``model_type = "arktts"``).
|
||||
|
||||
Audio8's HuggingFace config is flat and uses DualAR field names (``dim``,
|
||||
``n_layer``, ``n_head``, ``fast_dim``, ...); this module re-exports them under
|
||||
Qwen2 attribute names so vLLM's ``Qwen2Model`` consumes the Slow AR half, and
|
||||
splits the Fast AR half into its own sub-config. The backbone is Qwen2 rather
|
||||
than Qwen3 (Fish Speech) because Audio8 uses ``qkv_bias=true`` with no q/k
|
||||
norm -- exactly the Qwen2 attention shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from transformers import PretrainedConfig
|
||||
|
||||
#: Codec frame stride in waveform samples (encoder rates 2*4*8*8 = 512 times the
|
||||
#: quantizer's 2*2 downsample). 44100 / 2048 ~= 21.5 frames per second.
|
||||
ARKTTS_CODEC_FRAME_SIZE = 2048
|
||||
ARKTTS_CODEC_SAMPLE_RATE = 44100
|
||||
#: Only the first codebook is a 4096-entry semantic codebook; codebooks 1..N-1
|
||||
#: are 1024-entry residual codebooks (see ``ArkttsDownsampleQuantizer``).
|
||||
ARKTTS_SEMANTIC_CODEBOOK_SIZE = 4096
|
||||
ARKTTS_RESIDUAL_CODEBOOK_SIZE = 1024
|
||||
|
||||
|
||||
class Audio8TTSSlowARConfig(PretrainedConfig):
|
||||
"""Slow AR config exposed with Qwen2-compatible attribute names."""
|
||||
|
||||
model_type = "arktts_slow_ar"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vocab_size: int = 155776,
|
||||
dim: int = 896,
|
||||
n_head: int = 14,
|
||||
n_local_heads: int = 2,
|
||||
head_dim: int = 64,
|
||||
n_layer: int = 24,
|
||||
intermediate_size: int = 4864,
|
||||
max_seq_len: int = 2048,
|
||||
rope_base: float = 1_000_000.0,
|
||||
norm_eps: float = 1e-6,
|
||||
attention_qkv_bias: bool = True,
|
||||
attention_qk_norm: bool = False,
|
||||
tie_word_embeddings: bool = True,
|
||||
codebook_size: int = ARKTTS_SEMANTIC_CODEBOOK_SIZE,
|
||||
num_codebooks: int = 10,
|
||||
semantic_begin_id: int = 151678,
|
||||
semantic_end_id: int = 155773,
|
||||
eos_token_id: int = 151645,
|
||||
pad_token_id: int = 151643,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
# Audio8 field names -> standard Transformers / Qwen2 names.
|
||||
self.hidden_size = int(dim)
|
||||
self.num_attention_heads = int(n_head)
|
||||
self.num_key_value_heads = int(n_local_heads)
|
||||
self.head_dim = int(head_dim)
|
||||
self.num_hidden_layers = int(n_layer)
|
||||
self.intermediate_size = int(intermediate_size)
|
||||
self.max_position_embeddings = int(max_seq_len)
|
||||
self.rms_norm_eps = float(norm_eps)
|
||||
self.hidden_act = "silu"
|
||||
# Qwen2Attention hardcodes qkv bias=True / o_proj bias=False, which is
|
||||
# what Audio8 TTS ships. Keep the flags so a future checkpoint that
|
||||
# flips them fails loudly in the model rather than loading silently.
|
||||
self.attention_qkv_bias = bool(attention_qkv_bias)
|
||||
self.attention_qk_norm = bool(attention_qk_norm)
|
||||
# Set explicitly (rather than relying on vLLM's set_default_rope_theta)
|
||||
# so that a checkpoint with a non-default rope_base is honoured.
|
||||
self.rope_parameters = {"rope_type": "default", "rope_theta": float(rope_base)}
|
||||
|
||||
# Codec / codebook fields.
|
||||
self.codebook_size = int(codebook_size)
|
||||
self.num_codebooks = int(num_codebooks)
|
||||
self.semantic_begin_id = int(semantic_begin_id)
|
||||
self.semantic_end_id = int(semantic_end_id)
|
||||
|
||||
super().__init__(
|
||||
vocab_size=int(vocab_size),
|
||||
tie_word_embeddings=bool(tie_word_embeddings),
|
||||
eos_token_id=int(eos_token_id),
|
||||
pad_token_id=int(pad_token_id),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class Audio8TTSFastARConfig(PretrainedConfig):
|
||||
"""Fast AR config: the ``n_fast_layer`` residual-codebook predictor."""
|
||||
|
||||
model_type = "arktts_fast_ar"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
codebook_size: int = ARKTTS_SEMANTIC_CODEBOOK_SIZE,
|
||||
num_codebooks: int = 10,
|
||||
fast_dim: int = 896,
|
||||
fast_n_head: int = 14,
|
||||
fast_n_local_heads: int = 2,
|
||||
fast_head_dim: int = 64,
|
||||
n_fast_layer: int = 4,
|
||||
fast_intermediate_size: int = 4864,
|
||||
fast_attention_qkv_bias: bool = False,
|
||||
fast_attention_qk_norm: bool = False,
|
||||
rope_base: float = 1_000_000.0,
|
||||
norm_eps: float = 1e-6,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.hidden_size = int(fast_dim)
|
||||
self.num_attention_heads = int(fast_n_head)
|
||||
self.num_key_value_heads = int(fast_n_local_heads)
|
||||
self.head_dim = int(fast_head_dim)
|
||||
self.num_hidden_layers = int(n_fast_layer)
|
||||
self.intermediate_size = int(fast_intermediate_size)
|
||||
# The Fast AR sequence is [slow hidden state, code_0, ..., code_{N-1}].
|
||||
self.max_position_embeddings = int(num_codebooks)
|
||||
self.rms_norm_eps = float(norm_eps)
|
||||
self.hidden_act = "silu"
|
||||
self.attention_qkv_bias = bool(fast_attention_qkv_bias)
|
||||
self.attention_qk_norm = bool(fast_attention_qk_norm)
|
||||
self.rope_theta = float(rope_base)
|
||||
self.num_codebooks = int(num_codebooks)
|
||||
|
||||
super().__init__(vocab_size=int(codebook_size), **kwargs)
|
||||
|
||||
|
||||
class Audio8TTSConfig(PretrainedConfig):
|
||||
"""Top-level Audio8 TTS config (``model_type = "arktts"``).
|
||||
|
||||
Accepts the flat HF checkpoint fields and derives ``text_config`` (Slow AR)
|
||||
and ``fast_ar_config`` (Fast AR). ``get_text_config()`` returns the Slow AR
|
||||
config, which is what ``Qwen2Model`` reads.
|
||||
"""
|
||||
|
||||
model_type = "arktts"
|
||||
sub_configs = {
|
||||
"text_config": Audio8TTSSlowARConfig,
|
||||
"fast_ar_config": Audio8TTSFastARConfig,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text_config: dict | Audio8TTSSlowARConfig | None = None,
|
||||
fast_ar_config: dict | Audio8TTSFastARConfig | None = None,
|
||||
vocab_size: int = 155776,
|
||||
dim: int = 896,
|
||||
n_head: int = 14,
|
||||
n_local_heads: int = 2,
|
||||
head_dim: int = 64,
|
||||
n_layer: int = 24,
|
||||
intermediate_size: int = 4864,
|
||||
max_seq_len: int = 2048,
|
||||
rope_base: float = 1_000_000.0,
|
||||
norm_eps: float = 1e-6,
|
||||
attention_qkv_bias: bool = True,
|
||||
attention_qk_norm: bool = False,
|
||||
tie_word_embeddings: bool = True,
|
||||
codebook_size: int = ARKTTS_SEMANTIC_CODEBOOK_SIZE,
|
||||
num_codebooks: int = 10,
|
||||
semantic_begin_id: int = 151678,
|
||||
semantic_end_id: int = 155773,
|
||||
n_fast_layer: int = 4,
|
||||
fast_dim: int = 896,
|
||||
fast_n_head: int = 14,
|
||||
fast_n_local_heads: int = 2,
|
||||
fast_head_dim: int = 64,
|
||||
fast_intermediate_size: int = 4864,
|
||||
fast_attention_qkv_bias: bool = False,
|
||||
fast_attention_qk_norm: bool = False,
|
||||
norm_fastlayer_input: bool = True,
|
||||
codec_filename: str = "codec.pth",
|
||||
codec_sample_rate: int = ARKTTS_CODEC_SAMPLE_RATE,
|
||||
codec_frame_size: int = ARKTTS_CODEC_FRAME_SIZE,
|
||||
codec_post_n_layer: int = 8,
|
||||
codec_post_n_head: int = 16,
|
||||
codec_post_n_local_heads: int = 8,
|
||||
codec_post_intermediate_size: int = 1216,
|
||||
ras_window_size: int = 10,
|
||||
ras_temperature: float = 1.0,
|
||||
ras_top_p: float = 0.9,
|
||||
eos_token_id: int = 151645,
|
||||
pad_token_id: int = 151643,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if isinstance(text_config, dict):
|
||||
text_config = Audio8TTSSlowARConfig(**text_config)
|
||||
if isinstance(fast_ar_config, dict):
|
||||
fast_ar_config = Audio8TTSFastARConfig(**fast_ar_config)
|
||||
|
||||
self.text_config = text_config or Audio8TTSSlowARConfig(
|
||||
vocab_size=vocab_size,
|
||||
dim=dim,
|
||||
n_head=n_head,
|
||||
n_local_heads=n_local_heads,
|
||||
head_dim=head_dim,
|
||||
n_layer=n_layer,
|
||||
intermediate_size=intermediate_size,
|
||||
max_seq_len=max_seq_len,
|
||||
rope_base=rope_base,
|
||||
norm_eps=norm_eps,
|
||||
attention_qkv_bias=attention_qkv_bias,
|
||||
attention_qk_norm=attention_qk_norm,
|
||||
tie_word_embeddings=tie_word_embeddings,
|
||||
codebook_size=codebook_size,
|
||||
num_codebooks=num_codebooks,
|
||||
semantic_begin_id=semantic_begin_id,
|
||||
semantic_end_id=semantic_end_id,
|
||||
eos_token_id=eos_token_id,
|
||||
pad_token_id=pad_token_id,
|
||||
)
|
||||
self.fast_ar_config = fast_ar_config or Audio8TTSFastARConfig(
|
||||
codebook_size=codebook_size,
|
||||
num_codebooks=num_codebooks,
|
||||
fast_dim=fast_dim,
|
||||
fast_n_head=fast_n_head,
|
||||
fast_n_local_heads=fast_n_local_heads,
|
||||
fast_head_dim=fast_head_dim,
|
||||
n_fast_layer=n_fast_layer,
|
||||
fast_intermediate_size=fast_intermediate_size,
|
||||
fast_attention_qkv_bias=fast_attention_qkv_bias,
|
||||
fast_attention_qk_norm=fast_attention_qk_norm,
|
||||
rope_base=rope_base,
|
||||
norm_eps=norm_eps,
|
||||
)
|
||||
|
||||
# Fields the stages read directly off the top-level config.
|
||||
self.codebook_size = int(codebook_size)
|
||||
self.num_codebooks = int(num_codebooks)
|
||||
self.semantic_begin_id = int(semantic_begin_id)
|
||||
self.semantic_end_id = int(semantic_end_id)
|
||||
self.norm_fastlayer_input = bool(norm_fastlayer_input)
|
||||
self.codec_filename = str(codec_filename)
|
||||
self.codec_sample_rate = int(codec_sample_rate)
|
||||
self.codec_frame_size = int(codec_frame_size)
|
||||
self.codec_post_n_layer = int(codec_post_n_layer)
|
||||
self.codec_post_n_head = int(codec_post_n_head)
|
||||
self.codec_post_n_local_heads = int(codec_post_n_local_heads)
|
||||
self.codec_post_intermediate_size = int(codec_post_intermediate_size)
|
||||
# Repetition-Aware Sampling (RAS): resample a repeated semantic token
|
||||
# from a flatter distribution instead of masking it.
|
||||
self.ras_window_size = int(ras_window_size)
|
||||
self.ras_temperature = float(ras_temperature)
|
||||
self.ras_top_p = float(ras_top_p)
|
||||
|
||||
super().__init__(
|
||||
eos_token_id=int(eos_token_id),
|
||||
pad_token_id=int(pad_token_id),
|
||||
tie_word_embeddings=bool(tie_word_embeddings),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_text_config(self, *args: Any, **kwargs: Any) -> Audio8TTSSlowARConfig:
|
||||
return self.text_config
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ARKTTS_CODEC_FRAME_SIZE",
|
||||
"ARKTTS_CODEC_SAMPLE_RATE",
|
||||
"ARKTTS_RESIDUAL_CODEBOOK_SIZE",
|
||||
"ARKTTS_SEMANTIC_CODEBOOK_SIZE",
|
||||
"Audio8TTSConfig",
|
||||
"Audio8TTSFastARConfig",
|
||||
"Audio8TTSSlowARConfig",
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS Preview pipeline topology.
|
||||
|
||||
Stage 0: ``audio8_tts_slow_ar`` -- text -> semantic tokens + codec codes.
|
||||
Stage 1: ``audio8_tts_codec_decoder`` -- codec codes -> 44.1 kHz waveform.
|
||||
"""
|
||||
|
||||
from vllm_omni.config.stage_config import (
|
||||
PipelineConfig,
|
||||
StageExecutionType,
|
||||
StagePipelineConfig,
|
||||
)
|
||||
|
||||
_PROC = "vllm_omni.model_executor.stage_input_processors.audio8_tts"
|
||||
|
||||
#: ``<|im_end|>``: the Slow AR ends the utterance with an end-of-turn token.
|
||||
AUDIO8_TTS_EOS_TOKEN_ID = 151645
|
||||
|
||||
AUDIO8_TTS_PIPELINE = PipelineConfig(
|
||||
model_type="arktts",
|
||||
default_deploy_config_name="audio8_tts.yaml",
|
||||
model_arch="Audio8TTSSlowARForConditionalGeneration",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="audio8_tts_slow_ar",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(),
|
||||
owns_tokenizer=True,
|
||||
engine_output_type="latent",
|
||||
async_chunk_process_next_stage_input_func=(f"{_PROC}.slow_ar_to_codec_decoder_async_chunk"),
|
||||
sampling_constraints={
|
||||
"detokenize": False,
|
||||
"stop_token_ids": [AUDIO8_TTS_EOS_TOKEN_ID],
|
||||
},
|
||||
),
|
||||
StagePipelineConfig(
|
||||
stage_id=1,
|
||||
model_stage="audio8_tts_codec_decoder",
|
||||
model_arch="Audio8TTSCodecDecoder",
|
||||
execution_type=StageExecutionType.LLM_GENERATION,
|
||||
input_sources=(0,),
|
||||
final_output=True,
|
||||
final_output_type="audio",
|
||||
engine_output_type="audio",
|
||||
sampling_constraints={"detokenize": True},
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS prompt construction.
|
||||
|
||||
Reproduces ``ArkttsProcessor._prompt_segments`` from the reference checkpoint,
|
||||
so tokenisation is byte-identical to HF inference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import regex as re
|
||||
|
||||
SYSTEM_PROMPT_TEXT_ONLY = "convert the provided text to speech"
|
||||
SYSTEM_PROMPT_CLONE_PREFIX = "convert the provided text to speech reference to the following:\n\nText:\n"
|
||||
SYSTEM_PROMPT_CLONE_SUFFIX = "\n\nSpeech:\n"
|
||||
|
||||
_SPEAKER_TAG_PATTERN = re.compile(r"<\|speaker:\d+\|>")
|
||||
_LEGACY_SPEAKER_TAG_PATTERN = re.compile(r"<speaker:(\d+)>")
|
||||
_CONTROL_TOKEN_PATTERN = re.compile(r"<\|[^>]+\|>")
|
||||
|
||||
|
||||
def clean_text(text: str) -> str:
|
||||
"""Collapse all whitespace runs to single spaces (reference ``_clean_text``)."""
|
||||
return " ".join(str(text).strip().split())
|
||||
|
||||
|
||||
def normalize_text(text: str, *, add_default_speaker: bool = False) -> str:
|
||||
"""Clean text, normalise speaker tags, reject other control tokens.
|
||||
|
||||
``<|speaker:N|>`` is allowed; any other ``<|...|>`` token raises so callers
|
||||
cannot inject ``<|semantic:...|>`` / ``<|im_end|>`` into a prompt.
|
||||
"""
|
||||
normalized = clean_text(_LEGACY_SPEAKER_TAG_PATTERN.sub(r"<|speaker:\1|>", text))
|
||||
|
||||
disallowed = [
|
||||
token for token in _CONTROL_TOKEN_PATTERN.findall(normalized) if not _SPEAKER_TAG_PATTERN.fullmatch(token)
|
||||
]
|
||||
if disallowed:
|
||||
tokens = ", ".join(sorted(set(disallowed)))
|
||||
raise ValueError(f"Audio8 TTS input contains unsupported control token(s): {tokens}")
|
||||
|
||||
if add_default_speaker and not _SPEAKER_TAG_PATTERN.search(normalized):
|
||||
normalized = f"<|speaker:0|>{normalized}"
|
||||
return normalized
|
||||
|
||||
|
||||
def _encode(tokenizer: Any, text: str) -> list[int]:
|
||||
return list(tokenizer.encode(text, add_special_tokens=False))
|
||||
|
||||
|
||||
def _encode_segments(tokenizer: Any, segments: list[str]) -> list[int]:
|
||||
ids: list[int] = []
|
||||
for segment in segments:
|
||||
ids.extend(_encode(tokenizer, segment))
|
||||
return ids
|
||||
|
||||
|
||||
def build_text_only_prompt_ids(tokenizer: Any, text: str) -> tuple[list[int], str]:
|
||||
"""Build the no-reference prompt.
|
||||
|
||||
Returns:
|
||||
``(prompt_token_ids, normalized_text)``.
|
||||
"""
|
||||
normalized = normalize_text(text)
|
||||
if not normalized:
|
||||
raise ValueError("Audio8 TTS input text must not be empty")
|
||||
ids = _encode_segments(
|
||||
tokenizer,
|
||||
[
|
||||
"<|im_start|>system\n",
|
||||
SYSTEM_PROMPT_TEXT_ONLY,
|
||||
"<|im_end|>\n",
|
||||
"<|im_start|>user\n",
|
||||
normalized,
|
||||
"<|im_end|>\n",
|
||||
"<|im_start|>assistant\n<|voice|>",
|
||||
],
|
||||
)
|
||||
return ids, normalized
|
||||
|
||||
|
||||
def build_voice_clone_prompt_parts(
|
||||
tokenizer: Any,
|
||||
text: str,
|
||||
ref_text: str,
|
||||
) -> tuple[list[int], list[int], str, str]:
|
||||
"""Build the clone prompt around the reference-code slot.
|
||||
|
||||
Caller splices ``[code + semantic_begin_id for code in ref_codes[:, 0]]``
|
||||
between the returned prefix and suffix.
|
||||
"""
|
||||
normalized = normalize_text(text)
|
||||
if not normalized:
|
||||
raise ValueError("Audio8 TTS input text must not be empty")
|
||||
normalized_ref = normalize_text(ref_text, add_default_speaker=True)
|
||||
if not normalized_ref:
|
||||
raise ValueError("Audio8 TTS voice cloning requires a non-empty reference transcript")
|
||||
|
||||
prefix = _encode_segments(
|
||||
tokenizer,
|
||||
[
|
||||
"<|im_start|>system\n",
|
||||
SYSTEM_PROMPT_CLONE_PREFIX,
|
||||
normalized_ref,
|
||||
SYSTEM_PROMPT_CLONE_SUFFIX,
|
||||
],
|
||||
)
|
||||
suffix = _encode_segments(
|
||||
tokenizer,
|
||||
[
|
||||
"<|im_end|>\n",
|
||||
"<|im_start|>user\n",
|
||||
normalized,
|
||||
"<|im_end|>\n",
|
||||
"<|im_start|>assistant\n<|voice|>",
|
||||
],
|
||||
)
|
||||
return prefix, suffix, normalized, normalized_ref
|
||||
|
||||
|
||||
def build_voice_clone_prompt_ids(
|
||||
tokenizer: Any,
|
||||
text: str,
|
||||
ref_text: str,
|
||||
semantic_token_ids: list[int],
|
||||
) -> tuple[list[int], int, str, str]:
|
||||
"""Build the full clone prompt.
|
||||
|
||||
``ref_start_index`` is the position of the first reference frame, i.e.
|
||||
where the residual codebook embeddings must be added.
|
||||
"""
|
||||
prefix, suffix, normalized, normalized_ref = build_voice_clone_prompt_parts(tokenizer, text, ref_text)
|
||||
return prefix + list(semantic_token_ids) + suffix, len(prefix), normalized, normalized_ref
|
||||
|
||||
|
||||
def estimate_voice_clone_prompt_len(
|
||||
tokenizer: Any,
|
||||
normalized_text: str,
|
||||
normalized_ref_text: str,
|
||||
ref_frames: int,
|
||||
) -> int:
|
||||
"""Exact clone prompt length, without encoding the reference audio."""
|
||||
prefix, suffix, _, _ = build_voice_clone_prompt_parts(tokenizer, normalized_text, normalized_ref_text)
|
||||
return len(prefix) + max(0, int(ref_frames)) + len(suffix)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SYSTEM_PROMPT_CLONE_PREFIX",
|
||||
"SYSTEM_PROMPT_CLONE_SUFFIX",
|
||||
"SYSTEM_PROMPT_TEXT_ONLY",
|
||||
"build_text_only_prompt_ids",
|
||||
"build_voice_clone_prompt_ids",
|
||||
"build_voice_clone_prompt_parts",
|
||||
"clean_text",
|
||||
"estimate_voice_clone_prompt_len",
|
||||
"normalize_text",
|
||||
]
|
||||
@@ -0,0 +1,227 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS sampling primitives, ported 1:1 from the reference checkpoint.
|
||||
|
||||
Filter (top-k/top-p on **raw** logits) -> divide surviving logits by
|
||||
temperature -> Gumbel-max draw. This is the reverse of the usual filter order
|
||||
and is audibly different at ``temperature != 1``, which is why this cannot
|
||||
reuse ``models/common/nucleus_ras_sampling.py``. Per-row scalars are accepted
|
||||
so mixed batches run in one shot without a GPU->CPU sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
#: Below this temperature a request is treated as greedy, matching vLLM.
|
||||
SAMPLING_EPS = 1e-5
|
||||
|
||||
|
||||
def _as_column(
|
||||
value: float | int | torch.Tensor,
|
||||
batch_size: int,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
) -> torch.Tensor:
|
||||
"""Broadcast a scalar or ``[B]`` tensor to a ``[B, 1]`` tensor."""
|
||||
if isinstance(value, torch.Tensor):
|
||||
return value.to(device=device, dtype=dtype).reshape(-1, 1).expand(batch_size, 1)
|
||||
return torch.full((batch_size, 1), float(value), device=device, dtype=dtype)
|
||||
|
||||
|
||||
def filter_top_k_top_p(
|
||||
logits: torch.Tensor,
|
||||
top_k: int | torch.Tensor,
|
||||
top_p: float | torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
"""Mask candidates outside the top-k / top-p set of the raw logits.
|
||||
|
||||
``top_k <= 0`` and ``top_p >= 1`` disable each filter. The highest-scoring
|
||||
candidate is always kept, so no row can become all ``-inf``.
|
||||
"""
|
||||
if logits.ndim != 2:
|
||||
raise ValueError(f"logits must be 2-D [B, V], got {tuple(logits.shape)}")
|
||||
batch_size, vocab = logits.shape
|
||||
scalar_k = not isinstance(top_k, torch.Tensor)
|
||||
scalar_p = not isinstance(top_p, torch.Tensor)
|
||||
if scalar_k and scalar_p and (int(top_k) <= 0 or int(top_k) >= vocab) and float(top_p) >= 1.0:
|
||||
return logits
|
||||
|
||||
sorted_scores, sorted_indices = torch.sort(logits, descending=True, dim=-1)
|
||||
cumulative = torch.cumsum(torch.softmax(sorted_scores, dim=-1), dim=-1)
|
||||
positions = torch.arange(vocab, device=logits.device).unsqueeze(0)
|
||||
|
||||
keep_k = _as_column(top_k, batch_size, logits.device, torch.long)
|
||||
# top_k <= 0 means "no cut"; represent it as the full vocabulary.
|
||||
keep_k = torch.where(keep_k <= 0, torch.full_like(keep_k, vocab), keep_k)
|
||||
threshold_p = _as_column(top_p, batch_size, logits.device, cumulative.dtype)
|
||||
|
||||
remove_sorted = (cumulative > threshold_p) | (positions >= keep_k)
|
||||
# Always keep the argmax so at least one candidate survives.
|
||||
remove_sorted[:, 0] = False
|
||||
remove = torch.zeros_like(remove_sorted).scatter(-1, sorted_indices, remove_sorted)
|
||||
return logits.masked_fill(remove, float("-inf"))
|
||||
|
||||
|
||||
def gumbel_argmax_sample(
|
||||
scores: torch.Tensor,
|
||||
generator: torch.Generator | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Draw one index per row via Gumbel-max over ``softmax(scores)``."""
|
||||
probabilities = torch.softmax(scores, dim=-1)
|
||||
uniform = torch.rand(
|
||||
probabilities.shape,
|
||||
dtype=probabilities.dtype,
|
||||
device=probabilities.device,
|
||||
generator=generator,
|
||||
)
|
||||
# -log(u) is Exp(1); argmax(p / Exp(1)) samples from p.
|
||||
noise = -torch.log(uniform.clamp_min(torch.finfo(probabilities.dtype).tiny))
|
||||
return torch.argmax(probabilities / noise, dim=-1)
|
||||
|
||||
|
||||
def sample_scores(
|
||||
logits: torch.Tensor,
|
||||
*,
|
||||
temperature: float | torch.Tensor,
|
||||
top_k: int | torch.Tensor,
|
||||
top_p: float | torch.Tensor,
|
||||
do_sample: bool = True,
|
||||
generator: torch.Generator | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Filter -> temper -> draw, in the reference order.
|
||||
|
||||
Rows whose temperature is below :data:`SAMPLING_EPS` fall back to argmax.
|
||||
"""
|
||||
filtered = filter_top_k_top_p(logits, top_k, top_p)
|
||||
greedy = filtered.argmax(dim=-1)
|
||||
if not do_sample:
|
||||
return greedy
|
||||
if not isinstance(temperature, torch.Tensor):
|
||||
if float(temperature) < SAMPLING_EPS:
|
||||
return greedy
|
||||
return gumbel_argmax_sample(filtered / float(temperature), generator=generator)
|
||||
|
||||
temperature_col = _as_column(temperature, filtered.shape[0], filtered.device, filtered.dtype)
|
||||
safe_temperature = torch.where(
|
||||
temperature_col < SAMPLING_EPS,
|
||||
torch.ones_like(temperature_col),
|
||||
temperature_col,
|
||||
)
|
||||
sampled = gumbel_argmax_sample(filtered / safe_temperature, generator=generator)
|
||||
return torch.where(temperature_col.reshape(-1) < SAMPLING_EPS, greedy, sampled)
|
||||
|
||||
|
||||
def ras_sample_semantic(
|
||||
logits: torch.Tensor,
|
||||
recent_ids: torch.Tensor | None,
|
||||
*,
|
||||
temperature: float | torch.Tensor,
|
||||
top_k: int | torch.Tensor,
|
||||
top_p: float | torch.Tensor,
|
||||
ras_temperature: float,
|
||||
ras_top_p: float,
|
||||
do_sample: bool = True,
|
||||
num_semantic_ids: int | None = None,
|
||||
generator: torch.Generator | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Repetition-Aware Sampling for the semantic (Slow AR) token.
|
||||
|
||||
Draws with the request's ``temperature`` / ``top_p``; if the drawn id is
|
||||
already in ``recent_ids`` (pad with a value that cannot be sampled, e.g.
|
||||
``-1``), redraws from the flatter RAS distribution instead. The
|
||||
``num_semantic_ids`` slot and beyond (EOS) are never substituted, so a
|
||||
repeat EOS still ends the utterance.
|
||||
|
||||
DualAR RAS *resamples* repeats rather than masking them — masking the
|
||||
dominant token inflates EOS probability and truncates the utterance.
|
||||
"""
|
||||
normal = sample_scores(
|
||||
logits,
|
||||
temperature=temperature,
|
||||
top_k=top_k,
|
||||
top_p=top_p,
|
||||
do_sample=do_sample,
|
||||
generator=generator,
|
||||
)
|
||||
if not do_sample or recent_ids is None or recent_ids.numel() == 0:
|
||||
return normal
|
||||
|
||||
high = sample_scores(
|
||||
logits,
|
||||
temperature=ras_temperature,
|
||||
top_k=top_k,
|
||||
top_p=ras_top_p,
|
||||
do_sample=True,
|
||||
generator=generator,
|
||||
)
|
||||
repeated = (recent_ids == normal[:, None]).any(dim=1)
|
||||
if num_semantic_ids is not None:
|
||||
repeated &= normal < int(num_semantic_ids)
|
||||
return torch.where(repeated, high, normal)
|
||||
|
||||
|
||||
def _row_value(value: object, index: int) -> object:
|
||||
"""Slice a per-row value for request ``index``; scalars pass through."""
|
||||
return value[index : index + 1] if isinstance(value, torch.Tensor) else value
|
||||
|
||||
|
||||
def ras_sample_batch(
|
||||
logits: torch.Tensor,
|
||||
recent_ids: torch.Tensor | None,
|
||||
*,
|
||||
temperature: float | torch.Tensor,
|
||||
top_k: int | torch.Tensor,
|
||||
top_p: float | torch.Tensor,
|
||||
ras_temperature: float,
|
||||
ras_top_p: float,
|
||||
num_semantic_ids: int | None,
|
||||
generators: dict,
|
||||
num_reqs: int,
|
||||
) -> torch.Tensor:
|
||||
"""Batched RAS that honors each request's own RNG generator.
|
||||
|
||||
vLLM keys ``generators`` by batch slot, so a single shared generator would
|
||||
leak slot 0's RNG stream to every row and make a prompt's audio depend on
|
||||
its batch neighbours (undercutting per-request ``seed``). The single-request
|
||||
case keeps the batched fast path; larger batches loop so each row draws from
|
||||
its own (possibly unseeded) generator.
|
||||
"""
|
||||
common = dict(
|
||||
ras_temperature=ras_temperature,
|
||||
ras_top_p=ras_top_p,
|
||||
num_semantic_ids=num_semantic_ids,
|
||||
)
|
||||
if num_reqs == 1:
|
||||
return ras_sample_semantic(
|
||||
logits,
|
||||
recent_ids,
|
||||
temperature=temperature,
|
||||
top_k=top_k,
|
||||
top_p=top_p,
|
||||
generator=generators.get(0),
|
||||
**common,
|
||||
)
|
||||
rows = [
|
||||
ras_sample_semantic(
|
||||
logits[i : i + 1],
|
||||
None if recent_ids is None else recent_ids[i : i + 1],
|
||||
temperature=_row_value(temperature, i),
|
||||
top_k=_row_value(top_k, i),
|
||||
top_p=_row_value(top_p, i),
|
||||
generator=generators.get(i),
|
||||
**common,
|
||||
)
|
||||
for i in range(num_reqs)
|
||||
]
|
||||
return torch.cat(rows, dim=0)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SAMPLING_EPS",
|
||||
"filter_top_k_top_p",
|
||||
"gumbel_argmax_sample",
|
||||
"ras_sample_batch",
|
||||
"ras_sample_semantic",
|
||||
"sample_scores",
|
||||
]
|
||||
@@ -304,6 +304,17 @@ _OMNI_MODELS = {
|
||||
"gepard_talker",
|
||||
"GepardTalkerForConditionalGeneration",
|
||||
),
|
||||
## audio8_tts (Audio8 TTS Preview 0.6B)
|
||||
"Audio8TTSSlowARForConditionalGeneration": (
|
||||
"audio8_tts",
|
||||
"audio8_tts_slow_ar",
|
||||
"Audio8TTSSlowARForConditionalGeneration",
|
||||
),
|
||||
"Audio8TTSCodecDecoder": (
|
||||
"audio8_tts",
|
||||
"audio8_tts_codec_decoder",
|
||||
"Audio8TTSCodecDecoder",
|
||||
),
|
||||
## VoxCPM2
|
||||
"VoxCPM2TalkerForConditionalGeneration": (
|
||||
"voxcpm2",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS Preview: Slow AR -> codec decoder stage input processor."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from vllm_omni.data_entry_keys import (
|
||||
CodesStruct,
|
||||
MetaStruct,
|
||||
OmniPayloadStruct,
|
||||
)
|
||||
|
||||
#: Chunking defaults; ~21.5 codec frames per second, so 25 frames ~= 1.16 s.
|
||||
DEFAULT_CHUNK_FRAMES = 25
|
||||
DEFAULT_LEFT_CONTEXT_FRAMES = 25
|
||||
|
||||
|
||||
def _connector_extra(transfer_manager: Any) -> dict[str, Any]:
|
||||
connector = getattr(transfer_manager, "connector", None)
|
||||
raw_cfg = getattr(connector, "config", {}) or {}
|
||||
return raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {}
|
||||
|
||||
|
||||
def _cfg_int(cfg: dict[str, Any], key: str, default: int) -> int:
|
||||
value = cfg.get(key, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid Audio8 TTS integer config {key}={value!r}") from exc
|
||||
|
||||
|
||||
def _request_initial_chunk_frames(request: Any, default: int) -> int:
|
||||
"""Per-request override of ``initial_codec_chunk_frames``, if present."""
|
||||
additional_information = getattr(request, "additional_information", None)
|
||||
entries = getattr(additional_information, "entries", None)
|
||||
if not entries or "initial_codec_chunk_frames" not in entries:
|
||||
return default
|
||||
entry = entries["initial_codec_chunk_frames"]
|
||||
if entry.list_data is not None and len(entry.list_data) == 1:
|
||||
return int(entry.list_data[0])
|
||||
return default
|
||||
|
||||
|
||||
def extract_last_frame(multimodal_output: Mapping[str, Any]) -> torch.Tensor | None:
|
||||
"""Return the newest frame of codec codes, or ``None`` if this step had none.
|
||||
|
||||
Prefill steps emit an all-zero placeholder frame, which must not be
|
||||
forwarded to the decoder.
|
||||
"""
|
||||
audio_codes = multimodal_output.get("audio_codes")
|
||||
if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0:
|
||||
return None
|
||||
if audio_codes.ndim == 1:
|
||||
return audio_codes.to(device="cpu", dtype=torch.long).reshape(-1)
|
||||
if audio_codes.ndim != 2:
|
||||
raise ValueError(f"Invalid audio_codes shape for Audio8 TTS async_chunk: {tuple(audio_codes.shape)}")
|
||||
|
||||
frame = audio_codes[-1]
|
||||
if frame.numel() == 0:
|
||||
return None
|
||||
valid = multimodal_output.get("audio_code_valid")
|
||||
if isinstance(valid, torch.Tensor) and valid.numel() > 0:
|
||||
is_valid = bool(valid.reshape(-1)[-1].item())
|
||||
elif valid is not None:
|
||||
is_valid = bool(valid)
|
||||
else:
|
||||
is_valid = bool(frame.any().item())
|
||||
if not is_valid:
|
||||
return None
|
||||
return frame.to(device="cpu", dtype=torch.long).reshape(-1)
|
||||
|
||||
|
||||
def slow_ar_to_codec_decoder_async_chunk(
|
||||
transfer_manager: Any,
|
||||
multimodal_output: dict[str, Any] | None,
|
||||
request: Any,
|
||||
is_finished: bool = False,
|
||||
) -> OmniPayloadStruct | None:
|
||||
"""Emit ``[left_context | new_frames]`` chunks as the Slow AR produces codes.
|
||||
|
||||
Codes accumulate across AR steps and are shipped as a
|
||||
``[num_codebooks, frames]`` tensor. ``meta.left_context_size`` tells the
|
||||
decoder how many leading frames to drop, keeping chunk boundaries
|
||||
artefact-free. Returns ``None`` when the current chunk is not full yet.
|
||||
"""
|
||||
request_id = request.external_req_id
|
||||
finished = bool(is_finished or request.is_finished())
|
||||
cfg = _connector_extra(transfer_manager)
|
||||
|
||||
appended_this_call = False
|
||||
if isinstance(multimodal_output, Mapping):
|
||||
frame = extract_last_frame(multimodal_output)
|
||||
if frame is not None:
|
||||
transfer_manager.code_prompt_token_ids[request_id].append(frame.detach())
|
||||
appended_this_call = True
|
||||
elif not finished:
|
||||
return None
|
||||
|
||||
chunk_frames = _cfg_int(cfg, "codec_chunk_frames", DEFAULT_CHUNK_FRAMES)
|
||||
left_context_frames = _cfg_int(cfg, "codec_left_context_frames", DEFAULT_LEFT_CONTEXT_FRAMES)
|
||||
initial_chunk_frames = _request_initial_chunk_frames(request, _cfg_int(cfg, "initial_codec_chunk_frames", 0))
|
||||
if chunk_frames <= 0 or left_context_frames < 0 or initial_chunk_frames < 0:
|
||||
raise ValueError(
|
||||
"Invalid Audio8 TTS codec chunk config: "
|
||||
f"codec_chunk_frames={chunk_frames}, codec_left_context_frames={left_context_frames}, "
|
||||
f"initial_codec_chunk_frames={initial_chunk_frames}"
|
||||
)
|
||||
initial_chunk_frames = min(initial_chunk_frames, chunk_frames)
|
||||
|
||||
frames = transfer_manager.code_prompt_token_ids[request_id]
|
||||
length = len(frames)
|
||||
if length <= 0:
|
||||
if finished:
|
||||
# Still emit a terminal payload so the decoder can close the stream.
|
||||
return OmniPayloadStruct(
|
||||
codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)),
|
||||
meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)),
|
||||
)
|
||||
return None
|
||||
|
||||
# A smaller first chunk trades a little quality for time-to-first-audio.
|
||||
if initial_chunk_frames > 0 and length <= chunk_frames:
|
||||
already_sent = transfer_manager.put_req_chunk[request_id] * initial_chunk_frames
|
||||
pending = length - already_sent
|
||||
if pending <= 0:
|
||||
return None
|
||||
if pending < initial_chunk_frames and not finished:
|
||||
return None
|
||||
new_frames = min(pending, initial_chunk_frames)
|
||||
left_context_size = max(0, length - new_frames)
|
||||
window = frames[:length]
|
||||
else:
|
||||
initial_coverage = (
|
||||
(chunk_frames // initial_chunk_frames) * initial_chunk_frames if initial_chunk_frames > 0 else 0
|
||||
)
|
||||
pending = (length - initial_coverage) % chunk_frames
|
||||
if pending != 0 and not finished:
|
||||
return None
|
||||
if pending == 0 and finished and not appended_this_call:
|
||||
# This boundary's chunk was already shipped when the stream first
|
||||
# reached it; a terminal call carrying no new frame must close the
|
||||
# stream, not re-send that window (otherwise the last chunk_frames
|
||||
# frames ship twice).
|
||||
return OmniPayloadStruct(
|
||||
codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)),
|
||||
meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)),
|
||||
)
|
||||
new_frames = pending if pending != 0 else chunk_frames
|
||||
end_index = min(length, left_context_frames + new_frames)
|
||||
left_context_size = max(0, end_index - new_frames)
|
||||
window = frames[-end_index:]
|
||||
|
||||
# Codebook-major [num_codebooks, frames]; the tensor payload avoids
|
||||
# expanding codec indices into Python ints across the connector boundary.
|
||||
codes_qf = torch.stack(window, dim=0).transpose(0, 1).contiguous()
|
||||
return OmniPayloadStruct(
|
||||
codes=CodesStruct(audio=codes_qf),
|
||||
meta=MetaStruct(
|
||||
left_context_size=left_context_size,
|
||||
finished=torch.tensor(finished, dtype=torch.bool),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CHUNK_FRAMES",
|
||||
"DEFAULT_LEFT_CONTEXT_FRAMES",
|
||||
"extract_last_frame",
|
||||
"slow_ar_to_codec_decoder_async_chunk",
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""
|
||||
Custom model configs that cannot be expressed via HuggingFace Transformers alone,
|
||||
following the same pattern as vllm.transformers_utils.configs.
|
||||
@@ -10,6 +10,9 @@ from __future__ import annotations
|
||||
import importlib
|
||||
|
||||
_CLASS_TO_MODULE: dict[str, str] = {
|
||||
"Audio8TTSConfig": "vllm_omni.transformers_utils.configs.audio8_tts",
|
||||
"Audio8TTSSlowARConfig": "vllm_omni.transformers_utils.configs.audio8_tts",
|
||||
"Audio8TTSFastARConfig": "vllm_omni.transformers_utils.configs.audio8_tts",
|
||||
"HiggsAudioV3Config": "vllm_omni.transformers_utils.configs.higgs_audio_v3",
|
||||
"Mammothmoda2Config": "vllm_omni.transformers_utils.configs.mammoth_moda2",
|
||||
"Mammothmoda2Qwen2_5_VLConfig": "vllm_omni.transformers_utils.configs.mammoth_moda2",
|
||||
@@ -35,6 +38,9 @@ _CLASS_TO_MODULE: dict[str, str] = {
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"Audio8TTSConfig",
|
||||
"Audio8TTSFastARConfig",
|
||||
"Audio8TTSSlowARConfig",
|
||||
"HiggsAudioV3Config",
|
||||
"Mammothmoda2Config",
|
||||
"Mammothmoda2Qwen2_5_VLConfig",
|
||||
@@ -75,6 +81,7 @@ def __dir__():
|
||||
|
||||
# Eagerly import all config modules so their AutoConfig.register() side-effects
|
||||
# run as soon as `vllm_omni.transformers_utils.configs` is imported.
|
||||
from vllm_omni.transformers_utils.configs import audio8_tts as _audio8_tts # noqa: F401, E402
|
||||
from vllm_omni.transformers_utils.configs import cosyvoice3 as _cosyvoice3 # noqa: F401, E402
|
||||
from vllm_omni.transformers_utils.configs import dots_tts as _dots_tts # noqa: F401, E402
|
||||
from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Audio8 TTS config registration with transformers AutoConfig.
|
||||
|
||||
Registers ``model_type = "arktts"`` (and the two sub-config types) so
|
||||
``AutoConfig.from_pretrained("Audio8/Audio8-TTS-Preview-0.6b")`` resolves to
|
||||
vllm-omni's Qwen2-shaped config instead of the checkpoint's remote code.
|
||||
|
||||
This only wins when ``trust_remote_code`` is **off** -- transformers prefers a
|
||||
checkpoint's ``auto_map`` over registered classes otherwise -- which is why
|
||||
``deploy/audio8_tts.yaml`` sets ``trust_remote_code: false``.
|
||||
"""
|
||||
|
||||
from transformers import AutoConfig
|
||||
|
||||
from vllm_omni.model_executor.models.audio8_tts.configuration_audio8_tts import (
|
||||
Audio8TTSConfig,
|
||||
Audio8TTSFastARConfig,
|
||||
Audio8TTSSlowARConfig,
|
||||
)
|
||||
|
||||
AutoConfig.register("arktts", Audio8TTSConfig)
|
||||
AutoConfig.register("arktts_slow_ar", Audio8TTSSlowARConfig)
|
||||
AutoConfig.register("arktts_fast_ar", Audio8TTSFastARConfig)
|
||||
|
||||
__all__ = [
|
||||
"Audio8TTSConfig",
|
||||
"Audio8TTSFastARConfig",
|
||||
"Audio8TTSSlowARConfig",
|
||||
]
|
||||
Reference in new issue
Block a user