65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的 MIDI 文件切片器和 MIDI 库

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (12投票s)

2020 年 3 月 18 日

MIT

12分钟阅读

viewsIcon

38055

downloadIcon

379

从 MIDI 文件中截取片段,并使用此简单实用程序拉伸或压缩播放时间。或使用 Midi 库构建您自己的 MIDI 应用程序

MIDI Slicer app

引言

考虑访问我在这里的后续文章 它包含了改进的代码,并对 MIDI 和 Midi 库 API 进行了更详尽的解释。

很久以前,我编写了一个 VST 和 FL Studio 插件,它使用循环 MIDI 流来播放音频。我用 C++ 编写,但首先用 C# 原型化了许多 MIDI 操作。后来,我将这些 C# 原型代码扩展成了一个 MIDI 文件编辑库,我已在此提供,以及一个允许简单编辑 MIDI 文件的示例实用程序。

更新:添加了拍号支持。播放中的小 bug 修复。

更新 2:为每种 MIDI 操作类型添加了 MIDI 消息类,并为 MidiSlicer 应用程序添加了几项功能

更新 3:添加了标准化和级别缩放。将偏移量和长度设置为浮点数

更新 4:改进了 API 创建的 MIDI 序列和文件的正确性,改进了预览和保存。

更新 5:在 API 和 GUI 中添加了 Transpose() 选项

更新 6:API 增强和整体 GUI 行为改进

概念化这个混乱的局面

MIDI 代表 Musical Instrument Digital Interface(乐器数字接口)。它的作用是允许您自动化数字乐器,类似于老式自动演奏钢琴的工作方式。MIDI 包含所有音符信息 - 基本是一首歌的“乐谱”,然后它可以广播到最多 16 个不同的数字音频设备,如鼓机和合成器,或具有 MIDI 功能的钢琴等。您的 Windows 计算机包含一个默认设备,可以播放许多模仿各种乐器(如钢琴和吉他)的合成声音。您的手机也是如此。您的系统可以使用它通过默认音频设备(通常是您的主声卡或音频硬件)播放 MIDI 文件。MIDI 通常被手机用作存储铃声。话虽如此,MIDI 最初是为音乐家设计的,其主要目的是允许音乐家录制或“排序”他们的表演,并将其保存到文件中以便重播或编辑。

MIDI 协议

MIDI 首先是线路协议,其次是文件格式。该协议由实时 MIDI“消息”组成。本质上,MIDI 文件就是协议流存储为文件,并为每条“消息”加上一个增量时间 - “消息”应该在歌曲中的播放时间。增量时间加上消息称为 MIDI 事件。MIDI 事件作为流存储在文件中,供以后重播。因此,理解协议是理解文件格式的基础。

MIDI 设计于 20 世纪 80 年代,它定义了一个 8 位线路协议。所有字符串都是 ASCII,大多数值为 0-127(7 位加上前导 0 位),有些值为 0-255(8 位)。流中偶尔会有多字节值(大于 8 位)。这些总是大端序,所以在 Intel 平台上,您需要交换字节顺序。

MIDI 消息至少包含一个 8 位“状态字节”。但是,根据状态字节的值,消息可能包含其他字段/有效负载。状态字节同时告诉我们消息的“类型”(在前 4 位),以及消息的目标“通道”(MIDI“设备”)在最后 4 位 - 记住 MIDI 协议允许控制多达 16 个设备 - 从此处开始称为通道。大多数 MIDI 消息都有附加字段。例如,“音符开启”消息包含音符编号,以及要播放的音符的力度。以下 MIDI 消息由 3 字节组成。第一个字节中,9 是音符开启的代码,0 是通道 0 的代码。接下来要播放的 C 音符,5 组(下方 48 hex)的音符编号以最大力度(127/7F)播放,如最后一个字节所示,它必须将高位设置为零,为您留下 0-127 的数字范围。

90 48 7F

这将导致设备敲击该音符并一直保持,直到找到针对同一音符的“音符关闭”消息。

80 48 7F

音符关闭和音符开启之间的唯一区别是第一个半字节是 8 hex 而不是 9 hex。大多数设备不会为音符关闭消息使用力度字节,但会发送它。我通常会使用我在音符开启中指定的相同值,或在不知道相应音符开启力度的情况下使用零。无论哪种方式,在现有的 MIDI 设备上都应该“奏效”,但设备可能很奇怪。这个协议就是您必须对奇怪的设备有些宽容,这需要良好的、传统的测试。

