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

在上一篇中,老周介绍了一些乐理知识,有了那些常识后,进行 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 = 1000; // 每秒一拍
        const byte CHANNEL = 0; // 通道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>(55, 2 * TEMPO));
            // 低音6 = 57,两拍
            notes.Add(new Tuple<byte, int>(57, 2 * TEMPO));
            // 中音 3 = 64,一拍
            notes.Add(new Tuple<byte, int>(64, TEMPO));
            // 中音 2 = 62,一拍
            notes.Add(new Tuple<byte, int>(62, TEMPO));
            // 中音 3 = 64,一拍
            notes.Add(new Tuple<byte, int>(64, TEMPO));
            // 低音 6 = 57,一拍
            notes.Add(new Tuple<byte, int>(57, TEMPO));
            // 中音 3 = 64,半拍
            notes.Add(new Tuple<byte, int>(64, TEMPO / 2));
            // 低音 6 = 57,半拍
            notes.Add(new Tuple<byte, int>(57, TEMPO / 2));
            // 低音 6 = 57,一拍
            notes.Add(new Tuple<byte, int>(57, TEMPO));
            // 中音 1 = 60,两拍
            notes.Add(new Tuple<byte, int>(60, 2 * TEMPO));
            // 中音 5 = 67,两拍
            notes.Add(new Tuple<byte, int>(67, 2 * TEMPO));
            // 中音 3 = 64,一拍
            notes.Add(new Tuple<byte, int>(64, TEMPO));
            // 中音 1 = 60,一拍
            notes.Add(new Tuple<byte, int>(60, TEMPO));
            // 低音 7 = 59,半拍
            notes.Add(new Tuple<byte, int>(59, TEMPO / 2));
            // 中音 2 = 62,半拍
            notes.Add(new Tuple<byte, int>(62, TEMPO / 2));
            // 低音 5 = 55,一拍
            notes.Add(new Tuple<byte, int>(55, TEMPO));
            // 低音 7 = 59,一拍
            notes.Add(new Tuple<byte, int>(59, TEMPO));
            // 中音 2 = 62,一拍
            notes.Add(new Tuple<byte, int>(62, TEMPO));
            // 低音 7 = 59,一拍
            notes.Add(new Tuple<byte, int>(59, TEMPO));
            // 低音 6 = 57,一拍
            notes.Add(new Tuple<byte, int>(57, TEMPO));
            // 中音 1 = 60,两拍
            notes.Add(new Tuple<byte, int>(60, 2 * TEMPO));

            // 开始操作
            foreach (var tp in notes)
            {
                // 开启音符
                noteOn = new MidiNoteOnMessage(CHANNEL, tp.Item1, 127);
                // 发送
                mSynthesizer.SendMessage(noteOn);
                // 延时
                await Task.Delay(tp.Item2);
                // 停止
                noteOff = new MidiNoteOffMessage(CHANNEL, tp.Item1, 127);
                // 发送
                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 种乐器音色,这个老周将在下一篇中介绍。

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

时间: 2024-10-08 23:23:36

【Win 10 应用开发】MIDI 音乐合成——音符消息篇的相关文章

【Win 10应用开发】如何知道当前APP在哪个平台设备上运行

[Win 10应用开发]如何知道当前APP在哪个平台设备上运行 在做Win10开发的时候,我们可能经常会需要获得当前程序在在哪个平台设备上运行,用于UI和相关API的调用,那么可以通过什么方式知道当前APP运行的平台呢? 今天这里提供两个方法给大家做参考: 方法一:DeviceFamily 通过Windows.System.Profile.AnalyticsInfo.VersionInfo.DeviceFamily,来获取当前的平台设备,目前只可以得到两个值Windows.Mobile或Wind

【Win 10 应用开发】打印UI元素

原文:[Win 10 应用开发]打印UI元素 Windows App支持将UI界面进行打印的功能,这与浏览器中的打印网页的用途相近,其好处就是“所见即所得”,直接把界面上呈现的内容打印下来,比重新创建打印图像方便得多. 要在通用App中实现打印,主要依靠以下几个类型: PrintManager:位于Windows.Graphics.Printing命名空间,主要负责显示打印对话框,设置打印源等操作.在使用时,首先调用GetForCurrentView静态方法得到一个PrintManager实例:

【Win 10应用开发】实现全屏播放的方法

原文:[Win 10应用开发]实现全屏播放的方法 有人会问,以前的MediaElement控件不是有现成的一排操作按钮吗?而且可以直接进入全屏播放.是的,我们知道,以往的Store App都是在全屏模式下运行的,只要MediaElement控件填满整个窗口,就等于全屏播放了,但是,Win10应用是窗口化的,将MediaElement控件的IsFullWindow属性设置为true后,就会这样: 从上面的截图看,MediaElement控件只是覆盖整个窗口而已,并没有实现全屏.那有办法让它全屏播放

【Win 10应用开发】如何知道UAP在哪个平台上运行

原文:[Win 10应用开发]如何知道UAP在哪个平台上运行 面向22世纪的现代化应用程序可以同时在多种设备上运行,于是有朋友会有一个疑问:有时候,我们还真的需要判断一下,UAP应用程序在哪个平台上运行.尽管大多情况下我们不必要这样做,但某些特殊情况还得考虑.比如一串数据列表,我希望如果在桌面上运行时就以横向列表展现:但要是运行在手机上就以纵向列表展现. 也就是说,其实我们只需分析两种情况即可: 一.桌面.(台式机.笔记本.平板.游戏机.发广告专用机.导航器……) 二.移动环境.其实就是手机.

【Win 10 应用开发】RTM版的UAP项目解剖

原文:[Win 10 应用开发]RTM版的UAP项目解剖 Windows 10 发布后,其实SDK也偷偷地在VS的自定义安装列表中出现了,今天开发人员中心也更新了下载.正式版的SDK在API结构上和以前预览的时候是一样的,只是版本变成10240罢了,所以大家不要问老周有什么新的API. API虽然没变,但VS中的应用程序项目是有了新变化.毕竟以前都是预览的,而现在是“正规军”,以前练兵时都用土豆枪,现在都是真刀真枪干了,故而应用程序项目是有变化的. 以前老周跟大家讲的修改项目模板,去掉遥测类库的

【Win 10 应用开发】Toast通知激活应用——前台&amp;后台

原文:[Win 10 应用开发]Toast通知激活应用--前台&后台 老周最近热衷于讲故事,接下来还是讲故事时间. 有人问我:你上大学的时候,有加入过学生会吗?读大学有没有必要加入学生会? 哎哟,这怎么回答呢,从短期来说,加入学生会有点用,至少可以娱乐一下,运气好的话,说不定能遇到红颜知己,但这概率相当低.从长远发展看嘛,是没什么用.老周当年读了四年本科,在学生会混了四年,什么名堂也没混出来. 一方面老周向来不求虚名,所以也没去参选所谓的什么部长.主席之类的,这些“官衔”听起来很高大上,实际上很

【Win 10 应用开发】导入.pfx证书

这个功能其实并不常用,一般开发较少涉及到证书,不过,简单了解一下还是有必要的. 先来说说制作测试证书的方法,这里老周讲两种方法,可以生成用于测试的.pfx文件. 产生证书,大家都知道有个makecert工具.好,我们先用这个工具来生成一个证书,并存放到当前用户的证书存储中.打开VS的开发人员命令提示符,然后输入: makecert -n "CN=中国好男人" -pe -sr CurrentUser -ss My -b 01/01/2016 -e 12/31/2018 -n 表示证书的标

【Win 10应用开发】Adaptive磁贴模板的XML文档结构

在若干天之前,老周给大家讲了Adaptive Toast通知的XML模板,所以相应地,今天老周给大家介绍一下Adaptive磁贴的新XML模板. 同样道理,你依旧可以使用8.1时候的磁贴模板,在win 10的API中也是支持的,此外,Win10 App还支持全新的自适应磁贴模板,本文老周就给大家先讲一下基本结构,下一篇文章中咱们再说一说复杂排版. 应用程序的图标可以分为两类:第一类是应用商店上专用的,就是你的应用提交到商店后,给用户看的图标:另一类就是应用本身的一些图标或磁贴. 磁贴其实也就这么

【Win 10 应用开发】启动远程设备上的应用

这个功能必须在“红石-1”(build 14393)以上的系统版中才能使用,运行在一台设备上的应用,可以通过URI来启动另一台设备上的应用.激活远程应用需要以下前提: 系统必须是build 14393或以上版本,UWP应用必须使用14393或以上版本的SDK开发. 被启动的应用应当支持协议激活,比如Cortana的协议URI为:[ms-cortana:],当然如果是你自己开发的应用,可以自己定义一个协议,名字随便取,不要跟系统的协议或别人的应用冲突就行,比如:[haha:][zxzx:]等. 必