一、需求:把调音台搬上台灯
第 4 篇的网页调色台解决了"精确选色",但灯是死的——要人戳屏幕才动。这一篇让它自己活过来:
Mac 麦克风实时收音
板上的 LED0~LED7 排成电平条,像调音台那样随声音起伏
RGB LED 彩虹平滑流转,声音越大流得越快,鼓点一打瞬间飙升
效果目标很明确:放一首歌,电平条跟着节奏跳动,RGB 像夜店灯球一样流转。
【配图 1】板子实拍:放音乐时电平条亮到 5~6 格、RGB 呈彩色

二、总体流程:一条从声波到 PWM 的流水线
麦克风 → ffmpeg 采 8kHz PCM → 25ms 分块 → RMS/dB → 快攻慢释峰值(VU 表) → 低频包络(节拍检测) → 色相流速合成(非线性+节拍脉冲) → "M r g b l" 串口指令 → 主控解析 → PWM 驱动 RGB + LATC 驱动电平条
分层和第 4 篇一样:每一环可独立调试。分析错了看终端 ASCII 电平表;串口断了看回显计数;渲染错了用手敲一条 M 255 0 0 8 命令验证。
三、VU 表力学:灵魂在"快攻慢释"
电平条的"调音台手感",来自两条调度规则:
快攻:声音一来,峰值立刻顶上去——反应速度决定"跟不跟手"
慢释:声音停了,指针缓缓回落,而不是瞬间掉零
就是这两个分支,让电平条有了"惯性"。完整的分析入口长这样——RMS、dB、归一化、快攻慢释、慢包络,一口气走完:
def feed(self, samples): n = len(samples) rms = math.sqrt(sum(s * s for s in samples) / n) db = 20.0 * math.log10(max(rms / 32768.0, 1e-6)) db = max(FLOOR_DB, min(0.0, db)) # -50dB ~ 0dB 归一化 if db >= self.peak_db: self.peak_db = db # 快攻 else: self.peak_db = max(db, self.peak_db - DECAY_DB_S * (n / SR)) # 慢释 level = (self.peak_db - FLOOR_DB) / -FLOOR_DB # 电平条用:峰值 target = (db - FLOOR_DB) / -FLOOR_DB self.smooth += (target - self.smooth) * 0.15 # 颜色用:慢包络 return level, self.smooth
注意电平条和颜色用的是两个不同的量:条跟峰值(有惯性、好看),颜色跟慢包络(不抖、平滑)。一个 feed 返回两个值,各取所需。快攻慢释的比例(12dB/s)我调到过 20dB/s 和 6dB/s:前者太神经质,后者像慢动作。12 是个不错的起点,喜欢绵软感的可以调小。
RMS 到 dB 的归一化也值得说:-50dB ~ 0dB 映射到 0~1。安静房间的麦克风电平大约 -50dB 上下,这个地板选得合适,平时电平条归零、放音乐才起步。
四、节拍检测:几行代码抓住鼓点
不装 NumPy、不上 FFT——鼓点的本质是低频能量的瞬时突增,用一个一阶低通 + 基线对比就能抓:
bass_in = sum(samples) / n # 均值天然滤掉高频 self.bass += 0.08 * (bass_in - self.bass) # 低频包络 self.bass_base += 0.02 * (bass_mag - self.bass_base) # 慢基线 if bass_mag > 2.0 * (self.bass_base + 200.0): # 突增 2 倍 = 鼓点 beat = True
sum(samples)/n 是个讨巧的低通:高频在均值里自相抵消,剩下的是低频包络。再加 180ms 的最小间隔防抖(防止一次鼓点连发三次),拍子就稳了。
五、彩虹流速:非线性映射 + 节拍脉冲
需求拆成两条:
彩虹永远平滑流转——色相连续推进,不是跳色
声音只控制流速——安静时 36 秒转一圈,越响越快
第一条用浮点色相累积就行;第二条的关键是非线性映射。线性映射的问题:中等音量就已经转得飞快,大音量反而没区别了。改成 smooth² 之后,安静段几乎不动,响度一上来曲线才抬头:
rate = HUE_RATE_MIN + (HUE_RATE_MAX - HUE_RATE_MIN) * smooth**2 rate *= 1.0 + BEAT_BOOST * beat_env # 节拍脉冲:鼓点瞬间 ×3.5
实测 504 帧里出现 431 种颜色——每帧的色相都在移动,且相邻帧颜色渐变(第 2 篇的 EMA 哲学在这里复现了一次)。
主循环把整条管线串起来,每 25ms 一块,从声波到串口一气呵成:
while True:
data = proc.stdout.read(chunk_bytes) # ffmpeg 实时 PCM
samples = struct.unpack(f'<{len(data)//2}h', data)
level, smooth = meter.feed(samples) # VU + 慢包络
hue_deg = (hue_deg + rate(smooth) * dt) % 360 # 非线性流速 + 节拍脉冲
r, g, b = level_to_color(hue_deg, smooth)
bar = min(8, int(level * 8.999)) # 电平条 0~8 档
os.write(fd, f"M {r} {g} {b} {bar}\n".encode()) # 一条命令全带走
read_echo(fd, stats) # 收 OK,统计丢包固件侧对应新增 M 命令的解析和电平条输出,RGB 部分沿用第 4 篇:
/* LED0~LED7 电平条:档位 0~8,从 LED7(左)向右点亮,调音台风格 */
static void setBarLevel(uint8_t level)
{
if (level > 8u) level = 8u;
uint16_t pattern = (uint16_t)(((0xFFu << (8u - level)) & 0xFFu) << 3);
LATC = (uint16_t)((LATC & ~0x07F8u) | pattern); /* RC3..RC10 */
}六、踩坑两连:ANSELD 漏配与 UART 停摆
这篇的调试故事值得单开一节,因为两个坑都很经典。
坑 1:MCC 漏配 ANSELD,UART 只能发不能收。 烧完第一版固件,命令发出去石沉大海——TX 通(能收到板子的启动横幅),RX 死。查 MCC 生成的 pins.c,它对 RB5 清过模拟(ANSELB),但 RD3(UART1 RX)的 ANSELD 压根没配。dsPIC33A 的引脚复位默认是模拟态,模拟态的引脚读不到数字电平,RX 就是死的。一行修复:
ANSELD &= ~(1u << 3); // RD3 = UART1 RX,必须数字输入
(顺带吐槽:这个寄存器在头文件里连 bits 结构体都没有,只能裸位操作。MCC 配了 RPINR9 的 PPS 映射却忘了模拟开关,属于生成器的疏漏。)
坑 2:UART 错误标志不清,RX 永久停摆。 回显统计突然出现 89% 丢失——不是慢,是彻底没响应。定位到 FIFO 溢出错误标志:这版 UART 的错误中断标志是写 1 清零,溢出一次不清,接收就永久停摆。修复是在接收循环里顺手清错:
uint32_t errs = UART1_ErrorGet() & (_U1STAT_FERIF_MASK | _U1STAT_RXFOIF_MASK
| _U1STAT_CERIF_MASK | _U1STAT_ABDOVIF_MASK);
if (errs != 0) { U1STAT = errs; }这两个坑的共同点:都是"能用但会死"的隐患——第一版能跑,跑快了、跑久了才暴露。串口回显统计(发 N 条收 N 个 OK)是抓住它们的功臣,这个自检机制在第 4 篇埋的线,这篇收了网。
七、自测方法:没有眼睛也能验证
固件加回显(每条有效命令回一个 OK)之后,测试闭环完全自动化:
合成测试音(0.5s 正弦 + 1s 静音 ×3)→ 跑 analyzer → 断言1: 响段电平 > 80%,静音段 < 5% (分析正确) → 断言2: 收 OK ≈ 发指令 (链路完整) → 断言3: 504 帧 431 种颜色 (流转平滑) 麦克风实测 → 说话/拍手 → 电平从 0 跳到 60% 再缓释回零
【配图 2】终端实录:跑测试音时 hue 从红扫到蓝(RGB 数值按自身颜色着色)、VU 峰值 85% 保持、停音后缓释衰减

【配图 3】10 秒小视频:放歌 → 电平条跳动 + RGB 流转

八、小结
到目前为止,台灯的控制权经历了完整的一圈:
| M1~M2 | 旋钮 + 按键 | 本地调光、彩虹 |
| M3 | 手机网页 | 远程精确选色 |
| M4 | 声音 | 自己跟着音乐起舞 |
M4 的工程亮点不在算法(VU 和节拍检测加起来不到 50 行),在于把第 2、3、4 篇的积累全部串了起来:EMA 的平滑切换、文本协议的可调试性、回显的自检闭环、状态机的控制权管理——每一篇的一个点,都是这一篇的一块砖。
我要赚赏金
