【Win 10 应用开发】MIDI 音乐合成——音符消息篇

2023-03-07,,

在上一篇中,老周介绍了一些乐理知识,有了那些常识后,进行 MIDI 编程就简单得多了。尽管微软已经把 API 封装好,用起来也很简单,但是,如果你没有相应的音乐知识基础,你是无法进行 MIDI 编程的。

这一篇老周将给你讲述一下如何让你的声卡播放一个音符,这会包含两条消息,而且这两条消息是很常用的。

1、Note On:让 MIDI 设备(如果没有专业设备,那就是你的声卡)发出某个音符的声音,比如,发出中音 3 的声音。注意啊,Note on 一旦发送,设备会一直播放这个声音,要想停止播放一个音符,你就要用到下面这条消息,它们是天生的一对。

2、Note Off:关闭某个音符,即停止播放某个音符。

咱们先来了解三个很重要的类,跟 MIDI 设备通信相关的 API 都在 Windows.Devices.Midi 命名空间下,封装好的。

1、MidiInPort:用来从 MIDI 输入设备接收消息,所以它公开了一个 MessageReceived 事件,只要 MIDI 输入设备发送了消息,就会引发这个事件,这时候你可以处理这个事件,把收到的消息再传到声卡上进行播放。MIDI 输入设备一般是 MIDI 键盘,估计大部分人用不上这个类,因为一般人不会购买 MIDI 键盘。真想买个好用的,起码是 88 键的,价格还是不低的。

2、MidiOutPort:连接 MIDI 输出设备,可以播放 MIDI 音乐。如果没有专业的 MIDI 音响,就可以连到你的声卡上,内置外置都可以,市面上有外置的 MIDI 声卡卖,当然了,想省钱的话,你是买不到好音色的,要是你不在乎音色的话,那无所谓。

3、MidiSynthesizer:这个类非常好使,它其实类似于 MidiOutPort 类,但它可以自动选择默认的设备(当然也可选择设备)。这个类是专门针对 MIDI 合成而设计的,尽管它与 MidiOutPort 相似,但侧重点不同。MidiOutPort 侧重于与 MIDI 设备的通信,而 MidiSynthesizer 类是侧重于合成。

我们在进行电子音乐合成的时候,只需要使用 MidiSynthesizer 类即可,它没有构造函数,可以调用 CreateAsync 静态方法来获取实例。对于普通设备而言,我们调用无参数的重载版本就行了,应用程序会默认选择声卡作为输出设备。然后,我们尽管发送 MIDI 消息就OK。当不再使用 MidiSynthesizer 实例时,应该把它 Dispose 掉,以释放资源占用。

是不是很简单呢,一切都是封装好的,所以说,你只要有一定的乐理基础就可以轻松玩耍这些 API。据说,这个 MidiSynthesizer 类还包含了罗兰公司(Roland)的通用音色库。

当然了,这只能是通用的 128 种乐器的声音,不包含各种演奏技巧(如揉弦、波音、颤音等)。其目的是尽可能地兼容各类声卡,包括很烂的声卡,虽然比较普通,不过嘛,音色听着还是可以的,只是少了点感觉。不过也是,电声毕竟是虚假的乐音,而不是自然音,就算是专业级别的音源,其实听着也不会太有乐感的。所以嘛,真想感受音乐之美,还是买个真实的乐器自己去演奏。老周小时候喜欢口琴和笛子,上初中的时候,学了一点电子琴、口风琴和扬琴,不过只是学了一点点而已。上高中的三年基本没碰过乐器。大学的时候,在学生会里面鬼混,所以经常可以拿乐队的吉他拨两下。

后来,像洞箫、巴乌、葫芦丝、陶埙、陶笛等都学过。想学学古琴,但是买一把好琴比较贵,就没有去学了。吹奏类乐器一般比较便宜,至少像老周这种穷人还能买得起,因此老周家里放的乐器,多数是吹奏类的。击打类的有一对小铜鼓,在路边捡的。

好,不扯了,咱们说正题。本篇的重点是学会两条 MIDI 消息,对,就是上面说的 Note on 和 Note off。不管是 on 还是 off,这两条音符消息的格式是一样的,都是包含三个字节。

第一个字节是 【状态码 + 通道编号】,这个可能你不太理解,没事,老周待会儿再解释。

第二个字节是音符,对,就是上一篇中,简谱上面的 1234567,唱出来就是 dol re mi fa sol la xi,用一个字节表示,从 0 - 127,共128 个音符。

第三个字节是音速,值也是从 0 到 127。这个音速其实你感觉不到什么,发送到声卡上的效果就是音量。值越小声音越小,如果是 0 就等于静音了,127 时声音最大。

好,下面逐个解释两下。

首先,状态码,在前一篇中,老周简单地说了一下 MIDI 文件的结构,一个 MIDI 事件是由 delta-time 和事件主体组成。而一个事件的开头都有一个标志字节。在MIDI文件中, Note on 和 Note off 都是一个事件;而在实时通信中,可认为是一条 MIDI 消息,其实结构是一样的。

