テクノロジー

CNNで軽量音声認識モデルを作ってTensorFlow.jsで動かしてみた

こんにちは。エンジニアの八尾です。
TensorFlow.js を使って、任意のキーワードを含む指示コマンド(短時間音声)をブラウザ上で分類できるか検証しました。今回はキーワードを「アシアル(asial)」にします。
音声データを元にしたCNN(畳み込みニューラルネットワーク)を使った約 100 KB の軽量音声認識モデルの作成手順と、Webアプリでの推論実装(マイク入力→MFCC→予測)までをまとめています。

ソースコード一式はこちらのリポジトリで公開しています。
https://github.com/ytatsuno/202601_voice_recognition

1. TensorFlow.js とは

TensorFlow.js は、ブラウザや Node.js 上で機械学習モデルの学習・推論ができる JavaScript 向けフレームワークです。
内部では Tensor(テンソル、テンサー) という多次元配列データ構造を使い、ニューラルネットワークとデータのやり取りを行います。
そのため、Webサイトや IoT デバイスなどでも、モデル推論を比較的手軽に組み込めます。

2. 「アシアル」を認識するカスタム音声モデルを作成

今回作成したモデルは、以下のクラスを分類します。

- 指示コマンド:`up, down, left, right, go, stop`
- 追加コマンド:`asial`
- 特殊クラス:`unknown`, `background_noise`

指示コマンド(短時間音声)のデータは、TensorFlow が提供している Speech Commands データセットを利用しました。
Speech Commands

2-1. 録音データを集める

MacBook Air の「ボイスメモ」で asial の音声を収録します。

  1. 「ボイスメモ」を開く
  2. 赤い録音ボタンを押す
  3. 0.5秒待ってから「アシアル」と1回発話
  4. 0.5秒待って停止
  5. タイトルを分かるように変更
    • 例:asial_yao_001(キーワード_話者名_連番)

目安として 1人あたり 100〜300 件用意します。今回は CNN かつ軽量モデルを利用しているため、比較的多くのデータが必要です。
(今回は検証のため、1人分のみ作成しました)

2-2. 録音データを成形する

Speech Commands 系の短時間音声運用に合わせるため、ffmpeg で音声の形式・長さを揃えます。

形式:WAV
コーデック:PCM(非圧縮)
サンプルレート:16,000 Hz
チャンネル:1(mono)
ビット深度:16-bit(pcm_s16le)
長さ:1.0秒(speech-commands系の短音コマンド運用に合わせやすい)

以下のようなスクリプトで、ffmpeg を利用して dataset ディレクトリ内の m4a ファイルを wav に変換しました。

# m4a => wav 変換
ffmpeg -i input.m4a -ac 1 -ar 16000 -c:a pcm_s16le input.wav
# 開始から0.5秒後から、長さ:1.0秒間の wav に変換
ffmpeg -i input.wav -ss 0.5 -t 1.0 -ac 1 -ar 16000 -c:a pcm_s16le cut.wav

2-3. 音声解析モデルの作成

モデル学習は Google Colab で行いました。
使用したノートブック(ipynb)はこちらです。
ipynbのリンク

処理の流れは以下の通りです。

  1. Speech Commands をダウンロード
    • 既存の指示コマンド(up, down, left, right, go, stop)と unknown 用データとして利用
  2. _background_noise_ ディレクトリに含まれる長尺ノイズ音声から 1 秒のノイズ片を作り、学習時にランダムにミックス
  3. 追加コマンド asial の音声を Google Drive(My Drive)から読み込み
  4. 前処理(特徴量変換)を実行
    1. 指示コマンドの音声WAV を 16kHz / mono / 1.0秒 / float32 に統一(重要)
    2. 波形(時間領域): Float32Array => Tensor1D化
    3. Tensor1D化した波形 => 短時間フーリエ変換(複素数)
    4. 波形の複素スペクトル => スペクトログラム(振幅(magnitude)を利用)へ変換
    5. スペクトログラム => 波形のメル尺度(音の高低の尺度) を計算
    6. 波形のメル尺度 => MFCC(メル周波数ケプストラム係数)のまとまりに変換
    7. モデル入力形状に合わせて Tensor を [T, 13] から [1, T, 13, 1] に次元拡張
  5. 成形したデータを用いて CNN(畳み込みニューラルネットワーク)を学習
  6. 学習済みモデルを TensorFlow.js 向けに変換し model.json を出力

