用Canvas和Web Audio API实现酷炫的实时音频可视化效果
又踩坑了,AudioContext在iOS上一碰就挂
今天上线前最后测音频可视化,结果 iOS 上点一下播放按钮——直接白屏。控制台没报错,页面静默死亡。Chrome 里跑得好好的,Safari 里连 new AudioContext() 都不执行。我第一反应是:完了,又是那个老熟人。
其实这已经是我第三次被 AudioContext 的自动暂停策略坑了。第一次是做 WebRTC,第二次是做播客播放器,这次是给一个音乐博客加频谱动画(就是那种随音乐跳动的条形图)。本来以为用 analyserNode + requestAnimationFrame 拿到 FFT 数据画 canvas 就完事,结果连第一步都卡死。
我先确认了 Safari 的限制文档:iOS Safari 要求 AudioContext 必须在**用户手势触发的上下文中**才能启动,而且一旦进入后台、切 Tab、甚至锁屏,它就自动 suspend。更坑的是,它不会抛异常,state 直接变成 suspended,但你如果没主动检查,后续所有 analyser.getByteFrequencyData() 都返回全 0——画面不动,你还以为是 canvas 没刷新。
折腾了半天发现,不是我的 canvas 没重绘,是数据压根没进来。我在 requestAnimationFrame 里加了 console.log(analyser.frequencyBinCount),输出是 1024,但 getByteFrequencyData() 后打印 dataArray[0] 永远是 0。查了下,果然:audioCtx.state === 'suspended'。
后来试了下发现,必须在用户点击/触摸后,立刻调用 audioCtx.resume(),而且得是同步的、不能包在 Promise.then 或 setTimeout 里——哪怕延迟 1ms,Safari 就判定“非直接用户交互”,resume 失败(不报错,但 state 不变)。
所以最终方案非常简单粗暴:把 resume 放在播放按钮的 click handler 最开头,且只调一次(加个 flag 防重复)。
核心代码就这几行
下面是我最后上线的最小可运行片段(删掉了 UI 和样式,只留音频可视化主干):
<button id="playBtn">播放</button>
<canvas id="visualizer" width="800" height="200"></canvas>
<audio id="audioSource" src="https://jztheme.com/audio/sample.mp3"></audio>
const audioEl = document.getElementById('audioSource');
const playBtn = document.getElementById('playBtn');
const canvas = document.getElementById('visualizer');
const ctx = canvas.getContext('2d');
let audioCtx = null;
let analyser = null;
let dataArray = null;
let isContextResumed = false;
// 初始化音频上下文和分析器(但不启动)
function initAudio() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
dataArray = new Uint8Array(analyser.frequencyBinCount);
// 把 audio 元素连接到 analyser
const source = audioCtx.createMediaElementSource(audioEl);
source.connect(analyser);
analyser.connect(audioCtx.destination);
}
// 手势触发时立即 resume
playBtn.addEventListener('click', () => {
if (!isContextResumed && audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume().then(() => {
isContextResumed = true;
console.log('AudioContext resumed ✅');
}).catch(e => {
console.warn('resume failed:', e);
});
}
// 然后才开始播放(这里也可以换成 play())
if (audioEl.paused) {
audioEl.play().catch(e => {
console.error('play() failed:', e);
});
}
});
// 可视化绘制循环
function draw() {
if (!analyser || !dataArray) return;
// 每次都重新获取数据(注意:必须在 resume 后才有有效数据)
analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / dataArray.length;
for (let i = 0; i < dataArray.length; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
ctx.fillStyle = hsl(${i * 1.5}, 70%, 60%);
ctx.fillRect(i * barWidth, canvas.height - barHeight, barWidth - 1, barHeight);
}
}
// 启动可视化(但不启动 audioCtx,等用户点再 resume)
initAudio();
// 开始动画
function animate() {
draw();
requestAnimationFrame(animate);
}
animate();
这里我踩了个坑:别在 audio 标签上加 autoplay
一开始我为了“体验好”,给 <audio> 加了 autoplay 和 muted,想着 iOS 总该放行了吧?结果还是不行。Safari 对 muted autoplay 的支持极其有限——它只允许在页面加载完成后的几秒内触发,而且一旦用户没跟页面交互过,它照样 suspend。更糟的是,如果你在 autoplay 触发失败后手动调 play(),这时候 context 还是 suspended,你又得 resume 一次。干脆全砍掉,让用户点一下,明明白白。
还有个小问题没彻底解决
现在有个小尾巴:iOS 锁屏后,音频继续播,但可视化会停(因为 RAF 被浏览器降频甚至暂停)。我试过用 visibilitychange 监听,但恢复可见时 audioCtx.state 有时还是 suspended,得再 resume 一次。不过这个对用户体验影响不大,毕竟锁屏时也看不到 canvas。我就没深究了,加了个兜底:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && audioCtx?.state === 'suspended') {
audioCtx.resume();
}
});
原理上就一句话
AudioContext 在移动端不是“创建即可用”,它是“创建即待命”,得等一个明确的、同步的用户手势(click/touchstart),才能从 suspended 切到 running。这个设计是为了省电和防骚扰,不是 bug。你不能绕过它,只能适配它——而适配成本,就是多写那一行 audioCtx.resume(),放在事件回调最开头。
踩坑提醒:这三点一定注意
- 不要在
DOMContentLoaded或load里调resume(),iOS 会无视 - 不要把
resume()包在异步逻辑里(比如 fetch 完再 resume),Safari 不认 - 每次 resume 都要检查
audioCtx.state,避免重复调用(虽然不会报错,但没必要)
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如怎么优雅地处理锁屏恢复、或者兼容旧版 Safari 的 fallback),欢迎评论区交流。

暂无评论