Web MIDI 入门:如何用电子钢琴做一款游戏

3,402 阅读14分钟
译注:本文是作者 Peter Anglea 发布在 《SmashingMagazine》上的一篇介绍 Web MIDI API 及用其开发一款电子钢琴游戏的文章。(转载请注明出处)

原文链接www.smashingmagazine.com/2018/03/web…

原文作者Peter Anglea 译者西楼听雨

随着 Web 的不断发展,浏览器新技术的不断涌现,本地开发和 Web 开发之间的分界线变得越来越模糊。新的 API 使得在浏览器中开发各类新型软件的能力得到释放。

就在不久之前,与数码乐器进行交互的能力还一直被局限在本地和桌面应用中,现在,Web MIDI API 的到来就是为了改变这个现状。

在本文中,我们会对 MIDI 及 Web MIDI API 进行基本的讨论,展示它对于创建一个可以响应乐器相关输入的Web应用有多简单。

什么是 MIDI?

MIDI 其实已经存在相当长的一段时间了,但在浏览器中的亮相还是首次。MIDI (Musical Instrument Digital Interface : 乐器数字接口)是一种技术标准,于1983年首次发布,旨在为数码乐器,混音合成器,电脑,及各类音频设备之间的通讯创建一种方式。在这些设备之间传递的 MIDI 消息是音符的和基于时间的信息。

一套典型的 MIDI 配置,通常会有一个数字钢琴键盘,这个键盘可以把各类消息,如音调、颤音、音量、平移、调节等等发送给一个混音合成器,进而转化为可听见的声音,也可以向桌面类音频音符化展示软件及数字音频工作站(DAW)发送信号,进而转化为音符,保存为文件等。

MIDI 是一个非常多才的协议。除了播放和录制音乐外,它已经成为舞台、剧院类应用的一种标准协议,常用于灯光设备的控制及场景信息的提示。

A performer plays a digital piano onstage


浏览器中的 MIDI

WebMIDI API 通过 javascript 给浏览器带来了所有 MIDI 所具备的好处。我们只需要学习几个方法和对象即可。

介绍

首先,这有一个 navigator.requestMIDIAccess() 方法,它的作用就和它名字一样——发起访问连接到你电脑上的 MIDI 设备的请求。你可以通过检查这个方法的存在性来确认浏览器是否支持这个 API。

if (navigator.requestMIDIAccess) {
    console.log('该浏览器支持 WebMIDI!');
} else {
    console.log('WebMIDI 不被该浏览器支持。');
}

第二,我们还有一个 MIDIAccess 对象,它包含了所有可用的输入设备(如钢琴键盘)、输出设备(如混音合成器)的引用。调用 requestMIDIAccess() 方法会返回一个 promise,如果浏览器与你的 MIDI 设备连接成功,它将返回一个 MIDIAccess 对象并将其作为连接成功的回调函数的一个参数。

navigator.requestMIDIAccess()
    .then(onMIDISuccess, onMIDIFailure);

function onMIDISuccess(midiAccess) {
    console.log(midiAccess);

    var inputs = midiAccess.inputs;
    var outputs = midiAccess.outputs;
}

function onMIDIFailure() {
    console.log('无法访问你的 MIDI 设备。');
}

第三,MIDI 消息在输入和输出设备之前的往返都是通过一个 MIDIMessageEvent 进行传递的。这些消息包含了关于 MIDI 事件的信息,如“音调”、“音频”、“力度”、“时间”等等。我们可以通过向这些输入、输出设备添加回调函数(监听器)来接收这些消息。

深入“腹地”

我们接着深入。为了让 MIDI 设备可以发送消息到浏览器中,我们需要在每个 input 上添加一个 onmidimessage 监听器,每次当输入设备发送一个消息,例如钢琴键盘的一次按键,这个回调就会被触发。

我们可以像下面这样遍历 inputs 添加监听器:

function onMIDISuccess(midiAccess) {
    for (var input of midiAccess.inputs.values())
        input.onmidimessage = getMIDIMessage;
    }
}

function getMIDIMessage(midiMessage) {
    console.log(midiMessage);
}

我们取到的 MIDIMessageEvent 对象会包含许多信息,但我们最感兴趣的是它的数据数组(data 属性)。通常这个数组包含三个值(例如:[144, 72, 64]),第一个值告诉我们发送的命令是什么类型,第二个是 note 值(译:可理解为音符、键位值),第三个是力度值(velocity)。命令的类型可以是“note on”、“note off”、控制器(如弯音轮、钢琴踏板)及其他与这台设备相关的系统专用的事件。

