使用的MCU型號為 AIR32F103CCT6. 通過工作機制和示例代碼, 說明如何使用AIR32自帶的記憶體實現簡單的語音錄製和播放功能, 以及使用 ADPCM 對音頻數據進行壓縮, 提高錄製時長. 通過這些機制, 可以快速擴充為實用的錄製設備, 例如外掛I2C或SPI存儲, 或提升無線傳輸的音質,... ...
目錄
- AIR32F103(一) 合宙AIR32F103CBT6開發板上手報告
- AIR32F103(二) Linux環境和LibOpenCM3項目模板
- AIR32F103(三) Linux環境基於標準外設庫的項目模板
- AIR32F103(四) 27倍頻216MHz,CoreMark跑分測試
- AIR32F103(五) FreeRTOSv202112核心庫的集成和示例代碼
- AIR32F103(六) ADC,I2S,DMA和ADPCM實現的錄音播放功能
關於
使用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存儲, 或提升無線傳輸的音質, 在同樣的碼率下使用更高採樣率.