Web Audio API 与音乐播放器
Web Audio API 与音乐播放器
音乐播放器是这个站点里花时间最长的模块。不是因为功能多复杂,而是 Web Audio API 的细节很多,稍不注意就会有奇怪的表现。
AudioContext 的生命周期
AudioContext 的创建时机是我遇到的第一个坑。
早期版本在组件 mount 时直接 new AudioContext(),在桌面端没什么问题。但在手机上,iOS Safari 要求 AudioContext 必须在用户手势(click/touchstart)内创建或 resume,否则不会发声。
一开始的解决方案是每次用户交互都调 audioCtx.resume(),但发现第一次点击播放按钮时经常需要点两次才能出声音。排查后发现是因为 a.play() 和 audioCtx.resume() 之间存在竞态——后者还没完成前者就开始播放了。
现在的做法是把 AudioContext 的创建和 resume 都放在用户手势的回调里同步执行,不在 useEffect 里提前创建。
createMediaElementSource 只能调一次
另一个容易踩的坑是 createMediaElementSource。同一个 HTMLAudioElement 只能调用一次这个方法,第二次调用会直接抛错误。
之前 AudioVisualizer 组件每次挂载都会尝试重新连接,导致切歌几次后整个频谱就罢工了——因为第一次连接成功了,第二次就开始抛异常,catch 之后 analyser 就拿不到数据。
解决办法是把 MediaElementSource 的创建移到 GlobalAudio 里统一管理,只在初始化时连接一次,之后所有需要 analyser 的组件都通过全局引用来获取数据。
频谱可视化的渲染路径
频谱用 AnalyserNode 的 getByteFrequencyData 获取频域数据,然后在 canvas 上画条形图。
为了降低性能开销,canvas 在初始化时根据 devicePixelRatio 设置实际尺寸,然后用 CSS 控制显示尺寸,避免每次 resize 都重新分配 buffer。
遇到的一个问题是:在安卓 Chrome 上切换 tab 回来后,analyser 的数据会变成全零。这是因为 AudioContext 在后台运行时会被系统挂起。解决方案是在 visibilitychange 事件里检查 AudioContext 状态,如果被挂起就 resume。
歌词同步
LRC 歌词的解析本身不复杂,按 [mm:ss.xx] 格式解析时间戳和文本,排序后按当前播放时间定位到对应的行。
滚动逻辑调整了很久。一开始用 scrollTo 加 behavior: "smooth",但歌词每句切换都会触发一次 smooth scroll,高频切换时动画互相覆盖,看起来像卡住了一样。后面改成直接设置 scrollTop,配合 position: relative 确保 offsetTop 的计算锚点正确。
还有一个细节:.lrc 文件大部分是 UTF-8 with BOM 格式,解析时如果不处理 BOM 头,第一行歌词的时间戳会匹配不上。加个 replace(/^/, '') 就解决了。