解决大音频文件加载波形方案

0 阅读3分钟

背景

现有项目使用wavesurfer.js生成音频波形,内部基于AudioBuffer加载音频文件(1h+)时,解码非常慢和内存占用高。

  • 首次绘制波形需等待音频全量解码,48分钟的音频解析出来需要:7400.469970703125 ms

  • 常见的 44100hz 采样率,双声道音频为例,每小时解码后的数据需占用内存 44100 x 3600 x 2 x 4B ≈ 1.2GB。

    时长内存计算公式Float32 内存(MB)
    10 分钟44100 × 600 × 2 × 4 ÷ 1024²206 MB
    30 分钟44100 × 1800 × 2 × 4 ÷ 1024²618 MB
    1 小时44100 × 3600 × 2 × 4 ÷ 1024²1,235 MB ≈ 1.2 GB
    2 小时44100 × 7200 × 2 × 4 ÷ 1024²2,470 MB ≈ 2.4 GB

分帧

常见音频文件编码算法都是先把原始 PCM 数据分为一帧一帧的最小单元(一帧时长通常在 20~60ms,该时间段内的声音信号保持特征稳定的概率较大)再分别对每一帧进行编码,得到一个编码后的帧序列,最后将它们以一定的格式封装为完整的音频文件。 todo:补充编码封装格式大致示例

  • 根据编码封装格式,可以在解码前进行解封装获取到每一个帧,分帧不涉及编解码,性能非常高。
  • 一个小时的音频文件大概有13.8w帧,绘制波形时可采样帧解码,耗时大大降低。
  • 可以使用codec-parser库实现

了解更多音频文件结构

帧解码

浏览器提供了两个 API 可用于音频解码,decodeAudioData 和 WebCodecs。

  • decodeAudioData只支持完整文件解码,只能全量解码。
  • WebCodecs则支持流式解码、单帧解码。

代码实现

flowChart.png

  1. 分帧实现
import CodecParser from "codec-parser";
const mimeType = "audio/mpeg";
const options = {
  onCodec: () => {},
  onCodecUpdate: () => {},
  enableLogging: true,
};
const parser = new CodecParser(mimeType, options);
const frames = parser.parseAll(new Uint8Array(buffer));
  1. 采样帧解码(摘要波音),减少解码量
  • 我们根据屏幕的两倍宽度进行采样:每一个像素桶取第一帧、中间帧、最后帧;
function pickFrames(frames, width) {
  if (frames.length <= width) return frames;
  const bucketCount = Math.max(1, Math.floor(width));
  const bucketSize = frames.length / bucketCount;
  const picked = [];
  const used = new Set(); // 防重复

  for (let i = 0; i < bucketCount; i++) {
    // 当前桶范围
    const start = Math.floor(i * bucketSize);
    const end = Math.min(
      frames.length - 1,
      Math.floor((i + 1) * bucketSize) - 1,
    );
    // 中间帧
    const center = Math.round((start + end) / 2);
    // 三个关键点:起始 / 中间 / 结束
    const indices = [start, center, end];
    for (const idx of indices) {
      if (!used.has(idx)) {
        used.add(idx);
        picked.push(frames[idx]);
      }
    }
  }
  return picked;
}
  1. 解码生成peaks
function decodeFrames(frames, mimeType) {
  if (!('AudioDecoder' in window)) throw new Error('WebCodecs not supported');
  if (!frames || frames.length === 0) return [];
  const header = frames[0].header;
  let writeOffset = 0;
  const totalSamples = frames.reduce((sum, frame) => sum + frame.samples, 0);
  const mergedData = new Float32Array(totalSamples);
  const decoder = new AudioDecoder({
    output: (audioData) => {
      const numChannels = audioData.numberOfChannels;
      const framesCount = audioData.numberOfFrames;
      const channels = [];
      for (let ch = 0; ch < numChannels; ch++) {
        const pcm = new Float32Array(framesCount);
        audioData.copyTo(pcm, { planeIndex: ch, format: 'f32-planar' });
        channels.push(pcm);
      }
      for (let i = 0; i < framesCount; i++) {
        let maxVal = 0;
        for (let ch = 0; ch < numChannels; ch++) {
          const v = channels[ch][i];
          if (Math.abs(v) > Math.abs(maxVal)) {
            maxVal = v;
          }
        }
        mergedData[writeOffset++] = maxVal;
      }
      audioData.close();
    },
    error: (e) => console.error(e),
  });

  decoder.configure({
    codec: mimeType,
    sampleRate: header.sampleRate,
    numberOfChannels: header.channels,
  });

  for (let i = 0; i < frames.length; i++) {
    const frame = frames[i];
    if (!frame) continue;
    decoder.decode(
      new EncodedAudioChunk({
        type: i === 0 ? 'key' : 'delta',
        timestamp: i,
        data: frame.data,
      })
    );
  }

  await decoder.flush();

  return getPeaks(mergedData, frames.length);
}

function getPeaks(pcm, width) {
  const barWidth = 2;
  const barGap = 1;
  const barSpacing = barWidth + barGap;
  const columns = Math.max(1, Math.floor(width / barSpacing));
  const peaks = new Float32Array(columns);
  const bucketSize = pcm.length / columns;
  for (let i = 0; i < columns; i++) {
    const start = Math.floor(i * bucketSize);
    const end = Math.min(pcm.length, Math.floor((i + 1) * bucketSize));

    let peak = 0;

    for (let j = start; j < end; j++) {
      const v = Math.abs(pcm[j]);
      if (v > peak) peak = v;
    }
    peaks[i] = peak;
  }

  return peaks;
}
  1. 渲染
import CodecParser from "codec-parser";
import WaveSurfer from 'wavesurfer.js';
const mimeType = "audio/mpeg";
const options = {
  onCodec: () => {},
  onCodecUpdate: () => {},
  enableLogging: true,
};
const parser = new CodecParser(mimeType, options);
const frames = parser.parseAll(new Uint8Array(buffer));
const containerWidth = playContainerRef.value.clientWidth * 2;
const pickedFrames = pickFrames(frames, containerWidth);
const peaks = await decodeFrames(pickedFrames, "mp3");
wavesurfer = WaveSurfer.create({
  container: waveRef.value,
  waveColor: "#E5E7EB",
  progressColor: "#5782F9",
  cursorColor: token.value.colorPrimary,
  cursorWidth: 1,
  barWidth: 2,
  barGap: 1,
  height: 32,
  url: props.url,
  peaks: peaks,
  dragToSeek: true,
  splitChannels: false,
});