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

C# MIDI 工具包

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (170投票s)

2004 年 2 月 27 日

MIT

18分钟阅读

viewsIcon

4048844

downloadIcon

42185

一个用于使用 C# 创建 MIDI 应用程序的工具包。

Screenshot - SequencerDemo.png

目录

  1. 引言
  2. 基于流的编程
    1. 在 C# 中实现基于流的编程
  3. MIDI 消息
    1. 消息构建器
    2. MessageDispatcher 类
  4. 时钟
  5. MidiEvent 类
  6. Track 类
  7. Sequence 类
  8. MIDI 设备
    1. InputDevice 类
    2. OutputDeviceBase 类
    3. OutputDevice 类
    4. OutputStream 类
  9. Sequencer 类
  10. 依赖项
  11. 结论
  12. 历史

引言

这是我的 .NET MIDI 工具包的第五个版本。我曾以为上一个版本是最终版,但我做了许多改动,足以出一个新版本。此版本在基于流的编程方面采用了更传统的 C#/.NET 方法,我将在下面进行介绍。我对第四版在这方面的实现感到不满意,于是我退后一步,在保留基于流方法的同时,进行了符合 C#/.NET 惯用法的更改。我希望这能让工具包更容易使用和理解。

在过去的两到三年里,该工具包经历了许多次修订。每一次修订几乎都是对前一次的完全重写。在编写软件时,进行会破坏之前版本软件更新通常是个坏主意。然而,我创建此工具包的目标是提供尽可能好的设计。随着我作为程序员的成长,我提高了我的技能和对软件设计的理解。这促使我修订了工具包的早期设计,而不考虑这些修订会如何破坏代码。这在职业场合并非一种值得采纳的态度,但由于该工具包是免费的,并且我已将其作为学习经验来提高我的技艺,所以我的优先级有所不同。

顶部

基于流的编程

在我深入探讨工具包的具体细节之前,我想先谈谈它的架构。在工具包的每个版本中,我都一直在努力如何组织消息在系统中的流动。我想要一种通用且允许自定义的方法。我想,如果用户可以将自己的类插入 MIDI 消息流中来随心所欲地做任何事情,那将是件好事。例如,假设您想编写一个和弦生成器,一个能够在指定键中转置音符以自动创建和弦部分的类。应该可以轻松地将和弦生成器插入工具包,而不影响其他类。换句话说,工具包应该是可定制和可配置的。

对这个问题进行调查后,我发现了 J. Paul Morrison 关于基于流的编程的杰出 网站。他写了一本关于这个主题的书,可以在他的网站以及 Amazon 上找到。

这个想法很简单,对大多数人来说可能很熟悉:数据流经一组组件。每个组件都可以对数据做一些有趣的事情,然后再将其传递给下一个组件。从设计模式的角度来看,这种方法最接近管道和过滤器模式,也与责任链模式相似。有关更多信息,请参阅 J. Paul Morrison 的精彩著作。

(为表清晰:当我提到“组件”时,我不一定指的是实现了 IComponent 接口的类。我指的是更广泛的意义。组件就是一系列用于处理消息流的对象的对象。)

下面是一个非常基础的组件网络,用于处理 MIDI 通道消息的流动

Screenshot - MessageFlow.PNG

消息流始于输入设备。输入设备从外部源接收 MIDI 消息。接下来,消息流经用户组件。此组件可能想做一些事情,例如更改 MIDI 通道、转置音符消息或以某种方式更改消息。然后消息通过通道停止器。此组件仅负责跟踪所有当前正在播放的音符。当消息流停止时,通道停止器可以关闭所有正在播放的音符,以免它们一直挂起。最后,消息到达输出设备。在这里,它们被发送到外部 MIDI 设备。

顶部

在 C# 中实现基于流的编程

嗯,这是我一直以来非常头疼的一个问题。您可以通过阅读我的 博客 来了解我尝试过的几种不同方法。我发现自己在这方面一直在原地打转。在工具包的第四版中,我采用了源/接收器抽象的想法。我创建了一个代表“源”的接口。源代表 MIDI 消息的来源。“接收器”由可以连接到源的委托表示;接收器就是能够接收 MIDI 消息的方法。这工作得很好,但有点令人困惑,因为实现看起来有点“奇怪”。也就是说,首次查看代码的 C# 程序员可能会对正在发生的事情感到困惑。

