1)实验平台:正点原子stm32f103战舰开发板V4
2)平台购买地址:https://detail.tmall.com/item.htm?id=609294757420
3)全套实验源码+手册+视频下载地址: http://www.openedv.com/thread-340252-1-1.html#
上一章,我们实现了一个简单的音乐播放器,本章我们将在上一章的基础上,继续用VS1053实现一个简单的录音机,录制WAV格式的录音。
55.1 WAV格式简介
55.2 硬件设计
55.3 软件设计
55.4 下载验证
WAV即WAVE文件,WAV是计算机领域最常用的数字化声音文件格式之一,它是微软专门为Windows系统定义的波形文件格式(WaveformAudio),由于其扩展名为"*.wav"。它符合RIFF(Resource Interchange File Format)文件规范,用于保存Windows平台的音频信息资源,被Windows平台及其应用程序所广泛支持,该格式也支持MSADPCM,CCITTA LAW等多种压缩运算法,支持多种音频数字,取样频率和声道,标准格式化的WAV文件和CD格式一样,也是44.1K的取样频率,16位量化数字,因此在声音文件质量和CD相差无几!
要想实现WAV录音得先了解一下WAV文件的格式。WAVE文件的数据采用“Chunk”来存储。因此,如果想要在WAVE文件中补充一些新的信息,只需要在新Chunk中添加信息,而不需要改变整个文件。所以可以把WAVE文件看成是很多不同Chunk的集合。每个Chunk由块标识符、数据大小和数据三部分组成,如图55.1.1所示:
图55.1.1 Chunk结构示意图
其中块标识符由4个ASCII码构成,数据大小则标出紧跟其后的数据的长度(单位为字节),注意这个长度不包含块标识符和数据大小的长度,即不包含最前面的8个字节。所以实际Chunk的大小为数据大小加8。
对于一个基本的WAVE文件而言,以下三种Chunk是必不可少的:文件中第一个Chunk是RIFF Chunk,然后是FMT Chunk,最后是Data Chunk。对于其他的Chunk,顺序没有严格的限制。使用WAVE文件的应用程序必须具有读取以上三种chunk信息的能力,如果程序想要复制WAVE文件,必须拷贝文件中所有的chunk。本章,我们主要讨论PCM,因为这个最简单,它只包含3个Chunk,我们看一下它的文件构成,如图55.1.2。
图55.1.2 PCM格式的wav文件构成
可以看到,不同的Chunk有不同的长度,编码文件时,按照Chunk的字节和位序排列好之后写入文件头,加上wav的后缀,就可以生成一个能被正确解析的wav文件了,对于PCM结构,我们只需要把获取到的音频数据填充到Data Chunk中即可。我们将利用VS1053实现16位,8Khz采样率的单声道WAV录音(PCM格式)。
首先,我们来看看RIFF块(RIFF WAVE Chunk),该块以“RIFF”作为标示,紧跟wav文件大小(该大小是wav文件的总大小-8),然后数据段为“WAVE”,表示是wav文件。RIFF块的Chunk结构如下:
typedef __PACKED_STRUCT
{
uint32_t ChunkID; /* chunk id;这里固定为"RIFF",即0X46464952 */
uint32_t ChunkSize ; /* 集合大小;文件总大小-8 */
uint32_t Format; /* 格式;WAVE,即0X45564157 */
} ChunkRIFF ;
接着,我们看看Format块(FormatChunk),该块以“fmt”作为标示(注意有个空格!),一般情况下,该段的大小为16个字节,但是有些软件生成的wav格式,该部分可能有18个字节,含有2个字节的附加信息。Format块的Chunk结构如下:
typedef __PACKED_STRUCT
{
uint32_t ChunkID; /* chunk id;这里固定为"fmt ",即0X20746D66 */
uint32_t ChunkSize ; /* 子集合大小(不包括ID和Size);这里为:20 */
uint16_t AudioFormat; /* 音频格式;0X10,表示线性PCM;0X11表示IMA ADPCM */
uint16_t NumOfChannels; /* 通道数量;1,表示单声道;2,表示双声道; */
uint32_t SampleRate; /* 采样率;0X1F40,表示8Khz */
uint32_t ByteRate; /* 字节速率 */
uint16_t BlockAlign; /* 块对齐(字节 */
uint16_t BitsPerSample; /* 单个采样数据大小;4位ADPCM,设置为4 */
//uint16_t ByteExtraData; /* 附加的数据字节;2个; 线性PCM,没有这个参数 */
//uint16_t ExtraData; /* 附加的数据,单个采样数据块大小;0X1F9:505字节 线性PCM,没有这个参数 */
} ChunkFMT;
接下来,我们再看看Fact块(Fact Chunk),该块为可选块,以“fact”作为标示,不是每个 WAV 文件都有,在非 PCM 格式的文件中,一般会在Format结构后面加入一个Fact块,该块Chunk结构如下:
typedef __PACKED_STRUCT
{
uint32_t ChunkID; /* chunk id;这里固定为"fact",即0X74636166 */
uint32_t ChunkSize ; /* 子集合大小(不包括ID和Size);这里为:4 */
uint32_t NumOfSamples; /* 采样的数量 */
} ChunkFACT;
DataFactSize是这个Chunk中最重要的数据,如果这是某种压缩格式的声音文件,那么从这里就可以知道他解压缩后的大小。对于解压时的计算会有很大的好处!不过本章我们使用的是PCM格式,所以不存在这个块。
最后,我们来看看数据块(Data Chunk),该块是真正保存wav数据的地方,以“data”'作为该Chunk的标示。然后是数据的大小。紧接着就是wav数据。根据Format Chunk中的声道数以及采样bit数,wav数据的bit位置可以分成如表55.1.1所示的几种形式:
单声道 取样1 取样2 取样3 取样4
8位量化 声道0 声道0 声道0 声道0
双声道 取样1 取样2
8位量化 声道0(左) 声道1(右) 声道0(左) 声道1(右)
单声道 取样1 取样2
16位量化 声道0(低字节) 声道0(高字节) 声道0(低字节) 声道0(高字节)
双声道 取样1
16位量化 声道0
(左,低字节) 声道0
(左,高字节) 声道1
(右,低字节) 声道1
(右,高字节)
表55.1.1 WAVE文件数据采样格式
本实验,我们采用的是16位,单声道,所以每个取样为2个字节,低字节在前,高字节在后。数据块的Chunk结构如下:
typedef __PACKED_STRUCT
{
uint32_t ChunkID; /* chunk id;这里固定为"data",即0X61746164 */
uint32_t ChunkSize ; /* 子集合大小(不包括ID和Size);文件大小-60 */
} ChunkDATA;
通过以上学习,我们对WAVE文件结构有了个大概了解。如果对WAV的格式还存在疑问,请参考我们“A盘6,软件资料8,WAV文件格式说明”的内容。
接下来,我们看看如何使用VS1053实现WAV(PCM格式)录音。
表55.1.2 VS1053激活PCM录音相关寄存器
通过设置SCI_MODE寄存器的2、12、14位,来激活PCM录音,SCI_MODE的各位描述见表48.1.4(也可以参考VS1053的数据手册)。SCI_AICTRL0寄存器用于设置采样率,我们本章用的是8K的采样率,所以设置这个值为8000即可。SCI_AICTRL1寄存器用于设置AGC,1024相当于数字增加1,这里建议大家设置AGC在4(4*1024)左右比较合适。SCI_AICTRL2用于设置自动AGC的时候的最大值,当设置为0的时候表示最大64(65536),这个大家按自己的需要设置即可。最后,SCI_AICTRL3,我们本章用到的是咪头线性PCM单声道录音,所以设置该寄存器值为6。
通过这几个寄存器的设置,我们就激活VS1053的PCM录音了。不过,VS1053的PCM录音有一个小BUG,必须通过加载patch才能解决,如果不加载patch,那么VS1053是不输出PCM数据的,VLSI提供了我们这个patch,只需要通过软件加载即可。
2. 读取PCM数据
在激活了PCM录音之后,SCI_HDAT0和SCI_HDAT1有了新的功能。VS1053的PCM采样缓冲区由1024个16位数据组成,如果SCI_HDAT1大于0,则说明可以从SCI_HDAT0读取至少SCI_HDAT1个16位数据,如果数据没有被及时读取,那么将溢出,并返回空的状态。
注意,如果SCI_HDAT1≥896,最好等待缓冲区溢出,以免数据混叠。所以,对我们来说,只需要判断SCI_HDAT1的值非零,然后从SCI_HDAT0读取对应长度的数据,即完成一次数据读取,以此循环,即可实现PCM数据的持续采集。
最后,我们看看本章实现WAV录音需要经过哪些步骤:
图55.3.1.1 录音机实验程序流程图
我们通过板载的按键控制录音的开始和停止,检测到录音开始后在录音目录下随机生成一个wav后缀的文件名并写入文件头信息,通过VS1053的录音模式不断采集声音信息并定入文件,录音结束后,我们保存文件并修改对应的文件头信息以便文件能被解码。最后,我们设计了用TPAD触摸按键来播放上一次录音的文件,以便查看录音效果。
55.3.2 程序解析
void recoder_enter_rec_mode(uint16_t agc)
{
/* 如果是IMA ADPCM, 采样率计算公式如下:
* 采样率 Fs = CLKI / 256 * d;
* CLKI , 表示内部时钟频率(倍频后的频率)
* d , 表示SCI_AICTRL0的分频值, 注意: 如果 d = 0, 则表示12分频
* 假设d = 0, 并2倍频, 外部晶振为12.288M. 那么Fs = (2 * 12288000)/256*12 = 8Khz
* 如果是线性PCM, 采样率直接就写采样值
*/
vs10xx_write_cmd(SPI_BASS, 0x0000);
vs10xx_write_cmd(SPI_AICTRL0, 8000); /* 设置采样率, 设置为8Khz */
vs10xx_write_cmd(SPI_AICTRL1, agc); /* 设置增益 */
vs10xx_write_cmd(SPI_AICTRL2, 0); /* 设置增益最大值,0,代表最大值65536=64X */
vs10xx_write_cmd(SPI_AICTRL3, 6); /* 左通道(MIC单声道输入), 线性PCM */
/* 设置VS10XX的时钟,MULT:2倍频;ADD:不允许;CLK:12.288Mhz */
vs10xx_write_cmd(SPI_CLOCKF, 0X2000);
vs10xx_write_cmd(SPI_MODE, 0x1804); /* MIC, 录音激活 */
delay_ms(5); /* 等待至少1.35ms */
vs10xx_load_patch((uint16_t *)wav_plugin, 40);/* VS1053的WAV录音需要patch */
}
该函数就是用我们前面介绍的方法,激活VS1053的PCM模式,本章,我们使用的是8Khz采样率,16位单声道线性PCM模式,AGC通过函数参数设置。最后加载patch(用于修复VS1053录音BUG)。
由于最后要把录音写入到文件,这里需要准备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.Format = 0X45564157; /* "WAVE" */
wavhead->fmt.ChunkID = 0X20746D66; /* "fmt " */
wavhead->fmt.ChunkSize = 16; /* 大小为16个字节 */
wavhead->fmt.AudioFormat = 1; /* 1, 表示PCM; 0, 表示IMA ADPCM; */
wavhead->fmt.NumOfChannels = 1; /* 单声道 */
wavhead->fmt.SampleRate = 8000; /* 8Khz采样率 采样速率 */
/* 字节速率, 等于采样率*2(单声道, 16位) */
wavhead->fmt.ByteRate = wavhead->fmt.SampleRate * 2;
wavhead->fmt.BlockAlign = 2; /* 块大小,2个字节为一个块 */
wavhead->fmt.BitsPerSample = 16; /* 16位PCM */
wavhead->data.ChunkID = 0X61746164; /* "data" */
wavhead->data.ChunkSize = 0; /* 数据大小, 还需要计算 */
}
录音完成我们还要重新计算录音文件的大小写入文件头,以保证音频文件能正常被解析。录音时通过读取SCI_HDAT0寄存器中的16位数据获取到录音的ADC值,我们把这些数据直接按顺序写入文件即可完成录音操作,结合文件操作和按键功能定义,我们用recoder_play()函数实现录音过程,代码如下:
/**
* @brief 录音机
* @note 所有录音文件, 均保存在 SD卡 RECORDER 文件夹内
* @param 无
* @retval 0, 成功; 0XFF, 播放出错;
*/
uint8_t recoder_play(void)
{
uint8_t res;
uint8_t key;
uint8_t rval = 0;
__WaveHeader *wavhead = 0;
uint32_t sectorsize = 0;
FIL *f_rec = 0; /* 文件 */
DIR recdir; /* 目录 */
UINT bw; /* 写入长度 */
uint8_t *recbuf; /* 数据内存 */
uint16_t w;
uint16_t idx = 0;
char *pname = 0;
uint8_t timecnt = 0; /* 计时器 */
uint32_t recsec = 0; /* 录音时间 */
uint8_t recagc = 4; /* 默认增益为4 */
uint8_t rec_sta = 0; /* 录音状态
* [7] : 0, 没有录音; 1, 有录音;
* [6:1]: 保留
* [0] : 0, 正在录音; 1, 暂停录音
*/
while (f_opendir(&recdir, "0:/RECORDER")) /* 打开录音文件夹 */
{
text_show_string(30, 230, 240, 16, "RECORDER文件夹错误!", 16, 0, RED);
delay_ms(200);
lcd_fill(30, 230, 240, 246, WHITE); /* 清除显示 */
delay_ms(200);
f_mkdir("0:/RECORDER"); /* 创建该目录 */
}
pname = mymalloc(SRAMIN, 30);/*申请30个字节内存,类似"0:RECORDER/REC00001.wav"*/
f_rec = (FIL *)mymalloc(SRAMIN, sizeof(FIL)); /* 开辟FIL字节的内存区域 */
/* 开辟__WaveHeader字节的内存区域 */
wavhead = (__WaveHeader *)mymalloc(SRAMIN, sizeof(__WaveHeader));
recbuf = mymalloc(SRAMIN, 512);
if (pname == NULL || f_rec == NULL ||wavhead == NULL || recbuf == NULL)
{
rval = 1; /* 申请失败 */
}
if (rval == 0) /* 内存申请OK */
{
recoder_enter_rec_mode(1024 * recagc);
while (vs10xx_read_reg(SPI_HDAT1) >> 8); /* 等到buf 较为空闲再开始 */
recoder_show_time(recsec); /* 显示时间 */
recoder_show_agc(recagc); /* 显示agc */
pname[0] = 0; /* pname没有任何文件名 */
while (rval == 0)
{
key = key_scan(0);
switch (key)
{
case KEY2_PRES: /* STOP&SAVE */
if (rec_sta & 0X80) /* 有录音 */
{
/* 整个文件的大小-8 */
wavhead->riff.ChunkSize = sectorsize * 512 + 36;
wavhead->data.ChunkSize = sectorsize * 512; /* 数据大小 */
f_lseek(f_rec, 0); /* 偏移到文件头 */
f_write(f_rec, (const void *)wavhead,
sizeof(__WaveHeader), &bw); /* 写入头数据 */
f_close(f_rec);
sectorsize = 0;
}
rec_sta = 0;
recsec = 0;
LED1(1); /* 关闭DS1 */
/* 清除显示,清除之前显示的录音文件名 */
lcd_fill(30, 230, 240, 246, WHITE);
recoder_show_time(recsec); /* 显示时间 */
break;
case KEY0_PRES: /* REC/PAUSE */
if (rec_sta & 0X01) /* 原来是暂停,继续录音 */
{
rec_sta &= 0XFE; /* 取消暂停 */
}
else if (rec_sta & 0X80) /* 已经在录音了,暂停 */
{
rec_sta |= 0X01; /* 暂停 */
}
else /* 还没开始录音 */
{
rec_sta |= 0X80; /* 开始录音 */
recoder_new_pathname((uint8_t *)pname); /* 得到新的名字 */
/* 显示当前录音文件名字 */
text_show_string(30, 230, 240, 16, pname + 11, 16, 0, RED);
recoder_wav_init(wavhead); /* 初始化wav数据 */
res = f_open(f_rec, pname, FA_CREATE_ALWAYS | FA_WRITE);
if (res) /* 文件创建失败 */
{
rec_sta = 0; /* 创建文件失败,不能录音 */
rval = 0XFE; /* 提示是否存在SD卡 */
}
else
{
res = f_write(f_rec, (const void *)wavhead,
sizeof(__WaveHeader), &bw); /* 写入头数据 */
}
}
LED1(!(rec_sta & 0X01)); /* 提示录音状态 */
break;
case WKUP_PRES: /* AGC+ */
case KEY1_PRES: /* AGC- */
if (key == WKUP_PRES)
{
recagc++;
}
else if (recagc)
{
recagc--;
}
/* 范围限定为0~15.0, 自动AGC; 其他,AGC倍数; */
if (recagc > 15) recagc = 15;
recoder_show_agc(recagc);
/* 设置增益,0,自动增益.1024相当于1倍,512相当于0.5倍 */
vs10xx_write_cmd(SPI_AICTRL1, 1024 * recagc);
break;
}
/* 读取数据 */
if (rec_sta == 0X80) /* 已经在录音了 */
{
w = vs10xx_read_reg(SPI_HDAT1);
if ((w >= 256) && (w < 896))
{
idx = 0;
while (idx < 512) /* 一次读取512字节 */
{
w = vs10xx_read_reg(SPI_HDAT0);
recbuf[idx++] = w & 0XFF;
recbuf[idx++] = w >> 8;
}
res = f_write(f_rec, recbuf, 512, &bw); /* 写入文件 */
if (res)
{
printf("err:%d\r\n", res);
printf("bw:%d\r\n", bw);
break; /* 写入出错 */
}
sectorsize++; /* 扇区数增加1,约为32ms */
}
}
else /* 没有开始录音,则检测TPAD按键 */
{
if (tpad_scan(0) && pname[0]) /* 如果触摸按键被按下,且pname不为空 */
{
text_show_string(30, 230, 240, 16, "播放:", 16, 0, RED);
/* 显示当播放的文件名字 */
text_show_string(30 + 40, 230, 240, 16, pname + 11, 16, 0, RED);
rec_play_wav((uint8_t *)pname); /* 播放pname */
lcd_fill(30,230,240,246, WHITE);/*清除显示,清除之前显示的录音文件名 */
recoder_enter_rec_mode(1024 * recagc); /* 重新进入录音模式 */
while (vs10xx_read_reg(SPI_HDAT1) >> 8);/*等到buf较为空闲再开始*/
recoder_show_time(recsec); /* 显示时间 */
recoder_show_agc(recagc); /* 显示agc */
}
delay_ms(5);
timecnt++;
if ((timecnt % 20) == 0)LED0_TOGGLE(); /* DS0闪烁 */
}
if (recsec != (sectorsize * 4 / 125)) /* 录音时间显示 */
{
LED0_TOGGLE(); /* DS0闪烁 */
recsec = sectorsize * 4 / 125;
recoder_show_time(recsec); /* 显示时间 */
}
}
}
myfree(SRAMIN, wavhead);
myfree(SRAMIN, recbuf);
myfree(SRAMIN, f_rec);
myfree(SRAMIN, pname);
return rval;
}
int main(void)
{
HAL_Init(); /* 初始化HAL库 */
sys_stm32_clock_init(RCC_PLL_MUL9); /* 设置时钟, 72Mhz */
delay_init(72); /* 延时初始化 */
usart_init(115200); /* 串口初始化为115200 */
usmart_dev.init(72); /* 初始化USMART */
led_init(); /* 初始化LED */
lcd_init(); /* 初始化LCD */
key_init(); /* 初始化按键 */
sram_init(); /* SRAM初始化 */
norflash_init(); /* 初始化NORFLASH */
tpad_init(6); /* 初始化TPAD */
vs10xx_init(); /* VS10XX初始化 */
my_mem_init(SRAMIN); /* 初始化内部SRAM内存池 */
my_mem_init(SRAMEX); /* 初始化外部SRAM内存池 */
exfuns_init(); /* 为fatfs相关变量申请内存 */
f_mount(fs[0], "0:", 1); /* 挂载SD卡 */
f_mount(fs[1], "1:", 1); /* 挂载FLASH */
while (fonts_init()) /* 检查字库 */
{
lcd_show_string(30, 50, 200, 16, 16, "Font Error!", RED);
delay_ms(200);
lcd_fill(30, 50, 240, 66, WHITE); /* 清除显示 */
delay_ms(200);
}
text_show_string(30, 50, 200, 16, "正点原子STM32开发板", 16, 0, RED);
text_show_string(30, 70, 200, 16, "WAV录音机 实验", 16, 0, RED);
text_show_string(30, 110, 200, 16, "KEY0:REC/PAUSE", 16, 0, RED);
text_show_string(30, 130, 200, 16, "KEY2:STOP&SAVE", 16, 0, RED);
text_show_string(30, 150, 200, 16, "KEY_UP:AGC+ KEY1:AGC-", 16, 0, RED);
text_show_string(30, 170, 200, 16, "TPAD:Play The File", 16, 0, RED);
while (1)
{
LED1(0);
text_show_string(30, 190, 200, 16, "存储器测试...", 16, 0, RED);
printf("Ram Test:0X%04X\r\n", vs10xx_ram_test()); /* 打印RAM测试结果 */
text_show_string(30, 190, 200, 16, "正弦波测试...", 16, 0, RED);
vs10xx_sine_test();
text_show_string(30, 190, 200, 16, "<>" , 16, 0, RED);
LED1(1);
recoder_play();
}
}
可以看到main函数与音乐播放器实验十分类似,封装好了APP,main函数会精简很多。
55.4 下载验证
在代码编译成功之后,我们下载代码到正点原子战舰STM32开发板上,程序先检测字库,然后对VS1053进行RAM测试和正弦测试,之后检测SD卡的RECORDER文件夹,一切顺利通过之后,激活VS1053的PCM录音模式,得到,如图55.4.1所示
图55.4.1 录音机实验界面
此时,我们按下KEY0就开始录音了,此时看到屏幕显示录音文件的名字以及录音时长,如图55.4.2所示:
图55.4.2 录音进行中
在录音的时候按下KEY0则执行暂停/继续录音的切换,通过DS1指示录音暂停,按KEY_UP和KEY1可以调节AGC,AGC越大,越灵敏,不过不建议设置太大,因为这可能导致失真。通过按下KEY2,可以停止当前录音,并保存录音文件。在完成一次录音文件保存之后,我们可以通过按TPAD按键,来实现播放这个录音文件(即播放最近一次的录音文件),实现试听。
我们可以把录音完成的wav文件放到电脑上,可以通过一些播放软件播放并查看详细的音频编码信息,本例程使用的是KMPlayer播放,查看到的信息如图55.4.3所示:
图55.4.3 录音文件属性
这和我们程序设计时的效果一样,通过电脑端的播放器可以直接播放我们所录的音频。经实测,效果还是非常不错的。