6

AIR32F103(六) ADC,I2S,DMA和ADPCM实现录音播放功能 - Milton

 1 year ago
source link: https://www.cnblogs.com/milton/p/16919589.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

使用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

完整的示例代码

定义了全局变量

// 定义不同的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种情况.

完整的示例代码

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存储, 或提升无线传输的音质, 在同样的码率下使用更高采样率.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK