ブラウザで拾った音声をストリーミングSTTに流すとき、無音まで送り続けるとその分がそのまま課金対象になります。STTベンダは実際に送られた音声に対して請求するので、送らない区間を作れば請求も減ります。そこでAudioWorklet側でRMSを出し、メインスレッド側に小さなゲートを置きました。
ただしこのゲートはマイクにしか使えませんでした。タブ音声やシステム音声が混ざった瞬間に、同じしきい値が信号を丸ごと無音と判定してしまう。結局、系統ごとに分けることになりました。その実装と、切った理由を書きます。
パイプラインの前提
- ブラウザでキャプチャ、AudioWorkletでPCM16 / モノラル / 16kHzに変換
- 1チャンク1600サンプル、つまり100ms
- WebSocketでストリーミングSTTのAPIへ送信
ゲートの判断材料はチャンクごとのRMSです。PCM16に落とす前のFloat32の段階で、各サンプルを[-1, 1]にクランプしてから二乗平均平方根を取り、PCM16のバッファと一緒にメインスレッドへpostMessageしています。
let sumSq = 0;
for (let i = 0; i < this._chunkSamples; i++) {
const s = Math.max(-1, Math.min(1, this._buf[(readStart + i) % this._capacity]));
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
sumSq += s * s;
}
const rms = Math.sqrt(sumSq / this._chunkSamples);
this.port.postMessage({ type: "audio", samples: pcm16.buffer, rms }, [pcm16.buffer]);
ループ1本でクランプ、二乗和、PCM16化を同時に済ませています。100msごとに回るので、ここは余計な走査を増やしたくありませんでした。
しきい値は固定値ではなくノイズフロアから出す
固定のRMSカットオフだと、遠い声や小さい声が落ちやすいです。推定したノイズフロアに係数を掛け、上下を絶対値で挟む形にしました。
const DEFAULT_CONFIG = {
margin: 1.8,
absMin: 0.0015,
absMax: 0.02,
noiseFloorAlpha: 0.03,
trailingChunks: 20,
preBufferSize: 5,
};
function clampGate(noiseFloor: number, config: Required<VadGateConfig>): number {
const raw = noiseFloor * config.margin;
return Math.max(config.absMin, Math.min(config.absMax, raw));
}
clamp(noiseFloor * 1.8, 0.0015, 0.02) です。下限 0.0015 は、静かな部屋でも遠い声や小さい声が通りやすいように低くしてあります。上限 0.02 は、うるさい部屋でしきい値が上がり続けて話し声まで落ちるのを止めます。
ノイズフロアの更新はEMAで、無音と判定したチャンクだけを使います。
this.noiseFloor =
this.noiseFloor * (1 - this.config.noiseFloorAlpha) + rms * this.config.noiseFloorAlpha;
alphaは0.03です。100msチャンクなので、フロアが追従する時定数はおおよそ30チャンク、数秒です。発話中はこの更新を回しません。
最初の1チャンクでシードする
実装していて一番はまったのがここです。noiseFloorの初期値を0のままにしておくと、ゲートは下限の0.0015になります。起動直後に少しでも騒がしい場所だと、最初のチャンクのRMSが0.0015を超えてspeechに入ります。そしてspeechの間はEMAを回さないので、フロアは0のまま更新されない。以降ずっとゲートが0.0015に張り付き、実質ゲートなしの状態でラッチします。
対策は1行です。
if (this.noiseFloor === 0 && rms > 0) {
this.noiseFloor = rms;
}
分類の前に入れます。最初に来た有音のチャンクをそのままフロアの初期値として採用する。乱暴に見えますが、その後の無音チャンクでEMAが効くので、0のまま放置するよりはるかにましでした。
状態機械
状態は3つだけです。
rms >= gate
silence ----------------> speech
^ |
| | rms < gate
| count > 20 v
+-------------------- trailing
| rms >= gate
+-------> speech
trailingを挟んでいるのは、文の途中の短い間で送信を止めないためです。「えーと」の前後や、句点のあたりでRMSは普通にゲートを下回ります。そこで即座に切ると、発話の末尾が欠けます。20チャンク、約2秒は下回っても送り続ける。
逆方向の取りこぼし対策がプリロールのリングバッファです。無音中のチャンクを最大5個、約0.5秒だけ保持しておき、silenceからspeechに入る瞬間に先に吐き出します。発話の立ち上がりはRMSがゲートを超えるまで数チャンクかかるので、これがないと語頭が消えます。
簡略化した分岐はこうなります。
const gate = clampGate(this.noiseFloor, this.config);
if (rms >= gate) {
// silence から来たときだけプリロールを先に流す
const preRoll = this.state === "silence" ? this.preBuffer.splice(0) : [];
this.state = "speech";
this.trailing = 0;
return { emit: true, speaking: true, preRoll };
}
if (this.state === "speech") {
this.state = "trailing";
this.trailing = 1;
return { emit: true, speaking: true, preRoll: [] };
}
if (this.state === "trailing") {
this.trailing += 1;
if (this.trailing <= this.config.trailingChunks) {
return { emit: true, speaking: true, preRoll: [] };
}
this.state = "silence";
// 20を超えたこのチャンクは送らない。フロア更新とプリロールに回す
}
this.noiseFloor =
this.noiseFloor * (1 - this.config.noiseFloorAlpha) + rms * this.config.noiseFloorAlpha;
this.preBuffer.push(chunk);
if (this.preBuffer.length > this.config.preBufferSize) {
this.preBuffer.shift();
}
return { emit: false, speaking: false, preRoll: [] };
境界で気をつけた点が2つあります。trailingを終わらせるチャンク自体は送信せず、無音側として扱ってEMAに食わせること。そしてtrailingの間はspeakingをtrueのまま維持すること。UIの発話インジケータがこのフラグを見ているので、句点ごとに点滅すると落ち着きません。
タブ音声とシステム音声ではゲートを切った
ここまでの数値は全部、口元のマイクを前提にしています。
タブ音声やOSのループバックはライン系の信号です。レベルの出方がマイクとは別物で、同じカットオフを当てるとまともな音声が無音側に落ちます。落ちるとSTTへの送信が止まり、文字起こしが丸ごと静かになる。マイク用に決めた定数を、そのまま共有させるのが間違いでした。
なのでキャプチャ側で系統を見て、システム音声が1本でも含まれるならゲートを通しません。マイクとシステムのミックスも同じ扱いです。
// VAD gate is bypassed for system audio: the signal is line-level (not close-mic),
// so RMS thresholds calibrated for microphone would silence all audio.
if (disableVAD) {
emit();
return;
}
判定はストリームの有無だけ。
const hasSystemAudio = systemStream !== null;
await setupWorklet(audioContext, sourceNode, targetSessionId, hasSystemAudio);
disableVADがtrueのときは全チャンクを無条件に送信し、speakingフラグも立てっぱなしにします。状態機械には一切入りません。
代償ははっきりしていて、この経路では無音も環境音もそのままSTTに届き、その分が請求されます。ゲートをかけて文字起こしが無音になるより、送りすぎて課金されるほうがましなので、このトレードオフは受け入れました。マイク単体のときだけ、ちゃんと絞る。
マイク用のしきい値は、ラインレベルの信号には合いません。システム音声が混ざる経路ではゲートを外して無音も送り、マイクだけの経路ではゲートを残す。その切り分けにしました。
このゲートはLiveSuggestの中で動いています。ブラウザ上で、ユーザーが共有すると選んだ音声から、会話の途中で提案を出すツールです。