教你用原生JS造“钢琴”

灵感来源:https://www.zhangxinxu.com/wordpress/2017/06/html5-web-audio-api-js-ux-voice/
向大神张鑫旭致敬!

吾乃好乐者,一日,偶见旭之佳作,欣喜至极,即践之,故有此文。

华丽版 “钢琴” 本尊

在开始之前,我们先来感受一下优美的钢琴旋律 → https://www.zhanhu56.com/h5/music_box/
(请在PC端浏览哦~)

Ok,接下来,我将为大家详细讲解“钢琴”的开发过程~


一、乐音的诞生

1. 基本乐理

音乐有三大基本要素:旋律、节奏与和声,而旋律又是由音阶组成,音阶是什么?音阶就是我们所熟知的1(Do)、2(Re)、3(Mi)、4(Fa)、5(So)、6(La)、7(Si),每个音都有它们各自的音高(音调高低不同),而音调不同则表示物体振动的频率不同。所以,这个频率是我们创造音乐的关键因素。

2. Audio API

<audio>标签想必大家都熟悉,当网站中需要加入音频时我们就可以用到它。但是,今天我们所要讲的此Audio并非彼<audio>,它是HTML5所提供的Web API接口,就像HTML5提供的Canvas接口一样,实际用法确实也有几分相似。

可以说,“钢琴”的核心就在于这个强大的Audio API了。

3. 产生音调

// 创建音频上下文  
var audioCtx = new AudioContext();
// 创建音调控制对象  
var oscillator = audioCtx.createOscillator();
// 创建音量控制对象  
var gainNode = audioCtx.createGain();
// 音调音量关联  
oscillator.connect(gainNode);
// 音量和设备关联  
gainNode.connect(audioCtx.destination);
// 音调类型指定为正弦波  
oscillator.type = 'sine';
// 设置音调频率  
oscillator.frequency.value = 196.00;
// 先把当前音量设为0  
gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
// 0.01秒时间内音量从刚刚的0变成1,线性变化 
gainNode.gain.linearRampToValueAtTime(1, audioCtx.currentTime + 0.01);
// 声音走起 
oscillator.start(audioCtx.currentTime);
// 1秒时间内音量从刚刚的1变成0.001,指数变化 
gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 1);
// 1秒后停止声音 
oscillator.stop(audioCtx.currentTime + 1);

以上代码写了这么多,无非就发出了一个频率为196Hz、类型为正弦波、时值为1秒的音调,既然如此,为了使其更加灵活,为了能发出更多不同音调的声音,我们可以将其封装成一个方法,甚至是类!

4. 封装音调类

封装类的好处就是能将某一模块中用于实现该模块不同功能的方法放在一起,当需要用到它们时,我们能够灵活地长期复用。

现在,我们要封装的这个类还比较简单,只有一个功能:产生音调

class MusicBox {
  constructor(options){
    // 默认值
    let defaults = {
      type: 'sine',  // 音色类型  sine|square|triangle|sawtooth
      duration: 2  // 键音延长时间
    };

    this.opts = Object.assign(defaults, options);

    // 创建新的音频上下文接口
    this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  }

  createSound(freq) {
    // 创建一个OscillatorNode, 它表示一个周期性波形(振荡),基本上来说创造了一个音调
    let oscillator = this.audioCtx.createOscillator();
    // 创建一个GainNode,它可以控制音频的总音量
    let gainNode = this.audioCtx.createGain();
    // 把音量,音调和终节点进行关联
    oscillator.connect(gainNode);
    // this.audioCtx.destination返回AudioDestinationNode对象,表示当前audio context中所有节点的最终节点,一般表示音频渲染设备
    gainNode.connect(this.audioCtx.destination);
    // 指定音调的类型  sine|square|triangle|sawtooth
    oscillator.type = this.opts.type;
    // 设置当前播放声音的频率,也就是最终播放声音的调调
    oscillator.frequency.value = freq;
    // 当前时间设置音量为0
    gainNode.gain.setValueAtTime(0, this.audioCtx.currentTime);
    // 0.01秒后音量为1
    gainNode.gain.linearRampToValueAtTime(1, this.audioCtx.currentTime + 0.01);
    // 音调从当前时间开始播放
    oscillator.start(this.audioCtx.currentTime);
    // this.opts.duration秒内声音慢慢降低,是个不错的停止声音的方法
    gainNode.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + this.opts.duration);
    // this.opts.duration秒后完全停止声音
    oscillator.stop(this.audioCtx.currentTime + this.opts.duration);
  }
}

然后我们就可以通过这个类创建的对象来调用createSound()方法了。

let music = new MusicBox({
  type: 'square',  // 音色类型  sine|square|triangle|sawtooth
  duration: 5  // 键音延长时间
});

music.createSound(262);   // 发出一个频率为262Hz的声音

打开浏览器运行代码,你就能听到一个时长为5秒渐渐减弱的声音。

5. 让音符“动”起来吧!

现在我们的这个MusicBox类功能还很单一,只能用来发出某一频率的声音,而我们真正想要做的,是想要让它产生悦耳动听的音乐,所以接下来我们要做的就是将乐谱中的音符转换为音频播放出来。

① 创建音符数组与音阶频率数组

// 音阶频率
this.arrFrequency = [262, 294, 330, 349, 392, 440, 494, 523, 587, 659, 698, 784, 880, 988, 1047, 1175, 1319, 1397, 1568, 1760, 1967];
// 音符
this.arrNotes = ['·1', '·2', '·3', '·4', '·5', '·6', '·7', '1', '2', '3', '4', '5', '6', '7', '1·', '2·', '3·', '4·', '5·', '6·', '7·'];

音符数组中,小圆点在数字前代表低音,在数字后代表高音,比如 ·1 代表低音Do,比如 代表高音Do。有了这两个数组,就可以将音符与频率对应起来,这样也就能把音符转换成声音了。

② 把音符转成乐音

继续给MusicBox类添加播放乐音的方法。

createMusic(note){
    let index = this.arrNotes.indexOf(note);
    if(index !== -1){
      this.createSound(this.arrFrequency[index]);
    }
}

然后调用该方法,我们就能发出悦耳的乐音了。

music.createMusic('1');  // 发出中音 Do

二、“钢琴”自动演奏

1. 绘制“钢琴”

<!-- 核心HTML -->
<div class="music-box"></div>
/* 核心CSS */
.music-box ul{ display: flex; display: -webkit-flex; justify-content: center; -webkit-justify-content: center; width: 96%; margin: 0 auto 12%;}
.music-box li{ flex: 1; -webkit-flex: 1; position: relative; margin: 0 4px; color: #fff; font-size: 16px;}
.music-box li span{ display: block; height: 200px; box-shadow: 0 0 10px #000; border-radius: 4px; background-color: #fff; cursor: pointer;}
.music-box li span.cur{ background-color: #999;}
.music-box li i{ position: absolute; bottom: -30px; width: 100%; text-align: center; font-style: normal;}
.music-box li i.low::before,
.music-box li i.high::before{ content: '·'; position: absolute; left: 0; width: 100%;}
.music-box li i.low::before{ bottom: -10px;}
.music-box li i.high::before{ top: -10px;}
/* 核心JS */
draw(){    // 绘制钢琴
    this.musicBtn = null;

    let musicBtns = document.querySelector(this.selector),
        li = '',
        noteClass = '';

    //  绘制钢琴键
    for(let i = 0; i < this.arrFrequency.length; i++){
      noteClass = this.arrNotes[i][0] === '·' ? 'low' : (this.arrNotes[i][1] === '·' ? 'high' : '');
      li += '<li><span></span><i class="'+ noteClass +'">'+ this.arrNotes[i].replace(/\·/,'') +'</i></li>'
    }

    musicBtns.innerHTML = '<ul>'+ li +'</ul>';

    // 给钢琴按键绑定方法
    let oLi = musicBtns.querySelectorAll('li');
    for(let i = 0; i < this.arrFrequency.length; i++){
      oLi[i].addEventListener('mousedown',(e)=>{
        this.pressBtn(e.target,i);
      })
    }

    this.musicBtn = musicBtns.querySelectorAll('li span');

    // 鼠标起来时样式消失
    document.onmouseup = () => {
      for(let i = 0; i < this.arrFrequency.length; i++){
        this.musicBtn[i].className = '';
      }
    };
}

pressBtn(obj,i) {   // 按下钢琴键
    obj.className = 'cur';
    this.createSound(this.arrFrequency[i]);
    setTimeout(() => {
      this.musicBtn[i].className = '';
    },200);
}

然后调用draw()方法就可以绘制钢琴。

constructor(selector, options){
    let defaults = {
      type: 'sine',  // 音色类型  sine|square|triangle|sawtooth
      duration: 2  // 键音延长时间
    };

    this.selector = selector;
    this.opts = Object.assign(defaults, options);

    // 创建新的音频上下文接口
    this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();

    // 音阶频率
    this.arrFrequency = [262, 294, 330, 349, 392, 440, 494, 523, 587, 659, 698, 784, 880, 988, 1047, 1175, 1319, 1397, 1568, 1760, 1967];
    // 音符
    this.arrNotes = ['·1', '·2', '·3', '·4', '·5', '·6', '·7', '1', '2', '3', '4', '5', '6', '7', '1·', '2·', '3·', '4·', '5·', '6·', '7·'];

    // 绘制钢琴
    this.draw();
}
let music = new MusicBox('.music-box', {
  type: 'square',  // 音色类型  sine|square|triangle|sawtooth
  duration: 5  // 键音延长时间
});

2. 自动演奏

// 播放乐谱
playMusic(musicText, speed = 2) {
    let i = 0, musicArr = musicText.split(' ');

    let timer = setInterval(() => {

      try{
        let n = this.arrNotes.indexOf(musicArr[i]);  // 钢琴键位置

        if(musicArr[i] !== '-' && musicArr[i] !== '0'){
          this.pressBtn(this.musicBtn[n],n);
        }
        i++;

        if(i >= musicArr.length){
          this.opts.loop ? i = 0 : clearInterval(timer);
        }
      }
      catch (e) {
        alert('请输入正确的乐谱!');
        clearInterval(timer);
      }

    }, 1000 / speed);

    return timer;
}
constructor(selector, options){
    let defaults = {
      loop: false, // 循环播放
      musicText: '',  // 乐谱
      autoplay: false, // 自动弹奏速度
      type: 'sine',  // 音色类型  sine|square|triangle|sawtooth
      duration: 2  // 键音延长时间
    };

    this.selector = selector;
    this.opts = Object.assign(defaults, options);

    // 创建新的音频上下文接口
    this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();

    // 音阶频率
    this.arrFrequency = [262, 294, 330, 349, 392, 440, 494, 523, 587, 659, 698, 784, 880, 988, 1047, 1175, 1319, 1397, 1568, 1760, 1967];
    // 音符
    this.arrNotes = ['·1', '·2', '·3', '·4', '·5', '·6', '·7', '1', '2', '3', '4', '5', '6', '7', '1·', '2·', '3·', '4·', '5·', '6·', '7·'];

    // 绘制钢琴
    this.draw();

    // 播放乐谱
    this.opts.autoplay && this.playMusic(this.opts.musicText, this.opts.autoplay);
}

然后我们就可以输入自己想听曲子的乐谱,“钢琴”就会自动给你演奏,尽情享受吧!

let music = new MusicBox('.music-box', {
  loop: true, // 循环播放
  musicText: '1 1 5 5 6 6 5 - 4 4 3 3 2 2 1 - 5 5 4 4 3 3 2 - 5 5 4 4 3 3 2 - 1 1 5 5 6 6 5 - 4 4 3 3 2 2 1 - -',  // 儿歌《小星星》乐谱
  autoplay: 2, // 自动弹奏速度
  type: 'square',  // 音色类型  sine|square|triangle|sawtooth
  duration: 5  // 键音延长时间
});

musicText为乐谱,其中-为延音符,每个音符之间用空格隔开,这里我导入的是儿歌《小星星》

三、“钢琴”插件源码

class MusicBox {

  constructor(selector, options){

    let defaults = {

      loop: false, // 循环播放
      musicText: '',  // 乐谱
      autoplay: false, // 自动弹奏速度
      type: 'sine',  // 音色类型  sine|square|triangle|sawtooth
      duration: 2  // 键音延长时间

    };

    this.selector = selector;
    this.opts = Object.assign(defaults, options);

    // 创建新的音频上下文接口
    this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();

    // 音阶频率
    this.arrFrequency = [262, 294, 330, 349, 392, 440, 494, 523, 587, 659, 698, 784, 880, 988, 1047, 1175, 1319, 1397, 1568, 1760, 1967];
    // 音符
    this.arrNotes = ['·1', '·2', '·3', '·4', '·5', '·6', '·7', '1', '2', '3', '4', '5', '6', '7', '1·', '2·', '3·', '4·', '5·', '6·', '7·'];

    // 绘制钢琴
    this.draw();

    // 播放乐谱
    this.opts.autoplay && this.playMusic(this.opts.musicText, this.opts.autoplay);

  }

  // 创建乐音
  createMusic(note){

    let index = this.arrNotes.indexOf(note);

    if(index !== -1){
      this.createSound(this.arrFrequency[index]);
    }

  }

  // 创建声音
  createSound(freq) {

    // 创建一个OscillatorNode, 它表示一个周期性波形(振荡),基本上来说创造了一个音调
    let oscillator = this.audioCtx.createOscillator();
    // 创建一个GainNode,它可以控制音频的总音量
    let gainNode = this.audioCtx.createGain();
    // 把音量,音调和终节点进行关联
    oscillator.connect(gainNode);
    // this.audioCtx.destination返回AudioDestinationNode对象,表示当前audio context中所有节点的最终节点,一般表示音频渲染设备
    gainNode.connect(this.audioCtx.destination);
    // 指定音调的类型  sine|square|triangle|sawtooth
    oscillator.type = this.opts.type;
    // 设置当前播放声音的频率,也就是最终播放声音的调调
    oscillator.frequency.value = freq;
    // 当前时间设置音量为0
    gainNode.gain.setValueAtTime(0, this.audioCtx.currentTime);
    // 0.01秒后音量为1
    gainNode.gain.linearRampToValueAtTime(1, this.audioCtx.currentTime + 0.01);
    // 音调从当前时间开始播放
    oscillator.start(this.audioCtx.currentTime);
    // this.opts.duration秒内声音慢慢降低,是个不错的停止声音的方法
    gainNode.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + this.opts.duration);
    // this.opts.duration秒后完全停止声音
    oscillator.stop(this.audioCtx.currentTime + this.opts.duration);

  }

  // 绘制钢琴
  draw(){

    this.musicBtn = null;

    let musicBtns = document.querySelector(this.selector),
        li = '',
        noteClass = '';

    for(let i = 0; i < this.arrFrequency.length; i++){
      noteClass = this.arrNotes[i][0] === '·' ? 'low' : (this.arrNotes[i][1] === '·' ? 'high' : '');
      li += '<li><span></span><i class="'+ noteClass +'">'+ this.arrNotes[i].replace(/\·/,'') +'</i></li>'
    }

    musicBtns.innerHTML = '<ul>'+ li +'</ul>';

    let oLi = musicBtns.querySelectorAll('li');
    for(let i = 0; i < this.arrFrequency.length; i++){
      oLi[i].addEventListener('mousedown',(e)=>{
        this.pressBtn(e.target,i);
      })
    }

    this.musicBtn = musicBtns.querySelectorAll('li span');

    // 鼠标起来时样式消失
    document.onmouseup = () => {
      for(let i = 0; i < this.arrFrequency.length; i++){
        this.musicBtn[i].className = '';
      }
    };

  }

  // 按下钢琴键
  pressBtn(obj,i) {

    obj.className = 'cur';
    this.createSound(this.arrFrequency[i]);
    setTimeout(() => {
      this.musicBtn[i].className = '';
    },200);

  }

  // 播放乐谱
  playMusic(musicText, speed = 2) {

    let i = 0, musicArr = musicText.split(' ');

    let timer = setInterval(() => {

      try{
        let n = this.arrNotes.indexOf(musicArr[i]);  // 钢琴键位置

        if(musicArr[i] !== '-' && musicArr[i] !== '0'){
          this.pressBtn(this.musicBtn[n],n);
        }
        i++;

        if(i >= musicArr.length){
          this.opts.loop ? i = 0 : clearInterval(timer);
        }
      }
      catch (e) {
        alert('请输入正确的乐谱!');
        clearInterval(timer);
      }

    }, 1000 / speed);

    return timer;

  }

}

window.MusicBox = MusicBox;

export default MusicBox;

至此,我们其实已经使用ES6语法完成了一个音乐盒插件的开发,由于ES6语法目前浏览器支持还不是很完善,所以可以使用Webpack进行打包处理。有关Webpack的基础入门教程,可以关注我相关系列文集 → Webpack轻松入门


结束语

星星之火,可以燎原。一个可能很小的创意,都有可能产生一个伟大的产品。这台“钢琴”很小,仍有很多功能等着大家去完善,还有很多创意点等着大家去发现!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • 前言: 记载资料多为网络搜集,侵删。 根据最近接触的整机项目做了一些整机音频相关基础知识的总结,如有不足或表述问题...
    Gawain_Knowknow阅读 7,844评论 0 4
  • 教你100条乐理基础小知识 1:音(Tone) 是一种物理现象。物体振动时产生音波,通过空气传到耳膜,经过大脑的反...
    af88c59abbf6阅读 1,558评论 0 3
  • 今天看到孩子的手机、电话手表和Ipad ,突然想问孩子一个问题:宝贝,你觉得你富有吗? 孩子:我觉得我很富有啊! ...
    榜样妈妈在行动阅读 214评论 0 0
  • 今天感恩节,我不习惯外露表达,恰恰在两天前机缘巧合的要开始记录感激之事,一切皆是缘起。 1.感激老天今天在马上要被...
    想你是件很美的事阅读 270评论 0 0
  • 假若我是你 一个世纪我要做一路的标记 清楚的记得每一次的轮回 假若我是你 我要埋葬诗人,唤醒战士 宁愿去随战士上战...
    三尺冷阅读 143评论 0 2