IOS Midi播放

前言

MIDI 文件是在做音乐应用时,很可能会遇到的一种文件格式。Github上面有相关的类库,可以用来解析MIDI,因为不想满足于仅仅能够拿来能用就好,还是希望能够了解MIDI到底是怎么解析,所以自己找了一下资料看了一下,但是发现在网上还没有找到一篇讲MIDI比较详细的,可以让人看一遍,就知道还MIDI是怎么一回事。因此我尝试自己写一篇,个人的水平有限,可能有一些说不清楚的地方。如果大家有啥意思或者问题,可以留言讨论。

什么是MIDI

MIDI(Musical Instrument Digital Interface)乐器数字接口 ,是20 世纪80 年代初为解决电声乐器之间的通信问题而提出的。MIDI是编曲界最广泛的音乐标准格式,可称为计算机能理解的乐谱。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
4D 54 72 6B 00 00 00 67 00 FF 03 13 5B 47 4D 20
30 35 34 5D 20 56 6F 69 63 65 20 4F 6F 68 73 8F
00 90 3C 64 8C 18 80 3C 40 82 68 90 3E 64 8C 18
80 3E 40 82 68 90 40 64 86 48 80 40 40 78 90 41
64 86 48 80 41 40 78 90 43 64 86 48 80 43 40 78
90 45 64 86 48 80 45 40 78 90 47 64 87 40 80 47
40 87 40 90 48 64 8F 00 80 48 40 00 FF 2F 00 4D
54 72 6B 00 00 00 0E 00 FF 03 06 4D 61 72 6B 65
72 00 FF 2F 00

一串16进制的数字,看不懂对不对,那就好了。如果能够看懂了,本文可能就不太适合你,可以关了页面,去干别的事了。
然后我们用Mac自带GarageBand(中文名字为库乐队)来打开这份MIDI文件(可以把这些数据写到文件,保存为文件后缀是midi就可以了)。

Midi内容在GarageBand中的展示

这份MIDI文件其中是包含了3条音轨,但是演奏的主音轨只有一条。而图中两条绿线的区域就是这个演奏音轨的内容,里面每一段绿色的长条就是一个音符的演奏信息。我们先记下来第一个音符的信息,它的音高是C3,力度是100。

OK,目前为止,我们已经通过GarageBand看到一个MIDI信息是怎么的。接下来,我们就要来讲讲怎么从上面的那串十六进制的数据,也懂出这些信息。
MIDI文件基本由两块组成

<文件头块> + <音轨块数据>

其中音轨块数据就是由若干个格式相同的子数据构成。
先来看看文件头块,头块主要有三块:

<标志符串>(4字节) + <头块数据区长度>(4字节) + <头块数据区>(6字节)

如果细心的同学可能会想到,那一个4分音符时长是多少呢?如果不能确定一个4分音符时长,就不能确定每单位ticks的具体时长,后面的逻辑也就走不通了。而4分音符在不同的节拍下是不同时长,那么midi是怎么解决这个问题的。之前我也困惑过,不过现在我们先保留这个问题,后面会讲到。

总结一下,每个midi文件都会有一段相似的开头,用十六进制表示为4d 54 68 64 00 00 00 06 ff ff nn nn dd dd,这就是头块信息。

动态字节

在讲音轨块数据之前,必须先讲讲动态字节,因为音轨块数据中的数值是用到了动态字节来表示。在前面,我们讲文件头块的时候,说到会用4个字节来表示头块数据的长度,这样就是用固定字节表示。用固定字节表示,有两个缺点:

  1. 可能造成空间浪费,比如我们用4个字节表示头块数据长度,为 00 00 00 06,其实前面的3个字节是用不到的,浪费空间。
  2. 可能出现最大值不够用,比如我们用固定4个字节表示长度,然后它的范围 0 ~~ 2^64 - 1 。如果我们要指定更大的数值,就没有办法了。当然可以使用更大的固定字节,比如6字节或者8字节,但是这样缺点1可能造成浪费也就更大了。

