精確排定網路音訊
簡介
使用網路平台打造優質的音訊和音樂軟體,是其中之一。這裡的「時間」並非指「編寫程式碼的時間」,而是指時鐘時間。Web Audio 中最不為人知的議題之一,就是如何正確使用音訊時鐘。Web Audio AudioContext 物件具有 currentTime 屬性,可公開這個音訊時鐘。
特別是針對網頁音訊的音樂應用程式 (不僅是編寫序列器和合成器,也包括任何以節奏使用音訊事件的應用程式,例如鼓機、遊戲和其他應用程式),都必須確保音訊事件的時間精確一致,不僅要開始和停止音訊,還要安排音訊變更 (例如變更頻率或音量)。有時您可能會希望事件的時間稍微隨機,例如在使用 Web Audio API 開發遊戲音效的機關槍示範中,但通常我們希望音符的時間能保持一致且準確。
我們已在「開始使用 Web Audio」和「使用 Web Audio API 開發遊戲音效」中,說明如何使用 Web Audio noteOn 和 noteOff (現在已改名為 start 和 stop) 方法的時間參數排定音符。不過,我們尚未深入探討更複雜的情況,例如播放長音樂序列或節奏。為深入瞭解這項功能,我們先來簡單介紹一下時鐘。
最佳時代 - 網路音訊時鐘
Web Audio API 可公開音訊子系統的硬體時鐘。這個時鐘會透過 AudioContext 物件的 .currentTime 屬性公開,以浮點數的秒數表示 AudioContext 建立後的秒數。這可讓這個時鐘 (以下稱為「音訊時鐘」) 達到極高精確度;它可在個別音訊取樣層級指定對齊,即使取樣率很高也一樣。由於「double」的精確度約為 15 位小數,即使音訊時鐘已運行多天,仍應有足夠的位元可指向特定的取樣,即使取樣率很高也是如此。
音訊時鐘用於在 Web Audio API 中排程參數和音訊事件,包括 start() 和 stop(),以及 AudioParams 上的 set*ValueAtTime() 方法。這可讓我們提前設定精確的音訊事件時間。事實上,您很可能會想將 Web Audio 中的所有內容都設為開始/停止時間,但在實際操作中,這會造成問題。
舉例來說,請參考這段從 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 和輸出內容之間插入增益節點,藉此將自己的聲音設為靜音!)
簡而言之,由於您需要靈活變更節奏或頻率或增益等參數 (或完全停止排程),因此不應將太多音訊事件推送至佇列,更準確地說,您不應將太多時間推送至佇列,因為您可能需要完全變更該排程。
The Worst of Times - the JavaScript Clock
我們也提供備受喜愛且經常遭到誤解的 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 直接開始播放音訊事件並不理想,因為在最佳情況下,音符會在應發出時的毫秒內觸發,在最壞的情況下,音符會延遲更久的時間。最糟糕的是,對於應以節奏性序列觸發的事件,系統不會以精確的間隔觸發這些事件,因為時間點會受到主要 JavaScript 執行緒上發生的其他事件影響。
為說明這個問題,我編寫了一個「不良」節拍器應用程式範例,也就是直接使用 setTimeout 排程音符,並執行大量版面配置。開啟這個應用程式,按一下「播放」,然後在播放時快速調整視窗大小,您會發現時間會明顯不穩定 (您會聽到節奏不一致)。你或許會說:「但這很牽強!」當然,但這並不代表現實世界不會發生這種情況。即使是相對靜態的使用者介面,也會因為中繼而導致 setTimeout 出現時間問題。舉例來說,我發現快速調整視窗大小會導致原本優異的 WebkitSynth 出現明顯的卡頓情形。想像一下,如果您想順暢捲動完整的音樂分數,並搭配音訊播放,會發生什麼事?您不難想像,這會對現實世界中複雜的音樂應用程式造成什麼影響。
我最常聽到的問題之一是「為什麼我無法從音訊事件取得回呼?」雖然這類回呼可能有用途,但無法解決目前的特定問題。請務必瞭解,這些事件會在主要 JavaScript 執行緒中觸發,因此會受到與 setTimeout 相同的潛在延遲影響,也就是說,從排定的確切時間到實際處理之間,可能會延遲一段不確定的毫秒數。
那我們可以怎麼做?處理時間的最佳方式,就是在 JavaScript 計時器 (setTimeout()、setInterval() 或 requestAnimationFrame() - 稍後會進一步說明) 和音訊硬體排程之間建立協同作業。
超前預測,取得穩固的時間安排
讓我們回到節拍器示範,實際上,我正確編寫了這部簡單的節拍器示範,示範這項合作排程技巧。(程式碼也提供在 GitHub 上) 這個示範會在每個十六分音符、八分音符或四分音符上以高精確度播放嗶聲 (由振盪器產生),並根據節拍改變音高。您也可以在播放時變更節奏和音符間隔,或隨時停止播放,這也是任何實際節奏序列器的重要功能。您也可以輕鬆新增程式碼,隨時變更節拍器使用的音效。
為了在維持精準時機的同時,允許暫時性控制,我們採用了合作模式:setTimeout 計時器會每隔一段時間觸發一次,並在日後為個別音符設定 Web Audio 排程。setTimeout 計時器基本上只會檢查系統是否需要根據目前的節奏,將記事排定成「即將」時間,然後安排如下的執行時間:
實際上,setTimeout() 呼叫可能會延遲,因此排程呼叫的時間可能會隨時間而抖動 (並且偏移,具體取決於您使用 setTimeout 的方式)。雖然本例中的事件間隔約為 50 毫秒,但實際上通常會稍微超過這個時間 (有時甚至會超過很多)。不過,在每次呼叫期間,我們會為所有需要立即播放的音符 (例如第一個音符) 和所有需要在現在和下一個間隔之間播放的音符,排定 Web Audio 事件。
事實上,我們不想只根據 setTimeout() 呼叫之間的間隔精確預測,還需要在這個計時器呼叫和下一個計時器呼叫之間安排一些重疊的排程,以便因應主要執行緒的極端行為,也就是在主要執行緒中發生的垃圾收集、版面配置、算繪或其他程式碼,導致下一個計時器呼叫延遲的極端情況。我們也需要考量音訊區塊排程時間,也就是作業系統在處理緩衝區保留多少音訊,這會因作業系統和硬體而異,從低位數毫秒到 50 毫秒不等。上方顯示的每個 setTimeout() 呼叫都有一個藍色間隔,代表該呼叫嘗試排定事件的整個時間範圍;舉例來說,如果我們等到下一個 setTimeout 呼叫發生時才播放,上方圖表中排定的第四個網路音訊事件可能會「延遲」播放,前提是該 setTimeout 呼叫只晚了幾毫秒。在實際情況中,這些時間的抖動可能會更嚴重,而且隨著應用程式變得更複雜,這類重疊情況就會更加重要。
整體預計延遲時間會影響節拍速度控制 (和其他即時控制) 的緊湊程度;排程呼叫之間的間隔,是權衡最低延遲與程式碼對處理器的影響頻率。預測時間與下一個間隔的開始時間重疊的程度,決定了應用程式在不同機器上的彈性,以及隨著複雜度增加 (版面配置和垃圾收集可能會耗費更多時間) 的彈性。一般來說,為了因應速度較慢的機器和作業系統,最好設定較長的整體預測時間,並設定合理的短時間間隔。您可以調整以減少重疊和較長的間隔,降低處理回呼的次數,但有時您會開始聽到大型延遲時間導致節奏改變等,但可能會無法立即生效;反過來說,如果提前縮減過頭,您可能會開始聽到一些時差 (因為排程呼叫可能必須「化」過去應該發生的事件)。
下列時間圖表顯示節拍器示範程式碼實際執行的動作:它具有 25 毫秒的 setTimeout 間隔,但重疊時間更有彈性:每個呼叫都會排定在接下來的 100 毫秒內執行。這種長時間預測的缺點是,節奏變化等會需要十分之一秒才會生效;不過,我們對中斷情形的耐受力會大幅提升:
事實上,您可以從這個範例看出,我們在過程中發生了 setTimeout 中斷情形,因為我們本應在約 270 毫秒時收到 setTimeout 回呼,但實際上卻延遲了約 320 毫秒,比預期時間晚了 50 毫秒!不過,即使我們在那之前提高節奏速度,以 240 bpm 播放十六分音符 (甚至超過硬核鼓與貝斯節奏的速度),但由於預測延遲時間很長,因此時間安排仍能順利進行,不會錯過任何節拍。
每個排程器呼叫也可能會排定多個音符,讓我們看看如果使用較長的排程間隔 (250 毫秒的預測時間,間隔 200 毫秒),並在中間增加節奏,會發生什麼情況:
這個案例說明每個 setTimeout() 呼叫可能會排定多個音訊事件 - 事實上,這個節拍器是簡單的一次一音應用程式,但您可以輕鬆瞭解這種做法如何用於鼓機 (通常會同時有多個音符) 或音序器 (音符之間可能經常有非規則間隔)。
在實際操作時,我們會建議您調整排程間隔和展望,看看在主要 JavaScript 執行執行緒中對版面配置、垃圾收集和其他情況的影響,並調整控制速度的精細程度。舉例來說,如果版面配置經常發生相當複雜,建議您讓版面配置更大。重點在於我們希望規模夠大,可避免發生延遲的情況,但規模更小,使得調整節拍控制項時容易造成明顯延遲。上述案例的重疊程度非常小,因此在執行速度較慢、具備複雜網頁應用程式的機器上,這項運算能力不大。建議您先從 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;
}
}
這很簡單,但請注意,在這個排程範例中,我並未追蹤「序列時間」,也就是從開始啟動節拍器到現在的時間。我們只需記住上一個音符的播放時間,並找出下一個音符的預定播放時間。如此一來,我們就可以輕而易舉地調整節奏 (或停止播放)。
網路上許多其他音訊應用程式也都採用這項排程技術,例如網路音訊鼓機、有趣的 Acid Defender 遊戲,以及 Granular Effects 示範等更深入的音訊範例。
Yet Another Timing System
現在,正如所有優秀的音樂家,每個音訊應用程式都需要更多的牛鈴,例如更多的計時器。值得一提的是,視覺顯示的正確做法是使用第三方時間系統!
天啊,為什麼我們需要另一個計時系統?這個方法會透過 requestAnimationFrame API 與視覺顯示畫面 (也就是圖形刷新率) 同步。以節拍器例子中的繪圖框來說,這看起來看似不大,但隨著圖像日益複雜,使用 requestAnimationFrame() 配合視覺刷新率就越來越重要。事實上,從一開始就能輕鬆使用 requestAnimationFrame() ,就像使用非常複雜的同步圖像一樣簡單 (例如,在進行同步時,可精確顯示音樂片段、精確顯示音樂片段),
我們會在排程器中追蹤佇列中的節拍:
notesInQueue.push( { note: beatNumber, time: time } );
您可以在 Draw() 方法中找到與 Menome 目前時間的互動情形,只要圖形系統準備好更新,就會呼叫該方法 (使用 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() 的其中一個因素,您仍然希望仍然希望網路音訊時間能夠是實際註記的排程準確度。
結論
希望這份教學課程能幫助您瞭解時鐘、計時器,以及如何在網路音訊應用程式中建立出色的時間安排。您可以輕鬆運用這些技巧,建構序列播放器、鼓機等等。下次再一起玩吧…