☰
STM32音频采集播放与Speex压缩解压实战
2026/10/15 12:02:45 网站建设 项目流程

简介:这份资源面向嵌入式音频开发初学者与STM32进阶开发者,提供一套基于STM32F103RCT6的音频采集与播放完整工程,重点解决SPEEX编解码在微控制器上的落地问题。工程将ADC采样、DMA传输、SPEEX压缩与解压、DAC回放串联成闭环,可用于语音通信、录音回放等场景的学习与二次开发。压缩包共402个文件,约7.62MB,以106个h头文件与91个c源文件为核心,配合58个d依赖文件、58个o目标文件及57个crf编译中间文件,另含uvprojx、uvoptx等Keil工程配置、hex烧录文件与map映射文件,完整保留编译链路。目前已有1799人学习下载。读者可从中获取SPEEX库在Cortex-M3上的移植思路、ADC与DAC的DMA双缓冲配置、中断调度与缓冲区管理方法,以及音量调节、增益控制等音频优化手段,适合对照工程结构理解嵌入式音频处理的完整流程。

1. 从一段“能听清但占空间”的录音说起

如果你手头有一块 STM32 开发板,想做一个能录音、能回放、还能把音频压到很小再解出来的小系统,那这个STM32音频采集播放speex解压缩.zip就是冲着你来的。它把 AD 采集、Speex 编解码、DA 播放这三件事串成了一条完整链路,解决的是“原始 PCM 数据太大、串口和 Flash 都扛不住”这个最实际的问题。Speex 本身就是为语音优化的开源编解码器,在 8kHz 采样、窄带语音场景下,能把 16bit PCM 压到原来的十分之一左右,而 STM32 的算力和内存刚好能跑得动。适合谁?适合正在做语音对讲、录音笔、语音留言、工业喊话终端这类产品的嵌入式工程师,也适合想搞懂“编解码到底怎么塞进 MCU”的学生和爱好者。你拿到手不是一堆散装驱动,而是一条能跑通的采集—压缩—存储—解压—播放闭环。

2. 拆开压缩包:AD、Speex、DA 三块怎么咬合

2.1 先看清数据流,再谈代码

很多新手一上来就翻main.c,结果被中断和 DMA 绕晕。我一般会先画一条数据流:麦克风或音频线进 ADC,ADC 用定时器触发或 DMA 搬运,拿到的是原始 PCM;PCM 送进 Speex 编码器,输出压缩后的字节流;字节流可以存 Flash、SD 卡,也可以直接通过串口发出去;回放时反过来,压缩流进解码器,还原成 PCM,再通过 DAC 或 PWM+RC 滤波输出。这个压缩包里通常包含speex编解码库的移植文件、ADC/DAC 的初始化代码、以及一个主循环或中断调度逻辑。你要盯住三个接口:ADC 的采样率、Speex 的帧长、DAC 的更新率,三者必须对齐,否则出来的声音要么变调,要么断断续续。

2.2 采样率与帧长的硬约束

Speex 窄带模式固定按 8kHz 设计,帧长通常是 160 个采样点,对应 20ms。这意味着你的 ADC 必须每 20ms 正好凑够 160 个点,不能多也不能少。常见做法是用一个定时器产生 8kHz 的触发信号,让 ADC 在每次触发时采一个点,再用 DMA 搬进一个长度为 160 的缓冲区。缓冲区满一半或全满时产生中断,在中断里调用编码函数。下面这段是 ADC+DMA 的典型配置骨架,基于 HAL 库,你可以对照自己的芯片型号改通道和引脚。

// ADC + TIM 触发 + DMA 搬运,目标:每 20ms 收满 160 个 16bit 采样点 #define AUDIO_FRAME_LEN 160 static uint16_t adc_buf[AUDIO_FRAME_LEN]; void audio_adc_init(void) { // 1. 定时器配置为 8kHz 更新事件,作为 ADC 的外部触发源 // 定时器时钟 72MHz,预分频 8999,重装载 0,得到 8kHz htim3.Instance = TIM3; htim3.Init.Prescaler = 8999; htim3.Init.Period = 0; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim3); HAL_TIM_Base_Start(&htim3); // 2. ADC 配置为外部触发,触发源选 TIM3 TRGO hadc1.Instance = ADC1; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T3_TRGO; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; HAL_ADC_Init(&hadc1); // 3. DMA 循环模式,把 ADC 数据搬到 adc_buf HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, AUDIO_FRAME_LEN); }

逻辑说明:定时器每 125us 产生一次触发,ADC 采一个点,DMA 自动搬走,CPU 完全不参与单个点的搬运。参数上,预分频和重装载值决定了采样率,公式是采样率 = 定时器时钟 / ((预分频+1) * (重装载+1))。如果你用的是 168MHz 的 F4 系列,预分频要重新算,别直接抄。DMA 缓冲区长度必须等于帧长,否则编码器会读到脏数据。

2.3 Speex 编码器的初始化与调用

