做语音提示类产品的人,早晚会撞上这么一个需求:设备正慢条斯理地播着背景解说,突然来了一个告警,必须立刻把当前这条压下去,把告警这句话喊出来,喊完之后,原来那条还得从被掐断的地方接着往下说。听起来简单,真做起来能把你卡上一周。我最近用唯创的 WT2003Hx 系列语音芯片做了一版这套逻辑,核心就是用它串口协议里的 B1 指令,配合一套状态机和几个坑位排查,把紧急语音中断与恢复播放这条链路跑通了。在 WT2003H-16S 上实测稳定,端到端响应大约 40ms,插播结束后的恢复点误差控制在一句话之内。
这篇东西写给两类人:一类是刚拿到 WT2003Hx 手册、还没搞明白 B1 到底怎么发包的嵌入式新手;另一类是背景音已经播起来了,但一插播就乱套,要么不恢复、要么爆音、要么偶发死机的老手。帧结构、校验算法、状态机、时序计算、电源处理和一堆实测踩过的坑我会全部摊开讲,代码都是能直接抄的完整版本。需要先说明一点:不同型号、不同批次的手册在字节定义上可能略有出入,下面这套是我实际跑通的版本,动手前务必拿自己手上的型号手册核对一次命令字节和长度定义。
1. 先把需求拆干净:插播到底难在哪
1.1 三个真实场景,暴露的是同一类问题
第一个场景是工业控制柜的语音提示。柜子在播"当前温度 42 摄氏度,运行正常"这种长句,突然超温了,得马上喊"超温报警,请立即检查散热"。喊完以后再回到刚才那句,但它已经播到一半了,不能重头来。
第二个场景是共享设备。用户扫码后设备在讲操作步骤,讲到第三步的时候,旁边有人按了急停,急停提示音必须插进来。用户的心理预期是"我先听急停,急停听完了,第三步接着听"。
第三个场景是导览设备。背景讲解音频很长,中途有按钮触发的点播。早期版本我用的是"停掉背景音,播完点播再重播背景音",用户直接投诉说听了一段重复内容,体验很差。
这三个场景的共同点就一句话:插播内容要能抢占音频通道,插播结束后原内容要从断点续上。 它本质上是两个需求叠在一起,一个是抢占,一个是续播。很多人只做了抢占,忘了续播,结果就是上面说的那种投诉。
1.2 为什么"停止 + 重新播放"这种土办法在工程上站不住
最容易想到的方案是:检测到紧急事件,发停止指令,播插播文件,播完后重新发播放指令播背景音。这个方案我第一版就是这么写的,跑通之后问题一堆。
首先是丢上下文。 用户听到一半的内容被重置了,长音频越明显。一条 30 秒的提示音播到 20 秒被打断,回来又从第 0 秒开始,等于白等了 20 秒。
其次是切换延迟大。 停止指令要发一帧,播放指令又要发一帧,中间还有芯片内部的状态切换,实测从"决定插播"到"插播出声"要 80ms 以上。紧急场景下这个延迟是能被人耳感知到的,尤其是告警音,慢半拍会显得设备很迟钝。
再次是音质断裂。 停止和重启之间会有一个明显的静音凹坑,配合功放的上下电时序,很容易在断点处产生"咔"的一声。用户对爆音的敏感度远高于对音质的敏感度,一次爆音就能让整个产品显得廉价。
最后是并发处理无解。 如果插播期间又来一个更紧急的事件怎么办?土办法里没有优先级的概念,只能靠加标志位硬凑,代码会迅速烂掉。
1.3 B1 指令的能力边界:它能做什么,不能做什么
B1 这条指令的存在意义,就是把"暂停当前、切到新内容、播完回填"这三步收敛成芯片内部的一个原子动作。MCU 只要发一帧 7 个字节的指令,剩下的事情芯片自己做。
我实测下来它能做到的是:插播内容立刻出声、插播期间原播放进度被芯片内部保存、插播播完后自动回到原来的位置继续。整个过程的断点衔接基本听不出来,比 MCU 侧手动拼接要干净得多。
但它的边界也要清楚。第一,它不支持"插播队列",你连发两条 B1,后一条大概率会顶掉前一条,而不是排队播完。第二,它不告诉你"插播播完了",B1 是单向指令,没有回执(除非你的型号支持应答帧,那一定要开)。第三,插播音频和背景音频是分时复用同一个 DAC 通道,不是真正意义上的混音,所以做不出"背景音压低继续播、告警音叠在上面"的效果。如果你要的是那种电台式的闪避效果,WT2003Hx 这套方案做不到,得换带双通道混音的型号或者外挂一颗音频 DSP。
搞清这三条边界,后面的状态机设计思路就顺了。
提示:动手前先确认你的型号手册里 0xB1 这个命令字节的确切定义,有些型号把"插播"叫"插入播放",有些叫"指定文件插入",命令字节也可能不是 B1。本文以 0xB1 为插播命令展开,其余型号按其手册替换即可,帧结构和状态机逻辑完全通用。
2. WT2003Hx 的串口协议与 B1 指令帧拆解
2.1 通信接口怎么选:标准 UART、一线串口还是 IO 触发
WT2003Hx 一般提供三种控制方式,选错了后面全是麻烦。
标准 UART 两线(TX/RX)是我最推荐的。 波特率 9600 到 115200 都支持,时序宽松,用 MCU 的硬件串口发就行,代码量最小,调试起来也最直观——拿个 USB 转串口直接怼上去就能手动发帧验证。唯一的代价是多占一个 IO。
一线串口 只占一个 IO,但位宽定义在不同型号手册里差别挺大,有的是用高电平占位周期的比例来编码 0 和 1,有的用的是固定脉宽区分。我一般不推荐新手碰,因为一旦时序偏了,芯片就是完全不响应,你还看不出是哪一位错了。如果非要省这个 IO,建议先用示波器量一下官方 demo 板的实际波形,把位宽抄下来再写代码。
IO 触发模式 是速度最快的方案。把要插播的音频预先绑定到某个触发脚上,拉低(或拉高)一段时间就播。省掉了整条串口链路,响应能压到 10ms 以内。缺点是不灵活,文件换不了、组合不了。我现在的做法是混合: 高频、必须最快的告警用 IO 触发,其余用 UART 发 B1。这个组合拳在实际项目里非常好用。
2.2 帧结构逐字节拆解
WT2003Hx 的串口帧是一个很典型的"帧头 + 长度 + 命令 + 参数 + 校验 + 帧尾"结构。我手上这版的字段定义如下:
| 字段 | 字节数 | 取值示例 | 说明 |
|---|---|---|---|
| 起始码 | 1 | 0x7E | 固定,帧的起点 |
| 长度 | 1 | 0x04 | 从命令字节到校验字节的总字节数 |
| 命令 | 1 | 0xB1 | 插播指令 |
| 参数高 | 1 | 0x00 | 插播文件编号高字节 |
| 参数低 | 1 | 0x05 | 插播文件编号低字节 |
| 校验 | 1 | 0xBA | 长度到参数逐字节累加,取低 8 位 |
| 结束码 | 1 | 0xEF | 固定,帧的终点 |
整帧 7 个字节。长度字段这里填 0x04,是因为"命令 1 + 参数 2 + 校验 1 = 4"。注意这个定义是有坑的,有些型号手册里的"长度"不含校验字节,那就得填 0x03。这两个版本我都见过,发出去没反应的时候,第一个要试的就是把长度改一改。
为什么要设计校验?因为串口线上是可能有干扰的,尤其是在电机、继电器旁边跑的设备。如果一帧被干扰错了,芯片可能会执行一条完全意想不到的指令,比如把音量拉满、或者去播一个不存在的文件号导致卡死。加一字节累加校验,成本几乎为零,收益很大。
2.3 B1 指令的参数该怎么填
参数就是要插播的文件编号,两字节,高位在前。文件编号是你烧录音频到外挂 SPI Flash 时分配的序号,通常从 0 或者 1 开始。
举个例子,要插播 5 号文件:
- 长度 = 0x04
- 命令 = 0xB1
- 参数 = 0x00 0x05
- 校验 = 0x04 + 0xB1 + 0x00 + 0x05 = 0xBA(取低 8 位)
- 完整帧:0x7E 0x04 0xB1 0x00 0x05 0xBA 0xEF
再举个例子,插播 18 号文件(0x12):
- 校验 = 0x04 + 0xB1 + 0x00 + 0x12 = 0xC7
- 完整帧:0x7E 0x04 0xB1 0x00 0x12 0xC7 0xEF
两个例子算出来的校验值不一样,这就是校验的意义。你可以拿这两组数据当自测用例,代码写完之后先算一遍,对上号了再去连硬件。
注意:文件编号一定要先确认有效范围。发一个超出 Flash 实际容量的文件号,部分型号会直接卡在等待状态,BUSY 脚再也不翻转,表现就是"设备死了"。这类问题非常难查,因为它看起来像硬件故障。
2.4 校验和的两种算法与验证方法
上面用的是累加和取低 8 位,这是最常见的一种。另一种是累加和取反加一,也就是补码形式,两者结果不同,别搞混。
#include <stdint.h> /* 累加和,取低 8 位 */ uint8_t wt_checksum_sum(const uint8_t *buf, uint8_t len) { uint16_t sum = 0; for (uint8_t i = 0; i < len; i++) { sum += buf[i]; } return (uint8_t)(sum & 0xFF); } /* 累加和取反加一 */ uint8_t wt_checksum_neg(const uint8_t *buf, uint8_t len) { uint16_t sum = 0; for (uint8_t i = 0; i < len; i++) { sum += buf[i]; } return (uint8_t)((~sum + 1) & 0xFF); }怎么验证哪个是对的?最省事的办法是拿厂家提供的上位机调试工具,手动发一条指令,用逻辑分析仪或者带协议解析的示波器抓一下 TX 线上的实际波形,把厂家工具发出来的字节流抄下来,跟你代码算出来的比对。这五分钟的功夫能帮你省掉后面两天的瞎猜。
还有一个更快的土办法:用 USB 转串口模块直接把这两种校验的帧都发一遍,哪种能让芯片出声,哪种就是对的。前提是你已经确认接线和波特率没问题。
3. MCU 端完整实现:从发包到状态机
3.1 硬件连接与最小系统清单
先把最小系统列清楚,接线错误是"不发声音"这类问题的第一大来源。
| 连接项 | WT2003Hx 侧 | MCU 侧 | 备注 |
|---|---|---|---|
| 串口接收 | RX | UART_TX | 交叉连接 |
| 串口发送 | TX | UART_RX | 只在需要回执时才接 |
| 忙信号 | BUSY | 任意 GPIO(带中断) | 强烈建议接,用于判断播放状态 |
| 电源 | VCC | 3.3V 或 5V | 按型号手册,别超压 |
| 地 | GND | GND | 必须共地 |
| 音频输出 | DAC/PWM | 功放或喇叭 | 直推喇叭注意功率 |
BUSY 脚是我强烈建议接上的。它的作用是告诉 MCU"我现在在忙"。虽然 B1 插播没有回执,但 BUSY 至少能让你知道芯片有没有在工作状态,是排查问题的第一手信息。
接线时有两个细节要注意。串口线尽量短,超过 10cm 又在强干扰环境里,建议串一个 100Ω 的电阻并加对地小电容做缓冲。另外,如果 MCU 是 5V 系统而芯片是 3.3V,TX 线上最好加个电平转换或者至少串个电阻分压,别硬怼。
3.2 发送 B1 插播帧的完整代码
下面这段是可以直接编译的完整实现,包含了帧组装、校验和发送。
#include <stdint.h> #include <stdbool.h> #define WT_FRAME_HEAD 0x7E #define WT_FRAME_TAIL 0xEF #define WT_CMD_INSERT 0xB1 /* 由你的 HAL 实现,发送 len 字节 */ extern void uart_write(const uint8_t *data, uint16_t len); /* 由你的 HAL 实现,返回毫秒级系统时间 */ extern uint32_t millis(void); static uint8_t wt_calc_sum(const uint8_t *buf, uint16_t len) { uint16_t sum = 0; for (uint16_t i = 0; i < len; i++) { sum += buf[i]; } return (uint8_t)(sum & 0xFF); } /* 发送 B1 插播指令,file 为插播文件编号 */ bool wt_send_insert(uint16_t file) { uint8_t frame[7]; uint16_t idx = 0; frame[idx++] = WT_FRAME_HEAD; frame[idx++] = 0x04; /* 长度:命令+2参数+校验 */ frame[idx++] = WT_CMD_INSERT; /* 0xB1 */ frame[idx++] = (uint8_t)(file >> 8); /* 文件编号高字节 */ frame[idx++] = (uint8_t)(file & 0xFF); /* 文件编号低字节 */ frame[idx++] = wt_calc_sum(&frame[1], 4); /* 校验覆盖长度~参数 */ frame[idx++] = WT_FRAME_TAIL; uart_write(frame, idx); return true; }调用就是wt_send_insert(5);,一行搞定。但工程上不能这么裸奔,还得做三件事:加发送间隔保护、加超时重发、加错误计数。
发送间隔保护很好理解。连续发帧的时候,一定要保证两帧之间至少间隔 5ms,保险起见留 10ms。我用一个静态变量记最后一次发送的时间戳,进来先判断间隔够不够,不够就延后或者直接拒绝本次请求。
static uint32_t s_last_tx_ms = 0; #define WT_MIN_FRAME_GAP_MS 10 bool wt_send_insert_safe(uint16_t file) { uint32_t now = millis(); if ((now - s_last_tx_ms) < WT_MIN_FRAME_GAP_MS) { return false; /* 间隔不足,让调用方稍后重试 */ } s_last_tx_ms = now; return wt_send_insert(file); }超时重发是另一层保护。B1 没有回执,怎么知道芯片收到了?我用的办法是"看副作用":发完 B1 之后启动一个定时器,如果 200ms 内 BUSY 脚状态一点变化都没有,就判定这次发送失败,重发一次。如果连重发两次都没动静,就置一个故障标志,通过指示灯或者日志报出来。这套机制在电磁环境恶劣的现场设备上救过我好几次。
3.3 插播状态机的设计与实现
这是整个方案的心脏。没有状态机,代码会长成一堆 if-else 的意大利面,而且一定会漏掉某个边界情况。
我用五个状态:
| 状态 | 含义 |
|---|---|
| VS_IDLE | 空闲,什么都没播 |
| VS_BG_PLAYING | 背景音正在播 |
| VS_INSERTING | 插播中 |
| VS_WAIT_RESUME | 插播应已结束,等待背景音恢复 |
| VS_FAULT | 异常,需要重初始化 |
状态迁移的规则是:VS_BG_PLAYING收到插播请求 → 发 B1 → 进VS_INSERTING;插播计时到期 → 进VS_WAIT_RESUME;确认 BUSY 正常 → 回VS_BG_PLAYING。
难点在最后一步,怎么知道插播播完了。B1 没有回执,BUSY 脚在整条链路里一直是"忙"的状态,没法区分是背景音在忙还是插播在忙。我的做法是用插播音频的实际时长来定时。所有插播音频都是固定文件,时长是已知的,在 MCU 里做一张时长表:
typedef enum { VS_IDLE = 0, VS_BG_PLAYING, VS_INSERTING, VS_WAIT_RESUME, VS_FAULT } vs_state_t; static vs_state_t s_state = VS_IDLE; static uint16_t s_insert_file = 0; static uint32_t s_insert_start = 0; static uint8_t s_resume_probe = 0; /* 插播文件时长表(毫秒),编号 0 占位不用,其余按实测填写 */ static const uint16_t s_insert_dur[] = { 0, 1850, /* 1 号:一级告警 */ 960, /* 2 号:操作提示 */ 2400, /* 3 号:故障说明 */ }; #define INSERT_DUR_MAX ((uint16_t)(sizeof(s_insert_dur)/sizeof(s_insert_dur[0]) - 1)) #define RESUME_MARGIN_MS 150 /* 留出芯片切换和恢复的余量 */ bool voice_request_insert(uint16_t file) { if (file == 0 || file > INSERT_DUR_MAX) { return false; /* 编号越界,直接拒绝,防止芯片卡死 */ } if (s_state == VS_INSERTING) { return false; /* 已经在插播,不排队,由上层裁决 */ } if (!wt_send_insert_safe(file)) { return false; } s_insert_file = file; s_insert_start = millis(); s_resume_probe = 0; s_state = VS_INSERTING; return true; } void voice_tick(uint32_t now) { switch (s_state) { case VS_INSERTING: if ((now - s_insert_start) > (uint32_t)(s_insert_dur[s_insert_file] + RESUME_MARGIN_MS)) { s_state = VS_WAIT_RESUME; s_resume_probe = 0; } break; case VS_WAIT_RESUME: /* 再等 100ms,用 BUSY 脚复核一次是否真的在播 */ if (++s_resume_probe >= 10) { if (voice_busy_read()) { s_state = VS_BG_PLAYING; /* 背景音已恢复 */ } else { s_state = VS_IDLE; /* 背景音已结束或有异常 */ } } break; default: break; } }voice_busy_read()就是读一下 BUSY 脚的电平。这个复核步骤很有必要,因为芯片内部恢复播放可能有几毫秒的抖动,如果直接在时长到期的那一刻就认为恢复了,偶尔会误判。
3.4 时序参数计算与端到端延迟预算
做紧急告警类功能,延迟是要算清楚的,不能凭感觉说"挺快的"。
先算单帧发送时间。波特率 9600,8 数据位、无校验、1 停止位,每字节 10 位:10 / 9600 ≈ 1.042ms。一帧 7 字节就是7 × 1.042 ≈ 7.3ms。
如果换成 115200,每字节10 / 115200 ≈ 0.0868ms,一帧 7 字节只要0.61ms,几乎可以忽略。所以只要你的 MCU 和走线扛得住,波特率尽量往高了设。
再算端到端延迟:
| 环节 | 典型耗时 |
|---|---|
| MCU 检测事件到组帧完成 | < 1ms |
| 串口发送 7 字节 @9600 | 7.3ms |
| 芯片解析指令并切换音源 | 10 ~ 20ms |
| 读 Flash 首帧并解码 | 5 ~ 15ms |
| DAC 输出经功放出声 | 1 ~ 3ms |
| 合计 | 约 25 ~ 45ms |
这个数字是可以接受的,人能感知到的音频延迟大概在 60ms 以上。如果你要压到 20ms 以内,只有两条路:提波特率加上用 IO 触发模式。
还有一个容易忽略的参数是帧间隔。我给的最小值是 10ms。如果连发指令,间隔不够,有些型号会把第二帧当成第一帧的续传数据,直接丢弃或者解析错乱。拿到新板子的时候,我会用一个循环连发 100 帧 B1,看有没有丢帧,以此确认最小安全间隔。
4. 优先级与多级插播:真正产品化的那一层
4.1 用优先级队列管住"插播风暴"
单条插播跑通了,产品化还差一步:多个事件同时来怎么办。我的做法是在 B1 之上再包一层优先级仲裁。
先把插播内容分级:
| 优先级 | 典型内容 | 是否可被打断 | 打断后是否恢复 |
|---|---|---|---|
| P0 | 安全告警、急停 | 否 | 不恢复,直接进入新告警 |
| P1 | 操作反馈、错误提示 | 仅 P0 可打断 | 恢复 |
| P2 | 背景解说、状态播报 | P0、P1 均可打断 | 恢复 |
规则的核心是:P0 不允许被任何东西打断,而且 P0 打断 P1/P2 之后不恢复被打断的内容。 因为安全提示的语境已经变了,再回去播"当前温度 42 摄氏度"没有意义,反而会干扰用户判断。
队列的实现很简单,固定深度的循环队列,每个元素存文件号和优先级。入队的时候如果队列满,就丢弃优先级最低的那一条;如果新来的优先级比队尾还低,直接丢新来的。出队只在VS_IDLE或VS_BG_PLAYING状态时进行。
#define Q_DEPTH 4 typedef struct { uint16_t file; uint8_t prio; } ireq_t; static ireq_t s_q[Q_DEPTH]; static uint8_t s_q_cnt = 0; /* 入队,队满时淘汰优先级最低的一条 */ void voice_enqueue(uint16_t file, uint8_t prio) { if (s_q_cnt < Q_DEPTH) { s_q[s_q_cnt].file = file; s_q[s_q_cnt].prio = prio; s_q_cnt++; return; } uint8_t worst = 0; for (uint8_t i = 1; i < Q_DEPTH; i++) { if (s_q[i].prio > s_q[worst].prio) worst = i; /* 数值越大优先级越低 */ } if (prio < s_q[worst].prio) { s_q[worst].file = file; s_q[worst].prio = prio; } }这段逻辑看起来朴素,但它把"插播风暴"这个最容易出问题的场景摁住了。我见过一个项目因为没做这层,报警连续触发的时候芯片疯了一样反复切歌,最后直接卡死。
4.2 断点续播的降级方案:分段拼播
有些型号的 B1 只做"插入",不保证"回到原位",或者固件版本较老,恢复点总是会往回跳一点。遇到这种情况,我用的降级方案是分段拼播。
具体做法是把一条 30 秒的长解说切成 30 个 1 秒的小片段,编号连续。MCU 维护一个"当前段号",每段播完就播下一段。插播来了,发 B1 播告警,告警结束后从"下一段"开始播,而不是从头。
代价是有段间间隙。能不能听出来,取决于芯片换文件的切换速度。实测 WT2003Hx 播相邻编号文件的间隙在 10ms 以内,人耳基本听不出来,只有在极其安静的环境下仔细听才会发现一点点不连贯。
分段还有个额外好处:粒度细了,插播的响应点更准。最坏情况下,用户只损失不到 1 秒的内容,而不是整条音频。
代价也很实在:Flash 容量和烧录时间都上去了。切割的时候要保证片段首尾对齐,别在字的中间切开,否则会有明显的截断感。我一般用音频编辑软件手动切,虽然慢,但质量可控。用脚本自动切的话,一定要先听几段验证效果。
5. 实测踩坑与排查速查表
5.1 常见故障速查表
这张表是我这几个月攒下来的,遇到问题先照着过一遍,能省掉大半时间。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 发 B1 完全没反应 | 长度字段定义不对 / 校验算法不对 | 换 0x03 和 0x04 两种长度各试一次,两种校验各试一次 |
| 发 B1 完全没反应 | TX/RX 没交叉 / 没共地 | 用万用表量通断,用示波器看 TX 线有没有波形 |
| 插播能响,背景音不恢复 | 芯片固件不支持自动恢复 | 换成暂停+记录段号+续播的降级方案 |
| 插播能响,但恢复点往回跳 | 时长表填短了,状态机提前切状态 | 把时长表的余量从 150ms 加到 300ms 再试 |
| 插播声音断续、卡顿 | 电源跌落,功放瞬态电流拉垮电压 | 加大滤波电容,见下一节 |
| 插播后有明显爆音 | 音频切换时的直流跳变 | DAC 输出加 RC 低通和隔直电容 |
| 连续插播变慢甚至卡死 | 帧间隔不足 / 队列没做淘汰 | 帧间隔提到 10ms 以上,加优先级队列 |
| BUSY 脚一直不变 | 文件编号越界,芯片卡在等待 | 检查文件编号是否在有效范围 |
| 偶发出错但复现不了 | 串口线受干扰 | 缩短走线,加缓冲电阻,打开校验严格模式 |
5.2 插播后不恢复的三个典型原因
这个症状太常见了,单独拎出来说。
第一个原因是时长表填得太短。 芯片播插播音频的实际耗时,往往比音频文件本身标称的时长要长一点,因为里面有解码启动、DAC 切换的开销。如果你按标称时长设定时器,状态机会在插播还没播完的时候就认为结束了,然后去复核 BUSY——这时候 BUSY 当然是"忙"的,逻辑就乱了。我的经验值是在标称时长上加 150 到 300ms 的余量,宁可多一点。
第二个原因是状态机没有处理"插播期间又来请求"。 如果插播中又发了一次 B1,有的型号会把它当成新的插入,第二次插播结束后,第一次的恢复点就丢了。解决办法就是在voice_request_insert里挡住VS_INSERTING状态下的请求,交给优先级队列处理。
第三个原因是 BUSY 脚的极性搞反了。 有的型号播放时 BUSY 输出高,有的是低,手册上写的是哪一种一定要看清楚。搞反了之后你的"播放中"判断全部反过来,表现就是时好时坏,特别迷惑人。我一般会在初始化的时候让芯片播一条短提示音,同时用万用表量 BUSY 脚的对地电压,确认一下极性再写代码。
5.3 爆音、电流声与电源处理
音频类项目里,电源和爆音这两个问题占了故障的一半以上,而且都和软件没太大关系,但用户会认为是软件的问题。
先说电流。WT2003Hx 直推 8Ω 喇叭的时候,峰值电流能到 200mA 以上。如果你的电源是个 LDO,输出能力只有 100mA,那就等着听爆音吧。我的做法是:芯片的 VCC 引脚旁边紧贴放一颗 100μF 的电解电容加一颗 0.1μF 的瓷片电容,越大越好但要注意体积。这颗电容的作用就是在瞬态电流拉起来的时候顶一下,别让电压掉下去。
如果用的是外挂功放(比如常见的 8002 之类),还要注意功放的使能脚时序。上电的时候如果芯片音频输出已经有效,而功放使能还没稳定,就会"啪"一声。解决办法是把功放使能放在芯片初始化之后拉高,掉电时反过来,先关功放再断芯片电源。
再说爆音本身。音源切换时的爆音,本质是 DAC 输出端的直流电平发生跳变。硬件上加一个 RC 低通(比如 1kΩ 加 10nF)再加一颗隔直电容,能压掉大部分。软件上看看手册有没有音量渐变或者淡入淡出的指令,有的话在插播前后各加一小段渐变,效果会更自然。
还有个很容易忽略的点:地和屏蔽。 音频地一定要和数字地分开走线,最后单点汇合。我见过一个板子,音频线跟电机的驱动线捆在一起走,结果插播的时候能听到电机换向的啸叫声。改走线之后问题直接消失。
5.4 几条不怎么写在手册里的经验
第一条,插播音频本身要做得比背景音"响"。 B1 插播是分时复用的,没法做动态音量闪避(ducking),所以唯一的办法就是在录音阶段把插播文件本身的响度做上去。我的做法是插播音频的归一化峰值比背景音高 3 到 6dB。这样插播一出来,人耳立刻能感觉到"这件事更重要",体验差别很大。
第二条,插播文件越短越好。 紧急提示控制在 2 秒以内,超过 3 秒用户就开始不耐烦了。如果内容确实多,宁可分成两条短提示,也不要一条长告警。短文件还有个好处,时长表的余量占比小,状态机的时序判断更准。
第三条,务必留一个"静默重启"的兜底路径。 不管代码写得多严谨,现场总会有奇奇怪怪的干扰。我在VS_FAULT状态里做了一件事:连续 3 次插播失败之后,发一条停止指令,等 500ms,再从当前段号重新开始播。这个兜底逻辑在整个项目生命周期里救过两次现场,代价几乎为零。
第四条,用日志把每一次插播记录下来。 记录时间戳、文件编号、优先级、触发时的状态。现场出问题的时候,这几行日志能让你在五分钟内定位到是软件逻辑问题还是硬件问题,比拿示波器在现场蹲半天高效得多。存储空间不够的话,存在 RAM 里循环覆盖,通过串口导出就行。
第五条,也是我最想强调的一条:一定要拿真实的告警场景测一遍。 我在实验室里用按键触发测了几百次都正常,结果到现场用真实的传感器信号触发,发现传感器的抖动导致 200ms 内连续来了 5 次中断请求。这就是优先级队列和去抖动逻辑真正体现价值的地方。软件消抖我加的是 150ms 的窗口,窗口内的重复请求只认第一条,这个参数是根据现场传感器的实际抖动特性调出来的。
这套方案我在三个项目上复用过了,从工业控制柜到共享设备,逻辑基本没改,改的只是文件编号和时长表。真正需要花心思的地方,永远是那些看起来不起眼的边界情况:插播没播完又来一个怎么办、串口被干扰了怎么办、电源掉了一下怎么办。这些处理好了,功能才算是真的稳。