学习并训练 tensorflow 的语音识别模型

学习并训练 tensorflow 的语音识别模型

介绍

玩了单片机有一段时间了,预期自己的AI智能管家的语音通话功能还相差万里,所以想尝试着看看能不能从现有的技术方案找一个合适的,我对这个模型的预期很简单:

  1. 能实现语音唤醒
  2. 能支持语音交互
  3. 可以支持nodejs最好
  4. 低功耗,高性能,响应速度快
  5. 容易部署,可以在香橙派上部署,如果能部署到单片机上最佳

为什么要这么设计的原因是为了未来我发布的产品能够集成这些功能并且实现最小化的维护成本和最快最稳定的部署成本。

正所谓只要用心啥事儿都有上帝帮忙的,这可不被我找到了speech-commands方案。下面就来看看它的部署方案。

环境搭建

1. Parcel 快速构建

由于是实例项目,只是为了学习它的功能调用,所以这里我使用的是 parcel,后期我也会大量的使用这个工具,因为它零配置开箱即用,对快速上手某个库很有帮助。

2. 关闭浏览器的https安全校验

  • Chrome和Edge浏览器:在浏览器地址栏输入chrome://flags/#unsafely-treat-insecure-origin-as-secure,然后将该设置从Disabled切换到Enabled,并在输入框中填写需要开启的域名,重启浏览器后生效。

  • Firefox浏览器:在浏览器地址栏输入about:config,搜索并修改以下两项为true

    1
    2
    media.devices.insecure.enabled = true
    media.getusermedia.insecure.enabled = true

    修改后重启浏览器。

上述两个问题解决了的话就可以正式开始进入开发环境了。

快速上手

1. 收集数据

1.1 首先第一步,我们需要先收集模型数据

以下代码添加到 <body> 标记的 <div id="console"> 前面,即可为应用添加一个简单的界面:

1
2
3
<button id="left" onmousedown="collect(0)" onmouseup="collect(null)">Left</button>
<button id="right" onmousedown="collect(1)" onmouseup="collect(null)">Right</button>
<button id="noise" onmousedown="collect(2)" onmouseup="collect(null)">Noise</button>

1.2 然后把以下的代码加到 index.js 中

注释掉之前的 predictWord 方法代码,添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const NUM_FRAMES = 3;
let examples = [];

function collect(label) {
if (recognizer.isListening()) {
return recognizer.stopListening();
}
if (label == null) {
return;
}
recognizer.listen(async ({ spectrogram: { frameSize, data } }) => {
let vals = normalize(data.subarray(-frameSize * NUM_FRAMES)); // 截取最后3帧数据
examples.push({ vals, label }); // 将数据(696个数字,频谱信息)和标签(左、右、噪音)存入examples数组
document.querySelector('#console').textContent =
`${examples.length} examples collected`;
}, {
overlapFactor: 0.999, // 重叠因子
includeSpectrogram: true, // 显示频谱图
invokeCallbackOnNoiseAndUnknown: true // 在噪音和未知情况下调用回调
});
}

function normalize(x) {
const mean = -100; // 均值 0
const std = 10; // 标准差 1
return x.map(x => (x - mean) / std); // 归一化
}

1.3 移除 app 中的 predictWord

1
2
3
4
5
async function app() {
recognizer = speechCommands.create('BROWSER_FFT');
await recognizer.ensureModelLoaded();
// predictWord() no longer called.
}

代码分解

Html 界面中添加的三个标记为“Left”“Right”和“Noise”的按钮,分别对应于我们希望模型识别的三个命令。按下这些按钮会调用我们新添加的 collect() 函数,该函数会为模型创建训练示例。

collect() 会将 labelrecognizer.listen() 的输出相关联。由于 includeSpectrogram 为 true, recognizer.listen() 会给出 1 秒音频的原始声谱图(频率数据),分 43 帧,因此每帧的音频时长约为 23 毫秒:

1
2
3
recognizer.listen(async ({spectrogram: {frameSize, data}}) => {
...
}, {includeSpectrogram: true});

由于我们想使用较短的声音而不是文字来控制滑块,因此我们仅考虑最后 3 帧(约 70 毫秒):

1
let vals = normalize(data.subarray(-frameSize * NUM_FRAMES));

