Compare commits

...
Author SHA1 Message Date
Sy03 6ffdf1a8f1 [Bugfix][Qwen3-TTS] Handle codec generations that exhaust their token budget
Signed-off-by: Sy03 <1370724210@qq.com>
2026-08-29 14:50:42 +08:00
14 changed files with 551 additions and 43 deletions

No files matched your search

+14 -5
View File
@@ -18,11 +18,11 @@ examples in this repository.
Qwen3-TTS supports three task types, each backed by a dedicated model checkpoint: Qwen3-TTS supports three task types, each backed by a dedicated model checkpoint:
| Task Type | Model | Description | | Task Type | Model | Description |
| ------------- | ---------------------------------------- | ------------------------------------------------------------ | | ------------- | ---------------------------------------- | ------------------------------------------------------------- |
| `CustomVoice` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | Predefined speaker voices with optional style/emotion control | | `CustomVoice` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | Predefined speaker voices with optional style/emotion control |
| `VoiceDesign` | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | Generate speech from a natural language voice description | | `VoiceDesign` | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | Generate speech from a natural language voice description |
| `Base` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | Voice cloning from reference audio + transcript | | `Base` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | Voice cloning from reference audio + transcript |
Smaller 0.6B variants are also available for `CustomVoice` and `Base`. Smaller 0.6B variants are also available for `CustomVoice` and `Base`.
@@ -166,6 +166,15 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py --query-ty
- Key flags: `--omni` is required. `--deploy-config` points to the bundled two-stage pipeline config. - Key flags: `--omni` is required. `--deploy-config` points to the bundled two-stage pipeline config.
- Async chunking: Enabled by default in `qwen3_tts.yaml` for streaming-friendly first-audio latency. Raw audio streaming requires `stream=true`, `stream_format="audio"`, and `response_format="pcm"`. - Async chunking: Enabled by default in `qwen3_tts.yaml` for streaming-friendly first-audio latency. Raw audio streaming requires `stream=true`, `stream_format="audio"`, and `response_format="pcm"`.
- Task/model matching: Each task type requires its matching model checkpoint. Using a CustomVoice model for a Base (voice clone) request will fail. - Task/model matching: Each task type requires its matching model checkpoint. Using a CustomVoice model for a Base (voice clone) request will fail.
- Base codec termination: Base requests without an explicit `max_new_tokens`
use a text-scaled safety ceiling (at least 192 codec frames and no more than
the configured model limit). If the Talker reaches that ceiling without
codec EOS, non-streaming serving discards the incomplete audio and retries
once with a fresh seed; an explicit `seed` or `max_new_tokens` disables the
retry. SSE and WebSocket clients receive structured errors and must discard
previously emitted audio when the error contains `"action":"discard"`;
raw PCM streams terminate with a connection error after any partial bytes
already sent.
- Known limitations: The server serves one model variant at a time. To switch task types (e.g., CustomVoice to Base), restart the server with the corresponding model. - Known limitations: The server serves one model variant at a time. To switch task types (e.g., CustomVoice to Base), restart the server with the corresponding model.
## Hardware Support ## Hardware Support
+7 -1
View File
@@ -2216,7 +2216,12 @@ def test_stage_pool_metrics_use_resumable_segment_token_count() -> None:
) )
output = SimpleNamespace( output = SimpleNamespace(
request_id="req-stream", request_id="req-stream",
outputs=[SimpleNamespace(cumulative_token_ids=list(range(11)))], outputs=[
SimpleNamespace(
cumulative_token_ids=list(range(11)),
finish_reason=FinishReason.LENGTH,
)
],
) )
metrics = pool.build_stage_metrics( metrics = pool.build_stage_metrics(
@@ -2228,6 +2233,7 @@ def test_stage_pool_metrics_use_resumable_segment_token_count() -> None:
assert metrics.num_tokens_out == 3 assert metrics.num_tokens_out == 3
assert metrics.output_unit_count == 3 assert metrics.output_unit_count == 3
assert metrics.finish_reason == "length"
def test_image_ttfo_preserves_request_time_and_tracks_stage_time() -> None: def test_image_ttfo_preserves_request_time_and_tracks_stage_time() -> None:
@@ -45,9 +45,15 @@ from vllm_omni.entrypoints.openai.serving_speech import (
OmniOpenAIServingSpeech, OmniOpenAIServingSpeech,
_create_wav_header, _create_wav_header,
) )
from vllm_omni.entrypoints.openai.tts_adapters.base import DEFAULT_TTS_LANGUAGES, PreparedRequest, SpeechServingContext from vllm_omni.entrypoints.openai.tts_adapters.base import (
DEFAULT_TTS_LANGUAGES,
PreparedRequest,
SpeechServingContext,
TTSGenerationError,
)
from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_supported_speakers from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_supported_speakers
from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import MingTTSAdapter from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import MingTTSAdapter
from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSCodecLimitError
from vllm_omni.entrypoints.openai.tts_adapters.voxtral import VoxtralTTSAdapter from vllm_omni.entrypoints.openai.tts_adapters.voxtral import VoxtralTTSAdapter
from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( from vllm_omni.model_executor.models.fish_speech.prompt_utils import (
FISH_TEXT_ONLY_SYSTEM_PROMPT, FISH_TEXT_ONLY_SYSTEM_PROMPT,
@@ -2928,6 +2934,40 @@ class TestStreamingResponse:
assert "text/event-stream" not in response.headers["content-type"] assert "text/event-stream" not in response.headers["content-type"]
assert len(response.content) > 0 assert len(response.content) > 0
def test_raw_stream_passes_tts_params_to_common_guard(self, streaming_app, monkeypatch):
"""The raw route must not bypass the shared Qwen3-TTS EOS guard."""
speech_server = streaming_app.state.speech_server
finalized_tts_params = {"_qwen3_tts_effective_max_tokens": [192]}
captured: dict = {}
async def prepare(_request, request_id=None):
return request_id, object(), finalized_tts_params
async def generate_chunks(
_generator,
_request_id,
_response_format="pcm",
raw_request=None,
request_start_s=None,
include_sample_rate=False,
usage_acc=None,
tts_params=None,
collect=None,
):
captured["tts_params"] = tts_params
yield b"\x00\x00"
monkeypatch.setattr(speech_server, "_prepare_speech_generation", prepare)
monkeypatch.setattr(speech_server, "_generate_audio_chunks", generate_chunks)
response = TestClient(streaming_app).post(
"/v1/audio/speech",
json={"input": "Hello", "stream_format": "audio", "response_format": "pcm"},
)
assert response.status_code == 200
assert captured["tts_params"] is finalized_tts_params
def test_sse_streaming(self, streaming_app): def test_sse_streaming(self, streaming_app):
"""stream_format=sse without stream=True returns audio deltas as SSE.""" """stream_format=sse without stream=True returns audio deltas as SSE."""
client = TestClient(streaming_app) client = TestClient(streaming_app)
@@ -4848,6 +4888,57 @@ class TestTTSAsyncOffloading:
assert ("artifact-a", False) not in qwen3_tts_server._ref_audio_model_artifact_ready assert ("artifact-a", False) not in qwen3_tts_server._ref_audio_model_artifact_ready
assert "artifact-b" in {entry[3] for entry in qwen3_tts_server._ref_audio_resolve_cache.values()} assert "artifact-b" in {entry[3] for entry in qwen3_tts_server._ref_audio_resolve_cache.values()}
@pytest.mark.asyncio
async def test_qwen3_tts_nonstream_retries_codec_limit_once(self, qwen3_tts_server, mocker: MockerFixture) -> None:
qwen3_tts_server._check_model = mocker.AsyncMock(return_value=None)
qwen3_tts_server._generate_audio_bytes = mocker.AsyncMock(
side_effect=[
Qwen3TTSCodecLimitError("codec limit"),
(b"RIFF" + b"\x00" * 32, "audio/wav"),
]
)
request = OpenAICreateSpeechRequest(input="Hello", task_type="Base")
response = await qwen3_tts_server.create_speech(request)
assert response.status_code == 200
assert qwen3_tts_server._generate_audio_bytes.await_count == 2
first_call, retry_call = qwen3_tts_server._generate_audio_bytes.await_args_list
assert first_call.args[0] is request
assert first_call.kwargs["request_id"] != retry_call.kwargs["request_id"]
assert retry_call.args[0].seed is not None
assert request.seed is None
@pytest.mark.asyncio
async def test_nonstream_does_not_retry_nonretryable_generation_error(
self, qwen3_tts_server, mocker: MockerFixture
) -> None:
qwen3_tts_server._check_model = mocker.AsyncMock(return_value=None)
qwen3_tts_server._generate_audio_bytes = mocker.AsyncMock(side_effect=TTSGenerationError("invalid generation"))
response = await qwen3_tts_server.create_speech(OpenAICreateSpeechRequest(input="Hello", task_type="Base"))
assert response.status_code == 500
qwen3_tts_server._generate_audio_bytes.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("request_kwargs", [{"seed": 100423}, {"max_new_tokens": 192}])
async def test_qwen3_tts_nonstream_does_not_retry_explicit_sampling_controls(
self,
qwen3_tts_server,
mocker: MockerFixture,
request_kwargs: dict[str, int],
) -> None:
qwen3_tts_server._check_model = mocker.AsyncMock(return_value=None)
qwen3_tts_server._generate_audio_bytes = mocker.AsyncMock(side_effect=Qwen3TTSCodecLimitError("codec limit"))
response = await qwen3_tts_server.create_speech(
OpenAICreateSpeechRequest(input="Hello", task_type="Base", **request_kwargs)
)
assert response.status_code == 500
qwen3_tts_server._generate_audio_bytes.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_audio_chunks_discards_ref_audio_artifact_warmup_on_error(self, qwen3_tts_server): async def test_generate_audio_chunks_discards_ref_audio_artifact_warmup_on_error(self, qwen3_tts_server):
async def failing_generator(): async def failing_generator():
@@ -4862,6 +4953,80 @@ class TestTTSAsyncOffloading:
assert "req-fail" not in qwen3_tts_server._request_ref_audio_artifact_keys assert "req-fail" not in qwen3_tts_server._request_ref_audio_artifact_keys
assert ("artifact-fail", False) not in qwen3_tts_server._ref_audio_model_artifact_ready assert ("artifact-fail", False) not in qwen3_tts_server._ref_audio_model_artifact_ready
@pytest.mark.asyncio
async def test_generate_audio_chunks_rejects_qwen3_codec_limit_before_success(self, qwen3_tts_server):
async def length_limited_generator():
yield SimpleNamespace(
multimodal_output={"audio": torch.zeros(16, dtype=torch.float32), "sr": 24000},
metrics={
"stage_metrics": {
"0": {
"num_tokens_out": 191,
"finish_reason": "length",
}
}
},
)
chunks = qwen3_tts_server._generate_audio_chunks(
length_limited_generator(),
"req-codec-limit",
tts_params={"task_type": ["Base"], "_qwen3_tts_effective_max_tokens": [192]},
)
with pytest.raises(Qwen3TTSCodecLimitError, match="191/192"):
async for _ in chunks:
pass
@pytest.mark.asyncio
async def test_sse_codec_limit_marks_partial_audio_for_discard(self, qwen3_tts_server):
async def length_limited_generator():
yield SimpleNamespace(
multimodal_output={"audio": torch.zeros(16, dtype=torch.float32), "sr": 24000},
metrics={
"stage_metrics": {
"0": {
"num_tokens_out": 191,
"finish_reason": "length",
}
}
},
)
events = [
event
async for event in qwen3_tts_server._generate_audio_sse_events(
length_limited_generator(),
"req-sse-codec-limit",
tts_params={"task_type": ["Base"], "_qwen3_tts_effective_max_tokens": [192]},
)
]
assert any("event: speech.audio.delta" in event for event in events)
assert not any("event: speech.audio.done" in event for event in events)
error_event = next(event for event in events if "event: speech.audio.error" in event)
payload = json.loads(next(line for line in error_event.splitlines() if line.startswith("data: "))[6:])
assert payload["error"]["partial_audio"] is True
assert payload["error"]["action"] == "discard"
@pytest.mark.asyncio
async def test_sse_error_before_audio_is_not_marked_partial(self, qwen3_tts_server):
async def failing_generator():
raise RuntimeError("boom")
yield # pragma: no cover
events = [
event
async for event in qwen3_tts_server._generate_audio_sse_events(
failing_generator(),
"req-sse-no-audio",
)
]
error_event = next(event for event in events if "event: speech.audio.error" in event)
payload = json.loads(next(line for line in error_event.splitlines() if line.startswith("data: "))[6:])
assert "partial_audio" not in payload["error"]
assert "action" not in payload["error"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_audio_chunks_discards_ref_audio_artifact_warmup_on_close(self, qwen3_tts_server): async def test_generate_audio_chunks_discards_ref_audio_artifact_warmup_on_close(self, qwen3_tts_server):
async def pcm_generator(): async def pcm_generator():
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio import asyncio
import base64 import base64
from types import SimpleNamespace from types import SimpleNamespace
@@ -44,7 +47,9 @@ def _build_test_app(
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-1", object(), {})) speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-1", object(), {}))
speech_service.forced_aligner_enabled = False speech_service.forced_aligner_enabled = False
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, collect=None): async def mock_generate_pcm_chunks(
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
):
for chunk in (b"\x01\x02", b"\x03\x04\x05"): for chunk in (b"\x01\x02", b"\x03\x04\x05"):
yield (chunk, 24000) if include_sample_rate else chunk yield (chunk, 24000) if include_sample_rate else chunk
@@ -244,6 +249,7 @@ class TestStreamingSpeechWebSocket:
def test_streaming_multiple_binary_frames(self, mocker: MockerFixture): def test_streaming_multiple_binary_frames(self, mocker: MockerFixture):
captured_requests = [] captured_requests = []
captured_tts_params = []
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech) speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"", "audio/wav")) speech_service._generate_audio_bytes = mocker.AsyncMock(return_value=(b"", "audio/wav"))
@@ -253,11 +259,12 @@ class TestStreamingSpeechWebSocket:
async def mock_prepare_speech_generation(request): async def mock_prepare_speech_generation(request):
captured_requests.append(request) captured_requests.append(request)
return "req-stream", object(), {} return "req-stream", object(), {"_qwen3_tts_effective_max_tokens": [192]}
speech_service._prepare_speech_generation = mock_prepare_speech_generation speech_service._prepare_speech_generation = mock_prepare_speech_generation
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, tts_params=None):
captured_tts_params.append(tts_params)
for chunk in (b"\x01\x02", b"\x03\x04\x05", b"\x06"): for chunk in (b"\x01\x02", b"\x03\x04\x05", b"\x06"):
yield (chunk, 24000) if include_sample_rate else chunk yield (chunk, 24000) if include_sample_rate else chunk
@@ -302,6 +309,7 @@ class TestStreamingSpeechWebSocket:
assert captured_requests[0].stream is True assert captured_requests[0].stream is True
assert captured_requests[0].response_format == "pcm" assert captured_requests[0].response_format == "pcm"
assert captured_requests[0].initial_codec_chunk_frames == 12 assert captured_requests[0].initial_codec_chunk_frames == 12
assert captured_tts_params == [{"_qwen3_tts_effective_max_tokens": [192]}]
assert speech_service._generate_audio_bytes.await_count == 0 assert speech_service._generate_audio_bytes.await_count == 0
def test_word_timestamps_requires_configured_aligner(self, mocker: MockerFixture): def test_word_timestamps_requires_configured_aligner(self, mocker: MockerFixture):
@@ -344,7 +352,9 @@ class TestStreamingSpeechWebSocket:
# The forced-aligner stage rides the same generator: its pooling output # The forced-aligner stage rides the same generator: its pooling output
# is surfaced via the ``collect`` channel once the audio has streamed. # 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): async def mock_generate_pcm_chunks(
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
):
for chunk in (first_chunk, second_chunk): for chunk in (first_chunk, second_chunk):
yield (chunk, 1000) if include_sample_rate else chunk yield (chunk, 1000) if include_sample_rate else chunk
if collect is not None: if collect is not None:
@@ -427,7 +437,9 @@ class TestStreamingSpeechWebSocket:
speech_service.forced_aligner_enabled = True speech_service.forced_aligner_enabled = True
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req", object(), {})) speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req", object(), {}))
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, collect=None): async def mock_generate_pcm_chunks(
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
):
chunk = b"\x01" * 1000 chunk = b"\x01" * 1000
yield (chunk, 1000) if include_sample_rate else chunk yield (chunk, 1000) if include_sample_rate else chunk
if collect is not None: if collect is not None:
@@ -608,7 +620,7 @@ class TestStreamingSpeechWebSocket:
speech_service.engine_client.abort = mocker.AsyncMock() speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_enabled = False 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, tts_params=None):
yield b"\x01\x02" yield b"\x01\x02"
raise RuntimeError("stream boom") raise RuntimeError("stream boom")
@@ -633,6 +645,8 @@ class TestStreamingSpeechWebSocket:
assert ws.receive_json() == { assert ws.receive_json() == {
"type": "error", "type": "error",
"message": "Generation failed for utterance 0, sentence 0: stream boom", "message": "Generation failed for utterance 0, sentence 0: stream boom",
"partial_audio": True,
"action": "discard",
} }
assert ws.receive_json() == { assert ws.receive_json() == {
"type": "audio.done", "type": "audio.done",
@@ -702,7 +716,7 @@ class TestStreamingSpeechWebSocket:
speech_service.engine_client.abort = mocker.AsyncMock() speech_service.engine_client.abort = mocker.AsyncMock()
speech_service.forced_aligner_enabled = False 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, tts_params=None):
yield b"\x01\x02" yield b"\x01\x02"
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Unit tests for speech token-usage accounting (issue #4646). """Unit tests for speech token-usage accounting (issue #4646).
These exercise the pure usage logic without a model/server: input-token These exercise the pure usage logic without a model/server: input-token
@@ -205,6 +206,14 @@ def test_output_tokens_from_stage_metrics():
assert acc.total() == 77 assert acc.total() == 77
def test_output_tokens_preserves_stage0_finish_reason():
acc = SpeechOutputTokenCounter()
res = _final_res(stage0_out=128)
res.metrics["stage_metrics"]["0"]["finish_reason"] = "length"
acc.observe(res)
assert acc.stage0_finish_reason == "length"
def test_output_tokens_takes_max_across_stages(): def test_output_tokens_takes_max_across_stages():
acc = SpeechOutputTokenCounter() acc = SpeechOutputTokenCounter()
acc.observe(_final_res(stage0_out=42, stage1_out=0)) acc.observe(_final_res(stage0_out=42, stage1_out=0))
@@ -32,7 +32,11 @@ from vllm_omni.entrypoints.openai.tts_adapters.moss_tts import (
MossTTSAdapter, MossTTSAdapter,
MossTTSNanoAdapter, MossTTSNanoAdapter,
) )
from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSAdapter from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import (
QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY,
Qwen3TTSAdapter,
Qwen3TTSCodecLimitError,
)
from vllm_omni.model_executor.models.indextts2 import prompt_utils from vllm_omni.model_executor.models.indextts2 import prompt_utils
from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import ( from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import (
INDEXTTS25_TOKENIZER_FILE, INDEXTTS25_TOKENIZER_FILE,
@@ -162,6 +166,56 @@ def test_qwen3_tts_metadata():
assert issubclass(Qwen3TTSAdapter, ARTTSAdapter) assert issubclass(Qwen3TTSAdapter, ARTTSAdapter)
@pytest.mark.parametrize(
("task_type", "text_tokens", "request_cap", "expected_cap"),
[
("Base", 0, None, 4096),
("Base", 10, None, 192),
("Base", 23, None, 276),
("Base", 23, 128, 128),
("Base", 23, 512, 512),
("Base", 400, None, 4096),
("CustomVoice", 10, None, 4096),
("CustomVoice", 10, 256, 256),
],
)
def test_qwen3_tts_applies_text_scaled_codec_safety_limit(task_type, text_tokens, request_cap, expected_cap, mocker):
server = mocker.Mock()
server._count_usage_text_tokens.return_value = text_tokens
adapter = Qwen3TTSAdapter(SpeechServingContext(server=server))
stage_defaults = [SamplingParams(max_tokens=4096, min_tokens=2)]
request = OpenAICreateSpeechRequest(
input="test text",
task_type=task_type,
max_new_tokens=request_cap,
)
prompt: dict[str, Any] = {"additional_information": {}}
overridden = adapter.apply_sampling_overrides(stage_defaults, request, prompt)
assert overridden[0].max_tokens == expected_cap
assert prompt["additional_information"][QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY] == [expected_cap]
assert stage_defaults[0].max_tokens == 4096
def test_qwen3_tts_rejects_only_length_finished_base_audio(mocker):
adapter = Qwen3TTSAdapter(SpeechServingContext(server=mocker.Mock()))
params = {
"task_type": ["Base"],
QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY: [192],
}
# EOS at the exact budget is valid; the token count alone is not a
# sufficient failure signal.
adapter.validate_generation(params, stage0_finish_reason="stop", output_tokens=192)
adapter.validate_generation(params, stage0_finish_reason=None, output_tokens=192)
# Some frontends expose 191 decoded frames for max_new_tokens=192. The
# engine terminal reason still makes this an unambiguous limit failure.
with pytest.raises(Qwen3TTSCodecLimitError, match="191/192"):
adapter.validate_generation(params, stage0_finish_reason="length", output_tokens=191)
def test_indextts_adapters_are_versioned(): def test_indextts_adapters_are_versioned():
assert resolve_adapter("indextts2") is IndexTTS2Adapter assert resolve_adapter("indextts2") is IndexTTS2Adapter
assert resolve_adapter("indextts2_5") is IndexTTS25Adapter assert resolve_adapter("indextts2_5") is IndexTTS25Adapter
+10
View File
@@ -633,6 +633,15 @@ class StagePool:
if callable(pop_native_text_metrics): if callable(pop_native_text_metrics):
native_text_metrics = pop_native_text_metrics(request_id) native_text_metrics = pop_native_text_metrics(request_id)
native_generation_tokens = native_text_metrics.get("num_generation_tokens") native_generation_tokens = native_text_metrics.get("num_generation_tokens")
finish_reason = next(
(
str(reason)
for request_output in reversed(request_outputs)
for completion in reversed(getattr(request_output, "outputs", []) or [])
if (reason := getattr(completion, "finish_reason", None)) is not None
),
None,
)
num_tokens_out = ( num_tokens_out = (
max(int(native_generation_tokens), 0) max(int(native_generation_tokens), 0)
if isinstance(native_generation_tokens, int) and not isinstance(native_generation_tokens, bool) if isinstance(native_generation_tokens, int) and not isinstance(native_generation_tokens, bool)
@@ -701,6 +710,7 @@ class StagePool:
# happens inside the model runner and is not observable here. # happens inside the model runner and is not observable here.
batch_size=1, batch_size=1,
replica_id=replica_id, replica_id=replica_id,
finish_reason=finish_reason,
rx_decode_time_ms=0.0, rx_decode_time_ms=0.0,
rx_transfer_bytes=0, rx_transfer_bytes=0,
rx_in_flight_time_ms=0.0, rx_in_flight_time_ms=0.0,
+97 -16
View File
@@ -56,6 +56,7 @@ from vllm_omni.entrypoints.openai.speech_usage import (
) )
from vllm_omni.entrypoints.openai.tts_adapters import ( from vllm_omni.entrypoints.openai.tts_adapters import (
SpeechServingContext, SpeechServingContext,
TTSGenerationError,
all_tts_stage_keys, all_tts_stage_keys,
detect_tts_model_type, detect_tts_model_type,
resolve_adapter, resolve_adapter,
@@ -78,6 +79,7 @@ from vllm_omni.utils.speaker_cache import get_speaker_cache
logger = init_logger(__name__) logger = init_logger(__name__)
_SPEECH_USAGE_INPUT_TOKENS_HEADER = "X-VLLM-OMNI-INPUT-TOKENS" _SPEECH_USAGE_INPUT_TOKENS_HEADER = "X-VLLM-OMNI-INPUT-TOKENS"
_SPEECH_USAGE_OUTPUT_TOKENS_HEADER = "X-VLLM-OMNI-OUTPUT-TOKENS" _SPEECH_USAGE_OUTPUT_TOKENS_HEADER = "X-VLLM-OMNI-OUTPUT-TOKENS"
_SPEECH_USAGE_TOTAL_TOKENS_HEADER = "X-VLLM-OMNI-TOTAL-TOKENS" _SPEECH_USAGE_TOTAL_TOKENS_HEADER = "X-VLLM-OMNI-TOTAL-TOKENS"
@@ -750,6 +752,20 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
details = self._compute_speech_input_details(request, tts_params) details = self._compute_speech_input_details(request, tts_params)
return build_speech_usage(details, output_tokens) return build_speech_usage(details, output_tokens)
def _validate_tts_generation(
self,
tts_params: dict[str, Any],
usage_acc: SpeechOutputTokenCounter,
) -> None:
adapter = self._get_tts_adapter()
if adapter is None:
return
adapter.validate_generation(
tts_params,
stage0_finish_reason=usage_acc.stage0_finish_reason,
output_tokens=usage_acc.total(),
)
@staticmethod @staticmethod
def _build_speech_usage_headers(usage: SpeechTokenUsage | None) -> dict[str, str]: def _build_speech_usage_headers(usage: SpeechTokenUsage | None) -> dict[str, str]:
"""Map speech usage into non-streaming response headers. """Map speech usage into non-streaming response headers.
@@ -2173,6 +2189,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
request_start_s: float | None = None, request_start_s: float | None = None,
include_sample_rate: bool = False, include_sample_rate: bool = False,
usage_acc: SpeechOutputTokenCounter | None = None, usage_acc: SpeechOutputTokenCounter | None = None,
tts_params: dict[str, Any] | None = None,
collect: dict | None = None, collect: dict | None = None,
): ):
"""Generate audio chunks for streaming response. """Generate audio chunks for streaming response.
@@ -2198,6 +2215,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
stream_start_s = request_start_s if request_start_s is not None else time.perf_counter() stream_start_s = request_start_s if request_start_s is not None else time.perf_counter()
artifact_ready = False artifact_ready = False
# SSE supplies an accumulator for usage output. Raw-audio and WebSocket
# streams retain terminal metrics only when their model adapter needs
# generation validation.
adapter = self._get_tts_adapter()
if tts_params is not None and usage_acc is None and adapter is not None and adapter.validates_generation:
usage_acc = SpeechOutputTokenCounter()
try: try:
async for res in generator: async for res in generator:
# Tally generated codec tokens for usage (reads per-stage metrics # Tally generated codec tokens for usage (reads per-stage metrics
@@ -2275,6 +2299,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# Audex contract: zero codec tokens must abort the stream, not # Audex contract: zero codec tokens must abort the stream, not
# complete it cleanly with zero audio bytes. # complete it cleanly with zero audio bytes.
raise ValueError("Audex produced no audio (the thinker emitted zero or invalid codec tokens)") raise ValueError("Audex produced no audio (the thinker emitted zero or invalid codec tokens)")
# Check before committing the reference-audio artifact or logging
# success. Streaming protocols may already have emitted partial
# bytes, but they must terminate as an error rather than cleanly.
if tts_params is not None and usage_acc is not None:
self._validate_tts_generation(tts_params, usage_acc)
self._mark_ref_audio_artifact_ready_for_request(request_id) self._mark_ref_audio_artifact_ready_for_request(request_id)
artifact_ready = True artifact_ready = True
total_ms = (time.perf_counter() - stream_start_s) * 1000.0 total_ms = (time.perf_counter() - stream_start_s) * 1000.0
@@ -2359,6 +2388,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
``input_tokens`` is computed from the request text + reference audio. ``input_tokens`` is computed from the request text + reference audio.
""" """
usage_acc = SpeechOutputTokenCounter() usage_acc = SpeechOutputTokenCounter()
emitted_audio = False
try: try:
async for chunk in self._generate_audio_chunks( async for chunk in self._generate_audio_chunks(
generator, generator,
@@ -2367,6 +2397,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
raw_request=raw_request, raw_request=raw_request,
request_start_s=request_start_s, request_start_s=request_start_s,
usage_acc=usage_acc, usage_acc=usage_acc,
tts_params=tts_params,
): ):
payload = { payload = {
"type": "speech.audio.delta", "type": "speech.audio.delta",
@@ -2374,6 +2405,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
"response_format": response_format, "response_format": response_format,
} }
data = json.dumps(payload, separators=(",", ":")) data = json.dumps(payload, separators=(",", ":"))
emitted_audio = True
yield f"event: speech.audio.delta\ndata: {data}\n\n" yield f"event: speech.audio.delta\ndata: {data}\n\n"
done_payload: dict[str, Any] = {"type": "speech.audio.done"} done_payload: dict[str, Any] = {"type": "speech.audio.done"}
if request is not None: if request is not None:
@@ -2385,16 +2417,19 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as e: except Exception as e:
payload = { error: dict[str, Any] = {
"type": "speech.audio.error", "message": str(e),
"error": { "type": "server_error",
"message": str(e), "param": None,
"type": "server_error", "code": HTTPStatus.INTERNAL_SERVER_ERROR.value,
"param": None,
"code": HTTPStatus.INTERNAL_SERVER_ERROR.value,
},
} }
data = json.dumps(payload, separators=(",", ":")) if emitted_audio:
error.update(partial_audio=True, action="discard")
error_payload: dict[str, Any] = {
"type": "speech.audio.error",
"error": error,
}
data = json.dumps(error_payload, separators=(",", ":"))
yield f"event: speech.audio.error\ndata: {data}\n\n" yield f"event: speech.audio.error\ndata: {data}\n\n"
@staticmethod @staticmethod
@@ -3109,7 +3144,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return request_id, generator, tts_params return request_id, generator, tts_params
async def _generate_pcm_chunks( async def _generate_pcm_chunks(
self, generator, request_id: str, *, include_sample_rate: bool = False, collect: dict | None = None self,
generator,
request_id: str,
*,
include_sample_rate: bool = False,
tts_params: dict[str, Any] | None = None,
collect: dict | None = None,
): ):
"""Yield raw PCM byte chunks from the engine generator. """Yield raw PCM byte chunks from the engine generator.
@@ -3123,15 +3164,16 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
request_id, request_id,
response_format="pcm", response_format="pcm",
include_sample_rate=include_sample_rate, include_sample_rate=include_sample_rate,
tts_params=tts_params,
collect=collect, collect=collect,
): ):
yield chunk yield chunk
async def _iter_pcm_audio_bytes(self, request: OpenAICreateSpeechRequest): async def _iter_pcm_audio_bytes(self, request: OpenAICreateSpeechRequest):
"""Yield raw PCM bytes for a speech request as soon as chunks are decoded.""" """Yield raw PCM bytes for a speech request as soon as chunks are decoded."""
request_id, generator, _ = await self._prepare_speech_generation(request) request_id, generator, tts_params = await self._prepare_speech_generation(request)
try: try:
async for chunk in self._generate_pcm_chunks(generator, request_id): async for chunk in self._generate_pcm_chunks(generator, request_id, tts_params=tts_params):
yield chunk yield chunk
finally: finally:
self._discard_ref_audio_artifact_warmup(request_id) self._discard_ref_audio_artifact_warmup(request_id)
@@ -3203,6 +3245,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if final_output is None: if final_output is None:
raise ValueError("No output generated from the model.") raise ValueError("No output generated from the model.")
self._validate_tts_generation(bytes_tts_params or {}, usage_acc)
# Extract audio from the audio-bearing res (not necessarily the last # Extract audio from the audio-bearing res (not necessarily the last
# yielded one, which may be the aligner's timestamps output). # yielded one, which may be the aligner's timestamps output).
audio_source = audio_res if audio_res is not None else final_output audio_source = audio_res if audio_res is not None else final_output
@@ -3527,7 +3571,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return error return error
media_type = "audio/wav" if response_format == "wav" else "audio/pcm" media_type = "audio/wav" if response_format == "wav" else "audio/pcm"
_, generator, _ = await self._prepare_speech_generation(request, request_id=request_id) _, generator, raw_tts_params = await self._prepare_speech_generation(request, request_id=request_id)
return StreamingResponse( return StreamingResponse(
self._generate_audio_chunks( self._generate_audio_chunks(
generator, generator,
@@ -3535,6 +3579,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
response_format, response_format,
raw_request=raw_request, raw_request=raw_request,
request_start_s=request_start_s, request_start_s=request_start_s,
tts_params=raw_tts_params,
), ),
media_type=media_type, media_type=media_type,
) )
@@ -3563,9 +3608,35 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
collect: dict = {} collect: dict = {}
usage_box: list[SpeechTokenUsage] = [] usage_box: list[SpeechTokenUsage] = []
audio_bytes, media_type = await self._generate_audio_bytes( try:
request, request_id=request_id, usage_out=usage_box, collect=collect audio_bytes, media_type = await self._generate_audio_bytes(
) request, request_id=request_id, usage_out=usage_box, collect=collect
)
except TTSGenerationError as error:
# An adapter can reject otherwise completed audio. Retry only
# retryable, stochastic, server-budgeted non-streaming
# requests: changing an explicit seed would violate
# reproducibility, and changing a caller-provided token limit
# would violate the requested budget.
if not error.retryable or request.seed is not None or request.max_new_tokens is not None:
raise
retry_seed = int(random_uuid()[:8], 16)
retry_request = request.model_copy(update={"seed": retry_seed})
retry_request_id = f"speech-{random_uuid()}"
collect.clear()
usage_box.clear()
logger.warning(
"TTS request %s failed generation validation; retrying once as %s with seed=%d",
request_id,
retry_request_id,
retry_seed,
)
audio_bytes, media_type = await self._generate_audio_bytes(
retry_request,
request_id=retry_request_id,
usage_out=usage_box,
collect=collect,
)
total_ms = (time.perf_counter() - request_start_s) * 1000.0 total_ms = (time.perf_counter() - request_start_s) * 1000.0
logger.info( logger.info(
"[SpeechE2E] request_id=%s stream=false status=ok total_ms=%.2f response_bytes=%d", "[SpeechE2E] request_id=%s stream=false status=ok total_ms=%.2f response_bytes=%d",
@@ -3618,6 +3689,16 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
e, e,
) )
return self.create_error_response(e) return self.create_error_response(e)
except TTSGenerationError as e:
total_ms = (time.perf_counter() - request_start_s) * 1000.0
logger.error(
"[SpeechE2E] request_id=%s stream=%s status=invalid_generation total_ms=%.2f error=%s",
request_id,
bool(request.stream),
total_ms,
e,
)
return self._diffusion_error_response(str(e), status_code=500)
except Exception as e: except Exception as e:
total_ms = (time.perf_counter() - request_start_s) * 1000.0 total_ms = (time.perf_counter() - request_start_s) * 1000.0
logger.exception( logger.exception(
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""WebSocket handler for streaming text input TTS. """WebSocket handler for streaming text input TTS.
Accepts text incrementally via WebSocket, buffers it until input.done, and Accepts text incrementally via WebSocket, buffers it until input.done, and
@@ -329,7 +332,7 @@ class OmniStreamingSpeechHandler:
request_id = None request_id = None
try: try:
if config.stream_audio: if config.stream_audio:
request_id, generator, _ = await self._speech_service._prepare_speech_generation(request) request_id, generator, tts_params = await self._speech_service._prepare_speech_generation(request)
if config.word_timestamps: if config.word_timestamps:
total_bytes = await self._stream_audio_with_alignments( total_bytes = await self._stream_audio_with_alignments(
websocket=websocket, websocket=websocket,
@@ -339,9 +342,16 @@ class OmniStreamingSpeechHandler:
utterance_index=utterance_index, utterance_index=utterance_index,
sentence_index=sentence_index, sentence_index=sentence_index,
language=config.language, language=config.language,
tts_params=tts_params,
) )
else: else:
async with aclosing(self._speech_service._generate_pcm_chunks(generator, request_id)) as stream: async with aclosing(
self._speech_service._generate_pcm_chunks(
generator,
request_id,
tts_params=tts_params,
)
) as stream:
async for chunk in stream: async for chunk in stream:
total_bytes += len(chunk) total_bytes += len(chunk)
await websocket.send_bytes(chunk) await websocket.send_bytes(chunk)
@@ -367,6 +377,7 @@ class OmniStreamingSpeechHandler:
await self._send_error( await self._send_error(
websocket, websocket,
f"Generation failed for utterance {utterance_index}, sentence {sentence_index}: {e}", f"Generation failed for utterance {utterance_index}, sentence {sentence_index}: {e}",
partial_audio=total_bytes > 0,
) )
finally: finally:
try: try:
@@ -392,6 +403,7 @@ class OmniStreamingSpeechHandler:
utterance_index: int, utterance_index: int,
sentence_index: int, sentence_index: int,
language: str | None = None, language: str | None = None,
tts_params: dict | None = None,
) -> int: ) -> int:
"""Stream PCM as JSON ``audio.chunk`` frames, aligned per sentence. """Stream PCM as JSON ``audio.chunk`` frames, aligned per sentence.
@@ -438,6 +450,7 @@ class OmniStreamingSpeechHandler:
generator, generator,
request_id, request_id,
include_sample_rate=True, include_sample_rate=True,
tts_params=tts_params,
collect=collect, collect=collect,
) )
) as stream: ) as stream:
@@ -463,14 +476,15 @@ class OmniStreamingSpeechHandler:
return total_bytes return total_bytes
@staticmethod @staticmethod
async def _send_error(websocket: WebSocket, message: str) -> None: async def _send_error(websocket: WebSocket, message: str, *, partial_audio: bool = False) -> None:
"""Send an error message to the client.""" """Send an error message to the client."""
try: try:
await websocket.send_json( payload: dict[str, object] = {
{ "type": "error",
"type": "error", "message": message,
"message": message, }
} if partial_audio:
) payload.update(partial_audio=True, action="discard")
await websocket.send_json(payload)
except Exception: except Exception:
pass # Connection may already be closed; safe to ignore pass # Connection may already be closed; safe to ignore
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Token-usage accounting for the Speech (``/v1/audio/speech``) API. """Token-usage accounting for the Speech (``/v1/audio/speech``) API.
Why this module exists (issue #4646) Why this module exists (issue #4646)
@@ -160,6 +161,7 @@ class SpeechOutputTokenCounter:
""" """
output_tokens: int = 0 output_tokens: int = 0
stage0_finish_reason: str | None = None
def observe(self, res: Any) -> None: def observe(self, res: Any) -> None:
metrics = getattr(res, "metrics", None) metrics = getattr(res, "metrics", None)
@@ -168,6 +170,11 @@ class SpeechOutputTokenCounter:
stage_metrics = metrics.get("stage_metrics") stage_metrics = metrics.get("stage_metrics")
if not isinstance(stage_metrics, dict): if not isinstance(stage_metrics, dict):
return return
stage0 = stage_metrics.get("0", stage_metrics.get(0))
if isinstance(stage0, dict):
reason = stage0.get("finish_reason")
if reason is not None:
self.stage0_finish_reason = str(reason)
for stage in stage_metrics.values(): for stage in stage_metrics.values():
if not isinstance(stage, dict): if not isinstance(stage, dict):
continue continue
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Registry of TTS serving adapters, and the model detection built on it. """Registry of TTS serving adapters, and the model detection built on it.
Adapters register themselves by their ``name`` (the model-type discriminator) Adapters register themselves by their ``name`` (the model-type discriminator)
@@ -19,6 +20,7 @@ from vllm_omni.entrypoints.openai.tts_adapters.base import (
OutputPolicy, OutputPolicy,
PreparedRequest, PreparedRequest,
SpeechServingContext, SpeechServingContext,
TTSGenerationError,
TTSModelAdapter, TTSModelAdapter,
) )
@@ -126,6 +128,7 @@ __all__ = [
"OutputPolicy", "OutputPolicy",
"PreparedRequest", "PreparedRequest",
"SpeechServingContext", "SpeechServingContext",
"TTSGenerationError",
"TTSModelAdapter", "TTSModelAdapter",
"TTS_ADAPTER_REGISTRY", "TTS_ADAPTER_REGISTRY",
"all_tts_model_types", "all_tts_model_types",
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Base contract for per-model TTS serving adapters. """Base contract for per-model TTS serving adapters.
This package factors the per-model ``if self._tts_model_type == ...`` dispatch This package factors the per-model ``if self._tts_model_type == ...`` dispatch
@@ -11,7 +12,7 @@ See the RFC for the full design (issue #4327).
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable from collections.abc import Callable, Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar from typing import TYPE_CHECKING, Any, ClassVar
@@ -69,6 +70,14 @@ def apply_max_new_tokens(
return sampling_params_list return sampling_params_list
class TTSGenerationError(RuntimeError):
"""A completed TTS generation that must not be returned as valid audio."""
def __init__(self, message: str, *, retryable: bool = False) -> None:
super().__init__(message)
self.retryable = retryable
@dataclass @dataclass
class OutputPolicy: class OutputPolicy:
"""How the orchestrator aggregates engine output for a model. """How the orchestrator aggregates engine output for a model.
@@ -153,6 +162,8 @@ class TTSModelAdapter(ABC):
detect_priority: ClassVar[int] = 100 detect_priority: ClassVar[int] = 100
#: Serving backend: ``"ar"`` (engine_client) or ``"diffusion"``. #: Serving backend: ``"ar"`` (engine_client) or ``"diffusion"``.
backend: ClassVar[str] = "ar" backend: ClassVar[str] = "ar"
#: Whether streaming entrypoints must retain terminal metrics for validation.
validates_generation: ClassVar[bool] = False
#: Whether the model consumes ``request.speed`` in its native parameters. #: Whether the model consumes ``request.speed`` in its native parameters.
native_speed_control: ClassVar[bool] = False native_speed_control: ClassVar[bool] = False
@@ -233,6 +244,21 @@ class TTSModelAdapter(ABC):
""" """
return sampling_params_list return sampling_params_list
def validate_generation(
self,
tts_params: Mapping[str, object],
*,
stage0_finish_reason: str | None,
output_tokens: int,
) -> None:
"""Reject a completed generation that is invalid for this model.
Adapters that override this hook must also set
:attr:`validates_generation` so streaming entrypoints retain the
terminal metrics needed by the validation without charging that cost
to unrelated TTS models.
"""
async def warmup(self) -> None: async def warmup(self) -> None:
return return
@@ -304,5 +330,6 @@ __all__ = [
"OutputPolicy", "OutputPolicy",
"PreparedRequest", "PreparedRequest",
"SpeechServingContext", "SpeechServingContext",
"TTSGenerationError",
"TTSModelAdapter", "TTSModelAdapter",
] ]
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Qwen3-TTS serving adapter.""" """Qwen3-TTS serving adapter."""
from collections.abc import Mapping from collections.abc import Mapping
@@ -12,7 +13,7 @@ from vllm_omni.entrypoints.openai.tts_adapters.base import (
DEFAULT_TTS_LANGUAGES, DEFAULT_TTS_LANGUAGES,
ARTTSAdapter, ARTTSAdapter,
PreparedRequest, PreparedRequest,
apply_max_new_tokens, TTSGenerationError,
) )
from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_precomputed_speakers from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_precomputed_speakers
from vllm_omni.utils.speaker_cache import validate_qwen3_tts_profile from vllm_omni.utils.speaker_cache import validate_qwen3_tts_profile
@@ -22,11 +23,23 @@ if TYPE_CHECKING:
logger = init_logger(__name__) logger = init_logger(__name__)
QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY = "_qwen3_tts_effective_max_tokens"
_MIN_CODEC_FRAMES = 192
_MAX_CODEC_FRAMES_PER_TEXT_TOKEN = 12
class Qwen3TTSCodecLimitError(TTSGenerationError):
"""Qwen3-TTS Base exhausted its codec budget without emitting EOS."""
def __init__(self, message: str) -> None:
super().__init__(message, retryable=True)
@register_tts_adapter @register_tts_adapter
class Qwen3TTSAdapter(ARTTSAdapter): class Qwen3TTSAdapter(ARTTSAdapter):
"""Adapter for Qwen3-TTS (AR ``engine_client`` backend).""" """Adapter for Qwen3-TTS (AR ``engine_client`` backend)."""
validates_generation = True
stage_keys = frozenset({"qwen3_tts"}) stage_keys = frozenset({"qwen3_tts"})
name = "qwen3_tts" name = "qwen3_tts"
@@ -226,4 +239,95 @@ class Qwen3TTSAdapter(ARTTSAdapter):
prompt: dict[str, Any] | None = None, prompt: dict[str, Any] | None = None,
request_id: str | None = None, request_id: str | None = None,
) -> list: ) -> list:
return apply_max_new_tokens(sampling_params_list, request) """Apply a text-scaled safety ceiling to Base codec generation.
Qwen3-TTS can rarely enter a repetitive state in which codec EOS is no
longer reachable through top-k sampling. A fixed 4096-frame ceiling
turns that into several minutes of unusable audio. Bound the default
Base-task budget by text length, while preserving an explicit caller
``max_new_tokens`` override and the configured budget for other tasks.
"""
import copy
del request_id
server = self.ctx.server
# Only scalar fields on stage 0 are changed below. Shallow-copy each
# stage so the shared defaults stay immutable without deep-copying the
# complete sampling configuration on every request.
sampling_params_list = [copy.copy(params) for params in sampling_params_list]
configured_cap = getattr(sampling_params_list[0], "max_tokens", None)
task_type = request.task_type or "CustomVoice"
text_tokens = None
dynamic_cap = None
effective_cap: int | None
if request.max_new_tokens is not None:
# An explicit request budget is an opt-out from the automatic
# ceiling. It remains an upper bound, and a length finish is still
# surfaced as an incomplete generation rather than valid audio.
effective_cap = int(request.max_new_tokens)
elif task_type == "Base":
counted_text_tokens = server._count_usage_text_tokens(request.input)
if counted_text_tokens > 0:
text_tokens = counted_text_tokens
dynamic_cap = max(_MIN_CODEC_FRAMES, text_tokens * _MAX_CODEC_FRAMES_PER_TEXT_TOKEN)
effective_cap = min(dynamic_cap, int(configured_cap)) if configured_cap is not None else dynamic_cap
else:
# Token counting is best-effort. If the tokenizer is missing or
# rejects the input, preserve the configured budget instead of
# truncating an otherwise valid request at the minimum ceiling.
effective_cap = int(configured_cap) if configured_cap is not None else None
else:
effective_cap = int(configured_cap) if configured_cap is not None else None
if effective_cap is not None:
effective_cap = max(1, effective_cap)
sampling_params_list[0].max_tokens = effective_cap
sampling_params_list[0].min_tokens = min(
int(getattr(sampling_params_list[0], "min_tokens", 0) or 0),
effective_cap,
)
if isinstance(prompt, dict):
additional_information = prompt.get("additional_information")
if isinstance(additional_information, dict) and effective_cap is not None:
additional_information[QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY] = [effective_cap]
logger.debug(
"Qwen3-TTS codec budget: task_type=%s text_tokens=%s dynamic_cap=%s "
"configured_cap=%s request_cap=%s effective_cap=%s",
task_type,
text_tokens,
dynamic_cap,
configured_cap,
request.max_new_tokens,
effective_cap,
)
return sampling_params_list
def validate_generation(
self,
tts_params: Mapping[str, object],
*,
stage0_finish_reason: str | None,
output_tokens: int,
) -> None:
if QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY not in tts_params:
return
task_type = tts_params.get("task_type")
if isinstance(task_type, (list, tuple)):
task_type = task_type[0] if task_type else None
if task_type != "Base" or stage0_finish_reason != "length":
return
raw_limit = tts_params.get(QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY)
if isinstance(raw_limit, (list, tuple)):
raw_limit = raw_limit[0] if raw_limit else None
try:
limit = int(raw_limit) if isinstance(raw_limit, (str, bytes, bytearray, int, float)) else 0
except (TypeError, ValueError):
limit = 0
raise Qwen3TTSCodecLimitError(
"Qwen3-TTS Base did not emit codec EOS before its token budget "
f"({output_tokens}/{limit} codec tokens); the generated audio is incomplete."
)
+5
View File
@@ -45,6 +45,7 @@ class StageRequestStats:
stage_id: int | None = None stage_id: int | None = None
replica_id: int | None = None replica_id: int | None = None
final_output_type: str | None = None final_output_type: str | None = None
finish_reason: str | None = None
request_id: str | None = None request_id: str | None = None
postprocess_time_ms: float = 0.0 postprocess_time_ms: float = 0.0
diffusion_metrics: dict[str, float] = None diffusion_metrics: dict[str, float] = None
@@ -126,6 +127,7 @@ STAGE_EXCLUDE = {
"rx_decode_time_ms", "rx_decode_time_ms",
"rx_in_flight_time_ms", "rx_in_flight_time_ms",
"final_output_type", "final_output_type",
"finish_reason",
"pipeline_timings", "pipeline_timings",
} }
TRANSFER_EXCLUDE = {"from_stage", "to_stage", "request_id", "used_shm"} TRANSFER_EXCLUDE = {"from_stage", "to_stage", "request_id", "used_shm"}
@@ -473,6 +475,7 @@ class OrchestratorAggregator:
current = { current = {
"stage_id": sid, "stage_id": sid,
"final_output_type": evt.final_output_type, "final_output_type": evt.final_output_type,
"finish_reason": evt.finish_reason,
defs.NUM_TOKENS_IN: int(evt.num_tokens_in), defs.NUM_TOKENS_IN: int(evt.num_tokens_in),
defs.NUM_TOKENS_OUT: int(evt.num_tokens_out), defs.NUM_TOKENS_OUT: int(evt.num_tokens_out),
defs.STAGE_GEN_TIME_MS: float(evt.stage_gen_time_ms), defs.STAGE_GEN_TIME_MS: float(evt.stage_gen_time_ms),
@@ -498,6 +501,8 @@ class OrchestratorAggregator:
current[defs.NUM_TOKENS_IN] = int(current.get(defs.NUM_TOKENS_IN, 0)) + int(evt.num_tokens_in) current[defs.NUM_TOKENS_IN] = int(current.get(defs.NUM_TOKENS_IN, 0)) + int(evt.num_tokens_in)
current[defs.NUM_TOKENS_OUT] = int(current.get(defs.NUM_TOKENS_OUT, 0)) + int(evt.num_tokens_out) current[defs.NUM_TOKENS_OUT] = int(current.get(defs.NUM_TOKENS_OUT, 0)) + int(evt.num_tokens_out)
if evt.finish_reason is not None:
current["finish_reason"] = evt.finish_reason
current[defs.STAGE_GEN_TIME_MS] = float(current.get(defs.STAGE_GEN_TIME_MS, 0.0)) + float(evt.stage_gen_time_ms) current[defs.STAGE_GEN_TIME_MS] = float(current.get(defs.STAGE_GEN_TIME_MS, 0.0)) + float(evt.stage_gen_time_ms)
current[defs.POSTPROCESS_TIME_MS] = float(current.get(defs.POSTPROCESS_TIME_MS, 0.0)) + float( current[defs.POSTPROCESS_TIME_MS] = float(current.get(defs.POSTPROCESS_TIME_MS, 0.0)) + float(
evt.postprocess_time_ms evt.postprocess_time_ms