簡介:Hotbox Prototype是一款基於Web技術的音頻鼓機應用,集成原型UI設計,為音樂製作人提供在線創作鼓節奏的交互工具。該應用以JavaScript為核心,結合HTML和CSS構建動態網頁結構,並利用Web Audio API實現音頻播放、混音與效果處理。通過事件監聽、音頻樣本加載、實時編輯與本地保存等功能,支持用户自定義鼓組模式。項目採用響應式佈局適配多設備,並引入動畫增強視覺反饋,展現Web平台在音樂創作中的強大潛力。

Web音頻驅動的鼓機引擎:從零構建Hotbox交互式節奏系統

你有沒有試過在瀏覽器裏敲出一段完整的鼓點?不是下載軟件,也不是打開App——就是簡簡單單地點擊幾下網頁上的按鈕,然後“咚、噠、嚓”地響起來。聽起來像是魔法?其實這背後是一整套精密運轉的Web音頻技術體系。

現代瀏覽器早已不只是看網頁的地方了。它們現在可以成為 真正的音樂工作站 ,而這一切的核心,就是Web Audio API。今天我們要做的,不是簡單播放一個音頻文件,而是深入到聲音生成的底層,用代碼親手“造”一台能打節奏的鼓機——就叫它 Hotbox 吧。

我們不會只停留在“點一下響一聲”的層面。我們要解決真實項目中的關鍵問題:如何讓聲音不卡頓?怎麼避免連續點擊時炸耳的重疊噪音?怎樣實現電子鼓那種標誌性的“音高滑落”效果?又該如何把複雜的音頻處理流程組織得井井有條?

別擔心,即使你是第一次接觸Web音頻開發,也能跟上。我們會像搭積木一樣,一塊一塊地構建這個系統。先從最基礎的聲音開始,再到採樣回放、混音總線、視覺反饋……最終你會看到,一個原本靜態的網頁,是如何被賦予節奏與生命,變成一台真正可玩的鼓機。

準備好了嗎?讓我們從按下第一個鍵開始。

音頻上下文:一切聲音的起點

在瀏覽器裏發出任何聲音之前,必須先喚醒一個叫 AudioContext 的東西。你可以把它想象成一個看不見的“聲音工作室”,所有後續的操作——無論是合成波形還是播放採樣——都得在這個工作室裏進行。

但這裏有個坑:出於用户體驗考慮,幾乎所有現代瀏覽器都規定, 必須由用户主動操作(比如點擊)才能啓動音頻 。這意味着你不能一打開頁面就自動播放背景音樂,否則會淪為廣告彈窗般的存在。

所以我們的第一步,是安全地初始化這個上下文:

let audioCtx;

const initAudio = () => {
  if (!audioCtx) {
    audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  } else if (audioCtx.state === 'suspended') {
    audioCtx.resume(); // 用户再次交互時恢復
  }
};

// 只監聽一次點擊事件來激活音頻
document.addEventListener('click', initAudio, { once: true });

這段代碼看似簡單,實則覆蓋了三種狀態:
- 首次加載 :創建全新的 AudioContext
- 已被掛起 :調用 resume() 恢復運行
- 已正常運行 :無需額外操作

為什麼用 { once: true } ?因為一旦音頻被激活,就不需要重複綁定。這樣既符合安全策略,又能防止多次註冊帶來的性能浪費。

有趣的是, AudioContext 的時間是獨立於JavaScript主線程的高精度時鐘。也就是説,哪怕你的頁面正在卡頓,它的計時依然精準到微秒級別——這對音樂應用來説至關重要。

順帶提一句,如果你打算支持老版本Safari,記得加上 webkitAudioContext 的兼容寫法。雖然現在大多數人都用現代瀏覽器了,但在生產環境裏,這種細節能幫你少踩很多坑。

用振盪器製造電子鼓的靈魂

現在工作室建好了,該製造聲音了。Web Audio API 提供了兩種主要方式:一種是實時計算出來的波形,另一種是預先錄製好的音頻片段。我們先來看看第一種——使用 OscillatorNode

顧名思義,振盪器就是不斷重複某種波形的節點。它特別適合做電子風格的聲音,比如8-bit遊戲音效或者TR-808那種經典的底鼓。最大的好處是輕量級,不需要加載外部資源,內存佔用極小。

四種基本波形,四種性格

Web Audio API 內置了四種標準波形:正弦波、方波、鋸齒波和三角波。每種都有獨特的“性格”。

波形類型

諧波特徵

聽感描述

典型用途

正弦波 ( sine )

只有基頻,乾淨無雜質

柔和圓潤,像風吹過口哨

低頻震動、音頭起始

方波 ( square )

包含奇次諧波(3rd, 5th…)

尖鋭有力,帶金屬質感

軍鼓邊緣、脈衝打擊

鋸齒波 ( sawtooth )

所有整數次諧波都很強

明亮飽滿,類似小號

主旋律合成、通鼓掃掠

三角波 ( triangle )

奇次諧波衰減很快

温暖中性,接近木琴

節奏點綴、輕柔打擊

不信你可以親自試試聽差別。下面是一個測試腳本,每隔1.5秒播放一種波形:

const ctx = new AudioContext();

function playTone(type, freq = 440, dur = 1) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();

  osc.type = type;
  osc.frequency.setValueAtTime(freq, ctx.currentTime);

  gain.gain.setValueAtTime(0.5, ctx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);

  osc.connect(gain).connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + dur);
}

['sine', 'square', 'sawtooth', 'triangle'].forEach((t, i) => {
  setTimeout(() => playTone(t), i * 1500);
});

注意這裏的 exponentialRampToValueAtTime ——我們用了指數衰減而不是線性下降。這是因為在人耳感知中,音量是對數變化的,指數曲線聽起來更自然,不會有“咔”的截斷聲。

流程圖清晰展示了信號流路徑:

graph TD
    A[AudioContext] --> B(OscillatorNode)
    B --> C{Waveform Type}
    C -->|sine| D[純淨音色]
    C -->|square| E[尖鋭音色]
    C -->|sawtooth| F[明亮音色]
    C -->|triangle| G[圓潤音色]
    B --> H(GainNode)
    H --> I[AudioDestination]

你會發現,無論哪種波形,最後都要經過一個 GainNode 來控制響度。這是個好習慣:永遠不要直接把源節點連到輸出端,中間留個控制器,以後擴展起來方便得多。

讓音高動起來:模擬經典底鼓的衝擊感

傳統鼓類樂器大多沒有固定音高,但電子鼓不一樣。像Roland TR-808的經典底鼓,其實就是一段快速下滑的頻率。我們可以用 frequency.exponentialRampToValueAtTime 實現這個效果:

function playKickWithPitchSweep() {
  const now = audioCtx.currentTime;
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();

  osc.type = 'triangle';
  osc.frequency.setValueAtTime(150, now);
  osc.frequency.exponentialRampToValueAtTime(0.01, now + 0.3); // 快速滑向靜止

  gain.gain.setValueAtTime(1, now);
  gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3); // 音量同步衰減

  osc.connect(gain).connect(audioCtx.destination);
  osc.start(now);
  osc.stop(now + 0.3);
}

重點在於“同步”。頻率和增益的變化必須基於同一個時間基準( now ),否則會出現相位錯位,聲音變得奇怪。150Hz到幾乎0Hz的快速滑落,配合音量衰減,就能製造出那種“砰!”的一下衝擊感。

進階玩法?加個低通濾波器再疊加兩個振盪器試試。比如一個高頻正弦波做音頭,一個低頻方波做主體,再通過濾波器塑形——恭喜,你已經快摸到模擬合成器的大門了 🎉

真實採樣:讓鼓機更有“肉感”

