[Feature] timestamps for TTS via a forced-aligner pooling (#4795)
Signed-off-by: wjinxu <1299461899@qq.com> Co-authored-by: xurui <xurui1@modelbest.onaliyun.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28 files changed
+850
-627
No files matched your search
@@ -372,18 +372,10 @@ 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.
|
||||
Word-level timestamps are currently a serving-path feature: launch
|
||||
`vllm-omni serve` with `--forced-aligner` and request `word_timestamps`
|
||||
(see `examples/online_serving/text_to_speech/README.md`). An offline
|
||||
example is not provided in this release.
|
||||
|
||||
### 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):
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
"""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())
|
||||
@@ -605,6 +605,13 @@ python qwen3_tts/streaming_speech_client.py \
|
||||
The client writes one PCM file per sentence and a matching
|
||||
`sentence_XXX_timestamps.json` sidecar.
|
||||
|
||||
Non-streaming requests can also ask for timestamps: pass
|
||||
`"word_timestamps": true` to `POST /v1/audio/speech` and read the
|
||||
`X-Word-Timestamps` response header (JSON list of `{word, start_ms, end_ms}`,
|
||||
ASCII-escaped). Past 4 KB the header is replaced by
|
||||
`X-Word-Timestamps-Omitted: oversize; bytes=<n>; limit=4096` and the audio
|
||||
still returns — use the WebSocket path for long transcripts.
|
||||
|
||||
To *see* the alignment instead of reading a JSON sidecar, run the
|
||||
word-timestamp Gradio demo (server must be launched with `--forced-aligner`):
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
|
||||
import vllm_omni.config.pipeline_registry # noqa: F401 (populate registry)
|
||||
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES as _PIPELINE_REGISTRY
|
||||
from vllm_omni.config.stage_config import (
|
||||
DeployConfig,
|
||||
StageExecutionType,
|
||||
)
|
||||
from vllm_omni.model_executor.stage_input_processors.forced_aligner import (
|
||||
POOLING_OUTPUT_DECODER_PATH,
|
||||
TIMESTAMPS_MODALITY,
|
||||
)
|
||||
from vllm_omni.utils.forced_aligner import _DEFAULT_CONFIG_PATH, inject_forced_aligner_stage
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_inject_appends_pooling_aligner_stage():
|
||||
pipeline = _PIPELINE_REGISTRY["qwen3_tts"]
|
||||
deploy = DeployConfig()
|
||||
n0 = len(pipeline.stages)
|
||||
|
||||
ext_pipeline, ext_deploy = inject_forced_aligner_stage(
|
||||
pipeline, deploy, {"forced_aligner": "/models/Qwen3-ForcedAligner-0.6B"}
|
||||
)
|
||||
|
||||
# A new pooling stage is appended; the original pipeline is unchanged.
|
||||
assert len(pipeline.stages) == n0
|
||||
assert len(ext_pipeline.stages) == n0 + 1
|
||||
# The original deploy config is not mutated either (double-injection safe).
|
||||
assert len(deploy.stages) == 0
|
||||
assert len(ext_deploy.stages) == 1
|
||||
aligner = ext_pipeline.stages[-1]
|
||||
# Pooling stage reuses LLM_AR (+ runner=pooling), no dedicated LLM_POOLING.
|
||||
assert aligner.execution_type == StageExecutionType.LLM_AR
|
||||
assert aligner.final_output is True
|
||||
assert aligner.final_output_type == TIMESTAMPS_MODALITY
|
||||
assert aligner.input_sources == (n0 - 1,)
|
||||
assert aligner.requires_multimodal_data is True
|
||||
assert aligner.owns_tokenizer is True
|
||||
|
||||
# The deploy stage carries the separate aligner checkpoint + decoder hook.
|
||||
ds = ext_deploy.stages[-1]
|
||||
assert ds.engine_extras["model"] == "/models/Qwen3-ForcedAligner-0.6B"
|
||||
assert ds.engine_extras["runner"] == "pooling"
|
||||
assert ds.engine_extras["pooling_output_decoder"] == POOLING_OUTPUT_DECODER_PATH
|
||||
|
||||
|
||||
def test_inject_from_config_only():
|
||||
# --forced-aligner-config alone (model in the YAML) must also inject.
|
||||
pipeline = _PIPELINE_REGISTRY["qwen3_tts"]
|
||||
deploy = DeployConfig()
|
||||
n0 = len(pipeline.stages)
|
||||
|
||||
ext_pipeline, ext_deploy = inject_forced_aligner_stage(
|
||||
pipeline, deploy, {"forced_aligner": None, "forced_aligner_config": str(_DEFAULT_CONFIG_PATH)}
|
||||
)
|
||||
|
||||
assert len(ext_pipeline.stages) == n0 + 1
|
||||
assert ext_pipeline.stages[-1].final_output_type == TIMESTAMPS_MODALITY
|
||||
assert ext_deploy.stages[-1].engine_extras["model"] == "Qwen/Qwen3-ForcedAligner-0.6B"
|
||||
|
||||
|
||||
def test_inject_noop_without_forced_aligner():
|
||||
pipeline = _PIPELINE_REGISTRY["qwen3_tts"]
|
||||
deploy = DeployConfig()
|
||||
|
||||
ext_pipeline, ext_deploy = inject_forced_aligner_stage(pipeline, deploy, {})
|
||||
|
||||
assert ext_pipeline is pipeline
|
||||
assert len(ext_deploy.stages) == 0
|
||||
@@ -27,6 +27,7 @@ _MIXIN_UPDATE_HELPERS = (
|
||||
"_attach_finished_request_sets",
|
||||
"_attach_scheduler_stats",
|
||||
"_capture_omni_connector_output",
|
||||
"_maybe_decode_pooling_output",
|
||||
)
|
||||
|
||||
|
||||
@@ -138,6 +139,7 @@ def _make_scheduler_stub(requests: list[_Request]) -> SimpleNamespace:
|
||||
pending_stop_after_extraction=set(),
|
||||
waiting_for_transfer_free=set(),
|
||||
_new_prompt_len_snapshot={},
|
||||
_pooling_output_decoder=None,
|
||||
finished_req_ids=set(),
|
||||
finished_req_ids_dict=defaultdict(set),
|
||||
kv_cache_manager=SimpleNamespace(take_events=lambda: None),
|
||||
@@ -267,3 +269,72 @@ def test_invalid_logprobs_finish_only_the_affected_scheduler_request() -> None:
|
||||
assert output_by_id["bad"].new_token_ids == []
|
||||
assert output_by_id["good"].new_token_ids == [8]
|
||||
np.testing.assert_array_equal(output_by_id["good"].new_logprobs.logprob_token_ids[:, 0], [8])
|
||||
|
||||
|
||||
def _pooling_model_runner_output(pooler_tensor) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
sampled_token_ids=[[]],
|
||||
logprobs=None,
|
||||
prompt_logprobs_dict={},
|
||||
pooler_output=[pooler_tensor],
|
||||
num_nans_in_logits=None,
|
||||
kv_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
req_id_to_index={"req": 0},
|
||||
routed_experts=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_pooling_stub(decoder) -> tuple[_Request, SimpleNamespace]:
|
||||
request = _Request("req")
|
||||
request.sampling_params = SimpleNamespace(num_logprobs=None)
|
||||
request.pooling_params = SimpleNamespace()
|
||||
scheduler = _make_scheduler_stub([request])
|
||||
scheduler._pooling_output_decoder = decoder
|
||||
scheduler.vllm_config = SimpleNamespace(model_config=SimpleNamespace(hf_config=None))
|
||||
_bind_request_lifecycle(scheduler, update_request=lambda req, tokens: (tokens, False))
|
||||
return request, scheduler
|
||||
|
||||
|
||||
def test_pooling_decode_success_emits_decoded_payload() -> None:
|
||||
import torch
|
||||
|
||||
decoded = torch.tensor([[1, 2]], dtype=torch.int32)
|
||||
request, scheduler = _make_pooling_stub(lambda logits, req, hf: decoded)
|
||||
|
||||
scheduler_output = SimpleNamespace(
|
||||
num_scheduled_tokens={"req": 1},
|
||||
scheduled_spec_decode_tokens={},
|
||||
num_invalid_spec_tokens=0,
|
||||
)
|
||||
outputs = OmniARScheduler.update_from_output(
|
||||
scheduler, scheduler_output, _pooling_model_runner_output(torch.zeros(2, 4))
|
||||
)
|
||||
(output,) = outputs[0].outputs
|
||||
|
||||
assert output.finish_reason is FinishReason.STOP
|
||||
assert output.pooling_output is decoded
|
||||
|
||||
|
||||
def test_pooling_decode_failure_finishes_request_with_error() -> None:
|
||||
import torch
|
||||
|
||||
def raising_decoder(logits, req, hf):
|
||||
raise ValueError("boom")
|
||||
|
||||
request, scheduler = _make_pooling_stub(raising_decoder)
|
||||
|
||||
scheduler_output = SimpleNamespace(
|
||||
num_scheduled_tokens={"req": 1},
|
||||
scheduled_spec_decode_tokens={},
|
||||
num_invalid_spec_tokens=0,
|
||||
)
|
||||
outputs = OmniARScheduler.update_from_output(
|
||||
scheduler, scheduler_output, _pooling_model_runner_output(torch.zeros(2, 4))
|
||||
)
|
||||
(output,) = outputs[0].outputs
|
||||
|
||||
# Decoder failure is a request error (500), not an empty success.
|
||||
assert output.finish_reason is FinishReason.ERROR
|
||||
assert output.pooling_output is None
|
||||
assert "pooling output decode failed" in str(request.stop_reason)
|
||||
@@ -3,6 +3,7 @@ import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from pytest_mock import MockerFixture
|
||||
from starlette.testclient import TestClient
|
||||
@@ -11,11 +12,24 @@ 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
|
||||
from vllm_omni.model_executor.stage_input_processors.forced_aligner import ALIGNER_WORDS_KEY
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _fake_aligner_res(pairs, words):
|
||||
"""Build a stand-in for the forced-aligner stage's pooling output.
|
||||
|
||||
Mirrors what rides the generator in production: ``res.outputs.data`` is a
|
||||
``[n_words, 2]`` int32 tensor of ``[start_ms, end_ms]`` and the word strings
|
||||
travel in ``additional_information``. Decoded by ``extract_word_timestamps``.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
outputs=SimpleNamespace(data=torch.tensor(pairs, dtype=torch.int32), additional_information=None),
|
||||
additional_information={ALIGNER_WORDS_KEY: list(words)},
|
||||
)
|
||||
|
||||
|
||||
def _build_test_app(
|
||||
speech_service=None,
|
||||
*,
|
||||
@@ -28,9 +42,9 @@ 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
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, collect=None):
|
||||
for chunk in (b"\x01\x02", b"\x03\x04\x05"):
|
||||
yield (chunk, 24000) if include_sample_rate else chunk
|
||||
|
||||
@@ -235,7 +249,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
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_prepare_speech_generation(request):
|
||||
captured_requests.append(request)
|
||||
@@ -311,13 +325,13 @@ class TestStreamingSpeechWebSocket:
|
||||
assert error["type"] == "error"
|
||||
assert "without --forced-aligner" in error["message"]
|
||||
|
||||
def test_word_timestamps_emit_sidecar_json_frame(self, mocker: MockerFixture):
|
||||
def test_word_timestamps_emit_pipeline_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")
|
||||
speech_service.forced_aligner_enabled = True
|
||||
|
||||
async def mock_prepare_speech_generation(request):
|
||||
captured_requests.append(request)
|
||||
@@ -328,19 +342,15 @@ class TestStreamingSpeechWebSocket:
|
||||
first_chunk = b"\x01" * 1000
|
||||
second_chunk = b"\x02" * 1000
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
|
||||
# The forced-aligner stage rides the same generator: its pooling output
|
||||
# is surfaced via the ``collect`` channel once the audio has streamed.
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, collect=None):
|
||||
for chunk in (first_chunk, second_chunk):
|
||||
yield (chunk, 1000) if include_sample_rate else chunk
|
||||
if collect is not None:
|
||||
collect["aligner_res"] = _fake_aligner_res([[0, 200], [200, 900]], ["Hello", "world"])
|
||||
|
||||
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:
|
||||
@@ -406,7 +416,6 @@ class TestStreamingSpeechWebSocket:
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -415,21 +424,16 @@ 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 = SimpleNamespace(model="aligner")
|
||||
speech_service.forced_aligner_enabled = True
|
||||
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req", object(), {}))
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, collect=None):
|
||||
chunk = b"\x01" * 1000
|
||||
yield (chunk, 1000) if include_sample_rate else chunk
|
||||
if collect is not None:
|
||||
collect["aligner_res"] = _fake_aligner_res([[0, 1000], [1000, 1200]], ["Hello", "world"])
|
||||
|
||||
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:
|
||||
@@ -572,7 +576,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
|
||||
speech_service.forced_aligner_enabled = False
|
||||
app, _ = _build_test_app(speech_service)
|
||||
|
||||
with TestClient(app) as client:
|
||||
@@ -602,7 +606,7 @@ 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
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
|
||||
yield b"\x01\x02"
|
||||
@@ -696,7 +700,7 @@ 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
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False):
|
||||
yield b"\x01\x02"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.forced_aligner import (
|
||||
ALIGNER_WORDS_KEY,
|
||||
_extract_text,
|
||||
_extract_waveform,
|
||||
code2wav2aligner,
|
||||
decode_pooling_output,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _FakeCompletion:
|
||||
def __init__(self, multimodal_output):
|
||||
self.multimodal_output = multimodal_output
|
||||
|
||||
|
||||
class _FakeOutput:
|
||||
def __init__(self, multimodal_output, finished=True):
|
||||
self.finished = finished
|
||||
self.outputs = [_FakeCompletion(multimodal_output)]
|
||||
|
||||
|
||||
def _audio_mm(n=2400, sr=24000):
|
||||
return {"audio": torch.zeros(n, dtype=torch.float32), "sr": torch.tensor(sr, dtype=torch.int32)}
|
||||
|
||||
|
||||
def test_extract_waveform_from_audio_key():
|
||||
wav, sr = _extract_waveform(_audio_mm(n=100, sr=16000))
|
||||
assert isinstance(wav, np.ndarray) and wav.dtype == np.float32
|
||||
assert wav.shape == (100,)
|
||||
assert sr == 16000
|
||||
|
||||
|
||||
def test_extract_waveform_falls_back_to_model_outputs_and_default_sr():
|
||||
wav, sr = _extract_waveform({"model_outputs": [torch.ones(50)]})
|
||||
assert wav.shape == (50,)
|
||||
assert sr == 24000 # default when sr missing
|
||||
|
||||
|
||||
def test_extract_text_unwraps_additional_information_list():
|
||||
prompt = {"additional_information": {"text": ["Hello world."]}}
|
||||
assert _extract_text(prompt) == "Hello world."
|
||||
|
||||
|
||||
def test_code2wav2aligner_builds_text_prompt_and_audio():
|
||||
prompt = {"additional_information": {"text": ["U.S.A test"], "language": ["English"]}}
|
||||
out = code2wav2aligner([_FakeOutput(_audio_mm())], prompt)
|
||||
|
||||
assert len(out) == 1
|
||||
item = out[0]
|
||||
# text prompt: tokenization + audio features deferred to the stage preprocessor
|
||||
assert "prompt_token_ids" not in item
|
||||
# prompt built from official segmentation: "U.S.A" -> "USA"
|
||||
assert "USA<timestamp><timestamp>test" in item["prompt"]
|
||||
# audio carried as (np.float32, sr) tuple
|
||||
audio, sr = item["multi_modal_data"]["audio"]
|
||||
assert isinstance(audio, np.ndarray) and sr == 24000
|
||||
assert item["additional_information"]["aligner_words"] == [["USA", "test"]]
|
||||
assert item["additional_information"]["language"] == ["English"]
|
||||
|
||||
|
||||
def test_code2wav2aligner_skips_unfinished_and_empty():
|
||||
# unfinished -> skipped
|
||||
assert code2wav2aligner([_FakeOutput(_audio_mm(), finished=False)], {}) == []
|
||||
# finished but empty waveform -> skipped
|
||||
empty = _FakeOutput({"audio": torch.zeros(0), "sr": torch.tensor(24000)})
|
||||
assert code2wav2aligner([empty], {"additional_information": {"text": ["hi"]}}) == []
|
||||
|
||||
|
||||
def test_decode_pooling_output_produces_word_timestamps_ms():
|
||||
# 2 words -> 4 <timestamp> markers (id=99). classify_num=5, seg=200ms.
|
||||
hf_config = SimpleNamespace(
|
||||
timestamp_token_id=99,
|
||||
timestamp_segment_time=200.0,
|
||||
thinker_config=SimpleNamespace(classify_num=5),
|
||||
)
|
||||
# prompt: marker positions at indices 2,3,5,6
|
||||
prompt_token_ids = [1, 1, 99, 99, 1, 99, 99]
|
||||
request = SimpleNamespace(
|
||||
prompt_token_ids=prompt_token_ids,
|
||||
additional_information={ALIGNER_WORDS_KEY: [["hello", "world"]]},
|
||||
)
|
||||
logits = torch.zeros((7, 5), dtype=torch.float32)
|
||||
logits[2, 0] = 1.0 # word0 start -> bin 0
|
||||
logits[3, 2] = 1.0 # word0 end -> bin 2
|
||||
logits[5, 2] = 1.0 # word1 start -> bin 2
|
||||
logits[6, 4] = 1.0 # word1 end -> bin 4
|
||||
|
||||
ms = decode_pooling_output(logits, request, hf_config)
|
||||
|
||||
# decoder returns a bare [n_words, 2] int32 tensor (not a dict)
|
||||
assert ms.tolist() == [[0, 400], [400, 800]] # bins x 200ms
|
||||
|
||||
|
||||
def test_decode_pooling_output_handles_hf_config_without_thinker():
|
||||
# hf_config without thinker_config falls back to itself for classify_num.
|
||||
hf_config = SimpleNamespace(
|
||||
timestamp_token_id=99,
|
||||
timestamp_segment_time=100.0,
|
||||
classify_num=4,
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
prompt_token_ids=[99, 99],
|
||||
additional_information={ALIGNER_WORDS_KEY: [["hi"]]},
|
||||
)
|
||||
logits = torch.zeros((2, 4), dtype=torch.float32)
|
||||
logits[0, 1] = 1.0
|
||||
logits[1, 3] = 1.0
|
||||
|
||||
ms = decode_pooling_output(logits, request, hf_config)
|
||||
assert ms.tolist() == [[100, 300]]
|
||||
@@ -105,9 +105,7 @@ forced_aligner:
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
args = type("Args", (), {"forced_aligner": None, "forced_aligner_config": str(cfg)})()
|
||||
|
||||
out = forced_aligner.build_forced_aligner_config(args)
|
||||
out = forced_aligner.build_forced_aligner_config(None, str(cfg))
|
||||
|
||||
assert out == forced_aligner.ForcedAlignerConfig(
|
||||
model="Qwen/Qwen3-ForcedAligner-0.6B",
|
||||
@@ -127,16 +125,7 @@ def test_build_config_cli_model_overrides_yaml(tmp_path):
|
||||
"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)
|
||||
out = forced_aligner.build_forced_aligner_config("new", str(cfg))
|
||||
|
||||
assert out is not None
|
||||
# --forced-aligner overrides the YAML model; gpu_memory_utilization/dtype
|
||||
@@ -148,16 +137,7 @@ def test_build_config_cli_model_overrides_yaml(tmp_path):
|
||||
|
||||
|
||||
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)
|
||||
out = forced_aligner.build_forced_aligner_config("local-aligner", None)
|
||||
|
||||
assert out is not None
|
||||
assert out.model == "local-aligner"
|
||||
|
||||
@@ -46,20 +46,3 @@ def test_fix_timestamp_repairs_dip_onto_monotonic_sequence():
|
||||
|
||||
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"])
|
||||
@@ -409,6 +409,10 @@ class StageConfigFactory:
|
||||
if cli_async_chunk is not None:
|
||||
deploy_cfg.async_chunk = bool(cli_async_chunk)
|
||||
|
||||
from vllm_omni.utils.forced_aligner import inject_forced_aligner_stage
|
||||
|
||||
pipeline_cfg, deploy_cfg = inject_forced_aligner_stage(pipeline_cfg, deploy_cfg, cli_overrides)
|
||||
|
||||
stages = merge_pipeline_deploy(pipeline_cfg, deploy_cfg, cli_overrides)
|
||||
|
||||
# Overlay declarative parallel strategies (opt-in) before CLI overrides.
|
||||
|
||||
@@ -129,6 +129,9 @@ class OmniModelConfig(ModelConfig):
|
||||
model_arch: str | None = None
|
||||
worker_type: str | None = None
|
||||
engine_output_type: str | None = None
|
||||
# Optional dotted path of a per-stage pooling-output decoder applied
|
||||
# worker-side before IPC. Read by the AR scheduler.
|
||||
pooling_output_decoder: str | None = None
|
||||
hf_config_name: str | None = None
|
||||
custom_process_next_stage_input_func: str | None = None
|
||||
stage_connector_config: dict[str, Any] = field(
|
||||
|
||||
@@ -340,6 +340,7 @@ class StageDeployConfig:
|
||||
output_connectors: dict[str, str] | None = None
|
||||
input_connectors: dict[str, str] | None = None
|
||||
default_sampling_params: dict[str, Any] | None = None
|
||||
default_pooling_params: dict[str, Any] | None = None
|
||||
subtalker_sampling_params: dict[str, Any] | None = None
|
||||
|
||||
# === Generic stage engine fields ===
|
||||
@@ -509,6 +510,7 @@ _STAGE_RESERVED_KEYS = frozenset(
|
||||
"output_connectors",
|
||||
"input_connectors",
|
||||
"default_sampling_params",
|
||||
"default_pooling_params",
|
||||
"engine_extras",
|
||||
"engine_args",
|
||||
"runtime",
|
||||
@@ -562,11 +564,14 @@ def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig:
|
||||
kwargs["output_connectors"] = stage_data.get("output_connectors")
|
||||
kwargs["input_connectors"] = stage_data.get("input_connectors")
|
||||
kwargs["default_sampling_params"] = stage_data.get("default_sampling_params")
|
||||
kwargs["default_pooling_params"] = stage_data.get("default_pooling_params")
|
||||
kwargs["engine_extras"] = _get_recursively_merged_dict(explicit_engine_extras, flat_args)
|
||||
return StageDeployConfig(**kwargs)
|
||||
|
||||
|
||||
_DEEP_MERGE_KEYS = frozenset({"default_sampling_params", "subtalker_sampling_params", "engine_extras", "engine_args"})
|
||||
_DEEP_MERGE_KEYS = frozenset(
|
||||
{"default_sampling_params", "default_pooling_params", "subtalker_sampling_params", "engine_extras", "engine_args"}
|
||||
)
|
||||
|
||||
|
||||
def _deep_merge_stage(base: dict, overlay: dict) -> dict:
|
||||
@@ -886,6 +891,8 @@ def _build_extras(
|
||||
sampling.update(ps.sampling_constraints)
|
||||
if sampling:
|
||||
extras["default_sampling_params"] = sampling
|
||||
if ds is not None and ds.default_pooling_params:
|
||||
extras["default_pooling_params"] = dict(ds.default_pooling_params)
|
||||
if ds is not None and ds.output_connectors:
|
||||
extras["output_connectors"] = dict(ds.output_connectors)
|
||||
if ds is not None and ds.input_connectors:
|
||||
|
||||
@@ -415,6 +415,17 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
finish_reason = None
|
||||
routed_experts = None
|
||||
|
||||
# Decode the pooling output before stop handling so a decoder
|
||||
# failure finishes the request with FinishReason.ERROR (500).
|
||||
try:
|
||||
pooling_output_payload = self._maybe_decode_pooling_output(request, pooler_output)
|
||||
except Exception as exc:
|
||||
logger.exception("[pooling] decoder hook failed for request %s", req_id)
|
||||
pooling_output_payload = None
|
||||
request.status = RequestStatus.FINISHED_ERROR
|
||||
request.stop_reason = f"pooling output decode failed: {exc}"
|
||||
request.resumable = False
|
||||
|
||||
# Check for stop and update request status.
|
||||
if new_token_ids:
|
||||
num_sampled_tokens = len(new_token_ids)
|
||||
@@ -428,7 +439,8 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
new_logprobs = logprobs.slice_request(req_index, len(new_token_ids))
|
||||
elif request.pooling_params and pooler_output is not None:
|
||||
# Pooling stops as soon as there is output.
|
||||
request.status = RequestStatus.FINISHED_STOPPED
|
||||
if request.status != RequestStatus.FINISHED_ERROR:
|
||||
request.status = RequestStatus.FINISHED_STOPPED
|
||||
stopped = True
|
||||
|
||||
# If criteria returns True, it means we must STOP the request.
|
||||
@@ -522,7 +534,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
finish_reason=finish_reason,
|
||||
new_logprobs=new_logprobs,
|
||||
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
||||
pooling_output=pooler_output,
|
||||
pooling_output=pooling_output_payload,
|
||||
multimodal_output=mm_output,
|
||||
stop_reason=request.stop_reason,
|
||||
prefill_stats=request.take_prefill_stats(),
|
||||
|
||||
@@ -5,6 +5,7 @@ from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from vllm.compilation.cuda_graph import CUDAGraphStat
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
|
||||
from vllm.v1.core.sched.interface import PauseState
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
@@ -25,6 +26,8 @@ from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestDat
|
||||
from vllm_omni.core.sched.utils import omni_routed_experts_for_request
|
||||
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -442,10 +445,22 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
is_segment_finished = False
|
||||
routed_experts = None
|
||||
|
||||
# Decode the pooling output before stop handling so a decoder
|
||||
# failure finishes the request with FinishReason.ERROR (500).
|
||||
try:
|
||||
pooling_output_payload = self._maybe_decode_pooling_output(request, pooler_output)
|
||||
except Exception as exc:
|
||||
logger.exception("[pooling] decoder hook failed for request %s", req_id)
|
||||
pooling_output_payload = None
|
||||
request.status = RequestStatus.FINISHED_ERROR
|
||||
request.stop_reason = f"pooling output decode failed: {exc}"
|
||||
request.resumable = False
|
||||
|
||||
# One-shot generation request: finish after its current input unit
|
||||
# has been fully processed.
|
||||
if (
|
||||
request.status == RequestStatus.FINISHED_STOPPED
|
||||
request.status == RequestStatus.FINISHED_ERROR
|
||||
or request.status == RequestStatus.FINISHED_STOPPED
|
||||
or (self.chunk_transfer_adapter is None and request.num_computed_tokens >= request.num_prompt_tokens)
|
||||
or (
|
||||
self.chunk_transfer_adapter is not None
|
||||
@@ -453,7 +468,8 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
and request.num_computed_tokens >= len(request.prompt_token_ids)
|
||||
)
|
||||
):
|
||||
request.status = RequestStatus.FINISHED_STOPPED
|
||||
if request.status != RequestStatus.FINISHED_ERROR:
|
||||
request.status = RequestStatus.FINISHED_STOPPED
|
||||
# Optional: set a stop_reason for front-end clarity
|
||||
# (does not affect protocol)
|
||||
stopped = True
|
||||
@@ -508,7 +524,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
finish_reason=finish_reason,
|
||||
new_logprobs=new_logprobs,
|
||||
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
||||
pooling_output=pooler_output,
|
||||
pooling_output=pooling_output_payload,
|
||||
multimodal_output=mm_output,
|
||||
stop_reason=request.stop_reason,
|
||||
prefill_stats=request.take_prefill_stats(),
|
||||
|
||||
@@ -5,10 +5,12 @@ import time
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from vllm.compilation.cuda_graph import CUDAGraphStat
|
||||
from vllm.distributed.kv_events import KVEventBatch
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.import_utils import resolve_obj_by_qualname
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.core.sched.utils import remove_all
|
||||
from vllm.v1.engine import (
|
||||
@@ -80,6 +82,29 @@ class OmniSchedulerMixin:
|
||||
else None
|
||||
)
|
||||
self._latest_omni_connector_output = None
|
||||
# Optional per-stage pooling-output decoder hook (dotted path in
|
||||
# model_config); applied worker-side before IPC.
|
||||
self._pooling_output_decoder = None
|
||||
_decoder_path = getattr(model_config, "pooling_output_decoder", None)
|
||||
if _decoder_path:
|
||||
self._pooling_output_decoder = resolve_obj_by_qualname(str(_decoder_path))
|
||||
|
||||
def _maybe_decode_pooling_output(self, request: Request, pooler_output: Any) -> Any:
|
||||
"""Apply the stage's pooling-output decoder hook to the pooler tensor
|
||||
before IPC, or pass it through unchanged when none is configured.
|
||||
Decoder exceptions propagate; callers fail the request with
|
||||
FinishReason.ERROR rather than emitting an empty success."""
|
||||
if self._pooling_output_decoder is None:
|
||||
return pooler_output
|
||||
if pooler_output is None or getattr(request, "pooling_params", None) is None:
|
||||
return pooler_output
|
||||
if not isinstance(pooler_output, torch.Tensor):
|
||||
return pooler_output
|
||||
return self._pooling_output_decoder(
|
||||
pooler_output,
|
||||
request,
|
||||
self.vllm_config.model_config.hf_config,
|
||||
)
|
||||
|
||||
def _free_input_coordinator_request(self, request_id: str) -> None:
|
||||
"""Prune full-payload coordinator state for a completed request."""
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# 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.
|
||||
# Default deploy knobs for Qwen/Qwen3-ForcedAligner-0.6B, the TTS word-timestamp
|
||||
# aligner. These configure the pooling stage that --forced-aligner appends to
|
||||
# the pipeline tail (see utils.forced_aligner.inject_forced_aligner_stage); they are
|
||||
# NOT a standalone engine. Pass --forced-aligner-config to override this file;
|
||||
# --forced-aligner sets just the model path.
|
||||
#
|
||||
# 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).
|
||||
# The stage runs the model with runner="pooling" + 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
|
||||
|
||||
@@ -191,6 +191,8 @@ class OmniEngineArgs(EngineArgs):
|
||||
quantization_config: Any | None = None
|
||||
force_cutlass_fp8: bool | None = None
|
||||
worker_type: str | None = None
|
||||
# Dotted path of a per-stage pooling-output decoder applied worker-side.
|
||||
pooling_output_decoder: str | None = None
|
||||
task_type: str | None = None
|
||||
worker_cls: str = None # type: ignore[assignment] # Upstream default is "auto"; omni resolves
|
||||
# in __post_init__ based on worker_type (ar/generation), so None is safe here.
|
||||
@@ -380,6 +382,7 @@ class OmniEngineArgs(EngineArgs):
|
||||
model_stage=self.model_stage,
|
||||
model_arch=self.model_arch,
|
||||
worker_type=self.worker_type,
|
||||
pooling_output_decoder=self.pooling_output_decoder,
|
||||
engine_output_type=self.engine_output_type,
|
||||
hf_config_name=self.hf_config_name,
|
||||
custom_process_next_stage_input_func=self.custom_process_next_stage_input_func,
|
||||
@@ -475,6 +478,11 @@ class OrchestratorArgs:
|
||||
# === Mode Switches (orchestrator reads, DeployConfig redistributes) ===
|
||||
async_chunk: bool | None = None
|
||||
|
||||
# === Forced aligner (orchestrator injects a pooling stage; never a per-stage knob) ===
|
||||
forced_aligner: str | None = None
|
||||
forced_aligner_config: str | None = None
|
||||
forced_aligner_device: str | None = None
|
||||
|
||||
# === Observability ===
|
||||
log_stats: bool = False
|
||||
enable_orch_monitor: bool = False
|
||||
|
||||
@@ -1543,11 +1543,19 @@ class Orchestrator:
|
||||
return request
|
||||
|
||||
processor = self._get_stage_input_processor(next_stage_id)
|
||||
# A pooling stage is driven by PoolingParams; vLLM validates them against
|
||||
# the stage's supported tasks, so advertise the model's pooling tasks (or
|
||||
# the configured task) instead of generate.
|
||||
if isinstance(params, PoolingParams):
|
||||
model_cfg = next_pool.stage_vllm_config.model_config
|
||||
supported_tasks = tuple(getattr(model_cfg, "supported_tasks", ()) or ()) or (params.task,)
|
||||
else:
|
||||
supported_tasks = ("generate",)
|
||||
request = processor.process_inputs(
|
||||
request_id=req_id,
|
||||
prompt=next_input,
|
||||
params=params,
|
||||
supported_tasks=("generate",),
|
||||
supported_tasks=supported_tasks,
|
||||
arrival_time=_time.time(),
|
||||
resumable=resumable,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from dataclasses import dataclass, fields, replace
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from vllm.logger import init_logger
|
||||
from vllm.pooling_params import PoolingParams
|
||||
from vllm.renderers import BaseRenderer
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.tokenizers import cached_tokenizer_from_config
|
||||
@@ -395,8 +396,19 @@ def extract_legacy_stage_metadata(stage_config: Any) -> StageMetadata:
|
||||
final_output_type: str | None = stage_config.final_output_type
|
||||
|
||||
default_sp = _to_dict(_get_attr_or_item(stage_config, "default_sampling_params", {}))
|
||||
SPClass = SamplingParams if stage_type == "llm" else OmniDiffusionSamplingParams
|
||||
default_sampling_params: OmniSamplingParams = SPClass(**default_sp)
|
||||
# A pooling stage carries its task via default_pooling_params, set where the
|
||||
# stage is declared.
|
||||
default_pp = _to_dict(_get_attr_or_item(stage_config, "default_pooling_params", {}))
|
||||
# A pooling stage is an LLM stage run with runner="pooling" (vLLM's
|
||||
# is_pooling_model signal); pick params by that signal, not execution_type.
|
||||
is_pooling = str(engine_args.get("runner", "")).lower() == "pooling"
|
||||
default_params: OmniSamplingParams | PoolingParams
|
||||
if stage_type == "diffusion":
|
||||
default_params = OmniDiffusionSamplingParams(**default_sp)
|
||||
elif is_pooling:
|
||||
default_params = PoolingParams(**default_pp)
|
||||
else: # generative llm: ar / generation
|
||||
default_params = SamplingParams(**default_sp)
|
||||
|
||||
custom_process_input_func: Callable | None = None
|
||||
_cpif_path = _get_attr_or_item(stage_config, "custom_process_input_func")
|
||||
@@ -428,7 +440,7 @@ def extract_legacy_stage_metadata(stage_config: Any) -> StageMetadata:
|
||||
engine_input_source=engine_input_source,
|
||||
final_output=final_output,
|
||||
final_output_type=final_output_type,
|
||||
default_sampling_params=default_sampling_params,
|
||||
default_sampling_params=default_params,
|
||||
custom_process_input_func=custom_process_input_func,
|
||||
model_stage=model_stage,
|
||||
runtime_cfg=runtime_cfg,
|
||||
@@ -448,7 +460,7 @@ def extract_legacy_stage_metadata(stage_config: Any) -> StageMetadata:
|
||||
engine_input_source=engine_input_source,
|
||||
final_output=final_output,
|
||||
final_output_type=final_output_type,
|
||||
default_sampling_params=default_sampling_params,
|
||||
default_sampling_params=default_params,
|
||||
custom_process_input_func=custom_process_input_func,
|
||||
model_stage=model_stage,
|
||||
runtime_cfg=runtime_cfg,
|
||||
|
||||
@@ -237,9 +237,8 @@ class OmniServeCommand(CLISubcommand):
|
||||
"VoiceDesign, or Base, while diffusion models may use it to select "
|
||||
"task-specific weights. If omitted, the model default is used.",
|
||||
)
|
||||
# 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.
|
||||
# Forced aligner / word timestamps. Either flag opts in (--forced-aligner-config
|
||||
# alone works when its YAML sets the model); heavier knobs live in that YAML.
|
||||
omni_config_group.add_argument(
|
||||
"--forced-aligner",
|
||||
type=str,
|
||||
@@ -260,6 +259,13 @@ class OmniServeCommand(CLISubcommand):
|
||||
"flag, when set, overrides the YAML model field."
|
||||
),
|
||||
)
|
||||
omni_config_group.add_argument(
|
||||
"--forced-aligner-device",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Device(s) for the forced-aligner stage (e.g. '2'). Defaults to "
|
||||
"sharing an existing stage's GPU when unset.",
|
||||
)
|
||||
omni_config_group.add_argument(
|
||||
"--deploy-config",
|
||||
type=str,
|
||||
|
||||
@@ -1127,7 +1127,11 @@ async def omni_init_app_state(
|
||||
state.openai_serving_models,
|
||||
request_logger=request_logger,
|
||||
model_name=model_name,
|
||||
forced_aligner_config=build_forced_aligner_config(args),
|
||||
forced_aligner_enabled=build_forced_aligner_config(
|
||||
getattr(args, "forced_aligner", None),
|
||||
getattr(args, "forced_aligner_config", None),
|
||||
)
|
||||
is not None,
|
||||
)
|
||||
|
||||
# Warm up speech pipeline (CUDA Graph capture, torch.compile) so the first
|
||||
|
||||
@@ -161,10 +161,12 @@ class OpenAICreateSpeechRequest(BaseModel):
|
||||
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."
|
||||
"When true, non-streaming responses carry per-word timestamps in the "
|
||||
"X-Word-Timestamps header (JSON list of {word, start_ms, end_ms}, "
|
||||
"ASCII-escaped; replaced by X-Word-Timestamps-Omitted past 4 KB). "
|
||||
"Requires the server to be launched with --forced-aligner (400 otherwise). "
|
||||
"Not supported with stream=true; for streaming use the WebSocket "
|
||||
"/v1/audio/speech/stream path."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -423,7 +423,9 @@ 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)
|
||||
# True when the server was launched with --forced-aligner (a pooling
|
||||
# aligner stage is appended to the pipeline). Gates word_timestamps.
|
||||
self.forced_aligner_enabled: bool = bool(kwargs.pop("forced_aligner_enabled", False))
|
||||
super().__init__(*args, **kwargs)
|
||||
self._init_speaker_storage()
|
||||
|
||||
@@ -2186,6 +2188,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
request_start_s: float | None = None,
|
||||
include_sample_rate: bool = False,
|
||||
usage_acc: SpeechOutputTokenCounter | None = None,
|
||||
collect: dict | None = None,
|
||||
):
|
||||
"""Generate audio chunks for streaming response.
|
||||
|
||||
@@ -2218,6 +2221,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
usage_acc.observe(res)
|
||||
audio_output, audio_key = self._extract_audio_output(res)
|
||||
if audio_key is None:
|
||||
# Stash the aligner's timestamps output for streaming callers.
|
||||
if collect is not None and self._is_timestamps_output(res):
|
||||
collect["aligner_res"] = res
|
||||
continue
|
||||
|
||||
sr_raw = audio_output.get("sr")
|
||||
@@ -2406,6 +2412,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
data = json.dumps(payload, separators=(",", ":"))
|
||||
yield f"event: speech.audio.error\ndata: {data}\n\n"
|
||||
|
||||
@staticmethod
|
||||
def _is_timestamps_output(res) -> bool:
|
||||
"""True when ``res`` is the forced-aligner stage's terminal timestamps output."""
|
||||
from vllm_omni.model_executor.stage_input_processors.forced_aligner import TIMESTAMPS_MODALITY
|
||||
|
||||
return getattr(res, "final_output_type", None) == TIMESTAMPS_MODALITY
|
||||
|
||||
@staticmethod
|
||||
def _extract_audio_output(res) -> tuple[dict | None, str | None]:
|
||||
"""Return (audio_output dict, audio key) or (None, None).
|
||||
@@ -3332,11 +3345,20 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
stage0_params.extra_args = {}
|
||||
stage0_params.extra_args.setdefault("tts_local_seed", int(default_seed))
|
||||
|
||||
# When word_timestamps is requested, also ask for the aligner stage's
|
||||
# output so the orchestrator drives the request through the forced-aligner
|
||||
# stage (final_stage_id extends to it). Harmless if no aligner stage exists.
|
||||
output_modalities = ["audio"]
|
||||
if getattr(request, "word_timestamps", False):
|
||||
from vllm_omni.model_executor.stage_input_processors.forced_aligner import TIMESTAMPS_MODALITY
|
||||
|
||||
output_modalities.append(TIMESTAMPS_MODALITY)
|
||||
|
||||
generator = self.engine_client.generate(
|
||||
prompt=prompt,
|
||||
request_id=request_id,
|
||||
sampling_params_list=sampling_params_list,
|
||||
output_modalities=["audio"],
|
||||
output_modalities=output_modalities,
|
||||
)
|
||||
self._track_ref_audio_artifact_warmup(
|
||||
request_id,
|
||||
@@ -3345,17 +3367,22 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
)
|
||||
return request_id, generator, tts_params
|
||||
|
||||
async def _generate_pcm_chunks(self, generator, request_id: str, *, include_sample_rate: bool = False):
|
||||
async def _generate_pcm_chunks(
|
||||
self, generator, request_id: str, *, include_sample_rate: bool = False, collect: dict | None = None
|
||||
):
|
||||
"""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``.
|
||||
``collect`` (when given) receives the forced-aligner stage's pooling
|
||||
output under ``"aligner_res"`` for downstream word-timestamp extraction.
|
||||
"""
|
||||
async for chunk in self._generate_audio_chunks(
|
||||
generator,
|
||||
request_id,
|
||||
response_format="pcm",
|
||||
include_sample_rate=include_sample_rate,
|
||||
collect=collect,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
@@ -3375,6 +3402,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
request_id: str | None = None,
|
||||
usage_out: list[SpeechTokenUsage] | None = None,
|
||||
has_inline_ref_audio: bool | None = None,
|
||||
collect: dict | None = None,
|
||||
) -> tuple[bytes | str, str]:
|
||||
# ``usage_out`` is an opt-in output channel: when a list is passed, the
|
||||
# computed SpeechTokenUsage is appended to it. The return stays a
|
||||
@@ -3398,9 +3426,20 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
# Non-streaming is FINAL_ONLY, so the stage-0 output carries the full
|
||||
# token sequence; the counter records its length for output_tokens.
|
||||
usage_acc = SpeechOutputTokenCounter()
|
||||
audio_res: OmniRequestOutput | None = None
|
||||
aligner_res: OmniRequestOutput | None = None
|
||||
async for res in generator:
|
||||
final_output = res
|
||||
usage_acc.observe(res)
|
||||
# The generator yields both the audio output (Code2Wav) and, with
|
||||
# a forced-aligner stage, a timestamps output. Keep the audio res
|
||||
# for the WAV and the aligner res for word timestamps.
|
||||
if self._is_timestamps_output(res):
|
||||
aligner_res = res
|
||||
else:
|
||||
_, audio_key = self._extract_audio_output(res)
|
||||
if audio_key is not None:
|
||||
audio_res = res
|
||||
if not is_moss:
|
||||
continue
|
||||
try:
|
||||
@@ -3422,10 +3461,26 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
if final_output is None:
|
||||
raise ValueError("No output generated from the model.")
|
||||
|
||||
audio_output, audio_key = self._extract_audio_output(final_output)
|
||||
# Extract audio from the audio-bearing res (not necessarily the last
|
||||
# yielded one, which may be the aligner's timestamps output).
|
||||
audio_source = audio_res if audio_res is not None else final_output
|
||||
audio_output, audio_key = self._extract_audio_output(audio_source)
|
||||
if audio_key is None:
|
||||
raise ValueError("TTS model did not produce audio output.")
|
||||
|
||||
# Surface forced-aligner word timestamps to the caller (set as a
|
||||
# response header) when requested and an aligner stage produced them.
|
||||
if collect is not None and getattr(request, "word_timestamps", False):
|
||||
from vllm_omni.utils.forced_aligner import extract_word_timestamps
|
||||
|
||||
ts = (
|
||||
extract_word_timestamps(aligner_res, request.input, getattr(request, "language", None))
|
||||
if aligner_res is not None
|
||||
else None
|
||||
)
|
||||
if ts is not None:
|
||||
collect["word_timestamps"] = ts
|
||||
|
||||
audio_tensor = audio_output[audio_key]
|
||||
sr_raw = audio_output.get("sr", 24000)
|
||||
sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw
|
||||
@@ -3695,6 +3750,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
"/v1/audio/speech/stream path. Use session.config with "
|
||||
"stream_audio=true and response_format='pcm'."
|
||||
)
|
||||
if request.word_timestamps and not self.forced_aligner_enabled:
|
||||
# Fail loud instead of silently returning 200 with no timestamps header.
|
||||
return self.create_error_response(
|
||||
"word_timestamps=true requires the server to be launched with --forced-aligner."
|
||||
)
|
||||
|
||||
if request.is_raw_audio_stream():
|
||||
response_format, error = self._validate_speech_streaming_request(
|
||||
@@ -3739,7 +3799,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
audio_bytes, media_type = await self._generate_audio_bytes(request, request_id=request_id)
|
||||
collect: dict = {}
|
||||
audio_bytes, media_type = await self._generate_audio_bytes(request, request_id=request_id, collect=collect)
|
||||
total_ms = (time.perf_counter() - request_start_s) * 1000.0
|
||||
logger.info(
|
||||
"[SpeechE2E] request_id=%s stream=false status=ok total_ms=%.2f response_bytes=%d",
|
||||
@@ -3747,7 +3808,22 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
total_ms,
|
||||
len(audio_bytes) if isinstance(audio_bytes, (bytes, bytearray)) else len(str(audio_bytes)),
|
||||
)
|
||||
return Response(content=audio_bytes, media_type=media_type)
|
||||
headers = {}
|
||||
if collect.get("word_timestamps") is not None:
|
||||
# Default ensure_ascii keeps the header latin-1 encodable (non-ASCII words \uXXXX-escaped).
|
||||
ts_json = json.dumps(collect["word_timestamps"])
|
||||
# Cap at 4 KB: oversized headers turn into opaque 502s at common reverse-proxy defaults.
|
||||
if len(ts_json) <= 4096:
|
||||
headers["X-Word-Timestamps"] = ts_json
|
||||
else:
|
||||
# Marker header so clients can tell an oversized alignment from no alignment.
|
||||
headers["X-Word-Timestamps-Omitted"] = f"oversize; bytes={len(ts_json)}; limit=4096"
|
||||
logger.warning(
|
||||
"X-Word-Timestamps header omitted: %d bytes exceeds the 4 KB budget "
|
||||
"(use the WebSocket streaming path for long transcripts)",
|
||||
len(ts_json),
|
||||
)
|
||||
return Response(content=audio_bytes, media_type=media_type, headers=headers)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
total_ms = (time.perf_counter() - request_start_s) * 1000.0
|
||||
|
||||
@@ -61,8 +61,7 @@ from vllm_omni.entrypoints.openai.protocol.audio import (
|
||||
StreamingSpeechSessionConfig,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
|
||||
from vllm_omni.utils.forced_aligner import ForcedAlignerLoadError
|
||||
from vllm_omni.utils.forced_aligner import align as forced_align
|
||||
from vllm_omni.utils.forced_aligner import extract_word_timestamps
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
@@ -273,7 +272,7 @@ class OmniStreamingSpeechHandler:
|
||||
|
||||
# Reject unmet word-timestamps preconditions early with a clear reason.
|
||||
if config.word_timestamps:
|
||||
if self._speech_service.forced_aligner_config is None:
|
||||
if not self._speech_service.forced_aligner_enabled:
|
||||
await self._send_error(
|
||||
websocket,
|
||||
"word_timestamps=true but the server was launched without "
|
||||
@@ -285,7 +284,8 @@ class OmniStreamingSpeechHandler:
|
||||
await self._send_error(
|
||||
websocket,
|
||||
"word_timestamps=true requires stream_audio=true and "
|
||||
"response_format='pcm' (the aligner consumes raw PCM).",
|
||||
"response_format='pcm' (timestamps ride the per-sentence "
|
||||
"PCM audio.chunk stream).",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -395,19 +395,20 @@ class OmniStreamingSpeechHandler:
|
||||
) -> 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).
|
||||
Forward each PCM chunk live (``timestamps: null``). The forced-aligner
|
||||
pipeline stage (appended when the server is launched with
|
||||
``--forced-aligner``) consumes the synthesized audio internally and its
|
||||
pooling output rides the same generator, so once the audio finishes we
|
||||
pull the word timestamps straight off that aligner output and emit a
|
||||
final empty-audio ``audio.chunk`` carrying them. Timestamps is ``null``
|
||||
when the aligner produced none; 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()
|
||||
audio_bytes_seen = 0
|
||||
total_bytes = 0
|
||||
sample_rate = _PCM_SAMPLE_RATE
|
||||
chunk_id = 0
|
||||
# Receives the aligner stage's pooling output from the generator.
|
||||
collect: dict = {}
|
||||
|
||||
async def send_chunk(
|
||||
chunk: bytes,
|
||||
@@ -437,62 +438,30 @@ class OmniStreamingSpeechHandler:
|
||||
generator,
|
||||
request_id,
|
||||
include_sample_rate=True,
|
||||
collect=collect,
|
||||
)
|
||||
) 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))
|
||||
chunk_start_ms = int(round((audio_bytes_seen / _BYTES_PER_SAMPLE / sample_rate) * 1000.0))
|
||||
audio_bytes_seen += len(chunk)
|
||||
chunk_end_ms = int(round((audio_bytes_seen / _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))
|
||||
# Pull word timestamps off the aligner stage's pooling output (it rode
|
||||
# the same generator); extract_word_timestamps re-segments the sentence
|
||||
# text for the word strings when the aligner output doesn't carry them.
|
||||
aligner_res = collect.get("aligner_res")
|
||||
timestamps_payload = (
|
||||
extract_word_timestamps(aligner_res, sentence_text, language) if aligner_res is not None else None
|
||||
)
|
||||
sentence_end_ms = int(round((audio_bytes_seen / _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."""
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""stage input processor for a forced-aligner stage.
|
||||
|
||||
Bridges a Code2Wav (audio) stage to a pooling forced-aligner stage:
|
||||
|
||||
Talker (LLM_AR) -> Code2Wav (LLM_GENERATION) -> ForcedAligner (LLM_AR + runner=pooling)
|
||||
|
||||
``code2wav2aligner`` is a synchronous ``custom_process_input_func`` (the same
|
||||
shape as ``qwen3_tts.talker2code2wav``). It runs in the orchestrator process,
|
||||
reads the synthesized waveform from the upstream Code2Wav output and the
|
||||
ground-truth text from the original request, then builds the aligner prompt
|
||||
(``<timestamp>`` markers per word) + audio multimodal input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.model_executor.stage_input_processors.tts_utils import (
|
||||
extract_language_from_prompt,
|
||||
)
|
||||
from vllm_omni.utils.qwen3_force_align_processor import build_prompt, segment_words
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Shared keys / names for the forced-aligner stage payloads, so the producer
|
||||
# (this processor), the worker-side decode, and the serving consumer don't
|
||||
# duplicate magic strings.
|
||||
ALIGNER_WORDS_KEY = "aligner_words"
|
||||
ALIGNER_DURATION_MS_KEY = "aligner_audio_duration_ms"
|
||||
TIMESTAMPS_MODALITY = "timestamps"
|
||||
# Dotted path of the pooling-output decoder hook (read by the AR scheduler for
|
||||
# a stage whose engine_args set ``pooling_output_decoder``).
|
||||
POOLING_OUTPUT_DECODER_PATH = "vllm_omni.model_executor.stage_input_processors.forced_aligner.decode_pooling_output"
|
||||
|
||||
|
||||
def decode_pooling_output(logits: Any, request: Any, hf_config: Any) -> Any:
|
||||
"""Worker-side decoder for the forced-aligner pooling stage.
|
||||
|
||||
Converts the aligner's ``[n_token, classify_num]`` logits into a bare
|
||||
``[n_words, 2]`` int32 tensor of per-word ``[start_ms, end_ms]`` (word
|
||||
strings travel separately via ``additional_information``).
|
||||
"""
|
||||
from vllm_omni.engine.serialization import deserialize_additional_information
|
||||
from vllm_omni.utils.forced_aligner import _decode_timestamps
|
||||
|
||||
ts_id = int(getattr(hf_config, "timestamp_token_id"))
|
||||
thinker_cfg = getattr(hf_config, "thinker_config", hf_config)
|
||||
classify_num = int(getattr(thinker_cfg, "classify_num"))
|
||||
seg_ms = float(getattr(hf_config, "timestamp_segment_time"))
|
||||
|
||||
prompt_token_ids = list(getattr(request, "prompt_token_ids", []) or [])
|
||||
positions = [i for i, t in enumerate(prompt_token_ids) if t == ts_id]
|
||||
|
||||
info = deserialize_additional_information(getattr(request, "additional_information", None)) or {}
|
||||
words_field = info.get(ALIGNER_WORDS_KEY)
|
||||
words = words_field[0] if isinstance(words_field, list) and words_field else []
|
||||
|
||||
# Real waveform duration (stashed by code2wav2aligner) drives the decode clamp; fall back to the
|
||||
# theoretical ceiling (clamp no-op) if absent.
|
||||
duration_field = info.get(ALIGNER_DURATION_MS_KEY)
|
||||
duration_ms = (
|
||||
float(duration_field[0])
|
||||
if isinstance(duration_field, list) and duration_field
|
||||
else float(classify_num) * seg_ms
|
||||
)
|
||||
|
||||
timestamps = _decode_timestamps(
|
||||
logits=logits,
|
||||
words=list(words),
|
||||
timestamp_positions=positions,
|
||||
classify_num=classify_num,
|
||||
timestamp_segment_time_ms=seg_ms,
|
||||
audio_duration_ms=duration_ms,
|
||||
)
|
||||
# No failure sentinel: an empty [0, 2] tensor decodes downstream to timestamps: null.
|
||||
ms = torch.tensor(
|
||||
[[t.start_ms, t.end_ms] for t in timestamps],
|
||||
dtype=torch.int32,
|
||||
).reshape(-1, 2)
|
||||
return ms
|
||||
|
||||
|
||||
def _extract_waveform(multimodal_output: dict[str, Any]) -> tuple[np.ndarray, int]:
|
||||
"""Pull a mono float32 waveform + sample rate from a Code2Wav output."""
|
||||
wav = multimodal_output.get("audio")
|
||||
if wav is None:
|
||||
wav = multimodal_output.get("model_outputs")
|
||||
if isinstance(wav, list):
|
||||
wav = wav[0] if wav else None
|
||||
if isinstance(wav, torch.Tensor):
|
||||
wav_np = wav.detach().cpu().to(torch.float32).flatten().numpy()
|
||||
elif isinstance(wav, np.ndarray):
|
||||
wav_np = wav.astype(np.float32).flatten()
|
||||
else:
|
||||
wav_np = np.zeros(0, dtype=np.float32)
|
||||
|
||||
sr_raw = multimodal_output.get("sr")
|
||||
if isinstance(sr_raw, list):
|
||||
sr_raw = sr_raw[-1] if sr_raw else None
|
||||
if isinstance(sr_raw, torch.Tensor):
|
||||
sr = int(sr_raw.reshape(-1)[-1].item()) if sr_raw.numel() > 0 else 24000
|
||||
elif sr_raw is None:
|
||||
sr = 24000
|
||||
else:
|
||||
sr = int(sr_raw)
|
||||
return wav_np, sr
|
||||
|
||||
|
||||
def _extract_text(prompt: Any, index: int = 0) -> str:
|
||||
"""Read the ground-truth text the aligner needs from the original prompt."""
|
||||
if not isinstance(prompt, dict):
|
||||
return ""
|
||||
text = (prompt.get("additional_information") or {}).get("text")
|
||||
if isinstance(text, list):
|
||||
return str(text[index]) if index < len(text) else ""
|
||||
return str(text) if text else ""
|
||||
|
||||
|
||||
def code2wav2aligner(
|
||||
source_outputs: list[Any],
|
||||
prompt: Any = None,
|
||||
_requires_multimodal_data: bool = False,
|
||||
_streaming_context: Any | None = None,
|
||||
) -> list[Any]:
|
||||
"""Build forced-aligner stage inputs from finished Code2Wav audio.
|
||||
|
||||
Emits a **text prompt** (``prompt`` string + ``multi_modal_data`` audio).
|
||||
Tokenization and audio feature extraction are deferred to the aligner
|
||||
stage's input preprocessor (run by the orchestrator), so this function
|
||||
needs neither the aligner tokenizer nor its mm processor and stays
|
||||
unit-testable on CPU.
|
||||
"""
|
||||
from vllm_omni.inputs.data import OmniTextPrompt
|
||||
|
||||
aligner_inputs: list[OmniTextPrompt] = []
|
||||
for i, src in enumerate(source_outputs):
|
||||
if not getattr(src, "finished", False):
|
||||
# Sentence-final alignment: only run once the audio is complete.
|
||||
continue
|
||||
mm = src.outputs[0].multimodal_output or {}
|
||||
wav_np, sample_rate = _extract_waveform(mm)
|
||||
if wav_np.size == 0:
|
||||
logger.warning("code2wav2aligner: empty waveform for request; skipping alignment.")
|
||||
continue
|
||||
|
||||
text = _extract_text(prompt, index=i)
|
||||
language_list = extract_language_from_prompt(prompt, index=i)
|
||||
language = language_list[0] if language_list else None
|
||||
words = segment_words(text, language)
|
||||
prompt_str = build_prompt(words)
|
||||
|
||||
additional_information: dict[str, Any] = {
|
||||
"text": [text],
|
||||
ALIGNER_WORDS_KEY: [words],
|
||||
ALIGNER_DURATION_MS_KEY: [float(wav_np.size) / float(sample_rate) * 1000.0],
|
||||
}
|
||||
if language_list is not None:
|
||||
additional_information["language"] = language_list
|
||||
|
||||
aligner_inputs.append(
|
||||
OmniTextPrompt(
|
||||
prompt=prompt_str,
|
||||
multi_modal_data={"audio": (wav_np, sample_rate)},
|
||||
additional_information=additional_information,
|
||||
)
|
||||
)
|
||||
return aligner_inputs
|
||||
+118
-257
@@ -1,30 +1,29 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Shared forced aligner for streaming TTS word timestamps.
|
||||
"""Forced-aligner config + word-timestamp decoding for TTS.
|
||||
|
||||
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.
|
||||
The aligner runs as a pooling stage appended to the pipeline by
|
||||
``--forced-aligner`` (see :func:`inject_forced_aligner_stage`). Qwen-specific
|
||||
word segmentation / prompt building / marker repair live in
|
||||
:mod:`vllm_omni.utils.qwen3_force_align_processor`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
from vllm_omni.utils import qwen3_force_align_processor as _processor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.config.stage_config import DeployConfig, PipelineConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[1] / "deploy" / "qwen3_tts_forced_aligner.yaml"
|
||||
|
||||
@@ -42,7 +41,8 @@ class WordTimestamp:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForcedAlignerConfig:
|
||||
"""Plain-data config for constructing the aligner ``vllm.LLM`` (lazy-loaded)."""
|
||||
"""Plain-data config for the forced-aligner pipeline stage; fields are
|
||||
consumed by :func:`inject_forced_aligner_stage`."""
|
||||
|
||||
model: str
|
||||
runner: str | None = None
|
||||
@@ -52,7 +52,6 @@ class ForcedAlignerConfig:
|
||||
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]:
|
||||
@@ -61,8 +60,8 @@ def _load_forced_aligner_yaml(path: str | Path) -> dict[str, Any]:
|
||||
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.
|
||||
def build_forced_aligner_config(model: str | None, config_path: str | None = None) -> ForcedAlignerConfig | None:
|
||||
"""Build a config from the CLI values, 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
|
||||
@@ -70,254 +69,94 @@ def build_forced_aligner_config(args: Any) -> ForcedAlignerConfig | None:
|
||||
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:
|
||||
if not config_data.get("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``.
|
||||
def inject_forced_aligner_stage(
|
||||
pipeline: PipelineConfig,
|
||||
deploy: DeployConfig,
|
||||
cli_overrides: dict[str, Any],
|
||||
) -> tuple[PipelineConfig, DeployConfig]:
|
||||
"""Append a forced-aligner pooling stage to the pipeline tail when
|
||||
``--forced-aligner`` or ``--forced-aligner-config`` resolves an aligner
|
||||
model; no-op otherwise. The stage runs the aligner model with
|
||||
``runner="pooling"``, consuming the previous stage's audio and emitting a
|
||||
terminal word-timestamps side-output.
|
||||
"""
|
||||
if not (cli_overrides.get("forced_aligner") or cli_overrides.get("forced_aligner_config")):
|
||||
return pipeline, deploy
|
||||
|
||||
from vllm_omni.config.stage_config import (
|
||||
StageDeployConfig,
|
||||
StageExecutionType,
|
||||
StagePipelineConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.stage_input_processors.forced_aligner import (
|
||||
POOLING_OUTPUT_DECODER_PATH,
|
||||
TIMESTAMPS_MODALITY,
|
||||
)
|
||||
|
||||
# --- 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
|
||||
fa = build_forced_aligner_config(
|
||||
cli_overrides.get("forced_aligner"),
|
||||
cli_overrides.get("forced_aligner_config"),
|
||||
)
|
||||
if fa is None:
|
||||
return pipeline, deploy
|
||||
|
||||
_FA = "vllm_omni.model_executor.stage_input_processors.forced_aligner"
|
||||
new_id = len(pipeline.stages)
|
||||
aligner_ps = StagePipelineConfig(
|
||||
stage_id=new_id,
|
||||
model_stage="forced_aligner",
|
||||
# Pooling stage = LLM_AR stage run with runner="pooling" (vLLM derives
|
||||
# is_pooling_model from runner_type).
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(new_id - 1,),
|
||||
final_output=True,
|
||||
final_output_type=TIMESTAMPS_MODALITY,
|
||||
owns_tokenizer=True,
|
||||
requires_multimodal_data=True,
|
||||
model_arch=fa.architecture,
|
||||
custom_process_input_func=f"{_FA}.code2wav2aligner",
|
||||
)
|
||||
extended = replace(pipeline, stages=pipeline.stages + (aligner_ps,))
|
||||
|
||||
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)},
|
||||
engine_extras: dict[str, Any] = {
|
||||
"model": fa.model,
|
||||
"runner": fa.runner or "pooling",
|
||||
# Worker-side decoder hook: pooler logits -> word-timestamps tensor.
|
||||
"pooling_output_decoder": POOLING_OUTPUT_DECODER_PATH,
|
||||
}
|
||||
|
||||
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 fa.architecture:
|
||||
engine_extras["hf_overrides"] = {"architectures": [fa.architecture]}
|
||||
if fa.trust_remote_code is not None:
|
||||
engine_extras["trust_remote_code"] = fa.trust_remote_code
|
||||
if fa.dtype is not None:
|
||||
engine_extras["dtype"] = fa.dtype
|
||||
aligner_ds = StageDeployConfig(
|
||||
stage_id=new_id,
|
||||
devices=cli_overrides.get("forced_aligner_device"),
|
||||
gpu_memory_utilization=fa.gpu_memory_utilization,
|
||||
max_model_len=fa.max_model_len,
|
||||
engine_extras=engine_extras,
|
||||
default_pooling_params={"task": fa.pooling_task or "token_classify"},
|
||||
# Pooling is one-shot -> sync AR scheduler (the async one assumes
|
||||
# token-by-token generation).
|
||||
async_scheduling=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()
|
||||
deploy = replace(deploy, stages=list(deploy.stages) + [aligner_ds])
|
||||
logger.info("[stage_init] Injected forced-aligner stage %d (model=%s)", new_id, fa.model)
|
||||
return extended, deploy
|
||||
|
||||
|
||||
def _decode_timestamps(
|
||||
@@ -382,18 +221,40 @@ def _decode_timestamps(
|
||||
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 extract_word_timestamps(res: Any, text: str, language: str | None = None) -> list[dict] | None:
|
||||
"""Read a forced-aligner stage's engine output into per-word timestamps.
|
||||
|
||||
Returns ``[{word, start_ms, end_ms}, ...]`` from the stage's pooling output
|
||||
(``res.outputs.data`` is an int32 ``[n_words, 2]`` tensor). Words come from
|
||||
re-segmenting ``text``; any mismatch or non-aligner input returns ``None``.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
outs = getattr(res, "outputs", None)
|
||||
if outs is None:
|
||||
return None
|
||||
# The aligner stage's res.outputs is a single PoolingOutput (not a list).
|
||||
# Its .data is a [n_words, 2] int32 tensor of [start_ms, end_ms] per word.
|
||||
out0 = outs[0] if isinstance(outs, (list, tuple)) else outs
|
||||
ms = getattr(out0, "data", None)
|
||||
if ms is None or not hasattr(ms, "tolist"):
|
||||
return None
|
||||
ms_list = ms.tolist()
|
||||
if not ms_list or not isinstance(ms_list[0], (list, tuple)):
|
||||
return None
|
||||
|
||||
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()
|
||||
words = _processor.segment_words(text, language)
|
||||
if len(words) != len(ms_list):
|
||||
# Segmentation drifted from the engine side; pairing positionally would mislabel every word.
|
||||
logger.warning(
|
||||
"word/timestamp count mismatch (%d words vs %d intervals); returning no timestamps",
|
||||
len(words),
|
||||
len(ms_list),
|
||||
)
|
||||
return None
|
||||
|
||||
return [{"word": w, "start_ms": int(p[0]), "end_ms": int(p[1])} for w, p in zip(words, ms_list)]
|
||||
except Exception:
|
||||
logger.debug("failed to extract word timestamps from aligner output", exc_info=True)
|
||||
return None
|
||||
@@ -12,15 +12,15 @@
|
||||
"""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.
|
||||
it turns text into the aligner's word units and prompt, and repairs the
|
||||
predicted timestamp bins. It feeds the forced-aligner pipeline stage (input
|
||||
glue in :mod:`vllm_omni.model_executor.stage_input_processors.forced_aligner`,
|
||||
timestamp decoding in :mod:`vllm_omni.utils.forced_aligner`).
|
||||
|
||||
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`.
|
||||
:func:`build_prompt`, :func:`fix_timestamp`.
|
||||
|
||||
Word segmentation prefers qwen_asr's official ``Qwen3ForceAlignProcessor``
|
||||
when installed (full multilingual fidelity, incl. Japanese/Korean) and
|
||||
@@ -260,26 +260,3 @@ def fix_timestamp(values: list[int]) -> list[int]:
|
||||
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
|
||||
@@ -1360,7 +1360,12 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
hidden_states = model_output
|
||||
aux_hidden_states = None
|
||||
|
||||
hidden_states, multimodal_outputs = self.extract_multimodal_outputs(model_output)
|
||||
# A pooling stage skips mm extraction + the omni prefix cache (both assume an AR
|
||||
# [n_tok, hidden] output) and exits through the shared _pool() branch below.
|
||||
if self.is_pooling_model:
|
||||
multimodal_outputs = None
|
||||
else:
|
||||
hidden_states, multimodal_outputs = self.extract_multimodal_outputs(model_output)
|
||||
hidden_states_cpu = None
|
||||
|
||||
# Async-write pipeline (replaces the per-step blocking
|
||||
@@ -1369,7 +1374,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin):
|
||||
# the actual CPU scatter into ``hidden_states_cache`` /
|
||||
# ``mm_outputs_cache`` happens in ``drain_ready_async_writes``
|
||||
# at the top of subsequent execute_model() calls.
|
||||
if self.omni_prefix_cache is not None and get_pp_group().is_last_rank:
|
||||
if not self.is_pooling_model and self.omni_prefix_cache is not None and get_pp_group().is_last_rank:
|
||||
hs_for_cache = hidden_states if self._model_needs_full_prefix_hidden_states() else None
|
||||
# Some models (e.g. qwen3-tts-talker) opt out of full-hidden-state
|
||||
# prefix caching but the downstream pooler payload path still
|
||||
|
||||
Reference in new issue
Block a user