You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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;
}

问题分析

  1. 闭包引用累积:原代码中process函数通过oc.suspend(...).then(process)递归调用,形成的闭包会持续持有OfflineAudioContext、AnalyserNode等对象的引用。Safari的垃圾回收机制对这类循环引用的清理能力较弱,导致每次调用processFrequencyData后,旧的音频上下文和节点资源无法被释放,逐渐累积。
  2. 资源未显式清理:音频节点和上下文使用完毕后,没有主动断开连接或清空引用,Safari无法及时识别这些资源已不再使用。
  3. 挂起逻辑的残留状态:递归式的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.14 00:15:33