Signed-off-by: wjinxu <1299461899@qq.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
400 lines
15 KiB
Python
400 lines
15 KiB
Python
# 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()
|