返回文章列表

第十章|Web Audio 实战:浏览器端音频处理与混音

实现浏览器端多轨音频编辑器 MVP:上传解码、时间线调度、音量控制、淡入淡出、变速、归一化、波形频谱、静音检测、离线混音和 WAV 导出。

第十章|Web Audio 实战:浏览器端音频处理与混音

目标: 能做真实业务里常见的音频处理:多音轨混音、音量控制、淡入淡出、变速、音量归一、静音检测、波形绘制、频谱分析、离线渲染和导出 WAV。

这一章不追求做一个完整 DAW,也不是写一个专业音频宿主,而是做一个前端工程师面试和项目展示都能讲清楚的 浏览器端多轨音频编辑器 MVP


1. 本章学习目标

学完这一章,你应该能回答并实现这些问题:

  1. 用户上传多个音频文件后,如何在浏览器中解码成可处理的 PCM 数据?
  2. 如何把多个音频放到一条项目时间线上?
  3. 如何给每条音轨设置开始时间、音量、淡入淡出?
  4. 如何使用 OfflineAudioContext 离线混音并导出 WAV?
  5. 如何绘制波形?
  6. 如何做频谱可视化?
  7. 如何做简单静音检测?
  8. AudioWorklet 适合解决什么问题?
  9. Web Audio 和 WebCodecs 在音频处理链路中分别负责什么?

Web Audio API 的核心模型是 AudioNode 组成的音频路由图:你把音源节点、处理节点、输出节点连接起来,浏览器底层负责实际渲染和处理。W3C Web Audio 规范也把它描述为用于 Web 应用音频处理与合成的高级 API,其主要范式就是由多个 AudioNode 连接而成的 routing graph。(W3C)


2. 本章 Demo:浏览器端多轨混音器

我们要设计一个这样的 Demo:

上传多个音频文件

decodeAudioData 解码成 AudioBuffer

每个 AudioBuffer 变成一条 Track

设置开始时间、音量、fade in、fade out、playbackRate

绘制每条音轨波形

使用 AudioContext 做实时预听

使用 OfflineAudioContext 离线混音

得到混音后的 AudioBuffer

可选:峰值归一化

编码成 WAV Blob

下载 mixed.wav

一句话理解:

AudioBuffer 是原材料,AudioNode 图是调音台,OfflineAudioContext 是“离线导出引擎”,WAV encoder 是最后把 PCM 打包成文件的步骤。


3. 实战心智模型

第 9 章已经讲过 AudioContextAudioBufferAudioNode graphGainNodeOfflineAudioContext。本章不再重复 API 定义,只把它们放进一个多轨混音器里:

AudioBuffer:每条音轨的 PCM 素材
Timeline:决定素材在项目里的位置
GainNode / AudioParam automation:控制音量和淡入淡出
OfflineAudioContext:把整张图离线渲染成导出结果
WAV encoder:把最终 PCM 打包成可下载文件

3.1 Timeline:项目时间线

多轨混音不是简单地把数组相加,而是先有一条 项目时间线

例如:

时间线:0s      2s      4s      6s      8s      10s

Track A: [-------- 人声 --------]
Track B:      [----- BGM ---------------------]
Track C:                  [--- 音效 ---]

每条轨道需要至少这些信息:

type AudioTrack = {
  id: string;
  name: string;
  buffer: AudioBuffer;

  // 这条音频从项目时间线的第几秒开始播放
  startTime: number;

  // 从源音频自己的第几秒开始取
  offset?: number;

  // 取源音频多长,单位秒;不填就是从 offset 播到结尾
  duration?: number;

  // 线性音量,1 是原始音量,0.5 是一半,2 是放大一倍
  volume: number;

  // 淡入淡出,单位秒
  fadeIn?: number;
  fadeOut?: number;

  // 变速;1 是原速,2 是两倍速,0.5 是半速
  playbackRate?: number;

  muted?: boolean;
};

这里要非常清楚:startTime 是项目时间线上的位置,offset 是源音频文件内部的位置。


3.2 音量包络:从静态音量到自动化

每条音轨都可以有一个自己的增益节点。实战里它不只负责“音量大小”,还负责淡入淡出和音量包络:

AudioBufferSourceNode

GainNode

Master GainNode

destination

GainNode 用来改变输入信号在输出前的增益,适合做音量控制。(MDN Web Docs) 但实际做 fade in / fade out 时,不建议在 JS 里用 setInterval 不断改音量,而应该用 AudioParam automation,例如 setValueAtTime()linearRampToValueAtTime()linearRampToValueAtTime() 的作用就是把参数值按线性方式逐渐变化到目标值。(MDN Web Docs)

例如一条音轨从第 3 秒开始,音量为 0.8,淡入 1 秒,淡出 2 秒:

gain
1.0 |
0.8 |      ┌───────────────┐
    |     /                 \
0.0 |____/                   \____
       3s  4s             end-2s end

3.3 离线导出:同一张图,不同的渲染目标

实时预听和离线导出可以共享大部分调度逻辑,区别是输出目标不同:

