第十章|Web Audio 实战:浏览器端音频处理与混音
实现浏览器端多轨音频编辑器 MVP:上传解码、时间线调度、音量控制、淡入淡出、变速、归一化、波形频谱、静音检测、离线混音和 WAV 导出。
第十章|Web Audio 实战:浏览器端音频处理与混音
目标: 能做真实业务里常见的音频处理:多音轨混音、音量控制、淡入淡出、变速、音量归一、静音检测、波形绘制、频谱分析、离线渲染和导出 WAV。
这一章不追求做一个完整 DAW,也不是写一个专业音频宿主,而是做一个前端工程师面试和项目展示都能讲清楚的 浏览器端多轨音频编辑器 MVP。
1. 本章学习目标
学完这一章,你应该能回答并实现这些问题:
- 用户上传多个音频文件后,如何在浏览器中解码成可处理的 PCM 数据?
- 如何把多个音频放到一条项目时间线上?
- 如何给每条音轨设置开始时间、音量、淡入淡出?
- 如何使用
OfflineAudioContext离线混音并导出 WAV? - 如何绘制波形?
- 如何做频谱可视化?
- 如何做简单静音检测?
AudioWorklet适合解决什么问题?- 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 章已经讲过 AudioContext、AudioBuffer、AudioNode graph、GainNode 和 OfflineAudioContext。本章不再重复 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 | 实时频谱、音量表、预听可视化 | 适合预听链路,不负责离线导出本身 |
AudioWorklet | RMS 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 Audio | WebCodecs |
|---|---|---|
| 播放和处理 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调度到同一个AudioContext或OfflineAudioContext。实时预听用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. 多轨混音时,startTime 和 offset 有什么区别?
答案:
startTime是音轨在项目时间线上的开始时间;offset是从源音频文件内部第几秒开始播放。
3. 为什么导出混音适合用 OfflineAudioContext?
答案:
因为它可以把整张 AudioNode 图离线渲染成一个新的
AudioBuffer,不需要实时播放,也不需要输出到扬声器。
4. AudioBufferSourceNode 能不能重复播放?
答案:
不能。一个
AudioBufferSourceNode调用start()后就是一次性的。要重新播放同一个AudioBuffer,需要创建新的AudioBufferSourceNode。
5. fade in / fade out 应该怎么实现?
答案:
给音轨接
GainNode,然后对gain.gain使用AudioParamautomation,例如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
→ 下载文件
核心能力可以总结成三句话:
- Web Audio 的核心是 AudioNode graph。
- 多轨混音的核心是时间线调度 + 每轨 gain envelope。
- 导出文件的核心是 OfflineAudioContext 渲染 PCM,再编码或封装。
20. 下一章衔接
下一章进入:
第十一章|时间戳、同步与播放控制
到这里为止,我们已经能处理音频,也能在前面章节里处理视频。但真实音视频项目最容易出问题的地方不是“能不能播放”,而是:
音频和视频时间对不对?
seek 后从哪里开始?
视频帧 timestamp 怎么传?
音频 sample 和视频 frame 怎么对齐?
为什么处理后声音慢了半拍?
为什么导出后音画不同步?
所以第十一章会重点讲:
timestamp
duration
PTS
DTS
timebase
sampleRate
frameRate
A/V sync
seek
keyframe
WebCodecs timestamp 传递
也就是把前面的 WebCodecs 视频处理 和这一章的 Web Audio 音频处理 串成真正的音视频工程链路。