假设我们有几个监视器。 我们想将这些显示器用作花环。 例如,使它们同时闪烁。 或者也许根据某种智能算法来同步更改颜色。 而且,如果您在浏览器中进行操作,那么可以将智能手机和平板电脑与此连接。 一切就在眼前。

而且,由于我们使用的是浏览器,因此您还可以添加声音设计。 毕竟,如果它足够准确,可以及时同步设备,那么您可以像在一个多声道系统上一样在每个设备上播放声音。
在javascript应用程序中同步Web音频和游戏时钟时会遇到什么情况; javasctipt中有多少个不同的“小时”(三个!),为什么需要所有这些“小时”,以及猫下的node.js的现成应用程序 。
检查时钟
对于任何有条件的在线花环,都需要精确的时钟同步。 毕竟,您可以忽略任何(甚至是间歇性的)网络延迟。 为控制命令提供时间戳并在“将来”生成这些命令就足够了。 在客户端上,它们将被缓冲,然后按时同步执行。
或者甚至可以走得更远-采用良好的老式确定性随机算法,并在所有设备上使用一个公共种子(由服务器在连接时发出一次)。 如果将此类种子与准确时间
一起使用,则可以完全确定算法在所有设备上的行为。 试想一下:实际上,您不需要网络或服务器来唯一且同步地更改状态。 种子已经预先包含动作的全部(有条件的无限)“视频录制”。 最主要的是确切的时间。

每种方法都有其适用范围。 当然,有了即时用户输入,就什么都不做,剩下的就是“按原样”传输。 但是所有可以计算的都是要计算的。 在实现过程中,我会根据情况使用所有三种方法。
主观“同时”
理想情况下,所有声音都应“同时”听起来-组合设备中最差的一对的相差不超过±10 ms。 您无法从系统时间上获得这种准确性,并且浏览器中不提供使用NTP协议同步时间的标准方法。 因此,我们将驱动我们的同步服务器。 原理很简单:头盔“ pings”并接受带有服务器时间戳的“ pongs”。 如果您连续执行多次,则可以从统计角度上对错误进行平均,并获得平均延迟时间。
Websocket和基于它的解决方案最适合,因为它们不需要时间来创建TCP连接,并且您可以在两个方向上与其进行“通信”。 当然不是UDP或ICMP,但是比使用HTTP API的常规冷连接要快得多。 因此,请使用socket.io。 那里的一切都很容易:
代码:socket.io实现
与其计算平均值,不如计算平均值,这将是不错的选择-如果连接不稳定,这将提高准确性。 过滤方法的选择取决于读者。 我故意在这里简化代码,以使用示意图。 我的完整解决方案可以在存储库中找到。 performance.now()
让我提醒您,
performance
对象是一个提供对高分辨率计时器的访问的API。 比较:
Date.now()
返回自1970年1月1日以来的毫秒数,并且以整数形式进行计算。 即,仅来自舍入的误差平均为0.5ms。 例如,在一个减法运算ab
您最多可能失败“丢失” 2 ms。 另外,从历史上和概念上讲,计时器本身不能保证高精度,并且对于较大的时标工作会变得更加尖锐。performance.now()
返回自打开网页以来的毫秒数。
这是一个相对较新的API,专门针对时间间隔的精确测量而“锐化”。 返回浮点值 ,理论上讲其精度水平接近于OS本身的功能。
我认为此信息几乎为所有javascript开发人员所了解。 但并非所有人都知道...
幽灵
由于Spectre在2018年引起了轰动的计时攻击,如果没有其他解决漏洞问题的方法,一切都将变成高分辨率计时器被人为地粗糙化的地步。 从版本60开始的Firefox将此计时器的值四舍五入到毫秒,而Edge甚至更糟。
这是
MDN所说的:
时间戳实际上不是高分辨率的。 为了缓解诸如Spectre之类的安全威胁,浏览器当前将结果不同程度地取整。 (Firefox在Firefox 60中开始四舍五入到1毫秒。)某些浏览器可能还会稍微随机化时间戳。 在将来的版本中,精度可能会再次提高; 浏览器开发人员仍在调查这些计时攻击以及如何最好地缓解它们。
让我们运行测试并查看图表。 这是间隔10毫秒的测试结果:
测试代码:一个周期中的时间测量 function measureTimesLoop(length) { const d = new Array(length); const p = new Array(length); for (let i = 0; i < length; i++) { d[i] = Date.now(); p[i] = performance.now(); } return { d, p } }
Date.now()
performance.now()
边缘