实时预听:
AudioContext → destination → 扬声器

离线导出:
OfflineAudioContext → startRendering() → AudioBuffer → WAV Blob

这就是为什么本章的混音导出使用 OfflineAudioContext,而不是边播放边录。


4. 本章新增术语表

基础 API 术语已经在第 9 章解释过。这里仅保留实战里新增的项目词:

术语含义面试里怎么说
Timeline多轨项目的统一时间线决定每段素材从项目第几秒开始播放
startTime素材在项目时间线上的开始时间和源文件内部 offset 不是一回事
offset从源音频自己的第几秒开始取样用来做裁剪或从中间开始播放
volume envelope音量随时间变化的曲线淡入淡出本质是 gain automation
normalization归一化调整整体峰值或响度,避免太小或削波
clipping削波混音后采样超过 [-1, 1] 导致失真
WAV encoder把 PCM 写成 WAV 容器浏览器端最容易手写的音频导出格式

5. 项目架构设计

5.1 功能模块

推荐目录结构:

web-audio-mixer/
├─ src/
│  ├─ audio/
│  │  ├─ decode.ts              # 文件解码成 AudioBuffer
│  │  ├─ renderMix.ts           # OfflineAudioContext 离线混音
│  │  ├─ wav.ts                 # AudioBuffer 导出 WAV
│  │  ├─ waveform.ts            # 波形数据生成
│  │  ├─ silence.ts             # 静音检测
│  │  ├─ preview.ts             # 实时预听图
│  │  └─ normalize.ts           # 峰值归一化
│  ├─ worklets/
│  │  └─ rms-meter-processor.js # AudioWorklet 示例
│  ├─ components/
│  │  ├─ FileUploader.tsx
│  │  ├─ TrackList.tsx
│  │  ├─ Timeline.tsx
│  │  ├─ WaveformCanvas.tsx
│  │  └─ SpectrumCanvas.tsx
│  ├─ types.ts
│  └─ main.tsx

5.2 数据流

File[]

decodeAudioFile(file)

AudioTrack[]

UI 编辑 startTime / volume / fadeIn / fadeOut

renderMix(tracks)

mixed AudioBuffer

normalizePeakInPlace(buffer)

audioBufferToWav(buffer)

download mixed.wav

6. 本项目的 API 使用提醒

第 9 章已经逐个解释过 Web Audio API。这里不再重复定义,只列多轨混音器里最容易影响实现的点:

API本项目里的用法注意点
decodeAudioData()把上传文件解码成 AudioBuffer不是流式解码;结果是 PCM;可能按当前 AudioContext 重采样
AudioBufferSourceNode每次播放或离线渲染一段素材source node 只能 start() 一次,重复播放要新建
GainNode控制音量、淡入淡出、音量包络fade 用 AudioParam automation,不用 JS 定时器硬改
AnalyserNode实时频谱、音量表、预听可视化适合预听链路,不负责离线导出本身
AudioWorkletRMS meter、limiter、噪声门等实时处理适合低延迟自定义处理,不要再用旧的 ScriptProcessorNode 开新项目

7. TypeScript 代码骨架

下面代码不是完整 UI,而是核心音频引擎。你可以用 React/Vue/Svelte 包一层 UI。


7.1 类型定义

// src/types.ts

export type AudioTrack = {
  id: string;
  name: string;
  buffer: AudioBuffer;

  /**
   * 这条音轨在项目时间线上的开始时间,单位秒。
   */
  startTime: number;

  /**
   * 从源音频的第几秒开始播放。
   */
  offset?: number;

  /**
   * 从源音频中截取多长,单位秒。
   * 不填则从 offset 播到 buffer 结尾。
   */
  duration?: number;

  /**
   * 线性音量。
   * 1 = 原始音量,0.5 = 降低一半,2 = 放大一倍。
   */
  volume: number;

  /**
   * 淡入淡出时长,单位秒。
   */
  fadeIn?: number;
  fadeOut?: number;

  /**
   * 变速。
   * 1 = 原速,2 = 两倍速,0.5 = 半速。
   * 注意:AudioBufferSourceNode 的 playbackRate 会同时改变速度和音高。
   */
  playbackRate?: number;

  muted?: boolean;
};

export type RenderMixOptions = {
  sampleRate?: number;
  numberOfChannels?: 1 | 2;
  normalizePeakDb?: number;
};

7.2 解码音频文件

// src/audio/decode.ts

let decodeContext: AudioContext | null = null;

function getDecodeContext(): AudioContext {
  if (!decodeContext) {
    decodeContext = new AudioContext({
      sampleRate: 48_000,
    });
  }

  return decodeContext;
}

export async function decodeAudioFile(file: File): Promise<AudioBuffer> {
  const context = getDecodeContext();
  const arrayBuffer = await file.arrayBuffer();

  try {
    return await context.decodeAudioData(arrayBuffer);
  } catch (error) {
    throw new Error(`Failed to decode audio file: ${file.name}`);
  }
}

工程解释:

File
  ↓ file.arrayBuffer()
ArrayBuffer
  ↓ decodeAudioData()
AudioBuffer
  ↓ 放入项目 track
AudioTrack

7.3 计算项目总时长

// src/audio/renderMix.ts

import type { AudioTrack } from "../types";

function getSourceDuration(track: AudioTrack): number {
  const offset = track.offset ?? 0;
  const available = Math.max(0, track.buffer.duration - offset);

  return Math.min(track.duration ?? available, available);
}

function getTimelineDuration(track: AudioTrack): number {
  const playbackRate = track.playbackRate ?? 1;
  const sourceDuration = getSourceDuration(track);

  if (playbackRate <= 0) {
    throw new Error(`Invalid playbackRate for track: ${track.name}`);
  }

  return sourceDuration / playbackRate;
}

export function getProjectDuration(tracks: AudioTrack[]): number {
  let maxEnd = 0;

  for (const track of tracks) {
    if (track.muted) continue;

    const timelineDuration = getTimelineDuration(track);
    const endTime = track.startTime + timelineDuration;

    maxEnd = Math.max(maxEnd, endTime);
  }

  return maxEnd;
}

这里要区分两个 duration:

sourceDuration:从原音频里取多少秒
timelineDuration:变速后,在项目时间线上占多少秒

例如原音频取 10 秒,playbackRate = 2,那么时间线上只占 5 秒。


7.4 设置音量包络:volume + fade in + fade out

// src/audio/renderMix.ts

function scheduleGainEnvelope(
  gainParam: AudioParam,
  track: AudioTrack,
  startTime: number,
  endTime: number
): void {
  const volume = Math.max(0, track.volume);
  const totalDuration = Math.max(0, endTime - startTime);

  const fadeIn = Math.max(0, Math.min(track.fadeIn ?? 0, totalDuration));
  const fadeOut = Math.max(
    0,
    Math.min(track.fadeOut ?? 0, totalDuration - fadeIn)
  );

  const fadeInEnd = startTime + fadeIn;
  const fadeOutStart = endTime - fadeOut;

  gainParam.cancelScheduledValues(0);

  // 起点先归零,避免突然出现爆音。
  gainParam.setValueAtTime(0, startTime);

  if (fadeIn > 0) {
    gainParam.linearRampToValueAtTime(volume, fadeInEnd);
  } else {
    gainParam.setValueAtTime(volume, startTime);
  }

  // 中间保持目标音量。
  gainParam.setValueAtTime(volume, fadeOutStart);

  if (fadeOut > 0) {
    gainParam.linearRampToValueAtTime(0, endTime);
  } else {
    gainParam.setValueAtTime(volume, endTime);
  }
}

7.5 使用 OfflineAudioContext 离线混音

// src/audio/renderMix.ts

import type { AudioTrack, RenderMixOptions } from "../types";
import { peakNormalizeInPlace } from "./normalize";

export async function renderMix(
  tracks: AudioTrack[],
  options: RenderMixOptions = {}
): Promise<AudioBuffer> {
  const sampleRate = options.sampleRate ?? 48_000;
  const numberOfChannels = options.numberOfChannels ?? 2;

  const projectDuration = getProjectDuration(tracks);

  if (projectDuration <= 0) {
    throw new Error("Project duration is zero.");
  }

  const length = Math.ceil(projectDuration * sampleRate);

  const offlineContext = new OfflineAudioContext(
    numberOfChannels,
    length,
    sampleRate
  );

  const masterGain = new GainNode(offlineContext, {
    gain: 1,
  });

  masterGain.connect(offlineContext.destination);

  for (const track of tracks) {
    if (track.muted) continue;

    const offset = track.offset ?? 0;
    const sourceDuration = getSourceDuration(track);
    const timelineDuration = getTimelineDuration(track);

    if (sourceDuration <= 0 || timelineDuration <= 0) continue;

    const source = new AudioBufferSourceNode(offlineContext, {
      buffer: track.buffer,
    });

    source.playbackRate.setValueAtTime(
      track.playbackRate ?? 1,
      offlineContext.currentTime
    );

    const trackGain = new GainNode(offlineContext, {
      gain: 0,
    });

    const startTime = track.startTime;
    const endTime = startTime + timelineDuration;

    scheduleGainEnvelope(trackGain.gain, track, startTime, endTime);

    source.connect(trackGain).connect(masterGain);

    source.start(startTime, offset);
    source.stop(endTime);
  }

  const mixedBuffer = await offlineContext.startRendering();

  if (typeof options.normalizePeakDb === "number") {
    peakNormalizeInPlace(mixedBuffer, options.normalizePeakDb);
  }

  return mixedBuffer;
}

这段代码做了几件核心事:

1. 创建 OfflineAudioContext
2. 为每条 track 创建 AudioBufferSourceNode
3. 为每条 track 创建 GainNode
4. 设置 startTime / offset / duration
5. 设置 volume / fade in / fade out
6. 所有 track 汇入 masterGain
7. startRendering() 得到混音后的 AudioBuffer

7.6 峰值归一化

// src/audio/normalize.ts

export function peakNormalizeInPlace(
  buffer: AudioBuffer,
  targetDb = -1,
  maxGain = 10
): number {
  const targetPeak = Math.pow(10, targetDb / 20);

  let peak = 0;

  for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
    const data = buffer.getChannelData(channel);

    for (let i = 0; i < data.length; i++) {
      peak = Math.max(peak, Math.abs(data[i]));
    }
  }

  if (peak === 0) {
    return 1;
  }

  const gain = Math.min(targetPeak / peak, maxGain);

  for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
    const data = buffer.getChannelData(channel);

    for (let i = 0; i < data.length; i++) {
      data[i] *= gain;
    }
  }

  return gain;
}

注意:这个是 峰值归一化,不是专业响度归一化。

面试可以这么说:

峰值归一化只保证最高峰不超过某个目标值,比如 -1 dBFS。它不等于人耳听起来一样响。如果要做平台级响度标准,需要 LUFS / EBU R128 这类响度测量和响度归一算法。


7.7 AudioBuffer 导出 WAV

Web Audio 得到的是 AudioBuffer,浏览器不会自动把它保存成 .wav 文件。WAV 本质上可以理解成一个 RIFF 头加 PCM 数据,所以我们可以手写一个最小 WAV encoder。

// src/audio/wav.ts

function writeString(view: DataView, offset: number, value: string): void {
  for (let i = 0; i < value.length; i++) {
    view.setUint8(offset + i, value.charCodeAt(i));
  }
}

function floatTo16BitPcm(sample: number): number {
  const clamped = Math.max(-1, Math.min(1, sample));

  return clamped < 0
    ? clamped * 0x8000
    : clamped * 0x7fff;
}

export function audioBufferToWav(buffer: AudioBuffer): Blob {
  const numberOfChannels = buffer.numberOfChannels;
  const sampleRate = buffer.sampleRate;

  const bytesPerSample = 2;
  const blockAlign = numberOfChannels * bytesPerSample;
  const dataSize = buffer.length * blockAlign;

  const arrayBuffer = new ArrayBuffer(44 + dataSize);
  const view = new DataView(arrayBuffer);

  // RIFF header
  writeString(view, 0, "RIFF");
  view.setUint32(4, 36 + dataSize, true);
  writeString(view, 8, "WAVE");

  // fmt chunk
  writeString(view, 12, "fmt ");
  view.setUint32(16, 16, true); // PCM fmt chunk size
  view.setUint16(20, 1, true); // audio format: 1 = PCM
  view.setUint16(22, numberOfChannels, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * blockAlign, true);
  view.setUint16(32, blockAlign, true);
  view.setUint16(34, bytesPerSample * 8, true);

  // data chunk
  writeString(view, 36, "data");
  view.setUint32(40, dataSize, true);

  const channelData: Float32Array[] = [];

  for (let channel = 0; channel < numberOfChannels; channel++) {
    channelData.push(buffer.getChannelData(channel));
  }

  let offset = 44;

  for (let i = 0; i < buffer.length; i++) {
    for (let channel = 0; channel < numberOfChannels; channel++) {
      const sample = channelData[channel][i] ?? 0;
      view.setInt16(offset, floatTo16BitPcm(sample), true);
      offset += 2;
    }
  }

  return new Blob([arrayBuffer], {
    type: "audio/wav",
  });
}

export function downloadBlob(blob: Blob, filename: string): void {
  const url = URL.createObjectURL(blob);

  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();

  URL.revokeObjectURL(url);
}

使用:

import { renderMix } from "./audio/renderMix";
import { audioBufferToWav, downloadBlob } from "./audio/wav";

const mixedBuffer = await renderMix(tracks, {
  sampleRate: 48_000,
  numberOfChannels: 2,
  normalizePeakDb: -1,
});

const wavBlob = audioBufferToWav(mixedBuffer);

downloadBlob(wavBlob, "mixed.wav");

8. 波形绘制

8.1 波形是什么

波形图展示的是音频在时间维度上的振幅变化。

振幅
 1.0 |       /\      /\
 0.0 |______/  \____/  \_____
-1.0 |
       0s   1s   2s   3s

真实音频采样点可能非常多。比如 3 分钟、48kHz、双声道音频:

180 秒 × 48000 = 8,640,000 个 sample frame

所以绘制波形时不能每个 sample 画一个点,而是按 canvas 宽度做降采样。常见做法是:每个像素列取一段 samples 的 min/max。


8.2 生成波形 peaks

// src/audio/waveform.ts

export type WaveformPeak = {
  min: number;
  max: number;
};

export function getWaveformPeaks(
  buffer: AudioBuffer,
  points: number
): WaveformPeak[] {
  const peaks: WaveformPeak[] = [];
  const blockSize = Math.ceil(buffer.length / points);

  for (let point = 0; point < points; point++) {
    const start = point * blockSize;
    const end = Math.min(start + blockSize, buffer.length);

    let min = 1;
    let max = -1;

    for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
      const data = buffer.getChannelData(channel);

      for (let i = start; i < end; i++) {
        const value = data[i] ?? 0;
        min = Math.min(min, value);
        max = Math.max(max, value);
      }
    }

    peaks.push({ min, max });
  }

  return peaks;
}

