音频计量语义

ES7210 只是一个来源。页面对外暴露的是电平——这一刻多少 dBFS、这两秒里持续多少——而不是一串 LSB 计数,更不是原始样本。本页说明契约、屏幕参数的含义,以及这些数字为什么长这样。

契约

采集驱动从不停止。它一直在把 I2S 落下的样本折进包络,ST 的相位只决定面板画哪一份包络、扫描条跟不跟随实时那一份。把 ST 读成"录音机在录/在停"会同时读错两件事:录音时驱动并没有多做任何事,停录时它也没有少做。

一条采集 poll 是纯数据面:刷新包络、抬升零语义。窗口因此不上 intent 总线,走板层接线时交给驱动的那条单槽平面(见传输的三种形状)。开始与停止只由业务侧的单击决定(intent.rs 里的 BusinessIntent::Capture(CaptureAction::Start) / Capture(CaptureAction::Stop)),且只在 Audio 页生效——别的页上的单击仍然换颜色。

相位只有三态:IDLE(未采,面板只画 TAP TO REC,不画扫描条)→ REC(跟随实时包络)→ STOP(画锁存的那一份)。IDLE 与 STOP 上单击都直接回到 REC,没有"继续"语义:锁存的是停止那一刻的窗,不是一段录音。

板级没有找到 ES7210 时 audio_enabled 为假,AUDIO 页不进入页面循环(DisplayPage::next_page 跳过没有能力背书的页),其余页照常。进入本页是三连击按键或三指点(NextPage)。

出声的那一侧在放音语义。两页共用一根 12.288 MHz MCLK,但契约相反:采集从不停止,放音放完就停——所以放音有相位而本页没有。

参数速查

台架对着这两张表走。计量参数全在 core(drivers/audio.rs、overlay.rs),与硬件无关;板级只决定往里送多少字节,以及这只麦的满刻度对应多少分贝。

AUDIO 读数列

标签字段单位意义对应参数
STaudio.phase—IDLE 未采 / REC 跟随实时包络 / STOP 画锁存包络—
MSaudio.elapsed_msms已运行的墙钟时长,不是样本计数CAPTURE_MS
PKenvelope.loudest()dBFS / dB SPL窗内最大峰值SCOPE_FLOOR_DECIBELS
RMSenvelope.loudest_rms()dBFS / dB SPL窗内最大列的 RMSSCOPE_FLOOR_DECIBELS
COLenvelope.committed()列已提交列数,封顶 200ENVELOPE_COLUMNS
RSTaudio.restarts次DMA 停摆后重臂的次数CaptureWatchdog

PK 与 RMS 各有两个读数,同一瞬间的两种量法:左起 dBFS,紧跟 DBFS;再右是 dB SPL,紧跟 SPL。

这两行是本页唯一宽到放不进一个块的读数,所以它们横着铺开、扫带从它们下面开始。ST / MS / COL / RST 与 CLIP 排在扫带右边的窄块里,实时 dB(A) 在那一块的头上一行,见「实时 dB(A) 读数」。

四个标记的列位置固定在 overlay.rs 的 LEVEL_UNIT_X / LEVEL_SPL_X / LEVEL_SPL_UNIT_X,不随读数位数变化。

原因是右对齐会让单位跟着数字宽度左右挪,两行的单位就对不齐了,眼睛得在两个位置之间找。

LEVEL_SPL_X 与 LEVEL_UNIT_X 之间留的是 LEVEL_UNIT_GLYPHS + 1 个字符位,不是读数位。

DBFS 有四个字符宽,按读数宽度排就会把它和右边的 SPL 数值贴成一片。每一对相邻列之间因此都有整整一个空白字符位(13 px)。

overlay.rs 里的 every_level_column_keeps_a_whole_blank_cell_between_it_and_the_next 钉住的就是这个距离:它按每列真实的结束位置算,而不是按一个固定的字符数。否则「DBFS 离下一个数值只差 1 px」这种贴连可以在所有数值测试都通过的情况下溜过去。

COL 涨而 PK 不涨,才是采集链路死了:安静房间的 PK 会跟着一起涨,停在 -90 只说明这一窗里没有任何样本到达过。RST 与 COL 一起看——两者都在动而 PK 是 -90,是驱动在反复重臂;两者都不动,是页面没在跑。

