频域维纳滤波降噪算法——设计思路与工程手记

频域维纳滤波降噪算法——设计思路与工程手记 写在前头 这份笔记记录的是一个面向实时语音通信的频域降噪算法。它的设计目标很明确:在 48kHz 采样率下,以 10ms 帧长实时处理单通道语音,把稳态和准稳态背景噪声压下去,同时尽量不伤语音。算法脱胎于 WebRTC 的 Noise Suppression 模块,但经过了面向嵌入式的重构——所有动态分配被消除,所有 C++ 模板和虚函数被拍平成纯 C 结构体。 我按照信号流经的顺序来谈。先说为什么要这么做,再说具体怎么做。 1. 信号模型 一切降噪算法的出发点都是同一个假设:麦克风采集到的信号 $x(n)$ 是干净语音 $s(n)$ 和加性噪声 $d(n)$ 的叠加: $$ x(n) = s(n) + d(n) $$这个模型简单到近乎天真,但它成立的前提条件其实很苛刻:噪声和语音不相关,噪声在短时间尺度上是准平稳的。实际工程中,空调嗡嗡声、风扇声、马路上的低频轰鸣,在几十毫秒内确实变化不大——这就够了。 我们的目标是从 $x(n)$ 中恢复 $\hat{s}(n)$。在频域里,这意味着对每个频率 bin 施加一个增益 $G(k)$: $$ \hat{S}(k) = G(k) \cdot X(k) $$问题归结为:怎么求这个 $G(k)$? 2. 为什么选频域 时域降噪(比如 LMS 自适应滤波)需要参考信号,这在单麦克风场景下不现实。频域方法的好处是: 频率分辨率。噪声和语音在不同频段的能量分布差异很大。白噪声全频段平坦,语音能量集中在 300Hz–4kHz。频域处理可以逐 bin 调节衰减深度。 短时平稳假设更容易满足。一帧 10ms 内,噪声功率谱几乎不变,这给估计器提供了稳定的观测窗口。 计算量可控。256 点 FFT 在嵌入式平台上是成熟操作,远比时域长 FIR 滤波器经济。 代价是引入了帧延迟和频谱泄漏,这些后面会谈。 3. 总体处理流程 一帧 480 个采样点(48kHz × 10ms)进来后,经过以下环节: ...

July 28, 2026 · 17 min · Leventure

GTCRN 演进路径:v4 → v5 → 落地

GTCRN 演进路径:v4 → v5 → 落地 记录噪声抑制模型从架构精简开始,经历质量优化、极限压缩,到最终在嵌入式 C 端落地的全过程。 前言 v4.1 把 464 KB 的推理管线交到了 C 端手里。这个数字已经够小——能在大多数嵌入式芯片上跑起来,RTF 不到 0.04。但我们还想要更多。 不是「把模型再做小一点」这么简单。键盘敲击声和风扇底噪的压制效果已经不错了,如果裁剪的过程中把这两个能力丢掉,小就没有意义。换句话说,压缩是手段,质量是底线。每次下手之前,先问一句:压完之后,瞬态噪声还能不能盖住?听感会不会变差? v5 这条线走了四个月。它从 v4.1 的 464 KB 跑到了最终的 412 KB,中间踩了不少坑。这份文档把踩过的坑、走通的路、放弃的岔路都记下来。 时间线:2026-03(v4.1 交付) → 2026-06(v5.6 C 端落地) 版本总览 版本 改了什么 参数 关键指标 内存 结论 v4.1 INT8 混合精度 C 推理 87K PESQ 2.037 464 KB 基线,已交付 v5.1 架构定型 (4层, CH=20) 55.6K PESQ 2.462 ~466 KB 可靠起点 v5.2 多模块 all-in 73.9K PESQ ~1.20 ~538 KB 失败,复盘后放弃 v5.3 网络优化,单模块消融 61.6K [5,10) PESQ 1.92 ~466 KB n4 被接受 v5.4 宽度裁剪 CH→16 41.2K [5,10) PESQ 1.46 ~400 KB 失败,暂停 v5.5 极限压缩 (INT4/INT8) — PESQ drop < 0.05 ~314-349 KB (投) 过门,主线收敛 v5.6 C 端落地:GTC6/INT4/hidden INT8 60.3K DNSMOS SIG +0.86 412 KB 交付 网络结构 (v5.3-n4 最终) 输入 spec (B, 513, T, 2) │ ▼ ERB_48k.bm(): 513 → 219 │ ▼ in_conv: Conv2d(2→3) │ ▼ ┌─ CausalEncoder ───────────────────────────┐ │ DSConv: 219→110 ← skip1 │ │ DSConv: 110→55 ← skip2 │ │ CausalGTConv×4 (d=1,2,4,2) ← skip3-6│ │ SubbandAttention │ └───────────────────────────────────────────┘ │ ▼ CausalDPGRNN × 2 │ intra: 双向GRU (频率轴) │ inter: 单向GRU (时间轴) │ ▼ ┌─ CausalDecoder ───────────────────────────┐ │ SkipResidualFusion + CausalGTConv×4 │ │ Fuse + DSDeconv: 55→110 │ │ SkipResidualFusion + DSDeconv: 110→219 │ └───────────────────────────────────────────┘ │ ▼ out_conv → ERB_48k.bs() → CRM → 输出 相比 v4,decoder 的 skip 连接从简单的 x + skip 换成了带门控和残差分支的融合模块。 ...

June 18, 2026 · 7 min · Leventure

PPG 情绪识别:几条路线的取舍与实测