雖然算法生成的聲音很酷,但如果你想做出逼真的軍鼓或踩鑔,光靠振盪器是不夠的。這時候就得請出 BufferSourceNode AudioBuffer 組合拳了。

簡單説, AudioBuffer 是一段解碼後的音頻數據,而 BufferSourceNode 則是用來播放這段數據的“播放器”。它每次只能播放一次,播完即廢,所以適合短促的打擊音效。

加載採樣:異步流程的藝術

要加載一個 .wav 文件,步驟如下:

  1. fetch 獲取二進制數據
  2. 轉成 ArrayBuffer
  3. 交給 decodeAudioData 解碼為 AudioBuffer

封裝成類更好管理:

class SamplePlayer {
  constructor(context) {
    this.ctx = context;
    this.buffers = {};
  }

  async loadSample(name, url) {
    const res = await fetch(url);
    const buf = await res.arrayBuffer();
    const decoded = await this.ctx.decodeAudioData(buf);
    this.buffers[name] = decoded;
    console.log(`✅ ${name} loaded`);
  }

  playSample(name) {
    if (!this.buffers[name]) return;

    const src = this.ctx.createBufferSource();
    src.buffer = this.buffers[name];
    src.connect(this.ctx.destination);
    src.start(this.ctx.currentTime);
  }
}

// 初始化並預加載
const player = new SamplePlayer(audioCtx);
player.loadSample('kick', '/samples/kick.wav');
player.loadSample('snare', '/samples/snare.wav');

⚠️ 強烈建議提前加載!千萬別等到用户點擊時才去請求網絡資源,那延遲足以毀掉整個體驗。

常見鼓點採樣的推薦參數如下:

採樣名稱

格式

採樣率

位深

大小

備註

Kick

WAV PCM

44.1kHz

16bit

~50KB

單聲道優先

Snare

WAV PCM

44.1kHz

16bit

~80KB

可含混響尾

Hi-hat

WAV PCM

44.1kHz

16bit

~30KB

分開閉兩版

Clap

WAV PCM

44.1kHz

16bit

~60KB

多層疊加增強立體感

整個生命週期可以用序列圖表示:

sequenceDiagram
    participant Browser
    participant Server
    participant AudioContext
    participant BufferSource

    Browser->>Server: fetch("/samples/kick.wav")
    Server-->>Browser: 返回ArrayBuffer
    Browser->>AudioContext: decodeAudioData(buffer)
    AudioContext-->>Browser: 解碼為AudioBuffer
    Browser->>BufferSource: createBufferSource()
    BufferSource->>AudioContext: start() → 輸出聲音

注意到 decodeAudioData 是異步的,而且可能在主線程執行(取決於瀏覽器實現)。因此建議批量預加載,並顯示進度條提升用户體驗。

併發控制:別讓聲音亂成一鍋粥

如果用户手速太快,連續猛點同一個按鈕會發生什麼?多個 BufferSourceNode 同時播放同一段採樣,結果往往是刺耳的相位干擾和音量倍增。

解決方案有兩種思路:

🔒 門控模式:一次只允許一個實例

最簡單的做法是加鎖:

class GatedPlayer {
  constructor(ctx) {
    this.ctx = ctx;
    this.buffers = {};
    this.active = {}; // 當前活躍的source
  }

  playSample(name) {
    if (this.active[name]) return; // 正在播放,忽略新請求

    const src = this.ctx.createBufferSource();
    src.buffer = this.buffers[name];
    src.connect(this.ctx.destination);

    src.onended = () => delete this.active[name];
    this.active[name] = src;
    src.start();
  }
}

優點是邏輯清晰,缺點也很明顯:犧牲了連擊能力,不適合高速節拍輸入。

🔄 池化策略:有限併發下的優雅複用

更好的方案是維護一個“音源池”,限制最大併發數:

class PooledPlayer {
  constructor(ctx, size = 4) {
    this.ctx = ctx;
    this.pool = Array(size).fill(null).map(() => ({
      source: null,
      isActive: false,
      endTime: 0
    }));
    this.buffers = {};
  }

