* qwen3_tts_talker_only pipeline (single terminal LLM_AR stage, final_output_type "latent") + vllm_omni/deploy/qwen3_tts_talker_only.yaml; a terminal talker publishes its per-step codes.audio rows to the client (omni_client_multimodal_output_keys). * /v1/audio/speech/stream: session.config.output_mode="codec_tokens" streams codec.start (JSON) + binary frames (<IHB seq/n_frames/flags + int16 rows, vllm_omni/entrypoints/openai/codec_stream.py) + codec.done; new input.cancel message aborts an in-flight generation for both codec and PCM sessions (single reader task + session inbox replaces direct socket reads); optional session.config.seed; audio.done gains "cancelled" only when it happened. * serving_speech._generate_codec_chunks aligns the cumulative code tensor to output token ids (drops prefill placeholder/EOS rows) and emits deltas. * TTS adapters: supports_codec_stream / codec_stream_spec (Qwen3-TTS: 16 codebooks, 2048 ids, 12.5 Hz, 24 kHz, decoder qwen3_tts_tokenizer_v2_12hz). * examples/online_serving/text_to_speech/qwen3_tts/edge_decoder_client.py decodes locally with Qwen3TTSTokenizerV2Decoder (ramp schedule, 72-frame context, cancel/reset), codec_parity_check.py and benchmarks/tts/codec_stream_e2e.py compare against the two-stage server. * docs/serving/codec_token_streaming.md; CPU unit tests for the wire format, alignment, session config and handler (frames, unsupported adapter, cancel); L3 e2e tests/e2e/online_serving/test_qwen3_tts_codec_stream.py. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""E2E: codec-token streaming from a talker-only Qwen3-TTS deployment.
|
|
|
|
Deploy Setting: qwen3_tts_talker_only.yaml (single LLM_AR stage)
|
|
Input Modal: text (WebSocket /v1/audio/speech/stream, output_mode=codec_tokens)
|
|
Output Modal: codec frames decoded on the client with Qwen3TTSTokenizerV2Decoder
|
|
"""
|
|
|
|
import os
|
|
|
|
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
|
|
|
import json
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from tests.helpers.mark import hardware_test
|
|
from tests.helpers.runtime import OmniServerParams
|
|
from tests.helpers.stage_config import get_deploy_config_path
|
|
from vllm_omni.entrypoints.openai.codec_stream import FLAG_EOS, unpack_codec_frame
|
|
|
|
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
|
|
_STAGE_CONFIG = get_deploy_config_path("qwen3_tts_talker_only.yaml")
|
|
|
|
server_params = [
|
|
pytest.param(
|
|
OmniServerParams(model=MODEL, stage_config_path=_STAGE_CONFIG, server_args=["--trust-remote-code"]),
|
|
id="talker_only",
|
|
)
|
|
]
|
|
|
|
|
|
def _stream_codec(port: int, text: str) -> tuple[dict, list[tuple[int, int, int, np.ndarray]], dict]:
|
|
from websockets.sync.client import connect
|
|
|
|
frames = []
|
|
with connect(f"ws://localhost:{port}/v1/audio/speech/stream", max_size=64 * 1024 * 1024) as ws:
|
|
ws.send(
|
|
json.dumps(
|
|
{
|
|
"type": "session.config",
|
|
"model": MODEL,
|
|
"output_mode": "codec_tokens",
|
|
"stream_audio": True,
|
|
"response_format": "pcm",
|
|
"voice": "vivian",
|
|
"task_type": "CustomVoice",
|
|
}
|
|
)
|
|
)
|
|
ws.send(json.dumps({"type": "input.text", "text": text}))
|
|
ws.send(json.dumps({"type": "input.done"}))
|
|
start = json.loads(ws.recv())
|
|
assert start["type"] == "codec.start", start
|
|
done = None
|
|
while True:
|
|
raw = ws.recv()
|
|
if isinstance(raw, (bytes, bytearray)):
|
|
frames.append(unpack_codec_frame(bytes(raw), start["codebooks"]))
|
|
continue
|
|
msg = json.loads(raw)
|
|
if msg["type"] == "codec.done":
|
|
done = msg
|
|
elif msg["type"] == "session.done":
|
|
break
|
|
elif msg["type"] == "error":
|
|
raise AssertionError(msg)
|
|
ws.send(json.dumps({"type": "session.close"}))
|
|
return start, frames, done
|
|
|
|
|
|
@pytest.mark.advanced_model
|
|
@pytest.mark.tts
|
|
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
|
@pytest.mark.parametrize("omni_server", server_params, indirect=True)
|
|
def test_codec_token_stream_and_client_decode(omni_server) -> None:
|
|
start, frames, done = _stream_codec(omni_server.port, "Codec token streaming end to end test.")
|
|
assert start["codebooks"] == 16 and start["frame_rate_hz"] == 12.5 and start["sample_rate"] == 24000
|
|
assert frames, "no codec frames received"
|
|
seqs = [f[0] for f in frames]
|
|
assert seqs == list(range(len(seqs)))
|
|
assert frames[-1][2] & FLAG_EOS
|
|
total = sum(f[1] for f in frames)
|
|
assert total >= 10, f"too few frames: {total}"
|
|
assert done is not None and done["total_frames"] == total and done["error"] is False
|
|
codes = np.concatenate([f[3].astype(np.int64) for f in frames if f[1]], axis=0)
|
|
assert codes.min() >= 0 and codes.max() < 2048
|
|
|
|
import sys
|
|
|
|
sys.path.insert(
|
|
0,
|
|
str(
|
|
os.path.join(
|
|
os.path.dirname(__file__), "..", "..", "..", "examples", "online_serving", "text_to_speech", "qwen3_tts"
|
|
)
|
|
),
|
|
)
|
|
from edge_decoder_client import LocalDecoder
|
|
|
|
dec = LocalDecoder(MODEL, device="cpu")
|
|
pcm = np.concatenate([dec.decode(codes[i : i + 25]) for i in range(0, codes.shape[0], 25)])
|
|
expected_s = total / 12.5
|
|
assert abs(pcm.size / dec.sample_rate - expected_s) < 0.1 * expected_s + 0.2
|
|
assert float(np.abs(pcm).max()) > 1e-3
|