每一台 PC 都包含一個內置的 16 人樂隊,可播放一些音樂。人們不容易注意此樂隊的成員,因為它 們表示的可能是 Windows 所支持的聲音和視頻功能陣列中利用最不充分的組件。
此 16 人樂隊是在符合 MIDI(樂器數字接口)標准的硬件或軟件中實現的電子音樂合成器。在 Win32 API 中,以單詞 midiOut 開頭的函數支持使用 MIDI 合成器播放音樂。
MIDI 支持不是 .NET Framework 的一部分,但如果要在 Windows 窗體或 Windows Presentation Foundation (WPF) 應用程序中訪問此 MIDI 合成器,則需要使用 P/Invoke 或外部庫。
我在上一期專欄文章中討論的 CodePlex 上的 NAudio 聲音庫中提供了 MIDI 支持,發現這一點時, 我感到非常高興。您可以從 codeplex.com/naudio 下載該庫及其源代碼。在本文中,我使用的是 NAudio 1.3.8 版。
簡單示例
MIDI 可視為波形音頻的高級接口,用於處理樂器和樂音。
MIDI 標准形成於 20 世紀 80 年代早期。電子音樂合成器制造商希望通過標准方法來連接電子音樂控 制器(如鍵盤)和合成器,因此開發出一種系統,通過具有 5 針接頭的電纜以 3,125 字節/秒的速度傳 輸短消息(長度大多為 1、2 或 3 個字節)。
其中兩條最重要的消息稱為 Note On 和 Note Off。當音樂家按下 MIDI 鍵盤的某個鍵時,鍵盤生成 一條 Note On 消息,指示所按的樂音和鍵的速度。合成器通過演奏該樂音進行響應,通常鍵速度越高, 聲音越大。音樂家釋放鍵時,鍵盤生成 Note Off 消息,生成器通過關閉樂音進行響應。沒有實際音頻數 據通過 MIDI 電纜。
盡管 MIDI 仍用於連接電子音樂硬件,它還是可以完全通過軟件在 PC 中使用。聲卡可以包含 MIDI 合成器,Windows 本身完全通過軟件模擬 MIDI 合成器。
若要在使用 NAudio 庫的 WinForms 或 WPF 應用程序中訪問該合成器,請將 NAudio.dll 添加為引用 ,並在源代碼中包含以下 using 指令:
using NAudio.Midi;
假設應用程序需要演奏單個 1 秒長的樂音,聲音類似於鋼琴的中央 C。使用以下代碼可實現這一功能 :
MidiOut midiOut = new MidiOut(0);
midiOut.Send(MidiMessage.StartNote(60, 127, 0).RawData);
Thread.Sleep(1000);
midiOut.Send(MidiMessage.StopNote(60, 0, 0).RawData);
Thread.Sleep(1000);
midiOut.Close();
midiOut.Dispose();
PC 可能擁有多個 MIDI 合成器的訪問權限;MidiOut 構造函數的參數是一個數字 ID,用於選擇要打 開的 MIDI 合成器。如果 MIDI 輸出設備已被使用,該構造函數將引發異常。
程序可以先使用靜態 MidiOut.NumberOfDevices 屬性發現存在多少合成器,從而獲取有關 MIDI 合成 器的信息。數字 ID 的范圍從 0 到設備數減 1。靜態 MidiOut.DeviceInfo 方法接受數字 ID,返回一個 描述合成器的 MidiOutCapabilities 類型的對象。(我不准備使用這些功能,在本文後面部分,我只使 用 ID 為 0 的默認 MIDI 合成器。)
MidiOut 類的 Send 方法向 MIDI 合成器發送一條消息。MIDI 消息包含 1、2 或 3 個字節,但 Win32 API(和 Naudio)需要將其打包為一個 32 位整數。MidiMessage.StartNote 和 MidiMessage.StopNote 方法執行此打包操作。Send 的兩個參數可分別替換為 0x007F3C90 和 0x00003C80。
StartNote 和 StopNote 的第一個參數是 0 到 127 范圍內的代碼,用於指示實際樂音,其中值 60 是中央 C。高八度是 72。低八度是 48。第二個參數是按下或釋放鍵的速度。(合成器通常會忽略釋放速 度。)這些參數在 0 到 127 范圍內。MidiMessage.StartNote 的第二個參數越小,樂音越柔和。(我很 快會討論第三個參數。)
對 Thread.Sleep 的兩次調用會將線程掛起 1,000 毫秒。這是用來確定消息時間的很簡單的方法,但 應避免在用戶界面線程中使用。為了使樂音在被 Close 調用突然截斷之前消失,需要第二次調用 Sleep 。
如何處理復調?
上面介紹了如何演奏單個樂音。那麼如何同時演奏多個樂音呢?這也是可以實現的。例如,如果要演 奏 C 主和弦而不是簡單的樂音 C,可將第一條 Send 消息替換為:
midiOut.Send(MidiMessage.StartNote(60, 127, 0).RawData);
midiOut.Send(MidiMessage.StartNote(64, 127, 0).RawData);
midiOut.Send(MidiMessage.StartNote(67, 127, 0).RawData);
midiOut.Send(MidiMessage.StartNote(72, 127, 0).RawData);
然後,將第二條 Send 消息替換為:
midiOut.Send(MidiMessage.StopNote(60, 0, 0).RawData);
midiOut.Send(MidiMessage.StopNote(64, 0, 0).RawData);
midiOut.Send(MidiMessage.StopNote(67, 0, 0).RawData);
midiOut.Send(MidiMessage.StopNote(72, 0, 0).RawData);
如果要在不同時刻啟動和停止不同的樂音,可能需要放棄使用 Thread.Sleep 而使用實際計時器,特 別是在用戶界面線程中播放音樂時。稍後將對此進行詳細介紹。
有一種 MIDI 文件格式可組合 MIDI 消息與計時信息,但需要使用專門軟件來創建這些文件,這裡不 做論述。
樂器和頻道
到目前為止,我只演奏了鋼琴的樂音。您可以使用在 NAudio 中通過 ChangePatch 方法實現的 MIDI “程序更改”消息切換合成器,以演奏其他樂器的樂音:
midiOut.Send(MidiMessage.ChangePatch(47, 0).RawData);
ChangePatch 的第一個參數是在 0 到 127 范圍內的數字代碼,指示特定樂器的樂音。
回顧早期的 MIDI,由合成器發出的實際聲音完全由執行者通過調節盤和插線電纜來控制。(因此,特 定合成器設置或樂器樂音通常稱為“音色”。)後來,MIDI 文件的創建者需要一組標准樂器,這樣無論 使用哪個合成器,文件播放起來都有同樣的效果。從而產生了稱為“General MIDI”(常規 MIDI)的標 准。
有關 General MIDI 的信息,請參閱 Wikipedia 詞條 en.wikipedia.org/wiki/General_midi。在標 題“Melodic sounds”下,是代碼在 1 到 128 范圍內的 128 種樂器樂音。您可以在 ChangePatch 方法 中使用從零開始的代碼,這樣前一示例中的代碼 47 是此列表中的樂器 48,即定音鼓樂音。
在本文開頭我提到過,MIDI 合成器相當於一個 16 人樂隊。MIDI 合成器支持 16 個頻道。在任何時 候,每個頻道都根據最新的“程序更改”消息與特定樂器相關聯。頻道數介於 0 到 15 之間,在 StartNote、StopNote 和 ChangePatch 方法的最後一個參數中指定。
頻道 9 比較特殊。這是打擊樂器頻道。(通常將其稱為頻道 10,不過這是從 1 開始編號的情況。) 對於頻道 9,傳遞給 StartNote 和 StopNote 方法的代碼引用特定的非樂音的打擊樂器聲音而不是標准 音高。在 Wikipedia 的 General MIDI 詞條中,請參閱標題“Percussion”下的列表。例如,下面的調 用將演奏代碼 56 所指示的鈴铛樂音:
midiOut.Send(MidiMessage.StartNote(56, 127, 9).RawData);
有關 MIDI 的信息還有很多,但這些是最基本的。
基於 XAML 的 MIDI
按照 WPF 和 XAML 的實質,我認為,開發一種基於字符串的格式,從而直接在 XAML 文件中嵌入音樂 片段,然後播放這些音樂,會是一種有趣的嘗試。我將這種格式稱為 MIDI 字符串,即樂音和計時信息的 文本字符串。所有標記都由空白分隔。
樂音由 A 至 G 的大寫字母、任意數量的 + 號或 # 號(每個符號使音高提高一個半音)或者 – 號 或字母 b(使音高降低一個半音)以及可選的八度音階序號(其中八度音階開始處的中央 C 的序號是 4 )組成。(這是對八度音階進行編號的標准方法。)因此,中央 C 下的 C# 為:
C#3
字母 R 本身是一個休止符。樂音或休止符可以後跟持續時間,指示與下一個樂音的間隔時間。例如, 下面是一個四分音符,這也是在未指定持續時間時的默認值:
1/4
持續時間是“粘滯的”,也就是說,如果持續時間後面沒有樂音,則使用最後一個持續時間。如果持 續時間以斜線開頭,則假定分子為 1。
持續時間指示與下一個樂音的間隔時間。此持續時間也用作樂音的長度,即與樂音關閉的間隔時間。 對於不連貫的聲音,可能希望樂音的長度小於它的持續時間。或者,您可能希望連續樂音有部分重疊。指 示樂音長度的方法與指示持續時間的方法相同,但帶有一個減號:
–3/16
持續時間和長度始終出現在應用它們的樂音之後,但順序並不重要。長度不是“粘滯的”。如果未出 現樂音長度,則將持續時間用作長度。
樂音前面也可以添加標記。要設置樂器聲音,請在字母 I 後面使用從零開始的音色號。例如,下面的 代碼指示小提琴連續樂音:
I40
鋼琴是默認音色。
若要為連續樂音設置新音量(即速度),請使用 V,如:
V64
對於 I 和 V,其後的數字必須在 0 到 127 之間。
默認情況下,節拍為 60 個四分音符/分鐘。若要為以下樂音設置新節拍,請使用 T 後跟每分鐘四分 音符數的形式,例如:
T120
如果要使用完全相同的參數演奏一組樂音,可將這些樂音放置在括號中。下面是 C 主和弦:
(C4 E4 G4 C5)
只有樂音可以出現在括號內。豎線 | 用於分隔頻道。各個頻道同時播放,彼此完全獨立,包括節拍。
如果特定頻道中的任意位置包含大寫字母 P,該頻道就是打擊樂器頻道。該頻道可以包含常規樂譜中 的樂音或休止符,不過也允許以數字方式指示打擊樂器的聲音。例如,以下是鈴铛:
P56
如果您訪問 en.wikipedia.org/wiki/Charge_(fanfare),可以看到通常在運動會上播放的“Charge! ”曲調。該曲調可用 MIDI 字符串格式表示為:
"T100 I56 G4 /12 C5 E5 G5 3/16 -3/32 E5 /16 G5 /2"
MidiStringPlayer
MidiStringPlayer 是可下載源代碼提供的 Petzold.Midi 庫項目中唯一的公共類。該類派生自 FrameworkElement,因此可將其嵌入 XAML 文件的可視樹中,但它沒有可視外觀。以前一示例中的格式將 MidiString 屬性設置為字符串,然後調用 Play(也可以選擇調用 Stop 在序列完成之前停止序列)。
MidiStringPlayer 還有一個用於在加載元素時播放序列的 PlayOnLoad 屬性,以及一個只讀的 IsPlaying 屬性。該元素在播放完序列時生成一個 Ended 事件,在 MIDI 字符串出現語法錯誤時激發 Failed 事件。該事件包含文本字符串格式的偏移值,指示有問題的標記和錯誤的文本說明。
可下載代碼中還包含兩個 WPF 程序。MusicComposer 程序允許您以交互方式集中 MIDI 字符串。 WpfMusicDemo 程序將一些簡單序列編碼在一個 MIDI 文件中,如圖 1 所示。
圖 1 WpfMusicDemo.xaml 對多個簡單 MIDI 字符串進行編碼
<Window x:Class="WpfMusicDemo.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:midi="clr-namespace:Petzold.Midi;assembly=Petzold.Midi"
Title="WPF Music Demo"
Height="300" Width="300">
<Grid>
<midi:MidiStringPlayer Name="player"
PlayOnLoad="True"
MidiString="{Binding ElementName=chargeButton, Path=Tag}" />
<UniformGrid Rows="2"
ButtonBase.Click="OnButtonClick">
<UniformGrid.Resources>
<Style TargetType="Button">
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Style.Triggers>
<DataTrigger
Binding="{Binding ElementName=player, Path=IsPlaying}"
Value="True">
<Setter Property="IsEnabled" Value="False" />
</DataTrigger>
</Style.Triggers>
</Style>
</UniformGrid.Resources>
<Button Name="chargeButton"
Content="Charge!"
Tag="T100 I56 G4 /12 C5 E5 G5 3/16 -3/32 E5 /16 G5 /2" />
<Button Content="Bach D-Minor Toccata"
Tag="T24 I19 A5 /64 G5 A5 5/32 R /32 G5 /64 F5 E5 D5 C#5 /32 D5 /16 R 4/16 A4 /64 G4 A4 5/32 R /32 E4 F4 C#4 D4 /16 R 4/16 | T24
I19 A4 /64 G4 A4 5/32 R /32 G4 /64 F4 E4 D4 C#4 /32 D4 /16 R 4/16 A3 /64 G3 A3 5/32 R /32 E3 F3 C#3 D3 /16 R 4/16"/>
<Button Content="Shave & a Haircut"
Tag="T130 I58 C5 G4 /8 G4 Ab4 /4 G4 R I75 B4 C5" />
<Button Content="Beethoven Fifth"
Tag="T200 I71 R /8 G4 G4 G4 Eb4 7/8 R /8 F4 F4 F4 D4 5/4 | T200 I40 R /8 G4 G4 G4 Eb4 7/8 R /8 F4 F4 F4 D4 5/4 | T200 I40 R /8 G4
G4 G4 Eb4 7/8 R /8 F4 F4 F4 D4 5/4 | T200 I41 R /8 G3 G3 G3 Eb3 7/8 R /8 F3 F3 F3 D3 5/4 | T200 I43 R /8 G2 G2 G2 Eb2 7/8 R /8 F2 F2 F2 D2
5/4 | T200 I43 R /8 G2 G2 G2 Eb2 7/8 R /8 F2 F2 F2 D2 5/4"/>
</UniformGrid>
</Grid>
</Window>
對任何音樂播放軟件都至關重要的組成部分是計時器,但對於 MidiStringPlayer,我使用了非常簡單 的 DispatcherTimer,該計時器在 UI 線程上運行。這當然不是最優方案。如果另一個程序過多占用 CPU ,音樂播放將不規律。DispatcherTimer 生成 Tick 事件的速度也無法超過 60 個/秒,此速度可滿足簡 單音樂,但無法提供更有節奏的復雜音樂所需要的精度。
Win32 API 包括一個專門用來播放 MIDI 序列的高分辨率計時器,但 NAudio 庫尚不包括該計時器。 可能在以後某個時候,我會將 DispatcherTimer 替換為更精確、更普通的計時器,但目前為止,我很高 興地看到,在這個簡單的解決放案中,DispatcherTimer 工作正常。
下載示例代 碼:http://code.msdn.microsoft.com/mag201003UIFrontiers