不管是Note on 和 Note off ,还是其他通道消息,其第一个字节是由两部分信息组成的。我们知道,一个字节有 8 位,从右边起,1 - 4位表示通道编号,所以,MIDI 音乐有 16 个通道。为什么是 16 个通道呢,不是刚说了吗,只有 4 位二进制位表示通道编号,二进制 1111 就是 15,所以,通道的有效编号是 0 - 15,共16个。

注意:轨道与通道不同。轨道地用于 MIDI 文件的,可以是单轨,可以是多轨,轨道只是方便存储,也方便人类查看,但 MIDI 设置并不认轨道,只认识标准的 16 个通道。故 MIDI 消息只有通道的概念。另外,还要注意,第 10 个通道(编号 9 )是打击乐专用通道,在 GM 2 标准中,增加了一个,即第 10、11 通道可用于打击乐(编号 9、10)。

第 5 到 8 位表示状态码,或者说事件标志,总之,用来标识某个指令。Note Off 的标志是 1000,换算为十六进制就是 0x8 ;Note On 的标志是 1001,换算为十六进制就是 0x9。

假设,要向第四个通道发送一条 Note on 消息。第四个通道的编号是 3,换算为二进制就是 0011,Note on 的标志为 1001,所以,组合起来,第一个字节就是 1001 0011,换算为十六进制就是 0x93。再比如,要向第一个通道发送一条消息,第一通道的编号是0,即 0000,Note on 的标志是 1001,组合起来的字节就是 1001 0000,换算为十六进制就是 0x90。

如果要向第二个通道发送一条 Note off 消息。第二个通道的编号是 1,即 0001,Note off 的标志为 1000,组合起来的字节就是 0x81。

音符消息的第二个字节是音符,值从 0 - 127,共128个。虽然有 128 个音符,但实际上你只要记住一个值就行了—— 60,它表示的是中音 1 。128 / 12,余数为 8 ,凑不成一个 12,所以,中音 1 就位于 120 / 2 = 60 处。为什么音符是 12 个一组呢?上一篇中老周为啥要介绍“十二平均律”,就是有用的,MIDI 的音符排序是遵守十二平均律的,所以每 12 个音符构成一个“八度”。

于是这一来,这里头就有十来个八度了,其实我们大多数歌曲根本用不上,很多情况下,只用到三个八度:低音区、中音区、高音区。所以,你只需要记住中音 1 的编号是 60 就好办了。你看啊,中音 1 是 60,那么,低音 1 就是 60 - 12 = 48,高音 1 就是 60 + 12 = 72,倍高音 1 就是 60 + 12*2 = 84,倍低音 1 就是 60 - 12*2 = 36。

下面老周给你一张表,用以参考。

音符消息的第三个字节是音速,值从 0 - 127,这个所谓的音速,发送到设备后实际表现出来的效果是音量,127时音量最大,如果是0就无声了。如果我们向 MIDI 设备发送一条音速 = 0 的 Note on 消息,它的结果等同于 Note off 消息。说白了就是,音速为 0 的 note on 消息等同于 note off 消息,结果都是停止播放音符。

举几个例子,如果要让通道0发出中音 1 的声音,首先,note on 的标志是 0x9,通道为0,合起来第一个字节是 0x90;第二个字节表示音符,中音1是60,即 0x3C; 第三个字节是音速,我们用最大值127,即 0x7F。所以这条 note on 消息就是:

0x90  0x3C  0x7F

要是想停止上面的音符,就发送:

0x80  0x3C  0x7F

因为 Note Off 消息是停止音符的,所以音速值可以随便,这里我还是用 127 吧。

再比如,向通道14发送一条播放中音 5 的消息。Note On 的标志是 0x9,通道 14 是 1110,即 0xE;中音 5 是 67,即 0x43;音速用最大值,所以,整条消息为:

0x9E  0x43  0x7F

======================================================================

下面咱们开始编程,先说说连接设备。不管是输入还是输出设备,我们都可以用这种方法连接。

        IMidiOutPort midiOuter = null;

        async Task<IMidiOutPort> GetOuterPortAsync()
{
// 获取设备查询字符串
string q = MidiOutPort.GetDeviceSelector();
// 查找相关 MIDI 输出设备
DeviceInformationCollection devs = await DeviceInformation.FindAllAsync(q);
// 如果连接多个 MIDI 设备,就要选一个来耍,
// 如果没有连外设,那只能有一个,就是声卡兼容的合成器
return await MidiOutPort.FromIdAsync(q);
}

然后初始化一下 out port。

  midiOuter = await GetOuterPortAsync();

不需要的时候,记得要清理一下。

  midiOuter?.Dispose();

这里有一个很 TNND 重要的事情,一定要注意,声明变量时,一定要声明为 IMidiOutPort 接口类型,不要声明为 MidiOutPort 类型,这样做到时候很可能你无法与设备通信,发了消息过去没声音。不要问为什么了,记住就行,这是封装 COM 组件的,COM通常都是用接口中来操作的。

