跳至主要内容

捕捉音頻 - Wio Terminal

在這部分課程中,你將編寫代碼來捕捉 Wio Terminal 上的音頻。音頻捕捉將由 Wio Terminal 頂部的一個按鈕控制。

編程設備以捕捉音頻

你可以使用 C++ 代碼從麥克風捕捉音頻。Wio Terminal 只有 192KB 的 RAM,不足以捕捉超過幾秒鐘的音頻。它還有 4MB 的閃存,因此可以用來保存捕捉到的音頻。

內建的麥克風捕捉的是模擬信號,這些信號會被轉換為 Wio Terminal 可以使用的數字信號。在捕捉音頻時,數據需要在正確的時間捕捉 - 例如,要以 16KHz 捕捉音頻,則需要每秒精確地捕捉 16,000 次音頻,每次樣本之間的間隔相等。與其使用你的代碼來完成這個工作,你可以使用直接內存存取控制器 (DMAC)。這是一種電路,可以從某處捕捉信號並寫入內存,而不會中斷處理器上運行的代碼。

✅ 在 維基百科上的直接內存存取頁面 閱讀更多關於 DMA 的內容。

音頻從麥克風進入 ADC 然後進入 DMAC。這會寫入一個緩衝區。當這個緩衝區滿了,它會被處理,然後 DMAC 會寫入第二個緩衝區

DMAC 可以以固定的間隔從 ADC 捕捉音頻,例如每秒 16,000 次以捕捉 16KHz 的音頻。它可以將這些捕捉到的數據寫入預分配的內存緩衝區,當這個緩衝區滿了,會通知你的代碼來處理它。使用這些內存可能會延遲捕捉音頻,但你可以設置多個緩衝區。DMAC 會寫入緩衝區 1,然後當它滿了,通知你的代碼來處理緩衝區 1,同時 DMAC 會寫入緩衝區 2。當緩衝區 2 滿了,它會通知你的代碼,然後回到寫入緩衝區 1。這樣,只要你在填滿一個緩衝區的時間內處理每個緩衝區,你就不會丟失任何數據。

一旦每個緩衝區被捕捉到,它可以被寫入閃存。閃存需要使用定義的地址來寫入,指定寫入的位置和大小,類似於更新內存中的字節數組。閃存具有粒度,這意味著擦除和寫入操作不僅依賴於固定的大小,還需要對齊到該大小。例如,如果粒度是 4096 字節,而你請求在地址 4200 處擦除,它可能會擦除從地址 4096 到 8192 的所有數據。這意味著當你將音頻數據寫入閃存時,必須以正確大小的塊來寫入。

