0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

弱段はサーバ側 VAD に飲まれる:送る前に音声 RMS だけ上げる

0
Posted at

クラウド STT に 16-bit PCM を流すとき、サーバ側の適応 VAD は直前の大声に合わせて閾値を上げます。ハンズフリーの遠場(amp 0.008 前後)は、その閾値の下に沈んで「無音」扱いになり、文ごと落ちます。クライアント側で VAD を自作しても、送った先の閾値は触れません。

やることは一つです。送る直前に、音声の RMS だけTARGET=0.05 へ寄せる。床 3e-3 未満(暗騒音)は tracked を更新しない。これで弱段は数フレームで持ち上がり、無音は 12 倍に増幅されません。

PCM を SpeechAgc に通してからクラウド STT へ渡す。床 3e-3 未満は追わない

何が起きるか

近場の声(口がマイクに近い)のあとに、スピーカー越しの弱段が来ます。クラウド側は適応 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 のままです。床を跨いでいないので、据え置きです。

近場は下げ、遠場は数チャンクで持ち上がり、暗騒音は tracked を動かさない

床を追うと何が壊れるか

同じ入力を 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 以外で効いた前処理があれば知りたいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?