音乐研发必备:理解 MIDI 协议与标准 MIDI 文件格式

568次阅读  |  发布于2年以前

1 . MIDI 简介

MIDI 协议即数字音乐接口(Musical Instrument Digital Interface),是电子乐器、合成器等演奏设备之间的一种即时通信协议,用于硬件之间的实时演奏数据传递。MIDI 协议诞生之初希望解决的事情是通过统一通信协议让不同乐器制造商的设备可以互相兼容,比如把 Roland 键盘接入 Yamaha 合成器。MIDI 协议的编码经过拓展后也可以作为一种记录音乐信息的文件格式,被称为“标准 MIDI 文件格式”。

在音乐技术研发中除了需要与音频打交道之外,许多场景中还需要直接处理音符信息。如果说 wav 与 mp3 记录的是音乐的物理现象,那么 MIDI 协议与 MIDI 文件则记录的是音乐这门语言的“文字”。本文的目的是让开发中涉及到音乐“本体”的同学可以了解这一最通用的演奏信息交互和文件存储格式的编码规则。同时通过对 MIDI 事件流等概念的认识,能在开发中更好地抽象自己的业务逻辑。

1.1 MIDI 数据流 & 编码

和 HTTP 这类协议不同,MIDI 作为传输协议时所有传递的信息都需要被实时响应,比如一个触键信息、一个效果器参数的改变都需要立刻被执行,所以其采用数据流的方式进行数据传输。MIDI 定义了一个 8 位的二进制数据流,许多时候我们可以使用 ASCII 码来将其表示为 16 进制的字符用于传输和保存。

对于 MIDI 标准文件格式来说,其存储的内容也是 MIDI 产生的事件流。一段典型的 MIDI 文件长这样:

4D 54 68 64 00 00 00 06 00 01 00 03 01 E0 4D 54
72 6B 00 00 00 1A 00 FF 03 03 31 32 33 00 FF 51
03 08 7A 23 00 FF 58 04 04 02 18 08 00 FF 2F 00
...

上面这个例子可能会造成一些困惑,因为 MIDI 文件确实对人类阅读不太友好,但其编码规则实际上是较易掌握的,下面我们就来逐步认识 MIDI 的编码规则。

注:在本文中,一个字节的最低有效位为第 0 位,最高有效位是第 7 位。比如在 X000 000Y 中,X 为第 7 位,Y 为第 0 位。

1.2 MIDI 消息

MIDI 最核心的功能是用于传输实时的音乐演奏信息,这些信息本质上是一条条包含了音高、力度、效果器参数等信息的指令,我们将这些指令称之为 MIDI 消息(MIDI message)。一条 MIDI 消息通常由数个字节组成,其中第一个字节被称为 STATUS byte,其后面有跟有数个 DATA bytes。STATUS byte 第七位为 1,而 DATA byte 第七位为 0。

开头的 STATUS byte 有两个作用:一个作用是表示系统或者某个信道状态的改变,其二个作用是确定当前 MIDI Message 的类型,MIDI 类型会确定后面 DATA byte 的数量和意义。这样说比较空洞,下面我们举一个例子:

Status byte : 1100 CCCC
Data byte 1 : 0XXX XXXX
Status byte : 1001 CCCC
Data byte 1 : 0PPP PPPP
Data byte 2 : 0VVV VVVV

第一个 STATUS byte 告诉我们这是一个进行乐器选择的 MIDI Message(1100 为乐器选择指令,CCCC 是信道编号)。乐器选择的 MIDI Message 只有一条 DATA byte,而这条 DATA Byte 的数据表示选择的乐器编号。第二条 1001 开头的 STATUS byte 则告诉我们这是一条 Note On 类型 MIDI message,这个类型按照约定有两个 DATA byte。

除了向整个系统发送的 MIDI 消息, STATUS byte 通常包含了信道编号(即例子中的 CCCC),16 个信道分别从 0000 到 1111。而向整个系统发送的 MIDI 信息则以 1111 开头,原来的信道编号变成了指令编号(比如播放指令:1111 1010,终止指令:1111 1100)。

需要注意的是,许多时候我们会连续发送许多相同状态的 MIDI 消息,这个时候可以省略 STATUS byte,合成器会沿用最后一个接收的 STATUS byte,被合成器记录的状态称之为 MIDI RUNNING STATUS。

总结一下:

  • 一条 MIDI message 由 STATUS byte 和 Data byte 构成。
  • STATUS byte 以 1 开头,DATA byte 以 0 开头。
  • STATUS byte 确定消息的类型。后面的 DATA 字节数取决于消息的类型。
  • STATUS byte 通常包含信道编号,除了面向系统发送的指令。
  • 连续相同的 STATUS byte 可以省略。

2 . 常用 MIDI Message

MIDI Message 不需要全部掌握,需要的时候可以直接到 MIDI 标准中查询,日常开发中只需要了解常用的几种 MIDI Message 即可。下面笔者介绍最常用的几种 MIDI Message。

2.1 NOTE ON & NOTE OFF - 音符的触发与终止

NOTE ON 和 NOTE OFF 是最主要的两个 MIDI Message。当演奏者敲击音乐键盘的琴键时发送 NOTE ON 消息,它包含了音高以及“力度”的参数。当合成器收到此消息时,它会开始以相应的音高和“力度”播放该音符。当收到 NOTE OFF 消息时,合成器会终止该音符。

每个 NOTE ON 消息都需要相应的 NOTE OFF 消息,否则该音符将一直处于播放状态。但打击乐器可以只发送 NOTE ON,因为打击乐音符会自动停止。但最好养成始终发送 NOTE OFF 的习惯,因为不同合成器对这一特性的实现可能不一样。

下面我们举例说明 NOTE ON:

Status byte : 1001 CCCC
Data byte 1 : 0PPP PPPP
Data byte 2 : 0VVV VVVV

在这个例子中,1001 可以理解为 NOTE ON 事件的编码,CCCC 是信道编号。

PPP PPPP 表示音高值,在 General MIDI 协议中(后文会提到),通常使用 69 表示标准音 A4(440 Hz),音高值增减一,就增减一个半音。比如 60 表示 C4(中央 C), 61 表示 C#4。同样,升高或者降低八度只需要在当前音高上增减 12 即可。

VVV VVVV 表示速率(velocity),这个速率可以理解为敲击键盘的速度,或者管乐器气流的速度。在最基础的合成器中,速率仅用于确定弹奏音符的力度,唯一的效果是音符音量变大或变小。总体来说,下面这张表可以作为速率和乐谱中的力度记号的对应关系参考:

但在在一些复杂的仿真建模合成器中,速率也会影响音色。我们以 Galaxy Steinway 采样器为例:

图片来源:https://zhuanlan.zhihu.com/p/19964066

左侧是小力度敲击的频域图,右侧是大力度敲击的频域图。我们可以看到大力度敲击不仅产生了更多的泛音,也在低频区产生了一些噪音(木材被撞击的声音)。

注:关于 velocity 可以参考附录的介绍。

NOTE OFF 消息和 NOTE ON 消息基本一样:

Status byte : 1000 CCCC
Data byte 1 : 0PPP PPPP
Data byte 2 : 0VVV VVVV

其中 CCCC 和 PPPPPPP 含义同上。VVVVVVV 是释放速率,可以看作是按键抬起的速度,这个值很少使用,通常将其设置为零。另外,在实践中经常使用速率为 0 的 NOTE ON 消息取代 NOTE OFF 消息。

需要额外说明的是 MIDI 协议还提供了一组 All Notes Off 消息,当某个信道接收到 All Note Off 消息之后会关闭所有还在发音的振荡器,通常来说 All Notes Off 消息用于在演奏、播放结束后用于清理状态,这里不多赘述。

2.2 乐器选择

乐器选择消息的格式如下:

Status byte : 1100 CCCC
Data byte 1 : 0XXX XXXX

