如何彻底销毁 AudioContext 并清理相关线程?
结论
Web Audio API 没有“强制销毁 AudioContext”或“立即终止 AudioContext 线程”的接口。应用可以完成以下清理工作:
- 停止所有输入
MediaStreamTrack。 - 断开音频节点。
- 关闭
AudioWorkletNode.port,并清除事件处理器。 - 只要上下文尚未关闭,就调用并等待
AudioContext.close(),无论它当前处于running还是suspended状态。 - 移除业务代码对
AudioContext、节点、流、回调和缓冲区的引用,让垃圾回收器能够回收 JavaScript 对象。
清理完成后,资源何时从开发者工具中消失,以及底层工作线程何时退出或被浏览器复用,仍由浏览器决定。Performance 面板暂时保留线程记录,不能单独作为内存泄漏的证据。
当前代码中的主要问题
下面的判断没有处理处于 suspended 状态的上下文:
if (context.state == "running") {
await context.close();
}
close() 也可以关闭 suspended 状态的上下文。只要状态不是 closed,就应尝试关闭:
if (context.state !== "closed") {
await context.close();
}
另外,disconnect() 只负责断开音频图中的连接。它不会销毁节点,也不会移除 MessagePort、onmessage、onprocessorerror 等引用。如果某个长生命周期对象仍持有这些回调,对应闭包可能继续引用整个录音会话。
建议的停止顺序
清理时可以采用以下顺序:
- 向
AudioWorkletProcessor发送Finish。 - 等待处理器发送最后一批音频数据和
Finished。 - 停止处理来自 Worklet 的后续消息。
- 关闭
recorder.port。 - 清除节点和轨道上的事件处理器。
- 断开
source与recorder。 - 停止流中的所有轨道。
- 调用并等待
context.close()。 - 清空业务对象仍保存的引用。
如果只使用一条音轨,停止 audioTrack 即可。如果同一个 MediaStream 可能包含多条轨道,使用 stream.getTracks() 更合适:
for (const track of stream.getTracks()) {
track.stop();
}
如果 prewarmedMediaStream 会缓存或复用这个流,还要检查它是否长期保存了已经停止的 MediaStream 或相关回调。关闭 AudioContext 不会自动移除外部对象持有的流引用。
可重复调用的清理实现
停止操作适合设计成幂等流程,避免用户主动停止、轨道结束和异常处理同时触发重复清理。
let stopPromise: Promise<void> | null = null;
let finishing = false;
let disposed = false;
let recordingEndedError: AsyncError | null = null;
async function disposeRecording(): Promise<void> {
if (disposed) return;
disposed = true;
// 先清除回调,避免清理过程中再次进入停止流程。
audioTrack.onended = null;
recorder.onprocessorerror = null;
recorder.port.onmessage = null;
// MessagePort 不再需要时应显式关闭。
recorder.port.close();
try {
source.disconnect();
} catch {
// 节点可能已经断开。
}
try {
recorder.disconnect();
} catch {
// 节点可能已经断开。
}
for (const track of stream.getTracks()) {
if (track.readyState !== "ended") {
track.stop();
}
}
if (context.state !== "closed") {
await context.close();
}
}
recorder.port.onmessage = async (event: MessageEvent<IncomingMessage>) => {
if (disposed) return;
const msg = event.data;
if (msg.type === "Audio") {
listener.onDataAvailable?.(msg.data);
return;
}
if (msg.type !== "Finished") return;
try {
await disposeRecording();
if (recordingEndedError) {
listener.onRecordingTerminated?.(recordingEndedError);
} else {
try {
listener.onRecordingCompleted?.();
} catch {
// 用户回调异常不应阻止停止 Promise 完成。
}
}
} finally {
recordingStopped.resolve();
}
};
function stopRecording(error?: AsyncError | null): Promise<void> {
if (stopPromise) return stopPromise;
stopPromise = recordingStopped.promise;
recordingEndedError = error ?? null;
if (!finishing && !disposed) {
finishing = true;
recorder.port.postMessage({ type: "Finish" });
}
return stopPromise;
}
audioTrack.onended = () => {
void stopRecording(new AudioEndedError());
};
如果这些变量保存在录音器实例中,停止完成后还应清空相应字段:
this.context = null;
this.stream = null;
this.source = null;
this.recorder = null;
this.listener = null;
局部变量会在闭包不可达后自然回收,无须逐个赋值为 null。更需要检查的是全局变量、单例、缓存、事件监听器和未完成的 Promise。
改进 AudioWorkletProcessor 的结束逻辑
处理器发送 Finished 后,可以清空缓冲区和消息回调,同时保证结束消息只发送一次:
class PcmRecorder extends AudioWorkletProcessor {
constructor() {
super();
this.buffers = [];
this.finishRequested = false;
this.finished = false;
this.port.onmessage = (event) => {
if (event.data?.type === "Finish") {
this.finishRequested = true;
}
};
}
process(inputs) {
if (this.finished) return false;
const floats = inputs[0]?.[0];
if (floats) {
const samples = Int16Array.from(floats, this.convertSample);
this.buffers.push(samples);
}
const total = this.buffers.reduce(
(length, buffer) => length + buffer.length,
0
);
if (total > 1600 || this.finishRequested) {
if (total > 0) {
const mergedBuffer = new Int16Array(total);
let writeLocation = 0;
for (const source of this.buffers) {
mergedBuffer.set(source, writeLocation);
writeLocation += source.length;
}
this.port.postMessage(
{
type: "Audio",
data: new Uint8Array(mergedBuffer.buffer),
},
[mergedBuffer.buffer]
);
}
this.buffers = [];
}
if (this.finishRequested) {
this.finished = true;
this.port.onmessage = null;
this.port.postMessage({ type: "Finished" });
return false;
}
return true;
}
convertSample(floatSample) {
return Math.max(
-32768,
Math.min(Math.round(floatSample * 32768), 32767)
);
}
}
这段代码使用 transferable object 传递 mergedBuffer.buffer,可以少保留一份缓冲区副本。传输完成后,Worklet 一侧的 ArrayBuffer 会被分离,后续不能再读取。
使用 inputs[0]?.[0] 也比直接访问 inputs[0][0] 更安全,因为输入暂时不可用时,inputs 或对应通道可能为空。
为什么 close() 后仍能看到线程
AudioContext.close() 会关闭上下文,并释放它占用的系统音频资源,但不保证:
- JavaScript 对象立即被垃圾回收。
- 开发者工具中的历史记录立即消失。
- 底层线程立即退出。
- 浏览器不再保留或复用音频工作线程。
- Performance 面板立即移除所有异步分析数据。
await context.close() 表示关闭流程已经完成到 API 所规定的程度,不等于浏览器必须在这一刻销毁线程对象。线程可能处于空闲状态,也可能由多个音频上下文共享,或者正在等待浏览器内部回收。
要判断是否真的发生泄漏,可以连续执行多轮录音,观察以下现象:
- 活跃的麦克风轨道是否回到零。
- 已关闭上下文的数量是否无限增长。
- 可达的
AudioContext、AudioWorkletNode和MessagePort对象是否持续增加。 - 触发垃圾回收后,内存是否仍持续增长。
- 旧回调是否还会收到新消息。
Performance 面板中出现多个名为 AudioContext 的线程,无法说明它们是真实泄漏、浏览器复用的线程,还是残留的分析记录。
需要注意的边界情况
不要无限等待 Finished。如果 Worklet 抛出异常、音频图停止渲染或消息丢失,当前流程可能始终无法调用 close()。生产代码可以设置超时,并在超时后直接执行 disposeRecording()。这样做可能丢失最后一小段缓冲区,需要在数据完整性和确定释放资源之间作出取舍。
以下失败路径也要分别处理:
audioWorklet.addModule()失败。new AudioWorkletNode()失败。context.resume()失败。onprocessorerror被触发。- 获取到的
MediaStream没有音频轨道。 - 初始化尚未完成,调用方已经要求停止。
遇到这些情况,也要停止已经取得的轨道,并关闭已经创建的 AudioContext。正常停止流程即使没有问题,初始化异常仍可能留下资源。
如果应用需要频繁开始和停止录音,可以考虑复用一个 AudioContext,减少反复创建底层音频资源的开销。前提是应用能够正确管理节点和录音会话。如果每次录音都需要完全隔离,继续使用独立上下文,并在结束时调用 close() 也可以。
应用应关闭上下文、断开连接并移除引用,让资源进入可回收状态。浏览器没有提供更强的同步销毁机制,业务清理也不应依赖手动触发垃圾回收。
备注:内容仅供参考。
