3. 降噪问题定义

音频降噪不是凭空变魔术。你得先拿到原始音频数据,处理完再送出去。这一章,我们就来打通这条从麦克风到扬声器的路。

说实话,我刚接触 Web Audio API 时,觉得这东西挺绕的。一堆节点连来连去,像搭积木。但用顺手了就会发现,这套设计其实很优雅——每个节点只管一件事,组合起来就能做很复杂的事情。

3.1 用 getUserMedia 采集麦克风音频

浏览器里拿麦克风权限,靠的是 navigator.mediaDevices.getUserMedia。这个 API 会弹个权限框,用户点了允许,你才能拿到音频流。

核心要点:getUserMedia 返回的是一个 Promise,resolve 后给你一个 MediaStream 对象。这个流里可能包含音频轨和视频轨,我们只需要音频。

async function getMicrophoneStream() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: false,
        noiseSuppression: false,
        autoGainControl: false
      }
    });
    return stream;
  } catch (err) {
    console.error('麦克风权限被拒绝:', err);
    throw err;
  }
}

注意:我把 echoCancellation、noiseSuppression、autoGainControl 都关了。为什么?因为这些是浏览器自带的处理,会干扰我们自己的降噪算法。你想想看,如果浏览器先降了一次噪,你的算法再降一次,效果反而会变差。我曾经在一个项目里没关这些,调试了整整两天才发现是浏览器在捣鬼。

3.2 把 MediaStream 接入 Web Audio 图

拿到 MediaStream 之后,得把它接到 Web Audio 的图里。这里需要一个 MediaStreamSourceNode。

const audioContext = new AudioContext();
const sourceNode = audioContext.createMediaStreamSource(stream);

嗯,就这么简单。现在 sourceNode 就成了音频图的起点。你可以把它连到任意处理节点上。

3.3 播放音频文件:AudioBufferSourceNode

除了实时麦克风,我们还得能播放音频文件。比如测试降噪效果时,放一段带噪语音,处理完再听对比。

加载音频文件,我习惯用 fetch 加 arrayBuffer,然后 decode 成 AudioBuffer:

async function loadAudioFile(url) {
  const response = await fetch(url);
  const arrayBuffer = await response.arrayBuffer();
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
  return audioBuffer;
}

function playAudioBuffer(buffer) {
  const source = audioContext.createBufferSource();
  source.buffer = buffer;
  source.connect(audioContext.destination);
  source.start(0);
  return source;
}

小技巧:AudioBufferSourceNode 是一次性的。播完就不能再 start 了。如果你想反复播同一段音频,每次都得新建一个 source 节点。我一般会封装一个播放器类,内部维护 source 的创建和销毁。

3.4 实时处理链路:从麦克风到扬声器

现在我们把采集和播放串起来。实时处理链路长这样:

麦克风 → MediaStreamSourceNode → [处理节点] → AudioDestinationNode → 扬声器

最简单的链路,就是直接把麦克风接到扬声器——相当于一个直通。但我们要做降噪,所以中间得插一个处理节点。

Web Audio API 提供了几种处理节点:

节点类型 用途 适用场景
GainNode 调节音量 音量控制、静音
BiquadFilterNode 频率滤波 简单降噪、EQ
ScriptProcessorNode 自定义处理(已废弃) 旧项目兼容
AudioWorkletNode 自定义处理(推荐) 实时降噪算法

对于降噪算法,我们主要用 AudioWorkletNode。它跑在独立线程里,不会阻塞主线程,延迟也低。

我的建议:别用 ScriptProcessorNode 了。它虽然写起来简单,但跑在主线程上,音频处理一重就会卡。AudioWorklet 才是正道。我第一次用 ScriptProcessorNode 做实时降噪时,音频一卡一卡的,换成 AudioWorklet 后立马流畅了。

3.5 完整的实时处理链路代码

下面是一个完整的例子,把麦克风采集的音频经过一个自定义处理节点,再送到扬声器:

async function startRealTimeProcessing() {
  // 1. 创建 AudioContext
  const audioContext = new AudioContext();

  // 2. 获取麦克风流
  const stream = await getMicrophoneStream();

  // 3. 创建源节点
  const sourceNode = audioContext.createMediaStreamSource(stream);

  // 4. 创建处理节点(这里用 GainNode 做演示,实际换成你的降噪节点)
  const gainNode = audioContext.createGain();
  gainNode.gain.value = 1.0;

  // 5. 连接:源 → 处理 → 输出
  sourceNode.connect(gainNode);
  gainNode.connect(audioContext.destination);

  console.log('实时处理链路已启动');
}

这段代码跑起来后,你对着麦克风说话,扬声器会实时播放出来。当然,现在还没做降噪,只是直通。后面几章我们会把真正的降噪算法塞进这个链路里。

3.6 链路结构总览

我把整个音频采集与播放的链路画成了 SVG 图,方便你理解各个节点的关系:

音频采集与播放链路结构 麦克风 getUserMedia MediaStream SourceNode 处理节点 AudioWorkletNode (降噪算法) 扬声器 destination 音频文件 fetch → decode AudioBuffer SourceNode 或 关键说明: 1. 实时采集走麦克风路径(实线),文件播放走 AudioBuffer 路径(虚线) 2. 两条路径最终都汇入处理节点,降噪算法只需处理一次 3. 处理后的音频统一送到扬声器输出

3.7 避坑指南

做音频采集和播放时,有几个坑我踩过,分享给你:

  • AudioContext 必须在用户交互后创建——浏览器为了防滥用,不允许页面一加载就自动创建 AudioContext。你得在点击按钮之类的用户操作后再 new。我习惯在点击「开始录音」时创建。
  • 采样率要一致——getUserMedia 返回的音频采样率通常是 48000Hz 或 44100Hz。如果你的降噪算法要求 16000Hz,得做重采样。AudioContext 的 sampleRate 属性可以帮你确认当前采样率。
  • 别忘了断开连接——停止录音时,要调用 sourceNode.disconnect() 和 stream.getTracks().forEach(track => track.stop())。否则麦克风一直亮着,用户会慌。
  • 延迟问题——实时处理链路会有延迟。AudioWorklet 的延迟通常在 10ms 以内,人耳基本感觉不到。但如果你的处理逻辑太重,延迟会飙升。我曾经把一个复杂的 FFT 算法塞进 AudioWorklet,延迟直接飙到 100ms,说话都有回声了。

经验之谈:调试音频链路时,我习惯先搭一个直通链路(麦克风直接到扬声器),确认采集和播放都没问题,再逐步加入处理节点。这样出了问题,你能快速定位是采集端、处理端还是播放端的问题。

3.8 小结

这一章我们打通了音频采集与播放的完整链路。你学会了:

  • 用 getUserMedia 采集麦克风音频
  • 用 AudioBufferSourceNode 播放音频文件
  • 搭建实时处理链路:源节点 → 处理节点 → 输出节点
  • 理解了 AudioWorklet 为什么是实时降噪的首选

链路搭好了,下一件事就是把降噪算法塞进去。嗯,那是后面几章的事。先把基础打牢,后面才能跑得快。