一、为什么要板载音频算法
第 5 版的架构里,Mac 扛了所有计算:收音频、跑 VU、算节拍、合成颜色,主控只负责"收指令、点灯"。用起来没毛病,但心里总有个疙瘩:
Mac 必须开着,还得跑着 Python 进程——台灯离了电脑就是块砖头
音频分析在电脑上,PWM 在板子上,中间隔着一条 USB 串口——延迟和抖动都不可控
最难受的是角色错位:一台 200MHz、带硬件 DSP 和 64 位 FPU 的数字信号控制器,居然在当" dumb 渲染器"——杀鸡用了屠龙刀,刀还借别人的
所以这一版的目标:Mac 退化成"录音机 + 传声筒",所有算法在主控上跑。
二、第一道坎:带宽预算
音频流和指令流不是一个量级。先算账:
| v1 指令流 | 25 条 × 14B ≈ 350 B/s | ✅ 轻松 |
| 原始音频 8kHz×8bit | 8000 B/s | ❌ 8 倍超载 |
结论很干脆:9600 必须换掉。把 MCP2221A 拉到 230400 实测:
写入 102400 字节耗时 5.08s -> 实际 19.7 kB/s ✅ 8kB/s 需求,余量 2.4 倍
230400 8-N-1,就这么定了。 固件里改一行:U1BRG = 434(100MHz / 434 ≈ 230414,误差 0.006%)。主控改波特率就这么朴实无华。
【配图 1】v1 vs v2 职责对比图

三、块协议:1 字节计数头换来的自愈能力
原始字节流有个经典难题:没有帧结构,丢一个字节全场错位。解法是给每 50ms 的数据块(400 字节 PCM)加一个 1 字节连续计数头:
[计数 0..255][PCM ×400] [计数+1][PCM ×400] ...
主控只要校验"本块计数 == 上块计数 + 1",不等就滑动 1 字节重新对位——丢几个字节都能在几毫秒内自愈,无需重传、无需 ACK。带宽就这么多,能自愈就别重传。
这个逻辑我第一版就写错了,而且错得很典型:
// 错误版本:收养计数头时就把 lastCounter 赋成当前块
if (lastCounter < 0) { lastCounter = rxBuf[0]; ... }
// 之后校验 rxBuf[0] == lastCounter + 1 —— x == x+1,永远不相等!lastCounter 应该是上一块的计数,我在收养第一块时手滑把它写成了当前块,校验变成了自己跟自己比——死锁。滑动重同步因此永远滑不到正确位置,主控一块都收不到。修好的版本——lastCounter 明确保存上一块的计数,校验不过就滑动 1 字节重新对位:
static void uartDrain(void)
{
while (UART1_IsRxReady()) {
rxBuf[rxLen++] = (uint8_t)UART1_Read();
if (rxLen == BLOCK + 1) { /* 401 字节到齐 */
if (lastCounter < 0
|| rxBuf[0] == (uint8_t)((lastCounter + 1) & 0xFF)) {
lastCounter = rxBuf[0]; /* 同步成功,块就绪 */
rxLen = 0xFFFFu;
return;
}
memmove(rxBuf, rxBuf + 1, BLOCK); /* 错位滑 1 字节自愈 */
rxLen = BLOCK;
}
}
if (U1STATbits.FERIF) { U1STATbits.FERIF = 0; ferCount++; }
if (U1STATbits.RXFOIF) { U1STATbits.RXFOIF = 0; ferCount++; }
}这种"单变量语义搞混"的 bug,肉眼极难发现,最后靠给固件加块计数器回传才定位——让板子自己报告"我处理了几块",一眼看出是 0。
四、主控算法:FPU64 的杀鸡用牛刀
算法本体从 Python 翻译到 C 几乎无损——dsPIC33A 带 64 位浮点单元,sqrtf/log10f/expf/fmodf 全都有硬件加速。每 50ms 一块的计算量:
400 点平方累加(FPU MAC) ≈ 几千时钟 sqrtf + log10f ≈ 数百时钟 一阶低通 + 节拍判定 + 色相推进 + HSV→RGB ≈ 几百时钟 ───────────────────────────────────────── 合计 < 0.1% CPU @ 200MHz
说实话有点浪费这颗 DSC——它本职是跑电机 FOC 的。但从另一个角度看,这也回答了第 0 篇开箱时的问题:"dsPIC 和 PIC32A 有什么区别?"——这个专为控制而生的 DSP 内核,跑音频算法就像让 F1 车手送外卖:大材小用,但稳得离谱。
参数在线配置也保留了:非声控模式下发 P k v 就能改量程、衰减、流速上下限,调参不用烧录。
全部算法就这一个函数,C 版和第 5 篇的 Python 版逐行对应:
static float processBlock(const uint8_t *pcm) /* 返回 0..1 电平 */
{
float sum = 0.0f, sumSq = 0.0f;
for (uint16_t i = 0; i < BLOCK; i++) {
float s = (float)((int16_t)pcm[i] - 128); /* u8 -> 有符号 */
sum += s; sumSq += s * s;
}
const float mean = sum / BLOCK;
const float rms = sqrtf((sumSq - BLOCK * mean * mean) / BLOCK);
float db = 20.0f * log10f(fmaxf(rms / 128.0f, 1e-6f)); /* RMS -> dB */
if (db < p_floor_db) db = p_floor_db;
if (db > 0.0f) db = 0.0f;
if (db >= au.peak_db) { /* VU 快攻 */
au.peak_db = db;
} else { /* VU 慢释 */
au.peak_db = fmaxf(db, au.peak_db - p_decay_dbs * 0.05f);
}
const float level = (au.peak_db - p_floor_db) / (-p_floor_db);
const float target = (db - p_floor_db) / (-p_floor_db);
au.smooth += (target - au.smooth) * 0.15f; /* 颜色用慢包络 */
/* 节拍:低频包络相对自身基线突增 */
au.bass += 0.08f * (mean - au.bass);
const float bassMag = fabsf(au.bass);
au.bass_base += 0.02f * (bassMag - au.bass_base);
au.cooldown = fmaxf(0.0f, au.cooldown - 0.05f);
if (au.cooldown == 0.0f && bassMag > 2.0f * (au.bass_base + 2.0f)) {
au.beat_env = 1.0f;
au.cooldown = 0.18f; /* 鼓点最小间隔防抖 */
}
au.beat_env *= expf(-0.05f / 0.35f);
/* 色相推进:非线性流速 + 节拍脉冲 */
const float rate = (p_rate_min + (p_rate_max - p_rate_min) * au.smooth * au.smooth)
* (1.0f + p_boost * au.beat_env);
au.hue = fmodf(au.hue + rate * 0.05f, 360.0f);
return level;
}Python 转 C 几乎逐行直译——sqrtf/log10f/expf 在 64 位 FPU 上都是硬件加速,这也是敢把算法下沉的底气。
五、坑三连环:文本通道与数据流抢线
这一版最精彩的调试故事,是三个小坑连环引爆:
坑 1(上一节的同步死锁)修完后,块能收到了,但调试回传突然变成了洪水——设备每秒吐几千行,把透传器的读取循环卡死在串口上(read_uplink 的"读到没数据为止"在洪水面前永远有数据)。顺藤摸瓜发现:调试计数器写在了主循环上,而主循环每秒跑几十万圈,不是"每 25 块报一次",是"每 25 圈报一次"。改成按块计数,洪水立止。
坑 2 洪水退了又发现透传器只发了一块就假死——因为 read_uplink 卡死时主循环根本走不到"写下一块"。给读取加了个字节预算上限(每次最多 drain 4KB),双保险。
坑 3 最阴的一个:中途测试时电平突然恒定 85%、色相冻住、块计数狂涨——像是收到了不存在的数据。给固件加 ferCount(FIFO 错误计数)回传后才看清真相:高速收流下 FIFO 错误标志一旦置位,这版 UART 的错误标志是写 1 清零,不清的话接收链路永久停摆(第 5 篇末尾刚修过同款,这次又确认了一次它的必要性)。接收循环里顺手自清,错误数归 0。
三个坑的共同模式:都是"能跑但会死"的隐性故障,单个看都不致命,串在一起就成了灵异现象。破局手段还是那个——让设备自报状态(块数、错误数、电平、色相),用数据代替猜测。
Mac 侧退化成什么样?整个透传器的核心就这一个循环——录音、加头、发串口:
while True: data = proc.stdout.read(BLOCK) # ffmpeg 实时输出 8kHz/u8 os.write(fd, bytes([counter]) + data) # 计数头 + 400B PCM counter = (counter + 1) & 0xFF read_uplink(fd, buf, stats, budget=4096) # 收 D 行(带预算上限防卡死)
六、实测:一块不丢
修完所有坑,上硬指标(合成测试音,限速实时):
D 行 10 条(2Hz 设计值) 板子块 25 → 50 → 75 ... 250 (步进精确 25) 错误 0 (250 块全程) hue 123 → 269 → 50 → 163 → ... (连续流转,正常回绕) 本机发 256 块 vs 板子收 250 (97.7%,尾部在途)
【配图 2】终端实录:板子自报的块计数、错误计数、电平与色相

真实麦克风实测:安静时电平 0%、色相以 20°/s 缓行;说话冲到 55% 后按 12dB/s 缓释回落——和第 5 版 Mac 算法的表现逐帧一致,因为本来就是同一套参数同一套逻辑的移植。
【配图 3】板子实拍:声控模式下电平条 + RGB 流转

七、系列的终点,台灯的起点
到这里,「电位器调光台灯」系列完整收官。回看这七篇的演化路线:
| 0 | 开箱 | DIM 模块化平台 |
| 1 | 环境 | macOS 命令线开发、MPLAB 生态观察 |
| 2 | 调光 | ADC/PWM、EMA、无符号下溢 |
| 3 | 交互 | 按键消抖、HSV、状态机 |
| 4 | Web | H5 调色盘、串口文本协议 |
| 5 | 声控 v1 | VU 力学、节拍检测、Mac 算法 |
| 6 | 声控 v2 | 算法下沉、块协议、FPU64 本地 DSP |
而这个项目的真正终点,在篇 1 就埋了伏笔:当 Mac 只剩"录音"一个职责时,一颗 5 毛钱的模拟 MEMS 麦克风直连 ANx 引脚就能把它也换掉——那将是一台插上 USB 电源就能跟着音乐起舞的独立声控灯,不依赖任何电脑。到那时,第 2 篇的 ADC、第 3 篇的状态机、第 6 篇的 DSP 全会派上用场。
我要赚赏金