8.3 绘制到 Canvas

// src/audio/waveform.ts

export function drawWaveform(
  canvas: HTMLCanvasElement,
  peaks: WaveformPeak[]
): void {
  const ctx = canvas.getContext("2d");

  if (!ctx) return;

  const width = canvas.width;
  const height = canvas.height;
  const middle = height / 2;

  ctx.clearRect(0, 0, width, height);

  for (let x = 0; x < width; x++) {
    const peak = peaks[x];

    if (!peak) continue;

    const yMin = middle + peak.min * middle;
    const yMax = middle + peak.max * middle;

    ctx.beginPath();
    ctx.moveTo(x, yMin);
    ctx.lineTo(x, yMax);
    ctx.stroke();
  }
}

使用:

const peaks = getWaveformPeaks(track.buffer, canvas.width);
drawWaveform(canvas, peaks);

9. 频谱分析

9.1 波形 vs 频谱

类型看什么横轴纵轴
波形振幅随时间变化时间振幅
频谱不同频率能量大小频率能量

波形回答:

这个声音什么时候变大、什么时候变小?

频谱回答:

这个声音低频多,还是高频多?


9.2 使用 AnalyserNode 做实时频谱

// src/audio/preview.ts

export function createSpectrumPreview(
  buffer: AudioBuffer,
  canvas: HTMLCanvasElement
): {
  context: AudioContext;
  stop: () => void;
} {
  const context = new AudioContext();

  const source = new AudioBufferSourceNode(context, {
    buffer,
  });

  const analyser = new AnalyserNode(context, {
    fftSize: 2048,
    smoothingTimeConstant: 0.8,
  });

  source.connect(analyser);
  analyser.connect(context.destination);

  const frequencyData = new Uint8Array(analyser.frequencyBinCount);

  const ctx = canvas.getContext("2d");
  let rafId = 0;
  let stopped = false;

  function draw() {
    if (stopped || !ctx) return;

    analyser.getByteFrequencyData(frequencyData);

    ctx.clearRect(0, 0, canvas.width, canvas.height);

    const barWidth = canvas.width / frequencyData.length;

    for (let i = 0; i < frequencyData.length; i++) {
      const value = frequencyData[i] / 255;
      const barHeight = value * canvas.height;

      ctx.fillRect(
        i * barWidth,
        canvas.height - barHeight,
        Math.max(1, barWidth),
        barHeight
      );
    }

    rafId = requestAnimationFrame(draw);
  }

  source.start();
  draw();

  return {
    context,
    stop() {
      stopped = true;
      cancelAnimationFrame(rafId);

      try {
        source.stop();
      } catch {
        // source 可能已经自然结束
      }

      context.close();
    },
  };
}

注意:浏览器通常要求用户手势触发音频播放,所以真实项目里要在按钮点击事件里创建或 resume AudioContext


10. 静音检测

10.1 静音检测的基本思路

静音检测不是看 sample 是否等于 0,因为真实录音里常有底噪。

常见做法是按窗口计算 RMS:

每 2048 个 sample 作为一个窗口

计算 RMS

转换成 dBFS

低于阈值,例如 -45 dBFS

持续超过 300ms

认为是静音片段

10.2 静音检测代码

// src/audio/silence.ts

export type SilenceRegion = {
  start: number;
  end: number;
  duration: number;
};

export type DetectSilenceOptions = {
  thresholdDb?: number;
  minDuration?: number;
  windowSize?: number;
};

function rmsToDb(rms: number): number {
  return 20 * Math.log10(Math.max(rms, 1e-12));
}

export function detectSilence(
  buffer: AudioBuffer,
  options: DetectSilenceOptions = {}
): SilenceRegion[] {
  const thresholdDb = options.thresholdDb ?? -45;
  const minDuration = options.minDuration ?? 0.3;
  const windowSize = options.windowSize ?? 2048;

  const regions: SilenceRegion[] = [];

  let silenceStart: number | null = null;

  for (let startSample = 0; startSample < buffer.length; startSample += windowSize) {
    const endSample = Math.min(startSample + windowSize, buffer.length);

    let sumSquares = 0;
    let count = 0;

    for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
      const data = buffer.getChannelData(channel);

      for (let i = startSample; i < endSample; i++) {
        const sample = data[i] ?? 0;
        sumSquares += sample * sample;
        count++;
      }
    }

    const rms = Math.sqrt(sumSquares / Math.max(1, count));
    const db = rmsToDb(rms);

    const time = startSample / buffer.sampleRate;
    const isSilent = db < thresholdDb;

    if (isSilent && silenceStart === null) {
      silenceStart = time;
    }

    if (!isSilent && silenceStart !== null) {
      const end = time;
      const duration = end - silenceStart;

      if (duration >= minDuration) {
        regions.push({
          start: silenceStart,
          end,
          duration,
        });
      }

      silenceStart = null;
    }
  }

  if (silenceStart !== null) {
    const end = buffer.duration;
    const duration = end - silenceStart;

    if (duration >= minDuration) {
      regions.push({
        start: silenceStart,
        end,
        duration,
      });
    }
  }

  return regions;
}