统计资料浏览器版本:44.17763.771.0
Date.now()
平均间隔:1.0538336052202284 ms
与平均间隔的偏差RMS:0.7547819181245603 ms
间隔中位数:1毫秒
performance.now()
平均间隔:1.567100970873786 ms
与平均间隔的偏差,RMS:0.6748006785171455 ms
间隔中位数:1.5015000000003056 ms
火狐浏览器

统计资料浏览器版本:71.0
Date.now()
平均间隔:1.0168350168350169 ms
与平均间隔的偏差,RMS:0.21645930182417966 ms
间隔中位数:1毫秒
performance.now()
平均间隔:1.0134453781512605 ms
与平均间隔的偏差,RMS:0.1734108492762375 ms
间隔中位数:1毫秒
镀铬

统计资料浏览器版本:79.0.3945.88
Date.now()
平均间隔:1.02442996742671 ms
与平均间隔的偏差,RMS:0.49858684744444 ms
间隔中位数:1毫秒
performance.now()
平均间隔:0.005555847229948915 ms
与平均间隔的偏差,RMS:0.027497846727194235 ms
间隔中位数:0.0050000089686363935 ms
好的,Chrome,放大到1毫秒。

因此,Chrome仍在坚持,其
performance.now()
实现尚未被勒死,步长为0.005毫秒。 在Edge下,
performance.now()
计时器比
Date.now()
还要粗糙! 在Firefox中,两个计时器的毫秒精度相同。
在这一阶段,已经可以得出一些结论。 但是javascript中还有另一个计时器(没有它,我们不能没有)。
WebAudio API计时器
这是一个略有不同的野兽。 它用于延迟的音频队列。 事实是,音频事件(弹奏音符,管理效果)不能依赖于标准的异步javascript工具:
setInterval
和
setTimeout
因为它们的误差太大。 这不仅是计时器
值的错误(我们之前已经处理过),而且是事件机执行事件的错误。 即使在温室条件下,也已经是5-25毫秒左右。
扰流器下异步情况的图形间隔100毫秒的测试结果:
测试代码:异步周期中的时间测量 function pause(duration) { return new Promise((resolve) => { setInterval(() => { resolve(); }, duration); }); } async function measureTimesInAsyncLoop(length) { const d = new Array(length); const p = new Array(length); for (let i = 0; i < length; i++) { d[i] = Date.now(); p[i] = performance.now(); await pause(1); } return { d, p } }
Date.now()
performance.now()
边缘

统计资料浏览器版本:44.17763.771.0
Date.now()
平均间隔:25.595959595959595 ms
与平均间隔的偏差,RMS:10.12639235162126 ms
间隔中值:28毫秒
performance.now()
平均间隔:25.862596938775525 ms
与平均间隔的偏差,RMS:10.123711255512573 ms
间隔中位数:27.027099999999336 ms
火狐浏览器

统计资料浏览器版本:71.0
Date.now()
平均间隔:1.6914893617021276 ms
与平均间隔的偏差,RMS:0.6018870280772611 ms
间隔中位数:2毫秒
performance.now()
平均间隔:1.7865168539325842 ms
与平均间隔的偏差,RMS:0.6442818510935484 ms
间隔中位数:2毫秒
镀铬

统计资料浏览器版本:79.0.3945.88
Date.now()
平均间隔:4.7878787878787888,ms
平均间隔偏差,RMS:0.7557553886872682 ms
间隔中位数:5毫秒
performance.now()
平均间隔:4.783989898979516 ms
与平均间隔的偏差,RMS:0.6483716900974945 ms
中位间隔:4.750000000058208 ms
也许有人会记得最早的实验性HTML音频应用程序。 在成熟的WebAudio进入浏览器之前,它们听起来都像是有点醉,马虎。 只是因为他们使用
setTimeout
作为定序器。
相比之下,现代的WebAudio API可以保证高达0.02 ms的分辨率(基于44100Hz的采样频率进行推测)。 这是由于以下事实:与
setTimeout
相比,延迟的声音回放使用了不同的机制:
source.start(when);
实际上,音频样本的任何复制都是“延迟的”。 只是要丢失它“不推迟”,您需要将其推迟到“直到现在”。
source.start(audioCtx.currentTime);
关于实时软件生成的音乐如果您从音符中播放程序合成的旋律,则需要提前将这些音符添加到播放队列中。 这样,尽管计时器受到了所有非基本的限制和不规则,旋律仍将完美流畅地播放。
换句话说,实时合成的旋律不应实时“发明”,而应提前一点。
一个计时器来统治所有人
由于
audioCtx.currentTime
非常稳定且准确,因此也许我们应该将它用作相对时间的主要来源? 让我们再次运行测试。
测试代码:在一个周期中测量同步时间测量 function measureTimesInLoop(length) { const d = new Array(length); const p = new Array(length); const a = new Array(length); for (let i = 0; i < length; i++) { d[i] = Date.now(); p[i] = performance.now(); a[i] = audioCtx.currentTime * 1000; } return { d, p, a } }
Date.now()
performance.now()
audioCtx.currentTime
边缘

