Safari 上拖动进度条有重音
博客右侧的音乐播放器是我自己写的,一个独立的 Web Component。最近发现它在 Safari 上有个毛病:拖动或点击进度条,跳过去的那一下会有重音,像两段声音叠在一起。
范围很快就清楚了。Mac 上只有 Safari 有,Chrome 和 Firefox 都正常;iPhone 上不管用哪个浏览器都有。iPhone 上的浏览器都得用 Safari 的 WebKit 内核,所以问题出在 WebKit。
巧的是同一天我刚把博客的 DNS 换到 Cloudflare,音乐文件也开了缓存,所以第一反应是缓存的问题。结果不是,为此绕了一大圈。排查和修改都是让 Claude Opus 5.5 做的,我负责在 Safari 里听。
先查的是播放器和 Cloudflare
播放器本身没发现问题:全程只有一个 audio 元素;拖动进度条时只改预览,松手才跳转一次;也没有重复调用 play()。在内置的 Chromium 里接上真实的歌,把播放器的输出录下来,和原曲逐段比对,跳转那一刻旧位置的声音就停了,新位置几十毫秒内接上,没有叠在一起。
Cloudflare 的响应也没问题。跳转时浏览器只请求文件的一段,Cloudflare 返回的是正确的 206,字节范围、内容和完整文件一致,跨域头也在。和以前相比,它只多了两样:浏览器缓存 7 天(max-age=604800),以及 HTTP/3。
找原因
Chromium 复现不了,只能靠我在 Safari 里听。Opus 5.5 做了一个对照页,同一首歌六种播放方式,每次只改一个地方:
- 用不用 Safari 的
fastSeek跳转 - 音频接不接 Web Audio
- 跳转时把音量降到 0 再恢复
- 先暂停,跳完再播放
结果只有不接 Web Audio 的两种没有重音。跳转方式、静音、先暂停都没用。
我还是觉得跟 Cloudflare 有关,就直接在 Cloudflare 上加了一条规则,让浏览器不再缓存 MP3,用 Safari 无痕窗口再听。还是重音,规则随后删掉了。
最后做了一次本机对照:在 Mac 上起一个小服务器提供同一首歌,不经过 Cloudflare,没有缓存头,走 HTTP/1.1。分快和慢两种,慢的每次响应先等 0.4 秒,模拟以前直连服务器的速度。全都重音。
到这里可以确定,跟 Cloudflare 没有关系。所谓「加缓存之前没事」,大概只是那时候没在 Safari 上拖过进度条。
原因
播放器要画跟着音乐跳的波形,在 iPhone 上调音量也得靠 Web Audio(iOS 不让网页改媒体元素的音量),所以用 createMediaElementSource 把 audio 元素接进了 Web Audio。在 WebKit 上,接进去以后再跳转,旧位置残留的声音会和新位置叠在一起。
网上没找到和这个现象一模一样的 WebKit bug 报告,不过这个节点在 WebKit 上的问题不少:延迟、断续、采样率不一致时的爆音,iOS 17 还一度完全没声音。有个叫 zvid 的项目测过,WebKit 里接了这个节点的元素,每次跳转后会卡住 300 毫秒左右。他们的做法是在 WebKit 上不用媒体元素,把音频解码以后直接用 Web Audio 播放。
修改过程
第一版最直接:WebKit 上不接 Web Audio,用原生播放。重音没了,但波形也没了,跳转还很慢。我猜以前也要等这么久,只是被残留的声音盖住了,不过没去验证。
第二版在后台把整首下载下来,解码出数据来画波形;下载完以后,下一次跳转时把播放源换成内存里的副本。波形有了,可在 Safari 里拖动还是一卡一卡的。
于是又做了一页对比,四种方式都不会重音,只比跳转顺不顺:
- 原生播放,边下边播
- 同上,改用
fastSeek - 先整首下到内存,再原生播放
- 整首解码后,用 Web Audio 的
AudioBufferSourceNode直接播放
第 4 种最顺。这种方式里没有媒体元素,跳转只是停掉旧的音源节点、从新位置建一个,自然不会叠音。缺点是要先下完、解码完才能开始播。
最后的做法是两种接起来用:
- 点播放时,先用原生 audio 元素边下边播,马上出声,不接 Web Audio;
- 同时在后台下载整首,用
decodeAudioData解码; - 解码完成后,等下一次跳转,或者暂停后再播放,改由
AudioBufferSourceNode播放,并释放 audio 元素。跳转本来就有个停顿,这时候切过去听不出来; - 之后跳转是瞬间的,波形和音量又回到原来那套 Web Audio 上,iPhone 上的音量滑块也能用了。
Chrome 和 Firefox 还是原来的逻辑,没动。
部分长歌没有波形
上线后,有几首歌在 Safari 上还是没有波形。原因是我设了上限:解码后的数据大约是 MP3 的 15 到 20 倍,一首 6 分半的歌解码完约 150 MB,怕 iPhone 内存不够,超过 12 MB 的文件就不解码,一直原生播放。
歌单里一共 155 首,有 42 首超了,基本是从网易云导入的 320kbps 长歌。后来改成按平台区分:Mac 上 64 MB,现有的歌全都能解码,最长的一首 30.6 MB、13 分钟,下载加解码大约 8 秒;iPhone 上 24 MB,大概是 10 分钟的 320kbps,只剩那首最长的不解码。
还剩的问题
- Safari 和 iPhone 上,每首歌会多下载一次完整文件,好在走的是 Cloudflare 的缓存。
- 长歌要等下载完、解码完,才有波形和快速跳转,在这之前和原来一样。
- 还没在 iPhone 上实测。一首歌解码后要占一两百 MB 内存,旧机型能不能扛住不好说;纯 Web Audio 播放在锁屏或切到后台后会不会被系统暂停,也还没试。
最后
这次最大的弯路是一开始认定了 Cloudflare,还动了线上的缓存规则。其实那个本机对照实验十几分钟就能做完,要是一开始就做,能省掉不少时间。
另外记一下:在 Safari 上,别把 audio 或 video 元素接进 Web Audio。要做波形这类效果,把音频解码后直接用 Web Audio 播放更稳。