放音语义

ES8311 只是一颗从器件。页面对外暴露的是一段声音的起点与终点——现在在放什么、放完了没有——而不是进度条,也不是音量。本页说明契约、面板读数,以及这四个字后面每一处"为什么是这样"。

本页描述系统现在是什么样;它怎么变成这样、被否掉过什么,在放音实现记录(AI 产出,未经人类审阅)。

采集那一侧的语义在音频计量语义。两页共用一根 MCLK,但契约相反:采集从不停止,放音一次性放完就停。所以放音有相位而采集没有。

契约

声音是 one-shot:一次触发放一段,放完报完成,中途没有「暂停」和「进度」。

因此 Speaker 驱动是三步而不是一步——play() 起头、feed() 往前推、驱动说 done() 才算完。把三步连成循环的是 app 的播放模块(crates/app/src/playback.rs),因为只有它知道节拍;板级只管环,因为只有它知道 DMA。

这个循环被拆成两个任务、跑在两个 executor 上,因为节拍和阻塞是两种不能混的东西。

feed_loop 只做 5 ms 节拍与一次内存写,spawn 到 S3 的 FROM_CPU_INTR1 上(Priority2)。control_loop 处理命令、走 I2C 的 play()/set_muted()、以及把 finished 送回 intent 总线,留在协作式的 FROM_CPU_INTR0 上。

驱动由 Box<dyn Speaker> 共享,因此 play() 与 feed() 不会互相踩坏对方正在写的环。这正是 Speaker 要求 Send + 'static、且错误类型是具名的 SpeakerFault 而不是各实现自定义的原因(trait object 没法写下一个未具名的关联类型)。

C6 和 host 没有第二个软件中断,走 SpeakerRunner::Inline,两个循环 join4 在同一个 executor 上,语义不变。

BusinessIntent::Playback(PlaybackAction::Finished) 由播放任务送回 intent 总线,不是轮询出来的标志位。

页面相位只在驱动自己开口时才离开 Playing。让页面和驱动各说各话,出现的就是「看着还在放,其实早就放完」,而此后每一次轻点都会被当成重播丢弃。

触发落在 Playing 上只把 dropped 加一,不打断当前声音。

打断会在两段音频的中间留下一个台阶——那是 click,而 click 比「这一下没听见」难解释得多。丢弃要能被看见,所以它单独占一个读数(DRP)而不是被悄悄合并。

PlaybackAction::SetMuted 锁存输出,不是停止播放。

静音中的触发照样放完、照样报完成:静音是耳朵的事,不是声音的事。把两者混在一起会让 ST 在静音时读成 IDLE 而声音其实还在放。

Speaker 页与 Audio 页是两个页。

板级没找到 ES8311 时 playback_enabled 为假,该页不进入页面循环(DisplayPage::next_page 跳过没有能力背书的页),采集与其余页照常。出声是加法,不该是采集的前置条件。

声音从哪来