用途:

1. 自动裁剪开头和结尾静音
2. 语音录制后自动找空白段
3. 音频编辑器里标记静音区域
4. 播客剪辑中辅助切段

11. AudioWorklet 实时处理示例

11.1 什么时候用 AudioWorklet

适合:

实时电平检测
实时降噪前处理
实时 limiter
实时合成器
实时变声
自定义滤波器

不适合:

简单离线导出
简单波形绘制
已经能用 GainNode / BiquadFilterNode 解决的问题
一次性文件转码

11.2 RMS Meter Worklet

public/worklets/rms-meter-processor.js

class RmsMeterProcessor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.frameCount = 0;
  }

  process(inputs) {
    const input = inputs[0];

    if (!input || input.length === 0) {
      return true;
    }

    let sumSquares = 0;
    let count = 0;

    for (const channel of input) {
      for (let i = 0; i < channel.length; i++) {
        const sample = channel[i];
        sumSquares += sample * sample;
        count++;
      }
    }

    const rms = Math.sqrt(sumSquares / Math.max(1, count));

    this.frameCount++;

    // 不要每个 render quantum 都 postMessage,避免主线程压力过大。
    if (this.frameCount % 16 === 0) {
      this.port.postMessage({ rms });
    }

    return true;
  }
}

registerProcessor("rms-meter", RmsMeterProcessor);

主线程使用:

export async function createRmsMeter(
  context: AudioContext,
  source: AudioNode,
  onLevel: (rms: number) => void
): Promise<AudioWorkletNode> {
  await context.audioWorklet.addModule("/worklets/rms-meter-processor.js");

  const meterNode = new AudioWorkletNode(context, "rms-meter");

  meterNode.port.onmessage = (event) => {
    onLevel(event.data.rms);
  };

  source.connect(meterNode);
  meterNode.connect(context.destination);

  return meterNode;
}

12. 和 WebCodecs 的关系

Web Audio 和 WebCodecs 很容易被混在一起,但它们负责的层次不同。

能力Web AudioWebCodecs
播放和处理 PCM强项不是主要定位
多轨混音强项不负责
音量、滤波、频谱强项不负责
音频解码到原始数据可通过 decodeAudioData可通过 AudioDecoder
音频编码成 AAC/Opus不负责可通过 AudioEncoder,取决于浏览器支持
导出 WAV可手写不需要
导出 MP4/M4A不负责封装编码后仍需要 muxer
低层 chunk/frame 控制较弱强项

WebCodecs 规范定义的是音频、视频和图像的编码/解码接口,不规定必须支持某个具体 codec;MDN 也把它描述为能表示 raw/encoded audio/video,并在 raw 与 encoded 之间转换的浏览器原生接口。(W3C)

实际工程里可以这样组合:

简单音频编辑器:
decodeAudioData
  → AudioBuffer
  → Web Audio 混音
  → WAV encoder
  → 下载 WAV

需要导出 AAC / Opus:
Web Audio 混音
  → AudioBuffer / PCM
  → 转成 AudioData
  → WebCodecs AudioEncoder
  → EncodedAudioChunk
  → muxer 封装成 MP4 / WebM / Ogg

一句面试回答:

Web Audio 更像音频处理图和混音引擎,WebCodecs 更像底层编解码接口。如果我要做多轨混音、音量包络、频谱分析,我优先用 Web Audio;如果我要把 PCM 编码成 AAC/Opus,或者精确控制 encoded chunk,我会考虑 WebCodecs,但最终生成 MP4/WebM 还需要 muxer。


13. 真实工程中的应用场景

场景对应本章能力
在线播客剪辑器多轨混音、静音检测、WAV 导出
短视频配乐工具人声 + BGM + 音效混音
语音消息处理静音裁剪、音量归一
在线铃声制作截取、淡入淡出、导出 WAV
直播音效面板AudioBufferSourceNode + GainNode
屏幕录制后处理麦克风轨 + 系统声音轨混音
音频可视化播放器波形 + 频谱
语言学习跟读工具录音、波形对齐、静音段检测

14. 常见误区

误区 1:Web Audio 可以直接导出 MP3 或 MP4

不对。

Web Audio 负责音频图处理和 PCM 渲染。导出 WAV 可以手写,因为 WAV/PCM 很简单;导出 MP3/AAC/Opus 需要编码器,导出 MP4/WebM 还需要封装器。


误区 2:多个音频混一起就是 sample 相加

思路上接近,但真实工程要考虑:

音量
声道数
采样率
开始时间
淡入淡出
削波 clipping
归一化
主音量 master gain

