两个时钟的故事

精确地安排 Web 音频

Chris Wilson
Chris Wilson

简介

使用 Web 平台构建出色的音频和音乐软件的最大挑战之一就是管理时间。这里的“时间”不是指“编写代码的时间”,而是指时钟时间。Web Audio 中最不为人所知的主题之一是如何正确使用音频时钟。Web Audio AudioContext 对象有一个 currentTime 属性,用于公开此音频时钟。

对于网络音频的音乐应用而言,不仅要写定序器和合成器,还要有节奏地使用音频事件(例如鼓机游戏其他 应用),务必要保证音频事件的连贯性、精确性;不仅要开始和停止声音,还要安排声音的变化(例如更改频率或音量)。有时,我们希望事件的时间略有随机化(例如,使用 Web Audio API 开发游戏音频中的机枪演示),但通常,我们希望音符的时间保持一致且准确。

Web Audio 使用入门使用 Web Audio API 开发游戏音频中,我们已经介绍了如何使用 Web Audio noteOn 和 noteOff(现已重命名为 start 和 stop)方法的时间参数来安排音符;不过,我们尚未深入探讨更复杂的场景,例如播放长音乐序列或节奏。要深入了解这一点,我们首先需要了解一下时钟的相关背景知识。

The Best of Times - the Web Audio Clock

Web Audio API 提供对音频子系统的硬件时钟的访问权限。此时钟通过其 .currentTime 属性在 AudioContext 对象上公开,以自创建 AudioContext 以来的秒数表示。这样,此时钟(以下简称“音频时钟”)的准确性非常高;它旨在能够在单个声音采样级别指定对齐,即使采样率较高也是如此。由于“double”的精度约为 15 位小数,因此即使音频时钟已运行数天,即使在高采样率下,也应该仍有足够的位数来指向特定的采样。

音频时钟用于在整个 Web Audio API 中调度参数和音频事件,当然也适用于 start()stop(),但也适用于 AudioParam 的 set*ValueAtTime() 方法。这样,我们就可以提前设置非常精确的音频事件。事实上,很容易只将 Web Audio 中的所有内容设置为开始/停止时间,但在实践中,这样做会出现问题。

我们以网络音频简介中的这个经过简化的代码段为例,这段代码为八分音踩踏板设置了两个竖条:

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 时钟的优点在于,它提供了两个非常实用的“稍后给我回电”方法: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 相同的所有潜在延迟影响;也就是说,从安排的确切时间到实际处理之前,它们可能会延迟一些未知且可变的毫秒数。

那么我们该怎么办?那么,处理时间的最佳方式是让 JavaScript 计时器(setTimeout()、setInterval() 或 requestAnimationFrame(),稍后会详细介绍)与音频硬件调度协同工作。

通过预测来实现稳定的时间安排

我们再来回顾一下节拍器演示。事实上,我编写了这个简单节拍器演示的第一个版本,以演示这种协作调度技术。(此代码也可在 GitHub 上找到)此演示会在每个 16 分音符、8 分音符或四分音符上以高精度播放哔哔声(由振荡器生成),并根据节拍改变音调。您还可以更改节奏和音符间隔,或随时停止播放,这对于任何现实世界的节奏序列器来说都是一项关键功能。您还可以很轻松地添加代码,以便动态更改此节拍器使用的音效。

它既支持温度控制,又保持稳定计时,这只是一项协作:一个不时触发一次的 setTimeout 计时器,可在日后为各个笔记设置网络音频时间设置。setTimeout 计时器基本上只是检查是否需要根据当前节奏“尽快”安排任何音符,然后进行安排,如下所示:

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

在实践中,setTimeout() 调用可能会延迟,因此调度调用的时机可能会随时间而抖动(并会出现偏差,具体取决于您使用 setTimeout 的方式)- 虽然此示例中的事件相隔约 50 毫秒触发,但它们通常会稍微超过这个时间(有时会延迟很长时间)。不过,在每次调用期间,我们不仅会为当前需要播放的任何音符(例如第一个音符)安排 Web Audio 事件,还会为当前到下一个时间间隔内需要播放的任何音符安排 Web Audio 事件。

事实上,我们不想仅仅根据 setTimeout() 调用之间的间隔时间来预测未来,我们还需要在这次计时器调用和下次调用之间进行一些调度重叠,以适应最糟糕的主线程行为,即主线程上发生的垃圾回收、布局、渲染或其他代码延迟了下一次计时器调用的最糟糕情况。我们还需要考虑音频块调度时间,即操作系统在处理缓冲区中保留了多少音频。该时间因操作系统和硬件而异,从低个位数毫秒到大约 50 毫秒不等。上面显示的每个 setTimeout() 调用都有一个蓝色间隔,显示它会尝试调度事件的整个时间范围;例如,上图中安排的第四个网络音频事件可能会“延迟”播放,如果我们等到下一次 setTimeout 调用发生,而该 setTimeout 调用在几毫秒后才出现。在现实生活中,这些时间的抖动可能比这更极端,随着应用变得越来越复杂,这种重叠就变得更加重要。