MS 用墙钟而非样本计数,是因为面板上其它读数都从同一个时钟打戳,迟到的 poll 不该让采集看起来比实际短。

dBFS 与 dB SPL 的换算

两者只差一个常数,core 的 spl(dbfs, offset) 只做加法,这个常数由板级给:

dB SPL = dBFS + SPL_OFFSET_DECIBELS

SPL_OFFSET_DECIBELS 在 bsp-esp/src/components/es7210.rs,当前是 102,推导链是:

  • ZTS6216 灵敏度 −38 dBV/Pa(0 dBV 以 1 Vrms 为基准)= 12.59 mV/Pa
  • 经 GAIN_30DB 放大 31.6 倍后为 397.8 mV/Pa
  • ES7210 手册的模拟输入满刻度是 AVDD/3.3 Vrms,在 ANALOG_POWER_RUN 的 3.3 V 模拟轨上即 1.0 Vrms
  • 1.0 Vrms 满刻度对应 2.51 Pa,对 20 µPa 参考声压即 101.98 dB

第三条有个容易算错的地方:满刻度不是「3.3 V 轨留一半余量」推出的 2 Vrms,而是 1.0 Vrms,两者差整整一倍(6.02 dB)。

于是 0 dBFS = 102 dB SPL,−90 的窗底 = 12。整个 16 位量程都摊进了 SPL 列,手机读约 25 dB SPL 的房间在刻度下仍有十几分贝的空间,不会再钉在仪表自己的底上。

顺带一条来自同一份手册的约束:VDDA 低于 2 V 时麦用的 PGA 增益必须 ≥ 21 dB。本板 VDDA = 3.3 V,这条不适用,所以 GAIN_30DB 保持 30 dB 不动。

标定记录(2026-09-28):结果维持 102 不动。

方法是用 30 cm 正常说话做人声锚:把 virtual_components/audio.rs 的编译期 CAL_SPL_LOG 置 true 重烧,在嘴前 30 cm 用正常聊天音量连续念话约 30 s,串口每 5 s 打一行 [AUDIO] CAL floor … dB SPL, rms … dB SPL。

说话段读数底在 66、峰冲到 80——正是未加权仪表上正常说话的区间,标称推导因此落在 ±3 dB 内。

CAL_SPL_LOG 平时保持 false;换麦或改 GAIN_30DB 时按同法重对。

手机 SPL App 在此类场景不可信(同一声场曾读 20 dB),不作基准。

换算列是整数加法,不是浮点:计量这一侧 core 里不出现 float(放音的合成是 core 里唯一的例外,理由相反,见放音语义),offset 变化时 SPL 的小数位不在面板的分辨率之内。

实时 dB(A) 读数

标题行右侧原本是静态的 +102dB,现在换成实时 dB(A):NN dBA。它回答「这间房现在多吵」,而 PK/RMS 仍是台架那份未加权的仪表。两者并存,谁也不顶替谁。

数值怎么来:I2S 样本先过真实的 A 计权滤波器(IEC 61672),慢速跟随(单指数释放,τ ≈ 1.4 s),再把 level_lsb() 经 dbfs()、spl(·, SPL_OFFSET_DECIBELS) 折成 dB(A)。

偏移还是 102:A 计权在 1 kHz 恰恰是 0 dB,满刻度换算一分未动。

为什么是 A:人对外部判读「多吵」,用的就是 A 计权那条曲线。安静房间读得比未加权列低一截,因为 A 计权把 100 Hz 以下再压 19 dB 起。

而这台未加权列的底噪本来就是低频为主的 codec 噪声,滤波器把它整个滤掉——RMS 还挂在那约 60 dB 高的时候,dBA 已经滑向 12 dB SPL 的窗底。采集驱动从不停止,这一格就一直贴着「现在」。

扫描条同源:每列先过同一个滤波器再画,实心区随听感长、随安静塌。窗口不会因为 codec 的未加权底噪而变成一条贯穿的厚实带。

