老张让我用TensorFlow识别语音命令:前进、停止、左转、右转

4,577 阅读14分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

一、老张的需求

我有朋友叫老张,他是做传统单片机的。他经常搞一些简单的硬件发明,比如他家窗帘的打开和关闭,就是他亲自设计的电路板控制的。他布线很乱,从电视柜的插座直接扯电线到阳台的窗户,电线就像蜘蛛网一样纵横交错。

老张的媳妇是个强迫症、完美主义者,没法忍受乱扯的电线。但是,她又害怕影响自己的丈夫成为大发明家。于是,她就顺着电线绑上绿萝,这样就把电线隐藏起来了,丝毫不影响房间的美观。

上周,老张邀请我去他家里,参观这个电动窗帘。老张很激动,赶紧拿来凳子,站在凳子上,用拖把杆去戳一个红色的按钮。他激动地差点滑下来。我问他,你为什么要把开关放那么偏僻。老张说,是为了避免3岁的儿子频繁地按开关。老张站在凳子上,像跳天鹅湖一样,垫着脚尖努力去戳按钮,给我演示窗帘的开和关。

我赶紧夸他的窗帘非常棒。我不知道,就这种情况,如果他摔下来,从法律上讲我有没有连带责任。

我连忙转移注意力:哎,你家的绿萝长得挺好。我不自觉地摸了一下叶子,感觉手指头麻了一下。我去!老张你家绿萝带刺!

老张说,不是带刺,可能是带电。

我并没有太惊奇,因为我认识老张10多年了。我们先是高中3年同学,后来4年大学同学,后来又2年同事。在老张这里,什么奇怪的事情都可能发生。我还记得,高中时,他晚上抱着半个西瓜插着勺子,去厕所蹲坑,上下同步进行。他丝毫没有尴尬的意思,并称之为豁达。

我回忆起往事,痛苦不堪。今天被绿萝电了,也会成为往事。我起身准备要走。老张说,我搞了好多年嵌入式板子,你知道为什么一直没有起色?

我说,什么?你对电路板还能起色?!

老张说,不。我意思是说,我搞嵌入式工作这么多年,一直是平平无奇。主要原因,我感觉就是没有结合高新技术,比如人工智能。而你,现在就在搞人工智能。

我说,我能让你起色吗?

老张说,是的。我研究的巡逻小车,都是靠无线电控制的,我一按,就发送个电波。你帮我搞一个语音控制的。我一喊:跑哇!它就往前走。我一喊:站住!它就停止。我一喊:往左,往右!它就转弯。

我说,这个不难。但是,这有用吗?

老张说,是的,这很起色。据我所知,我们车间里,还没有人能想出来我这个想法。而我,马上就能做出来了。

我说,可以。你这个不复杂。但是,我也有个要求。那就是,我把你这个事情,写到博客里,也让网友了解一下,可以吗?

老张说:没问题!

二、我的研究

人工智能有三大常用领域,视觉、文字和语音。前两者,我写过很多。这次,开始对语音领域下手。

以下代码,环境要求 TensorFlow 2.6(2021年10之后版) + python 3.9。

2.1 语音的解析

我们所看到的,听到的,都是数据。体现到计算机,就是数字。

比如,我们看到下面这张像素图,是4*4的像素点。图上有两个紫色的点。你看上去,是这样。

其实,如果是黑白单通道,数据是这样:

[[255, 255, 255, 255],
[255, 131, 255, 255],
[255, 131, 255, 255],
[255, 255, 255, 255]]

如果是多通道,也就是彩色的,数据是下面这样:

[[[255, 255,255],[255, 255, 255],[255, 255, 255],[255, 255, 255]],
[[255, 255, 255],[198, 102, 145],[255, 255, 255],[255, 255, 255]],
[[255, 255, 255],[198, 102, 145],[255, 255, 255],[255, 255, 255]],
[[255, 255, 255],[255, 255, 255],[255, 255, 255],[255, 255, 255]]]

我们看到,空白都是255。只是那2个紫色的格子有变化。彩色值是[198, 102, 145],单色值是131。可以说,一切皆数据。