  playSample(name) {
    const buf = this.buffers[name];
    if (!buf) return;

    const now = this.ctx.currentTime;
    const slot = this.pool.find(s => !s.isActive || s.endTime < now);
    if (!slot) {
      console.warn(`Pool full for ${name}`);
      return;
    }

    const src = this.ctx.createBufferSource();
    src.buffer = buf;
    src.connect(this.ctx.destination);
    src.start(now);

    slot.source = src;
    slot.isActive = true;
    slot.endTime = now + buf.duration;

    src.onended = () => slot.isActive = false;
  }
}

默認最多同時播放4個聲音。通過 endTime 預測回收時機,既能防爆音,又能保留一定連擊自由度。這對於多打擊墊同時觸發的場景尤其重要。

動態切換音源:合成 vs 採樣

在專業鼓機中,經常會有“電子模式”和“原聲模式”的切換需求。這就要求我們抽象出統一的接口,讓用户操作不受底層實現影響。

設計一個 AudioSourceManager 類:

class AudioSourceManager {
  constructor(ctx) {
    this.ctx = ctx;
    this.mode = 'synth'; // 'synth' or 'sample'
    this.samplePlayer = new PooledPlayer(ctx);
    this.oscillators = {};
  }

  setMode(mode) {
    if (['synth', 'sample'].includes(mode)) {
      this.mode = mode;
    }
  }

  trigger(frequency = 220) {
    if (this.mode === 'sample') {
      this.samplePlayer.playSample('kick');
    } else {
      this._playSynthKick(frequency);
    }
  }

  _playSynthKick(freq) {
    const now = this.ctx.currentTime;
    const osc = this.ctx.createOscillator();
    const gain = this.ctx.createGain();

    osc.type = 'triangle';
    osc.frequency.setValueAtTime(freq, now);
    osc.frequency.exponentialRampToValueAtTime(10, now + 0.2);

    gain.gain.setValueAtTime(1, now);
    gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);

    osc.connect(gain).connect(this.ctx.destination);
    osc.start(now);
    osc.stop(now + 0.2);
  }
}

上層UI只需要調 trigger() ,完全不用關心當前走的是哪條路徑。這種抽象讓功能擴展變得極其輕鬆——未來想加FM合成?只需新增一種模式即可。

生命週期管理:別忘了清理戰場

每個音頻節點都不是免費的。如果不妥善釋放,會導致內存泄漏甚至設備發熱。

節點類型

如何啓動

如何停止

是否可複用

OscillatorNode

start()

必須調 stop()


BufferSourceNode

start()

自動結束


GainNode / FilterNode

連接即生效

斷開連接後GC回收


最佳實踐:
- 所有一次性的節點,結束後立即 disconnect()
- 長期存在的控制器(如主音量)可用弱引用管理
- 頁面卸載前調 audioCtx.close() 徹底釋放資源

狀態流轉如下:

stateDiagram-v2
    [*] --> Idle
    Idle --> Playing: 用户點擊
    Playing --> Releasing: stop() called
    Releasing --> Idle: onended觸發
    note right of Releasing
      節點從音頻圖斷開,
      等待垃圾回收
    end note

記住一句話: 誰創建,誰負責清理 。只要遵循這個原則,就不會留下“幽靈節點”。

增益控制:給每個鼓點加上包絡

光有聲音還不夠,還得讓它“像”鼓。真實的打擊樂器都有明顯的瞬態特性:一瞬間爆發,然後迅速衰減。這就是所謂的ADSR包絡(Attack-Decay-Sustain-Release),而在鼓機中,我們重點關注“起音-衰減”部分。

用 GainNode 實現動態音量變化

核心工具是 GainNode ,它本質上是個乘法器,把輸入信號乘以一個增益值。結合 exponentialRampToValueAtTime ,可以做出非常自然的衰減效果:

function createPercussiveGain(duration = 0.3) {
  const gain = audioCtx.createGain();
  gain.gain.setValueAtTime(1, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
  return gain;
}

為什麼不設成0?因為指數函數不允許目標值為0,會拋異常。0.001已經足夠接近無聲(約-60dB)。

流程圖展示完整觸發流程:

graph TD
    A[用户觸發] --> B[創建 GainNode]
    B --> C[初始增益=1.0]
    C --> D[啓動播放]
    D --> E[設置指數衰減]
    E --> F[duration時間後趨近靜音]
    F --> G[自動釋放]

你可以預設幾種常用曲線:

類型

時長

曲線

適用場景

Sharp

0.1s

指數

軍鼓、踩鑔

Medium

0.3s

指數

底鼓、通鼓

Long

0.8s

線性+保持

特效音、延音

封裝成配置對象,運行時注入,就能輕鬆實現不同打擊墊的不同響應特性。

事件驅動的瞬態控制

當用户按下鼠標或觸摸屏幕時,不僅要響聲,還要立刻給予視覺反饋。這就形成了“動作-聲音-畫面”三位一體的交互閉環。

const pad = document.getElementById('kick-pad');

pad.addEventListener('mousedown', () => {
  if (audioCtx.state === 'suspended') audioCtx.resume();

  const src = audioCtx.createBufferSource();
  const gain = createPercussiveGain(0.4);

  src.buffer = kickBuffer;
  src.connect(gain).connect(audioCtx.destination);
  src.start(0);

  // 視覺反饋
  pad.style.opacity = 0.6;
  setTimeout(() => pad.style.opacity = 1, 150);
});

注意 audioCtx.resume() 這一步必不可少。很多初學者在這裏栽跟頭:明明代碼沒錯,就是沒聲音——原因就是上下文還處於掛起狀態。

進一步封裝成組件:

class TriggerPad {
  constructor(el, buffer, ctx, decay = 0.3) {
    this.el = el;
    this.buf = buffer;
    this.ctx = ctx;
    this.decay = decay;
    this.setup();
  }

  setup() {
    this.el.addEventListener('mousedown', () => this.trigger());
  }

  trigger() {
    if (this.ctx.state === 'suspended') this.ctx.resume();

    const src = this.ctx.createBufferSource();
    const gain = this.ctx.createGain();

    gain.gain.setValueAtTime(1, this.ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + this.decay);

    src.buffer = this.buf;
    src.connect(gain);
    gain.connect(this.ctx.destination);
    src.start(0);

    this.visualFeedback();
  }

  visualFeedback() {
    this.el.classList.add('active');
    setTimeout(() => this.el.classList.remove('active'), 150);
  }
}

面向對象的好處立馬體現出來了:批量初始化多個打擊墊變得輕而易舉,而且所有音量控制邏輯保持一致。

構建總線結構:打造可擴展的混音架構

隨着打擊墊數量增加,如果每個都直接連到輸出端,後期想加個全局壓縮或均衡器就會變得非常麻煩。聰明的做法是引入 總線(Bus) 概念,把各個軌道先匯合到一起,再統一處理。

主總線:集中管理最終輸出

創建一個 GainNode 作為主總線:

const masterBus = audioCtx.createGain();
masterBus.gain.value = 0.9; // 控制整體音量
masterBus.connect(audioCtx.destination);

然後讓所有打擊墊指向這個總線:

class TriggerPad {
  // ...
  trigger() {
    // ...
    gain.connect(this.output || this.ctx.destination); // 支持自定義輸出
  }

  setOutput(target) {
    this.output = target;
  }
}

// 使用時
hiHatPad.setOutput(masterBus);

這樣一來,你就有了分層控制的能力:

層級

節點類型

功能

可調節性

源層

SourceNode

聲音生成


通道層

GainNode

單軌音量/包絡


子總線

Effect Nodes

效果發送


主總線

Compressor + Gain

全侷限幅/標準化


結構清晰,擴展性強,這才是專業級音頻系統的模樣 👍

預留效果通道:為未來留一扇門

你想加混響嗎?延遲?失真?都可以通過“發送/返回”機制實現。原理很簡單:每個軌道分出一小部分信號送到效果處理器,處理完後再混回主路。

示例:添加混響通道

// 創建效果鏈
const send = audioCtx.createGain();
send.gain.value = 0.5; // 發送量

const convolver = audioCtx.createConvolver();
loadImpulseResponse(convolver, 'reverb-impulse.wav'); // 衝激響應

const reverbReturn = audioCtx.createGain();
reverbReturn.gain.value = 0.7;
reverbReturn.connect(masterBus); // 返回主總線

// 連接
send.connect(convolver);
convolver.connect(reverbReturn);

// 在某個軌道上啓用發送
src.connect(send); // 分流一部分信號

拓撲圖如下:

graph LR
    Kick --> Gain1
    Snare --> Gain2

    Gain1 --> MasterBus
    Gain2 --> MasterBus

    Gain1 --> Send
    Gain2 --> Send

    Send --> Convolver
    Convolver --> Return
    Return --> MasterBus

    MasterBus --> Destination

這套架構看似複雜,但實際上只多了幾個節點。但它帶來的靈活性是巨大的:你可以讓底鼓乾乾淨淨,而軍鼓帶着長長的混響尾巴,創造出豐富的空間層次感。

優化信號鏈:低延遲與內存安全雙保障

高性能鼓機的關鍵指標之一就是 端到端延遲 ——從你點擊屏幕到聽到聲音的時間差。理想情況下應低於10ms,否則會有明顯的“脱節感”。

最短路徑原則

儘量減少中間環節。例如:

✅ 推薦:

src.connect(masterBus);

❌ 不推薦:

src.connect(tempGain);
tempGain.connect(anotherGain);
anotherGain.connect(yetAnotherNode);
yetAnotherNode.connect(masterBus);

除非確實需要逐級處理,否則每多一個節點都會增加幾毫秒延遲,尤其是在移動設備上更為敏感。

防止內存泄漏

這是Web Audio新手最容易忽視的問題:只要節點還在連接狀態,就不會被垃圾回收!高頻觸發下很容易導致內存飆升。

解決辦法是在播放結束後手動斷開:

src.onended = () => {
  src.disconnect();
  gain.disconnect();
};

操作

是否必要

説明

disconnect()


切斷所有連接

設為null

⚠️ 輔助

有助於GC

監聽onended


確保清理時機正確

建立這個習慣後,你的應用就能長時間穩定運行而不崩潰。

交互系統:讓UI與聲音同步呼吸

一個好的鼓機,不僅是耳朵在聽,更是全身在感受。每一次敲擊都應該伴隨着即時的視覺反饋,形成強烈的節奏共鳴。

三合一事件監聽體系

我們需要同時支持三種輸入方式:

  1. 鼠標點擊
  2. 觸摸手勢
  3. 鍵盤快捷鍵

HTML結構:

<div class="grid-container">
  <div class="pad" data-key="q" data-sound="kick"></div>
  <div class="pad" data-key="w" data-sound="snare"></div>
  <div class="pad" data-key="e" data-sound="hihat"></div>
</div>

統一事件處理器:

document.querySelectorAll('.pad').forEach(pad => {
  pad.addEventListener('mousedown', handleTrigger);
  pad.addEventListener('touchstart', handleTrigger, { passive: false });

  // 釋放事件
  ['mouseup', 'mouseleave', 'touchend'].forEach(evt => {
    pad.addEventListener(evt, () => pad.classList.remove('active'));
  });
});

function handleTrigger(e) {
  const sound = e.target.dataset.sound;
  audioEngine.play(sound);
  e.target.classList.add('active');

  if (e.type === 'touchstart') e.preventDefault();
}

其中 { passive: false } 很關鍵,否則某些移動端瀏覽器會阻止 preventDefault() ,導致頁面跟着滑動。

鍵盤映射:提升演奏效率

專業用户往往更喜歡用鍵盤操作。Q-W-E-A-S-D 這種佈局已經成為行業慣例。

const KEYMAP = { q: 'kick', w: 'snare', e: 'hihat', a: 'clap', s: 'tom', d: 'crash' };

window.addEventListener('keydown', e => {
  const key = e.key.toLowerCase();
  if (KEYMAP[key] && !e.repeat) {
    const pad = document.querySelector(`[data-sound="${KEYMAP[key]}"]`);
    if (pad) handleTrigger({ target: pad });
  }
});

加上CSS提示:

.pad::after {
  content: attr(data-key);
  position: absolute;
  bottom: 5px;
  right: 5px;
  font-size: 12px;
  color: rgba(255,255,255,0.7);
}

用户一看就知道哪個鍵對應哪個鼓,學習成本降到最低。

視覺反饋升級:GSAP帶來電影級打擊感

原生CSS過渡雖然夠用,但表現力有限。想要那種“按下凹陷→回彈發光”的炫酷效果,就得上動畫庫了。

引入 GreenSock(GSAP):

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>

改造觸發函數:

function handleTrigger(e) {
  const pad = e.target;
  audioEngine.play(pad.dataset.sound);

  gsap.killTweensOf(pad); // 清除舊動畫
  gsap.to(pad, {
    scale: 0.95,
    boxShadow: '0 0 20px rgba(255,255,0,0.6)',
    duration: 0.1,
    ease: 'power1.out',
    onComplete: () => {
      gsap.to(pad, {
        scale: 1,
        boxShadow: '0 0 10px rgba(255,255,255,0.3)',
        duration: 0.2,
        ease: 'back.out(1.7)'
      });
    }
  });
}

back.out(1.7) 這個緩動函數會產生強烈的彈性反彈效果,模擬物理按壓的真實感。再加上光影變化,簡直讓人忍不住一直點 😂

響應式佈局:適配手機、平板和桌面

別忘了,人們會在各種設備上玩你的鼓機。響應式設計不再是加分項,而是必備能力。

CSS Grid + Flexbox 黃金組合

用Grid排布整體網格:

.grid-container {
  display: grid;
  gap: 10px;
  padding: 20px;
  grid-template-areas:
    "kick snare hihat"
    "clap tom   crash";
}

.pad[data-sound="kick"] { grid-area: kick; }
.pad[data-sound="snare"] { grid-area: snare; }
/* ...其他映射 */

內部居中用Flexbox:

.pad {
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(#333, #111);
  border-radius: 12px;
  position: relative;
}

媒體查詢適配多屏

根據不同屏幕寬度調整列數:

/* 手機 */
.grid-container {
  grid-template-columns: repeat(3, 1fr);
}

/* 平板 */
@media (min-width: 768px) {
  .grid-container {
    grid-template-columns: repeat(6, 1fr);
  }
}

/* 桌面 */
@media (min-width: 1024px) {
  .grid-container {
    max-width: 1200px;
    margin: 0 auto;
    grid-template-columns: repeat(8, 1fr);
  }
}

這樣無論是在手機豎屏還是4K顯示器上,界面都能優雅呈現。

資源管理與工程化實踐

到最後,我們得讓整個系統跑得穩、長得大、管得住。

異步加載與緩存複用

避免重複加載:

class AudioManager {
  constructor(ctx) {
    this.ctx = ctx;
    this.cache = new Map();
  }

  async load(url) {
    if (this.cache.has(url)) return this.cache.get(url);

    const buffer = await fetch(url)
      .then(r => r.arrayBuffer())
      .then(buf => this.ctx.decodeAudioData(buf));

    this.cache.set(url, buffer);
    return buffer;
  }
}

搭配AbortController還能支持取消加載:

const controller = new AbortController();
fetch(url, { signal: controller.signal })
  .catch(err => {
    if (err.name === 'AbortError') console.log('Canceled');
  });

節奏序列器與本地存儲