[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:
authored and GitHub committed 2026-08-12 05:33:34 +00:00
1 parent a15b5b4bcd
commit 9b0df3909a
28 files changed
+850 -627

No files matched your search

@@ -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]]
+3 -23
View File
@@ -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"])