语音是被我们耳朵听到的。但是,实际上,它也是数据。

你要不信,我们来解析一个音频文件。

# 根据文件路径,解码音频文件
import tensorflow as tf
audio_binary = tf.io.read_file("datasets\\go\\1000.wav")
audio, rate = tf.audio.decode_wav(contents=audio_binary)

使用tf.audio.decode_wav可以读取和解码音频文件,返回音频文件的数据audio和采样率rate

其中,解析的数据audio打印如下:

<tf.Tensor: shape=(11146, 1), dtype=float32, numpy=
array([[-0.00238037],
       [-0.0038147 ],
       [-0.00335693],
       ...,
       [-0.00875854],
       [-0.00198364],
       [-0.00613403]], dtype=float32)>

上面的数据,形式是[[x]]。这表示,这段音频是单声道(类比黑白照片)。x是声道里面某一时刻具体的数值。其实它是一个波形,我们可以把它画出来。

import matplotlib.pyplot as plt
plt.plot(audio)
plt.show()

这个波的大小,就是推动你耳朵鼓膜的力度。

上面的图是11146个采样点的形状。下面,我们打印10个点的形状。这10个点就好比是推了你耳朵10下。

import matplotlib.pyplot as plt
plt.plot(audio[0:10])
plt.show()

至此,我们可以看出,音频实际上就是几组带有序列的数字。

要识别音频,就得首先分析音频数据的特征。

2.2 音频的频谱

每个个体都有自己的组成成分,他们是独一无二的。就像你一样。

但是,多个个体之间,也有相似之处。就像我们都是程序员。于是,我们可以用一种叫“谱”的东西来描述一个事物。比如,辣子鸡的菜谱。正是菜谱描述了放多少辣椒,用哪个部位的鸡肉,切成什么形状。这才让我们看到成品时,大喊一声:辣子鸡,而非糖醋鱼。

声音也有“谱”,一般用频谱描述。

声音是振动发生的,这个振动的频率是有谱的。

把一段声音分析出来包含哪些固定频率,就像是把一道菜分析出来由辣椒、鸡肉、豆瓣酱组成。再通过分析食材,最终我们判断出来是什么菜品。

声音也是一样,一段声波可以分析出来它的频率组成。如果想要详细了解“频谱”的知识,我有一篇万字长文详解《终于,掘金有人讲傅里叶变换了》。看完需要半个小时。

我上面说的,谷歌公司早就知道了。因此,他们在TensorFlow框架中,早就内置了获取音频频谱的函数。它采用的是短时傅里叶变换stft

waveform = tf.squeeze(audio, axis=-1)
spectrogram = tf.signal.stft(waveform, frame_length=255, frame_step=128)

我们上面通过tf.audio.decode_wav解析了音频文件,它返回的数据格式是[[-0.00238037][-0.0038147 ]]这种形式。

你可能好奇,它为什么不是[-0.00238037, -0.0038147 ]这种形式,非要外面再套一层。回忆一下,我们的紫色像素的例子,一个像素点表示为[[198, 102, 145]],这表示RGB三个色值通道描述一个彩色像素。其实,这里也一样,是兼容了多声道的情况。

但是,我们只要一个通道就好。所以需要通过tf.squeeze(audio, axis=-1)对数据进行降一个维度,把[[-0.00238037][-0.0038147 ]]变为[-0.00238037, -0.0038147 ]。这,才是一个纯粹的波形。嗯,这样才能交给傅里叶先生进行分析。

tf.signal.stft里面的参数,是指取小样的规则。就是从总波形里面,每隔多久取多少小样本进行分析。分析之后,我们也是可以像绘制波形一样,把分析的频谱结果绘制出来的。

看不懂上面的图没有关系,这很正常,非常正常,极其正常。因为,我即便用了一万多字,50多张图,专门做了详细的解释。但是依然,有20%左右的读者还是不明白。

不过,此时,你需要明白,一段声音的特性是可以通过科学的方法抽取出来的。这,就够了。

把特性抽取出来之后,我们就交给人工智能框架去训练了。

