项目介绍
本项目是基于MAX78000实现的一款带有离线语音识别功能的小夜灯。项目采用MAX78000作为主控,识别语音控制命令并通过PWM对USB类的光源设备进行开关控制和亮度控制。
项目设计思路
MAX78000 的特点是能够超低功耗执行神经网络,个人理解其特性与Funpack2-1期的Syntiant ® NDP101神经决策处理器相类似, 那么发挥其优势的应用场景一定是边缘计算和功耗敏感相关应用场景。
你有出现过下面的情况吗?
总是出现上床睡觉时发现没关灯;
晚上回家或关灯后起夜时找不到开关的情况;
在学校的宿舍里到了熄灯时间谁也不愿意去关灯;
不知道你是不是经常遇到这样场景,我最近出现上述场景的概率倒还是挺高的,所以这次就打算以MAX78000这个平台为基础实现一个可以通过语音控制的小夜灯,方便自己能够在上述的场景或者其他不方便操作的情况下控制光源。
如果要实现声控小夜灯,那么首先是要有个小夜灯。翻了下自己的百宝箱找到了两个小夜灯,一个时在学校时买的磁吸式LED照明灯,另一个式前段时间某多多9.9包邮的小氛围灯。
搞定小灯问题后,还有几个问题需要解决,分别是设计语音控制词条并进行识别网络的训练与量化;小夜灯控制系统的硬件环境设计和识别工程的部署以及集成·。
前面已经明确该项目的场景和使用需求,总体上就是实现小夜灯的开关控制和亮度控制两项功能,所以在词条设计上能够满足上述两点需求即可。
词条设计列表:
开灯指令 | “开灯” |
关灯指令 | “关灯” |
调节指令 | “亮一点” “暗一点” |
定时关灯 | “定时” |
语音识别网络的训练与与量化参照官方文档进行操作即可,官方文档整体描述的还是比较清晰的,整体流程就是基于训练存储库ai8x-training进行深度学习模型开发和训练,基于综合存储库ai8x-synthesis对训练后的网络进行量化并使用“izer”工具将训练好的模型转换为C代码。
关于小夜灯控制系统的硬件,除了额外选择一个支持PWM控制的MOS管驱动模块用来驱动小夜灯。
在系统通过板载麦克风的语音通路识别到关键词后,通过MAX78000的TMR外设实现的PWM控制模块来对小夜灯进行控制。
整体的设计框图如下所示。
搜集素材的思路
KWS项目的素材收集主要就是关键词语音数据的收集过程,由于是定制化开发,所以就不能像官方的KWS-Demo一样采用开源的语音数据集用于模型的训练和测试。
这里我主要采用语音录制和语音合成的两种方式进行语音数据的收集。关于语音录制这块有两种思路,第一种是通过录音软件进行关键词的连续多次录音,然后编写脚本进行语音词段的分割;第二种是按照预期语音数据格式进行逐次录制,然后直接生成关键词语音数据。两种方式各有利弊,可以根据自己的喜好和习惯方式进行选择。
我这里采用的是第二种方法。官方是提供了一个语音录制脚本VoiceRecorder.py,但是存在着一些不足的地方,比如录制完之后不能确定录制音频的质量。
这里最先想起了之前Edge Impulse平台的语音数据收集功能,当时使用时感觉很方便,每次录完音可以直观的看到录音的质量,能够有效的判断出录音的质量(是否完整,是否超前或者滞后)。
一开始是想用Edge Impulse平台进行录音然后再导出,后来发现它的语音数据参数不能调整,所以最后是决定通过编写Python脚本实现类似Edge Impulse的语音数据的录制和分析功能;
在不修改模型和其他参数的前提下,整个KWS语音识别的效果好坏很大程度上是由语音数据收集的质量好坏决定的。这个项目其实是在这部分花费的时间是最多的,因为每次进行训练的时间是比较耗时的,如果训练结束发现效果不好,然后再反过来调整或者修改语音数据后整个流程需要再重新过一遍。
所以说在录音的过程中需要严格按网络模型的数据加载器的预期格式(1秒16 kHz单声道wav音频)进行录制。还有一点需要注意的是音量不要太小了,我前期录音的时候使用耳机麦克风进行录制的导致音量相对要小很多,那次的效果所有词基本上都不能正常识别。当时一度怀疑整个流程其他地方出问题了,后来通过与KWS Demo数据集进行对比时才发现这个原因。
录音脚本record.py的具体实现如下所示,包括采用pyaudio进行录音以及采用numpy和matplotlib进行音频数据时域分析和展现。
import wave
import pyaudio
import os
import shutil
import time
import wave
import numpy
import matplotlib.pyplot as plt
# 定义数据流块
CHUNK = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
# 录音时间
RECORD_SECONDS = 1
# 要写入的文件名
WAVE_OUTPUT_FILENAME = "output.wav"
# 创建PyAudio对象
p = pyaudio.PyAudio()
# 打开数据流
stream = p.open(format=FORMAT,
channels=CHANNELS,
rate=RATE,
input=True,
frames_per_buffer=CHUNK)
print("* recording")
OFF_TIME = int(RATE / CHUNK * 0.4)
# 开始录音
frames = []
for i in range(0, int(RATE / CHUNK * RECORD_SECONDS)+OFF_TIME):
data = stream.read(CHUNK)
if i >= OFF_TIME:
frames.append(data)
else:
continue
print("* done recording")
# 停止数据流
stream.stop_stream()
stream.close()
# 关闭PyAudio
p.terminate()
# 写入录音文件
wf = wave.open(WAVE_OUTPUT_FILENAME, 'wb')
wf.setnchannels(CHANNELS)
wf.setsampwidth(p.get_sample_size(FORMAT))
wf.setframerate(RATE)
wf.writeframes(b''.join(frames))
wf.close()
# 只读方式打开WAV文件
wf = wave.open('./output.wav', 'rb')
# 创建PyAudio对象
p = pyaudio.PyAudio()
stream = p.open(format = p.get_format_from_width(wf.getsampwidth()),
channels = wf.getnchannels(),
rate = wf.getframerate(),
output = True)
nframes = wf.getnframes()
framerate = wf.getframerate()
# 读取完整的帧数据到str_data中,这是一个string类型的数据
str_data = wf.readframes(nframes)
wf.close()
# 将波形数据转换成数组
wave_data = numpy.fromstring(str_data, dtype=numpy.short)
# 将wave_data数组改为2列,行数自动匹配
wave_data.shape = -1,CHANNELS
# 将数组转置
wave_data = wave_data.T
def time_plt():
# time也是一个数组,与wave_data[0]或wave_data[1]配对形成系列点坐标
time = numpy.arange(0, nframes)*(1.0/framerate)
# 绘制波形图
plt.subplot(211)
plt.plot(time, wave_data[0], c='r')
if CHANNELS == 2:
plt.subplot(212)
plt.plot(time, wave_data[1], c='g')
plt.xlabel('time (seconds)')
plt.show()
def freq():
# 采样点数,修改采样点数和起始位置进行不同位置和长度的音频波形分析
N = 44100
start = 0 # 开始采样位置
df = framerate/(N-1) # 分辨率
freq = [df*n for n in range(0, N)] # N个元素
wave_data2 = wave_data[0][start:start+N]
c = numpy.fft.fft(wave_data2)*2/N
# 常规显示采样频率一半的频谱
d = int(len(c)/2)
# 仅显示频率在4000以下的频谱
while freq[d] > 4000:
d -= 10
plt.plot(freq[:d-1], abs(c[:d-1]), 'r')
plt.show()
def main_plot():
time_plt()
# freq()
def mkdir(path):
folder = os.path.exists(path)
if not folder: #判断是否存在文件夹如果不存在则创建为文件夹
os.makedirs(path) #makedirs 创建文件时如果路径不存在会创建这个路径
if __name__ == '__main__':
main_plot()
laber = "kaideng"
mkdir(laber)
str_time = str(int(time.mktime(time.localtime())))
cp_name = './'+str(laber)+'/'+str(laber)+'_'+str_time+'.wav'
print("file:", cp_name)
shutil.copy('./output.wav', cp_name)
语音数据合成部分是基于pyttsx3库开发的Python脚本tts_gen.py生成的,脚本通过pypinyin 库将汉字转换成拼音标签然后通过tts进行语音数据生成并保存。
from ast import keyword
import pyttsx3
import os , shutil, time
from pypinyin import lazy_pinyin
def mkdir(path):
folder = os.path.exists(path)
if not folder: #判断是否存在文件夹如果不存在则创建为文件夹
os.makedirs(path) #makedirs 创建文件时如果路径不存在会创建这个路径
if __name__ == '__main__':
# main_plot()
key_word = "开灯"
pinyin_list = lazy_pinyin(key_word)
laber = ''.join(x for x in pinyin_list)
laber = laber +'_tts'
print(laber)
mkdir(laber)
str_time = str(int(time.mktime(time.localtime())))
cp_name = './'+str(laber)+'/'+str(laber)+'_'+str_time+'.mp3'
print("file:", cp_name)
engine = pyttsx3.init() # object creation5117
engine.save_to_file(key_word,cp_name)
engine.runAndWait()
engine.stop()
由于pyttsx3生成的语音数据直支持MP3格式,所以还需要一个脚本audio_transfor.py实现MP3格式转换WAV格式,这里用到的是ffmpy 库。
from ffmpy import FFmpeg
import os
# MP3转wav
def audio_transfor(audio_path: str, output_dir: str):
ext = os.path.basename(audio_path).strip().split('.')[-1]
if ext != 'mp3':
raise Exception('format is not mp3')
result = os.path.join(output_dir, '{}.{}'.format(os.path.basename(audio_path).strip().split('.')[0], 'wav'))
filter_cmd = '-f wav -ac 1 -ar 16000'
ff = FFmpeg(
inputs={
audio_path: None}, outputs={
result: filter_cmd})
print(ff.cmd)
ff.run()
return result
def handle(audio_dir: str, output_dir: str):
for x in os.listdir(audio_dir):
audio_transfor(os.path.join(audio_dir, x), output_dir)
if __name__ == '__main__':
handle('ttsmp3', 'ttswav')
训练实现过程
在前面的章节已经完成对语音数据的收集工作,在确保语音数据质量达标的前提,就可以逐步进行添加自定义命令词数据,调整数据加载器,修改网络模型参数,训练 ,量化,评估和综合。
1 添加自定义命令词数据
首先需要为新添加的关键词标签创建一个文件夹(例如:'kaideng')在ai8x-training/data/KWS/raw/kaideng文件夹中复制所有”开灯“语音样本到该文件夹中。
如果ai8x-training/data/KWS/processed存在,则需要删除它,数据加载器才可以重新创建带有其他标签的数据集,也就是每次语音数据集更新或发生变化都需重新生成processed部分。
操作时确保数据集的目录层次结构正确:
data -- KWS
|--raw: contains folders with labels name, each containing 1sec .wav file for that label
2 数据加载器调整
kws20项目的数据加载器脚本是ai8x-training/datasets/kws20.py文件。
添加新的标签(例如:'kaideng')到已排序的字典的正确位置(必须要按照标签搜字母进行排序),且具有唯一的增量值:
class_dict = {'backward': 0, 'bed': 1, 'bird': 2, 'cat': 3, 'dingshi': 4, 'dog': 5, 'down': 6,
'eight': 7, 'five': 8, 'follow': 9, 'forward': 10, 'four': 11, 'go': 12,'guandeng': 13,
'happy': 14, 'house': 15, 'kaideng': 16, 'learn': 17, 'left': 18, 'marvin': 19, 'nine': 20,
'no': 21, 'off': 22 ,'on': 23, 'one': 24, 'right': 25, 'seven': 26,
'sheila': 27, 'six': 28, 'stop': 29, 'three': 30,'tiaoanyidian': 31, 'tiaoliangyidian': 32,'tree': 33, 'two': 34,
'up': 35, 'visual': 36, 'wow': 37, 'yes': 38, 'zero': 39}
更新数据集中相关定义的“output”枚举。在“weight”中添加在kws20的基础上增加的词条标,最后一个类表示“未知”类别。
为了使优化器对每个类的样本数量保持平衡,可以将权重与每个标签的样本数量成反比。
具体的公式 weight[i] = (size of smallest label)/(size of label i)
{
'name': 'KWS_25', # 25 keywords
'input': (128, 128),
'output': ('up', 'down', 'left', 'right', 'stop', 'go', 'yes', 'no', 'on', 'off', 'one',
'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'zero','dingshi', 'guandeng', 'kaideng', 'tiaoliangyidian','tiaoanyidian',
'UNKNOWN'),
'weight': (1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 28, 28, 28, 28, 28,0.14),
'loader': KWS_25_get_datasets,
}
调整KWS_get_datasets函数适配25词的KWS词条列表,通过上面的“output”枚举去自动索引。
if num_classes in (6, 25):
classes = next((e for _, e in enumerate(datasets)
if len(e['output']) - 1 == num_classes))['output'][:-1]
print("classes", classes)
else:
raise ValueError(f'Unsupported num_classes {num_classes}')
创建KWS_25_get_datasets函数以返回新定义的分类类的数量。
def KWS_25_get_datasets(data, load_train=True, load_test=True):
return KWS_get_datasets(data, load_train, load_test, num_classes=25)
3 修改网络模型参数
更新ai8x-training/models/ai85net-kws20.py中初始化部分的num_classes变量默认值,根据自己的分类数进行调整。
# num_classes = n keywords + 1 unknown
def __init__(
self,
num_classes=26, # was 21
num_channels=128,
dimensions=(128, 1),
fc_inputs=7,
bias=False,
**kwargs
):
4 训练
训练环境的搭建是在电脑上安装Win+Ubuntu20.03双系统,然后按照官方训练环境搭建文档进行一步步操作搭建起来的,这部分官方文档描述的比较清楚,这里就不再赘述了。
训练一开始会从所有标记样本中创建处理数据集,大概需要几个小时左右。如果不更新数据集的话,第二次会直接开始训练。
进入ai8x-training目录激活python训练环境后,执行训练命令进行训练
python train.py --epochs 100 --optimizer Adam --lr 0.001 --wd 0 --deterministic --compress policies/schedule_kws20.yaml --model ai85kws20net --dataset KWS_25 --confusion --device MAX78000 "$@"
在这个过程中如果出现问题,可以根据log信息进行定位或者再重新按照文档说明重新梳理下自己的操作流程,一般的问题都可以解决。
这里我遇到的大部分问题是增加词条时操作和修改数据加载器的过程。
训练结束后会在ai8x-training/logs/目录生成对应的训练结果相关文件,后面评估量化评估需要用到。
5 量化
量化采用的qat方式,我这里为方便操作首先把前面训练好的数据拷贝到ai8x-synthesis/proj/目录,然后执行量化命令;
python quantize.py proj/qat_best.pth.tar proj/kws25-qat8-q.pth.tar --device MAX78000 -v "$@"
量化结束后会在ai8x-synthesis/proj/目录生成量化后数据文件。
6 评估和综合
量化完成后可以对量化后结果进行评估,
python train.py --model ai85kws20net --dataset KWS_25 --confusion --evaluate --exp-load-weights-from ../ai8x-synthesis/proj/kws25-qat8-q.pth.tar -8 --device MAX78000 "$@"
再回到ai8x-synthesis/目录通过ai8xize.py脚本执行转换命令,输出结果生成在ai8x-synthesis/demo/kws25_v1目录。
python ai8xize.py --test-dir demo --prefix kws25_v1 --checkpoint-file proj/kws25-qat8-q.pth.tar --config-file networks/kws20-hwc.yaml --softmax --device MAX78000 --timer 0 --display-checkpoint --verbose "$@"
7 更新kws20_demo Demo应用程序
将kws20_demo的工程中的cnn.c, cnn.h, weights.h, sampledata.h 4个文件替换为KAT C代码生成的(上一步生成)。
更新main.c的识别词数组
const char keywords[NUM_OUTPUTS][20] = {"up", "down", "left", "right", "stop", "go", "yes", "no", "on", "off", "one","two", "three", "four", "five", "six", "seven", "eight", "nine", "zero","dingshi", "guandeng", "kaideng", "tiaoliangyidian","tiaoanyidian","UNKNOWN"};
适配PWM控制逻辑,再识别到对应关键词后做出对应的响应,PWM控制实现过程可以参照进度里面的描述。
/* find detected class with max probability */
ret = check_inference(ml_softmax, ml_data, &out_class, &probability);
PR_DEBUG("----------------------------------------- \n");
if (!ret) {
PR_DEBUG("LOW CONFIDENCE!: ");
}
else{
if(out_class == 21){// guangdeng
if(PWM_CTRL_STATE != 21)
{
SetPWMDuty(1);
PWM_CTRL_Disable();
// MXC_GPIO_OutClr(MXC_GPIO2, MXC_GPIO_PIN_4);
PWM_CTRL_STATE = 21;
PR_DEBUG("guandeng \r\n");
}
}
else if(out_class == 22){//kaideng
if(PWM_CTRL_STATE != 22)
{
PWMTimer_Setup();
PWM_CTRL_STATE = 22;
PWM_CTRL_DUTY = 100;
PR_DEBUG("kaideng \r\n");
}
}
else if(out_class == 20){//dingshi
}
else if(out_class == 23){//liangyidian
PWM_CTRL_DUTY = PWM_CTRL_DUTY + PWM_CTRL_DUTY_OFFSET;
if(PWM_CTRL_DUTY>80){
PWM_CTRL_DUTY = 80;
}
SetPWMDuty(PWM_CTRL_DUTY);
PR_DEBUG("liangyidian \r\n");
}
else if(out_class == 24){//anyidian
PWM_CTRL_DUTY = PWM_CTRL_DUTY - PWM_CTRL_DUTY_OFFSET;
if(PWM_CTRL_DUTY<=20){
PWM_CTRL_DUTY = 20;
}
SetPWMDuty(PWM_CTRL_DUTY);
PR_DEBUG("anyidian \r\n");
}
}
PR_DEBUG("Detected word:(%d) %s (%0.1f%%)",out_class, keywords[out_class], probability);
PR_DEBUG("\n----------------------------------------- \n");
实现结果展示
对于本人的整体识别效果还是比较理想想,能够实现小夜灯的开关控制和明暗控制。具体的识别效果可以看视频中的演示效果,硬件上输出控制端口采用USB端口设计,可以很方便的切换加里的其他种类USB供电类的电源设备。
遇到的主要难题及解决方法,或未来的计划或建议等
1 Ubuntu环境相关
在搭建好整个环境后,系统已经可以正常进行训练的情况下电脑重启后ubuntu系统的NVIDIA显卡驱动就全没了。第一次是重装所有驱动。后来查阅资料了解的其原因是系统会自己升级内核,一重启就自动更新到新的内核。后面是通过设置禁止更新内核基本解决了。
2 语音数据收集相关
在语音数据收集过程中主要遇到了 数据展现、音频增益、Pyaudio录音延时等问题。
按照一开始的方法进行语音数据收集(使用官方的录音脚本)发现训练后的生成的网络一个词也不能识别,后来改用自己的录音脚本进行重新数据收集训练部署后发现是可以识别几个词了,但整体效果还不是很好。
在排除了前两个问题(数据收集部分描述了)后发现还存在一个问题,就是在使用Python脚本进行录音时会有200ms左右的无数据区段,因此后面进行了录音脚本的优化包括考虑到人的响应时间在时间轴上加了400ms的时间并做偏移切断处理,是最终录音效果尽量符合语音录制的环境。
3 工程部署相关
工程部署时遇到的问题主要有电平匹配问题和PWM控制问题。
通过查阅数据手册和相关例程了解到MAX78000的IO支持3.3和1.8两种电平,通过GPIO初始化时进行配置。
计划:
由于数据集增加命令词数据大部分时本人录制的,所以说整体识别效果对于我本人来说还算可以,但对于其他人的识别效果可能存在一些不理想的情况。所以后面如果有机会能够获取到更多数据的话,再进行重新进行训练部署。
也是由于上述的原因,所以想自己移植得话也要按照上诉步骤去进行录音和训练才能获得一个相对理想的识别效果。
建议:
最近在写项目报告时感觉最新的Ctrl+S保存的方式很方便,所以建议可以把项目进度的内容编辑也添加支持Ctrl+S保存。
- Making Your Own Audio and Image Classification Application Using Keyword Spotting and Cats-vs-Dogs Examples 文档
- ubuntu内核升级导致显卡驱动丢失解决方案
- ubuntu禁止自动更新内核方法
- 数据手册
- MAX78000板卡项目汇总
- 应用笔记