回声消除实现记录

apps/iot/crates/core/src/drivers/aec/ 的实现判断与被否方案。AI 产出,未经人类审阅,见上级说明。

每条判断标注依据:[测试] 有对应单元测试,[实测] 有测量并附复现方式,[据称] 未复现、仅留痕。

线性 AEC 只做一件事:把扬声器送出的样本所经声学路径建模出来,从麦克风里减掉。非线性抑制不在其中——见 aec/mod.rs 的模块文档。

板上的第一批数字:配对成立,消除为零

[实测] 复现:moon run iot:probe-echo-s3,烧到立创 S3,moon run iot:flash-probe-echo-s3(两块板同时插着时这个任务没有 --port,会自己挑一个),抓 30 秒串口。

读数实测判断
paired / unpaired160–200 / 0✅ 两路同步成立,日志 160 of 2944 远在跑道内
worst single poll15.3–16.6 ms / 30 ms 上限✅ 装得下,但已占掉一半预算
feed200 次/s,0 late,worst work 142–428 µs✅ 喂得稳
ERLE60 秒恒为 0 dB,一次都没动过❌
delayNone → 16 → 50 → 6,在 64 样本窗口里乱跳❌

延迟搜索乱跳是这里的重点,不是 ERLE 为零。 一条真实的声学路径有固定的延迟,它会收敛并停在那里;延迟每秒换一个值,说明参考与麦克风之间不存在稳定关系——滤波器于是永远收敛不到任何东西,而它收敛得很安静。

A/B:麦克风确实听得见喇叭,而「听不见」是我自己测出来的假象

[实测] ERLE 为 0 有两种完全不同的含义:参考配错了,或者根本没有回声可消。探针用每隔 6 秒翻转一次功放静音来分(probe/echo_room.rs)。

第一版 A/B 说:功放 on 时麦克风 A 计权只有 −76…−90 dBFS,off 时是 −43 dBFS,差 45 dB,于是判为「板上没有可测的声学路径」。

那条结论是错的,是测量设计的产物。 包络电平是 200 列 × 20 ms = 4 秒滚动窗口,而第一版每 1 秒翻转一次——窗口横跨两个完整周期,两种状态被平均,读出来的是拍频。改成 6 秒翻转、并在窗口完全落进一种状态之后才打印之后,同一处读数不再复现。

把每次 poll 的峰值(20 ms,不受窗口影响)逐次打印,得到连续曲线 —— 它和参考峰值同步起伏:

参考峰值 LSB麦克风峰值 LSB
229712180 / 2375
171002220
91781413 / 1454
88841513

麦克风跟着喇叭走,声学路径是通的,麦克风比参考低约 15 dB。

结论因此收窄:ERLE = 0 与延迟乱跳是 AEC 自身的问题,不是声学路径不存在。 滤波器拿着一条真实存在、真实相关的参考,交不到任何东西——这是配对/对齐的问题,仍然是那条静默失败的路子,只是错得比以前更深一层。

采集只交出一半的音频——这是延迟搜索锁不住的直接原因

[实测] 探针现在报采集吞吐量(capture rate),于是有了一个不靠推理的数字:

41 polls in 1007 ms, 8424 paired = 8365 samples/s of the 16000 the canceller runs, 205 per poll

poll 周期 24.6 ms,每次只配对 205 个抽取样本。 按 16 kHz 算,24.6 ms 本该是 394 个——实得 52%,几乎正好一半。

这个数字是致命的,因为延迟搜索要跨 256 ms 的历史(REFRESH_SAMPLES = 4096)做相关,而麦克风每个 poll 只交出实时所需的一半:两条流覆盖的时间跨度不成比例,历史里配对的「同一时刻」并不是同一时刻。相关峰因此不在任何格点上,best_lag 每次都在窗口里挑噪声,delay 于是 None → 16 → 50 → 6 地乱跳。

两条候选,都还没排除:

  1. RX 被配置成了一半的速率。 24.6 ms × 24000 = 590 帧 → 197 个抽取样本,与实测的 205 几乎吻合。
  2. 排空提前停了。 drain 每次取约 2460 字节,而按周期环里应该有约 4723 字节。

下一节给出这两条的裁决与真因。

根因:RX 每帧一个样本,驱动按两个算

[实测] 判定手段:在排空里数每 4 字节组中两半是否相同样本。

slots: 2/480 = 4 per mille
slots: 9/543 = 16 per mille

实质为零 —— 4 字节组的两半不是同一个样本。接收缓冲是每样本 2 字节,而驱动用 chunks_exact(BYTES_PER_FRAME = 4) 取 frame[0..2],于是每两个连续样本只留一个,丢掉麦克风的一半。

来源在 esp-hal 1.2.2 的 Channels 文档(与 Channels::MONO 定义同页):

mask ... Inactive channels do not consume or write data in the DMA buffer. fill ... This field is ignored in the receiver unit.

MONO = new_impl(2, 0b01, None) —— 槽 0 有效、槽 1 在 RX 侧不占 DMA 缓冲。所以两侧帧宽本来就不对称:

方向每帧机制
TX2 样本 = 4 Bfill=None → 非活动槽重复上一个活动槽
RX1 样本 = 2 B非活动槽不消费 DMA 数据

算术自洽:48000 ÷ 2 ÷ 3 = 8000,与修前实测的 8000 samples/s 分毫不差。

错误模型的出处是 core 里 SLOTS_PER_FRAME 的注释,它断言 Channels::MONO 只是槽选择、两个槽都会进 DMA,于是采集速率是 SAMPLE_RATE_HZ 的两倍。这条注释错在 core,driver 只是照抄 —— 所以修的不是某一处乘法,是这个模型本身。

修完之后

[实测] 同一支探针、同一次烧录:

修前修后
capture rate8000 samples/s15 561 samples/s(目标 16000)
paired / poll205442
unpaired00
delayNone→16→50→6 乱跳Some(16)…Some(64),None 不再出现
ERLE0 dB仍是 0 dB

采集速率修好了,配对还没有。 延迟搜索现在每次都给出答案(在 16–64 之间游走),而不是在噪声里乱挑 —— 但 ERLE 仍为零,说明对齐仍是另一个独立问题。

修它时踩到的二次故障

改对帧宽后探针立刻 panic(LoadProhibited,崩在 Es8311Tx::new)。二分确认与帧宽无关,真因是 BYTES_PER_POLL —— 它同样用 SLOTS_PER_FRAME 算采集字节数,于是采集侧每一个按它派生的缓冲都大了一倍,配对 scratch 从 1601 涨到 3201 个 i16,而 Board::new 把这个结构体在栈上返回,撑爆了主任务栈。

BYTES_PER_POLL 才是根常量:采集环深度、每次 poll 的排空预算、lag 换算、scratch 全从它来。把它从 SAMPLE_RATE_HZ × SLOTS_PER_FRAME × BYTES_PER_SAMPLE × CAPTURE_MS 改成 SAMPLE_RATE_HZ × CAPTURE_BYTES_PER_FRAME × CAPTURE_MS(3840 → 1920)之后,scratch 回到原尺寸,panic 消失。

[测试] 两个按旧常量写的测试因此失败(7708/7934/8188、9600/7680)。它们断言的是「以 poll 计的深度」——注释自己写着「2.13 polls」——所以改法不是抄新的观测值,而是改写成 poll 计(BYTES_PER_POLL * 2 + 偏移),让下一次帧宽修正不会再静默作废它们。

顺带发现:包络的时间轴一直是半速

[实测] SAMPLES_PER_COLUMN 同样用了 SLOTS_PER_FRAME,而 SampleStream::push_bytes 每 2 字节解一个样本 —— 每一列实际装的样本数只有它自称时长的一半。Audio 页的窗口在所有既往版本里都是半速绘制的,与 AEC 无关,是同一次错误的另一个受害者。

延迟搜索不是「测偏了」,是「根本没有峰」

[实测] 把 MAX_DELAY 从 64 临时放宽到 256(诊断用,已撤回 —— 8 倍搜索成本会撑爆 poll 预算),delay 立刻变成 223–255。

放宽到哪就贴到哪:窗口 64 时报 64,窗口 256 时报 255。

这不是「真实偏移超过窗口」,而是搜索返回的是窗口边界本身。best_lag 取分数最大的格点,而分数随 lag 单调上升到边缘 —— 说明根本没有相关峰,只有一条爬升的斜坡。

一条最可能的机制(未验证):两路信号若都带直流偏置,cross·1000/(√ref_pow·√mic_pow) 在每一个 lag 都约等于 ±1000 —— 远超 0.3 的置信门。于是搜索在整条平线上取「第一个严格大于当前最大值」的格点,结果由遍历顺序和噪声决定,表现为「总是贴着窗口边缘」。