Speex 库移植到 STM32 上,核心是两件事:内存分配和定点/浮点选择。STM32F1/F4 没有硬件浮点或浮点性能弱,建议用定点模式编译,在config.h里把FIXED_POINT打开。初始化时创建一个SpeexBits结构用于比特流打包,再创建编码器状态。下面是一个最小可用的编码调用示例。

#include "speex/speex.h" static void *enc_state = NULL; static SpeexBits enc_bits; void speex_enc_init(void) { speex_bits_init(&enc_bits); // 窄带模式,8kHz,定点运算 enc_state = speex_encoder_init(&speex_nb_mode); int quality = 8; // 0~10,8 约等于 15kbps 左右 speex_encoder_ctl(enc_state, SPEEX_SET_QUALITY, &quality); int complexity = 1; // 复杂度 1~10,越高越费 CPU speex_encoder_ctl(enc_state, SPEEX_SET_COMPLEXITY, &complexity); } // 在 DMA 半满/全满中断里调用,输入 160 个 16bit PCM int speex_encode_frame(int16_t *pcm, uint8_t *out, int out_max) { speex_bits_reset(&enc_bits); speex_encode_int(enc_state, pcm, &enc_bits); int nbytes = speex_bits_nbytes(&enc_bits); if (nbytes > out_max) return -1; return speex_bits_write(&enc_bits, (char *)out, nbytes); }

逻辑说明:speex_bits_reset每帧必须调用,否则比特流会累积。speex_encode_int是定点入口,输入就是 ADC 拿到的 16bit 有符号数。quality和complexity是两个最常调的参数:quality 影响码率和音质,complexity 影响 CPU 占用。在 72MHz 的 M3 上,complexity 设 1~2 比较稳,设太高会丢帧。输出字节数一般在 20~40 字节之间,取决于 quality。

2.4 解码与 DA 回放

解码是编码的逆过程,但多了一个“抖动缓冲”的概念。因为压缩帧到达的时间可能不均匀,直接解一帧播一帧会卡顿。常见做法是开一个环形缓冲区,解码后的 PCM 先入队,DAC 用定时器按 8kHz 稳定取出。下面是对应的解码调用。

