AIR32F103(六) ADC,I2S,DMA和ADPCM實現錄音播放功能

2022-11-24 06:01:14

目錄

關於

使用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儲存, 或提升無線傳輸的音質, 在同樣的位元速率下使用更高取樣率.