考虑到本文的主旨,我们的焦点只集中在识别“note no”和“note off”消息上。下面是他们的基本概念:

  • 命令类型值为 144 时,表示“note on”事件,128 则表示“note off”事件。
  • note 值的范围为 0-127。例如,在88键钢琴上,最小的值为 21,最大的值为 108。“C 中键”的值为 60。
  • 力度值的范围也是 0-127 (最温和到“最喧闹”)。事实上可能的最温和的“note on”时的力度值为 1。
  • 有时会将 144 命令类型值伴随着 0 力度值来表示“note off”消息,所以对于 0 力度值得检查也是识别“note off”所必要的。

基于以上认识,我们可以像下面这样展开前面我们的 getMIDIMessage 示例 :通过对来自输入设备的 MIDI 消息的分析,将不同意义上的消息进一步传递给其他处理函数进行处理。

function getMIDIMessage(message) {
    var command = message.data[0];
    var note = message.data[1];
    var velocity = (message.data.length > 2) ? message.data[2] : 0; // 在 noteoff 命令中,不一定会包含 velocity 值

    switch (command) {
        case 144: // noteOn
            if (velocity > 0) {
                noteOn(note, velocity);
            } else {
                noteOff(note);
            }
            break;
        case 128: // noteOff
            noteOff(note);
            break;
        // we could easily expand this switch statement to cover other types of commands such as controllers or sysex
        // 我们也以可非常容易地扩展这个 switch 语句来覆盖其他类型的命令    
    }
}

浏览器兼容性及相关垫片库(polyfill)

在写作本文的这个时间点,Web MIDI API 仅被 Chrome、Opera、安卓 WebView 从本地上支持。


对于其他不支持的浏览器,Chris Wilson 的 WebMIDIAPIShim 库可以作为 Web MIDI API 的一个垫片库使用。只需要在你的页面上引用这个垫片脚本,就可以拥有上面提到的所有特性。

<script src="WebMIDIAPI.min.js"></script>    
<script>
if (navigator.requestMIDIAccess) { //... returns true
</script>

不过,要使用这个垫片库,还需要安装 Jazz-Soft.net 的 Jazz-Plugin,也就是说,非常不幸,虽然对于只是需要保持灵活性的开发人员来说是没关系的,但是对于主流人群的话就是一个障碍了。但愿,随着时间的发展,其他浏览器也会相继对其进行本地上的支持。

使用 webmidi.js 来使我们的工作变的简单

目前为止,对于 WebMIDI API 带来的所有可能性,我们还只是做了比较肤浅、局部的了解。如果还要支持除了基础的“note on”和“note off”消息外的其他功能,事情就会变得非常复杂。

如果你想找到一个不错的 javascript 库来急剧地简化你的代码,请选择由 Jean-Philippe Côté 发布在 Github 上的 WebMidi.js。这个库对 MIDIAccess 和 MIDIMessageEvent的解析做了一个很好的抽象,让你可以以一种极简单的方式添加、移除某个特定事件的监听器。

WebMidi.enable(function () {

    // 查看可用的输入、输出设备
    console.log(WebMidi.inputs);
    console.log(WebMidi.outputs);

    // 通过 name、id 或者 index 来获取一个输入设备
    var input = WebMidi.getInputByName("My Awesome Keyboard");
    // 或者
    // input = WebMidi.getInputById("1809568182");
    // input = WebMidi.inputs[0];

    // 在所有通道监听 'note on' 消息
    input.addListener('noteon', 'all',
        function (e) {
            console.log("收到 'noteon' 消息 (" + e.note.name + e.note.octave + ").");
        }
    );

    // 在通道3监听 'pitchben' 消息
    input.addListener('pitchbend', 3,
        function (e) {
            console.log("收到 'pitchbend' 消息.", e);
        }
    );

    // 在所有通道监听“controlchange”消息
    input.addListener('controlchange', "all",
        function (e) {
            console.log("收到'controlchange' 消息.", e);
        }
    );

    // 移除所有通道上的 'noteoff' 监听器
    input.removeListener('noteoff');

    // 移除所有监听器
    input.removeListener();

});

真实场景:制作一个由钢琴键盘控制的室内脱逃游戏

几个月前,我和我的妻子做了个决定,决定在家里创建一个“室内逃脱”体验的决定,以给我们的朋友和家庭带来欢乐。我们想着这个游戏应该包含一些特效以提升体验。但不幸,我俩都没有过硬的工程技能,利用磁铁、激光以及电线制作复杂的锁具和特效都在我俩的专业范围之外。不过,我会——我对我的和浏览器打交道的工作非常了解,恰好我们又有一台电子钢琴。

因此,这个想法就随之浮现了。我们决定把制作在电脑上的一系列密码锁作为游戏的核心部分,玩家需要在我们的钢琴上弹出指定的音符序列才能解锁,a la Willy Wonka.

This is a musical lock
这是一个音乐锁

听起来很酷是吧?那我们来看下如何实现它吧。


起架

首先我们将从发起访问 WebMIDI 请求开始,然后对我们的键盘进行识别,接着添加相应的事件监听器,同时创建几个变量和函数来给我们在游戏的各个阶段提供帮助。

// 该变量告诉我们游戏当前所在的步骤。
// 我们将在之后解析 noteOn/Off 消息时使用它
var currentStep = 0;

// 请求访问 MIDI
if (navigator.requestMIDIAccess) {
    console.log('该浏览器支持 Web MIDI!');

    navigator.requestMIDIAccess().then(onMIDISuccess, onMIDIFailure);

} else {
    console.log('WebMIDI 不被该浏览器所支持.');
}

// 该函数用于在 requestMIDIAccess 成功后执行
function onMIDISuccess(midiAccess) {
    var inputs = midiAccess.inputs;
    var outputs = midiAccess.outputs;

    // 对每个 input 添加 MIDI 事件监听器
    for (var input of midiAccess.inputs.values()) {
        input.onmidimessage = getMIDIMessage;
    }
}

// 该函数用户在 requestMIDIAccess 失败后执行
function onMIDIFailure() {
    console.log('错误: 无法访问 MIDI 设备.');
}

// 该函数用于对我们接收到的 MIDI 消息进行解析
// 在这个应用中,我们只关心 note 值
// 当然我们也可以对其他信息进行解析
function getMIDIMessage(message) {
    var command = message.data[0];
    var note = message.data[1];
    var velocity = (message.data.length > 2) ? message.data[2] : 0;

    switch (command) {
        case 144: // note on
            if (velocity > 0) {
                noteOn(note);
            } else {
                noteOff(note);
            }
            break;
        case 128: // note off
            noteOffCallback(note);
            break;
    }
}

// 该函数用于处理 noteOn 消息(即,琴键被按下)
// 可以把他想象成 'onkeydown' 事件
function noteOn(note) {
    //...
}

// 该函数用于处理 noteOff 消息(即,琴键被释放)
// 可以把他想象成 'onkeyup' 事件
function noteOff(note) {
    //...
}

// 该函数用于触发特定的动画和推动游戏进入下一个环节
// 例如,一把锁被解开之后,或者计时器完成之后
function runSequence(sequence) {
    //...
}

第1步:按任意键开始

要开始游戏,玩家只需按任意键即可。这个步骤非常容易,同时可以向他们展示一些游戏玩法的信息,接着启动一个倒计时计时器。

function noteOn(note) {
    switch(currentStep) {
        // 如有游戏还没有开始,
        // 那么我们收到的第一个 noteOn 消息将触发第一个序列的运行
        case 0: 
            // 运行我们的游戏开始序列
            runSequence('gamestart');

            // 增加 currentStep,以确保该序列只被执行一次
            currentStep++;
            
            break;
    }
}

function runSequence(sequence) {
    switch(sequence) {
        case 'gamestart':            
            // 现在开始启动倒数计时器
            startTimer();
            
            // 触发动画的及给与第一把锁的线索的代码
            break;
    }
}

第2步:弹奏出正确的音符序列

第一把锁要求玩家必须按照正确的顺序弹奏出一个特定的符号序列。 

要实现这个锁,对于每个“note on”消息,我们需要把 note 值附加到一个数组后面,然后检查它是否与一个预定义的数组匹配。

我们假设玩家可以根据我们在室内的声音提示找到对应的键位,在本例中,我们以F大调的歌曲"Amazing Grace"的开头为提示音。它的键位序列如下图所示。

A visual representation of the first nine notes of “Amazing Grace” on a piano
它正确的 MIDI 音符数组为: [60, 65, 69, 65, 69, 67, 65, 62, 60].
var correctNoteSequence = [60, 65, 69, 65, 69, 67, 65, 62, 60]; // Amazing Grace in F
var activeNoteSequence = [];

function noteOn(note) {
    switch(currentStep) {
        // ... (case 0)

        // 第一把锁——弹奏出正确的序列
        case 1:
            activeNoteSequence.push(note);

            // 当数组的长度与正确的数组的一样是,进行匹配
            if (activeNoteSequence.length == correctNoteSequence.length) {
                var match = true;
                for (var index = 0; index < activeNoteSequence.length; index++) {
                    if (activeNoteSequence[index] != correctNoteSequence[index]) {
                        match = false;
                        break;
                    }
                }

                if (match) {
                    // 运行下一个序列,并增加当前步骤值
                    runSequence('lock1');
                    currentStep++;
                } else {
                    // 清空数组,以重头计算
                    activeNoteSequence = [];
                }
            }
            break;
    }
}

function runSequence(sequence) {
    switch(sequence) {
        // ...

        case 'lock1':
            // 触发动画并给与下一把锁的提示的代码
            break;
    }
}

弟3步:弹奏出正确的和弦(译:同时按下键位的组合)

下一把锁要求玩家找到正确的键位组合(同时),这个时候就是“note off”登台的时候了。对于每个“note on”消息,我们会把其 note 值添加到一个数组,而对于每个“note off”消息,我们又会把它的 note 值从数组中移除,这样的话这个数组就可以反应出任意时刻的键位组合状态。然后,剩下的就是在每次添加一个 note 值时对这个数组进行验证了,看看它是否匹配我们的目标数组。

我们将正确答案设置为由中 C 键开始的 C7 和弦,像下图所示的这样。

A visual representation of a C7 chord on a piano

它的正确的 MIDI note 值数组为: [60, 64, 67, 70].

var correctChord = [60, 64, 67, 70]; // C7 chord starting on middle C
var activeChord = [];

function noteOn(note) {
    switch(currentStep) {
        // ... (case 0, 1)

        case 2:
            // 把该 note 值添加至实时数组
            activeChord.push(note);

            // 如果数组的长度与正确答案的一致,则进行匹配
            if (activeChord.length == correctChord.length) {
                var match = true;
                for (var index = 0; index < activeChord.length; index++) {
                    if (correctChord.indexOf(activeChord[index]) < 0) {
                        match = false;
                        break;
                    }
                }

                if (match) {
                    runSequence('lock2');
                    currentStep++;
                }
            }
            break;
    }

function noteOff(note) {
    switch(currentStep) {
        case 2:
            // 从实时数组中移除该 note 值
            activeChord.splice(activeChord.indexOf(note), 1);
            break;
    }
}

function runSequence(sequence) {
    switch(sequence) {
        // ...

        case 'lock2':
            // 触发动画,停止计时器,并结束游戏
            stopTimer();

            break;
    }
}

到这,剩下的就是添加一些额外的界面元素和动画了,然后我们的游戏就可以开始使用了。

下面是一个该游戏从开始到结束的完整操作的视频,是在 Google Chrome 下演示的,同时也会显示一个虚拟的 MIDI 键盘以帮助查看当前各键位的按压状态。正常来说,我们应该把这种室内逃脱场景的游戏以全屏模式运行,并拿掉其他的输入设备(如鼠标、电脑键盘),以此防止用户关闭游戏窗口。

游戏演示视频:v.youku.com/v_show/id_X…

如果你身边没有 MIDI 设备,而你又想做一下尝试,没关系,网上有许多虚拟 MIDI 键盘类应用可以把你的电脑键盘作为乐器,如,VMPK。如果你想对上面这个游戏做详细探究,可 check out 这个游戏在 CodePen 上的完整原型。

该游戏的 CodePen 链接:codepen.io/peteranglea…

结语

MIDI.org 上有一句话“在相当常的一段时间内,Web MIDI 有可能会是最具颠覆性的音频类技术,也许会跟 MIDI 在1983年时最初的那样。”这是一个非常高的要求也是一个极高的赞美。

对于新型的、令人激动的基于浏览器的音乐应用的发展,这个 API 所带来的推动效果,我希望本文及文中的示例应用已经使你感受到了。很有可能,在接下来的几年,我们可以开始看到更多的在线音符类软件,如数字音频工作站,音频可视化应用,乐器教程等等。

如果你希望了解更多关于 Web MIDI 以及它所具备的能力的信息,我推荐你阅读下面这些:

如果想获得更多的启发,下面是其他一些经过实践的案例:

  • Web 音频 MIDI 合成器(Web Audio MIDI Synthesizer
    一个可以通过 MIDI 设备控制的简单的合成器
  • Web 音频电子鼓乐合成器(Web Audio Drum Machine
    一个有趣的可以制作你自己的鼓循环的应用(A fun app to create your own drum loops)
  • Noteflight
    一个在线乐谱制作应用,支持通过 Web MIDI 作为可能的输入方法