两个音轨都接近满音量时,简单相加很容易超过 [-1, 1],导致削波失真。


误区 3:AudioBufferSourceNode 可以反复 start / stop

不对。

它是一次性的。再次播放同一个 AudioBuffer 时,应该新建一个 AudioBufferSourceNode


误区 4:波形图就是频谱图

不对。

波形是时域,频谱是频域。

波形:看声音随时间怎么变化
频谱:看不同频率成分有多少

误区 5:直接改 gain.value 就能做平滑 fade

不推荐。

实时直接跳变音量可能产生 click/pop。淡入淡出应该用 AudioParam automation,让浏览器音频线程按音频时钟平滑处理。


误区 6:变速不会影响音高

使用 AudioBufferSourceNode.playbackRate 变速时,通常速度和音高会一起变化。 如果要“变速不变调”,那就是 time-stretch / pitch-shift 问题,比本章 MVP 复杂很多。


15. 性能注意事项

15.1 PCM 很占内存

PCM 体积估算:

duration × sampleRate × channels × bytesPerSample

例如 10 分钟、48kHz、双声道、Float32:

600 × 48000 × 2 × 4 = 230,400,000 bytes
约 220 MiB

这还只是一条音轨。多轨项目会更夸张。


15.2 不要为波形绘制遍历太频繁

波形 peaks 可以缓存:

AudioBuffer

生成 1000 / 2000 / 5000 个 peaks

UI 缩放时复用或分级缓存

不要每次 React render 都重新扫描几百万个 sample。


15.3 长音频导出要小心

OfflineAudioContext.startRendering() 会一次性返回完整 AudioBuffer。对于几十分钟甚至几小时的项目,内存压力很大。

工程策略:

短音频:OfflineAudioContext 一次性渲染
长音频:分片渲染 / Worker / WASM / 服务端处理
复杂导出:考虑 ffmpeg.wasm 或后端转码

15.4 频谱可视化不要阻塞主线程

AnalyserNode 拿数据很方便,但绘制 canvas 仍然在主线程。频谱柱数量、刷新频率、canvas 尺寸都要控制。

推荐:

requestAnimationFrame
适当的 fftSize
只在播放时绘制
暂停时停止动画循环

15.5 注意释放资源

AudioContext 不用时 close()
Object URL 用完 revokeObjectURL()
requestAnimationFrame 要 cancel
AudioBufferSourceNode 不要复用
大型 AudioBuffer 不用时从状态里移除

16. 面试可能怎么问

Q1:Web Audio 做混音的基本流程是什么?

参考回答:

先把用户上传的音频文件通过 decodeAudioData 解码成 AudioBuffer。每条音轨创建一个 AudioBufferSourceNode,再接一个 GainNode 控制音量和淡入淡出。所有音轨根据各自的 startTime 调度到同一个 AudioContextOfflineAudioContext。实时预听用 AudioContext 输出到 destination,导出文件用 OfflineAudioContext.startRendering() 渲染成新的 AudioBuffer,最后把 PCM 写成 WAV。


Q2:为什么导出时用 OfflineAudioContext,而不是 AudioContext?

参考回答:

AudioContext 是实时播放用的,渲染速度跟真实时间一致,并且输出到扬声器。OfflineAudioContext 不输出到硬件设备,而是尽快把 AudioNode graph 渲染成一个 AudioBuffer。所以做混音导出、批处理、音频分析时更适合用 OfflineAudioContext


Q3:如何实现 fade in / fade out?

参考回答:

通常给每条音轨接一个 GainNode,然后对 gain.gain 这个 AudioParam 做 automation。比如开始时 setValueAtTime(0, start),然后 linearRampToValueAtTime(volume, start + fadeIn)。淡出则在结束前一段时间保持 volume,然后 ramp 到 0。不要用 JS 定时器频繁改 gain 值,因为不够精确,也可能产生 click。


Q4:多轨混音为什么可能失真?

参考回答:

多条音轨叠加后,采样值可能超过 PCM 的正常范围,比如 Float32 PCM 通常按 [-1, 1] 理解。如果输出 WAV 时转成 int16,超过范围会被 clamp,听起来就是削波失真。工程上可以降低每轨音量、使用 master gain、limiter 或导出后做峰值归一化。


Q5:波形和频谱有什么区别?

参考回答:

波形是时域信息,展示振幅随时间变化;频谱是频域信息,展示不同频率上的能量大小。波形常用于编辑时间线,频谱常用于音乐可视化、均衡器、音频分析。


Q6:Web Audio 和 WebCodecs 如何配合?

参考回答:

Web Audio 适合处理解码后的 PCM,比如混音、滤波、音量控制、可视化。WebCodecs 更适合底层编码和解码,比如把 AudioData 编码成 AAC 或 Opus 的 chunk。但 WebCodecs 不负责最终容器封装,所以要导出 MP4/M4A/WebM,还需要 muxer。简单导出可以用 Web Audio 渲染 PCM,然后手写 WAV。


17. 实践任务

任务 1:上传多个音频并绘制波形

要求:

1. input[type=file] 支持 multiple
2. 每个文件 decode 成 AudioBuffer
3. 每个 AudioBuffer 生成 waveform peaks
4. 每条轨道画一个 canvas 波形

任务 2:实现多轨时间线混音

要求:

1. 每条 track 可以设置 startTime
2. 每条 track 可以设置 volume
3. 使用 OfflineAudioContext 渲染
4. 播放混音后的结果

任务 3:实现 fade in / fade out

要求:

1. 每条 track 可以设置 fadeIn
2. 每条 track 可以设置 fadeOut
3. 使用 GainNode.gain 的 automation
4. 对比直接修改 gain.value 的差异

任务 4:导出 WAV

要求:

1. 把 mixed AudioBuffer 转成 16-bit PCM
2. 写 RIFF/WAVE header
3. 生成 Blob
4. 下载 mixed.wav

任务 5:静音检测

要求:

1. 对 AudioBuffer 按窗口计算 RMS
2. 转换成 dBFS
3. 找出低于 -45dB 且持续超过 300ms 的片段
4. 在波形图上标记静音区域

任务 6:实时频谱可视化

要求:

1. 创建 AudioContext
2. source → AnalyserNode → destination
3. 使用 getByteFrequencyData()
4. requestAnimationFrame 绘制频谱柱

18. 自测题

1. Web Audio 中 AudioBuffer 存的是什么?

答案:

解码后的 PCM 音频数据。它包含采样率、声道数、采样帧长度、时长,以及每个声道的 Float32Array 数据。


2. 多轨混音时,startTimeoffset 有什么区别?

答案:

startTime 是音轨在项目时间线上的开始时间;offset 是从源音频文件内部第几秒开始播放。


3. 为什么导出混音适合用 OfflineAudioContext

答案:

因为它可以把整张 AudioNode 图离线渲染成一个新的 AudioBuffer,不需要实时播放,也不需要输出到扬声器。


4. AudioBufferSourceNode 能不能重复播放?

答案:

不能。一个 AudioBufferSourceNode 调用 start() 后就是一次性的。要重新播放同一个 AudioBuffer,需要创建新的 AudioBufferSourceNode


5. fade in / fade out 应该怎么实现?

答案:

给音轨接 GainNode,然后对 gain.gain 使用 AudioParam automation,例如 setValueAtTime()linearRampToValueAtTime()


6. 混音后为什么可能 clipping?

答案:

多条音轨叠加后,采样峰值可能超过正常范围。导出到 int16 WAV 时如果超过范围会被截断,导致失真。


7. 峰值归一化和响度归一化有什么区别?

答案:

峰值归一化只调整最大峰值到目标值,比如 -1 dBFS;响度归一化关注人耳感知响度,通常需要 LUFS 等算法。两者不是一回事。


8. 波形和频谱分别用于什么?

答案:

波形用于展示振幅随时间变化,适合时间线编辑;频谱用于展示不同频率上的能量,适合音乐可视化和频率分析。


9. AudioWorklet 适合解决什么问题?

答案:

适合自定义实时音频处理,例如电平检测、实时滤波、噪声门、limiter、合成器等。它比旧的 ScriptProcessorNode 更适合低延迟音频处理。


10. Web Audio 能不能直接导出 MP4?

答案:

不能。Web Audio 可以渲染 PCM,也可以手写 WAV;如果要导出 MP4/M4A,需要音频编码器和 MP4 muxer。WebCodecs 可以负责部分编码,但不负责最终容器封装。


19. 章节总结

这一章你已经把 Web Audio 从“会播放音频”推进到了“能做一个真实音频处理 Demo”的程度。

你现在应该掌握这条链路:

上传文件
→ ArrayBuffer
→ decodeAudioData
→ AudioBuffer
→ Track Timeline
→ AudioNode Graph
→ GainNode 音量包络
→ OfflineAudioContext 离线渲染
→ mixed AudioBuffer
→ normalize
→ WAV encoder
→ 下载文件

核心能力可以总结成三句话:

  1. Web Audio 的核心是 AudioNode graph。
  2. 多轨混音的核心是时间线调度 + 每轨 gain envelope。
  3. 导出文件的核心是 OfflineAudioContext 渲染 PCM,再编码或封装。

20. 下一章衔接

下一章进入:

第十一章|时间戳、同步与播放控制

到这里为止,我们已经能处理音频,也能在前面章节里处理视频。但真实音视频项目最容易出问题的地方不是“能不能播放”,而是:

音频和视频时间对不对?
seek 后从哪里开始?
视频帧 timestamp 怎么传?
音频 sample 和视频 frame 怎么对齐?
为什么处理后声音慢了半拍?
为什么导出后音画不同步?

所以第十一章会重点讲:

timestamp
duration
PTS
DTS
timebase
sampleRate
frameRate
A/V sync
seek
keyframe
WebCodecs timestamp 传递

也就是把前面的 WebCodecs 视频处理 和这一章的 Web Audio 音频处理 串成真正的音视频工程链路。