背景
现有项目使用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则支持流式解码、单帧解码。
代码实现
- 分帧实现
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));
- 采样帧解码(摘要波音),减少解码量
- 我们根据屏幕的两倍宽度进行采样:每一个像素桶取第一帧、中间帧、最后帧;
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;
}
- 解码生成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;
}
- 渲染
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,
});