这解释了此前所有观察:ERLE 恒为 0、delay 乱跳、以及修好帧宽后 delay 从「None 乱跳」变成「稳定贴着 64」—— 三者本是同一个症状的不同侧面。所以下一步不是继续调 lag,而是先确认真相位下的相关函数长什么样。

最小判据:在 best_lag 里把最大分数与它的 lag 一起报出来。若最大分数本身就在 1000 附近,是直流偏置;若分数很低却仍选在边缘,那是遍历/下标的问题。

判据:相关峰只有 78–271‰,门限是 300

[实测] 把 best_lag 的最大分数报出来(delay_peak)之后,这一整串症状一次说清:

delay None     peak 215 per mille
delay Some(53) peak  89
delay Some(64) peak 271
delay Some(60) peak  78
delay Some(55) peak 193

门限 MIN_CORRELATION = 300 没有被跨过一次。 于是 estimate 从未被更新,报出来的 delay 全是陈旧值 —— 修好帧宽后那些「稳定贴着 64」不是收敛,是残留。

DC 偏置假设到此证伪:那种情况下每个 lag 都会给到 ~1000,而实测最高 271。

真正的事实是:麦克风与参考在 ±64 样本内基本不相关,搜索取到的最好点也只有 0.08–0.27。延迟搜索没有坏 —— 它在正确地回答「这里测不到相关」。

MIN_CORRELATION 的注释原本就写着 [据称] 0.3 是留了余量的猜测,并想要一个台架数字。现在这个数字有了:这块板的声学路径只到 78–271‰。门限该按实测重定,而不是继续调 lag —— 那正是它当初说要避免的事。


本文顺序:先算法验证,后硬件代价。 这两件事的因果曾被我弄反过一次——先测了 CPU,然后拿一个合成房间的数字去证明算法行。顺序反过来才对,因为「算法值不值得做」决定「要不要为它花 CPU」,反过来不成立。


没有可用的公开录音做验证:三份实测不可用,第四份誊录不可验证

[实测] 计划要求「用官方测试向量量出定点线性 AEC 能压下多少 dB」。找遍之后:不存在一份干净的、含真实线性声学回声路径的 (参考, 麦克风) 公开录音。

乐鑫 docs/_static/aec_in_far.wav / aec_in_near.wav。 标注为「远端(播放参考)」与「近端(含回声的麦克风)」,是读者第一个会拿的文件。两者不同步:全程 53 s、遍历所有滞后,最大归一化互相关 0.137;单抽头拟合只解释 0.08 dB;包络相关 0.35;谱质心 1301 Hz vs 2620 Hz。本实现在这对上得到 −14.4 dB——正是「减掉一个不相关的信号」应有的结果。

乐鑫 audio_mic_file / audio_ref_file。 这是乐鑫自己 test_afe.cpp 喂给 aec_process 的一对,同步(相关 0.743,单抽头增益 0.9956)——但里面没有回声路径。离线最小二乘(任何平稳线性抵消器的上界):

抽头全程 ERLE核峰位置
32−1.04 dB1.02,落在最后一抽
64+0.04 dB1.03,落在最后一抽
128+0.42 dB1.02,落在最后一抽
256−4.97 dB1.02,落在最后一抽

能量压在窗口末尾那一抽、无衰减结构、无体延迟——不是声学路径。前半段拟合后半段测试得 +5.08 dB(样本内 +6.15 dB),关系是持续存在的,只是不是回声。

Microsoft ICASSP AEC-Challenge 的 real_doubled。 本领域真正的参考数据集,10,000 个真实环境。扫了 40 个 GUID,没有一个有线性回声关系:128 抽离线 Wiener 上界最好的是 −0.42 dB,任何位置的最大相关 0.137。这是有据可查的行为,Cutler 等《ICASSP 2023 AEC Challenge》(arXiv:2309.12553) §4.1:

Even though our application uses the WASAPI raw audio mode to bypass built-in audio effects, the PC can still include Audio DSP on the receive signal [...] it can also include Audio DSP on the send signal, such as AEC and noise suppression.

消费级音频栈若在环回抽头和落盘文件之间已经跑了它自己的回声消除,线性模型就没有任何东西可预测。同一节说明 ERLE 也不是这份数据集的指标(与主观质量 PCC 仅 0.31)。

ITU-T G.168 (04/2015) 是下一个答案,而且方向完全正确:Annex D 给出 8 个回声路径模型 m_i(k) 的显式冲激响应表加缩放因子 K_i,参考素材确实一个字音频文件都不需要——正是该找的东西。PDF 公开可下载(154 页)。但没有采用,因为誊录无法验证:

  • 部分页面上表格的首列被文本抽取吞掉(m1 每行的第一个数与同行其余数差 1–2 pt 基线)
  • 读取顺序有歧义("to be read in columns"),列主序与行主序两种读法差一倍
  • 两种读法都违反标准自己的不变式——「缩放后 g(k) 的幅度响应不得超过 0 dB」——超出 1 到 11 dB

验证不了的表格不是测试该建立其上的数据。这和删掉软件时钟标定是同一个理由:交付站不住的数字比不交付更糟。

不过 G.168 的 Test 12 注记替我方结论盖了章:

This test may be enhanced by the future addition of test vectors that represent real acoustic echo signals. This enhancement is for further study.

标准机构自己说真实的声学回声测试向量还不存在。

于是参考基准改为「同一条路径上的离线最优」

不是借用一条外部路径,而是对每条路径当场解最小二乘,报出同样长度下任何滤波器能达到的最好结果。这个数是算出来的不是誊来的,所以不可能错,而且它回答的是线性自适应滤波器真正要回答的问题:它找到最优线性解了吗,差多少?

复现(无外部资产、无网络):

cd apps/iot && cargo test -p iot-core --test aec_erle -- --nocapture
路径抽头离线最优本实现差距
短,快衰减1662.961.01.9
中等,中等衰减4858.857.21.6
长尾,慢衰减19238.938.80.1
尾长于滤波器25614.413.21.2

15 dB 那道闸被重新定义了:它是对路径说的,不是对算法说的

尾长于滤波器时,离线最优本身只有 14.4 dB —— 任何 128 抽线性滤波器都到不了 15 dB。要求算法到达 15,是要求它出错。

所以验收判据从「固定 dB」改成「与离线最优的差距 ≤ 3 dB」,而 15 dB 只施加在滤波器能完整容纳的路径上。这条区分是自验证基准才给得出的:借来的路径告诉不了你上限是谁设的。

同时保留两条限制:过了 15 dB 是必要不充分(PCC 0.31);这些路径是生成的,测的是契约不是房间,本板的回路增益、体延迟、门限仍需板子。


生成路径必须有直达反射,否则延迟搜索无处可寻

[实测] 第一版路径的每一个抽头都是同量级随机值,于是没有「起点」——每个滞后的相关都差不多,延迟搜索锁不住任何东西,只会报噪声碰巧最大的位置:12 抽的路径报 15,4 抽的报 22(离线核对确认峰值本来就在那里),滤波器瞄错地方,只拿到 0.4 dB。

真实的房间冲激响应有一个明确的直达反射压过其后所有反射。补上(直达 30,000、其后 ±8,000)之后,四条路径的互相关峰精确落在真值上(8/12/6/4),ERLE 随之从 0.4 dB 变成 61 dB。

同类的错误在断言里也出现过一次:原本测「说话人没被学成回声」的指标是滤波器能量落在路径之外的占比,而一条 48 抽路径配 128 抽滤波器,路径之外本来就有 46% 的能量(很多小系数)——该测的是增长量,不是占比。


冻结拦得住持续损伤,拦不住起始那半秒

[实测] 这是新测试逼出来的。相干度计量器的时间常数约 512 样本,所以说话人开口后,滤波器仍以全速自适应了约 543 个样本,然后冻结锁定了状态。

值
冻结覆盖31,457 / 32,000 样本(98%)
路径外能量,静默时3.203e10
冻结生效时1.176e17(3.7e6 倍)
结束时1.176e17(冻结之后没有再增长)

所以设计是部分有效的:冻结确实止住了损伤增长,但它止不住第一段。Q30 下这对应约 3.6% 的增益被学进了本该为零的抽头——不是 0,但也不是零。

根因是计量器的滞后,不是门限。 相干度要几百个样本才读得出「不相干」,而滤波器在这几百个样本里已经跑完。缩短保持窗口解决不了(相干度还是那么慢)。真正的修法需要一条更短的冻结决策专用计量器,那是设计改动,不在验证这一轮里做。

[据称] 听感上这半秒有多糟,没有在本板测过。


步长不能按残余电平缩放

[实测] Valin 的变步长 MDF/NLMS(TASLP 2007)确实按残余缩放步长,这也是「从残余推导步长」最自然的读法。但在收敛之前,残余就是麦克风本身——它不是一个和滤波器有关的量,它是房间里最响的东西。

