Day1:用原生JS把你的裝置變成一臺架子鼓!

語言: CN / TW / HK

這道題來自於Wes BosJavaScript30教程。

JavaScirpt30 是 Wes Bos 推出的一個 30 天挑戰。專案免費提供了 30 個視訊教程、30 個挑戰的起始文件和 30 個挑戰解決方案原始碼。目的是幫助人們用純 JavaScript 來寫東西,不借助框架和庫,也不使用編譯器和引用。

這道題比較有意思,挑戰者需要實現一個“擊鼓”的效果,介面上一共有九個鼓點,當用戶在鍵盤上按下 ASDFGHJKL 這幾個鍵,或者點選滑鼠和觸控式螢幕幕時,顯示對應的擊鼓視覺效果和播放對應的音效。

效果如下:

20220630-095855.gif

有興趣的同學可以挑戰一下,在碼上掘金上覆制這個模板 開始你的挑戰。


好,如果你已經嘗試過了挑戰,可以看一下參考答案,看看和你的思路是否一致。

然後我們來看看,通過這個挑戰,能學到什麼知識。

關鍵知識點

  1. 鍵盤和pointer事件
  2. 按鍵狀態和播放聲音
  3. 聲音的視覺化效果

結構

我們先來看一下HTML結構:

```html

Aclap
Shihat
Dkick
Fopenhat
Gboom
Hride
Jsnare
Ktom
Ltink

```

這裡我們用了三個結構,一組div表示九個按鍵,每個按鍵的data-key值與keyCode對應,一個canvas元素用來實現音訊視覺化,一組audio表示播放對應的聲音,同樣也是以data-key值與keyCode對應。

那麼我們可以註冊keydown和pointdown事件,如下:

```js function playSound({keyCode}) { ...... }

window.addEventListener('keydown', playSound); document.querySelectorAll(div[data-key]).forEach((btn) => { btn.addEventListener('pointerdown', () => { playSound({keyCode: btn.dataset.key}); }); }); ```

