|
5 | 5 |
|
6 | 6 | import os |
7 | 7 | import io |
| 8 | +import asyncio |
| 9 | +import struct |
8 | 10 | import logging |
9 | 11 | import logging.handlers # For RotatingFileHandler |
10 | 12 | import shutil |
@@ -386,6 +388,25 @@ def _remove_dc_offset( |
386 | 388 | return audio.astype(np.float32, copy=False) |
387 | 389 |
|
388 | 390 |
|
| 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 | + |
389 | 410 | # --- End Audio Stitching Helper Functions --- |
390 | 411 |
|
391 | 412 |
|
@@ -953,6 +974,99 @@ async def custom_tts_endpoint( |
953 | 974 | status_code=400, detail="Text processing resulted in no usable chunks." |
954 | 975 | ) |
955 | 976 |
|
| 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 | + |
956 | 1070 | for i, chunk in enumerate(text_chunks): |
957 | 1071 | logger.info(f"Synthesizing chunk {i+1}/{len(text_chunks)}...") |
958 | 1072 | try: |
|
0 commit comments