MIDI parsing thru JavaScript in Max
第二学期提交内容备份
说明
文件包内容
- Max patch*2
- midiparsing.maxpat (应用js脚本的主体Max patch)
- noteRec.maxpat (用于‘midiparsing’的独立Abstraction)
- Trout Quintet ver.2.maxpat(原可视化项目)
- MIDI文件*6
- JavaScript文件*1
- readMidi_final2.js
- Node库文件夹*1
- node_modules -> midi-file
- 录屏视频*3
- 第一版可视化Max录屏
- 第一版可视化Max中变化背景色Patch单独录屏(无声音)
- 脚本在Max中运行录屏
- JSON文件*1
- troutParsed.json
- python文件*1
- json_reading.py
- 说明文档*2
- 本文的markdown原文
- PDF导出版(第一版笔记附在其后)
本文结构
- 项目背景
- 第一版可视化的缺陷
- 开发脚本的动力与意义
- 利用JSON辅助解读MIDI文件数据结构
- 用于Max的MIDI解析脚本开发
- Max Patch设计、与Node之间的连接,运行与输出
- 利用JSON对比检测脚本准确性
- 总结
项目阐述
项目背景
本项目最初是一个将MIDI音符可视化的Max程序,这一程序将MIDI音符的基本信息 —— 音高、强度、时值等 —— 映射为某些图形的相应参数,将其绘制在Max内置的lcd画布中,从而实现了随音乐进行的可视化效果(见视频)。音高对应于图形的起始位置,更高的音出现在纵向上更高的起点;时值则映射为形状的宽度,更长的音横向上更宽广;强度上的微妙差别则体现为图形在纵向上的高度,使得更强的音显示为纵向上更多的拉伸。这一可视化思路符合人的直观感受,将无形的音乐呈现为易于理解的图像。
音乐可视化的应用意义自不必说,可视化的方式也无穷无尽,依据不同的需求、审美,可以有截然不同的标准。本项目的重点在于对MIDI信息的解读,可视化更多是作为一个直观辅助手段。项目的难点在于如何获取在整个时间流上的MIDI信息,包括各个音符的时值、出现的时间点,唯有获得这些精准信息才能实现准确地、与音乐一一对应的可视化。
第一版可视化的缺陷与开发脚本的意义
Max的一大特点是不提供timeline,但是它提供了丰富的获取时间、计算时间的工具。项目最初的版本所做的关键工作就在于此。这部分内容已经写成了一篇笔记,在此不再重复,请参见MIDI visulisation (Max project) — for Schubert’s Trout Quintet。为便于查看,笔记全文也附在此篇文章后面。可视化过程中的映射思路在笔记中有介绍,本文不再复述。
在上述Max编程过程中,一方面意识到Max本身对于MIDI解析的局限性,同时也发现了一些bug。原项目的时间获取过程用的是一种“笨办法”,一个多声部的MIDI文件(格式1)在Max中虽然可以正常播放、输出具体的各个音符信息,但是并不能按照音轨单独解析出来各个音轨的独立信息,因此设计原项目时手动拆分了几个声部,这显然破坏了原文件的数据结构,也损失了很多信息,尤其是文件头(header)。另外,Max中用于解析MIDI事件的内置object [midiparse]
在解析过程中还大量丢失“音符关”信息(详见原笔记)。因此,尽管这一项目已经完整成型并能够效果不错的可视化一段音乐,但依然不理想,对于MIDI数据结构的深入理解也没有带来特别大的帮助。
在此项目基础上,重新思考了MIDI的解析方法。当然,在Python中有很多便利的第三方库可以处理MIDI文件,但本项目的重点在于深入理解MIDI数据结构、为以后在开发上更好的处理MIDI事件打下良好基础,而非寻找一个现成的库把信息读取出来即可。Max作为一种可视化编程平台,能够直观呈现信息,在辅助理解上有很大帮助,同时也为Max在MIDI解析方面的局限性提供了一种解决方案。
利用JSON辅助解读MIDI文件数据结构
在解决“如何解析MIDI”之前,应该先搞清楚“MIDI数据是怎样的”。在解读MIDI文件的多种方法中,我选择了使用JSON文本格式,因其友好、易读的dictionary数据格式最适宜现阶段的需求。有很多web工具可以直接在线解读MIDI,不同的工具解析出来的内容会有所差别,不过,基本的音符信息和全局信息是一致的。对比了多个工具后,最终确定使用Tone.js。Tone.js 是一个网络音频框架,用于在浏览器中创建交互式音乐,其架构旨在为音乐家和音频程序开发者建立基于web的应用程序。而若使用MIDI文件,需要先将MIDI转换为Tone.js可读的JSON格式,因此,在其网站单独提供了一个用于转换MIDI为JSON的页面https://tonejs.github.io/Midi/,
转换后的JSON文本可以直接拷贝出来。它解读出来的信息非常完整且易于理解。原音符可视化项目是为舒伯特的鳟鱼五重奏而作的,在此还是使用这一MIDI文件作为样本。
将转换后的JSON文本复制到VS Code中,以便观察其结构,本节图片来自VS code截图。
MIDI文件使用一种特定的结构来存储音乐信息,其最基本的数据结构称为“块”(chunks),整个文件被分为很多个块,每个块中都包含特定类型的数据。最外层的结构是Header Chunk 和Track Chunks:
header and track
文件头header总是位于最前面,包含了必要的全局信息,比如音轨数、速度、时间格式等。
header内容
tracks chunks则是内容最多的部分了,"tracks"的值是一个列表,其中有几个元素就是解析出来了多少个音轨,每个音轨代表一个不同的声部或者独立旋律线。在室内乐这类音乐中,每个声部就是一个乐器。每一个track chunck又进一步分为多个“事件”(events)。MIDI格式本质上是一堆信息指令,所谓的MIDI events就是这些具体的指令,如演奏哪个音符、用什么样的力度演奏、使用哪个乐器等。
占据行数最多的当然就是notes,其中包含了每一个音符的具体信息(event data)。
表达音乐速度的基本单位BPM,并不包含绝对时间,在MIDI中,基本时间单位是ticks。header chunk中非常重要的一个信息就是ppq —— Pulses Per Quarter note 每四分音符脉冲数,它代表了这一MIDI文件的时间分辨率,因此有时候也直接成之为resolution。样本文件的时间分辨率是256,即每四分音符包含256个ticks。可以说,ppq定义了每四分音符的离散时间阶数,256是一个较高的分辨率。显然,更高的ppq意味着更好的时间分辨率,也就是能进行更精确的控制。结合ppq和BPM就可以计算出绝对时间。
以鳟鱼主题的前两个音符A、D为例:
"notes": [
{
"duration": 0.48046875,
"durationTicks": 123,
"midi": 69,
"name": "A4",
"ticks": 0,
"time": 0,
"velocity": 0.5984251968503937
},
{
"duration": 0.37109375,
"durationTicks": 95,
"midi": 74,
"name": "D5",
"ticks": 128,
"time": 0.5,
"velocity": 0.7322834645669292
},
......
"音符时长": 第一个音符A的时长为123(ticks),而一个四分音符是256个ticks,因此,这个音符的相对长度就是 $123/256=0.48046875$ 个四分音符。
"音符名": 音符的音高即MIDI音高数值69和74对应A4和D5;
"起始时间": 以ticks为单位的起始时间点,第一个音符的起始位置自然是零,第二个音符从128ticks开始,而ppq是256,因此其相对时间点就是0.5个四分音符时长的位置。当然这依然不是绝对时间,本MIDI的bpm是60,通过以下公式可以计算出绝对时间:
$$ 60000/bpm * \text{relative time} = \text{abs time (in miliseconds)} $$
将bpm和相对时间点带入就可以得到绝对时间点,而音符时长也是同样道理。第二个音符的实际起始位置就是第500毫秒。四分音符是音乐节拍的基本度量标准,这就是为什么要使用“相对时间”。
"音强": velocity这一属性的值在大部分平台中可能都是1~128或者0~127,在这一解析中,或许是Tone.js库的计算所需,将其归一化处理了。
用于Max的MIDI解析脚本开发
了解数据结构后,也清楚了具体的需求(本项目的关键需求就是时间获取),就可以选择合适的开发方式了。
Max提供了方便的直接运行JavaScript文件的内置object [js]
,但对于较为复杂的需求,尤其是需要用到第三方js库的情况,[node.script]
是更好的选择。Node是用于编写程序的JavaScript框架,对于很多Max无法完成的复杂任务,Node是一种功能实现上的扩展手段,也是一个相当强有力的工具。尽管这两种objects都指向某个js源文件,但在运行上,[node.script]
与[js]
是截然不同的。使用[js]
时,脚本是在Max软件内部执行的,而[node.script]
则用于运行独立的Node.js进程。这带来了极大的好处,即并行化,也提高了整体性能。[node.script]
相当于一个完整的应用程序,一旦启动,就有自己的运行流程。由于 Node.js 和 Max 运行在独立的进程中,因此发送到 Node.js 脚本的消息会异步执行。使用[node.script]
可以访问完整的Node.js,譬如有很多成熟的MIDI解析库是本项目必不可少的考虑因素,因此最终选择使用[node.script]
这一object。
在多种解析MIDI数据的第三方js库中,最终决定使用midi-file,它在处理MIDI数据方面不仅专业,代码也很简洁、轻盈,对于刚接触的人而言也易于上手。
本项目建立了一个解析MIDI数据的脚本(见源码"readMidi_final2.js"),它的逻辑很简单,就是读取MIDI文件,然后应用midi-file这个库来解析数据。Max与Node之间的连通直接应用‘max-api’即可。
最重要的函数是readMidi()
,是整套代码的核心。readMidi()
旨在解析MIDI文件并提取我所需要的信息,包括:bpm、finalTicks - 以ticks为单位的总时长、ppq - 每拍脉冲数、numOfTracks - 轨道数量等全局信息,再通过嵌套的两层forEach循环遍历每一轨音符事件。
readMidi()整体逻辑
readMidi函数解析MIDI文件并处理每个音轨中的事件:
- Header Chunk: 提取全局信息。
- Tracks Chunk: 遍历每个音轨:
- 初始化当下时间、建立活动音符和音轨事件等变量。
- 遍历音轨中的每个事件:
- 获取program change信息。
- 获取Tempo信息,计算bpm。
- note-on 事件:
- 创建音符唯一键(音轨索引、通道和音符编号);存储pitch、velocity、startTime、channel信息在 activeNotes 对象中(字典形式)。
- note-off 事件(或强度为0的note-on):
- 使用键从 activeNotes 中检索相应的音符信息;计算音符持续时间;
- 创建noteEvent对象(包含完整音符信息),添加到当前音轨的trackEvents中;
- 从 activeNotes 中删除已完成遍历的音符。
- 输出:
- 'note'标识
- 音轨号
- 乐器名称
- 音高
- 力度
- 音符起始
- 音符时值
- 通道
核心函数代码
// read and parse the input MIDI file
function readMidi(filePath) {
try {
Max.post(`Attempting to read MIDI file: ${filePath}`);
const input = fs.readFileSync(filePath);
Max.post('MIDI file read successfully.');
const parsed = MidiFile.parseMidi(input);
Max.post('MIDI file parsed successfully.');
// Header Chunck: time resolution & tracks number
const ppq = parsed.header.ticksPerBeat;
const numOfTracks = parsed.header.numTracks;
// Tracks Chunck:
const trackEvents = {}; // events for each track
const trackInstruments = {}; // program changes for each track
let finalTicks = 0; // initilize track end
let bpm = null;
parsed.tracks.forEach((track, trackIndex) => {
trackEvents[trackIndex] = [];
trackInstruments[trackIndex] = {};
let currentTime = 0;
const activeNotes = {};
track.forEach(event => {
currentTime += event.deltaTime;
if (event.type === 'programChange') {
trackInstruments[trackIndex][event.channel] = event.programNumber;
} // program change
if (event.type === 'setTempo' && bpm === null) {
bpm = tempoToBpm(event.microsecondsPerBeat); // 60M/mic_per_4n
}
if (event.type === 'noteOn') {
const noteKey = `${trackIndex}-${event.channel}-${event.noteNumber}`;
activeNotes[noteKey] = {
track: trackIndex,
type: 'noteOn',
pitch: event.noteNumber,
velocity: event.velocity,
startTime: currentTime,
channel: event.channel,
};
} else if ((event.type === 'noteOff' || (event.type === 'noteOn' && event.velocity === 0))) {
const noteKey = `${trackIndex}-${event.channel}-${event.noteNumber}`;
if (activeNotes[noteKey]) { // if the active note exists
const noteOn = activeNotes[noteKey];
const duration = currentTime - noteOn.startTime;
trackEvents[trackIndex].push({ // append note info after note-off
track: trackIndex,
type: 'noteEvent',
pitch: noteOn.pitch, // retrieves corresponding note-on info
velocity: noteOn.velocity,
startTime: noteOn.startTime,
duration: duration,
channel: noteOn.channel,
});
delete activeNotes[noteKey];
}
}
});
finalTicks = Math.max(finalTicks, currentTime); // calcu endTick after a track's been looped
});
Max Patch设计、与Node之间的连接,运行与输出
作为具有独立运行进程的object,[node.script]
的js文件指向是必选参数。'max-api'提供了便捷的与Node之间的连通,"Max.outlet()"函数相当于Max对象中的输出口。在[node.script]
之外发送的信息指令是通过Max.addHandler()函数中设定的。脚本中末尾的代码:
Max.addHandler('readMidiFile', (filePath) => {
readMidi(filePath);
});
增加了一个操控Node文件的指令'readMidiFile'。在连接到Node对象的输入口的指令中,除了内置的固定指令外,可以自己定义内容。根据本项目的需求,'readMidiFile'调用最核心的readMidi()
函数。
使用[node.script]
对象还有一个额外优点,Max提供了一个方便的监测对象[node.debug]
,即图中的可视化界面 "node.script debug tool",可以便捷查看文件读取情况和错误信息。
拖拽文件的部分是一个额外版块,方便读取不同的文件,这里对文件读取与debug tool之间的触发做了延迟 [p read_file_delay]
。根据Max的数据流顺序,先进行右侧的传输,那么就会出现文件状态监测早于文件读入,会显示报错,因此用延迟bang的方式处理。
读取MIDI文件后,可通过console看到输出的大量MIDI信息:
这些输出就是之后需要作可视化或任何其他需要MIDI信息的设计时所需的数据。为了使用这些数据,需要把它们存储下来。
类似于第一版Max中的时间处理模块,使用[coll]
对数据进行存储。每一个音轨对应一套数据存储,一个[coll]
对象(不同于原Max项目中用笨办法计算时间,每个音轨用了两个[coll]
)。理论上,可以支持无限多的音轨,只需要在[route]
中继续添加音轨序号、增加新的数据记录模块、无线发送数据即可。作为一个自定义的工具可以根据实际需求随时更改。在减小篇幅的考虑下,这里只罗列出8个模块作为示意。
从[node.script]
中输出的数据经过了两层route,首先将放在前面的一些全局信息单独过滤(bpm, endTick, ppq, tracks),后面的是音符信息,根据音轨序号进行route,之后发送给相应的记录模块,[coll]
直接以音轨+序号命名,简洁清晰、不会重复。
在主体Patch中,保存数据的模块是用[bpatcher]
呈现的,其背后加载的是另一个单独的max文件,当然在bpatcher中看到的是presentation模式,它的patch截图如下:
与原项目记录时间数据类似,同样使用[counter]
为每一条数据加上索引,这是[coll]
的属性决定的。用户界面(presentation 模式)中,只保留两个按钮用于清除数据和开关[coll]
的窗口。在主体patch中,从[route]
无线接收到的数据在传入记录模块的同时也会触发一个很大的[bang]
,这个大号bang不仅起到视觉上的提示作用,它同时也是用来触发[counter]
的。
多个声部的文件在读取后,可以明显看到代表各个音轨的bang快速的依次闪亮。实际上不是闪了一次,只不过这些数据传输的极快,几千上万次的闪烁可能在一秒钟之内就结束了。解析数据的脚本文件使用了遍历函数一次处理每个音轨,因此在此处看到各个音轨的bang是逐个点亮的。从Max Console中也可以看到,数据是按照音轨依次输出的。
利用JSON对比检测脚本准确性
在脚本做出来之后、记录模块设计之前,其实中间还有一个检测环节,同样利用最开始用Tone.js转换出来的JSON文件。全局信息,如ppq、结尾时间等很容易对照,但是直接通过JSON查看某一轨的音符个数、寻找具体的某个音符等是不现实的。为此,又单独做了一个简单的python文件,用于读取JSON,方便检测。
仍然以鳟鱼五重奏MIDI文件为例,读取出JSON数据后,又随机调取三个音符,将音符信息显示出来,再回到Max中与[coll]
中的数据对照。经过多次检测,音符都是完全对的上的。
到此,MIDI解析程序设计成功。
总结
简单来说,这一版所作的工作就是将原项目中的“Timing data processing”模块完全推翻,重新设计了一个专业、准确的MIDI解析程序。在最初的版本中,不能分解一个完整的MIDI文件;音符信息的录入必须将MIDI文件按照原速度播放一遍,等着音符信息一个个输入;每个音轨都需要用到两个[coll]
来记录时间信息且模块繁琐;另外,原项目中还发现Max内置object在MIDI解析时的出现大量bug,即音符事件丢失。
这一版通过使用Node.js,编写了一个JS脚本,利用midi-file库准确解析了一个完整的MIDI文件,理论上可以支持任意声部数量的MIDI(格式1),而且解析是在瞬间完成的,即使无意间删除了数据,也随时可以迅速重新读取。解析之后的数据并没有做过多的处理,仍然保留其原始状态,如时间信息依然是原Ticks信息。因为作为一个解析工具,它已经提供了足够的数据,进一步的处理取决于这些数据要拿去做什么,再根据实际需要进行处理(或直接使用)。
通过这个项目,一方面充分利用了多个编程平台、语言、数据格式,同时也深入了解了MIDI文件的数据结构,为以后深入MIDI相关操作与开发打下了良好基础。