1. 簡介
在本程式碼研究室中,您將建構音訊辨識網路,並透過該網路發出聲音來控制瀏覽器的滑桿。您將使用 TensorFlow.js,這個強大且彈性的 JavaScript 機器學習程式庫。
首先,您將載入並執行預先訓練模型,以便辨識 20 個語音指令。接著使用麥克風建立並訓練簡單的類神經網路,該網路可辨識你的聲音,並將滑桿向左或向右移動。
本程式碼研究室「不會」說明音訊辨識模型背後的理論。如有任何疑問,歡迎參閱這個教學課程。
我們也建立了您在本程式碼研究室中找到的機器學習詞彙詞彙表。
課程內容
- 如何載入預先訓練的語音辨識模型
- 如何使用麥克風進行即時預測
- 如何透過瀏覽器麥克風訓練及使用自訂音訊辨識模型
讓我們進入正題吧
2. 需求條件
如要完成本程式碼研究室,您需符合以下條件:
- 使用最新版本的 Chrome 或其他新版瀏覽器。
- 文字編輯器,可在您的電腦本機或透過 Codepen 或 Glitch 等網路執行。
- 具備 HTML、CSS、JavaScript 和 Chrome 開發人員工具 (或您偏好的瀏覽器開發人員工具) 的知識。
- 大致瞭解類神經網路的概念。如需相關簡介或複習,請觀看這部 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 個不同指令:「左」、「右」和「雜音」讓滑桿向左或向右移動正在辨識「雜音」(無需採取任何行動) 對語音偵測功能而言十分重要,因為我們希望滑桿只會在產生適當音效時做出反應,而不是在一般說話和移動時使用。
- 首先我們需要收集資料在
<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>
- 將此項目新增至
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);
}
- 從
app()
移除predictWord()
:
async function app() {
recognizer = speechCommands.create('BROWSER_FFT');
await recognizer.ensureModelLoaded();
// predictWord() no longer called.
}
解析
這段程式碼一開始可能會讓人覺得不堪負荷,所以我們先來詳細說明。
我們根據我們希望模型能辨識的三個指令,在 UI 中新增了三個標示為「左」、「右」和「雜訊」的按鈕。按下這些按鈕會呼叫我們新增的 collect()
函式,以建立模型的訓練範例。
collect()
會將 label
與 recognizer.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. 訓練模型
- 新增「訓練」「噪音」下方index.html:
<br/><br/>
<button id="train" onclick="train()">Train</button>
- 將以下內容新增至 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;
}
- 在應用程式載入時呼叫
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)
,傳回機率最高的類別索引。我們會在解碼時做為軸參數,因為我們要計算最後一個維度 numClasses
的 argMax
。
更新滑桿
如果標籤為 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/。