wololoEN

一個音檔都沒有:用程式合成遊戲音效與音樂

墨圈、迷因大亂鬥和符文堡壘沒有任何音檔,音效和音樂全用 Web Audio API 即時合成。這篇拆解做法與取捨。

墨圈、迷因大亂鬥和符文堡壘的資料夾裡,找不到任何一個 mp3 或 wav。每一聲鐘響、每一拳、每一下打字聲和每一首背景音樂,都是瀏覽器在當下用 Web Audio API 算出來的。整套聲音各自只占一個 audio.js,原始碼約 5 KB、15 KB 和 5.5 KB。這篇拆開來看它們怎麼做,以及這樣做的好處和代價。

先等玩家動一下

瀏覽器的自動播放政策規定,使用者跟網頁互動之前,頁面不能自己出聲。所以三款遊戲都等第一次輸入才建立 AudioContext(所有 Web Audio 聲音的總機),並用 window.AudioContext || window.webkitAudioContext 照顧舊版 Safari;已經建好但狀態是 suspended 的話,就呼叫 resume()。

墨圈在第一次 pointerdown、開局和切換靜音時呼叫 Sfx.unlock();符文堡壘在打字、點螢幕鍵盤、按選單和開局時呼叫 Snd.init()。迷因大亂鬥每次按鍵或觸控都呼叫 Sound.init();開機就要求的標題曲先記在 pending,context 建好才播,之前標題畫面會閃著「按任意鍵開啟音效」。

接著各自接上混音線路。墨圈只有一個 0.8 的主音量;符文堡壘把音效和音樂分成兩條匯流排,匯進 0.55 的總音量。迷因大亂鬥的音效匯流排 0.75、音樂匯流排 0.3,進到 0.6 的主音量後再經過動態壓縮器(threshold −14、ratio 4),音量一超過門檻就往下壓。

積木:振盪器、包絡、噪音和濾波器

三款用的零件幾乎一樣。

  • 振盪器(oscillator):用指定頻率重複同一個波形的聲源。內建正弦波(sine)、三角波(triangle)、方波(square)和鋸齒波(sawtooth),聽起來由圓潤到刺耳。迷因大亂鬥另外用 createPeriodicWave 做了一個 25% 脈衝波,註解寫著「for that NES lead sound」。
  • 包絡(envelope):聲音從出現到消失的音量曲線。三款的振盪器都從 0.0001 開始,預設在 4 到 6 毫秒內指數升到目標音量,再指數衰減回 0.0001。不用 0,是因為指數曲線無法從 0 出發,也無法剛好降到 0。
  • 滑音:對頻率做同樣的指數斜坡。正弦波從 170 Hz 滑到 60 Hz,「嗶」就變成「咚」。
  • 白噪音:每個取樣點都是亂數,像收音機沒對到台的沙沙聲。三款啟動時都用 Math.random() * 2 - 1 填滿一秒長的緩衝區,之後重複使用;墨圈和迷因大亂鬥每次從前 0.5 秒隨機挑起點,連續兩下不會完全一樣。
  • 濾波器(filter):只讓部分頻率通過。低通(lowpass)留下低頻,噪音變成悶悶的「噗」;高通(highpass)留下高頻,變成「嘶」或「喀」;帶通(bandpass)只留一段,像風聲。濾波頻率也能滑動。
  • 增益(gain):音量旋鈕,匯流排也是用它做的。

除了墨圈一條持續循環的噪音,每個聲音都現做現用,排好 start() 和 stop() 的時間就不管了。下面是 runekeep/js/audio.js 的包絡函式,另外兩款大同小異:

function env(g, t0, dur, vol) {
  g.gain.setValueAtTime(0.0001, t0);
  g.gain.exponentialRampToValueAtTime(vol, t0 + 0.006);
  g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
}

墨圈:宣紙、鐘聲和印章

墨圈是毛筆在宣紙上畫圈的遊戲,聲音也很安靜:幾乎只用正弦波和三角波,沒有鼓,也沒有背景音樂。

吃到墨點時響一聲 0.07 秒的三角波,頻率是 700 Hz 乘上 2 的「combo ÷ 12」次方。0.6 秒內接著吃就算連擊,每次升一個半音,最多升 14 個。兩聲至少隔 0.035 秒,免得吃成一陣嗡嗡聲。