统计资料浏览器版本:44.17763.771.0
Date.now()
平均间隔:1.037037037037037 ms
与平均间隔的偏差,RMS:0.6166609846299806 ms
间隔中位数:1毫秒
performance.now()
平均间隔:1.5447103117505993 ms
与平均间隔的偏差,RMS:0.4390514285320851 ms
间隔中位数:1.5015000000000782 ms
audioCtx.currentTime
平均间隔:2.955751134714949 ms
与平均间隔的偏差,RMS:0.6193645611529503 ms
间隔中位数:2.902507781982422 ms
火狐浏览器

统计资料浏览器版本:71.0
Date.now()
平均间隔:1.005128205128205205 ms
与平均间隔的偏差,RMS:0.12392867665225249 ms
间隔中位数:1毫秒
performance.now()
平均间隔:1.00513698630137 ms
与平均间隔的偏差,RMS:0.07148844433269844 ms
间隔中位数:1毫秒
audioCtx.currentTime
Firefox不会在同步循环中更新音频计时器值
镀铬

统计资料浏览器版本:79.0.3945.88
Date.now()
平均间隔:1.0207612456747406 ms
与平均间隔的偏差,RMS:0.49870223457982504 ms
间隔中位数:1毫秒
performance.now()
平均间隔:0.005414502034674972 ms
与平均间隔的偏差,RMS:0.027441293974958335 ms
中位间隔:0.004999999873689376 ms
audioCtx.currentTime
平均间隔:3.0877599266656963 ms
与平均间隔的偏差,RMS:1.1445555956407658 ms
间隔中位数:2.9024943310650997 ms
扰流器下异步情况的图形测试代码:异步周期中的时间测量间隔100毫秒的测试结果:
function pause(duration) { return new Promise((resolve) => { setInterval(() => { resolve(); }, duration); }); } async function measureTimesInAsyncLoop(length) { const d = new Array(length); const p = new Array(length); const a = new Array(length); for (let i = 0; i < length; i++) { d[i] = Date.now(); p[i] = performance.now(); await pause(1); } return { d, p } }
Date.now()
performance.now()
audioCtx.currentTime
边缘

统计资料浏览器版本:44.17763.771.0
Date.now():
平均间隔:24.505050505050505 ms
与平均间隔的偏差:11.513166584195204 ms
间隔中值:26毫秒
performance.now():
平均间隔:24.50935757575754 ms
与平均间隔的偏差:11.679091435527388 ms
间隔中位数:25.525499999999738 ms
audioCtx.currentTime:
平均间隔:24.76005164944396 ms
与平均间隔的偏差:11.311571546205316 ms
间隔中位数:26.121139526367187 ms
火狐浏览器

统计资料浏览器版本:71.0
Date.now():
平均间隔:1.6875毫秒
平均间隔偏差:0.6663410663216448 ms
间隔中位数:2毫秒
performance.now():
平均间隔:1.7234042553191489 ms
与平均间隔的偏差:0.6588877688171075 ms
间隔中位数:2毫秒
audioCtx.currentTime:
平均间隔:10.158730158730123 ms
与平均间隔的偏差:1.4512471655330046 ms
中位间隔:8.707482993195299 ms
镀铬