其中唯一的一个 DATA byte 表示乐器编号,支持 128 个不同的乐器。由于不同的软件上存在的乐器音源并不一致,为了让 A 设备上创建的标准 MIDI 文件在 B 设备上播放时听起来相似,乐器厂商边采用 General MIDI 协议来编排音源。Gerneral MIDI 通常简写为 GM ,它提供了一个标准化的音库,将 128 个乐器排列成 16 个系列,每个系列有 8 个同类型的乐器,并为每个乐器分配一个特定的程序编号。GM 乐器表可以参考:

http://www.harfesoft.de/aixphysik/sound/midi/pages/genmidi.html

在 GM 标准下,信道 10 是保留给打击乐器的(实际上合成器可以在任何信道上使用鼓),在这个信道上乐器编码遵循通用 MIDI 鼓乐器列表(General MIDI drum instruments list),具体可以参考:

https://en.wikipedia.org/wiki/General_MIDI#Percussion

由于鼓是总体上是噪音乐器,所以之前的音高参数则被映射为不同的鼓音效。

注:噪音乐器指没有明确音高的乐器,有明确音高的乐器称为乐音乐器。

2.3 控制器消息

MIDI 设备通常会提供一些控制器用于改变合成器的某个参数,比如混响、增益等。MIDI 协议可以使用控制器消息操作 128 个不同的控制器,控制器消息结构如下:

Status byte : 1011 CCCC
Data byte 1 : 0NNN NNNN
Data byte 2 : 0VVV VVVV

其中 NNN NNNN 是控制器的编号,VVV VVVV 则是控制器的值。

控制器消息一方面可以用于改变合成器的某些参数,比如我们可以用以下指令将某个信道的力度值设置为 100:

Status byte : 1011 CCCC
Data byte 1 : 0000 0111
Data byte 2 : 0110 0100

另一方面,控制器编码可以通过“组合”的方式实现一些更复杂的指令。如前文所述,选择乐器可以通过 1000 开头的 STATUS byte 实现,这个指令可以选择 128 种乐器。对于同一个乐器来说可以应用不同的音色库,比如我可以在钢琴上使用雅马哈的采样、施坦威的采样或者是珠江的采样,由于乐器厂商认为 128 这个数量对于音色库太小了,所以采用的 MSB + LSB 的方式表示音色库,例子如下:

Status byte : 1011 CCCC
Data byte 1 : 0000 0000   // 0 = Sound bank selection (MSB)
Data byte 2 : 0000 0101

Status byte : 1011 CCCC
Data byte 1 : 0010 0000   // 32 = Sound bank selection (LSB)
Data byte 2 : 0000 0001

Status byte : 1100 0000
Data byte 1 : 0000 0010

这段代码选择了一个编号为 2,并且音色编号为 MSB = 0, LSB = 32 的乐器。由于 MSB 和 LSB 的范围都是 2 ^ 7 = 128,所以理论上可以选择的音色为 (2 ^ 7) ^ 2 = 16384

