trim_tts_output silently discards correct speech after any pause over 1s
Summary
trim_tts_output discards correct speech. It cuts the audio at the first internal silence gap longer than max_internal_silence_ms (default 1000ms) and throws away everything after it, without testing whether what follows is a hallucinated tail or the rest of the utterance. A generation that reports completed with a valid WAV can be missing most of its content.
This is data loss rather than a quality setting, and it is not reachable from the HTTP API — neither trim_fn nor its thresholds are exposed, so a client cannot opt out.
Impact
Expressive speech pauses for well over a second, so the exposed case is precisely the well-delivered line — a beat after a dramatic sentence, or any paralinguistic tag ([sigh], [laugh]) that renders as low-energy audio.
Reproduction
Chatterbox Turbo, cloned profile, one 416-character text containing a [sigh], six seeds. Generated directly against the model, then passed through trim_tts_output as a pure function call:
| seed | raw | after trim_tts_output |
|---|---|---|
| 7000 | 21.80s | 21.78s |
| 7001 | 25.48s | 25.44s |
| 7002 | 24.48s | 24.38s |
| 7003 | 25.00s | 10.52s |
| 7004 | 25.52s | 4.02s |
| 7005 | 24.00s | 8.84s |
The model produced full, correct audio in all six. Three were damaged by the trim, one reduced to 16% of its length. Supporting evidence: 0 truncations in 10 direct (untrimmed) renders of the same text, against roughly 7 anomalies in 16 renders through /generate.
Root cause
backend/utils/audio.py, in trim_tts_output:
consecutive_silence += 1
if consecutive_silence >= max_silence_frames:
cut_frame = i - consecutive_silence + 1
break
The docstring states the intent correctly — strip a [speech][silence][hallucinated noise] tail — but the implementation infers "hallucinated noise" from the gap alone. An RMS threshold cannot distinguish a pause from a hallucination, so the length of what follows has to decide.
Proposed fix
Only cut when the audio after the gap is not a substantial continuation. Minimal patch, preserving the original intent:
min_silence_ms: int = 200,
max_internal_silence_ms: int = 1000,
fade_ms: int = 30,
+ min_continuation_ms: int = 1500,
) -> np.ndarray:
@@
consecutive_silence += 1
if consecutive_silence >= max_silence_frames:
+ speech_after_frames = int(np.count_nonzero(is_speech[i + 1 :]))
+ if speech_after_frames * frame_ms >= min_continuation_ms:
+ consecutive_silence = 0
+ continue
cut_frame = i - consecutive_silence + 1
break
Verification
Same six seeds, patched: 21.78 / 25.44 / 24.38 / 24.84 / 25.40 / 23.96 — all preserved, trailing-silence trim and fade still applied.
Synthetic cases confirming the original behaviour is intact:
| case | before -> after | expected |
|---|---|---|
| 3s speech, 1.5s gap, 0.4s noise | 4.90s -> 3.00s | tail still cut |
| 3s speech, 1.5s gap, 4s speech | 8.50s -> 8.50s | pause preserved |
| 5s speech, no gap | 5.00s -> 5.00s | unchanged |
| 3s speech, gap, 4s speech, gap, 0.3s noise | 10.30s -> 8.50s | speech kept, tail cut |
Happy to open a PR if the approach looks right. An alternative (or addition) would be exposing trim/thresholds through GenerationRequest so clients can opt out.
Source: jamiepine/voicebox