在 12 000 LSB 的合成房间上,这把起始步长压到全速的 1/48,滤波器得到 −0.5 dB;同一套循环换成固定全速步长得到 20 dB。慢启动不是「精度差一点」,是整个收敛过程都没发生。

所以 gate.rs 里的步长是常数:全速,或者冻结时为零。残余只用于它能唯一回答的那一个判断。

「全速」是选出来的,不是默认值:NLMS 在 (0, 2) 内稳定,1 << 29(Q30 的 0.5)是常规选择,下面还有余量。


冻结不能只看残余电平:收敛中的滤波器和一个说话的人留下的都是大误差

[测试] 这是上面那个失败从另一侧撞上来的同一件事。绝对门限作用在残余上,会在滤波器自己的启动过程中把它冻住。

分开这两者的量是相干度:

  • 滤波器还在收敛时,误差主要是它还没解释掉的那部分回声,因此与参考高度相关(相干度 ≈ 1);
  • 有人说话时,误差是语音,与参考几乎无关(相干度 ≈ 0)。

这是 Gänsler 的相干度双讲检测(JASIP 2015 指出它的两个缺点:门限依赖背景噪声与说话人音量)。它放在滤波器旁边而不是旁边再放一个检测器,是因为滤波器本来就要算的东西全在这里:三个滑动均值,每个样本多一次乘法。

gate.rs 的两个门限都标了 [据称]:LEAKAGE_FREEZE = 1<<24(约 4096 LSB RMS,取自音频记录里 30 cm 语音峰值 80 dB SPL 的标定)、MAX_COHERENCE = 0.6。两个都要在台架上用真人重测。

has_path 这一层被删掉了。 它用「预测量够不够大」代替相干度,而一个把麦克风整个预测出来的滤波器预测量也很大——它其实什么都没学到。相干度一个量就够。


相干度的分子是带符号均值,不是平方均值

[测试] coherence = |E[e·x]| / (rms_e · rms_x)。第一版把 e·x 平方后再进滑动均值,得到的是幅度平方相干——它分不出「路径反相」和「路径不存在」,而两者在这里要分开。

代价一样是一个乘法,收益是分得开。所以 power.rs 里多了一个 SignedMeter,和 PowerMeter 同一个位移、同一条理由:截断时补 ±1 的下限是它能真的收敛到目标而不是停在半步之外的唯一办法,而对带符号的量这件事重要两倍——到不了零的均值不是均值。


Noise::new(seed | 1) 把相邻种子折叠成同一个序列

[测试] xorshift 状态为零就永久卡死,而零正是调用方最可能写下的那个种子,所以它需要被换掉。但只需要非零,不需要奇数。

原来的 seed | 1 把 2 折成 3、把 3 折成 3。于是「两个独立的噪声源」其实是同一个信号,相干度读 1.0,而正确的值是 0。这个 bug 的危险在于它完全静默:测试照跑,只是断言了一个假的数。

signal.rs 里现在有一条测试专门钉住它。

顺带:同一次重写里还发现测试生成器用 i32 LCG 配 n >> 16 做均匀噪声是错的——算术右移让一半的值变负,% 16000 保留符号,再减 8000,样本范围变成 [−24000, −1],均值平方大 6.8 倍,冻结门限因此形同虚设。现在统一用 signal::Noise。


延迟搜索必须有置信度门限

[测试] 比窗更远的路径,在窗内每个滞后上的相关都约等于零,而搜索返回的是最大的那一个——也就是噪声里的最大值。功率门限挡不住这一种:两个不算静音的房间照样能找到一个峰。

没有门限时,超出搜索范围的路径被回答成窗内某个随机的数(实测报 2,真值 84),而调用方无从分辨。加 MIN_CORRELATION = 300(千分比)之后同样的输入回答「没找到」,settled() 保持 false。

窗外的路径返回窗边缘同样不对——那是一个看起来很确定的错数。现在返回 None。


延迟搜索必须限频:它比用它的滤波器还贵

[实测] 搜索一次是 (2·SEARCH + 1)·WINDOW = 65 × 256 = 16,640 次乘加,约等于自适应滤波在同样本上工作量的三分之二。每个样本都跑,找到延迟会比用它抵消更贵。

延迟是板子放在哪里的属性,不是音频的属性,所以按 40 ms 一次(REFRESH_SAMPLES = 16 × WINDOW)。首次搜索在历史攒满时立即发生,不等一个间隔——延迟是最该早拿到的量。

加之前 cargo test -p iot-core aec 要 45 s(851,940 样本上跑成平方复杂度);加之后 0.14 s。DelayEstimator::searches() 报告实际搜索次数,因为提高这个速率的人有权知道自己买到了什么。


ERLE 的稳态值和全程值不是同一个量

[实测] 同一段音频、同一套滤波器,两个数都不是错的,只是回答不同的问题。AEC Challenge 的规则与此一致(Cutler 等,§7):

For the far end single talk scenario, we evaluate the second half of each clip, to avoid initial degradations from initialization, convergence periods, and initial delay estimation.

所以只报后半程是标准做法,全程数降级为附带。

第一版把「全程求和」和「末态瞬时读数」放在一起比,差 51 dB,然后花时间去怀疑仪表。真正的教训是:收敛过程和稳态必须分开报,否则其中一个必然被当成另一个。逐秒曲线(16 → 19 → 21 dB)是它收敛多快的证据——一个只报终点的数字分不清「收敛很快」和「二十秒后才收敛」,而这个差别决定电话的前半程能不能用。

计量器自身的读数(63.0 dB)与样本求和(57.2 dB)差 6 dB,在容差内;两者的窗口不同,一个是 ~30 ms 的指数平均,一个是整段求和。


数字上踩过的几处

[测试] 都是先错了、后被测量推翻、再改的:

PowerMeter 的步长下限不能用 max。 max 取较大的那个值,于是电平下降时每样本走 1 LSB 而不是 2⁹,计量器把电平多保持约 1000 倍。残差停在 389 而真值是 900。正确做法是仅在移位结果为零时取 ±1。

环形缓冲要先推进再写入。 否则 head 指向最新样本的下一个槽,tap(0) 读到错位一格,每个峰都晚一抽落地。

整数 log2 的尾数提取。 平方尾数时进位位必须和被缩放的值分开处理:进位用 squared >> 1,不是 squared >> (LOG_FRACTION+1)。

ErleMeter 的比值不要用 Q 缩放。 分子乘 2¹⁶ 之后会从对数里多出 16 个八度。整数除法截断误差不到 0.5 dB。

保持计数器必须封顶。 above 自由增长会在长段说话时饱和到 u32::MAX,之后每个样本只减 1,再也解不开——滤波器永久冻结。


CPU:先量时钟,再谈结论

[实测] 第一版这里贴了一张我站不住的表。它说 L=128 要 74.7 ms/tick、「219 cycles/MAC」,并用一条数据手册上的时钟去换算。两处都错了,错在同一个根上:我从来没读过这颗芯片实际被配成了多少。

参照循环揭穿了它

探针里加了一条尽可能平凡的循环:256 系数 × 1024 样本的整数 FIR,无边界检查、不走 &mut self——就是 Espressif 公开基准 dsps_fir_f32 的原题,所以数字可以直接对。

我预期它跑在 1.7 cycles/MAC 附近。实际 12 cycles/MAC。一条比 AEC 滤波器更简单的循环慢 193 倍,说明问题不在 AEC 的代码。

CPU 当时跑在 80 MHz

esp_hal::clock::cpu_clock() 读回 80,000,000 Hz。原因在 esp-hal 1.2 自己的源码里:

