介绍
玩了单片机有一段时间了,预期自己的AI智能管家的语音通话功能还相差万里,所以想尝试着看看能不能从现有的技术方案找一个合适的,我对这个模型的预期很简单:
- 能实现语音唤醒
 
- 能支持语音交互
 
- 可以支持nodejs最好
 
- 低功耗,高性能,响应速度快
 
- 容易部署,可以在香橙派上部署,如果能部署到单片机上最佳
 
为什么要这么设计的原因是为了未来我发布的产品能够集成这些功能并且实现最小化的维护成本和最快最稳定的部署成本。
正所谓只要用心啥事儿都有上帝帮忙的,这可不被我找到了speech-commands方案。下面就来看看它的部署方案。
环境搭建
1. Parcel 快速构建
由于是实例项目,只是为了学习它的功能调用,所以这里我使用的是 parcel,后期我也会大量的使用这个工具,因为它零配置开箱即用,对快速上手某个库很有帮助。
2. 关闭浏览器的https安全校验
上述两个问题解决了的话就可以正式开始进入开发环境了。
快速上手
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));       examples.push({ vals, label });       document.querySelector('#console').textContent =       `${examples.length} examples collected`;   }, {     overlapFactor: 0.999,       includeSpectrogram: true,       invokeCallbackOnNoiseAndUnknown: true    }); }
  function normalize(x) {   const mean = -100;     const std = 10;     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();   }
  | 
 
代码分解
Html 界面中添加的三个标记为“Left”“Right”和“Noise”的按钮,分别对应于我们希望模型识别的三个命令。按下这些按钮会调用我们新添加的 collect() 函数,该函数会为模型创建训练示例。
collect() 会将 label 与 recognizer.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];   let model;
 
 
 
 
 
 
 
 
 
  async function train() {   toggleButtons(false);     const ys = tf.oneHot(examples.map(e => e.label), 3);     const xsShape = [examples.length, ...INPUT_SHAPE];    const xs = tf.tensor(flatten(examples.map(e => e.vals)), xsShape); 
    await model.fit(xs, ys, {       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);   }
 
 
 
 
 
 
 
  function buildModel() {   model = tf.sequential();     model.add(tf.layers.depthwiseConv2d({        depthMultiplier: 8,        kernelSize: [NUM_FRAMES, 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);      model.compile({       optimizer,       loss: 'categoricalCrossentropy',      metrics: ['accuracy']     }); }
  function toggleButtons(enable) {   document.querySelectorAll('button').forEach(b => b.disabled = !enable); }
 
 
 
 
 
 
 
  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({        depthMultiplier: 8,        kernelSize: [NUM_FRAMES, 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, {       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
   | 
 
 
 
 
 
  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); }
 
 
 
 
 
 
  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));      const input = tf.tensor(vals, [1, ...INPUT_SHAPE]);      const probs = model.predict(input);       const predLabel = probs.argMax(1);       await moveSlider(predLabel);       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”作为轴参数,因为我们想要计算最后一个维度 numClasses 的 argMax。
更新滑块
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 按钮,使用麦克风进行声音输入来查看滑块的变化!

后记
自己动手尝试过训练模型了之后,就觉得这种关于神经网络学习的东西确实是博大精深。虽然自己还只是入门,已经感受到那种思维的严谨和神奇了。
接下来就是尝试着把这种东西通过实战用起来了。
dev的艺术空间
参考资料:tensorflowjs