为了避免数值问题,我们将数据归一化,使平均值为 0,标准差为 1。在这种情况下,声谱图值通常是 -100 左右的较大负值,偏差为 10:

1
2
3
const mean = -100;
const std = 10;
return x.map(x => (x - mean) / std);

最后,每个训练样本都包含 2 个字段:

  • label******:0、1 和 2 分别表示“左”和“右”和“噪音”。
  • vals******:696 个数字,包含频率信息(声谱图)

并将所有数据存储在 examples 变量中:

1
examples.push({vals, label});

2. 测试收集数据功能

注意:测试数据收集后,系统会将样本丢弃,因此不要浪费时间收集过多数据!

在浏览器中打开 index.html,您应该会看到与 3 个命令相对应的 3 个按钮。如果您通过本地文件访问麦克风,则必须启动网络服务器并使用 http://localhost:port/

如需收集每条指令的示例,请在 按住 每个按钮 3-4 秒的同时(或连续)发出一致的声音。官方建议收集大约150 个样本。我们可以用打响指代表 Left,用吹口哨代表 Right,启动/关闭电脑静音功能来表示 Noice

页面上显示的计数器会随着样本的增多而变大。可以随时通过在控制台中的 examples 变量调用 console.log() 来检查数据。此阶段的目标是测试数据收集流程。

3. 训练模型

第一步:在 html 的 noice 之后添加以下内容:

1
2
<br/><br/>
<button id="train" onclick="train()">Train</button>

第二步:将以下内容添加到 index.js 中的现有代码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
...more code
const INPUT_SHAPE = [NUM_FRAMES, 232, 1]; // 输入形状 每帧是 23 毫秒的音频,包含 232 个对应于不同频率的数字
let model;

/**
* 该函数用于训练模型。
* 函数内部可能包含训练的具体逻辑,例如数据迭代、参数更新、损失计算等操作。
* 具体的训练过程会根据不同的模型和训练数据而有所不同。
* @param {Object} model - 要训练的模型对象,该对象应该已经被初始化。
* @param {Array} data - 用于训练的数据,通常是一个数组,包含训练样本。
* @param {Object} options - 训练的选项,可能包含学习率、迭代次数等参数。
* @returns {Object} 训练好的模型对象,可能包含更新后的参数和训练结果信息。
*/
async function train() {
toggleButtons(false); // 禁用按钮
const ys = tf.oneHot(examples.map(e => e.label), 3); // 将标签转换为 one-hot 编码 左、右、噪音 3个类别 3个数字 0 1 2
const xsShape = [examples.length, ...INPUT_SHAPE]; // 输入形状 训练样本数量 输入形状 每帧是 23 毫秒的音频,包含 232 个对应于不同频率的数字
const xs = tf.tensor(flatten(examples.map(e => e.vals)), xsShape); // 将训练数据转换为张量

await model.fit(xs, ys, { // 训练模型 训练次数 10 次 每批次 16 个样本 每批次训练完后更新模型参数
batchSize: 16,
epochs: 10,
callbacks: {
onEpochEnd: (epoch, logs) => {
document.querySelector('#console').textContent =
`Accuracy: ${(logs.acc * 100).toFixed(1)}% Epoch: ${epoch + 1}`;
}
}
});
tf.dispose([xs, ys]); // 释放内存
toggleButtons(true); // 启用按钮
}

/**
* 该函数用于构建模型。
* 具体的构建逻辑需要根据实际情况在函数内部实现。
* 可能会涉及到数据处理、模型初始化、参数设置等操作。
* @param {Object} config - 构建模型所需的配置对象,包含各种参数信息。
* @returns {Object} 构建好的模型对象,具体结构取决于模型的实现。
*/
function buildModel() {
model = tf.sequential(); // 顺序模型
model.add(tf.layers.depthwiseConv2d({ // 2维卷积层
depthMultiplier: 8, // 8个卷积核
kernelSize: [NUM_FRAMES, 3], // 3个卷积核
activation: 'relu', // 激活函数
inputShape: INPUT_SHAPE // 输入形状
}));
model.add(tf.layers.maxPooling2d({ poolSize: [1, 2], strides: [2, 2] })); // 最大池化层
model.add(tf.layers.flatten()); // 展平层
model.add(tf.layers.dense({ units: 3, activation: 'softmax' })); // 全连接层
const optimizer = tf.train.adam(0.01); // 优化器 梯度下降算法 学习率 0.01
model.compile({ // 编译模型
optimizer, // 优化器
loss: 'categoricalCrossentropy', // 损失函数 交叉熵损失函数
metrics: ['accuracy'] // 准确率 预测正确的概率
});
}

