Make speech streaming default to SSE (#4679)

Signed-off-by: syd520zy <529477025@qq.com>
This commit is contained in:
faceless void authored and GitHub committed 2026-06-30 02:17:24 +00:00
1 parent c45ac74d43
commit 11f6a0b72e
39 files changed
+146 -58

No files matched your search

@@ -201,6 +201,7 @@ async def main():
"input": prompt, "input": prompt,
"voice": "default", "voice": "default",
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"ref_audio": ref_audio_b64, "ref_audio": ref_audio_b64,
"ref_text": args.ref_text, "ref_text": args.ref_text,
@@ -240,6 +241,7 @@ async def main():
"input": prompt, "input": prompt,
"voice": args.voice_name, "voice": args.voice_name,
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"ref_text": args.ref_text, "ref_text": args.ref_text,
"max_new_tokens": 2048, "max_new_tokens": 2048,
+7 -10
View File
@@ -122,8 +122,8 @@ Content-Type: application/json
| `max_new_tokens` | integer | 2048 | Maximum tokens to generate | | `max_new_tokens` | integer | 2048 | Maximum tokens to generate |
| `initial_codec_chunk_frames` | integer | null | Per-request initial chunk size override for TTFA tuning. When null, IC is computed dynamically based on server load. | | `initial_codec_chunk_frames` | integer | null | Per-request initial chunk size override for TTFA tuning. When null, IC is computed dynamically based on server load. |
| `non_streaming_mode` | bool | null | Qwen3-TTS prompt construction mode override. Does not affect HTTP response streaming or async-chunk pipelining. When null, Qwen3-TTS uses model defaults: Base=false, CustomVoice/VoiceDesign=true. | | `non_streaming_mode` | bool | null | Qwen3-TTS prompt construction mode override. Does not affect HTTP response streaming or async-chunk pipelining. When null, Qwen3-TTS uses model defaults: Base=false, CustomVoice/VoiceDesign=true. |
| `stream_format` | string | null | Streaming output format. `"audio"` streams raw audio bytes as they are decoded; `"sse"` streams OpenAI `speech.audio.*` Server-Sent Events. Omit (null) for a single non-streaming binary response. See [Response Format](#response-format). | | `stream` | bool | false | When true, stream OpenAI `speech.audio.*` SSE events (requires `response_format="pcm"` or `"wav"`). For raw PCM/WAV byte streaming, set `stream_format="audio"`. |
| `stream` | bool | false | Legacy streaming switch, equivalent to `stream_format="audio"`. Requires `response_format="pcm"` or `"wav"`. Speed adjustment is not supported when streaming. | | `stream_format` | string | null | Streaming output format. `"audio"` streams raw audio bytes as they are decoded; `"sse"` streams OpenAI `speech.audio.*` Server-Sent Events. If omitted, `stream=true` selects SSE and `stream=false` remains non-streaming. See [Response Format](#response-format). |
**Supported languages:** Only applicable to Qwen3-TTS. Derived from the model configuration (`talker_config.codec_language_id` in the checkpoint's `config.json`), plus `Auto`, which is always accepted. Official Qwen3-TTS checkpoints support: Auto, Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian. **Supported languages:** Only applicable to Qwen3-TTS. Derived from the model configuration (`talker_config.codec_language_id` in the checkpoint's `config.json`), plus `Auto`, which is always accepted. Official Qwen3-TTS checkpoints support: Auto, Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian.
@@ -143,18 +143,15 @@ The response shape depends on the streaming parameters:
complete clip as binary audio data with an appropriate `Content-Type` header (e.g. complete clip as binary audio data with an appropriate `Content-Type` header (e.g.
`audio/wav`). The raw-bytes body has no JSON carrier, so no `usage` is reported. `audio/wav`). The raw-bytes body has no JSON carrier, so no `usage` is reported.
**Raw audio stream** (`stream=true` or `stream_format="audio"`). Streams raw **Raw audio stream** (`stream_format="audio"`). Streams raw audio bytes (PCM or
audio bytes (PCM or WAV) as they are decoded. WAV) as they are decoded.
Both streaming modes (`stream_format="audio"` and `"sse"`) require Both streaming modes (`stream_format="audio"` and `"sse"`) require
`response_format="pcm"` or `"wav"`, and `speed` must be `1.0` (or omitted). `response_format="pcm"` or `"wav"`, and `speed` must be `1.0` (or omitted).
**SSE stream** (`stream_format="sse"`). Streams [OpenAI `speech.audio.*` Server-Sent **SSE stream** (`stream=true` or `stream_format="sse"`). Streams [OpenAI
Events](https://platform.openai.com/docs/api-reference/audio-streaming). Set `speech.audio.*` Server-Sent Events](https://platform.openai.com/docs/api-reference/audio-streaming).
`stream_format="sse"` and leave the legacy `stream` unset: `stream=true` takes Each event has an `event:` line and a JSON `data:` line:
precedence and forces the raw-audio path, so the response would be audio bytes with
no `speech.audio.*` events (and no terminal `usage`). Each event has an `event:`
line and a JSON `data:` line:
- `speech.audio.delta` — a base64 audio chunk: - `speech.audio.delta` — a base64 audio chunk:
@@ -79,7 +79,7 @@ response = client.audio.speech.create(
response.stream_to_file("output.wav") response.stream_to_file("output.wav")
``` ```
Streaming PCM output (where supported) — set `stream=true` with `response_format="pcm"`: Streaming PCM output (where supported) — set `stream=true`, `stream_format="audio"`, and `response_format="pcm"`:
```bash ```bash
curl -X POST http://localhost:8091/v1/audio/speech \ curl -X POST http://localhost:8091/v1/audio/speech \
@@ -88,6 +88,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
"input": "Hello, how are you?", "input": "Hello, how are you?",
"voice": "default", "voice": "default",
"stream": true, "stream": true,
"stream_format": "audio",
"response_format": "pcm" "response_format": "pcm"
}' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 - }' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 -
``` ```
@@ -414,10 +415,11 @@ curl -X POST http://localhost:8091/v1/audio/speech \
"voice": "vivian", "voice": "vivian",
"language": "English", "language": "English",
"stream": true, "stream": true,
"stream_format": "audio",
"response_format": "pcm" "response_format": "pcm"
}' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 - }' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 -
``` ```
Streaming requires `response_format="pcm"` and `async_chunk: true` on the stage config (default in `qwen3_tts.yaml`). `speed` is not supported when streaming. Raw PCM streaming requires `stream_format="audio"`, `response_format="pcm"`, and `async_chunk: true` on the stage config (default in `qwen3_tts.yaml`). `speed` is not supported when streaming.
### Streaming WebSocket ### Streaming WebSocket
The `/v1/audio/speech/stream` endpoint accepts text incrementally, splits it at sentence boundaries, and emits one PCM stream per sentence: The `/v1/audio/speech/stream` endpoint accepts text incrementally, splits it at sentence boundaries, and emits one PCM stream per sentence:
@@ -496,7 +498,7 @@ After startup, `/v1/audio/voices` lists `alice`, and `/v1/audio/speech` can use
```bash ```bash
python voxcpm2/gradio_demo.py python voxcpm2/gradio_demo.py
``` ```
Uses an AudioWorklet-based player adapted from the Qwen3-TTS demo for gap-free playback. Audio is streamed from the OpenAI Speech endpoint with `stream=true`. Uses an AudioWorklet-based player adapted from the Qwen3-TTS demo for gap-free playback. Raw PCM audio is streamed from the OpenAI Speech endpoint with `stream=true` and `stream_format="audio"`.
--- ---
@@ -79,7 +79,7 @@ response = client.audio.speech.create(
response.stream_to_file("output.wav") response.stream_to_file("output.wav")
``` ```
Streaming PCM output (where supported) — set `stream=true` with `response_format="pcm"`: Streaming PCM output (where supported) — set `stream=true`, `stream_format="audio"`, and `response_format="pcm"`:
```bash ```bash
curl -X POST http://localhost:8091/v1/audio/speech \ curl -X POST http://localhost:8091/v1/audio/speech \
@@ -88,6 +88,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
"input": "Hello, how are you?", "input": "Hello, how are you?",
"voice": "default", "voice": "default",
"stream": true, "stream": true,
"stream_format": "audio",
"response_format": "pcm" "response_format": "pcm"
}' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 - }' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 -
``` ```
@@ -363,6 +364,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
\"input\": \"Hello, streaming output from MOSS-TTS-Nano.\", \"input\": \"Hello, streaming output from MOSS-TTS-Nano.\",
\"ref_audio\": \"data:audio/wav;base64,${REF_AUDIO}\", \"ref_audio\": \"data:audio/wav;base64,${REF_AUDIO}\",
\"stream\": true, \"stream\": true,
\"stream_format\": \"audio\",
\"response_format\": \"pcm\" \"response_format\": \"pcm\"
}" --no-buffer | play -t raw -r 48000 -e signed -b 16 -c 1 - }" --no-buffer | play -t raw -r 48000 -e signed -b 16 -c 1 -
``` ```
@@ -528,10 +530,11 @@ curl -X POST http://localhost:8091/v1/audio/speech \
"voice": "vivian", "voice": "vivian",
"language": "English", "language": "English",
"stream": true, "stream": true,
"stream_format": "audio",
"response_format": "pcm" "response_format": "pcm"
}' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 - }' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 -
``` ```
Streaming requires `response_format="pcm"` and `async_chunk: true` on the stage config (default in `qwen3_tts.yaml`). `speed` is not supported when streaming. Raw PCM streaming requires `stream_format="audio"`, `response_format="pcm"`, and `async_chunk: true` on the stage config (default in `qwen3_tts.yaml`). `speed` is not supported when streaming.
### Streaming WebSocket ### Streaming WebSocket
The `/v1/audio/speech/stream` endpoint accepts text incrementally, splits it at sentence boundaries, and emits one PCM stream per sentence: The `/v1/audio/speech/stream` endpoint accepts text incrementally, splits it at sentence boundaries, and emits one PCM stream per sentence:
@@ -632,7 +635,7 @@ After startup, `/v1/audio/voices` lists `alice`, and `/v1/audio/speech` can use
```bash ```bash
python voxcpm2/gradio_demo.py python voxcpm2/gradio_demo.py
``` ```
Uses an AudioWorklet-based player adapted from the Qwen3-TTS demo for gap-free playback. Audio is streamed from the OpenAI Speech endpoint with `stream=true`. Uses an AudioWorklet-based player adapted from the Qwen3-TTS demo for gap-free playback. Raw PCM audio is streamed from the OpenAI Speech endpoint with `stream=true` and `stream_format="audio"`.
--- ---
@@ -60,6 +60,7 @@ def run_tts(args) -> None:
if args.stream: if args.stream:
payload["stream"] = True payload["stream"] = True
payload["stream_format"] = "audio"
payload["response_format"] = "pcm" payload["response_format"] = "pcm"
print(f"Model: {args.model}") print(f"Model: {args.model}")
@@ -66,6 +66,8 @@ def build_payload(
"response_format": "pcm" if stream else response_format, "response_format": "pcm" if stream else response_format,
"stream": stream, "stream": stream,
} }
if stream:
payload["stream_format"] = "audio"
# Voice cloning: ref_audio takes priority over URL. # Voice cloning: ref_audio takes priority over URL.
ref_url_stripped = ref_audio_url.strip() if ref_audio_url else "" ref_url_stripped = ref_audio_url.strip() if ref_audio_url else ""
@@ -53,6 +53,7 @@ def run_tts(args) -> None:
if args.stream: if args.stream:
payload["stream"] = True payload["stream"] = True
payload["stream_format"] = "audio"
payload["response_format"] = "pcm" payload["response_format"] = "pcm"
print(f"Model: {args.model}") print(f"Model: {args.model}")
@@ -64,6 +64,8 @@ def build_payload(
"stream": stream, "stream": stream,
"max_new_tokens": max_new_tokens, "max_new_tokens": max_new_tokens,
} }
if stream:
payload["stream_format"] = "audio"
# Voice cloning: ref_audio takes priority over URL. # Voice cloning: ref_audio takes priority over URL.
ref_url_stripped = ref_audio_url.strip() if ref_audio_url else "" ref_url_stripped = ref_audio_url.strip() if ref_audio_url else ""
@@ -40,6 +40,9 @@ def run_tts_generation(args) -> None:
"ref_audio": args.ref_audio, "ref_audio": args.ref_audio,
"ref_text": args.ref_text, "ref_text": args.ref_text,
} }
if args.stream:
payload["stream_format"] = "audio"
payload["response_format"] = "pcm"
if args.max_new_tokens: if args.max_new_tokens:
payload["max_new_tokens"] = args.max_new_tokens payload["max_new_tokens"] = args.max_new_tokens
@@ -123,6 +123,7 @@ def run_tts(args) -> None:
payload["max_new_tokens"] = args.max_new_tokens payload["max_new_tokens"] = args.max_new_tokens
if args.stream: if args.stream:
payload["stream"] = True payload["stream"] = True
payload["stream_format"] = "audio"
payload["response_format"] = "pcm" payload["response_format"] = "pcm"
api_url = f"{args.api_base}/v1/audio/speech" api_url = f"{args.api_base}/v1/audio/speech"
@@ -65,6 +65,7 @@ PY
\"model\": \"${MODEL}\", \"model\": \"${MODEL}\",
\"input\": \"${TEXT}\", \"input\": \"${TEXT}\",
\"stream\": true, \"stream\": true,
\"stream_format\": \"audio\",
\"response_format\": \"pcm\" \"response_format\": \"pcm\"
}" "$STREAM_OUTPUT" }" "$STREAM_OUTPUT"
;; ;;
@@ -335,6 +335,7 @@ def build_payload(
"ref_audio": encode_audio_to_base64(ref_audio), "ref_audio": encode_audio_to_base64(ref_audio),
"response_format": "pcm" if stream else response_format, "response_format": "pcm" if stream else response_format,
"stream": stream, "stream": stream,
**({"stream_format": "audio"} if stream else {}),
} }
@@ -93,6 +93,8 @@ def build_payload(
"response_format": "pcm" if stream else response_format, "response_format": "pcm" if stream else response_format,
"stream": stream, "stream": stream,
} }
if stream:
payload["stream_format"] = "audio"
if not stream: if not stream:
payload["speed"] = speed payload["speed"] = speed
@@ -515,6 +515,8 @@ def create_app(api_base: str):
"response_format": "pcm" if stream_enabled else "wav", "response_format": "pcm" if stream_enabled else "wav",
"stream": stream_enabled, "stream": stream_enabled,
} }
if stream_enabled:
payload["stream_format"] = "audio"
# Reference audio for cloning # Reference audio for cloning
ref_url_s = ref_url.strip() if ref_url else "" ref_url_s = ref_url.strip() if ref_url else ""
+1
View File
@@ -149,6 +149,7 @@ curl -X POST http://127.0.0.1:8000/v1/audio/speech \
"input": "Streaming PCM test on a single RTX 4090 with VoxCPM2.", "input": "Streaming PCM test on a single RTX 4090 with VoxCPM2.",
"voice": "default", "voice": "default",
"stream": true, "stream": true,
"stream_format": "audio",
"response_format": "pcm" "response_format": "pcm"
}' --no-buffer | play -t raw -r 48000 -e signed -b 16 -c 1 - }' --no-buffer | play -t raw -r 48000 -e signed -b 16 -c 1 -
``` ```
+2 -1
View File
@@ -104,7 +104,8 @@ curl -X POST http://localhost:8091/v1/audio/speech \
"voice": "default", "voice": "default",
"ref_audio": "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS/main/assets/audio/zh_1.wav", "ref_audio": "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS/main/assets/audio/zh_1.wav",
"response_format": "wav", "response_format": "wav",
"stream": true "stream": true,
"stream_format": "audio"
}' --output output.wav }' --output output.wav
``` ```
+2 -1
View File
@@ -148,6 +148,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
"voice": "vivian", "voice": "vivian",
"language": "English", "language": "English",
"stream": true, "stream": true,
"stream_format": "audio",
"response_format": "pcm" "response_format": "pcm"
}' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 - }' --no-buffer | play -t raw -r 24000 -e signed -b 16 -c 1 -
``` ```
@@ -163,7 +164,7 @@ python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py --query-ty
- Memory usage: The deploy config allocates `gpu_memory_utilization: 0.3` per stage (talker + code2wav share a single GPU). For the 0.6B variants or constrained GPUs, adjust via `--gpu-memory-utilization`. - Memory usage: The deploy config allocates `gpu_memory_utilization: 0.3` per stage (talker + code2wav share a single GPU). For the 0.6B variants or constrained GPUs, adjust via `--gpu-memory-utilization`.
- 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. Streaming requires `stream=true` with `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.
- 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.
+1
View File
@@ -80,6 +80,7 @@ curl -X POST http://127.0.0.1:8091/v1/audio/speech \
"voice": "casual_female", "voice": "casual_female",
"language": "English", "language": "English",
"stream": true, "stream": true,
"stream_format": "audio",
"response_format": "pcm" "response_format": "pcm"
}' \ }' \
--output voxtral_stream.pcm --output voxtral_stream.pcm
@@ -104,6 +104,7 @@ def test_voice_clone_zh_002(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt("zh"), "input": get_prompt("zh"),
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "wav", "response_format": "wav",
"ref_audio": REF_AUDIO_URL, "ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT, "ref_text": REF_TEXT,
@@ -97,6 +97,7 @@ def test_voice_clone_zh_async_chunk(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": "今天天气真不错,适合出去散散步。", "input": "今天天气真不错,适合出去散散步。",
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "wav", "response_format": "wav",
"ref_audio": REF_AUDIO_URL, "ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT, "ref_text": REF_TEXT,
@@ -98,6 +98,7 @@ class TestHiggsAudioV2OnlineHappyPath:
"model": omni_server.model, "model": omni_server.model,
"input": "Streaming the quick brown fox over the lazy dog.", "input": "Streaming the quick brown fox over the lazy dog.",
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"timeout": DEFAULT_SPEECH_TIMEOUT_S, "timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_AUDIO_BYTES, "min_audio_bytes": _MIN_AUDIO_BYTES,
@@ -270,6 +271,7 @@ class TestHiggsAudioV2OnlineVoiceClone:
"ref_audio": _REF_AUDIO_URL, "ref_audio": _REF_AUDIO_URL,
"ref_text": _REF_TEXT, "ref_text": _REF_TEXT,
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"timeout": DEFAULT_SPEECH_TIMEOUT_S, "timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_AUDIO_BYTES, "min_audio_bytes": _MIN_AUDIO_BYTES,
@@ -135,6 +135,7 @@ class TestHiggsAudioV3OnlineHappyPath:
"model": omni_server.model, "model": omni_server.model,
"input": "The quick brown fox jumps over the lazy dog.", "input": "The quick brown fox jumps over the lazy dog.",
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"timeout": DEFAULT_SPEECH_TIMEOUT_S, "timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_hnr_db": 0.0, "min_hnr_db": 0.0,
@@ -160,6 +161,7 @@ class TestHiggsAudioV3OnlineHappyPath:
"model": omni_server.model, "model": omni_server.model,
"input": "She sells seashells by the seashore.", "input": "She sells seashells by the seashore.",
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"timeout": DEFAULT_SPEECH_TIMEOUT_S, "timeout": DEFAULT_SPEECH_TIMEOUT_S,
"min_hnr_db": -2.0, "min_hnr_db": -2.0,
@@ -75,6 +75,7 @@ def test_streaming(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": "Hello, this is a voice cloning demo.", "input": "Hello, this is a voice cloning demo.",
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"ref_audio": REF_AUDIO_URL, "ref_audio": REF_AUDIO_URL,
"min_audio_bytes": 1024, "min_audio_bytes": 1024,
@@ -90,6 +90,7 @@ def test_text_to_audio_streaming_001(omni_server, openai_client) -> None:
"input": get_prompt("zh_short"), "input": get_prompt("zh_short"),
"voice": "灵小甄", "voice": "灵小甄",
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "wav", "response_format": "wav",
"timeout": 300.0, "timeout": 300.0,
} }
@@ -137,6 +137,7 @@ def test_text_to_audio_002_streaming(omni_server, openai_client, ref_audio_data_
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt(), "input": get_prompt(),
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"ref_audio": ref_audio_data_url, "ref_audio": ref_audio_data_url,
"min_hnr_db": -5.0, "min_hnr_db": -5.0,
@@ -129,6 +129,7 @@ def test_text_to_audio_002(omni_server, openai_client, ref_audio_data_url) -> No
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt(), "input": get_prompt(),
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"ref_audio": ref_audio_data_url, "ref_audio": ref_audio_data_url,
"min_hnr_db": -5.0, "min_hnr_db": -5.0,
@@ -100,6 +100,7 @@ def test_text_to_audio_002(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt(), "input": get_prompt(),
"stream": True, "stream": True,
"stream_format": "audio",
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
"response_format": "wav", "response_format": "wav",
"task_type": "Base", "task_type": "Base",
@@ -80,6 +80,7 @@ def test_voice_clone_streaming_001(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt(), "input": get_prompt(),
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "wav", "response_format": "wav",
"task_type": "Base", "task_type": "Base",
"voice": "clone", "voice": "clone",
@@ -95,6 +95,7 @@ def test_text_to_audio_002(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt(), "input": get_prompt(),
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "wav", "response_format": "wav",
"task_type": "CustomVoice", "task_type": "CustomVoice",
"voice": "vivian", "voice": "vivian",
@@ -75,6 +75,7 @@ def test_voice_001(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt(), "input": get_prompt(),
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "wav", "response_format": "wav",
"task_type": "CustomVoice", "task_type": "CustomVoice",
"voice": "uncle_fu", "voice": "uncle_fu",
@@ -89,6 +89,7 @@ def test_text_to_audio_002(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt(), "input": get_prompt(),
"stream": True, "stream": True,
"stream_format": "audio",
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
"response_format": "wav", "response_format": "wav",
"voice": "default", "voice": "default",
@@ -62,6 +62,7 @@ def test_voice_clone_streaming_001(omni_server, openai_client) -> None:
"model": omni_server.model, "model": omni_server.model,
"input": get_prompt(), "input": get_prompt(),
"stream": True, "stream": True,
"stream_format": "audio",
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
"response_format": "wav", "response_format": "wav",
"voice": "default", "voice": "default",
@@ -56,6 +56,7 @@ def test_speech_english_streaming(omni_server, openai_client) -> None:
"voice": "casual_female", "voice": "casual_female",
"language": "English", "language": "English",
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
"timeout": 120.0, "timeout": 120.0,
"min_audio_bytes": _MIN_AUDIO_BYTES_BASIC, "min_audio_bytes": _MIN_AUDIO_BYTES_BASIC,
@@ -141,6 +141,7 @@ class TestSpeakerEmbeddingBase:
"x_vector_only_mode": True, "x_vector_only_mode": True,
"response_format": "pcm", "response_format": "pcm",
"stream": True, "stream": True,
"stream_format": "audio",
"max_new_tokens": MAX_NEW_TOKENS, "max_new_tokens": MAX_NEW_TOKENS,
} }
with httpx.Client(timeout=120.0) as client: with httpx.Client(timeout=120.0) as client:
@@ -1849,17 +1849,22 @@ class TestStreamingProtocolValidation:
OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm", speed=2.0) OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm", speed=2.0)
def test_stream_valid(self): def test_stream_valid(self):
"""stream=True + response_format in ('pcm', 'wav') + speed=1.0 is accepted.""" """stream=True + response_format in ('pcm', 'wav') + speed=1.0 is accepted as SSE."""
req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm") req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm")
assert req.stream is True assert req.stream is True
assert req.is_sse_stream() is True
assert req.is_raw_audio_stream() is False
req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="wav") req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="wav")
assert req.stream is True assert req.stream is True
assert req.is_sse_stream() is True
assert req.is_raw_audio_stream() is False
def test_stream_format_audio_is_valid(self): def test_stream_format_audio_is_valid(self):
req = OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm") req = OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm")
assert req.stream_format == "audio" assert req.stream_format == "audio"
assert req.is_raw_audio_stream() is True assert req.is_raw_audio_stream() is True
assert req.is_sse_stream() is False
def test_sse_stream_format_is_valid(self): def test_sse_stream_format_is_valid(self):
"""stream_format='sse' is accepted for /audio/speech.""" """stream_format='sse' is accepted for /audio/speech."""
@@ -1867,6 +1872,7 @@ class TestStreamingProtocolValidation:
assert req.stream_format == "sse" assert req.stream_format == "sse"
assert req.is_sse_stream() is True assert req.is_sse_stream() is True
assert req.is_raw_audio_stream() is False
class TestStreamingResponse: class TestStreamingResponse:
@@ -1939,13 +1945,24 @@ class TestStreamingResponse:
app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None) app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None)
return app return app
@staticmethod
def _assert_sse_audio_response(response, response_format: str = "pcm"):
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
body = response.text
assert "event: speech.audio.delta" in body
assert "event: speech.audio.done" in body
data_line = next(line for line in body.splitlines() if line.startswith("data: "))
payload = json.loads(data_line.removeprefix("data: "))
assert payload["type"] == "speech.audio.delta"
assert payload["response_format"] == response_format
assert base64.b64decode(payload["audio"])
def test_streaming(self, streaming_app): def test_streaming(self, streaming_app):
"""stream=True must return audio/pcm with non-empty body.""" """stream=True defaults to OpenAI speech.audio.* SSE events."""
client = TestClient(streaming_app) client = TestClient(streaming_app)
response = client.post("/v1/audio/speech", json={"input": "Hello", "stream": True, "response_format": "pcm"}) response = client.post("/v1/audio/speech", json={"input": "Hello", "stream": True, "response_format": "pcm"})
assert response.status_code == 200 self._assert_sse_audio_response(response)
assert "audio/pcm" in response.headers["content-type"]
assert len(response.content) > 0
def test_stream_format_audio_streaming(self, streaming_app): def test_stream_format_audio_streaming(self, streaming_app):
"""stream_format=audio without stream=True returns raw audio/pcm chunks.""" """stream_format=audio without stream=True returns raw audio/pcm chunks."""
@@ -1967,25 +1984,26 @@ class TestStreamingResponse:
json={"input": "Hello", "stream_format": "sse", "response_format": "pcm"}, json={"input": "Hello", "stream_format": "sse", "response_format": "pcm"},
) )
assert response.status_code == 200 self._assert_sse_audio_response(response)
assert "text/event-stream" in response.headers["content-type"]
body = response.text
assert "event: speech.audio.delta" in body
assert "event: speech.audio.done" in body
data_line = next(line for line in body.splitlines() if line.startswith("data: "))
payload = json.loads(data_line.removeprefix("data: "))
assert payload["type"] == "speech.audio.delta"
assert payload["response_format"] == "pcm"
assert base64.b64decode(payload["audio"])
def test_stream_true_prefers_raw_audio_over_sse(self, streaming_app): def test_stream_true_with_stream_format_sse_uses_sse(self, streaming_app):
"""stream=True keeps the existing raw audio stream behavior even with stream_format=sse.""" """stream=True and stream_format=sse both select SSE streaming."""
client = TestClient(streaming_app) client = TestClient(streaming_app)
response = client.post( response = client.post(
"/v1/audio/speech", "/v1/audio/speech",
json={"input": "Hello", "stream": True, "stream_format": "sse", "response_format": "pcm"}, json={"input": "Hello", "stream": True, "stream_format": "sse", "response_format": "pcm"},
) )
self._assert_sse_audio_response(response)
def test_stream_format_audio_with_stream_true_opts_into_raw_audio(self, streaming_app):
"""stream_format=audio remains an explicit raw audio opt-in."""
client = TestClient(streaming_app)
response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream": True, "stream_format": "audio", "response_format": "pcm"},
)
assert response.status_code == 200 assert response.status_code == 200
assert "audio/pcm" in response.headers["content-type"] assert "audio/pcm" in response.headers["content-type"]
assert "text/event-stream" not in response.headers["content-type"] assert "text/event-stream" not in response.headers["content-type"]
@@ -3021,7 +3039,10 @@ class TestWAVStreaming:
def test_wav_streaming_success(self, wav_streaming_app): def test_wav_streaming_success(self, wav_streaming_app):
"""Test WAV format streaming returns correct content type and includes WAV header.""" """Test WAV format streaming returns correct content type and includes WAV header."""
client = TestClient(wav_streaming_app) client = TestClient(wav_streaming_app)
response = client.post("/v1/audio/speech", json={"input": "Hello", "stream": True, "response_format": "wav"}) response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream": True, "stream_format": "audio", "response_format": "wav"},
)
assert response.status_code == 200 assert response.status_code == 200
assert "audio/wav" in response.headers["content-type"] assert "audio/wav" in response.headers["content-type"]
@@ -3040,7 +3061,10 @@ class TestWAVStreaming:
unsupported_formats = ["mp3"] unsupported_formats = ["mp3"]
for fmt in unsupported_formats: for fmt in unsupported_formats:
response = client.post("/v1/audio/speech", json={"input": "Hello", "stream": True, "response_format": fmt}) response = client.post(
"/v1/audio/speech",
json={"input": "Hello", "stream": True, "stream_format": "audio", "response_format": fmt},
)
assert response.status_code == 422 assert response.status_code == 422
@@ -3336,6 +3360,23 @@ class TestTTSAsyncOffloading:
mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True) mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True)
def test_prepare_speech_generation_treats_stream_true_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream=True should request delta-style multimodal outputs for SSE streaming."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
qwen3_tts_server._build_tts_params = mocker.MagicMock(
return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]}
)
qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512)
mock_coerce = mocker.patch(
"vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types",
return_value=qwen3_tts_server.engine_client.default_sampling_params_list,
)
request = OpenAICreateSpeechRequest(input="hello", stream=True, response_format="pcm")
asyncio.run(qwen3_tts_server._prepare_speech_generation(request))
mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True)
def test_prepare_speech_generation_treats_audio_as_streaming(self, qwen3_tts_server, mocker: MockerFixture): def test_prepare_speech_generation_treats_audio_as_streaming(self, qwen3_tts_server, mocker: MockerFixture):
"""stream_format=audio should request delta-style multimodal outputs.""" """stream_format=audio should request delta-style multimodal outputs."""
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
+1
View File
@@ -1770,6 +1770,7 @@ class OpenAIClientHandler:
"seed", "seed",
"instructions", "instructions",
"speed", "speed",
"stream_format",
): ):
if key in request_config: if key in request_config:
extra_body[key] = request_config[key] extra_body[key] = request_config[key]
+6 -4
View File
@@ -1133,9 +1133,9 @@ async def async_request_openai_audio_speech(
) -> MixRequestFuncOutput: ) -> MixRequestFuncOutput:
"""Streaming request to /v1/audio/speech endpoint. """Streaming request to /v1/audio/speech endpoint.
Sends ``stream=true`` with ``response_format=pcm`` so the server returns Sends ``stream=true`` with ``stream_format=audio`` and ``response_format=pcm``
raw PCM chunks as they are decoded. This allows measuring TTFP (time to so the server returns raw PCM chunks as they are decoded. This allows measuring
first audio packet) separately from E2EL. TTFP (time to first audio packet) separately from E2EL.
""" """
api_url = request_func_input.api_url api_url = request_func_input.api_url
_validate_api_url(api_url, "OpenAI Audio Speech API", "audio/speech") _validate_api_url(api_url, "OpenAI Audio Speech API", "audio/speech")
@@ -1144,12 +1144,14 @@ async def async_request_openai_audio_speech(
"model": request_func_input.model_name if request_func_input.model_name else request_func_input.model, "model": request_func_input.model_name if request_func_input.model_name else request_func_input.model,
"input": request_func_input.prompt, "input": request_func_input.prompt,
"stream": True, "stream": True,
"stream_format": "audio",
"response_format": "pcm", "response_format": "pcm",
} }
_update_payload_common(payload, request_func_input) _update_payload_common(payload, request_func_input)
# Seed-TTS + WER: ``--extra-body`` may set stream=false / other formats; speech must stream PCM. # Seed-TTS + WER: ``--extra-body`` may set stream=false / other formats; speech must stream PCM.
if getattr(request_func_input, "seed_tts_row", False) and _seed_tts_capture_pcm_for_wer(): if getattr(request_func_input, "seed_tts_row", False) and _seed_tts_capture_pcm_for_wer():
payload["stream"] = True payload["stream"] = True
payload["stream_format"] = "audio"
payload["response_format"] = "pcm" payload["response_format"] = "pcm"
headers = { headers = {
@@ -1219,7 +1221,7 @@ async def async_request_openai_audio_speech(
ct = response.headers.get("Content-Type", "") ct = response.headers.get("Content-Type", "")
logger.warning( logger.warning(
"Seed-TTS WER: HTTP 200 but no PCM bytes (Content-Type=%r, url=%s). " "Seed-TTS WER: HTTP 200 but no PCM bytes (Content-Type=%r, url=%s). "
"Check stream=true and response_format=pcm on the server.", "Check stream=true, stream_format=audio, and response_format=pcm on the server.",
ct, ct,
api_url, api_url,
) )
@@ -69,13 +69,15 @@ class OpenAICreateSpeechRequest(BaseModel):
default=None, default=None,
description=( description=(
"Streaming output format. 'audio' streams raw pcm/wav bytes; " "Streaming output format. 'audio' streams raw pcm/wav bytes; "
"'sse' streams OpenAI speech.audio.* SSE events. Omit for non-streaming." "'sse' streams OpenAI speech.audio.* SSE events. If omitted, stream=true "
"selects SSE and stream=false remains non-streaming."
), ),
) )
stream: bool = Field( stream: bool = Field(
default=False, default=False,
description=( description=(
"Legacy streaming switch; equivalent to stream_format='audio'. " "Streaming switch; defaults to OpenAI speech.audio.* SSE events. "
"Set stream_format='audio' to opt into raw pcm/wav byte streaming. "
"Requires response_format='pcm' or 'wav'. Speed adjustment is not supported when streaming." "Requires response_format='pcm' or 'wav'. Speed adjustment is not supported when streaming."
), ),
) )
@@ -292,13 +294,13 @@ class OpenAICreateSpeechRequest(BaseModel):
return self return self
def is_raw_audio_stream(self) -> bool: def is_raw_audio_stream(self) -> bool:
return self.stream or self.stream_format == "audio" return self.stream_format == "audio"
def is_sse_stream(self) -> bool: def is_sse_stream(self) -> bool:
return self.stream_format == "sse" and not self.is_raw_audio_stream() return (self.stream or self.stream_format == "sse") and not self.is_raw_audio_stream()
def is_streaming(self) -> bool: def is_streaming(self) -> bool:
return self.is_raw_audio_stream() or self.stream_format == "sse" return self.is_raw_audio_stream() or self.is_sse_stream()
@model_validator(mode="after") @model_validator(mode="after")
def validate_streaming_constraints(self) -> "OpenAICreateSpeechRequest": def validate_streaming_constraints(self) -> "OpenAICreateSpeechRequest":
+12 -12
View File
@@ -4038,11 +4038,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
- ref_text: Transcript of reference audio (Base task) - ref_text: Transcript of reference audio (Base task)
- x_vector_only_mode: Use speaker embedding only (Base task) - x_vector_only_mode: Use speaker embedding only (Base task)
Streaming is supported via ``stream_format='audio'`` or the legacy Streaming is supported via the ``stream=True`` switch or ``stream_format='sse'``,
``stream=True`` switch, with ``response_format='pcm'`` or ``'wav'``. which return OpenAI ``speech.audio.*`` SSE events. ``stream_format='audio'``
``stream_format='sse'`` returns OpenAI ``speech.audio.*`` SSE events instead. opts into raw audio streaming with ``response_format='pcm'`` or ``'wav'``.
Each Code2Wav chunk is yielded as raw audio bytes as soon as it is decoded. Raw audio streaming yields each Code2Wav chunk as raw bytes as soon as it is
For WAV format, a header with placeholder size values is emitted first. decoded. Raw WAV streaming emits a header with placeholder size values first.
""" """
if self._diffusion_mode: if self._diffusion_mode:
return await self._create_diffusion_speech(request) return await self._create_diffusion_speech(request)
@@ -4060,14 +4060,14 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
) )
try: try:
if request.is_raw_audio_stream(): if request.is_streaming() and request.word_timestamps:
if request.word_timestamps: return self.create_error_response(
return self.create_error_response( "word_timestamps=true is currently supported by the WebSocket "
"word_timestamps=true is currently supported by the WebSocket " "/v1/audio/speech/stream path. Use session.config with "
"/v1/audio/speech/stream path. Use session.config with " "stream_audio=true and response_format='pcm'."
"stream_audio=true and response_format='pcm'." )
)
if request.is_raw_audio_stream():
response_format, error = self._validate_speech_streaming_request( response_format, error = self._validate_speech_streaming_request(
request, request,
mode_label="Streaming", mode_label="Streaming",