static void *dec_state = NULL; static SpeexBits dec_bits; void speex_dec_init(void) { speex_bits_init(&dec_bits); dec_state = speex_decoder_init(&speex_nb_mode); int enh = 1; // 开启增强,语音更自然 speex_decoder_ctl(dec_state, SPEEX_SET_ENH, &enh); } // 输入压缩字节流,输出 160 个 PCM 点 int speex_decode_frame(uint8_t *in, int nbytes, int16_t *pcm) { speex_bits_read_from(&dec_bits, (char *)in, nbytes); return speex_decode_int(dec_state, &dec_bits, pcm); }

逻辑说明:speex_bits_read_from把字节流读进比特结构,speex_decode_int输出固定 160 个点。返回值 0 表示正常,-1 表示流结束,-2 表示数据损坏。如果你从 Flash 读压缩数据,注意每帧长度不固定,需要自己记录每帧的字节数,或者用固定 quality 下的典型长度做偏移。DA 输出可以用芯片自带的 DAC,也可以用 PWM+RC 滤波,后者成本低但底噪大,语音场景够用。

3. 把工程跑起来:从零搭建到听到第一声

3.1 工程目录与关键文件定位

解压后你通常会看到类似这样的结构:Core/放 main 和中断,Drivers/放 HAL 库,Speex/放编解码源码,Audio/放 ADC/DAC 驱动。先别急着编译,打开Speex/config.h确认FIXED_POINT已定义,EXPORT为空。再打开main.c,找到while(1)之前的外设初始化顺序:时钟、GPIO、DMA、ADC、TIM、DAC、Speex。顺序错了会出现 DMA 搬了但 ADC 没启动,或者编码器拿到全零。我一般会在这几个初始化后面各加一个点亮 LED 的动作,用示波器或肉眼确认每一步都过了。

3.2 编译选项与内存占用

Speex 定点库编译后大概占 20~30KB Flash,RAM 占用取决于你开的缓冲区。编码器状态约 3KB,解码器约 2KB,加上 ADC/DAC 缓冲和栈,整体在 10KB RAM 以内,F103C8T6 这种 20KB RAM 的片子也能跑。编译时注意两点:一是把优化等级设成-O2,-O0下编码一帧可能超过 20ms 导致丢帧;二是如果报undefined reference to __aeabi_f2d之类的错,说明你混用了浮点版本,检查config.h和库文件是否一致。

3.3 用串口打印验证每一帧

在没接 DAC 之前,最稳的验证方式是把编码后的字节数和解码后的前几个 PCM 值通过串口打出来。下面这段代码放在编码之后、解码之前,能帮你判断链路是否通。

// 串口打印:编码字节数 + 解码后前 4 个采样值 printf("enc_len=%d, pcm0=%d, pcm1=%d\r\n", nbytes, pcm[0], pcm[1]);

逻辑说明:如果enc_len稳定在 20~40 之间,说明编码正常;如果一直是 0 或负数,检查 ADC 是否真的在采。解码后的pcm0应该在 -32768~32767 之间波动,如果恒为 0,多半是比特流没写对或解码器没初始化。串口波特率建议 115200,太高在有些板子上会丢字符。

3.4 回放时的定时器与 DAC 对齐

DAC 回放最怕的是“解码快、播放慢”或反过来。正确做法是:DAC 用定时器触发,触发率严格等于 8kHz,每次触发从环形缓冲区取一个点。解码任务只管往缓冲区里填,填满了就等。下面是一个简化的环形缓冲写入逻辑。

#define RING_SIZE 320 // 两帧的余量 static int16_t ring[RING_SIZE]; static volatile int w_idx = 0, r_idx = 0; // 解码后调用,把 160 个点写入环形缓冲 void ring_write(int16_t *pcm, int len) { for (int i = 0; i < len; i++) { ring[w_idx] = pcm[i]; w_idx = (w_idx + 1) % RING_SIZE; } } // DAC 定时器中断里调用,每次取一个点 void dac_timer_isr(void) { if (r_idx != w_idx) { int16_t v = ring[r_idx]; r_idx = (r_idx + 1) % RING_SIZE; dac_set_value(v); } }

逻辑说明:RING_SIZE至少是两帧长度,给解码留出余量。w_idx和r_idx都是 volatile,因为分别在主循环和中断里改。如果发现声音有周期性的“咔哒”,多半是环形缓冲太小导致下溢,把 RING_SIZE 加到 480 或 640 试试。

4. 避坑排查:那些让我熬夜的瞬间

4.1 现象:录音回放全是噪声,听不清人声

原因:ADC 的参考电压或输入偏置不对。很多开发板的麦克风模块输出是带直流偏置的交流信号,如果你直接按单极性采,或者参考电压选错,采到的就是削顶或全零。解决:先用示波器看 ADC 输入引脚的波形,确认偏置在 VCC/2 附近,幅度不超过参考电压。如果是驻极体麦克风,检查偏置电阻和耦合电容。

4.2 现象:编码一帧的时间超过 20ms,声音断断续续

原因:Speex 的 complexity 设太高,或者编译优化没开。在 72MHz 的 M3 上,complexity=8 时一帧编码可能跑到 30ms 以上。解决:把 complexity 降到 1~2,编译加-O2,如果还不行就换 M4 带 FPU 的片子,或者把采样率降到 6kHz 但帧长相应调整。

4.3 现象:解码出来的声音变调,像快进或慢放

原因:DAC 的更新率和编码时的采样率不一致。比如编码按 8kHz 采,DAC 定时器却按 10kHz 触发。解决:用同一个定时器源或者严格计算分频值,确保两边都是 8kHz。用示波器测 DAC 输出引脚,看一个 1kHz 正弦波周期是不是 1ms。

4.4 现象:串口能收到数据,但解码返回 -2

原因:比特流在传输或存储时被截断或错位。Speex 每帧的比特流是连续的,如果你在串口发送时加了额外的包头,解码前必须去掉。解决:检查speex_bits_read_from传入的字节数是否和编码时speex_bits_write返回的一致,不要多传也不要少传。

4.5 现象:Flash 存了几帧后写不进去

原因:Flash 页大小和擦除对齐没处理。STM32 的 Flash 按页擦除,如果你一帧一帧写,很快就把同一页写满了。解决:开一个页大小的缓冲区,攒够一页再擦再写,或者直接用外部 SD 卡。另外注意 Flash 写入期间 CPU 会停顿,可能影响音频中断,建议在暂停采集时写。

5. 进阶:把码率再压一压,把音质再提一提

如果你已经能跑通基本链路,下一步可以玩两个方向:一是动态调整 quality,在静音段降码率,在语音段升码率;二是用 Speex 的预处理模块做降噪和自动增益。下面是一个根据信号能量动态改 quality 的小技巧,放在编码前调用。

// 根据帧能量动态调整 quality,静音降码率,语音保音质 int calc_energy(int16_t *pcm, int len) { int sum = 0; for (int i = 0; i < len; i++) sum += abs(pcm[i]); return sum / len; } void adaptive_quality(int16_t *pcm) { int e = calc_energy(pcm, AUDIO_FRAME_LEN); int q = (e < 200) ? 3 : 8; // 静音用低 quality,语音用高 quality speex_encoder_ctl(enc_state, SPEEX_SET_QUALITY, &q); }

逻辑说明:calc_energy算平均绝对值,阈值 200 是经验值,你可以根据实际底噪调。静音时 quality=3 能把码率降到 10kbps 以下,语音时 quality=8 保证可懂度。注意频繁改 quality 会让每帧字节数变化,存储时要记录每帧长度。另一个进阶点是开启 Speex 的SPEEX_SET_VBR,让编码器自己根据内容分配比特,但 VBR 下帧长不固定,对存储和传输的缓冲管理要求更高。

验证方法很简单:录一段 10 秒的语音,分别用固定 quality 和动态 quality 各跑一遍,对比总字节数和主观听感。我一般会在串口里把每帧的 quality 和字节数打出来,画个曲线看波动。从那以后我每次做音频项目,都会先把采样率、帧长、DMA 缓冲这三个数写在纸上,确认对齐了再写代码,省得后面返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询