You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

如何用Web Audio API提前提取音频的FFT频谱、峰值等预处理数据?

如何用Web Audio API预分析音频(播放前提取FFT、峰值、RMS等数据)

当然可以实现!你遇到的问题是因为Web Audio API的处理是异步的,你原来的同步循环方式根本没给音频处理的时间,导致每次获取的都是初始状态的数据。下面我会先解释你的代码为什么不行,再给出两种可靠的实现方案。


为什么你的代码会返回相同值?

你在analyse()里用了一个while循环,每次设置audio.currentTime = time后立刻调用getFrame()取数据,但Web Audio的音频处理是在后台异步线程中进行的——你刚设置完播放位置,音频还没跳到那个时间点,analyser自然还没处理到对应帧的数据,所以每次获取的都是初始的默认值(比如时间域的128,对应0振幅)。

这种同步阻塞的方式完全不符合Web Audio的工作逻辑,必须改用异步/离线处理的思路。


方案一:直接处理AudioBuffer样本(推荐,效率高)

这个思路是先把音频文件解码成AudioBuffer(包含原始PCM样本数据),然后按你需要的60FPS间隔截取样本片段,直接计算峰值、RMS和FFT数据。

步骤1:加载并解码音频文件

const fileChooser = document.getElementById("chooseAudio");
fileChooser.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  // 读取文件为ArrayBuffer
  const arrayBuffer = await file.arrayBuffer();
  // 初始化AudioContext并解码音频
  const audioContext = new AudioContext();
  try {
    const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
    // 开始预分析
    const analysisData = await preAnalyzeAudio(audioBuffer);
    console.log("预分析完成,数据:", analysisData);
  } catch (err) {
    console.error("音频解码失败:", err);
  }
});

步骤2:实现预分析函数

async function preAnalyzeAudio(audioBuffer) {
  const FPS = 60;
  const FFT_SIZE = 256;
  const SMOOTHING = 0.7;
  const sampleRate = audioBuffer.sampleRate;
  const duration = audioBuffer.duration;
  const frameCount = Math.ceil(duration * FPS);
  // 每帧对应的样本数
  const samplesPerFrame = Math.floor(sampleRate / FPS);
  // 取单声道数据(多声道可以先合并为单声道)
  const channelData = audioBuffer.getChannelData(0);

  const analysisData = [];

  for (let i = 0; i < frameCount; i++) {
    // 计算当前帧的样本范围
    const startSample = i * samplesPerFrame;
    const endSample = Math.min(startSample + samplesPerFrame, channelData.length);
    const frameSamples = channelData.slice(startSample, endSample);

    // 计算峰值、RMS
    const peak = calculatePeak(frameSamples);
    const rms = calculateRMS(frameSamples);
    // 计算FFT的低频/高频平均值
    const { lowFreq, highFreq } = await calculateFFT(frameSamples, sampleRate, FFT_SIZE, SMOOTHING);

    analysisData.push([peak, rms, lowFreq, highFreq]);
  }

  return analysisData;
}

步骤3:实现各计算函数

// 计算峰值(转换为0-255范围,和Web Audio的Byte数据对齐)
function calculatePeak(samples) {
  let maxAmplitude = 0;
  for (const sample of samples) {
    const abs = Math.abs(sample);
    if (abs > maxAmplitude) maxAmplitude = abs;
  }
  // 把[-1,1]的振幅映射到0-255(128对应0振幅)
  return Math.round(maxAmplitude * 127 + 128);
}

// 计算RMS值
function calculateRMS(samples) {
  let sumOfSquares = 0;
  for (const sample of samples) {
    sumOfSquares += sample * sample;
  }
  const rms = Math.sqrt(sumOfSquares / samples.length);
  return Math.round(rms * 127 + 128);
}

// 计算FFT并返回低频/高频平均值
async function calculateFFT(samples, sampleRate, fftSize, smoothing) {
  // 使用OfflineAudioContext离线处理当前帧的样本
  const offlineCtx = new OfflineAudioContext(1, samples.length, sampleRate);
  const analyser = offlineCtx.createAnalyser();
  analyser.fftSize = fftSize;
  analyser.smoothingTimeConstant = smoothing;

  // 创建BufferSource播放当前帧样本
  const buffer = offlineCtx.createBuffer(1, samples.length, sampleRate);
  buffer.copyToChannel(samples, 0);
  const source = offlineCtx.createBufferSource();
  source.buffer = buffer;
  source.connect(analyser);
  source.start();

  // 完成渲染后获取频谱数据
  await offlineCtx.startRendering();
  const freqData = new Uint8Array(analyser.frequencyBinCount);
  analyser.getByteFrequencyData(freqData);

  // 划分低频(前1/4)和高频(后1/4)
  const quarterLength = Math.floor(freqData.length / 4);
  const lowAvg = freqData.slice(0, quarterLength).reduce((a, b) => a + b, 0) / quarterLength;
  const highAvg = freqData.slice(freqData.length - quarterLength).reduce((a, b) => a + b, 0) / quarterLength;

  return { lowFreq: lowAvg, highFreq: highAvg };
}

