案例研究 -《The Sounds of Racer》

简介

Racer 是一项多人游戏、多设备 Chrome 实验性功能。一款复古风格的老虎机游戏,支持各种设备。在手机或平板电脑、Android 或 iOS 设备上。任何人都可以加入。无应用。无下载内容。只有移动网络。

Plan814islands 的朋友们一起,根据 Giorgio Moroder 的原创乐曲打造了动态的音乐和声音体验。《Racer》的引擎声音和赛车音效;更重要的是,随着赛车手的加入,动态混音会投放到多种设备上。这种设备配有多个扬声器,由智能手机构成。

将多部设备连接在一起是我们有一段时间一直在玩的。我们曾进行过音乐实验,在该实验中,声音会分散到不同的设备上,或在设备之间跳转,因此我们迫不及待地想将这些想法应用到 Racer。

更具体地说,随着越来越多的用户加入游戏,我们想要测试是否可以跨设备构建音乐曲目 - 先是鼓和贝司,然后是吉他和合成器,等等。我们进行了一些音乐演示并深入研究了编码。多扬声器效果非常好用。我们此时还没有完成所有同步,但是当我们听到声音层在各台设备上扩散时,我们就知道我们已经进入了良好的状态。

创作声音

Google 创意实验室概述了声音和音乐的创意方向。我们希望使用模拟合成器来制作音效,而不是录制真实的声音或利用声音库。我们还知道,在大多数情况下,输出扬声器都是小型手机或平板电脑扬声器,因此必须限制声音的频谱,以避免扬声器失真。事实证明,这并非易事。当我们收到 Giorgio 的第一批音乐草稿时,我们都松了一口气,因为他的作品能完美地与我们创作的声音完美融合。

引擎提示音

对声音进行编程的最大挑战是找到最佳引擎声音并塑造其行为。赛道类似于 F1 或 Nascar 赛道,因此赛道必须快速且易爆。同时,这些汽车非常小,以致于巨大的引擎声音无法真正将声音与视觉元素联系起来。反正我们也不可能在手机扬声器里播放一声呼啸而过的强劲引擎,所以我们得想办法解决。

为了获得一些灵感,我们联系了我们的朋友 Jon Ekstrand 收集的一些模块化合成器,并开始乱成一团。我们很喜欢听到的内容。这就是使用两个振荡器、一些很棒的滤波器和 LFO 时的声音。

我们以前使用 Web Audio API 对模拟装备进行了改造,并取得了巨大的成功,因此我们抱有很大的期望,并开始在网络音频中制作简单的合成器。生成的声音会响应最灵敏,但会降低设备的处理能力。我们需要尽可能精简,以便节省所有资源,以便流畅地显示视觉效果。因此,我们改用了技术,改为播放音频样本。

模块化合成器,激发引擎音效灵感

有几种技术可以用样本制作引擎声音。主机游戏最常见的方法是在不同的 RPM(有负载)下在引擎中多一层(越多越好)声音,然后在这些声音之间交错淡出和交叉音阶。然后,添加一层引擎,在相同的 RPM 下在旋转(无负载)的同时,在两者之间添加淡入淡出和交叉音调。如果进行适当的切换,在这些层之间交叉淡入淡出听起来非常逼真,但前提是您有大量声音文件。交叉音调不能太宽,否则听起来会很合成。由于我们不得不避免长时间加载,因此这个选项不适合我们。我们尝试为每一层添加五到六个声音文件,但是声音令人失望。我们必须想方设法减少文件数量。

事实证明,最有效的解决方案是:

  • 一个声音文件,包含加速和档位更换,与汽车的视觉加速度同步,最后以最高音调 / 转速结束一个编程循环。Web Audio API 非常擅长精确循环,因此我们可以在不出现故障或爆音的情况下完成循环。
  • 1 个声音文件,其中减速 / 引擎正在减速。
  • 最后,一个声音文件循环播放静止 / 空闲声音。

看起来像这样

引擎声音图片

对于第一个触摸事件 / 加速,我们将从头开始播放第一个文件;如果玩家释放声音文件,我们会计算释放声音文件位置的时间,这样一来,当节流再次响起时,在第二个(减速)文件播放后,它会跳到加速文件中的正确位置。

function throttleOn(throttle) {
    //Calculate the start position depending 
    //on the current amount of throttle.
    //By multiplying throttle we get a start position 
    //between 0 and 3 seconds.
    var startPosition = throttle * 3;

    var audio = context.createBufferSource();
    audio.buffer = loadedBuffers["accelerate_and_loop"];

    //Sets the loop positions for the buffer source.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //Starts the buffer source at the current time
    //with the calculated offset.
    audio.start(context.currentTime, startPosition);
}

试试看

启动引擎,然后按下“油门”按钮。

<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>

因此,我们只有三个小声音文件和一个良好的声音引擎,我们决定继续下一个挑战。

正在获取同步

我们与来自 14islands 的 David Lindkvist 一起开始深入研究如何让设备完美同步。基本理论很简单。设备向服务器索要时间,将网络延迟考虑在内,然后计算本地时钟偏移量。

syncOffset = localTime - serverTime - networkLatency

使用此偏移时,每个连接的设备具有相同的时间概念。很简单,对吧?(同样,在理论上。)

计算网络延迟时间

我们可能假设延迟时间是请求和接收来自服务器的响应所用时间的一半:

networkLatency = (receivedTime - sentTime) × 0.5