统计资料浏览器版本:79.0.3945.88
Date.now():
平均间隔:4.585858585858586 ms
与平均间隔的偏差:0.9102125516015199 ms
间隔中位数:5毫秒
performance.now():
平均间隔:4.592424242424955 ms
与平均间隔的偏差:0.719936993603155 ms
间隔中位数:4.605000001902226 ms
audioCtx.currentTime:
平均间隔:10.12648022171832 ms
与平均间隔的偏差:1.4508887886499262 ms
间隔中位数:8.707482993197118 ms
好吧,那行不通。 在“外部”,此计时器最不准确。 Firefox不会在循环内更新计时器值。 但总的来说:分辨率为3毫秒,并且抖动更明显。 也许
audioCtx.currentTime
的值反映了声卡驱动程序在环形缓冲区中的位置。 换句话说,它显示了可以安全延迟播放的最短时间。
怎么办? 毕竟,我们既需要用于与服务器同步并在屏幕上启动javascript事件的准确计时器,又需要用于声音事件的音频计时器!
事实证明,您需要将所有计时器彼此同步:
- 客户端上的客户端
audioCtx.currentTime
与客户端performance.now()
。 - 而客户端的
performance.now()
与performance.now()
服务器端。
同步,同步

通常,如果考虑一下,这是很有趣的:您可以有两个良好的时间源A和B,每个时间源在输出端都非常粗糙且嘈杂(A'= A + err
A ; B'= B + err
B ),以便可以甚至无法单独使用。 但是原始非噪声源之间的差异d可以非常准确地恢复。
由于理想时钟之间的真实时间距离是一个常数,因此要进行n次测量,因此我们将分别减少err次测量误差。 当然,除非时钟以相同的速度运行。
是,未同步
坏消息是,不,它们的运行速度不同。 我并不是在谈论服务器和客户端上的时间差异,这是可以理解和期望的。 更出乎意料的是:
audioCtx.currentTime
逐渐与
performance.now()
audioCtx.currentTime
。 它在客户端内部。 我们可能没有注意到,但是有时在负载下,音频系统可能不会吞噬一小段数据,并且(与环形缓冲区的性质相反),音频时间将相对于系统时间偏移。 发生这种情况的情况并不罕见,它并没有引起很多人的关注:但是,例如,如果您同时在不同的计算机上同时启动两个YouTube视频,那么它们就不会同时停止播放了。 当然,重点不在于广告。
因此,用于稳定和同步的操作。 我们需要
定期以服务器时间作为参考重新检查所有时钟。 然后要权衡的是要使用多少个测量值进行平均:越多-越准确,但是
audioCtx.currentTime
的急剧跳
audioCtx.currentTime
落入我们过滤值的时间窗口的机会就越大。 然后,例如,如果我们使用分钟窗口,则所有分钟都将经过时间。 过滤器的选择范围很广:
指数过滤器,
中值 过滤器 ,
卡尔曼过滤器等。 但是无论如何,这种权衡都是有代价的。
时间窗
在异步循环
audioCtx.currentTime
与
performance.now()
同步的情况下,为了不干扰UI,我们可以进行一次测量,例如100 ms。
假设测量误差err = errA + errB = 1 + 3 = 4 ms
因此,我们可以在1秒内将其减少到0.4毫秒,并在10秒内减少到0.04毫秒。 结果的进一步改善是没有意义的,一个好的过滤窗口将是:1-10秒。
在网络同步的情况下,延迟和错误已经非常重要,但不会出现时间的突然跳动,就像
audioCtx.currentTime
的情况
audioCtx.currentTime
。 而且,您可以让自己积累非常出色的统计数据。 毕竟,ping的err可能长达500毫秒。 而测量本身我们就不会那么频繁了。
在这一点上,我建议停止。 如果有人感兴趣,我很乐意告诉您如何“绘制其余的猫头鹰”。 但是,作为有关计时器的故事的一部分,我认为我的故事结束了。
我想分享我得到的。 都一样,新的一年。
发生什么事了
免责声明:从技术上讲,这是Habré上的PR网站,但这是一个完全非营利的开源宠物项目,我保证永远不会在该项目上投放广告或以任何其他方式赚钱。 相反,我现在从我的资金中筹集了更多的实例,以求生存。 因此,请好人,不要打扰我,也不要接近我。 这纯粹是很有趣。
新年快乐,哈伯!
您可以旋转旋钮并控制可视化,音乐和音频效果。 如果您有普通的视频卡,请转到设置并将粒子数设置为100%。
需要WebAudio和WebGL。
UPD:在macOS Mojave下的Safari中不起作用。 不幸的是,由于缺少Safari本身,因此无法快速确定发生了什么。 iOS似乎正在运行。
UPD2:如果
snowtime.fun和
web.snowtime.fun没有响应,请尝试新的
habr .snowtime.fun子域。 他将服务器移至另一个数据中心,并将旧IP缓存在DNS中,
expire=1w
。 :(
仓库:
bitbucket在撰写本文时,使用了
macrovector / Freepik插图。