这个故事要从一个很小的问题说起:我想听一首歌。
不是用手机那个小喇叭播出来、听起来像坏掉的收音机那种。我要的是整间房都听得到、音量还要有一点大,而我手上有两部手机加一台平板,全都可以当喇叭用。那为什么不用完三台?
因为三台设备不同步地播同一首歌,你得到的不是一个大喇叭,而是一个回声。一台手机已经唱到第二段主歌,另一台还在第一段,第三台则一直卡在缓冲。
真正的需求不是「播一首歌」,而是每一台设备在同一时间听到同一个瞬间。这就是我做出来的东西:一个小小的网站,叫 Sync Stream。
sync: +0 ms (net ±7ms)。为什么不用现成的 App?
我试过了。那些 App 并不差——它们只是要歌在它们的曲库里、在它们的账号下,或者要你有它们的订阅。我的文件就在我自己的文件夹里。我要的是这些。
还有一点:一个浏览器标签、一部手机、一台平板和一台桌面电脑,什么都不用安装,就能加入同一个「房间」。有人想听?开个链接就行。
于是清单变成这样:
- 所有人共用一个播放头——播放、暂停、拖动进度、换歌,大家都跟着走。
- 任何人都能控制。不需要「主持人」一直开着标签页。
- 没人在看的时候,电台也照跑。关掉手机,下一首歌照样播。
- 分开的房间,我的播放列表不会跟别人的混在一起。
- 还有一样我原本没料到会需要的:一个麦克风按钮,让「DJ」可以在歌上面讲话。
难的部分是时间,不是声音
串流音频很简单。难的是让三台设备对「现在」是哪一刻有共识。
每台设备都有自己的时钟,而且每台慢的幅度都略有不同。你发一句「在第 42.0 秒播放」,让每台设备按自己的本地时钟去读,结果还是会有回声。
所以服务器是唯一的事实来源。每一次状态变化都存成一个锚点:
{ "mediaId": "song.mp3", "playing": true, "posMs": 0, "anchor": 1792125755489 }
anchor 是 posMs 正确那一刻的服务器时钟。所以每个客户端自己算:如果正在播放,位置就是 posMs + (server_now − anchor)。
问题在于:客户端不知道服务器的时钟。解法很小也很准——快速发四次时间请求,取往返时间最短的那次样本,把它当作服务器时钟。网络延迟和时钟偏差刚好互相抵消。
之后,客户端每 200 毫秒做两件事之一:
- 硬跳(Snap)——直接跳到正确的位置:状态变化时、载入新文件时、误差超过 600 毫秒时,或者误差超过 200 毫秒且距离上次硬跳已过 2.5 秒时。
- 微调(Nudge)——把播放速度调快或调慢 ±5%,让差距在听不出来的情况下收窄。小幅修正之所以让人察觉不到,就是靠这个。
硬跳是保证,微调是润色。有些浏览器干脆无视小幅的 playbackRate 改动——回报 1.05,实际音频时钟却还是跑 1.00。只靠微调的话,总有一台设备会落后。
在两台浏览器之间实测:10–150 毫秒。耳朵听不出这跟「同步」有什么区别。
那次失败的硬跳,和一台落后 20 秒的手机
这个我搞了很久才搞懂。浏览器只能在它已经载入的那部分文件里跳转。你叫它跳到缓冲区里没有的某一秒,它不会报错——它会静静地待在原地。
而且我的程序在指令发出去的那一刻就标记「硬跳成功」,所以循环看起来永远健康。有一次测试,一台手机落后了 20 秒,另外两台却是对的。
修法:
- 硬跳之后等大约 800 毫秒,把
currentTime跟目标比对。差超过 2 秒=跳转被拒绝。 - 用在该偏移量重新打开来源来修复——
src#t=second会让浏览器只请求那一段,而不是从零开始下载。 - 限制修复次数(每次都要重新载入,相隔约 8 秒),并在状态栏写明状态:
🔄 catching up…。
我现在的原则是:落后的设备必须看起来像是在被修复,而不是像个谜团。
电台必须在没人听的时候也照跑
用着用着还发现另一件事:关掉浏览器,音乐播完这首歌就停了。这是结构性的问题——只靠客户端推进的播放列表,在没有任何客户端时就会卡住。
所以服务器自己推进房间,每秒检查一次,按文件时长来:
- 没有听众→在歌曲结束的那一刻准时推进。只有一个文件?那就永远循环。
- 有听众→等时长 + 2 秒。客户端的
ended事件更准确,而且来得更早;服务器只是用来救卡死或切到后台的标签页。
重点:绝对不要在时长之前推进。我有一次试过提前 250 毫秒,结果每首歌的结尾都被吃掉。听众听到的是一首被切断的歌,而不是一首播完的歌。
手机:浏览器处处跟你作对
手机浏览器里有两件事你绕不过去:
第一,没有用户触碰,音频不会开始播放。第二,后台标签页随时可能被杀掉。这两样都不是你用 JavaScript 能补的 bug。
所以我写了一个小小的 Android 外壳——WebView 加一个前台服务。干四件事:
mediaPlaybackRequiresUserGesture = false——每次打开 App,声音自己就开始。- 前台服务——切换 App 或锁屏,音乐继续播。
- 锁屏控制——通知上的播放/暂停按钮按的是房间的按钮,所以在锁屏按暂停,所有人都会暂停。
- 持久化的 session cookie——密码只需要输入一次。
没有用任何第三方库。只用平台本身的 API。
房间、音量和 DJ
每个房间都有自己的串流、播放位置和听众列表。链接很简单:/r/room-name。房间和正在播放的内容在服务器重启后都还在。
音量是房间的设置,不是每台设备各自的。调低一次,到处都变低——就像一个喇叭。这才让三台设备成为一个系统,而不是三个 App。
还有 DJ:按下麦克风按钮,你的声音会传到每一位听众,音乐降到 18%——不是静音。停止说话,音量就回上来。麦克风音频是原始 12 kHz PCM,所以任何浏览器都播得了;每位听众大约 32 KB/s,而且只在讲话时才传。
我学到了什么
有几件事我会记住,但全都跟音乐无关:
- 先测量,别靠感觉。“感觉很慢”和“真的很慢”是两回事。每次我一看到问题就急着修,结果都修错了地方。
- 就算优化失败,保底机制也必须存在。Snap 负责保住同步;nudge 负责把它调顺。
- 一个“看起来健康”的状态,可能藏着故障。浏览器拒绝执行的 snap 依然会回报成功。要验证效果,不是验证指令。
- 别把 session 标记放在
sessionStorage。它会随着标签页一起消失,而在 Android 上每次启动都是新的 session——所以你每次都得按“Join”。localStorage才是唯一正确的答案。 - 网络错误不等于页面跳转。
EventSource会自己重连。一个在连接中断时重新载入页面的 handler,会把“网络闪了一下”变成“重新加入”。
现在
我在同一个房间里,用三台设备听音乐。一台手机在桌上,一台在旁边,平板在架子上。三台都在同一秒,而且我可以从任何一台暂停。
DJ 还是没出现。等他来了,他只需要打开一个链接,输入名字,按下麦克风按钮。什么都不用安装。
代码以 MIT 授权开源在 github.com/wzul/sync-stream——一个 Node 文件,零 npm 依赖,没有 build 步骤。两台闲置的手机,加上你自己的播放清单?一个下午就能跑起来。

发表回复
要发表评论,您必须先登录。