两个时钟的故事

精确安排 Web 音频

克里斯·威尔逊 (Chris Wilson)
Chris Wilson

简介

使用 Web 平台构建出色的音频和音乐软件时,最大的挑战之一是管理时间。与“编写代码的时间”不一样,而是像时钟时间一样。关于网络音频,人们最难理解的一个话题就是如何正确地使用音频时钟。Web Audio AudioContext 对象具有一个公开此音频时钟的 currentTime 属性。

特别是对于网络音频的音乐应用 - 不仅是编写音序器和合成器,而是任何有节奏地使用音频事件(例如鼓机游戏其他 应用),必须确保音频事件的一致且精确的时间;不仅仅是开始和停止声音,还需要安排声音的变化(例如频率或音量的变化)。有时,我们希望有略微随机的事件(例如,使用 Web Audio API 开发游戏音频中的机器枪演示),但通常情况下,我们希望拥有一致且准确的音符时间。

网络音频使用入门以及使用 Web Audio API 开发游戏音频部分中,我们已经介绍了如何使用网络音频 noteOn 和 noteOff(现已更名为 start 和 stop)方法的时间参数安排音符,但是,我们没有深入探讨过更复杂的场景,例如播放较长的音乐序列或节奏。为深入探索这些内容,首先我们需要了解有关时钟的一些背景。

The Best of Times - 网络音频时钟

Web Audio API 提供对音频子系统的硬件时钟的访问权限。此时钟通过其 .currentTime 属性在 AudioContext 对象上公开,表示为自 AudioContext 创建以来的浮点秒数。这样一来,此时钟(下文中称为“音频时钟”)可以实现非常高的精确度;即使采样率较高,也能在单个声音样本级别指定校准。由于“双精度”值大约有 15 位小数精度,因此即使音频时钟已运行数天,即使在高采样率下,该时钟仍然应该还有大量可用于指向特定样本的位。

音频时钟用于整个 Web Audio API 中的参数和音频事件调度,当然,用于 start()stop(),还用于 AudioParams 上的 set*ValueAtTime() 方法。这让我们可以提前设置时间非常精确的音频事件。事实上,很容易将网络音频中的所有内容都设置为开始/停止时间,但是在实践中却存在一个问题。

我们以我们的《网络音频简介》中的这段简化代码段为例,该代码段设置了两个八分音高帽子模式的两个小节:

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime;

  // Play the hi-hat every eighth note.
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime);
  }

这个代码非常实用。但是,如果你想改变这两个小节中间的拍子,或者想在两个小节还音之前停止演奏,那就没办法了。(我见过开发者会执行一些操作,例如在预先安排的 AudioBufferSourceNodes 和输出之间插入增益节点,这样他们就可以将自己的声音静音!)

简而言之,由于您需要灵活地更改节奏或频率或增益等参数(或完全停止调度),因此不要将过多音频事件推入队列;或者更确切地说,不要将时间向前看得太远,因为您可能需要彻底改变时间安排。

最糟糕的时代 - JavaScript 时钟

我们还拥有备受用户喜爱且高度一致的 JavaScript 时钟,由 Date.now() 和 setTimeout() 表示。JavaScript 时钟的优点在于,它具有几个非常有用的 call-me-back-later window.setTimeout() 和 window.setInterval() 方法,可让系统在特定时间回调代码。

JavaScript 时钟的缺点在于它不够精确。对于初学者,Date.now() 会返回一个以毫秒为单位的值(以毫秒为单位的整数),因此您希望达到的最佳精度是 1 毫秒。在某些音乐情境下,这种变化并没那么糟糕 - 如果您的音符提前或延迟一毫秒,您甚至可能注意到,但即使在相对较低的音频硬件速率 (44.1kHz) 下,其速度也差不多是音频调度时钟的 44.1 倍。请记住,丢弃任何样本都可能会导致音频干扰,因此,如果我们要将样本链接在一起,则需要这些样本是精确依序的。

事实上,最新的高分辨率时间规范确实会通过 window.performance.now); 能够为我们提供更精确的当前时间精度;此类规范甚至已应用于当前许多浏览器(尽管添加了前缀)。在某些情况下,这样做会有帮助,但与 JavaScript 计时 API 最糟糕的部分并不相关。