閉合一個圈時響的是一口小寺廟鐘,出自 inkloop/js/audio.js:

loop(value) {
  // A small temple bell; louder and lower for a bigger haul.
  const base = PENTA[Math.floor(Math.random() * 3)] / (value > 40 ? 2 : 1);
  const vol = clamp(0.07 + Math.log2(1 + value) * 0.025, 0.07, 0.24);
  tone(base, 1.6, { vol });
  tone(base * 2.01, 0.9, { vol: vol * 0.35 });
  tone(base * 3.02, 0.5, { vol: vol * 0.18 });
  tone(base * 4.17, 0.3, { vol: vol * 0.1 });
},

四個正弦波的倍率是 1、2.01、3.02、4.17,越高的越小聲,也越早消失。倍率刻意不是整數,這種不整齊的泛音正是鐘聲的來源。基音從五聲音階 PENTA 的前三個音(523.25、587.33、659.25 Hz)隨機挑,註解說這樣重疊的鐘聲才不會打架。圈到的墨越多,鐘聲越大(取對數,上限 0.24),超過 40 還會低八度。

加速時,一段持續循環、中心 1100 Hz 的帶通噪音會從音量 0 滑到 0.08,註解說這是毛筆刷過紙面的聲音。封印對手時,先是 420 Hz 的低通噪音加上從 90 Hz 掉到 45 Hz 的悶響,代表印章壓上紙;0.05 秒後再接一面以 110 Hz 為底、最長響 2.4 秒的鑼。

迷因大亂鬥:一支晶片樂團

迷因大亂鬥是像素風的清版格鬥,聲音走紅白機路線。SFX 表裡有 50 個音效,大多一行就寫完,以下從 meme-brawl/js/audio.js 節錄三行:

punch(t) { noise(t, 0.07, 0.5, 'lowpass', 1400); osc('sine', 170, 60, t, 0.09, 0.6); },
clack(t) { for (let i = 0; i < 4; i++) noise(t + i * 0.025, 0.02, 0.35, 'highpass', 4000); },
coin(t) { osc('pulse', midi(83), 0, t, 0.06, 0.16); osc('pulse', midi(88), 0, t + 0.06, 0.16, 0.16); },

一拳是 0.07 秒的低通噪音加上從 170 Hz 掉到 60 Hz 的正弦波,前者是碰撞的「啪」,後者是有重量的「咚」。機械鍵盤這把武器的 clack,是四個 20 毫秒的高通噪音,每 25 毫秒一下。撿到加分道具的 coin 是兩個脈衝波音符,MIDI 83 接 88,也就是 B5 跳到 E6。

用文字寫的音序器

音樂是每小節 16 步的步進音序器,歌直接寫成字串:d5、f#3 是音符,- 延長上一個音,. 是休止;鼓軌用 k(大鼓)、s(小鼓)、h(腳踏鈸)、o(開放的腳踏鈸)、c(碎音鈸)組合。標題、五個關卡、頭目戰和片尾共八首,112 到 156 BPM,譜大約 130 行。聲部有脈衝波主旋律、三角波低音,以及用噪音和正弦波拼成的鼓,例如大鼓是 0.14 秒內從 150 Hz 掉到 40 Hz 的正弦波。

難的是時間。JavaScript 計時器會飄,直接在 setInterval 裡發聲,節奏就不穩。所以計時器每 25 毫秒醒來一次,把接下來 0.12 秒內該響的音照音訊時鐘 ac.currentTime 先排好:

function tick() {
  if (!ac || !song) return;
  const spb = 60 / song.bpm / 4;
  while (nextT < ac.currentTime + 0.12) {
    const l = song.L[step % song.L.length];
    if (l) osc('pulse', midi(l.m), 0, nextT, l.len * spb * 0.92, 0.11, musBus, 0.006);
    const b = song.B[step % song.B.length];
    if (b) osc('triangle', midi(b.m), 0, nextT, b.len * spb * 0.9, 0.34, musBus, 0.005);
    const d = song.D[step % song.D.length];
    if (d && d !== '.') for (const ch of d) drum(ch, nextT);
    nextT += spb;
    step++;
  }
}