说了这么多,正式来讲讲动态字节~~~~~
一个字节有8块,除了最高位用作标志位,还有7位,可以表示的范围为0 ~~ 2^7 - 1 (即为127)。如果要表示的数是在这个范围之内,那么标志位为0,然后用其余7位表示就好了。比如120,可以表示为0111 10000x78)。
如果要表示的数值超过这个范围,那么先记录低7位为一个字节,超过7位的数值移交给前面的字节,而这个前字节的标志位必须为1,表示它是进位的。如果前字节还是超过127,继续同样的步骤。举个栗子:我们要表示500这个数,二进制为:1 1111 0100 一共有9位。先记录下低7位在一个字节为0111 0100。高位还有11 ,存在一个字节为1000 0011。所以500这个数值用动态字节表示为1000 0011 0111 01000x8374)。
在举一个例子:,解析一个动态字节(0x83FF7F),先读取第一个字节83,因为最高标志位为1,所以它是进位的,不是最终字节,表示的数值为3 R。读取第二个字节FF,同理因为最高标志位为1,也是进位的,不是最终字节,表示的数值为127 R。读取第三个字节7F,因为最高标志位为0,表示是最终字节,动态字节取值结束,该字节表示的数值为127 R
所以动态字节(0x83FF7F)表示的值为 3 _ 128^2 + 127 _ 128^1 + 127 * 128^0 = 65535.

音轨块

midi文件,在头块之后,剩余是一个或者多个音轨块。每个音轨块的结构如下所示也是包含3部分。

<标志符串>(4字节) + <音轨块数据区长度>(4字节) + <音轨块数据区>(多个MIDI事件构成)

上面说过,音轨块的标志符串为"MTrk",也是记录ASCII码,用十六进制表示就是4d 54 72 6b。音轨块数据区长度也为固定4字节,指定后面的数据区长度。

其中MIDI事件的构成是

<delta time> + <MIDI 消息>

其中delta time 就是采用动态字节来表示,单位就是tick。
MIDI 消息,由一个状态字节 + 多个数据字节 构成。状态字节可以理解为方法,数据字节可以理解为这个方法的参数。状态字节的最高位永远为1,因为它的范围介于128~ 255之间,而数据字节最高位永远为0,所以的它的范围介于0 ~ 127 之间。消息根据性质可分成通道消息和系统消息两大类。
通道消息是对单一的MIDI Channel起作用,其Channel是利用状态字节的低 4 位来表示,从0~F共有16个。
下表为通道消息的同类,其中X为0~16.

状态字节 功能描述 数据字节描述
8X 松开音符 1字节:音符号(00~7F) / 2字节:力度(00~7F)
9X 按下音符 1字节:音符号(00~7F) / 2字节:力度(00~7F)
AX 触后音符 1字节:音符号(00~7F) / 2字节:力度(00~7F)
BX 控制器变化 1字节:控制器号码(00~79) / 2字节:控制器参数(00~7F)
CX 改变乐器 1字节:乐器号码(00~7F)
DX 通道触动压力 1字节:压力(00~7F)
EX 弯音轮变换 1字节:弯音轮变换值的低字节 / 2字节:弯音轮变换值的高字节

还有一种特殊的状态字节FF,表示非MIDI事件(Non- MIDI events),也叫meta-event(元事件)。元事件的语法定于如下:

FF + <种类字节>(1字节) + <数据字节长度> + <数据字节>

FF的部分功能,其他如果数据字节数不是固定,而是有前面的动态字节制定,则用--表示

种类 功能描述 数据字节长度 数据字节描述
00 设置轨道音序 2 音序号 00 00-FF FF
01 文字事件 文本信息
02 版权公告 版权信息
03 指定歌曲/音轨的名称 歌曲名称(用于全局音轨时)/音轨的名称
04 指定乐器 乐器名称
05 歌词 歌词
06 标记 标记(通常在一个格式0的音轨,或在格式1的第一个音轨。)
07 注释 描述一些在这一点上发生的动作或事件
2F 音轨终止 音轨结束标志(必须有的)
51 指定速度 设定速度,以微妙为单位,是四分音符的时值
58 指定节拍

上面两个表是常见消息的状态字节,还有一些其他消息没有列举出来,但是这两个表已经够用了。

