🎙️

ブラウザを裏に回すとマイクに「ブツッ」とノイズが乗る問題の調査

に公開

はじめに

こんにちは!PortalKeyの渋谷です。
今回は WebRTC を使った通話アプリで遭遇した マイク音声に「ブツッ」としたノイズが混じる問題 について、その原因と解決方法をまとめます。

問題の発生状況

  • 環境: Chrome / Edge などのブラウザ
  • 状況: フォアグラウンドでは正常に録音できるが、裏に回すと「ブツッ」というノイズが入る
  • 自作のデシベルカットノードを使用中に再現

当初は WebRTC 側の不具合マイクデバイスの問題 を疑いましたが、調査の結果「ブラウザを裏に回した瞬間に発生」することが判明。さらに 自作デシベルカットノードを外すと問題が起きない ことも確認できました。

自作のノードの見直し

問題の切り分けで怪しいと判明した自作ノードの実装がこちらです。

createDbCutNode.ts
export interface DbCutProcessor {
  scriptProcessorNode: ScriptProcessorNode
  setThreshold: (threshold: number) => void
}

export function createDbCutNode(audioContext: AudioContext): DbCutProcessor {
  const node = audioContext.createScriptProcessor()
  let dbCutThreshold = -60 // デフォルトの閾値

  node.onaudioprocess = (event: AudioProcessingEvent) => {
    const numberOfChannels = event.inputBuffer.numberOfChannels

    for (let channel = 0; channel < numberOfChannels; channel++) {
      const input = event.inputBuffer.getChannelData(channel)
      const output = event.outputBuffer.getChannelData(channel)

      const sum = input.reduce((acc, curr) => acc + curr * curr, 0.0)
      const volume = Math.sqrt(sum / input.length)
      const volumeInDb = 20 * Math.log10(volume)

      if (volumeInDb < dbCutThreshold) {
        for (let i = 0; i < input.length; ++i) {
          output[i] = 0
        }
      } else {
        for (let i = 0; i < input.length; ++i) {
          output[i] = input[i] || 0
        }
      }
    }
  }

  return {
    scriptProcessorNode: node,
    setThreshold: (threshold: number) => {
      dbCutThreshold = threshold
    }
  }
}

小さな音をカットするノードで、環境音を抑える目的では正常に機能していました。
ではなぜ裏タブにするとノイズが発生するのでしょうか?

ScriptProcessorNode の問題

参考:

原因は単純で、この実装に使っている ScriptProcessorNode が非推奨 だからです。
ScriptProcessorNode は メインスレッドで動作するため、バックグラウンドに移行するとスロットリングの影響を受け、処理が間に合わなくなります。

Page Visibility API の MDN ドキュメント にも、裏タブでタイマーや処理が制限される旨が書かれています。

Tabs which are playing audio are considered foreground and aren't throttled.

とありますが、これは「オーディオの再生」や「WebRTC通信」の I/O が対象。ScriptProcessorNode のような メインスレッド依存の処理 は例外外で、影響を受けてしまいます。

AudioWorklet への移行

この問題を解決する手段はシンプルで、AudioWorklet に移行することです。
AudioWorklet はメインスレッドとは独立したリアルタイムオーディオスレッドで動くため、バックグラウンドでも安定します。

参考: AudioWorklet (MDN)

改善後の実装例

createDbCutProcessor.ts
class DbCutProcessor extends AudioWorkletProcessor {
  static get parameterDescriptors() {
    return [{ name: "threshold", defaultValue: -60 }]
  }

  private currentGain = 1.0
  private readonly feedSpeed = 0.2
  private readonly hysteresisThreshold = 2.0

  process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>) {
    const threshold = parameters.threshold[0] ?? -60
    const lowerThreshold = threshold - this.hysteresisThreshold
    const upperThreshold = threshold + this.hysteresisThreshold

    for (let stream = 0; stream < inputs.length; stream++) {
      const input = inputs[stream]
      const output = outputs[stream]
      if (!input || !output) continue

      for (let channel = 0; channel < input.length; channel++) {
        const inputChannel = input[channel]
        const outputChannel = output[channel]
        if (!inputChannel || !outputChannel) continue

        const sum = inputChannel.reduce((acc, curr) => acc + curr * curr, 0.0)
        const volume = Math.sqrt(sum / inputChannel.length)
        const volumeInDb = volume > 1e-8 ? 20 * Math.log10(volume) : -160

        if (volumeInDb < lowerThreshold) {
          this.currentGain += (0.0 - this.currentGain) * this.feedSpeed
        } else if (volumeInDb > upperThreshold) {
          this.currentGain += (1.0 - this.currentGain) * this.feedSpeed
        }

        for (let i = 0; i < inputChannel.length; ++i) {
          outputChannel[i] = this.currentGain * (inputChannel[i] ?? 0)
        }
      }
    }
    return true
  }
}

registerProcessor("db-cut-processor", DbCutProcessor)
createDbCutNode.ts
import processorURL from "./createDbCutProcessor.ts?worker&url"

export type DbCutNodeResult = {
  node: AudioWorkletNode
  setThreshold: (threshold: number) => void
}

export async function createDbCutNode(audioContext: AudioContext): Promise<DbCutNodeResult> {
  await audioContext.audioWorklet.addModule(processorURL)
  const node = new AudioWorkletNode(audioContext, "db-cut-processor")

  return {
    node,
    setThreshold: (threshold: number) => {
      node.parameters.get("threshold")?.setValueAtTime(threshold, audioContext.currentTime)
    }
  }
}

まとめ

  • ScriptProcessorNode は非推奨で、メインスレッド依存のためバックグラウンドではノイズが発生することがある。
  • AudioWorklet に移行することで、裏タブでも安定した処理が可能になる。
  • 古い記事や実装を参考にする際は、現在の推奨 API を必ず確認することが大切。
PortalKey Tech Blog

Discussion