Day1:用原生JS把你的設備變成一台架子鼓!
這道題來自於Wes Bos的JavaScript30教程。
JavaScirpt30 是 Wes Bos 推出的一個 30 天挑戰。項目免費提供了 30 個視頻教程、30 個挑戰的起始文檔和 30 個挑戰解決方案源代碼。目的是幫助人們用純 JavaScript 來寫東西,不借助框架和庫,也不使用編譯器和引用。
這道題比較有意思,挑戰者需要實現一個“擊鼓”的效果,界面上一共有九個鼓點,當用户在鍵盤上按下 ASDFGHJKL
這幾個鍵,或者點擊鼠標和觸摸屏幕時,顯示對應的擊鼓視覺效果和播放對應的音效。
效果如下:
有興趣的同學可以挑戰一下,在碼上掘金上覆制這個模板 開始你的挑戰。
好,如果你已經嘗試過了挑戰,可以看一下參考答案,看看和你的思路是否一致。
然後我們來看看,通過這個挑戰,能學到什麼知識。
關鍵知識點
- 鍵盤和pointer事件
- 按鍵狀態和播放聲音
- 聲音的可視化效果
結構
我們先來看一下HTML結構:
```html
```
這裏我們用了三個結構,一組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]);
這樣的話,界面中,底部的橫條就保持了上一次敲擊鼓點的顏色值,顯得更加活潑一點。
最終實現版本如下:
http://code.juejin.cn/pen/7111233570496053255
總結
這樣,這道題就完成了。通過這道題,主要我們學到了這些知識點:
1. 使用keydown和pointerdown事件來處理擊鼓動作。
🎯 用pointerdown,可以讓移動設備支持多點觸摸,從而同時敲響多個鼓點
2. 使用transition來實現按鍵UI,使用audio.play來實現音頻播放。
🎯 之所以不用transitionend事件來移除playing狀態,是因為transitionend在快速切換元素狀態時有可能不觸發。
3. 使用AudioContext API來實現音頻可視化效果。
🎯 最核心是通過audioAnalyser獲取音頻數據,然後利用該數據在canvas中繪製圖形,注意多個音頻通道同時繪製的處理方法。
你的思路和我一樣嗎?或者你有什麼獨特的想法?歡迎在評論區討論。
- Day1:用原生JS把你的設備變成一台架子鼓!
- 【零基礎】充分理解WebGL(七)
- 【零基礎】充分理解WebGL(六)
- 【零基礎】充分理解WebGL(五)
- 冷知識:不起眼但有用的String.raw方法
- 【零基礎】充分理解WebGL(四)
- 【零基礎】充分理解WebGL(三)
- 【零基礎】充分理解WebGL(二)
- 【零基礎】充分理解WebGL(一)
- css-doodle:如何讓CSS成為藝術?
- 創建合輯,將【碼上掘金】作為開源項目的demo庫使用
- 使用 babel 插件來打造真正的“私有”屬性
- 使用 Node.js 對文本內容分詞和關鍵詞抽取
- 用信號來控制異步流程
- 設計 Timeline 時間軸來更精確地控制動畫
- 簡單構建 ThinkJS Vue2.0 前後端分離的多頁應用
- 冷門函數之Math.hypot
- 你還在用charCodeAt那你就out了
- 巧用 currentColor 屬性來實現自定義 checkbox 樣式
- 在什麼情況下 a === a - 1 ?