在 MIDI 中控制器消息和音源与效果器的参数密切相关,不同编号的控制器有一些约定俗称的含义,在程序中实现控制器时尽量与已有的规范对齐,具体内容可以参考这个表格:MIDI CC List(https://professionalcomposers.com/midi-cc-list/)

注:MSB 指最高有效字节(most significant byte),LSB 指最低有效字节(least significant byte)。一个 14 位的数据 XXX XXXX YYY YYYY 可以用 MSB + LSB 表示为:0XXX XXXX 0YYY YYYY

2.4 弯音消息

弯音消息也用到了我们刚才提到的 MSB + LSB 表示法,其消息结构如下:

Status byte : 1110 CCCC
Data byte 1 : 0LLL LLLL
Data byte 2 : 0MMM MMMM

其中 LLL LLLL 表示 LSB,MMM MMMM 表示 MSB,弯音值 0x2000(即 0b10000000000000)为同音高,0x3FFF(即 0b11111111111111)表示上方大二度,0x0000(即 0b00000000000000)表示下方大二度。在实践中,我们可以通过连续发送递增或者递减的弯音消息来表现滑音。

2.5 系统独占消息

所有系统消息都以 1111 开头,其中有两个特殊的消息。一个是 1111 0000 它表示后面的消息是系统独有的。另外一个 1111 0111 则表示系统独有消息结束,消息结构如下:

11110000
0iiiiiii
0ddddddd
..
..
0ddddddd
11110111

当合成器监听到 1111 0000 时,检查下一个字节 0iii iiiiiii iiii 是一个 7 位的制造商 ID。如果合成器识别出这个代码则会继续监听后面的数据,否则则忽略掉收到的消息,直到结束消息 1111 0111 出现。

3 . 宿主的 MIDI API

许多宿主环境都提供了用于编写 MIDI 交互程序的 API,在浏览器上是 Web MIDI API,在 iOS & Mac 上是 Core MIDI,Android 上则有 AMidi。为了方便读者进行实际操作,我们以 Web MIDI API 为例展示如何编写一个最基本的 MIDI 程序:

const button = document.getElementById('console-message')

button.addEventListener('click', () => {
  if (navigator.requestMIDIAccess) {
    navigator.requestMIDIAccess()
      .then(success, failure);
  }
})

function success (midiAccess) {
    const inputs = midiAccess.inputs.values();
    for (let input of inputs) {
        input.value.onmidimessage = onMIDIMessage;
    }
}

function failure () {
    console.error('No access to your midi devices.')
}

function onMIDIMessage (messageEvent) {
  console.log(messageEvent)
}

在这里,我们可以通过 requestMIDIAccess 向用户索要访问 MIDI 设备的权限,用户允许后我们会拿到一个 midiAccess 对象,可以通过这个对象拿到所有的输入和输出设备。我们可以通过设备对象提供的 onmidimessage 回调监听 midi message。

MIDI 消息的编码存储在 messageEvent 的 data 成员中,通过打印出的信息我们可以发现 Web MIDI API 并不会省略 Status Byte,这是为了便于开发者更容易区分指令属于哪个状态,而不必手动保存 MIDI 的运行状态。

如果想要 MIDI 可以发音,我们可以使用 Web Audio API 提供的振荡器:

const button = document.getElementById('play-sound')
const oscillators = {};
let context


button.addEventListener('click', () => {
  context = new AudioContext()

  if (navigator.requestMIDIAccess) {
    navigator.requestMIDIAccess()
      .then(success, failure);
  }
})

function success (midiAccess) {
    const inputs = midiAccess.inputs.values();

    for (let input of inputs) {
        input.onmidimessage = onMIDIMessage;
    }
}

function failure () {
    console.error('No access to your midi devices.')
}

function onMIDIMessage (message) {
    const frequency = midiNoteToFrequency(message.data[1]);

    // midi 键盘的普通按键默认使用通道 0,所以其 note on 事件为 1100 0000
    if (message.data[0] === 144) {
        playNote(frequency);
    }

    // note off
    if (message.data[0] === 128) {
        stopNote(frequency);
    }
}

function midiNoteToFrequency (note) {
    return Math.pow(2, ((note - 69) / 12)) * 440;
}

function playNote (frequency) {
    oscillators[frequency] = context.createOscillator();
    oscillators[frequency].frequency.value = frequency;
    oscillators[frequency].connect(context.destination);
    oscillators[frequency].start(context.currentTime);
}

function stopNote (frequency) {
    oscillators[frequency].stop(context.currentTime);
    oscillators[frequency].disconnect();
}

我们可以使用这个小程序来回顾与验证我们之前讲到的 MIDI Message 知识。

这里有一个笔者以前做的视唱练耳小工具,可以使用 MIDI 键盘进行视唱练耳练习:

演示地址:muse-training(https://muse-training-8gwn0lc039762917-1252681582.tcloudbaseapp.com/)

仓库地址:https://github.com/lipd/muse-training

4 . 标准 MIDI 文件格式规范

MIDI 协议解决的是音乐设备之间的即时通讯问题,它本质上是一个硬件之间的通信协议。而当我们想把 MIDI 演奏保存在磁盘上则需要用到标准 MIDI 文件格式规范(Standard MIDI-File Format Spec)。和 MIDI 通信协议一样,MIDI 文件也是 8 位字节流,下文将会说明 MIDI 文件一些最基本的格式规范。

4.1 Chunk

Chunk 是构成 MIDI 文件的基本单元。一个 Chunk 由三个部分组成:Chunk 类型 、Chunk 长度以及 Chunk 数据。Chunk 类型是 4 个 ASCII 字符,之后使用 32 位表示 Chunk 数据的长度,最后才是 Chunk 需要存储的数据。

MIDI 中一共有两种 Chunk,分别为 Header Chunk 和 Track Chunk。Header Chunk 标记为 MThd,存储的是整个 MIDI 文件的基本信息,和 PNG 等文件的 Header Chunk 类似。Track Chunk 标记为 MTrk,每个 Track Chunk 都存储了一个 MIDI 事件流,一个事件流可以包含 16 个 MIDI 信道的消息。一个典型 MIDI 文件的结构如下:

MThd <length>
<MThd data>
MTrk <length>
<MTrk data>
MTrk <length>
<MTrk data>

4.2 Header Chunk

MIDI 文件的 Header Chunk 包含的信息非常简单,我们以上面这个文件为例:

4D 54 68 64    // MThd 的 ASCII 码
00 00 00 06    // MThd 的数据长度,MThd Data 固定为 6 字节
---- DATA 部分 ----
00 01          // MIDI 文件格式,有 0、1、2 三种
00 02          // MIDI 文件的包含的音轨数量,即 Track Chunk 数量
00 DC          // MIDI 文件的时间类型

前两条数据已经介绍过,这里不再赘述。我们来解释一下 MIDI 文件格式与 MIDI 时间类型:

MIDI 文件格式(MIDI File Formats)

MIDI 文件格式分为三种,格式 0 的 MIDI 文件只有一个 Header Chunk 和一个 Track Chunk。对于只有一个轨道的程序可以采用这种格式。

格式 1 有一个 Header Chunk ,和多个 Track Chunk 。其中第一条 Track Chunk 是特殊的,负责记录 MIDI 文件的所有 Meta Event(后面会讲到),而从第二条 Track Chunk 开始才会记录 MIDI Event,所以我们上图中的 MIDI 文件实际上只有一条用于演奏的音轨。目前绝大部分的支持多音轨的程序都采用这种格式,笔者也建议读者尽量使用这种格式。

格式 2 的 MIDI 文件也有多个 Track Chunk,但不同的是格式 1 所有 Track Chunk 共用一条时间轴,所有 Track 应当被视作同时播放的。而格式 2 中 Track Chunk 都有自己独立的时间信息,这种格式非常少见,不建议使用。

我们用一张表总结一下:

音轨数量 时间轴
格式0 1个 1 条
格式 1 多个 1 条
格式 2 多个 多条

MIDI 时间类型

MIDI 时间类型主要有两种,为了方便介绍读者可以简单将其理解为“按音符分割的”和“按帧分割的”:

“按音符分割的”时间类型 15 位为 0,被称为 TPQN(Ticks Per Quarter-Note),即一个四分音符中包含了多少 Tick。在前文的例子中 00 DC 表示 TPQN 为 220,那么一个八分音符为 110 Ticks,一个二分音符为 440 Ticks。另外 TPQN 也被称为 Pulses Per Quarter-Note (每四分音符的脉冲数),如果你在代码中看到 PPQ、PPQN 这样的简写,你知道他们是一个意思即可。

“按帧分割的”时间类型 15 为 1,这种格式单纯 MIDI 文件中几乎不用而且比较复杂,建议读者跳过。其编码规则简单说就是使用了 SMPTE 时间码的规范。其 14 - 8 位包含了包含 -24、-25、-29 或 -30 四个值之一,对应于四种标准 SMPTE 时间码格式(-29 对应于 30 个丢帧),并表示每秒的帧数。第 7 到 0 位表示帧内分辨率。我们依然用一张表总结一下:

15 位 14-8位 7-0位
按音符 0 四分音符的Tick数
按帧 1 SMPTE格式 每帧Tick数

4.3 Track Chunk

Track Chunk 的主要功能是用于存储实际的演奏数据。它的 Chunk Data 中存储的是一串事件流,被 Track Chunk 记录的事件我们称为 MTrk 事件,其结构如下:

<MTrk event> = <delta time> <event>

在这个结构中,事件可以指代三类事件:midi 事件、系统独有事件、元事件:

<event> = <midi event> | <sysex event> | <meta event>

delta time

MIDI 通信时所有信息都是即时执行,所以 MIDI 消息并没有记录时间,但是 MIDI 文件则需要记录时间在时间轴上的位置。MIDI 文件采用差量时间来记录 MIDI 事件,即 Δt。delta time 表示的是当前事件与上一个时间相差的 Tick 数。如果要表示同时发生的数个任务,则记录一串 delta time 为 0 的事件流即可。比如我们控制器一章中切换乐器的事件流可以表示为:

Delta time  : 0000 0000
Status byte : 1011 CCCC
Data byte 1 : 0000 0000   // 0 = Sound bank selection (MSB)
Data byte 2 : 0000 0101

Delta time  : 0000 0000
Status byte : 1011 CCCC
Data byte 1 : 0010 0000   // 32 = Sound bank selection (LSB)
Data byte 2 : 0000 0001

Delta time  : 0000 0000
Status byte : 1100 0000
Data byte 1 : 0000 0010

sysex event

即系统独占的消息事件,具体可以参考前文中的系统独占消息。

meta event

所有元事件以 1111 1111 开头,这个指令在 MIDI 消息中表示系统复位。这个指令是一个系统实时信息,通常在使用 MIDI 文件的程序并不会用到,所以在这里用于表示元事件。元事件主要用于指定拍号、调号、速度等。

需要注意的是FF 2F 00 是一个特殊的元事件,表示轨道结束。所有 Track Chunk 都以这个元事件结束。下面这张表是标准中已定义的元事件:

意义
FF 00 02 序列号
FF 01 len text 文本事件
FF 02 len text 版权声明
FF 03 len text 轨道名称
FF 04 len text 轨道中使用的乐器类型
FF 05 len text 歌词
FF 06 len text 某个点的名称,比如“第一乐章”
FF 07 len text Cue Point 某个舞台事件描述
FF 20 01 cc MIDI 通道前缀
FF 2F 00 End of Track
FF 51 03 tttttt 设置速度
FF 54 05 hr mn se fr ff SMPTE Offset
FF 58 04 nn dd cc bb 拍号
FF 59 02 sf mi 调号

5 . MIDI 协议的缺陷与改良方案

5.1 MIDI 2.0 & MPE

MIDI 通信协议目前看来主要有两个较明显的缺陷。第一个缺陷是许多值可以表示的范围实在有限,比如 note off 的 velocity 就只有 128 个、乐器也只有 128 个、只有 16 个信道。

另一个问题更为麻烦,MIDI 中控制器、和弯音消息只能发送给某个信道,你根本就没法将它和某个音联系在一起。这一局限在以前并没有引起多少问题,因为传统乐器很少碰到按音处理控制器的情况。而弯音用得最频繁的更多是单声部乐器。

但电子音乐界向来不缺乏整活健将,工程师总是会想方设法突破现有限制。最典型的例子就是 seaboard 键盘,这玩意儿可以在每个键上提供弯音能力。你可以从下面这段演奏上感受到这一乐器的神奇魅力:

原视频链接:https://www.youtube.com/watch?v=6SCug5kUsBs

为了解决让控制器消息能按“音”发送,seaboard 的制造商 ROLI 制订了 MIDI Polyphonic Expression(MPE,MIDI 复音表示法)。其原理基本上可以概括为:让每个发声的音符都会在其 Note On 和 Note Off 之间临时分配一个 MIDI 通道。这样便把控制器消息和弯音消息与特定音符建立了联系,并且很好的兼容了 MIDI 协议。

上述问题现在都正在通过新的 MIDI 2.0 得到解决,在 MIDI 2.0 中 volocity 从 0 - 128 扩展到 0 - 65535,信道从 16 个增加到 256 个,同时 MIDI 2.0 也支持 MPE 以及远程控制。

5.2 如何拓展 MIDI

如果 MIDI 2.0 和 MPE 这类现成的解决方案无法满足你的需求,那么你可以考虑自己来拓展 MIDI 协议或者 MIDI 标准格式。目前来看,可靠的拓展方式有几下几种:

  1. 使用未定义的 MIDI 消息:比如系统消息 1111 0101 的行为在 MIDI 标准中就未被定义。这种方法的好处是不需要进行额外的解析工作,但缺点便是可以使用的指令十分有限。
  2. 使用自定义 Chunk:Chunk 在设计之初便考虑到了拓展的问题,你可以按照 Chunk 的格式自由地声明一个新的 Chunk 类型,主流解析工具在碰到无法解析的 Chunk 时会自动忽略掉,所以不用担心兼容的问题。如果你有整段的数据,既不属于 Track,又不能被 Heaer 所包含,那么可以考虑这种方式。
  3. 使用系统独占消息:如果你需要在 MIDI 通信协议上进行拓展,可以考虑使用系统独占消息,合成器会自动忽略无法解析的独占消息。具体可以参考附录中的系统独占消息一节。
  4. 其他:你也可以参考 MPE 的方式,基于现有的编码方案但是重新定义指令的意义和执行。

6 . 思考与讨论

6.1 什么时候使用 MIDI 格式,什么时候不用?

首先我们需要认识到 MIDI 的优点,MIDI 记录的实际上是事件流,最适合的场景就是在现场演奏时用于硬件之间的通信。作为 MIDI 文件格式作为一种存储格式,其优点是数据十分紧凑,体积较小。但 MIDI 的缺点是十分明显的,一方面我们无法快速查询、访问其中某个具体内容的值:比如我们没法快速找到某一个轨道的拍号,或者某个音的音高。

所以我的建议是,尽量避免在现场演奏场景之外使用 MIDI 文件格式,但可以在抽象上对齐 MIDI。在内存中我们尽量把 MIDI 文件转化为实例对象,便于我们快速访问。在需要持久化的场景下则可以使用更容易解析的 JSON 或者 MusicXML 格式。只有在用户需要或者向其他编辑工具导出数据的时候,我们才考虑使用 MIDI 标准文件格式。

6.2 MIDI 协议无法满足的需求如何解决?

绝大部分这类问题可以通过不使用 MIDI 编码来解决。原则很简单,只要不涉及现场演奏场景和向其他工具导出数据,就避免使用 MIDI 编码来做任何事情。只用确保在需要 MIDI 的场景可以导出 MIDI 文件就行。

6.3 如果多数场景不使用 MIDI,那有必要深入学习 MIDI 协议吗?

如果你的开发工作涉及到音乐的“本体”部分,那么我建议多了解一些 MIDI 协议,因为虽然我们可能多数情况下不直接使用 MIDI 协议的编码,但是 MIDI 的事件流是创作场景和存储场景会大量用到的,同时 MIDI 中的多数抽象和概念是行业内通用的。

6.4 如何设计自定义的音乐数据格式?

我的建议是用一个文档维护所有的基础字段和拓展字段,各项目在定义 Model 时尽量参考这个文档。如果现有的拓展字段可以解决你的需求,就不要新增拓展字段。

附录

可变长度数量(Variable-Length Quantities)

由于单个字节表示的最大范围为 0 - 256,所以在 MIDI 文件中表示较大数字时会采用可变长度数量。其每一个字节使用第 7 位表示这个字节是否为最后一个字节,1 表示不是最后一个字节,0 表示是最后一个字节, 0 - 6 位则作为有效位。

举一个例子,数字 127 可以表示为 0111 1111 ,128 则表示为 1000 0001 0000 0000,这样理论上可以表示的数字可以无限大,不过在实践中通常不会使用超过 32 位。

总结一下就是:

7 位 0-6 位
是否为最后一个字节 有效位

速率的解释

note on 中的 velocity 实际上是按键的“触发速率”,你可以把其视为从键盘能感知到下按到下按结束这个过程中的键程除以按下时间,note off 则是反向的“释放速率”。速率的计算方式和更多细节可以参考这篇论文:The Interpretation of MIDI Velocity

一堆速查表

参考文献

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8