結論:RMS 値だけで「音声あり」を判定すると、キーボード音や語尾の短い無音で状態が頻繁に反転します。フレーム単位のエネルギー判定に、開始・終了で異なる連続フレーム数を使うヒステリシスを加えると、依存パッケージなしでも安定した VAD(Voice Activity Detection)になります。
リアルタイム文字起こし、会話ボット、面接練習のような音声アプリでは、「いつ STT に渡し始め、いつ発話を確定するか」が待ち時間と認識品質を左右します。本稿ではブラウザが返す PCM サンプルだけを使い、最小構成の VAD ゲートを JavaScript で実装します。
この図が示すとおり、閾値をまたいだ 1 フレームだけでは状態を切り替えません。開始時は数フレームの音声を確認し、終了時は少し長めの無音を確認します。
なぜ単純なしきい値判定だけでは壊れるのか
PCM の各サンプルを -1 から 1 に正規化済みとします。フレームの音量は RMS(root mean square)で求められます。
RMS = √(Σx² / N)
例えば 48 kHz、1024 samples のフレームは約 21.3 ms です。次のような単純な判定は、実装こそ短いものの実用では不安定です。
const speaking = rms >= 0.02;
入力レベルがしきい値付近で揺れると、数十 ms ごとに speaking が true / false を繰り返します。このチャタリングにより、STT への送信が細切れになったり、発話の途中で終了イベントが出たりします。
対策は次の 2 つです。
- 開始判定:しきい値以上が startFrames 回連続してから開始する
- 終了判定:しきい値未満が endFrames 回連続してから終了する
終了側を長くすると、単語間の自然な間で発話を切りません。これが時間方向のヒステリシスです。
最小の VAD ゲートを実装する
以下は入力フレームを受け取り、speechStart と speechEnd だけを返す実装です。特定の STT やクラウド API には依存しません。
class VadGate {
#active = false;
#speechFrames = 0;
#silenceFrames = 0;
constructor({ threshold = 0.02, startFrames = 6, endFrames = 15 } = {}) {
this.threshold = threshold;
this.startFrames = startFrames;
this.endFrames = endFrames;
}
process(samples) {
let sum = 0;
for (const sample of samples) sum += sample * sample;
const rms = Math.sqrt(sum / samples.length);
const aboveThreshold = rms >= this.threshold;
if (aboveThreshold) {
this.#speechFrames += 1;
this.#silenceFrames = 0;
if (!this.#active && this.#speechFrames >= this.startFrames) {
this.#active = true;
return { event: "speechStart", rms };
}
} else {
this.#silenceFrames += 1;
this.#speechFrames = 0;
if (this.#active && this.#silenceFrames >= this.endFrames) {
this.#active = false;
return { event: "speechEnd", rms };
}
}
return { event: null, rms };
}
}
threshold: 0.02、startFrames: 6、endFrames: 15 はあくまで初期値です。1024 samples / 48 kHz なら、開始は約 128 ms、終了は約 320 ms 待つ計算になります。
ブラウザのマイク入力につなぐ
Web Audio API の AnalyserNode を使うと、マイク入力から正規化済みの float PCM を取り出せます。ここでは状態遷移の見通しを優先し、監視ループとして requestAnimationFrame を使います。
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const context = new AudioContext();
const source = context.createMediaStreamSource(stream);
const analyser = context.createAnalyser();
analyser.fftSize = 1024;
source.connect(analyser);
const samples = new Float32Array(analyser.fftSize);
const gate = new VadGate({
threshold: 0.02,
startFrames: 6,
endFrames: 15,
});
function tick() {
analyser.getFloatTimeDomainData(samples);
const { event, rms } = gate.process(samples);
if (event === "speechStart") {
console.log("STT のストリームを開始", { rms });
}
if (event === "speechEnd") {
console.log("発話を確定", { rms });
}
requestAnimationFrame(tick);
}
tick();
getFloatTimeDomainData() は時系列の PCM データを返します。仕様とサンプルの詳細は MDN の AnalyserNode ドキュメント を参照してください。
高頻度の音声処理でメインスレッドの描画タイミングに依存したくない場合は、同じ VadGate の考え方を AudioWorklet に移します。重要なのは API の選択よりも、音量判定と状態遷移を分けることです。
パラメータをフレーム時間から決める
フレーム数を固定値で覚えるより、先に許容する時間を決める方が調整しやすくなります。
| 目的 | 例 | 1024 samples / 48 kHz の目安 |
|---|---|---|
| 発話開始の遅延 | 短いノイズを無視する | 6 frames ≒ 128 ms |
| 発話終了の猶予 | 単語間の間を保持する | 15 frames ≒ 320 ms |
| 最大発話長 | 無限ストリームを避ける | 別のタイマーで制限する |
環境ノイズが大きい場所では、固定の 0.02 が低すぎる場合があります。最初の数秒を「無音区間」とみなし、その RMS の中央値に余裕を足す方法が扱いやすいです。
const median = (values) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
};
const noiseFloor = median(idleRmsValues);
const threshold = Math.max(0.01, noiseFloor * 2.5);
ただし、ユーザーが最初から話しているとこの初期化は破綻します。実サービスでは、設定画面で短いマイクチェックを行うか、しきい値をゆっくり追従させつつ上限・下限を設けるのが安全です。
実行できる遷移テスト
状態機械は、音声データそのものより「どの入力列でイベントが出るか」をテストすると壊れにくくなります。次のテストでは、小さい振幅のフレーム 2 回、音声フレーム 3 回、無音フレーム 4 回を与えます。
const frame = (amplitude) =>
Float32Array.from({ length: 320 }, () => amplitude);
const gate = new VadGate({
threshold: 0.02,
startFrames: 3,
endFrames: 4,
});
const events = [
...Array.from({ length: 2 }, () => gate.process(frame(0.01))),
...Array.from({ length: 3 }, () => gate.process(frame(0.05))),
...Array.from({ length: 4 }, () => gate.process(frame(0.005))),
].flatMap(({ event }) => (event ? [event] : []));
console.assert(
JSON.stringify(events) === JSON.stringify(["speechStart", "speechEnd"])
);
このテストは Node.js 22 で実行し、speechStart -> speechEnd となることを確認しました。開始前の小さいノイズではイベントを出さず、指定回数の連続入力でのみ開始・終了します。
RMS VAD の限界と次の一手
RMS VAD は「音量」を見るだけなので、キーボード音、空調、BGMも発話として扱います。まずは低コストなゲートとして使い、誤検出が問題になった時点で次の順に強化するのが現実的です。
- マイクチェックからノイズフロアを推定する
- 周波数帯域を絞り、低域の振動や高域ノイズを減らす
- WebRTC VAD や学習済み VAD を導入し、音声らしさを判定する
- VAD の開始・終了時刻と STT の確定結果をログに取り、閾値を検証する
いきなり高精度モデルを入れるより、先にこの状態遷移を観測可能にしておくと、遅延・コスト・認識精度のどこを改善すべきか判断できます。
まとめ
- RMS は軽量な音量指標だが、単発のしきい値判定だけではチャタリングする
- 開始と終了に連続フレーム数を設けると、短いノイズと自然な無音を扱いやすい
- フレーム数はサンプルレートと fftSize から時間に換算して決める
- 状態遷移をテストしてから STT ストリームや WebSocket に接続する
この構成は、リアルタイム音声アプリで「音声を送る境界」をまず正しく作るための土台になります。