兩款時鐘的故事

精確安排網路音訊排程

克里斯.威爾森 (Chris Wilson)
Chris Wilson

引言

透過網路平台打造優質的音訊和音樂軟體時,最大的挑戰之一就是管理時間。不像「撰寫程式碼的時間」,但就像時鐘時間一樣,網路音訊內容最難以理解的其中一個主題就是如何正確使用音訊時鐘。Web AudioContext 物件具有 currentTime 屬性,可公開此音訊時鐘。

尤其是針對網路音訊的應用程式,而不是僅寫出序列器和合成器,以及對音訊事件 (例如鼓機遊戲其他 應用程式) 的任何節奏使用。因此,請務必設定一致且準確的音訊事件,不僅是開始和停止音效變更,也要安排音效變更 (例如改變頻率或音量)。有時候,通常會隨機發生的事件 (例如使用 Web Audio API 開發遊戲音訊中的機器槍示範影片),但我們通常會希望對音符具有一致且準確的時間點。

我們已在「開始使用網路音訊」和「使用 Web Audio API 開發遊戲音訊」章節中已介紹如何使用網路音訊筆記開啟和 noteOff (現已更名為開始和停止) 方法的時間參數,排定備忘稿。然而,我們尚未深入探索較複雜的情境,例如播放較長的音樂序列或節奏。為深入說明,首先需要一些時鐘的背景。

年度最佳時間 - 網路音訊時鐘

Web Audio API 可讓使用者存取音訊子系統的硬體時鐘,此時鐘會透過 AudioContext 物件的 .currentTime 屬性在 AudioContext 物件上顯示,為 AudioContext 建立後經過的浮點數 (秒)。如此一來,這個時鐘 (稱為「音訊時鐘」) 精確度會非常高;此外,這個時鐘能夠根據個別聲音取樣程度指定對齊方式,即使取樣率較高也不受影響。由於「雙重」的精確度大約有 15 位小數,因此即使音訊時鐘已執行數天,還是仍應有足夠的位元,指向特定樣本,即使在高取樣率的情況下也是如此。

音訊時鐘會用來排程整個 Web Audio API 中的參數和音訊事件,當然適用於 start()stop(),也適用於 AudioParams 上的 set*ValueAtTime() 方法。這樣一來,我們就能提前設定非常精確的音訊事件。事實上,很多網路音訊內容都設為開始/停止時間,但實際上會發生問題。

例如,請參考我們 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 時鐘不甚精確。以 starter 來說,Date.now() 會傳回值 (以毫秒為單位),也就是整數毫秒,因此您可以預期 1 毫秒的精確度。以某些音樂情境來說,這並非非常糟糕。如果筆記是在毫秒內或延遲開始,你可能根本不會注意到,但即使音訊硬體速率相對較低,44.1kHz 也會像排程音訊的速度太慢 44.1 倍。請注意,捨棄任何樣本都可能會導致音訊出現故障,因此如果要將樣本鏈結在一起,可能需要精確依序排列樣本。

不斷更新的高解析度時間規格實際上透過 window.performance.now() 的做法,實際上也更加準確,而且在許多目前的瀏覽器中甚至能實作 (無論是否加上前置字元)。在某些情況下,這個做法雖然與 JavaScript 時間 API 最差的部分沒有關聯,但在某些情況下非常實用。

JavaScript 時間 API 最糟的是,雖然 Date.now() 的毫秒精確度不好看不好,但在 JavaScript 中計時器事件的實際回呼 (透過 window.setTimeout() 或 window.setInterval),版面配置、轉譯、垃圾收集,以及主要執行緒所執行還記得我怎麼說可以用 Web Audio API 安排的「音訊活動」嗎?其實這些是在單獨的執行緒上進行處理,因此,即使主執行緒暫時停滯處理複雜的版面配置或其他長時間工作,音訊仍會在被告知的時間點發生。事實上,即使您在偵錯工具的中斷點停止音訊,音訊執行緒仍會繼續播放已排定的事件!

在音訊應用程式中使用 JavaScript setTimeout()

由於主要執行緒一次容易停滯數毫秒,因此建議您使用 JavaScript 的 setTimeout 直接開始播放音訊事件,因為最好的方式最多會在一毫秒或不久後啟動,而在最壞的情況下可能會延遲更長時間。最糟糕的是,應該是節奏順序的,因為時間對主 JavaScript 執行緒上發生的其他狀況來說比較敏感,所以不會以精確的間隔觸發。

為了示範,我編寫了一個「不良」的 Metronome 應用程式範例,也就是使用 setTimeout 直接排定註記的時程,並且執行大量的版面配置。開啟這個應用程式,按一下「播放」,然後在播放期間快速調整視窗大小;您會發現時間的顯示時間明顯斷斷續續 (例如聽到的節奏無法保持一致)。但你說:「但這勢如破竹!」當然,但這不代表現實生活並不容易。即使是相對靜態的使用者介面,由於重新調整版面,在 setTimeout 中也會發生時間問題。例如,我發現快速重新調整視窗大小會導致它時間過長,導致 WebkitSynth 出現的時間不穩定。現在,請想想當你嘗試搭配音訊來順暢捲動時,系統會如何產生完整音樂,並輕鬆想像這對於現實世界中複雜的音樂應用程式會有什麼影響。

我最常聽到的問題之一是「為何我無法從音訊事件取得回呼?」不過,雖然這類回呼可能用得到,但可能無法解決手邊的特定問題。請務必瞭解這些事件會在主 JavaScript 執行緒中觸發,因此可能會受到與 setTimeout 相同的所有延遲,而且這些事件實際發生的時間可能會與 setTimeout 一樣,都和 setTimeout 一樣,都發生延遲的情況。

我們該怎麼做?處理時間的最佳方法就是在 JavaScript 計時器 (setTimeout()、setInterval() 或 requestAnimationFrame() (稍後會進一步說明)) 與音訊硬體排程之間進行協作。

引領新潮流,搶眼前所未見

回到 Metronome 示範影片 - 其實我編寫了第一個簡易 Mememe 示範影片,以示範這種協作排程技巧。(程式碼也可在 GitHub 上取得:這個示範模式會播放由 Oscillator 產生的嗶聲),且每隔六、八、四分或四分音符以高精確度播放,根據節拍調整音調。還可以在播放時變更節奏和註記間隔,也可以隨時停止播放,這對於現實世界的節奏感器來說都是不可或缺的功能。新增程式碼來變更這個節拍器在飛行過程中所使用的聲音,十分簡單。

採用這項機制,可同時在維持穩定熱度的同時,控管臨時設備的執行時間:一種會經常觸發一次的 setTimeout 計時器,並設定未來的個別記事網路音訊排程。基本上,setTimeout 計時器只會根據目前的速度,檢查是否有任何記事需要「不久後」需要執行,並且排定時程,例如:

setTimeout() 和音訊事件互動。
setTimeout() 和音訊事件互動。

實際上,setTimeout() 呼叫可能會延遲,因此排定呼叫的時間點可能會出現抖動 (視您如何使用 setTimeout 而定)。雖然這個範例中的事件每次觸發約 50 毫秒,但有時可能會略多 (有時甚至多於) 發生。不過,在每次通話期間,我們會安排網路音訊事件,因此不只需要立即播放的記事 (例如第一則記事),也要安排從現在到下一個間隔之間必須演奏的任何音符。

事實上,我們不希望直接即時查看 setTimeout() 呼叫之間的間隔,為了配合最嚴重的主執行緒行為,也就是垃圾收集、版面配置、轉譯或其他在主執行緒呼叫會延遲下一個計時器呼叫的程式碼,執行一些重疊的排程。我們也必須考量音訊區塊的排程時間,也就是作業系統在處理緩衝區時保留的音訊量,這些資料會因作業系統和硬體而異,從短的毫秒至約 50 毫秒都有可能。上方顯示的每個 setTimeout() 呼叫都有一個藍色間隔,顯示用來排定事件的整個時段;舉例來說,如果我們要等到系統才播放到下一個 setTimeout 呼叫的時間,然後才執行該事件,那麼上圖中的第四個網路音訊事件可能會一直播放「遲到」。現實生活中造成的誤差可能比上述情況來得嚴重,而且隨著應用程式變得越來越複雜,重疊就顯得更加重要。

