Skip to content

DeepFilterNet truncates 69-97 ms off the end of every utterance (unflushed soxr round trip) #61

Closed

Description

@RevolutionLA

What happens

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.

Measured (2026-10-11, i7-1260P, deep_filter 0.5.6, bench_asr --denoise)

wav audio (ms) samples out expected at 16 kHz deficit
f-henan.wav 7990 126738 127840 1102 (69 ms)
f-sichuan.wav 7835 123802 125360 1558 (97 ms)
utt10s.wav 10053 159523 160848 1325 (83 ms)
z1.wav 5100 80251 81600 1349 (84 ms)
z2.wav 4690 73890 75040 1150 (72 ms)

Raw artifact: docs/benchmarks/2026-10-11-i7-1260p-deepfilter-raw.jsonl.

Two sources, split by measurement

  1. 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.
  2. 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).

Activity

  1. RevolutionLA commented on Oct 11, 2026

    @RevolutionLA
    OwnerAuthor

    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.

  2. RevolutionLA commented on Oct 11, 2026

    @RevolutionLA
    OwnerAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions