使用AIR32的ADC, I2S 和 DMA 實現簡單的語音錄音和播放功能, 以及使用 ADPCM 編碼提升錄音時長. 使用的MCU型號為 AIR32F103CCT6. 如果用CBT6, 對應的音訊資料陣列大小需要相應減小.
加電後開始錄音, 錄音結束後迴圈播放
對中間每個環節的說明
首先是儲存, MCU的記憶體有限, 如果不借助AT24C, MX25L這類外部儲存, 只用記憶體儲存的資料是有限的, AIR32F103CCT6 帶 64K Byte記憶體, 如果按原始取樣值儲存, 錄音時長為
使用AIR32的ADC, 配合定時器實現精確的每秒8K, 11K和16K取樣. AIR32的ADC解析度和STM32F103一樣都是固定的12bit(STM32F4之後才可以用暫存器調節解析度)
音訊採集裝置如果直接用駐極體話筒, 取樣的訊號很弱(不是沒有, 但是非常小), 需要加一個三極體做放大. 也可以買成品的 MAX9814 模組. 兩者的效果區別不大, 但是在偵錯階段, 建議用 MAX9814, 因為不用擔心訊號是否過飽和和失真問題, 在調通之後, 再換回低成本的駐極體話筒和三極體.
駐極體話筒放大的電路和元件引數可以參考這一篇 https://www.cnblogs.com/milton/p/15315783.html
播放可以使用PWM轉DAC, 也可以直接用I2S.
I2S模組可以用 MAX98357A 模組, 自帶I2S解碼和放大可以直連喇叭, 也可以買PT8211/TM8211/GH8211, 0.3元一片非常便宜還是雙聲道, 缺點是不帶功放, 如果直連喇叭得貼著耳朵才能聽到, 可以再加一個LM386或者PAM8403做放大, 都非常便宜.
接線
* 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);
}
中斷處理
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);
}
}
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 的原理和計算方式可以參考這一篇 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必須是硬體到硬體, 如果想做成雙緩衝, 比如做一個1K左右的DMA陣列, 一半結束後批次編碼, 再等另一半結束再編碼? 這樣其實不行, 因為集中編碼時ADC也還在進行, 一邊在計算一邊在轉換和中斷, 會互相影響, 導致取樣不均勻. 因為ADC轉換使用定時器觸發, 定時器兩個中斷之間, ADC轉換的時間很短, 中間間隔的時間完全可以用於編碼, 所以需要將DMA去掉, 改成使用ADC的轉換完成中斷, 在完成中斷的處理常式中對取樣值進行編碼
為了計算方便, 將語音陣列轉換為uint8_t, 這樣每個值記錄的是兩個取樣點, 相應的陣列大小擴充到了60000
因為每個值儲存的是兩個取樣, 因此在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儲存, 或提升無線傳輸的音質, 在同樣的位元速率下使用更高取樣率.