这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 活动中心 » 板卡试用 » 【DSPIC33CURIOSITYPLATFMDEVBRD】6-声控节拍灯:把大

共1条 1/1 1 跳转至页

【DSPIC33CURIOSITYPLATFMDEVBRD】6-声控节拍灯:把大脑搬进主控,板载音频算法

菜鸟
2026-10-07 12:33:22     打赏

一、为什么要板载音频算法

第 5 版的架构里,Mac 扛了所有计算:收音频、跑 VU、算节拍、合成颜色,主控只负责"收指令、点灯"。用起来没毛病,但心里总有个疙瘩:

  • Mac 必须开着,还得跑着 Python 进程——台灯离了电脑就是块砖头

  • 音频分析在电脑上,PWM 在板子上,中间隔着一条 USB 串口——延迟和抖动都不可控

  • 最难受的是角色错位:一台 200MHz、带硬件 DSP 和 64 位 FPU 的数字信号控制器,居然在当" dumb 渲染器"——杀鸡用了屠龙刀,刀还借别人的

所以这一版的目标:Mac 退化成"录音机 + 传声筒",所有算法在主控上跑。

二、第一道坎:带宽预算

音频流和指令流不是一个量级。先算账:

内容数据率9600 波特(≈960B/s)
v1 指令流25 条 × 14B ≈ 350 B/s✅ 轻松
原始音频 8kHz×8bit8000 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 职责对比图

6_1_v1_vs_v2.png


三、块协议: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】终端实录:板子自报的块计数、错误计数、电平与色相

6_2_terminal.png


真实麦克风实测:安静时电平 0%、色相以 20°/s 缓行;说话冲到 55% 后按 12dB/s 缓释回落——和第 5 版 Mac 算法的表现逐帧一致,因为本来就是同一套参数同一套逻辑的移植。

【配图 3】板子实拍:声控模式下电平条 + RGB 流转

6_3_mcu_sound_board.jpg


七、系列的终点,台灯的起点

到这里,「电位器调光台灯」系列完整收官。回看这七篇的演化路线:

篇里程碑核心关键词
0开箱DIM 模块化平台
1环境macOS 命令线开发、MPLAB 生态观察
2调光ADC/PWM、EMA、无符号下溢
3交互按键消抖、HSV、状态机
4WebH5 调色盘、串口文本协议
5声控 v1VU 力学、节拍检测、Mac 算法
6声控 v2算法下沉、块协议、FPU64 本地 DSP

而这个项目的真正终点,在篇 1 就埋了伏笔:当 Mac 只剩"录音"一个职责时,一颗 5 毛钱的模拟 MEMS 麦克风直连 ANx 引脚就能把它也换掉——那将是一台插上 USB 电源就能跟着音乐起舞的独立声控灯,不依赖任何电脑。到那时,第 2 篇的 ADC、第 3 篇的状态机、第 6 篇的 DSP 全会派上用场。





关键词: DSPIC33CURIOSITYPLATFMDEV    

共1条 1/1 1 跳转至页

回复

匿名不能发帖!请先 [ 登陆 注册 ]