任務 - 配置閃存

  1. 使用 PlatformIO 創建一個全新的 Wio Terminal 項目。將此項目命名為 smart-timer。在 setup 函數中添加代碼來配置串行端口。

  2. 將以下庫依賴項添加到 platformio.ini 文件中,以提供對閃存的訪問:

    lib_deps =
    seeed-studio/Seeed Arduino FS @ 2.1.1
    seeed-studio/Seeed Arduino SFUD @ 2.0.2
  3. 打開 main.cpp 文件,並在文件頂部添加以下包含指令以使用閃存庫:

    #include <sfud.h>
    #include <SPI.h>

    🎓 SFUD 代表串行閃存通用驅動程序,是一個設計用於所有閃存芯片的庫

  4. setup 函數中,添加以下代碼來設置閃存存儲庫:

    while (!(sfud_init() == SFUD_SUCCESS))
    ;

    sfud_qspi_fast_read_enable(sfud_get_device(SFUD_W25Q32_DEVICE_INDEX), 2);

    這會循環直到 SFUD 庫初始化完成,然後打開快速讀取。內建的閃存可以使用隊列串行外設接口 (QSPI) 訪問,這是一種 SPI 控制器,允許通過隊列進行連續訪問,最小化處理器使用。這使得讀取和寫入閃存更快。

  5. src 文件夾中創建一個新文件,名為 flash_writer.h

  6. 在此文件頂部添加以下內容:

    #pragma once

    #include <Arduino.h>
    #include <sfud.h>

    這包括一些需要的頭文件,包括 SFUD 庫的頭文件以與閃存交互

  7. 在這個新頭文件中定義一個名為 FlashWriter 的類:

    class FlashWriter
    {
    public:

    private:
    };
  8. private 部分中添加以下代碼:

    byte *_sfudBuffer;
    size_t _sfudBufferSize;
    size_t _sfudBufferPos;
    size_t _sfudBufferWritePos;

    const sfud_flash *_flash;

    這定義了一些用於在寫入閃存之前存儲數據的緩衝區字段。有一個字節數組 _sfudBuffer 用於寫入數據,當這個緩衝區滿了,數據會被寫入閃存。_sfudBufferPos 字段存儲當前寫入緩衝區的位置,_sfudBufferWritePos 存儲寫入閃存的位置。_flash 是指向要寫入的閃存的指針 - 一些微控制器有多個閃存芯片。

  9. 添加以下方法到 public 部分來初始化這個類:

    void init()
    {
    _flash = sfud_get_device_table() + 0;
    _sfudBufferSize = _flash->chip.erase_gran;
    _sfudBuffer = new byte[_sfudBufferSize];
    _sfudBufferPos = 0;
    _sfudBufferWritePos = 0;
    }

    這配置了 Wio Terminal 上的閃存以進行寫入,並根據閃存的粒度設置緩衝區。這是在 init 方法中,而不是構造函數中,因為這需要在 setup 函數中設置閃存之後調用。

  10. 添加以下代碼到 public 部分:

    void writeSfudBuffer(byte b)
    {
    _sfudBuffer[_sfudBufferPos++] = b;
    if (_sfudBufferPos == _sfudBufferSize)
    {
    sfud_erase_write(_flash, _sfudBufferWritePos, _sfudBufferSize, _sfudBuffer);
    _sfudBufferWritePos += _sfudBufferSize;
    _sfudBufferPos = 0;
    }
    }

    void writeSfudBuffer(byte *b, size_t len)
    {
    for (size_t i = 0; i < len; ++i)
    {
    writeSfudBuffer(b[i]);
    }
    }

    void flushSfudBuffer()
    {
    if (_sfudBufferPos > 0)
    {
    sfud_erase_write(_flash, _sfudBufferWritePos, _sfudBufferSize, _sfudBuffer);
    _sfudBufferWritePos += _sfudBufferSize;
    _sfudBufferPos = 0;
    }
    }

    這段代碼定義了將字節寫入閃存存儲系統的方法。它通過寫入一個內存緩衝區來工作,該緩衝區的大小適合閃存,當這個緩衝區滿了,這些數據會被寫入閃存,擦除該位置的任何現有數據。還有一個 flushSfudBuffer 方法來寫入不完整的緩衝區,因為捕捉的數據不會是粒度大小的整數倍,所以需要寫入數據的末尾部分。

    💁 數據的末尾部分會寫入額外的無用數據,但這沒關係,因為只會讀取需要的數據。

