diff --git a/ASR_provider/src/audio_capture.cpp b/ASR_provider/src/audio_capture.cpp index 0e3bd6d..1a04452 100644 --- a/ASR_provider/src/audio_capture.cpp +++ b/ASR_provider/src/audio_capture.cpp @@ -656,6 +656,10 @@ namespace { // 绝不能让一次录音挂在子进程上, 更不能让音频静默绕过降噪。 constexpr int kDfTimeoutMs = 5000; constexpr int kDfMaxConsecutiveFailures = 3; +// deep-filter 固定少交 30 ms(1440 个 48k 采样),这是已知的模型 framing 开销, +// 补回原样尾巴就行。缺口大到超过 160 ms 就不是「少给一点」而是结果不完整, +// 那种情况退回 speexdsp,不拿半截降噪结果上屏。 +constexpr size_t kDfMaxTailRestore = 2560; // 160 ms at 16 kHz // 打包者/发行版可以用 NEXTLESS_DEEP_FILTER 指向系统里的 deep-filter, // 否则用随包安装在 ~/.local/share/nextless/bin 的那一份。 @@ -674,6 +678,41 @@ std::string dfTempDir() { return "/tmp"; } +// soxr_process() 一次只交出它此刻算得出的采样:HQ 的多相滤波器把群延迟(16k→48k +// 是 1024 个输出采样,48k→16k 是 341 个)留在内部缓冲区,结尾那段只有把「输入结束」 +// 告诉它才会被吐出来 —— 而 soxr 表示 EOS 的方式是 **传 in = NULL**,ilen=0 不算 +// (头文件里写的)。两段以前都只调一次:单看上行少 1024 个 48k 采样,整条往返实测 +// 16000 进 15170 出(少 830 个 ≈ 52 ms),而且两端 click 探针显示输出与输入完全对齐 +// —— 缺的就是最后一个字的尾巴,不是静音(issue #61)。 +// 上采样/下采样共用这里,flush 有硬上限不会打转。 +bool resampleFully(double inRate, double outRate, const std::vector &in, + std::vector &out) { + soxr_error_t err; + soxr_t resampler = soxr_create(inRate, outRate, 1, &err, nullptr, nullptr, nullptr); + if (err) return false; + struct Deleter { + soxr_t s; + ~Deleter() { soxr_delete(s); } + } closer{resampler}; + + const size_t capacity = (size_t)(in.size() * (outRate / inRate)) + 8192; + out.assign(capacity, 0.0f); + size_t consumed = 0, generated = 0; + err = soxr_process(resampler, in.data(), in.size(), &consumed, + out.data(), capacity, &generated); + if (err) return false; + size_t produced = generated; + for (int i = 0; i < 32 && produced < capacity; ++i) { + // in = NULL 才是「没有更多输入了」,把缓冲区里的最后一段交出来 + err = soxr_process(resampler, nullptr, 0, &consumed, + out.data() + produced, capacity - produced, &generated); + if (err || generated == 0) break; + produced += generated; + } + out.resize(produced); + return produced > 0; +} + uint64_t fnv1a(const void *data, size_t len) { const auto *bytes = static_cast(data); uint64_t h = 1469598103934665603ULL; @@ -807,25 +846,17 @@ bool AudioCapture::dfDenoise(std::vector &samples) { // Resample 16kHz → 48kHz size_t inLen = samples.size(); - double ratio = 48000.0 / 16000.0; - size_t outLen48k = (size_t)(inLen * ratio) + 64; - + // 结尾补原样用的尾巴(见函数末尾):只可能缺 kDfMaxTailRestore 个,就只留这么多 + const size_t tailLen = std::min(inLen, kDfMaxTailRestore); + std::vector originalTail(samples.end() - tailLen, samples.end()); std::vector fin(inLen); for (size_t i = 0; i < inLen; i++) fin[i] = samples[i] / 32768.0f; - std::vector fout(outLen48k); - soxr_error_t err; - soxr_t resampler = soxr_create(16000, 48000, 1, &err, nullptr, nullptr, nullptr); - if (err) return giveUp("soxr create error"); - - size_t consumed, generated; - err = soxr_process(resampler, fin.data(), fin.size(), &consumed, - fout.data(), fout.size(), &generated); - soxr_delete(resampler); - if (err) return giveUp("soxr process error"); + std::vector fout; + if (!resampleFully(16000.0, 48000.0, fin, fout)) return giveUp("soxr upsample error"); - std::vector samples48k(generated); - for (size_t i = 0; i < generated; i++) + std::vector samples48k(fout.size()); + for (size_t i = 0; i < fout.size(); i++) samples48k[i] = (int16_t)std::clamp((int)(fout[i] * 32768.0f), -32768, 32767); // 每次调用一个独立临时文件: 并发录音不会互相覆盖 @@ -903,25 +934,36 @@ bool AudioCapture::dfDenoise(std::vector &samples) { std::vector fIn(denoised.size()); for (size_t i = 0; i < denoised.size(); i++) fIn[i] = denoised[i] / 32768.0f; - size_t outLen16k = (size_t)(fIn.size() * 16000.0 / denoisedRate) + 64; - std::vector f16k(outLen16k); - - resampler = soxr_create(denoisedRate, 16000, 1, &err, nullptr, nullptr, nullptr); - if (!err) { - err = soxr_process(resampler, fIn.data(), fIn.size(), &consumed, - f16k.data(), f16k.size(), &generated); - soxr_delete(resampler); + std::vector f16k; + if (!resampleFully(static_cast(denoisedRate), 16000.0, fIn, f16k)) + return giveUp("soxr downsample error"); + + // 结果先攒在本地:giveUp 的每条路都必须把调用方的缓冲区原样交还, + // 由 applyDenoise 退回 speexdsp 处理完整音频,绝不能留下半截重采样结果。 + std::vector result(f16k.size()); + for (size_t i = 0; i < f16k.size(); i++) + result[i] = (int16_t)std::clamp((int)(f16k[i] * 32768.0f), -32768, 32767); + + // deep-filter 会砍掉结尾:实测每个文件都正好少 1440 个 48k 采样(30 ms),而且 + // 与输入零时移(click/相关探针 lag=0、corr=0.983)—— 就是纯截尾,不是延迟。 + // 松手那一刻用户往往还在发声,所以把这 30 ms 用**原样**采样补回来:只让最后 30 ms + // 没降噪,换一个「dfDenoise 不改变音频长度」的硬不变量,最后一个字不会被吃掉。 + // 缺口超过 kDfMaxTailRestore 就不是模型 framing 而是结果不完整,按失败退回 + // speexdsp,而不是交出一份半降噪的音频。 + size_t restored = 0; + if (result.size() < inLen) { + restored = inLen - result.size(); + if (restored > kDfMaxTailRestore) return giveUp("processed audio is incomplete"); + result.insert(result.end(), originalTail.end() - restored, originalTail.end()); + } else if (result.size() > inLen) { + result.resize(inLen); } - if (err) return giveUp("soxr downsample error"); - - samples.resize(generated); - for (size_t i = 0; i < generated; i++) - samples[i] = (int16_t)std::clamp((int)(f16k[i] * 32768.0f), -32768, 32767); + samples.swap(result); auto t1 = std::chrono::steady_clock::now(); - fprintf(stderr, "Nextless Capture [timer] df_denoise=%ldms samples=%zu\n", + fprintf(stderr, "Nextless Capture [timer] df_denoise=%ldms samples=%zu restored=%zu\n", (long)std::chrono::duration_cast(t1 - t0).count(), - samples.size()); + samples.size(), restored); return true; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 686703f..f9449b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,28 @@ user-visible change rather than one per commit. mutation-checked. ### Fixed +- **`denoise: deepfilter` no longer eats the end of your sentence** (issue #61). + Both legs of the 16 kHz → 48 kHz → 16 kHz round trip called `soxr_process()` + exactly once, and a polyphase resampler holds its group delay (1024 output + samples on the up leg, 341 on the down leg) in an internal buffer until it is + told the input has ended - which soxr does **not** read from `ilen = 0`, only + from `in == NULL`. So every denoised utterance came back shorter than it went + in: 830 samples of 16000 on a tone through a length-preserving stub, 69-97 ms + on real speech. deep-filter's own framing then drops a further constant 30 ms, + which a burst probe shows is truncation rather than delay (lag 0, correlation + 0.983) - so the end of the last word was the part being lost, on a pipeline + whose VAD step deliberately keeps only 20 ms of trailing audio. Both legs are + now drained, which by itself took the tone round trip from 15170 back to an + exact 16000; the model's own 30 ms is then appended back from the raw input, so + `dfDenoise()` preserves the buffer length exactly (0 samples lost across the + benchmark corpus) and the last 30 ms is un-denoised instead of missing. Timing + is unchanged within measurement noise - three runs of this corpus before and + after spanned RTF 0.131-0.163, and the docs now say so instead of quoting one + number. A response that comes back more than 160 ms short is rejected as a + failure (fallback to speexdsp) rather than padded into a half-denoised buffer, + and no failure path may mutate the caller's audio on the way out. + `tests/test_denoise_fallback.cpp` pins all of it against stubs, including the + exact sample index where denoised audio hands over to the restored raw tail. - `docs/benchmarks.md` linked its own artifacts as `docs/` while they live in `docs/benchmarks/`, so every "prove the number" link 404'd (issue #23's family of anchor drift). Both rows now resolve. diff --git a/README.md b/README.md index 656d31a..007e7d1 100644 --- a/README.md +++ b/README.md @@ -296,14 +296,16 @@ to speexdsp instead of losing your words — and after three failures in a row D switched off for the rest of the session. `meson test` covers all four cases. What it costs, measured with the real binary on a 2022 i7-1260P -([docs/benchmarks.md](docs/benchmarks.md)): **≈1.2 s per utterance** (median 1223 ms for 4.7–10 s -clips, RTF 0.156, ~60 MB) versus single-digit milliseconds for speexdsp. The process is spawned -per utterance, so that ~1.2 s lands between releasing the key and the first character. The 5 s -deadline is comfortably clear up to roughly 40 s of continuous speech. It is also currently known -to drop 69–97 ms off the end of each take ([issue #61](https://github.com/RevolutionLA/nextless/issues/61)) -— more than the 20 ms of trailing audio the VAD step keeps as protection, so on this path the -"the last syllable is never clipped" guarantee above does not fully hold. That is why `speexdsp` -remains the default. +([docs/benchmarks.md](docs/benchmarks.md)): **≈1.0–1.3 s per utterance** of 5–10 s speech +(RTF 0.13–0.16 across three runs, ~60 MB) versus single-digit milliseconds for speexdsp. The +process is spawned per utterance, so that second lands between releasing the key and the first +character, and the 5 s deadline stays clear up to well past 30 s of continuous speech. It is also **not** lossless +in the way the bullet above promises: deep-filter hands back 30 ms less than it receives, so +Nextless appends the last 30 ms of your *raw* audio to keep the utterance whole — those final +30 ms are un-denoised, but nothing is cut off +([issue #61](https://github.com/RevolutionLA/nextless/issues/61), fixed; before that the round trip +dropped 69–97 ms off the end). `speexdsp` stays the default because it costs single-digit +milliseconds rather than a second per utterance and never touches the audio length. ## Usage @@ -423,7 +425,7 @@ Each item has a finish line, so whoever picks it up knows when it is done. | 2 | **`/etc/nextless` defaults are never installed**: the config loader reads them and copies them into `~/.config/nextless/` on first use, but no install rule ships them, so every source install silently runs on compiled-in defaults. **Fixed in [#11](https://github.com/RevolutionLA/nextless/pull/11)** — meson installs the examples as `*.json` into the build's `sysconfdir`/nextless and compiles the same path into the loader, so install and read cannot drift; CI does a staged install and fails if any packaged example stops landing (six since `hotwords.json`). | ✅ meson ships them — `/etc/nextless` for distro builds (`--sysconfdir=/etc`), `/usr/local/etc/nextless` for a plain source install, the same path the loader reads | | 3 | **First run without models or binaries**: a missing sherpa-onnx runtime or model currently surfaces as `Zipformer: spawn failed` / `recognition failed`, with no hint of what to download. **Fixed in [#12](https://github.com/RevolutionLA/nextless/pull/12)** — providers pre-check the runtime and each model file and report the exact path; the panel shows "sherpa-onnx runtime missing" / "offline model missing" with the README pointer (`test_missing_components` drives all four paths against a fake `$HOME`). | ✅ the input panel names the piece and points at the README's download section; the exact path it looked for goes to the fcitx5 log (and feeds the wizard below) | | 4 | **Real install and removal path**: the `.deb` half is **Fixed in [#34](https://github.com/RevolutionLA/nextless/pull/34)** (packaging, dependency list, install/purge assertions in CI) and **[#38](https://github.com/RevolutionLA/nextless/pull/38)** (published on Releases with `sha256sums.txt`, `tools/install-deb.sh` one-command route, fail-closed digest check in [#51](https://github.com/RevolutionLA/nextless/pull/51)); model fetch is `nextless-get-models` ([#5](https://github.com/RevolutionLA/nextless/issues/5)). Still open here: someone dictating a sentence on a clean Ubuntu VM, and a rollback paragraph. AUR after that. | a clean Ubuntu VM installs, dictates, `apt remove` leaves no debris, and rollback is documented | -| 5 | **DeepFilterNet has never run for real**: the tests drive it with stub binaries, so one-shot `deep-filter` cost per utterance is unmeasured. **Measured 2026-10-11** with `BACKENDS="deepfilter" tools/benchmark/run_bench.sh` against `deep_filter 0.5.6` — 7/7 utterances (4.7–10 s), median 1223 ms, RTF 0.156, 60 MB child RSS; the option stays, and the run also found [issue #61](https://github.com/RevolutionLA/nextless/issues/61). | ✅ [docs/benchmarks.md](docs/benchmarks.md) carries the row plus the committed machine header and raw JSONL | +| 5 | **DeepFilterNet has never run for real**: the tests drive it with stub binaries, so one-shot `deep-filter` cost per utterance is unmeasured. **Measured 2026-10-11** with `BACKENDS="deepfilter" tools/benchmark/run_bench.sh` against `deep_filter 0.5.6` — 7/7 utterances (4.7–10 s), 1.0–1.3 s each, RTF 0.13–0.16, 60 MB child RSS. The option stays, and the same run found and fixed [issue #61](https://github.com/RevolutionLA/nextless/issues/61) (the denoiser had been dropping 69–97 ms off the end of every take). | ✅ [docs/benchmarks.md](docs/benchmarks.md) carries the row plus the committed machine header and raw JSONL | | 6 | **Coexistence with the typing-side IME**: document the supported combinations (wetype, fcitx5 pinyin) and the key-collision rules. | a short doc plus one smoke test per combination | The longer-term list: diff --git a/README.zh.md b/README.zh.md index 2dc2a63..01ffacb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -262,14 +262,16 @@ Nextless 会先重采样到 48 kHz 交给模型,再采回 16 kHz 送识别。 丢掉你说的话;连续失败三次,本次会话就不再尝试。这四种情况都有 `meson test` 覆盖。 真实二进制的代价(2026-10-11,i7-1260P,数据在 -[docs/benchmarks.md](docs/benchmarks.md)):**每句约 1.2 秒**(4.7–10 秒素材,中位 1223 ms, -RTF 0.156,子进程 60 MB RSS),而 speexdsp 只有几毫秒。进程是每句重新 spawn 的,所以这 1.2 秒落在 -「松手」和「出字」之间。按实测拟合(`wall ≈ 282 + 0.110 × 音频毫秒`),5 秒超时大约要连续说到 -40 秒以上才会触发,超时那一句退回 speexdsp,不会卡住。 +[docs/benchmarks.md](docs/benchmarks.md)):**5–10 秒素材每句约 1.0–1.3 秒**(三次实测 +RTF 0.13–0.16,子进程 ~60 MB RSS),而 speexdsp 只有几毫秒。进程是每句重新 spawn 的,所以这一秒 +落在「松手」和「出字」之间;5 秒超时大概要连续说 30 秒以上才会碰到,超时那一句退回 speexdsp, +不会卡住。 -但它在 [issue #61](https://github.com/RevolutionLA/nextless/issues/61) 里会**吃掉每句结尾 -69–97 ms** —— 比 VAD 裁剪特意保留的 20 ms 尾巴还多,所以开了这个降噪之后,上面「最后一个字不会被 -切掉」那条保证就不完全成立了。这也是默认仍然用 speexdsp 的原因。 +上面「最后一个字不会被切掉」这条保证对开启它的人来说曾经是不成立的:往返重采样每句悄悄少掉 +69–97 ms([issue #61](https://github.com/RevolutionLA/nextless/issues/61),已修)。现在两段 +soxr 都会 flush,deep-filter 自己少给的 30 ms 用**原始音频**补回结尾 —— 也就是最后 30 ms 没降噪, +但一句都不会被截掉,`dfDenoise` 从此保持音频长度不变。默认仍是 speexdsp:它每句只花几毫秒, +也完全不改动音频长度。 ## 使用 @@ -380,7 +382,7 @@ Nextless fork 自 **xander-lin 的 [vinput](https://github.com/xander-lin/vinput | 2 | **`/etc/nextless` 的默认配置从未被安装**:配置读取逻辑会去读它、并在首次使用时复制到 `~/.config/nextless/`,但没有任何 install 规则把它装进去——所以源码安装实际上一直在用编译进代码的默认值。**已修复,[#11](https://github.com/RevolutionLA/nextless/pull/11)**——meson 现在把示例以 `*.json` 装进构建时的 `sysconfdir`/nextless,并把同一路径编译进 loader,安装与读取不可能再漂移;CI 会做 staged install,随包示例(自 `hotwords.json` 起共六份)少装一个就红。 | ✅ meson 确实会装——发行版构建是 `/etc/nextless`(`--sysconfdir=/etc`),普通源码安装是 `/usr/local/etc/nextless`,与 loader 读取的路径同源 | | 3 | **没模型/没二进制时的首次运行**:现在只会显示 `Zipformer: spawn failed` / `recognition failed`,完全不提示要下载什么。**已修复,[#12](https://github.com/RevolutionLA/nextless/pull/12)**——provider 在 spawn 前预检运行时与每个模型文件,报出具体路径;面板显示「sherpa-onnx runtime missing / offline model missing」并指向 README(`test_missing_components` 用假 `$HOME` 覆盖全部四条失败路径)。 | ✅ 面板说清缺哪一块、并指到 README 的下载小节;实际找过的路径进 fcitx5 日志(也是下面向导的前置) | | 4 | **真正的安装与卸载路径**:`.deb` 那一半已经落地——**[#34](https://github.com/RevolutionLA/nextless/pull/34)**(打包、依赖清单、CI 里装完再卸载的断言)与 **[#38](https://github.com/RevolutionLA/nextless/pull/38)**(发布到 Releases 附 `sha256sums.txt`、`tools/install-deb.sh` 一条命令,校验在 [#51](https://github.com/RevolutionLA/nextless/pull/51) 改成 fail-closed);模型获取是 `nextless-get-models`([#5](https://github.com/RevolutionLA/nextless/issues/5))。还差的:在干净 Ubuntu 虚拟机上真的念一句,以及一段回滚说明。AUR 放后面。 | 干净的 Ubuntu 虚拟机上装完能听写,`apt remove` 不留残余,回滚有文档 | -| 5 | **DeepFilterNet 从没真跑过**:测试全用桩二进制驱动,真实 `deep-filter` 单句耗时从未测过。**2026-10-11 已用真实二进制测过**:`BACKENDS="deepfilter" tools/benchmark/run_bench.sh` 驱动 `deep_filter 0.5.6`,7/7 句(4.7–10 秒),中位 1223 ms、RTF 0.156、子进程 60 MB RSS;选项保留,同时这次测量挖出了 [issue #61](https://github.com/RevolutionLA/nextless/issues/61)。 | ✅ [docs/benchmarks.md](docs/benchmarks.md) 里有这一行,以及随包的 machine header 与原始 JSONL | +| 5 | **DeepFilterNet 从没真跑过**:测试全用桩二进制驱动,真实 `deep-filter` 单句耗时从未测过。**2026-10-11 已用真实二进制测过**:`BACKENDS="deepfilter" tools/benchmark/run_bench.sh` 驱动 `deep_filter 0.5.6`,7/7 句(4.7–10 秒),每句 1.0–1.3 秒、RTF 0.13–0.16、子进程 60 MB RSS。选项保留;同一次测量挖出并修掉了 [issue #61](https://github.com/RevolutionLA/nextless/issues/61)(这个降噪器原先每句结尾少 69–97 ms)。 | ✅ [docs/benchmarks.md](docs/benchmarks.md) 里有这一行,以及随包的 machine header 与原始 JSONL | | 6 | **与打字侧输入法共存**:写清支持的组合(wetype、fcitx5 拼音)和抢键规则。 | 一份短文档,外加每个组合一次冒烟验证 | 方向清单: diff --git a/docs/benchmarks.md b/docs/benchmarks.md index cf8464c..4033347 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -57,8 +57,10 @@ deep-filter". `first token` and `cold` are `--` because this is a denoiser: it h streaming output and no warm/cold split — **every** call spawns the process again, so the ~280 ms of spawn + model load is paid once per utterance. Fit over the seven rows: `wall_ms ≈ 282 + 0.110 × audio_ms` (R² 0.89), median RTF -0.156, range 0.131–0.163; a repeat run gave 0.151, so the number is stable to a -few percent. RSS is 26 MB harness + 59 MB child — two orders of magnitude below +0.156, per-row range 0.131–0.163. A second run minutes later printed 0.151, which +looked like stability — the third run (after the fix below) printed 0.131, so the +repeat that agreed was luck: treat single-digit differences between runs as noise. +RSS is 26 MB harness + 59 MB child — two orders of magnitude below the ASR models, so "heavy" in the docs refers to CPU, not memory. What this decides: @@ -69,11 +71,47 @@ What this decides: the open question in row 5, and it is now measured rather than assumed. - **It is not free.** ~1.2 s added to the end-of-speech → first-character gap, per utterance, on every take. `speexdsp` (default) costs single-digit ms. -- **It loses audio.** 69–97 ms off the *end* of every take, most of it from our - own unflushed resampler round trip rather than the model. That is +- **It lost audio** (fixed further down this page, same day). 69–97 ms off the + *end* of every take, most of it from our own unflushed resampler round trip + rather than the model. That is [issue #61](https://github.com/RevolutionLA/nextless/issues/61), found by this measurement; `samples_out` per row in the raw JSONL is the evidence. +### Same corpus after the issue #61 fix (same day) + +Machine header: [2026-10-11-i7-1260p-deepfilter-fixed-machine.txt](benchmarks/2026-10-11-i7-1260p-deepfilter-fixed-machine.txt) +· raw outputs: [2026-10-11-i7-1260p-deepfilter-fixed-raw.jsonl](benchmarks/2026-10-11-i7-1260p-deepfilter-fixed-raw.jsonl) + +| backend | ok | RTF (warm) | wall median (ms) | peak RSS (GB) | +|---|---|---|---|---| +| deepfilter | 7/7 | 0.131 | 1043 | 0.05 | + +`deficit = audio_ms × 16 − samples_out` per row, before → after: + +| wav | before | after | +|---|---|---| +| f-henan.wav | 1102 (69 ms) | 0 | +| f-sichuan.wav | 1558 (97 ms) | 0 | +| utt10s.wav | 1325 (83 ms) | −2 (0 ms) | +| z1.wav | 1349 (84 ms) | 0 | +| z2.wav | 1150 (72 ms) | 0 | + +Two changes: both resampler legs are now flushed with `in = NULL` (soxr's actual +end-of-input signal — `ilen = 0` is not one), which on a length-preserving stub +takes the round trip from `16000 in → 15170 out` to exact, and the model's own +30 ms framing loss (1440 samples at 48 kHz, measured with a burst probe: lag 0, +corr 0.983, so it is truncation not delay) is padded back from the raw input +instead of being dropped. The −2 on the 10 s clips is 2 samples *over* +`audio_ms × 16`, i.e. WAV-header rounding in the expected value, not extra +audio: the invariant under test is `samples_out == samples_in`. + +**Do not read 0.156 → 0.131 as a speed-up from the fix.** Three runs of this +corpus on this machine within twenty minutes gave wall medians of +1223 / 1275 / 1043 ms (RTF 0.156 / 0.163 / 0.131): thermal state and scheduling +move this harness by ~20%. Quote the denoiser as **RTF ≈ 0.13–0.16, about 1.0–1.3 s +per 5–10 s utterance**, and read the deficit column above as the point of this +row. The same caveat applies to the single-run ASR rows at the top of the file. + ## Relation to the old README numbers The README used to quote RTF ≈ 0.13 (zipformer) and ≈ 0.38 (FireRed) plus diff --git a/docs/benchmarks/2026-10-11-i7-1260p-deepfilter-fixed-machine.txt b/docs/benchmarks/2026-10-11-i7-1260p-deepfilter-fixed-machine.txt new file mode 100644 index 0000000..e9a068e --- /dev/null +++ b/docs/benchmarks/2026-10-11-i7-1260p-deepfilter-fixed-machine.txt @@ -0,0 +1,10 @@ +date: 2026-10-11T21:31:47+08:00 +host: (redacted) +kernel: 7.0.0-38-generic +cpu: 12th Gen Intel(R) Core(TM) i7-1260P +threads: 16 +governor: powersave +mem: 15562380 kB +backends: deepfilter +corpus: /tmp/dfcorpus +deep-filter: deep_filter 0.5.6 diff --git a/docs/benchmarks/2026-10-11-i7-1260p-deepfilter-fixed-raw.jsonl b/docs/benchmarks/2026-10-11-i7-1260p-deepfilter-fixed-raw.jsonl new file mode 100644 index 0000000..e91d1c3 --- /dev/null +++ b/docs/benchmarks/2026-10-11-i7-1260p-deepfilter-fixed-raw.jsonl @@ -0,0 +1,8 @@ +{"backend":"deepfilter","wav":"f-henan.wav","audio_ms":7990,"wall_ms":1043,"rtf":0.130538,"ok":true,"changed":true,"in_rms":1761.725944,"out_rms":1708.372214,"samples_out":127840} +{"backend":"deepfilter","wav":"f-sichuan.wav","audio_ms":7835,"wall_ms":1057,"rtf":0.134907,"ok":true,"changed":true,"in_rms":1135.138229,"out_rms":869.310849,"samples_out":125360} +{"backend":"deepfilter","wav":"utt10s.wav","audio_ms":10053,"wall_ms":1215,"rtf":0.120858,"ok":true,"changed":true,"in_rms":1642.821575,"out_rms":1606.429958,"samples_out":160850} +{"backend":"deepfilter","wav":"z0.wav","audio_ms":10053,"wall_ms":1146,"rtf":0.113994,"ok":true,"changed":true,"in_rms":1642.821575,"out_rms":1606.429958,"samples_out":160850} +{"backend":"deepfilter","wav":"z1.wav","audio_ms":5100,"wall_ms":690,"rtf":0.135294,"ok":true,"changed":true,"in_rms":1871.240121,"out_rms":1468.875417,"samples_out":81600} +{"backend":"deepfilter","wav":"z2.wav","audio_ms":4690,"wall_ms":676,"rtf":0.144136,"ok":true,"changed":true,"in_rms":1466.706190,"out_rms":1153.803272,"samples_out":75040} +{"backend":"deepfilter","wav":"z3.wav","audio_ms":8830,"wall_ms":1027,"rtf":0.116308,"ok":true,"changed":true,"in_rms":1018.402648,"out_rms":876.367880,"samples_out":141280} +{"backend":"deepfilter","summary":true,"files":7,"failures":0,"peak_rss_kb":26908,"child_peak_rss_kb":53864} diff --git a/tests/test_bench_denoise.sh b/tests/test_bench_denoise.sh index a7ade16..abea95d 100755 --- a/tests/test_bench_denoise.sh +++ b/tests/test_bench_denoise.sh @@ -89,16 +89,14 @@ run "$tmp/good.jsonl" NEXTLESS_DEEP_FILTER="$tmp/changes.sh" if field "$tmp/good.jsonl" '",".join(r)' 0 | grep -q error; then fail "a measured row must not carry an error field" fi -# dfDenoise resamples 16k->48k->16k. Two things can go wrong and both are -# pinned: a scale error (audio left at 48 kHz would be 3x this length), and a -# growing silent truncation. The round trip is known to lose a fixed resampler -# latency - measured 2026-10-11 at 644-1017 samples (~40-64 ms) regardless of -# utterance length - so the deficit is bounded in samples, not hidden in a -# percentage that a 4 s utterance would make look generous. +# dfDenoise resamples 16k->48k->16k and guarantees the buffer comes back the same +# length it went in (issue #61: both legs used to drop their resampler tail, 830 +# samples of 6400, and deep-filter's own 30 ms was then truncated off too). The +# stub preserves the file length, so the row must be exactly the input length - +# 3x would mean the audio never came back down to 16 kHz. got=$(field "$tmp/good.jsonl" 'r["samples_out"]' 0) -[ "$got" -ge $((6400 * 3 / 4)) ] && [ "$got" -le $((6400 * 105 / 100)) ] || - fail "samples_out=$got is not within 75-105% of the 6400 input samples" -[ $((6400 - got)) -le 1200 ] || fail "resample round trip lost $((6400 - got)) samples (> 75 ms)" +[ "$got" = "6400" ] || fail "samples_out=$got != the 6400 input samples (length-preserving invariant broken)" +[ "$(field "$tmp/good.jsonl" 'r["wall_ms"] > 0' 0)" = "True" ] || fail "a real spawn measured 0 ms" [ "$(field "$tmp/good.jsonl" 'r["wall_ms"] > 0' 0)" = "True" ] || fail "a real spawn measured 0 ms" [ "$(field "$tmp/good.jsonl" 's["failures"]' 0)" = "0" ] || fail "summary counted failures for a clean run" grep -q 'df_denoise=' "$tmp/good.jsonl.err" || fail "stderr lost the df_denoise timing line" diff --git a/tests/test_denoise_fallback.cpp b/tests/test_denoise_fallback.cpp index 3004c41..c49ed08 100644 --- a/tests/test_denoise_fallback.cpp +++ b/tests/test_denoise_fallback.cpp @@ -103,9 +103,56 @@ int main() { "truncate -s \"$n\" \"$f.tmp\"\n" "mv \"$f.tmp\" \"$f\"\n"); + // 真实 deep-filter 会砍掉结尾:每个文件正好少 1440 个 48k 采样(30 ms),且与 + // 输入零时移。chop.sh 复刻这个形状(头里的长度故意不修,readWavPcm 会按文件 + // 实际大小夹住)。gut.sh 是返回只剩一半的模型 —— 那种结果必须算失败。 + writeScript(root / "chop.sh", + "#!/bin/sh\n" + "f=\"$4\"\n" + "n=$(wc -c < \"$f\")\n" + "body=$(( n - 44 - 2880 ))\n" + "head -c 44 \"$f\" > \"$f.tmp\"\n" + "dd if=/dev/zero bs=1024 count=$(( (body + 1023) / 1024 )) >> \"$f.tmp\" 2>/dev/null\n" + "truncate -s $(( 44 + body )) \"$f.tmp\"\n" + "mv \"$f.tmp\" \"$f\"\n"); + writeScript(root / "gut.sh", + "#!/bin/sh\n" + "f=\"$4\"\n" + "n=$(wc -c < \"$f\")\n" + "body=$(( (n - 44) / 2 ))\n" + "head -c 44 \"$f\" > \"$f.tmp\"\n" + "dd if=/dev/zero bs=1024 count=$(( (body + 1023) / 1024 )) >> \"$f.tmp\" 2>/dev/null\n" + "truncate -s $(( 44 + body )) \"$f.tmp\"\n" + "mv \"$f.tmp\" \"$f\"\n"); + + // 这两条排在 zero.sh 之前:一次成功就会把连续失败计数清零,后面的 + // 「三次失败停用」序列不受影响。 + const auto gutted = run((root / "gut.sh").string()); + check("a mostly-missing result is not a success", !gutted.ok); + check("a mostly-missing result keeps the audio", audioIntact(gutted)); + + const auto chopped = run((root / "chop.sh").string()); + check("a 30 ms-short result still counts as success", chopped.ok); + check("the denoised audio keeps the full utterance", + chopped.samples.size() == kToneSamples); + // body 被 stub 刷成 0,所以「结尾非 0」只可能来自原样补回的采样。断点位置是 + // 有分量的:1440 个 48k 采样回到 16k 正好 480 个,所以 -481 必须还是模型给的 + // 0、-480 必须是原始波形。少 flush 一段(重采样自己就会少 341+830 个)会把断点 + // 往前推,这条就会红 —— 它同时证明两段重采样都冲干净了,而不只是长度对得上。 + check("compensation stops exactly at the model's 30 ms", + chopped.samples.size() == kToneSamples && + chopped.samples[kToneSamples - 481] == 0 && + chopped.samples[kToneSamples - 480] != 0 && + chopped.samples[kToneSamples - 1] != 0); + check("the body still comes back from the model", + chopped.samples.size() == kToneSamples && + chopped.samples[kToneSamples / 2] == 0); + const auto processed = run((root / "zero.sh").string()); check("a working deep-filter succeeds", processed.ok); check("a working deep-filter is prompt", processed.ms < 3000); + check("a length-preserving deep-filter keeps the full utterance", + processed.samples.size() == kToneSamples); bool silenced = !processed.samples.empty(); for (auto value : processed.samples) { if (value != 0) { silenced = false; break; }