2.3 音频数据的预处理

上面,我们已经成功地获取到一段音频的重要灵魂:频谱。

下面,就该交给神经网络模型去训练了。

在正式交给模型之前,其实还有一些预处理工作要做。比如,给它切一切毛边,叠一叠,整理成同一个形状。

正如计算机只能识别0和1,很多框架也是只能接收固定的结构化数据。

举个简单的例子,你在训练古诗的时候,有五言的和七言的。比如:“床前明月光”和“一顿不吃饿得慌”两句。那么,最终都需要处理成一样的长短。要么前面加0,要么后边加0,要么把长的裁短。总之,必须一样长度才行。

床前明月光〇〇
一顿不吃饿得慌
蜀道难〇〇〇〇

那么,我们的音频数据如何处理呢?我们的音波数据经过短时傅里叶变换之后,格式是这样的:

<tf.Tensor: shape=(86, 129, 1), dtype=float32, numpy=
array([[[4.62073803e-01],
        ...,
        [2.92062759e-05]],

       [[3.96062881e-01],
        [2.01166332e-01],
        [2.09505502e-02],
        ...,
        [1.43915415e-04]]], dtype=float32)>

这是因为我们11146长度的音频,经过tf.signal.stftframe_step=128分割之后,可以分成86份。所以我们看到shape=(86, 129, 1)。那么,如果音频的长度变化,那么这个结构也会变。这样不好。

因此,我们首先要把音频的长度规范一下。因为采样率是16000,也就是1秒钟记录16000次音频数据。那么,我们不妨就拿1秒音频,也就是16000个长度,为一个标准单位。过长的,我们就裁剪掉后面的。过短的,我们就在后面补上0。

我说的这一系列操作,反映到代码上,就是下面这样:

waveform = tf.squeeze(audio, axis=-1)
input_len = 16000
waveform = waveform[:input_len]
zero_padding = tf.zeros([16000] - tf.shape(waveform),dtype=tf.float32)
waveform = tf.cast(waveform, dtype=tf.float32)
equal_length = tf.concat([waveform, zero_padding], 0)
spectrogram = tf.signal.stft(equal_length, frame_length=255, frame_step=128)
spectrogram = tf.abs(spectrogram)
spectrogram = spectrogram[..., tf.newaxis]

这时候,再来看看我们的频谱数据结构:

<tf.Tensor: shape=(124, 129, 1), dtype=float32, numpy=
array([[[4.62073803e-01],
        ...,
        [2.92062759e-05]],
...
        [0.00000000e+00],
        ...,
        [0.00000000e+00]]], dtype=float32)>

现在,不管你输入任何长短的音频,最终它的频谱都是shape=(124, 129, 1)。从图上我们也可以看出,不足的就算后面补0,也得凑成个16000长度。

下面,真的要开始构建神经网络了。

2.4 构建模型和训练

依照老张的要求……我现在不想提他,因为我的手指被绿萝电的还有点发麻。

依照要求……他要四种命令,分别是:前进、停止、左转、右转。那么,我就搞了四种音频,分别放在对应的文件夹下面。

从文件夹读取数据、将输入输出结对、按照比例分出数据集和验证集,以及把datasets划分为batch……这些操作,在TensorFlow中已经很成熟了。而且,随着版本的更新,越来越成熟。体现在代码上,就是字数越来越少。此处我就不说了,我会把完整代码上传到github,供诸君参考。

下面,我重点说一下,本例子中,实现语音分类,它的神经网络的结构,以及模型训练的配置。

import tensorflow as tf
from tensorflow.keras import layers
from tensorflow.keras import models

model = models.Sequential([
       layers.Input(shape= (124, 129, 1)),
       layers.Resizing(32, 32),
       layers.Normalization(),
       layers.Conv2D(32, 3, activation='relu'),
       layers.Conv2D(64, 3, activation='relu'),
       layers.MaxPooling2D(),
       layers.Dropout(0.25),
       layers.Flatten(),
       layers.Dense(128, activation='relu'),
       layers.Dropout(0.5),
       layers.Dense(4),
])
model.compile(
       optimizer=tf.keras.optimizers.Adam(),
       loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
       metrics=['accuracy']
)