任務 - 設置音頻捕捉

  1. src 文件夾中創建一個新文件,名為 config.h

  2. 在此文件頂部添加以下內容:

    #pragma once

    #define RATE 16000
    #define SAMPLE_LENGTH_SECONDS 4
    #define SAMPLES RATE * SAMPLE_LENGTH_SECONDS
    #define BUFFER_SIZE (SAMPLES * 2) + 44
    #define ADC_BUF_LEN 1600

    這段代碼設置了一些音頻捕捉的常量。

    常量描述
    RATE16000音頻的採樣率。16,000 是 16KHz
    SAMPLE_LENGTH_SECONDS4要捕捉的音頻長度。設置為 4 秒。要錄製更長的音頻,增加這個值。
    SAMPLES64000將要捕捉的音頻樣本總數。設置為採樣率 * 秒數
    BUFFER_SIZE128044捕捉音頻的緩衝區大小。音頻將被捕捉為 WAV 文件,這是 44 字節的頭部,然後是 128,000 字節的音頻數據(每個樣本是 2 字節)
    ADC_BUF_LEN1600用於從 DMAC 捕捉音頻的緩衝區大小

    💁 如果你發現 4 秒太短,無法請求計時器,你可以增加 SAMPLE_LENGTH_SECONDS 值,所有其他值將重新計算。

  3. src 文件夾中創建一個新文件,名為 mic.h

  4. 在此文件頂部添加以下內容:

    #pragma once

    #include <Arduino.h>

    #include "config.h"
    #include "flash_writer.h"

    這包括一些需要的頭文件,包括 config.hFlashWriter 頭文件。

  5. 添加以下內容來定義一個可以從麥克風捕捉音頻的 Mic 類:

    class Mic
    {
    public:
    Mic()
    {
    _isRecording = false;
    _isRecordingReady = false;
    }

    void startRecording()
    {
    _isRecording = true;
    _isRecordingReady = false;
    }

    bool isRecording()
    {
    return _isRecording;
    }

    bool isRecordingReady()
    {
    return _isRecordingReady;
    }

    private:
    volatile bool _isRecording;
    volatile bool _isRecordingReady;
    FlashWriter _writer;
    };

    Mic mic;

    這個類目前只有幾個字段來跟踪錄音是否已經開始,以及錄音是否準備好使用。當 DMAC 設置好後,它會不斷地寫入內存緩衝區,所以 _isRecording 標誌決定這些緩衝區是否應該被處理或忽略。當需要的 4 秒音頻被捕捉到時,_isRecordingReady 標誌將被設置。_writer 字段用於將音頻數據保存到閃存。

    然後聲明了一個 Mic 類的實例的全局變量。

  6. 添加以下代碼到 Mic 類的 private 部分:

    typedef struct
    {
    uint16_t btctrl;
    uint16_t btcnt;
    uint32_t srcaddr;
    uint32_t dstaddr;
    uint32_t descaddr;
    } dmacdescriptor;

    // 全局變量 - DMA 和 ADC
    volatile dmacdescriptor _wrb[DMAC_CH_NUM] __attribute__((aligned(16)));
    dmacdescriptor _descriptor_section[DMAC_CH_NUM] __attribute__((aligned(16)));
    dmacdescriptor _descriptor __attribute__((aligned(16)));

    void configureDmaAdc()
    {
    // 配置 DMA 以定期從 ADC 取樣(由計時器/計數器觸發)
    DMAC->BASEADDR.reg = (uint32_t)_descriptor_section; // 指定描述符的位置
    DMAC->WRBADDR.reg = (uint32_t)_wrb; // 指定寫回描述符的位置
    DMAC->CTRL.reg = DMAC_CTRL_DMAENABLE | DMAC_CTRL_LVLEN(0xf); // 啟用 DMAC 外設
    DMAC->Channel[1].CHCTRLA.reg = DMAC_CHCTRLA_TRIGSRC(TC5_DMAC_ID_OVF) | // 設置 DMAC 在 TC5 計時器溢出時觸發
    DMAC_CHCTRLA_TRIGACT_BURST; // DMAC 突發傳輸

    _descriptor.descaddr = (uint32_t)&_descriptor_section[1]; // 設置循環描述符
    _descriptor.srcaddr = (uint32_t)&ADC1->RESULT.reg; // 從 ADC0 RESULT 寄存器中取結果
    _descriptor.dstaddr = (uint32_t)_adc_buf_0 + sizeof(uint16_t) * ADC_BUF_LEN; // 將其放入 adc_buf_0 數組中
    _descriptor.btcnt = ADC_BUF_LEN; // 範圍計數
    _descriptor.btctrl = DMAC_BTCTRL_BEATSIZE_HWORD | // 範圍大小為 HWORD(16 位)
    DMAC_BTCTRL_DSTINC | // 增加目標地址
    DMAC_BTCTRL_VALID | // 描述符有效
    DMAC_BTCTRL_BLOCKACT_SUSPEND; // 在塊傳輸後暫停 DMAC 通道 0
    memcpy(&_descriptor_section[0], &_descriptor, sizeof(_descriptor)); // 將描述符複製到描述符部分

    _descriptor.descaddr = (uint32_t)&_descriptor_section[0]; // 設置循環描述符
    _descriptor.srcaddr = (uint32_t)&ADC1->RESULT.reg; // 從 ADC0 RESULT 寄存器中取結果
    _descriptor.dstaddr = (uint32_t)_adc_buf_1 + sizeof(uint16_t) * ADC_BUF_LEN; // 將其放入 adc_buf_1 數組中
    _descriptor.btcnt = ADC_BUF_LEN; // 範圍計數
    _descriptor.btctrl = DMAC_BTCTRL_BEATSIZE_HWORD | // 範圍大小為 HWORD(16 位)
    DMAC_BTCTRL_DSTINC | // 增加目標地址
    DMAC_BTCTRL_VALID | // 描述符有效
    DMAC_BTCTRL_BLOCKACT_SUSPEND; // 在塊傳輸後暫停 DMAC 通道 0
    memcpy(&_descriptor_section[1], &_descriptor, sizeof(_descriptor)); // 將描述符複製到描述符部分

    // 配置 NVIC
    NVIC_SetPriority(DMAC_1_IRQn, 0); // 將 DMAC1 的嵌套向量中斷控制器(NVIC)優先級設置為 0(最高)
    NVIC_EnableIRQ(DMAC_1_IRQn); // 將 DMAC1 連接到嵌套向量中斷控制器(NVIC)

    // 啟用 DMAC 通道 1 上的暫停(SUSP)中斷
    DMAC->Channel[1].CHINTENSET.reg = DMAC_CHINTENSET_SUSP;

    // 配置 ADC
    ADC1->INPUTCTRL.bit.MUXPOS = ADC_INPUTCTRL_MUXPOS_AIN12_Val; // 將模擬輸入設置為 ADC0/AIN2(PB08 - Metro M4 上的 A4)
    while (ADC1->SYNCBUSY.bit.INPUTCTRL)
    ; // 等待同步
    ADC1->SAMPCTRL.bit.SAMPLEN = 0x00; // 將最大採樣時間長度設置為一半的分頻 ADC 時鐘脈衝(2.66us)
    while (ADC1->SYNCBUSY.bit.SAMPCTRL)
    ; // 等待同步
    ADC1->CTRLA.reg = ADC_CTRLA_PRESCALER_DIV128; // 將 ADC GCLK 時鐘分頻 128(48MHz/128 = 375kHz)
    ADC1->CTRLB.reg = ADC_CTRLB_RESSEL_12BIT | // 將 ADC 分辨率設置為 12 位
    ADC_CTRLB_FREERUN; // 將 ADC 設置為自由運行模式
    while (ADC1->SYNCBUSY.bit.CTRLB)
    ; // 等待同步
    ADC1->CTRLA.bit.ENABLE = 1; // 啟用 ADC
    while (ADC1->SYNCBUSY.bit.ENABLE)
    ; // 等待同步
    ADC1->SWTRIG.bit.START = 1; // 啟動軟件觸發以開始 ADC 轉換
    while (ADC1->SYNCBUSY.bit.SWTRIG)
    ; // 等待同步

    // 啟用 DMA 通道 1
    DMAC->Channel[1].CHCTRLA.bit.ENABLE = 1;

    // 配置計時器/計數器 5
    GCLK->PCHCTRL[TC5_GCLK_ID].reg = GCLK_PCHCTRL_CHEN | // 啟用 TC5 的外設通道
    GCLK_PCHCTRL_GEN_GCLK1; // 連接 0 號通用時鐘,頻率為 48MHz

    TC5->COUNT16.WAVE.reg = TC_WAVE_WAVEGEN_MFRQ; // 將 TC5 設置為匹配頻率(MFRQ)模式
    TC5->COUNT16.CC[0].reg = 3000 - 1; // 將觸發設置為 16 kHz:(4Mhz / 16000)- 1
    while (TC5->COUNT16.SYNCBUSY.bit.CC0)
    ; // 等待同步

    // 啟動計時器/計數器 5
    TC5->COUNT16.CTRLA.bit.ENABLE = 1; // 啟用 TC5 計時器
    while (TC5->COUNT16.SYNCBUSY.bit.ENABLE)
    ; // 等待同步
    }

    uint16_t _adc_buf_0[ADC_BUF_LEN];
    uint16_t _adc_buf_1[ADC_BUF_LEN];

    這段代碼定義了一個 configureDmaAdc 方法,該方法配置 DMAC,將其連接到 ADC 並設置為填充兩個不同的交替緩衝區 _adc_buf_0_adc_buf_1

    💁 微控制器開發的一個缺點是與硬件交互所需代碼的複雜性,因為你的代碼在非常低的層次上運行,直接與硬件交互。這段代碼比你為單板計算機或桌面計算機編寫的代碼更複雜,因為沒有操作系統的幫助。有一些庫可以簡化這個過程,但仍然有很多複雜性。

  7. 在這段代碼下面,添加以下內容:

    // WAV 文件有一個頭部。這個結構體定義了這個頭部
    struct wavFileHeader
    {
    char riff[4]; /* "RIFF" */
    long flength; /* 文件長度(字節) */
    char wave[4]; /* "WAVE" */
    char fmt[4]; /* "fmt " */
    long chunk_size; /* FMT 塊的大小(字節)(通常為 16) */
    short format_tag; /* 1=PCM, 257=Mu-Law, 258=A-Law, 259=ADPCM */
    short num_chans; /* 1=單聲道,2=立體聲 */
    long srate; /* 採樣率(每秒樣本數) */
    long bytes_per_sec; /* 每秒字節數 = 採樣率*每樣本字節數 */
    short bytes_per_samp; /* 2=16 位單聲道,4=16 位立體聲 */
    short bits_per_samp; /* 每樣本位數 */
    char data[4]; /* "data" */
    long dlength; /* 數據長度(字節)(文件長度 - 44) */
    };

    void initBufferHeader()
    {
    wavFileHeader wavh;

    strncpy(wavh.riff, "RIFF", 4);
    strncpy(wavh.wave, "WAVE", 4);
    strncpy(wavh.fmt, "fmt ", 4);
    strncpy(wavh.data, "data", 4);

    wavh.chunk_size = 16;
    wavh.format_tag = 1; // PCM
    wavh.num_chans = 1; // 單聲道
    wavh.srate = RATE;
    wavh.bytes_per_sec = (RATE * 1 * 16 * 1) / 8;
    wavh.bytes_per_samp = 2;
    wavh.bits_per_samp = 16;
    wavh.dlength = RATE * 2 * 1 * 16 / 2;
    wavh.flength = wavh.dlength + 44;

    _writer.writeSfudBuffer((byte *)&wavh, 44);
    }

    這段代碼將 WAV 頭部定義為一個佔用 44 字節內存的結構體。它寫入有關音頻文件速率、大小和聲道數的詳細信息。然後將這個頭部寫入閃存。

  8. 在這段代碼下面,添加以下內容來聲明一個在音頻緩衝區準備好處理時調用的方法:

    void audioCallback(uint16_t *buf, uint32_t buf_len)
    {
    static uint32_t idx = 44;

    if (_isRecording)
    {
    for (uint32_t i = 0; i < buf_len; i++)
    {
    int16_t audio_value = ((int16_t)buf[i] - 2048) * 16;

    _writer.writeSfudBuffer(audio_value & 0xFF);
    _writer.writeSfudBuffer((audio_value >> 8) & 0xFF);
    }

    idx += buf_len;

    if (idx >= BUFFER_SIZE)
    {
    _writer.flushSfudBuffer();
    idx = 44;
    _isRecording = false;
    _isRecordingReady = true;
    }
    }
    }

    音頻緩衝區是包含來自 ADC 的音頻的 16 位整數數組。ADC 返回 12 位無符號值(0-1023),因此需要將這些值轉換為 16 位有符號值,然後轉換為 2 個字節以存儲為原始二進制數據。

    這些字節被寫入閃存緩衝區。寫入從索引 44 開始 - 這是從作為 WAV 文件頭部寫入的 44 字節的偏移量。一旦捕捉到所需長度的所有字節,剩餘數據會被寫入閃存。

  9. Mic 類的 public 部分,添加以下代碼:

    void dmaHandler()
    {
    static uint8_t count = 0;

    if (DMAC->Channel[1].CHINTFLAG.bit.SUSP)
    {
    DMAC->Channel[1].CHCTRLB.reg = DMAC_CHCTRLB_CMD_RESUME;
    DMAC->Channel[1].CHINTFLAG.bit.SUSP = 1;

    if (count)
    {
    audioCallback(_adc_buf_0, ADC_BUF_LEN);
    }
    else
    {
    audioCallback(_adc_buf_1, ADC_BUF_LEN);
    }

    count = (count + 1) % 2;
    }
    }

    這段代碼將由 DMAC 調用,以告訴你的代碼處理緩衝區。它檢查是否有數據需要處理,並調用 audioCallback 方法處理相關緩衝區。

  10. 在類外部,在 Mic mic; 聲明之後,添加以下代碼:

    void DMAC_1_Handler()
    {
    mic.dmaHandler();
    }

    DMAC_1_Handler 將由 DMAC 調用,當緩衝區準備好處理時。這個函數通過名稱找到,只需要存在即可被調用。

  11. Mic 類的 public 部分,添加以下兩個方法:

    void init()
    {
    analogReference(AR_INTERNAL2V23);

    _writer.init();

    initBufferHeader();
    configureDmaAdc();
    }

    void reset()
    {
    _isRecordingReady = false;
    _isRecording = false;

    _writer.reset();

    initBufferHeader();
    }

    init 方法包含初始化 Mic 類的代碼。這個方法設置麥克風引腳的正確電壓,設置閃存寫入器,寫入 WAV 文件頭部,並配置 DMAC。reset 方法在音頻被捕捉和使用後重置閃存並重新寫入頭部。