我决定放弃接收器/源基础设施,采用更符合惯用法的东西。 MIDI 消息的源在有消息要发送时会引发事件。它们不再实现 ISource 接口,并且不提供用于连接接收器的 ConnectDisconnect 方法,而是直接提供事件。这有两个优点:首先,源不再需要实现 ISource 接口;其次,.NET 事件对我们来说非常熟悉。因此,MIDI 消息的源现在看起来就像普通的类,只是碰巧有一个或多个事件。

那么接收 MIDI 消息的对象——接收器呢?接收器可以是任何东西。它只是一个具有可以接收 MIDI 消息的方法的对象。在第四版中,我使用了一个 Sink 委托来表示能够接收 MIDI 消息的对象的各种方法。这些委托用于连接源。虽然这种使用委托来“连接”源和接收器的方法仍在工具包中使用,但方式已有所不同。现在,委托被用作适配器,连接到源中引发的事件,并适配这些事件,以便需要接收消息的对象可以在不了解源的情况下接收消息。

让我们看一个例子。假设我们使用 InputDevice 从 MIDI 设备(例如声卡)接收 MIDI 消息。每次接收到通道消息时,InputDevice 都会引发一个 ChannelMessageReceived 事件。假设我们想跟踪所有音符开启的通道消息,以便在我们决定停止接收消息时,可以关闭所有当前正在播放的音符,以免它们“挂起”。ChannelStopper 类就是为此目的而设计的。然而,ChannelStopperInputDevice 类一无所知。我们需要一种方法来连接它们,以便由 InputDevice 生成的消息可以传递给 ChannelStopper。下面是我们如何使用匿名方法来实现这一点:

InputDevice inDevice = new InputDevice(0);
ChannelStopper stopper = new ChannelStopper();

inDevice.ChannelMessageReceived += delegate(object sender, ChannelMessageEventArgs e)
{
    stopper.Process(e.Message);
};

inDevice.StartRecording();

// ...

在此示例中,匿名方法适配了 InputDevice 引发的事件,以便 ChannelStopper 可以处理它们。InputDevice 是通道消息的源,ChannelStopper 是能够接收和处理通道消息的接收器。这种方法的好处在于不需要显式的源/接收器基础设施。这两个类都不知道自己是源或接收器。消息流由一个外部代理协调,在本例中就是匿名方法。

顶部

MIDI 消息

MIDI 消息有几种类别:通道、系统独占、元消息等。在设计 MIDI 工具包时,挑战在于如何表示这些消息。一种方法是创建两三个通用的 MIDI 类,并通过这些类的属性来表示特定类型的 MIDI 消息。Java MIDI API 采用了这种方法。另一种方法是创建大量精细的类来表示所有不同类型的 MIDI 消息。例如,通道消息有很多类型,如音符开启、音符关闭、程序更改和音高弯音消息。精细类方法将为每种消息类型创建一个类。我的方法是折衷。我为通用 MIDI 消息类别创建了类,但将特定消息类型作为这些类内的属性。这保持了类层次结构的轻量级和可管理性,同时提供了足够的专业化来简化 MIDI 消息的处理。

以下是 MIDI 工具包中的 MIDI 消息类层次结构

  • IMidiMessage
    • ShortMessage
      • ChannelMessage
      • SysCommonMessage
      • SysRealtimeMessage
    • SysExMessage
    • MetaMessage

消息的特定类型通过属性表示。例如,ChannelMessage 类有一个 Command 属性,可以设置为表示各种类型的通道消息。

顶部

消息构建器

所有消息类都是不可变的。这使得在应用程序中安全地共享消息成为可能。要创建消息,请将所需的属性值传递给它们的构造函数。此外,该工具包还提供了一组构建器类,以使消息创建更加便捷。

该工具包提供了以下消息构建器

  • ChannelMessageBuilder
  • SysCommonMessageBuilder
  • KeySignatureBuilder
  • MetaTextBuilder
  • SongPositionPointerBuilder
  • TempoChangeBuilder
  • TimeSignatureBuilder