其实,我感觉人工智能应用层面的开发,预处理和后处理比较难。中间的模型基本上都是有固定招式的。

第1层layers.Input(shape= (124, 129, 1))叫输入层,是训练样本的数据结构。就是我们上一节凑成16000之后,求频谱得出的(124, 129)这个结构。

最后一层layers.Dense(4),是输出层。我们搞了“走”,“停”,“左”,“右”4个文件夹分类,最终结果是4类,所以是4

头尾基本固定后,这个序列Sequential就意味着:吃音频文件,然后排出它是4个分类中的哪一种。

那么中间我们就可以自己操作了。Normalization是归一化。Conv2D是做卷积。MaxPooling2D是做池化。Dropout(0.25)是随机砍掉一定比例(此处是25%)的神经网络,以保证其健壮性。快结束时,通过Flatten()将多维数据拉平为一维数据。后面给个激活函数,收缩神经元个数,准备降落。最后,对接到Dense(4)

这就实现了,将前面16000个音频采样点,经过一系列转化后,最终输出为某个分类。

最后,进行训练和保存模型。

model = create_model()
cp_callback = tf.keras.callbacks.ModelCheckpoint(filepath='model/model.ckpt',
       save_weights_only=True,
       save_best_only=True)
history = model.fit(
       train_ds,
       validation_data=val_ds,
       epochs=50,
       callbacks=[cp_callback]
)

filepath='model/model.ckpt'表示训练完成后,存储的路径。save_weights_only=True只存储权重数据。save_best_only=True意思是只存储最好的训练的结果。调用训练很简单,调用model.fit,传入训练集、验证集、训练轮数、以及训练回调就可以啦。

2.5 加载模型并预测

上一节中,我们指定了模型的保存路径,调用model.fit后会将结果保存在对应的路径下。这就是我们最终要的产物:

我们可以加载这些文件,这样就让我们的程序具备了多年功力。可以对外来音频文件做预测。

model = create_model()
if os.path.exists('model/model.ckpt.index'):
       model.load_weights('model/model.ckpt')  
labels = ['go', 'left', 'right', 'stop']
# 音频文件转码
audio = get_audio_data('mysound.wav')
audios = np.array([audio])
predictions = model(audios)
index = np.argmax(predictions[0])
print(labels[index])

上面代码中,先加载了历史模型。然后,将我录制的一个mysound.wav文件进行预处理,方式就是前面说的凑成16000,然后通过短时傅里叶解析成(124, 129)结构的频谱数据。这也是我们训练时的模样。

最后,把它输入到模型。出于惯性,它会顺势输出这是'go'分类的语音指令。尽管这个模型,从来没有见过我这段动听的嗓音。但是它也能识别出来,我发出了一个包含'go'声音特性的声音。

以上,就是利用TensorFlow框架,实现声音分类的全过程。

音频分类项目开源地址:github.com/hlwgy/sound

再次提醒大家:要求TensorFlow 2.6(2021年10之后版) + python 3.9。因为,里面用了很多新特性。旧版本是跑不通的,具体体现在TensorFlow各种找不到层。

三、我们的合作

我带着成果去找老张。老张沉默了一会儿,不说话。

我说,老张啊,你就说吧。你不说话,我心里没底,不知道会发生啥。

老张说,兄弟啊,其实语音小车这个项目,没啥创意。我昨天才知道,我们车间老王,三年前,自己一个人,就做出来过了。说完,老张又沉默了。

我安慰他说,没关系的。这个不行,你就再换一个呗。

老张猛然抬起头,眼睛中闪着光,他说:兄弟,宇宙飞船相关的软件,你搞得定吗?!火星车也行。

我不紧不忙地关闭服务,并把电脑收进包里。

我穿上鞋,然后拿上包。打开门,回头跟老张说了一句:兄弟,三个月内,我们先不联系了吧。

我是ITF男孩,在掘金是TF男孩,带你从IT视角看世界。