F5-TTS: per-chunk silence trim + fade (port from VoiceClone) + restore per-sentence streaming
This commit is contained in:
parent
015bbdfad6
commit
831b937fd1
1 changed files with 53 additions and 16 deletions
69
speech.py
69
speech.py
|
|
@ -511,6 +511,38 @@ class qwen3_wrapper():
|
||||||
logger.error(f"Qwen3-TTS generation failed: {e}")
|
logger.error(f"Qwen3-TTS generation failed: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _f5_trim_audio(audio_np, sample_rate, silence_db=-40):
|
||||||
|
"""Trim leading/trailing silence + fade + normalize for F5-TTS output.
|
||||||
|
|
||||||
|
Adapted from MonumentalSystems/VoiceClone web_tts_server.py (MIT).
|
||||||
|
Lets per-sentence chunking be artifact-free: silence-trim at -40 dB
|
||||||
|
cuts the brief ref-bleed at chunk start; small fade kills click.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
if len(audio_np) == 0:
|
||||||
|
return audio_np
|
||||||
|
abs_thresh = 10 ** (silence_db / 20)
|
||||||
|
above = np.where(np.abs(audio_np) > abs_thresh)[0]
|
||||||
|
if len(above) == 0:
|
||||||
|
return audio_np[:1]
|
||||||
|
lead_in = int(sample_rate * 0.01) # keep 10 ms before first speech
|
||||||
|
trail_out = int(sample_rate * 0.03) # keep 30 ms after last speech
|
||||||
|
start_idx = max(0, above[0] - lead_in)
|
||||||
|
end_idx = min(len(audio_np), above[-1] + trail_out)
|
||||||
|
audio_np = audio_np[start_idx:end_idx].copy()
|
||||||
|
if len(audio_np) == 0:
|
||||||
|
return audio_np
|
||||||
|
fi = min(int(sample_rate * 0.015), len(audio_np)) # 15 ms fade in
|
||||||
|
fo = min(int(sample_rate * 0.005), len(audio_np)) # 5 ms fade out
|
||||||
|
if fi > 0:
|
||||||
|
audio_np[:fi] *= np.linspace(0.0, 1.0, fi, dtype=audio_np.dtype)
|
||||||
|
if fo > 0:
|
||||||
|
audio_np[-fo:] *= np.linspace(1.0, 0.0, fo, dtype=audio_np.dtype)
|
||||||
|
peak = float(np.max(np.abs(audio_np)))
|
||||||
|
if peak > 0.01:
|
||||||
|
audio_np = audio_np * (10 ** (-1.0 / 20) / peak) # normalize -1 dB
|
||||||
|
return audio_np
|
||||||
|
|
||||||
class f5_wrapper():
|
class f5_wrapper():
|
||||||
"""Wrapper for F5-TTS model (SWivid/F5-TTS, MIT)
|
"""Wrapper for F5-TTS model (SWivid/F5-TTS, MIT)
|
||||||
|
|
||||||
|
|
@ -569,9 +601,11 @@ class f5_wrapper():
|
||||||
audio = np.asarray(wav, dtype=np.float32).flatten()
|
audio = np.asarray(wav, dtype=np.float32).flatten()
|
||||||
|
|
||||||
self.sample_rate = sr
|
self.sample_rate = sr
|
||||||
logger.info(f"F5-TTS generated {len(audio)} samples at {sr}Hz")
|
raw_len = len(audio)
|
||||||
|
audio = _f5_trim_audio(audio, sr, silence_db=-40)
|
||||||
|
logger.info(f"F5-TTS generated {raw_len} samples at {sr}Hz, trimmed to {len(audio)}")
|
||||||
|
|
||||||
return audio.tobytes()
|
return audio.astype(np.float32).tobytes()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"F5-TTS generation failed: {e}")
|
logger.error(f"F5-TTS generation failed: {e}")
|
||||||
|
|
@ -1295,10 +1329,11 @@ async def generate_speech(request: GenerateSpeechRequest):
|
||||||
logger.info(f"Loading F5-TTS model on device '{device}'")
|
logger.info(f"Loading F5-TTS model on device '{device}'")
|
||||||
f5_model = await asyncio.to_thread(f5_wrapper, device=device)
|
f5_model = await asyncio.to_thread(f5_wrapper, device=device)
|
||||||
|
|
||||||
# F5-TTS handles long text via internal chunking. Per-sentence splitting
|
# Split into sentences so first audio bytes stream early (~2 s for long texts).
|
||||||
# produces a ref-to-gen transition artifact at the start of EACH chunk
|
# f5_wrapper.tts() applies _f5_trim_audio per chunk to scrub the brief
|
||||||
# (audible as gibberish/tinny prefix). Pass full text as one inference.
|
# ref-bleed at chunk start so per-sentence boundaries stay clean.
|
||||||
logger.info(f"F5-TTS processing {len(input_text)} chars as single inference")
|
sentences = simple_sentence_split(input_text, max_length=500)
|
||||||
|
logger.info(f"F5-TTS streaming {len(sentences)} sentence chunks from {len(input_text)} chars")
|
||||||
|
|
||||||
# F5-TTS outputs float32 PCM at 24kHz
|
# F5-TTS outputs float32 PCM at 24kHz
|
||||||
ffmpeg_args = build_ffmpeg_args(response_format, input_format="f32le", sample_rate="24000")
|
ffmpeg_args = build_ffmpeg_args(response_format, input_format="f32le", sample_rate="24000")
|
||||||
|
|
@ -1308,20 +1343,22 @@ async def generate_speech(request: GenerateSpeechRequest):
|
||||||
in_q = queue.Queue()
|
in_q = queue.Queue()
|
||||||
|
|
||||||
def generator():
|
def generator():
|
||||||
"""Run F5-TTS once for the full input text"""
|
"""Process sentences sequentially and feed to queue"""
|
||||||
try:
|
try:
|
||||||
audio_bytes = f5_model.tts(
|
for idx, sentence in enumerate(sentences):
|
||||||
text=input_text,
|
logger.debug(f"F5-TTS chunk {idx+1}/{len(sentences)}: {len(sentence)} chars")
|
||||||
ref_audio=ref_audio,
|
audio_bytes = f5_model.tts(
|
||||||
ref_text=ref_text,
|
text=sentence,
|
||||||
speed=speed,
|
ref_audio=ref_audio,
|
||||||
)
|
ref_text=ref_text,
|
||||||
in_q.put(audio_bytes)
|
speed=speed,
|
||||||
|
)
|
||||||
|
in_q.put(audio_bytes)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"F5-TTS generation error: {e}")
|
logger.error(f"F5-TTS streaming error: {e}")
|
||||||
finally:
|
finally:
|
||||||
in_q.put(None)
|
in_q.put(None)
|
||||||
logger.info("F5-TTS generation complete")
|
logger.info(f"F5-TTS streaming complete: {len(sentences)} chunks")
|
||||||
|
|
||||||
def out_writer():
|
def out_writer():
|
||||||
"""Write audio from queue to ffmpeg stdin"""
|
"""Write audio from queue to ffmpeg stdin"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue