DirectSound によるドラム マシンの作成
Ianier Munoz Chronotron
February 2, 2004
日本語最終更新日 2004 年 2 月 16 日
要約: ゲスト コラムニストの Ianier Munoz が、Managed DirectX ライブラリと C# を使って、オーディオ ストリームをリアルタイムに合成するドラム マシンを作成します。
この記事のソース コードをダウンロードする。
ビートの作り方
(Duncan Mackenzie による紹介)
Ianier はクールな仕事を持っています。DJ のために、Microsoft(R) Windows Media(R) Player のようなコンシューマ ソフトウェアによるプロフェッショナルなデジタル信号処理(DSP)を可能にするコードを書いているのです。さらに、私たちにとってはありがたいことに、彼はマネージ コードと Managed DirectX(R) の世界にも足を踏み入れています。この文章で、Ianier は、数分間の作業で読者の小さなコンピュータ スピーカーから独自のバス ビートを鳴らせるようにするデモ(図 1 を参照)を作成しました。これは、マルチチャンネルのサンプリング ミュージックの設定と再生を可能にする、マネージ ドラム マシンです。このコードは細かい設定なしで動作するはずですが、winrythm サンプル プロジェクトを開いて実行する前に、DirectX SDK をダウンロードし(ここから入手可能) 、インストールする(その後、再起動する)必要があります。