总体预测延迟时间会影响节奏控制(和其他实时控制)的紧密程度;调度调用之间的间隔是最低延迟时间与代码影响处理器的频率之间的权衡。预测性延迟与下一个间隔的开始时间重叠的程度决定了应用在不同机器上的弹性程度,以及随着应用变得越来越复杂(布局和垃圾回收可能需要更长时间),应用的弹性程度。一般来说,为了能够适应运行速度较慢的机器和操作系统,最好设置较大的整体向前先向和合理的间隔时间。您可以进行调整,以缩短重叠时间并延长间隔时间,以便处理更少的回调,但在某个时候,您可能会发现延迟时间过长导致节奏变化等效果未立即生效;反之,如果您过多缩短了预测时间,则可能会开始听到一些抖动(因为调度调用可能必须“补充”过去应该发生的事件)。

以下时间图展示了节拍器演示代码的实际操作:它的 setTimeout 间隔为 25 毫秒,但重叠更为弹性:每次调用都会安排在接下来的 100 毫秒内。这种长预测的缺点是,节奏等更改需要 1/10 秒才能生效;不过,我们对中断的抵抗力更强:

安排较长重叠时间。
存在长时间重叠的安排

事实上,您可以从这个示例中看出,我们在中途发生了 setTimeout 中断 - 我们本应在大约 270 毫秒时收到 setTimeout 回调,但由于某种原因,它延迟到了大约 320 毫秒 - 比预期晚了 50 毫秒!不过,较长的预测延迟时间确保了节奏正常进行,我们没有错过任何节拍,即使我们在演奏前提高了节奏,以 240 bpm 的速度演奏了 16 分音符(甚至超过了硬核鼓和贝斯节奏!)

每个调度程序调用也可能会最终调度多个音符 - 我们来看看如果使用更长的调度间隔(250 毫秒的预测时间,间隔 200 毫秒),并在中间增加节奏,会发生什么情况:

setTimeout() (采用长先向和长时间间隔)。
具有较长预测时间和较长间隔的 setTimeout()

本例演示了每次调用 setTimeout() 都可能会最终调度多个音频事件。事实上,此节拍器是一个简单的一次发音应用,但您可以轻松了解此方法如何适用于鼓机(其中经常有多个同时发音)或序列器(音符之间可能经常有不规则的间隔)。

在实践中,您需要调整调度间隔和预测时间,以了解其受到布局、垃圾回收和主 JavaScript 执行线程中发生的其他事情的影响程度,并调整对节奏等的控制粒度。例如,如果您经常遇到非常复杂的布局,则可能需要延长预测时间。要点是,我们希望“提前调度”的量足够大,以避免任何延迟,但也不会太大,以免在调整节奏控制时造成明显延迟。即使上述情况存在很小的重叠,在运行复杂 Web 应用且运行缓慢的机器上,其弹性也不会很强。一个不错的起点可能是 100 毫秒的“预测”时间,并将间隔设置为 25 毫秒。在音频系统延迟时间较长的机器上运行复杂应用时,这可能仍会出现问题,在这种情况下,您应延长预读时间;或者,如果您需要更严格的控制,但会失去一些弹性,请使用较短的预读时间。

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

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

此函数只会获取当前的音频硬件时间,并将其与序列中的下一个音符的时间进行比较。在这种情况下,大多数时候*,此函数不会执行任何操作(因为没有待调度的节拍器“音符”),但如果成功,它将使用 Web Audio API 调度该音符,并推进到下一个音符。

scheduleNote() 函数负责实际安排下一个要播放的 Web Audio“音符”。在本例中,我使用了振荡器来发出不同频率的哔哔声;您也可以轻松创建 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 演示)。

Yet Another Timing System

现在,任何优秀的音乐家都知道,每个音频应用需要的只是更多功能,也就是更多计时器。值得注意的是,进行可视化显示的正确方法是使用第三个计时系统!

为什么,为什么,天哪,为什么我们需要另一个计时系统?此帧速率会通过 requestAnimationFrame API 与视觉显示(即图形刷新率)同步。对于节拍器示例中的绘制框,这可能并不重要,但随着图形越来越复杂,使用 requestAnimationFrame() 与视觉刷新率同步就变得越来越重要 - 实际上,从一开始,它就与使用 setTimeout() 一样简单!对于非常复杂的同步图形(例如,在音乐符号包中准确显示密集的音乐符号),requestAnimationFrame() 可为您提供最流畅、最精确的图形和音频同步。

我们在调度程序中跟踪队列中的节拍:

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() 的替代项;您仍然需要为实际音符使用 Web Audio 定时的精确调度。

总结

希望本教程对您有所帮助,为您介绍了时钟、计时器以及如何在 Web 音频应用中构建出色的计时功能。这些相同的技术可以轻松推广到构建序列播放器、鼓机等。下次再一起玩…