spb 是一個十六分音符的長度。遊戲暫停時,音樂匯流排會從 0.3 降到 0.1。

符文堡壘:打字聲和自己長出來的配樂

符文堡壘是打字遊戲,最常響的就是按鍵聲。每打對一個字母,key(combo) 發出一聲 45 毫秒的方波,從 620 Hz 滑到 880 Hz,音量只有 0.045。連擊每滿 6 下,音高就沿著 SCALE(0、2、3、5、7、8、10、12…個半音,也就是自然小調)往上爬一階,最高到 19 個半音。打錯時連擊歸零、音高回到最低,同時響一聲從 150 Hz 滑到 92 Hz 的鋸齒波低鳴。

按空白鍵放的流星,是帶通噪音在 0.9 秒內從 3200 Hz 掃到 140 Hz,底下墊著從 320 Hz 降到 60 Hz 的鋸齒波,由高到低,就像東西從天而降。龍出場則是 1.5 秒、從 58 Hz 降到 38 Hz 的鋸齒波,加上一段低頻噪音。

配樂沒有寫譜,每首只存速度和四個和弦:calm 是 88 BPM 的 Am、F、C、G,boss 是 126 BPM 的 Dm、B♭、C、A。每個和弦一小節,每個八分音符依序彈根音、三音、五音,再高八度彈一次,用的是比按鍵聲小很多的方波(0.016,頭目戰 0.022);三角波低音每 8 步(頭目戰每 4 步)彈一次低八度的根音。

排程跟迷因大亂鬥同一個概念,計時器 50 毫秒一次、往前看 0.25 秒,還多一道保險:分頁切到背景、計時器停擺後,若排程落後超過 0.1 秒就直接跳到現在,不一口氣補放一串遲到的音。

靜音設定怎麼存

三款都把設定存在 localStorage,並包在 try/catch 裡,因為無痕視窗或封鎖儲存時,它可能不存在或丟錯。

  • 墨圈:按鈕或 M 鍵切換,存成 inkloop.muted,值是 JSON 的 true 或 false。主音量用 0.02 秒的時間常數滑到 0,而不是瞬間切斷。
  • 迷因大亂鬥:M 鍵或選單切換,存成 memebrawl.mute,值是 '1' 或 '0'。靜音時 play() 直接不產生音效。
  • 符文堡壘:音樂和音效各一個開關,一起存成 runekeep.sound,例如 {"sfx":true,"music":true},分別把兩條匯流排設成 0 或 1。

三種畫風,三種聲音

三款的聲音各自跟著畫面走。墨圈的水墨宣紙配正弦波的鐘和鑼、五聲音階和紙面沙沙聲,唯一的方波是切斷筆畫的 cut,音量只有 0.03。迷因大亂鬥的像素格鬥配脈衝波、方波、鋸齒波和整組鼓,連 bruh、sus、laugh 這種梗都有專屬音效。符文堡壘的像素奇幻用短促的方波當按鍵聲,背景是安靜的琶音,龍和流星才搬出低沉的鋸齒波加噪音。

好處與代價

好處很直接。沒有音檔就沒有下載和載入,context 一建好就能出聲;三款的音訊程式加起來約 26 KB 原始碼,還包了十首歌。改聲音就是改數字,而且聲音能直接讀遊戲狀態:連擊決定音高、圈到的墨決定音量和八度、鐘聲的基音隨機挑、噪音從隨機位置開始,同一個音效每次都有點不同。

代價也很實在。每個聲音都是一串靠耳朵調出來的數字,沒有素材庫可挑。音色也有天花板:嗶、咚、嘶和鐘聲做得出來,人聲和真實樂器就難了。迷因大亂鬥的 laugh 是五個往下滑的方波音,roar 是 110 Hz 滑到 60 Hz 的鋸齒波加低通噪音,只能說有那個意思。另外還有用音檔時不必操心的工程:等輸入才能解鎖、自己寫往前看的排程器、處理背景分頁停擺,以及像墨圈那樣幫連續觸發的音效限速。

想自己試試的話,最小的起點就是一個振盪器、一個增益節點,加上前面那三行包絡。

設計筆記