Safari中OfflineAudioContext处理耗时逐次增加问题排查
Safari中OfflineAudioContext重复调用耗时递增的内存泄漏问题分析与修复
问题描述
我使用OfflineAudioContext处理音频缓冲,节点链路为:[AudioBufferSourceNode] -> [AnalyserNode] -> [OfflineAudioContext]
代码在Chrome 106.0.5249.119上运行正常,但在macOS Safari 16(版本17614.1.25.9.10, 17614)中,每次运行音频分析的耗时会持续递增,必须完全退出Safari才能重置处理耗时。怀疑存在内存泄漏,想确认是否是JavaScript代码的问题导致Safari无法正常进行垃圾回收。
原代码如下:
async function processFrequencyData( audioBuffer, options ) { const { fps, numberOfSamples, maxDecibels, minDecibels, smoothingTimeConstant, } = options; const frameFrequencies = []; const oc = new OfflineAudioContext({ length: audioBuffer.length, sampleRate: audioBuffer.sampleRate, numberOfChannels: audioBuffer.numberOfChannels, }); const lengthInMillis = 1000 * (audioBuffer.length / audioBuffer.sampleRate); const source = new AudioBufferSourceNode(oc); source.buffer = audioBuffer; const az = new AnalyserNode(oc, { fftSize: numberOfSamples * 2, smoothingTimeConstant, minDecibels, maxDecibels, }); source.connect(az).connect(oc.destination); const msPerFrame = 1000 / fps; let currentFrame = 0; function process() { const frequencies = new Uint8Array(az.frequencyBinCount); az.getByteFrequencyData(frequencies); // const times = new number[](az.frequencyBinCount); // az.getByteTimeDomainData(times); frameFrequencies[currentFrame] = frequencies; const nextTime = (currentFrame + 1) * msPerFrame; if (nextTime < lengthInMillis) { currentFrame++; const nextTimeSeconds = (currentFrame * msPerFrame) / 1000; oc.suspend(nextTimeSeconds).then(process); } oc.resume(); } oc.suspend(0).then(process); source.start(0); await oc.startRendering(); return frameFrequencies; } const buttonsDiv = document.createElement('div'); document.body.appendChild(buttonsDiv); const initButton = document.createElement('button'); initButton.onclick = init; initButton.innerHTML = 'Load audio' buttonsDiv.appendChild(initButton); const processButton = document.createElement('button'); processButton.disabled = true; processButton.innerHTML = 'Process' buttonsDiv.appendChild(processButton); const resultElement = document.createElement('pre'); document.body.appendChild(resultElement) async function init() { initButton.disabled = true; resultElement.innerText += 'Loading audio... '; const audioContext = new AudioContext(); const arrayBuffer = await fetch('https://gist.githubusercontent.com/marcusstenbeck/da36a5fc2eeeba14ae9f984a580db1da/raw/84c53582d3936ac78625a31029022c8fdb734b2a/base64audio.txt').then(r => r.text()).then(fetch).then(r => r.arrayBuffer()) resultElement.innerText += 'finished.'; resultElement.innerText += '\nDecoding audio... '; const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); resultElement.innerText += 'finished.'; processButton.onclick = async () => { processButton.disabled = true; resultElement.innerText += '\nStart processing... '; const t0 = Date.now(); await processFrequencyData(audioBuffer, { fps: 30, numberOfSamples: 2 ** 13, maxDecibels: -25, minDecibels: -70, smoothingTimeConstant: 0.2, }); resultElement.innerText += `finished in ${Date.now() - t0} ms`; processButton.disabled = false; }; processButton.disabled = false; }
问题分析
- 闭包引用累积:原代码中
process函数通过oc.suspend(...).then(process)递归调用,形成的闭包会持续持有OfflineAudioContext、AnalyserNode等对象的引用。Safari的垃圾回收机制对这类循环引用的清理能力较弱,导致每次调用processFrequencyData后,旧的音频上下文和节点资源无法被释放,逐渐累积。 - 资源未显式清理:音频节点和上下文使用完毕后,没有主动断开连接或清空引用,Safari无法及时识别这些资源已不再使用。
- 挂起逻辑的残留状态:递归式的
suspend调用可能导致Safari中OfflineAudioContext的状态残留,多次调用后上下文的内部状态越来越复杂,增加处理耗时。
修复方案
1. 替换递归为async/await循环,减少闭包引用
改用async/await的Promise循环替代.then()递归,避免闭包长期持有资源引用,让垃圾回收器更容易识别可释放对象。
2. 显式清理音频资源
在处理完成后,主动断开音频节点的连接,清空相关引用,帮助Safari回收资源。
3. 优化帧计算逻辑
提前计算总帧数,避免循环内重复计算,减少不必要的性能开销。
修改后的核心代码如下:
async function processFrequencyData(audioBuffer, options) { const { fps, numberOfSamples, maxDecibels, minDecibels, smoothingTimeConstant, } = options; const frameFrequencies = []; const oc = new OfflineAudioContext({ length: audioBuffer.length, sampleRate: audioBuffer.sampleRate, numberOfChannels: audioBuffer.numberOfChannels, }); const lengthInMillis = 1000 * (audioBuffer.length / audioBuffer.sampleRate); const msPerFrame = 1000 / fps; // 提前计算总帧数,避免循环内重复判断 const totalFrames = Math.floor(lengthInMillis / msPerFrame); const source = new AudioBufferSourceNode(oc); source.buffer = audioBuffer; const az = new AnalyserNode(oc, { fftSize: numberOfSamples * 2, smoothingTimeConstant, minDecibels, maxDecibels, }); source.connect(az).connect(oc.destination); let currentFrame = 0; // 用async/await循环替代递归,减少闭包引用 const processFrame = async () => { const frequencies = new Uint8Array(az.frequencyBinCount); az.getByteFrequencyData(frequencies); frameFrequencies[currentFrame] = frequencies; currentFrame++; if (currentFrame < totalFrames) { const nextTimeSeconds = (currentFrame * msPerFrame) / 1000; await oc.suspend(nextTimeSeconds); await processFrame(); oc.resume(); } }; await oc.suspend(0); await processFrame(); oc.resume(); source.start(0); await oc.startRendering(); // 显式断开节点连接,清空引用 source.disconnect(); az.disconnect(); source.buffer = null; return frameFrequencies; }
额外建议
- 在调用
processFrequencyData后,若不再需要返回的frameFrequencies数组,可主动将其赋值为null,进一步帮助垃圾回收。 - 若仍存在耗时递增问题,可尝试将
OfflineAudioContext的创建逻辑移到外部,复用上下文(需注意上下文的状态重置),但这种方式需要更谨慎的状态管理。
内容的提问来源于stack exchange,提问作者marcusstenbeck
相关产品推荐
相关产品推荐