实现在 core 的 drivers/audio/weighting.rs,这条链全定点。三个 Q30 定点双线性 biquad(预扭曲、1 kHz 归一)级联出 6 阶 A 计权,带内 ±0.7 dB,输出夹回 i16。

信号在级联里额外携带 8 位小数(STATE_FRACTION)。理由是最高 Q 的极点贴近单位圆:state 截断的步宽若容不下信号的低位,量化误差会被这些极点循环放大成自持极限环——静房读出约 65 dBA,与真实响度无关(host 复现:19 LSB 输入 → 489 LSB 输出)。

把信号整体抬 8 位,这个放大器就缩了 2⁸ 倍,系数一个不用动。

DbaMeter 把每一样本的平方喂进指数平均(METER_RELEASE_SHIFT = 16,τ ≈ 1.37 s),level_lsb() 取 isqrt。

测试用 384 点正弦表把各频点增益钉在整数原型上,而不是对着浮点期望值猜;另有一条断言安静输入读得安静、不喂给极点。

刻度与图形

元素含义对应参数
左侧 00 dBFS,即柱顶SCOPE_FLOOR_DECIBELS
左侧 -24 / -48标尺刻度;虚线网格每 12 dB 一条GRID_DECIBELS
左侧 -90底噪,也就是中线(实线)SCOPE_FLOOR_DECIBELS
中线静默。柱从这里向上下两侧长,是镜像WAVE_CY
一列 10 ms一列的时间宽度COLUMN_MS
200 列整个窗 2.0 sENVELOPE_COLUMNS
列内实心该列 A 计权 RMS,即听感音量weighted_rms_columns()
列内抖动肩部A 计权峰值高出 A 计权 RMS 的部分weighted_released_peaks()
顶端方块削波发生的那一列(按未加权峰值锁存)FULL_SCALE_LSB
最高柱上的短横线峰值保持:窗内最高(计权)柱及其位置weighted_released_peaks()
扫带下方 2.0S窗长,由常量算出COLUMN_MS × ENVELOPE_COLUMNS
右下 FPS面板重绘率,唯一不属于任何一页的条带FPS_WINDOW_MS
TAP TO RECIDLE 时唯一的提示,此时不画扫描条AudioPhase::Idle

计量语义

表格说得清"这一格是什么",说不清"为什么这样画"。下面四条是下一人改绘图时最容易踩的。

镜像刻度是反着长的

柱从中线往上、往下同时长,所以 0 dBFS 在带的两端,底噪在中线。

一柱的高度量的不是「离顶多近」,而是「离静默多远」。标尺因此只标上半,下半是上半的镜像;两端不需要规则,因为它们就是满刻度。

把刻度按"顶是 0、往下递减"来标,会与柱的实际边缘差一个八度,而且差得很难看出——每一格都还有点东西。

单墨水叠印里画不出第二种灰

覆盖层是反色 stamp_pixel,没有第二种灰度。于是 RMS 不能画成峰值内部的一根窄条:窄条的像素就是宽条的像素,两者相同即等于没画。

实心内芯 + 抖动肩部既是唯一可行的画法,也是更便宜的一种:抖动只点亮肩部的一半行,画满一根实心柱的调用次数还更多。CLIP 同理只有一种——它不是电平读数,是关于整个窗的一句话,所以用文字而不只用标记。

释放斜率由窗长反推,不照搬表值

仪表的常规释放是 16–20 dB/s,这里不用。

那段衰减规律是为「会动的指针」选的:指针要快速稳定下来才能读成一个电平。而这里的一柱是过去两秒的记录,释放太慢会让单个瞬态在整窗里留下三十 dB 的尾巴——窗永远回不到静默,底噪失去意义,之后每一个事件都要对着上一个事件的尾巴来读。

RELEASE_SHIFT = 4 是反推出来的:每列降 1/16,即 0.56 dB/列、56 dB/s,走完 90 dB 的整个刻度用掉约 160 列,仍在 200 列的窗内。

每步至少降 1 LSB,所以释放能走出位移整数截断的约 15 LSB 不动点,长静默真实落到带底——底限放低之前这个残值一直藏在 −60 之下看不见。

