Midi:C# 中的 Windows MIDI 库





5.00/5 (16投票s)
提供了一个完整的托管 API,用于处理 MIDI 文件、序列和设备
引言
我做一些 MIDI 排序和录制工作,发现能够从 MIDI 文件中剪切出部分内容很有帮助,但我没有一个工具能轻松地做到这一点。在创建这样一个工具的过程中,我制作了一个 Midi 程序集,其中包含了核心的 MIDI 文件操作选项。我最初也写了一些补救性的播放代码,使用了 32 位 Windows MIDI API。
随着我添加更多功能并完善已有的功能,这个库不断壮大。我添加了更多的演示、流式传输支持、MIDI 输入支持、设备枚举等。最终,我封装了 API 的 90-95%,并拥有了一系列用于在内存中搜索和修改序列和文件的 MIDI 操作函数。
在此过程中,MidiSlicer
从一个头等应用程序变成了一个演示项目,因此解决方案仍然命名为MidiSlicer
- 我被困在了这个 GitHub 名称上。核心库项目名为Midi
。
我曾发表过关于使用其中部分功能的文章,但从未有过全面的指南,我打算在这里做到这一点。
更新:为库添加了实验性的节奏同步功能。它无法完美地实现时序,因为我无法稳定地获得足够的低延迟来实现非常精确的同步,但我提供它是为了完整性。
更新 2: 为 MidiSequence 添加了一些改进,以帮助根据时间在音轨中定位,例如GetPositionAtTime()
和GetNextEventAtPosition()
。和以前一样,你仍然可以使用MidiSequence.GetContext(position).Time
从一个位置获取时间。
更新 3: 修复了MidiStream
中的稳定性问题。事实证明,我对 MIDI 驱动 API 的工作方式有些误解,而且它文档并不完善,所以我没有得到太多帮助。它本来是工作的,直到我“优化”它来稍微减少非托管堆碎片,但由于某些原因它无法承受这种优化。事实证明,它并没有像我想象的那样处理内存。无论如何,我修好了。请获取此更新,特别是如果您的应用程序随机崩溃。
更新 4: 终于添加了MidiSequence.AddAbsoluteEvent()
,这是一种优化方法,用于将单个绝对定位的MidiEvent
添加到MidiSequence
中,而无需 resort to Merge()
,后者更复杂且效率较低。这在技术部分有更详细的探讨。
更新 5: 这不是对本文的直接更新,但我发布了一篇相关文章,介绍了我如何处理这个库中一些棘手的 P/Invoke 调用。它特别涵盖了MidiStream
的一些底层内部机制。
更新 6: MidiStream
现在派生自MidiOutputDevice
更新 7: 修复了MidiSequence.ToNoteMap()
的 bug,并添加了MidiUI项目,其中包含 MIDI 排序用户界面控件的初步功能,包括钢琴控件和 MIDI 序列可视化控件。我仍在开发这些控件,因此它们目前仅被视为粗略的证明。当我取得更大进展时,我会写一篇关于它们的文章。
概念化这个混乱的局面
这个库主要有两个部分,尽管它们彼此完全无缝地集成在一起。
一个部分处理 MIDI 文件和内存中的序列,提供操作和查询功能。
另一部分处理 MIDI 设备的通信和查询。这是您读取键盘按键或通过合成器(包括计算机声卡内置的波表合成器)发声的方式。
深入研究这些内容后,我们将介绍 MIDI 协议,因为文件和 MIDI 设备 API 都依赖于 MIDI 协议格式。MIDI 协议格式在本节后面介绍,但首先,我们将介绍表示它的 API。
MIDI API
协议 API
消息 API
协议主要由 MIDI 消息组成,这些消息表示各种操作,例如调节旋钮或弹奏键盘上的音符。MIDI 消息的 API 相对简单。它是一系列MidiMessage
的派生类,紧密地镜像底层协议,并提供每个操作的更高级别的表示,例如MidiMessageNoteOn
/MidiMessageNoteOff
来表示音符的按下和释放,以及MidiMessageCC
来表示控制更改,例如旋钮的微调。
由于几乎所有消息的每种类型消息都有特定长度,因此每个MidiMessage
进一步派生自MidiMessageByte
(用于带有单个字节负载的消息)或MidiMessageWord
(用于带有双字节负载的消息),它们提供对消息中数据的原始字节级别访问。最后,这些由代表消息的最终高级 MIDI 消息派生,例如MidiMessageNoteOn
,它派生自MidiMessageWord
,因为它需要两个字节来表示。
建议使用MidiMessageNoteOn
上的高级成员,例如Note
和Velocity
来调整数据,即使它们也可以通过从MidiMessageWord
继承的Data1
和Data2
来访问。每个高级消息都具有表示该消息特定参数的高级成员,如上所述为MidiMessageNoteOn
。
虽然大多数消息的有效负载长度固定为零、一或两个字节,但有两个例外。第一个是 MIDI 系统独占消息,也称为 sysex 消息,它将设备特定信息发送到 MIDI 输出设备或从 MIDI 输入设备接收。这些由MidiMessageSysex
表示,它具有由Data
表示的可变长度有效负载。
第二个例外通常仅出现在文件中,并且不会通过线路发送或从设备接收。这些称为 MIDI 元消息,提供诸如节奏更改或版权信息之类的内容。尽管它们仅出现在文件中,但设备 API 包装器会接受某些元事件,如节奏更改,但这些永远不会作为 MIDI 消息发送到输出设备,也不会从设备接收。这是由包装器代码本身提供的,以便更容易地直接从文件读取到设备,但它是为了方便起见提供的。基本上发生的是,每当 MIDI 流包装器找到这些消息之一时,它就会调整其内部节奏。这些元消息由MidiMessageMeta
的派生类表示,例如MidiMessageMetaTempo
,它表示节奏更改。MidiMessageMeta
消息的类型由Type
表示,它表示元消息的种类,并构成有效负载的第一部分,剩余的有效负载由Data
表示。
协议的另一部分,用于文件和排队消息以进行定时播放,由事件组成,这些事件只是 MIDI 消息,还带有时间戳增量。时间戳增量是从上一个消息开始的 MIDI tick 数。MIDI tick 的持续时间基于序列或排队事件设置的时间基准(分辨率)和节奏。一系列事件代表一个特定的乐谱,适合存储在文件中或用于排队播放。MidiEvent
表示一个 MIDI 事件,它由表示 tick 中时间戳增量的Position
和一个包含关联 MIDI 消息的Message
组成。虽然事件几乎总是包含时间戳增量,但从MidiSequence
获取AbsoluteEvents
(见下文)会将Position
设置为序列中消息的绝对位置(以 tick 为单位)。
最后,有一个MidiContext
类,它可以轻松跟踪乐谱中的当前位置,以及消息播放流中所有 CC 旋钮和音符的状态。基本上,它保存了所有音符力度和 CC 值、音高轮、当前节奏、当前歌曲位置和其他信息的状态。您沿着进度向其中馈送MidiMessage
消息和/或MidiEvent
事件,它会处理所有跟踪。然后,您可以查询它的任何播放方面状态。
文件和序列 API
MIDI API 的核心以及大多数功能的基础是MidiSequence
,它只是包含内存中的一系列 MIDI 事件(由MidiEvent
表示)以及用于查询和操作事件的各种成员。所有操作都以 tick 为单位。
Events
列表是您逐个事件修改序列的主要访问方式。它使用MidiEvent
实例中的时间戳增量来表示事件。还有一个只读枚举AbsoluteEvents
,它生成MidiEvent
对象,其中Position
设置为序列中的绝对位置(以 tick 为单位),这有时使操作更容易。目前,您无法修改此枚举,但在未来版本中它可能是一个可修改的列表。
像Lyrics
、Tempos
和Copyright
这样的成员是通过扫描序列以查找适当的MidiMessageMeta
派生消息来检索的。目前,要更改这些,您需要自己向序列中添加和删除元消息,因为这些属性是只读的。这在未来版本中可能会改变。
有一些高级查询,如FirstDownBeat
和FirstNoteOn
,用于获取相应目标的位置。
使用 MIDI 音符开/关消息非常适合实时性能,但在对序列和乐谱进行更高级分析时却不尽如人意。将音符理解为具有绝对位置、力度和长度的内容通常更好。MidiSequence
提供了ToNoteMap()
方法,该方法检索代表序列中音符的MidiNote
实例列表(包括长度),而不是音符开/关范式。它还提供了静态的FromNoteMap()
方法,该方法从MidiNote
对象的音符列表中获取一个序列。这可以使创建和分析乐谱都更容易。
还有AdjustTempo()
、Stretch()
、Resample()
、GetRange()
、Merge()
等方法,每个方法都会返回一个新的序列,并在其中应用了指示的操作。特别是Merge()
是一个多功能的方法,它允许您通过合并查询跨多个序列,或执行诸如为播放合并之类的操作。
Preview()
将使用调用的线程播放序列,并使用可选指示的MidiOutputDevice
。它可以选择循环,但建议在单独的线程上执行此操作,以便可以中止,因为没有办法退出循环。此方法不进行流式传输。相反,它立即将每条消息发送到硬件。这会占用大量 CPU 资源。有一个更好的方法可以通过流式传输序列到硬件来播放,这可以异步完成。这在技术部分进行了介绍。
MidiFile
表示内存中的 MIDI 文件。MIDI 文件包含多个轨道,每个轨道由一个MidiSequence
表示。第一个轨道通常 - 至少对于“类型 1”MIDI 文件 - 只包含元消息,包括节奏图,不包含性能消息。API 假定如此,因此当您查询Tempo
之类的属性时,它会在第一个轨道上查找。
MidiFile
包含许多与MidiSequence
相同的成员,这些成员要么操作第一个轨道,要么操作所有轨道,具体取决于操作的合理性。您始终可以修改每个单独的轨道,但请记住将修改后的序列重新分配给该索引处的Tracks
列表,因为对序列的修改始终返回序列的副本 - 它们不会修改序列本身。任何修改 MIDI 文件的方法,如Stretch()
,都会返回一个新的 MIDI 文件,类似于MidiSequence
的工作方式。
MidiFile
还包含ReadFrom()
和WriteTo()
,可以从Stream
或文件读取 MIDI 文件。自然,这就是您将内存表示转换为实际 MIDI 文件,或将 MIDI 文件转换为内存中的MidiFile
的方式。如果尚不清楚,对MidiFile
的所有操作都在内存中进行。修改文件的过程包括读取它,修改它,然后将新修改的文件写回磁盘,覆盖旧文件。
支持类包括MidiTimeSignature
(表示时间签名)、MidiKeySignature
(表示调号)、MidiNote
(稍后介绍)以及MidiUtility
(您应该不需要太多)。
设备 API
注意:所有事件都可能从不同的线程调用。
设备 API 主要由MidiDevice
及其派生类MidiOutputDevice
和MidiInputDevice
组成,用于与 MIDI 设备通信,另外还有MidiStream
,用于高性能异步输出流式传输。
您可以从MidiDevice
的Inputs
、Outputs
和Streams
成员中枚举以上每种设备,但通常您会从MidiOutputDevice
的Stream
成员中获取流。每次枚举它们时,系统都会被重新查询,因此您可以每次想要新的设备列表时都获取这些列表,但显然不要查询次数超过您需要的次数。
MidiOutputDevice
包含几个与打开设备通信的成员。通常,过程是先Open()
它,然后开始使用Send()
发送消息,最后调用Close()
关闭它。Reset()
可以视为一种紧急方法,它向所有通道发送音符关闭消息,因此它基本上清除了所有正在播放的音符。如果支持,您还可以使用Volume
属性获取或设置音量,它通过MidiVolume
获取/报告左耳和右耳的音量。该对象是可处置的,因此在处置时会关闭。
MidiStream
提供了一种更有效的方式来与能够接受多个排队的 MIDI 事件并在后台播放的设备通信,尽管它也支持立即发送消息。使用它类似于使用MidiOutputDevice
,不同之处在于它还必须使用Start()
启动,然后排队的事件才会开始播放,因为一旦使用Open()
打开,它就开始处于暂停状态。您可能希望设置TimeBase
,以及可能设置Tempo
或MicroTempo
。
如果您使用MidiMessage
调用Send()
,则消息将立即发送到输出。如果您使用一个或多个MidiEvent
对象调用Send()
,它们将被排队以供播放。除非您一次性发送完后就不再处理,否则您需要处理SendComplete
事件,该事件将告诉您排队的事件何时已播放完毕。请注意,在所有事件都播放完毕之前,您无法排队更多事件。Send 接受节奏更改消息,并将尊重音轨结束消息。其他元消息将被丢弃。在技术部分,将演示如何流式传输文件或序列。
MidiInputDevice
包含捕获 MIDI 输入的成员。您所做的是挂钩相关的事件,包括Input
,Open()
设备,Start()
设备以开始捕获。您可以使用StartRecording()
和EndRecording()
轻松地将 MIDI 性能录制到文件中。每次收到有效消息时,都会触发Input
事件,并带有参数,这些参数告诉您消息以及自调用Start()
以来经过的毫秒数。还有Error
、Opened
和Closed
。如果收到无效或格式错误的 MIDI 消息,则会触发Error
。
协议格式
消息格式
以下指南以 MIDI 协议格式教程的形式呈现,但您不必完全熟悉它才能使用此库。所有 MIDI 协议功能都由 API 封装。
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 后跟一个 Note Off 用于 C#4,则该通道上的所有 C#4 音符都会被释放。此消息长度为 3 字节,包括状态字节。第二个字节是音符 ID(0-0x7F/127),第三个字节是力度(0-0x7F/127)。力度几乎从未被用于音符关闭消息。我不确定它为什么存在。我遇到的任何东西都没有使用它。它通常设置为零,或者可能与相应的音符打开消息的力度相同。这真的不重要。0x9 Note On
- 弹奏并保持指定音符,直到找到相应的音符关闭消息。此消息长度为 3 字节,包括状态字节。参数与音符关闭相同。0xA Key Pressure/Aftertouch
- 指示按下音符的压力。这通常用于支持它的高端键盘,以便在按住音符时产生额外的效果,具体取决于按下的压力。此消息长度为 3 字节,包括状态字节。第二个字节是音符 ID(0-0x7F/127),第三个字节是压力(0-0x7F/127)。0xB Control Change
- 表示控制器值将更改为指定值。控制器因乐器而异,但有一些标准的控制代码用于常用控件,例如声像。此消息长度为 3 字节,包括状态字节。第二个字节是控制 ID。有常用 ID,如声像(0x0A/10)和音量(7),以及许多自定义 ID,通常是硬件特定的或在您的硬件中可自定义映射到不同参数的。有一张关于标准和可用自定义代码的表格这里。第三个字节是值(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 代码(简单来说就是一个 4 字节的 ASCII 码),指示块类型,后跟一个 4 字节整数值,表示块的长度,然后是指定长度的字节流。文件中的第一个块的 FourCC 始终是“MThd”。唯一其他相关块类型的 FourCC 是“MTrk”。所有其他块类型都是专有的,除非被理解,否则应忽略。块按顺序排列在文件中,背靠背。
第一个块“MThd”的长度字段始终设置为 6 字节。后面的数据是 3 个 2 字节整数。第一个指示 MIDI 文件类型,几乎总是 1,但简单文件可以是类型 0,还有一个专门的类型 - 类型 2 - 用于存储模式。第二个数字是文件中“轨道”的数量。MIDI 文件可以包含多个轨道,每个轨道包含自己的乐谱。第三个数字是 MIDI 文件的“时间基准”(通常为 480),表示每四分音符的 MIDI“tick”数。一个 tick 代表的时间量取决于当前的节奏。
接下来的块是“MTrk”块或专有块。我们跳过专有块,并读取找到的每个“MTrk”块。“MTrk”块代表一个 MIDI 文件轨道(如下所述)- 它本质上只是带有时间戳的 MIDI 消息。带有时间戳的 MIDI 消息称为 MIDI “事件”。时间戳以增量指定,每个时间戳是自上一个时间戳以来的 tick 数。它们以一种奇怪的方式编码在文件中。这是 1980 年代以及当时有限的磁盘空间和内存(尤其是在硬件排序器上)的产物 - 节省的每一字节都很重要。增量使用“可变长度数量”进行编码。
可变长度数量的编码如下:每字节 7 位,最高有效位在前(小端序!)。每个字节都大于 0x7F,除了最后一个字节必须小于 0x80。如果值在 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 文件读写到磁盘
var file = MidiFile.ReadFrom("sample.mid");
// code here modifying file...
file.WriteTo("sample.mid");
修改文件中的单个轨道
// get the 2nd track of the MIDI file
var track = file.Tracks[1];
// normalize - remember all
// modifications create a copy
track = track.NormalizeVelocities();
// reassign the modified track
file.Tracks[1]=track;
枚举 MIDI 设备(包括丰富的显示)
Console.WriteLine("Output devices:");
Console.WriteLine();
foreach (var dev in MidiDevice.Outputs)
{
var kind = "";
switch (dev.Kind)
{
case MidiOutputDeviceKind.MidiPort:
kind = "MIDI Port";
break;
case MidiOutputDeviceKind.Synthesizer:
kind = "Synthesizer";
break;
case MidiOutputDeviceKind.SquareWaveSynthesizer:
kind = "Square wave synthesizer";
break;
case MidiOutputDeviceKind.FMSynthesizer:
kind = "FM synthesizer";
break;
case MidiOutputDeviceKind.WavetableSynthesizer:
kind = "Wavetable synthesizer";
break;
case MidiOutputDeviceKind.SoftwareSynthesizer:
kind = "Software synthesizer";
break;
case MidiOutputDeviceKind.MidiMapper:
kind = "MIDI Mapper";
break;
}
Console.WriteLine(dev.Name + " " + dev.Version + " " + kind);
}
Console.WriteLine();
Console.WriteLine();
Console.WriteLine("Input devices:");
Console.WriteLine();
foreach (var dev in MidiDevice.Inputs)
{
Console.WriteLine(dev.Name + " " + dev.Version);
}
打开设备并发送输出
// just grab the first output device
using(var dev = MidiDevice.Outputs[0])
{
// open the device
dev.Open();
// send a C5 major chord
dev.Send(new MidiMessageNoteOn("C5", 127, 0));
dev.Send(new MidiMessageNoteOn("E5", 127, 0));
dev.Send(new MidiMessageNoteOn("G5", 127, 0));
Console.Error.WriteLine("Press any key to exit...");
Console.ReadKey();
// note offs
dev.Send(new MidiMessageNoteOff("C5", 127, 0));
dev.Send(new MidiMessageNoteOff("E5", 127, 0));
dev.Send(new MidiMessageNoteOff("G5", 127, 0));
}
捕获输入
// just grab the first input device
using(var dev = MidiDevice.Inputs[0])
{
Console.Error.WriteLine("Press any key to exit...");
// hook the input
dev.Input += delegate(object s,MidiInputEventArgs ea) {
Console.WriteLine(ea.Message);
};
// open the device
dev.Open();
// start capturing
dev.Start();
// wait for keypress
Console.ReadKey();
}
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()
是一个多功能的方法,它是您的朋友。
插入绝对计时事件
使用绝对时间指定事件通常要容易得多。有几种方法可以做到。第一种是直接进行。
// 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
myTrack.AddAbsoluteEvent(absoluteTicks,msg);
上面的代码直接在指定的绝对位置插入带有指定消息的事件。然而,很多时候,您需要将MidiMessageMetaEndTrack
插入到已经存在的轨道中。使用上面的方法存在的问题是,其中一个结束轨道消息几乎肯定已经存在,除非您自己构建它。您需要先删除它,然后再添加自己的。以下技术处理了所有这些问题,既插入新事件,又删除旧的结束轨道。
// 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);
首先,我们创建一个新序列并将绝对计时消息添加到其中。基本上,由于它是唯一的消息,增量是从零开始的滴答数,这与绝对位置相同。最后,我们取当前序列,并将其重新赋值为合并当前序列与我们刚刚创建的序列的结果。所有操作都会返回新实例。我们不修改现有实例,因此我们经常会发现我们像这样重新赋值变量。
创建音符图
以上操作的一种更简单的方法,至少在处理音符时,是使用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
。这与流式 API 不同,是同步的,但不需要使用MidiStream
。从主应用程序线程使用它几乎不是您想要的,因为它会阻塞。当指定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”的播放。
预览/播放(简单流式传输)
以下是流式传输序列以供播放的简单方法。
// just grab the first output stream
using (var stm = MidiDevice.Streams[0])
{
// open it
stm.Open();
// read a MIDI file
var mf = MidiFile.ReadFrom(@"..\..\Feel_good_4beatsBass.mid");
// merge the tracks for playback
var seq = MidiSequence.Merge(mf.Tracks);
// set the stream timebase
stm.TimeBase = mf.TimeBase;
// start the playback
stm.Start();
Console.Error.WriteLine("Press any key to exit...");
// if we weren't looping
// we wouldn't need to
// hook this:
stm.SendComplete += delegate (object s, EventArgs e)
{
// loop
stm.Send(seq.Events);
};
// kick things off
stm.Send(seq.Events);
// wait for exit
Console.ReadKey();
}
请注意,我们只挂钩了SendComplete
,以便我们可以循环播放。
预览/播放(复杂流式传输)
以下技术允许更实时的控制,但缺点是使用起来更复杂。通过这种方式,您可以分块处理流。
// demonstrate streaming a midi file 100 events at a time
// this allows you to handle files with more than 64kb
// of in-memory events (not the same as "on disk" size)
// this replays the events in a loop
var mf = MidiFile.ReadFrom(@"..\..\Bohemian-Rhapsody-1.mid"); // > 64kb!
// we use 100 events, which should be safe and allow
// for some measure of SYSEX messages in the stream
// without bypassing the 64kb limit
const int EVENT_COUNT = 100;
// our current cursor pos
int pos = 0;
// merge our file for playback
var seq = MidiSequence.Merge(mf.Tracks);
// the number of events in the seq
int len = seq.Events.Count;
// stores the next set of events
var eventList = new List<MidiEvent>(EVENT_COUNT);
// just grab the first output stream
// should be the wavetable synth
using (var stm = MidiDevice.Streams[0])
{
// open the stream
stm.Open();
// start it
stm.Start();
// first set the timebase
stm.TimeBase = mf.TimeBase;
// set up our send complete handler
stm.SendComplete += delegate (object sender,EventArgs eargs)
{
// clear the list
eventList.Clear();
// iterate through the next events
var next = pos+EVENT_COUNT;
for(;pos<next;++pos)
{
// if it's past the end, loop it
if (len <= pos)
{
pos = 0;
break;
}
// otherwise add the next event
eventList.Add(seq.Events[pos]);
}
// send the list of events
stm.SendDirect(eventList);
};
// add the first events
for(pos = 0;pos<EVENT_COUNT;++pos)
{
// if it's past the end, loop it
if (len <= pos)
{
pos = 0;
break;
}
// otherwise add the next event
eventList.Add(seq.Events[pos]);
}
// send the list of events
stm.SendDirect(eventList);
// loop until a key is pressed
Console.Error.WriteLine("Press any key to exit...");
Console.ReadKey();
// close the stream
stm.Close();
}
我们在这里做的是将文件的音轨合并到一个序列中进行播放。我们打开流,然后启动它,一次抓取多达 100 个(EVENT_COUNT
)事件,并使用SendDirect()
而不是Send()
将它们排队。原因是前者不进行缓冲,尽管它更底层,并且仅限于 64kb 的事件内存。我们已经在上面进行了缓冲,所以我们不需要这样做。我们挂钩了SendComplete
,因此每次触发时,我们都会获取下一个 100 个事件并将它们发送到队列。如果我们超过了末尾,我们将位置重置为零以进行循环。我们一直这样做,直到按下某个键。
录制表演(简单)
您可以使用StartRecording()
和EndRecording()
将表演简单地录制到MidiFile
中。基本上,您所做的是Open()
输入设备,可选地Start()
它 - 如果需要,它将为您启动 - 然后调用StartRecording()
并传递一个布尔值,该值指示是立即开始录制还是等待第一个 MIDI 输入。录制完成后应调用EndRecording()
。您可以选择将剩余部分修剪到最后一个接收到的 MIDI 信号。否则,所有剩余的空白时间将位于文件末尾。EndRecording()
返回一个类型 1 MIDI 文件,包含两个轨道。第一个轨道包含节奏图,但不包含性能数据。第二个轨道包含性能数据。如果您想将输入传递到输出,以便您能听到正在录制的内容,您需要挂钩Input
事件并将收到的内容Send()
到输出设备。如下所示。
MidiFile mf;
using (var idev = MidiDevice.Inputs[0])
{
using (var odev = MidiDevice.Outputs[0])
{
idev.Input += delegate (object s, MidiInputEventArgs e)
{
// this is so we can pass through and hear
// our input while recording
odev.Send(e.Message);
};
// open the input
// and output
idev.Open();
odev.Open();
// start recording, waiting for input
idev.StartRecording(true);
// wait to end it
Console.Error.WriteLine("Press any key to stop recording...");
Console.ReadKey();
// get our MidiFile from this
mf = idev.EndRecording();
// the MIDI file is always two
// tracks, with the first track
// being the tempo map
}
}
录制表演(复杂)
手动录制允许您在输入被记录之前对其进行处理。这可能相当复杂,特别是跟踪 MIDI tick 位置可能很棘手。您可以使用Stopwatch
来实现此目的,但我更喜欢使用 Windows 7 及以上版本提供的“精确时间”API,以确保没有“漂移”——请参阅scratch项目以获取 Win32 P/Invoke 声明和辅助属性。
using (var idev = MidiDevice.Inputs[0])
{
// TODO: currently this doesn't let you
// change the tempo in the middle of recording
// match these two variables to your input rate
short timeBase = 480;
var microTempo = MidiUtility.TempoToMicroTempo(120);
// track 0 - meta track for tempo info
var tr0 = new MidiSequence();
// our seq for recording
var seq = new MidiSequence();
// compute our timing based on current microTempo and timeBase
var ticksusec = microTempo / (double)timeBase;
var tickspertick = ticksusec / (TimeSpan.TicksPerMillisecond / 1000) * 100;
var pos = 0;
// set this to _PreciseUtcNowTicks in order
// to start recording now. Otherwise it will
// not record until the first message is
// received:
var startTicks = 0L;
using (var odev = MidiDevice.Outputs[0])
{
// hook up the delegate
idev.Input += delegate (object s, MidiInputEventArgs ea)
{
// initialize start ticks with the current time in ticks
if (0 == startTicks)
startTicks = _PreciseUtcNowTicks;
// compute our current MIDI ticks
var midiTicks = (int)Math.Round((_PreciseUtcNowTicks - startTicks) / tickspertick);
// pass through to play
odev.Send(ea.Message);
// HACK: technically the sequence isn't threadsafe but as long as this event
// is not reentrant and the MidiSequence isn't touched outside this it should
// be fine
seq.Events.Add(new MidiEvent(midiTicks - pos, ea.Message));
// this is to track our old position
// so we can compute deltas
pos = midiTicks;
};
// open the input device
idev.Open();
// open the output device
odev.Open();
// add our tempo to the beginning of track 0
tr0.Events.Add(new MidiEvent(0, new MidiMessageMetaTempo(microTempo)));
// start listening
idev.Start();
Console.Error.WriteLine("Recording started.");
// wait
Console.Error.WriteLine("Press any key to stop recording...");
Console.ReadKey();
// stop the buffer and flush any pending events
idev.Stop();
idev.Reset();
}
// create termination track
var endTrack = new MidiSequence();
var len = seq.Length;
// comment the following to terminate
// without the trailing empty score:
len = unchecked((int)((_PreciseUtcNowTicks - startTicks) / tickspertick));
endTrack.Events.Add(new MidiEvent(len, new MidiMessageMetaEndOfTrack()));
// terminate the tracks
tr0 = MidiSequence.Merge(tr0, endTrack);
seq = MidiSequence.Merge(seq, endTrack);
// build a type 1 midi file
var mf = new MidiFile(1, timeBase);
// add both tracks
mf.Tracks.Add(tr0);
mf.Tracks.Add(seq)
}
在这里,我们的大部分工作都在设置中,然后是处理Input
事件。对于设置,我们必须以 MIDI tick 精确持续时间的形式计算计时。我们将其放入tickspertick
中,即一个 MIDI tick 中的 .NET “tick”数(或反之亦然,我不记得了。这很混乱!)。然后我们用它来跟踪我们的位置。我们不断地将旧位置减去当前位置以获得增量。请注意,我们正在从另一个线程访问seq
。这没关系,因为有几个条件,包括seq
在Input
开始触发后不会在委托之外被触及。总之,最后,我们确保用结束轨道标记终止轨道,并创建一个简单的内存中 MIDI 文件。这应该可以播放以收听刚刚录制的内容,并/或写入磁盘。请注意,录制直到收到第一个 MIDI 信号才开始,并且保留了录制的剩余静默部分。这可以通过修改代码轻松更改。
演示项目
MidiSlicer
MidiSlicer(顶部图片)允许您对 MIDI 文件执行多种操作,例如提取 MIDI 文件的部分、提取特定轨道、更改音量、移调等等。它对于操作您已排序的原始 MIDI 文件很有用。
这里是执行魔法的主要代码块,位于Main.cs的_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 insert 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;
您可以看到这相当复杂,仅仅是因为有太多的选项。它确实利用了前面概述的许多技术,对MidiSequence
进行了全面测试。
FourByFour
FourByFour 是一个简单的鼓机步进排序器,可以创建 MIDI 文件。
这里有一个节拍控件,我们不会在此处介绍,但这是Main.cs中主要的魔法,位于_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.NoteId;
// 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;
基本上,我们在这里所做的就是使用音符图来创建我们的鼓序列,然后使用前面概述的技术设置轨道长度和程序数据。
MidiMonitor
MIDI 监视器只是监视MidiInputDevice
的传入 MIDI 消息并显示它们。它非常非常简单。这是Main.cs中的主要内容。
private void InputsComboBox_SelectedIndexChanged(object sender, EventArgs e)
{
if (null != _device)
_device.Close();
_device = InputsComboBox.SelectedItem as MidiInputDevice;
_device.Input +=device_Input;
_device.Open();
_device.Start();
}
private void device_Input(object sender, MidiInputEventArgs args)
{
try
{
Invoke(new Action(delegate ()
{
MessagesTextBox.AppendText(
args.Message.ToString() +
Environment.NewLine);
}));
}
catch
{
}
}
我们所做的就是像前面展示的那样捕获传入的消息,然后将其附加到文本框中。这里需要注意的一个问题是,由于我们是从另一个线程触发的,因此我们需要使用控件的Invoke()
方法将代码封送回主线程执行。我们还将其包装在try
/catch
块中,以防在关闭过程中意外收到消息,但我不确定这是否必要。
taptempo
Taptempo 演示了手动(而不是自动)节奏同步功能。手动同步比使用MidiStream.UseTempoSynchronization=true
更准确,因为它不必依赖定时器。相反,它会运行一个紧密的循环并使用它来进行计时。不幸的是,在接收端没有类似的方法可以及时接收节奏同步消息 - 我们必须依赖回调,因此接收端的计时不完美。
scratch
Scratch 只是演示了上面已经概述的一些技术,因此不值得在这里介绍。它基本上只是一个测试代码的游乐场。
与之配套的 CPP 项目只是一个从 C++ 调用 API 的测试平台,以确保我做得正确,但我目前没有使用它。
Bug
首先,节奏同步的准确性不高,这就是为什么它目前是实验性的。这个限制可能无法克服。
其次,并非所有实时消息都已被尊重。唯一同步功能是节奏。
历史
- 2020 年 6 月 28 日 - 初始提交
- 2020 年 7 月 2 日 - 两处代码库更新,列在顶部
- 2020 年 7 月 3 日 - 稳定性修复,API 改进
- 2020 年 7 月 5 日 - 重构 MidiStream 以派生自 MidiOutputDevice
- 2020 年 7 月 6 日 - 修复了 MidiSequence.ToNoteMap() 并添加了 MidiUI 项目中的一些 UI 控件