目录
- AIR32F103(一) 合宙AIR32F103CBT6开发板上手报告
- AIR32F103(二) Linux环境和LibOpenCM3项目模板
- AIR32F103(三) Linux环境基于标准外设库的项目模板
- AIR32F103(四) 27倍频216MHz,CoreMark跑分测试
- AIR32F103(五) FreeRTOSv202112核心库的集成和示例代码
- AIR32F103(六) ADC,I2S,DMA和ADPCM实现的录音播放功能
- AIR32F103(七) AIR32F103CBT6/CCT6启用96K内存
- AIR32F103(八) 集成Helix MP3解码库播放MP3
- AIR32F103(九) CAN总线的通信和ID过滤机制及实例
- AIR32F103(十) 在无系统环境和FreeRTOS环境集成LVGL
- AIR32F103(十一) 在AIR32F103上移植微雪墨水屏驱动
- AIR32F103(十二) 搭载 AIR32F103CBT6 的Bluepill核心板
关于
使用AIR32的ADC, I2S 和 DMA 实现简单的语音录音和播放功能, 以及使用 ADPCM 编码提升录音时长. 使用的MCU型号为 AIR32F103CCT6. 如果用CBT6, 对应的音频数据数组大小需要相应减小.
音频录音和播放
工作方式
加电后开始录音, 录音结束后循环播放
- 录音: 麦克风模块 -> ADC采样(12bit, 8K, 11K 或 16K) -> 存储在内存
- 播放: I2S -> I2S外设(MAX98357A / PT8211) -> 喇叭
对中间每个环节的说明
存储
首先是存储, MCU的内存有限, 如果不借助AT24C, MX25L这类外部存储, 只用内存存储的数据是有限的, AIR32F103CCT6 带 64K Byte内存, 如果按原始采样值存储, 录音时长为
- 16bit
- 8K: 128kbps, 约4秒
- 11K: 176kbps, 约3秒
- 16K: 256kbps, 约2秒
- 8bit
- 8K: 64kbps, 约8秒
- 11K: 88kbps, 约6秒
- 16K: 128kbps, 约4秒
采样
使用AIR32的ADC, 配合定时器实现精确的每秒8K, 11K和16K采样. AIR32的ADC分辨率和STM32F103一样都是固定的12bit(STM32F4之后才可以用寄存器调节分辨率)
- 如果使用ADC的中断, 可以向高位偏移做成16bit, 也可以去掉低位做成8bit
- 如果使用DMA, 因为AIR32不能像STM32那样, 在4字节地址上偏移一个字节取值, 所以只能按16bit(halfword)传值
音频采集设备如果直接用驻极体话筒, 采样的信号很弱(不是没有, 但是非常小), 需要加一个三极管做放大. 也可以买成品的 MAX9814 模块. 两者的效果区别不大, 但是在调试阶段, 建议用 MAX9814, 因为不用担心信号是否过饱和和失真问题, 在调通之后, 再换回低成本的驻极体话筒和三极管.
驻极体话筒放大的电路和元件参数可以参考这一篇 https://www.cnblogs.com/milton/p/15315783.html
播放
播放可以使用PWM转DAC, 也可以直接用I2S.
- 如果使用PWM, 因为PWM本身是方波, 会产生大量的谐振噪音, 只有将PWM频率设置到16KHz以上才能明显降低噪音(因为谐振频率超出人耳的听觉范围了), 用8KHz时的噪音非常明显.
- 因为AIR32F103全系列都支持I2S(数据手册上写只有RPT7才有, 实际上CBT6和CCT6也有), 所以直接用I2S输出是最简单的. 这时候需要一个能接收I2S输出并转为音频的模块.
I2S模块可以用 MAX98357A 模块, 自带I2S解码和放大可以直连喇叭, 也可以买PT8211/TM8211/GH8211, 0.3元一片非常便宜还是双声道, 缺点是不带功放, 如果直连喇叭得贴着耳朵才能听到, 可以再加一个LM386或者PAM8403做放大, 都非常便宜.
实现
硬件
- AIR32F103CCT6
- MAX9814
- PT8211
- 8欧小喇叭
接线
* AIR32F103 MAX98357A / PT8211 * PB13(SPI1_SCK/I2S_CK) -> BCLK, BCK * PB15(SPI1_MOSI/I2S_SD) -> DIN * PB12(SPI1_NSS/I2S_WS) -> LRC, WS * GND -> GND * VIN -> 3.3V * + -> speaker * - -> speaker * * AIR32F103 MAX9814 * PA2 -> Out * 3.3V -> VDD * GND -> GND * GND -> A/R * GAIN -> float:60dB, gnd:50dB, 3.3v:40dB
代码
完整的示例代码
- GitHub: https://github.com/IOsetting/air32f103-template/tree/master/Examples/NonFreeRTOS/I2S/Audio_Recorder
- Gitee: https://gitee.com/iosetting/air32f103-template/tree/master/Examples/NonFreeRTOS/I2S/Audio_Recorder
定义了全局变量
// 定义不同的AUDIO_FREQ值, 可以切换不同的采样频率, 8K, 11K, 16K, 越高的采样频率, 音质越好, 录音时长越短 #define AUDIO_FREQ 8000 //#define AUDIO_FREQ 11000 //#define AUDIO_FREQ 16000 // 定义存储的音频数据大小, CCT6用的是30000, CBT6 或 RPT6 可以相应的减小或增大 #define BUFF_SIZE 30000 // 音频数据数组, 同时用于DMA的接收地址 uint16_t dma_buf[BUFF_SIZE]; // I2S传输时, 用于记录传输的位置 uint32_t index; // I2S传输时, 用于区分左右声道 __IO uint8_t lr = 0;
初始化GPIO, PA2是采样输入, PB12, PB13, PB15 用于I2S传输, PC13 是板载的LED, 用于指示录音开始和结束. 如果使用的不是Bluepill而是合宙的开发板, 可以修改为开发板对应的LED GPIO.
void GPIO_Configuration(void) { GPIO_InitTypeDef GPIO_InitStructure; // PA2 as analog input GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); // PB12,PB13,PB15 as I2S AF output GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_15; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOB, &GPIO_InitStructure); // PC13 as GPIO output GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOC, &GPIO_InitStructure); }
初始化ADC, 设置为外部触发模式, 这里使用TIM3的Update中断作为触发源, 初始化之后ADC并不会立即开始转换, 而是在TIM3的每次Update中断时进行转换. 所以如果要停止ADC, 需要先停掉TIM3
void ADC_Configuration(void) { ADC_InitTypeDef ADC_InitStructure; // Reset ADC1 ADC_DeInit(ADC1); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // 设置 TIM3 为外置触发源 ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; // 结果右对齐 ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; // 只使用一个通道 ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); // PA2对应的channel是 ADC_Channel_2 ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 1, ADC_SampleTime_239Cycles5); // 启用ADC1的外部触发源 ADC_ExternalTrigConvCmd(ADC1, ENABLE); // 在 ADC1 上启用 DMA ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); // 校准 ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); }
初始化DMA, 用 ADC1->DR 作为外设地址, dma_buf作为内存地址, 内存地址递增, 数据大小为16bit, 循环填充. 同时打开DMA的填充完成中断 DMA_IT_TC
//调用 DMA_Configuration(DMA1_Channel1, (uint32_t)&ADC1->DR, (uint32_t)dma_buf, BUFF_SIZE); // 函数实现 void DMA_Configuration(DMA_Channel_TypeDef *DMA_CHx, uint32_t ppadr, uint32_t memadr, uint16_t bufsize) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA_CHx); DMA_InitStructure.DMA_PeripheralBaseAddr = ppadr; DMA_InitStructure.DMA_MemoryBaseAddr = memadr; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = bufsize; // Addresss increase - peripheral:no, memory:yes DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // Data unit size: 16bit DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; // Memory to memory: no DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA_CHx, &DMA_InitStructure); // Enable 'Transfer complete' interrupt DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); // Enable DMA DMA_Cmd(DMA_CHx, ENABLE); }
打开外设的中断控制, DMA用于转换结束, SPI2的中断用于每次的数据发送
void NVIC_Configuration(void) { // DMA1 interrupts NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = DMA1_Channel1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 6; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // SPI2 interrupts NVIC_InitStructure.NVIC_IRQChannel = SPI2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 6; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }
初始化定时器TIM3, 根据MCU频率72MHz, 计算得到分别在8K, 11K, 16K时的定时器周期和预分频系数. 启用计时器的Update中断, 但是不启动定时器, 因为启动后就会产生中断, 就会触发ADC转换. 需要将计时器的启动放到main()中.
void TIM_Configuration(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 9 - 1; #if AUDIO_FREQ == 8000 // Period = 72,000,000 / 8,000 = 1000 * 9 TIM_TimeBaseStructure.TIM_Prescaler = 1000 - 1; #elif AUDIO_FREQ == 11000 // Period = 72,000,000 / 11,000 = 727 * 9 TIM_TimeBaseStructure.TIM_Prescaler = 727 - 1; #else // Period = 72,000,000 / 16,000 = 500 * 9 TIM_TimeBaseStructure.TIM_Prescaler = 500 - 1; #endif TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); // Enable TIM3 'TIM update' trigger for adc TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update); // Timer will be started in main() }
初始化I2S, 如果使用的是PT8211, 需要将 I2S_Standard 设置为 I2S_Standard_LSB. 否则双声道传数据时工作不正常
void IIS_Configuration(void) { I2S_InitTypeDef I2S_InitStructure; SPI_I2S_DeInit(SPI2); I2S_InitStructure.I2S_Mode = I2S_Mode_MasterTx; // PT8211:LSB, MAX98357A:Phillips I2S_InitStructure.I2S_Standard = I2S_Standard_Phillips; // 16-bit data resolution I2S_InitStructure.I2S_DataFormat = I2S_DataFormat_16b; #if AUDIO_FREQ == 8000 // 8K sampling rate I2S_InitStructure.I2S_AudioFreq = I2S_AudioFreq_8k; #elif AUDIO_FREQ == 11000 // 11K sampling rate I2S_InitStructure.I2S_AudioFreq = I2S_AudioFreq_11k; #else // 16K sampling rate I2S_InitStructure.I2S_AudioFreq = I2S_AudioFreq_16k; #endif I2S_InitStructure.I2S_CPOL = I2S_CPOL_Low; I2S_InitStructure.I2S_MCLKOutput = I2S_MCLKOutput_Disable; I2S_Init(SPI2, &I2S_InitStructure); I2S_Cmd(SPI2, ENABLE); }
中断处理
- DMA中断: DMA中断时表示内存数组已经装满了, 此时要停掉TIM3和ADC1, 并关掉PC13 LED指示录音结束
void DMA1_Channel1_IRQHandler(void) { // DMA1 Channel1 Transfer Complete interrupt if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_GL1); // Stop ADC(by stopping TIM3) TIM_Cmd(TIM3, DISABLE); ADC_Cmd(ADC1, DISABLE); GPIO_SetBits(GPIOC, GPIO_Pin_13); } }
- SPI2(I2S)中断, 用于每个I2S数据的传输, 因为传输时左右声道的数据是交替传输的, 所以这里需要用一个全局变量切换当前的声道. 因为录音是单声道, 所以传输时对应两个声道, 每个值会被传输两遍. 到达最后一个值后, 会停掉I2S.
void SPI2_IRQHandler(void) { // If TX Empty flag is set if (SPI_I2S_GetITStatus(SPI2, SPI_I2S_IT_TXE) == SET) { // Put data to both channels if (lr == 0) { lr = 1; SPI_I2S_SendData(SPI2, (uint16_t)dma_buf[index] << 3); } else { lr = 0; SPI_I2S_SendData(SPI2, (uint16_t)dma_buf[index++] << 3); if (index == BUFF_SIZE) { index = 0; // Disable the I2S1 TXE Interrupt to stop playing SPI_I2S_ITConfig(SPI2, SPI_I2S_IT_TXE, DISABLE); } } } }
主函数. 在主函数中, 先开启录音, 然后等待4秒(对应 3万个样本, 8K采样, 4秒之内就结束了), 然后开始播放. 每个循环等待5秒. 播放会在中断中判断是否结束, 结束就停止.
int main(void) { Delay_Init(); USART_Printf_Init(115200); printf("SystemClk:%ld\r\n", SystemCoreClock); RCC_Configuration(); GPIO_Configuration(); ADC_Configuration(); DMA_Configuration(DMA1_Channel1, (uint32_t)&ADC1->DR, (uint32_t)dma_buf, BUFF_SIZE); NVIC_Configuration(); TIM_Configuration(); IIS_Configuration(); GPIO_SetBits(GPIOC, GPIO_Pin_13); Delay_S(1); // Start timer to start recording printf("Start recording\r\n"); TIM_Cmd(TIM3, ENABLE); // Turn on LED, DMA TC1 interrupt will turn it off GPIO_ResetBits(GPIOC, GPIO_Pin_13); Delay_S(4); printf("Start playing\r\n"); while (1) { // Restart the playing SPI_I2S_ITConfig(SPI2, SPI_I2S_IT_TXE, ENABLE); Delay_S(5); } }
使用ADPCM压缩音频数据
ADPCM 的原理和计算方式可以参考这一篇 https://www.cnblogs.com/milton/p/16914797.html.
使用ADPCM可以将16bit的数据压缩为4bit, 同时保持基本一致的听觉信息. 这样对于64kB的CCT6, 可以在12bit的效果下记录接近16秒的语音(64K = 16 * 8K * 0.5). 而且 ADPCM 的计算简单, AIR32这种M3核心的MCU处理起来非常轻松.
工作机制调整
如果使用ADPCM, 需要对前面的例子进行一些调整. 硬件和前面的一致, 改动都在代码.
去掉DMA
因为DMA必须是硬件到硬件, 如果想做成双缓冲, 比如做一个1K左右的DMA数组, 一半结束后批量编码, 再等另一半结束再编码? 这样其实不行, 因为集中编码时ADC也还在进行, 一边在计算一边在转换和中断, 会互相影响, 导致采样不均匀. 因为ADC转换使用定时器触发, 定时器两个中断之间, ADC转换的时间很短, 中间间隔的时间完全可以用于编码, 所以需要将DMA去掉, 改成使用ADC的转换完成中断, 在完成中断的处理函数中对采样值进行编码
调整数组
为了计算方便, 将语音数组转换为uint8_t, 这样每个值记录的是两个采样点, 相应的数组大小扩充到了60000
调整I2S传输
因为每个值存储的是两个采样, 因此在I2S的TXE中断处理中, 原先的左右声道判断需要叠加4bit偏移判断, 变成4种情况.
代码
完整的示例代码
- GitHub: https://github.com/IOsetting/air32f103-template/tree/master/Examples/NonFreeRTOS/I2S/Audio_Recorder_ADPCM
- Gitee: https://gitee.com/iosetting/air32f103-template/tree/master/Examples/NonFreeRTOS/I2S/Audio_Recorder_ADPCM
ADC启用中断
void ADC_Configuration(void) { ADC_InitTypeDef ADC_InitStructure; // Reset ADC1 ADC_DeInit(ADC1); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // Select TIM3 trigger output as external trigger ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); // ADC_Channel_2 for PA2 ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 1, ADC_SampleTime_7Cycles5); // Enable ADC1 external trigger ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_ITConfig(ADC1, ADC_IT_EOC, ENABLE); // Enable ADC1 ADC_Cmd(ADC1, ENABLE); // Calibration ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); }
在ADC中断中, 对ADC结果值的编码
void Audio_Encode(void) { static uint32_t idx = 0; static uint8_t msb = 0; uint8_t val; val = ADPCM_Encode((uint16_t)(ADC1->DR << 2)) & 0x0F; if (msb == 0) { voice[idx] = val; msb = 1; } else { voice[idx] |= (val << 4); msb = 0; idx++; if (idx == BUFF_SIZE) { // Stop ADC(by stopping TIM3) TIM_Cmd(TIM3, DISABLE); ADC_Cmd(ADC1, DISABLE); ADC_ExternalTrigConvCmd(ADC1, DISABLE); GPIO_SetBits(GPIOC, GPIO_Pin_13); idx = 0; finish = 1; } } }
在I2S传输中断中, 对值的解码. 每传输四个数据(低4位左右声道, 高4位左右声道)下标才加1, 传输结束后重置下标.
uint16_t Audio_Decode(void) { static uint32_t idx = 0; static __IO uint8_t msb = 0, lr = 0; static uint16_t val; if (msb == 0) { // Put data to both channels if (lr == 0) { val = ADPCM_Decode(voice[idx] & 0x0F); lr = 1; } else if (lr == 1) { lr = 0; msb = 1; } } else { if (lr == 0) { val = ADPCM_Decode((voice[idx] >> 4) & 0x0F); lr = 1; } else if (lr == 1) { lr = 0; msb = 0; idx++; if (idx == BUFF_SIZE) { idx = 0; ADPCM_Reset(); } } } return val; }
使用ADPCM后, 在8K采样下语音音质没有明显下降, 但是录音时长增长到了15秒, 提升明显.
最后
以上说明了如何使用AIR32自带的内存实现简单的语音录制和播放功能, 以及使用 ADPCM 对音频数据进行压缩, 提高录制时长. 通过这些机制, 可以快速扩充为实用的录制设备, 例如外挂I2C或SPI存储, 或提升无线传输的音质, 在同样的码率下使用更高采样率.