You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
DeepFilterNet truncates 69-97 ms off the end of every utterance (unflushed soxr round trip) #61
With denoise: deepfilter enabled, every utterance comes back 69-97 ms shorter
than what was recorded. The missing audio is the end of the take, which for a
push-to-talk dictation tool is the part most likely to contain the last syllable.
Nothing is logged, nothing falls back, and the ASR just sees a truncated buffer.
Raw artifact: docs/benchmarks/2026-10-11-i7-1260p-deepfilter-raw.jsonl.
Two sources, split by measurement
The soxr round trip is never flushed.AudioCapture::dfDenoise() calls soxr_process() exactly once per leg (16k->48k on the way in, 48k->16k on the
way out) and takes generated as the final length. A polyphase resampler
holds a filter-delay tail in its internal buffer; the drain call
(soxr_process(..., num_in = 0, ...)) is what gives it back. Isolating this
leg with a stub binary that leaves the 48k file the same length still loses
644-1017 samples (40-64 ms) - so the majority of the truncation is ours, not
the model's.
deep-filter writes a shorter file than it reads. A 2 s 48 kHz probe
(192 044 B) came back as 189 164 B, i.e. 1 440 samples = 30 ms at 48 kHz.
That part is the model's own framing and is expected.
tests/test_denoise_fallback.cpp never asserts the length on the success path
(its working stub zeroes the whole buffer, so a shorter all-zero result passes silenced), which is why this shipped unnoticed.
Suggested fix
Flush both resampler legs, then assert the returned length against the input:
the round trip should be within the model's ~30 ms, not ~95 ms.
Decide explicitly what to do with the remainder: either pad the tail from the
original (un-denoised) samples so the recogniser sees the whole take, or trim
from the head instead (leading silence is cheaper to lose than a trailing
vowel). Either is defensible; silently dropping the end is not.
Pin the length on the success path in the fallback test so a regression here
fails CI. tests/test_bench_denoise.sh already bounds the deficit at 1200
samples for a 6400-sample tone, so it will notice the number changing.
Why it is not a release blocker
deepfilter is opt-in and off by default (speexdsp), and the audio still
recognises fine on the corpus - this is a quality loss on a minority path, found
by the roadmap row 5 measurement (PR that adds bench_asr --denoise).
Sharpening the severity, because the pipeline order makes this worse than "80 ms
of silence":
AudioCapture runs normalise -> denoise -> trimSilence(), so the
truncation happens before the VAD trim, and the trim's own trailing protection
is kPadFrames = 1 frame = 320 samples = 20 ms
(ASR_provider/src/audio_capture.cpp:1030). 69-97 ms removed off the end is 3-5x
that padding, i.e. the last ~50-80 ms of what could be a vowel is gone rather
than the silence after it.
That also means README's audio-pipeline bullet - "VAD silence trimming with a
retained tail so the last syllable is never clipped" - is true on the default speexdsp path and not true with denoise: deepfilter. The README now says
so in the DeepFilterNet section, but the honest fix is here: recover the tail
(flush both soxr legs) and then make the length assertion in test_denoise_fallback.cpp prove it, so the bullet needs no asterisk again.
Fixed in #63 (merged as part of this run's follow-up). Root cause was the one
this issue guessed at, and more precisely: soxr signals end-of-input with in == NULL, not with ilen = 0, so a drain call written the obvious way
(soxr_process(p, in.data(), 0, ...)) silently returns nothing - that cost me a
round of "the fix changed nothing" before the header explained it.
What landed:
both resampler legs drained, measured as exact on a length-preserving stub
(16000 in -> 16000 out, was 15170);
deep-filter's own constant 30 ms (1440 samples at 48 kHz, proven truncation not
delay by a lag/correlation probe) is appended back from the raw input, so dfDenoise() now guarantees samples_out == samples_in;
a response more than 160 ms short is rejected as a malfunction (falls back to
speexdsp) instead of padded into a half-denoised utterance, and no giveUp path
may mutate the caller's buffer;
test_denoise_fallback.cpp gained stubs for both shapes and asserts the exact
sample index where denoised audio hands over to the restored tail, so a
regression here fails CI. Four mutations, each caught.
End-to-end on a 10 s clip with the real binary: df_denoise=1404ms samples=160850 restored=480, and trimSilence afterwards removed only 210
trailing samples - the recording now reaches the recogniser whole. Timing inside
the published RTF 0.13-0.16 band, i.e. free.
This is a nextless.so behaviour change, so it needs a release (0.3.2) for .deb
users to get it; not cut yet.
Activity
RevolutionLA commentedon Oct 11, 2026
Sharpening the severity, because the pipeline order makes this worse than "80 ms
of silence":
AudioCaptureruns normalise -> denoise ->trimSilence(), so thetruncation happens before the VAD trim, and the trim's own trailing protection
is
kPadFrames = 1frame = 320 samples = 20 ms(
ASR_provider/src/audio_capture.cpp:1030). 69-97 ms removed off the end is 3-5xthat padding, i.e. the last ~50-80 ms of what could be a vowel is gone rather
than the silence after it.
That also means README's audio-pipeline bullet - "VAD silence trimming with a
retained tail so the last syllable is never clipped" - is true on the default
speexdsppath and not true withdenoise: deepfilter. The README now saysso in the DeepFilterNet section, but the honest fix is here: recover the tail
(flush both soxr legs) and then make the length assertion in
test_denoise_fallback.cppprove it, so the bullet needs no asterisk again.bench: measure the optional DeepFilterNet denoiser for real (roadmap …
fix(capture): stop DeepFilterNet from eating the end of every utteran…
RevolutionLA commentedon Oct 11, 2026
Fixed in #63 (merged as part of this run's follow-up). Root cause was the one
this issue guessed at, and more precisely: soxr signals end-of-input with
in == NULL, not withilen = 0, so a drain call written the obvious way(
soxr_process(p, in.data(), 0, ...)) silently returns nothing - that cost me around of "the fix changed nothing" before the header explained it.
What landed:
(
16000 in -> 16000 out, was 15170);delay by a lag/correlation probe) is appended back from the raw input, so
dfDenoise()now guaranteessamples_out == samples_in;speexdsp) instead of padded into a half-denoised utterance, and no giveUp path
may mutate the caller's buffer;
test_denoise_fallback.cppgained stubs for both shapes and asserts the exactsample index where denoised audio hands over to the restored tail, so a
regression here fails CI. Four mutations, each caught.
End-to-end on a 10 s clip with the real binary:
df_denoise=1404ms samples=160850 restored=480, andtrimSilenceafterwards removed only 210trailing samples - the recording now reaches the recogniser whole. Timing inside
the published RTF 0.13-0.16 band, i.e. free.
This is a
nextless.sobehaviour change, so it needs a release (0.3.2) for .debusers to get it; not cut yet.
release: 10.12.0, the first date-based version (#65)