用Canvas和Web Audio API实现酷炫的实时音频可视化效果

公孙梓艺 交互 阅读 2,167
赞 29 收藏
二维码
手机扫码查看
反馈

又踩坑了,AudioContext在iOS上一碰就挂

今天上线前最后测音频可视化,结果 iOS 上点一下播放按钮——直接白屏。控制台没报错,页面静默死亡。Chrome 里跑得好好的,Safari 里连 new AudioContext() 都不执行。我第一反应是:完了,又是那个老熟人。

用Canvas和Web Audio API实现酷炫的实时音频可视化效果

其实这已经是我第三次被 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> 加了 autoplaymuted,想着 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(),放在事件回调最开头。

踩坑提醒:这三点一定注意

  • 不要在 DOMContentLoadedload 里调 resume(),iOS 会无视
  • 不要把 resume() 包在异步逻辑里(比如 fetch 完再 resume),Safari 不认
  • 每次 resume 都要检查 audioCtx.state,避免重复调用(虽然不会报错,但没必要)

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如怎么优雅地处理锁屏恢复、或者兼容旧版 Safari 的 fallback),欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论