這裡有個小細節是,如果是keydown事件,那麼event引數的keyCode屬性就是對應的鍵值,所以直接回調playSound函式就行;如果是pointerdown事件,那麼我們要把按鍵上的data-key值作為引數的keyCode屬性傳給playSound,所以實際註冊的回撥就是()=> {playSound({keyCode: btn.dataset.key});},這裡也可以使用 playSound.bind(null, {keyCode: btn.dataset.key}

為什麼是pointerdown事件?

有細心的同學可能會問,這裡為什麼使用pointerdown事件,而不是click或mousedown事件?

Pointer Events 是瀏覽器支援的一種比較新的事件型別,任意指標輸入裝置都可以產生該事件型別。

對於PC來說,用click或mousedown完全沒問題,但是click事件在移動端裝置上只支援單點,而且部分裝置有300ms的延遲,mousedown事件在移動裝置上要用touch event替代。而新的pointer events則沒有問題,在移動裝置上也可以支援多點觸控,所以當我們同時觸碰敲響兩個鼓點的時候,用pointer events就完全沒有問題了。

按鍵UI和播放聲音

接著我們實現具體的playSound函式裡,按鍵UI和聲音的播放。

``js let lastAudio = null; function playSound({keyCode}) { const audio = document.querySelector(audio[data-key="${keyCode}"]); // 根據觸發按鍵的鍵碼,獲取對應音訊 const key = document.querySelector([data-key="${keyCode}"]`); // 獲取頁面對應按鈕 DIV 元素 if (!audio) return; // 處理無效的按鍵事件

key.classList.add('playing'); // 改變樣式 audio.currentTime = 0; // 每次播放先使音訊播放進度歸零 audio.play(); // 播放相應音效 lastAudio = audio;

// 如果用transitionend快速敲擊不一定觸發 if(key.timer) clearTimeout(key.timer); key.timer = setTimeout(() => { key.classList.remove('playing'); }, 170); } ```

如上面程式碼所示,我們在playSound中,根據keyCode獲取對應的audio和key元素,要實現按鍵UI,只需要給它的播放狀態新增一個.playing的樣式,我們在CSS中用transition實現playing的效果。

```css .key { ... transition:all .17s; ... }

.playing { transform:scale(1.1); border-color:#ffc600; box-shadow: 0 0 10px #ffc600; } ```

這裡我們使用了一個0.17s,也就是170毫秒的transition效果,在170毫秒之後將.playing狀態刪除。

刪除.playing狀態有兩個思路,一個是監聽transitionend事件,然後在事件處理中將.playing移出,一個是像我的程式碼那樣,乾脆用setTimeout來移除。

但實際效果看,使用transitionend會有問題,如果我們非常快來回敲擊多個鼓點,某些transitionend事件由於樣式切換太快,不會被觸發,那樣按鍵狀態就沒法恢復了,而使用setTimeout則避免了這個bug。

至於聲音播放則非常簡單,直接呼叫audio元素的play方法就可以,不過要支援多次播放,所以我們需要在播放前將currentTime重置為0。

這樣實現playSound函式之後,主體功能就實現完成了,接下來要實現播放聲音的視覺化效果。

播放聲音的視覺效果

把聲音轉換成視覺效果,我們需要拿到聲音的取樣資料,在瀏覽器中,可以使用AudioContext API

``js document.querySelectorAll("audio").forEach((audio, i) => { const color =hsl(${i / 9 * 360}, 100%, 50%)` audio.addEventListener('play', () => { const audioCtx = new AudioContext(); const audioSrc = audioCtx.createMediaElementSource(audio); const audioAnalyser = audioCtx.createAnalyser();

audioSrc.connect(audioAnalyser);
audioSrc.connect(audioCtx.destination);
audioAnalyser.smoothingTimeConstant = .85;
audioAnalyser.fftSize = 1024;
const audioBufferData = new Uint8Array(audioAnalyser.frequencyBinCount);
const audioData = new AudioData(audioBufferData);
tasks.push(() => {
  audioAnalyser.getByteFrequencyData(audioBufferData);
  draw(ctx, {audio, audioData, color, points: 50});
});

}, {once: true}); }); ```

按照上面的程式碼,我們在audio元素首次播放的時候,建立一個AudioContext物件,然後通過createMediaElementSource,用當前audio物件建立一個audioSource,再通過createAnalyer建立一個audioAnalyser物件,把這個物件和audioSource物件連線起來。

這樣我們建立一個Uint8Array的緩衝區,通過audioAnalyser.getByteFrequencyData就可以將資料寫入緩衝區,然後用緩衝區資料建立一個AudioData物件,這個物件從緩衝區中讀取和處理音訊資料。

js class AudioData { constructor(bufferData) { this.bufferData = bufferData; } get base() { const length = this.bufferData.length; return this.bufferData.slice(0, length * 0.0625); } get low() { const length = this.bufferData.length; return this.bufferData.slice(length * 0.0625 + 1, length * 0.125); } get mid() { const length = this.bufferData.length; return this.bufferData.slice(length * 0.125 + 1, length * 0.5); } get high() { const length = this.bufferData.length; return this.bufferData.slice(length * 0.5 + 1); } static scale(data, maxSize) { const ret = []; for(let i = 0; i < data.length; i++) { const value = data[i]; let percent = Math.round((value / 255) * 100) / 100; ret[i] = maxSize * percent; } return ret; } }

根據我們的取樣,緩衝資料中前半部分是基音訊率(base)、低音訊率(low)和中音訊率(mid)的資料,後半部分是高音訊率(high)資料。

我們採用中音訊率資料來繪製圖形:

js function draw(ctx, {audio, audioData, color, points}) { const { height, width } = ctx.canvas; const data = AudioData.scale(audioData.mid, Math.min(height, width)); const count = points || data.length; ctx.save(); ctx.translate(0, height); ctx.scale(1, -1); ctx.fillStyle = color; const w = width / count; ctx.beginPath(); ctx.moveTo(0, 0); for(let i = 0; i < count; i++) { const index = i * Math.floor(data.length / count); const e = lastAudio === audio ? 5 : 0; ctx.lineTo(i * w, e + data[index]); if(i === count - 1) { ctx.lineTo(width, e + data[index]); ctx.lineTo(width, 0); } } ctx.fill(); ctx.restore(); }

最後,因為我們有9個鼓點,分別對應9個audio資料通道,所以我們以tasks陣列的方式,在第一次播放每個audio的時候才將要處理的繪製動作給新增到tasks陣列中:

js tasks.push(() => { audioAnalyser.getByteFrequencyData(audioBufferData); draw(ctx, {audio, audioData, color, points: 50}); });

我們在繪製canvas的時候,每一幀遍歷所有的tasks,對其進行繪製:

js const canvas = document.querySelector('canvas'); canvas.width = document.documentElement.clientWidth; canvas.height = document.documentElement.clientHeight; const ctx = canvas.getContext('2d'); ctx.globalAlpha = 0.5; const tasks = []; const tick = () => { const { height, width } = ctx.canvas; ctx.clearRect(0, 0, width, height); tasks.forEach(task => task()); window.requestAnimationFrame(tick); } tick();

這裡還有一個小的UI細節,我們在前面實現playSound方法的時候記錄下了lastAudio,也就是上次播放的audio,我們在canvas繪製的時候,保留上次播放audio至少5畫素點的高度:

js const e = lastAudio === audio ? 5 : 0; ctx.lineTo(i * w, e + data[index]);

這樣的話,介面中,底部的橫條就保持了上一次敲擊鼓點的顏色值,顯得更加活潑一點。

最終實現版本如下:

https://code.juejin.cn/pen/7111233570496053255

總結

這樣,這道題就完成了。通過這道題,主要我們學到了這些知識點:

1. 使用keydown和pointerdown事件來處理擊鼓動作。

🎯 用pointerdown,可以讓移動裝置支援多點觸控,從而同時敲響多個鼓點

2. 使用transition來實現按鍵UI,使用audio.play來實現音訊播放。

🎯 之所以不用transitionend事件來移除playing狀態,是因為transitionend在快速切換元素狀態時有可能不觸發。

3. 使用AudioContext API來實現音訊視覺化效果。

🎯 最核心是通過audioAnalyser獲取音訊資料,然後利用該資料在canvas中繪製圖形,注意多個音訊通道同時繪製的處理方法。

你的思路和我一樣嗎?或者你有什麼獨特的想法?歡迎在評論區討論。