JavaScript 计时 API 最糟糕的一点是,虽然 Date.now() 的毫秒级精度听起来不太现实,但 JavaScript 中计时器事件的实际回调(通过 window.setTimeout() 或 window.setInterval)很容易因布局、渲染、垃圾回收、XMLHTTPRequest 和其他回调执行(简而言之,主线程执行任务)出现几十毫秒或更长的偏差。还记得我提到过我们可以使用 Web Audio API 安排的“音频事件”吗?所有这些都是在单独的线程上进行处理的。因此,即使主线程在执行复杂的布局或其他耗时较长的任务时暂时停滞,音频仍会恰好在系统告知发生的时间播放。实际上,即使您在调试程序中的断点处停止,音频线程也会继续播放预定事件!

在音频应用中使用 JavaScript setTimeout()

由于主线程每次很容易停止运行数毫秒,因此最好使用 JavaScript 的 setTimeout 直接开始播放音频事件,因为您的音符最好在实际应该触发的时间大约 1 毫秒内触发,最糟糕的情况是会延迟更长时间。最糟糕的是,对于有节奏的序列,它们不会按精确的间隔触发,因为时间对 JavaScript 主线程上发生的其他事件比较敏感。

为说明这一点,我编写了一个“糟糕”节拍器应用示例,该应用直接使用 setTimeout 安排音符,而且也进行大量布局。打开此应用,点击“播放”,然后在播放时快速调整窗口大小;您会注意到时间明显不稳定(您可以听到节奏不一致)。“但这是人造的!”你说?当然,但这并不意味着现实世界中就不会发生这种事情。即使是相对静态的界面,也会因重新布局而出现 setTimeout 中的计时问题。例如,我注意到快速调整窗口大小会导致原本非常出色的 WebkitSynth 中的时间明显卡顿。现在,想象一下,当你尝试平滑地滚动完整乐谱和音频时会发生什么,你可以轻松想象这会对现实世界中复杂的音乐应用产生怎样的影响。

我听到的最常见问题之一是:“为什么我不能从音频事件中获取回调?”虽然可能有一些此类回调的用途,但它们并不能解决眼前的特定问题

那我们能做些什么呢?处理时间的最佳方法是在 JavaScript 计时器(setTimeout()、setInterval() 或 requestAnimationFrame(),稍后会详细介绍)和音频硬件调度之间建立协作。

提前做好充分准备,确保时间万无一失