目录里五个声音,每个都是一份 MIDI 文件(bsp-esp/src/assets/*.mid),格式是同一种:Standard MIDI File,任何 DAW 都能打开、放、改。

选 MIDI 不是为了合成能力——MIDI 不带音色,逐音符包络两个版本都没有——是为了出处。存成 PCM blob 的话,声音是不可追溯的二进制,只有一份构建不运行的配方注释跟着它;存成 MIDI 的话,任何人打开文件就知道它是什么、怎么改。

布局不是品味问题,是按耳标准则搜出来的——三十二个半音要装五个声音,下面这张表是唯一装得下的排法。为什么是这五个数,记在放音实现记录。

SoundSRC谱音符数音区时长语义
RiseRISED#4 137 ms / 歇 25 / D#5 150 ms2最低312 ms上升
ErrorERRORA5 112 ms / 歇 25 / E5 125 ms2中低262 ms出错了
NoticeNOTICEB5 200 ms1中200 ms有事发生
Tap(默认)TAPD6 100 / 歇 25 / E6 100 / 歇 25 / F#6 1003中高350 ms确认
WarnWARNA6 87 ms ×4,间歇 12 ms4最高384 ms需要注意

相邻两条音区恰好隔 3 个半音,没有任何一个音高被两个声音共用,五条时长各不相同。唯一重复的是「2 音」这个数,落在 ERROR 与 RISE 上——因为它们是唯一一对轮廓相反(下行对上行)的组合,这是耳朵唯一不会出错的差别。

RISE 的纯八度照的是 Google 品牌音 Super G 的形状,放在最低的音区因此不与 TAP 撞。理由记在放音实现记录。

TAP 从两音改成三音是这一版最贵的一处退让:它既是默认音也是开机音,而 Google 的规矩是交互越频繁越该安静,三音比两音长 30 ms。理由记在放音实现记录。

D#4 是这块喇叭最不容易发出来的音,RISE 实际靠的是它的 2.76 分音(765 Hz)撑着,而不是基频本身——这一条没有实测,是按小喇叭的滚降推的,上板要听一下。

Sweep 仍是第六个声音,但不在轻点循环里——它是台架激励,由频段参数生成而不是从文件读。

解析走 from_midi,在 play() 里发生(协作侧,可分配);feed 中断只走解析出来的音符表,一个字节的文件都碰不到。

from_midi 跳过一切耳标用不上的事件(轨道名、program change、CC、sysex)而不是拒绝文件:DAW 默认就往文件里写这些,因为带了就拒,等于让唯一在用的授权工具变成唯一不好使的那个。轨道按文件顺序首尾相接——一段跨轨的音乐是一个声音,不是几个,分开读会静默地只放第一轨。

ramp 是推断出来的,不是声明的

MIDI 只有 note on / note off,没有包络。而音从阶跃边起止就是 click。

所以 ramp_of(ms) = min(ms / 4, 40 ms),每个音符两端各一段,在 from_midi 里算好写进 Note。40 ms 是上限,否则三秒长的音会花掉四分之三的时间在渐入。

这是从「手工声明」退到「规则推断」的真实代价——不能再对某一个音单独说「这个要 40 ms 释放」。规则由 a_note_ramps_at_both_of_its_ends 钉住。

音色:一个家族,两个非谐分音

TIMBRE = [(100, 180), (276, 76)],比值 ×100、增益 Q8。

非谐是故意的:真实钟声的泛音不是基频的整数倍,而整数倍正是合成音听起来像蜂鸣器的原因——这也是原来那个声音只能存成采样、不能现场合成的原因。

「五个声音共用一个音色」这句话只对了一半:分音比固定在 ×2.76,但它跟着基频走,所以 RISE 的分音在 765 Hz、WARN 的在 4858 Hz。五条音的实际频谱差了一个数量级,响起来本来就是五个音色,不是一个。文档以前说成一个,是把「同一份分音比」当成了「同一个音色」——顺带也是好消息:WARN 那条 4858 Hz 的分音正好压在小喇叭的滚降边上,再往上目录就必须让步了。

增益在 Q8 上加起来正好是满量程(180 + 76 = 256),所以两个分音加起来不会比单个分音走得更远。这道封顶是防一个音符的分音刚好对齐时把总和推出满刻度。

分音数超过 3 个以后要重新算账:每样本成本随分音数线性增长,8 个就会吃掉 5 ms 节拍。届时要么回到缓存波表(只能接受谐波,就回到了蜂鸣器),要么把间隔拉长。

Tone::on(score, &PLAIN) 可以用单一分音发声,a_lone_partial_is_the_oscillator_it_replaced 用它对照被替换掉的那套合成原样写出来的参考实现,逐样本比完整条谱。

interleave_mono 把每个样本复制进两个槽。单声道源进两槽的帧必须「看起来像立体声」,否则帧里只有一半是我们的;放不下整帧的尾巴直接不写——半帧上线就是 click。

render_slice 分两步走:先向音源要整段单声道,再 interleave_mono 摊开。老写法逐帧调用,每拍重入音源 240 次,而且交织逻辑是在 audio_out 里自己抄了一遍——抄的那份没有任何测试能碰到。

峰值 TONE_PEAK_LSB = 23_000(16 bit 轨的 LSB),故意不顶到满刻度:电平控制是 DAC 的音量寄存器(0x32),这 23 000 只是留给它的余量。

合成这一侧和采集一侧一样是定点的,但理由不同:计权是主动选了定点,而合成是被迫的。

Tone::next_sample 跑在 feed 中断里,这个上下文一旦执行 FPU 指令就抛 Cp0Disabled,板子停在第一帧 chime 上、不复位。协作文本上跑同一段代码则正常,所以这是中断上下文的性质,不是算术的性质。

所以 Tone 的相位是 WAVE_ENTRIES 项静态表的定点下标(8 位小数),包络是 Q16:inv_ramp 每音符算一次,之后每样本只剩整数乘法和移位。合成侧的单次 feed 成本实测见放音实现记录。

表在编译期由 sine()(整圈折叠到 1/4 圈再泰勒展开)生成——libm::sinf 不是 const fn。

保真度由测试钉住,不靠注释保证:表项和插值后的结果都必须在 sinf 的 1 LSB 之内,相位必须永远落在表内。

参数速查

标签字段单位意义
STplayback.phase—IDLE 未放 / PLAY 在放;只有驱动能把它推回 IDLE
SRCplayback.sound—下一次轻点会放的声音,TAP / NOTICE / WARN / ERROR / RISE,单击循环
MUTEplayback.muted—只在锁存时出现,与 CLIP 同一种写法:它是一个状态不是一个事件
PLYplayback.plays次真的放出去过的次数
DRPplayback.dropped次落在 PLAY 上被丢掉的轻点

PLY 与 DRP 分成两个数,因为它们回答的是两个问题——"它出声了吗"和"它听见我了吗"。合成一个计数的页面没法区分"喇叭不响"和"手指太忙"。

Speaker 页没有动画,也因此不在 is_live_page() 里:它的相位每次播放只翻两次,且两次都来自 intent(于是都带来一份新的 diagnostics),一个每帧重画 320×240 像素只为说同一句话的页面是纯浪费。Audio 页相反,扫描条一直在动。

一个常驻环,120 ms 余量

板级的放音驱动(virtual_components/audio_out.rs)在 bring-up 时建一次流式环就再也不动:24 个 960 字节的描述符,共 23 040 字节 = 120 ms 的线上音频。DMA 从头到尾绕着这个环走,每拍把它已经放完的那一片补上,而不是换一个臂。

所以「一臂一分配」那种写法在这里是错的,而且错两次:TX 方向的描述符不会从 DMA 手里回到 CPU 手里,I2s::write() 交给你的可写字节数是一次性预算,链跑完之后没有把环还给你的路径——同一个环第二次用要么 available_bytes 归零再也填不进去,要么把上一轮的内容再放一遍。流式环之所以成立,正是因为它只补、不换。

一臂(描述符)的长度 960 字节 = 5 ms,是被 app 的节拍选的,不是被 DMA 选的:整除意味着每一拍都在自己的格子上补片,而不是晚一拍。晚一拍的代价是播放时刻被量化,存储的声音会跑低几个百分点,而那正是「听起来不对劲但说不出哪里不对」的典型。

播放任务不等待 DMA。喂的动作只是「把空出来的那片填上」,是纯内存写;真正等的是 DMA 自己。

环有 120 ms 余量不是余量估算,是环长本身——被推后一次就放空一次 DMA,这是它必须常驻而不是按需分配的直接理由。

为什么喂要独占一个可抢占的 executor,是因为协作式调度下「没有别的任务在跑」并不成立。

同一个协作 executor 上,capture、面板写和 input 诊断各自持有一段远超 5 ms 节拍的工作,于是喂迟早被推后;而播放环只有 120 ms,被推后一次就放空一次 DMA。

支撑这个判断的逐项测量(各任务占用、暖机 soak、迁移后的 158 秒连续记录)在放音实现记录。

把 feed_loop 挪到 FROM_CPU_INTR1 的 Priority2 之后,节拍回到目标附近,capture 与面板写不再能推迟它。这正是这两个 executor 存在的理由。测量数据见放音实现记录。

Priority2 是有意的上限:再高一级,喂就会反过来饿死 input 和渲染,成为所有人都在等的东西。

play() 也不打断环上正在走的声音:换段的台阶就是 click。代价是触发到出声最多晚一臂 = 5 ms。

时钟:一根 MCLK,RX 挂在 TX 的时钟上

两只 codec 共用 12.288 MHz MCLK 与 48 kHz(components/audio_clock.rs 里的 MCLK_HZ = 12_288_000,const _: () 钉住 MCLK_HZ / 256 == SAMPLE_RATE_HZ)。

共享不是「两路各自配置恰好一样」,而是一路真的从另一路取时钟:板级在找到 ES8311 时用 TdmConfig::with_signal_loopback(true) 建 TDM,TX 驱动 BCLK/WS(GPIO14/GPIO13),RX 只连自己的 DIN(GPIO12),以从模式挂在 TX 的时钟上。

signal_loopback 这个名字容易读成「数据环回」,但它不管数据,只管时钟。

一个 I2S 外设的 TX 与 RX 跑在各自独立的分频器上,共享 BCLK/WS 就要求把 RX 设成从模式去跟随 TX。esp-hal 的 HIL 测试里数据是物理环回的(用 dout.split() 把同一个物理引脚劈成 DOUT 和 DIN),这正好说明 signal_loopback 不会把播放数据塞进 RX。

「RX 引脚 + loopback=true」这一格坏掉的不是 loopback,而是引脚归属 + 从模式这一对:让 RX 一边驱动 BCLK/WS 一边又变成从单元,组合本身自相矛盾。

两种排法各自只能满足一个 codec:

配置TXRX结果(同一环境 HPF 角阶扫描)
改动前自有分频器拥有引脚、自驱麦克风 −18…−28 dBFS(peak≠floor);喇叭爆音
RX 引脚 + loopback=true从自驱却被迫从模式采集瘫痪
TX 引脚 + loopback=false拥有引脚、自驱自有分频器麦克风恒定 −3 dBFS(peak==floor,采到直线);喇叭不爆
TX 引脚 + loopback=true拥有引脚、自驱从模式跟随麦克风 −33…−37 dBFS(peak≠floor);喇叭不爆

signal_loopback 同时写 tx_conf.sig_loopback 与 rx_conf.rx_slave_mod(esp-hal 1.2.0 i2s/master/low_level/v3.rs),所以「共享时钟」与「从模式」必须一起打开,缺一个都会坏一半。

找不到 ES8311 时退回采集那份自驱动的配置——RX 从设备模式下没有时钟源是拿不到任何样本的,这一支不能为了没有喇叭的板子而放弃。

这一位没有自动化测试守着,audio_out 被 audio-out feature 门控,只有 lckfb-szpi-esp32s3 会打开。

test-bsp 跑在 host 上、故意不带任何 esp-hal feature(esp-hal 在 host target 上编译不过),而 with_signal_loopback/signal_loopback 也都不是 const fn,const _: () = assert!(…) 同样用不上。

改这一位只能靠上面的注释和这张表。

一处 GDMA 通道同时供 RX/TX 两半(I2s::new 把通道劈成 split 的两半),和 SPI 驱动已有的双向用法是同一个形状,所以板级保持 DMA_CH1,不需要第二条通道。

待台架确认:tx_stop() 只清 TX_START,不关外设,所以 BCLK/WS 预期在两臂之间继续输出。这一点是整段设计的地基,必须在真板上量到。

备选是让驱动不停地补静音臂(app 就不能停车),代价是每 5 ms 醒一次换一块永远不会被听到的静默。

这条在 TX 成为时钟主控之后赌注变大了:麦克风现在是从模式跟着 TX 的时钟,所以 tx_stop() 若真把 BCLK 拉掉,坏的不只是喇叭,麦克风也会一起炸。

最新一次 60 秒验证里播放排空为 0 次,所以这条仍未实测到。

ES8311 寄存器行

寄存器布局抄自乐鑫 esp-audio-dev 的 es8311_adf.c。ES8311 的数据手册不公开,所以该文件怎么写,这里就怎么写;凡是有语义查不到的地方都保留成掩码而不是猜。

阶段写什么为什么是掩码而不是整字节
上电行0x01–0x11 的默认值,含 0x03/0x04 = 0x10参考驱动就是这么整字节写的
从机行0x00 复位 + 清 master 位,0x01 = 0x3F 打开全部时钟门master 位是 0x00 里唯一不是上电值的位
时钟行0x02 掩 0x07、0x03/0x04 掩 0xF0、0x06 掩 0x1F、0x07 掩 0xC0掩码逐字对应参考驱动的 read & mask
DAC 行0x09 帧格式合并,模拟上电帧是位域,不整字节覆盖
起播行0x09 掩 0x4F 置 16-bit 词宽并清运行位;音量 0x32 = 0xBF,0x31 掩 0x60 清静音词宽也是位域:参考驱动对每个采样格式都显式写它,把它留给上电值的话,DAC 会按另一种字长咽下 16-bit 帧而静默

时钟表的一行(COEFF_48K)就是 coeff_div[] 里 12288000/48000 那一行:pre 1、mult 1、adc_div 1、dac_div 1、fs 0、lrck 0x00:0xFF、bclk 4、两个 OSR 都是 0x10。

核心里的测试把 MCLK_HZ / (lrck_l + 1) == SAMPLE_RATE_HZ 钉住,所以换一行系数时算错会当场编译或测试失败,而不是变成「声音慢了 4%」。

0x03 的掩码为什么是 0xF0 而不是 0x80:参考驱动写 0x03 时先 read & 0x80 保住 bit 7,下一行却是 regv |= fs_mode << 6。bit 7 就在这个字段里,那份「保住」的值下一行就被覆盖掉了。

0xF0 覆盖 fs 两位与 OSR:参考表里每行的 OSR 只可能是 0x10 或 0x20,低四位本来就是零,整字节写出来的字节与参考驱动逐位相同,而换行真的能换掉 OSR——掩 0x80 的话新 OSR 会被 OR 到旧值上,永远换不掉。

这一条是 the_row_lands_the_bytes_the_reference_driver_writes 钉的:断言的是参考驱动写出的那三个字节(0x10/0x10/0xC3),不是「看起来合理」的数。

地址是 0x18,探测失败再试 0x19;芯片 ID 读 0xFD/0xFE,应为 0x83/0x11。

ID 要读不要信:地址是这条总线上唯一能静默弄错的东西,而一块没验过身份的芯片只能按「假定能用」对待。

PA_EN 是 PCA9557 输出寄存器的 bit 1(Pca9557::set_output_bit 读-改-写,参数是位号不是掩码)。上电输出 0x05、配置 0xF8;LCD 片选引线随 set_output(DVP_PWDN_BIT) 落下,基线 0x04,PA 使能后去 0x06。

功放不在 Speaker 里:它是板级接线,不是驱动契约。

测试

moon run iot:test        # core:MIDI 解析、加法合成、状态机、UnwiredSpeaker
moon run iot:test-bsp    # 板级:声音目录(每个文件是什么音)、ES8311 寄存器行(MockI2c)
moon run iot:smoke-host  # 贯通:声音目录、丢弃、完成、静音

分两处,因为两者要的东西不同。

moon run iot:test-bsp 里 sounds.rs 的七条按 Sound::ALL 遍历,每个声音都断言:起于零、止于近零、不顶轨、是 100–400 ms 的 one-shot、没有短于 82.5 ms 的音符、ramp 合乎推断规则。它们对着真实文件跑,所以一个文件解析不出来、或者被改成了一个合成吃不下的谱,这里就红。

moon run iot:test-tool 里的六条管的是文件和配方:磁盘上的 .mid 逐字节等于配方生成的字节、目录里没有没人认领的 .mid、配方表自己的形状规则(音区隔 3 个半音、时长各不相同)、以及板子 include_bytes! 的文件名和配方对得上。在 DAW 里改了一个音而没改 RECIPES 那张表,moon run iot:test-tool 就红。

ES8311 的 13 条板级测试全部跑在 MockI2c 上:上电值、掩码合并后的落盘字节、芯片 ID 校验、重复写(参考驱动在第一次写后加写一次,因为上电后第一次写偶发丢失)、从机位、以及静音位域的读-改-写。板级没有硬件也能验证寄存器序列,是这条驱动能进仓库的前提。

UnwiredSpeaker(core)是没接喇叭的板在 HasPlayback 里必须报的类型:驱动不占位不代表驱动不存在,所以 run() 的那一份拷贝在 C6 上照样跑得起来(走 Inline),而它 feed() 恒假、play() 无声——一个静默兜底会让页面提供一个没人能发出来的声音。

拆成两个任务之后,smoke-host 里「落在正在放的声音上会被丢弃」那一条,是唯一能抓住一个具体回归的断言。

feed() 在没有声音时恒报「没东西可喂」,而它在 play() 之前就已经在跑了。于是那条「结束」报告会在 play() 接受之后仍站在 done 里,被当成新声音的结束读走——声音一起来就被判结束,页面立刻回到 Idle。

下一次触发于是不丢弃而是接着放下一段,表现为 played = [Notice, Tap]、dropped = 0。

因此接受 play() 的那一次持锁必须顺手把 done 清掉:两半放在同一个临界区里,喂就不可能在中间把它重新置位。