這套網頁取樣機的編曲引擎把時鐘放在音訊端,聲音依音訊時間排程,畫面另外跟上。以下依序說明資料模型、主時鐘、聲音與畫面的分工、lookAhead 的取捨、播放中換段與 groove 的時間換算。
輸出成 MP3 屬於同一系列的另一個主題,離線算繪的做法見瀏覽器離線算繪與 MP3 輸出。
JavaScript 計時器不直接觸發聲音
setTimeout、setInterval 與 requestAnimationFrame 都排在瀏覽器主執行緒上,頁面忙於繪製或計算時,回呼會晚到,節奏就跟著歪。Chris Wilson 在 A tale of two clocks 中建議,讓 JavaScript 計時器與音訊硬體排程合作:計時器定期醒來,把接下來要發的聲音提前排進音訊時鐘。
Web Audio 的排程時間都相對於 AudioContext 的 currentTime,`start(when)` 可以讓聲音在確切時間播放,見 W3C Web Audio API。Tone.js 的 Transport 在這個基礎上提供音樂時間,包含 BPM、小節與 tick,tick 的基準是每個 4 分音符 192 ticks(Tone.js Time)。

編曲資料模型
取樣機的一份卡帶,編曲部分由下列欄位組成。
- 4 個序列(SEQ 1 至 4),每個序列各有 12 個音色格。
- 每個音色格有 32 個布林值,每個值代表一步,亮起表示該步發聲。
- 一步是一個 8 分音符,每小節 8 步,32 步等於 4 小節。
- 每個序列的 loop 長度為 1 到 4 小節,存成 "N:0:0" 字串;引擎取小節數乘以 8 得到步數。
- BPM 預設 120,可調範圍 30 到 300。
每個音色格另外帶音量、反轉、音高、濾波、swing 與 groove 參數,以及取樣的起訖秒數。這些參數在觸發時才讀取,不影響時鐘本身。
單一主時鐘帶動全部音色
主時鐘是一個 `Tone.Loop`,間隔 12 ticks,由 `Tone.Transport` 驅動。PPQ 為 192,8 分音符是 4 分音符的一半,所以每 8 次回呼前進一個 8 分音符。120 BPM 時,一次回呼約 31.25 ms。
回呼裡維護一個 `currentStep`,每次加 1,對「序列長度乘以 8」取模,得到目前落在序列的第幾步。12 個音色格共用這一個計數,在回呼內逐格檢查該步是否亮起,亮起的就以回呼傳入的 `time` 觸發。
單一時鐘有三個好處。各音色格的相對位置只由步數決定,不會因為各自計時而漂移。改 BPM 只需要改 Transport 一處。長度不同的序列在同一個計數上各自取模,換段與 loop 長度的處理都能集中在回呼內。
聲音與畫面分開排程
Loop 回呼收到的 `time` 是音訊時間,位於聲音實際發出之前。聲音直接用這個 `time` 呼叫 `start`,由音訊端在確切時間發聲。
畫面不能照同一個時間點立刻改。回呼觸發時聲音還沒有出來,此刻改步進燈,燈會比聲音早亮。步進燈與進度條改用 `Tone.Draw.schedule(fn, time)`,延到該時間點附近、畫面準備重繪時才執行,React 狀態在那時才改。requestAnimationFrame 的回呼本來就在下一次重繪之前執行(MDN),畫面更新與重繪因此落在同一個節拍。
步進燈每次回呼都排一次,位置是 8 分音符步數對序列長度取模。進度條只在計數為偶數時更新,降低更新頻率。換段時的畫面狀態也走同一條 Draw 路徑。
依引擎設計整理。時間點以 Loop 回呼傳入的 time 為準。
lookAhead 的取捨
Tone.js 的 lookAhead 決定排程比發聲提早多少。官方的 Performance 說明寫明,這讓排程器有時間在聲音被聽到之前完成排程,代價是延遲;要降低延遲,可以把 lookAhead 調小或設為 0。
編曲引擎在每次初始化卡帶時把 lookAhead 設為 0.15 秒,目的是避免音訊斷續。回呼因主執行緒忙碌而晚到時,仍有 0.15 秒的餘裕可以在聲音發出前完成排程。
按鍵試聽沒有排程的問題,在意的是按下去到出聲的延遲。每個音色格因此有兩組獨立的 `Tone.Player`:預覽組不經音高與濾波,起始時間取 `Tone.immediate() + lookAhead / 8`,約 18.75 ms,只留一點餘裕避免爆音;編曲組有音高與濾波,走排程。兩組可以同時發聲,也能只停其中一組。
- 音色格要發聲每格有預覽與編曲兩組 Player
- 使用者按下音色格預覽組約 18.75 ms 後發聲,不經音高與濾波
- Loop 回呼排程編曲組使用回呼的 time,lookAhead 0.15 秒,經音高與濾波
數值為程式設定值,非實測。

播放中換段在 loop 起點生效
播放中按下別的序列,引擎只記下待切換的序列,同步更新畫面上的選取狀態,聲音不動。回呼走到 `currentStep` 為 0 且有待切換序列時,才切換並清除記錄。
這個 0 是目前序列的 loop 起點,不是每一小節。序列長 4 小節時,要等整段 4 小節播完才換。目前的樂句因此不會在中途被切斷,下一個序列從頭接上。停止狀態下按序列則直接切換。
groove 換算成時間偏移
每個音色格可以選一組 groove。groove 資料對每一步各有一個 offset,單位是 8 分音符的比例,負值提早、正值延後。每格另有 swing 設定。
觸發時間等於排程時間,加上「該步 offset 乘以該格的 groove 強度,再加 swing 乘以 0.5」,整個結果再乘以一個 8 分音符的秒長。8 分音符的秒長是 60 除以 BPM 再除以 2。偏移量依 BPM 換算成秒,所以速度改變時,groove 與 swing 的感覺跟著縮放。偏移加在傳給 Tone 的 `time` 上,仍在音訊時鐘內完成,不另起計時器。
不需要這套時鐘的情況
只播放單一音效或介面提示音,例如按鈕點擊,在操作當下直接發聲即可,不需要時鐘與提前排程。需要把麥克風輸入即時監聽並回放的場景,lookAhead 的延遲屬於額外負擔,要另外評估。
延伸閱讀與後續主題
官方教學影片可以對照本文的序列與換段:1-5 建立基礎循環段落示範基礎循環,2-3 段落設計示範多段落的安排。取樣機的整體做法見北流雲取樣機案例。
網頁音訊還包含取樣的載入與快取、每格音色的處理鏈與輸出檔案的離線算繪,輸出部分見瀏覽器離線算繪與 MP3 輸出。規劃網頁音訊、取樣機或編曲工具時,可參考網頁音訊與取樣機開發,也可以與開發團隊聯繫討論範圍。