ChannelMessageBuilderSysCommonBuilder 还使用了享元设计模式。当构建一条新消息时,它会被存储在缓存中。当需要一条属性与已构建消息完全相同的消息时,将检索之前的消息,而不是创建新消息。考虑到典型的 MIDI 序列由数千条消息组成,其中许多消息是相同的,因此很容易看出享元模式的适用性。

以下是创建表示音符开启消息的 ChannelMessage 对象的示例

ChannelMessageBuilder builder = new ChannelMessageBuilder();

builder.Command = ChannelCommand.NoteOn;
builder.MidiChannel = 0;
builder.Data1 = 60;
builder.Data2 = 127;
builder.Build();
Console.WriteLine(builder.Result);

初始化构建器并设置所需的属性后,就可以通过调用 Build 方法来构建 MIDI 消息。然后可以通过 Result 属性检索 MIDI 消息。

有几种构建器类可用于创建特定类型的元消息。例如,要创建键签名元消息,可以使用 KeySignatureBuilder

KeySignatureBuilder builder = new KeySignatureBuilder();
builder.Key = Key.CMajor;
builder.Build();
Console.WriteLine(builder.Result);

顶部

MessageDispatcher 类

通常需要处理 IMidiMessage 的集合。每条消息的处理方式取决于其类型。问题在于,如果没有显式的检查,您无法确定 IMidiMessage 的类型。IMidiMessage 为此目的提供了一个 MessageType 属性。但是,在代码中反复检查消息类型可能会很麻烦。

MessageDispatch 类旨在自动执行这些检查。该类充当所有类型 MIDI 消息的源。它在分派每条消息时引发一个事件。事件的类型由其正在分派的消息类型决定。

顶部

时钟

MIDI 播放由周期性发生的节拍驱动。这些节拍的来源是 MIDI 时钟。MIDI 时钟有各种形状和大小。例如,播放可以由内部或外部时钟驱动。此外,节拍的生成方式取决于 MIDI 序列是具有每四分音符脉冲分辨率还是 SMPTE 分辨率。对于绝大多数情况,一个内部时钟生成每四分音符脉冲分辨率的节拍就足够了。

IClock 接口代表所有 MIDI 时钟的基本功能

public interface IClock
{
    event EventHandler Tick;
    event EventHandler Started;
    event EventHandler Continued;
    event EventHandler Stopped;
    bool IsRunning
    {
        get;
    }
}

Tick 事件在 MIDI 节拍经过时发生。StartedContinuedStopped 事件是不言自明的。但是,应该指出的是,当 Started 事件发生时,序列播放从序列的开头开始。当 Continued 事件发生时,播放从当前位置开始。IsRunning 属性指示时钟是否正在运行。

您可能会注意到接口中没有用于启动和停止时钟的方法。这是因为对于由外部源驱动的时钟,源负责启动和停止时钟。时钟通过 MIDI 接收消息,并根据这些消息开始或停止生成节拍。由于所有 MIDI 时钟都实现 IClock,它仅代表所有时钟的通用功能。

目前,该工具包只有一个时钟类,即 MidiInternalClock。此内部时钟使用每四分音符脉冲分辨率以内部方式生成 MIDI 节拍。对于大多数情况,这个时钟都可以正常工作。

MidiInternalClock 具有 Tempo 属性,用于设置每拍微秒为单位的速度。例如,要将速度设置为 120 bpm,您需要将 Tempo 属性设置为 500000。它可以接收元速度更改消息。当一条元速度更改消息传递给它时,它会将其速度更改为与消息所表示的速度相匹配。

顶部

MidiEvent 类

MIDI 文件由多个音轨组成。每个音轨包含一个或多个带时间戳的 MIDI 消息。时间戳表示自上一条消息播放以来的节拍数。此时间戳称为增量节拍。MidiEvent 类表示带时间戳的 MIDI 消息。它有三个 public 属性

  • DeltaTicks
  • AbsoluteTicks
  • MidiMessage

DeltaTicks 属性表示自上一条 MidiEvent 以来的节拍数。换句话说,此值表示在播放当前 MidiEvent 之前,应该允许多少个节拍过去。例如,如果 DeltaTicks 值为 10,则在播放当前 MidiEvent 表示的 MIDI 消息之前,我们将允许 10 个节拍过去。

