TensorFlow.js - 使用遷移學習進行音訊辨識

1. 簡介

在本程式碼研究室中,您將建構音訊辨識網路,並透過該網路發出聲音來控制瀏覽器的滑桿。您將使用 TensorFlow.js,這個強大且彈性的 JavaScript 機器學習程式庫。

首先,您將載入並執行預先訓練模型,以便辨識 20 個語音指令。接著使用麥克風建立並訓練簡單的類神經網路,該網路可辨識你的聲音,並將滑桿向左或向右移動。

本程式碼研究室「不會」說明音訊辨識模型背後的理論。如有任何疑問,歡迎參閱這個教學課程

我們也建立了您在本程式碼研究室中找到的機器學習詞彙詞彙表

課程內容

  • 如何載入預先訓練的語音辨識模型
  • 如何使用麥克風進行即時預測
  • 如何透過瀏覽器麥克風訓練及使用自訂音訊辨識模型

讓我們進入正題吧

2. 需求條件

如要完成本程式碼研究室,您需符合以下條件:

  1. 使用最新版本的 Chrome 或其他新版瀏覽器。
  2. 文字編輯器,可在您的電腦本機或透過 CodepenGlitch 等網路執行。
  3. 具備 HTML、CSS、JavaScript 和 Chrome 開發人員工具 (或您偏好的瀏覽器開發人員工具) 的知識。
  4. 大致瞭解類神經網路的概念。如需相關簡介或複習,請觀看這部 3blue1brown 前的影片,或是這部 Ashi Krishnan 推出的「Deep Learning in JavaScript」影片

3. 載入 TensorFlow.js 和音訊模型

在編輯器中開啟 index.html,然後新增以下內容:

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/speech-commands"></script>
  </head>
  <body>
    <div id="console"></div>
    <script src="index.js"></script>
  </body>
</html>

第一個 <script> 標記會匯入 TensorFlow.js 程式庫,第二個 <script> 則會匯入預先訓練的語音指令模型<div id="console"> 標記將用於顯示模型的輸出內容。

4. 即時預測

接著,在程式碼編輯器中開啟/建立 index.js 檔案,並加入下列程式碼:

let recognizer;

function predictWord() {
 // Array of words that the recognizer is trained to recognize.
 const words = recognizer.wordLabels();
 recognizer.listen(({scores}) => {
   // Turn scores into a list of (score,word) pairs.
   scores = Array.from(scores).map((s, i) => ({score: s, word: words[i]}));
   // Find the most probable word.
   scores.sort((s1, s2) => s2.score - s1.score);
   document.querySelector('#console').textContent = scores[0].word;
 }, {probabilityThreshold: 0.75});
}

async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 predictWord();
}

app();

5. 測試預測

確認裝置有麥克風。值得一提的是,這也能在手機上使用!如要執行網頁,請在瀏覽器中開啟 index.html。如果您使用的是本機檔案,那麼若要存取麥克風,您必須啟動網路伺服器並使用 http://localhost:port/

如要在通訊埠 8000 啟動簡易網路伺服器,請按照下列指示操作:

python -m SimpleHTTPServer

系統可能需要一些時間來下載模型,請耐心等候。模型載入後,頁面頂端會顯示一個字詞。模型經過訓練,可辨識數字 0 到 9,並進行一些其他指令,例如「left」、「right」、「yes」、「no」等。

說出其中一個字詞。拼字正確嗎?使用 probabilityThreshold 控制模型的觸發頻率 – 0.75 表示模型在聽到特定字詞的信心程度超過 75% 時就會觸發。

如要進一步瞭解 Speech Commands 模型及其 API,請參閱 GitHub 上的 README.md

6. 收集資料

為了增添趣味,現在我們來使用簡短音效 (而非整個字詞) 來控制滑桿!

您會訓練模型辨識 3 個不同指令:「左」、「右」和「雜音」讓滑桿向左或向右移動正在辨識「雜音」(無需採取任何行動) 對語音偵測功能而言十分重要,因為我們希望滑桿只會在產生適當音效時做出反應,而不是在一般說話和移動時使用。

  1. 首先我們需要收集資料在 <div id="console"> 之前的 <body> 標記內,在應用程式中新增簡易 UI:
<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. 將此項目新增至 index.js
// One frame is ~23ms of audio.
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. app() 移除 predictWord()
async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 // predictWord() no longer called.
}

解析

這段程式碼一開始可能會讓人覺得不堪負荷,所以我們先來詳細說明。

我們根據我們希望模型能辨識的三個指令,在 UI 中新增了三個標示為「左」、「右」和「雜訊」的按鈕。按下這些按鈕會呼叫我們新增的 collect() 函式,以建立模型的訓練範例。

collect() 會將 labelrecognizer.listen() 的輸出內容建立關聯。由於 includeSpectrogram 為 true, recognizer.listen() 提供 1 秒音訊的原始頻譜 (頻率資料) 並分成 43 個影格,因此每個影格的音訊約為 23 毫秒:

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

由於我們希望使用簡短音效來控制滑桿,因此只會將最近 3 個畫面 (約 70 毫秒) 列入考量:

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