第一列仍能留住一个 3 ms 瞬态,所以第一列也不能更慢。core 里的测试直接断言「回到底噪的列数 < 窗长」,参数与这条要求绑在一起。

对数映射的弦误差,不是量化误差

读 log₂ 只看顶端几个位是常规做法,留下的误差常被说成「量化误差」。不是。

量化只值 1/16 个八度(0.38 dB),而线性 mantissa 与真 log₂ 之间的弦偏差在半八度处接近 0.5 dB,且恰好落在 −20 dBFS 附近——也就是人说话的音量。裸弦会把 −20 dBFS 读成 −21。

16 字节修正表(LOG2_16_CHORD_ERROR,按解出的 mantissa 索引)把全刻度误差收进 0.02 dB。

更要紧的是 scope_height() 与 dbfs() 共用同一个 log2_16(),柱高与数字不可能互相矛盾;测试里也钉住了这一点,包括「释放只把柱往下拉,最高柱即该列集的最大值」这条不变量。

扫描条整体是计权的,所以最高柱不再跟未加权的 PK 行背对背比较——那是两种滤波器,PK/RMS 行与扫描条各自内部自洽即可。

PK 与 RMS 同窗最高时是稳态,峰值远高于 RMS 时是敲击或瞬态。这两个数是并列的,因为峰值是一个样本的性质、RMS 才是听感音量,音频工具都画成两档而不是二选一。

直流偏置靠 ES7210 自己的隔直滤波器滤掉

麦被偏置在 2.87 V 直流上(MIC_BIAS = 0x70),所以它的输出带着这个直流工作点。ZTS6216 手册因此明确要求输出端串隔直电容。板上有——否则 MIC1 会吃到 2.87 V 直流,读数会削平而不是停在某个小值。

滤掉残余偏置的是 ES7210 内部的直流阻塞滤波器,寄存器 0x22/0x23(ADC1/2 这一对,MIC1 就在上面)。

两个值 0x0A 与 0x2A 只差 bit 5(0x0A ^ 0x2A = 0x20),低 3 位相同(& 0x07 都是 0x02)。也就是说 bit 5 控制的不是截止频率,换掉它不会让滤波器更陡或更缓。

bit 5 的语义查不到。ES7210 的公开数据手册是 "Everest Semiconductor Confidential",11 页,正文只说 "For more information, please refer to the user guide",寄存器手册不公开。而且两家驱动连命名都互相矛盾:

来源0x220x23
espressif/esp-bspHPF2HPF1
esp-audio-devHPF1HPF2

因为没有公开资料能判定哪一级在前,寄存器不按级命名,只按它带的位命名:REG_ADC12_FILTER_SET(0x22,持 0x2A)与 REG_ADC12_FILTER_CLEAR(0x23,持 0x0A)。

写入顺序仍照 esp-bsp 发出字节的次序(先 bit 5 清零的那一个,再置位的),而不是重排成一个没有资料支持的级序。常数名与写入顺序的矛盾因此是消掉的,不是靠改名掩盖的。

两条假设(两值只差 bit 5、且共享低 3 位的角点)由 const _: () = assert!(...) 钉住:真要变的是数据手册,不是重构。

不要按「级序」猜着对调这两个值:实测对调后安静时底噪抬高约 10 dB(PK 从约 -40 升到约 -30)。bit 5 不是截止频率,对调只是换成另一个更差的模式。当时的测量与推断见音频记录。

开机扫一遍 HPF 角点

安静时 PK 停在约 -40,原因只有一个判据:只扫 HPF 角点。

做法是固定 bit 5,把低 3 位从 0x00 扫到 0x07,看底噪是否随截止频率抬升而塌陷。塌了说明有可滤的低频成分,纹丝不动就说明是宽带环境噪声、读数可信。

