Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 72 additions & 30 deletions ASR_provider/src/audio_capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 的那一份。
Expand All @@ -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<float> &in,
std::vector<float> &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<const unsigned char *>(data);
uint64_t h = 1469598103934665603ULL;
Expand Down Expand Up @@ -807,25 +846,17 @@ bool AudioCapture::dfDenoise(std::vector<int16_t> &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<int16_t> originalTail(samples.end() - tailLen, samples.end());
std::vector<float> fin(inLen);
for (size_t i = 0; i < inLen; i++) fin[i] = samples[i] / 32768.0f;

std::vector<float> 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<float> fout;
if (!resampleFully(16000.0, 48000.0, fin, fout)) return giveUp("soxr upsample error");

std::vector<int16_t> samples48k(generated);
for (size_t i = 0; i < generated; i++)
std::vector<int16_t> 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);

// 每次调用一个独立临时文件: 并发录音不会互相覆盖
Expand Down Expand Up @@ -903,25 +934,36 @@ bool AudioCapture::dfDenoise(std::vector<int16_t> &samples) {
std::vector<float> 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<float> 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<float> f16k;
if (!resampleFully(static_cast<double>(denoisedRate), 16000.0, fIn, f16k))
return giveUp("soxr downsample error");

// 结果先攒在本地:giveUp 的每条路都必须把调用方的缓冲区原样交还,
// 由 applyDenoise 退回 speexdsp 处理完整音频,绝不能留下半截重采样结果。
std::vector<int16_t> 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<std::chrono::milliseconds>(t1 - t0).count(),
samples.size());
samples.size(), restored);
return true;
}

Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<file>` 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.
Expand Down
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
18 changes: 10 additions & 8 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:它每句只花几毫秒,
也完全不改动音频长度。

## 使用

Expand Down Expand Up @@ -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 拼音)和抢键规则。 | 一份短文档,外加每个组合一次冒烟验证 |

方向清单:
Expand Down
Loading
Loading