做嵌入式语音播报项目,最让人头疼的往往不是怎么把声音放出来,而是音频正放着呢,系统突然要插一句更重要的话。WT2003Hx是一款在提示音、报警器、自动售货机、排队叫号设备里非常常见的语音播放芯片,它的指令集里有一条B1指令,专门干插播这件事:不打断当前播放流程的前提下,把声音临时切到一条更高优先级的语音。很多刚接触这颗芯片的朋友以为B1发出去就完了,结果发现紧急语音播完后原语音回不来,或者回来的时候位置不对、有爆音,问题层出不穷。这篇文章来自我这边一个真实落地的车间报警终端项目,我会把B1指令实现紧急语音中断与恢复播放的完整方案拆开讲清楚,包括硬件接线、协议帧、状态机代码和调试踩坑,适合正在用WT2003Hx做语音播报、又需要紧急打断能力的嵌入式开发朋友参考。
1. 项目背景:为什么语音播报需要“插播”能力
1.1 紧急语音场景与普通播放的冲突
我手上这个项目是一台车间设备报警终端,平时循环播放设备操作提示音,方便现场工人了解当前工位状态。突然有一天,产线反馈说设备出故障的时候,报警提示要等十几秒才响,因为正在播放的那条提示音有十几秒长,必须等它播完才能轮到报警语。这在紧急情况下完全不可接受,因为故障发生后的每一秒都可能影响设备安全。
当时我脑子里跳出来的第一个方案很朴素:检测到故障信号后,先发送“停止播放”指令,再发送“播放紧急语音”指令,等紧急语音播完再想办法恢复原来的提示音。这个方案听起来简单,实际一测就发现问题一串串。首先是“停止”和“切换”之间有时间缝隙,芯片要处理完停止指令、释放解码缓冲,再重新加载新的音频文件,耳朵能明显听出中间“卡”了一下。其次是断点信息完全丢失,等紧急语音播完,原来那条提示音到底播到哪了,只能重新从头开始。对于报警场景,从头播还能接受,但如果是一段操作教学音频,播到一半被报警打断再从头开始,体验就很差。
插播功能的好处恰恰在于,它把“切换声源”这件事放到芯片内部去处理。主控MCU只需要告诉芯片“现在临时插播地址0x010000上的语音”,芯片自己会完成当前音频的解码暂停、新音频加载、紧急语音播放,这一连串动作在几十毫秒内完成。更关键的是,插播完成后的行为是可以设计的,这也是这篇文章要解决的核心问题:如何让原音频在紧急语音播完后,从被打断的位置继续往下播。
1.2 B1指令在WT2003Hx指令体系中的定位
WT2003Hx这种语音芯片,控制方式很传统,走UART串口,主控发一帧指令,芯片执行后返回应答。它的指令集里大致分成几类:播放控制类比如播放指定地址、停止、暂停、上下曲,音量控制类,状态查询类,还有一类比较特殊的就是插播。B1指令就是插播这一类的代表。
我第一次看数据手册时,对B1的理解是“插入一段语音”。实测后发现,不同批次、不同固件版本的WT2003Hx,B1指令的细节差别很大。有的版本支持插播完成后自动回到原来的断点继续播放,有的版本只负责把当前播放内容切到指定语音,播完就停在原地,后面的恢复要靠主控自己做。很不巧,我手里这批模块就属于后者。
所以这篇文章里我采用的方案是“软件断点保存 + B1插播 + 软件恢复播放”。不依赖芯片固件是否具备自动恢复能力,不管什么时候拿到新批次芯片,只要B1指令能完成语音切换,后面的恢复逻辑就都跑在MCU侧,这样最保险。如果你手里的芯片版本本身就支持自动恢复,那可以把我这套逻辑里的断点保存部分进一步简化。
1.3 系统架构与数据流
整个系统的硬件组成很简单:一块主控MCU,我用的是STM32F103,换成GD32、ESP32或者普通51都行;一块WT2003Hx语音模块,我项目里用的是WT2003H8,板上带SPI Flash;功放选用常见的8002系列D类功放,带MUTE引脚;紧急触发源是一个急停按钮,接到MCU的外部中断引脚上。
数据流是这样一个路径:正常情况下,MCU按业务逻辑发送“播放指定地址”指令,WT2003Hx播放普通语音,MCU同时定时查询并记录当前播放地址。急停按钮按下的瞬间,MCU的GPIO外部中断触发,在中断服务函数里只做一件事:置一个“紧急插播请求”标志位。主循环检测到这个标志后,先读取缓存的断点信息,然后发送B1插播指令,目标地址是紧急语音区。WT2003Hx收到B1后迅速切换,紧急语音响起。等到紧急语音播放完成,芯片会给出一个完成事件或者BUSY脚电平跳变,MCU收到这个信号后再发送“播放指定地址”指令,把地址指回之前记录的断点,原语音恢复播放。
这个架构的好处是把紧急插播请求从普通业务逻辑里剥离出来。不管主循环当时在处理什么,紧急插播永远优先执行,而且整个过程只有一个关键动作会被业务逻辑干扰,那就是“断点信息的更新频率”。
2. 硬件准备与B1指令帧结构
2.1 最小硬件系统与接线
WT2003Hx做最小系统并不复杂,但有几个引脚必须接对。下面这组接线是我在项目里实际用过的方案,大家可以参考着来,具体引脚定义以你自己拿到的模块丝印和规格书为准。
| WT2003Hx引脚 | 连接目标 | 说明 |
|---|---|---|
| VDD/VCC | 3.3V稳压电源 | 我用AMS1117-3.3供电,VDD就近放100nF和10uF去耦电容 |
| GND | MCU GND | 必须和主控共地,否则串口通信会不稳定 |
| UART_TX | MCU的RX引脚 | TTL电平,模块向主控上传状态和完成事件 |
| UART_RX | MCU的TX引脚 | 主控向模块发送控制指令 |
| BUSY | MCU普通GPIO | 播放期间忙信号,用来判断是否正在播放 |
| DAC_L / DAC_R | 功放输入 | 我用的8002功放是单端输入,只接了DAC_L,GND接功放GND |
| 功放MUTE | MCU普通GPIO | 用于插播切换时抑制爆音 |
| SPK+ / SPK- | 喇叭 | 直接接功放输出,喇叭功率别超过功放额定值 |
这里提醒一个很容易踩的坑:WT2003Hx模块和主控之间的UART电平,绝大多数是TTL电平,不是RS232电平。有人图省事直接用USB转RS232线去接模块,结果指令发过去毫无反应,查了半天才发现电平都不对。正确做法是USB转TTL模块,或者直接用MCU的UART引脚对接。
另一点是功放的MUTE脚,强烈建议接出来。初期调试如果不接MUTE,插播切换瞬间大概率会出现“啪”一声爆音,声音不大但很影响产品质感。后面我在调试章节会详细讲这个问题的处理方式。
2.2 语音素材烧录与地址规划
语音素材需要通过厂家提供的上位机工具烧录到Flash里。工具支持直接把MP3、WAV等音频文件拖进去,自动生成地址索引。我一开始图省事,让工具自动排列所有音频,结果紧急语音被排到了很靠后的位置,后面程序里写死地址特别别扭。
后来我改成手动规划地址,专门给紧急语音留一个固定区域。我这里用的是三字节地址格式,规划如下:
| 区域 | Flash起始地址 | 内容 | 备注 |
|---|---|---|---|
| 系统参数区 | 0x000000 | 烧录工具自动管理 | 不要手动占用 |
| 普通语音区1 | 0x001000 | 操作提示音 | 长度约30秒 |
| 普通语音区2 | 0x002000 | 流程播报音 | 长度约60秒 |
| 紧急语音区 | 0x010000 | 故障报警语 | 固定地址,不随业务更新 |
这样规划的考虑有几个:普通语音区放在低地址,方便后续如果要增加语音文件,不会把紧急语音区挤掉;紧急语音区放在0x010000这种相对靠后且固定的位置,B1指令里的目标地址在程序里写死,哪天要换报警语音内容,只要重新烧录紧急语音区,主控程序一行都不用改。语音段的起始地址尽量按0x100对齐,这是我在实践中养成的习惯。有些烧录工具会在音频文件前加文件头,起始地址不对齐容易导致用“指定地址播放”时多出一段静音或者杂音。
2.3 B1指令帧格式详解
不同批次的WT2003Hx串口协议在细节上会有差异,所以看这部分的时候,一定要拿你手上那批芯片的官方指令表做比对。我这边验证过的帧格式属于B1指令三字节地址版本,特点是用0x7E作为帧头,0xEF作为帧尾,校验采用前面所有字节累加和的低八位。
帧的组成如下表:
| 字段 | 字节内容 | 说明 |
|---|---|---|
| 帧头 | 0x7E | 起始标志 |
| 长度/命令类别 | 0x02 | 固定段,表示控制指令 |
| 命令号 | 0x01 | 控制类命令标识 |
| 操作码 | 0xB1 | 插播指令专属操作码 |
| 目标地址高字节 | 0x01 | 紧急语音区高字节 |
| 目标地址中字节 | 0x00 | 紧急语音区中字节 |
| 目标地址低字节 | 0x00 | 紧急语音区低字节 |
| 保留/扩展 | 0x00 | 默认填0 |
| 校验字节 | 计算值 | 前面所有字节累加和取低八位 |
| 帧尾 | 0xEF | 结束标志 |
如果紧急语音区的起始地址是0x010000,那么发送B1插播指令的完整帧应该是:
7E 02 01 B1 01 00 00 00 93 EF校验字节0x93就是前面8个字节(7E 02 01 B1 01 00 00 00)的累加和0x193,取低八位得到0x93。这个算法在WT系列里很常见,但不敢保证所有版本都一样,所以建议大家拿到芯片后先用串口助手手工发一帧,观察模块是否正确响应,确认无误后再往程序里写。
2.4 指令校验与数据手册核对要点
我踩过一个大坑,重要到值得单独拿出来说。当时我在一个项目里用了新一批芯片,固件版本号和之前不一样,文档上写着支持B1,但实际发送后芯片直接没反应,查了半天发现是校验算法变了,从累加和变成了异或和。从那以后我就养成了一个习惯:每个批次的芯片到位后,第一件事不是写业务代码,而是用串口助手把所有要用的指令手工发一遍,把每个指令的响应记录下来和手册比对。
核对指令时重点看三个地方:一是校验字节的生成算法,是累加和还是异或,取高八位还是低八位;二是地址字段是两字节还是三字节,如果芯片支持的Flash空间大于64KB,两字节地址根本覆盖不到;三是B1指令后面有没有“恢复模式标志位”,有些版本这个标志位直接决定插播完成后芯片是停在原地还是自动恢复。
这些细节看起来枯燥,但真要等代码写完再发现问题,排查成本会成倍上升。我的经验是,拿到芯片先花半小时做协议热身,后面能省下一整天的调试点。
3. 核心逻辑设计:中断、断点保存与恢复策略
3.1 两种插播模式的取舍
B1指令插播完成后,原语音的走向基本上有两种情况。第一种是芯片硬件自动恢复,紧急语音播完后,芯片自己把原来的音频接回来继续播,断点由芯片内部维护,主控什么都不用管。这种情况最理想,但依赖固件。
第二种是我遇到的情况,B1只是完成“切换”这一步,紧急语音播完就停住,芯片回到空闲状态,原语音的播放进度完全丢失。这种时候如果主控不介入,整个播报流程就断了。
我在项目里选择的是软件断点保存方案,原因有两个。第一,不依赖固件特性,兼容性最好。不管是哪批芯片,只要指令集里有B1、有查询指令,这套逻辑就能跑。第二,软件方案可以把恢复时机控制得更精细,比如紧急语音播放完成后,先静音一段时间再恢复原语音,避免切换瞬间的不自然感,这一点硬件自动恢复不一定能调节。代价也很明显,就是MCU侧代码量多了一些,而且断点信息需要主控自己维护。
3.2 断点信息怎么记录最可靠
要恢复播放,首先得知道当初被打断的位置在哪里。这里有两条路径:一条是紧急信号到来时,MCU临时发指令去查询“当前播放地址”;另一条是MCU平时持续查询并缓存地址,紧急插播时直接用缓存值。
我一开始用的是第一条,结果并不理想。按下急停按钮后,MCU进中断置标志,主循环再发查询指令,芯片返回应答,一套流程下来少说几十毫秒。如果紧急语音本身只有几秒,这段等待时间拖慢了插播启动速度。更麻烦的是,如果主循环当时正在忙着处理别的事情,查询指令可能被推迟几百毫秒,这时候芯片可能都已经播放完了一小段,断点就不准了。
后来我改成周期缓存方案。主循环里每100ms查询一次播放状态,只有在确认“正在播放”时才把当前播放地址写进断点缓存。紧急信号一来,MCU直接读缓存值,不需要现场再发查询指令,插播动作可以做到几毫秒内启动。
具体缓存的数据结构很简单:
typedef struct { uint8_t is_playing; // 是否正在播放 uint8_t track_id; // 曲目编号,用于辅助判断 uint32_t play_address; // 当前播放地址,三字节有效 uint8_t volume; // 插播前的音量,恢复时还原 } playback_snapshot_t; playback_snapshot_t g_snapshot;这里还有一层很关键的设计。周期查询播放地址时,如果芯片处于空闲状态,查询结果可能返回的是上一次的残留地址,如果直接写进缓存,后面恢复时就会跳到奇怪的位置。所以代码里必须带状态判断,只有状态字确认是“播放中”,才更新play_address。
3.3 状态机设计
插播逻辑如果用一长串if else硬写,很容易漏状态,特别是在“紧急语音播放中又来了新的紧急信号”这种情况下,处理不好就会重复插播。我采用的是状态机方式,把整个流程分成五个状态:
| 状态 | 含义 | 关键动作 |
|---|---|---|
| IDLE | 空闲或未初始化 | 不做任何插播处理 |
| PLAYING | 正在播放普通语音 | 周期更新断点缓存 |
| EMERGENCY_REQ | 收到紧急插播请求 | 保存断点,发送B1指令 |
| EMERGENCY_PLAYING | 紧急语音播放中 | 等待完成事件,禁止重复插播 |
| RESUMING | 正在恢复原语音 | 发送播放地址,等待恢复完成 |
状态之间的转换严格受事件驱动。比如在EMERGENCY_PLAYING状态下,即使急停按钮再次被按下,也只是刷新一个时间戳,不会再去发B1指令,因为紧急语音已经在播了,重复触发没有意义。只有当紧急语音播放完成事件到来,状态才切到RESUMING。
这种状态机的好处是让每个运行阶段的行为都明确可预期,不容易出现“紧急插播后原语音没恢复”这类难以排查的bug。就算真的出了问题,一个状态一个状态查,很快就知道卡在哪一步。
3.4 MCU端代码实现
核心代码我用C语言写,主要分成三个部分:串口指令封装、断点缓存更新、插播状态机。先看串口指令这块。
// 串口发送一帧数据 static void uart_send_bytes(const uint8_t *buf, uint16_t len) { for (uint16_t i = 0; i < len; i++) { // 这里按你的串口发送函数来,逐字节发或者DMA发送都行 uart_write_byte(buf[i]); } } // 计算累加和低八位,注意只算到帧尾之前 static uint8_t calc_check_sum(const uint8_t *buf, uint16_t len) { uint32_t sum = 0; for (uint16_t i = 0; i < len; i++) { sum += buf[i]; } return (uint8_t)(sum & 0xFF); } // 发送B1插播指令,addr是要插入播放的语音地址 static void send_b1_interrupt(uint32_t addr) { uint8_t frame[10]; frame[0] = 0x7E; // 帧头 frame[1] = 0x02; // 长度/命令类别 frame[2] = 0x01; // 命令号 frame[3] = 0xB1; // 插播操作码 frame[4] = (addr >> 16) & 0xFF; // 地址高字节 frame[5] = (addr >> 8) & 0xFF; // 地址中字节 frame[6] = addr & 0xFF; // 地址低字节 frame[7] = 0x00; // 保留字节 frame[8] = calc_check_sum(frame, 8); frame[9] = 0xEF; // 帧尾 uart_send_bytes(frame, sizeof(frame)); } // 发送播放指定地址指令,用于恢复播放 static void send_play_address(uint32_t addr) { // 帧结构和B1类似,只是操作码换成播放指令,这里就不重复展开了 }然后是断点缓存的更新函数,放在主循环里定时调用:
void update_playback_snapshot(void) { // 查询芯片当前播放状态,假设查询结果存到status里 uint8_t status = query_play_status(); if (status == STATUS_PLAYING) { g_snapshot.is_playing = 1; g_snapshot.play_address = query_play_address(); g_snapshot.volume = query_volume(); } else { g_snapshot.is_playing = 0; } }最后是插播状态机的实现:
void playback_state_machine(void) { static uint8_t state = STATE_PLAYING; switch (state) { case STATE_PLAYING: if (g_emergency_request == 1) { // 紧急请求到来,保存当前断点 memcpy(&g_resume_point, &g_snapshot, sizeof(g_snapshot)); // 发送B1插播指令,切入紧急语音 send_b1_interrupt(EMERGENCY_ADDR); // 通知功放静音,避免切换爆音 amp_mute_enable(); g_emergency_request = 0; state = STATE_EMERGENCY_PLAYING; } break; case STATE_EMERGENCY_PLAYING: // 等待芯片播放完成事件,可以轮询BUSY引脚或者接收UART主动上传事件 if (is_emergency_done()) { // 先等几十毫秒,让芯片完全回到空闲 delay_ms(50); // 如果插播前确实在播放,就恢复到断点 if (g_resume_point.is_playing) { send_play_address(g_resume_point.play_address); // 音量也恢复 send_set_volume(g_resume_point.volume); } // 功放解除静音 amp_mute_disable(); state = STATE_PLAYING; } break; default: state = STATE_PLAYING; break; } }这段代码里最重要的细节是:B1插播指令发送后,芯片切换需要时间,所以功放MUTE要在切换期间保持有效,等恢复播放指令发出去之后再解除。MUTE处理不好,就会听到明显的爆音。
3.5 时序细节
插播恢复播放的整个时序,我实测大致是这样的:
- t0时刻:急停按钮按下,MCU GPIO中断触发,置紧急请求标志。
- t0 + 2ms:主循环检测到标志,读取断点缓存,发送B1帧。
- t0 + 20ms左右:WT2003Hx完成音频切换,紧急语音开始播放,BUSY电平变化。
- 紧急语音播放期间:功放MUTE保持有效,喇叭无声或只有轻微底噪,具体取决于MUTE脚控制方式。
- 紧急语音播完:芯片上传播放完成事件,或者BUSY电平跳变,MCU在几个毫秒内感知到。
- 播放完成 + 50ms:MCU等待芯片完全回空闲,发送恢复播放地址指令。
- 恢复指令发出后:芯片重新加载原语音地址,功放解除MUTE,原语音从断点继续播放。
从紧急语音结束到原语音恢复,中间大概不到100ms,实际操作中人是察觉不到明显卡顿的。关键是恢复指令发出前必须给芯片留出处理上一条完成事件的时间,太着急发指令容易上下文冲突,造成恢复失败。
4. 实际调试过程与踩坑实录
4.1 恢复播放位置不准
这个坑几乎必踩。第一次联调时我发现,紧急语音播完后,原语音确实恢复了,但位置明显偏了。比如一条30秒的提示音,被打断在第10秒,恢复之后却从第10.5秒甚至第11秒开始,多播或者少播了一截。
现象排查下来有两个原因。一是周期缓存的地址不是实时刷新的,而是每100ms才更新一次,这0.1秒就是误差来源。二是芯片解码时本身有缓冲,从紧急语音切回原地址时,芯片重新从Flash读取数据到解码缓冲,这个缓冲的填充量和音频格式有关,不同码率下表现不一样。
这个问题没有彻底消除的完美办法,只能在方案上优化。我的处理是:不要试图用字节地址精确定位到毫秒级。如果产品对“从被打断位置继续”没有硬性要求,直接从曲目头恢复反而更稳定,不容易出现卡顿或者奇怪的跳变。我后来又调整了缓存更新频率到50ms,误差缩小了一些,但真正让体验变好的,是把恢复策略改成“能精确就精确,不能精确就从曲目头播”。
4.2 插播瞬间爆音
爆音这个问题,我第一次调试时差点忽略了。当时用耳机听,没注意就过去了,换成喇叭试音,每次插播都“啪”一声,特别明显。
原因基本都是功放在音频切换瞬间没有静音。WT2003Hx内部做声源切换时,DAC输出电平会有一个短暂的跳变,如果功放正好处于放大状态,这个跳变就被放大成爆音。
解决方案就是前面提到的功放MUTE脚。在发送B1指令前把MUTE拉低,等紧急语音恢复播放、原语音稳定输出后再解除MUTE。这里有一个细节要留意:解除MUTE不能和恢复播放指令同时做,必须等恢复播放指令发出后大约20到50ms,芯片已经稳定输出音频,再解除静音。具体延时根据功放上电响应时间微调,我在项目里试过10ms档,还偶尔有轻微爆音,改到50ms后彻底消失。
4.3 紧急语音重复触发
车间环境里按钮抖动特别严重,急停按钮按下时,金属触点会反复通断几十毫秒。如果不对信号做处理,一次按压可能触发五六次外部中断,B1指令就被重复发送了。
一开始我在中断服务函数里加软件延时消抖,结果发现治标不治本。后来改成了“状态锁”思路:只要状态机处于EMERGENCY_PLAYING,新的紧急请求标志位一律忽略。这样即使按钮抖动产生多次中断,也只有第一次能进入插播流程,后面的全部无效。
另外还在主控程序里加了一个“紧急触发次数”计数器,每次有效触发加一,可以通过串口打印出来,方便现场确认设备是不是被多次触发过。这个计数器平时不显示,调试时非常有用。
4.4 B1指令偶尔丢
调试中段出现过一个很隐蔽的问题:插播功能大部分时间正常,偶尔一次按下急停,紧急语音没响,原来播放的音也没了。用串口分析仪抓数据,发现MCU确实发了B1帧,但芯片没有任何响应。
查下来有两个原因。第一个是上电初期,WT2003Hx还没完全初始化,MCU如果提前发指令,芯片直接忽略。这个问题好解决,在系统初始化时等待至少500ms再发第一条控制指令。第二个原因是连续两条UART指令间隔太短。芯片底层串口接收缓冲区有限,连续发帧时如果间隔小于20ms,后面的帧可能被覆盖。
我处理的方式有两个:所有指令之间强制延时20ms以上;B1指令发送后等待芯片应答,如果超过200ms没有应答,自动重发一次。重发逻辑看起来简单,但确实把丢指令的概率降到了近乎为零。
4.5 烧录后地址和代码不一致
有一次从工厂拿到整机,发现紧急语音播报出来的是别人的操作提示音,找了一下午才发现是烧录工具版本升级后,音频文件的起始地址偏移变了,工具默认给每个文件加了新的文件头,导致程序里写死的0x010000实际上指向了另一个音频。
这个问题很坑,因为单独看每一个环节都正常。我的解决办法是:每次烧录完成后,用上位机的“读取语音信息”功能,把每段语音的实际起始地址导出来,和程序里的地址表做一次自动比对。量产产线会有烧录测试工装,在测试软件里顺手加一个地址校验,能挡住大多数这类问题。
4.6 调试工具建议
调试这个功能,我建议准备三样东西:一个USB转TTL串口助手,用来被动监听MCU和WT2003Hx之间的通信;一个逻辑分析仪,抓UART波形和MUTE脚时序,确认先后关系;如果手头有示波器更好,可以看DAC输出切换瞬间的电平跳变。
串口助手监听时要注意,模块和MCU之间是双向通信,串口助手只能接在一根线上,要么看MCU发出的指令,要么看芯片返回的事件,两者不能同时在一个串口里看。我自己的习惯是同时开两个串口助手,一个监听TX线,一个监听RX线,加上逻辑分析仪抓三根线,整个通信过程一目了然。
5. 常见问题速查与方案扩展
5.1 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| B1指令发出后无反应 | 指令帧校验错误 / 芯片未初始化完成 | 核对协议版本,上电后延时500ms再发,加应答重试 |
| 紧急语音能播,但原语音恢复失败 | 断点缓存未更新 / 恢复指令发送过早 | 确认缓存只保存“播放中”状态,播放完成事件后延时50ms再发恢复指令 |
| 插播瞬间有爆音 | 功放MUTE时序不对 | 发送B1前拉低MUTE,恢复播放后延时20~50ms再拉高 |
| 紧急语音重复播报 | 按钮抖动 / 中断重复触发 | 状态机加EMERGENCY_PLAYING锁,未完成前忽略新请求 |
| 恢复位置不准 | 缓存延迟 / 解码缓冲偏移 | 提高缓存更新频率,或者改成曲目头恢复 |
| 紧急语音播完停在原地 | 芯片固件不支持自动恢复 | 使用软件断点保存方案,主动发恢复播放指令 |
| 烧录后紧急语音内容是错的 | Flash地址偏移或工具版本差异 | 烧录后从工具读取地址表,和程序地址常量比对 |
5.2 从单级插播到多级语音调度
这个项目做完之后,我紧接着遇到一个新需求:设备上不止一个紧急源,除了急停按钮,还有温度报警、压力报警,而且这些报警的优先级不一样,温度过高和压力异常同时发生时,只能播最高优先级的那个。
单级插播状态机在这个需求面前不够用,我把它扩展成了“优先级插播队列”。做法是定义一个数组,每个元素包含语音地址和优先级。紧急信号到来时,先对比当前正在播放的内容优先级,如果新信号优先级更高,就打断当前插播,播放新的紧急语音;如果新信号优先级低,就记到队列里,等当前紧急语音播完再播。恢复逻辑不变,只不过恢复的目标地址要跟着插播层级走,用了一个简单的栈结构来保存每一层被打断的断点。
多级插播能覆盖的场景比单级丰富得多。排队叫号系统里,普通叫号语音正在播,突然插入一个重要客户优先叫号,播完再恢复普通叫号;电梯里楼层播报和消防报警同时发生,消防报警必须打断一切。这些都是同一个设计思路的变体。
5.3 从报警终端扩展到更多产品形态
插播功能真正发挥价值的地方,往往不是单一设备,而是那些需要“随时插入重要信息”的场景。我总结了一下,至少下面这几类产品都天然适合用WT2003Hx搭配B1指令来做。
自动售货机是最典型的例子。出货电机转动时播放普通广告语,如果检测到卡货故障,立刻插播“商品可能卡住,请联系管理员”,故障处理完自动恢复广告播报。电梯语音播报系统里,楼层提示音正常播放,消防信号触发时插播“请立即离开电梯”,播完后切回正常楼层播报。医疗床旁呼叫系统中,正在播放健康宣教音频时,患者按呼叫铃,插播“3号床呼叫”,播完宣教内容继续。这些场景对恢复时机的容忍度不同,但核心逻辑完全一致。
5.4 还可以怎么优化
这套方案稳定运行一段时间后,我又做了一些小优化,可以给大家参考。
一是把断点缓存从查询方式改成事件驱动方式。WT2003Hx播放过程中如果会通过UART主动上报当前播放状态,MCU可以直接在上报事件里更新断点缓存,省掉定时查询的轮询开销。二是加入掉电保护。紧急插播过程中如果设备突然断电,恢复播放时会发现断点缓存丢失,我用的Flash里专门留了一个CRC校验的配置块,正常播放状态下每10秒把断点信息写一次,掉电重启后能恢复到上次保存的位置。三是把音量单独管理。紧急语音插播时强制使用最大音量,保证现场人员一定能听到,恢复原语音时再恢复到插播前的音量,不要简单沿用芯片默认音量。
还有一个很多项目中会遇到的点:语音素材更新。量产之后如果想要替换紧急语音内容,只需要重新烧录0x010000地址区域,不需要动主控程序。这一点在地址规划时就该提前想清楚,如果把紧急语音和其他普通语音混在一起自动排列,后面每次更新语音都要改主控程序里的地址常量,维护成本很高。
这套插播功能从立项到稳定运行,我前后大概花了两个晚上加一个白天。最大的体会是,B1指令本身不复杂,真正复杂的永远是“播完之后怎么办”。如果你的产品对恢复位置要求很高,不要指望一条B1能解决所有问题,一定要把断点记录、播放完成事件、功放静音时序这三件事当成一个整体来设计。建议拿到新模块后,第一件事不是写业务逻辑,而是用串口助手把普通播放、B1插播、状态查询、完成事件全部手动过一遍,确认固件行为后再动手写状态机。这样后面能少走很多弯路。