function toggleButtons(enable) {
document.querySelectorAll('button').forEach(b => b.disabled = !enable);
}

/**
* 该函数用于将嵌套数组扁平化。
* 它接收一个数组作为输入,该数组可以包含任意深度的嵌套数组。
* 函数会递归地将嵌套数组中的元素提取出来,最终返回一个一维数组。
* @param {Array} arr - 要扁平化的数组
* @returns {Array} 扁平化后的一维数组
*/
function flatten(tensors) {
const size = tensors[0].length;
const result = new Float32Array(tensors.length * size);
tensors.forEach((arr, i) => result.set(arr, i * size));
return result;
}
...more code

第三步:在 app 函数中添加 buildModel() 方法:

1
2
3
4
5
async function app() {
recognizer = speechCommands.create('BROWSER_FFT');
await recognizer.ensureModelLoaded();
buildModel();
}

代码分解

概括来讲,我们要做两件事:buildModel() 定义模型架构,train() 使用收集的数据训练模型。

模型架构

该模型有 4 个层:用于处理音频数据的卷积层(表示为声谱图)、一个最大池层、一个扁平化层,以及一个映射到以下 3 个操作的密集层:

1
2
3
4
5
6
7
8
9
10
model = tf.sequential();  // 顺序模型
model.add(tf.layers.depthwiseConv2d({ // 2维卷积层
depthMultiplier: 8, // 8个卷积核
kernelSize: [NUM_FRAMES, 3], // 3个卷积核
activation: 'relu', // 激活函数
inputShape: INPUT_SHAPE // 输入形状
}));
model.add(tf.layers.maxPooling2d({ poolSize: [1, 2], strides: [2, 2] })); // 最大池化层
model.add(tf.layers.flatten()); // 展平层
model.add(tf.layers.dense({ units: 3, activation: 'softmax' })); // 全连接层

模型的输入形状是 [NUM_FRAMES, 232, 1],其中每帧是 23 毫秒的音频,包含 232 个对应于不同频率的数字(选择了 232 个,因为这是捕获人类语音所需的频率范围量)。在此 Codelab 中,我们将使用时长为 3 帧的样本(样本时长约为 70 毫秒),因为我们通过发出声音来控制滑块,而不是读出整个字词。

我们编译模型,使其准备好进行训练:

1
2
3
4
5
6
const optimizer = tf.train.adam(0.01);   // 优化器  梯度下降算法  学习率 0.01
model.compile({ // 编译模型
optimizer, // 优化器
loss: 'categoricalCrossentropy', // 损失函数 交叉熵损失函数
metrics: ['accuracy'] // 准确率 预测正确的概率
});

我们使用 Adam 优化器(深度学习中常用的优化器)和 categoricalCrossEntropy(用于分类的标准损失函数)处理损失函数。简而言之,它会测量预测的概率(每个类别一个概率)与真实类别中的概率为 100%,所有其他类别中的概率为 0% 的差距。我们还提供 accuracy 作为监控的指标,这可提供每个训练周期后模型正确获取样本的百分比。

训练

使用批量大小为 16 的数据对数据进行 10 次(周期)训练(一次处理 16 个样本),并在界面中显示当前的准确率:

1
2
3
4
5
6
7
8
9
10
await model.fit(xs, ys, {  // 训练模型  训练次数 10 次  每批次 16 个样本  每批次训练完后更新模型参数
batchSize: 16,
epochs: 10,
callbacks: {
onEpochEnd: (epoch, logs) => {
document.querySelector('#console').textContent =
`Accuracy: ${(logs.acc * 100).toFixed(1)}% Epoch: ${epoch + 1}`;
}
}
});

4. 实时更新监听声音

现在我们可以训练模型了。接下来添加一个可以验证训练模型结果的滑块,先将以下代码添加到index.html “Train”之后:

1
2
3
4
<button id="train" onclick="train()">Train</button>
<br/><br/>
<button id="listen" onclick="listen()">Listen</button>
<input type="range" id="output" min="0" max="10" step="0.1">

