[Feat] Streaming TTS word-level timestamps via forced aligner (#4034)

Signed-off-by: wjinxu <1299461899@qq.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-06-22 04:51:45 +00:00
1 parent a60242092a
commit ad07f98b73
17 files changed
+2162 -20

No files matched your search

@@ -369,6 +369,20 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py \
```
Streaming requires `async_chunk: true` in the stage config.
### Word Timestamps
Generate a WAV offline and a JSON sidecar with word-level timestamps from
`Qwen/Qwen3-ForcedAligner-0.6B`:
```bash
python examples/offline_inference/text_to_speech/qwen3_tts/word_timestamps.py \
--model Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--forced-aligner Qwen/Qwen3-ForcedAligner-0.6B \
--text "Hello world." \
--output-dir /tmp/qwen3_tts_timestamps
```
The script writes `qwen3_tts_word_timestamps.wav` and
`qwen3_tts_word_timestamps.json`. On machines without a local CUDA toolkit,
set `VLLM_USE_FLASHINFER_SAMPLER=0` to avoid FlashInfer sampler JIT.
### Batched decoding
The Code2Wav stage supports batched decoding through the SpeechTokenizer. Pass multiple prompts via `--txt-prompts` and set `--batch-size` accordingly. To raise `max_num_seqs` on either stage, point `--stage-configs-path` at a stage configs YAML with the desired values (see `vllm_omni/model_executor/stage_configs/` for templates):
```bash
@@ -0,0 +1,172 @@
"""Offline Qwen3-TTS example with word-level timestamps.
This demo runs Qwen3-TTS offline, aligns the synthesized audio in memory with
the same shared forced aligner used by the streaming server path, then writes
the WAV and JSON sidecar:
python examples/offline_inference/text_to_speech/qwen3_tts/word_timestamps.py \
--model Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--forced-aligner Qwen/Qwen3-ForcedAligner-0.6B
On machines without a local CUDA toolkit, set ``VLLM_USE_FLASHINFER_SAMPLER=0``
to avoid FlashInfer sampler JIT during warmup.
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from typing import Any
os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
# The in-process offline aligner consumes token_classify outputs. Keeping V1
# multiprocessing off avoids msgspec serialization issues for token-wise logits.
os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
import numpy as np
import soundfile as sf
import torch
from end2end import _estimate_prompt_len
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni import Omni
from vllm_omni.engine.arg_utils import nullify_stage_engine_defaults
from vllm_omni.utils.forced_aligner import align, build_forced_aligner_config
def _default_stage_config() -> str:
repo_root = Path(__file__).resolve().parents[4]
return str(repo_root / "vllm_omni" / "deploy" / "qwen3_tts.yaml")
def _build_custom_voice_input(args: Any) -> dict[str, Any]:
additional_information = {
"task_type": ["CustomVoice"],
"text": [args.text],
"language": [args.language],
"speaker": [args.speaker],
"instruct": [args.instructions],
"max_new_tokens": [args.max_new_tokens],
}
return {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, args.model),
"additional_information": additional_information,
}
def _audio_tensor_and_sample_rate(mm: dict[str, Any]) -> tuple[torch.Tensor, int]:
audio_data = mm["audio"]
sr_raw = mm["sr"]
sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw
sample_rate = int(sr_val.item()) if hasattr(sr_val, "item") else int(sr_val)
audio_tensor = torch.cat(audio_data, dim=-1) if isinstance(audio_data, list) else audio_data
return audio_tensor.float().detach().cpu().flatten(), sample_rate
def _float_audio_to_pcm16_bytes(audio: torch.Tensor) -> bytes:
samples = audio.numpy()
pcm = (np.clip(samples, -1.0, 1.0) * 32767.0).astype("<i2")
return pcm.tobytes()
async def _run_alignment(args: Any, audio: torch.Tensor, sample_rate: int) -> list[dict[str, Any]] | None:
aligner_config = build_forced_aligner_config(args)
if aligner_config is None:
raise ValueError("--forced-aligner or --forced-aligner-config is required")
timestamps = await align(
audio=_float_audio_to_pcm16_bytes(audio),
text=args.text,
sample_rate=sample_rate,
config=aligner_config,
language=args.language,
)
if timestamps is None:
return None
return [
{
"word": item.word,
"start_ms": item.start_ms,
"end_ms": item.end_ms,
}
for item in timestamps
]
def main(args: Any) -> None:
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
omni_kwargs = vars(args).copy()
for key in (
"forced_aligner",
"text",
"language",
"speaker",
"instructions",
"max_new_tokens",
"output_dir",
):
omni_kwargs.pop(key, None)
omni_kwargs["stage_configs_path"] = args.stage_configs_path or _default_stage_config()
omni_kwargs["log_stats"] = args.log_stats
omni = Omni(**omni_kwargs)
prompt = _build_custom_voice_input(args)
final_output = None
for stage_outputs in omni.generate([prompt]):
final_output = stage_outputs.request_output
if final_output is None:
raise RuntimeError("Qwen3-TTS did not produce an output.")
mm = final_output.outputs[0].multimodal_output
audio, sample_rate = _audio_tensor_and_sample_rate(mm)
wav_path = output_dir / "qwen3_tts_word_timestamps.wav"
# Align the in-memory tensor, not the saved WAV. The WAV is only a demo
# artifact so users can listen to the same audio referenced by the sidecar.
timestamps = asyncio.run(_run_alignment(args, audio, sample_rate))
sf.write(wav_path, audio.numpy(), samplerate=sample_rate, format="WAV")
sidecar = {
"text": args.text,
"sample_rate": sample_rate,
"audio_path": str(wav_path),
"timestamps": timestamps,
}
json_path = output_dir / "qwen3_tts_word_timestamps.json"
json_path.write_text(json.dumps(sidecar, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Saved audio: {wav_path}")
print(f"Saved timestamps: {json_path}")
print(json.dumps(sidecar["timestamps"], ensure_ascii=False, indent=2))
def parse_args() -> Any:
parser = FlexibleArgumentParser(description="Offline Qwen3-TTS word timestamp example")
parser.add_argument("--model", default="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", help="Qwen3-TTS model path/name")
parser.add_argument(
"--forced-aligner",
default=None,
help="Qwen3 forced aligner model path/name",
)
parser.add_argument(
"--forced-aligner-config",
default=None,
help="Optional YAML file for forced aligner settings (incl. gpu_memory_utilization)",
)
parser.add_argument("--stage-configs-path", default=None, help="Qwen3-TTS deploy YAML")
parser.add_argument("--text", default="Hello world.", help="Text to synthesize and align")
parser.add_argument("--language", default="English", help="Qwen3-TTS language field")
parser.add_argument("--speaker", default="Vivian", help="CustomVoice speaker name")
parser.add_argument("--instructions", default="", help="Optional speaking style instruction")
parser.add_argument("--max-new-tokens", type=int, default=2048, help="TTS max_new_tokens")
parser.add_argument("--output-dir", default="output_audio", help="Directory for WAV and JSON sidecar")
parser.add_argument("--log-stats", action="store_true", default=False, help="Enable vLLM-Omni stats logging")
nullify_stage_engine_defaults(parser)
return parser.parse_args()
if __name__ == "__main__":
main(parse_args())
@@ -504,6 +504,35 @@ python qwen3_tts/streaming_speech_client.py --text "Hello world. How are you? I
python qwen3_tts/streaming_speech_client.py --text "..." --simulate-stt --stt-delay 0.1
```
To receive word-level timestamps, launch the server with a forced aligner:
```bash
vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
--omni \
--deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--trust-remote-code \
--forced-aligner Qwen/Qwen3-ForcedAligner-0.6B
```
Then request PCM JSON sidecar chunks:
```bash
python qwen3_tts/streaming_speech_client.py \
--text "Hello world. How are you?" \
--stream-audio \
--response-format pcm \
--word-timestamps
```
The client writes one PCM file per sentence and a matching
`sentence_XXX_timestamps.json` sidecar.
To *see* the alignment instead of reading a JSON sidecar, run the
word-timestamp Gradio demo (server must be launched with `--forced-aligner`):
```bash
python qwen3_tts/word_timestamps_demo.py --api-base http://localhost:8091
```
Each sentence's audio plays in an `<audio>` element while its text is rendered
as inline word spans; the current word highlights as `audio.currentTime`
crosses each `start_ms`. The **Stop (barge-in)** button cuts playback and
reports the last-spoken word, useful for the voice-agent barge-in case.
### Gradio demos
```bash
./qwen3_tts/run_gradio_demo.sh # CustomVoice (default)
@@ -18,6 +18,21 @@ Usage:
--text "Hello world. How are you? I am fine." \
--simulate-stt --stt-delay 0.1
# Receive JSON sidecar chunks with word-level timestamps
python streaming_speech_client.py \
--text "Hello world. How are you?" \
--stream-audio \
--response-format pcm \
--word-timestamps
# Also save every received audio.chunk frame as individual PCM/WAV files
python streaming_speech_client.py \
--text "Hello world. How are you?" \
--stream-audio \
--response-format pcm \
--word-timestamps \
--save-chunks
# VoiceDesign task
python streaming_speech_client.py \
--text "Today is a great day. The weather is nice." \
@@ -37,8 +52,10 @@ Requirements:
import argparse
import asyncio
import base64
import json
import os
import wave
try:
import websockets
@@ -47,6 +64,37 @@ except ImportError:
raise SystemExit(1)
def save_audio_file(output_dir: str, sentence_index: int, response_format: str, chunks: list[bytes]) -> str:
audio_bytes = b"".join(chunks)
if response_format == "pcm":
raw_path = os.path.join(output_dir, f"sentence_{sentence_index:03d}.pcm")
with open(raw_path, "wb") as f:
f.write(audio_bytes)
wav_path = os.path.join(output_dir, f"sentence_{sentence_index:03d}.wav")
save_pcm_wav(wav_path, audio_bytes)
return wav_path
filename = os.path.join(output_dir, f"sentence_{sentence_index:03d}.{response_format}")
with open(filename, "wb") as f:
f.write(audio_bytes)
return filename
def save_pcm_wav(path: str, audio_bytes: bytes, sample_rate: int = 24000) -> None:
with wave.open(path, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(audio_bytes)
def timestamp_words_text(timestamps: list[dict] | None) -> str:
if not timestamps:
return ""
return " ".join(str(item.get("word", "")) for item in timestamps if item.get("word"))
async def stream_tts(
url: str,
text: str,
@@ -54,6 +102,7 @@ async def stream_tts(
output_dir: str,
simulate_stt: bool = False,
stt_delay: float = 0.1,
save_chunks: bool = False,
) -> None:
"""Connect to the streaming TTS endpoint and process audio responses."""
os.makedirs(output_dir, exist_ok=True)
@@ -99,8 +148,14 @@ async def stream_tts(
sender_task = asyncio.create_task(send_text())
response_format = config.get("response_format", "wav")
word_timestamps = bool(config.get("word_timestamps", False))
current_sentence_index = 0
current_sentence_text = ""
current_chunks: list[bytes] = []
current_timestamps: list[dict] = []
# Distinguish `null` (aligner failed) from `[]` (silence / no tokens):
# the sentence is a failure only if no frame ever carried timestamps.
alignment_frame_seen = False
try:
while True:
@@ -116,22 +171,94 @@ async def stream_tts(
if msg_type == "audio.start":
current_sentence_index = msg["sentence_index"]
current_sentence_text = msg.get("sentence_text", "")
current_chunks = []
print(f" [sentence {msg['sentence_index']}] Generating: {msg['sentence_text']!r}")
current_timestamps = []
alignment_frame_seen = False
print(f" [sentence {msg['sentence_index']}] Generating: {current_sentence_text!r}")
elif msg_type == "audio.chunk":
audio = base64.b64decode(msg["audio_b64"])
current_chunks.append(audio)
chunk_timestamps = msg.get("timestamps")
# Sentence-level alignment sends a trailing timestamp-only
# frame with empty audio; don't write an empty chunk file.
if save_chunks and audio:
chunk_base = os.path.join(
output_dir,
f"sentence_{msg['sentence_index']:03d}_chunk_{msg['chunk_id']:03d}",
)
chunk_pcm = f"{chunk_base}.pcm"
chunk_wav = f"{chunk_base}.wav"
chunk_json = f"{chunk_base}_timestamps.json"
with open(chunk_pcm, "wb") as f:
f.write(audio)
save_pcm_wav(chunk_wav, audio, int(msg.get("sample_rate", 24000)))
with open(chunk_json, "w", encoding="utf-8") as f:
json.dump(
{
"sentence_index": msg["sentence_index"],
"chunk_id": msg["chunk_id"],
"chunk_start_ms": msg.get("chunk_start_ms"),
"chunk_end_ms": msg.get("chunk_end_ms"),
"sentence_text": current_sentence_text,
"covered_text": timestamp_words_text(chunk_timestamps),
"timestamps": chunk_timestamps,
},
f,
ensure_ascii=False,
indent=2,
)
if chunk_timestamps is None:
print(
f" [sentence {msg['sentence_index']}] chunk {msg['chunk_id']}: "
f"{len(audio)} bytes, timestamps unavailable"
)
else:
alignment_frame_seen = True
current_timestamps.extend(chunk_timestamps)
print(
f" [sentence {msg['sentence_index']}] chunk {msg['chunk_id']}: "
f"{len(audio)} bytes, {len(chunk_timestamps)} timestamp(s)"
)
if save_chunks and audio:
print(f" saved chunk -> {chunk_wav}")
elif msg_type == "audio.done":
filename = os.path.join(
filename = save_audio_file(
output_dir,
f"sentence_{msg['sentence_index']:03d}.{response_format}",
msg["sentence_index"],
response_format,
current_chunks,
)
with open(filename, "wb") as f:
f.write(b"".join(current_chunks))
if word_timestamps:
# `null` on failure, else the accumulated list (`[]` for silence).
sentence_timestamps = current_timestamps if alignment_frame_seen else None
ts_filename = os.path.join(
output_dir,
f"sentence_{msg['sentence_index']:03d}_timestamps.json",
)
with open(ts_filename, "w", encoding="utf-8") as f:
json.dump(
{
"sentence_index": msg["sentence_index"],
"sentence_text": current_sentence_text,
"covered_text": timestamp_words_text(sentence_timestamps),
"timestamps": sentence_timestamps,
},
f,
ensure_ascii=False,
indent=2,
)
print(
f" [sentence {msg['sentence_index']}] Done"
f" bytes={msg.get('total_bytes', len(b''.join(current_chunks)))}"
f" error={msg.get('error', False)}"
f" -> {filename}"
)
if word_timestamps:
print(f" [sentence {msg['sentence_index']}] Timestamps -> {ts_filename}")
current_chunks = []
current_timestamps = []
alignment_frame_seen = False
elif msg_type == "session.done":
print(f"\nSession complete: {msg['total_sentences']} sentence(s) generated")
break
@@ -189,6 +316,16 @@ def main():
action="store_true",
help="Receive one or more PCM chunks per sentence (requires --response-format pcm)",
)
parser.add_argument(
"--word-timestamps",
action="store_true",
help="Receive JSON sidecar chunks with word-level timestamps (requires --stream-audio --response-format pcm)",
)
parser.add_argument(
"--save-chunks",
action="store_true",
help="Save each audio.chunk frame as sentence_XXX_chunk_YYY.{pcm,wav,json}",
)
parser.add_argument("--speed", type=float, default=1.0, help="Playback speed (0.25-4.0)")
parser.add_argument("--max-new-tokens", type=int, default=None, help="Max tokens")
@@ -238,6 +375,10 @@ def main():
config[key] = val
if args.stream_audio:
config["stream_audio"] = True
if args.word_timestamps:
config["word_timestamps"] = True
config["stream_audio"] = True
config["response_format"] = "pcm"
if args.x_vector_only_mode:
config["x_vector_only_mode"] = True
@@ -249,6 +390,7 @@ def main():
output_dir=args.output_dir,
simulate_stt=args.simulate_stt,
stt_delay=args.stt_delay,
save_chunks=args.save_chunks,
)
)
@@ -0,0 +1,457 @@
"""Gradio demo for streaming TTS word-level timestamps.
Connects to the WebSocket endpoint ``/v1/audio/speech/stream`` with
``word_timestamps: true`` and visualizes the alignment: each sentence's
audio plays in a native ``<audio>`` element while its text is rendered as
inline word spans, the current word highlighting as ``audio.currentTime``
crosses each ``start_ms``.
A "Stop (barge-in)" button cuts playback and reports the last-spoken word,
demonstrating the voice-agent barge-in use case.
Timestamps are sentence-level: audio streams in real time, then the forced
aligner runs once per sentence and the complete word timestamps arrive in a
trailing frame. The demo therefore plays one sentence block at a time.
Usage:
# Launch the server with a forced aligner first, e.g.:
# vllm-omni serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --omni \
# --deploy-config vllm_omni/deploy/qwen3_tts.yaml --trust-remote-code \
# --forced-aligner Qwen/Qwen3-ForcedAligner-0.6B
# Then:
python word_timestamps_demo.py --api-base http://localhost:8000
"""
import argparse
import asyncio
import json
import logging
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
try:
import websockets
except ImportError:
raise ImportError("websockets is required to run this demo. Install it with: pip install websockets") from None
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from tts_common import (
PCM_SAMPLE_RATE,
SUPPORTED_LANGUAGES,
TASK_TYPES,
add_common_args,
fetch_voices,
)
logger = logging.getLogger(__name__)
def _upstream_ws_url(api_base: str) -> str:
"""Map an http(s) API base to its WebSocket streaming endpoint."""
base = api_base.rstrip("/")
if base.startswith("https://"):
base = "wss://" + base[len("https://") :]
elif base.startswith("http://"):
base = "ws://" + base[len("http://") :]
else:
base = "ws://" + base
return base + "/v1/audio/speech/stream"
# ── Front-end ────────────────────────────────────────────────────────
PLAYER_HTML = """
<div id="wt-root">
<div style="display:flex; align-items:center; gap:10px;">
<div id="wt-dot" style="width:10px;height:10px;border-radius:50%;background:#ccc;flex-shrink:0;"></div>
<span id="wt-status" style="font-weight:600;font-size:1.05em;">Ready</span>
<button id="wt-stop" onclick="window.wtStop()"
style="display:none; margin-left:auto; padding:5px 16px; border-radius:6px; border:1px solid #EF5552;
background:#fff; color:#EF5552; cursor:pointer; font-size:0.85em;">Stop (barge-in)</button>
</div>
<div id="wt-barge" style="display:none; margin-top:10px; padding:8px 12px; border-radius:6px;
background:#fff5f5; border:1px solid #f3c2c2; color:#b02a2a; font-size:0.9em;"></div>
<div id="wt-sentences" style="margin-top:12px; display:flex; flex-direction:column; gap:14px;"></div>
</div>
"""
def _build_head_js(sample_rate: int) -> str:
"""Browser-side player: WebSocket -> per-sentence <audio> + word highlight."""
return f"""
<script>
const WT_SR = {sample_rate};
let wtWS = null, wtQueue = [], wtPlaying = false, wtCur = null, wtLastWord = "";
function wtSet(text, color) {{
const s = document.getElementById('wt-status');
const d = document.getElementById('wt-dot');
if (s) s.textContent = text;
if (d) d.style.background = color || '#ccc';
}}
function wtStopBtn(show) {{
const b = document.getElementById('wt-stop');
if (b) b.style.display = show ? 'inline-block' : 'none';
}}
// Build a WAV Blob (16-bit mono PCM) from concatenated Int16 samples.
function wtPcmToWav(int16, sr) {{
const n = int16.length;
const buf = new ArrayBuffer(44 + n * 2);
const v = new DataView(buf);
const w = (off, s) => {{ for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); }};
w(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); w(8, 'WAVE');
w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
w(36, 'data'); v.setUint32(40, n * 2, true);
let off = 44;
for (let i = 0; i < n; i++) {{ v.setInt16(off, int16[i], true); off += 2; }}
return new Blob([buf], {{ type: 'audio/wav' }});
}}
function wtConcat(chunks) {{
let total = 0;
for (const c of chunks) total += c.length;
const out = new Int16Array(total);
let off = 0;
for (const c of chunks) {{ out.set(c, off); off += c.length; }}
return out;
}}
// Render one finished sentence: <audio> + inline word spans.
function wtRenderSentence(sentence) {{
const wrap = document.getElementById('wt-sentences');
if (!wrap) return;
const block = document.createElement('div');
block.style.cssText = 'border:1px solid #e3e8ef;border-radius:8px;padding:10px 12px;';
const head = document.createElement('div');
head.style.cssText = 'font-size:0.75em;color:#888;margin-bottom:6px;';
head.textContent = 'Sentence ' + sentence.index +
(sentence.timestamps ? ' (' + sentence.timestamps.length + ' words)' : ' (no timestamps)');
block.appendChild(head);
const textEl = document.createElement('div');
textEl.style.cssText = 'font-size:1.15em;line-height:1.8;margin-bottom:8px;';
if (sentence.timestamps && sentence.timestamps.length) {{
sentence.spans = [];
for (const ts of sentence.timestamps) {{
const span = document.createElement('span');
span.textContent = ts.word;
span.dataset.start = ts.start_ms;
span.dataset.end = ts.end_ms;
span.style.cssText = 'padding:1px 2px;border-radius:3px;transition:background 0.1s;';
textEl.appendChild(span);
// Keep spacing for spaced languages; CJK chars sit flush.
textEl.appendChild(document.createTextNode(' '));
sentence.spans.push(span);
}}
}} else {{
textEl.textContent = sentence.text || '(timestamps unavailable)';
}}
block.appendChild(textEl);
const audio = document.createElement('audio');
audio.controls = true;
audio.style.cssText = 'width:100%;';
audio.src = URL.createObjectURL(wtPcmToWav(wtConcat(sentence.chunks), WT_SR));
block.appendChild(audio);
sentence.audio = audio;
// Highlight the current word. Driven by requestAnimationFrame (~60fps)
// rather than the <audio> 'timeupdate' event, which only fires ~4x/sec
// and is too coarse for short words. We light the latest word whose
// start_ms has passed, so there is always exactly one active word (no
// inter-word flicker) and wtLastWord stays well-defined for barge-in.
let raf = null;
function highlight() {{
if (!sentence.spans) return;
const t = audio.currentTime * 1000;
let idx = -1;
for (let i = 0; i < sentence.spans.length; i++) {{
if (t >= +sentence.spans[i].dataset.start) idx = i;
else break;
}}
sentence.spans.forEach((sp, i) => {{
const on = i === idx;
sp.style.background = on ? '#ffe680' : 'transparent';
sp.style.fontWeight = on ? '700' : '400';
}});
if (idx >= 0) wtLastWord = sentence.spans[idx].textContent;
}}
function loop() {{
highlight();
if (!audio.paused && !audio.ended) raf = requestAnimationFrame(loop);
}}
audio.addEventListener('play', () => {{
wtCur = audio;
wtSet('Playing sentence ' + sentence.index + '...', '#64dd17');
cancelAnimationFrame(raf);
raf = requestAnimationFrame(loop);
}});
audio.addEventListener('pause', () => cancelAnimationFrame(raf));
audio.addEventListener('seeked', highlight);
audio.addEventListener('ended', () => {{
cancelAnimationFrame(raf);
highlight();
wtPlaying = false;
wtPlayNext();
}});
wrap.appendChild(block);
wtQueue.push(audio);
wtPlayNext();
}}
function wtPlayNext() {{
if (wtPlaying) return;
const next = wtQueue.shift();
if (!next) {{
if (wtWS === null) {{ wtSet('Done', '#64dd17'); wtStopBtn(false); }}
return;
}}
wtPlaying = true;
next.play().catch(() => {{ wtPlaying = false; }});
}}
window.wtStop = function() {{
// Barge-in: cut playback, stop generation, report the last-spoken word.
if (wtCur) {{ try {{ wtCur.pause(); }} catch (e) {{}} }}
wtQueue = [];
wtPlaying = false;
if (wtWS) {{ try {{ wtWS.close(); }} catch (e) {{}} wtWS = null; }}
wtSet('Stopped (barge-in)', '#999');
wtStopBtn(false);
const b = document.getElementById('wt-barge');
if (b) {{
b.style.display = 'block';
b.textContent = wtLastWord
? 'Barge-in at word: "' + wtLastWord + '" (a voice agent would resume the conversation from here)'
: 'Barge-in before any word was spoken.';
}}
}};
window.wtGenerate = function(payload) {{
if (wtWS) {{ try {{ wtWS.close(); }} catch (e) {{}} }}
wtQueue = []; wtPlaying = false; wtCur = null; wtLastWord = "";
document.getElementById('wt-sentences').innerHTML = '';
const barge = document.getElementById('wt-barge');
if (barge) {{ barge.style.display = 'none'; barge.textContent = ''; }}
wtStopBtn(true);
wtSet('Connecting...', '#4A90D9');
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(proto + '//' + location.host + '/proxy/stream');
wtWS = ws;
const sentences = {{}};
ws.onopen = () => {{
wtSet('Streaming...', '#4A90D9');
ws.send(JSON.stringify(Object.assign({{ type: 'session.config' }}, payload.config)));
ws.send(JSON.stringify({{ type: 'input.text', text: payload.text }}));
ws.send(JSON.stringify({{ type: 'input.done' }}));
}};
ws.onmessage = (ev) => {{
let msg;
try {{ msg = JSON.parse(ev.data); }} catch (e) {{ return; }}
if (msg.type === 'audio.start') {{
sentences[msg.sentence_index] = {{
index: msg.sentence_index, text: msg.sentence_text || '',
chunks: [], timestamps: null
}};
}} else if (msg.type === 'audio.chunk') {{
const s = sentences[msg.sentence_index];
if (!s) return;
if (msg.audio_b64) {{
const bin = atob(msg.audio_b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const usable = bytes.length - (bytes.length % 2);
if (usable > 0) s.chunks.push(new Int16Array(bytes.buffer, 0, usable / 2));
}}
if (msg.timestamps != null) s.timestamps = msg.timestamps;
}} else if (msg.type === 'audio.done') {{
const s = sentences[msg.sentence_index];
if (s) wtRenderSentence(s);
}} else if (msg.type === 'session.done') {{
wtWS = null;
try {{ ws.close(); }} catch (e) {{}}
wtPlayNext();
}} else if (msg.type === 'error') {{
wtSet('Error: ' + (msg.message || 'unknown'), '#EF5552');
}}
}};
ws.onerror = () => wtSet('WebSocket error', '#EF5552');
ws.onclose = () => {{ if (wtWS === ws) wtWS = null; }};
}};
</script>
"""
def create_app(api_base: str) -> FastAPI:
"""FastAPI app with a same-origin WebSocket proxy + Gradio UI."""
fastapi_app = FastAPI()
upstream_url = _upstream_ws_url(api_base)
@fastapi_app.websocket("/proxy/stream")
async def proxy_stream(client_ws: WebSocket):
await client_ws.accept()
try:
async with websockets.connect(upstream_url, max_size=None) as upstream:
async def client_to_upstream():
try:
while True:
await upstream.send(await client_ws.receive_text())
except (WebSocketDisconnect, Exception):
await upstream.close()
async def upstream_to_client():
try:
async for msg in upstream:
if isinstance(msg, bytes):
await client_ws.send_bytes(msg)
else:
await client_ws.send_text(msg)
except Exception:
pass
finally:
try:
await client_ws.close()
except Exception:
pass
await asyncio.gather(client_to_upstream(), upstream_to_client())
except Exception as exc:
logger.exception("WS proxy error")
try:
await client_ws.send_text(json.dumps({"type": "error", "message": f"proxy: {exc}"}))
await client_ws.close()
except Exception:
pass
voices = fetch_voices(api_base)
with gr.Blocks(title="Qwen3-TTS Word Timestamps") as demo:
gr.HTML(f"""
<div style="margin-bottom:8px;">
<h1 style="margin:0; font-size:1.5em;">Qwen3-TTS Streaming Word Timestamps</h1>
<span style="font-size:0.85em; color:#666;">
Served by <a href="https://github.com/vllm-project/vllm-omni" target="_blank">vLLM-Omni</a>
&nbsp;&middot;&nbsp; <code>{api_base}</code> &nbsp;&middot;&nbsp;
launch the server with <code>--forced-aligner</code>.
</span>
</div>
""")
with gr.Row():
with gr.Column(scale=3):
text_input = gr.Textbox(
label="Text to Synthesize",
value="Hello world. How are you today? I am doing great.",
lines=3,
)
with gr.Row():
task_type = gr.Radio(choices=TASK_TYPES, value="CustomVoice", label="Task Type", scale=2)
language = gr.Dropdown(choices=SUPPORTED_LANGUAGES, value="Auto", label="Language", scale=1)
voice = gr.Dropdown(
choices=voices,
value=voices[0] if voices else None,
label="Speaker",
allow_custom_value=True,
)
instructions = gr.Textbox(
label="Instructions",
placeholder="Optional style/emotion (required for VoiceDesign)",
lines=1,
)
with gr.Column(visible=False) as ref_group:
ref_audio_url = gr.Textbox(
label="Reference Audio URL (Base task)",
placeholder="https://example.com/reference.wav",
lines=1,
)
ref_text = gr.Textbox(label="Reference Transcript", lines=1)
generate_btn = gr.Button("Generate + Align", variant="primary", size="lg")
with gr.Column(scale=2):
gr.HTML(value=PLAYER_HTML)
hidden_payload = gr.Textbox(visible=False, elem_id="wt-payload")
def on_task_change(tt):
return gr.update(visible=(tt == "Base"))
task_type.change(fn=on_task_change, inputs=[task_type], outputs=[ref_group])
def on_generate(text, tt, voice_v, lang_v, instr_v, ref_url, ref_t):
if not text or not text.strip():
raise gr.Error("Please enter text to synthesize.")
config: dict = {
"task_type": tt,
"word_timestamps": True,
"stream_audio": True,
"response_format": "pcm",
}
if lang_v:
config["language"] = lang_v
if tt == "CustomVoice":
if voice_v:
config["voice"] = voice_v
if instr_v and instr_v.strip():
config["instructions"] = instr_v.strip()
elif tt == "VoiceDesign":
if not instr_v or not instr_v.strip():
raise gr.Error("VoiceDesign requires voice style instructions.")
config["instructions"] = instr_v.strip()
elif tt == "Base":
if not ref_url or not ref_url.strip():
raise gr.Error("Base (voice clone) needs a reference audio URL.")
config["ref_audio"] = ref_url.strip()
if ref_t and ref_t.strip():
config["ref_text"] = ref_t.strip()
return json.dumps({"config": config, "text": text.strip()})
generate_btn.click(
fn=on_generate,
inputs=[text_input, task_type, voice, language, instructions, ref_audio_url, ref_text],
outputs=[hidden_payload],
).then(
fn=lambda p: p,
inputs=[hidden_payload],
outputs=[hidden_payload],
js="(p) => { if (p && p.trim()) window.wtGenerate(JSON.parse(p)); return p; }",
)
demo.queue()
return gr.mount_gradio_app(
fastapi_app,
demo,
path="/",
head=_build_head_js(PCM_SAMPLE_RATE),
)
def parse_args():
parser = argparse.ArgumentParser(description="Gradio demo for streaming TTS word-level timestamps.")
add_common_args(parser)
return parser.parse_args()
def main():
args = parse_args()
logging.basicConfig(level=logging.INFO)
print(f"Connecting to vLLM server at: {args.api_base}")
app = create_app(args.api_base)
import uvicorn
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()
+7
View File
@@ -76,6 +76,10 @@ demo = [
"cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次")
]
forced-aligner = [
"qwen-asr>=0.0.6", # faithful Japanese/Korean word segmentation for the TTS forced aligner
]
# Local-test-only extras
local = [
"FlagEmbedding",
@@ -304,3 +308,6 @@ CANN = "CANN"
thi = "thi"
ND = "ND"
byt5 = "byt5"
# "seeked" is the standard HTML media element DOM event name (fires when a
# seek completes), used by the word-timestamps Gradio demo. Not a typo.
seeked = "seeked"
@@ -1,4 +1,6 @@
import asyncio
import base64
from types import SimpleNamespace
import pytest
from fastapi import FastAPI, WebSocket
@@ -9,6 +11,7 @@ from starlette.websockets import WebSocketDisconnect
from vllm_omni.entrypoints.openai import serving_speech_stream as streaming_speech_module
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler
from vllm_omni.utils.forced_aligner import WordTimestamp
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -25,10 +28,11 @@ def _build_test_app(
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"RIFF" + b"\x00" * 32, "audio/wav"))
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-1", object(), {}))
speech_service.forced_aligner_config = None
async def mock_generate_pcm_chunks(_generator, _request_id):
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
for chunk in (b"\x01\x02", b"\x03\x04\x05"):
yield chunk
yield (chunk, 24000) if include_sample_rate else chunk
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
speech_service.engine_client = mocker.MagicMock()
@@ -82,6 +86,7 @@ class TestStreamingSpeechWebSocket:
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"", "audio/wav"))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = None
async def mock_prepare_speech_generation(request):
captured_requests.append(request)
@@ -89,9 +94,9 @@ class TestStreamingSpeechWebSocket:
speech_service._prepare_speech_generation = mock_prepare_speech_generation
async def mock_generate_pcm_chunks(_generator, _request_id):
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
for chunk in (b"\x01\x02", b"\x03\x04\x05", b"\x06"):
yield chunk
yield (chunk, 24000) if include_sample_rate else chunk
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
app, _ = _build_test_app(speech_service)
@@ -130,6 +135,164 @@ class TestStreamingSpeechWebSocket:
assert captured_requests[0].initial_codec_chunk_frames == 12
assert speech_service._generate_audio_bytes.await_count == 0
def test_word_timestamps_requires_configured_aligner(self, mocker: MockerFixture):
app, _ = _build_test_app(mocker=mocker)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"stream_audio": True,
"response_format": "pcm",
"word_timestamps": True,
}
)
ws.send_json({"type": "input.text", "text": "Hello world. "})
error = ws.receive_json()
assert error["type"] == "error"
assert "without --forced-aligner" in error["message"]
def test_word_timestamps_emit_sidecar_json_frame(self, mocker: MockerFixture):
captured_requests = []
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"", "audio/wav"))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = SimpleNamespace(model="aligner")
async def mock_prepare_speech_generation(request):
captured_requests.append(request)
return "req-stream", object(), {}
speech_service._prepare_speech_generation = mock_prepare_speech_generation
first_chunk = b"\x01" * 1000
second_chunk = b"\x02" * 1000
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
for chunk in (first_chunk, second_chunk):
yield (chunk, 1000) if include_sample_rate else chunk
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
# Sentence-level: aligner runs once over the whole sentence audio.
mock_align = mocker.AsyncMock(
return_value=[
WordTimestamp("Hello", 0, 200),
WordTimestamp("world", 200, 900),
]
)
mocker.patch.object(streaming_speech_module, "forced_align", mock_align)
app, _ = _build_test_app(speech_service)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"stream_audio": True,
"response_format": "pcm",
"word_timestamps": True,
}
)
ws.send_json({"type": "input.text", "text": "Hello world. "})
start = ws.receive_json()
assert start["type"] == "audio.start"
assert start["word_timestamps"] is True
# Audio streams first; timestamps are null until the sentence
# is fully aligned.
chunk = ws.receive_json()
assert chunk["type"] == "audio.chunk"
assert chunk["chunk_id"] == 0
assert chunk["chunk_start_ms"] == 0
assert chunk["chunk_end_ms"] == 500
assert chunk["sample_rate"] == 1000
assert base64.b64decode(chunk["audio_b64"]) == first_chunk
assert chunk["timestamps"] is None
chunk = ws.receive_json()
assert chunk["type"] == "audio.chunk"
assert chunk["chunk_id"] == 1
assert chunk["chunk_start_ms"] == 500
assert chunk["chunk_end_ms"] == 1000
assert chunk["sample_rate"] == 1000
assert base64.b64decode(chunk["audio_b64"]) == second_chunk
assert chunk["timestamps"] is None
# Final frame: empty audio carrying the whole-sentence timestamps.
chunk = ws.receive_json()
assert chunk["type"] == "audio.chunk"
assert chunk["chunk_id"] == 2
assert chunk["chunk_start_ms"] == 0
assert chunk["chunk_end_ms"] == 1000
assert chunk["sample_rate"] == 1000
assert base64.b64decode(chunk["audio_b64"]) == b""
assert chunk["timestamps"] == [
{"word": "Hello", "start_ms": 0, "end_ms": 200},
{"word": "world", "start_ms": 200, "end_ms": 900},
]
done = ws.receive_json()
assert done == {"type": "audio.done", "sentence_index": 0, "total_bytes": 2000, "error": False}
assert captured_requests[0].word_timestamps is True
assert mock_align.await_count == 1
def test_word_timestamps_emit_word_dicts(self, mocker: MockerFixture):
# The streaming layer forwards the aligner's (already monotonic,
# non-overlapping) words as JSON dicts in the trailing frame.
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"", "audio/wav"))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = SimpleNamespace(model="aligner")
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req", object(), {}))
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
chunk = b"\x01" * 1000
yield (chunk, 1000) if include_sample_rate else chunk
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
mock_align = mocker.AsyncMock(
return_value=[
WordTimestamp("Hello", 0, 1000),
WordTimestamp("world", 1000, 1200),
]
)
mocker.patch.object(streaming_speech_module, "forced_align", mock_align)
app, _ = _build_test_app(speech_service)
with TestClient(app) as client:
with client.websocket_connect("/v1/audio/speech/stream") as ws:
ws.send_json(
{
"type": "session.config",
"voice": "Vivian",
"stream_audio": True,
"response_format": "pcm",
"word_timestamps": True,
}
)
ws.send_json({"type": "input.text", "text": "Hello world. "})
assert ws.receive_json()["type"] == "audio.start"
# Real-time audio chunk (timestamps null), then the timestamp frame.
assert ws.receive_json()["timestamps"] is None
final = ws.receive_json()
assert final["type"] == "audio.chunk"
timestamps = final["timestamps"]
assert timestamps == [
{"word": "Hello", "start_ms": 0, "end_ms": 1000},
{"word": "world", "start_ms": 1000, "end_ms": 1200},
]
for ts in timestamps:
assert ts["end_ms"] >= ts["start_ms"]
def test_flush_on_input_done(self, mocker: MockerFixture):
app, _ = _build_test_app(mocker=mocker)
@@ -249,6 +412,7 @@ class TestStreamingSpeechWebSocket:
speech_service._generate_pcm_chunks = mocker.AsyncMock()
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = None
app, _ = _build_test_app(speech_service)
with TestClient(app) as client:
@@ -269,8 +433,9 @@ class TestStreamingSpeechWebSocket:
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-stream-err", object(), {}))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = None
async def mock_generate_pcm_chunks(_generator, _request_id):
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
yield b"\x01\x02"
raise RuntimeError("stream boom")
@@ -361,8 +526,9 @@ class TestStreamingSpeechWebSocket:
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-abort", object(), {}))
speech_service.engine_client = mocker.MagicMock()
speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_config = None
async def mock_generate_pcm_chunks(_generator, _request_id):
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
yield b"\x01\x02"
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
@@ -385,7 +551,9 @@ class TestStreamingSpeechWebSocket:
config.ref_audio = None
config.ref_text = None
config.x_vector_only_mode = None
config.speaker_embedding = None
config.stream_audio = True
config.word_timestamps = False
with pytest.raises(WebSocketDisconnect):
asyncio.run(handler._generate_and_send(websocket, config, "Hello world.", 0))
+165
View File
@@ -0,0 +1,165 @@
import numpy as np
import pytest
from vllm_omni.utils import forced_aligner
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_decode_timestamps_maps_boundary_bins_to_words():
logits = np.zeros((4, 5), dtype=np.float32)
logits[0, 0] = 1.0
logits[1, 2] = 1.0
logits[2, 2] = 1.0
logits[3, 4] = 1.0
timestamps = forced_aligner._decode_timestamps(
logits=logits,
words=["hello", "world"],
timestamp_positions=[0, 1, 2, 3],
classify_num=5,
audio_duration_ms=1000,
)
assert timestamps == [
forced_aligner.WordTimestamp("hello", 0, 400),
forced_aligner.WordTimestamp("world", 400, 800),
]
def test_decode_timestamps_repairs_non_monotonic_bins():
# Word 1's end bin (1) dips below its start bin (2); fix_timestamp should
# snap it back so the frame never carries end_ms < start_ms.
logits = np.zeros((4, 5), dtype=np.float32)
logits[0, 0] = 1.0 # word0 start -> bin 0
logits[1, 2] = 1.0 # word0 end -> bin 2
logits[2, 1] = 1.0 # word1 start -> bin 1 (out of order)
logits[3, 3] = 1.0 # word1 end -> bin 3
timestamps = forced_aligner._decode_timestamps(
logits=logits,
words=["hello", "world"],
timestamp_positions=[0, 1, 2, 3],
classify_num=5,
timestamp_segment_time_ms=200,
audio_duration_ms=1000,
)
for ts in timestamps:
assert ts.end_ms >= ts.start_ms
# bins [0, 2, 1, 3] -> repaired [0, 2, 2, 3] -> ms x200
assert timestamps == [
forced_aligner.WordTimestamp("hello", 0, 400),
forced_aligner.WordTimestamp("world", 400, 600),
]
def test_decode_timestamps_clamps_to_audio_duration_and_stays_ordered():
# Bins run past the 1000 ms audio; the decoder must cap each bound at the
# audio length and keep words non-overlapping (start <= end, monotonic).
logits = np.zeros((4, 5), dtype=np.float32)
logits[0, 0] = 1.0 # word0 start -> bin 0
logits[1, 3] = 1.0 # word0 end -> bin 3 (1200 ms, past audio)
logits[2, 3] = 1.0 # word1 start -> bin 3 (1200 ms, past audio)
logits[3, 4] = 1.0 # word1 end -> bin 4 (1600 ms, past audio)
timestamps = forced_aligner._decode_timestamps(
logits=logits,
words=["a", "b"],
timestamp_positions=[0, 1, 2, 3],
classify_num=5,
timestamp_segment_time_ms=400,
audio_duration_ms=1000,
)
assert timestamps == [
forced_aligner.WordTimestamp("a", 0, 1000),
forced_aligner.WordTimestamp("b", 1000, 1000),
]
def test_decode_timestamps_rejects_marker_count_mismatch():
logits = np.zeros((2, 5), dtype=np.float32)
timestamps = forced_aligner._decode_timestamps(
logits=logits,
words=["hello", "world"],
timestamp_positions=[0, 1],
classify_num=5,
audio_duration_ms=1000,
)
assert timestamps == []
def test_build_config_from_yaml(tmp_path):
cfg = tmp_path / "forced_aligner.yaml"
cfg.write_text(
"""
forced_aligner:
model: Qwen/Qwen3-ForcedAligner-0.6B
gpu_memory_utilization: 0.42
dtype: float16
max_model_len: 2048
trust_remote_code: false
""",
encoding="utf-8",
)
args = type("Args", (), {"forced_aligner": None, "forced_aligner_config": str(cfg)})()
out = forced_aligner.build_forced_aligner_config(args)
assert out == forced_aligner.ForcedAlignerConfig(
model="Qwen/Qwen3-ForcedAligner-0.6B",
runner="pooling",
architecture="Qwen3ASRForcedAlignerForTokenClassification",
pooling_task="token_classify",
gpu_memory_utilization=0.42,
dtype="float16",
max_model_len=2048,
trust_remote_code=False,
)
def test_build_config_cli_model_overrides_yaml(tmp_path):
cfg = tmp_path / "forced_aligner.yaml"
cfg.write_text(
"forced_aligner:\n model: old\n gpu_memory_utilization: 0.2\n dtype: float16\n",
encoding="utf-8",
)
args = type(
"Args",
(),
{
"forced_aligner": "new",
"forced_aligner_config": str(cfg),
},
)()
out = forced_aligner.build_forced_aligner_config(args)
assert out is not None
# --forced-aligner overrides the YAML model; gpu_memory_utilization/dtype
# come from the user YAML (there is no longer a CLI flag for gpu mem).
assert out.model == "new"
assert out.gpu_memory_utilization == 0.2
assert out.dtype == "float16"
assert out.runner == "pooling"
def test_build_config_from_cli_model_uses_default_yaml():
args = type(
"Args",
(),
{
"forced_aligner": "local-aligner",
"forced_aligner_config": None,
},
)()
out = forced_aligner.build_forced_aligner_config(args)
assert out is not None
assert out.model == "local-aligner"
assert out.runner == "pooling"
assert out.architecture == "Qwen3ASRForcedAlignerForTokenClassification"
@@ -0,0 +1,65 @@
import pytest
from vllm_omni.utils import qwen3_force_align_processor as processor
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_build_prompt_has_boundary_timestamp_markers():
prompt = processor.build_prompt(["hello", "world"])
assert prompt.count("<timestamp>") == 4
assert "hello<timestamp><timestamp>world" in prompt
assert prompt.endswith("<|im_start|>assistant\n")
@pytest.mark.parametrize(
"text,expected",
[
# Punctuation inside a whitespace segment is stripped, not split on:
# the hand-rolled splitter used to break these into many fake words.
("U.S.A", ["USA"]),
("hello, world!", ["hello", "world"]),
("don't stop", ["don't", "stop"]),
# CJK characters peel off as individual tokens, Latin runs stay whole.
("你好world", ["你", "好", "world"]),
("我爱 China", ["我", "爱", "China"]),
(" spaced out ", ["spaced", "out"]),
],
)
def test_tokenize_space_lang_matches_official_segmentation(text, expected):
assert processor._tokenize_space_lang(text) == expected
def test_segment_words_falls_back_to_port_without_qwen_asr(monkeypatch):
# With qwen_asr unavailable, segmentation must use the built-in port and
# still produce the faithful result for the common (non-JP/KO) case.
monkeypatch.setattr(processor, "_get_official_processor", lambda: None)
assert processor.segment_words("U.S.A is here", "auto") == ["USA", "is", "here"]
def test_fix_timestamp_repairs_dip_onto_monotonic_sequence():
# 200 dips below the preceding 400; it snaps back up to 400.
assert processor.fix_timestamp([0, 400, 200, 800]) == [0, 400, 400, 800]
def test_fix_timestamp_passes_through_monotonic():
assert processor.fix_timestamp([0, 100, 100, 250]) == [0, 100, 100, 250]
def test_resolve_timestamp_token_id_defaults_to_marker_token():
# Regression: the default must resolve the same <timestamp> marker that
# build_prompt inserts, not None (which would degrade every request).
seen = {}
class FakeTokenizer:
def convert_tokens_to_ids(self, token):
seen["token"] = token
return 151705
tid = processor.resolve_timestamp_token_id(FakeTokenizer())
assert tid == 151705
assert seen["token"] == processor.TIMESTAMP_TOKEN
assert processor.TIMESTAMP_TOKEN in processor.build_prompt(["hello", "world"])
@@ -0,0 +1,19 @@
# Reference deploy knobs for Qwen/Qwen3-ForcedAligner-0.6B, the shared forced
# aligner for streaming TTS word timestamps. Pass with --forced-aligner-config;
# --forced-aligner overrides the model path without editing this file.
#
# Run as LLM(runner="pooling") with pooling_task="token_classify". Prompt tokens,
# chat template and word segmentation are Qwen-specific and live in
# qwen3_force_align_processor (not configurable here).
forced_aligner:
model: Qwen/Qwen3-ForcedAligner-0.6B
runner: pooling
architecture: Qwen3ASRForcedAlignerForTokenClassification
pooling_task: token_classify
gpu_memory_utilization: 0.30
dtype: bfloat16
max_model_len: 4096
trust_remote_code: true
+23
View File
@@ -216,6 +216,29 @@ class OmniServeCommand(CLISubcommand):
help="Default task type for TTS models (CustomVoice, VoiceDesign, or Base). "
"If not specified, will be inferred from model path.",
)
# Forced aligner / word timestamps. --forced-aligner is the opt-in
# toggle; heavier knobs (gpu_memory_utilization, dtype, max_model_len)
# live in the deploy YAML passed via --forced-aligner-config.
omni_config_group.add_argument(
"--forced-aligner",
type=str,
default=None,
help=(
"Enable streaming TTS word timestamps via a forced aligner. "
"Pass the aligner model path/name, e.g. 'Qwen/Qwen3-ForcedAligner-0.6B'. "
"Disabled when omitted."
),
)
omni_config_group.add_argument(
"--forced-aligner-config",
type=str,
default=None,
help=(
"Optional YAML file for forced aligner settings (model, runner, "
"gpu_memory_utilization, dtype, max_model_len). The --forced-aligner "
"flag, when set, overrides the YAML model field."
),
)
# TODO(@lishunyang12): deprecate once all models migrate to --deploy-config
omni_config_group.add_argument(
"--stage-configs-path",
+6 -1
View File
@@ -144,6 +144,7 @@ from vllm_omni.entrypoints.openai.video_api_utils import decode_audio_url, decod
from vllm_omni.entrypoints.openpi.serving import ServingRealtimeRobotOpenPI
from vllm_omni.errors import OmniClientError
from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt
from vllm_omni.utils.forced_aligner import build_forced_aligner_config
from vllm_omni.utils.tracking_parser import TrackingArgumentParser, TrackingNamespace
logger = init_logger(__name__)
@@ -1058,7 +1059,11 @@ async def omni_init_app_state(
)
state.openai_serving_speech = OmniOpenAIServingSpeech(
engine_client, state.openai_serving_models, request_logger=request_logger, model_name=model_name
engine_client,
state.openai_serving_models,
request_logger=request_logger,
model_name=model_name,
forced_aligner_config=build_forced_aligner_config(args),
)
# Warm up speech pipeline (CUDA Graph capture, torch.compile) so the first
@@ -145,6 +145,15 @@ class OpenAICreateSpeechRequest(BaseModel):
default=None,
description=("Optional model-specific parameters passed directly to the model's extra_args."),
)
word_timestamps: bool = Field(
default=False,
description=(
"When true, the server runs a shared forced aligner alongside the streamed "
"audio and emits per-chunk word timestamps. Requires the server to be "
"launched with --forced-aligner pointing at an aligner model. No effect "
"when streaming is off."
),
)
@field_validator("stream_format")
@classmethod
@@ -463,6 +472,15 @@ class StreamingSpeechSessionConfig(BaseModel):
"'clause' also splits on CJK commas , and semicolons ;."
),
)
word_timestamps: bool = Field(
default=False,
description=(
"When true, audio chunks are wrapped in JSON 'audio.chunk' frames carrying "
"base64-encoded PCM plus aligned word timestamps. Requires the server to be "
"launched with --forced-aligner. When false, audio is sent as raw binary "
"frames (existing behavior)."
),
)
@model_validator(mode="after")
def validate_streaming_constraints(self) -> "StreamingSpeechSessionConfig":
+21 -3
View File
@@ -411,6 +411,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
def __init__(self, *args, **kwargs):
self.model_name = kwargs.pop("model_name", None)
self.forced_aligner_config: Any | None = kwargs.pop("forced_aligner_config", None)
super().__init__(*args, **kwargs)
self._init_speaker_storage()
@@ -2586,6 +2587,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
response_format: str = "pcm",
raw_request: Request | None = None,
request_start_s: float | None = None,
include_sample_rate: bool = False,
):
"""Generate audio chunks for streaming response.
@@ -2662,7 +2664,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
)
if first_audio_chunk_s is None:
first_audio_chunk_s = time.perf_counter()
yield self.create_audio(audio_obj).audio_data
audio_bytes = self.create_audio(audio_obj).audio_data
if include_sample_rate:
yield audio_bytes, sample_rate_val
else:
yield audio_bytes
self._mark_ref_audio_artifact_ready_for_request(request_id)
artifact_ready = True
total_ms = (time.perf_counter() - stream_start_s) * 1000.0
@@ -3508,13 +3514,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
self._track_ref_audio_artifact_warmup(request_id, qwen3_ref_audio_warmup_artifact_key)
return request_id, generator, tts_params
async def _generate_pcm_chunks(self, generator, request_id: str):
async def _generate_pcm_chunks(self, generator, request_id: str, *, include_sample_rate: bool = False):
"""Yield raw PCM byte chunks from the engine generator.
Delegates to ``_generate_audio_chunks`` with ``response_format="pcm"``.
Used by the WebSocket streaming handler and ``_iter_pcm_audio_bytes``.
"""
async for chunk in self._generate_audio_chunks(generator, request_id, response_format="pcm"):
async for chunk in self._generate_audio_chunks(
generator,
request_id,
response_format="pcm",
include_sample_rate=include_sample_rate,
):
yield chunk
async def _iter_pcm_audio_bytes(self, request: OpenAICreateSpeechRequest):
@@ -3810,6 +3821,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
try:
if request.stream:
if request.word_timestamps:
return self.create_error_response(
"word_timestamps=true is currently supported by the WebSocket "
"/v1/audio/speech/stream path. Use session.config with "
"stream_audio=true and response_format='pcm'."
)
# Determine response format and media type for streaming
response_format = (request.response_format or "wav").lower()
@@ -9,15 +9,26 @@ Protocol:
{"type": "input.text", "text": "..."} # Text chunks
{"type": "input.done"} # End of input
Server -> Client:
Server -> Client (default, word_timestamps=false):
{"type": "audio.start", "sentence_index": 0, "sentence_text": "...", "format": "wav"}
<binary frame: audio bytes>
...
{"type": "audio.done", "sentence_index": 0}
{"type": "session.done", "total_sentences": N}
{"type": "error", "message": "..."}
Server -> Client (when word_timestamps=true):
{"type": "audio.start", "sentence_index": 0, "sentence_text": "...", "format": "pcm"}
{"type": "audio.chunk", "sentence_index": 0, "chunk_id": 0, "audio_b64": "<base64 PCM>", "timestamps": null}
...
{"type": "audio.chunk", "audio_b64": "", "timestamps": [{"word", "start_ms", "end_ms"}, ...]}
{"type": "audio.done", "sentence_index": 0}
# Audio is JSON base64 PCM (not binary). A trailing empty-audio chunk carries the
# full sentence-relative alignment. timestamps: list = aligned, [] = silence, null = failed.
"""
import asyncio
import base64
import json
from contextlib import aclosing
@@ -35,12 +46,15 @@ from vllm_omni.entrypoints.openai.text_splitter import (
SPLIT_SENTENCE,
SentenceSplitter,
)
from vllm_omni.utils.forced_aligner import ForcedAlignerLoadError
from vllm_omni.utils.forced_aligner import align as forced_align
logger = init_logger(__name__)
_DEFAULT_IDLE_TIMEOUT = 30.0 # seconds
_DEFAULT_CONFIG_TIMEOUT = 10.0 # seconds
_PCM_SAMPLE_RATE = 24000
_BYTES_PER_SAMPLE = 2 # 16-bit mono PCM
_MAX_CONFIG_MESSAGE_SIZE = 4 * 1024 * 1024 # allow large ref_audio payloads
_MAX_INPUT_TEXT_MESSAGE_SIZE = 128 * 1024
@@ -210,6 +224,24 @@ class OmniStreamingSpeechHandler:
"""Generate audio for a single sentence and send it over WebSocket."""
response_format = config.response_format or "wav"
# Reject unmet word-timestamps preconditions early with a clear reason.
if config.word_timestamps:
if self._speech_service.forced_aligner_config is None:
await self._send_error(
websocket,
"word_timestamps=true but the server was launched without "
"--forced-aligner; either restart the server with that flag "
"or set word_timestamps=false in session.config.",
)
return
if not (config.stream_audio and response_format == "pcm"):
await self._send_error(
websocket,
"word_timestamps=true requires stream_audio=true and "
"response_format='pcm' (the aligner consumes raw PCM).",
)
return
request = OpenAICreateSpeechRequest(
input=sentence_text,
model=config.model,
@@ -227,6 +259,7 @@ class OmniStreamingSpeechHandler:
x_vector_only_mode=config.x_vector_only_mode,
speaker_embedding=config.speaker_embedding,
stream=config.stream_audio,
word_timestamps=config.word_timestamps,
)
start_payload = {
@@ -236,7 +269,11 @@ class OmniStreamingSpeechHandler:
"format": response_format,
}
if config.stream_audio and response_format == "pcm":
# Nominal stream rate; each audio.chunk carries the authoritative
# per-chunk sample_rate.
start_payload["sample_rate"] = _PCM_SAMPLE_RATE
if config.word_timestamps:
start_payload["word_timestamps"] = True
await websocket.send_json(start_payload)
total_bytes = 0
@@ -245,10 +282,20 @@ class OmniStreamingSpeechHandler:
try:
if config.stream_audio:
request_id, generator, _ = await self._speech_service._prepare_speech_generation(request)
async with aclosing(self._speech_service._generate_pcm_chunks(generator, request_id)) as stream:
async for chunk in stream:
total_bytes += len(chunk)
await websocket.send_bytes(chunk)
if config.word_timestamps:
total_bytes = await self._stream_audio_with_alignments(
websocket=websocket,
request_id=request_id,
generator=generator,
sentence_text=sentence_text,
sentence_index=sentence_index,
language=config.language,
)
else:
async with aclosing(self._speech_service._generate_pcm_chunks(generator, request_id)) as stream:
async for chunk in stream:
total_bytes += len(chunk)
await websocket.send_bytes(chunk)
else:
audio_bytes, _ = await self._speech_service._generate_audio_bytes(request)
total_bytes = len(audio_bytes)
@@ -277,6 +324,115 @@ class OmniStreamingSpeechHandler:
except Exception:
logger.debug("Failed to send audio.done for sentence %d", sentence_index, exc_info=True)
async def _stream_audio_with_alignments(
self,
*,
websocket: WebSocket,
request_id: str,
generator,
sentence_text: str,
sentence_index: int,
language: str | None = None,
) -> int:
"""Stream PCM as JSON ``audio.chunk`` frames, aligned per sentence.
Forward each PCM chunk live (``timestamps: null``) while buffering the
sentence audio, then run the forced aligner once over the whole
sentence and emit a final empty-audio ``audio.chunk`` with the word
timestamps. On aligner failure timestamps is ``null``; for silence it
is ``[]`` (audio always flows regardless).
"""
aligner_config = self._speech_service.forced_aligner_config
assert aligner_config is not None # gated by the precondition check
sentence_audio = bytearray()
total_bytes = 0
sample_rate = _PCM_SAMPLE_RATE
chunk_id = 0
async def send_chunk(
chunk: bytes,
chunk_sample_rate: int,
timestamps_payload: list[dict] | None,
chunk_start_ms: int,
chunk_end_ms: int,
) -> None:
nonlocal chunk_id
await websocket.send_json(
{
"type": "audio.chunk",
"sentence_index": sentence_index,
"chunk_id": chunk_id,
"chunk_start_ms": chunk_start_ms,
"chunk_end_ms": chunk_end_ms,
"sample_rate": chunk_sample_rate,
"audio_b64": base64.b64encode(chunk).decode("ascii"),
"timestamps": timestamps_payload,
}
)
chunk_id += 1
async with aclosing(
self._speech_service._generate_pcm_chunks(
generator,
request_id,
include_sample_rate=True,
)
) as stream:
async for chunk, chunk_sample_rate in stream:
sample_rate = chunk_sample_rate
chunk_start_ms = int(round((len(sentence_audio) / _BYTES_PER_SAMPLE / sample_rate) * 1000.0))
sentence_audio.extend(chunk)
chunk_end_ms = int(round((len(sentence_audio) / _BYTES_PER_SAMPLE / sample_rate) * 1000.0))
total_bytes += len(chunk)
# Audio first, timestamps after the whole sentence is aligned.
await send_chunk(chunk, chunk_sample_rate, None, chunk_start_ms, chunk_end_ms)
# Single alignment pass over the full sentence, then emit timestamps.
# A load/config failure is permanent, so surface the reason once; audio
# has already streamed, so the trailing frame still carries null.
try:
timestamps_payload = await self._align_sentence(
audio=bytes(sentence_audio),
text=sentence_text,
sample_rate=sample_rate,
config=aligner_config,
language=language,
)
except ForcedAlignerLoadError as exc:
await self._send_error(websocket, f"forced aligner unavailable: {exc}")
timestamps_payload = None
sentence_end_ms = int(round((len(sentence_audio) / _BYTES_PER_SAMPLE / sample_rate) * 1000.0))
await send_chunk(b"", sample_rate, timestamps_payload, 0, sentence_end_ms)
return total_bytes
@staticmethod
async def _align_sentence(
*,
audio: bytes,
text: str,
sample_rate: int,
config,
language: str | None = None,
) -> list[dict] | None:
"""Convert a sentence alignment into JSON word-timestamp dicts.
Returns ``None`` on aligner failure, ``[]`` when it ran but produced no
tokens. Monotonic, non-overlapping bounds are guaranteed by the decoder.
``language`` is forwarded to word segmentation.
"""
aligned = await forced_align(
audio=audio,
text=text,
sample_rate=sample_rate,
config=config,
language=language,
)
if aligned is None:
return None
return [{"word": ts.word, "start_ms": ts.start_ms, "end_ms": ts.end_ms} for ts in aligned]
@staticmethod
async def _send_error(websocket: WebSocket, message: str) -> None:
"""Send an error message to the client."""
+399
View File
@@ -0,0 +1,399 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Shared forced aligner for streaming TTS word timestamps.
Hosts one lazy-loaded, in-process pooling ``vllm.LLM`` shared by the TTS
frontend. ``llm.encode`` is sync/blocking, so :func:`align` runs it in
``asyncio.to_thread``, serialized on a lock since the offline LLM is not
thread-safe. See :func:`align` for the return-value contract.
"""
from __future__ import annotations
import asyncio
import logging
import os
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
import yaml
from vllm_omni.utils import qwen3_force_align_processor as _processor
logger = logging.getLogger(__name__)
_DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[1] / "deploy" / "qwen3_tts_forced_aligner.yaml"
@dataclass(frozen=True, slots=True)
class WordTimestamp:
"""Internal alignment record. Serialized to a plain JSON object
(``{"word", "start_ms", "end_ms"}``) at the WebSocket boundary.
"""
word: str
start_ms: int
end_ms: int
@dataclass(frozen=True, slots=True)
class ForcedAlignerConfig:
"""Plain-data config for constructing the aligner ``vllm.LLM`` (lazy-loaded)."""
model: str
runner: str | None = None
architecture: str | None = None
pooling_task: str | None = None
gpu_memory_utilization: float | None = None
dtype: str | None = None
max_model_len: int | None = None
trust_remote_code: bool | None = None
extra_llm_kwargs: dict[str, Any] = field(default_factory=dict)
def _load_forced_aligner_yaml(path: str | Path) -> dict[str, Any]:
with Path(path).open(encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
return dict(raw.get("forced_aligner", raw))
def build_forced_aligner_config(args: Any) -> ForcedAlignerConfig | None:
"""Build a config from CLI args, or ``None`` when the feature is off.
Precedence (lowest to highest): the packaged default YAML
(:data:`_DEFAULT_CONFIG_PATH`, Qwen deploy defaults) -> a user YAML passed
via ``--forced-aligner-config`` -> the ``--forced-aligner`` model path. The
feature is off (returns ``None``) unless a model resolves from this chain.
Per-field overrides such as ``gpu_memory_utilization`` live in the YAML.
"""
config_path = getattr(args, "forced_aligner_config", None)
config_data: dict[str, Any] = {}
model = getattr(args, "forced_aligner", None)
if config_path or model:
config_data.update(_load_forced_aligner_yaml(_DEFAULT_CONFIG_PATH))
if config_path:
config_data.update(_load_forced_aligner_yaml(config_path))
if model:
config_data["model"] = str(model)
model = config_data.get("model")
if not model:
return None
allowed = set(ForcedAlignerConfig.__dataclass_fields__)
return ForcedAlignerConfig(**{k: v for k, v in config_data.items() if k in allowed})
class ForcedAlignerLoadError(RuntimeError):
"""The aligner model/config could not be loaded (permanent until restart).
Distinguished from a per-request alignment failure so the streaming layer
can report a clear reason once rather than degrading every request to
``timestamps: null``.
"""
# --- Singleton state ---
# A single LLM serves the whole API server. ``_lock`` guards the lazy
# constructor; ``_encode_lock`` serializes ``encode`` because the offline
# ``vllm.LLM`` is a sync batch interface that is not safe to call from multiple
# threads at once (concurrent WebSocket sessions otherwise race the engine).
_lock = threading.Lock()
_encode_lock = asyncio.Lock()
_llm: Any = None
_classify_num: int | None = None
_timestamp_token_id: int | None = None
_timestamp_segment_time_ms: float | None = None
_loaded_config: ForcedAlignerConfig | None = None
async def align(
*,
audio: bytes,
text: str,
sample_rate: int,
config: ForcedAlignerConfig,
language: str | None = None,
) -> list[WordTimestamp] | None:
"""Run one forced-alignment pass.
Args:
audio: Signed-int16 little-endian mono PCM bytes.
text: Ground-truth text whose words to align.
sample_rate: Sample rate of ``audio`` in Hz.
config: Aligner config (same instance for every call across the
server's lifetime; reload requires a server restart).
language: Optional language hint for word segmentation. ``None`` /
``"auto"`` use the space + Chinese-mixed path; ``"japanese"`` /
``"korean"`` (or their codes) need ``qwen_asr`` installed for
faithful tokenisation, else they degrade to whitespace splitting.
Returns:
List of :class:`WordTimestamp` on success (possibly empty for
silence / no aligned tokens), ``None`` on a per-request alignment
failure (decode error).
Raises:
ForcedAlignerLoadError: the model/config could not be loaded. This is
permanent until restart, so callers surface it once instead of
degrading every request to ``None``.
"""
async with _encode_lock:
try:
await asyncio.to_thread(_ensure_loaded, config)
except Exception as exc: # noqa: BLE001
logger.exception("Forced aligner failed to load")
raise ForcedAlignerLoadError(str(exc)) from exc
try:
return await asyncio.to_thread(_align_sync, audio, text, sample_rate, config, language)
except Exception: # noqa: BLE001
logger.exception("Forced aligner failed for text=%r", text)
return None
def _align_sync(
audio: bytes,
text: str,
sample_rate: int,
config: ForcedAlignerConfig,
language: str | None = None,
) -> list[WordTimestamp]:
# Precondition: align() has already run _ensure_loaded(config) under
# _encode_lock and owns the load-failure -> ForcedAlignerLoadError mapping.
# Don't reload here, so a load error can't be swallowed as a per-request None.
audio_arr = _pcm_bytes_to_float32(audio)
if audio_arr.size == 0:
return []
audio_duration_ms = (audio_arr.size / sample_rate) * 1000.0
# Segment once and reuse for both the prompt and the decode: the word
# units MUST match between the two or the markers drift out of sync.
words = _processor.segment_words(text, language)
prompt = _processor.build_prompt(words)
request = {
"prompt": prompt,
"multi_modal_data": {"audio": (audio_arr, sample_rate)},
}
from vllm.pooling_params import PoolingParams
outputs = _llm.encode( # type: ignore[union-attr]
[request],
pooling_params=PoolingParams(),
pooling_task=config.pooling_task or "token_classify",
use_tqdm=False,
)
if not outputs:
return []
result = outputs[0]
logits = result.outputs.data # [n_token, classify_num]
prompt_token_ids = list(result.prompt_token_ids)
timestamp_positions = [i for i, tid in enumerate(prompt_token_ids) if tid == _timestamp_token_id]
if not timestamp_positions:
logger.warning(
"No <|timestamp|> tokens found in prompt for text=%r; aligner returned %d rows.",
text,
logits.shape[0] if hasattr(logits, "shape") else len(logits),
)
return []
return _decode_timestamps(
logits=logits,
words=words,
timestamp_positions=timestamp_positions,
classify_num=_classify_num,
timestamp_segment_time_ms=_timestamp_segment_time_ms,
audio_duration_ms=audio_duration_ms,
)
def _ensure_loaded(config: ForcedAlignerConfig) -> None:
"""Lazy-load the singleton ``vllm.LLM`` under lock; idempotent."""
global _llm, _classify_num, _timestamp_token_id, _timestamp_segment_time_ms, _loaded_config
if _llm is not None:
if _loaded_config is not None and _loaded_config.model != config.model:
# Multiple configs from different requests — refuse rather
# than swap models silently. A server restart is required.
raise RuntimeError(
f"Forced aligner already loaded with config={_loaded_config!r}; "
f"cannot serve a request that asks for {config!r}. "
"Restart the server to change the aligner config."
)
return
with _lock:
if _llm is not None:
return # raced; another caller did the load
# Lazy import: vllm pulls torch + CUDA, which we want to avoid at
# module import time.
from vllm import LLM
logger.info(
"Loading forced aligner %s (gpu_memory_utilization=%s)",
config.model,
config.gpu_memory_utilization if config.gpu_memory_utilization is not None else "default",
)
llm_kwargs: dict[str, Any] = {"model": config.model}
if config.runner is not None:
llm_kwargs["runner"] = config.runner
if config.architecture is not None:
llm_kwargs["hf_overrides"] = {"architectures": [config.architecture]}
if config.gpu_memory_utilization is not None:
llm_kwargs["gpu_memory_utilization"] = config.gpu_memory_utilization
if config.trust_remote_code is not None:
llm_kwargs["trust_remote_code"] = config.trust_remote_code
if config.dtype is not None:
llm_kwargs["dtype"] = config.dtype
if config.max_model_len is not None:
llm_kwargs["max_model_len"] = config.max_model_len
llm_kwargs.update(config.extra_llm_kwargs)
# Force the aligner engine in-process: in multiprocessing mode the
# token_classify ``pooling_output`` fails to round-trip over the
# engine-core IPC on current vLLM. Hard-set (not setdefault) so a
# globally exported ``=1`` can't push it into a subprocess; save/restore
# to avoid leaking the override to anything built later in this process.
prev_mp = os.environ.get("VLLM_ENABLE_V1_MULTIPROCESSING")
os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0"
try:
llm = LLM(**llm_kwargs)
finally:
if prev_mp is None:
os.environ.pop("VLLM_ENABLE_V1_MULTIPROCESSING", None)
else:
os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = prev_mp
thinker_config = getattr(llm.llm_engine.model_config.hf_config, "thinker_config", None)
if thinker_config is None or not hasattr(thinker_config, "classify_num"):
raise RuntimeError(
"Loaded aligner has no thinker_config.classify_num; "
"expected a Qwen3ASRForcedAlignerForTokenClassification checkpoint."
)
timestamp_segment_time_ms = getattr(llm.llm_engine.model_config.hf_config, "timestamp_segment_time", None)
if timestamp_segment_time_ms is None:
raise RuntimeError(
"Loaded aligner has no timestamp_segment_time; expected a Qwen3ASR forced aligner checkpoint."
)
tokenizer = llm.get_tokenizer()
timestamp_token_id = _processor.resolve_timestamp_token_id(tokenizer)
# Publish in this order so a concurrent reader either sees
# _llm == None (will block on the lock) or sees a fully
# initialized aligner.
_classify_num = int(thinker_config.classify_num)
_timestamp_token_id = timestamp_token_id
_timestamp_segment_time_ms = float(timestamp_segment_time_ms)
_loaded_config = config
_llm = llm
logger.info(
"Forced aligner ready: timestamp_token_id=%d, classify_num=%d, timestamp_segment_time_ms=%.1f",
timestamp_token_id,
_classify_num,
_timestamp_segment_time_ms,
)
# --- pure helpers (testable without a GPU / vllm) ---
#
# Word segmentation, prompt building, timestamp repair and marker-token
# lookup are Qwen-specific and live in
# :mod:`vllm_omni.utils.qwen3_force_align_processor`. This module owns only
# the generic vLLM orchestration.
def _pcm_bytes_to_float32(audio: bytes) -> np.ndarray:
"""Decode signed-int16 mono PCM bytes into a [-1, 1] float32 array."""
if not audio:
return np.zeros(0, dtype=np.float32)
if len(audio) % 2 != 0:
# Drop a trailing odd byte rather than raise; keeps streaming
# robust against off-by-one chunk boundaries.
audio = audio[:-1]
pcm = np.frombuffer(audio, dtype=np.int16)
return (pcm.astype(np.float32) / 32768.0).copy()
def _decode_timestamps(
*,
logits: Any,
words: list[str],
timestamp_positions: list[int],
classify_num: int,
audio_duration_ms: float,
timestamp_segment_time_ms: float | None = None,
) -> list[WordTimestamp]:
"""Translate ``[n_token, classify_num]`` logits into word timestamps.
``words`` must be the exact segmentation used to build the prompt (see
:func:`qwen3_force_align_processor.segment_words`); each word owns two
consecutive markers.
"""
arr = logits.detach().cpu().numpy() if hasattr(logits, "detach") else np.asarray(logits)
if arr.ndim != 2:
raise ValueError(f"Expected 2D logits [n_token, classify_num]; got shape {arr.shape}")
if arr.shape[1] != classify_num:
raise ValueError(
f"Logits last dim {arr.shape[1]} != classify_num {classify_num}; "
"model config and prompt template may be out of sync."
)
expected = len(words) * 2
if len(timestamp_positions) != expected:
logger.warning(
"Got %d timestamp positions but text has %d words (expected %d start/end markers); "
"returning empty alignment.",
len(timestamp_positions),
len(words),
expected,
)
return []
marker_logits = arr[timestamp_positions, :]
bin_idx = marker_logits.argmax(axis=-1)
bin_size_ms = (
float(timestamp_segment_time_ms)
if timestamp_segment_time_ms is not None
else (audio_duration_ms / classify_num if classify_num > 0 else 0.0)
)
# Repair non-monotonic bins across the whole start/end sequence before
# pairing, so each (start, end) pair stays ordered and one bad bin can't
# corrupt its neighbours.
marker_ms = _processor.fix_timestamp([int(round(int(b) * bin_size_ms)) for b in bin_idx])
# Pair markers into per-word intervals, enforcing the wire contract here so
# callers don't have to: monotonic and non-overlapping across words, with
# start <= end and nothing past the audio length.
duration_ms = int(round(audio_duration_ms))
out: list[WordTimestamp] = []
prev_end_ms = 0
for i, word in enumerate(words):
start_ms = min(max(marker_ms[i * 2], prev_end_ms), duration_ms)
end_ms = min(max(marker_ms[i * 2 + 1], start_ms), duration_ms)
out.append(WordTimestamp(word=word, start_ms=start_ms, end_ms=end_ms))
prev_end_ms = end_ms
return out
# Test hooks ---------------------------------------------------------------
# Tests need a way to reset module state without restarting Python. Not
# part of the public API; do not call in production code.
def _reset_for_tests() -> None:
"""Drop the cached aligner state so the next call reloads."""
global _llm, _classify_num, _timestamp_token_id, _timestamp_segment_time_ms, _loaded_config
with _lock:
_llm = None
_classify_num = None
_timestamp_token_id = None
_timestamp_segment_time_ms = None
_loaded_config = None
_processor._reset_for_tests()
@@ -0,0 +1,285 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Adapted from:
# https://github.com/QwenLM/Qwen3-ASR/blob/main/qwen_asr/inference/qwen3_forced_aligner.py
#
# Copyright 2026 The Alibaba Qwen team. All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
"""Qwen3 forced-aligner text/timestamp processor.
This is the model-specific half of upstream's ``Qwen3ForceAlignProcessor``:
it turns text into the aligner's word units and prompt, repairs the predicted
timestamp bins, and resolves the marker token id. The generic "run a vLLM
pooling model" half lives in :mod:`vllm_omni.utils.forced_aligner`, which
calls into this module.
Keeping the Qwen-specific pieces here marks the seam for the model-agnostic
aligner the issue asks for: a different aligner family would supply its own
processor exposing the same small surface — :func:`segment_words`,
:func:`build_prompt`, :func:`fix_timestamp`, :func:`resolve_timestamp_token_id`.
Word segmentation prefers qwen_asr's official ``Qwen3ForceAlignProcessor``
when installed (full multilingual fidelity, incl. Japanese/Korean) and
otherwise uses the faithful port below: exact for whitespace-delimited and
Chinese-mixed text; Japanese/Korean degrade to whitespace splitting.
"""
from __future__ import annotations
import logging
import unicodedata
from typing import Any
logger = logging.getLogger(__name__)
# Prompt tokens for the Qwen3 forced aligner. Baked in (not config) because the
# prompt template and the word segmentation are inherently Qwen-specific: a
# different aligner family needs a different processor module regardless.
AUDIO_PLACEHOLDER = "<|audio_start|><|audio_pad|><|audio_end|>"
TIMESTAMP_TOKEN = "<timestamp>"
_CJK_RANGES = (
(0x4E00, 0x9FFF),
(0x3400, 0x4DBF),
(0x20000, 0x2A6DF),
(0x2A700, 0x2B73F),
(0x2B740, 0x2B81F),
(0x2B820, 0x2CEAF),
(0xF900, 0xFAFF),
)
# Map common language codes to the names qwen_asr's processor expects.
_LANG_ALIASES = {
"ja": "japanese",
"jp": "japanese",
"jpn": "japanese",
"ko": "korean",
"kr": "korean",
"kor": "korean",
}
def build_prompt(words: list[str]) -> str:
"""Wrap segmented words in the Qwen3 aligner prompt template.
Each word gets two trailing ``<timestamp>`` markers (start + end); the
model classifies each marker into an audio time bin.
"""
if not words:
# Pad with one timestamp so the decoder always has something to
# read; an empty result still surfaces as "[]" upstream.
body = TIMESTAMP_TOKEN
else:
body = f"{TIMESTAMP_TOKEN}{TIMESTAMP_TOKEN}".join(words) + f"{TIMESTAMP_TOKEN}{TIMESTAMP_TOKEN}"
return f"<|im_start|>user\n{AUDIO_PLACEHOLDER}{body}<|im_end|>\n<|im_start|>assistant\n"
# --- word segmentation (port of Qwen3ForceAlignProcessor) ----------------
#
# The marker count fed to the model must match the segmentation the aligner
# was trained with, or the decode drifts and returns empty.
def _is_cjk_char(ch: str) -> bool:
code = ord(ch)
return any(lo <= code <= hi for lo, hi in _CJK_RANGES)
def _is_kept_char(ch: str) -> bool:
"""Keep letters/numbers (any script) and the apostrophe; drop punctuation."""
if ch == "'":
return True
cat = unicodedata.category(ch)
return cat.startswith("L") or cat.startswith("N")
def _clean_token(token: str) -> str:
return "".join(ch for ch in token if _is_kept_char(ch))
def _split_segment_with_chinese(seg: str) -> list[str]:
"""Split a cleaned segment so each CJK char is its own token."""
tokens: list[str] = []
buf: list[str] = []
def flush() -> None:
if buf:
tokens.append("".join(buf))
buf.clear()
for ch in seg:
if _is_cjk_char(ch):
flush()
tokens.append(ch)
else:
buf.append(ch)
flush()
return tokens
def _tokenize_space_lang(text: str) -> list[str]:
"""Whitespace + Chinese-mixed segmentation (Qwen3ForceAlignProcessor port).
Splits on whitespace, strips punctuation from each segment (keeping
cross-script letters/digits and the apostrophe), then peels off CJK
characters as individual tokens.
"""
tokens: list[str] = []
for seg in text.split():
cleaned = _clean_token(seg)
if cleaned:
tokens.extend(_split_segment_with_chinese(cleaned))
return tokens
# Cached qwen_asr processor (or a sentinel that it is unavailable). Importing
# it eagerly pulls nagisa + the Qwen3ASR transformers backend, so we only try
# once, lazily, and remember the outcome.
_official_processor: Any = None
_official_processor_unavailable = False
def _get_official_processor() -> Any | None:
global _official_processor, _official_processor_unavailable
if _official_processor is not None:
return _official_processor
if _official_processor_unavailable:
return None
try:
from qwen_asr.inference.qwen3_forced_aligner import ( # type: ignore[import-not-found]
Qwen3ForceAlignProcessor,
)
_official_processor = Qwen3ForceAlignProcessor()
except Exception: # noqa: BLE001 - any import/init error means "use the port"
_official_processor_unavailable = True
logger.info(
"qwen_asr not available; using the built-in word segmentation "
"(exact for whitespace/Chinese-mixed text; Japanese/Korean fall "
"back to whitespace splitting). Install qwen-asr for full fidelity."
)
return None
return _official_processor
def segment_words(text: str, language: str | None = None) -> list[str]:
"""Split ``text`` into the aligner's word units.
Prefers qwen_asr's official processor (full multilingual fidelity); falls
back to the built-in port otherwise. ``language`` follows the official
naming — ``"japanese"`` / ``"korean"`` (or codes like ``ja`` / ``ko``)
trigger language-specific tokenisers; anything else (incl. ``None`` /
``"auto"``) uses the whitespace + Chinese-mixed path.
"""
lang = (language or "auto").strip().lower()
lang = _LANG_ALIASES.get(lang, lang)
processor = _get_official_processor()
if processor is not None:
try:
word_list, _ = processor.encode_timestamp(text, lang)
return list(word_list)
except Exception: # noqa: BLE001
logger.warning(
"Official Qwen3ForceAlignProcessor failed for language=%r; falling back to built-in segmentation.",
lang,
exc_info=True,
)
if lang in ("japanese", "korean"):
logger.warning(
"language=%r needs qwen_asr (nagisa/soynlp) for faithful word "
"segmentation; falling back to whitespace splitting.",
lang,
)
return _tokenize_space_lang(text)
def fix_timestamp(values: list[int]) -> list[int]:
"""Repair non-monotonic timestamp bins via LIS + interpolation.
The model occasionally emits out-of-order bins; this snaps anomalies back
onto the longest non-decreasing subsequence (interpolating longer runs)
so paired start/end markers stay ordered. Port of
``Qwen3ForceAlignProcessor.fix_timestamp`` (Apache-2.0).
"""
n = len(values)
if n == 0:
return []
dp = [1] * n
parent = [-1] * n
for i in range(1, n):
for j in range(i):
if values[j] <= values[i] and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
parent[i] = j
idx = dp.index(max(dp))
lis: list[int] = []
while idx != -1:
lis.append(idx)
idx = parent[idx]
is_normal = [False] * n
for k in lis:
is_normal[k] = True
result = [float(v) for v in values]
i = 0
while i < n:
if is_normal[i]:
i += 1
continue
j = i
while j < n and not is_normal[j]:
j += 1
count = j - i
left = next((result[k] for k in range(i - 1, -1, -1) if is_normal[k]), None)
right = next((result[k] for k in range(j, n) if is_normal[k]), None)
if count <= 2:
for k in range(i, j):
if left is None:
result[k] = right
elif right is None:
result[k] = left
else:
result[k] = left if (k - (i - 1)) <= (j - k) else right
elif left is not None and right is not None:
step = (right - left) / (count + 1)
for k in range(i, j):
result[k] = left + step * (k - i + 1)
elif left is not None:
for k in range(i, j):
result[k] = left
elif right is not None:
for k in range(i, j):
result[k] = right
i = j
return [int(r) for r in result]
def resolve_timestamp_token_id(tokenizer: Any, timestamp_token: str = TIMESTAMP_TOKEN) -> int:
"""Look up the integer id of the timestamp special token."""
convert = getattr(tokenizer, "convert_tokens_to_ids", None)
if not callable(convert):
raise RuntimeError("Aligner tokenizer has no convert_tokens_to_ids method.")
tid = convert(timestamp_token)
if isinstance(tid, list):
tid = tid[0] if tid else None
if tid is None or (isinstance(tid, int) and tid < 0):
raise RuntimeError(
f"Aligner tokenizer does not recognise {timestamp_token!r} (got id={tid}). "
"Check the model card; the marker token may use a different name."
)
return int(tid)
def _reset_for_tests() -> None:
"""Drop the cached official-processor probe so the next call re-checks."""
global _official_processor, _official_processor_unavailable
_official_processor = None
_official_processor_unavailable = False