図 1. ドラム マシンのメイン フォーム (Oh yeah, oh yeah... boom, boom, boom...)
はじめに
DirectX 9 SDK がリリースされるまで、Microsoft(R) .NET Framework は無音の世界にいました。この制限を回避するためには、COM Interop か P-Invoke を通して Microsoft(R) Windows(R) API にアクセスするしかなかったのです。
DirectX 9 のコンポーネントである Managed DirectSound(R) を使えば、COM Interop や P-Invoke に頼ることなく、.NET でサウンドを再生できます。この文章では、オーディオ サンプルをリアルタイムで合成し、結果として得られたストリームを DirectSound を使って再生する、簡単なドラム マシン (図 1 を参照) を実装する方法を説明します。
この記事は C# と.NET Framework に関する知識を前提として書かれています。また、オーディオ処理に関する基本的な知識があれば、ここに書かれているアイデアをよりよく理解できるでしょう。
この記事に付属しているコードは、Microsoft(R) Visual Studio(R) .NET 2003 を使ってコンパイルしています。ここからダウンロードできる DirectX 9 SDK が必要です。
DirectSound の簡単な概要
DirectSound は、アプリケーションからオーディオ リソースに、ハードウェアに依存しない形でアクセスできるようにする DirectX のコンポーネントです。DirectSound では、オーディオ再生の単位はサウンド バッファです。サウンド バッファは、ホスト システム内のサウンド カードに対応するオーディオ デバイスに属しています。DirectSound を使ってサウンドを再生するアプリケーションは、オーディオ デバイス オブジェクトを作成し、デバイス上にバッファを作成し、バッファにサウンド データを格納した後に、そのバッファを再生します。各種の DirectSound オブジェクトの相互関係の詳細については、DirectX SDK ドキュメントを参照してください。
サウンド バッファは、その用途に応じて、静的バッファとストリーミング バッファに分類できます。静的バッファは、何らかの定義済みのオーディオ データで 1 回だけ初期化され、必要に応じて何度も再生されます。一般にこの種のバッファは、ゲーム内で銃声やその他の短いエフェクトに使用します。一方、ストリーミング バッファは、一般にメモりに入りきらないコンテンツや、テレフォニー アプリケーションの音声のように、その長さや内容を事前に決定できないサウンドに使用します。ストリーミング バッファは、バッファの再生中もつねに新しいデータで更新される小さなバッファを使って実装されます。Managed DirectSound は静的バッファについては優れたドキュメントとサンプルを用意していますが、ストリーミング バッファのサンプルは現時点ではありません。
ただし、Managed DirectX はオーディオ ストリームを再生するためのクラスを持っているということには言及しておくべきでしょう。AudioVideoPlayback 名前空間の Audio クラスです。このクラスを使用すると、WAV や MP3 を含むほとんどの種類のオーディオ ファイルを再生できます。ただし、Audio クラスでは出力デバイスをプログラムから選択できず、またオーディオ サンプルにアクセスできないため、その内容を変更できません。
ストリーミング オーディオ プレーヤー
筆者は、ストリーミング オーディオ プレーヤーを、何らかのソースからオーディオ データを取り出し、何らかのデバイスを通して再生するコンポーネントとして定義しています。典型的なストリーミング オーディオ プレーヤー コンポーネントは、入ってきたストリームをサウンド カードを通して再生しますが、オーディオ ストリームをネットワーク上で転送したり、ファイルに保存したりすることも可能です。
IAudioPlayer インターフェイスは、われわれのアプリケーションがプレーヤーに関して知っているべきすべての情報を含んでいます。また、このインターフェイスによって、サウンド合成エンジンを実際のプレーヤーの実装から分離することが可能となります。これは、このサンプルを別の再生テクノロジを使用する別の .NET プラットフォームに移植する場合に便利です。
/// <summary>
/// バッファへのデータの格納にデリゲートを使用
/// </summary>
public delegate void PullAudioCallback(IntPtr data, int count);
/// <summary>
/// オーディオ プレーヤー インターフェイス
/// </summary>
public interface IAudioPlayer : IDisposable
{
int SamplingRate { get; }
int BitsPerSample { get; }
int Channels { get; }
int GetBufferedSize();
void Play(PullAudioCallback onAudioData);
void Stop();
}
SamplingRate、BitsPerSample、Channels の各プロパティは、プレーヤーが理解するオーディオ形式を記述しています。Play メソッドは PullAudioCallback デリゲートが供給するストリームの再生を開始し、Stop メソッドは、その名のとおりオーディオ再生を停止します。
PullAudioCallback は、count バイトのオーディオ データが、IntPtr のデータ バッファにコピーされると期待します。読者は、IntPtr ではなくバイト配列を使うべきだと思うかもしれません。IntPtr のデータを使うと、アプリケーションはアンマネージ コードの実行権限を必要とする関数を呼び出さなくてはならないからです。しかし、Managed DirectSound はいずれにしてもこの権限を必要とするので、IntPtr の使用はたいした影響を与えません。また、異なるサンプル形式や他の再生テクノロジを利用するときに、余分なデータのコピーを行わなくても済む可能性があります。
GetBufferedSize は、前回の PullAudioCallback デリゲートの呼び出し以降にプレーヤーのキューに入れられたデータのバイト数を返します。このメソッドは、入力ストリーム内での現在の再生位置を計算するために使用します。
DirectSound による IAudioPlayer の実装
上で述べたように、DirectSound のストリーミング バッファは、バッファの再生中もつねに更新される小さいバッファに過ぎません。StreamingPlayer クラスは、IAudioPlayer インターフェイスの実装にストリーミング バッファを使用しています。
まず StreamingPlayer のコンストラクタを見てみましょう。
public StreamingPlayer(Control owner,
Device device, WaveFormat format)
{
m_Device = device;
if (m_Device == null)
{
m_Device = new Device();
m_Device.SetCooperativeLevel(
owner, CooperativeLevel.Normal);
m_OwnsDevice = true;
}
BufferDescription desc = new BufferDescription(format);
desc.BufferBytes = format.AverageBytesPerSecond;
desc.ControlVolume = true;
desc.GlobalFocus = true;
m_Buffer = new SecondaryBuffer(desc, m_Device);
m_BufferBytes = m_Buffer.Caps.BufferBytes;
m_Timer = new System.Timers.Timer(
BytesToMs(m_BufferBytes) / 6);
m_Timer.Enabled = false;
m_Timer.Elapsed += new System.Timers.ElapsedEventHandler(Timer_Elapsed);
}
StreamingPlayer コンストラクタは、まず有効な DirectSound オーディオ デバイスが存在することを確認し、指定されていなければ新しいデバイスを作成します。Device オブジェクトを作成するときには、DirectSound がアプリケーション フォーカスの追跡に使用する Microsoft(R) Windows Forms コントロールを指定する必要があります。owner パラメータが必要なのはこのためです。その後、DirectSound SecondaryBuffer インスタンスを作成、初期化し、タイマーを割り当てます。このタイマーの役割については後に解説します。
IAudioPlayer.Start と IAudioPlayer.Stop の実装はかなり単純なものです。Play メソッドは、再生すべき何らかのオーディオ データが存在することを確認した後に、タイマーを有効にし、バッファの再生を開始します。これとは対照的に、Stop メソッドはタイマーを無効にし、バッファを停止します。
public void Play(
Chronotron.AudioPlayer.PullAudioCallback pullAudio)
{
Stop();
m_PullStream = new PullStream(pullAudio);
m_Buffer.SetCurrentPosition(0);
m_NextWrite = 0;
Feed(m_BufferBytes);
m_Timer.Enabled = true;
m_Buffer.Play(0, BufferPlayFlags.Looping);
}
public void Stop()
{
if (m_Timer != null)
m_Timer.Enabled = false;
if (m_Buffer != null)
m_Buffer.Stop();
}
ここでのアイデアは、デリゲートから送られてくるサウンド データをつねにバッファに供給しつづけることです。この目的を達成するために、タイマーはどれだけのオーディオ データが再生されたのかを定期的にチェックし、必要に応じてバッファにデータを追加します。
private void Timer_Elapsed(
object sender,
System.Timers.ElapsedEventArgs e)
{
Feed(GetPlayedSize());
}
GetPlayedSize 関数はバッファの PlayPosition プロパティを使って、再生カーソルが何バイト前進したかを計算します。バッファはループとして再生されるため、GetPlayedSize は再生カーソルが先頭に戻ったことを検出し、結果を適宜調整しなくてはならないことに注意してください。
private int GetPlayedSize()
{
int pos = m_Buffer.PlayPosition;
return
pos < m_NextWrite ?
pos + m_BufferBytes - m_NextWrite
: pos - m_NextWrite;
}
バッファにデータを供給するルーチンは、下に示す Feed です。このルーチンは、ストリームからオーディオ データを取り出してバッファに書き込む SecondaryBuffer.Write を呼び出します。このケースでのストリームは、Play メソッドで受け取った PullAudioCallback デリゲートのラッパーに過ぎません。
private void Feed(int bytes)
{
// 遅延を数ミリ秒に制限
int tocopy = Math.Min(bytes, MsToBytes(MaxLatencyMs));
if (tocopy > 0)
{
// バッファを復元
if (m_Buffer.Status.BufferLost)
m_Buffer.Restore();
// データをバッファにコピー
m_Buffer.Write(m_NextWrite, m_PullStream,
tocopy, LockFlag.None);
m_NextWrite += tocopy;
if (m_NextWrite >= m_BufferBytes)
m_NextWrite -= m_BufferBytes;
}
}
再生の遅延を減らすために、バッファに追加するデータの量を一定量に抑えていることに注意してください。遅延は、着信するオーディオ ストリームの変化が生じてから、その変化が実際に耳に聞こえるまでのずれとして定義できます。このような遅延制御を行わなかった場合、平均的な遅延は合計のバッファ長とほぼ等しくなり、リアルタイムのシンセサイザには許容できない長さとなる可能性があります。
ドラム マシン エンジン
ドラム マシンはリアルタイム シンセサイザの 1 つの例です。個々の考えうるドラム サウンドを表すサンプル波形のセット (音楽の世界では「パッチ」と呼ばれます) が、ドラマーの演奏をシミュレートするために、何らかのリズム パターンに沿ってミキシングして出力ストリームに送ります。これは簡単な話なので、さっそくコードを見てみましょう!
中心部
ドラム マシンのメインとなる要素は、Patch、Track、Mixer クラスに実装されています (図 2 を参照)。これらはいずれも Rhythm.cs 内で実装しています。
図 2. Rhythm.cs のクラス図
Patch クラスは特定の楽器の波形を含んでいます。Patch は、WAV 形式のオーディオ データを持つ Stream オブジェクトを使って初期化します。ここでは WAV ファイルの読み込みについては詳しく触れませんが、WaveStream ヘルパ クラスを見ると全体像がわかるでしょう。
簡単に説明すると、Patch は左右の両方のチャンネルを足し合わせてオーディオ データをモノラルにし (指定されたファイルがステレオだった場合)、結果を 32ビット整数の配列に格納します。実際のデータ範囲は -32768 +32767 なので、オーバーフローを心配することなく、複数のオーディオ ストリームをミキシングできます。
PatchReader クラスを使用すると、PatchReader からオーディオ データを読み込み、ミキシングを行って、出力バッファに格納できます。リーダーを実際の Patch データから分離しているのは、同じ PatchReader が異なる位置で多重再生されることがあるからです。これは特に、非常に短い時間に同じサウンドが何度も鳴る場合に起こります。
Track クラスは、1 つの楽器による演奏のイベントのシーケンスを表します。トラックは Patch、いくつかのタイム スロット (ビート ポジション)、およびオプションとして初期パターンによって初期化します。パターンは単なるブール値の配列であり、トラック内のタイム スロットの数に等しい長さを持ちます。配列の要素を true に設定すると、選択された Patch をそのビート位置で再生するという意味になります。Track.GetBeat メソッドは特定のビート位置の PatchReader インスタンスを返し、現在のビートで何も再生すべきでない場合には null を返します。
Mixer クラスは、指定されたトラックのセットに対応する実際のオーディオ ストリームを生成します。このため、PullAudioCallback シグニチャに適したメソッドを実装しています。また、ミキサーは現在のビート位置と、現在再生されている PullAudioCallback インスタンスのリストを追跡しています。
最も難しい作業は、次のコードに示す DoMix メソッドの中で行われます。ミキサーは、ビート期間に対応するサンプルの数を計算し、出力ストリームの合成が進行するのに合わせて、現在のビート位置を進めます。また、サンプルのブロックを生成するために、ミキサーは単に現在のビートで再生されているパッチを足し合わせます。
private void DoMix(int samples)
{
// 必要に応じてミックス バッファを増やす
if (m_MixBuffer == null || m_MixBuffer.Length < samples)
m_MixBuffer = new int[samples];
// ミックス バッファをクリアする
Array.Clear(m_MixBuffer, 0, m_MixBuffer.Length);
int pos = 0;
while(pos < samples)
{
// load current patches
if (m_TickLeft == 0)
{
DoTick();
lock(m_BPMLock)
m_TickLeft = m_TickPeriod;
}
int tomix = Math.Min(samples - pos, m_TickLeft);
// 現在のストリームをミキシングする
for (int i = m_Readers.Count - 1; i >= 0; i--)
{
PatchReader r = (PatchReader)m_Readers[i];
if (!r.Mix(m_MixBuffer, pos, tomix))
m_Readers.RemoveAt(i);
}
m_TickLeft -= tomix;
pos += tomix;
}
}
特定のテンポで、1 つのタイム スロットにどれだけの数のオーディオ サンプルが対応するのかを計算するために、ミキサーは (SamplingRate * 60 / BPM) / Resolution という式を使用します。SamplingRate はヘルツ単位で表現されたプレーヤーのサンプリング周波数、Resolution は 1 ビートあたりのスロットの数、BPM はビート数/分で表現されたテンポです。BPM プロパティは、この式を使用して、m_TickPeriod メンバ変数を初期化します。
全体の組み立て
ドラム マシンを実装するために必要な要素がすべて揃ったので、あとはそれらを組み合わせるだけです。以下に、処理の順序を示します。
- ストリーミング オーディオ プレーヤーを作成する。
- ミキサーを作成する。
- WAV ファイルまたはリソースからドラム パッチ(サウンド) のセットを作成する。
- ミキサーに対し、目的のパッチを再生するためのトラックのセットを追加する。
- 再生する各トラックのパターンを定義する。
- ミキサーをデータ ソースとして使用して、プレーヤーの実行を開始する。
次のコードからわかるように、RythmMachineApp クラスはまさにこの作業を行います。
public RythmMachineApp(Control control, IAudioPlayer player)
{
int measuresPerBeat = 2;
Type resType = control.GetType();
Mixer = new Chronotron.Rythm.Mixer(
player, measuresPerBeat);
Mixer.Add(new Track("Bass drum",
new Patch(resType, "media.bass.wav"), TrackLength));
Mixer.Add(new Track("Snare drum",
new Patch(resType, "media.snare.wav"), TrackLength));
Mixer.Add(new Track("Closed hat",
new Patch(resType, "media.closed.wav"), TrackLength));
Mixer.Add(new Track("Open hat",
new Patch(resType, "media.open.wav"), TrackLength));
Mixer.Add(new Track("Toc",
new Patch(resType, "media.rim.wav"), TrackLength));
// 事前に設定された値で初期化
Mixer["Bass drum"].Init(new byte[]
{ 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0 } );
Mixer["Snare drum"].Init(new byte[]
{ 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0 } );
Mixer["Closed hat"].Init(new byte[]
{ 1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 0, 0 } );
Mixer["Open hat"].Init(new byte[]
{ 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1 } );
BuildUI(control);
m_Timer = new Timer();
m_Timer.Interval = 250;
m_Timer.Tick += new EventHandler(m_Timer_Tick);
m_Timer.Enabled = true;
}
これですべてです。コードの残りの部分は、ユーザーがコンピュータの画面上でリズム パターンを作成するための単純なユーザー インターフェイスを実装しています。
結論
この文章では、Managed DirectSound API を使ってストリーミング バッファを作成する方法と、オーディオ ストリームをリアルタイムで生成する方法を示しました。筆者が用意したサンプル コードを楽しんでいただけたでしょうか。コードを改善して、パターンのロードと保存、テンポを変更するためのユーザー インターフェイス コントロール、ステレオ再生などの機能を追加することも考えられます。これについては、読者のみなさんにお任せしましょう。楽しみを独り占めはしたくないので…
最後に、この記事を Coding4Fun コラムに投稿することを許可してくれた Duncan に感謝します。読者のみなさんが、私がこのコードを書くのを楽しんだのと同じように、コードを読むのを楽しんでくれることを期待します。今後の記事では、ドラム マシンを Compact Framework に移植して、Pocket PC で動作するようにする方法を解説する予定です。
コーディング チャレンジ
Duncan は、この Coding4Fun コラムの最後に、関心を持っている人のためのちょっとしたコーディングの問題を出しています。そこで、この記事を読み終えた方には、私の手法を参考にして、DirectX を使用する何らかのコードを書いてみるという課題を出すことにしましょう。作品は GotDotNet あるいは GotDotNet Japan に投稿し、その解説と、それが興味深いものだと思う理由を書いた電子メールを Duncan に送ってください(duncanma@microsoft.com)。Duncan にはいつでもアイデアを送ってかまいませんが、コード サンプルそのものではなく、サンプルへのリンクを送るようにしてください。
ホビイスト向けのコンテンツに関するアイデアをお持ちですか? ゲスト コラムニストとして登場してもらいたいと思う人は? Duncan に duncanma@microsoft.com 宛てにメールを送ってください。
リソース
この記事のコアの部分は、ここから入手できる DirectX 9 SDK を使って作られていますが、MSDN DirectX Developer Center http://www.microsoft.com/japan/msdn/directx/ にも目を通してみてください。このトピックに関するマルチメディアのイントロダクションを探している方は、Managed DirectX に焦点を当てた .NET Show のエピソード (http://www.microsoft.com/japan/msdn/theshow/episode037/) も参考になるでしょう。
Coding4Fun
フランスの Metz 在住の Ianier Munoz は、ルクセンブルグの国際的なコンサルティング会社のシニア コンサルタント兼アナリストとして働いています。彼は Chronotron、Adapt-X、および American DJ's Pro-Mix といったいくつかの有名なマルチメディア ソフトウェアの作者です。連絡先は http://www.chronotron.com/ です。
|