MIDI parsing thru JavaScript in Max
内容纲要

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相关操作与开发打下了良好基础。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