视频讲解与演示:https://www.bilibili.com/video/BV13S4y137qU
1 简介
模组/芯片介绍
本项目使用了乐鑫公司的ESP32-S2-Mini-1模块,ESP32-S2-MINI-1是一颗通用型Wi-Fi MCU模组,功能强大,具有丰富的外设接口,可用于可穿戴电子设备、智能家居等场景。主要配置如下。
- Core: Xtensa® single-core 32-bit LX7 CPU, frequency up to 240MHz
- Memories:
- 128 KB of ROM
- 320 KB of SRAM
- 16 KB of RTCSRAM
- 4 MB of Flash memory
更多信息,请参考:https://www.eetree.cn/doc/detail/2298
开发板介绍
本项目使用硬禾学堂提供的使用上述模组设计的音频开发板,并集成以下外围电路和功能:
- 基于ESP32-S2 WiFi核心模块
- 128*64 OLED显示,SPI接口,显示信息、参数、波形
- 4个按键,用于参数控制、菜单选择
- 1路Mic音频输入 – 模拟电路,通过电位计可以调节增益0-40dB调节范围,并有带通滤波器
- 1路耳机插座音频输入 – 模拟电路,通过电位计可以调节增益 0-40dB调节范围,并有带通滤波器
- 2路音频输出,并有功率放大,可以驱动喇叭和耳机插座
- 一个FM接收模块,ESP32通过I2C接口对其进行参数设置,调节FM电台以及设置音量大小
- 一个模拟开关切换来自ESP32产生的音频还是FM输出的音频,模块开关的输出送到喇叭或耳机输出
更多信息,请参考:https://www.eetree.cn/project/detail/419
任务介绍
音频场景分类(ASC),是计算机声学领域的一项基本任务。其目的是将一段采集到的音频正确地分类为相应的环境标签,如狗叫声、下雨等。本项目在ESP32平台上实现了ASC的嵌入式部署,使其可以低功耗低运行在我们的生活中。本项目将介绍使用ESP32实现音频场景分类的各个流程,包括数据采集、特征提取、神经网络,和实现方法。
2 数据采集
观察原理图可以得知,Mic接收到的信号通过运算放大器U9放大,并可以通过RV1调节增益,最终接入ESP32 ADC输入中。因此采集部分根据参考手册和编程手册,使用DIG ADC1以20kHz的采样率来测量引脚的电压,并通过DMA(直接存储器访问)直接复制到内存中而无需CPU处理。本部分驱动程序直接使用了该项目开发好的,不过因为API有更新,我强行使用了已弃用的库函数,因此编译时会有警告产生,日后可以对此部分驱动升级。
https://www.eetree.cn/project/detail/537
3 特征提取
特征提取是对于音频感知来说最重要的一个步骤了。这一部分的最终目标是把一段接近一秒的音频转换为一张30×30的频谱图。接下来会讲解如何生成这个频谱图中的一列数据,
3.1 窗函数
首先我们截取采样到的音频数据中的连续的1×1024个点。这些点后续将会被进行傅里叶变换。在此之前为减少频谱泄露,我们会对这一段数据乘上一个中间大,最大为1,两边小并且逐渐接近0的函数,称为“窗函数”。我是用的窗是常用的汉宁窗(Hanning Window),具体可以参考:https://numpy.org/doc/stable/reference/generated/numpy.hanning.html
3.2 快速傅里叶变换
接下来对这1024个点作快速傅里叶变换。也就是说原本的信号的横轴是时间,体现了信号在不同时间的强度。变换完之后得到的便是这段时间内音频的能量在各个频率上分布的结果。如图3.1(a)所示,左边红色的信号为变换前的时域信号,右边则为变换后的频域的能量分布。如果您对傅里叶变换不了解,在此强烈推荐该科普视频:
https://www.bilibili.com/video/av19141078
对于实现部分,这里参考了实数傅里叶变换(RFFT)的开源代码:
https://github.com/willhope/Noise-reduction/blob/master/rfft.c
3.3 梅尔频谱
研究表明,人类对频率的感知并不是线性的,并且对低频信号的感知要比高频信号敏感。例如,人们可以比较容易地发现500和1000Hz的区别,确很难发现7500和8000Hz的区别。然而,傅里叶变换后的频谱的横轴是线性的。这时,梅尔标度(the Mel Scale)被提出,它是Hz的非线性变换,对于以mel scale为单位的信号,可以做到人们对于相同频率差别的信号的感知能力几乎相同。图3.1(b)展示了人耳对音调的感知和它们实际频率的关系。更多介绍请参考:
https://zhuanlan.zhihu.com/p/351956040
在本项目中,我使用了30组滤波器,因此原本长度1024的数组在变换后的长度为30。实现时参考并移植了STM32官方的音频处理库,包含了特征提取部分的常用功能,包括傅里叶变换、梅尔频谱等。由于ST使用了ARM的DSP来加速浮点运算,在移植到ESP32时还要全部替换成标准的数学函数,对于缺失的函数还要手动添加它的实现,并且最后解决各种编译问题,耗费了一番功夫。ST的音频处理库地址:
https://www.st.com/zh/embedded-software/fp-ai-sensing1.html
值得一提的是,ESP官方也有DSP库,不过由于本人时间关系没能仔细研究。
3.4 程序流程
如图3.2所示,要让程序实现上述过程,首先我创建了6个长度为256个uint16的DMA接收缓冲区。中断发生时,把当前时间以前的1024个点的数据取出,并归一化为浮点数,做快速傅里叶变换得到傅里叶频谱。之后再计算得到长度为30的数组,作为梅尔频谱图的一列。每隔512个采样点,计算1024个点得到的梅尔频谱的一列。当有40列结果计算出来后,这些数据便可以用来后面的分类。
4 模型训练
模型训练在个人电脑中,使用python脚本和Pytorch深度学习框架完成。
4.1 数据集
我们采用ESC-10数据集作为训练数据。ESC即环境声音分类,ESC10中包含了10个音频分类,每个分类中包含了40个音频片段。数据集下载地址:
https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/YDEPUT
此外常用的数据集还有UrbanSound8k等。下载好后使用上述的特征提取流程对其进行特征提取,得到每个音频片段的频谱图。不过在python环境中使用librosa库,仅需要两行代码便可完成傅里叶变换和梅尔频谱的操作。
4.2 模型结构
在特征提取后,音频分类问题就被转换成了图像分类问题。我们使用较简单的CNN卷积神经网络来对其进行分类。由于ESP32平台内存有限并且硬禾使用的是没有2M PSRAM的芯片型号,也就是只有320k的内置SRAM,因此网络的规模不能太大。一般在嵌入式平台中,内存大小限制了网络的规模,而CPU性能决定了网络能跑多快。对于CNN模型结构的可视化,推荐参考以下网站:
https://poloclub.github.io/cnn-explainer/
5 模型部署
模型训练好后,可以在ESP32中使用C语言实现相同的CNN模型结构。之后可以从pytorch训练好的模型中导出参数,将这些参数以数组的形式编译进程序中,就实现了模型的部署。ESP官方也推出了更加先进的深度学习库,可以直接转换并部署已经训练好的模型,并提供了一系列工具,不过由于个人时间关系还是手写了自制的模型推理代码。
6 程序框图
程序由于使用了之前作者的代码作为框架,因此还保留了DAC信号发生器等功能。具体操作为:
- 按键1:DAC开关(ADC关时有效);ADC数字滤波器参数设置(ADC开时)
- 按键2:ADC开关(DAC关时有效)
- 按键3:ADC开时:ADC模式调整(下文详细介绍);DAC开时:DAC音量调整
- 按键4:DAC频率切换
图6重点介绍了ADC模式时的程序逻辑。当ADC打开后,ADC_Task任务会开始执行并持续响应ADC DMA中断。之后根据ADC的按键选择的4种模式之一来执行对应的代码。
7 结果
最终实验现象为,板子上电运行后,会不断采集音频数据,之后把分类结果和概率展示在oled屏幕上,如图7所示。
附录 关键代码
神经网络推理过程代码如下,也体现了神经网络的结构。代码中调用的函数为自行实现的C语言卷积池化等操作的函数。
xSemaphoreTake( xMutexSpec, portMAX_DELAY );
conv2d_relu(&spec, 1, 30, 40, feature1, 10, conv1_1_w, conv1_1_b);
xSemaphoreGive( xMutexSpec );
conv2d_relu(feature1, 10, 28, 38, feature2, 10, conv1_2_w, conv1_2_b);
swap_feature(feature1, feature2);
max_pool2d(feature1, 10, 26, 36, feature2);
swap_feature(feature1, feature2);
conv2d_relu(feature1, 10, 13, 18, feature2, 10, conv2_1_w, conv2_1_b);
swap_feature(feature1, feature2);
conv2d_relu(feature1, 10, 11, 16, feature2, 10, conv2_2_w, conv2_2_b);
swap_feature(feature1, feature2);
max_pool2d(feature1, 10, 9, 14, feature2);
swap_feature(feature1, feature2);
flatten_fc(feature1, 10, 4, 7, out_neurons, 10, fc_w, fc_b);
神经网络推理代码,训练10轮,每轮约有2000个1秒的声音片段,batch_size为10。
for epoch in range(num_epochs): # loop over the dataset multiple times
running_loss = 0.0
correct = 0
for i, data in enumerate(asc_dataloader, 0):
# get the inputs; data is a list of [inputs, labels]
inputs, labels = data
labels = torch.tensor(labels, dtype=torch.long)
# zero the parameter gradients
optimizer.zero_grad()
# forward + backward + optimize
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
_, preds = torch.max(outputs, 1)
# show_images(inputs[:6], labels[:6], preds[:6])
correct += (preds == labels).sum()
# print statistics
running_loss += loss.item()
if i % 1 == 0: # print every 2000 mini-batches
print('Epoch [{}/{}], Step [{}/{}], Loss(Avg): {:.3f}'
.format(epoch + 1, num_epochs, i + 1, len(asc_dataloader), running_loss / 1))
running_loss = 0.0
print("Epoch {}, Acc: {}/{}".format(epoch + 1, correct, asc_dataset.__len__()))
correct = 0