C# 中的深入 MIDI 文件操作






4.54/5 (9投票s)
关于进行高级 MIDI 操作的深度指南。包括一个演示鼓步进音序器和文件切片器
引言
本文旨在教您,亲爱的读者,如何使用我简单的“Midi” MIDI 库来生成和修改 MIDI 序列。它还旨在解释 MIDI 协议和文件格式的核心,以便您完全理解正在发生的事情。该库允许您深入了解 MIDI 流的结构,并且您经常直接处理消息,因此了解如何使用它很有帮助。市面上有其他提供更高级 MIDI 操作的库,但它们通常不像这个库那样提供原始访问权限。
更新:音序器长度加倍。添加了一些注释和小的改进
更新 2:为 FourByFour 添加了 Bars(拍号)选项
概念化这个混乱的局面
MIDI 是 **M**usical **I**nstrument **D**igital **I**nterface(乐器数字接口)的缩写。它是一种 8 位大端序的线路协议,开发于 20 世纪 80 年代,旨在取代更传统的模拟控制方式,例如对合成器等设备进行控制。在此之前,模拟控制使用“电压控制”参数,根据脚踏开关控制器等因素进行更改。它还添加了大量可扩展的控制器 ID 和各种其他功能,极大地扩展了音乐家控制合成器、MIDI 钢琴和鼓机等数字乐器的能力,并允许他们回放艺术家编排的乐谱,无论是通过 MIDI 控制器设备(如电子键盘)实时录制的,还是从文件中回放,甚至是其他方式生成的。
本质上,MIDI 旨在让音乐家能够控制表演的各个方面,包括控制多个乐器、调整声像和颤音等参数,以及按照音乐家期望的速度和时间精确演奏音符。它也是制作人的好帮手,因为它可以自动化混音台等设备。一些 MIDI 应用程序甚至可以控制演出时的灯光,以及音乐表演和混音数据!
该协议的可扩展性非常强,以至于在 MIDI 1.0 存在近 35 年后,它最近才更新到 2.0 规范。由于几乎所有乐器都支持 MIDI 1.0 而几乎没有乐器支持 2.0,因此此库仅支持 1.0。
MIDI 线路协议
MIDI 通过“消息”来工作,这些消息告诉乐器该做什么。MIDI 消息分为两种类型:*通道消息* 和 *系统消息*。通道消息构成了数据流的大部分,并承载表演信息,而系统消息控制全局/环境设置。
通道消息之所以称为通道消息,是因为它针对特定的通道。每个通道可以控制自己的乐器,最多有 16 个通道可用,其中通道 #10(零基索引 9)是一个特殊通道,始终承载打击乐信息,其他通道则映射到任意设备。这意味着 MIDI 协议一次最多可以与 16 个独立设备通信。
系统消息之所以称为系统消息,是因为它控制适用于所有通道的全局/环境设置。一个例子是通过“系统独占”或“sysex”消息将专有信息发送到特定硬件。另一个例子是 MIDI 文件中包含的特殊信息(但在线路协议中不存在),例如文件回放的节奏。系统消息的另一个例子是“系统实时消息”,它允许访问传输功能(播放、停止、继续以及设置传输设备的时间)。
每条 MIDI 消息都有一个关联的“状态字节”。这通常是 MIDI 消息中的第一个字节。状态字节在高四位(4 位)中包含消息 ID,在低四位中包含目标通道。因此,状态字节 0xC5 表示通道消息类型为 0xC,目标通道为 0x5。高四位必须大于或等于 0x8。如果高四位是 0xF,则为系统消息,并且整个状态字节就是消息 ID,因为没有通道。例如,0xFF 是 MIDI “元事件”消息的消息 ID,可以在 MIDI 文件中找到。同样,如果高四位是 0xF,低四位也是状态的一部分。
** 由于协议的优化,状态字节可能会被省略,在这种情况下将使用前一条消息的状态字节。这允许在不为每条消息重复冗余字节的情况下发送具有相同状态但参数不同的消息“序列”。
以下通道消息可用
0x8 Note Off
- 释放指定的音符。此消息中包含但未使用的力度值。所有具有指定音符 ID 的音符都会被释放,因此如果两个 Note On 后面跟着一个针对 C#4 的 Note Off,则该通道上的所有 C#4 音符都会被释放。此消息长度为 3 字节,包括状态字节。第二个字节是音符 ID (0-0x7F/127),第三个字节是力度 (0-0x7F/127)。Note Off 消息通常不尊重力度值。我不确定它为何存在。我所遇到的一切都没有使用它。它通常设置为零,或者可能是对应 Note On 的相同力度值。这真的无关紧要。0x9 Note On
- 敲击并保持指定的音符,直到找到相应的 Note Off 消息。此消息长度为 3 字节,包括状态字节。参数与 Note Off 相同。0xA Key Pressure/Aftertouch
- 指示按键按下的压力。这通常用于支持此功能的高端键盘,用于在按住音符时根据按压力度产生后续效果。此消息长度为 3 字节,包括状态字节。第二个字节是音符 ID (0-0x7F/127),第三个字节是压力 (0-0x7F/127)。0xB Control Change
- 表示要将控制器值更改为指定值。控制器因乐器而异,但对于声像(Panning)等常见控件,存在标准的控制代码。此消息长度为 3 字节,包括状态字节。第二个字节是控制器 ID。常见的 ID 包括声像 (0x0A/10) 和音量 (7),还有许多是自定义的,通常是硬件特定的或在硬件中可自定义映射到不同参数的。在此处 可以找到标准和可用自定义代码的表格。第三个字节是值 (0-0x7F/127),其含义很大程度上取决于第二个字节。0xC Patch/Program Change
- 一些设备有多个不同的“程序”或设置,可以产生不同的声音。例如,您的合成器可能有用于模拟电钢琴的程序,也有用于模拟弦乐合奏的程序。此消息允许您设置设备要播放的声音。此消息长度为 2 字节,包括状态字节。第二个字节是音色/程序 ID (0-0x7F/127)。0xD Channel Pressure/Non-Polyphonic Aftertouch
- 这类似于 aftertouch 消息,但适用于不支持复音 aftertouch 的不太复杂的乐器。它会影响整个通道而不是单个琴键,因此会影响所有正在播放的音符。它被指定为所有按下琴键的最大 aftertouch 值。此消息长度为 2 字节,包括状态字节。第二个字节是压力 (0x7F/127)。0xE Pitch Wheel Change
- 这表示音高轮已移动到新位置。这通常会对通道中的所有音符应用一个总体音高修改器,因此随着音高轮向上移动,所有正在播放的音符的音高都会相应增加,反之亦然。此消息长度为 3 字节,包括状态字节。第二和第三个字节分别包含最低 7 位 (0-0x7F/127) 和最高 7 位,产生一个 14 位值。
以下系统消息可用(非详尽列表)
0xF0 System Exclusive
- 这表示要将设备特定的数据流发送到 MIDI 输出端口。消息的长度可变,以“结束系统独占”消息为界。我还不清楚这如何传输,但它与线路协议不同于文件格式,因此它是独一无二的。在文件中,长度紧跟在状态字节之后,并编码为“可变长度数量”,稍后会介绍。最后,是指定字节长度的数据。0xF7 End of System Exclusive
- 这表示系统独占消息流的结束标记。0xFF Meta Message
- 这在 MIDI 文件中定义,但在线路协议中未定义。它表示特定于文件的数据,例如文件应播放的节奏,以及有关乐谱的其他信息,例如序列的名称、各个音轨的名称、版权声明,甚至歌词。这些可能具有任意长度。状态字节后面是一个字节,指示元消息的“类型”,然后是一个“可变长度数量”,表示长度,同样,后面是数据。
以下是消息在有线传输中的样子示例。
音符开,中央 C,通道 0 上的最大力度
90 3C 7F
通道 2 上的音色更改为 1
C2 01
请记住,状态字节可以省略。这里有一些在连续消息中发送到通道 0 的音符开消息。
90 3C 7F 3F 7F 42
这会产生中央 C 的 C 大调和弦。每条省略了状态字节的消息都使用前一条状态字节 0x90。
MIDI 文件格式
一旦您理解了 MIDI 有线协议,文件格式就相当简单,因为平均 MIDI 文件中有 80% 或更多是带有时间戳的 MIDI 消息。
MIDI 文件通常具有“*.mid”扩展名,并且与线路协议一样,它是一种大端序格式。MIDI 文件以“块”的形式布局。“块”是指 FourCC 代码(简单地说,是 ASCII 中的 4 字节代码),它表示块类型,后跟一个 4 字节整数值,表示块的长度,然后是指定长度的字节流。第一个块的 FourCC 始终是“MThd”。唯一其他相关块类型的 FourCC 是“MTrk”。所有其他块类型都是专有的,除非被理解,否则应忽略。块按顺序排列,文件中的块前后相连。
第一个块“MThd”的长度字段始终设置为 6 字节。后面的数据是 3 个 2 字节整数。第一个整数指示 MIDI 文件类型,几乎总是 1,但简单文件可以是类型 0,还有一个专门的类型 - 类型 2 - 用于存储模式。第二个数字是文件中的“音轨”数量。MIDI 文件可以包含多个音轨,每个音轨都有自己的乐谱。第三个数字是 MIDI 文件的“时间基”(通常为 480),它表示每四分音符的 MIDI“节拍”数。一个节拍代表的时间取决于当前节奏。
接下来的块是“MTrk”块或专有块。我们跳过专有块,读取找到的每个“MTrk”块。一个“MTrk”块代表一个 MIDI 文件音轨(下面解释) - 本质上只是带有时间戳的 MIDI 消息。带时间戳的 MIDI 消息称为 MIDI“事件”。时间戳以增量指定,每个时间戳是自上一个时间戳以来的节拍数。这些以一种奇怪的方式在文件中编码。这是 20 世纪 80 年代和当时有限的磁盘空间和内存(尤其是在硬件音序器上)的副产品 - 节省的每个字节都很重要。增量使用“可变长度数量”进行编码。
可变长度数量的编码如下:每字节 7 位,最高有效位在前(小端序!)。除了最后一个必须小于 0x80 的字节外,每个字节都大于 0x7F。如果值在 0 到 127 之间,它由一个字节表示,如果值更大,则需要更多字节。可变长度数量理论上可以是任何大小,但实际上它们不能大于 0xFFFFFFF - 大约 3.5 字节。您可以将它们保存在 int 中,但读写它们可能会很麻烦。
可变长度数量增量后面是一个 MIDI 消息,至少有一个字节,但其长度会因消息类型以及某些消息类型(元消息和 sysex 消息)是可变长度的而有所不同。它可能不带状态字节编写,在这种情况下将使用前一个状态字节。您可以通过字节大于 0x7F (127) 来判断字节是否为状态字节,而所有消息的负载都将是小于 0x80 (128) 的字节。它并不像听起来那么难读。基本上,对于每条消息,您都会检查当前字节是否为高位(> 0x7F/127),如果是,那就是您的新运行状态字节,以及该消息的状态字节。如果它是低位,您只需参考当前状态字节而不是设置它。
MIDI 文件轨道
MIDI 类型 1 文件通常包含多个“音轨”(上面简要提及)。一个音轨通常代表一个乐谱,多个音轨共同组成整个表演。虽然通常是这样布局的,但实际上是通道而不是音轨指示特定设备应播放哪个乐谱。也就是说,通道 0 的所有音符都将被视为同一乐谱的一部分,即使它们散布在不同的音轨中。音轨只是组织的一种便捷方式。它们实际上并不会改变 MIDI 的行为。在 MIDI 类型 1 文件(最常见的类型)中,音轨 0 是“特殊的”。它通常不包含性能消息(通道消息)。相反,它通常包含元信息,如节奏和歌词,而其余音轨包含性能信息。这样布局您的文件可确保与 MIDI 设备最大程度的兼容性。
非常重要:轨道必须始终以 MIDI “结束轨道”元消息结尾。
尽管音轨在概念上是分开的,但乐谱的分隔实际上是在底层按通道进行的,而不是按音轨。这意味着您可以拥有多个音轨,它们组合起来可以代表特定通道(或多个通道)的设备乐谱。您可以根据需要组合通道和音轨,但请记住,同一通道的所有通道消息都代表单个设备的实际乐谱,而音轨本身基本上是虚拟/抽象的便利项。
有关 MIDI 线路协议和 MIDI 文件格式的更多信息,请参阅 此页面。
现在我们已经涵盖了 MIDI 的基础知识,甚至包括一些棘手的细节,让我们继续讨论 API 的使用。
编写这个混乱的程序
包含的 **Midi** 程序集和项目公开了一个丰富的 API,用于处理 MIDI 文件、单个音轨和消息。
主要有 3 个类和一个次要类可供使用,其余的都是辅助和支持。
MidiFile
代表 MIDI 文件格式中的 MIDI 数据。它不一定由实际文件支持。通常,这些是在内存中创建和丢弃的,而无需写入磁盘。它以“File”为后缀的唯一原因是它代表 MIDI 文件格式的数据。它包含 `Tracks`,可以访问单个音轨,以及许多便捷成员,用于访问所有重要的“特殊”音轨 0 信息,例如 `Tempo`,或者在某些情况下,将操作应用于文件中的所有音轨,例如 `GetRange()`。该类还公开了静态 `ReadFrom()` 方法,允许您从流或文件路径读取 MIDI 文件,或 `WriteTo()` 方法将 MIDI 文件写入指定的流或文件。您还可以使用指定的时间基创建新实例,但请注意,一旦创建,就无法更改时间基。但是,您可以调用 `Resample()`,它接受新的时间基并为您创建一个具有新时间基的新 `MidiFile` 实例。
MidiSequence
是该库的主力。它处理所有核心操作。它可以进行归一化或缩放力度,使用 `NormalizeVelocities()` 和 `ScaleVelocities()`,检索子范围使用 `GetRange()`,拉伸或压缩序列的时间使用 `Stretch()`,甚至可以 `Merge()` 和 `Concat()` 序列。您可以使用 `Preview()` 在调用线程上播放序列,这对于在保存 MIDI 之前收听生成的 MIDI 非常有用,或者仅仅出于任何原因播放它。`MidiSequence`,与音轨一样,只是 MIDI 事件的集合。事实上,`MidiFile` 的“文件”中的每个 MIDI 音轨都由一个 `MidiSequence` 实例表示。请注意,上述大多数方法也可在 `MidiFile` 本身上使用,它会一次性处理文件中的所有音轨。您可以访问序列的事件,通过 `Events` 和 `AbsoluteEvents` 属性,具体取决于您是希望事件以增量还是绝对节拍返回(见上文)。`Events` 本身是一个可修改的列表,而 `AbsoluteEvents` 是一个惰性计算的枚举,不可修改。底线是,在添加或插入事件时,您必须以增量指定节拍。
MidiEvent
是一个次要类,仅包含一个 `Position` 和一个 `Message`。位置可以表示为节拍的增量位置,或节拍的绝对位置,具体取决于它是通过 `MidiSequence` 的 `Events` 属性还是 `AbsoluteEvents` 属性检索的(见上文)。
MidiMessage
及其许多派生类,如 `MidiMessageNoteOn` 和 `MidiMessageNoteOff`,代表线路协议和文件格式都支持的单个 MIDI 消息。有很多。值得注意的是,它们通常派生自 `MidiMessageByte`(具有单个字节参数的消息)或 `MidiMessageWord`(具有两个字节参数的消息,如 Note On,或具有 2 字节参数的消息,如 Pitch Wheel Change 消息)。您可以直接使用这些低级类,但它们不如高级类友好,尤其是在处理时间签名和节奏更改元消息等怪异或复杂的 MIDI 消息时。
支持类包括 `MidiTimeSignature`(代表时间签名)、`MidiKeySignature`(代表调号)、`MidiNote`(我们将稍后介绍)和 `MidiUtility`(您应该不需要太多)。
使用 MIDI Note On/Note Off 消息非常适合实时表演,但在对序列和乐谱进行更高级别的分析时,还有待改进。将音符理解为具有绝对位置、力度和长度的东西通常更好。`MidiSequence` 提供了 `ToNoteMap()` 方法,该方法检索一个 `MidiNote` 实例列表,代表序列中的音符,并附带长度,而不是 Note On/Note Off 的模式。它还提供了静态 `FromNoteMap()` 方法,该方法从 `MidiNote` 列表获取一个序列。这可以使创建和分析乐谱变得更容易。
API 技术
终止序列/音轨
重要:我们将从这里开始,因为它至关重要。在使用 `Merge()`、`Concat()` 或 `GetRange()` 等操作时,API 通常会自动为您使用音轨结束标记来终止序列,但如果您从头开始构建序列,您将需要手动在末尾插入它。虽然此 API 在没有它的情况下基本可用,但许多(如果不是大多数)MIDI 应用程序将无法正常工作,因此不写入它们就相当于写入了损坏的文件。
track.Events.Add(new MidiEvent(0,new MidiMessageMetaEndOfTrack()));
您很少需要这样做,但同样,如果您手动从头开始构建序列,则需要这样做。此外,还需要将 `0` 调整为您自己的增量时间以获得正确的音轨长度。
连续执行序列和文件转换
这很简单。每次我们进行转换时,它都会产生一个新的对象,因此我们每次都用新结果替换变量。
// assume file (variable) is our MidiFile
// modify track #2 (index 1)
var track = file.Track[1];
track = track.NormalizeVelocities();
track = track.ScaleVelocities(.5);
track = track.Stretch(.5);
// reassign our track
file.Track[1]=track;
同样的基本思想也适用于 `MidiFile` 实例。
搜索或同时分析多个音轨
有时您可能需要同时搜索多个音轨。虽然 `MidiFile` 提供了在文件中所有音轨上进行常见搜索的方法,但您可能需要操作序列列表或其他来源。解决方案很简单:将目标音轨暂时合并到一个新音轨中,然后对其进行操作。例如,假设您想查找目标音轨中任意一个音轨的第一个拍点。
// assume IList<MidiSequence> trks is declared
// and contains the list of tracks to work on
var result = MidiSequence.Merge(trks).FirstDownBeat;
您还可以通过循环遍历合并音轨中的事件来执行手动搜索。此技术几乎适用于所有情况。`Merge()` 是一个功能强大的方法,它是您的朋友。
插入绝对时间事件
以绝对时间指定事件通常要容易得多。API 不直接提供方法来实现这一点,但有间接的方法。
// myTrack represents the already existing sequence
// we want to insert an absolutely timed event into
// while absoluteTicks specifies the position at
// which to insert the message, and msg contains
// the MidiMessage to insert
// create a new MidiSequence and add our absolutely
// timed event as the single event in this sequence
var newTrack = new MidiSequence();
newTrack.Events.Add(new MidiEvent(absoluteTicks, msg));
// now reassign myTrack with the result of merging
// it with newTrack:
myTrack = MidiSequence.Merge(myTrack,newTrack);
首先,我们创建一个新序列并将绝对时间消息添加到其中。基本上,由于它是唯一的 `Message`,增量就是自零以来的节拍数,这与绝对位置相同。最后,我们将当前序列与刚刚创建的序列*合并*,并*重新赋值*。所有操作都会返回新实例。我们不会修改现有实例,因此我们经常发现像这样重新赋值变量。
创建音符图
至少在处理音符时,上面的一个更简单的方法是使用 `FromNoteMap()`。基本上,您只需要排队一个绝对位置音符列表,然后调用 `FromNoteMap()` 来从中获取一个序列。
var noteMap = new List<MidiNote>();
// add a C#5 note at position zero, channel 0,
// velocity 127, length 1/8 note @ 480 timebase
noteMap.Add(new MidiNote(0,0,"C#5",127,240));
// add a D#5 note at position 960 (1/2 note in), channel 0,
// velocity 127, length 1/8 note @ 480 timebase
noteMap.Add(new MidiNote(960,0,"D#5",127,240));
// now get a MidiSequence
var seq = MidiSequence.FromNoteMap(noteMap);
您还可以通过调用 `ToNoteMap()` 从任何序列获取音符图。
循环
指定节拍(4/4 时间下的 1/4 音符)会更容易,因此我们可以将所需的节拍数乘以 `MidiFile` 的 `TimeBase` 来获得节拍,至少对于 4/4 时间来说是这样。我在这里不涵盖其他时间签名,因为这属于乐理范畴,超出了范围。如果您想让此技术准确,则必须处理时间签名。总之,从 `FirstDownBeat` 或 `FirstNote` 开始循环,或者至少从这些位置之一开始偏移节拍也很有帮助。它们之间的区别是 `FirstDownBeat` 搜索低音/底鼓,而 `FirstNote` 搜索任何音符。一旦我们计算了偏移量和长度,我们就可以将它们传递给 `GetRange()`,以获得仅包含指定范围的 `MidiSequence` 或 `MidiFile`,并可以选择从序列开头复制节奏、时间签名和音色。
// assume file holds a MidiFile we're working with
var start = file.FirstDownBeat;
var offset = 16; // 16 beats from start @ 4/4
var length = 8; // copy 8 beats from start
// convert beats to ticks
offset *= file.TimeBase;
length *= file.TimeBase;
// get the range from the file, copying timing
// and patch info from the start of each track
file = file.GetRange(start+offset,length,true);
// file now contains an 8 beat loop
预览/播放
您可以使用 `Preview()` 播放任何 `MidiSequence` 或 `MidiFile`,但从主应用程序线程使用它几乎不是您想要的,因为它会阻塞。尤其是在指定 `loop` 参数时,因为它会无限期地挂起调用线程。您实际上想要做的是创建一个线程并在该线程上播放。这里有一个简单的技术,每次运行此代码时都可以通过切换播放状态来做到这一点。
// assume a member field is declared:
// Thread _previewThread and file
// contains a MidiFile instance
// to play.
if(null==_previewThread)
{
// create a clone of file for
// thread safety. not necessary
// if "file" is never touched
// again
var f = file.Clone();
_previewThread = new Thread(() => f.Preview(0, true));
_previewThread.Start();
} else {
// kill the thread
_previewThread.Abort();
// wait for it to exit
_previewThread.Join();
// update our _previewThread
_previewThread = null;
}
然后,您可以从主线程调用此代码来开始或停止“file”的播放。
演示项目
查看 MidiSlicer 切片器应用程序,以及 FourByFour,一个简单的 4/4 鼓步进音序器。我们将从鼓音序器开始,因为它更简单。
MidiFile _CreateMidiFile()
{
var file = new MidiFile();
// we'll need a track 0 for our tempo map
var track0 = new MidiSequence();
// set the tempo at the first position
track0.Events.Add(new MidiEvent(0, new MidiMessageMetaTempo((double)TempoUpDown.Value)));
// compute the length of our loop
var len = ((int)BarsUpDown.Value) * 4 * file.TimeBase;
// add an end of track marker just so all
// of our tracks will be the loop length
track0.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
// here we need a track end with an
// absolute position for the MIDI end
// of track meta message. We'll use this
// later to set the length of the track
var trackEnd = new MidiSequence();
trackEnd.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
// add track 0 (our tempo map)
file.Tracks.Add(track0);
// create track 1 (our drum track)
var track1 = new MidiSequence();
// we're going to create a new sequence for
// each one of the drum sequencer tracks in
// the UI
var trks = new List<MidiSequence>(BeatsPanel.Controls.Count);
foreach (var ctl in BeatsPanel.Controls)
{
var beat = ctl as BeatControl;
// get the note for the drum
var note = beat.Note;
// it's easier to use a note map
// to build the drum sequence
var noteMap = new List<MidiNote>();
for (int ic = beat.Steps.Count, i = 0; i < ic; ++i)
{
// if the step is pressed create
// a note for it
if (beat.Steps[i])
noteMap.Add(new MidiNote(i * (file.TimeBase / 4),
9, note, 127, file.TimeBase / 4-1));
}
// convert the note map to a sequence
// and add it to our working tracks
trks.Add(MidiSequence.FromNoteMap(noteMap));
}
// now we merge the sequences into one
var t = MidiSequence.Merge(trks);
// we merge everything down to track 1
track1 = MidiSequence.Merge(track1, t, trackEnd);
// .. and add it to the file
file.Tracks.Add(track1);
return file;
}
这是鼓音序器的核心,它会根据 UI 信息创建一个 `MidiFile`。奇怪的部分可能是 `endTrack`。`endTrack` 是一个带有单个音轨结束 MIDI 元消息的音轨。这是为了让我们能够为事件设置绝对节拍时间,如前所述。我们将它与我们创建的每个音轨合并,只是为了确保音轨在正确的时间结束。否则,一个音轨可能会过早结束,在这种情况下不是大问题,因为音轨 0 的长度已经设置好,但我们希望我们的文件干净整洁,以防加载已保存文件的音序器过于敏感和挑剔。其余部分应该能从上面的注释中自行解释。
接下来,我们有 MidiSlicer,由于功能众多,它稍微复杂一些。
MidiFile _ProcessFile()
{
// first we clone the file to be safe
// that way in case there's no modifications
// specified in the UI we'll still return
// a copy.
var result = _file.Clone();
// transpose it if specified
if(0!=TransposeUpDown.Value)
result = result.Transpose((sbyte)TransposeUpDown.Value,
WrapCheckBox.Checked,!DrumsCheckBox.Checked);
// resample if specified
if (ResampleUpDown.Value != _file.TimeBase)
result = result.Resample(unchecked((short)ResampleUpDown.Value));
// compute our offset and length in ticks or beats/quarter-notes
var ofs = OffsetUpDown.Value;
var len = LengthUpDown.Value;
if (0 == UnitsCombo.SelectedIndex) // beats
{
len = Math.Min(len * _file.TimeBase, _file.Length);
ofs = Math.Min(ofs * _file.TimeBase, _file.Length);
}
switch (StartCombo.SelectedIndex)
{
case 1:
ofs += result.FirstDownBeat;
break;
case 2:
ofs += result.FirstNoteOn;
break;
}
// nseq holds our patch and timing info
var nseq = new MidiSequence();
if(0!=ofs && CopyTimingPatchCheckBox.Checked)
{
// we only want to scan until the
// first note on
// we need to check all tracks so
// we merge them into mtrk and scan
// that
var mtrk = MidiSequence.Merge(result.Tracks);
var end = mtrk.FirstNoteOn;
if (0 == end) // break later:
end = mtrk.Length;
var ins = 0;
for (int ic = mtrk.Events.Count, i = 0; i < ic; ++i)
{
var ev = mtrk.Events[i];
if (ev.Position >= end)
break;
var m = ev.Message;
switch (m.Status)
{
// the reason we don't check for MidiMessageMetaTempo
// is a user might have specified MidiMessageMeta for
// it instead. we want to handle both
case 0xFF:
var mm = m as MidiMessageMeta;
switch (mm.Data1)
{
case 0x51: // tempo
case 0x54: // smpte
if (0 == nseq.Events.Count)
nseq.Events.Add(new MidiEvent(0,ev.Message.Clone()));
else
nseq.Events.Insert(ins, new MidiEvent(0,ev.Message.Clone()));
++ins;
break;
}
break;
default:
// check if it's a patch change
if (0xC0 == (ev.Message.Status & 0xF0))
{
if (0 == nseq.Events.Count)
nseq.Events.Add(new MidiEvent(0, ev.Message.Clone()));
else
nseq.Events.Insert(ins, new MidiEvent(0, ev.Message.Clone()));
// increment the instrument count
++ins;
}
break;
}
}
// set the track to the loop length
nseq.Events.Add(new MidiEvent((int)len, new MidiMessageMetaEndOfTrack()));
}
// see if track 0 is checked
var hasTrack0 = TrackList.GetItemChecked(0);
// slice our loop out of it
if (0!=ofs || result.Length!=len)
result = result.GetRange((int)ofs, (int)len,CopyTimingPatchCheckBox.Checked,false);
// normalize it!
if (NormalizeCheckBox.Checked)
result = result.NormalizeVelocities();
// scale levels
if (1m != LevelsUpDown.Value)
result = result.ScaleVelocities((double)LevelsUpDown.Value);
// create a temporary copy of our
// track list
var l = new List<MidiSequence>(result.Tracks);
// now clear the result
result.Tracks.Clear();
for(int ic=l.Count,i=0;i<ic;++i)
{
// if the track is checked in the list
// add it back to result
if(TrackList.GetItemChecked(i))
{
result.Tracks.Add(l[i]);
}
}
if (0 < nseq.Events.Count)
{
// if we don't have track zero we insert
// one.
if(!hasTrack0)
result.Tracks.Insert(0,nseq);
else
{
// otherwise we merge with track 0
result.Tracks[0] = MidiSequence.Merge(nseq, result.Tracks[0]);
}
}
// stretch the result. we do this
// here so the track lengths are
// correct and we don't need ofs
// or len anymore
if (1m != StretchUpDown.Value)
result = result.Stretch((double)StretchUpDown.Value, AdjustTempoCheckBox.Checked);
// if merge is checked merge the
// tracks
if (MergeTracksCheckBox.Checked)
{
var trk = MidiSequence.Merge(result.Tracks);
result.Tracks.Clear();
result.Tracks.Add(trk);
}
return result;
}
如您所见,MidiSlicer 的文件处理例程同时处理现有文件(由成员 `_file` 表示),并且由于 UI 选项众多,比鼓音序器复杂得多。请注意,我们在音轨开头手动扫描了节奏和 SMPTE 消息。这是为了能够将它们复制到我们正在创建的新文件中,即使新文件位于偏移和/或开始位置而不是开头。我们不必这样做。`GetRange()` 可以为我们做到。事实是,我在编写此代码后添加了该功能,但我决定保留它,以便您可以看到如何稳健地扫描特定消息的事件。
历史
- 2020 年 3 月 25 日 - 首次提交
- 2020 年 3 月 26 日 - 音序器长度加倍。小的改进
- 2020 年 3 月 26 日 - FourByFour 中的错误修复
- 2020 年 3 月 26 日 - 为 FourByFour 添加了 Bars 选项
- 2020 年 3 月 26 日 - API 略有改进