任務 - 捕捉音頻

  1. main.cpp 文件中,添加 mic.h 頭文件的包含指令:

    #include "mic.h"
  2. setup 函數中,初始化 C 按鈕。當按下這個按鈕時,將開始捕捉音頻,並持續 4 秒:

    pinMode(WIO_KEY_C, INPUT_PULLUP);
  3. 在這段代碼下面,初始化麥克風,然後打印到控制台,表示音頻準備好捕捉:

    mic.init();

    Serial.println("Ready.");
  4. loop 函數上方,定義一個處理捕捉到的音頻的函數。現在這個函數什麼也不做,但在這節課的後面,它會將語音轉換為文本:

    void processAudio()
    {

    }
  5. loop 函數中添加以下代碼:

    void loop()
    {
    if (digitalRead(WIO_KEY_C) == LOW && !mic.isRecording())
    {
    Serial.println("Starting recording...");
    mic.startRecording();
    }

    if (!mic.isRecording() && mic.isRecordingReady())
    {
    Serial.println("Finished recording");

    processAudio();

    mic.reset();
    }
    }

    這段代碼檢查 C 按鈕,如果按下這個按鈕並且錄音尚未開始,則將 Mic 類的 _isRecording 字段設置為 true。這將導致 Mic 類的 audioCallback 方法存儲音頻,直到捕捉到 4 秒的音頻。當捕捉到 4 秒的音頻後,_isRecording 字段設置為 false,_isRecordingReady 字段設置為 true。然後在 loop 函數中檢查這個字段,當為 true 時,調用 processAudio 函數,然後重置 mic 類。

  6. 構建這段代碼,將其上傳到你的 Wio Terminal,並通過串行監視器進行測試。按下 C 按鈕(左側最靠近電源開關的那個),然後說話。將捕捉 4 秒的音頻。

    --- Available filters and text transformations: colorize, debug, default, direct, hexlify, log2file, nocontrol, printable, send_on_enter, time
    --- More details at http://bit.ly/pio-monitor-filters
    --- Miniterm on /dev/cu.usbmodem1101 9600,8,N,1 ---
    --- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H ---
    Ready.
    Starting recording...
    Finished recording

💁 你可以在 code-record/wio-terminal 文件夾中找到這段代碼。

😀 你的音頻錄製程序成功了!