2-4. 完成した音声解析モデル

3層CNNによるシンプルな構成です。

入力 (MFCC 13次元)
Conv2D (16)MaxPool
Conv2D (32)MaxPoolDropout
Conv2D (64)GlobalAvgPoolDropout
Dense (9クラス, softmax)

- パラメータ数:24,000
- ファイルサイズ: 約100KB

3. (ブラウザ側)音声推論処理の実装

こちらの音声認識のモデルを利用して以下のWebアプリを作成しました。

- 絵文字「:turtle:」を画面中央に描画
-「up」、「down」、「left」、「right」が音声認識で聞こえたら、「:turtle:」の向きを変更
-「go」 を音声認識で聞こえたら、「:turtle:」の位置を変更(向いている方向へ + 1-「asial」が聞こえたら「:turtle:」の大きさを40pxに変更し元の大きさに戻す変更
-「stop」 を音声認識で聞こえたら、「アプリ終了」を表示して voiceRecognition も止める

動作は以下のような動きとなりました。「asial」という言葉に反応して亀のサイズが変化しているのが分かります。

推論までの流れは以下です。

  1. AudioContext から音声データを Float32Array(1次元配列)として取得
  2. 取得した波形を、モデル入力形式(MFCC)へ変換(方式は学習時の前処理と同様)
  3. 1秒分のMFCCのまとまりについて、model.predict を実行

実装は以下の通りとなります。

www/js/asialVoiceRecognition.js


// ------------- グローバル変数 -------------
let model, labels;

// ------------- 音声の基本パラメータ -------------
// このサンプルは「16kHz / 1秒」の固定長クリップを前提にしています。
const SR = 16000;               // サンプリング周波数 (Hz)
const CLIP_SECONDS = 1.0;       // 1回の推論に使う長さ(秒)
const CLIP_SAMPLES = SR * CLIP_SECONDS; // 1秒ぶんのサンプル数(=16000)

// ------------- 特徴量(STFT / Mel / MFCC)のパラメータ -------------
// ★ここは学習時と一致している必要があります(窓長/ステップ/FFT長が違うと次元や分布がズレる)
const frameLength = 640; // 40ms (= 16000Hz * 0.04s)
const frameStep   = 320; // 20ms (= 16000Hz * 0.02s)
const fftLength   = 1024;

// Melフィルタバンクの数と、最終的に使うMFCC係数数
const numMelBins  = 40;
const numMfcc     = 13;

// Melフィルタの周波数レンジ
const lowerEdgeHz = 20.0;
const upperEdgeHz = SR / 2; // ナイキスト周波数(= 8000Hz)

// STFTの出力(rfft)の周波数bin数
// rfftのユニークbin数 = fftLength/2 + 1
const numSpectrogramBins = fftLength / 2 + 1;

// ------------- キャッシュ(毎フレーム作らない) -------------
// Mel重み行列とDCT行列は毎回作ると重いので、初回だけ作って保持します。
let melW = null;   // shape: [numSpectrogramBins, numMelBins]
let dctM = null;   // shape: [numMelBins, numMfcc]

// ------------- Hz <-> Mel 変換 -------------
function hzToMel(hz) {
  return 2595 * Math.log10(1 + hz / 700);
}
function melToHz(mel) {
  return 700 * (Math.pow(10, mel / 2595) - 1);
}

// ------------- Melフィルタバンク重み行列を生成 -------------
// 目的:
//   |spectrogram| (T x numSpectrogramBins) に掛けることで
//   mel (T x numMelBins) を得るための重み行列を作る
//
// 返り値:
//   shape [numSpectrogramBins, numMelBins]
//   ※あとで spectrogram.matMul(melW) する想定
function buildMelWeightMatrix(numMelBins, numSpectrogramBins, sampleRate, lowerEdgeHz, upperEdgeHz) {
  // numSpectrogramBins = fftLength/2 + 1 なので、fftLength は (numSpectrogramBins - 1) * 2 で戻せる
  const fftLen = (numSpectrogramBins - 1) * 2;

  // 周波数レンジをMelスケールに変換
  const lowerMel = hzToMel(lowerEdgeHz);
  const upperMel = hzToMel(upperEdgeHz);

  // Mel上で等間隔な「三角フィルタ」の中心点(+両端)を作る
  // 三角フィルタは numMelBins 個必要なので、境界も含めて numMelBins+2 点
  const melPoints = new Array(numMelBins + 2);
  for (let i = 0; i < melPoints.length; i++) {
    melPoints[i] = lowerMel + (upperMel - lowerMel) * (i / (numMelBins + 1));
  }

  // Mel点をHzに戻す
  const hzPoints = melPoints.map(melToHz);

  // Hz -> FFT bin index へ変換
  // (fftLen+1) を掛けているのは実装上の一般的な換算(学習実装に合わせるのが大事)
  const bin = hzPoints.map(hz => Math.floor((fftLen + 1) * hz / sampleRate));

  // 重み行列本体(2Dを1Dに詰めて最後にtensor2d化)
  // shape: [numSpectrogramBins, numMelBins]
  const data = new Float32Array(numSpectrogramBins * numMelBins);

  // m番目の三角フィルタ(m=1..numMelBins)を作る
  for (let m = 1; m <= numMelBins; m++) {
    // 三角の左端/中心/右端(FFT bin)
    const f0 = bin[m - 1];
    const f1 = bin[m];
    const f2 = bin[m + 1];

    // 上り部分:f0 -> f1 で 0 -> 1
    for (let k = f0; k < f1; k++) {
      if (k >= 0 && k < numSpectrogramBins && f1 !== f0) {
        const w = (k - f0) / (f1 - f0);
        data[k * numMelBins + (m - 1)] = w;
      }
    }

    // 下り部分:f1 -> f2 で 1 -> 0
    for (let k = f1; k < f2; k++) {
      if (k >= 0 && k < numSpectrogramBins && f2 !== f1) {
        const w = (f2 - k) / (f2 - f1);
        data[k * numMelBins + (m - 1)] = Math.max(0, w);
      }
    }
  }

  return tf.tensor2d(data, [numSpectrogramBins, numMelBins], 'float32');
}

// ------------- DCT-II(直交正規化)行列を生成 -------------
// 目的:
//   log-mel (T x numMelBins) に掛けて MFCC (T x numMfcc) を得る
//
// 返り値:
//   shape [numMelBins, numMfcc]
function buildDctMatrix(numMelBins, numMfcc) {
  const data = new Float32Array(numMelBins * numMfcc);

  // k=0 とそれ以外でスケールが違う(直交正規化)
  const scale0 = 1 / Math.sqrt(numMelBins);
  const scale  = Math.sqrt(2 / numMelBins);

  // n: mel bin index, k: mfcc index
  for (let k = 0; k < numMfcc; k++) {
    for (let n = 0; n < numMelBins; n++) {
      const basis = Math.cos(Math.PI / numMelBins * (n + 0.5) * k);
      data[n * numMfcc + k] = (k === 0 ? scale0 : scale) * basis;
    }
  }
  return tf.tensor2d(data, [numMelBins, numMfcc], 'float32');
}

// ------------- 特徴量変換用行列の初期化(キャッシュ) -------------
function ensureFeatureMatrices() {
  if (!melW) melW = buildMelWeightMatrix(numMelBins, numSpectrogramBins, SR, lowerEdgeHz, upperEdgeHz);
  if (!dctM) dctM = buildDctMatrix(numMelBins, numMfcc);
}

// -----------------------------------------------------------------------------
// 音声クリップ(1秒, 16kHz想定)をモデル入力用のMFCCテンソルに変換する
//
// 入力:
//   audio1d: Float32Array などの1次元配列
//            長さは CLIP_SAMPLES (= SR * CLIP_SECONDS) を想定
//
// 出力:
//   tf.Tensor, shape: [1, T, numMfcc(=13), 1]
//     - 先頭の 1 : バッチ次元(1クリップ分)
//     - T        : 時間フレーム数(frameLength / frameStep / 入力長で決まる)
//     - 13       : MFCC 次元(各フレームの特徴量数)
//     - 最後の 1 : チャンネル次元(CNN等「画像扱い」の入力形状に合わせるため)
//
// 処理の流れ:
//   wav(波形) → STFT(複素スペクトル) → |.|(振幅スペクトログラム)
//          → Melフィルタバンク → log → DCT → MFCC
//
// メモリ:
//   tf.tidy() により、この関数内で生成した中間Tensorは自動的に破棄される
//   (return する MFCC テンソルだけが tidy の外に残る)
// -----------------------------------------------------------------------------
function audioToMfcc(audio1d) {
  // Mel重み行列(melW)とDCT行列(dctM)を未作成なら生成してキャッシュする
  ensureFeatureMatrices();

  return tf.tidy(() => {
    // 1) 波形(時間領域): JS配列/TypedArray -> Tensor1D へ
    const wav = tf.tensor1d(audio1d);

    // 2) STFT: 短時間フーリエ変換(複素数)
    //    shape: [T, numSpectrogramBins](numSpectrogramBins = fftLength/2 + 1)
    const stft = tf.signal.stft(wav, frameLength, frameStep, fftLength);

    // 3) 複素スペクトル -> スペクトログラムへ
    //    ここでは振幅(magnitude)= abs を採用
    //    ※学習時が「パワー(magnitude^2)」なら square() 等に合わせる必要あり
    const spectrogram = tf.abs(stft); // shape: [T, numSpectrogramBins]

    // 4) Melフィルタバンク適用: 周波数bin -> Mel帯域へ集約
    //    (T x bins) @ (bins x mel) => (T x mel)
    const mel = spectrogram.matMul(melW); // shape: [T, numMelBins]

    // 5) log-Mel: 強度を対数圧縮(0回避のため微小値を加算)
    const logMel = tf.log(mel.add(1e-6)); // shape: [T, numMelBins]

    // 6) DCTでMFCCへ: (T x mel) @ (mel x mfcc) => (T x mfcc)
    //    Mel帯域の情報を少数次元(ここでは13)に圧縮し、音声識別に使いやすくする
    const mfcc = logMel.matMul(dctM); // shape: [T, numMfcc]

    // 7) モデル入力形状に合わせて次元追加
    //    [T, 13] -> [T, 13, 1] -> [1, T, 13, 1]
    return mfcc.expandDims(-1).expandDims(0);
  });
}

// ------------- モデルとラベルの読み込み -------------
async function loadAssets() {
  // 例: ./tfjs_model_tfonly/model.json と ./tfjs_model_tfonly/labels.json がある想定
  // model.json は tfjs-converter などで出力されたKeras互換のモデル
  model = await tf.loadLayersModel("./tfjs_model/model.json");

  // labels.json は { "labels": ["silence", "yes", ...] } のような形式を想定
  labels = await (await fetch("./tfjs_model/labels.json")).json();

  // ラベル一覧を画面に表示
  document.querySelector("#words").textContent = labels.labels.join(", ");
}

// ------------- マイク入力をループして推論する -------------
async function startMicLoop(onaudioprocessCallback) {
  // マイク許可を取り、音声ストリームを取得
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });

  // AudioContext を 16kHz 指定で生成(環境により厳密に16kHzにならない場合もあります)
  const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SR });

  // MediaStream -> AudioNode
  const source = audioCtx.createMediaStreamSource(stream);

  // ScriptProcessorNode(レガシーAPI)
  // 4096サンプル単位でコールバックが来る(16kHzなら約256msごと)
  // ※本格運用なら AudioWorklet が推奨ですが、最小構成としてScriptProcessorを使用
  const processor = audioCtx.createScriptProcessor(4096, 1, 1);

  // ここに入力音声を溜めていく(可変長)
  let buffer = new Float32Array(0);
  let running = true;

  // 音声が来るたびに呼ばれる
  processor.onaudioprocess = async (e) => {
    // モノラル1ch想定で0番を取る
    const input = e.inputBuffer.getChannelData(0);

    // 既存bufferの末尾に input を連結
    const tmp = new Float32Array(buffer.length + input.length);
    tmp.set(buffer, 0);
    tmp.set(input, buffer.length);
    buffer = tmp;

    // 1秒分たまったら推論
    // (重い場合は、推論間隔を長くする/キュー化/非同期制御などを検討)
    if (buffer.length >= CLIP_SAMPLES) {
      // 先頭から1秒を切り出し
      const clip = buffer.slice(0, CLIP_SAMPLES);

      // 次回に向けて半分(0.5秒)だけ残して捨てる = 0.5秒オーバーラップ
      buffer = buffer.slice(Math.floor(CLIP_SAMPLES / 2));

      // 特徴量化してモデル入力へ
      const x = audioToMfcc(clip);

      // 推論
      // model.predict は通常 Tensor を返す(分類なら shape [1, numLabels] 等)
      const y = model.predict(x);

      // Tensor -> JS配列へ(GPU/wasm→CPUへ転送が発生)
      const scores = await y.data();

      // tidy外のTensorなので明示破棄
      x.dispose();
      y.dispose();

      if(onaudioprocessCallback && typeof onaudioprocessCallback === 'function') {
        onaudioprocessCallback(scores, labels);
      }
    }
  };

  // ノード接続
  // source -> processor へ入力を流し、
  // processor -> destination へ繋がないとScriptProcessorが動かない実装が多いです
  source.connect(processor);
  processor.connect(audioCtx.destination);

  // 停止用コントローラを返す
  return {
    stop: async () => {
      running = false;

      try { processor.disconnect(); } catch {}
      try { source.disconnect(); } catch {}

      try {
        stream.getTracks().forEach(t => t.stop());
      } catch {}

      try {
        await audioCtx.close();
      } catch {}
    }
  };
}

