• Day1:用原生JS把你的设备变成一台架子鼓!


    JavaScirpt30 是 Wes Bos 推出的一个 30 天挑战。项目免费提供了 30 个视频教程、30 个挑战的起始文档和 30 个挑战解决方案源代码。目的是帮助人们用纯 JavaScript 来写东西,不借助框架和库,也不使用编译器和引用。

    这道题比较有意思,挑战者需要实现一个“击鼓”的效果,界面上一共有九个鼓点,当用户在键盘上按下 ASDFGHJKL 这几个键,或者点击鼠标和触摸屏幕时,显示对应的击鼓视觉效果和播放对应的音效。

    效果如下:

    有兴趣的同学可以挑战一下,在码上掘金上复制这个模板 开始你的挑战。


    好,如果你已经尝试过了挑战,可以看一下参考答案,看看和你的思路是否一致。

    然后我们来看看,通过这个挑战,能学到什么知识。

    关键知识点

    1.键盘和pointer事件
    2.按键状态和播放声音
    3.声音的可视化效果

    结构

    我们先来看一下HTML结构:

    Aclap
    Shihat
    Dkick
    Fopenhat
    Gboom
    Hride
    Jsnare
    Ktom
    Ltink
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    这里我们用了三个结构,一组div表示九个按键,每个按键的data-key值与keyCode对应,一个canvas元素用来实现音频可视化,一组audio表示播放对应的声音,同样也是以data-key值与keyCode对应。

    那么我们可以注册keydown和pointdown事件,如下:

    function playSound({keyCode}) { ......
    }
    
    window.addEventListener('keydown', playSound);
    document.querySelectorAll(`div[data-key]`).forEach((btn) => {btn.addEventListener('pointerdown', () => {playSound({keyCode: btn.dataset.key});});
    }); 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这里有个小细节是,如果是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和声音的播放。

    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);
    } 
    
    • 1
    • 2
    • 3

    如上面代码所示,我们在playSound中,根据keyCode获取对应的audio和key元素,要实现按键UI,只需要给它的播放状态添加一个.playing的样式,我们在CSS中用transition实现playing的效果。

    .key {...transition:all .17s;...
    }
    
    .playing {transform:scale(1.1);border-color:#ffc600;box-shadow: 0 0 10px #ffc600;
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这里我们使用了一个0.17s,也就是170毫秒的transition效果,在170毫秒之后将.playing状态删除。

    删除.playing状态有两个思路,一个是监听transitionend事件,然后在事件处理中将.playing移出,一个是像我的代码那样,干脆用setTimeout来移除。

    但实际效果看,使用transitionend会有问题,如果我们非常快来回敲击多个鼓点,某些transitionend事件由于样式切换太快,不会被触发,那样按键状态就没法恢复了,而使用setTimeout则避免了这个bug。

    至于声音播放则非常简单,直接调用audio元素的play方法就可以,不过要支持多次播放,所以我们需要在播放前将currentTime重置为0。

    这样实现playSound函数之后,主体功能就实现完成了,接下来要实现播放声音的可视化效果。

    播放声音的视觉效果

    把声音转换成视觉效果,我们需要拿到声音的采样数据,在浏览器中,可以使用AudioContext API

    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});
    }); 
    
    • 1
    • 2

    按照上面的代码,我们在audio元素首次播放的时候,创建一个AudioContext对象,然后通过createMediaElementSource,用当前audio对象创建一个audioSource,再通过createAnalyer创建一个audioAnalyser对象,把这个对象和audioSource对象连接起来。

    这样我们创建一个Uint8Array的缓冲区,通过audioAnalyser.getByteFrequencyData就可以将数据写入缓冲区,然后用缓冲区数据创建一个AudioData对象,这个对象从缓冲区中读取和处理音频数据。

    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;}
    } 
    
    • 1
    • 2

    根据我们的采样,缓冲数据中前半部分是基音频率(base)、低音频率(low)和中音频率(mid)的数据,后半部分是高音频率(high)数据。

    我们采用中音频率数据来绘制图形:

    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();
    } 
    
    • 1
    • 2

    最后,因为我们有9个鼓点,分别对应9个audio数据通道,所以我们以tasks数组的方式,在第一次播放每个audio的时候才将要处理的绘制动作给添加到tasks数组中:

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

    我们在绘制canvas的时候,每一帧遍历所有的tasks,对其进行绘制:

    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(); 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这里还有一个小的UI细节,我们在前面实现playSound方法的时候记录下了lastAudio,也就是上次播放的audio,我们在canvas绘制的时候,保留上次播放audio至少5像素点的高度:

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

    这样的话,界面中,底部的横条就保持了上一次敲击鼓点的颜色值,显得更加活泼一点。

    最终实现版本如下:

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

    总结

    这样,这道题就完成了。通过这道题,主要我们学到了这些知识点:

    1. 使用keydown和pointerdown事件来处理击鼓动作。

    🎯 用pointerdown,可以让移动设备支持多点触摸,从而同时敲响多个鼓点

    2. 使用transition来实现按键UI,使用audio.play来实现音频播放。

    🎯 之所以不用transitionend事件来移除playing状态,是因为transitionend在快速切换元素状态时有可能不触发。

    3. 使用AudioContext API来实现音频可视化效果。

    🎯 最核心是通过audioAnalyser获取音频数据,然后利用该数据在canvas中绘制图形,注意多个音频通道同时绘制的处理方法。

    你的思路和我一样吗?或者你有什么独特的想法?欢迎在评论区讨论。

  • 相关阅读:
    RNA剪接增强免疫检查点抑制疗效
    激活码问题解疑【点盾云】
    Go语言并发赋值的安全性
    眼科动态图像处理系统使用说明(2023-8-11 ccc)
    Docker安装canal、mysql进行简单测试与实现redis和mysql缓存一致性
    Windows共享文件夹
    简单介绍24种设计模式
    牛客 题解
    QT系列第1节 QT中窗口使用简介
    程序人生,中秋共享
  • 原文地址:https://blog.csdn.net/web2022050902/article/details/126090036