再次,不同的消息长度不同。音色/程序更改消息指示通道将使用哪个“声音”。通常 MIDI 设备(如合成器)可以产生许多不同种类的声音。此消息允许您发送一个 7 位(0-127/7F)代码(编码为高位为 0 的完整字节),指示要使用的音色。选择音色 2 即可得到

C0 02

您可能已经注意到,除了状态字节之外,我们所有的值都是 7 位编码为 8 位,高位为零,因此我们有效的值范围是 0-127/7F。这很重要,因为有一个称为运行状态字节的优化,我将在下面简要介绍,并且在“进一步阅读”部分的链接中也有介绍。

MIDI 消息可能是一个带有状态字节的完整消息,或者状态字节可以被省略,在这种情况下将使用前面的状态。这允许“运行”多个相同类型且发送到同一通道的消息,但具有不同的参数。大多数时候,这只是为了优化而导致额外的复杂性,而优化通常并不重要,所以您不必真正发送它,但您必须能够读取它。也就是说,MIDI 在技术上是带宽受限的,所以如果您有很多事件,发送它也是有意义的。

在 MIDI 消息中,例如音高弯音(状态半字节 E),偶尔会发现消息中使用 14 位值。这些通过最低有效 7 位(在高位为 0 的 8 位字段中)后跟最高有效 7 位(在高位为 0 的 8 位字段中)进行编码。

您可以在“进一步阅读”部分的链接中找到 MIDI 消息的完整列表。

MIDI 文件格式

MIDI 文件布局为“块”。每个块是一个 fourCC ASCII 值,指示块的“类型”。后面是一个大端序的 4 字节整数,指示后续数据的长度,这就是块的实际数据。第一个块类型必须是“MThd”,另一个常见的块类型是“MTrk”。任何未知的块类型都应被跳过。

“MThd”块包含 MIDI 文件类型(通常为 1)、音轨数和时间基(通常为 480),每个都编码为大端序的 16 位字。

“MTrk”块包含一个音轨,这是一个音符开启/关闭消息事件和其他 MIDI 事件的序列。每个事件是一个增量时间,后跟一个部分或完整的 MIDI 消息。MIDI 文件中的第一个 MIDI 音轨通常是“特殊的”,因为它包含 MIDI 文件的元信息,包括节拍信息等关键数据,但也有歌词等内容。

增量时间编码为“可变长度整数”,我在这里不赘述,但在“进一步阅读”部分中的标准 MIDI 文件格式链接中有介绍。它表示自上一个事件以来的 MIDI“节拍”数(因此是增量)。我将在下面介绍计时。

后面的消息可以是完整的 MIDI 消息,也可以是状态字节被省略的部分 MIDI 消息,如前所述。

MIDI 文件中的计时

MIDI 计时以节拍为单位。节拍的计时取决于 MIDI 文件的时间基,该时间基以每四分音符节拍数为单位。这也称为每四分音符脉冲或 PPQ。这给出了一个节拍的长度,在默认的 4/4 拍下。默认速度为每分钟 120 拍。速度和拍号使用带有特殊 MIDI“元”消息(状态字节为 FF)的 MIDI 事件设置,并且可以在整个播放过程中设置。这些通常位于第一个音轨中,并且对所有音轨都是全局的。

我在项目目录中包含了两个我下载的 MIDI 文件用于测试。任何版权信息都可在 MIDI 文件本身中找到。A-Warm-Place.mid 使用的功能不完全受此库支持,但它“大部分”能播放。我包含它的原因是,它是扩展库以支持 SMPTE 计时和正确 sysex 传输的有用测试。

编写这个混乱的程序

主类是 MidiFile,名称恰当,因为它代表 MIDI 文件格式中的 MIDI 数据。它不一定基于物理文件。它可以完全在内存中创建和操作。它提供了对适用于所有音轨的常用功能的访问,以及计时信息,以及对音轨 0 中的元信息的访问。使用 MidiFile.ReadFrom() 读取 MIDI 文件,使用 WriteTo() 写入 MIDI 文件。

另一个非常重要的类是 MidiSequence,它代表 MidiFile 中的单个序列或音轨。此类允许您访问其所有 MidiEvent,无论是基于相对增量还是绝对时间的,并提供对存储在序列中的任何元信息的访问。序列可以通过 Merge() 合并或通过 Concat() 连接。它们可以通过 Stretch() 拉伸或压缩。您可以使用 GetRange() 检索事件范围。请注意,其中一些操作也出现在 MidiFile 上,并且那些将作用于文件中的每个音轨/序列。

在演示代码中,我们根据 UI 中的设置处理每个音轨。