AbsoluteTicks 表示 MidiEvent 的总位置。这是直到当前 MidiEvent 经过的总节拍数。

MidiMessage 属性是 MidiEvent 表示的 IMidiMessage

此外,还有两个内部属性,一个指向音轨中前一个 MidiEvent,另一个指向音轨中下一个 MidiEvent。换句话说,MidiEvent 类充当 MidiEvent 双向链表中的一个节点。

顶部

Track 类

Track 类表示 MidiEvent 的集合。它负责按正确顺序维护 MidiEvent 的集合。 MidiEvent 不直接添加到 Track。相反,您需要添加一个 IMidiMessage,并指定其在 Track 中的绝对位置。然后,Track 会创建一个 MidiEvent 来表示该消息,并将其插入其 MidiEvent 集合中。

除了提供添加和删除 MIDI 事件的功能外,Track 类还提供了几个迭代器。有一个标准迭代器,它一次只迭代 MidiEvent。另一个迭代器接受一个 MessageDispatcher 对象,并将每个 IMidiMessage 传递给调度器,调度器会引发特定于它正在分派的消息类型的事件。迭代器返回的值是当前 MidiEvent 的绝对节拍数。

也许最有用的迭代器是每次只前进一个节拍的迭代器。迭代器会跟踪它在 Track 中的节拍位置。当节拍数达到播放下一个 MidiEvent 的时间值时,它会将 MidiEvent 表示的 IMidiMessage 传递给 MessageDispatcher,并返回绝对节拍数。此迭代器还接受一个 ChannelChaser 对象以及一个起始位置值,并在切换到播放模式之前“追逐”到起始位置。本质上,此迭代器允许我们实时流式传输 Track

顶部

Sequence 类

Sequence 类表示 Track 的集合。它还提供了加载和保存 MIDI 文件的功能,因此 Sequence 可以自行加载和保存。

每个 Sequence 都有一个 division 值。此值代表 Sequence 的分辨率,并通过一个属性表示。Division 值有两种类型:每四分音符脉冲和 SMPTE。Sequence 有一个 SequenceType 属性指示序列类型。不幸的是,SMPTE 序列目前不支持。

顶部

MIDI 设备

该工具包中有几种 MIDI 设备类。每个设备类直接或间接派生自 Sanford.Multimedia 命名空间中的抽象 Device 类。InputDevice 类表示能够从外部源(如 MIDI 键盘控制器或合成器)接收 MIDI 消息的 MIDI 设备。OutputDeviceBase 类是一个 abstract 类,作为输出设备类的基类。OutputDevice 类表示能够将 MIDI 消息发送到外部源或您的声卡的 MIDI 设备。而 OutputStream 类封装了 Windows Multimedia MIDI 输出流 API。它能够播放带时间戳的 MIDI 消息。

您的计算机上可能存在多个这样的设备。例如,要确定存在的输入设备的数量,您可以查询 InputDevicestatic DeviceCount 属性。输出设备类也有此属性。

每个 MIDI 设备都有其唯一的 ID。这只是一个整数值,表示设备在可用设备列表中的顺序。例如,您系统上的第一个输出设备的 ID 将是 0。第二个输出设备的 ID 将是 1,依此类推。输入设备也是如此。创建 MIDI 设备时,您会将希望使用的设备的 ID 传递给其构造函数。如果打开设备时发生错误,将抛出异常。

要了解设备的功能,请查询类的 static GetDeviceCapabilities 方法,并传入您感兴趣的设备的设备 ID。此方法将返回一个结构,其中包含表示指定 MIDI 设备功能的各种值。

下面将详细描述每个设备类

顶部

InputDevice 类

InputDevice 类表示能够接收 MIDI 消息的 MIDI 设备。它有一个事件对应于它可以接收的每种主要的 MIDI 消息。要接收 MIDI 消息,您需要连接到一个或多个这些事件。然后调用 StartRecording 方法。录制将一直持续到调用 StopRecordingResetInputDevice 允许您设置用于接收 sysex 消息的 sysex 缓冲区大小。当 InputDevice 收到完整的 sysex 消息时,它会引发 SysExReceived 事件。