好的,下面正式实现我们今天的示例,为了演示,老周特意写了一首歌,意境优美,相当动听,值得收藏。

由于这首歌热情扬溢,老周故意把节拍设置为 60,即每分钟 60 拍,正好一秒一拍。

用来进行音乐合成,最好直接使用 MidiSynthesizer 类。

第一步。初始化。

        MidiSynthesizer mSynthesizer = null;

        protected async override void OnNavigatedTo(NavigationEventArgs e)
{
mSynthesizer = await MidiSynthesizer.CreateAsync();
}

在离开当前页面时,不再需要,释放掉,洗地。

        protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
{
mSynthesizer?.Dispose();
}

第二步,定义几个变量,后面要用。

        const int TEMPO = ; // 每秒一拍
const byte CHANNEL = ; // 通道0,本例只用一个通道
bool isPlaying = false;

TEMPO 是节拍,咱们的曲子是 J = 60,故一秒一拍,这里表示为 1000 毫秒。CHANNEL表示我们要用到的通道,为了简单演示,我们这个示例只用第一个 MIDI 通道,编号为 0。

isPlaying 防止重复播放,当正在播放时,它为 true,播放完后变为 false。

第三步,组合音符,并发送到 MIDI 设备上。

            if (isPlaying)
{
return;
} isPlaying = true;
// 播放音符
MidiNoteOnMessage noteOn = null;
// 停止音符
MidiNoteOffMessage noteOff = null; // 组合音符列表
List<Tuple<byte, int>> notes = new List<Tuple<byte, int>>();
// 低音5 = 55,两拍
notes.Add(new Tuple<byte, int>(, * TEMPO));
// 低音6 = 57,两拍
notes.Add(new Tuple<byte, int>(, * TEMPO));
// 中音 3 = 64,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 中音 2 = 62,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 中音 3 = 64,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 低音 6 = 57,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 中音 3 = 64,半拍
notes.Add(new Tuple<byte, int>(, TEMPO / ));
// 低音 6 = 57,半拍
notes.Add(new Tuple<byte, int>(, TEMPO / ));
// 低音 6 = 57,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 中音 1 = 60,两拍
notes.Add(new Tuple<byte, int>(, * TEMPO));
// 中音 5 = 67,两拍
notes.Add(new Tuple<byte, int>(, * TEMPO));
// 中音 3 = 64,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 中音 1 = 60,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 低音 7 = 59,半拍
notes.Add(new Tuple<byte, int>(, TEMPO / ));
// 中音 2 = 62,半拍
notes.Add(new Tuple<byte, int>(, TEMPO / ));
// 低音 5 = 55,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 低音 7 = 59,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 中音 2 = 62,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 低音 7 = 59,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 低音 6 = 57,一拍
notes.Add(new Tuple<byte, int>(, TEMPO));
// 中音 1 = 60,两拍
notes.Add(new Tuple<byte, int>(, * TEMPO)); // 开始操作
foreach (var tp in notes)
{
// 开启音符
noteOn = new MidiNoteOnMessage(CHANNEL, tp.Item1, );
// 发送
mSynthesizer.SendMessage(noteOn);
// 延时
await Task.Delay(tp.Item2);
// 停止
noteOff = new MidiNoteOffMessage(CHANNEL, tp.Item1, );
// 发送
mSynthesizer.SendMessage(noteOff);
} isPlaying = false;

Tuple 是元组,以前老周在其他博文中说过,就是简单地把两个值组合起来,我们这里用了两种值,byte类型的表示音符编号,int类型的表示音符要持续的时间,即时值。

我先用一个 List 把所有的音符与时值组合起来,然后再通过一个循环来发送到声卡。

注意,在发送完 Note On后,不能立即发 Note Off,因为那样音符会停止,你就听不到了,所以要用 Delay 方法延时一下,而延时的时间就是音符的时值。如果是一拍,就是 1000 毫秒,如果是两拍就是 2000 毫秒,如果是半拍,就是 500 毫秒……

第四步,现在虽然代码已经写完了,但你是无法合成 MIDI 音乐的,因为 MIDI API 是微软为我们封装过的,咱们还需要添加一个引用。如下图,请勾选【Microsoft General MIDI DLS for Universal Windows Apps】,注意是勾上前面的对勾,不要只选中,最后点确定即可。

现在,运行应用,然后点击【演奏这首歌】按钮,就能听到了。

你听到的是大钢琴的声音,因为这是默认音色。通用音色库可以使用 128 种乐器音色,这个老周将在下一篇中介绍。

本篇示例源代码,请猛点击这里下载。

【Win 10 应用开发】MIDI 音乐合成——音符消息篇的相关教程结束。

《【Win 10 应用开发】MIDI 音乐合成——音符消息篇.doc》

下载本文的Word格式文档,以方便收藏与打印。