这种假设存在的问题是,到服务器的往返并不总是对称的,即请求可能比响应需要更长的时间,反之亦然。网络延迟越长,这种不对称性的影响就越大,从而导致声音延迟播放,并与其他设备不同步。

幸运的是,我们的大脑不会注意到声音是否略有延迟。研究表明,我们的大脑需要延迟 20 到 30 毫秒 (ms) 才能将声音识别为独立声音。然而,在大约 12 至 15 毫秒时,即使您无法完全“感知”延迟信号,也会开始“感受”延迟信号的影响。我们研究了几种既定的时间同步协议和更简单的替代方案,并尝试实际实现其中一些。最后,得益于 Google 的低延迟基础架构,我们能够简单地对大量请求进行采样,并使用延迟最低的样本作为参考。

对抗时钟偏移

成功了!我们有超过 5 台设备播放了完美同步的脉冲信号,但这只有一段时间了。播放几分钟之后,即使我们使用高度精确的 Web Audio API 上下文时间安排了声音,设备仍会偏离。延迟时间缓慢累积,每次仅几毫秒,且一开始无法检测到,但导致音乐层在播放更长时间后完全不同步。您好,时钟偏移。

解决方案是每隔几秒重新同步一次,计算新的时钟偏差,并将其无缝馈送给音频调度器。为了降低因网络延迟而导致音乐出现显著变化的风险,我们决定通过保留最新的同步偏移历史记录并计算平均值来消除这种变化。

调度歌曲和切换编曲

提供互动式声音体验意味着您不再能够控制歌曲的某些部分何时播放,因为您需要依靠用户操作来更改当前状态。我们必须确保能够及时在歌曲的各种排列方式之间切换,这意味着我们的调度器必须能够计算当前播放条形的剩余数量,然后再切换到下一个排列方式。 我们的算法最终结果如下所示:

  • Client(1)开始播放歌曲。
  • Client(n)向第一个客户询问歌曲开始播放的时间。
  • Client(n) 会使用其网络音频上下文计算歌曲开始播放时的参考点,考虑的是 syncOffset 以及自创建音频上下文以来经过的时间。
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) 使用 playDelta 计算歌曲已经播放了多长时间。歌曲编排程序使用此数据来确定接下来要播放当前编曲中的哪个小节。
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

为了保持正常,我们将布局限制为始终为 8 个节拍,并采用相同的拍子(每分钟节拍数)。

往前看

在 JavaScript 中使用 setTimeoutsetInterval 时,请务必提前安排。这是因为 JavaScript 的时钟不太精确,并且预定回调很容易因布局、渲染、垃圾回收和 XMLHTTPRequests 而出现数十毫秒或更长的偏差。在我们的例子中,我们还必须考虑所有客户端通过网络收到同一事件所需的时间。

音频精灵

无论是 HTML 音频还是 Web Audio API,将声音合并到一个文件中是减少 HTTP 请求的好方法。这也恰好是利用 Audio 对象响应声音的最佳方式,因为它不需要在播放前加载新的音频对象。市面上已经有一些良好的实现方式,我们曾以此为起点。我们扩展了精灵模型,使其在 iOS 和 Android 上都能可靠地运行,并能够处理一些导致设备进入睡眠状态的异常情况。

在 Android 设备上,即使设备处于睡眠模式,音频元素也会继续播放。在睡眠模式下,系统会限制 JavaScript 执行以节省电量,并且您不能依赖 requestAnimationFramesetIntervalsetTimeout 来触发回调。这是一个问题,因为音频精灵依赖于 JavaScript 来不断检查是否应停止播放。更糟糕的是,在某些情况下,虽然音频仍在播放,但音频元素的 currentTime 不会更新。

查看我们在 Chrome Racer 中用作非网络音频回退的 AudioSprite 实现

音频元素

在我们开始开发 Racer 时,Android 版 Chrome 还不支持 Web Audio API。对一些设备使用 HTML 音频,对其他设备使用 Web Audio API,并结合我们想要实现的高级音频输出的逻辑,以应对一些有趣的挑战。庆幸的是,这已经成为了所有历史。Web Audio API 是在 Android M28 Beta 版中实现的。

  • 延迟/时间问题。音频元素不一定会在你指示播放时准确地播放。由于 JavaScript 是单线程的,因此浏览器可能处于忙碌状态,导致播放延迟长达两秒。
  • 播放延迟意味着有时无法流畅地循环播放。在桌面设备上,您可以使用双缓冲来实现一定程度的无间断循环,但在移动设备上,您无法选择这样做,因为:
    • 大多数移动设备一次不会播放多个音频元素。
    • 固定音量。Android 和 iOS 都不允许您更改 Audio 对象的音量。
  • 无需预加载。在移动设备上,除非通过 touchStart 处理程序发起播放,否则音频元素不会开始加载其来源。
  • 查找问题。除非您的服务器支持 HTTP 字节范围,否则获取 duration 或设置 currentTime 会失败。如果您像我们一样构建了音频精灵,请注意这个错误。
  • 对 MP3 文件的基本身份验证失败。无论您使用哪个浏览器,部分设备都无法加载受基本身份验证保护的 MP3 文件

总结

自从将静音按钮作为处理网页声音的最佳选项以来,我们已经取得了长足的进步,但这只是一个开始,网络音频会让网络音频变得非常震撼。当涉及到同步多台设备时,我们这里仅介绍了一些浅层问题。我们没有手机和平板电脑的处理能力来深入了解信号处理和效果(例如混响),但随着设备性能的提高,网络游戏也会利用这些功能。这是一个激动人心的时刻,它将继续推动声音的无限可能。