index.html

<html>
  <head>
    <meta charset="utf-8" />
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>
    <style>
      html, body {
        margin: 0;
        padding: 0;
        height: 100%;
        background: #fefefe;  /* ★背景 */
        color: #111;
        font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", sans-serif;
      }

      /* 画面左上から 90% x 90% を使う */
      #stage {
        position: absolute;
        left: 0;
        top: 0;
        width: 90vw;
        height: 90vh;
        border: 1px solid rgba(0,0,0,0.15);
        box-sizing: border-box;
        overflow: hidden;
        background: #fefefe; /* ★背景(念のため) */
      }

      /* ★描画は canvas に寄せる */
      #gameCanvas {
        display: block;
        width: 100%;
        height: 100%;
      }

      #hud {
        position: absolute;
        left: 8px;
        bottom: 8px;
        font-size: 12px;
        opacity: 0.9;
        background: rgba(255,255,255,0.75);
        color: #111;
        padding: 6px 8px;
        border-radius: 8px;
        border: 1px solid rgba(0,0,0,0.08);
        backdrop-filter: blur(4px);
      }

      #endOverlay {
        position: absolute;
        inset: 0;
        display: none;
        align-items: center;
        justify-content: center;
        font-size: 32px;
        background: rgba(255,255,255,0.75);
        color: #111;
      }
    </style>
  </head>

  <body>
    <div id="stage">
      <canvas id="gameCanvas"></canvas>

      <div id="endOverlay">アプリ終了</div>
      <div id="hud">
        <div>labels: <span id="words">(loading...)</span></div>
        <div>heard: <span id="heard">(none)</span></div>
      </div>
    </div>

    <script src="./js/asialVoiceRecognition.js"></script>
    <script>
      // ---------------- DOM ----------------
      const stage = document.getElementById("stage");
      const canvas = document.getElementById("gameCanvas");
      const ctx = canvas.getContext("2d");
      const endOverlay = document.getElementById("endOverlay");
      const heardEl = document.getElementById("heard");

      // HUD表示は既存のまま利用
      const wordsEl = document.getElementById("words");

      // ---------------- 状態 ----------------
      const BASE_SIZE_PX = 28;
      const ASIAL_SIZE_PX = 40;

      // 「+1」の単位(グリッド1マス分)
      const STEP_PX = 30;

      // 方向(あなたの手元で動いている rot を踏襲)
      // ※rot は「絵文字の向き」を canvas で回転して描くために使う
      const DIRECTIONS = {
        up:    { dx:  0, dy: -1, rot:  90 },
        left:  { dx: -1, dy:  0, rot:   0 },
        down:  { dx:  0, dy:  1, rot: -90 },
        right: { dx:  1, dy:  0, rot: 180 },
      };

      let dir = "left";
      let gridX = 0;
      let gridY = 0;

      // 連打防止
      const COOLDOWN_MS = 700;
      let lastCmdAt = 0;

      // asial パルス
      let asialTimer = null;
      let turtleSizePx = BASE_SIZE_PX;

      // stop されたか
      let ended = false;

      // startMicLoop の controller
      let micController = null;

      // ★軌跡(通った地点を保存)
      // 中央を (0,0) とする「グリッド座標」で保存して、描画時に px に変換
      const trail = [{ x: 0, y: 0 }];

      // ---------------- 位置変換 ----------------
      function getStageSize() {
        return { w: stage.clientWidth, h: stage.clientHeight };
      }

      function gridToPx(gx, gy) {
        const { w, h } = getStageSize();
        const centerX = Math.floor(w / 2);
        const centerY = Math.floor(h / 2);
        return {
          x: centerX + gx * STEP_PX,
          y: centerY + gy * STEP_PX,
        };
      }

      // ---------------- canvas リサイズ ----------------
      function resizeCanvas() {
        // CSSサイズと実ピクセルを一致させる(高DPI対応)
        const { w, h } = getStageSize();
        const dpr = window.devicePixelRatio || 1;
        canvas.width = Math.floor(w * dpr);
        canvas.height = Math.floor(h * dpr);
        canvas.style.width = w + "px";
        canvas.style.height = h + "px";
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

        draw(); // リサイズ時に再描画
      }

      // ---------------- 描画 ----------------
      function clearBackground() {
        const { w, h } = getStageSize();
        ctx.clearRect(0, 0, w, h);
        ctx.fillStyle = "#fefefe";
        ctx.fillRect(0, 0, w, h);
      }

      function drawTrail() {
        if (trail.length < 2) return;

        ctx.save();
        ctx.lineWidth = 3;
        ctx.lineJoin = "round";
        ctx.lineCap = "round";
        ctx.strokeStyle = "rgba(20, 20, 20, 0.35)";

        ctx.beginPath();
        const p0 = gridToPx(trail[0].x, trail[0].y);
        ctx.moveTo(p0.x, p0.y);

        for (let i = 1; i < trail.length; i++) {
          const p = gridToPx(trail[i].x, trail[i].y);
          ctx.lineTo(p.x, p.y);
        }
        ctx.stroke();
        ctx.restore();

        // 点も少し強調(見やすく)
        ctx.save();
        ctx.fillStyle = "rgba(20, 20, 20, 0.35)";
        for (const t of trail) {
          const p = gridToPx(t.x, t.y);
          ctx.beginPath();
          ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.restore();
      }

      function drawTurtle() {
        const { w, h } = getStageSize();
        const margin = 20;

        let p = gridToPx(gridX, gridY);

        // クランプ(canvas 版でも同様)
        p.x = Math.max(margin, Math.min(w - margin, p.x));
        p.y = Math.max(margin, Math.min(h - margin, p.y));

        ctx.save();
        ctx.translate(p.x, p.y);
        ctx.rotate((DIRECTIONS[dir].rot * Math.PI) / 180);

        ctx.font = `${turtleSizePx}px system-ui, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`;
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillStyle = "#111";
        ctx.fillText("🐢", 0, 0);

        ctx.restore();
      }

      function draw() {
        clearBackground();
        drawTrail();
        drawTurtle();
      }

      // ---------------- 操作 ----------------
      function setDirection(newDir) {
        if (!DIRECTIONS[newDir]) return;
        dir = newDir;
        draw();
      }

      function goForward() {
        gridX += DIRECTIONS[dir].dx;
        gridY += DIRECTIONS[dir].dy;

        // ★軌跡に追加(同じ点連続追加は避ける)
        const last = trail[trail.length - 1];
        if (!last || last.x !== gridX || last.y !== gridY) {
          trail.push({ x: gridX, y: gridY });
        }

        draw();
      }

      function asialPulse() {
        clearTimeout(asialTimer);
        turtleSizePx = ASIAL_SIZE_PX;
        draw();
        asialTimer = setTimeout(() => {
          turtleSizePx = BASE_SIZE_PX;
          draw();
        }, 350);
      }

      function endApp() {
        if (ended) return;
        ended = true;

        endOverlay.style.display = "flex";

        if (micController && typeof micController.stop === "function") {
          micController.stop();
        }
      }

      function shouldAcceptCommand(now) {
        if (ended) return false;
        if (now - lastCmdAt < COOLDOWN_MS) return false;
        lastCmdAt = now;
        return true;
      }

      // ---------------- アプリ起動 ----------------
      async function app() {
        // 初期描画
        turtleSizePx = BASE_SIZE_PX;
        resizeCanvas();

        await loadAssets();

        // labels を HUD へ(asialVoiceRecognition.js 側が既に書いてくれるなら不要だが、念のため)
        if (wordsEl && window.labels && window.labels.labels) {
          wordsEl.textContent = window.labels.labels.join(", ");
        }

        micController = await startMicLoop((scores, labels) => {
          if (ended) return;

          // 推論結果のベストを探す
          let bestIdx = 0;
          for (let i = 1; i < scores.length; i++) {
            if (scores[i] > scores[bestIdx]) bestIdx = i;
          }

          const bestWord = labels.labels[bestIdx];
          const bestScore = scores[bestIdx];

          heardEl.textContent = `${bestWord} (${bestScore.toFixed(3)})`;

          const THRESHOLD = 0.60;
          if (bestScore < THRESHOLD) return;

          const now = Date.now();

          if (bestWord === "stop") {
            endApp();
            return;
          }

          if (!shouldAcceptCommand(now)) return;

          if (bestWord === "up" || bestWord === "down" || bestWord === "left" || bestWord === "right") {
            setDirection(bestWord);
            return;
          }

          if (bestWord === "go") {
            goForward();
            return;
          }

          if (bestWord === "asial") {
            asialPulse();
            return;
          }
        });
      }

      window.addEventListener("resize", resizeCanvas);

      app();
    </script>
  </body>
</html>

4. 感想

  • 今回は私の声だけで asial コマンドを学習したため、私以外に喋ってもらったときは精度が落ちやすいと感じました。
    複数人の音声データで学習し、汎化性能を上げたいです。
  • 今回のモデルは、約100KBと軽量で音声を推論できるため、推論側で同じ前処理(Float32Array → MFCC)を行えば、ブラウザ以外(アプリ/IoTデバイス等)でも同様に動かせる可能性があります。
    次は別プラットフォームへの移植性も検証してみたいです。
  • 短い単語(キーワード)を音声で認識し、Webアプリを操作できる点は、TensorFlow.js を使うことで実現できる新しいユーザビリティの可能性を感じました。
    • 例えば go や left のような単語を繰り返し発話して操作する仕組みは、英語の発音練習(正しい発音の定着)にもつながりそうです。
    • また、音声コマンドに会社名(例:asial)を組み込むことで、ユーザーの体験の中で自然にブランド名が触れられるため、ブランド認知を促す仕掛けとしても活用できると思いました。

参考

author img for Yao Yusuke
Yao Yusuke

Web系のシステムエンジニア。主にフロントエンド(html5、JS、TS)、バックエンド(JS、TS、PHP、Python)のWebアプリケーション構築、cordovaやReactNativeを用いたモバイルアプリケーション構築、IaaS(AWS等)を利用したシステム導入を行っています。

記事一覧

次の記事へ

一覧へ戻る

「テクノロジー」カテゴリの最新記事

PAGE TOP