為了避免數字問題,我們會將資料正規化,平均分為 0 和標準差 1。在此範例中,光譜圖值通常為大約 -100 及偏差 10 的大負數:

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

最後,每個訓練範例有 2 個欄位:

  • label****:「左」、「右」為 0、1 和 2和「雜音」。
  • vals****:696 位數數字,呈現頻率資訊 (頻譜)

然後將所有資料儲存在 examples 變數中:

examples.push({vals, label});

7. 測試資料收集

在瀏覽器中開啟 index.html,您應該會看到與這 3 個指令相對應的 3 個按鈕。如果您使用的是本機檔案,則必須啟動網路伺服器並使用 http://localhost:port/,才能存取麥克風。

如要在通訊埠 8000 啟動簡易網路伺服器,請按照下列指示操作:

python -m SimpleHTTPServer

如要收集每個指令的範例,請重複 (或持續) 讓每個按鈕按住 3 到 4 秒。每個標籤應收集約 150 個樣本。例如,用手指扣住「左」鍵、用小聲朗讀「右」按鈕,並在靜音與說出「噪音」間切換。

隨著您收集到更多樣本,頁面上顯示的計數器可能會增加。您也可以針對控制台的 examples 變數呼叫 console.log() 來查看資料。這個階段的目標是測試資料收集程序,稍後當您測試整個應用程式時,您需要重新收集資料。

8. 訓練模型

  1. 新增「訓練」「噪音」下方index.html:
<br/><br/>
<button id="train" onclick="train()">Train</button>
  1. 將以下內容新增至 index.js 中的現有程式碼:
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;
}
  1. 在應用程式載入時呼叫 buildModel()
async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 // Add this line.
 buildModel();
}

此時,重新整理應用程式後,就會顯示新的「Train」(訓練)按鈕。您可以重新收集資料並點選「訓練」,或是等到步驟 10 再測試訓練和預測作業。

解析

大致來說,我們會執行以下兩件事:buildModel() 定義模型架構,train() 則使用收集的資料來訓練模型。

模型架構

這個模型包含 4 層:處理音訊資料的捲積層 (以光譜圖表示)、最大池層層、扁平層,以及對應至 3 個操作的稠密層:

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 個數字)。在本程式碼研究室中,我們將使用長度為 3 個影格 (約 70 毫秒的樣本) 的範例,因為我們要做出音效,而不是透過語音控制滑桿。

我們會編譯模型以做好訓練:

const optimizer = tf.train.adam(0.01);
 model.compile({
   optimizer,
   loss: 'categoricalCrossentropy',
   metrics: ['accuracy']
 });

我們使用用於深度學習的常見最佳化器 Adam 最佳化工具,以及用於分類的標準損失函式 categoricalCrossEntropy。簡單來說,這個指標會衡量預測機率 (每類別一次) 與真實類別 100% 機率相差多少,其他類別則為 0% 的機率。我們也提供 accuracy 做為監控指標,方便我們在每個訓練週期後模型正確計算樣本百分比。

訓練

訓練作業會以 16 的批量 (一次處理 16 個範例) 對資料執行 10 次 (訓練週期),且會在 UI 中顯示目前的準確率:

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}`;
     }
   }
 });

9. 即時更新滑桿

現在我們已能訓練模型,接著要加入程式碼來即時進行預測,並移動滑桿。請在「Train」(訓練) 之後新增這項資訊index.html 中的按鈕:

<br/><br/>
<button id="listen" onclick="listen()">Listen</button>
<input type="range" id="output" min="0" max="10" step="0.1">

index.js 中的以下內容:

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 影格以外的所有影格。唯一的差別在於我們也會呼叫訓練過的模型來取得預測結果:

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

model.predict(input) 的輸出內容是形狀 [1, numClasses] 的 Tensor,代表類別數量的可能性分佈。簡單來說,這只是每個可能的輸出類別 (總和為 1) 的一組信心值。Tensor 的外尺寸是 1,因為這是批次的大小 (單一例子)。

如要將機率分佈轉換為代表最有可能類別的單一整數,我們會呼叫 probs.argMax(1),傳回機率最高的類別索引。我們會在解碼時做為軸參數,因為我們要計算最後一個維度 numClassesargMax

更新滑桿

如果標籤為 0 (「左」) ,moveSlider() 會減少滑桿的值,如果標籤為 1 (「右」),則減少滑桿的值;如果標籤為 2,則忽略「雜訊」。

處理張量

如要清理 GPU 記憶體,我們必須針對輸出 Tensor 手動呼叫 tf.dispose()。手動 tf.dispose() 的替代方案是包裝 tf.tidy() 中的函式呼叫,但無法與非同步函式搭配使用。

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

10. 測試最終版應用程式

在瀏覽器中開啟 index.html,並透過與 3 指令相對應的 3 個按鈕,按照上一節所述的方式收集資料。收集資料時,請記得按住每個按鈕 3 到 4 秒。

收集到範例後,請按下「Train」按鈕。模型會開始訓練模型,模型的準確率應超過 90%。如果無法達到模型成效,請嘗試收集更多資料。

訓練完成後,按下「聆聽」按鈕透過麥克風進行預測,並控制滑桿!

如需更多教學課程,請前往 http://js.tensorflow.org/。