pub enum CpuClock {
    /// 80 MHz CPU clock
    #[default]
    _80MHz  = 80,

Config 派生 Default,CpuClock 的默认臂就是 _80MHz。esp_hal::init(Config::default()) 给的是 80 MHz。 我先前引用的「Config::default() 会设到最高频率」来自一篇讲 Arduino-ESP32 的博客,不是 esp-hal。

换成 80 MHz 重算,参照循环 12 cycles/MAC,而 Espressif 的浮点汇编在 240 MHz 是 1.69 —— 折到 80 MHz 是 36。我的整数循环比它还好。平台、时钟、计时器、构建全部正常。

修正后的数字

µs / tick (L=128)cycles/MAC @80 MHz
参照 FIR(平凡整数循环)—12
AEC 卷积16,20631.6
AEC 系数更新48,941119

不是 219。 但「5.4 倍来自边界检查和 &mut self 栈重装」这个归因也是错的——下一节。

分块自适应:过了

系数更新是最大的单项——L=16 的 7.7 ms、L=128 的 49 ms——而每样本更新一次不是必须的。改成每 4 个样本一次(UPDATE_STRIDE,用 head % 4 计数,不加任何状态)。

[实测] L=16 从 15,531 掉到 9,680 µs,过了 ≤10 ms 那条线。

逐样本stride 4
L=16 系数更新7,699 µs1,880 µs(−76%)
L=16 滤波器整体9,8033,945(−60%)
L=16 整体15,5319,680 µs
L=3222,89213,528
L=6436,77220,448
L=12870,94534,911 µs(−51%)

代价在 ERLE 上:稳态逐位不变(四条路径对离线最优的 gap 仍是 1.9 / 1.6 / 0.1 / 1.2,移动路径照样跟过六样本的突变),但 whole-file 从 20.9 掉到 16.2 dB——慢的是收敛,不是收敛之后。

卷积现在占滤波器的一半(2,065 vs 1,880),更新不再是主要代价。

频域路线:写完了 FFT,量出来是「否」

最佳实践指向 MDF / 分块频域 NLMS。arduino-audio-tools 恰好把两套并存(时域 NLMS 与 speexdsp 的 MDF 移植),对时域那套的判词是:

O(filter_len) work per sample, no FFT … a reasonable fit for small microcontrollers with a short, simple echo path cost grows linearly with it — this is not the right tool for a long, reverberant echo tail

所以按计划先写 FFT、先测它,再谈架构:512 点 radix-2 定点,旋转因子用二进制幂生成(递推相乘会漂,逐项 ≤9 次舍入),7 个测试,其中一个拿独立的 f64 直接 DFT 逐点对拍。

[已删] **那个 FFT 量完之后删掉了。**结论留下,代码没有——它在产品路径里零引用,留着就是一份没有调用者的代码,而记录里留着一个「变换要占预算 182%」的结论比留着一个没人用的变换更有用。下面的数字是实测的,不是推断。

[实测]:

512 点正变换3,046 µs
cycles/蝶形105.8
一个 320 样本 tick 需要 2 块 × 3 次变换 = 6 次18,276 µs = AEC 全部预算的 182%

变换一项就超了预算 1.8 倍,分区卷积、滤波更新、延迟搜索一分钱都还没花。

改小帧长救不回来:每样本代价 ∝ 3·5·2M·log(2M)/M = 15·log(2M),M=256 是 135、128 是 120、32 是 90——**渐近到常数。**实数 FFT 打包砍一半到 9.1 ms(91%),剩下 9% 给滤波器,不成立。RAM 也要从 3,064 B 涨到 6 KB。

第二核:数字逐位相同

FROM_CPU_INTR1 是同一颗核上的第二个中断源(InterruptExecutor<1>),不是第二核——**产品固件全部跑在 core 0,core 1 完全空闲。**探针改成两个核先后跑同一份测量(不重叠,否则会抢总线):

cpu 0cpu 1
FFT 正变换3,046 µs3,046 µs
cycles/蝶形105105
参照 FIR34,628 µs34,628 µs
L=128 整体70,945 µs70,945 µs
系数更新48,939 µs48,939 µs

20 多项独立测量、跨 4 个抽头数、含均值和最坏值,全部逐位相同。

双核一分钱都不值,而这同时把那个悬着的诊断问题关掉了:两个独立的 LX7 核撞在同一个共享资源上,每次访问的固定延迟相同、操作次数相同,总时间必然相同。这不是「core 0 太忙」,是两边共享的瓶颈。

IRAM:也不是

把蝶形单独提出来放进 .rwtext(链接脚本把它映射到 0x4037a4c4 的 IRAM 区,确认不是 flash 的 0x42010020):

flashIRAM
FFT 正变换3,046 µs3,235 µs
cycles/蝶形105112

放进去反而慢 6%。取指不是瓶颈。

四条循环,IPC 全在 0.1–0.33

循环cycles指令数(约)IPC
参照 FIR12 /MAC40.33
卷积31.6 /MAC50.16
系数更新(逐样本)119 /MAC120.10
FFT 蝶形105.8100.09

FFT 蝶形是这批里最干净的一段代码——没有钳位、没有 &mut self、没有 64 位累加器链——IPC 最低。取指否了、双核否了,剩下的只能是内存系统本身。

[据称] 是 flash 控制器、缺数据缓存、还是乘法未流水,未测。

延迟搜索:栅格抽稀不行,但它是最后的固定成本

[实测] 一次搜索约 32 ms,每 256 ms 一次(REFRESH_SAMPLES = 4096),摊到每 tick 3.0 ms——L=16 那 9,680 µs 的 31%。而且它单独就能撑爆 30 ms 的 MAX_SINGLE_HOLD_MS 上界。

最直接的想法是把延迟栅格每 4 个样本取一个(搜索成本 ÷4)。**试了,不行。**测出来这个模块自己的参考信号上:

真实延迟网格上的最高分
0lag 0,1000
1lag −48,146
7lag 56,101

延迟为 1 时,连相邻的 lag 0 和 lag 4 都相关不到。宽带参考下相关峰只有一个样本宽,栅格一个都不能少。所以「只损失几个抽头余量」这个判断是错的。

剩下的两个旋钮是 SEARCH(找得到的延迟范围)和 WINDOW(能锁住的基频结构)——两个都要拿能力换时间,不是白捡的。delay.rs 里留了 LAG_STEP = 1 和一条 const _: () = assert!,让下一个想抽稀的人必须先删掉一句写着为什么不能的声明。

时钟:80 → 240 MHz,控制台没坏

CpuClock 的默认臂是 _80MHz,整块板当时一直跑在数据手册上限的三分之一。esp-hal 对 160/240 预设有一条注释说换 PLL 会影响 USB Serial/JTAG,而本仓库控制台正是它——[据称],一直没测。

[实测] 测了,240 MHz 跑通,USB Serial/JTAG 控制台全程正常。(只测过一次,这是它唯一的后续风险。)

@80 MHz@240 MHz
L=16 整体9,735 µs3,248 µs
L=16 最坏 tick39,594 µs(破 30 ms 界)13,287 µs
延迟搜索最坏32,870 µs(单独就破界)10,957 µs
L=32 / L=64 / L=128 整体13,528 / 20,448 / 34,9114,511 / 6,818 / 11,639
L=128 最坏 tick—21,614 µs(30 ms 界内)
FFT / 参照 FIR3,235 / 34,629 µs1,079 / 11,549 µs
cycles/蝶形112112(一点没变)
参照 FIR cycles/MAC1212(一点没变)

两件事同时成立:最坏 tick 的问题自己消失了(延迟搜索从 32.9 ms 到 11.0 ms,单独就在界内),而 cycles 一个都没变——时钟纯粹是个除数。

所以 chip_init_at(CpuClock) 加到了 iot-chip-esp,探针用一个常量在两者间切换,两张表来自同一棵源码树、只差这一个常量。

数据缓存:走不通,但原因不是「没开」

ESPHome 在 ESP32-S3 上跑 AEC 的 sdkconfig 里是:

CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"

后两条与 ESP-SR 那张 AEC 性能表的脚注逐字相同,而 ESP-SR 没有它们直接拒绝启动(E (4) SR_SYS: Data cache should be 64KB)。数据手册 3.1.5:数据缓存 32 KB(单 bank)或 64 KB(双 bank)。

但这条路我走不通:esp-hal 1.2 没有 S3 的缓存配置 API,esp-metadata 也没暴露对应的 eFuse 字段。ESP-SR 的做法是「拒绝启动」而不是「去配它」,本身就说明这个大小是芯片启动时的属性,软件改不了。

而且 IPC 那个谜题不靠它解释。四条循环按读写而非工作集大小排开:

循环工作集读写cycles
参照 FIR5.2 KB只读12
卷积450 B只读31.6
FFT 蝶形6 KB读+写105.8
系数更新450 B读+写119

**工作集最小的那个不是最快的,只读的比读写的快。**S3 的数据缓存是 write-back,一个每样本每抽头都回写同一位置的内层循环是写回缓存最差的模式——这也顺带解释了 stride 4 为什么直接省下 76%:写次数除以 4。

[据称] 这块板有没有 64 KB 数据缓存,未读出来。但提时钟之后它不再决定 AEC 塞不塞得下——cycles 没变,只是墙钟除以了 3。

还有一条:这个 target 的 FPU 是关的

[实测] 探针里同形状的 f32 参照循环跑 >120 秒,而 i32 孪生 140 ms;二进制里带着 libgcc 的全部软浮点助手(__mulsf3、__addsf3…)。浮点在 xtensa-esp32s3-none-elf 上走软件模拟。

所以 ESP-DSP 那个 1.69 cycles/MAC 在这里够不着,「换成浮点」不是条捷径。已把 f32 参照从探针里撤掉(否则每次探针运行要多花两分钟),结论留在这里。


计时器本身:一次被折叠的标定,和一个最终没交付的数

[测试] 探针最初的版本报 cycles/MAC,为此加过时钟标定。三版都被否决:

  1. acc += 1 链被强度削减成 acc += 64,读出 85 GHz。
  2. acc = acc * M + C(LCG)正是 InstCombine 的 pop idiom 识别的形式,同样被折叠,读出 251 MHz。
  3. 链内加 black_box 不折叠了,但每个 black_box 自身 ~100 周期,读出 2.34 MHz。

最终 cycles/MAC 的软件标定被删掉了。 esp-hal 在这个目标上没有暴露循环计数器,而我写的每一种标定要么被折叠、要么被自己的屏障成本主导。

它的替代品是现在这条参照循环,而且比标定强得多:它不猜时钟,它在同一个镜像里、同一块板上、同一套构建下跑一条已知的活,然后把比值拿回来。时钟本身则直接用 esp_hal::clock::cpu_clock() 读回。

微秒是可靠的:systimer 跑在 XTAL(40 MHz × 0.4 = 16 MHz,ticks >> 4),与 CPU 时钟无关,所以 Instant 给出的是真实微秒,而日志墙钟与它一致。


文献对齐:三条路线被证据否掉,一条按计划改的东西也不该改

这一节记的是查了外部资料之后决定不做的事,以及为什么。

比例型 NLMS(PNLMS / IPNLMS):否

最顺手的重构方向——按抽头大小分配步长。原始文献 Duttweiler 2000,以及综述对适用面的定性:

PNLMS 的真正收益在回声路径稀疏时出现……主要应用是线路/网络回声消除器,因为混合电路的典型回声路径是稀疏的。否则,对于弥散回声路径(典型就是在声学环境中观测到的),几乎没有任何收益。

这块板就是声学环境。抄过来兑现不了。

子集轮转的部分更新:否,而且是降级

本来打算把「每 4 样本全量更新」换成「每样本更新 1/4 抽头、轮转」——乘加数完全一样,听起来严格更好。Partial Update LMS 那篇正好对比了这两者:

每 P 次迭代做一次更新,得到的滤波器与每步做 P 次更新完全相同。因此该算法在收敛性上毫无损失。

子集版反而有损失,代价是把步长从 0.004 压到 0.001 才能追平。当前实现已经是两者中更好的那个,不用动。

混合式开环/闭环双讲检测:改了,然后撤掉

文献(IRISA;Benesty 等 2000)把双讲检测分成开环(麦克风×远端激励,与滤波器无关,收敛前可用)和闭环(麦克风×滤波器估计,收敛后最锐、收敛前无效),并建议先用开环直到足够收敛,再切闭环。当前实现两个都不是:它算的是 残差 × 参考,而残差 = mic − ŷ,已经含滤波器状态。

于是把三种统计量都实现了,在同一条房间路径上逐样本对比。结果:

说话人峰值误冻结冻结延迟漏检残余均方过 LEAKAGE_FREEZE
8000075275126 038 827✓
40000032 0007 366 231✗ 差 43%
20000032 0001 841 437✗ 差 89%
10000032 000460 200✗
5000032 000114 877✗

三个统计量在每一个电平上逐位相同。 绑定约束是 loud 那个电平门,不是相关性:门限 LEAKAGE_FREEZE = 1<<24 是均方(RMS 4096 LSB),说话人峰值 4000 时残余均方只有 7 366 231,低于门限 43%,相关性根本没被读到。

所以混合式检测在这个电平设定下买不到任何东西。已撤回,update 保持两参数。留下的不是机制而是一条钉住的盲区:gate.rs 里 a_talker_below_the_level_gate_is_not_detected 断言 2000 LSB 说话人现在测出来低于门限、闸门不动;改门限是一次有意的动作,不是副作用。

这也说明真正要修的是门限,而门限标着 [据称]——它是照 audio.md 的麦克风标定推出来的,不是台架上量出来的。

双滤波器 Bi-LMSM:暂缓

Gänsler 2000 指出它不像 PNLMS 那样依赖稀疏假设,是声学场景对口的。但要两组系数加逐样本择优,乘加翻倍。本板稳态 ERLE 61.0 dB 对 G.168 的 15 dB 门槛,没有需要它解决的问题。

频域 / 多延时滤波器:维持否决

写完了 512 点定点 FFT,量出来每 tick 3,046 µs = 10 ms 预算的 182%,代码已删。文献侧不推翻这个决定。

内存核算曾是一个谎

state_bytes() 的注释写着「板子想提前知道占用多少时不必来问」。它数的是 ring + taps,只覆盖了滤波器:

旧函数报的板上实测host 实测低报
L=16450 B2 592 B2 616 B5.8×
L=128898 B3 040 B3 064 B3.4×

板上比 host 少 24 B:size_of 跟着目标字长走,xtensa 的 usize 是 4 字节。引用这个数必须说清在哪个目标上量的——host 的数字不是这块板的代价。探针会打印当前目标的真值。

漏掉的是 DelayEstimator 的 2 096 B(两个 [i16; 512] 历史缓冲)。而 nlms.rs 里那条 assert!(state_bytes(MAX_TAPS) < 1024, "a kilobyte was budgeted") 就建立在这个数上,通过了。探针至今在打 [ECHO] --- L=16, 450 B of state ---。

两条测试让它活下来,因为它们断言的正是错误函数的定义:

assert_eq!(bytes, ring + taps, "the estimate counts something twice");  // 恒真
assert!(bytes < 1024, ...);                                             // 在谎上通过

现在 state_bytes<L>() 就是 size_of::<Canceller<L>>(),L 是 const 泛型而不是运行时参数——运行时参数让调用方能传一个编译器无法核对的数字,那正是它漂移的原因。新增的 the_reported_size_covers_the_whole_canceller_not_just_the_filter 会断言报告值至少装得下滤波器和延迟估计器两个主要部分。

参考源接入了,而「取自 render_slice」这条线索是错的

[实测] 上面那条「数字抽头取自 audio_out::render_slice」在实现时才发现走不通,而走不通的原因就是本文件反复出现的那类错误:量对了,接错了位置。

render_slice 是写入侧。发送环是 24 个描述符 × 960 B = 23 040 B = 5 760 帧,48 kHz 下是 120 ms 还在路上的音频。所以从写入侧抽头,拿到的是一个比回声早最多 120 ms 的参考——而延迟搜索只覆盖 MAX_DELAY = 64 样本 = 16 kHz 下 4 ms。三十倍在窗外:搜索会答「没找到」,滤波器于是朝互相关栅格碰巧最大的噪声收敛,看起来完全正常。

[测试] 这条差异被钉在 host 上,而且钉的是阈值不是修辞(aec/stage.rs::a_small_misalignment_is_absorbed_and_a_large_one_is_not):

参考相对麦克风的偏移结果
0 帧正常抵消
1 帧仍然抵消
MAX_DELAY + 1 = 65 帧回声基本原样留下

一帧失配是可恢复的——延迟搜索 ±64 样本的窗加上滤波器自己的滑动窗,会把路径学到偏一抽的地方去。所以「错一帧就毁掉」这句话本来是错的,钉它的那条测试反而把正确的话说出来了:不可恢复的分界在搜索窗之外,而不在一帧。

于是接入按这个形状做:

  • 写入侧照旧抽(render_slice 里每个帧一个样本——两个槽载同一个流,按槽抽会让抽取率减半),读取侧走播放游标:Es8311Tx::advance_play_cursor 从 DMA 自己的 free space 反推线缆走到哪儿,把增量喂给 ReferenceLog::played。这就是 Speaker 没有 played_frames() 的答案——不给 Speaker 加方法,而是加一条从扬声器单向下游发布的共享单元。
  • 共享单元跨两个 executor:喂数在 Priority2 中断上,折叠在协作式 input_task 里,所以是 CriticalSectionRawMutex 的一个 cell(bsp-esp/virtual_components/reference.rs),每次喂数取一次、每次 poll 取一次,绝不每样本一次——48 kHz 上每样本一次锁比它服务的取消器还贵。
  • 落位:降采样器在 core(drivers/audio/decimator.rs,按帧号而非自由计数器取相位,否则两流失相是静默失效),参考影子缓冲在 core(drivers/aec/reference.rs),抽头在 bsp 播出侧,配对在 bsp 采集侧,连线在板模块 Board::new。没有给 Speaker 加任何东西——那会把数据面的耦合从中断侧拉回来,而它的契约正是为了挡这个。
  • 尺寸从几何算出来:REFERENCE_SAMPLES = ⌈(5 760 + 2 880 + 192) / 3⌉ = 2 944,const _: () = assert! 钉住「装得下整个跑道」。采集侧另有一条断言钉住自己的环确实是三趟 poll 深——两个模块各写一份的数字是会漂的,漂了就是日志悄悄 lap。

[测试] ReferenceLog 的 14 条测试里最要紧的一条是 at_play_cursor 的索引单位:环按保留样本编址,而计数器数的是帧。第一版用 written % N 而 written 每帧自增,结果三个槽里只有一个被写过——测试全绿,采样全是零。现在断言的是具体样本值(frames(n) = n × 97)而不是下标,因为错的下标长得像对的。

「空闲空间」是占用量不是位置——差一个整环

[实测] 写完第一版 advance_play_cursor 才发现的错,而且它是这一整节里最危险的一个:读出来仍然全部正常。

DMA 释放环字节,CPU 立刻补回去,所以「空闲」量的是环里排队的量(ring − (已推 − 已发)),不是线缆走到哪儿了。把它当成位置,开机那一刻环里装着整整一环预填的静音、一个字节都没发出去,读数却说线缆已经在一环之后——于是 played 一上来就是 5 760,at_play_cursor 每次都返回 None 或者环里最老的那一格。每一次配对都是垃圾,而每一行日志都像在正常工作。

修法是把算术搬进 core(wire_position)并连同预填一起计入 pushed_frames:

wire_position = pushed_frames − (ring_frames − free_bytes / frame_bytes)

[测试] 两条钉住它:开机(推了一环、释放 0)必须读 0 而不是 5 760;释放量超过推过量时永远不许报在推过量之前(stale DMA 或 restart 后的环)。写这两条测试时自己也错了一次——把 push 之前的空闲量和 push 之后的推过量混在一起算,那当然对不上。两个数必须取自同一瞬间,这条也写进注释了。

两路方向相反,直接 zip 会把整段配错

[实测] 同一类的第二个错,同样静默。麦克风是正着走的(scratch_mic[0] 是本次排空里最老的一帧),参考日志只能倒着读(out[0] 是此刻线缆上的那一帧,也就是最新麦克风听到的那一帧)。这两段直接 zip,最老的麦克风配上了最新的参考——每一对都错了整段那么远,而滤波器照样会收敛到某个东西上,于是 ERLE 仍是一个看着合理的数,不会是刺眼的 0。

修法是在 pair 里把参考反转回时间序再喂(随后 cancel_pairing 才把它交给消除器)。[测试] a_reference_run_read_newest_first_pairs_once_reversed 钉住「反转一次回到对齐配对」与「不反转明显更差」两条;Reference::read_backwards 的文档里也写明了这个方向,免得下一个读它的人重踩。

两处都留在记录里,因为它们是同一类:RING_BYTES - free 看着像位置其实是占用量,而 read_backwards 看着该直接配对其实要先反转。两个都是「读起来完全正常、结果全是垃圾」——这正是这条路径上唯一真正危险的一类缺陷,也是为什么配对的每一列都跟着 ERLE 一起报。

麦克风环的延迟没算进去——差 60 ms

[实测] 三个同类错里最要命的一个,也是唯一一个「不修就整套白做」的。

排空一次采集环时,环里最新的一帧是多久之前采的?答:环有多深就多久之前——本板 3 趟 poll = 60 ms。而 far[0] 是poll 跑这一刻线缆上的那一帧。两者直接配对,等于把每一对都配到了比它新 60 ms 的参考上:

最坏错位   960 样本 @16 kHz = 60 ms
搜索窗     64 样本 @16 kHz =  4 ms

十五倍在窗外。 延迟搜索照样答「没找到」,滤波器照样收敛到某个东西,ERLE 照样是个看着合理的低值。

[实测] 修法是把排空前测到的环深(produced / BYTES_PER_FRAME,正是 poll 已经在算的那个数)作为 lag 传进配对,参考从 play_cursor − lag 开始读。换算成保留样本时向上取整(lag_in_samples):向下取整会少够一点、把配对错开不到一个样本;向上取整最多多够一个样本,方向反了但仍在窗内。

顺带发现尺寸本来就为它留够了:参考日志按 5 760 + 2 880 + 192 帧算出的 2 944 个样本,正好覆盖发送环跑道 + 采集环延迟 + 搜索窗。2 944 − 960 = 1 984 个样本的余量。所以不是加内存,是把已经算进去的那一项用上——之前它只是躺在尺寸公式里没人用。

[测试] a_capture_ring_s_lag_rounds_up_into_whole_samples 钉住取整方向,并逐个检查「转出来的样本数乘回去不小于原帧数」。

L 定在 16,因为 240 MHz 下 L=128 的最坏 tick 是 21.6 ms,而 input tick 本身已经占 17–25 ms、MAX_SINGLE_HOLD_MS 是 30——128 抽在这个 poll 里塞不下。要更大就是滤波器放慢或者 poll 不再折别的,是设计改动不是一个常数。

产品固件里默认不开

[实测] 接完之后自己引入的一个问题,也是上面这条路径最容易顺手犯的:包络折的仍然是原始麦克风,取消器的输出被丢掉了,而取消器每个 poll 都在跑。这正是 architecture.md 明说不可表达的那个象限——「低保真 + 高成本」。

根因不是算术,是速率对不上:取消器工作在 16 kHz,而包络是按 48 kHz 两槽一帧定的。要把残余折进包络就得重采样回去,而为了喂一个电平表而重采样回去是一次没人做过的测量决定(A 计权那条 6 阶 IIR 会看到阶梯波)。在有人读它之前,跑它就是每 tick 3 248 µs 换一份没人看的输出。

所以按本文件里 HPF_CORNER_WALK / CAL_SPL_LOG 的既有形状给了一个编译期开关,默认 false——不是 Cargo feature,因为它是一次测量不是一个产品档(features.md 判据一)。接线照旧在树里,echo-probe 显式打开并读数;状态仍然常驻 2.6 KB(对照板上已有的 150 KB 帧竞技场),换来的是时间而不是闪一个分支。

面板上没有新增 ERLE 行。理由同上:这一格现在恒为零(开关关着),为一个未验证的取消器在 audio.md 记着「列算错了在屏上只表现为看着像设计」的布局上加一格,是拿最贵的东西换一个还不能验证的数。

新增常驻内存 12.7 KB

[实测] 按本板几何算出来的,不是估的:

件字节说明
参考日志5 8882 944 个 i16,&'static cell
取消器 L=162 592本板实测值,见上
麦克风 scratch1 282每次 poll 最坏排空量除以抽取率
参考 scratch3 202环延迟 960 + 本次 641,见上节
合计12 964(12.7 KB)

对照:板子已经背着 150 KB 帧竞技场(面板 DMA 与摄像头共用),另有 64 KB 堆。12.7 KB 是一次性常驻且不进堆——feed 不得分配,所以日志不能是 Vec,这也是它定长、尺寸写死的原因。PSRAM 仍未开启(按约定先量后开),这 12.7 KB 也不需要它。

[实测] 产品镜像 vanling 在本改动下链接通过(.bss 303 688 B / .data 9 316 B),探针 echo-probe 也链接通过(.bss 259 872 B / .data 8 644 B,探针不画面板所以少背帧竞技场)。

未决与边界

