在上一篇中,老周介紹了一些樂理知識,有了那些常識后,進行 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 種樂器音色,這個老周將在下一篇中介紹。
本篇示例源代碼,請猛點擊這里下載。