Make speech streaming default to SSE (#4679)
Signed-off-by: syd520zy <529477025@qq.com>
This commit is contained in:
1 parent
c45ac74d43
commit
11f6a0b72e
39 files changed
+141
-53
No files matched your search
@@ -201,6 +201,7 @@ async def main():
|
||||
"input": prompt,
|
||||
"voice": "default",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"ref_audio": ref_audio_b64,
|
||||
"ref_text": args.ref_text,
|
||||
@@ -240,6 +241,7 @@ async def main():
|
||||
"input": prompt,
|
||||
"voice": args.voice_name,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"ref_text": args.ref_text,
|
||||
"max_new_tokens": 2048,
|
||||
|
||||
@@ -122,8 +122,8 @@ Content-Type: application/json
|
||||
| `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. |
|
||||
| `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 | Legacy streaming switch, equivalent to `stream_format="audio"`. Requires `response_format="pcm"` or `"wav"`. Speed adjustment is not supported when streaming. |
|
||||
| `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_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.
|
||||
|
||||
@@ -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.
|
||||
`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
|
||||
audio bytes (PCM or WAV) as they are decoded.
|
||||
**Raw audio stream** (`stream_format="audio"`). Streams raw audio bytes (PCM or
|
||||
WAV) as they are decoded.
|
||||
|
||||
Both streaming modes (`stream_format="audio"` and `"sse"`) require
|
||||
`response_format="pcm"` or `"wav"`, and `speed` must be `1.0` (or omitted).
|
||||
|
||||
**SSE stream** (`stream_format="sse"`). Streams [OpenAI `speech.audio.*` Server-Sent
|
||||
Events](https://platform.openai.com/docs/api-reference/audio-streaming). Set
|
||||
`stream_format="sse"` and leave the legacy `stream` unset: `stream=true` takes
|
||||
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:
|
||||
**SSE stream** (`stream=true` or `stream_format="sse"`). Streams [OpenAI
|
||||
`speech.audio.*` Server-Sent Events](https://platform.openai.com/docs/api-reference/audio-streaming).
|
||||
Each event has an `event:` line and a JSON `data:` line:
|
||||
|
||||
- `speech.audio.delta` — a base64 audio chunk:
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ response = client.audio.speech.create(
|
||||
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
|
||||
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?",
|
||||
"voice": "default",
|
||||
"stream": true,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm"
|
||||
}' --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",
|
||||
"language": "English",
|
||||
"stream": true,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm"
|
||||
}' --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
|
||||
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
|
||||
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")
|
||||
```
|
||||
|
||||
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
|
||||
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?",
|
||||
"voice": "default",
|
||||
"stream": true,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm"
|
||||
}' --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.\",
|
||||
\"ref_audio\": \"data:audio/wav;base64,${REF_AUDIO}\",
|
||||
\"stream\": true,
|
||||
\"stream_format\": \"audio\",
|
||||
\"response_format\": \"pcm\"
|
||||
}" --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",
|
||||
"language": "English",
|
||||
"stream": true,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm"
|
||||
}' --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
|
||||
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
|
||||
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:
|
||||
payload["stream"] = True
|
||||
payload["stream_format"] = "audio"
|
||||
payload["response_format"] = "pcm"
|
||||
|
||||
print(f"Model: {args.model}")
|
||||
|
||||
@@ -66,6 +66,8 @@ def build_payload(
|
||||
"response_format": "pcm" if stream else response_format,
|
||||
"stream": stream,
|
||||
}
|
||||
if stream:
|
||||
payload["stream_format"] = "audio"
|
||||
|
||||
# Voice cloning: ref_audio takes priority over URL.
|
||||
ref_url_stripped = ref_audio_url.strip() if ref_audio_url else ""
|
||||
|
||||
@@ -53,6 +53,7 @@ def run_tts(args) -> None:
|
||||
|
||||
if args.stream:
|
||||
payload["stream"] = True
|
||||
payload["stream_format"] = "audio"
|
||||
payload["response_format"] = "pcm"
|
||||
|
||||
print(f"Model: {args.model}")
|
||||
|
||||
@@ -64,6 +64,8 @@ def build_payload(
|
||||
"stream": stream,
|
||||
"max_new_tokens": max_new_tokens,
|
||||
}
|
||||
if stream:
|
||||
payload["stream_format"] = "audio"
|
||||
|
||||
# Voice cloning: ref_audio takes priority over URL.
|
||||
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_text": args.ref_text,
|
||||
}
|
||||
if args.stream:
|
||||
payload["stream_format"] = "audio"
|
||||
payload["response_format"] = "pcm"
|
||||
if 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
|
||||
if args.stream:
|
||||
payload["stream"] = True
|
||||
payload["stream_format"] = "audio"
|
||||
payload["response_format"] = "pcm"
|
||||
|
||||
api_url = f"{args.api_base}/v1/audio/speech"
|
||||
|
||||
@@ -65,6 +65,7 @@ PY
|
||||
\"model\": \"${MODEL}\",
|
||||
\"input\": \"${TEXT}\",
|
||||
\"stream\": true,
|
||||
\"stream_format\": \"audio\",
|
||||
\"response_format\": \"pcm\"
|
||||
}" "$STREAM_OUTPUT"
|
||||
;;
|
||||
|
||||
@@ -335,6 +335,7 @@ def build_payload(
|
||||
"ref_audio": encode_audio_to_base64(ref_audio),
|
||||
"response_format": "pcm" if stream else response_format,
|
||||
"stream": stream,
|
||||
**({"stream_format": "audio"} if stream else {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ def build_payload(
|
||||
"response_format": "pcm" if stream else response_format,
|
||||
"stream": stream,
|
||||
}
|
||||
if stream:
|
||||
payload["stream_format"] = "audio"
|
||||
if not stream:
|
||||
payload["speed"] = speed
|
||||
|
||||
|
||||
@@ -515,6 +515,8 @@ def create_app(api_base: str):
|
||||
"response_format": "pcm" if stream_enabled else "wav",
|
||||
"stream": stream_enabled,
|
||||
}
|
||||
if stream_enabled:
|
||||
payload["stream_format"] = "audio"
|
||||
|
||||
# Reference audio for cloning
|
||||
ref_url_s = ref_url.strip() if ref_url else ""
|
||||
|
||||
@@ -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.",
|
||||
"voice": "default",
|
||||
"stream": true,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm"
|
||||
}' --no-buffer | play -t raw -r 48000 -e signed -b 16 -c 1 -
|
||||
```
|
||||
|
||||
@@ -104,7 +104,8 @@ curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
"voice": "default",
|
||||
"ref_audio": "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS/main/assets/audio/zh_1.wav",
|
||||
"response_format": "wav",
|
||||
"stream": true
|
||||
"stream": true,
|
||||
"stream_format": "audio"
|
||||
}' --output output.wav
|
||||
```
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
"voice": "vivian",
|
||||
"language": "English",
|
||||
"stream": true,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm"
|
||||
}' --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`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ curl -X POST http://127.0.0.1:8091/v1/audio/speech \
|
||||
"voice": "casual_female",
|
||||
"language": "English",
|
||||
"stream": true,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm"
|
||||
}' \
|
||||
--output voxtral_stream.pcm
|
||||
|
||||
@@ -104,6 +104,7 @@ def test_voice_clone_zh_002(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt("zh"),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
"ref_audio": REF_AUDIO_URL,
|
||||
"ref_text": REF_TEXT,
|
||||
|
||||
@@ -97,6 +97,7 @@ def test_voice_clone_zh_async_chunk(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": "今天天气真不错,适合出去散散步。",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
"ref_audio": REF_AUDIO_URL,
|
||||
"ref_text": REF_TEXT,
|
||||
|
||||
@@ -98,6 +98,7 @@ class TestHiggsAudioV2OnlineHappyPath:
|
||||
"model": omni_server.model,
|
||||
"input": "Streaming the quick brown fox over the lazy dog.",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
|
||||
"min_audio_bytes": _MIN_AUDIO_BYTES,
|
||||
@@ -270,6 +271,7 @@ class TestHiggsAudioV2OnlineVoiceClone:
|
||||
"ref_audio": _REF_AUDIO_URL,
|
||||
"ref_text": _REF_TEXT,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
|
||||
"min_audio_bytes": _MIN_AUDIO_BYTES,
|
||||
|
||||
@@ -135,6 +135,7 @@ class TestHiggsAudioV3OnlineHappyPath:
|
||||
"model": omni_server.model,
|
||||
"input": "The quick brown fox jumps over the lazy dog.",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
|
||||
"min_hnr_db": 0.0,
|
||||
@@ -160,6 +161,7 @@ class TestHiggsAudioV3OnlineHappyPath:
|
||||
"model": omni_server.model,
|
||||
"input": "She sells seashells by the seashore.",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"timeout": DEFAULT_SPEECH_TIMEOUT_S,
|
||||
"min_hnr_db": -2.0,
|
||||
|
||||
@@ -75,6 +75,7 @@ def test_streaming(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": "Hello, this is a voice cloning demo.",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"ref_audio": REF_AUDIO_URL,
|
||||
"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"),
|
||||
"voice": "灵小甄",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
"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,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"ref_audio": ref_audio_data_url,
|
||||
"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,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"ref_audio": ref_audio_data_url,
|
||||
"min_hnr_db": -5.0,
|
||||
|
||||
@@ -100,6 +100,7 @@ def test_text_to_audio_002(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
|
||||
"response_format": "wav",
|
||||
"task_type": "Base",
|
||||
|
||||
@@ -80,6 +80,7 @@ def test_voice_clone_streaming_001(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
"task_type": "Base",
|
||||
"voice": "clone",
|
||||
|
||||
@@ -95,6 +95,7 @@ def test_text_to_audio_002(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
"task_type": "CustomVoice",
|
||||
"voice": "vivian",
|
||||
|
||||
@@ -75,6 +75,7 @@ def test_voice_001(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "wav",
|
||||
"task_type": "CustomVoice",
|
||||
"voice": "uncle_fu",
|
||||
|
||||
@@ -89,6 +89,7 @@ def test_text_to_audio_002(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
|
||||
"response_format": "wav",
|
||||
"voice": "default",
|
||||
|
||||
@@ -62,6 +62,7 @@ def test_voice_clone_streaming_001(omni_server, openai_client) -> None:
|
||||
"model": omni_server.model,
|
||||
"input": get_prompt(),
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
|
||||
"response_format": "wav",
|
||||
"voice": "default",
|
||||
|
||||
@@ -56,6 +56,7 @@ def test_speech_english_streaming(omni_server, openai_client) -> None:
|
||||
"voice": "casual_female",
|
||||
"language": "English",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
"timeout": 120.0,
|
||||
"min_audio_bytes": _MIN_AUDIO_BYTES_BASIC,
|
||||
|
||||
@@ -141,6 +141,7 @@ class TestSpeakerEmbeddingBase:
|
||||
"x_vector_only_mode": True,
|
||||
"response_format": "pcm",
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
}
|
||||
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)
|
||||
|
||||
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")
|
||||
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")
|
||||
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):
|
||||
req = OpenAICreateSpeechRequest(input="Hello", stream_format="audio", response_format="pcm")
|
||||
assert req.stream_format == "audio"
|
||||
assert req.is_raw_audio_stream() is True
|
||||
assert req.is_sse_stream() is False
|
||||
|
||||
def test_sse_stream_format_is_valid(self):
|
||||
"""stream_format='sse' is accepted for /audio/speech."""
|
||||
@@ -1867,6 +1872,7 @@ class TestStreamingProtocolValidation:
|
||||
|
||||
assert req.stream_format == "sse"
|
||||
assert req.is_sse_stream() is True
|
||||
assert req.is_raw_audio_stream() is False
|
||||
|
||||
|
||||
class TestStreamingResponse:
|
||||
@@ -1939,13 +1945,24 @@ class TestStreamingResponse:
|
||||
app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None)
|
||||
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):
|
||||
"""stream=True must return audio/pcm with non-empty body."""
|
||||
"""stream=True defaults to OpenAI speech.audio.* SSE events."""
|
||||
client = TestClient(streaming_app)
|
||||
response = client.post("/v1/audio/speech", json={"input": "Hello", "stream": True, "response_format": "pcm"})
|
||||
assert response.status_code == 200
|
||||
assert "audio/pcm" in response.headers["content-type"]
|
||||
assert len(response.content) > 0
|
||||
self._assert_sse_audio_response(response)
|
||||
|
||||
def test_stream_format_audio_streaming(self, streaming_app):
|
||||
"""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"},
|
||||
)
|
||||
|
||||
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"] == "pcm"
|
||||
assert base64.b64decode(payload["audio"])
|
||||
self._assert_sse_audio_response(response)
|
||||
|
||||
def test_stream_true_prefers_raw_audio_over_sse(self, streaming_app):
|
||||
"""stream=True keeps the existing raw audio stream behavior even with stream_format=sse."""
|
||||
def test_stream_true_with_stream_format_sse_uses_sse(self, streaming_app):
|
||||
"""stream=True and stream_format=sse both select SSE streaming."""
|
||||
client = TestClient(streaming_app)
|
||||
response = client.post(
|
||||
"/v1/audio/speech",
|
||||
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 "audio/pcm" 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):
|
||||
"""Test WAV format streaming returns correct content type and includes WAV header."""
|
||||
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 "audio/wav" in response.headers["content-type"]
|
||||
@@ -3040,7 +3061,10 @@ class TestWAVStreaming:
|
||||
|
||||
unsupported_formats = ["mp3"]
|
||||
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
|
||||
|
||||
|
||||
@@ -3336,6 +3360,23 @@ class TestTTSAsyncOffloading:
|
||||
|
||||
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):
|
||||
"""stream_format=audio should request delta-style multimodal outputs."""
|
||||
qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None)
|
||||
|
||||
@@ -1770,6 +1770,7 @@ class OpenAIClientHandler:
|
||||
"seed",
|
||||
"instructions",
|
||||
"speed",
|
||||
"stream_format",
|
||||
):
|
||||
if key in request_config:
|
||||
extra_body[key] = request_config[key]
|
||||
|
||||
@@ -1133,9 +1133,9 @@ async def async_request_openai_audio_speech(
|
||||
) -> MixRequestFuncOutput:
|
||||
"""Streaming request to /v1/audio/speech endpoint.
|
||||
|
||||
Sends ``stream=true`` with ``response_format=pcm`` so the server returns
|
||||
raw PCM chunks as they are decoded. This allows measuring TTFP (time to
|
||||
first audio packet) separately from E2EL.
|
||||
Sends ``stream=true`` with ``stream_format=audio`` and ``response_format=pcm``
|
||||
so the server returns raw PCM chunks as they are decoded. This allows measuring
|
||||
TTFP (time to first audio packet) separately from E2EL.
|
||||
"""
|
||||
api_url = request_func_input.api_url
|
||||
_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,
|
||||
"input": request_func_input.prompt,
|
||||
"stream": True,
|
||||
"stream_format": "audio",
|
||||
"response_format": "pcm",
|
||||
}
|
||||
_update_payload_common(payload, request_func_input)
|
||||
# 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():
|
||||
payload["stream"] = True
|
||||
payload["stream_format"] = "audio"
|
||||
payload["response_format"] = "pcm"
|
||||
|
||||
headers = {
|
||||
@@ -1219,7 +1221,7 @@ async def async_request_openai_audio_speech(
|
||||
ct = response.headers.get("Content-Type", "")
|
||||
logger.warning(
|
||||
"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,
|
||||
api_url,
|
||||
)
|
||||
|
||||
@@ -69,13 +69,15 @@ class OpenAICreateSpeechRequest(BaseModel):
|
||||
default=None,
|
||||
description=(
|
||||
"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(
|
||||
default=False,
|
||||
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."
|
||||
),
|
||||
)
|
||||
@@ -292,13 +294,13 @@ class OpenAICreateSpeechRequest(BaseModel):
|
||||
return self
|
||||
|
||||
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:
|
||||
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:
|
||||
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")
|
||||
def validate_streaming_constraints(self) -> "OpenAICreateSpeechRequest":
|
||||
|
||||
@@ -4038,11 +4038,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
- ref_text: Transcript of reference audio (Base task)
|
||||
- x_vector_only_mode: Use speaker embedding only (Base task)
|
||||
|
||||
Streaming is supported via ``stream_format='audio'`` or the legacy
|
||||
``stream=True`` switch, with ``response_format='pcm'`` or ``'wav'``.
|
||||
``stream_format='sse'`` returns OpenAI ``speech.audio.*`` SSE events instead.
|
||||
Each Code2Wav chunk is yielded as raw audio bytes as soon as it is decoded.
|
||||
For WAV format, a header with placeholder size values is emitted first.
|
||||
Streaming is supported via the ``stream=True`` switch or ``stream_format='sse'``,
|
||||
which return OpenAI ``speech.audio.*`` SSE events. ``stream_format='audio'``
|
||||
opts into raw audio streaming with ``response_format='pcm'`` or ``'wav'``.
|
||||
Raw audio streaming yields each Code2Wav chunk as raw bytes as soon as it is
|
||||
decoded. Raw WAV streaming emits a header with placeholder size values first.
|
||||
"""
|
||||
if self._diffusion_mode:
|
||||
return await self._create_diffusion_speech(request)
|
||||
@@ -4060,14 +4060,14 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
|
||||
)
|
||||
|
||||
try:
|
||||
if request.is_raw_audio_stream():
|
||||
if request.word_timestamps:
|
||||
if request.is_streaming() and request.word_timestamps:
|
||||
return self.create_error_response(
|
||||
"word_timestamps=true is currently supported by the WebSocket "
|
||||
"/v1/audio/speech/stream path. Use session.config with "
|
||||
"stream_audio=true and response_format='pcm'."
|
||||
)
|
||||
|
||||
if request.is_raw_audio_stream():
|
||||
response_format, error = self._validate_speech_streaming_request(
|
||||
request,
|
||||
mode_label="Streaming",
|
||||
|
||||
Reference in new issue
Block a user