  • CPU 过了,而且是全线。 @80 MHz + 分块自适应之后 L=16 是 9,680 µs;再提到 240 MHz,L=16 是 3,248 µs、L=128 是 11,639 µs,最坏 tick 21.6 ms,全部落在 30 ms 界内。 USB Serial/JTAG 控制台实测没坏。代价是收敛变慢(whole-file 20.9 → 16.2 dB),稳态 ERLE 逐位不变。 aec/mod.rs 与 44 个单元测试、tests/aec_erle.rs 全部留在树里,作为「这条路走不通」的证据与算法正确性的证明;aec-probe 留在 check-s3 的 bin 列表里,成本一变就编译失败。
  • 时钟已改成 240 MHz,产品固件也已经换过去了(app/src/main.rs 走 chip_init_at(_240MHz))——上面那条「只在探针里」是过时结论,17–25 ms 那个 input tick 数字是 80 MHz 测的,读它要带上 3 倍余量。240 MHz 的代价是功耗、PLL、USB Serial/JTAG 的长期稳定性(实测过一次,不是长期结论)。
  • 这块板有没有 64 KB 数据缓存,未读出来。 esp-hal 无 API、esp-metadata 无 eFuse 字段,而 ESP-SR 没有它会拒绝启动。提时钟后它不再决定 AEC 塞不塞得下,但 IPC 那个谜题还在。
  • 系数更新的 119 cycles/MAC 没解释。 降频绕开了它,根子还在。取指已否(IRAM 反而慢 6%)、双核已否(两核数字逐位相同),剩下的只能是内存系统本身。[据称] 是 flash 控制器、缺数据缓存、还是乘法未流水,未测。
  • 起始那半秒的冻结缺陷未修。 冻结止住了损伤增长,止不住第一段,根因是相干度计量器 ~512 样本的滞后。
  • [据称] LEAKAGE_FREEZE 与 MAX_COHERENCE 未在本板标定,仍取自 audio.md 的麦克风标定。
  • RAM 板上实测 3,040 B(state_bytes::<128>(),L=16 是 2,592 B),预算 4 KB。host 上是 3,064 B。
  • state_bytes() 曾低报 3.4–5.8 倍,现已按整个 Canceller 计量,见本文件《内存核算曾是一个谎》一节。
  • 参考源已接入,走播放游标而不是写入指针,见本文件《参考源接入了》一节。尚未在真实房间里跑过——echo-probe 就是为此建的构建,见下条。
  • PSRAM 仍未开启(按约定先量后开),3 KB 不需要它。
  • LTO 现在是整个 workspace 的 release 默认(apps/iot/Cargo.toml)。它让 AEC 快 9.6%,代价是探针的计时括号会被优化掉,所以 AdaptiveFilter::process 带 #[inline(never)]。这个属性是给测量用的,不是给性能用的——谁去掉它,下一份 CPU 数字就是假的。
  • 固定地板未攻。 L=16 的 9,680 µs 里滤波器只占 3,945,其余是延迟搜索 3,059 + 计量器 1,839 + 未归因 2,731。
  • 延迟搜索不再破界(240 MHz 下最坏 11.0 ms),但栅格抽稀已否(宽带参考下相关峰只有一个样本宽)。SEARCH 和 WINDOW 两个旋钮仍要拿能力换时间。
  • NLR/残差抑制未实现,测得的 ERLE 是它之前的值。
  • 延迟锁得住,消除量仍然没有数。 见本文件《延迟锁上之后:锁住了,ERLE 仍然没有数》一节:两次实测 delay 全程只动一次(26;换位置 53 → 42),而固件的 ERLE 计量器至今没有在板上出过非零读数。中途算出的 12.3 dB 来自一次已删除、不可复现的离线 dump,不作数。L=16 仍然是 host 合成房间 + 离线最优化上定的,板上这条路径没有改变抽头数。
  • echo-probe 会报「ERLE 好看但配对是坏的」这种情况,所以列不能分开读:配对数为零时 ERLE 恒为 0(日志不动就不测量),而日志 lap 过时 ERLE 可以看着正常。EchoReport 把它们捆在一起就是为了不让调用方拆开读。探针每行还会确认取消器确实开着——一串零和配对坏了输出的是同一个东西。
  • 产品固件默认不开取消器(编译期开关,见上节),所以这四个数在产品镜像上全是零,只有 echo-probe 会给出真数。
  • 降采样相位目前两路都取 0(发端比收端先 arm,帧 0 对帧 0)。换一块先 arm 麦克风的板子必须同时把偏移报给参考日志的相位和采集侧抽取器,差的部分由延迟搜索兜——这不是自动的,是一个会静默错一抽的常数。

MIC3 作为硬件参考:寄存器查对了,链路仍然不出数

立创·实战派这块板把 ES8311 的输出接到了 MIC3,官方例程要 tdm_enable = true。本轮把这条路走到底,得到三个推翻先前结论的结果。

先修的是代码里一处对数据手册的误读

es7210.rs 原先注释写着「REG_SDP_INTERFACE2 bit1 选择 TDM」,常量名 SDP2_NO_TDM 把这个误解固化了下来。手册原文:

  • 寄存器 0x12 bit1 是 TDMIN_PDN_OFF —— TDMIN 引脚内部下拉电阻的开关,与串行格式无关。 写 0x02 等于关掉下拉,编解码器随即停止驱动数据线,采集读出全零。
  • 格式在寄存器 0x11 的 SP_PROTOCAL(1:0),11 = DSP 模式。
  • 通道数在寄存器 0x08 的 LRCK_RATE_MODE(7:4),2 = 四通道,且仅在 SP_PROTOCAL = 11 时有意义。

原代码写 0x00 恰好保住了下拉:结果对、理由错。这解释了此前两次「0/0」——坏的从来不是硬件。

三个寄存器都写对了,编解码器仍然静默

init() 现在回读并打印序列口三个寄存器。加这行是因为串行格式错了不会有任何其他症状:零件照样在驱动,被测方只看到静音,而这两半各自都说得通。

[实测] TDM 打开后芯片持有 mode 0x20(LRCK_RATE_MODE = 2,四通道)、serial 0x63(SP_PROTOCAL = 11)、interface 0x00(下拉保留)—— 与手册完全一致。而采集在四个槽上全部读到 0,时钟表与双通道那条路用的完全一样。

顺带被回读证实的是字段解码本身没读错:关掉 TDM 时芯片持有 mode 0x10(LRCK_RATE_MODE = 1,手册明写「0/1 = 2 channels」)。

嫌疑因此落在接收端这一半(esp-hal 的槽数与 MCLK 分频是否让 ES7210 的采样率检测失锁),而不是寄存器。这条路目前封住,MIC3 参考保持关闭,理由写在 USE_MIC3_REFERENCE 上。

附带的诊断价值:峰值分不出复制与分道

双槽配置下四个槽读数在每种帧宽下都几乎逐位相等(2557/2558、946/949)—— 这既是「两个不同信号」的样本,也正是「单声道数据在左右各复制一次」的样本。所以排空现在多报一个两槽之差的峰值:复制的话它必然是 0。峰值本身分不出这两件事,差值可以。

延迟第一次被测出来,而测量它需要一个此前默认关掉的开关

A_B_RUN 原本是关的,理由是「关掉才能量到麦克风在没有回声时的本底」。这句话没错,但它掩盖了一件事:喇叭整个 run 都静音时,取消器拿到的是静音参考,相关度无从算起,延迟搜索永远不可能返回估计。 [实测] 静音探针的每一轮都是 delay None,而这和「板子上根本没有回声路径」读起来一模一样。

开着A/B 重跑:

[amplifier on — reference peaked 22876 LSB, microphone peaked 936 LSB]
321 paired this poll | ERLE 0 dB, delay Some(17), tap 13 ..., adapting

延迟稳定在 17 个样本 —— 16 kHz 下 1.06 ms,正是桌面到喇叭加编解码器往返的量级,相关度越过门限。路径存在,配对是通的,这两条到此确立。此前「无回声路径」的结论是探针配置造成的,不是硬件。

所以 A/B 的默认值改成开:静音本底仍然拿得到(每个窗口都报),但延迟不再被静音参考挡掉。

宽扫描:把「没有路径」和「路径超出滤波器」分开

SEARCH 只有 MAX_DELAY = 64 个样本,在 16 kHz 下是 4 ms。原先没有任何办法区分「这个房间没有回声」和「回声在 4 ms 之外」—— 两者都是搜索范围内没有峰。

DelayEstimator::scan_wide(reach) 补上了:扫到 HISTORY - WINDOW(256 样本,16 ms),网格必须是每样本一步。这不是选择而是本模块自己写明的结论:宽带参考下相关峰只有一个样本宽,粗网格只能取到几个噪声样本的最大值。新测试钉住了这一点,也钉住噪声不会被读成路径、超出扫描范围的路径不会被误报。

新的阻塞:采集流有洞

ERLE 仍是 0,而抽的位置一直在跳(13/14/1/13/14/9/6/13/12/15)—— 这是系数被噪声主导,不是收敛中的样子。同一批日志给出原因:

ring 1920 B arrived, 2400 B drained
capture rate: 12611 paired = 12400 samples/s of the 16000 the canceller runs

实际速率只有目标的 77%。 有断点的数据上,自适应滤波器收敛不了 —— 这与 delay 已经正确并存,不矛盾:延迟是对一段连续数据测出来的,收敛要在整条流上做。

根因不是链路,是一个除错了宽度的常量。 DECIMATED_PER_POLL 等三处按发送侧的 BYTES_PER_FRAME(4 字节)算,而采集侧每帧是 CAPTURE_BYTES_PER_FRAME(2 字节):

常量错值对值后果
DECIMATED_PER_POLL321641每轮预算能产出 400 个抽取样本进 321 的缓冲
DECIMATED_RING_LATENCY480960麦克风环延迟算少一半
lag_framesproduced / 4produced / 2参考回溯的距离只有应有一半

那个 if *frames_this_pass < DECIMATED_PER_POLL 静默丢掉了每轮最后 79 个样本 ≈ 4.9 ms 整段 —— 不是零星丢失,是每 25 ms 出现一次的连续空洞。321/400 = 80%,实测 12400/16000 = 77.5%。

而 BYTES_PER_POLL 的文档自己写着「this figure is the root every capture-side size is derived from」 —— 三个派生式却都用错了宽度。

[实测] 修后:paired this poll 321 → 400/413,速率 → 15 130–15 515 / 16 000,延迟 17(抖动)→ 41(稳定)。三项都与修前的算术预测吻合。

那个静默丢弃也一并删掉了,换成编译期断言:尺寸不对要编译失败,不能悄悄丢数据。

算法本身是对的 —— 这条排除了

core/tests/aec_erle.rs 在 host 上拿我们的取消器和离线最小二乘最优解逐路径对比,[实测]:

路径抽数离线最优我们的取消器差距
短、快衰减1662.9 dB61.0 dB1.9
中等4858.8 dB57.2 dB1.6
长尾慢衰减19238.9 dB38.8 dB0.0
尾长于滤波器25614.4 dB13.2 dB1.2

四条全部落在最优解的 0–1.9 dB 以内,且从不超越它(the_canceller_does_not_beat_the_offline_optimum 在钉这条 —— 超越最优解只能是过拟合或算错)。延迟在每条路径上都找对。

所以硬件上的 ERLE = 0 不是算法问题。 修掉采样丢失之后它仍是 0,缺的变量是这个房间的路径有多长 —— L=16 是照着合成房间定的,而这间屋子的尾有多长没人测过。

「测脉冲响应」这条路本身走不通,两次都是我自己把它走死的

上一节说缺的是房间脉冲响应。于是去测,测的方式是把麦克风落盘、host 上反卷积。两次都在同一个地方翻车,两次的错都不是代码。

第一次:削顶,而当时的结论是「扫频的时钟不可靠」

[实测] 扫频最早跑在半幅(SWEEP_PEAK_LSB 原来 11 500),麦克风录到 32 767 —— ADC 在削顶。同一份录音被四种分析各算一遍,四个答案互相矛盾。

当时给出的理由是「扫频只要放音与采集的时钟差一个样本就失相关,6 kHz 处是 45° 相位,所以要对齐两路独立晶振,而每次对齐都给出不同答案」,于是把激励换成了 1 kHz 正弦。

这个理由在本板不成立,而反驳它的代码就在同一个仓库里:

  • components/audio_clock.rs:3:「Both codecs are slaves of one host MCLK」,还有测试钉住;
  • es8311.rs:55 / es7210.rs:50,56:两片都是 slave,MCLK 来自引脚;
  • lckfb_szpi_esp32s3/mod.rs:481-493:同一个 i2s_master::I2s::new(I2S0, DMA_CH1, shared_tdm_config()).with_mclk(GPIO38),tx 与 rx 从同一个 handle 建出来。

一个 I2S0 外设、一条 MCLK 引脚 ⇒ 送进 DAC 的样本和 ADC 返回的样本是同一个时钟下的同一个样本序号。外部文献里扫频/MLS 反卷积在时钟失步下失效,那条机理真实,但它要求两套未同步的时钟,本板不满足。

而正弦是测不了路径的激励:能量只在一个频率上,只能给出那个频率的传递函数,给不出路径长度、给不出尾巴、给不出「这间屋子要几抽」。

SWEEP_PEAK_LSB 从 11 500 降到 2 500 才是那次真正的修法 —— 削顶产生的谐波反卷积消不掉,因为谐波就在录音里(外部资料对这一点说得和本节一样直接)。扫频也换回去了。

第二次:这条控制口传不动 16 kHz

落盘方案本身还有个更要命的前提,而它从头到尾没人验证过:这个控制口能不能把 16 kHz 的成对样本持续搬出去。

当时的抓取脚本是 B115200、无流控、读空时 sleep(0.05)。板子这边每个样本一行、每行 64 个十六进制字符起。log::info! 写 USB FIFO 时是要等的,主机读得慢,固件就阻塞在写上 —— 而阻塞的是那个还要 poll 采集的执行器。采集环只有 60 ms 深,一次超时的排空就溢出。

这条路线的第一个版本甚至没有报丢失:探针只打「配对数」,录像有洞从录像本身看不出来,而一个带洞的信号反卷积出来是「房间的形状被洞的长度抹开了」—— 读起来完全像一个普通的、稍错的答案。

[据称] 没有实测过具体丢多少。但方向是被否掉的:这条路唯一能交付的是一个必须先自证没有洞才可信的数字,而证明它没洞的证据恰恰要靠它自己那条已经很勉强的串口。

于是测量搬回板上:路径剖面

DelayEstimator::scan_wide 本来就在每个 lag 上算归一化互相关,然后只留最大值、其余全丢。把它们按延迟分带累加几乎不花钱 —— 每 lag 多一次乘加,而内层循环本来就在做除法。

PROFILE_BANDS = 8、PROFILE_BAND = 32(16 kHz 下每带 2 ms,覆盖 scan_wide 整个 256 样本的到达范围)。DelayEstimator::unreachable_by(taps) 给出一个 taps 抽的滤波器够不着的相关能量占比,百分数 —— 这就是抽头预算的输入。echo-probe 在宽扫描报出路径之后打一行:

[PATH] out of reach of a filter of — 16 taps N%, 64 taps N%, 128 taps N%, 256 taps N%.

不落盘,所以没有洞;不反卷积,所以没有重建误差;就在产生那条互相关的模块旁边,所以它有 79 个单测。

这条测度能说和不能说的

写在这里是因为它比冲激响应粗,而且差一点就会被当成冲激响应来读:

  • 它不是冲激响应,是按 lag 累加的相关值,被 WINDOW(256 样本的互相关窗口)抹开、又量化到一带。它的用处是「多少路径落在滤波器够不着的地方」,而那件事一带就够分辨。
  • 不能按每 lag 门限累加,这一点是量出来的。门限 300‰ 只收下主瓣:lag L 上的相关就是路径在 L 处的自相关,把一个 lag 收到 300‰ 等于要求路径在 L 处的能量接近直达声 —— 没有房间是这样。于是任何房间都报出「尾巴为零」。现在改为整体累加、读的时候把最安静的一带当底噪减掉。
  • 底部只分辨到百分之几:一个强band 会把自己百分之几抹到后面的带里。所以「接近 0」的意思是「低于本测度能分辨的下限」,不是「没有」。能分辨的是两个房间之间的差别,而预算要的正是这个差别。
  • 它数的是相关的那一份:与参考不相关的能量,线性滤波器本来也留不住,不计它不亏。
  • 分辨到一带为止(2 ms)。差不到一带的两个抽头数,这个测度分不出。

[测试] 钉住的是它真能证明的契约:抽头越多够不着的一定不更多;湿房间比活房间留下的少得多(quiet[2] = 3% 对 loud[2] = 12%);没有路径和没扫过都返回 −1 而不是 0(0 会被读成「全部够得着」);最响的一带就是直达反射所在的那一带。

这四个数还没有在板上跑出来过。 PATH_RUN 编译期开关默认关,打开后放扫频、关取消器、宽扫描报出路径后打那一行。它回答的是「这个房间要几抽」,不是「冲激响应长什么样」 —— 后者仍然没有,而前者已经足够决定 L 了。

延迟锁上之后:锁住了,ERLE 仍然没有数

前面「配对成立,消除为零」那一节里关于延迟的部分到此为止。给延迟加一把锁(首个搜索结果为锁,此后只允许被拖动 [DRIFT_SAMPLES] 样本,连续 [MISSES_BEFORE_MOVING] 次搜索都不同意才让位)之后,同一处实测:

读数之前之后
delayNone → 16 → 50 → 6,64 样本窗口里乱跳26,一次没动过;换位置再测是 53 → 42,同样只动了一次

[实测] 复现:moon run iot:probe-echo-s3 → IOT_PORT=/dev/cu.usbmodem1422301 moon run iot:flash-probe-echo-s3 → 复位后挂串口读 30 秒以上,数一数 delay 出现过几个取值。两次实测共 28 次 poll,只出现两个取值,中间那一次切换正好是连续四次搜索都指向新值之后。

所以「延迟搜索乱跳」这条结案了,而 ERLE 那条没有。

一次算出来的 12.3 dB,以及它为什么不能算数

中途有一版固件在锁定后一次性 dump 了三通道各 512 点(参考、麦克风、残差)。那份数据上 mic_rms = 20.7、res_rms = 5.0,即 12.3 dB。

但探针自己的 ERLE 列在那次读的是 --,不是 12。 12.3 是我在 PC 端拿 CSV 算的,不是固件报的。而且:

  • dump 代码已经删掉,那份日志也没留下,这个数现在复现不了;
  • 固件 ERLE 计量器至今没有在板上出过非零读数——复验那次全程 36 次 poll 全是 ERLE 0 dB,包括功放 on 的相位。

残差波形这个思路本身仍然对(ERLE 是比值,说「消了多少」不说「消的是不是回声」),但它需要一个可复现的测量,而当时那次不是。要做它,得把 dump 重新做成探针的一个常驻输出,而不是一次性调试代码。

现在能说的,和仍然不能说的

  • 能说:这一块板子上,延迟锁得住——两次实测、两个不同摆放,delay 全程只动一次。
  • 仍然不能说:线性 AEC 在板上消掉了多少。固件计量器没出过非零数,唯一那个 12.3 dB 来自一次已不可复现的离线计算。
  • 12 dB 够不够用取决于听感,而听感没有测。这台设备安静,安静房间里这点差距最容易被听见。
  • 仍然没有:非线性抑制(aec_nlp_process 那一半)。class-D 功放削顶产生的谐波不在线性模型的预测范围内,所以即使线性部分收敛得很好,残差里仍会有它。
  • 下一步该量的是 ERLE 计量器本身,不是滤波器:它在板上一次都没出过数,先查它为什么不累计。