方案二:用OfflineAudioContext全局渲染

如果不想手动处理样本,可以用OfflineAudioContext一次性渲染整个音频,通过ScriptProcessorNode(或现代的AudioWorklet)捕获每个处理块的分析数据,之后再按60FPS的间隔整理数据。

async function preAnalyzeWithOfflineCtx(audioBuffer) {
  const FPS = 60;
  const FFT_SIZE = 256;
  const SMOOTHING = 0.7;
  const sampleRate = audioBuffer.sampleRate;
  const duration = audioBuffer.duration;
  const frameInterval = 1 / FPS;

  // 创建离线上下文
  const offlineCtx = new OfflineAudioContext(audioBuffer.numberOfChannels, audioBuffer.length, sampleRate);
  const analyser = offlineCtx.createAnalyser();
  analyser.fftSize = FFT_SIZE;
  analyser.smoothingTimeConstant = SMOOTHING;

  // 连接节点
  const source = offlineCtx.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(analyser);
  analyser.connect(offlineCtx.destination);
  source.start();

  // 存储所有处理块的原始数据
  const rawAnalysis = [];

  // 使用ScriptProcessorNode捕获数据(注意:ScriptProcessorNode已废弃,推荐生产环境用AudioWorklet)
  const scriptNode = offlineCtx.createScriptProcessor(FFT_SIZE, audioBuffer.numberOfChannels, audioBuffer.numberOfChannels);
  analyser.connect(scriptNode);
  scriptNode.connect(offlineCtx.destination);

  scriptNode.onaudioprocess = (e) => {
    const currentTime = e.playbackTime;
    // 获取频谱和时间域数据
    const freqData = new Uint8Array(analyser.frequencyBinCount);
    analyser.getByteFrequencyData(freqData);
    const timeData = new Uint8Array(analyser.fftSize);
    analyser.getByteTimeDomainData(timeData);

    // 计算当前块的指标
    rawAnalysis.push({
      time: currentTime,
      peak: calculatePeakFromByte(timeData),
      rms: calculateRMSFromByte(timeData),
      low: calculateLowFreq(freqData),
      high: calculateHighFreq(freqData)
    });
  };

  // 开始离线渲染
  await offlineCtx.startRendering();

  // 按60FPS间隔整理数据
  const analysisData = [];
  let currentTime = 0;
  let dataIdx = 0;

  while (currentTime < duration) {
    // 找到最接近当前时间的原始数据点
    while (dataIdx < rawAnalysis.length && rawAnalysis[dataIdx].time < currentTime) {
      dataIdx++;
    }
    const entry = rawAnalysis[dataIdx] || rawAnalysis[rawAnalysis.length - 1];
    analysisData.push([entry.peak, entry.rms, entry.low, entry.high]);
    currentTime += frameInterval;
  }

  return analysisData;
}

// 从Byte数据计算峰值
function calculatePeakFromByte(timeData) {
  let maxVal = 0;
  let minVal = 255;
  for (const val of timeData) {
    if (val > maxVal) maxVal = val;
    if (val < minVal) minVal = val;
  }
  const peakPos = maxVal - 128;
  const peakNeg = 128 - minVal;
  return 128 + Math.max(peakPos, peakNeg);
}

// 从Byte数据计算RMS
function calculateRMSFromByte(timeData) {
  let sum = 0;
  for (const val of timeData) {
    const sample = (val - 128) / 128;
    sum += sample * sample;
  }
  const rms = Math.sqrt(sum / timeData.length);
  return Math.round(rms * 127 + 128);
}

// 计算低频平均值
function calculateLowFreq(freqData) {
  const quarter = Math.floor(freqData.length / 4);
  return freqData.slice(0, quarter).reduce((a, b) => a + b, 0) / quarter;
}

// 计算高频平均值
function calculateHighFreq(freqData) {
  const quarter = Math.floor(freqData.length / 4);
  return freqData.slice(freqData.length - quarter).reduce((a, b) => a + b, 0) / quarter;
}

总结

两种方案都能实现播放前预分析音频数据的需求:

  • 方案一更直接,手动处理PCM样本,效率更高,适合对性能有要求的场景。
  • 方案二更贴近Web Audio的原生处理流程,适合需要复用AnalyserNode逻辑的场景。

内容的提问来源于stack exchange,提问作者Conor McCauley

火山引擎 最新活动