音频波形如果只显示为一条细线,用户很难判断整段音频的响度变化和停顿位置。把波形展开在可滚动区域里,让滚动距离与播放时间一一对应,可以同时完成预览和定位。本文使用jQuery处理DOM事件、Web Audio API解码音频、Canvas绘制波形,实现一个滚动同步的音频播放器。

一、先把音频变成可绘制的峰值数据
波形可视化不是直接读取MP3文件的字节,而是先解码成PCM采样数据。浏览器提供的AudioContext配合decodeAudioData方法可以把ArrayBuffer转换为AudioBuffer。解码后的AudioBuffer包含声道数和采样率信息,通过getChannelData可以拿到Float32Array,每个元素代表一个采样点的振幅,范围在-1到1之间。
一首三分钟的音频在44.1kHz采样率下约有800万个采样点,而屏幕上的波形条通常只有几千根。直接绘制所有采样点既浪费性能也看不出细节,因此需要按目标条数做峰值聚合。聚合逻辑是:把总采样点均分成若干组,每组取绝对值最大的那个值作为该组的峰值。这个峰值既保留了响度攻击和瞬态变化,又能把数据量压缩到可渲染的范围。jQuery在这个环节不参与计算,但它可以负责管理加载状态和按钮交互。
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
async function loadAudio(url) {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return audioBuffer;
}
function extractPeaks(audioBuffer, samplesPerPixel) {
const channelData = audioBuffer.getChannelData(0);
const peaks = [];
const blockSize = Math.floor(channelData.length / samplesPerPixel);
for (let i = 0; i < samplesPerPixel; i++) {
let max = 0;
const start = i * blockSize;
for (let j = 0; j < blockSize; j++) {
const value = Math.abs(channelData[start + j] || 0);
if (value > max) {
max = value;
}
}
peaks.push(max);
}
return peaks;
}
这里有两个容易出错的地方:第一,decodeAudioData对跨域音频有限制,如果文件不在同源或没有正确CORS头,解码会抛异常;第二,老版本Safari要用webkitAudioContext前缀。实际项目中建议先做能力检测,并在fetch失败时给出提示。
二、用Canvas画出可滚动波形
波形数据准备好后,需要把它画到<canvas>上。要让容器产生滚动,核心条件是canvas的实际宽度大于外层容器的可视宽度。例如容器宽度固定为600px,而波形有3000根,每根占2px,那么canvas宽度设为6000px,外层容器设置overflow-x: auto后就会出现水平滚动条。
绘制时从0到峰值数组长度遍历,用fillRect画出每一根竖条。竖条高度由峰值乘以canvas高度得到,纵坐标从中间线向上下一半高度展开。为了区分已播放和未播放区域,可以根据当前进度计算一个已播放索引,小于等于该索引的竖条使用高亮色,其余使用灰色或主题色。横向坐标可以理解为时间轴,x除以canvas宽度约等于当前时间除以总时长。
function drawWaveform(canvas, peaks, progress) {
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const middle = height / 2;
const barWidth = width / peaks.length;
const playedIndex = Math.floor(peaks.length * progress);
ctx.clearRect(0, 0, width, height);
for (let i = 0; i < peaks.length; i++) {
const x = i * barWidth;
const barHeight = peaks[i] * height;
ctx.fillStyle = i <= playedIndex ? '#ff6600' : '#4a90e2';
ctx.fillRect(x, middle - barHeight / 2, barWidth - 1, barHeight);
}
}
如果希望波形更平滑,可以把相邻两根竖条合并成一次绘制,或者用lineTo绘制上下包络线。竖条模式的好处是视觉清晰,容易看出每一段的响度。需要注意的是canvas的width属性控制实际像素,CSS宽度可以保持不变,否则在Retina屏幕上会模糊。
三、滚动位置与播放进度如何双向同步
双向同步包含两个方向。第一个方向是用户滚动波形容器时,根据scrollLeft计算播放进度。公式是progress = scrollLeft / (scrollWidth - clientWidth)。这里不能直接除以scrollWidth,因为scrollWidth包含不可见区域,而scrollWidth - clientWidth才是真正可滚动的距离。得到进度后乘以audio.duration赋给audio.currentTime,再重绘波形。
第二个方向是音频正在播放时,让滚动条跟随播放头移动。可以监听timeupdate事件,用currentTime / duration计算进度,再反向更新scrollLeft。不过timeupdate的触发频率大约是250ms一次,滚动会显得不够连续。更平滑的做法是用requestAnimationFrame循环读取audio.currentTime,每帧更新一次滚动位置。
const $scroll = $('#waveform-scroll');
const audio = $('#audio')[0];
let isUserScrolling = false;
$scroll.on('scroll', function() {
isUserScrolling = true;
const maxScrollLeft = this.scrollWidth - this.clientWidth;
if (maxScrollLeft <= 0) return;
const progress = this.scrollLeft / maxScrollLeft;
const targetTime = audio.duration * progress;
if (Math.abs(audio.currentTime - targetTime) > 0.05) {
audio.currentTime = targetTime;
}
drawWaveform(canvas, peaks, progress);
clearTimeout($scroll.data('scrollTimer'));
$scroll.data('scrollTimer', setTimeout(function() {
isUserScrolling = false;
}, 120));
});
audio.addEventListener('timeupdate', function() {
if (isUserScrolling) return;
const progress = audio.duration ? audio.currentTime / audio.duration : 0;
const maxScrollLeft = $scroll[0].scrollWidth - $scroll[0].clientWidth;
$scroll[0].scrollLeft = maxScrollLeft * progress;
drawWaveform(canvas, peaks, progress);
});
上面代码里加了一个isUserScrolling锁,原因是scroll事件设置currentTime后,浏览器会触发timeupdate;如果timeupdate再反向设置scrollLeft,就会形成回环抖动。锁标记在用户滚动时置为true,滚动结束后延迟恢复false。另一个思路是设置时间差阈值,只有当计算出的目标时间和当前时间差异超过50ms时才更新currentTime,避免微小位移频繁触发音频跳转。
四、完整示例与性能调优建议
把以上逻辑组合起来,初始化流程是:加载音频并解码、提取峰值、根据峰值数量设置canvas宽度、绘制初始波形。由于移动端和部分桌面浏览器要求用户手势后才能启动音频上下文,建议在首次点击按钮时调用audioContext.resume(),否则解码成功也可能没有声音。完整HTML结构如下。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: sans-serif; }
#waveform-scroll {
width: 700px;
overflow-x: auto;
border: 1px solid #ddd;
}
#waveform {
display: block;
cursor: pointer;
}
</style>
</head>
<body>
<audio id="audio" src="demo.mp3" controls></audio>
<div id="waveform-scroll">
<canvas id="waveform"></canvas>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
// 将前面 loadAudio、extractPeaks、drawWaveform、事件绑定代码放在此处
</script>
</body>
</html>
性能方面,如果峰值数量超过一万根,每次滚动都全量重绘可能会占用较多CPU。优化方法是用离屏canvas先绘制整张静态波形,再在主canvas上用drawImage复制,最后只覆盖已播放部分的颜色。或者不重绘整张图,只更新进度蒙版的宽度。对于滚动事件,可以配合requestAnimationFrame做节流,保证一帧最多更新一次。
还需要注意:使用file://协议打开页面时fetch通常会失败,建议启动本地HTTP服务测试;音频文件与页面跨域时必须在响应中加上CORS头;Safari旧版本对AudioContext前缀和decodeAudioData的回调形式兼容性不同,生产环境建议加入特性检测或使用兼容层。
掌握了波形峰值提取、Canvas绘制和双向滚动同步这三步,就可以把它扩展成带剪切、标记、区域循环等功能的音频工具。jQuery在这里承担事件管理和DOM更新,音频解码和绘制仍交给原生API,整体实现轻量且可控。