然后在 index.js 中添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 该函数用于将滑动条的值移动到指定的标签。
* 它接收一个标签张量作为输入,该张量包含预测的标签值。
* 函数会根据标签值更新滑动条的位置,以模拟滑动条的移动。
* @param {Tensor} labelTensor - 包含预测标签值的张量
* @returns {void}
*/
async function moveSlider(labelTensor) {
const label = (await labelTensor.data())[0]; // 获取标签值
document.getElementById('console').textContent = label; // 输出标签值
if (label == 2) {
return;
}
let delta = 0.1;
const prevValue = +document.getElementById('output').value; // 获取滑动条的当前值
document.getElementById('output').value = prevValue + (label === 0 ? -delta : delta);
}

/**
* 该函数用于开始监听语音输入。
* 它会检查当前是否正在监听语音输入,如果是,则停止监听;否则,开始监听语音输入。
* 监听过程中,函数会更新按钮的状态和文本,以模拟按钮的点击效果。
* @returns {void}
*/
function listen() {
if (recognizer.isListening()) {
recognizer.stopListening();
toggleButtons(true);
document.getElementById('listen').textContent = 'Listen';
return;
}
toggleButtons(false);
document.getElementById('listen').textContent = 'Stop';
document.getElementById('listen').disabled = false;

recognizer.listen(async ({ spectrogram: { frameSize, data } }) => {
const vals = normalize(data.subarray(-frameSize * NUM_FRAMES)); // 截取最后3帧数据
const input = tf.tensor(vals, [1, ...INPUT_SHAPE]); // 将数据转换为张量
const probs = model.predict(input); // 预测
const predLabel = probs.argMax(1); // 预测标签
await moveSlider(predLabel); // 移动滑动条 0 左 1 右 2 噪音
tf.dispose([input, probs, predLabel]); // 释放内存
}, {
overlapFactor: 0.999,
includeSpectrogram: true,
invokeCallbackOnNoiseAndUnknown: true
});
}

代码分解

实时预测

listen() 会监听麦克风并进行实时预测。该代码与 collect() 方法非常相似,该方法会将原始声谱图归一化并删除最后 NUM_FRAMES 帧以外的所有帧。唯一的区别在于,我们还会调用经过训练的模型,以获取预测结果:

1
2
3
const probs = model.predict(input);
const predLabel = probs.argMax(1);
await moveSlider(predLabel);

model.predict(input) 的输出是形状为 [1, numClasses] 的张量,表示类别数量的概率分布。简而言之,这只是每个可能输出类别的一组置信度,总和为 1。张量的外维度为 1,因为这是批次(单个样本)的大小。

为了将概率分布转换为表示最可能类别的单个整数,我们调用 probs.argMax(1) 以返回概率最高的类别索引。我们将一个“1”作为轴参数,因为我们想要计算最后一个维度 numClassesargMax

更新滑块

moveSlider():如果标签为 0(“左”),则减小滑块的值;如果标签为 1(“右”),则减小滑块的值;如果标签为 2(“噪声”),则忽略该值。

处置张量

要清理 GPU 内存,我们必须对输出张量手动调用 tf.dispose()。手动 tf.dispose() 的替代方案是将函数调用封装在 tf.tidy() 中,但这不能用于异步函数。

1
tf.dispose([input, probs, predLabel]);

5. 测试最终结果

在浏览器中打开 index.html,并按照与上一部分相同的操作,使用对应于 3 个命令的 3 个按钮收集数据。收集数据时,记得 按住 每个按钮 3-4 秒。

收集样本后,按 训练 按钮。这将开始训练模型,建议模型的准确率超过 90%。如果模型性能不佳,尝试收集更多数据。

训练完成后,按 Listen 按钮,使用麦克风进行声音输入来查看滑块的变化!

![结果](https://oss.jzxer.cn/blog/截屏2024-11-29 10.06.59.png)

后记

自己动手尝试过训练模型了之后,就觉得这种关于神经网络学习的东西确实是博大精深。虽然自己还只是入门,已经感受到那种思维的严谨和神奇了。

接下来就是尝试着把这种东西通过实战用起来了。

dev的艺术空间

参考资料:tensorflowjs

学习并训练 tensorflow 的语音识别模型

http://blog.jzxer.cn/20241128/20241128_learning_tf_voice/

作者

dev

发布于

2024-11-28

更新于

2024-12-22

许可协议

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×