個案研究 - The Sounds of Racer

簡介

Racer 是一項多人對戰遊戲的 Chrome 實驗功能。一種復古風格的拉霸機遊戲,在各款裝置上進行。手機或平板電腦:Android 或 iOS。任何人都能加入。沒有任何應用程式。沒有已下載的內容。僅透過行動網路。

Plan814islands 的好友一起在 Giorgio Moroder 中根據原創作品打造出精彩動人的音樂和音效體驗。競速選手的特色是反應靈敏的引擎音效、賽車音效,更重要的是,在賽車活動中,利用多種裝置發行的動態音樂合輯。這是一款由智慧型手機組成的多功能揚聲器。

我們一直沒辦法連結多部裝置,我們先前進行過音樂實驗,聲音會分散在不同裝置上,或是在不同裝置間跳動,因此我們迫不及待想將這些想法應用到競速選手。

更具體來說,我們想測試是否有越來越多人加入遊戲 (先從打鼓和低音提琴開始,然後再加入吉他和合成器等等),看看能否在各種裝置上建立音樂曲目。我們試過一些音樂示範,深入了寫程式,多喇叭特效讓我們受益良多。雖然目前我們沒有同步完成所有同步,但是當我們聽到聲音層次傳至其他裝置時,我們知道我們很有用。

創造音效

Google 創意研究室計畫提出了音效和音樂的創作方向,我們想使用類比合成器來製作音效,而不是實際錄音,或在音效庫使用。我們也知道輸出揚聲器在大多數情況下都是小型手機或平板電腦的喇叭,因此必須限制音量大小,以免喇叭變形。事實證明這是一項艱鉅的挑戰。我們收到 Giorgio 首批音樂草稿時令人感到放鬆,因為他的樂曲完美搭配我們所建立的音效。

引擎音效

程式設計時,最大的挑戰在於如何找出最佳的引擎音效,並對其行為造成影響。賽道類似 F1 或 Nascar 跑道,因此車輛必須具有快速且爆炸的體驗。同時,車輛的體積很小,所以聲音無法與視覺效果真正連結。但我們根本沒辦法在行動揚聲器上播放牛頭骨號,因此我們得想辦法找出其他東西。

為了尋找靈感,我們成功召集了幾位好友 Jon Ekstrand 的模組化合成資料,並開始散播一番。我們喜歡聽到的內容。這是帶有兩個電扶手、一些不錯的濾鏡和 LFO 的音色。

類比裝備使用 Web Audio API 重新設計,獲得良好成效,因此擁有很高的期望,並開始在網路音訊中建立簡單的合成。產生的聲音是回應最靈敏的一種,但會影響裝置的處理能力。我們必須極力儲存所有可用資源,才能確保視覺效果順暢無礙。因此,我們改為播放音訊樣本。

組合引擎音效靈感的模組合成器

您可以運用數種技術,從樣本中發出引擎聲音。對主機遊戲來說,最常見的做法是以不同 RPM (載入時) 加入多組聲音 (越好越好),然後交互淡出和交叉混音。接著,以同樣的 RPM 、交叉漸變和交錯效果,將引擎的聲音添加至多層 (無載入)。如果能輕鬆切換,則在兩個層間交叉漸變,聽起來相當真實,但前提是所有聲音檔案都相當龐大。交叉口音的長度不能太寬,或是聽起來非常合成。我們得避免長時間的載入時間,因此對我們而言並非大有助益。我們為每個圖層嘗試使用 5 或 6 個音效檔案,但聲音很失望。我們必須設法減少檔案數量。

事實證明,最有效的解決方案是:

  • 其中一個音訊檔含有加速和裝備轉換,與汽車的視覺加速度同步,並在最高音調 / 每千次觀看循環時形成固定的循環播放。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()'>

所以,我們只有 3 個小型音效檔案和一個出色引擎,所以我們決定進入下一個挑戰。

取得同步處理

我們與 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 中使用 setTimeoutsetInterval 時,請務必事先排定時間。這是因為 JavaScript 時鐘並不精確,排程回呼很容易因版面配置、呈現、垃圾收集和 XMLHTTPRequests 等數十毫秒或更長時間的偏差。在本範例中,我們也必須考量所有用戶端透過網路接收相同事件所需的時間。

音訊精靈

無論是針對 HTML 音訊或 Web Audio API,將音效合併成一個檔案是減少 HTTP 要求的絕佳方法。這也是使用 Audio 物件回應播放音訊的最佳方式,因為音訊物件無須在播放前載入新的音訊物件。一些良好的導入做法可做為起點,我們已經擴大需求,能夠在 iOS 和 Android 裝置上穩定運作,並處理一些裝置入睡的奇怪情況。

在 Android 上,即使裝置進入休眠模式,音訊元素仍會繼續播放。在睡眠模式下,JavaScript 執行作業會受到限制以節省電池電力,而且您無法仰賴 requestAnimationFramesetIntervalsetTimeout 來觸發回呼。這會造成問題,因為音訊精靈依賴 JavaScript 持續檢查是否應停止播放。更糟的是,在某些情況下,音訊元素的 currentTime 在音訊仍在播放的情況下並不會更新。

查看我們在 Chrome Racer 中用於非網路音訊備用廣告的 AudioSprite 實作項目

音訊元素

在我們開始開發 Racer 的過程中,Chrome for Android 尚未支援 Web Audio API。在某些裝置上使用 HTML 音訊的邏輯,其他裝置的 Web Audio API,加上我們想要針對一些有趣的挑戰而實現的進階音訊輸出。幸好,現在這些都是歷史。Web Audio API 已在 Android M28 Beta 版中實作。

  • 延遲/時間問題。音訊元素在你要求播放時,不一定會完全播放。由於 JavaScript 是單一執行緒,瀏覽器可能很忙碌,因此最多會有兩秒的播放延遲。
  • 播放延遲意味著不一定能流暢地循環播放。您可以在電腦上使用雙緩衝處理功能,進行一些不間斷的迴圈,但在行動裝置上並不適用,原因如下:
    • 大部分行動裝置一次只能播放一個音訊元素。
    • 固定音量。無論是 Android 或 iOS,都不允許您變更「音訊」物件的音量。
  • 沒有預先載入。在行動裝置上,除非透過 touchStart 處理常式啟動播放,否則音訊元素不會開始載入音訊元素。
  • 尋求問題。除非伺服器支援 HTTP Byte-Range,否則無法取得 duration 或設定 currentTime。如果你正像我們一樣打造音訊精靈,請特別留意這點。
  • MP3 基本驗證失敗。部分裝置無論使用哪種瀏覽器,都無法載入受基本驗證保護的 MP3 檔案

結論

自從點按靜音按鈕是處理網路音效的最佳選項,我們都有了長足進步。不過,這只是開頭,網路音訊也要大飽耳福。我們剛才提到了在同步多部裝置時能夠執行哪些操作。我們沒有手機和平板電腦的處理能力,無法進一步分析訊號處理和影響 (例如回響),但隨著裝置效能提升,網頁式遊戲也會利用這些功能。這些充滿期待的時光,能持續提升音色的可能性。