顶部

OutputDeviceBase 类

OutputDeviceBase 类是一个 abstract 类,它提供了发送 MIDI 消息的基本功能。它有几个重载的 Send 方法,用于发送各种类型的 MIDI 消息。

顶部

OutputDevice 类

OutputDevice 类表示能够发送 MIDI 消息的 MIDI 设备。它继承了 OutputDeviceBase 类的大部分功能。它还提供运行状态功能。

以下代码创建了一个 OutputDevice,构建并发送了一个音符开启消息,然后休眠一秒钟,接着构建并发送了一个音符关闭消息

using(OutputDevice outDevice = new OutputDevice(0))
{
    ChannelMessageBuilder builder = new ChannelMessageBuilder();

    builder.Command = ChannelCommand.NoteOn;
    builder.MidiChannel = 0;
    builder.Data1 = 60;
    builder.Data2 = 127;
    builder.Build();

    outDevice.Send(builder.Result);

    Thread.Sleep(1000);

    builder.Command = ChannelCommand.NoteOff;
    builder.Data2 = 0;
    builder.Build();

    outDevice.Send(builder.Result);
}

顶部

OutputStream 类

OutputStream 类也派生自 OutputDeviceBase 类。它封装了 Windows 多媒体 MIDI 输出流 API。它提供了播放 MIDI 消息的功能。

要播放 MIDI 消息,请调用 StartPlayingOutputStream 然后开始播放其队列中的任何 MIDI 消息。要将 MIDI 消息放入队列,您首先使用 Write 方法写入一个或多个 MidiEvent。写入所需数量的 MidiEvent 后,调用 Flush。这将把事件刷新到流中,使其播放。

顶部

Sequencer 类

Sequencer 类回来了。它是一个轻量级的类,用于播放 Sequence。我认为之前的 MidiFilePlayer 类不是播放 MIDI 序列的最佳方式。我想让工具包能够播放您通过编程方式创建的 Sequence。导致我(在早先版本中创建了 Sequencer 类之后)回避 Sequencer 类的一个问题是,在 Sequencer 播放 Sequence 的同时,Sequence 可能会发生变化。我还没有解决这个问题,但我不希望这个问题阻止简单的 Sequence 播放。因此,我在此引入一个新版本的 Sequencer 类,并理解它用于简单的播放。对于更复杂的功能,您可以将其用作创建更复杂内容的起点。

顶部

依赖项

MIDI 工具包依赖于我 Sanford.Threading 命名空间中的 DelegateQueue 类;InputDeviceOutputDevice 类使用它来对 MIDI 事件进行排队。反过来,Sanford.Threading 命名空间依赖于我的 Sanford.Collection 命名空间,因此该程序集对于工具包编译也是必需的。最后,该工具包使用 Sanford.Multimedia 命名空间。我已将所有程序集随下载一起提供。我已将使用它们的项目链接到这些程序集,希望工具包能够开箱即用。希望那些因缺少正确的程序集而导致编译项目出现问题的日子已经一去不复返了。

顶部

结论

本文概述了我的 .NET MIDI 工具包。我希望它能成为编写 MIDI 应用程序有用且强大的工具。编写它很有趣。每个版本都代表了我作为程序员的最高技能和知识。我欢迎您提供反馈和任何 bug 报告。保重,感谢您的时间。

顶部

历史

  • 2004 年 2 月 20 日
    • 文章已提交。
  • 2004 年 5 月 7 日
    • 第二个主要版本。
  • 2004 年 10 月 27 日
    • 第三个主要版本。
  • 2006 年 3 月 8 日
    • 第四个主要版本。
  • 2006 年 3 月 14 日
    • 对文章进行了一些清理。
    • 将源合并到 Multimedia 命名空间中的一个通用接口中。
    • 将所有接收器合并到 Multimedia 命名空间中的一个通用委托中。
    • 修复了 MIDI 文件播放器演示中的一个 bug 以及 Sequence 类中的一个 bug。
  • 2007 年 4 月 14 日
    • 第五个主要版本。

顶部

© . All rights reserved.