录制音频 - Wio Terminal 在本课程的这一部分中,你将编写代码来录制Wio Terminal上的音频。音频录制将由Wio Terminal顶部的一个按钮控制。 编程设备以录制音频 你可以使用C++代码从麦克风录制音频。Wio Terminal只有192KB的RAM,不足以录制超过几秒钟的音频。然而,它有4MB的闪存,因此可以使用闪存来保存录制的音频。 内置麦克风捕获的是模拟信号,该信号会被转换成数字信号,供Wio Terminal使用。在录制音频时,数据需要在正确的时间点进行捕获——例如,对于16KHz的音频,每秒需要捕获16,000次,且每次采样的间隔相等。与其让你的代码去处理这些细节,你可以使用直接存储器访问控制器(DMAC)。
在本课程的这一部分中,你将编写代码来录制Wio Terminal上的音频。音频录制将由Wio Terminal顶部的一个按钮控制。
你可以使用C++代码从麦克风录制音频。Wio Terminal只有192KB的RAM,不足以录制超过几秒钟的音频。然而,它有4MB的闪存,因此可以使用闪存来保存录制的音频。
内置麦克风捕获的是模拟信号,该信号会被转换成数字信号,供Wio Terminal使用。在录制音频时,数据需要在正确的时间点进行捕获——例如,对于16KHz的音频,每秒需要捕获16,000次,且每次采样的间隔相等。与其让你的代码去处理这些细节,你可以使用直接存储器访问控制器(DMAC)。这是一种电路,可以从某个地方捕获信号并将其写入内存,而不会中断处理器上运行的代码。
✅ 请参阅维基百科上的直接存储器访问页面了解更多关于DMA的内容。

DMAC可以在固定的时间间隔内从ADC捕获音频,比如每秒16,000次用于16KHz音频。它可以将捕获的数据写入预先分配的内存缓冲区,当这个缓冲区满时,通知你的代码进行处理。使用这种内存可能会延迟音频的捕获,但你可以设置多个缓冲区。DMAC写入缓冲区1,当其满时,通知你的代码处理缓冲区1,同时DMAC写入缓冲区2。当缓冲区2满时,它会通知你的代码,并返回到写入缓冲区1。这样,只要你在每个缓冲区填满之前处理完它们,就不会丢失任何数据。
一旦每个缓冲区被填充,就可以将其写入闪存。闪存需要使用指定地址进行写入,指定写入位置和写入大小,类似于更新内存中的字节数组。闪存具有粒度性,这意味着擦除和写入操作不仅依赖于固定的大小,还必须与该大小对齐。例如,如果粒度为4096字节,并且请求在地址4200处擦除,则可能擦除从地址4096到8192的所有数据。这意味着当你向闪存写入音频数据时,必须以正确的大小分块写入。
使用PlatformIO创建一个新的Wio Terminal项目,命名为smart-timer。在setup函数中添加代码以配置串口。
在platformio.ini文件中添加以下库依赖项,以便提供对闪存的访问:
lib_deps = seeed-studio/Seeed Arduino FS @ 2.1.1 seeed-studio/Seeed Arduino SFUD @ 2.0.2
打开main.cpp文件,并在文件顶部添加以下包含指令,以包含闪存库:
#include <sfud.h> #include <SPI.h>
SFUD代表串行闪存通用驱动程序,是一个设计用于所有闪存芯片的库。
在setup函数中,添加以下代码以设置闪存存储库:
while (!(sfud_init() == SFUD_SUCCESS)) ; sfud_qspi_fast_read_enable(sfud_get_device(SFUD_W25Q32_DEVICE_INDEX), 2);
这段代码会循环直到SFUD库初始化完成,然后开启快速读取。内置闪存可以通过队列式串行外设接口(QSPI)访问,这是一种SPI控制器类型,允许通过队列进行连续访问,且处理器使用量最小。这使得读写闪存速度更快。
在src folder called flash_writer.h中创建一个新文件。
在此文件顶部添加以下内容:
#pragma once #include <Arduino.h> #include <sfud.h>
这些代码包括了一些必要的头文件,包括与闪存交互的SFUD库的头文件。
在这个新头文件中定义一个名为FlashWriter的类:
class FlashWriter { public: private: };
在private部分,添加以下代码:
byte *_sfudBuffer; size_t _sfudBufferSize; size_t _sfudBufferPos; size_t _sfudBufferWritePos; const sfud_flash *_flash;
这些代码定义了一些用于在写入闪存前存储数据的缓冲区字段。有一个字节数组_sfudBuffer, to write data to, and when this is full, the data is written to flash memory. The _sfudBufferPos field stores the current location to write to in this buffer, and _sfudBufferWritePos stores the location in flash memory to write to. _flash is a pointer the flash memory to write to - some microcontrollers have multiple flash memory chips.
Add the following method to the 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函数中的闪存设置完成后调用。
添加以下代码到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 to write an incomplete buffer, as the data being captured won't be exact multiples of the grain size, so the end part of the data needs to be written.
The end part of the data will write additional unwanted data, but this is ok as only the data needed will be read.
Create a new file in the src folder called config.h。
在此文件顶部添加以下内容:
#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
这些代码设置了一些音频捕获的常量。
| 常量 | 值 | 描述 |
|---|---|---|
| RATE | 16000 | 音频的采样率。16,000即16KHz |
| SAMPLE_LENGTH_SECONDS | 4 | 要捕获的音频长度。这里设置为4秒。要录制更长的音频,增加这个值。 |
| SAMPLES | 64000 | 将捕获的音频样本总数。设置为采样率乘以秒数 |
| BUFFER_SIZE | 128044 | 要捕获的音频缓冲区大小。音频将以WAV文件格式捕获,包括44字节的头和128,000字节的音频数据(每个样本2字节) |
| ADC_BUF_LEN | 1600 | 用于从DMAC捕获音频的缓冲区大小 |
如果你觉得4秒太短,可以增加
SAMPLE_LENGTH_SECONDSvalue, and all the other values will recalculate.
Create a new file in the src folder called mic.h。
在此文件顶部添加以下内容:
#pragma once #include <Arduino.h> #include "config.h" #include "flash_writer.h"
这些代码包括了一些必要的头文件,包括config.h and FlashWriter header files.
Add the following to define a 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 flag determines if these should be processed or ignored. The _isRecordingReady flag will be set when the required 4 seconds of audio has been captured. The _writer field is used to save the audio data to flash memory.
A global variable is then declared for an instance of the Mic class.
Add the following code to the private section of the Mic类:
typedef struct { uint16_t btctrl; uint16_t btcnt; uint32_t srcaddr; uint32_t dstaddr; uint32_t descaddr; } dmacdescriptor; // Globals - DMA and 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() { // Configure DMA to sample from ADC at a regular interval (triggered by timer/counter) DMAC->BASEADDR.reg = (uint32_t)_descriptor_section; // Specify the location of the descriptors DMAC->WRBADDR.reg = (uint32_t)_wrb; // Specify the location of the write back descriptors DMAC->CTRL.reg = DMAC_CTRL_DMAENABLE | DMAC_CTRL_LVLEN(0xf); // Enable the DMAC peripheral DMAC->Channel[1].CHCTRLA.reg = DMAC_CHCTRLA_TRIGSRC(TC5_DMAC_ID_OVF) | // Set DMAC to trigger on TC5 timer overflow DMAC_CHCTRLA_TRIGACT_BURST; // DMAC burst transfer _descriptor.descaddr = (uint32_t)&_descriptor_section[1]; // Set up a circular descriptor _descriptor.srcaddr = (uint32_t)&ADC1->RESULT.reg; // Take the result from the ADC0 RESULT register _descriptor.dstaddr = (uint32_t)_adc_buf_0 + sizeof(uint16_t) * ADC_BUF_LEN; // Place it in the adc_buf_0 array _descriptor.btcnt = ADC_BUF_LEN; // Beat count _descriptor.btctrl = DMAC_BTCTRL_BEATSIZE_HWORD | // Beat size is HWORD (16-bits) DMAC_BTCTRL_DSTINC | // Increment the destination address DMAC_BTCTRL_VALID | // Descriptor is valid DMAC_BTCTRL_BLOCKACT_SUSPEND; // Suspend DMAC channel 0 after block transfer memcpy(&_descriptor_section[0], &_descriptor, sizeof(_descriptor)); // Copy the descriptor to the descriptor section _descriptor.descaddr = (uint32_t)&_descriptor_section[0]; // Set up a circular descriptor _descriptor.srcaddr = (uint32_t)&ADC1->RESULT.reg; // Take the result from the ADC0 RESULT register _descriptor.dstaddr = (uint32_t)_adc_buf_1 + sizeof(uint16_t) * ADC_BUF_LEN; // Place it in the adc_buf_1 array _descriptor.btcnt = ADC_BUF_LEN; // Beat count _descriptor.btctrl = DMAC_BTCTRL_BEATSIZE_HWORD | // Beat size is HWORD (16-bits) DMAC_BTCTRL_DSTINC | // Increment the destination address DMAC_BTCTRL_VALID | // Descriptor is valid DMAC_BTCTRL_BLOCKACT_SUSPEND; // Suspend DMAC channel 0 after block transfer memcpy(&_descriptor_section[1], &_descriptor, sizeof(_descriptor)); // Copy the descriptor to the descriptor section // Configure NVIC NVIC_SetPriority(DMAC_1_IRQn, 0); // Set the Nested Vector Interrupt Controller (NVIC) priority for DMAC1 to 0 (highest) NVIC_EnableIRQ(DMAC_1_IRQn); // Connect DMAC1 to Nested Vector Interrupt Controller (NVIC) // Activate the suspend (SUSP) interrupt on DMAC channel 1 DMAC->Channel[1].CHINTENSET.reg = DMAC_CHINTENSET_SUSP; // Configure ADC ADC1->INPUTCTRL.bit.MUXPOS = ADC_INPUTCTRL_MUXPOS_AIN12_Val; // Set the analog input to ADC0/AIN2 (PB08 - A4 on Metro M4) while (ADC1->SYNCBUSY.bit.INPUTCTRL) ; // Wait for synchronization ADC1->SAMPCTRL.bit.SAMPLEN = 0x00; // Set max Sampling Time Length to half divided ADC clock pulse (2.66us) while (ADC1->SYNCBUSY.bit.SAMPCTRL) ; // Wait for synchronization ADC1->CTRLA.reg = ADC_CTRLA_PRESCALER_DIV128; // Divide Clock ADC GCLK by 128 (48MHz/128 = 375kHz) ADC1->CTRLB.reg = ADC_CTRLB_RESSEL_12BIT | // Set ADC resolution to 12 bits ADC_CTRLB_FREERUN; // Set ADC to free run mode while (ADC1->SYNCBUSY.bit.CTRLB) ; // Wait for synchronization ADC1->CTRLA.bit.ENABLE = 1; // Enable the ADC while (ADC1->SYNCBUSY.bit.ENABLE) ; // Wait for synchronization ADC1->SWTRIG.bit.START = 1; // Initiate a software trigger to start an ADC conversion while (ADC1->SYNCBUSY.bit.SWTRIG) ; // Wait for synchronization // Enable DMA channel 1 DMAC->Channel[1].CHCTRLA.bit.ENABLE = 1; // Configure Timer/Counter 5 GCLK->PCHCTRL[TC5_GCLK_ID].reg = GCLK_PCHCTRL_CHEN | // Enable peripheral channel for TC5 GCLK_PCHCTRL_GEN_GCLK1; // Connect generic clock 0 at 48MHz TC5->COUNT16.WAVE.reg = TC_WAVE_WAVEGEN_MFRQ; // Set TC5 to Match Frequency (MFRQ) mode TC5->COUNT16.CC[0].reg = 3000 - 1; // Set the trigger to 16 kHz: (4Mhz / 16000) - 1 while (TC5->COUNT16.SYNCBUSY.bit.CC0) ; // Wait for synchronization // Start Timer/Counter 5 TC5->COUNT16.CTRLA.bit.ENABLE = 1; // Enable the TC5 timer while (TC5->COUNT16.SYNCBUSY.bit.ENABLE) ; // Wait for synchronization } uint16_t _adc_buf_0[ADC_BUF_LEN]; uint16_t _adc_buf_1[ADC_BUF_LEN];
这段代码定义了一个configureDmaAdc method that configures the DMAC, connecting it to the ADC and setting it to populate two different alternating buffers, _adc_buf_0 and _adc_buf_0。
微控制器开发的一个缺点是,与硬件交互所需的代码复杂性,因为你的代码在非常低的级别上直接与硬件交互。这比你在单板计算机或台式机上编写的代码更复杂,因为没有操作系统来帮助。虽然有一些库可以简化这一点,但仍然有很多复杂性。
在此之下,添加以下代码:
// WAV files have a header. This struct defines that header struct wavFileHeader { char riff[4]; /* "RIFF" */ long flength; /* file length in bytes */ char wave[4]; /* "WAVE" */ char fmt[4]; /* "fmt " */ long chunk_size; /* size of FMT chunk in bytes (usually 16) */ short format_tag; /* 1=PCM, 257=Mu-Law, 258=A-Law, 259=ADPCM */ short num_chans; /* 1=mono, 2=stereo */ long srate; /* Sampling rate in samples per second */ long bytes_per_sec; /* bytes per second = srate*bytes_per_samp */ short bytes_per_samp; /* 2=16-bit mono, 4=16-bit stereo */ short bits_per_samp; /* Number of bits per sample */ char data[4]; /* "data" */ long dlength; /* data length in bytes (filelength - 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; // mono 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字节的内存。它会写入关于音频文件速率、大小和通道数量的信息。这个头部随后会被写入闪存。
在此代码下方,添加以下声明,以便在音频缓冲区准备处理时调用一个方法:
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位有符号值,然后再转换为两个字节以作为原始二进制数据存储。
这些字节会被写入闪存缓冲区。写入开始于索引44——这是从写入44字节的WAV文件头开始的偏移量。一旦捕捉到了所需音频长度的所有字节,剩余的数据将被写入闪存。
在public section of the Mic类中,添加以下代码:
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 method with the relevant buffer.
Outside the class, after the Mic mic;声明,添加以下代码:
void DMAC_1_Handler() { mic.dmaHandler(); }
DMAC_1_Handler will be called by the DMAC when there the buffers are ready to process. This function is found by name, so just needs to exist to be called.
Add the following two methods to the public section of the Mic类:
void init() { analogReference(AR_INTERNAL2V23); _writer.init(); initBufferHeader(); configureDmaAdc(); } void reset() { _isRecordingReady = false; _isRecording = false; _writer.reset(); initBufferHeader(); }
init method contain code to initialize the Mic class. This method sets the correct voltage for the Mic pin, sets up the flash memory writer, writes the WAV file header, and configures the DMAC. The reset method resets the flash memory and re-writes the header after the audio has been captured and used.
In the main.cpp file, and an include directive for the mic.h头文件:
#include "mic.h"
在setup函数中,初始化C按钮。音频捕获将在按下此按钮时开始,并持续4秒:
pinMode(WIO_KEY_C, INPUT_PULLUP);
在此之下,初始化麦克风,然后在控制台上打印出音频已准备好录制的消息:
mic.init(); Serial.println("Ready.");
在loop函数上方,定义一个函数来处理捕获的音频。目前这个函数什么也不做,但在本课程的后面部分,它会将语音发送进行文本转换:
void processAudio() { }
在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按钮,如果按钮被按下且录音尚未开始,则调用_isRecording field of the Mic class is set to true. This will cause the audioCallback method of the Mic class to store audio until 4 seconds has been captured. Once 4 seconds of audio has been captured, the _isRecording field is set to false, and the _isRecordingReady field is set to true. This is then checked in the loop function, and when true the processAudio函数,然后重置mic类。
构建此代码,上传到你的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
你可以在这个代码记录/Wio Terminal文件夹中找到这段代码。
你的音频录制程序成功了!
声明:
本文件灏天文库团队进行了翻译。尽管我们力求准确,但请注意,翻译可能包含错误或不准确之处。原文档以其原始语言为准。我们不对因使用此翻译而产生的任何误解或误译负责。