PPG 情绪识别:几条路线的取舍与实测 写在前面 这个工作并不算solid,目前正在数据收集和实测阶段,涉及的数据集也只有WESAD和DEAP,并没有很强的参考意义,请谨慎参考。 摘要 要解决的问题很简单:在手表侧只有 PPG 和三轴加速度这两类输入的前提下,能不能先估计 valence 和 arousal,再映射到 Russell 情绪模型中的象限和离散标签,从而形成一条可闭环的情绪识别链路。 当前项目一共走过三条路线。第一条是 XGBoost Feature Baseline,它使用 HRV + ACC + SQI 这类手工特征,目标是先把任务跑通,并给后续复杂模型提供一个稳定参照。第二条是 EMCNN PPG-only,它直接建模单通道 PPG 波形,是当前的默认主方案。第三条是 EMCNN + aux,在 EMCNN 的基础上再加入 ACC/SQI 辅助输入,作为实验增强路线。 在当前 WESAD LOSO 实测下,EMCNN PPG-only 是综合表现最好的方案:它同时超过了 XGBoost 基线,并且比 EMCNN + aux 保持了更好的连续回归误差。EMCNN + aux 虽然在分类指标上更强,但 VA MAE 更差,更像分类增强方案。除此之外,本文还补充了推理性能、CPU/GPU、并发、内存和显存开销的实测,目的是把“能不能识别”和“值不值得部署”放在同一张桌子上讨论。 本文不公开项目实现和数据实测,仅作交流学习。 这个工作并不算solid,目前正在数据收集和实测阶段,涉及的数据集也只有WESAD和DEAP,并没有很强的参考意义,请谨慎参考。 一、这件事到底在做什么 当前项目的目标不是做一个泛泛的“情绪分类器”,而是要在手表侧有限传感器条件下,建立一条可以闭环的 Russell 情绪建模链路。输入是 PPG + ACC,中间层输出是连续的 valence 和 arousal,最后再映射到 Russell 情绪模型中的象限和离散标签。当前仓库中的推理接口已经对应到了这条链路: 连续层:valence / arousal 决策层:quadrant 展示层:21 个 Russell 标签 + Top-3 这里有一个必须讲清楚的前提:当前项目并不预设 PPG 能直接“表达情绪”,也不假设 PPG 和 valence/arousal 之间存在简单、稳定、显式的一一对应关系。更稳妥的说法是,情绪状态会通过自主神经系统影响心率、血管张力、外周循环和节律变化,而 PPG 恰好能观测到这些变化的一部分。因此,当前项目真正检验的是这样一个工程假设:PPG/ACC 中可能存在与 valence/arousal 相关的可学习统计模式。如果这个假设成立,就有机会在手表侧形成一套弱侵入、持续性的情绪状态估计能力。 ...

June 18, 2026 · 4 min · Leventure

浮点数的 IEEE 754 标准与内存表达

前言 项目上遇到了double类型数据精度问题,嵌入式开发和算法争论了一会有关double和float的精度问题,究竟是强转造成的精度损失更多,还是在计算的过程中精度损失更多?这个问题很显然是使用float在计算过程中造成的精度损失更多,但是面对这样的问题,不能只靠猜测,而是需要进行一系列量化的测算。 IEEE754标注中的浮点数表达公式 $$ value = (-1)^{sign} \times 2^{exponent} \times (1 + mantissa) $$其中,sign为符号位,exponent为指数位,mantissa为尾数位。 float float类型通常占用4个字节(32位)的内存。具体分配如下: 符号位(Sign bit):1位 指数位(Exponent):8位 尾数位(Fraction/Mantissa):23位 内存布局示例 假设我们有一个单精度浮点数3.14,它的二进制表示如下: 符号位:0 指数位:10000000 尾数位:10010001111010111000011 0 10000000 10010001111010111000011 double 符号位(Sign bit):1位 指数位(Exponent):11位 尾数位(Fraction/Mantissa):52位 内存布局 假设我们有一个双精度浮点数3.14,它的二进制表示如下: 符号位:0 指数位:10000000000 尾数位:1001000111101011100001010001111010111000010100011110101110000101 float与doule之间的转换 float转double 这种转换称为扩展转换(promotion),因为double有更多的位数来表示数字。 1.内存模型变化: float使用32位存储,而double使用64位存储。 在将float转换为double时,计算机会将float的值复制到double的尾数部分,并扩展指数部分。 由于double的尾数部分更长,可以精确表示的有效数字更多,所以这种转换通常不会损失精度。 2.转换过程 符号位保持不变。 指数位从float的8位扩展到double的11位,计算机会根据需要调整指数的偏移量。 尾数位从23位扩展到52位,不足的部分用0填充。 double 转 float 这种转换称为缩减转换(narrowing),因为float有较少的位数来表示数字。 1. 内存模型变化: double使用64位存储,而float使用32位存储。 在将double转换为float时,计算机会将double的值截断或舍入以适应float的尾数部分和指数部分。 由于float的尾数部分较短,这种转换可能会损失精度。 2.转换过程 符号位保持不变。 指数位从double的11位缩减到float的8位,计算机会调整指数的偏移量,并可能会进行舍入。 尾数位从52位缩减到23位,超出的部分会被截断或舍入,这可能导致精度损失。 这里需要注意的是,在C++中,并不是做了简单的截断,而是做了舍入操作,这也是为什么我们在实际操作中,可以观测到逢7进1 例子: float转换double 假设我们有一个float值3.14: float: 0 10000000 10010001111010111000011 ...

June 18, 2026 · 3 min · Leventure