开始看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
4D 54 72 6B 00 00 00 67 00 FF 03 13 5B 47 4D 20
30 35 34 5D 20 56 6F 69 63 65 20 4F 6F 68 73 8F
00 90 3C 64 8C 18 80 3C 40 82 68 90 3E 64 8C 18
80 3E 40 82 68 90 40 64 86 48 80 40 40 78 90 41
64 86 48 80 41 40 78 90 43 64 86 48 80 43 40 78
90 45 64 86 48 80 45 40 78 90 47 64 87 40 80 47
40 87 40 90 48 64 8F 00 80 48 40 00 FF 2F 00 4D
54 72 6B 00 00 00 0E 00 FF 03 06 4D 61 72 6B 65
72 00 FF 2F 00
4D 54 68 64 00 00 00 06 00 01 00 03 01 E0

头块数据,然后就是`4D 54 72 6B` 转为字符就是`MTrk`,说明这是一个音轨块信息。接下来4个固定字节表示数据长度`00 00 00 1A`,所以接下来需要读取26个字节的数据。
接下来就是读取事件,根据语法第一个事件是`00 FF 03 03 31 32 33`。其中`00`表示时间间隔为0ticks;`FF` 说明是元事件;`03` 是状态字节,说明指定歌曲名称;下面的`03`指定下面还有3个字节作为文本信息;`31 32 33` 就是文本信息。
第二个事件为`00 FF 51 03 08 7A 23`,这里不一个一个字节解释了,整个事件就是指定演奏速度,则每拍的时间555555微秒。用每拍所占的时间而不是单位时间内的拍数表示速度,使得依据一个基于时间的(例如SMPTE时间代码或MIDI时间代码)实现时间的绝对同步成为可能。
每个音轨最后肯定是以00 FF 2F 00结束,因为这是一个音轨结束事件。
其他事件就不说明,通过事件的类型,我们可以得知这是一个全局事件。

> 我们找到下一个`4D 54 72 6B` ,一直到`00 FF 2F 00`为止,把下一个音轨的数据截取出来。

4D 54 72 6B 00 00 00 67 00 FF 03 13 5B 47 4D 20 30 35 34 5D 20 56 6F 69 63 65 20 4F 6F 68 73 8F 00 90 3C 64 8C 18 80 3C 40 82 68 90 3E 64 8C 18 80 3E 40 82 68 90 40 64 86 48 80 40 40 78 90 41 64 86 48 80 41 40 78 90 43 64 86 48 80 43 40 78 90 45 64 86 48 80 45 40 78 90 47 64 87 40 80 47 40 87 40 90 48 64 8F 00 80 48 40 00 FF 2F 00


> 除去音轨头块数据,第一个事件就是`FF 03`指定音轨的名称。
重点来了,第二个事件就是`00 90 3C 64`,`90`说明是个按下音符,也就是发出音符。`3C`是音符号,`64`是力度。大家还记得我们从GarageBand观察时候,记下第一个音符是C3,力度是100。
`3C` 表示为第60号音符。从MIDI音符号表可以找到第60号的音符为C4。
等等为什么是C4,这个问题,我也疑惑过。其实,这是对中央C的标号不同导致,在GarageBand,钢琴弹音域中央C为C3,其他乐器还是C4。只要降低一个八度,做个转换就好了。
力度`64`,即为`100 R`,所以力度为100。跟我们在GarageBand看到是一致的。
需要注意,`90`事件是个note_on事件就是发音事件,但是如果参数力度为0 ,它实际上就是一个note_off事件,不会发音。

> 第二个事件就是`8C 18 80 3C 40`,整个事件就是经过1560ticks之后,松开音符`3C`,力度为60。两个事件串联起来就是,音符C4发出声音时长为1560ticks。

> 其他事件和音轨就不看,大概读的方法是一样的思路。

### 怎么计算时间

> 我们还留着一个问题没回答,那就是怎么确定时间单位ticks。
我们从头块信息,可以得知到一个4分音符的ticks数为`480`,然后从全局音轨得到播放速度为,每个节拍`555555`微秒。1个4分音符为1节拍,也就是说1tick为

555555 / 480 = 1140625


微秒。
上面我们说过第一个字符时长为`1560`ticks,也就是

1560 * 1140625 / 1000 / 1000 = 8056


秒。

### The End?

> 实际应用的MIDI文件可能比我举个例子复杂很多,因为还可能出现多音轨,还有上面没有描述的消息,比如模式消息、实时消息、公共消息等等。但是解析方式都是同一个套路,只是可能消息的作用不同而已。所以希望本文,可以帮助到你理解MIDI就满足了。