第九章|Web Audio API 基础
从 AudioContext、AudioNode graph、AudioBuffer、AudioBufferSourceNode、MediaElementAudioSourceNode、GainNode、BiquadFilterNode、AnalyserNode、AudioWorklet 和 OfflineAudioContext 入手,建立浏览器音频处理的基础心智模型。
第九章|Web Audio API 基础
1. 本章学习目标
学完这一章,你要建立一个核心心智模型:
Web Audio 不是“播放音频文件”的 API,而是浏览器里的音频处理引擎。
如果说 <audio> 标签像一个“播放器”,那 Web Audio API 更像一台“调音台 + 效果器机架 + 录音室控制台”。
本章学完后,你应该能做到:
- 解释
AudioContext是什么。 - 画出一个简单的
AudioNode graph。 - 用 Web Audio 播放本地音频文件。
- 用
GainNode做音量控制。 - 用
BiquadFilterNode做简单滤波。 - 用
AnalyserNode做频谱可视化。 - 理解
AudioBuffer和AudioBufferSourceNode的关系。 - 理解
MediaElementAudioSourceNode和<audio>/<video>的关系。 - 理解
MediaStreamAudioSourceNode如何接麦克风。 - 知道为什么
ScriptProcessorNode不推荐,为什么应该关注AudioWorklet。 - 使用
OfflineAudioContext做离线混音和导出 WAV。
W3C 对 Web Audio API 的定位是:它是用于 Web 应用中音频处理和合成的高级 API,核心范式是把多个 AudioNode 连接成一个音频路由图;实际音频处理通常由浏览器底层高效实现完成。(W3C) MDN 也把它描述为一个可以选择音频源、添加效果、做音频可视化、空间化等处理的系统。(MDN Web Docs)
2. 一句话理解 Web Audio
Web Audio 的基本工作方式是:
音频源 Source
↓
处理节点 Processing Node
↓
分析节点 Analyser Node,可选
↓
输出节点 Destination
比如:
本地 MP3 文件
↓ decodeAudioData
AudioBuffer
↓
AudioBufferSourceNode
↓
GainNode,控制音量
↓
BiquadFilterNode,滤波
↓
AnalyserNode,拿频谱数据
↓
AudioDestinationNode,扬声器 / 耳机
更像工程里的真实画法:
+-------------------+
| AudioContext |
| |
| [Source Node] |
| ↓ |
| [GainNode] |
| ↓ |
| [FilterNode] |
| ↓ |
| [AnalyserNode] |
| ↓ |
| [destination] |
+-------------------+
MDN 对这个模型的描述也很直观:Web Audio 在一个 audio context 内处理音频操作,基础操作由 audio nodes 完成,节点通过输入和输出连接成 audio routing graph,最终可以连到 destination 输出到扬声器或耳机。(MDN Web Docs)
3. 核心概念解释
3.1 AudioContext:音频世界的运行时
AudioContext 可以理解成浏览器里的“音频引擎实例”。
它负责:
| 能力 | 说明 |
|---|---|
| 创建节点 | 创建 GainNode、AnalyserNode、BiquadFilterNode 等 |
| 管理音频时钟 | audioCtx.currentTime 是高精度音频时间 |
| 管理采样率 | audioCtx.sampleRate 决定当前上下文的采样率 |
| 解码音频 | decodeAudioData() 可以把完整音频文件解码成 AudioBuffer |
| 输出声音 | audioCtx.destination 通常代表系统扬声器 / 耳机 |
| 控制状态 | resume()、suspend()、close() 控制音频上下文生命周期 |
典型代码:
const audioCtx = new AudioContext();
console.log(audioCtx.sampleRate);
console.log(audioCtx.currentTime);
console.log(audioCtx.state); // "suspended" | "running" | "closed"
工程里通常不要到处创建 AudioContext。一个页面里可以优先复用一个主 AudioContext,否则资源管理和浏览器限制会让你踩坑。
3.2 AudioNode:音频管线里的一个节点
AudioNode 是所有音频节点的共同基类。你可以把每个 node 想成调音台上的一个模块:
输入音频 → 节点处理 → 输出音频
有些节点是“源头”,只输出不输入:
AudioBufferSourceNode
MediaElementAudioSourceNode
MediaStreamAudioSourceNode
OscillatorNode
有些节点是“处理器”,有输入也有输出:
GainNode
BiquadFilterNode
DelayNode
DynamicsCompressorNode
WaveShaperNode
AnalyserNode
AudioWorkletNode
有些节点是“终点”:
AudioDestinationNode
MediaStreamAudioDestinationNode
3.3 AudioDestinationNode:声音最终去哪里
audioCtx.destination 就是默认输出节点,通常代表用户的扬声器或耳机。
source.connect(audioCtx.destination);
如果一个音频 graph 没有连到 destination,用户通常听不到声音;但这不代表 graph 没有用。例如频谱分析、离线渲染、录制、生成 PCM 都可以不直接播放出来。MDN 也明确提到,连接到 destination 只有在用户需要听到声音时才是必要的。(MDN Web Docs)
3.4 AudioBuffer:解码后的 PCM 音频数据
AudioBuffer 可以理解成:
已经解码好的 PCM 音频,存在内存里。
比如一个 MP3 文件:
MP3 文件,压缩数据
↓ decodeAudioData
AudioBuffer,解码后的 PCM
AudioBuffer 里面通常包含:
| 属性 | 含义 |
|---|---|
sampleRate | 采样率,例如 44100 / 48000 |
numberOfChannels | 声道数,例如 1 / 2 |
length | 每个声道的 sample 数 |
duration | 时长,单位秒 |
getChannelData(index) | 获取某个声道的 Float32 PCM 数据 |
注意:AudioBuffer 是解码后的 PCM,内存占用会比压缩后的 MP3 / AAC 大很多。
举个例子,3 分钟、48kHz、双声道、Float32 PCM:
48000 samples/s × 180s × 2 channels × 4 bytes
≈ 69 MB
所以 Web Audio 很适合短音效、音乐编辑、混音、可视化,但如果你只是播放一个很长的播客,不一定要把整个文件 decodeAudioData() 到内存里。
3.5 decodeAudioData():把完整音频文件解码成 AudioBuffer
decodeAudioData() 用来把 ArrayBuffer 里的音频文件数据异步解码成 AudioBuffer。MDN 特别说明,它适用于完整文件数据,不适合只传音频文件片段;返回值现在主要使用 Promise。(MDN Web Docs)
典型流程:
const file = input.files?.[0];
if (!file) return;
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
重要区别:
MP3 / AAC / WAV 文件
↓ decodeAudioData
AudioBuffer
↓ AudioBufferSourceNode
播放 / 处理 / 混音
但它不是万能解码器。比如:
MP4 文件里有视频 + 音频
这种情况下,decodeAudioData() 不负责解析 MP4 容器,也不负责拆音轨。你通常需要先 demux 出音频轨,再考虑音频解码。这个点和前面 WebCodecs 那几章是连着的。
3.6 AudioBufferSourceNode:播放 AudioBuffer 的源节点
AudioBuffer 本身只是数据,不能自己播放。你需要创建一个 AudioBufferSourceNode:
const source = audioCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioCtx.destination);
source.start();
非常重要的坑:
AudioBufferSourceNode只能start()一次。
MDN 明确说明,AudioBufferSourceNode 每次播放后就不能再次播放;如果想重复播放同一个声音,需要复用 AudioBuffer,但重新创建新的 AudioBufferSourceNode。(MDN Web Docs)
错误写法:
source.start();
source.stop();
source.start(); // ❌ 会报错
正确写法:
function play(buffer: AudioBuffer) {
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start();
}
AudioBuffer 像唱片,AudioBufferSourceNode 像一次性唱针。唱片可以重复用,但每次播放要换一个唱针。
3.7 MediaElementAudioSourceNode:把 <audio> / <video> 接进 Web Audio
有时候你不想手动解码整个音频文件,只想让 <audio> 标签负责加载、播放、seek、缓冲,然后把它的声音接到 Web Audio 里做音量、滤波、可视化。
这时用:
const audio = document.querySelector("audio")!;
const source = audioCtx.createMediaElementSource(audio);
MediaElementAudioSourceNode 表示来自 HTML <audio> 或 <video> 元素的音频源,它没有输入,只有一个输出,由 AudioContext.createMediaElementSource() 创建。(MDN Web Docs)
典型 graph:
<audio> element
↓
MediaElementAudioSourceNode
↓
GainNode
↓
AnalyserNode
↓
destination
这种方式很适合:
| 场景 | 原因 |
|---|---|
| 音乐播放器可视化 | <audio> 负责播放,AnalyserNode 负责分析 |
| 视频声音处理 | <video> 音轨进入 Web Audio |
| 在线播放器 EQ | 用 BiquadFilterNode 做均衡器 |
| 播放控制复杂的长音频 | 用 media element 处理缓冲和 seek |
3.8 MediaStreamAudioSourceNode:接入麦克风 / 摄像头音频流
如果你要处理麦克风声音:
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const micSource = audioCtx.createMediaStreamSource(stream);
然后可以接:
麦克风 MediaStream
↓
MediaStreamAudioSourceNode
↓
GainNode
↓
AnalyserNode
↓
destination / MediaStreamDestination / AudioWorkletNode
常见用途:
| 场景 | 用法 |
|---|---|
| 录音音量检测 | AnalyserNode |
| 麦克风实时波形 | getByteTimeDomainData() |
| 语音活动检测 VAD | AudioWorklet 或 Worker + PCM |
| 实时变声 | AudioWorkletNode |
| 麦克风混背景音乐 | 多个 source 接同一个 master gain |
3.9 GainNode:音量控制
GainNode 的作用是把音频 sample 乘以一个系数:
输出 sample = 输入 sample × gain
例如:
const gainNode = audioCtx.createGain();
gainNode.gain.value = 1.0; // 原音量
gainNode.gain.value = 0.5; // 振幅减半
gainNode.gain.value = 0.0; // 静音
推荐用平滑变化,不要瞬间改值,否则可能产生“啪”的爆音:
gainNode.gain.setTargetAtTime(0.5, audioCtx.currentTime, 0.01);
音量控制 graph:
source
↓
GainNode
↓
destination
在真实项目里,一般会有多个 gain:
trackGain,每条轨道音量
↓
masterGain,总音量
↓
destination
3.10 BiquadFilterNode:滤波器 / 简单 EQ
BiquadFilterNode 是常用的二阶滤波器节点,可以做低通、高通、峰值、搁架 EQ 等。
常见类型:
| type | 作用 | 类比 |
|---|---|---|
lowpass | 低通,保留低频,削弱高频 | 声音变闷 |
highpass | 高通,保留高频,削弱低频 | 去低频轰鸣 |
bandpass | 带通,只保留一段频率 | 电话音效 |
notch | 陷波,削掉一段频率 | 去特定噪声 |
lowshelf | 提升 / 降低低频 | 低音旋钮 |
highshelf | 提升 / 降低高频 | 高音旋钮 |
peaking | 提升 / 降低某个频段 | 参数均衡器 |
例子:做一个低通滤波器。
const filter = audioCtx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = 1000; // 只让 1000Hz 附近以下更明显
filter.Q.value = 1;
source.connect(filter).connect(audioCtx.destination);
工程里做一个简单三段 EQ,大概就是:
低频 lowshelf
↓
中频 peaking
↓
高频 highshelf
3.11 AnalyserNode:波形和频谱可视化
AnalyserNode 用来拿音频数据做可视化。MDN 的 Web Audio 文档也把可视化列为 Web Audio 的典型能力之一。(MDN Web Docs)
它常用两个方法:
analyser.getByteTimeDomainData(dataArray);
analyser.getByteFrequencyData(dataArray);
区别:
| 方法 | 数据含义 | 适合画什么 |
|---|---|---|
getByteTimeDomainData() | 时域波形 | 波形图、示波器 |
getByteFrequencyData() | 频域能量 | 频谱柱状图 |
典型 graph:
source
↓
AnalyserNode
↓
destination
或者:
source
↓
GainNode
↓
AnalyserNode
↓
destination
AnalyserNode 通常不会改变声音本身,它更像“旁边接了一个仪表盘”。
3.12 ScriptProcessorNode:为什么不推荐
你会在很多旧教程里看到:
audioCtx.createScriptProcessor(...)
现在不建议新项目继续用它。
MDN 已经把 ScriptProcessorNode 标为 deprecated,并说明它被 AudioWorklet 和 AudioWorkletNode 替代。(MDN Web Docs) 主要问题是它的模型偏旧,容易带来延迟、主线程压力和音频 glitch。
你现在应该这么记:
旧方案:
ScriptProcessorNode
新方案:
AudioWorklet + AudioWorkletNode + AudioWorkletProcessor
3.13 AudioWorklet:自定义实时音频处理
AudioWorklet 用于提供自定义音频处理脚本,这些脚本运行在单独的 Web Audio 线程中,适合低延迟音频处理;MDN 还标注它需要 secure context,也就是通常要在 HTTPS 环境下使用。(MDN Web Docs)
适合做:
| 场景 | 为什么用 AudioWorklet |
|---|---|
| 实时音频特效 | 需要低延迟 |
| 麦克风 PCM 采集 | 需要稳定拿到 sample |
| 音量检测 / VAD | 需要实时分析 |
| 自定义合成器 | 需要 sample-level 生成 |
| 降噪 / 变声 | 需要持续处理音频流 |
最小结构:
main.ts
↓ addModule()
processor.js
↓ registerProcessor()
AudioWorkletNode
↓ connect graph
主线程:
const audioCtx = new AudioContext();
await audioCtx.audioWorklet.addModule("/meter-processor.js");
const meterNode = new AudioWorkletNode(audioCtx, "meter-processor");
meterNode.port.onmessage = (event) => {
console.log("level:", event.data.level);
};
source.connect(meterNode).connect(audioCtx.destination);
meter-processor.js:
class MeterProcessor extends AudioWorkletProcessor {
process(inputs: Float32Array[][]) {
const input = inputs[0];
const channel = input[0];
if (channel) {
let sum = 0;
for (let i = 0; i < channel.length; i++) {
sum += channel[i] * channel[i];
}
const rms = Math.sqrt(sum / channel.length);
this.port.postMessage({ level: rms });
}
return true;
}
}
registerProcessor("meter-processor", MeterProcessor);
这里的 rms 可以粗略表示音量大小。实际工程里不要每个 audio block 都 postMessage,可以节流,比如每 10~20 帧发一次,否则消息太多会影响主线程。
3.14 OfflineAudioContext:离线渲染,不实时播放
OfflineAudioContext 可以理解成“不接扬声器的 AudioContext”。它不是实时播放,而是尽可能快地把音频 graph 渲染成一个 AudioBuffer。MDN 对它的说明是:它表示由 AudioNode 连接组成的音频处理图,但不像标准 AudioContext 那样渲染到硬件设备,而是尽快生成结果并输出到 AudioBuffer。(MDN Web Docs)
适合做:
| 场景 | 用法 |
|---|---|
| 多轨混音导出 | 多个 source 定时 start |
| 加 fade in / fade out | 自动化 gain |
| 批量音频处理 | 不需要实时播放 |
| 导出 WAV | 渲染成 AudioBuffer 后写 WAV |
| 生成预览音频 | 离线快速合成 |
基本流程:
创建 OfflineAudioContext
↓
创建 source / gain / filter 等节点
↓
connect 到 offlineCtx.destination
↓
source.start(time)
↓
offlineCtx.startRendering()
↓
得到混音后的 AudioBuffer
↓
导出 WAV / PCM
4. 必须掌握的术语表
| 术语 | 中文理解 | 面试说法 |
|---|---|---|
AudioContext | 音频上下文 / 音频引擎 | 管理音频图、时间、采样率、节点创建和输出 |
AudioNode | 音频节点 | Web Audio graph 里的基本处理单元 |
AudioNode graph | 音频路由图 | 多个节点通过 connect 组成的音频处理链路 |
AudioDestinationNode | 输出节点 | 通常是扬声器 / 耳机 |
AudioBuffer | 解码后的 PCM 数据 | 存储一段内存中的多声道 PCM |
AudioBufferSourceNode | 播放 AudioBuffer 的源节点 | 一次性 source node,只能 start 一次 |
MediaElementAudioSourceNode | <audio> / <video> 音频源 | 把 HTMLMediaElement 接入 Web Audio |
MediaStreamAudioSourceNode | 麦克风 / 流媒体音频源 | 把 MediaStream 接入 Web Audio |
GainNode | 音量节点 | 对 sample 乘以 gain |
BiquadFilterNode | 二阶滤波节点 | 做低通、高通、EQ 等 |
AnalyserNode | 分析节点 | 拿波形 / 频谱数据做可视化 |
AudioWorklet | 实时自定义处理机制 | 替代 ScriptProcessorNode,适合低延迟处理 |
OfflineAudioContext | 离线音频上下文 | 不播放,快速渲染成 AudioBuffer |
decodeAudioData | 音频文件解码方法 | 把完整音频文件 ArrayBuffer 解码为 AudioBuffer |
| PCM | 原始音频采样数据 | Web Audio 内部处理的核心数据形态 |
5. Demo 1:播放本地音频文件
目标
用户选择一个本地音频文件,浏览器读取文件,使用 decodeAudioData() 解码,然后通过 AudioBufferSourceNode 播放。
HTML
<input id="audioFile" type="file" accept="audio/*" />
<button id="playBtn">Play</button>
<button id="stopBtn">Stop</button>
<p id="info"></p>
TypeScript
let audioCtx: AudioContext | null = null;
let audioBuffer: AudioBuffer | null = null;
let currentSource: AudioBufferSourceNode | null = null;
const fileInput = document.querySelector<HTMLInputElement>("#audioFile")!;
const playBtn = document.querySelector<HTMLButtonElement>("#playBtn")!;
const stopBtn = document.querySelector<HTMLButtonElement>("#stopBtn")!;
const info = document.querySelector<HTMLParagraphElement>("#info")!;
async function getAudioContext(): Promise<AudioContext> {
if (!audioCtx) {
audioCtx = new AudioContext();
}
if (audioCtx.state === "suspended") {
await audioCtx.resume();
}
return audioCtx;
}
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (!file) return;
const ctx = await getAudioContext();
const arrayBuffer = await file.arrayBuffer();
audioBuffer = await ctx.decodeAudioData(arrayBuffer);
info.textContent = [
`name: ${file.name}`,
`duration: ${audioBuffer.duration.toFixed(2)}s`,
`sampleRate: ${audioBuffer.sampleRate}`,
`channels: ${audioBuffer.numberOfChannels}`,
].join(" | ");
});
playBtn.addEventListener("click", async () => {
const ctx = await getAudioContext();
if (!audioBuffer) {
alert("请先选择一个音频文件");
return;
}
if (currentSource) {
try {
currentSource.stop();
} catch {
// source 可能已经自然结束
}
currentSource.disconnect();
currentSource = null;
}
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
source.start();
currentSource = source;
source.onended = () => {
if (currentSource === source) {
currentSource.disconnect();
currentSource = null;
}
};
});
stopBtn.addEventListener("click", () => {
if (!currentSource) return;
try {
currentSource.stop();
} catch {
// 已结束则忽略
}
currentSource.disconnect();
currentSource = null;
});
关键点
这里最重要的不是播放,而是理解这条链:
File
↓ file.arrayBuffer()
ArrayBuffer
↓ audioCtx.decodeAudioData()
AudioBuffer
↓ AudioBufferSourceNode
AudioNode graph
↓ destination
扬声器
AudioBufferSourceNode 每次播放都要新建,不能复用旧的 source。
6. Demo 2:音量控制
目标
在 source 和 destination 之间插入一个 GainNode。
AudioBufferSourceNode
↓
GainNode
↓
AudioDestinationNode
HTML
<input id="volumeFile" type="file" accept="audio/*" />
<button id="volumePlayBtn">Play</button>
<label>
Volume
<input id="volumeSlider" type="range" min="0" max="2" step="0.01" value="1" />
</label>
TypeScript
const volumeFile = document.querySelector<HTMLInputElement>("#volumeFile")!;
const volumePlayBtn = document.querySelector<HTMLButtonElement>("#volumePlayBtn")!;
const volumeSlider = document.querySelector<HTMLInputElement>("#volumeSlider")!;
let volumeCtx: AudioContext | null = null;
let volumeBuffer: AudioBuffer | null = null;
let gainNode: GainNode | null = null;
async function getVolumeContext(): Promise<AudioContext> {
if (!volumeCtx) {
volumeCtx = new AudioContext();
gainNode = volumeCtx.createGain();
gainNode.gain.value = Number(volumeSlider.value);
gainNode.connect(volumeCtx.destination);
}
if (volumeCtx.state === "suspended") {
await volumeCtx.resume();
}
return volumeCtx;
}
volumeFile.addEventListener("change", async () => {
const file = volumeFile.files?.[0];
if (!file) return;
const ctx = await getVolumeContext();
volumeBuffer = await ctx.decodeAudioData(await file.arrayBuffer());
});
volumeSlider.addEventListener("input", async () => {
const ctx = await getVolumeContext();
if (!gainNode) return;
const value = Number(volumeSlider.value);
// 比直接 gain.value = value 更平滑,减少点击爆音
gainNode.gain.setTargetAtTime(value, ctx.currentTime, 0.01);
});
volumePlayBtn.addEventListener("click", async () => {
const ctx = await getVolumeContext();
if (!volumeBuffer || !gainNode) {
alert("请先选择音频文件");
return;
}
const source = ctx.createBufferSource();
source.buffer = volumeBuffer;
source.connect(gainNode);
source.start();
});
关键点
GainNode 不是“播放器音量条”,它本质是对 PCM sample 乘一个值:
gain = 1.0 原始振幅
gain = 0.5 振幅减半
gain = 0.0 静音
gain = 2.0 振幅加倍,可能削波失真
真实工程里建议加一个 master gain:
track1 source → track1 gain ┐
track2 source → track2 gain ├→ master gain → destination
track3 source → track3 gain ┘
7. Demo 3:频谱可视化
目标
用 <audio> 负责播放,用 AnalyserNode 拿频谱数据,用 Canvas 画柱状图。
HTML
<audio id="player" controls src="/demo.mp3"></audio>
<canvas id="spectrum" width="800" height="240"></canvas>
<button id="visualizeBtn">Start Visualize</button>
TypeScript
const audio = document.querySelector<HTMLAudioElement>("#player")!;
const canvas = document.querySelector<HTMLCanvasElement>("#spectrum")!;
const button = document.querySelector<HTMLButtonElement>("#visualizeBtn")!;
const canvasCtx = canvas.getContext("2d")!;
let visualCtx: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let initialized = false;
button.addEventListener("click", async () => {
if (!visualCtx) {
visualCtx = new AudioContext();
}
if (visualCtx.state === "suspended") {
await visualCtx.resume();
}
if (!initialized) {
const source = visualCtx.createMediaElementSource(audio);
analyser = visualCtx.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
analyser.connect(visualCtx.destination);
initialized = true;
drawSpectrum();
}
await audio.play();
});
function drawSpectrum() {
if (!analyser) return;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
requestAnimationFrame(draw);
analyser!.getByteFrequencyData(dataArray);
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const value = dataArray[i];
const barHeight = (value / 255) * canvas.height;
canvasCtx.fillRect(
x,
canvas.height - barHeight,
barWidth,
barHeight,
);
x += barWidth;
}
};
draw();
}
关键点
这条链路是:
<audio>
↓
MediaElementAudioSourceNode
↓
AnalyserNode
↓
destination
AnalyserNode 一边让声音继续往后走,一边让你读取频谱数据。它不是“录音器”,也不是“编码器”,只是分析节点。
一个常见坑是:不要反复对同一个 <audio> 元素调用 createMediaElementSource()。在组件化框架里,比如 React,要把 source node 缓存起来,否则很容易遇到重复绑定的问题。
8. Demo 4:两段音频混音并导出 WAV
目标
用户选择两段音频,把它们混在一起,并导出 WAV。
这里使用:
AudioContext
↓ decodeAudioData
AudioBuffer A / B
↓
OfflineAudioContext
↓
AudioBufferSourceNode A / B
↓
GainNode A / B
↓
offlineCtx.destination
↓ startRendering()
混音后的 AudioBuffer
↓
手写 WAV
HTML
<input id="mixFileA" type="file" accept="audio/*" />
<input id="mixFileB" type="file" accept="audio/*" />
<button id="mixBtn">Mix And Export WAV</button>
<a id="downloadLink">Download WAV</a>
TypeScript
const mixFileA = document.querySelector<HTMLInputElement>("#mixFileA")!;
const mixFileB = document.querySelector<HTMLInputElement>("#mixFileB")!;
const mixBtn = document.querySelector<HTMLButtonElement>("#mixBtn")!;
const downloadLink = document.querySelector<HTMLAnchorElement>("#downloadLink")!;
mixBtn.addEventListener("click", async () => {
const fileA = mixFileA.files?.[0];
const fileB = mixFileB.files?.[0];
if (!fileA || !fileB) {
alert("请选择两段音频");
return;
}
const decodeCtx = new AudioContext();
const [bufferA, bufferB] = await Promise.all([
decodeFile(fileA, decodeCtx),
decodeFile(fileB, decodeCtx),
]);
const sampleRate = decodeCtx.sampleRate;
// 例子:A 从 0 秒开始,B 从 2 秒开始
const startA = 0;
const startB = 2;
const outputDuration = Math.max(
startA + bufferA.duration,
startB + bufferB.duration,
);
const outputChannels = 2;
const outputLength = Math.ceil(outputDuration * sampleRate);
const offlineCtx = new OfflineAudioContext(
outputChannels,
outputLength,
sampleRate,
);
const sourceA = new AudioBufferSourceNode(offlineCtx, {
buffer: bufferA,
});
const sourceB = new AudioBufferSourceNode(offlineCtx, {
buffer: bufferB,
});
const gainA = new GainNode(offlineCtx, {
gain: 0.9,
});
const gainB = new GainNode(offlineCtx, {
gain: 0.7,
});
sourceA.connect(gainA).connect(offlineCtx.destination);
sourceB.connect(gainB).connect(offlineCtx.destination);
sourceA.start(startA);
sourceB.start(startB);
const mixedBuffer = await offlineCtx.startRendering();
const wavBlob = audioBufferToWav(mixedBuffer);
const url = URL.createObjectURL(wavBlob);
downloadLink.href = url;
downloadLink.download = "mixed.wav";
downloadLink.textContent = "Download mixed.wav";
await decodeCtx.close();
});
async function decodeFile(
file: File,
ctx: AudioContext,
): Promise<AudioBuffer> {
const arrayBuffer = await file.arrayBuffer();
return ctx.decodeAudioData(arrayBuffer);
}
function audioBufferToWav(buffer: AudioBuffer): Blob {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const bytesPerSample = 2;
const blockAlign = numChannels * bytesPerSample;
const dataSize = buffer.length * blockAlign;
const headerSize = 44;
const arrayBuffer = new ArrayBuffer(headerSize + dataSize);
const view = new DataView(arrayBuffer);
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeString(view, 8, "WAVE");
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, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
view.setUint16(32, blockAlign, true);
view.setUint16(34, bytesPerSample * 8, true);
writeString(view, 36, "data");
view.setUint32(40, dataSize, true);
const channelData: Float32Array[] = [];
for (let channel = 0; channel < numChannels; channel++) {
channelData.push(buffer.getChannelData(channel));
}
let offset = 44;
for (let i = 0; i < buffer.length; i++) {
for (let channel = 0; channel < numChannels; channel++) {
const sample = clamp(channelData[channel][i], -1, 1);
const int16 =
sample < 0
? sample * 0x8000
: sample * 0x7fff;
view.setInt16(offset, int16, true);
offset += 2;
}
}
return new Blob([arrayBuffer], {
type: "audio/wav",
});
}
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 clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
关键点
这个 Demo 里导出的是:
WAV = RIFF header + PCM data
不是 MP3,也不是 AAC。
Web Audio 本身负责:
解码到 AudioBuffer
音量调整
混音
离线渲染
得到 PCM
但如果你要导出 MP3 / AAC,通常还需要额外编码器,例如浏览器的 MediaRecorder、WebCodecs 音频编码能力,或者 WASM 编码库。
9. 录制 Web Audio graph 的思路
Web Audio 可以把 graph 的输出接到 MediaStreamAudioDestinationNode,再交给 MediaRecorder 录制。
概念图:
source A ┐
source B ├→ masterGain → MediaStreamAudioDestinationNode → MediaRecorder
mic ┘
代码骨架:
const audioCtx = new AudioContext();
const masterGain = audioCtx.createGain();
const streamDestination = audioCtx.createMediaStreamDestination();
masterGain.connect(streamDestination);
const recorder = new MediaRecorder(streamDestination.stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunks, {
type: recorder.mimeType,
});
const url = URL.createObjectURL(blob);
console.log(url);
};
recorder.start();
// 把你的 source 接到 masterGain
// source.connect(masterGain)
注意:
Web Audio graph → MediaStream → MediaRecorder
这条链可以得到浏览器支持的录制格式,但具体能录成什么 MIME type,要看浏览器支持情况。
10. Web Audio 和 WebCodecs 的关系
你前面刚学完 WebCodecs,所以这里要把边界讲清楚。
| 问题 | Web Audio | WebCodecs |
|---|---|---|
| 处理对象 | PCM 音频、AudioBuffer、AudioNode graph | EncodedAudioChunk、AudioData、EncodedVideoChunk、VideoFrame |
| 擅长 | 播放、混音、滤波、可视化、实时音频图 | 编码、解码、逐帧 / 逐 chunk 处理 |
| 是否处理容器 | 不处理 MP4 / WebM 容器 | 也不处理容器 |
| 是否适合实时音频效果 | 适合,尤其 AudioWorklet | 不主要做 graph 效果 |
| 是否能导出文件 | 可导出 PCM / WAV,或接 MediaRecorder | 编码后仍需 muxer 才是标准文件 |
一句话总结:
WebCodecs 更靠近 codec。
Web Audio 更靠近 audio engine / mixer / effects graph。
典型浏览器音视频编辑器会同时用到:
MP4 demuxer
↓
WebCodecs 解码视频
↓
Canvas / WebGL 处理视频帧
音频轨
↓
Web Audio 解码 / 混音 / 可视化
↓
导出 PCM / WAV 或重新编码
↓
muxer 封装成文件
11. 和真实工程的关系
Web Audio 在前端音视频岗位里非常实用,尤其是这些业务:
| 业务 | Web Audio 用在哪里 |
|---|---|
| 在线剪辑器 | 多轨音频混音、fade in / fade out、导出 |
| 播客编辑器 | 波形、静音检测、音量归一、剪切 |
| K 歌 / 直播 | 麦克风采集、混伴奏、监听、可视化 |
| 视频编辑器 | 替换 BGM、调整原声、音画预览 |
| 语音 AI 产品 | 麦克风 PCM 采集、音量检测、VAD |
| 音乐播放器 | 频谱、EQ、可视化 |
| 游戏 | 音效调度、空间音频、低延迟播放 |
面试里你不用把自己讲成音频算法专家,但要能说清:
我知道 Web Audio 的核心是 AudioContext + AudioNode graph。
我可以把不同 source 接入 graph,用 GainNode 控制音量,用 BiquadFilterNode 做滤波,用 AnalyserNode 做可视化。
如果要做离线混音导出,我会用 OfflineAudioContext。
如果要做低延迟自定义处理,我会考虑 AudioWorklet,而不是 ScriptProcessorNode。
这就很够打了。
12. 常见误区
误区 1:Web Audio 就是播放音频
不对。
播放只是最基础能力。Web Audio 更重要的是:
routing
mixing
filtering
analysis
scheduling
offline rendering
custom processing
误区 2:AudioBufferSourceNode 可以暂停后继续播放
不太对。
AudioBufferSourceNode 是一次性播放节点,没有内置 pause / resume。要做暂停继续,一般要自己记录:
播放开始时刻
暂停时 currentTime
已播放 offset
再次播放时从 offset 开始 source.start(when, offset)
误区 3:同一个 AudioBufferSourceNode 可以重复 start
错。
它只能 start 一次。复用的是 AudioBuffer,不是 source node。
误区 4:decodeAudioData() 可以流式解码任意片段
不对。
它适合完整音频文件数据,不适合你随便切一段 compressed bytes 就丢进去解码。MDN 对这个限制说得很清楚:它只适用于 complete file data,不适用于音频文件片段。(MDN Web Docs)
误区 5:Web Audio 可以直接导出 MP3
不对。
Web Audio 输出的是 PCM / AudioBuffer。你可以自己写 WAV,因为 WAV PCM 很简单;但 MP3 / AAC 是编码格式,需要编码器。
误区 6:AnalyserNode 会改变声音
通常不会。
它主要是让你读取波形 / 频谱数据,像仪表盘,不是效果器。
误区 7:ScriptProcessorNode 还能用,所以新项目继续用它也没问题
不建议。
它已经 deprecated,MDN 明确建议避免使用并更新现有代码,替代方向是 AudioWorklet。(MDN Web Docs)
13. 面试可能怎么问
Q1:Web Audio API 的核心模型是什么?
简洁回答:
Web Audio 的核心是 AudioContext 和由多个 AudioNode 连接成的 audio graph。音频从 source node 出来,经过 gain、filter、analyser 等 processing node,最后到 destination 输出。
深入回答:
AudioContext 管理音频时钟、采样率、节点创建和输出。每个 AudioNode 有输入和输出,可以通过 connect() 组成路由图。这个模型适合做播放、混音、滤波、可视化、实时处理和离线渲染。
Q2:AudioBuffer 和 AudioBufferSourceNode 有什么区别?
简洁回答:
AudioBuffer 是解码后的 PCM 数据;AudioBufferSourceNode 是把这段数据接入 Web Audio graph 并播放的 source node。
深入回答:
AudioBuffer 可以复用,但 AudioBufferSourceNode 只能 start() 一次。每次播放同一个 buffer,都应该新建一个 source node。
Q3:Web Audio 和 <audio> 标签有什么区别?
简洁回答:
<audio> 更像播放器,负责加载、播放、暂停、seek;Web Audio 更像音频处理引擎,可以做混音、滤波、分析、可视化和离线渲染。
深入回答:
两者可以结合使用。比如用 <audio> 播放长音频,再用 createMediaElementSource() 把它接入 Web Audio,用 AnalyserNode 画频谱,或用 BiquadFilterNode 做 EQ。
Q4:为什么不推荐 ScriptProcessorNode?
简洁回答:
因为它已经 deprecated,推荐用 AudioWorklet 替代。
深入回答:
ScriptProcessorNode 是旧的 JS 音频处理方式,容易有延迟和 glitch。AudioWorklet 的处理代码运行在 Web Audio 相关线程里,更适合低延迟实时音频处理。
Q5:OfflineAudioContext 用来做什么?
简洁回答:
它用于离线渲染音频 graph,不播放到扬声器,而是快速生成一个 AudioBuffer。
深入回答:
比如多轨混音导出:创建 OfflineAudioContext,把多个 AudioBufferSourceNode 按时间接进去,加 gain、filter、fade,然后调用 startRendering(),得到最终混音后的 AudioBuffer,再导出 WAV 或交给编码器。
Q6:Web Audio 能不能直接生成 MP4?
简洁回答:
不能。Web Audio 不负责容器封装。
深入回答:
Web Audio 处理的是音频 PCM、AudioBuffer 和 AudioNode graph。要生成 MP4,需要音频编码器把 PCM 编成 AAC / Opus 等,再用 muxer 封装进 MP4 / WebM。Web Audio 可以负责音频处理,但不能替代 muxer。
14. 实践任务
任务 1:本地音频播放器
实现:
上传音频
↓
decodeAudioData
↓
显示 duration / sampleRate / channels
↓
播放 / 停止
重点检查:
是否每次播放都新建 AudioBufferSourceNode
任务 2:音量控制 + 静音按钮
实现:
source
↓
GainNode
↓
destination
功能:
音量 slider:0 ~ 2
Mute 按钮
恢复音量
进阶:
用 setTargetAtTime 平滑变化,避免爆音
任务 3:频谱可视化
实现:
<audio>
↓
MediaElementAudioSourceNode
↓
AnalyserNode
↓
Canvas
要求:
画频谱柱状图
画时域波形
切换 fftSize
任务 4:麦克风音量检测
实现:
getUserMedia({ audio: true })
↓
MediaStreamAudioSourceNode
↓
AnalyserNode
↓
计算 RMS / peak
展示:
实时音量条
静音判断
说话检测
任务 5:两段音频离线混音导出 WAV
实现:
上传 A / B
设置 B 的开始时间
设置各自音量
OfflineAudioContext 渲染
导出 WAV
进阶:
给 A 加 fade out
给 B 加 fade in
15. 本章总结
这一章你要抓住一条主线:
AudioContext 是音频运行时
AudioNode 是处理单元
connect() 组成音频图
source 产生声音
processing node 处理声音
destination 输出声音
最核心的 graph 是:
source
↓
gain
↓
filter
↓
analyser
↓
destination
本章最重要的 API:
AudioContext
AudioNode
AudioDestinationNode
AudioBuffer
AudioBufferSourceNode
MediaElementAudioSourceNode
MediaStreamAudioSourceNode
GainNode
BiquadFilterNode
AnalyserNode
AudioWorklet
OfflineAudioContext
decodeAudioData
最重要的工程判断:
播放短音效:AudioBuffer + AudioBufferSourceNode
播放长音频:<audio> + MediaElementAudioSourceNode
实时麦克风:MediaStreamAudioSourceNode
可视化:AnalyserNode
音量控制:GainNode
滤波 / EQ:BiquadFilterNode
低延迟自定义处理:AudioWorklet
离线混音导出:OfflineAudioContext
导出 WAV:AudioBuffer → PCM → WAV header
导出 MP3 / AAC:还需要编码器
16. 自测题
1. Web Audio 的核心模型是什么?
答案:
AudioContext + AudioNode graph。音频从 source node 出来,经过 processing node,最后到 destination。
2. AudioContext.destination 是什么?
答案:
默认输出节点,通常代表系统扬声器或耳机。
3. AudioBuffer 是压缩数据还是 PCM?
答案:
是解码后的 PCM 数据,通常以 Float32 形式按声道存储。
4. AudioBufferSourceNode 能不能重复 start()?
答案:
不能。它只能 start() 一次。要重复播放同一个声音,应复用 AudioBuffer,但重新创建 AudioBufferSourceNode。
5. decodeAudioData() 适合解码什么?
答案:
适合把完整音频文件数据的 ArrayBuffer 解码成 AudioBuffer。不适合随便传压缩音频片段做流式解码。
6. GainNode 的本质是什么?
答案:
对输入音频 sample 乘以一个 gain 系数,用于音量控制或音量包络。
7. AnalyserNode 常用哪两个方法?
答案:
getByteTimeDomainData()
getByteFrequencyData()
前者拿时域波形,后者拿频域数据。
8. 为什么 ScriptProcessorNode 不推荐?
答案:
因为它已经 deprecated,并被 AudioWorklet / AudioWorkletNode 替代。新项目做实时自定义音频处理应优先考虑 AudioWorklet。
9. OfflineAudioContext 和普通 AudioContext 有什么区别?
答案:
普通 AudioContext 通常实时渲染到扬声器;OfflineAudioContext 不输出到硬件,而是尽快把音频 graph 渲染成一个 AudioBuffer。
10. Web Audio 能直接导出 MP3 吗?
答案:
不能。Web Audio 可以处理和生成 PCM / AudioBuffer,可以手写 WAV,但 MP3 / AAC 需要额外编码器。
17. 下一章衔接
下一章是:
Chapter 10:浏览器端音频处理与混音
这一章我们学的是 Web Audio 的基础 API。下一章会把这些 API 组合成一个更像真实项目的东西:
多音轨上传
↓
decodeAudioData
↓
绘制波形
↓
设置开始时间
↓
设置音量
↓
fade in / fade out
↓
OfflineAudioContext 离线混音
↓
导出 WAV
也就是说,第九章解决的是:
每个 API 是什么
第十章解决的是:
怎么把这些 API 拼成一个浏览器端音频编辑器