クラウド STT に 16-bit PCM を流すとき、サーバ側の適応 VAD は直前の大声に合わせて閾値を上げます。ハンズフリーの遠場(amp 0.008 前後)は、その閾値の下に沈んで「無音」扱いになり、文ごと落ちます。クライアント側で VAD を自作しても、送った先の閾値は触れません。
やることは一つです。送る直前に、音声の RMS だけを TARGET=0.05 へ寄せる。床 3e-3 未満(暗騒音)は tracked を更新しない。これで弱段は数フレームで持ち上がり、無音は 12 倍に増幅されません。
何が起きるか
近場の声(口がマイクに近い)のあとに、スピーカー越しの弱段が来ます。クラウド側は適応 VAD なので、大きい RMS を見た直後は「これより小さいのは発話ではない」とみなします。遠場の一文は、部分的に欠けるのではなく、先頭から丸ごと欠けることが多いです。
こちらで VAD の閾値をいじっても、届いた PCM の絶対レベルが低いままだと意味がありません。正規化するなら、送る前です。
ただし、無音まで同じゲイン制御に入れると逆効果になります。暗騒音を追うと tracked RMS が床まで落ち、次の瞬間に MAX_GAIN=12 がかかって雑音が「発話レベル」になります。サーバ側の閾値は、また上がります。
実装
16-bit LE のチャンクをその場で増幅します。EMA は 0.7 / 0.3。音声だけ更新し、暗騒音は据え置きです。
export class SpeechAgc {
private trackedRms: number;
private readonly chaseNoise: boolean;
static readonly TARGET = 0.05;
static readonly MAX_GAIN = 12;
static readonly NOISE_FLOOR = 3e-3;
constructor(opts: { chaseNoise?: boolean } = {}) {
this.trackedRms = SpeechAgc.TARGET;
this.chaseNoise = opts.chaseNoise === true;
}
process(pcm16: Buffer): Buffer {
const n = Math.floor(pcm16.length / 2);
if (n === 0) return pcm16;
let sum = 0;
for (let i = 0; i < n; i++) {
const v = pcm16.readInt16LE(i * 2) / 32768;
sum += v * v;
}
const inRms = Math.sqrt(sum / n);
if (this.chaseNoise || inRms > SpeechAgc.NOISE_FLOOR) {
this.trackedRms = 0.7 * this.trackedRms + 0.3 * inRms;
}
const gain = Math.min(
SpeechAgc.TARGET / Math.max(this.trackedRms, 1e-4),
SpeechAgc.MAX_GAIN,
);
if (Math.abs(gain - 1) < 0.05) return pcm16;
const out = Buffer.alloc(n * 2);
for (let i = 0; i < n; i++) {
const v = pcm16.readInt16LE(i * 2) * gain;
out.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(v))), i * 2);
}
return out;
}
get tracked(): number {
return this.trackedRms;
}
}
chaseNoise は対照実験用です。本番は渡さない(床を有効にする)前提です。
正弦波で近場 → 遠場 → 暗騒音を順番に食わせます。440Hz、16kHz、100ms チャンクです。bun speech-agc.ts で同じ表が出ます。
function sinePcm(amp: number, samples = 1600): Buffer {
const buf = Buffer.alloc(samples * 2);
for (let i = 0; i < samples; i++) {
const v = Math.round(
amp * 32767 * Math.sin((2 * Math.PI * 440 * i) / 16000),
);
buf.writeInt16LE(v, i * 2);
}
return buf;
}
function rms(pcm: Buffer): number {
const n = Math.floor(pcm.length / 2);
let sum = 0;
for (let i = 0; i < n; i++) {
const v = pcm.readInt16LE(i * 2) / 32768;
sum += v * v;
}
return Math.sqrt(sum / n);
}
実験
正弦波の RMS は振幅 / √2 なので、近場 amp=0.20 は入力 RMS 0.1414、遠場 amp=0.008 は 0.0057 です。床 0.003 を遠場は超え、暗騒音 amp=0.001(RMS 0.0007)は超えません。
| チャンク | in RMS | tracked | out RMS | gain |
|---|---|---|---|---|
| 近場 #1 | 0.1414 | 0.0774 | 0.0913 | 0.65 |
| 近場 #8 | 0.1414 | 0.1361 | 0.0519 | 0.37 |
| 遠場 #1 | 0.0057 | 0.0970 | 0.0029 | 0.52 |
| 遠場 #4 | 0.0057 | 0.0370 | 0.0076 | 1.35 |
| 遠場 #12 | 0.0057 | 0.0075 | 0.0379 | 6.70 |
| 暗騒音 #20 | 0.0007 | 0.0075 | 0.0047 | 6.70 |
| 遠場再 #8 | 0.0057 | 0.0058 | 0.0491 | 8.68 |
近場は 8 チャンクで出力が TARGET 付近まで下がります。遠場は 1 チャンクでは足りません。EMA が追いつくまで、入力より小さく見えます。4 チャンク目で利得が 1 を超え、12 チャンク目で 6.7 倍まで持ち上がります。100ms チャンクなら、だいたい 1 秒ちょっとです。
暗騒音を 20 チャンク入れても tracked は 0.0075 のままです。床を跨いでいないので、据え置きです。
床を追うと何が壊れるか
同じ入力を chaseNoise: true で流すと、無音 20 チャンクのあとゲインが上限に張り付きます。
| チャンク | in RMS | tracked | out RMS | gain |
|---|---|---|---|---|
| naive 暗騒音 #20 | 0.0007 | 0.0008 | 0.0085 | 12.00 |
| naive 遠場 #1 | 0.0057 | 0.0023 | 0.0679 | 12.00 |
暗騒音が 12 倍されて RMS 0.0085 になります。これは遠場の生音声より大きい。クラウド側の適応 VAD から見ると「今も誰かが話している」ので、閾値は下がりません。次の弱段は、また飲み込まれます。
床を入れると、無音ではゲインが張り付きません。直前の音声の RMS を覚えたまま待つだけです。
挟む位置
ストリームのソケットに届いた PCM を、クラウドへ sendAudio する直前に通します。声紋や発話終了検出が同じバッファを見るなら、利得処理後のバイト列を残してください。時間軸を揃えないと、「AI が聞いた音」と「こちらが判定した音」がずれます。
const outbound = phoneAgc ? phoneAgc.process(buffer) : buffer;
stream.sendAudio(outbound);
retain(outbound); // 判定・再現用。生入力ではない
電話のハンズフリーだけ有効にしています。近接マイクでレベルが安定している経路に同じ処理を足す必要はありません。|gain - 1| < 0.05 ならコピーしないので、その場合のコストは RMS を一度読むだけです。
定数は現場で合わせます。TARGET=0.05 は「クラウドが安定して拾う実効レベル」、NOISE_FLOOR=3e-3 は「それ未満を発話とみなさない」、MAX_GAIN=12 はクリップと雑音増幅の天井です。床を下げすぎると naive 側に近づきます。
クラウド STT の適応 VAD、皆さんは入力のどこで正規化していますか。RMS 以外で効いた前処理があれば知りたいです。