扫一次要重烧八次固件,所以它是一次性的开机自检,不是面板上的功能:

  • Es7210::set_hpf_corner 是读-改-写,只动 FILTER_CORNER_MASK = 0x07,其余位原样保留。它不猜 bit 5 的语义,也不猜未来数据手册会加什么,因此走一圈能回到原处而不必知道整个字节
  • 时序在 iot_core::drivers::audio::CornerSweep(纯逻辑,放 core 才是能被 host 测的那一半;走一遍 8 个角点约 24 s),板级只在 Es7210Rx::sample 里按 poll 推进一步,读数取 loudest_rms()
  • 每码等 CORNER_SETTLE_MS = 3000。隔直滤波器的瞬态不到 1 ms,但读数要的是房间持续在产生的电平,所以这个等待由包络而不是滤波器决定,150 个 poll 足够让读数背后的整个窗口都晚于那次写入
  • 走完(含中途写失败)一律回到 HPF_CORNER,然后把走查丢掉,因此只花开机一次,之后捕获与显示完全不受影响。写失败就整个放弃而不是重试:不肯吃角点写的 codec 也不会吃下一次,而半死不活的走查报出来的电平属于一个没人能命名的状态
  • 开关是 virtual_components/audio.rs 的编译期 HPF_CORNER_WALK(不是 Cargo feature),量产置 false 即整段连同每码四次寄存器写一起消失
  • 角点码到实际频率的对应关系没有公开资料,所以日志只打原始码,实测趋势由人解读
[AUDIO] HPF corner walk starting: 8 codes, 3000 ms each, back on corner 2 after
[AUDIO] HPF corner 0 reads -26 dBFS, floor -45 dBFS
...
[AUDIO] HPF corner 7 reads -33 dBFS, floor -38 dBFS
[AUDIO] HPF corner walk done, back on corner 2

实测结果(静止时刻,2026-09-28,dBFS / 底噪中位数):

角点码面板读数底噪中位数
0x0-26-45
0x1-33-44
0x2(出厂)-35-42
0x3-35-42
0x4-36-41
0x5-36-41
0x6-35-40
0x7-33-38

结论:底噪是宽带的,不是直流偏置,也没有可滤的低频成分。

底噪随码单调上升(-45 → -38),说明码越大角点频率越高、通过的低频越多——这正是高通滤波器作用在宽带噪声上该有的形状。

如果底噪真由低频主导(工频嗡声、隆隆声、偏置漏电),把角点压到最低就应该把它压垮。而整条曲线只差 7 dB,且出厂码 0x2 距最安静的 0x0 只有 3 dB。

所以 HPF 能调到的全部好处就是 3 dB,不值得偏离 esp-bsp 的基线,出厂码保持 0x2。

顺带印证了统计量的选择:0x0 那一行面板读数 -26 而底噪 -45,19 dB 的差就是开机那一下的瞬态——用 loudest_rms() 量底噪会把它读成 -26,中位数不受影响。这也是面板上那个 -30 与真实底噪 -42 之间的差距来源。

于是 SPL 列的底数可以当作可信读数看待了:它不是偏置的投影,而确实是这个房间里的声压级。

测试

moon run iot:test        # core:计量数学、契约、状态机
moon run iot:test-bsp    # 板级:I2S 配置与缓冲
moon run iot:smoke-host  # 贯通:source → state → diagnostics

core 覆盖:dB 与柱高同源、dB 单调且不越顶、dBFS→dB SPL 只差偏移、SPL 读数不越出预留列宽、释放的每列下限与「窗内回底」、RMS 对稳态与单点尖峰的区别、两槽满幅信号的 RMS、削波锁存与标记随列老化、释放沿绕窗后的时间顺序、2 s 窗长、最高柱即 PK。

weighting.rs 另覆盖:表是干净满幅整周期、A 计权级联与整数原型逐 LSB 一致、增益落在 IEC 频带内、meter 随音起而涨随静默而回落、直流输入读数为静默。

overlay.rs 单独覆盖列布局:单位与最宽读数之间留得下一整个空白格、三列依次不相碰、SPL 读数不超出 LEVEL_VALUE_GLYPHS。

布局在 core 而不是板级,是因为列算错了在屏上只表现为「看着像设计」——单位被数字推歪是一种看不出是缺陷、却明显是错答案的 bug,而板级带 esp-hal,宿主根本编不出来。

smoke-host 的假音源按整轮询供数并断言 dB 读数真的穿过了 state 层。

它的峰值计数器以 isize::MIN 起种——电平永远 ≤ 0 dBFS,以 0 起种会让最大值永远停在「没有任何电平」。