if (NormalizeCheckBox.Checked)
    trk.NormalizeVelocities();
if (1m != LevelsUpDown.Value)
    trk.ScaleVelocities((double)LevelsUpDown.Value);
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);
}
...
if (1m != StretchUpDown.Value)
    trk = trk.Stretch((double)StretchUpDown.Value, AdjustTempoCheckBox.Checked);

首先,我们处理力度标准化和缩放。接下来,我们获取选择的偏移量和长度。然后,如果以节拍指定,我们使用 TimeBase 来计算节拍。其余的 Math 调用只是将值限制到最大允许长度。

接下来,如果我们的长度和偏移量不为 0 且长度是序列的长度,我们就会获取该时间范围内的事件。

我省略了上面列表中间的很多代码,但它通过调用适当的 MidiSequence API 方法来处理 UI 中的其余功能。

最后,如果我们将拉伸值指定为除 1 以外的值,我们就会调用 Stretch() 来拉伸音轨。

MidiEvent 仅包含一个位于 Position(以节拍为单位)的事件和一个 MidiMessage。根据此事件是通过 Events 还是 AbsoluteEvents 检索到的,Position 将分别是增量时间或绝对时间。

MidiMessage 及其派生类代表不同长度的 MIDI 消息,例如 MidiMessageByteMidiMessageWord。还有特殊的 MidiMessageMetaMidiMessageSysex 类,分别代表 MIDI 元消息和 MIDI 系统独占消息。有关这些消息的更多信息,请参阅“进一步阅读”部分中的标准 MIDI 文件格式链接。此外,每种类型的 MIDI 操作都有相应的 MIDI 消息,例如 MidiMessageNoteOnMidiMessageCCMidiMessageChannelPitch

MidiContext 是一个代表 MIDI 序列当前“状态”的类。使用 GetContext() 时,将从函数返回此类的一个实例,它将为您提供所有当前音符力度、控制位置、弯音轮位置、触后信息等。这可以让您了解序列中任何给定时刻正在播放的内容以及如何播放。

MidiUtility 提供低级 MIDI 功能,您通常不需要直接使用它。它提供了一些低级 IO 方法、字节交换以及速度/微速度的转换。

请注意,要执行设置速度和拍号等操作,您必须为每个操作添加 MidiMessageMeta 消息到相应的速度或拍号更改。在大多数文件中,这些应该添加到音轨 #0。

限制

  • 尽管该库的大部分代码是可移植的,但目前它只能在 Windows 上运行。
  • 虽然它可以从文件中读取 sysex 消息,但无法正确传输它们。
  • 它不支持 SMPTE 计时。
  • 在处理损坏文件方面,加载和保存可以更健壮。
  • FL Studio 对音轨 #0 的导入似乎没有被 FL Studio 完全识别。我还没有找出原因。WMP 似乎能正常处理,FL 也尊重了时序和音色信息,所以这似乎不是一个阻碍。

关注点

Preview() 方法不使用 Win32 MCI 的“play”来播放文件或序列。相反,序列是在调用线程上使用 C# 和直接调用 MIDI 设备输出 Win32 API 来播放的。在该方法中正确处理时序非常困难。您可能希望将其分派到单独的线程,因为它占用大量 CPU。通过用定时器回调替换硬循环,可能(也许)可以以更节省 CPU 的方式执行此操作,但这实现起来并不容易。请参阅演示代码中的 Preview 线程处理。

延伸阅读

历史

  • 2020 年 3 月 18 日 - 初始提交
  • 2020 年 3 月 18 日 - 更新 1:添加了拍号支持,播放中的 bug 修复
  • 2020 年 3 月 18 日 - 更新 2:为 MidiSlicer 和 MIDI API 添加了几项功能
  • 2020 年 3 月 18 日 - 预览现在可以循环播放。
  • 2020 年 3 月 20 日 - 添加了标准化和级别缩放。将偏移量和长度设置为浮点数
  • 2020 年 3 月 20 日 - 预览/另存为实现的 bug 修复和改进
  • 2020 年 3 月 21 日 - 改进了 UI 中的时序和渲染音轨的时序
  • 2020 年 3 月 22 日 - 改进了 API、保存和预览
  • 2020 年 3 月 22 日 - 添加了 Transpose() 和 Transpose GUI 选项
  • 2020 年 3 月 22 日 - 为 GetRange() API 方法添加了 copyTimingAndPatchInfo 选项
  • 2020 年 3 月 23 日 - 改进了 API 和整体 GUI 功能
© . All rights reserved.