Skip to content

Commit 87da40c

Browse files
authored
Add streaming support to /tts endpoint via stream param
Adds an opt-in stream: bool parameter (defaults to false) to CustomTTSRequest. When true, /tts returns a StreamingResponse with WAV audio yielded chunk-by-chunk as each chunk is synthesized. Useful for long-form input where time-to-first-audio matters. Default behavior is unchanged for existing clients.
1 parent cdce362 commit 87da40c

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

‎models.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ class CustomTTSRequest(BaseModel):
9494
None, description="Overrides default language if provided."
9595
)
9696

97+
stream: bool = Field(
98+
False,
99+
description="If true, returns a StreamingResponse with WAV audio yielded as each chunk is synthesized. output_format is ignored when streaming.",
100+
)
101+
97102

98103
class ErrorResponse(BaseModel):
99104
"""Standard error response model for API errors."""

‎server.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import os
77
import io
8+
import asyncio
9+
import struct
810
import logging
911
import logging.handlers # For RotatingFileHandler
1012
import shutil
@@ -386,6 +388,25 @@ def _remove_dc_offset(
386388
return audio.astype(np.float32, copy=False)
387389

388390

391+
def _create_wav_header(sample_rate: int, num_channels: int = 1, bits_per_sample: int = 16) -> bytes:
392+
"""Build a WAV header with 0xFFFFFFFF data size for streaming (size unknown upfront)."""
393+
byte_rate = sample_rate * num_channels * bits_per_sample // 8
394+
block_align = num_channels * bits_per_sample // 8
395+
header = struct.pack("<4sI4s", b"RIFF", 0xFFFFFFFF, b"WAVE")
396+
fmt_chunk = struct.pack(
397+
"<4sIHHIIHH",
398+
b"fmt ", 16, 1, num_channels, sample_rate, byte_rate, block_align, bits_per_sample,
399+
)
400+
data_header = struct.pack("<4sI", b"data", 0xFFFFFFFF)
401+
return header + fmt_chunk + data_header
402+
403+
404+
def _float32_to_pcm16(audio_np: np.ndarray) -> bytes:
405+
"""Convert a float32 numpy array in [-1, 1] to int16 PCM bytes."""
406+
clipped = np.clip(audio_np, -1.0, 1.0)
407+
return (clipped * 32767).astype(np.int16).tobytes()
408+
409+
389410
# --- End Audio Stitching Helper Functions ---
390411

391412

@@ -953,6 +974,99 @@ async def custom_tts_endpoint(
953974
status_code=400, detail="Text processing resulted in no usable chunks."
954975
)
955976

977+
# --- Streaming fork ---
978+
if request.stream:
979+
if request.output_format and request.output_format != "wav":
980+
logger.warning(
981+
f"stream=true: output_format '{request.output_format}' ignored; streaming always uses WAV."
982+
)
983+
984+
speed_factor_stream = (
985+
request.speed_factor
986+
if request.speed_factor is not None
987+
else get_gen_default_speed_factor()
988+
)
989+
audio_prompt_str = (
990+
str(audio_prompt_path_for_engine) if audio_prompt_path_for_engine else None
991+
)
992+
temperature_val = (
993+
request.temperature if request.temperature is not None else get_gen_default_temperature()
994+
)
995+
exaggeration_val = (
996+
request.exaggeration if request.exaggeration is not None else get_gen_default_exaggeration()
997+
)
998+
cfg_weight_val = (
999+
request.cfg_weight if request.cfg_weight is not None else get_gen_default_cfg_weight()
1000+
)
1001+
seed_val = request.seed if request.seed is not None else get_gen_default_seed()
1002+
language_val = (
1003+
request.language if request.language is not None else get_gen_default_language()
1004+
)
1005+
1006+
CROSSFADE_MS_STREAM = 20
1007+
1008+
async def _stream_generator():
1009+
loop = asyncio.get_running_loop()
1010+
carry: Optional[np.ndarray] = None
1011+
header_sent = False
1012+
1013+
for i, chunk_text in enumerate(text_chunks):
1014+
is_last = i == len(text_chunks) - 1
1015+
logger.info(f"Streaming chunk {i+1}/{len(text_chunks)}...")
1016+
1017+
audio_tensor, chunk_sr = await loop.run_in_executor(
1018+
None,
1019+
lambda c=chunk_text: engine.synthesize(
1020+
text=c,
1021+
audio_prompt_path=audio_prompt_str,
1022+
temperature=temperature_val,
1023+
exaggeration=exaggeration_val,
1024+
cfg_weight=cfg_weight_val,
1025+
seed=seed_val,
1026+
language=language_val,
1027+
),
1028+
)
1029+
1030+
if audio_tensor is None or chunk_sr is None:
1031+
logger.error(f"Streaming TTS: engine returned None for chunk {i+1}; stopping stream.")
1032+
return
1033+
1034+
if speed_factor_stream != 1.0:
1035+
audio_tensor, _ = utils.apply_speed_factor(
1036+
audio_tensor, chunk_sr, speed_factor_stream
1037+
)
1038+
1039+
audio_np = audio_tensor.cpu().numpy().squeeze().astype(np.float32)
1040+
1041+
if not header_sent:
1042+
yield _create_wav_header(chunk_sr)
1043+
header_sent = True
1044+
1045+
fade_samples = int(CROSSFADE_MS_STREAM / 1000 * chunk_sr)
1046+
1047+
if carry is not None:
1048+
# Crossfade the held-back tail of the previous chunk with the head of this one
1049+
audio_np = _crossfade_with_overlap(carry, audio_np, fade_samples)
1050+
carry = None
1051+
1052+
if not is_last and len(audio_np) > fade_samples:
1053+
carry = audio_np[-fade_samples:].copy()
1054+
yield _float32_to_pcm16(audio_np[:-fade_samples])
1055+
else:
1056+
yield _float32_to_pcm16(audio_np)
1057+
1058+
if carry is not None:
1059+
yield _float32_to_pcm16(carry)
1060+
1061+
timestamp_str = time.strftime("%Y%m%d_%H%M%S")
1062+
stream_filename = utils.sanitize_filename(f"tts_stream_{timestamp_str}.wav")
1063+
return StreamingResponse(
1064+
_stream_generator(),
1065+
media_type="audio/wav",
1066+
headers={"Content-Disposition": f'attachment; filename="{stream_filename}"'},
1067+
)
1068+
# --- End streaming fork ---
1069+
9561070
for i, chunk in enumerate(text_chunks):
9571071
logger.info(f"Synthesizing chunk {i+1}/{len(text_chunks)}...")
9581072
try:

0 commit comments

Comments
 (0)