第六十章 录音机实验
上一章,我们实现了一个简单的音乐播放器,本章我们将在上一章的基础上,继续用ES8388实现一个简单的录音机,录制WAV格式的录音。
60.1 SAI录音简介
60.2 硬件设计
60.3 软件设计
60.4 下载验证
60.1 SAI录音简介
本章涉及的知识点基本上在上一章都有介绍。本章要实现WAV录音,还是和上一章一样,要了解:WAV文件格式、ES8388和SAI接口。WAV文件格式,我们在上一章已经做了详细介绍了,这里就不作介绍了。
STM32H7R7开发板将板载的一个MIC分别接入到了ES8388的2个差分输入通道(LIP/LIN和RIP/RIN,原理图见:图59.2.1)。代码上,我们采用立体声WAV录音,不过,左右声道的音源都是一样的,录音出来的WAV文件,听起来就是个单声道效果。
关于ES8388的录音设置(即ADC的相关设置),我们在上一章的介绍已经顺带都介绍了,所以本章我们就不再介绍ES8388的寄存器了,想了解读者可以参考第59章的介绍,或者参考本例程源代码和ES8388的pdf数据手册理解。
上一章我们向大家介绍了STM32H7R7的SAI放音,通过上一章的了解,我们知道:STM32H7R7 SAI的全双工通信,需要用到SAI的两个子模块(SAI_A和SAI_B),一个工作在主模式,产生FS、SCK和MCLK,一个工作在从模式,通过SD引脚接收数据。
本章我们必须向ES8388提供WS(FS),CK(SCK)和MCK(MCLK)等时钟,同时又要录音,所以只能使用全双工模式。工作在主模式的SAI子模块循环发送数据0X0000,给ES8388,以产生CK、WS和MCK等信号,工作在从模式的SAI子模块,则接收来自ES8388的ADC数据(ADCDAT),并保存到SD卡,实现录音。
本章我们将同时使用SAI的两个子模块,以实现录音功能,SAI的相关寄存器,我们在上一章已经介绍的差不多了,这里就不再进行寄存器介绍,大家可以参考《STM32H7Rx参考手册_V6(英文版).pdf》第56.6小节。
最后,我们看看要通过STM32H7R7的SAI,驱动ES8388实现WAV录音的简要步骤,如下:
1)初始化ES8388
这个过程就是前面所讲的ES8388 MIC录音配置步骤,让ES8388的ADC以及其模拟部分工作起来。
2)初始化SAI_A和SAI_B
本章要用到SAI的全双工模式,所以,SAI_A和SAI_B都需要配置,其中SAI_A配置为主模式,SAI_B设置为从模式,且与SAI_A同步。他们的其他配置(协议、时钟电平特性、slot相关参数)基本一样,只是一个是发送一个是接收,且都要使能DMA。同时,还需要设置音频采样率,不过这个只需要设置SAI_A的即可,还是通过上一章介绍的查表法设置。
3)设置发送和接收DMA
放音和录音都是采用GPDMA传输数据的,本章放音其实就是个幌子,不过也得设置GPDMA(使用GPDMA1的通道0),配置同上一章一模一样,不过不需要开启GPDMA传输完成中断。对于录音,则使用的是GPDMA1的通道1实现的GPDMA数据接收,我们需要配置GPDMA1,本章将使用双缓冲循环模式,外设和存储器都是16位宽,并开启传输完成中断(方便接收数据)。
4)编写接收通道DMA传输完成中断服务函数
为了方便接收音频数据,我们使用DMA传输完成中断,每当一个缓冲接数据满了,硬件自动切换为下一个缓冲,同时进入中断服务函数,将已满缓冲的数据写入SD卡的wav文件。过程如图60.1.1所示:

图60.1.1 DMA双缓冲接收音频数据流框图
5)创建WAV文件,并保存wav头
前面4步完成,其实就可以开始读取音频数据了,不过在录音之前,我们需要先在创建一个新的文件,并写入wav头,然后才能开始写入我们读取到的的PCM音频数据。
6)开启DMA传输,接收数据
然后,我们就只需要开启DMA传输,然后及时将SAI_B读到的数据写入到SD卡之前新建的wav文件里面,就可以实现录音了。
7)计算整个文件大小,重新保存wav头并关闭文件
在结束录音的时候,我们必须知道本次录音的大小(数据大小和整个文件大小),然后更新wav头,重新写入文件,最后因为FATFS,在文件创建之后,必须调用f_close,文件才会真正体现在文件系统里面,否则是不会写入的!所以最后还需要调用f_close,以保存文件。
60.2 硬件设计
1. 例程功能
本章实验功能简介:开机后,先初始化各外设,然后检测字库是否存在,如果检测无问题,再检测SD卡根目录是否存在RECORDER文件夹,如果不存在则创建,如果创建失败,则报错。在找到SD卡的RECORDER文件夹后,即进入录音模式(包括配置ES8388和SAI等),此时可以在耳机(或喇叭)听到采集到的音频。KEY0用于开始/暂停录音,KEY1用于保存并停止录音,KEY_UP用于播放最近一次的录音。
当我们按下KEY0的时候,可以在屏幕上看到录音文件的名字、码率以及录音时间等,然后通过KEY1可以保存该文件,同时停止录音(文件名和时间也都将清零),在完成一段录音后,我们可以通过按KEY_UP按键,来试听刚刚的录音。LED0用于提示程序正在运行,LED1用于提示是否处于暂停录音状态。
2. 硬件资源
本实验,大家需要准备1个microSD/SD卡(在里面新建一个MUSIC文件夹,并存放一些歌曲在MUSIC文件夹下)和一个耳机(非必备),分别插入SD卡接口和耳机接口,然后下载本实验就可以实现录音机的效果。实验用到的硬件资源如下:
1)LED灯
LED0 – PD14
LED1 – PC0
2)独立按键
KEY0- PE9
KEY1- PE8
KEY2- PE7
KEY_UP- PC13 (程序中的宏名:WK_UP)
3)串口1 (PB14/PB15连接在板载USB转串口芯片CH340上面)
4)正点原子2.8/3.5/4.3/7/10寸TFTLCD模块(包括MCU屏和RGB屏,都支持)
5)SD卡:通过SDMMC连接
6)NOR FLASH
7)SAI,驱动ES8388芯片
8)开发板板载的咪头或自备麦克风输入
9)喇叭或耳机
录音机实验与上一章(音乐播放器实验)用到的硬件资源基本一样,我们这里就不重复介绍了,有差异的是这次我们用到板载的咪头用于信号输入,也可以通过3.5mm的音频接口通过LINE_IN接入麦克风输入录音音源。
60.3 程序设计
60.3.1 程序解析
1. recorder驱动代码
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码,RECORDER的驱动主要包括两个文件:recorder.c和recorder.h。
音乐播放器实验中我们已经学过配置ES8388的方法,我们在recoder.c编写函数配置ES8388工作在PCM录音模式,我们编写代码如下:
/**
* @brief 进入PCM 录音模式
* @param 无
* @retval 无
*/
void recoder_enter_rec_mode(void)
{
SAI1_TX_DMACx->CCR &= ~DMA_IT_TC;
/* 关闭传输完成中断(这里不用中断送数据) (如果在这里不关闭dma就会卡在清空数据过程)*/
sai1_tx_callback = NULL;
es8388_adda_cfg(0, 1); /* 开启ADC */
es8388_input_cfg(0); /* 开启输入通道(通道1,MIC所在通道) */
es8388_mic_gain(8); /* MIC增益设置为最大 */
es8388_alc_ctrl(3, 4, 4); /* 开启立体声ALC控制,以提高录音音量 */
es8388_output_cfg(0, 0); /* 关闭通道1和2的输出 */
es8388_spkvol_set(0); /* 关闭喇叭. */
es8388_sai_cfg(0, 3); /* 飞利浦标准,16位数据长度 */
sai1_saib_init(SAI_MODEMASTER_TX, SAI_CLOCKSTROBING_RISINGEDGE,
SAI_DATASIZE_16); /* SAI1 Block B,主发送,16位数据 */
sai1_saia_init(SAI_MODESLAVE_RX, SAI_CLOCKSTROBING_RISINGEDGE,
SAI_DATASIZE_16); /* SAI1 Block A从模式接收,16位 */
sai1_samplerate_set(REC_SAMPLERATE); /* 设置采样率 */
sai1_tx_dma_init((uint8_t *)&SAI_PLAY_BUF[0], (uint8_t *)&SAI_PLAY_BUF[1],
1, 1); /* 配置TX DMA,16位 */
SAI1_TX_DMACx->CCR |= DMA_IT_TC;
sai1_rx_dma_init(p_sai_recbuf1, p_sai_recbuf2, REC_SAI_RX_DMA_BUF_SIZE / 2,
1); /* 配置RX DMA */
sai1_rx_callback = recoder_sai_dma_rx_callback;
/* 初始化回调函数指sai_rx_callback */
sai1_play_start(); /* 开始SAI数据发送(主机) */
sai1_rec_start(); /* 开始SAI数据接收(从机) */
recoder_remindmsg_show(0);
}
该函数就是用我们前面介绍的方法,激活ES8388的PCM模式,本章,我们使用的是44.1Khz采样率,16位单声道线性PCM模式,以及发送与接收DMA配置。
由于最后要把录音写入到文件,这里需要准备wav的文件头,为方便,我们定义了一个__WaveHeader结构体来定义文件头的数据字节,这个结构体包含了前面提到的wav文件的数据结构块:
typedef __PACKED_STRUCT
{
ChunkRIFF riff; /* riff块 */
ChunkFMT fmt; /* fmt块 */
//ChunkFACT fact; /* fact块 线性PCM,没有这个结构体 */
ChunkDATA data; /* data块 */
} __WaveHeader;
我们定义一个recoder_wav_init()函数方便初始化文件信息,代码如下:
void recoder_wav_init(__WaveHeader *wavhead)
{
wavhead->riff.ChunkID = 0x46464952; /* RIFF" */
wavhead->riff.ChunkSize = 0; /* 还未确定,最后需要计算 */
wavhead->riff.Format = 0x45564157; /* "WAVE" */
wavhead->fmt.ChunkID = 0x20746D66; /* "fmt " */
wavhead->fmt.ChunkSize = 16; /* 大小为16个字节 */
wavhead->fmt.AudioFormat = 0x01; /* 0x01,表示PCM; 0x00,表示IMA ADPCM */
wavhead->fmt.NumOfChannels = 2; /* 双声道 */
wavhead->fmt.SampleRate = REC_SAMPLERATE;/* 采样速率 */
wavhead->fmt.ByteRate=wavhead->fmt.SampleRate*4;
/* 字节速率=采样率*通道数*(ADC位数/8) */
wavhead->fmt.BlockAlign = 4; /* 块大小=通道数*(ADC位数/8) */
wavhead->fmt.BitsPerSample = 16; /* 16位PCM */
wavhead->data.ChunkID = 0x61746164; /* "data" */
wavhead->data.ChunkSize = 0; /* 数据大小,还需要计算 */
}
录音完成我们还要重新计算录音文件的大小写入文件头,以保证音频文件能正常被解析。我们把这些数据直接按顺序写入文件即可完成录音操作,结合文件操作和按键功能定义,我们用wav_recoder()函数实现录音过程,代码如下:
/**
* @brief WAV录音
* @param 无
* @retval 无
*/
void wav_recorder(void)
{
uint8_t res, i;
uint8_t key;
uint8_t rval = 0;
uint32_t bw;
__WaveHeader *wavhead = 0;
DIR recdir; /* 目录 */
FIL *f_rec = 0; /* 录音文件 */
uint8_t *pdatabuf; /* 数据缓存指针 */
uint8_t *pname = 0;
uint8_t timecnt = 0; /* 计时器 */
uint32_t recsec = 0; /* 录音时间 */
while (f_opendir(&recdir, "0:/RECORDER")) /* 打开录音文件夹 */
{
lcd_show_string(30, 230, 240, 16, 16, "RECORDER文件夹错误!", RED);
delay_ms(200);
lcd_fill(30, 230, 240, 246, WHITE); /* 清除显示 */
delay_ms(200);
f_mkdir("0:/RECORDER"); /* 创建该目录 */
}
/* 申请内存 */
for (i = 0; i < REC_SAI_RX_FIFO_SIZE; i++)
{
p_sai_recfifo_buf[i] = mymalloc(SRAMIN, REC_SAI_RX_DMA_BUF_SIZE);
/* SAI录音FIFO内存申请 */
if (p_sai_recfifo_buf[i] == NULL)
{
break; /* 申请失败 */
}
}
p_sai_recbuf1 = mymalloc(SRAMIN, REC_SAI_RX_DMA_BUF_SIZE);
/* SAI录音内存1申请 */
p_sai_recbuf2 = mymalloc(SRAMIN, REC_SAI_RX_DMA_BUF_SIZE);
/* SAI录音内存2申请 */
f_rec = (FIL *)mymalloc(SRAMIN, sizeof(FIL));
/* 开辟FIL字节的内存区域 */
wavhead = (__WaveHeader *)mymalloc(SRAMIN, sizeof(__WaveHeader));
/* 开辟__WaveHeader字节的内存区域 */
pname = mymalloc(SRAMIN, 30);
/* 申请30个字节内存,文件名类似"0:RECORDER/REC00001.wav" */
if (!p_sai_recbuf2 || !f_rec || !wavhead || !pname)
{
rval = 1;
}
if (rval == 0)
{
recoder_enter_rec_mode();/* 进入录音模式,此时耳机可以听到咪头采集到的音频 */
pname[0] = 0; /* pname没有任何文件名 */
while (rval == 0)
{
key = key_scan(0);
switch (key)
{
case KEY1_PRES: /* STOP&SAVE */
if (g_rec_sta & 0x80) /* 有录音 */
{
g_rec_sta = 0; /* 关闭录音 */
wavhead->riff.ChunkSize = g_wav_size + 36;
/* 整个文件的大小-8; */
wavhead->data.ChunkSize = g_wav_size; /* 数据大小 */
f_lseek(f_rec, 0);
/* 偏移到文件头. */
f_write(f_rec, (const void *)wavhead,
sizeof(__WaveHeader), &bw); /* 写入头数据 */
f_close(f_rec);
g_wav_size = 0;
}
g_rec_sta = 0;
recsec = 0;
LED1(1); /* 关闭DS1 */
lcd_fill(30, 190, lcddev.width, lcddev.height, WHITE);
/* 清除显示,清除之前显示的录音文件名 */
break;
case KEY0_PRES: /* REC/PAUSE */
if (g_rec_sta & 0x01) /* 如果是暂停,继续录音 */
{
g_rec_sta &= 0xFE; /* 取消暂停 */
}
else if (g_rec_sta & 0x80) /* 已经在录音了,暂停 */
{
g_rec_sta |= 0x01; /* 暂停 */
}
else /* 还没开始录音 */
{
recsec = 0;
recoder_new_pathname(pname); /* 得到新的名字 */
text_show_string(30, 190, lcddev.width, 16, "录制:",
16, 0, RED);
text_show_string(30 + 40, 190, lcddev.width, 16,
(char *)pname + 11, 16, 0, RED); /* 显示当前录音文件名字 */
recoder_wav_init(wavhead); /* 初始化wav数据 */
res = f_open(f_rec, (const TCHAR *)pname,
FA_CREATE_ALWAYS | FA_WRITE);
if (res) /* 文件创建失败 */
{
g_rec_sta = 0; /* 创建文件失败,不能录音 */
rval = 0xFE; /* 提示是否存在SD卡 */
}
else
{
res = f_write(f_rec, (const void *)wavhead,
sizeof(__WaveHeader), &bw); /* 写入头数据 */
recoder_msg_show(0, 0);
g_rec_sta |= 0x80; /* 开始录音 */
}
}
if (g_rec_sta & 0x01)
{
LED1(0); /* 提示正在暂停 */
}
else
{
LED1(1);
}
break;
case WKUP_PRES: /* 播放最近一段录音 */
if (g_rec_sta != 0x80) /* 没有在录音 */
{
if (pname[0]) /* 如果按键被按下,且pname不为空 */
{
text_show_string(30, 190, lcddev.width, 16, "播放:",
16, 0, RED);
text_show_string(30 + 40, 190, lcddev.width, 16,
(char *)pname + 11, 16, 0, RED);
/* 显示当播放的文件名字 */
recoder_enter_play_mode(); /* 进入播放模式 */
audio_play_song(pname); /* 播放pname */
lcd_fill(30, 190, lcddev.width, lcddev.height,
WHITE); /* 清除显示,清除之前显示的录音文件名 */
recoder_enter_rec_mode(); /* 重新进入录音模式 */
}
}
break;
}
if (recoder_sai_fifo_read(&pdatabuf))
/* 读取一次数据, 读到数据了, 写入文件 */
{
res = f_write(f_rec, pdatabuf, REC_SAI_RX_DMA_BUF_SIZE, &bw);
/* 写入文件 */
if (res)
{
printf("write error:%d\r\n", res);
}
g_wav_size += REC_SAI_RX_DMA_BUF_SIZE; /* WAV数据大小增加 */
}
else
{
delay_ms(5);
}
timecnt++;
if ((timecnt % 20) == 0)
{
LED0_TOGGLE(); /* LED0闪烁 */
}
if (recsec != (g_wav_size / wavhead->fmt.ByteRate))
/* 录音时间显示 */
{
LED1_TOGGLE(); /* LED1闪烁 */
recsec = g_wav_size / wavhead->fmt.ByteRate; /* 录音时间 */
recoder_msg_show(recsec, wavhead->fmt.SampleRate *
wavhead->fmt.NumOfChannels * wavhead->fmt.BitsPerSample);
/* 显示码率 */
}
}
}
for (i = 0; i < REC_SAI_RX_FIFO_SIZE; i++)
{
myfree(SRAMIN, p_sai_recfifo_buf[i]); /* 录音FIFO内存释放 */
}
myfree(SRAMIN, p_sai_recbuf1); /* 释放内存 */
myfree(SRAMIN, p_sai_recbuf2); /* 释放内存 */
myfree(SRAMIN, f_rec); /* 释放内存 */
myfree(SRAMIN, wavhead); /* 释放内存 */
myfree(SRAMIN, pname); /* 释放内存 */
}
2. main.c代码
由于我们把大部分功能已经在wav_recoder()中实现了,main函数进行必要的外设初始化,显示相关的数据信息后,调用该接口即可实现我们需要的录音机功能了,最后我们在main.c中实现代码如下:
int main(void)
{
sys_mpu_config(); /* 配置MPU */
sys_cache_enable(); /* 使能Cache */
HAL_Init(); /* 初始化HAL库 */
sys_stm32_clock_init(300, 6, 2); /* 配置时钟,600MHz */
delay_init(600); /* 初始化延时 */
usart_init(115200); /* 初始化串口 */
led_init(); /* 初始化LED */
key_init(); /* 初始化按键 */
hyperram_init(); /* 初始化HyperRAM */
lcd_init(); /* 初始化LCD */
my_mem_init(SRAMIN); /* 初始化AXI-SRAM1~4内存池 */
my_mem_init(SRAMEX); /* 初始化XSPI2 HyperRAM内存池 */
my_mem_init(SRAM12); /* 初始化AHB-SRAM1~2内存池 */
my_mem_init(SRAMDTCM); /* 初始化DTCM内存池 */
my_mem_init(SRAMITCM); /* 初始化ITCM内存池 */
exfuns_init(); /* 为exfuns申请内存 */
f_mount(fs[0], "0:", 1); /* 挂载SD卡 */
f_mount(fs[1], "1:", 1); /* 挂载NOR Flash */
f_mount(fs[2], "2:", 1); /* 挂载NAND Flash */
/* 初始化SD卡 */
while (sd_init() != 0)
{
lcd_show_string(30, 30, 200, 16, 16, "SD Card Error!", RED);
delay_ms(500);
lcd_show_string(30, 30, 200, 16, 16, "Please Check! ", RED);
delay_ms(500);
}
lcd_fill(30, 30, 30 + 200, 30 + 16, WHITE);
/* 检查字库 */
while (fonts_init() != 0)
{
lcd_show_string(30, 30, 200, 16, 16, "Font Error! ", RED);
delay_ms(500);
lcd_show_string(30, 30, 200, 16, 16, "Please Check!", RED);
delay_ms(500);
}
lcd_fill(30, 30, 30 + 200, 30 + 16, WHITE);
text_show_string(30, 50, 200, 16, "正点原子STM32开发板",16,0, RED);
text_show_string(30, 70, 200, 16, "录音机实验", 16, 0, RED);
text_show_string(30, 90, 200, 16, "正点原子@ALIENTEK", 16, 0, RED);
es8388_init(); /* ES8388初始化 */
es8388_hpvol_set(25); /* 设置耳机音量 */
es8388_spkvol_set(25); /* 设置喇叭音量 */
while (1)
{
wav_recorder();
}
}
可以看到main函数与音乐播放器实验十分类似,封装好了APP,main函数会精简很多。
60.4 下载验证
在代码编译成功之后,我们下载代码到正点原子STM32H7R7开发板上,先初始化各外设,然后检测字库是否存在,如果检测无问题,再检测SD卡根目录是否存在RECORDER文件夹,如果不存在则创建,如果创建失败,则报错。在找到SD卡的RECORDER文件夹后,即进入录音模式(包括配置ES8388和SAI等),此时可以在耳机(或喇叭)听到采集到的音频。KEY0用于开始/暂停录音,KEY1用于保存并停止录音,KEY_UP用于播放最近一次的录音。

图60.4.1 录音机实验界面
此时,我们按下KEY0就开始录音了,此时看到屏幕显示录音文件的名字以及录音时长,如图60.4.2所示:

图60.4.2 录音进行中
在录音的时候按下KEY0则执行暂停/继续录音的切换,通过LED0指示录音暂停。通过按下KEY1,可以停止当前录音,并保存录音文件。在完成一次录音文件保存之后,我们可以通过按KEY_UP按键,来实现播放这个录音文件(即播放最近一次的录音文件),实现试听。
我们可以把录音完成的wav文件放到电脑上,可以通过一些播放软件播放并查看详细的音频编码信息,本例程使用的是KMPlayer播放,查看到的信息如图60.4.3所示:

图60.4.3 录音文件属性
这和我们程序设计时的效果一样,通过电脑端的播放器可以直接播放我们所录的音频。经实测,效果还是非常不错的。