[Edge][WP5] Add codec-token streaming mode with on-device decoding
* 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>
This commit is contained in:
1 parent
35e43b9983
commit
49a839ecf9
16 files changed
+1533
-6
No files matched your search
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end codec-token streaming check on one device.
|
||||
|
||||
1. Launch a talker-only server (``qwen3_tts_talker_only.yaml``), run the
|
||||
edge decoder client for each prompt (decoder on ``--client-device``),
|
||||
record TTFA / playback-start / RTF / decode time and save WAVs.
|
||||
2. Stop it, launch the full two-stage server, request the same prompts with
|
||||
the same ``tts_local_seed`` through ``/v1/audio/speech`` and compute the
|
||||
SNR between server-decoded and client-decoded audio.
|
||||
|
||||
Both servers run one after another on the same device, so this fits a single
|
||||
``gpu run --gpus 1`` window (or a CPU host with ``VLLM_TARGET_DEVICE=cpu``).
|
||||
Results: ``benchmarks/tts/edge_results/codec_stream/<timestamp>/summary.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
BENCH_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = BENCH_DIR.parent.parent
|
||||
CLIENT_DIR = REPO_ROOT / "examples" / "online_serving" / "text_to_speech" / "qwen3_tts"
|
||||
sys.path.insert(0, str(CLIENT_DIR))
|
||||
sys.path.insert(0, str(BENCH_DIR))
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(
|
||||
self, model: str, deploy_config: str | None, port: int, log_path: Path, extra: list[str], env: dict[str, str]
|
||||
):
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"vllm_omni.entrypoints.cli.main",
|
||||
"serve",
|
||||
model,
|
||||
"--omni",
|
||||
"--port",
|
||||
str(port),
|
||||
"--trust-remote-code",
|
||||
]
|
||||
if deploy_config:
|
||||
cmd += ["--deploy-config", deploy_config]
|
||||
cmd += extra
|
||||
self.cmd, self.port, self.log_path = cmd, port, log_path
|
||||
self.env = {**os.environ, **env}
|
||||
self.proc: subprocess.Popen | None = None
|
||||
|
||||
def start(self, timeout_s: int) -> float:
|
||||
self.log = open(self.log_path, "w")
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdout=self.log,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=self.env,
|
||||
cwd=str(REPO_ROOT),
|
||||
start_new_session=True,
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
import urllib.request
|
||||
|
||||
while time.perf_counter() - t0 < timeout_s:
|
||||
if self.proc.poll() is not None:
|
||||
raise RuntimeError(f"server exited early (rc={self.proc.returncode}); see {self.log_path}")
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{self.port}/health", timeout=2) as r:
|
||||
if r.status == 200:
|
||||
return time.perf_counter() - t0
|
||||
except Exception:
|
||||
time.sleep(1.0)
|
||||
raise TimeoutError(f"server on port {self.port} not healthy after {timeout_s}s")
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.proc and self.proc.poll() is None:
|
||||
os.killpg(self.proc.pid, signal.SIGTERM)
|
||||
try:
|
||||
self.proc.wait(timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(self.proc.pid, signal.SIGKILL)
|
||||
if getattr(self, "log", None):
|
||||
self.log.close()
|
||||
|
||||
|
||||
def snr_db(ref: np.ndarray, test: np.ndarray) -> float:
|
||||
n = min(ref.size, test.size)
|
||||
if n == 0:
|
||||
return float("nan")
|
||||
ref, test = ref[:n].astype(np.float64), test[:n].astype(np.float64)
|
||||
return 10.0 * np.log10((np.sum(ref**2) + 1e-12) / (np.sum((ref - test) ** 2) + 1e-12))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--model", default="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")
|
||||
p.add_argument("--prompts", default=str(BENCH_DIR / "prompts_12.txt"))
|
||||
p.add_argument("--n-prompts", type=int, default=4)
|
||||
p.add_argument("--seed", type=int, default=1234)
|
||||
p.add_argument("--voice", default="vivian")
|
||||
p.add_argument("--client-device", default="cpu")
|
||||
p.add_argument("--talker-deploy", default=str(REPO_ROOT / "vllm_omni" / "deploy" / "qwen3_tts_talker_only.yaml"))
|
||||
p.add_argument("--full-deploy", default=None, help="deploy YAML for the 2-stage server (default: model default)")
|
||||
p.add_argument("--server-timeout-s", type=int, default=1500)
|
||||
p.add_argument("--output-dir", default=str(BENCH_DIR / "edge_results" / "codec_stream"))
|
||||
p.add_argument("--skip-parity", action="store_true")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
import edge_decoder_client as client
|
||||
|
||||
prompts = [line.strip() for line in open(args.prompts) if line.strip()][: args.n_prompts]
|
||||
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out_dir = Path(args.output_dir) / ts
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
stage0_eager = ["--stage-overrides", json.dumps({"0": {"enforce_eager": True}})]
|
||||
summary: dict[str, Any] = {
|
||||
"model": args.model,
|
||||
"timestamp": ts,
|
||||
"prompts": prompts,
|
||||
"client_device": args.client_device,
|
||||
"rows": [],
|
||||
}
|
||||
|
||||
# --- talker-only server + client decoding
|
||||
port = _free_port()
|
||||
talker = Server(args.model, args.talker_deploy, port, out_dir / "talker_server.log", stage0_eager, {})
|
||||
try:
|
||||
summary["talker_server_start_s"] = talker.start(args.server_timeout_s)
|
||||
for i, text in enumerate(prompts):
|
||||
cargs = argparse.Namespace(
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
model=args.model,
|
||||
text=text,
|
||||
voice=args.voice,
|
||||
task_type="CustomVoice",
|
||||
language=None,
|
||||
instructions=None,
|
||||
max_new_tokens=None,
|
||||
device=args.client_device,
|
||||
left_context=72,
|
||||
ramp=None,
|
||||
out=str(out_dir / f"client_{i}.wav"),
|
||||
json=str(out_dir / f"client_{i}.json"),
|
||||
seed=args.seed,
|
||||
)
|
||||
res = asyncio.run(client.run(cargs))
|
||||
summary["rows"].append(
|
||||
{
|
||||
"i": i,
|
||||
"text": text,
|
||||
"frames": res["total_frames"],
|
||||
"client_ttfa_ms": res["ttfa_ms"],
|
||||
"client_playback_start_ms": res["playback_start_ms"],
|
||||
"client_rtf": res["rtf"],
|
||||
"client_decode_ms": res["decode_ms_total"],
|
||||
"client_audio_s": res["audio_s"],
|
||||
}
|
||||
)
|
||||
print(
|
||||
f"[codec_e2e] talker-only prompt {i}: frames={res['total_frames']} "
|
||||
f"ttfa={res['ttfa_ms']} rtf={res['rtf']}",
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
talker.stop()
|
||||
|
||||
# --- full server for parity
|
||||
if not args.skip_parity:
|
||||
import soundfile as sf
|
||||
|
||||
port = _free_port()
|
||||
full = Server(args.model, args.full_deploy, port, out_dir / "full_server.log", stage0_eager, {})
|
||||
try:
|
||||
summary["full_server_start_s"] = full.start(args.server_timeout_s)
|
||||
import urllib.request
|
||||
|
||||
for row in summary["rows"]:
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": args.model,
|
||||
"input": row["text"],
|
||||
"voice": args.voice,
|
||||
"task_type": "CustomVoice",
|
||||
"response_format": "wav",
|
||||
"seed": args.seed,
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/v1/audio/speech", data=body, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
with urllib.request.urlopen(req, timeout=600) as r:
|
||||
wav = r.read()
|
||||
row["full_server_s"] = time.perf_counter() - t0
|
||||
ref, sr = sf.read(io.BytesIO(wav), dtype="float32")
|
||||
ref = ref.reshape(-1)
|
||||
sf.write(str(out_dir / f"full_{row['i']}.wav"), ref, sr, format="WAV")
|
||||
test, _ = sf.read(str(out_dir / f"client_{row['i']}.wav"), dtype="float32")
|
||||
row["full_audio_s"] = ref.size / sr
|
||||
row["snr_db"] = snr_db(ref, test.reshape(-1))
|
||||
row["length_ratio"] = (test.size / ref.size) if ref.size else None
|
||||
print(
|
||||
f"[codec_e2e] parity prompt {row['i']}: snr_db={row['snr_db']:.1f} len_ratio={row['length_ratio']}",
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
full.stop()
|
||||
|
||||
(out_dir / "summary.json").write_text(json.dumps(summary, indent=1))
|
||||
print(json.dumps({k: v for k, v in summary.items() if k != "rows"}, indent=1))
|
||||
for row in summary["rows"]:
|
||||
print(json.dumps(row))
|
||||
print(f"[codec_e2e] wrote {out_dir / 'summary.json'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,65 @@
|
||||
# Codec-token streaming (on-device decoding)
|
||||
|
||||
For edge deployments the expensive part of a TTS pipeline that must run near
|
||||
the user is the audio decoder, while the autoregressive talker can stay on a
|
||||
server. Codec-token streaming serves the **talker only** and streams its codec
|
||||
frames to a client that decodes them locally (Qwen3-TTS: 16 codebooks at
|
||||
12.5 Hz, about 400 B/s of payload before framing).
|
||||
|
||||
## Server
|
||||
|
||||
Deploy the talker-only pipeline:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice --omni --port 8001 \
|
||||
--deploy-config qwen3_tts_talker_only.yaml --trust-remote-code
|
||||
```
|
||||
|
||||
`qwen3_tts_talker_only.yaml` selects the registered `qwen3_tts_talker_only`
|
||||
pipeline (stage 0 only, `final_output_type: latent`). The talker publishes its
|
||||
per-step `codes.audio` rows to the client (`omni_client_multimodal_output_keys`),
|
||||
and the WebSocket handler emits them as binary frames.
|
||||
|
||||
## Protocol (`/v1/audio/speech/stream`)
|
||||
|
||||
Client → server: the usual `session.config` with
|
||||
|
||||
```json
|
||||
{"type": "session.config", "output_mode": "codec_tokens", "stream_audio": true,
|
||||
"response_format": "pcm", "voice": "vivian", "task_type": "CustomVoice"}
|
||||
```
|
||||
|
||||
then `input.text` / `input.done`. `input.cancel` aborts the in-flight
|
||||
generation (works for PCM sessions too).
|
||||
|
||||
Server → client per utterance:
|
||||
|
||||
| message | content |
|
||||
|---|---|
|
||||
| `codec.start` (JSON) | `codebooks`, `codebook_size`, `frame_rate_hz`, `sample_rate`, `decoder_id`, `header_format` |
|
||||
| binary frames | header `<IHB` = `seq:uint32, n_frames:uint16, flags:uint8`, then `int16[n_frames × codebooks]`; flags `EOS=1`, `SEGMENT_END=2`, `RESET=4` |
|
||||
| `codec.done` (JSON) | `total_frames`, `seq_last`, `error`, `cancelled` |
|
||||
| `session.done` | as for PCM sessions |
|
||||
|
||||
Frames are emitted as they are produced (one talker step = one frame); the
|
||||
chunking policy for the decoder (first small chunk, ramp to 25 frames, 72-frame
|
||||
left context) is the client's choice. The wire format lives in
|
||||
`vllm_omni/entrypoints/openai/codec_stream.py`.
|
||||
|
||||
## Client
|
||||
|
||||
`examples/online_serving/text_to_speech/qwen3_tts/edge_decoder_client.py`
|
||||
loads `Qwen3TTSTokenizerV2Decoder` from the checkpoint's `speech_tokenizer/`
|
||||
folder (CPU or CUDA), applies the same chunk schedule and state carry-over as
|
||||
the server's Code2Wav stage, writes a WAV and reports TTFA, playback-start
|
||||
latency and RTF. `codec_parity_check.py` compares the client-decoded audio with
|
||||
the full two-stage server's output (same `tts_local_seed`) and reports SNR.
|
||||
|
||||
## Limits
|
||||
|
||||
- Only adapters with `supports_codec_stream = True` (Qwen3-TTS today) accept
|
||||
`output_mode: codec_tokens`; others return an `error` message.
|
||||
- The talker's per-step output carries the cumulative code tensor, so a long
|
||||
utterance costs O(N²) bytes between engine and API server (about 9 MB for
|
||||
30 s of audio); the wire stream itself is incremental.
|
||||
- `word_timestamps` and `speed != 1.0` are not available in this mode.
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parity check: server-decoded PCM vs client-decoded PCM from codec tokens.
|
||||
|
||||
Run the full 2-stage server and the talker-only server one after another (or
|
||||
on two ports) with ``enforce_eager`` on stage 0 so per-request seeds are
|
||||
reproducible, then:
|
||||
|
||||
python codec_parity_check.py --model Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice \\
|
||||
--full-port 8000 --talker-port 8001 --seed 1234 --text "..."
|
||||
|
||||
Reports the length-trimmed SNR (dB) between the two waveforms and the codec
|
||||
frame count. Both requests use the same ``seed``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
|
||||
def snr_db(ref: np.ndarray, test: np.ndarray) -> float:
|
||||
n = min(ref.size, test.size)
|
||||
if n == 0:
|
||||
return float("nan")
|
||||
ref, test = ref[:n].astype(np.float64), test[:n].astype(np.float64)
|
||||
noise = ref - test
|
||||
p_sig = float(np.sum(ref**2)) + 1e-12
|
||||
p_noise = float(np.sum(noise**2)) + 1e-12
|
||||
return 10.0 * np.log10(p_sig / p_noise)
|
||||
|
||||
|
||||
def fetch_full_pcm(host: str, port: int, model: str, text: str, voice: str, seed: int) -> np.ndarray:
|
||||
import requests
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "wav",
|
||||
"seed": seed,
|
||||
}
|
||||
r = requests.post(f"http://{host}:{port}/v1/audio/speech", json=body, timeout=600)
|
||||
r.raise_for_status()
|
||||
import soundfile as sf
|
||||
|
||||
data, sr = sf.read(io.BytesIO(r.content), dtype="float32")
|
||||
return data.reshape(-1)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--model", required=True)
|
||||
p.add_argument("--host", default="localhost")
|
||||
p.add_argument("--full-port", type=int, default=8000)
|
||||
p.add_argument("--talker-port", type=int, default=8001)
|
||||
p.add_argument("--voice", default="vivian")
|
||||
p.add_argument("--text", default="Parity check between server decoding and on-device decoding.")
|
||||
p.add_argument("--seed", type=int, default=1234)
|
||||
p.add_argument("--device", default="cpu")
|
||||
p.add_argument("--full-wav", default=None, help="use a previously saved full-server WAV instead of requesting one")
|
||||
p.add_argument("--out-dir", default=None)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
import edge_decoder_client as client
|
||||
|
||||
out_dir = Path(args.out_dir) if args.out_dir else None
|
||||
if out_dir:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.full_wav:
|
||||
import soundfile as sf
|
||||
|
||||
ref, _ = sf.read(args.full_wav, dtype="float32")
|
||||
ref = ref.reshape(-1)
|
||||
else:
|
||||
ref = fetch_full_pcm(args.host, args.full_port, args.model, args.text, args.voice, args.seed)
|
||||
client_args = argparse.Namespace(
|
||||
host=args.host,
|
||||
port=args.talker_port,
|
||||
model=args.model,
|
||||
text=args.text,
|
||||
voice=args.voice,
|
||||
task_type="CustomVoice",
|
||||
language=None,
|
||||
instructions=None,
|
||||
max_new_tokens=None,
|
||||
device=args.device,
|
||||
left_context=72,
|
||||
ramp=None,
|
||||
seed=args.seed,
|
||||
out=str(out_dir / "client.wav") if out_dir else None,
|
||||
json=str(out_dir / "client.json") if out_dir else None,
|
||||
)
|
||||
result = asyncio.run(client.run(client_args))
|
||||
import soundfile as sf
|
||||
|
||||
test, _ = sf.read(client_args.out, dtype="float32") if client_args.out else (np.zeros(0, dtype=np.float32), 0)
|
||||
report = {
|
||||
"ref_samples": int(ref.size),
|
||||
"client_samples": int(test.size),
|
||||
"snr_db": snr_db(ref, test),
|
||||
"length_ratio": (test.size / ref.size) if ref.size else None,
|
||||
"client_ttfa_ms": result.get("ttfa_ms"),
|
||||
"client_rtf": result.get("rtf"),
|
||||
"frames": result.get("total_frames"),
|
||||
}
|
||||
print(json.dumps(report, indent=1))
|
||||
if out_dir:
|
||||
(out_dir / "parity.json").write_text(json.dumps(report, indent=1))
|
||||
sf.write(str(out_dir / "full.wav"), ref, 24000, format="WAV")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Codec-token streaming client with on-device decoding (Qwen3-TTS).
|
||||
|
||||
Connects to ``/v1/audio/speech/stream`` of a **talker-only** deployment
|
||||
(``--deploy-config qwen3_tts_talker_only.yaml``), requests
|
||||
``output_mode="codec_tokens"``, receives 16-codebook frames at 12.5 Hz and
|
||||
decodes them locally with ``Qwen3TTSTokenizerV2Decoder`` (from the model's
|
||||
``speech_tokenizer/`` folder) using the same chunk schedule / 72-frame left
|
||||
context as the server-side Code2Wav stage. Measures TTFA (first PCM out),
|
||||
per-chunk decode time, playback-start latency and RTF, and writes a WAV.
|
||||
|
||||
Example:
|
||||
python edge_decoder_client.py --host localhost --port 8000 \\
|
||||
--model Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice --voice vivian \\
|
||||
--text "Hello from the edge." --device cpu --out out.wav
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
sys.path.insert(0, str(REPO_ROOT / "benchmarks" / "tts"))
|
||||
|
||||
from vllm_omni.entrypoints.openai.codec_stream import FLAG_EOS, FLAG_RESET, unpack_codec_frame # noqa: E402
|
||||
from vllm_omni.model_executor.stage_input_processors.chunk_size_utils import parse_chunk_ramp # noqa: E402
|
||||
|
||||
DEFAULT_RAMP = [2, 4, 8, 16, 25]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ decoder
|
||||
class LocalDecoder:
|
||||
"""Stateful chunked Qwen3-TTS decoder mirroring the Code2Wav stage."""
|
||||
|
||||
def __init__(
|
||||
self, model: str, device: str = "cpu", left_context: int = 72, chunk_frames: int = 25, dtype: str = "float32"
|
||||
):
|
||||
from safetensors.torch import load_file
|
||||
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import (
|
||||
Qwen3TTSTokenizerV2Config,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import (
|
||||
Qwen3TTSTokenizerV2Decoder,
|
||||
)
|
||||
|
||||
model_dir = self._resolve_dir(model)
|
||||
cfg = Qwen3TTSTokenizerV2Config.from_pretrained(str(model_dir), subfolder="speech_tokenizer")
|
||||
self.decoder = Qwen3TTSTokenizerV2Decoder._from_config(cfg.decoder_config)
|
||||
state = load_file(str(model_dir / "speech_tokenizer" / "model.safetensors"))
|
||||
dec_state = {k[len("decoder.") :]: v for k, v in state.items() if k.startswith("decoder.")}
|
||||
missing, unexpected = self.decoder.load_state_dict(dec_state, strict=False)
|
||||
if unexpected:
|
||||
raise RuntimeError(f"unexpected decoder keys: {unexpected[:5]}")
|
||||
if missing:
|
||||
print(f"[decoder] warning: {len(missing)} missing keys (first: {missing[:3]})", file=sys.stderr)
|
||||
self.decoder.eval().to(device=device, dtype=getattr(torch, dtype))
|
||||
if hasattr(self.decoder, "precompute_snake_caches"):
|
||||
self.decoder.precompute_snake_caches()
|
||||
if hasattr(self.decoder, "_incremental_chunk_frames"):
|
||||
self.decoder._incremental_chunk_frames = chunk_frames
|
||||
self.device = device
|
||||
self.num_quantizers = int(cfg.decoder_config.num_quantizers)
|
||||
self.sample_rate = int(cfg.output_sample_rate)
|
||||
self.left_context = left_context
|
||||
self.chunk_frames = chunk_frames
|
||||
self.reset()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_dir(model: str) -> Path:
|
||||
p = Path(model)
|
||||
if p.is_dir():
|
||||
return p
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
return Path(snapshot_download(model, local_files_only=True))
|
||||
|
||||
def reset(self) -> None:
|
||||
self.state: dict[str, Any] = {"prefix_frames": 0}
|
||||
self.frames_decoded = 0
|
||||
|
||||
@torch.inference_mode()
|
||||
def decode(self, codes: np.ndarray) -> np.ndarray:
|
||||
"""Decode ``[n_frames, Q]`` new frames, carrying state across calls; returns float32 PCM."""
|
||||
if codes.shape[0] == 0:
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
qf = torch.as_tensor(codes.T, dtype=torch.long, device=self.device) # [Q, F]
|
||||
wavs = self.decoder.batched_chunked_decode(
|
||||
[qf],
|
||||
[int(qf.shape[-1])],
|
||||
caches=[self.state],
|
||||
chunk_size=self.chunk_frames,
|
||||
left_context_size=self.left_context,
|
||||
max_batch_size=1,
|
||||
)
|
||||
wav = wavs[0]
|
||||
if wav.dim() == 2 and wav.shape[0] == 1:
|
||||
wav = wav[0]
|
||||
self.frames_decoded += int(codes.shape[0])
|
||||
return wav.float().cpu().numpy().reshape(-1)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- client
|
||||
async def run(args: argparse.Namespace) -> dict[str, Any]:
|
||||
import websockets
|
||||
|
||||
ramp = args.ramp or DEFAULT_RAMP
|
||||
schedule = parse_chunk_ramp({"codec_chunk_ramp": ramp}, steady=ramp[-1]) or ramp
|
||||
steady = schedule[-1]
|
||||
decoder = LocalDecoder(args.model, device=args.device, left_context=args.left_context, chunk_frames=steady)
|
||||
url = f"ws://{args.host}:{args.port}/v1/audio/speech/stream"
|
||||
config: dict[str, Any] = {
|
||||
"type": "session.config",
|
||||
"model": args.model,
|
||||
"output_mode": "codec_tokens",
|
||||
"stream_audio": True,
|
||||
"response_format": "pcm",
|
||||
}
|
||||
for key in ("voice", "task_type", "language", "instructions", "max_new_tokens", "seed"):
|
||||
val = getattr(args, key, None)
|
||||
if val is not None:
|
||||
config[key] = val
|
||||
pcm_parts: list[np.ndarray] = []
|
||||
chunk_log: list[dict[str, Any]] = []
|
||||
pending = np.zeros((0, decoder.num_quantizers), dtype=np.int64)
|
||||
chunk_idx = 0
|
||||
t0 = None
|
||||
t_first_pcm = None
|
||||
codec_start = None
|
||||
total_frames = 0
|
||||
async with websockets.connect(url, max_size=64 * 1024 * 1024) as ws:
|
||||
await ws.send(json.dumps(config))
|
||||
await ws.send(json.dumps({"type": "input.text", "text": args.text}))
|
||||
t0 = time.perf_counter()
|
||||
await ws.send(json.dumps({"type": "input.done"}))
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
now = time.perf_counter()
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
seq, n, flags, arr = unpack_codec_frame(bytes(raw), decoder.num_quantizers)
|
||||
if flags & FLAG_RESET:
|
||||
decoder.reset()
|
||||
pending = pending[:0]
|
||||
total_frames += n
|
||||
if n:
|
||||
pending = np.concatenate([pending, arr.astype(np.int64)], axis=0)
|
||||
eos = bool(flags & FLAG_EOS)
|
||||
target = schedule[min(chunk_idx, len(schedule) - 1)]
|
||||
while pending.shape[0] >= target or (eos and pending.shape[0] > 0):
|
||||
take = pending[: min(target, steady)] if not eos else pending[:steady]
|
||||
pending = pending[take.shape[0] :]
|
||||
td = time.perf_counter()
|
||||
pcm = decoder.decode(take)
|
||||
t_dec = time.perf_counter() - td
|
||||
if t_first_pcm is None and pcm.size:
|
||||
t_first_pcm = time.perf_counter()
|
||||
pcm_parts.append(pcm)
|
||||
chunk_log.append(
|
||||
{
|
||||
"idx": chunk_idx,
|
||||
"t_ms": (time.perf_counter() - t0) * 1e3,
|
||||
"arrival_ms": (now - t0) * 1e3,
|
||||
"frames": int(take.shape[0]),
|
||||
"samples": int(pcm.size),
|
||||
"decode_ms": t_dec * 1e3,
|
||||
}
|
||||
)
|
||||
chunk_idx += 1
|
||||
target = schedule[min(chunk_idx, len(schedule) - 1)]
|
||||
if eos:
|
||||
continue
|
||||
else:
|
||||
msg = json.loads(raw)
|
||||
mtype = msg.get("type")
|
||||
if mtype == "codec.start":
|
||||
codec_start = msg
|
||||
elif mtype in ("codec.done", "audio.done"):
|
||||
pass
|
||||
elif mtype == "session.done":
|
||||
break
|
||||
elif mtype == "error":
|
||||
raise RuntimeError(f"server error: {msg.get('message')}")
|
||||
await ws.send(json.dumps({"type": "session.close"}))
|
||||
import stream_latency_metrics as metrics
|
||||
|
||||
sr = decoder.sample_rate
|
||||
audio = np.concatenate(pcm_parts) if pcm_parts else np.zeros(0, dtype=np.float32)
|
||||
total_s = (time.perf_counter() - t0) if t0 else 0.0
|
||||
result = {
|
||||
"codec_start": codec_start,
|
||||
"schedule": schedule,
|
||||
"total_frames": total_frames,
|
||||
"ttfa_ms": ((t_first_pcm - t0) * 1e3) if t_first_pcm else None,
|
||||
"playback_start_ms": metrics.playback_start_ms(chunk_log, sr),
|
||||
"stall_at_ttfa_ms": metrics.stall_at_ttfa_ms(chunk_log, sr),
|
||||
"audio_s": audio.size / sr,
|
||||
"total_s": total_s,
|
||||
"rtf": (total_s / (audio.size / sr)) if audio.size else None,
|
||||
"decode_ms_total": sum(c["decode_ms"] for c in chunk_log),
|
||||
"chunks": chunk_log,
|
||||
"device": args.device,
|
||||
}
|
||||
if args.out:
|
||||
import soundfile as sf
|
||||
|
||||
sf.write(args.out, audio, sr, format="WAV")
|
||||
result["wav"] = args.out
|
||||
if args.json:
|
||||
Path(args.json).write_text(json.dumps(result, indent=1))
|
||||
print(json.dumps({k: v for k, v in result.items() if k != "chunks"}, indent=1))
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--host", default="localhost")
|
||||
p.add_argument("--port", type=int, default=8000)
|
||||
p.add_argument(
|
||||
"--model", required=True, help="HF id or local dir (decoder weights are read from speech_tokenizer/)"
|
||||
)
|
||||
p.add_argument("--text", default="Hello, this is a codec token streaming test from the edge decoder client.")
|
||||
p.add_argument("--voice", default="vivian")
|
||||
p.add_argument("--task-type", dest="task_type", default="CustomVoice")
|
||||
p.add_argument("--language", default=None)
|
||||
p.add_argument("--instructions", default=None)
|
||||
p.add_argument("--max-new-tokens", dest="max_new_tokens", type=int, default=None)
|
||||
p.add_argument("--seed", type=int, default=None)
|
||||
p.add_argument("--device", default="cpu")
|
||||
p.add_argument("--left-context", type=int, default=72)
|
||||
p.add_argument("--ramp", type=int, nargs="*", default=None, help="chunk schedule in frames, default 2 4 8 16 25")
|
||||
p.add_argument("--out", default=None)
|
||||
p.add_argument("--json", default=None)
|
||||
args = p.parse_args(argv)
|
||||
asyncio.run(run(args))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Talker-only Qwen3-TTS pipeline registration (CPU)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES
|
||||
from vllm_omni.config.stage_config import _DEPLOY_DIR, StageExecutionType, load_deploy_config, merge_pipeline_deploy
|
||||
from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_PIPELINE, QWEN3_TTS_TALKER_ONLY_PIPELINE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_registered_single_terminal_stage():
|
||||
assert OMNI_PIPELINES["qwen3_tts_talker_only"] is QWEN3_TTS_TALKER_ONLY_PIPELINE
|
||||
p = QWEN3_TTS_TALKER_ONLY_PIPELINE
|
||||
assert len(p.stages) == 1
|
||||
s = p.stages[0]
|
||||
assert s.execution_type is StageExecutionType.LLM_AR
|
||||
assert s.final_output is True and s.final_output_type == "latent" and s.engine_output_type == "latent"
|
||||
assert s.model_stage == QWEN3_TTS_PIPELINE.stages[0].model_stage == "qwen3_tts"
|
||||
assert s.custom_process_next_stage_input_func is None
|
||||
assert s.async_chunk_process_next_stage_input_func is None
|
||||
assert s.sampling_constraints["stop_token_ids"] == [2150]
|
||||
|
||||
|
||||
def test_deploy_yaml_merges_to_one_stage_config():
|
||||
deploy_path = _DEPLOY_DIR / "qwen3_tts_talker_only.yaml"
|
||||
assert deploy_path.exists()
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
assert len(deploy.stages) == 1 and deploy.stages[0].devices == "0"
|
||||
merged = merge_pipeline_deploy(QWEN3_TTS_TALKER_ONLY_PIPELINE, deploy)
|
||||
stage_configs = merged if isinstance(merged, list) else getattr(merged, "stages", merged)
|
||||
assert len(stage_configs) == 1
|
||||
cfg = stage_configs[0]
|
||||
engine_args = getattr(cfg, "engine_args", cfg)
|
||||
assert bool(getattr(engine_args, "async_chunk", False)) is False
|
||||
assert getattr(engine_args, "custom_process_next_stage_input_func", None) is None
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Codec-token streaming: wire format, row alignment, and handler behaviour (CPU)."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from vllm_omni.entrypoints.openai import codec_stream as cs
|
||||
from vllm_omni.entrypoints.openai.protocol.audio import StreamingSpeechSessionConfig
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_pack_unpack_roundtrip_and_flags():
|
||||
codes = np.random.randint(0, 2048, size=(25, 16)).astype(np.int64)
|
||||
buf = cs.pack_codec_frame(7, torch.from_numpy(codes), eos=False, segment_end=True)
|
||||
assert len(buf) == cs.HEADER_SIZE + 25 * 16 * 2
|
||||
seq, n, flags, arr = cs.unpack_codec_frame(buf, 16)
|
||||
assert seq == 7 and n == 25 and flags == cs.FLAG_SEGMENT_END
|
||||
assert arr.dtype == np.int16 and np.array_equal(arr.astype(np.int64), codes)
|
||||
# EOS with zero frames
|
||||
buf = cs.pack_codec_frame(8, None, eos=True, codebooks=16)
|
||||
seq, n, flags, arr = cs.unpack_codec_frame(buf, 16)
|
||||
assert (seq, n, flags) == (8, 0, cs.FLAG_EOS) and arr.shape == (0, 16)
|
||||
buf = cs.pack_codec_frame(2**32 + 5, np.zeros((1, 16), dtype=np.int64), reset=True)
|
||||
assert cs.unpack_codec_frame(buf, 16)[0] == 5
|
||||
assert cs.unpack_codec_frame(buf, 16)[2] == cs.FLAG_RESET
|
||||
|
||||
|
||||
def test_pack_rejects_bad_shapes_and_ranges():
|
||||
with pytest.raises(ValueError):
|
||||
cs.pack_codec_frame(0, np.zeros((2, 2, 2), dtype=np.int64))
|
||||
with pytest.raises(ValueError):
|
||||
cs.pack_codec_frame(0, np.full((1, 16), 40000, dtype=np.int64))
|
||||
with pytest.raises(ValueError):
|
||||
cs.unpack_codec_frame(b"\x00\x01", 16)
|
||||
with pytest.raises(ValueError):
|
||||
cs.unpack_codec_frame(cs.pack_codec_frame(0, np.zeros((1, 16), dtype=np.int64))[:-2], 16)
|
||||
|
||||
|
||||
def test_align_codec_rows_drops_prefill_placeholder_and_eos():
|
||||
# 1 prefill placeholder row (zeros, emitted before any token) + 3 real frames + EOS row
|
||||
codes = torch.tensor([[0] * 16, [5] * 16, [6] * 16, [7] * 16, [0] * 16])
|
||||
token_ids = [5, 6, 7, 2150] # codebook-0 ids then EOS
|
||||
rows = cs.align_codec_rows(codes, token_ids, 2048)
|
||||
assert rows.shape == (3, 16) and rows[:, 0].tolist() == [5, 6, 7]
|
||||
# trailing -1 placeholders in token ids are ignored
|
||||
rows = cs.align_codec_rows(codes, token_ids + [-1, -1], 2048)
|
||||
assert rows.shape == (3, 16)
|
||||
# out-of-range rows are dropped
|
||||
bad = torch.tensor([[5] * 16, [3000] * 16])
|
||||
assert cs.align_codec_rows(bad, [5, 6], 2048).shape == (1, 16)
|
||||
assert cs.align_codec_rows(None, [], 2048).shape == (0, 0)
|
||||
assert cs.align_codec_rows(torch.zeros(0, 16, dtype=torch.long), [], 2048).shape == (0, 16)
|
||||
|
||||
|
||||
def test_session_config_codec_mode_validation():
|
||||
cfg = StreamingSpeechSessionConfig(output_mode="codec_tokens", stream_audio=True, response_format="pcm")
|
||||
assert cfg.output_mode == "codec_tokens"
|
||||
with pytest.raises(ValueError):
|
||||
StreamingSpeechSessionConfig(output_mode="codec_tokens", stream_audio=False, response_format="pcm")
|
||||
with pytest.raises(ValueError):
|
||||
StreamingSpeechSessionConfig(
|
||||
output_mode="codec_tokens", stream_audio=True, response_format="pcm", word_timestamps=True
|
||||
)
|
||||
assert StreamingSpeechSessionConfig().output_mode == "pcm"
|
||||
|
||||
|
||||
def test_codec_start_message_fields():
|
||||
spec = {"codebooks": 16, "codebook_size": 2048, "frame_rate_hz": 12.5, "sample_rate": 24000, "decoder_id": "x"}
|
||||
msg = cs.codec_start_message(utterance_index=1, sentence_index=0, sentence_text="hi", spec=spec, model="m")
|
||||
assert msg["type"] == "codec.start" and msg["codebooks"] == 16 and msg["header_format"] == cs.HEADER_FMT
|
||||
assert msg["decoder_id"] == "x" and msg["version"] == cs.CODEC_STREAM_VERSION
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- handler
|
||||
def _codec_app(mocker, *, supports: bool = True, cancel_after: int | None = None):
|
||||
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
|
||||
from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler
|
||||
|
||||
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
|
||||
speech_service.forced_aligner_enabled = False
|
||||
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req-1", object(), {}))
|
||||
adapter = mocker.MagicMock()
|
||||
adapter.supports_codec_stream = supports
|
||||
adapter.codec_stream_spec = {
|
||||
"codebooks": 16,
|
||||
"codebook_size": 2048,
|
||||
"frame_rate_hz": 12.5,
|
||||
"sample_rate": 24000,
|
||||
"decoder_id": "d",
|
||||
}
|
||||
speech_service._get_tts_adapter = mocker.MagicMock(return_value=adapter)
|
||||
speech_service.engine_client = mocker.MagicMock()
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
|
||||
async def mock_codec_chunks(_generator, _request_id, *, tts_params=None, min_frames=1):
|
||||
import asyncio
|
||||
|
||||
for i in range(3):
|
||||
await asyncio.sleep(0.01)
|
||||
yield np.full((1 + i, 16), i, dtype=np.int64), i == 2
|
||||
|
||||
speech_service._generate_codec_chunks = mock_codec_chunks
|
||||
handler = OmniStreamingSpeechHandler(speech_service=speech_service, idle_timeout=5.0, config_timeout=5.0)
|
||||
app = FastAPI()
|
||||
|
||||
@app.websocket("/v1/audio/speech/stream")
|
||||
async def ws_endpoint(websocket: WebSocket):
|
||||
# NOTE: no ``from __future__ import annotations`` in this module: FastAPI
|
||||
# must resolve the WebSocket annotation, or it treats it as a query param.
|
||||
await handler.handle_session(websocket)
|
||||
|
||||
return app, speech_service
|
||||
|
||||
|
||||
def test_codec_stream_handler_frames_and_done(mocker):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app, svc = _codec_app(mocker)
|
||||
with TestClient(app) as client, client.websocket_connect("/v1/audio/speech/stream") as ws:
|
||||
ws.send_json(
|
||||
{"type": "session.config", "output_mode": "codec_tokens", "stream_audio": True, "response_format": "pcm"}
|
||||
)
|
||||
ws.send_json({"type": "input.text", "text": "hello"})
|
||||
ws.send_json({"type": "input.done"})
|
||||
start = ws.receive_json()
|
||||
assert start["type"] == "codec.start" and start["codebooks"] == 16
|
||||
frames = []
|
||||
while True:
|
||||
msg = ws.receive()
|
||||
if "bytes" in msg and msg["bytes"] is not None:
|
||||
frames.append(cs.unpack_codec_frame(msg["bytes"], 16))
|
||||
continue
|
||||
done = __import__("json").loads(msg["text"])
|
||||
break
|
||||
assert [f[1] for f in frames] == [1, 2, 3]
|
||||
assert [f[0] for f in frames] == [0, 1, 2]
|
||||
assert frames[-1][2] & cs.FLAG_EOS
|
||||
assert done["type"] == "codec.done" and done["total_frames"] == 6 and done["cancelled"] is False
|
||||
assert ws.receive_json()["type"] == "session.done"
|
||||
svc.engine_client.abort.assert_not_awaited()
|
||||
|
||||
|
||||
def test_codec_stream_rejected_when_adapter_lacks_support(mocker):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app, _ = _codec_app(mocker, supports=False)
|
||||
with TestClient(app) as client, client.websocket_connect("/v1/audio/speech/stream") as ws:
|
||||
ws.send_json(
|
||||
{"type": "session.config", "output_mode": "codec_tokens", "stream_audio": True, "response_format": "pcm"}
|
||||
)
|
||||
ws.send_json({"type": "input.text", "text": "hello"})
|
||||
ws.send_json({"type": "input.done"})
|
||||
err = ws.receive_json()
|
||||
assert err["type"] == "error" and "not supported" in err["message"]
|
||||
|
||||
|
||||
def test_input_cancel_mid_stream_aborts_request(mocker):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app, svc = _codec_app(mocker)
|
||||
with TestClient(app) as client, client.websocket_connect("/v1/audio/speech/stream") as ws:
|
||||
ws.send_json(
|
||||
{"type": "session.config", "output_mode": "codec_tokens", "stream_audio": True, "response_format": "pcm"}
|
||||
)
|
||||
ws.send_json({"type": "input.text", "text": "hello"})
|
||||
ws.send_json({"type": "input.done"})
|
||||
assert ws.receive_json()["type"] == "codec.start"
|
||||
ws.send_json({"type": "input.cancel"})
|
||||
types = []
|
||||
while True:
|
||||
msg = ws.receive()
|
||||
if "bytes" in msg and msg["bytes"] is not None:
|
||||
continue
|
||||
payload = __import__("json").loads(msg["text"])
|
||||
types.append(payload["type"])
|
||||
if payload["type"] == "session.done":
|
||||
break
|
||||
if payload["type"] == "codec.done":
|
||||
assert payload["cancelled"] is True
|
||||
assert "codec.done" in types and types[-1] == "session.done"
|
||||
svc.engine_client.abort.assert_awaited()
|
||||
@@ -107,7 +107,7 @@ from vllm_omni.model_executor.models.qwen3_omni.pipeline import (
|
||||
QWEN3_OMNI_THINKER_ONLY_PIPELINE,
|
||||
resolve_qwen3_omni_pipeline,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_PIPELINE
|
||||
from vllm_omni.model_executor.models.qwen3_tts.pipeline import QWEN3_TTS_PIPELINE, QWEN3_TTS_TALKER_ONLY_PIPELINE
|
||||
from vllm_omni.model_executor.models.step_audio2.pipeline import (
|
||||
STEP_AUDIO2_ASR_PIPELINE,
|
||||
STEP_AUDIO2_PIPELINE,
|
||||
@@ -134,6 +134,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
|
||||
"qwen3_omni_moe": resolve_qwen3_omni_pipeline,
|
||||
"qwen3_omni_moe_thinker_only": QWEN3_OMNI_THINKER_ONLY_PIPELINE,
|
||||
"qwen3_tts": QWEN3_TTS_PIPELINE,
|
||||
"qwen3_tts_talker_only": QWEN3_TTS_TALKER_ONLY_PIPELINE,
|
||||
"step_audio_2": STEP_AUDIO2_PIPELINE,
|
||||
"step_audio_2_asr": STEP_AUDIO2_ASR_PIPELINE,
|
||||
"covo_audio": COVO_AUDIO_PIPELINE,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Qwen3-TTS talker-only deployment: stage 0 is terminal and publishes codec
|
||||
# frames (codes.audio, 16 codebooks @ 12.5 Hz) to the client instead of PCM.
|
||||
# Used by the codec-token streaming mode of /v1/audio/speech/stream
|
||||
# (session.config.output_mode: "codec_tokens") with decoding on the client
|
||||
# device (see examples/online_serving/text_to_speech/qwen3_tts/edge_decoder_client.py).
|
||||
# Stage settings mirror stage 0 of qwen3_tts.yaml.
|
||||
pipeline: qwen3_tts_talker_only
|
||||
async_chunk: false
|
||||
stages:
|
||||
- stage_id: 0
|
||||
max_num_seqs: 64
|
||||
gpu_memory_utilization: 0.3
|
||||
trust_remote_code: true
|
||||
enable_prefix_caching: false
|
||||
async_scheduling: true
|
||||
max_num_batched_tokens: 32768
|
||||
max_model_len: 4096
|
||||
devices: '0'
|
||||
default_sampling_params:
|
||||
temperature: 0.9
|
||||
top_k: 50
|
||||
max_tokens: 4096
|
||||
min_tokens: 2
|
||||
repetition_penalty: 1.05
|
||||
silence_ban_frames: 0
|
||||
subtalker_sampling_params:
|
||||
do_sample: true
|
||||
temperature: 0.9
|
||||
top_k: 50
|
||||
top_p: 1.0
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Wire format for codec-token streaming (server → on-device decoder).
|
||||
|
||||
Binary frame layout (little-endian):
|
||||
|
||||
header ``<IHB`` seq:uint32 n_frames:uint16 flags:uint8 (7 bytes)
|
||||
body int16[n_frames * codebooks] row-major (frame-major)
|
||||
|
||||
Flags: ``FLAG_EOS`` (last frame of the utterance), ``FLAG_SEGMENT_END``
|
||||
(sentence boundary inside a flush), ``FLAG_RESET`` (client must drop its
|
||||
decoder state before consuming this frame). An EOS frame may carry
|
||||
``n_frames == 0``.
|
||||
|
||||
Codebook ids are ``< 32768`` for every supported codec (Qwen3-TTS: 2048), so
|
||||
int16 is lossless. Pure functions only; no engine imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
HEADER_FMT = "<IHB"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FMT)
|
||||
FLAG_EOS = 0x1
|
||||
FLAG_SEGMENT_END = 0x2
|
||||
FLAG_RESET = 0x4
|
||||
MAX_FRAMES_PER_PACKET = 0xFFFF
|
||||
CODEC_STREAM_VERSION = 1
|
||||
|
||||
|
||||
def pack_codec_frame(
|
||||
seq: int,
|
||||
codes: Any,
|
||||
*,
|
||||
eos: bool = False,
|
||||
segment_end: bool = False,
|
||||
reset: bool = False,
|
||||
codebooks: int | None = None,
|
||||
) -> bytes:
|
||||
"""Pack ``codes`` (``[n_frames, codebooks]`` ints, or ``None``/empty) into one binary frame."""
|
||||
if codes is None:
|
||||
arr = np.zeros((0, codebooks or 0), dtype=np.int16)
|
||||
else:
|
||||
if hasattr(codes, "detach"):
|
||||
codes = codes.detach().cpu().numpy()
|
||||
arr = np.asarray(codes)
|
||||
if arr.ndim == 1:
|
||||
arr = arr.reshape(1, -1) if arr.size else arr.reshape(0, codebooks or 0)
|
||||
if arr.ndim != 2:
|
||||
raise ValueError(f"codes must be 2-D [n_frames, codebooks], got shape {arr.shape}")
|
||||
if arr.size and (arr.min() < 0 or arr.max() > np.iinfo(np.int16).max):
|
||||
raise ValueError("codec ids must fit in int16")
|
||||
arr = arr.astype("<i2", copy=False)
|
||||
n_frames = int(arr.shape[0])
|
||||
if n_frames > MAX_FRAMES_PER_PACKET:
|
||||
raise ValueError(f"too many frames for one packet: {n_frames}")
|
||||
flags = (FLAG_EOS if eos else 0) | (FLAG_SEGMENT_END if segment_end else 0) | (FLAG_RESET if reset else 0)
|
||||
return struct.pack(HEADER_FMT, int(seq) & 0xFFFFFFFF, n_frames, flags) + arr.tobytes(order="C")
|
||||
|
||||
|
||||
def unpack_codec_frame(buf: bytes, codebooks: int) -> tuple[int, int, int, np.ndarray]:
|
||||
"""Inverse of :func:`pack_codec_frame`: ``(seq, n_frames, flags, int16[n_frames, codebooks])``."""
|
||||
if len(buf) < HEADER_SIZE:
|
||||
raise ValueError("codec frame shorter than its header")
|
||||
seq, n_frames, flags = struct.unpack(HEADER_FMT, buf[:HEADER_SIZE])
|
||||
body = buf[HEADER_SIZE:]
|
||||
expected = n_frames * codebooks * 2
|
||||
if len(body) != expected:
|
||||
raise ValueError(f"codec frame body has {len(body)} bytes, expected {expected} for {n_frames}x{codebooks}")
|
||||
arr = (
|
||||
np.frombuffer(body, dtype="<i2").reshape(n_frames, codebooks)
|
||||
if n_frames
|
||||
else np.zeros((0, codebooks), dtype=np.int16)
|
||||
)
|
||||
return seq, n_frames, flags, arr
|
||||
|
||||
|
||||
def align_codec_rows(codes_cum: Any, token_ids_cum: list[int], codebook_size: int) -> np.ndarray:
|
||||
"""Keep the codec rows that correspond to real codebook-0 tokens.
|
||||
|
||||
Mirrors the talker's full-payload filter: rows are tail-aligned to the
|
||||
output token ids, the prefill placeholder row (and any row whose token is
|
||||
not a codebook id, e.g. EOS) is dropped, and rows with out-of-range ids
|
||||
are dropped. Returns ``int64[n, codebooks]`` (possibly empty).
|
||||
"""
|
||||
if codes_cum is None:
|
||||
return np.zeros((0, 0), dtype=np.int64)
|
||||
if hasattr(codes_cum, "detach"):
|
||||
codes_cum = codes_cum.detach().cpu().numpy()
|
||||
arr = np.asarray(codes_cum)
|
||||
if arr.ndim != 2 or arr.size == 0:
|
||||
return np.zeros((0, arr.shape[1] if arr.ndim == 2 else 0), dtype=np.int64)
|
||||
ids = list(token_ids_cum)
|
||||
while ids and ids[-1] == -1:
|
||||
ids.pop()
|
||||
n = min(arr.shape[0], len(ids))
|
||||
if n <= 0:
|
||||
return arr[:0].astype(np.int64)
|
||||
rows = arr[-n:]
|
||||
toks = np.asarray(ids[-n:])
|
||||
tok_ok = (toks >= 0) & (toks < codebook_size)
|
||||
row_ok = (rows.min(axis=1) >= 0) & (rows.max(axis=1) < codebook_size)
|
||||
return rows[tok_ok & row_ok].astype(np.int64)
|
||||
|
||||
|
||||
def codec_start_message(
|
||||
*,
|
||||
utterance_index: int,
|
||||
sentence_index: int,
|
||||
sentence_text: str,
|
||||
spec: dict[str, Any],
|
||||
model: str | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "codec.start",
|
||||
"version": CODEC_STREAM_VERSION,
|
||||
"utterance_index": utterance_index,
|
||||
"sentence_index": sentence_index,
|
||||
"sentence_text": sentence_text,
|
||||
"model": model,
|
||||
"codebooks": int(spec["codebooks"]),
|
||||
"codebook_size": int(spec["codebook_size"]),
|
||||
"frame_rate_hz": float(spec["frame_rate_hz"]),
|
||||
"sample_rate": int(spec["sample_rate"]),
|
||||
"decoder_id": spec.get("decoder_id"),
|
||||
"header_format": HEADER_FMT,
|
||||
}
|
||||
@@ -578,9 +578,26 @@ class StreamingSpeechSessionConfig(BaseModel):
|
||||
"frames (existing behavior)."
|
||||
),
|
||||
)
|
||||
seed: int | None = Field(default=None, description="Random seed forwarded to each utterance request.")
|
||||
output_mode: Literal["pcm", "codec_tokens"] = Field(
|
||||
default="pcm",
|
||||
description=(
|
||||
"'pcm' streams decoded audio. 'codec_tokens' streams the talker's codec frames "
|
||||
"(binary frames described by a 'codec.start' message) for decoding on the client "
|
||||
"device; requires a talker-only deployment (e.g. deploy config qwen3_tts_talker_only.yaml), "
|
||||
"stream_audio=true, response_format='pcm' and word_timestamps=false."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_streaming_constraints(self) -> "StreamingSpeechSessionConfig":
|
||||
if self.output_mode == "codec_tokens":
|
||||
if not self.stream_audio:
|
||||
raise ValueError("output_mode='codec_tokens' requires stream_audio=true.")
|
||||
if self.word_timestamps:
|
||||
raise ValueError("output_mode='codec_tokens' does not support word_timestamps.")
|
||||
if self.response_format != "pcm":
|
||||
raise ValueError("output_mode='codec_tokens' requires response_format='pcm'.")
|
||||
if self.stream_audio:
|
||||
if self.response_format != "pcm":
|
||||
raise ValueError(
|
||||
|
||||
@@ -3251,6 +3251,77 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
):
|
||||
yield chunk
|
||||
|
||||
@staticmethod
|
||||
def _extract_codec_output(res) -> Any:
|
||||
"""Return the cumulative ``codes.audio`` tensor from an engine output, or ``None``."""
|
||||
outputs = getattr(res, "outputs", None)
|
||||
if not outputs:
|
||||
return None
|
||||
mm = getattr(outputs[0], "multimodal_output", None)
|
||||
if mm is None:
|
||||
return None
|
||||
getter = mm.get if hasattr(mm, "get") else None
|
||||
codes = getter("codes") if getter else None
|
||||
if codes is None:
|
||||
return None
|
||||
if hasattr(codes, "get"):
|
||||
return codes.get("audio")
|
||||
return None
|
||||
|
||||
async def _generate_codec_chunks(
|
||||
self,
|
||||
generator,
|
||||
request_id: str,
|
||||
*,
|
||||
tts_params: dict[str, Any] | None = None,
|
||||
min_frames: int = 1,
|
||||
):
|
||||
"""Yield ``(codes[int64, n, codebooks], eos)`` deltas from a talker-only pipeline.
|
||||
|
||||
The AR stage publishes the *cumulative* ``codes.audio`` tensor on every
|
||||
step (``omni_client_multimodal_output_keys``); this generator aligns the
|
||||
rows to the output token ids (dropping the prefill placeholder and the
|
||||
EOS row) and emits only the new rows. The final item always carries
|
||||
``eos=True`` (possibly with zero frames).
|
||||
"""
|
||||
from vllm_omni.entrypoints.openai.codec_stream import align_codec_rows
|
||||
|
||||
adapter = self._get_tts_adapter()
|
||||
spec = getattr(adapter, "codec_stream_spec", None) or {}
|
||||
codebook_size = int(spec.get("codebook_size", 2048))
|
||||
usage_acc = SpeechOutputTokenCounter() if (adapter is not None and adapter.validates_generation) else None
|
||||
token_ids: list[int] = []
|
||||
sent = 0
|
||||
finished = False
|
||||
try:
|
||||
async for res in generator:
|
||||
if usage_acc is not None:
|
||||
usage_acc.observe(res)
|
||||
outputs = getattr(res, "outputs", None)
|
||||
if outputs:
|
||||
new_ids = getattr(outputs[0], "token_ids", None) or []
|
||||
# DELTA outputs carry per-step ids; CUMULATIVE ones carry the whole prefix.
|
||||
if len(new_ids) > len(token_ids) and list(new_ids[: len(token_ids)]) == token_ids:
|
||||
token_ids = list(new_ids)
|
||||
else:
|
||||
token_ids.extend(int(t) for t in new_ids)
|
||||
codes_cum = self._extract_codec_output(res)
|
||||
done = bool(getattr(res, "finished", False))
|
||||
if codes_cum is not None:
|
||||
aligned = align_codec_rows(codes_cum, token_ids, codebook_size)
|
||||
delta = aligned[sent:]
|
||||
if delta.shape[0] >= min_frames or (done and delta.shape[0] > 0):
|
||||
sent = int(aligned.shape[0])
|
||||
yield delta, done
|
||||
if done:
|
||||
finished = True
|
||||
if done and not finished:
|
||||
finished = True
|
||||
yield None, True
|
||||
finally:
|
||||
if usage_acc is not None and tts_params is not None and finished:
|
||||
self._validate_tts_generation(tts_params, usage_acc)
|
||||
|
||||
async def _iter_pcm_audio_bytes(self, request: OpenAICreateSpeechRequest):
|
||||
"""Yield raw PCM bytes for a speech request as soon as chunks are decoded."""
|
||||
request_id, generator, tts_params = await self._prepare_speech_generation(request)
|
||||
|
||||
@@ -53,6 +53,7 @@ Protocol:
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections import deque
|
||||
from contextlib import aclosing
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
@@ -76,6 +77,54 @@ _MAX_CONFIG_MESSAGE_SIZE = 4 * 1024 * 1024 # allow large ref_audio payloads
|
||||
_MAX_INPUT_TEXT_MESSAGE_SIZE = 128 * 1024
|
||||
|
||||
|
||||
_DISCONNECT = object()
|
||||
|
||||
|
||||
class _SessionInbox:
|
||||
"""Single-reader message queue for one WebSocket session.
|
||||
|
||||
``pump`` is the only coroutine that calls ``websocket.receive_text()``;
|
||||
messages (or a disconnect marker) are queued and consumed by the session
|
||||
loop or, during a generation, by ``_iter_with_cancel``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: asyncio.Queue[object] = asyncio.Queue()
|
||||
self._replay: deque[str] = deque()
|
||||
|
||||
async def pump(self, websocket: WebSocket) -> None:
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
await self._queue.put(raw)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
await self._queue.put(_DISCONNECT)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
async def get(self, timeout: float | None) -> str:
|
||||
if self._replay:
|
||||
return self._replay.popleft()
|
||||
item = await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
||||
if item is _DISCONNECT:
|
||||
raise WebSocketDisconnect(code=1006)
|
||||
return item # type: ignore[return-value]
|
||||
|
||||
async def peek_control(self) -> str:
|
||||
"""Wait for the next message; classify cancel/disconnect, replay the rest later."""
|
||||
item = await self._queue.get()
|
||||
if item is _DISCONNECT:
|
||||
return "disconnect"
|
||||
try:
|
||||
msg = json.loads(item) if isinstance(item, str) and item else {}
|
||||
except json.JSONDecodeError:
|
||||
msg = {}
|
||||
if isinstance(msg, dict) and msg.get("type") == "input.cancel":
|
||||
return "cancel"
|
||||
self._replay.append(item) # type: ignore[arg-type]
|
||||
return "other"
|
||||
|
||||
|
||||
class OmniStreamingSpeechHandler:
|
||||
"""Handles WebSocket sessions for streaming text-input TTS.
|
||||
|
||||
@@ -116,14 +165,17 @@ class OmniStreamingSpeechHandler:
|
||||
config: StreamingSpeechSessionConfig | None = None
|
||||
text_parts: list[str] = []
|
||||
utterance_index = 0
|
||||
# One reader task owns ``websocket.receive_text()`` for the whole
|
||||
# session; every consumer reads from the inbox instead. This is what
|
||||
# lets a generation loop notice ``input.cancel`` (or a disconnect)
|
||||
# while it streams, without cancelling a socket receive mid-flight.
|
||||
inbox = _SessionInbox()
|
||||
reader = asyncio.ensure_future(inbox.pump(websocket))
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
raw = await asyncio.wait_for(
|
||||
websocket.receive_text(),
|
||||
timeout=self._config_timeout if config is None else self._idle_timeout,
|
||||
)
|
||||
raw = await inbox.get(timeout=self._config_timeout if config is None else self._idle_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
if config is None:
|
||||
await self._send_error(websocket, "Timeout waiting for session.config")
|
||||
@@ -180,6 +232,7 @@ class OmniStreamingSpeechHandler:
|
||||
full_text,
|
||||
utterance_index=utterance_index,
|
||||
sentence_index=0,
|
||||
inbox=inbox,
|
||||
)
|
||||
total_sentences = 1
|
||||
|
||||
@@ -192,6 +245,12 @@ class OmniStreamingSpeechHandler:
|
||||
)
|
||||
utterance_index += 1
|
||||
|
||||
elif msg_type == "input.cancel":
|
||||
# Nothing is streaming at this point (cancels that arrive
|
||||
# during generation are handled in ``_iter_with_cancel``).
|
||||
text_parts.clear()
|
||||
await websocket.send_json({"type": "input.cancelled", "utterance_index": utterance_index})
|
||||
|
||||
elif msg_type == "session.close":
|
||||
await websocket.close()
|
||||
return
|
||||
@@ -210,6 +269,9 @@ class OmniStreamingSpeechHandler:
|
||||
await self._send_error(websocket, f"Internal error: {e}")
|
||||
except Exception:
|
||||
logger.debug("Failed to send error to streaming speech client", exc_info=True)
|
||||
finally:
|
||||
if not reader.done():
|
||||
reader.cancel()
|
||||
|
||||
async def _parse_message(self, websocket: WebSocket, raw: str) -> dict | None:
|
||||
"""Decode one client message, or report why it was rejected.
|
||||
@@ -257,6 +319,58 @@ class OmniStreamingSpeechHandler:
|
||||
|
||||
return config
|
||||
|
||||
async def _iter_with_cancel(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
stream,
|
||||
request_id: str,
|
||||
inbox: "_SessionInbox | None",
|
||||
):
|
||||
"""Yield items from ``stream`` while watching the session inbox.
|
||||
|
||||
``input.cancel`` aborts the engine request and ends the stream (the
|
||||
``audio.done`` / ``codec.done`` message then carries ``cancelled``);
|
||||
a disconnect raises ``WebSocketDisconnect`` like a plain send would;
|
||||
any other message is left queued for the session loop.
|
||||
"""
|
||||
self._last_cancelled = False
|
||||
if inbox is None:
|
||||
async for item in stream:
|
||||
yield item
|
||||
return
|
||||
next_task = asyncio.ensure_future(stream.__anext__())
|
||||
msg_task = asyncio.ensure_future(inbox.peek_control())
|
||||
try:
|
||||
while True:
|
||||
done, _ = await asyncio.wait({msg_task, next_task}, return_when=asyncio.FIRST_COMPLETED)
|
||||
if msg_task in done:
|
||||
kind = msg_task.result()
|
||||
if kind == "disconnect":
|
||||
next_task.cancel()
|
||||
raise WebSocketDisconnect(code=1006)
|
||||
if kind == "cancel":
|
||||
self._last_cancelled = True
|
||||
try:
|
||||
await self._speech_service.engine_client.abort(request_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to abort request %s on input.cancel", request_id, exc_info=True)
|
||||
next_task.cancel()
|
||||
return
|
||||
msg_task = asyncio.ensure_future(inbox.peek_control())
|
||||
if next_task not in done:
|
||||
continue
|
||||
if next_task in done:
|
||||
try:
|
||||
item = next_task.result()
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
yield item
|
||||
next_task = asyncio.ensure_future(stream.__anext__())
|
||||
finally:
|
||||
for task in (msg_task, next_task):
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
async def _generate_and_send(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
@@ -265,6 +379,7 @@ class OmniStreamingSpeechHandler:
|
||||
*,
|
||||
utterance_index: int,
|
||||
sentence_index: int,
|
||||
inbox: "_SessionInbox | None" = None,
|
||||
) -> None:
|
||||
"""Generate audio for a single sentence and send it over WebSocket.
|
||||
|
||||
@@ -272,6 +387,18 @@ class OmniStreamingSpeechHandler:
|
||||
``sentence_index`` its position inside that flush.
|
||||
"""
|
||||
response_format = config.response_format or "wav"
|
||||
self._last_cancelled = False
|
||||
|
||||
if getattr(config, "output_mode", "pcm") == "codec_tokens":
|
||||
await self._generate_and_send_codec(
|
||||
websocket,
|
||||
config,
|
||||
sentence_text,
|
||||
utterance_index=utterance_index,
|
||||
sentence_index=sentence_index,
|
||||
inbox=inbox,
|
||||
)
|
||||
return
|
||||
|
||||
# Reject unmet word-timestamps preconditions early with a clear reason.
|
||||
if config.word_timestamps:
|
||||
@@ -308,6 +435,7 @@ class OmniStreamingSpeechHandler:
|
||||
ref_text=config.ref_text,
|
||||
x_vector_only_mode=config.x_vector_only_mode,
|
||||
speaker_embedding=config.speaker_embedding,
|
||||
seed=config.seed,
|
||||
stream=config.stream_audio,
|
||||
word_timestamps=config.word_timestamps,
|
||||
)
|
||||
@@ -352,7 +480,7 @@ class OmniStreamingSpeechHandler:
|
||||
tts_params=tts_params,
|
||||
)
|
||||
) as stream:
|
||||
async for chunk in stream:
|
||||
async for chunk in self._iter_with_cancel(websocket, stream, request_id, inbox):
|
||||
total_bytes += len(chunk)
|
||||
await websocket.send_bytes(chunk)
|
||||
else:
|
||||
@@ -388,11 +516,116 @@ class OmniStreamingSpeechHandler:
|
||||
"sentence_index": sentence_index,
|
||||
"total_bytes": total_bytes,
|
||||
"error": generation_failed,
|
||||
# Only present when a client input.cancel stopped the stream
|
||||
# (keeps the existing audio.done contract unchanged otherwise).
|
||||
**({"cancelled": True} if getattr(self, "_last_cancelled", False) else {}),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to send audio.done for sentence %d", sentence_index, exc_info=True)
|
||||
|
||||
async def _generate_and_send_codec(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
config: StreamingSpeechSessionConfig,
|
||||
sentence_text: str,
|
||||
*,
|
||||
utterance_index: int,
|
||||
sentence_index: int,
|
||||
inbox: "_SessionInbox | None" = None,
|
||||
) -> None:
|
||||
"""Stream the talker's codec frames (no server-side decoding).
|
||||
|
||||
Protocol: ``codec.start`` (JSON, codec description) → binary frames
|
||||
(``codec_stream.pack_codec_frame``) → ``codec.done``. Requires a
|
||||
talker-only deployment and an adapter with ``supports_codec_stream``.
|
||||
"""
|
||||
from vllm_omni.entrypoints.openai.codec_stream import codec_start_message, pack_codec_frame
|
||||
|
||||
adapter = self._speech_service._get_tts_adapter()
|
||||
if adapter is None or not getattr(adapter, "supports_codec_stream", False):
|
||||
await self._send_error(websocket, "output_mode='codec_tokens' is not supported by the loaded TTS model.")
|
||||
return
|
||||
spec = dict(adapter.codec_stream_spec)
|
||||
request = OpenAICreateSpeechRequest(
|
||||
input=sentence_text,
|
||||
model=config.model,
|
||||
voice=config.voice,
|
||||
task_type=config.task_type,
|
||||
language=config.language,
|
||||
instructions=config.instructions,
|
||||
response_format="pcm",
|
||||
speed=config.speed,
|
||||
max_new_tokens=config.max_new_tokens,
|
||||
non_streaming_mode=config.non_streaming_mode,
|
||||
ref_audio=config.ref_audio,
|
||||
ref_text=config.ref_text,
|
||||
x_vector_only_mode=config.x_vector_only_mode,
|
||||
speaker_embedding=config.speaker_embedding,
|
||||
seed=config.seed,
|
||||
stream=True,
|
||||
)
|
||||
await websocket.send_json(
|
||||
codec_start_message(
|
||||
utterance_index=utterance_index,
|
||||
sentence_index=sentence_index,
|
||||
sentence_text=sentence_text,
|
||||
spec=spec,
|
||||
model=config.model,
|
||||
)
|
||||
)
|
||||
total_frames = 0
|
||||
seq = 0
|
||||
generation_failed = False
|
||||
request_id = None
|
||||
eos_sent = False
|
||||
try:
|
||||
request_id, generator, tts_params = await self._speech_service._prepare_speech_generation(request)
|
||||
async with aclosing(
|
||||
self._speech_service._generate_codec_chunks(generator, request_id, tts_params=tts_params)
|
||||
) as stream:
|
||||
async for codes, eos in self._iter_with_cancel(websocket, stream, request_id, inbox):
|
||||
n = 0 if codes is None else int(codes.shape[0])
|
||||
total_frames += n
|
||||
await websocket.send_bytes(pack_codec_frame(seq, codes, eos=eos, codebooks=int(spec["codebooks"])))
|
||||
seq += 1
|
||||
eos_sent = eos_sent or eos
|
||||
except WebSocketDisconnect:
|
||||
if request_id is not None:
|
||||
try:
|
||||
await self._speech_service.engine_client.abort(request_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to abort codec stream request %s", request_id, exc_info=True)
|
||||
raise
|
||||
except Exception as e:
|
||||
generation_failed = True
|
||||
logger.error("Codec stream failed for utterance %d: %s", utterance_index, e)
|
||||
await self._send_error(
|
||||
websocket, f"Codec stream failed for utterance {utterance_index}: {e}", partial_audio=total_frames > 0
|
||||
)
|
||||
finally:
|
||||
cancelled = bool(getattr(self, "_last_cancelled", False))
|
||||
if not eos_sent and not generation_failed:
|
||||
try:
|
||||
await websocket.send_bytes(pack_codec_frame(seq, None, eos=True, codebooks=int(spec["codebooks"])))
|
||||
seq += 1
|
||||
except Exception:
|
||||
logger.debug("Failed to send terminal codec frame", exc_info=True)
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "codec.done",
|
||||
"utterance_index": utterance_index,
|
||||
"sentence_index": sentence_index,
|
||||
"total_frames": total_frames,
|
||||
"seq_last": seq - 1,
|
||||
"error": generation_failed,
|
||||
"cancelled": cancelled,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to send codec.done", exc_info=True)
|
||||
|
||||
async def _stream_audio_with_alignments(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -169,6 +169,13 @@ class TTSModelAdapter(ABC):
|
||||
#: Target sample rates validated for this adapter's output path. An empty
|
||||
#: set means that the adapter does not expose per-request resampling.
|
||||
supported_output_sample_rates: ClassVar[frozenset[int]] = frozenset()
|
||||
#: Whether the model's talker can be served alone and its codec frames
|
||||
#: streamed to a client-side decoder (``output_mode="codec_tokens"``).
|
||||
supports_codec_stream: ClassVar[bool] = False
|
||||
#: Codec description sent in ``codec.start`` when ``supports_codec_stream``:
|
||||
#: ``codebooks``, ``codebook_size``, ``frame_rate_hz``, ``sample_rate``,
|
||||
#: ``decoder_id`` and ``eos_token_id``.
|
||||
codec_stream_spec: ClassVar[dict[str, Any]] = {}
|
||||
|
||||
max_new_tokens_min = 1
|
||||
|
||||
|
||||
@@ -44,6 +44,17 @@ class Qwen3TTSAdapter(ARTTSAdapter):
|
||||
stage_keys = frozenset({"qwen3_tts"})
|
||||
name = "qwen3_tts"
|
||||
supported_output_sample_rates = frozenset({8000, 24000})
|
||||
# Talker-only deployments (qwen3_tts_talker_only.yaml) stream 16-codebook
|
||||
# frames at 12.5 Hz for on-device decoding with Qwen3TTSTokenizerV2Decoder.
|
||||
supports_codec_stream = True
|
||||
codec_stream_spec = {
|
||||
"codebooks": 16,
|
||||
"codebook_size": 2048,
|
||||
"frame_rate_hz": 12.5,
|
||||
"sample_rate": 24000,
|
||||
"decoder_id": "qwen3_tts_tokenizer_v2_12hz",
|
||||
"eos_token_id": 2150,
|
||||
}
|
||||
|
||||
def _get_model_variant(self) -> str | None:
|
||||
"""Return the task supported by the loaded Qwen3-TTS checkpoint.
|
||||
|
||||
@@ -56,3 +56,30 @@ QWEN3_TTS_PIPELINE = PipelineConfig(
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Talker-only pipeline: stage 0 is terminal and its codec frames (codes.audio)
|
||||
# are published to the client for on-device decoding (codec-token streaming,
|
||||
# see entrypoints/openai/codec_stream.py). No next-stage processors, so no
|
||||
# omni connector is created; ``final_output_type="latent"`` keeps the AR
|
||||
# output path (audio-specific terminal handling is not applied).
|
||||
QWEN3_TTS_TALKER_ONLY_PIPELINE = PipelineConfig(
|
||||
model_type="qwen3_tts_talker_only",
|
||||
default_deploy_config_name="qwen3_tts_talker_only.yaml",
|
||||
model_arch="Qwen3TTSTalkerForConditionalGeneration",
|
||||
stages=(
|
||||
StagePipelineConfig(
|
||||
stage_id=0,
|
||||
model_stage="qwen3_tts",
|
||||
execution_type=StageExecutionType.LLM_AR,
|
||||
input_sources=(),
|
||||
owns_tokenizer=True,
|
||||
final_output=True,
|
||||
final_output_type="latent",
|
||||
engine_output_type="latent",
|
||||
sampling_constraints={
|
||||
"detokenize": False,
|
||||
"stop_token_ids": [2150],
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user