整體提前延遲會影響速度控制 (和其他即時控制) 的節奏;排定呼叫的間隔時間是最低延遲時間,以及程式碼對處理器影響的頻率。預測與下一個間隔開始時間重疊的程度,決定了應用程式在不同機器間的適應能力,以及應用程式變得更加複雜 (版面配置和垃圾收集作業可能需要較長時間)。一般而言,為了適應較慢的機器和作業系統,最好將整體抬頭望向,並採用合理的時間間隔。您可以進行調整,縮短重疊時間和間隔較長的間隔來減少回呼次數,但在某些情況下,可能會發現長時間延遲導致時間表變化等,不立即生效;相反地,如果您降低了預測位幅度,可能會開始聽到一些應該發生的情況 (因為安排呼叫可能需要「建立」過去應發生的事件)。

以下時間圖表顯示了 Metronome 示範程式碼的實際操作:這個 setTimeout 時間間隔為 25 毫秒,但重疊的重疊性卻較大:每個呼叫都會排定接下來的 100 毫秒。提前預覽的缺點,就是速度改變 (例如節奏等) 需要 10 秒的時間才能生效。不過,我們能更有效地應對中斷情形:

安排重疊時間較長的時段。
重疊且重疊時間

事實上,您可以在此範例中得知我們作業過程中發生了 setTimeout 中斷 - 我們應該會在大約 270 毫秒時發生了 setTimeout 回呼,但由於某些原因而延遲到大約 320 毫秒 - 50 毫秒才發生了!然而,大額預覽的延遲時間可維持良好步調,不會造成任何問題,即使先前只是將節奏提高到 240bpm 的第 16 則音,還是不侷限在硬核鼓和低音節拍!

每個排程器呼叫也可能因此安排多則記事,讓我們看看如果使用較長的排程間隔 (例如 250 毫秒前看、間隔 200 毫秒),然後進行中間的時速增加,會發生什麼情況:

setTimeout()。
setTimeout() 含有長時間的 Look 圖表和較長的時間間隔

這個案例顯示,每個 setTimeout() 呼叫都最終可能會安排多個音訊事件。事實上,這個節拍器是一種一次性的簡易應用程式,但您可以輕鬆瞭解這個做法對鼓機 (經常有多個同時有數個音符) 或序列器 (記事之間經常不固定) 使用。

實際執行時,建議您調整排程間隔和一覽,查看因版面配置、垃圾收集以及其他主要 JavaScript 執行執行緒中正在發生的事情,以及調整對節奏的控制精細程度等。舉例來說,如果版面配置非常複雜,而且會經常發生,最好將外觀調大一點。重點在於,我們希望 Google 正在設定「超前排程」的數量,以避免任何延遲,但不要那麼大,以便在調整速度控制時產生明顯的延遲。即使上述情況的重疊性也很小,因此如果機器運作緩慢,而且具有複雜的網路應用程式,也不太具備彈性。建議您從 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;
  }
}

操作步驟非常簡單,但必須在這個排程範例中瞭解不會追蹤「序列時間」,也就是自該節拍器開始起算的時間。要做的是記得自己播放最後一則音,並知道下一則音何時要播放。如此一來,我們就能輕而易舉地調整速度 (或停止播放)。

網路上許多其他音訊應用程式都使用這個排程技巧,例如網路音訊鼓機、有趣的 Acid Defender 遊戲,以及 Granular Effects 示範等較為深入的語音範例。

但又是時間系統

現在,正如任何優秀的音樂家一樣,每個音訊應用程式都需要什麼計時器,但有更多計時器。值得一提的是,正確呈現螢幕的正確方式就是使用 THIRD 的時間系統!

為什麼啊,親愛的,為什麼還需要其他時間系統?好的,這會透過 requestAnimationFrame API 與視覺螢幕同步處理 (即圖形刷新率)。以我們都會區的繪圖方塊來說,這看起來可能不是真正的參與過程,但隨著您的圖像日趨複雜,使用 requestAnimationFrame() 同步顯示重新整理頻率也變得更加重要。而且從一開始,就和使用 setTimeout() 一樣,都能輕鬆使用經過精確同步的動畫 (例如,精確同步的動畫):

我們會在排程器中追蹤佇列中的節拍:

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

只要圖形系統準備好要進行更新,就可以透過 draw() 方法查看與 Metronome 目前時間的互動:

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() 的例子;您仍希望對實際記事執行網路音訊時間排程的準確性。

結語

希望本教學課程能解釋時鐘、計時器,以及如何在網路音訊應用程式中內建絕佳的時間。這些技巧也可以輕鬆推斷,以便建立序列玩家、鼓機等等。下次再一起玩吧…