案例研究 -《The Sounds of Racer》

简介

Racer 是一项多人、多设备 Chrome 实验。一款跨屏幕的复古风格赛车游戏。在 Android 或 iOS 手机或平板电脑上。任何人都可以加入。没有应用。无需下载。仅限移动网站。

Plan814islands 的团队一起,根据 Giorgio Moroder 的原创曲目打造了动态音乐和音效体验。Racer 具有响应迅速的发动机声音、赛车音效,但更重要的是,它还具有动态音乐混音功能,可在赛车手加入时将自己分发到多部设备上。它由智能手机组成的多扬声器装置。

我们已经尝试过将多部设备连接在一起。我们曾进行过音乐实验,让声音在不同设备上分屏或在设备之间跳转,因此非常希望将这些想法应用于 Racer。

更具体地说,我们希望测试能否随着越来越多的人加入游戏,在各个设备上逐渐构建音乐曲目,从鼓和贝斯开始,然后添加吉他和合成器等乐器。我们演示了一些音乐,并深入学习了编码。多音箱效果非常出色。当时,我们还没有完成所有同步,但当我们听到声音层层叠加在设备上时,就知道我们取得了重大突破。

创建音效

Google Creative Lab 为音效和音乐确定了创意方向。我们希望使用模拟合成器来制作音效,而不是录制真实的声音或使用音效库。我们还知道,在大多数情况下,输出扬声器都是小小的手机或平板电脑扬声器,因此必须限制声音的频谱,以免扬声器失真。这项工作后来证明非常具有挑战性。当我们收到 Giorgio 创作的第一批音乐初稿时,我们感到非常欣慰,因为他的曲目与我们创作的音效完美契合。

引擎声

编写音效的最大挑战是找到最佳的引擎声音并塑造其行为。赛道类似于 F1 或 Nascar 赛道,因此赛车必须给人以速度感和爆发力。同时,这些车辆非常小,因此大引擎声音无法真正将声音与画面联系起来。我们无法在移动音箱中播放轰鸣的强劲引擎声,因此不得不想出其他办法。

为了寻找灵感,我们连接了好友 Jon Ekstrand 的模块化合成器,并开始尝试各种组合。我们很喜欢您提供的信息。下面是使用两个振荡器、一些不错的滤波器和 LFO 的声音。

之前,我们曾使用 Web Audio API 成功改造过模拟设备,因此我们对 Web Audio 寄予厚望,并开始在 Web Audio 中创建简单的合成器。生成的声音响应速度最快,但会占用设备的处理能力。我们需要极其精简,尽可能节省所有资源,以便视觉效果顺畅运行。因此,我们改用播放音频选段的方式。

模块化合成器,可为引擎声音提供灵感

您可以使用多种方法来让引擎发出声音。对于主机游戏,最常见的方法是在不同转速(带负载)下提供一层包含多个引擎声音(越多越好),然后在这些声音之间进行交叉淡化和交叉调音。然后,添加一层在相同转速下仅转动(无负载)的引擎的多种声音,并在两者之间进行交叉淡化和交叉音调。如果您有大量的音频文件,那么在换挡时在这些层之间进行交叉淡化处理(如果处理得当),听起来会非常逼真。跨音调不能过大,否则会听起来很合成。由于我们必须避免加载时间过长,因此此选项不适合我们。我们尝试为每个图层使用五六个音频文件,但效果不尽如人意。我们必须想办法减少文件数量。

最有效的解决方案是:

  • 一个音频文件,其中加速和换挡与汽车的视觉加速同步,以最高音调 / 转速的预编程循环结束。Web Audio API 非常擅长精确循环,因此我们可以这样做,而不会出现故障或爆音。
  • 一个包含减速 / 发动机怠速的音频文件。
  • 最后,一个循环播放静态 / 空闲提示音的音频文件。

如下所示

引擎声音图形

对于第一次触摸事件 / 加速,我们会从头播放第一个文件;如果玩家松开油门,我们会计算从松开油门时音频文件中的位置开始算起的时间,以便当油门再次开启时,在播放第二个(降档)文件后跳转到加速文件中的正确位置。

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) 会使用其 Web Audio 上下文计算歌曲开始播放时的参考点,并将 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 时钟不太精确,并且由于布局、渲染、垃圾回收和 XMLHTTPRequest,安排的回调很容易偏差几十毫秒或更多。在我们的示例中,我们还必须考虑所有客户端通过网络接收同一事件所需的时间。

音频精灵

将音效合并到一个文件中是减少 HTTP 请求的绝佳方式,对于 HTML Audio 和 Web Audio API 而言,也是如此。这也是使用 Audio 对象响应式播放音频的最佳方式,因为它无需在播放前加载新的音频对象。我们已经找到了一些优秀的实现,并将其用作起点。我们扩展了精灵,使其能够在 iOS 和 Android 上可靠运行,并处理设备进入休眠状态的一些异常情况。

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

查看我们在 Chrome Racer 中用作非 Web Audio 后备的 AudioSprite 实现

音频元素

当我们开始开发 Racer 时,Android 版 Chrome 尚不支持 Web Audio API。我们需要针对某些设备使用 HTML Audio,针对其他设备使用 Web Audio API,再加上我们想要实现的高级音频输出,这给我们带来了一些有趣的挑战。幸运的是,这一切都已成为历史。Web Audio API 在 Android M28 Beta 版中实现。

  • 延迟/时间问题。音频元素并不总是在您指示播放时精确播放。由于 JavaScript 是单线程的,因此浏览器可能会处于忙碌状态,导致播放延迟最多两秒。
  • 播放延迟意味着并非总能顺畅循环。在桌面设备上,您可以使用双缓冲来实现无缝循环,但在移动设备上无法采用此方法,因为:
    • 大多数移动设备一次只能播放一个音频元素。
    • 固定音量。在 Android 和 iOS 中,您都无法更改 Audio 对象的音量。
  • 无预加载。在移动设备上,除非在 touchStart 处理程序中启动播放,否则 Audio 元素不会开始加载其来源。
  • 寻找问题。除非您的服务器支持 HTTP 字节范围,否则获取 duration 或设置 currentTime 将会失败。如果您像我们一样构建音频精灵,请注意这一点。
  • MP3 上的基本身份验证失败。无论您使用哪款浏览器,某些设备都无法加载受基本身份验证保护的 MP3 文件

总结

从按下静音按钮作为处理 Web 音频的最佳选项开始,我们已经走过了漫长的道路,但这只是一个开始,Web 音频即将大放异彩。关于多设备同步,我们仅介绍了一些基本功能。手机和平板电脑的处理能力不足以深入研究信号处理和效果(例如混响),但随着设备性能的提升,基于网页的游戏也将能够利用这些功能。这是一个激动人心的时代,我们将继续探索声音的无限可能。