我们再来看一下这个节拍器演示 - 事实上,我正确编写了这个简单节拍器演示的第一个版本,用来演示这种协作式日程安排技术。(该代码也可在 GitHub 上找到此演示版会在每十六分、八分或四分之一音上以高精确度播放(由振荡器生成)的哔哔声,根据节拍改变音高。这款应用还支持在播放过程中更改节奏和音符间隔,或者随时停止播放 - 这是所有实际节奏音序器的关键功能。添加代码来更改这个节拍器在运行时使用的声音,非常简单。

它能在保持温度稳健的同时,实现对温度的控制,同时又能保持稳定的时间,这是一种多方协作的方式:一个会频繁触发一次的 setTimeout 计时器,并在以后为各个笔记设置 Web Audio 的时间安排。setTimeout 计时器基本上只是检查是否需要根据当前节拍“快”安排任何音符,然后对这些音符进行安排,具体如下所示:

setTimeout() 和音频事件交互。
setTimeout() 和音频事件互动。

在实践中,setTimeout() 调用可能会延迟,因此调度调用的时间可能会随时间而抖动(和偏差,具体取决于您使用 setTimeout 的方式)。虽然此示例中的事件相隔约 50 毫秒,但其间隔时间通常略高于这个值(有时远远超过该值)。不过,在每次通话过程中,我们不仅要针对现在需要播放的任何音符(例如第一个音符)安排网络音频事件,还会针对从现在起到下一时段需要播放的所有音符安排事件。

事实上,我们并不希望只精确地通过 setTimeout() 调用之间的时间间隔向前看,我们还需要此计时器调用与下一次调用之间存在一定程度的调度重叠,以适应最糟糕的主线程行为,即主线程上发生的垃圾回收、布局、渲染或其他代码会延迟下一次计时器调用。我们还需要考虑音频块调度时间,即操作系统在其处理缓冲区中保留的音频量,该时间因操作系统和硬件而异,从低个位数毫秒到大约 50 毫秒。上面所示的每个 setTimeout() 调用都有一个蓝色间隔,表示它会尝试安排事件的整个时间范围。例如,上图中安排的第四个网络音频事件可能是“延迟”播放的,如果我们等到下一次 setTimeout 调用才播放,而该 setTimeout 调用延迟了几毫秒。在现实生活中,这些时间点的抖动可能比这更严重,而随着应用变得越来越复杂,这种重叠情况会变得更加重要。

整体先行延迟时间会影响节奏控制(和其他实时控制)的紧密程度;调度调用之间的时间间隔是在最短延迟时间和代码影响处理器的频率之间做出权衡。先行计划与下一个时间间隔的开始时间的重叠程度决定了您的应用在不同机器中的弹性如何,并且会随着它变得更加复杂(布局和垃圾回收可能需要更长时间)。一般来说,要适应运行速度较慢的机器和操作系统,最好设置较大的总体提前期和相对较短的时间间隔。您可以调整采用更短的重叠时间和更长的间隔,以便处理更少的回调,但在某些时候,您可能会听说,较长的延迟时间会导致节奏变化等无法立即生效;相反,如果您过度减小了向前减小,则可能会开始听到一些抖动(因为调度调用可能必须“弥补”过去本应发生的事件)。

以下时间图显示了节拍器演示代码的实际功能:它的 setTimeout 间隔为 25 毫秒,但重叠时的弹性要高得多:每次调用都会安排在接下来的 100 毫秒内。这种长时间提前预测的缺点是,节奏变化等需要十分之一秒才能生效;但是,我们对中断的适应性要高得多:

安排重叠时间较长的活动。
时间安排长时间重叠

事实上,您可以看出,在此示例中,中间发生了 setTimeout 中断 - 我们应该在大约 270 毫秒时设置了 setTimeout 回调,但由于某种原因,它由于某种原因延迟到大约 320 毫秒 - 比应该延迟 50 毫秒!但是,较长的前向延迟确保了节奏正常,我们没有错过任何一个节拍,尽管我们在这之前将拍子加快到了以 240bpm 播放十六分音符(甚至超出了硬核鼓和低音节奏!)

也有可能每次调度器调用最终都会安排多个音符。让我们来看一下如果使用更长的调度间隔(提前 250 毫秒,间隔 200 毫秒),并在中间加快节拍,会发生什么情况:

setTimeout() 的预设时间较长,间隔时间也较长。
setTimeout() 提前并间隔长

该示例表明,每次 setTimeout() 调用最终都可能会安排多个音频事件。事实上,该节拍器是一个每次一个音符的简单应用,但您可以轻松了解这种方法如何应用于鼓机(经常有多个同时音符)或定音器(可能经常有不规则的音符间间隔)。

在实践中,您需要调整调度间隔和先行预测,看看布局、垃圾回收和其他主要 JavaScript 执行线程中的操作对其的影响;调整对节奏的控制粒度等。例如,如果您有频繁发生的非常复杂的布局,则可能需要扩大前瞻性。主要的一点是,我们希望“提前安排”的时间要足够大,以免出现任何延迟,但又不至于在调整节奏控件时造成明显的延迟。即使上述情况也存在非常小的重叠,因此对于包含复杂 Web 应用的运行缓慢的计算机,其弹性不佳。建议从提前 100 毫秒开始,并将间隔设置为 25 毫秒。在音频系统延迟较大的机器上,这种可能仍然会出现问题,在这种情况下,您应增加先行时间;或者,如果您需要在失去一些弹性的同时需要更严格的控制,请使用较短的先行时间。

调度过程的核心代码位于 scheduler() 函数中:

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

此函数只会获取当前音频硬件时间,并将其与序列中下一个音符的时间进行比较。在此精确的场景中,大多数时间* 都不会起到任何作用(因为没有节拍器“音符”等待安排,但在成功时,它会使用 Web Audio API 安排该音符,并前进到下个音符。

scheduleNote() 函数实际安排要播放的下一个网络音频“笔记”。在本例中,我使用振荡器发出不同频率的哔哔声;你可以轻松创建 AudioBufferSource 节点,并将其缓冲区设为鼓声或您想要的任何其他声音。

currentNoteStartTime = time;

// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );

if (! (beatNumber % 16) )         // beat 0 == low pitch
  osc.frequency.value = 220.0;
else if (beatNumber % 4)          // quarter notes = medium pitch
  osc.frequency.value = 440.0;
else                              // other 16th notes = high pitch
  osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );

一旦安排并连接这些振荡器,此代码就会完全忘记它们;它们会启动,然后停止,然后自动进行垃圾回收。

nextNote() 方法负责前进到下一个十六分音,即将 nextNoteTime 和 current16thNote 变量设置为下个音:

function nextNote() {
  // Advance current note and time by a 16th note...
  var secondsPerBeat = 60.0 / tempo;    // picks up the CURRENT tempo value!
  nextNoteTime += 0.25 * secondsPerBeat;    // Add 1/4 of quarter-note beat length to time

  current16thNote++;    // Advance the beat number, wrap to zero
  if (current16thNote == 16) {
    current16thNote = 0;
  }
}

这个过程非常简单,但请务必注意,在这个时间安排示例中,我并没有跟踪“序列时间”,也就是从节拍器开始算起的时间。我们只需要记住我们演奏最后一个音符的时间,弄清楚下一个音符的演奏时间。这样,我们可以非常轻松地改变拍子(或停止演奏)。

网络上的许多其他音频应用都在使用这种调度技术,例如 Web Audio Drum Machine、非常有趣的 Acid Defender 游戏,以及 Granular Effects 演示等更深入的音频应用示例。

另一个计时系统

现在,正如所有优秀的音乐家所知道的,每个音频应用都需要的是更多牛铃,也就是更多计时器。值得一提的是,进行视觉展示的正确方法是使用 THIRD 计时系统!

天啊,为什么我们需要另一个时间系统呢?这个值通过 requestAnimationFrame API 同步到视觉显示,即图形刷新率。对于节拍器示例中的绘制框,这似乎没什么大不了的,但是随着图形变得越来越复杂,使用 requestAnimationFrame() 与视觉刷新率同步变得越来越重要 - 它从一开始就像使用 setTimeout() 一样易于使用! 使用非常复杂的同步图形(例如,最复杂的同步图形,精确显示密集的 Animation() 将精确地显示密集的 Animation(),而 Animation() 将精确显示密集的音符,而不是在音乐包中播放

我们在调度器中跟踪了队列中的节拍:

notesInQueue.push( { note: beatNumber, time: time } );

与节拍器当前时间的交互可以在 draw() 方法中找到,只要图形系统准备好进行更新,就会调用该方法(使用 requestAnimationFrame):

var currentTime = audioContext.currentTime;

while (notesInQueue.length && notesInQueue[0].time < currentTime) {
  currentNote = notesInQueue[0].note;
  notesInQueue.splice(0,1);   // remove note from queue
}

同样,您会注意到,我们正在检查音频系统的时钟,因为这确实是我们想要同步的时钟,因为它实际上会播放音符,以了解我们是否应绘制一个新的方框。事实上,我们根本并未使用 requestAnimationFrame 时间戳,因为我们是使用音频系统时钟来确定我们所处的位置。

当然,我也可以直接不使用 setTimeout() 回调,而将记事调度程序放入 requestAnimationFrame 回调中,然后就可以再使用两个计时器了。这也没有关系,但请务必注意,在本例中,requestAnimationFrame 只是 setTimeout() 的替代项;您仍然希望网页音频时间在实际音符上的调度准确性。

总结

希望本教程在介绍时钟、计时器以及如何在网络音频应用中构建出色的计时方面有所帮助。可以轻松地推断这些相同的技术用于构建音序播放机、鼓机等。下次再一起玩…