簡介
Racer 是一項多玩家、多裝置的 Chrome 實驗。復古風格的軌道賽車遊戲,可在多個螢幕上遊玩。在 Android 或 iOS 手機或平板電腦上。任何人皆可加入。沒有應用程式。沒有已下載的內容。僅限行動版網頁。
Plan8 與 14islands 合作,以喬吉歐莫洛德 (Giorgio Moroder) 的原創曲目為基礎,打造動態音樂和音效體驗。Racer 提供即時引擎聲和賽車音效,但更重要的是,當賽車手加入時,這款應用程式會在多部裝置上播放動態混音音樂。這是由智慧型手機組成的多喇叭安裝裝置。
我們一直在嘗試將多部裝置連結在一起,我們曾進行音樂實驗,在實驗中,聲音會在不同裝置之間分散或跳轉,因此我們很想將這些想法應用在 Racer 上。
具體來說,我們想測試是否能在越來越多人加入遊戲時,透過各裝置建立音樂音軌,從鼓和貝斯開始,然後加入吉他和合成器等樂器。我們製作了一些音樂示範,並深入研究程式碼。多位講者的效果非常棒。當時我們還沒有完成所有同步作業,但當我們聽到各個裝置上播放的音效層次,就知道我們正在進行一項有趣的實驗。
建立音效
Google Creative Lab 已為音效和音樂訂定創意方向。我們希望使用模擬合成器來製作音效,而不是錄製真實的聲音或使用音效庫。我們也知道,在大多數情況下,輸出喇叭會是小型手機或平板電腦喇叭,因此必須限制音訊的頻譜,以免喇叭產生失真。這項挑戰相當艱鉅。收到 Giorgio 提供的第一份音樂草稿時,我們鬆了一口氣,因為他的旋律與我們創造的音效完美搭配。
引擎聲音
程式設計音效時,最大的挑戰就是找出最適合的引擎音效,並塑造其行為。賽道類似 F1 或 Nascar 賽道,因此車輛必須給人速度快、爆發力的感覺。同時,車輛非常小,因此大引擎的聲音無法與視覺效果相符。我們無法在行動音箱中播放強烈的引擎聲,因此必須想出其他方法。
為了尋找靈感,我們連接了好友 Jon Ekstrand 的模組合成器收藏,並開始嘗試各種組合。我們很高興聽到你的回饋。以下是使用兩個振盪器、一些不錯的濾波器和 LFO 的音效。
我們曾使用 Web Audio API 成功重建類比裝置,因此對 Web Audio 抱有極大期望,並開始在 Web Audio 中建立簡單的合成器。產生的音效反應最快,但會耗用裝置的處理效能。我們必須極力精簡,盡可能節省資源,讓視覺效果順利運作。因此,我們改用播放音訊樣本的技術。

您可以使用多種技巧,將引擎聲音從樣本中取樣。對於家用遊戲機遊戲,最常見的方法是在引擎的不同 RPM (含負載) 中,加入多層的多種聲音 (越多越好),然後在這些聲音之間進行交錯淡出和交錯音高。接著,在相同的 RPM 下,新增一層多重音效,讓引擎以無負載的方式運轉,並在兩者之間進行交疊和交叉調整。在換檔時,如果這些層之間的轉場效果處理得當,聽起來會非常逼真,但前提是您必須有大量的音訊檔案。跨音程處理的音高差異不能太大,否則聽起來會很不自然。由於我們必須避免長時間載入,因此這個選項對我們來說並不理想。我們嘗試為每個圖層使用五或六個音訊檔案,但效果不如預期。我們必須想辦法減少檔案數量。
最有效的解決方案如下:
- 一個含有加速和換檔聲音檔案的檔案,與車輛的視覺加速效果同步,結尾為最高音調 / 轉速的程式設計迴圈。Web Audio API 非常擅長精確重複播放,因此我們可以執行這項操作,而不會發生錯誤或彈跳。
- 一個含減速 / 引擎轉速降低的音訊檔案。
- 最後一個音效檔案會循環播放靜止 / 閒置音效。
如下所示

針對第一個觸控事件 / 加速度,我們會從頭播放第一個檔案,如果玩家放開油門,我們會計算音效檔案在放開油門時的時間,這樣當油門再次開啟時,系統就會在播放第二個檔案 (減速) 後,跳到加速度檔案中的正確位置。
function throttleOn(throttle) {
//Calculate the start position depending
//on the current amount of throttle.
//By multiplying throttle we get a start position
//between 0 and 3 seconds.
var startPosition = throttle * 3;
var audio = context.createBufferSource();
audio.buffer = loadedBuffers["accelerate_and_loop"];
//Sets the loop positions for the buffer source.
audio.loopStart = 5;
audio.loopEnd = 9;
//Starts the buffer source at the current time
//with the calculated offset.
audio.start(context.currentTime, startPosition);
}
立即試用
啟動引擎並按下「油門」按鈕。
<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>
因此,我們決定只使用三個小型音訊檔案和音效良好的引擎,繼續進行下一個挑戰。
取得同步處理
我們與 14islands 的 David Lindkvist 合作,開始深入研究如何讓裝置完美同步播放。基本理論很簡單。裝置會向伺服器詢問時間,並考量網路延遲時間,然後計算本機時鐘偏移。
syncOffset = localTime - serverTime - networkLatency
有了這個偏移值,每部已連結的裝置都會共用相同的時間概念。就是這麼簡單!(再次強調,這只是理論上的說法)。
計算網路延遲時間
我們可以假設延遲時間是從伺服器要求及收到回應所需時間的一半:
networkLatency = (receivedTime - sentTime) × 0.5
這項假設的問題是,與伺服器的來回通訊不一定對稱,也就是說,要求可能比回應耗時更久,反之亦然。網路延遲時間越長,這種不對稱性就會造成越大的影響,導致聲音延遲,並與其他裝置不同步。
幸好我們的大腦會自動忽略稍微的聲音延遲。研究顯示,大腦需要 20 到 30 毫秒 (ms) 的延遲時間,才能將聲音分辨為不同的聲音。不過,在 12 到 15 毫秒左右,即使您無法完全「感知」延遲訊號的影響,但仍會開始「感覺」到延遲訊號的影響。我們研究了幾種已建立的時間同步通訊協定,以及更簡單的替代方案,並嘗試在實際情況中實作其中一些。最後,我們得益於 Google 低延遲基礎架構,只需對大量要求進行取樣,並使用延遲時間最短的樣本做為參考。
對抗時鐘漂移
可以正常運作!我們有 5 部以上的裝置完美同步播放脈衝訊號,但只有短暫時間。即使我們使用 Web Audio API 的高度精確時脈上下文時間安排音訊,裝置在播放幾分鐘後仍會出現時間差異。延遲時間累積緩慢,每次只會延遲幾毫秒,一開始無法察覺,但在播放一段較長時間後,音樂層會完全不同步。你好,時鐘誤差。
解決方案是每隔幾秒重新同步處理,計算新的時鐘偏移,並將這項資訊無縫地提供給音訊排程器。為降低網路延遲導致音樂發生明顯變化的風險,我們決定保留最新同步偏移記錄並計算平均值,以便平滑處理變化。
安排歌曲和切換曲目
製作互動式音效體驗,就表示您無法再控制歌曲的播放時間,因為您必須依賴使用者操作來變更目前狀態。我們必須確保能及時在歌曲中切換編曲,也就是說,排程器必須能夠計算目前播放列的剩餘時間,然後再切換至下一個編曲。我們的演算法最終會如下所示:
Client(1)
會啟動歌曲。Client(n)
會詢問第一個用戶端歌曲開始播放的時間。Client(n)
會計算歌曲開始播放時的參考點,並考量 syncOffset 和音訊內容建立後經過的時間。playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
Client(n)
會使用 playDelta 計算歌曲播放的時間長度。歌曲排程器會使用這個值,瞭解目前編排中應播放哪一小節。playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars
為了讓安排內容更合理,我們限制安排內容一律為八小節長度,並且具有相同的節奏 (每分鐘節拍數)。
展望未來
在 JavaScript 中使用 setTimeout
或 setInterval
時,請務必提前排定。這是因為 JavaScript 時鐘不夠精確,而排定的回呼很容易因為版面配置、算繪、垃圾收集和 XMLHTTPRequest 而偏移數十毫秒或更久。在我們的案例中,我們也必須考量所有用戶端透過網路接收相同事件所需的時間。
音訊精靈
將音訊合併為一個檔案,是減少 HTTP 要求的絕佳方法,無論是 HTML Audio 還是 Web Audio API 皆是如此。這也是使用 Audio 物件回應式播放音效的最佳方式,因為它不需要在播放前載入新的音訊物件。我們已使用一些優質的實作項目做為起點。我們已擴充精靈板,讓精靈板可在 iOS 和 Android 上穩定運作,並處理裝置進入休眠狀態的特殊情況。
在 Android 裝置上,即使裝置進入休眠模式,音訊元素仍會持續播放。在睡眠模式下,JavaScript 執行作業會受到限制,以便節省電池電量,因此您無法使用 requestAnimationFrame
、setInterval
或 setTimeout
觸發回呼。這是個問題,因為音訊精靈會依賴 JavaScript 持續檢查是否應停止播放。更糟的是,在某些情況下,即使音訊仍在播放,Audio 元素的 currentTime
也不會更新。
請查看我們在 Chrome Racer 中用於非網路音訊備用方案的 AudioSprite 實作。
音訊元素
我們開始開發 Racer 時,Android 版 Chrome 尚未支援 Web Audio API。在某些裝置上使用 HTML Audio,在其他裝置上使用 Web Audio API 的邏輯,加上我們希望實現的進階音訊輸出,這些都造成了一些有趣的挑戰。好消息是,現在已不再如此。Web Audio API 已在 Android M28 Beta 版中實作。
- 延遲/時間問題。音訊元素不一定會在您指定的時間播放。由於 JavaScript 是單執行緒,瀏覽器可能會忙碌,導致播放延遲時間最多達兩秒。
- 播放延遲意味著不一定能順利循環播放。在電腦上,您可以使用雙緩衝功能,以便實現無縫循環,但在行動裝置上,這並非可行選項,原因如下:
- 大多數行動裝置一次只能播放一個音訊元素。
- 固定音量。Android 和 iOS 都不允許您變更 Audio 物件的音量。
- 不使用預先載入模式。在行動裝置上,除非在
touchStart
處理常式中啟動播放作業,否則 Audio 元素不會開始載入來源。 - 尋找問題。除非伺服器支援 HTTP 位元組範圍,否則無法取得
duration
或設定currentTime
。如果您要建立像我們一樣的音訊精靈,請留意這一點。 - MP3 上的 Basic Auth 失敗。無論您使用哪種瀏覽器,某些裝置無法載入受基本驗證保護的 MP3 檔案。
結論
從按下靜音按鈕開始,我們已走過漫長的路,這也是處理網頁音訊的最佳做法,但這只是開始,網頁音訊即將大放異彩。我們目前只介紹了部分 Pixel 7 手機效能特點,手機和平板電腦的處理效能不足以處理信號處理和特效 (例如迴音),但隨著裝置效能提升,網路遊戲也能利用這些功能。這段時間充滿了無限可能,我們將持續探索音訊的可能性。