MIDI Star






4.86/5 (10投票s)
一篇关于解析 MIDI 文件和使用 MIDI 事件的文章。
引言
看到《摇滚乐队》和《吉他英雄》这些游戏,我想到它们可以做得更好,如果它们更具教育意义的话。因此,作为我在西蒙·弗雷泽大学的课程项目,我决定制作一个基于 MIDI 文件的类似游戏。MIDI 文件可能提供无限的歌曲选择,并且提供适合模拟《摇滚乐队》和《吉他英雄》的数据格式。此外,MIDI 使得《MIDI Star》可以使用真实的乐器作为输入(我使用了雅马哈 Clavinova 和雅马哈 DTXplorer 鼓组)。
我在艺电(Electronic Arts)工作了 20 年,在工作中,我不得不使用艺电的内部库和工具来制作游戏。退休后,我没有任何库可以使用,不得不搜索可用的代码片段来创建 MIDI Star。对我来说,最显而易见的选择是使用 DirectX,因为相比 OpenGL,我可以访问更多硬件。大约一半的示例代码使用了 MFC,另一半使用了 .NET。有些代码使用了包装器,有些则没有。DirectX 可能很难编译,因为每个人安装的 SDK 版本都不同,如果定义不正确,编译可能会非常令人沮丧。希望我所有的定义都是正确的,以便较新版本的 DirectX 能够编译。经过一番自我辩论后,我决定使用 MFC 可能会更快地将事情完成。此外,我无需处理安装正确的 .NET 运行时。不过,如果我以后有灵感,我可能会尝试将其移植到 .NET。
为什么要使用 MFC 或 .NET?简单来说,游戏是最重要的部分,而不是游戏之前的 GUI。GUI 在你脑海中有清晰的想法之前,也极易受到设计更改的影响。最后,我只有 6 周的时间来为我的课程设计和编写这个程序。如果你是编程新手,我的建议始终是先处理最重要的东西,然后是风险最大的东西。这样,你就可以在不浪费太多时间的情况下中止项目。为不存在的产品创建前端 GUI 没有意义。
那么,从这篇文章中,你能学到什么与本站其他优秀文章不同的东西呢?提供的代码是一个完整的游戏,因此你可以将其用作自己游戏的框架。我发现学习编程的好方法是研究大量的完整产品,看看它们是如何组合在一起的。在我自己创作游戏之前,我花了多年时间将别人的作品从一台机器移植到另一台机器。你可能喜欢我构建游戏的方式,也可能喜欢别人构建游戏的方式,但无论如何你都能学到东西。
然而,在我创建这个游戏的过程中遇到的最大问题是解析 MIDI 文件并将 MIDI 消息发送到驱动程序,这也是本文重点关注的内容。
编译源代码需要 August 2008 版本的 SDK,并且独立可执行文件需要安装等效的运行时。您可能需要在 Linker/Input 的 Additional Dependencies 框中添加这些库:strsafe.lib d3dxof.lib dxguid.lib d3dx9.lib d3d9.lib winmm.lib dinput8.lib comctl32.lib 以便编译器正常工作。
背景
如果您精通 C++,应该能够阅读所有代码。最复杂的 C++ 功能是使用带有虚函数的派生类和指针的指针。我使用的大多数库调用都在 MSDN 中有很好的文档记录。我严重依赖 MFC 和 DirectX,所以如果您理解这些库,就不会有问题。
如今,大多数机器都有大量的 RAM,并且可以轻松播放高质量的数字音频。那么,为什么要使用 MIDI 呢?让我们从缩写开始。MIDI 代表 Musical Instrument Digital Interface(乐器数字接口),它开发于 20 世纪 80 年代初。它允许乐器与其他乐器、控制器和计算机进行通信和同步。如今,它主要用于在计算机上进行音乐排序。回到问题,为什么要使用 MIDI?第一个原因是 MIDI 占用空间小:存储一首歌曲只需要几十 KB,而 MP3 则需要兆字节。这就是为什么许多铃声存储为 MIDI 文件。第二个原因是你可以改变歌曲的节奏而不会影响歌曲的音高。如果你放慢 MP3 或 WAV 的速度,音高会降低。但是,MIDI 只是发送播放和停止音符的消息,因此音高保持不变。对于游戏来说,你可以通过改变音乐的节奏来改变游戏的氛围。这比尝试在数字歌曲之间切换更平滑。第三个原因是录制数字音乐需要成为一名优秀的音乐家,但编写 MIDI 音乐则不需要。最后一个原因是,你可以在 MIDI 文件中插入元事件,这些事件可用于触发游戏中的事件,例如特殊的动画或特殊的逻辑。
尽管我不认为我教程中列出的 MIDI 文件有任何版权,但为了以防万一,我已将它们从我的 zip 包中移除。MIDI 文件在美国是可以获得版权的;您可以在 MIDI Manufacturers Association 的网站上找到这一点。如果您在运行游戏时点击“帮助”(Help), there are the links to download the suggested MIDI files.
使用代码 - 解析
为了处理 MIDI 文件,您需要提取以下文件(源代码和头文件):MIDIInterface、MIDISong、MIDITrack、MIDIEvents 和 Helper。除非您要修改 MIDI Star,否则您需要删除所有对 Mapper
和 Game
的引用。我不会展示太多代码,因为我将解释关键概念,从而使代码更容易理解,进而使您能够根据自己的需要进行修改。换句话说,我将采用自顶向下的方法进行解释。如果您想创建自己的 MIDI 播放器,当前代码应该可以轻松修改和扩展。MIDISong.cpp 中的 ExtractNotes
是如何为 MIDI 事件编写过滤器的示例。
这里有一个 MIDI 文件快速入门指南;我们将仅限于 SMF Type 1(SMF 代表 Standard MIDI File),因为它是最常见和通用的。首先要注意的是,任何多字节值都以大端序(Big Endian)格式存储。当然,这在 Intel 芯片上不起作用,因此 Helper 提供了例程来纠正这一点:ChangeEndianShort
和 ChangeEndianLong
。MIDI 文件包含任意数量的音轨,每个音轨包含任意数量的 MIDI 事件。MIDI 音轨与 MIDI 硬件标准无关;它们仅用于在排序时组织音乐。MIDI 允许同时使用 16 种不同的乐器(音色)。这 16 种乐器对应于通道,其中通道 10 通常用于鼓。顺便说一句,您可以在一首歌曲中使用超过 16 种乐器(但不能同时使用),通过使用音色变化。每个通道都是复音的,因此您可以同时演奏多个音符(声音),即和弦。
有两种 MIDI 事件类型:通道事件和系统事件。有 7 种通道事件类型用于控制乐器,系统事件类型进一步细分为系统事件和元事件。系统事件需要发送到 MIDI 接口,但元事件是为排序器准备的,因此它们不会发送到 MIDI 接口。通道事件的例子包括音符开、音符关和音高弯音。系统事件的例子包括下载波表定义、重置系统和时钟。元事件的例子包括音轨名称、乐器名称和音轨结束。
MIDI 消息发送到哪里?过去(20 世纪 80 年代),处理声音需要太多的 CPU 带宽(33 MHz 的 CPU 已经很不错了),因此硬件制造商开发了带有多个频率调制(FM)音源的声卡。更好的声卡拥有更多的 FM 音源,因此您可以同时演奏更多的乐器和更多的和弦。您只需通过设备驱动程序将 MIDI 消息发送到声卡。如今,计算机足够快,可以处理声音,Windows 也可以通过软件处理,即 Microsoft GS Wavetable SW Synth。如果您拥有一款带有驱动程序的好声卡,合成器可以利用音频加速。
您可能已经注意到我使用了 MIDI 事件和 MIDI 消息。我想区分一下:当事件从文件解析并发送到 MIDI 接口时,它们就成为消息。当消息被编码并放入文件缓冲区时,它们就成为事件。一个主要的区别是事件具有关联的时间,而消息是实时发生的,因此消息没有时间附加。有关 MIDI 的更多信息,请参阅 Wikipedia 获取概述,或 The Sonic Spot 获取更深入的信息,或事实上的参考 MIDI.org。有关音频和多媒体的通用信息,您可以阅读 Ze-Nian Li 和 Mark S. Drew 的著作 Fundamentals of Multimedia(这是对我教授的一个小小宣传)。
如果您了解一些特殊之处,解析文件并不是特别棘手:可变长度数据和运行状态。MIDI 接口仅以 31 Kbps 运行,因此通过接口传输的数据需要尽可能紧凑;这就是为什么需要可变长度数据和运行状态。所有事件都由增量时间、状态字节和参数组成。增量时间是音轨上事件之间的时间间隔,需要累加增量时间以计算歌曲中的绝对时间(0 是有效的增量时间)。增量时间以可变长度数据格式存储。可变长度数据必须以大端序的 7 位字节存储,其中高位零字节被丢弃。所有较高位的 7 位字节都设置了高位。最低位的 7 位字节高位被清除。可变长度数据也有 4 字节的限制,因此最大值为 0x0FFFFFFF(是的,这与 The Sonic Spot 的说法相矛盾)。以下是一些转换的示例。
值 |
值 |
可变长度数据编码 |
可变长度数据编码 (二进制) |
---|---|---|---|
0x46 |
1000110 |
0x46 |
1000110 |
0x3404 |
1101000 0000100 |
0xE804 |
111010000 00000100 |
0x03000000 |
0011000 0000000 |
0x98808000 |
10011000 10000000 |
这是用于解码和编码的代码。可以看到,解码要容易得多。
long DecodeVarLen(UCHAR **p)
{
UCHAR c;
int len = 0;
do
{
c = **p;
*p = (*p)+1;
len = (len << 7) + (c & 0x7f);
} while (c & 0x80);
return len;
}
void EncodeVarLen(UCHAR **p, UINT len)
{
UINT mask = 0x0fe00000;
int shift = 7*3;
bool setit = false;
do
{
if (((len & mask) != 0) || setit || (!setit && shift == 0))
{
if (shift == 0)
**p = (UCHAR)len;
else
**p = (UCHAR)((len >> shift) & 0x7F) + 0x80;
*p = (*p)+1;
setit = true;
len -= (len & mask);
}
mask >>= 7;
shift -= 7;
} while (shift >= 0);
}
状态字节始终设置高位。事件的参数始终清除高位。状态字节的上半字节(nibble)保存事件类型,并且由于高位已设置,因此只有 8 种事件类型(事件类型用于事件和消息)。前 7 种事件类型是通道事件类型,其中下半字节是通道号,额外的参数也列出了。
状态字节 (上半字节) |
通道事件类型 | 第一个参数 | 第二个参数 |
---|---|---|---|
0x8 | 音符关闭 | 音符编号 | 速度 |
0x9 | 音符开启 | 音符编号 | 速度 |
0xA | 多音符键压 | 音符编号 | 压力 |
0xB | 控制变化 | 控制器编号 | 控制器值 |
0xC | 程序变化 | 程序编号 | 未使用 |
0xD | 通道压 | 压力 | 未使用 |
0xE | 音高轮变化 | 最高 7 位 | 最低 7 位 |
最后一个事件类型是系统事件类型 - 0xF。那么,什么是运行状态字节?回想一下,MIDI 接口很慢,数据需要紧凑。如果您检查一系列消息,大多数将是音符开启。如果音符和通道号相同,那么状态字节会为连续的事件重复。在这种情况下,状态字节不会在 MIDI 消息中发送,也不会保存在事件中供 MIDI 文件使用以提高带宽。MIDI 排序器和解析器可以检测到这一点,因为状态字节设置了高位,而数据字节清除了高位。您可能会问,为什么 MIDI 流只有音符开启而没有相应的音符关闭?MIDI 将音符开启(音量为 0)视为音符关闭。我在连接 MIDI 鼓时发现了这一点,当时使用了一个免费的 MIDI 捕获工具,它没有正确处理这种情况,所以我没有声音(你得到你支付的)。这使得确定我的硬件/软件链哪个部分坏了变得非常困难。他们出错的一个可能因素是,对于鼓来说,音符关闭在音符开启之后很快就会到来。
从上表可以看出,程序变化和通道压只有一个数据字节,而其他通道事件有两个数据字节。系统事件可能会有点令人困惑,具体取决于您的解释方式。系统事件可以有零个、固定数量或可变数量的数据字节。无论如何,数据字节的数量都以可变长度数据格式存储(与增量时间相同)。然后是实际的数据字节。现在我们有足够的信息来解析 MIDI 文件。
可以将流传递到我的类中并解析流,但我更喜欢让调用例程将 MIDI 文件完全加载到内存中,并传递一个指向该缓冲区的指针。我发现使用 char 指针比使用流函数更容易、更有效,因为我可以简单地检查缓冲区并将指针与相对于解析算法的位置进行比较。解析歌曲头很简单,而且不言自明。每个音轨的数据都会复制到其自己的缓冲区中(这样可以在解析后释放文件缓冲区)。解析分为三个步骤。可以减少步骤数,但使用三个步骤可以使代码更具可读性。第一步,Parse
,进行语法检查以验证 MIDI 是否有效,并计算音符、事件和节奏变化的数量。第二步,Parse2
,将事件复制到我的数据结构中,并将节奏变化复制到一个数组中。第三步,Parse3
,计算所有事件相对于歌曲开始的实际时间。Parse
和 Parse2
使用 DecodeEvent
和 NextEvent
来解码和遍历音轨事件;它们并不特别有趣,因为它们只是大的 case
语句。有趣的例程是 DecodeEvent
(所有前面的例程都可以在 MIDITrack.cpp 中找到)。
void MIDITrack::DecodeEvent()
{
// decode variable length time
UCHAR *p = this->mpEvent;
this->mDeltaTime = DecodeVarLen(&p);
if (*p < 128)
// Running Status (if high bit clear, use old status)
{
p--;
this->mStatus = this->mRunningStatus;
}
else
{
this->mStatus = *p >> 4;
this->mRunningStatus = this->mStatus;
this->mChannel = *p & 0xf;
}
if (this->mStatus == 0xF)
{
if (this->mChannel == 0xF)
// Meta Event
{
this->mParam1 = *(p+1);
UCHAR *p2 = p+2;
this->mLength = DecodeVarLen(&p2);
this->mpData = p+3;
this->mpNextEvent = p+3+this->mLength;
}
else
// System Exclusive Event 0xF0 or 0xF7
{
UCHAR *p2 = p+1; // decode variable length param
this->mLength = DecodeVarLen(&p2);
this->mpData = p+2;
this->mpNextEvent = p+2+this->mLength;
}
}
else if (this->mStatus == 0xC || this->mStatus == 0xD)
// MIDI channel event with single data byte
{
this->mParam1 = *(p+1);
this->mpNextEvent = p+2;
}
else
// MIDI channel event
{
this->mParam1 = *(p+1);
this->mParam2 = *(p+2);
if (this->mStatus == 9 && this->mParam2 == 0)
// change note on to note off because velocity is zero
this->mStatus = 8;
this->mpNextEvent = p+3;
}
this->mDecoded = true;
}
在第二步中,事件被复制到 MIDIEvent
的派生类中:MIDINote
、MIDIChanSingle
、MIDIChanDouble
、MIDISystem
和 MIDIMeta
。这些是处理事件所需的最低类;如果您的 MIDI 要求不同,可以派生更专业的类。这是 MIDIEvents.h 中的基类。
class MIDIEvent
{
private:
double mRealTime;
UCHAR mEventType;
public:
MIDIEvent() {};
virtual ~MIDIEvent() {};
// return absolute real time of this event
double Time() {return this->mRealTime;};
// set the absolute real time of this event
void Time(double time) {this->mRealTime = time;};
// return the event type or status byte
UCHAR EventType() {return this->mEventType;};
// this is set as the event type or status byte depending on needs
void EventType(UCHAR e) {this->mEventType = e;};
// derived classes must send the relevant MIDI event
virtual void SendMessage(UCHAR *vlbuff) = 0;
// derived classes must provide its own TRACE messages
virtual void Trace() = 0;
// default channel to invalid
virtual UCHAR Channel() {return 255;};
// default as not a note event
virtual bool IsNoteEv() {return false;};
};
从基类可以看出,所有事件只有两个共同的属性;它们带有适当的 set
和 get
函数。IsNoteEv
函数仅应在派生类包含音符开或音符关事件类型时重写。Channel
函数应为通道事件类型重写,并且应仅返回通道号。如果您想显示有用的调试信息,则需要重写 Trace
函数。最后,派生类需要处理 SendMessage
函数;通道事件类型应发送短 MIDI 消息,而系统事件类型应发送长 MIDI 消息(MIDIInterface
中的 SendShrtMsg
和 SendLongMsg
)。显然,对于您的派生函数,您希望添加带有 set
和 get
函数的适当属性。
最初,我将解析器写成一个单步过程,重写的原因是节奏变化。出于某种奇怪的原因,我测试的下载 MIDI 文件为每个音符都有一个节奏变化,而不是改变增量时间。规格中也没有强制所有节奏变化都出现在同一音轨上。理论上,所有音轨都应并行处理,并且如果节奏变化放在较高的音轨上,当较低的音轨在较高的音轨的节奏变化之前处理时,可能会出现轻微的时间错误。在第三步执行之前,节奏变化的累加增量时间被排序,然后计算实际时间。我直到现在还没有提到,但增量时间不是实际时间值;它们必须转换为实际时间,但这可以是两种格式之一,具体取决于歌曲头中的一个标志。这并不太有趣,所以我只提供例程而不加解释。
double MIDISong::TempoTime(long absTime)
{
if (this->mTimeDivision & 0x8000)
{
double fps = (double)((this->mTimeDivision & 0x7f00)>>16);
if (fps == 29.0)
fps = 29.97;
return (double)absTime/(fps*(double)(this->mTimeDivision&0xff));
}
return (double)absTime*60.0/(double)(this->mTempo*this->mTimeDivision);
}
您可能会想,我为什么选择使用冒泡排序来处理节奏变化。节奏变化通常应该是排序的;事实上,如果它们都在一个音轨上,它们就会被排序,而冒泡排序的复杂度为 O(n),而快速排序为 O(n log n)。这个例程是 CalcTempoChanges
。最后,第三步使用 CalcRealTime
来确定所有事件的实际时间。
double MIDISong::CalcRealTime(int abstime)
{
int i = 0;
CTempo *ct = this->mpTempos;
while (abstime >= ct->mAbsTime && i < this->mNumTempos)
{
ct++;
i++;
}
ct--;
i--;
this->Tempo(ct->mTempo);
return ct->mRealTime + this->TempoTime(abstime-ct->mAbsTime);
}
使用代码 - MIDI 消息
如果您只使用键盘或游戏手柄,则不需要物理 MIDI 接口。如前所述,Microsoft GS Wavetable SW Synth 将处理任何 MIDI 输出。根据您的硬件设置,您可能可以选择不同的 MIDI 输出设备。如果您有 MIDI 乐器,那么您可能已经为您的计算机准备了硬件 MIDI 接口。以前在较老的声卡上就有,但现在较低端的声卡通常不再提供。一个便宜的选择是购买一个 USB MIDI 接口,我的就是 M-Audio 的。
MIDIInterface
是我与驱动程序级别 MIDI 接口的类。这个类需要 MFC;这可能是好是坏,取决于您如何使用它。它使用 CComboBox
来填充 MIDI 设备列表,但可以轻松地修改为字符串数组或向量。以下是 MIDI 输入(MIDI 输出相同,只是没有 StartOut
)的使用顺序。
EnumerateIn
- 获取 MIDI 设备列表InitializeIn
- 初始化特定的 MIDI 设备StartIn
- 启用消息捕获IsDeviceIn
- 测试 MIDI 设备是否对输入有效GetChanMess
- 获取输入消息StopIn
- 禁用消息捕获CloseIn
- 关闭 MIDI 设备
消息存储在 512 字节的循环缓冲区中。它不需要这么大,但最好安全起见。原因是消息应该被相当快地处理,这样缓冲区才不会填满。Windows 为 MIDI 消息生成以下 Windows 消息:MIM_OPEN
、MIM_CLOSE
、MIM_ERROR
、MIM_MOREDATA
、MIM_LONGDATA
、MIM_LONGERROR
和 MIM_DATA
。除 MIM_DATA
消息外,所有消息都被忽略,它捕获所有通道事件类型。您需要编写自己的处理程序来捕获系统事件类型,并且还需要创建新的结构来存储此信息。捕获 MIDI 消息并不难,您只需要知道不能在 MIDI 消息回调函数中调用系统例程(Windows),并且您希望函数速度快,以免错过任何消息;请参阅 MSDN 中的 MIDIInProc
以获取可调用系统例程的列表。尽管时间(以毫秒为单位)被存储,但我没有使用它,因为我想用游戏时钟量化消息。回调函数存储消息并将消息发布到 game.cpp 中的游戏消息泵,在那里可以将其与要播放的 MIDI 音轨和通道进行评估。以下是相关的代码片段。
void MIDIInterface::Callback(HMIDIIN hmidiIn, UINT wMsg, DWORD ,
DWORD dwParam1, DWORD dwParam2)
{
MIDIMessage md;
if (hmidiIn == this->mhMidiIn)
{
switch (wMsg)
{
case MIM_OPEN:
case MIM_CLOSE:
case MIM_ERROR:
case MIM_MOREDATA:
case MIM_LONGDATA:
case MIM_LONGERROR:
// ignore these messages for now
break;
case MIM_DATA:
md.mTime = dwParam2;
md.mStatus = (UCHAR)(dwParam1 & 0xFF);
md.mParam1 = (UCHAR)((dwParam1>>8) & 0xFF);
md.mParam2 = (UCHAR)((dwParam1>>16) & 0xFF);
assert(MIDI_BUFFER_SIZE == 512);
// do not post system message because we don't need them yet
if ((((this->mTail+sizeof(MIDIMessage)) &
(MIDI_BUFFER_SIZE-1)) != this->mHead) &&
(md.mStatus & 0xF0) != 0xF0)
{
memcpy(this->mBuffer+this->mTail, &md, sizeof(MIDIMessage));
this->mTail = (this->mTail+sizeof(MIDIMessage))&(MIDI_BUFFER_SIZE-1);
PostMessage(this->mHWnd, MM_MIM_DATA, 0, 0);
}
break;
default:
break;
}
}
}
LRESULT Game::MsgProc(UINT msg, WPARAM wParam, LPARAM lParam)
{
...
case MM_MIM_DATA:
if (midi->IsDeviceIn() && !this->mSongOver && !this->mReplaySong)
{
do
{
MIDIMessage *message = midi->GetChanMess();
if (message != NULL)
{
UCHAR evStat = message->mStatus & 0xF0;
if (evStat == 0x90 && message->mParam2 == 0)
evStat = 0x80;
if (evStat == 0x90 || evStat == 0x80)
{
evStat = evStat + this->mSong->PlayChannel();
if (this->mGameInterface == 1)
// send out all MIDI notes even if not used
{
UCHAR ind = 0xFF;
if (this->mPlayUnmarked ||
((ind = map->FindMIDI(message->mParam1)) !=
0xFF && map->Use(ind)))
{
UCHAR note;
if (ind != 0xFF)
note = map->GetNote(ind);
else
note = map->CodeMIDI(message->mParam1);
if (note != 0xFF)
{
UCHAR midiVel;
if (this->mPlayMIDIVel)
midiVel = message->mParam2;
else
midiVel = this->FindMIDIVel(note);
this->RecordNote(evStat, note, midiVel);
}
}
}
}
}
} while (midi->NextChanMess());
}
return 0;
发送 MIDI 消息比接收消息简单得多。只需调用初始化例程,然后使用以下例程进行输出。
void MIDIInterface::SendShrtMsg(UCHAR status, UCHAR param1, UCHAR param2)
{
if (this->mhMidiOut != NULL)
midiOutShortMsg(this->mhMidiOut,
(((UINT)param2)<<16)+(((UINT)param1)<<8)+status);
}
void MIDIInterface::SendLongMsg(void *buffer, int len)
{
if (this->mhMidiOut != NULL)
{
MIDIHDR mh;
mh.lpData = (LPSTR)buffer;
mh.dwBufferLength = len;
mh.dwFlags = 0;
UINT err = midiOutPrepareHeader(this->mhMidiOut,
&mh, sizeof(MIDIHDR));
if (!err)
{
err = midiOutLongMsg(this->mhMidiOut, &mh, sizeof(MIDIHDR));
if (err)
{
TCHAR errMsg[120];
midiOutGetErrorText(err, errMsg, 120);
// TRACE(_T("Error: %s\r\n"), errMsg);
}
while (MIDIERR_STILLPLAYING ==
midiOutUnprepareHeader(this->mhMidiOut, &mh, sizeof(MIDIHDR)))
;
}
}
}
好吧,我撒了一点谎。发送长消息并不那么简单。您需要知道用什么来填充长消息。这可以是简单的固定长度数据,也可以是可变长度数据,如波表信息。幸运的是,对于我的应用程序来说,这一切都在 MIDI 文件中提供,我只是发送那里有的内容,而无需知道我在发送什么。同样,您需要查阅 MMA 的网站来处理您想要处理的特定系统消息。
问题
尽管 MIDI Star 功能齐全,但我仍想尝试解决一些次要的技术问题。
第一个问题是定时器中断。虽然它有效,但有点像一种 hack。我使用 Windows 的 SetTimer
例程来生成中断,并且我请求 200 Hz 的中断率。但是,我只能得到 58 Hz 的中断率,所以我必须将我的中断经过时间乘以 3.44 的系数。当我降低中断率时,乘数会改变,因为有效中断率不再是 58 Hz。如果有人有解决方案,我非常想看到它。毫无疑问,这会是一些我忽略的简单事情。这是 game.cpp 中的代码。
this->mpTimer = SetTimer(d3di->HWnd(), 1, 1000/MUSIC_RATE, 0);
...
case WM_TIMER:
{
...
if (!this->mSongOver)
{
double oldTime = this->mSong->PlaySongTime();
this->mSong->PlayUpdate(RATE_MULTIPLIER/MUSIC_RATE);
if (this->mReplaySong)
this->InstantReplay(oldTime);
}
...
第二个问题是操纵杆/游戏手柄输入。我没有时间充分消化 MSDN 的代码片段,因此我无法刷新操纵杆/游戏手柄设备列表。因此,只有在应用程序启动期间插入游戏手柄时,才能识别游戏手柄。
关注点
将音符开启与音符关闭匹配起来很棘手,而且您不能假设 MIDI 文件是无错误的。您可能会收到多余的音符开启或关闭,因此无法匹配。在收到音符关闭之前,也可能收到两个相同音符编号的音符开启;这发生在我的鼓组上,它只是在音符开启后固定一段时间发送一个音符关闭消息。您必须决定您的代码如何处理这些情况。对于 MIDI Star,不允许或不希望同一音符编号有重叠的时间,所以我只是强制它们不重叠。
该项目原型开发大约需要 100 小时。在部分重写和最终调试上又花费了约 50 小时。注释和清理代码以符合本文以及撰写本文又花费了约 50 小时。
在处理 MIDI 设备或文件时,您可能会发现以下附加工具很有用:MIDI-OX 和 XVI32(十六进制文件编辑器)。
历史
- 2009 年 5 月 - 版本 1.0。