两个时钟的故事 - 精确调度网络音频
使用 Web 平台构建出色的音频和音乐软件的最大挑战之一是管理时间。 不是“编写代码的时间”,而是时钟时间——关于网络音频最不为人知的话题之一是如何正确使用音频时钟。 Web Audio AudioContext 对象具有公开此音频时钟的 currentTime 属性。
特别是对于网络音频的音乐应用——不仅仅是编写音序器和合成器,还有任何有节奏地使用音频事件,如 鼓机 、 游戏 和 其他 应用程序 ——对音频事件进行一致、精确的计时非常重要; 不仅是开始和停止声音,而且还安排声音的更改(例如更改频率或音量)。 有时需要稍微随机的事件——例如,在 的机枪演示中 使用 Web 音频 API 开发游戏音频 ——但通常,我们希望为音符提供一致和准确的时间。
我们已经向您展示了如何使用 Web Audio noteOn 和 noteOff(现在更名为 start 和 stop)方法的 time 参数来安排笔记,在 Web Audio 入门 和 使用 Web Audio API 开发游戏音频中 ; 但是,我们还没有深入探索更复杂的场景,例如播放长音乐序列或节奏。 为了深入了解这一点,首先我们需要一些关于时钟的背景知识。
最好的时代——网络音频时钟
Web 音频 API 公开了对音频子系统硬件时钟的访问。 此时钟通过其 .currentTime 属性在 AudioContext 对象上公开,作为自 AudioContext 创建以来的浮点秒数。 这使得该时钟(以下称为“音频时钟”)具有非常高的精度; 它旨在能够在单个声音样本级别指定对齐,即使是高采样率。 由于“双精度”中大约有 15 位十进制数字的精度,即使音频时钟已经运行了好几天,即使在高采样率下,它仍然应该有足够的位来指向特定的样本。
音频时钟用于在整个 Web Audio API 中调度参数和音频事件—— 用于 start() 和 stop() 当然, ,但也用于 AudioParams set*ValueAtTime() 方法 上的 。 这让我们可以提前设置非常精确定时的音频事件。 事实上,将 Web Audio 中的所有内容都设置为开始/停止时间是很诱人的——然而,在实践中存在这样的问题。
例如,看看我们的 Web Audio Intro 中的这个简化的代码片段,它设置了两个八分音符踩镲模式的小节:
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() 以毫秒为单位返回一个值——一个整数毫秒——所以你希望的最佳精度是一毫秒。 这在某些音乐环境中并不算太糟糕——如果你的音符早或晚开始一毫秒,你甚至可能没有注意到——但即使在 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 直接开始播放音频事件是一个坏主意,因为您的笔记最多会在真正应该播放的时间的一毫秒左右内触发,并且在最坏的情况下,它们会被延迟更长时间。 最糟糕的是,对于应该是有节奏的序列,它们不会以精确的间隔触发,因为时间将对主 JavaScript 线程上发生的其他事情很敏感。
为了证明这一点,我编写了一个 示例“糟糕”的节拍器应用程序 应用程序 ——即直接使用 setTimeout 来安排音符的 ——并且还做了很多布局。 打开这个应用程序,点击“播放”,然后在播放时快速调整窗口大小; 你会注意到时间明显紧张(你可以听到节奏不保持一致)。 “但这是人为的!” 你说? 好吧,当然——但这并不意味着它也不会在现实世界中发生。 由于重新布局,即使是相对静态的用户界面也会在 setTimeout 中出现计时问题——例如,我注意到快速调整窗口大小会导致原本出色的 上的计时 WebkitSynth 顿 明显卡 。 现在想象一下当您尝试平滑滚动完整乐谱和音频时会发生什么,您可以轻松想象这将如何影响现实世界中的复杂音乐应用程序。
我听到的最常见的问题之一是“为什么我不能从音频事件中获得回调?” 尽管这些类型的回调可能会有用处,但它们并不能解决手头的特定问题——重要的是要了解这些事件将在 JavaScript 主线程中触发,因此它们会受到与设置超时; 也就是说,它们可能会在实际处理之前从计划的精确时间延迟一些未知且可变的毫秒数。
所以,我们能做些什么? 好吧,处理时间的最好方法是在 JavaScript 计时器(setTimeout()、setInterval() 或 requestAnimationFrame() ——稍后会详细介绍)和音频硬件调度之间建立协作。
通过向前看获得坚如磐石的时机
让我们回到那个节拍器演示——事实上,我 编写了 的第一个版本 这个简单节拍器演示 正确地 来演示这种协作调度技术。 ( 代码也可以在 Github 上找到 。)这个演示在每十六分之一、八分之一或四分音符上以高精度播放哔声(由振荡器生成),根据节拍改变音高。 它还允许您在播放时更改速度和音符间隔,或随时停止播放 - 这是任何现实世界节奏音序器的关键功能。 添加代码来改变这个节拍器使用的声音也很容易。
它设法允许临时控制同时保持坚如磐石的计时方式是一种协作:一个每隔一段时间触发一次的 setTimeout 计时器,并在未来为单个音符设置 Web 音频调度。 setTimeout 计时器基本上只是检查是否需要根据当前速度“很快”安排任何音符,然后安排它们,如下所示:
在实践中,setTimeout() 调用可能会延迟,因此调度调用的时间可能会随着时间的推移而抖动(和倾斜,具体取决于您使用 setTimeout 的方式)——尽管此示例中的事件间隔大约 50 毫秒,但它们通常会稍微不止于此(有时甚至更多)。 但是,在每次通话期间,我们不仅为现在需要播放的任何音符(例如第一个音符)安排 Web 音频事件,而且还为现在和下一个间隔之间需要播放的任何音符安排 Web 音频事件。
事实上,我们不想仅仅通过 setTimeout() 调用之间的间隔来预测——我们还需要在这个定时器调用和下一个定时器调用之间有一些调度重叠,以适应最坏情况下的主线程行为——也就是说,主线程上发生的垃圾收集、布局、渲染或其他代码的最坏情况会延迟我们的下一次计时器调用。 我们还需要考虑音频块调度时间——即操作系统在其处理缓冲区中保留了多少音频——它因操作系统和硬件而异,从毫秒的低个位数到大约 50 毫秒不等。 上面显示的每个 setTimeout() 调用都有一个蓝色间隔,显示它将尝试安排事件的整个时间范围; 例如,如果我们等到下一次 setTimeout 调用发生时,上图中安排的第四个网络音频事件可能已“延迟”播放,如果该 setTimeout 调用仅晚了几毫秒。 在现实生活中,这些时间的抖动可能比这更加极端,并且随着您的应用程序变得越来越复杂,这种重叠变得更加重要。
整体前瞻延迟会影响速度控制(和其他实时控制)的紧密程度; 调度调用之间的间隔是最小延迟和代码影响处理器的频率之间的权衡。 前瞻与下一个间隔的开始时间重叠多少决定了您的应用程序在不同机器上的弹性,以及它变得更加复杂(并且布局和垃圾收集可能需要更长的时间)。 一般来说,为了适应较慢的机器和操作系统,最好有一个大的整体前瞻和一个合理的短时间间隔。 您可以调整为具有更短的重叠和更长的间隔,以处理更少的回调,但在某些时候,您可能会开始听到大延迟导致节奏变化等,不会立即生效; 相反,如果您过多地减少前瞻,您可能会开始听到一些抖动(因为调度调用可能必须“弥补”过去应该发生的事件)。
下面的时序图显示了节拍器演示代码的实际作用:它的 setTimeout 间隔为 25 毫秒,但重叠更具弹性:每次调用将安排在接下来的 100 毫秒。 这种长前瞻的缺点是节奏变化等需要十分之一秒才能生效; 但是,我们对中断的抵抗力要强得多:
事实上,在这个例子中你可以看出我们在中间有一个 setTimeout 中断——我们应该在大约 270 毫秒时有一个 setTimeout 回调,但由于某种原因它被延迟到大约 320 毫秒——比它应该的晚了 50 毫秒! 然而,大的前瞻延迟使时间保持没有问题,我们没有错过任何一个节拍,尽管我们在此之前将速度提高到以 240bpm 播放十六分音符(甚至超过了铁杆鼓和贝司的速度!)
也有可能每个调度程序调用最终会调度多个音符——让我们看看如果我们使用更长的调度间隔(250 毫秒前瞻,间隔 200 毫秒)并在中间增加节奏会发生什么:
这个案例展示了每个 setTimeout() 调用最终可能会调度多个音频事件——事实上,这个节拍器是一个简单的一次一个音符的应用程序,但是你可以很容易地看到这种方法是如何在鼓机上工作的(其中经常有多个同时出现的音符)或音序器(音符之间可能经常有不规则的间隔)。
在实践中,您需要调整调度间隔和前瞻,以了解布局、垃圾收集和主 JavaScript 执行线程中发生的其他事情对它的影响,并调整对速度的控制粒度等。例如,如果您有一个经常发生的非常复杂的布局,您可能希望使前瞻更大。 要点是我们希望我们正在做的“提前安排”的数量足够大以避免任何延迟,但又不能大到在调整速度控制时造成明显的延迟。 即使是上面的情况也有非常小的重叠,所以它在具有复杂 Web 应用程序的慢速机器上也不会很有弹性。 一个好的起点可能是 100 毫秒的“前瞻”时间,间隔设置为 25 毫秒。 在音频系统延迟很大的机器上的复杂应用程序中,这可能仍然存在问题,在这种情况下,您应该提前预测时间; 或者,如果您需要在失去一些弹性的情况下进行更严格的控制,请使用更短的前瞻。
调度过程的核心代码在 scheduler() 函数中:
while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) { scheduleNote( current16thNote, nextNoteTime ); nextNote(); }
此函数仅获取当前音频硬件时间,并将其与序列中下一个音符的时间进行比较——大多数情况下*在这种精确场景中,这将什么也不做(因为没有节拍器“音符”等待安排,但是当它成功时,它将使用 Web Audio API 安排该笔记,并前进到下一个笔记。
*调度程序每 25 毫秒调用一次。 以每分钟 120 个四分音符节拍的标准速度——在我们的例子中,每秒演奏 8 个十六分音符——每 125 毫秒就会出现一个音符进行调度。 因此,在这种情况下,大约 60% 的调度调用将找不到要调度的新笔记。 尽管在我们简单的十六分音符模式开始在单个 scheduler() 传递中安排多个音符之前,您必须达到每分钟 300 个四分音符节拍的速度,但是一旦您开始支持更复杂的,“while”子句就很重要节奏——第 32 个或更短的音符、flams、任意序列等——当然,第一个调用,具有长的前瞻和快速的节奏,可能会缓冲多个音符。
scheduleNote() 函数负责实际安排下一个要播放的网络音频“note”。 在这种情况下,我使用振荡器来发出不同频率的哔哔声; 您可以轻松地创建 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 演示 。
又一个计时系统
现在,正如任何优秀的音乐家都知道的那样,每个音频应用程序都需要更多的牛铃——呃,更多的计时器。 值得一提的是,进行视觉展示的正确方法是使用第三个计时系统!
为什么,为什么,天哪,为什么我们需要另一个计时系统? 好吧,这个是通过 与视觉显示同步的——即图形刷新率 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 音频应用程序中构建出色的计时功能。 这些相同的技术可以轻松地外推以构建音序播放器、鼓机等。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 案例研究:找到通往奥兹的路
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论