簡介: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 內置了四種標準波形:正弦波、方波、鋸齒波和三角波。每種都有獨特的“性格”。
|
波形類型
|
諧波特徵
|
聽感描述
|
典型用途
|
|
正弦波 ( |
只有基頻,乾淨無雜質
|
柔和圓潤,像風吹過口哨
|
低頻震動、音頭起始
|
|
方波 ( |
包含奇次諧波(3rd, 5th…)
|
尖鋭有力,帶金屬質感
|
軍鼓邊緣、脈衝打擊
|
|
鋸齒波 ( |
所有整數次諧波都很強
|
明亮飽滿,類似小號
|
主旋律合成、通鼓掃掠
|
|
三角波 ( |
奇次諧波衰減很快
|
温暖中性,接近木琴
|
節奏點綴、輕柔打擊
|
不信你可以親自試試聽差別。下面是一個測試腳本,每隔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 文件,步驟如下:
- 用
fetch獲取二進制數據 - 轉成
ArrayBuffer - 交給
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
|
|
必須調 |
❌
|
|
BufferSourceNode
|
|
自動結束
|
❌
|
|
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();
};
|
操作
|
是否必要
|
説明
|
|
|
✅
|
切斷所有連接
|
|
設為null
|
⚠️ 輔助
|
有助於GC
|
|
監聽onended
|
✅
|
確保清理時機正確
|
建立這個習慣後,你的應用就能長時間穩定運行而不崩潰。
交互系統:讓UI與聲音同步呼吸
一個好的鼓機,不僅是耳朵在聽,更是全身在感受。每一次敲擊都應該伴隨着即時的視覺反饋,形成強烈的節奏共鳴。
三合一事件監聽體系
我們需要同時支持三種輸入方式:
- 鼠標點擊
- 觸摸手勢
- 鍵盤快捷鍵
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');
});