.NETでBASS Audio Libraryを使って同時に複数の音源を再生する

少し前に.NET上で音声を再生しようとしてBASS Audio Libraryを使ったのでまとめることにしました。

BASSについて

BASSはUn4seen Developmentsが提供しているライブラリで、非商用であれば無料で利用することができます。
Windows/OS X/Linuxと幅広いプラットフォームで利用できるバイナリが提供されているほか、WAV、MP3はもちろんOGGやAIFFといったフォーマットも再生できるのがうれしい。

Nugetからライブラリを落とす

今回はライブラリをNugetから取得します。
バイナリを直接落としてもいいのですが、ソース管理するときはパッケージマネージャに任せたい派なので……。

file

Bass.NetWrapperをインストールすれば依存パッケージも自動で入ります。

BASSの基本的な使い方

とりあえずUn4seen.Bassを参照して裸のBASSを使ってみます。

最初にライブラリをBASS_Initで初期化します。
このメソッドにはデバイスのID、出力のサンプリングレート、初期化フラグを順に指定します。
初期化に失敗した場合はfalseが返されるので処理を中断します。

if (!Bass.BASS_Init(-1, 44100, BASSInit.BASS_DEVICE_DEFAULT, IntPtr.Zero))
{
    return;
}

初期化が完了したらBASS_StreamCreateFileで音源のハンドルを取得します。
このメソッドにはファイル名、オフセット位置、長さ(0ならば全体を利用)、動作フラグを順に指定します。

int handle = Bass.BASS_StreamCreateFile("sound.mp3", 0, 0, BASSFlag.BASS_DEFAULT);

ハンドルを取得できたらBASS_ChannelPlayで再生を開始します。

Bass.BASS_ChannelPlay(handle, false);

いざ実行!その前に……。

以上の内容から適当なコンソールアプリケーションなどで以下のようなコードを書けばテストが可能ですが、このままビルドするとDLLが読み込めずに実行が止まります。

class Program
{
    static void Main(string[] args)
    {
        if (!Bass.BASS_Init(-1, 44100, BASSInit.BASS_DEVICE_DEFAULT, IntPtr.Zero))
        {
            return;
        }
        int handle = Bass.BASS_StreamCreateFile("sound.mp3", 0, 0, BASSFlag.BASS_DEFAULT);
        Bass.BASS_ChannelPlay(handle, false);
        System.Threading.Thread.Sleep(TimeSpan.FromSeconds(8));
    }
}

というのはプロジェクト作成時の構成がAny CPUで指定されx86/x64のどちらのプロセスで実行されるかが起動時に解決されるのに対して、BASSライブラリ自体はバイナリの時点で明確に分けられているためです。
実際の出力を見てみるとbin以下のフォルダ構成が以下のようになっているため、実行ファイルから直接bass.dllが読み込めずに例外が発生します。

  • Debug
    • x86
      • bass.dll
    • App.exe

手っ取り早くこれを解決するためには、プラットフォームをAny CPUではなく明示的に指定した構成にする必要があります。

まずは構成マネージャを開いてプラットフォームを編集します。
file

file

とりあえず今回はx86を新規で追加。
file

このように構成を変更してビルドすると、出力の構成は以下のように変化してライブラリを正しく読み込めるようになります。

  • x86
    • Debug
      • bass.dll
      • App.exe

マネージャを作る

以上の手順を音源の再生ごとに繰り返すのは面倒なので、処理をラップするクラスを作ります。
ついでにIDisposableを実装してリソースの解放処理も記述します。

public class SoundManager : IDisposable
{
    private readonly HashSet<int> playing = new HashSet<int>();

    public bool IsSupported { get; } = true;

    public SoundManager()
    {
        if (!Bass.BASS_Init(-1, 44100, BASSInit.BASS_DEVICE_DEFAULT, IntPtr.Zero))
        {
            IsSupported = false;
        }
    }

    public void Dispose()
    {
        if (!IsSupported) return;
        Bass.BASS_Stop();
        Bass.BASS_PluginFree(0);
        Bass.BASS_Free();
    }

    protected int GetHandle(string filepath)
    {
        int handle = Bass.BASS_StreamCreateFile(filepath, 0, 0, BASSFlag.BASS_DEFAULT);
        if (handle == 0) throw new ArgumentException("cannot create a stream.");
        return handle;
    }

    public void Play(string path)
    {
        int handle = GetHandle(path);
        lock (playing) playing.Add(handle);
        Bass.BASS_ChannelPlay(handle, false);
    }

    public void StopAll()
    {
        CheckSupported();
        lock (playing)
        {
            foreach (int handle in playing)
            {
                Bass.BASS_ChannelStop(handle);
            }
            playing.Clear();
        }
    }

    protected void CheckSupported()
    {
        if (IsSupported) return;
        throw new NotSupportedException("The sound engine is not supported.");
    }
}

外部からはPlayで音源を再生し、StopAllで停止します。

同時に複数の音源を再生する

このクラスで音声の再生は可能になりますが、自分が作っている音ゲー譜面作成ツールにおいてはノーツ密度が高い部分で同時に複数の音源を再生する必要が出てきます。
さらに先ほどのクラスではPlayを呼び出すたびにハンドルを生成しているためにどんどんリソースを消費していく実装となっています。
以上の点を改善するために、既存のハンドルを使いまわす形に修正を行います。

ハンドルを使いまわす

ハンドルを使いまわすとは言うものの、常に使いまわせるとは限りません。
例えばある音源の再生が終わらない状態で同じ音源を別途再生する場合、新たなハンドルを取得する必要があります。

イメージとしてはこんな感じ。

音源Aは独立して再生されるため共通のハンドルを使い回すことができますが、音源Bは重複して再生されるため単一のハンドルでは足りません。

というわけで、音源に対応したハンドルを保持しつつ必要に応じてハンドルを作成するようにSoundManagerを書き換えていきます。

まずは取得したハンドルと対応したファイルを管理するDictionaryを追加します。

private readonly Dictionary<string, Queue<int>> handles = new Dictionary<string, Queue<int>>();

ここでValueにQueueを指定しているのは使い回し可能なハンドルを保持するためです。

再生終了を検知する

そもそもハンドルを使い回すためには、音源の再生が完了してハンドルが再利用可能になったことを知る必要があります。
これはBASS_ChannelSetSyncBASSSync.BASS_SYNC_ENDを指定して以下のように実現できます。

int handle = GetHandle(path);

var proc = new SYNCPROC((h, channel, data, user) =>
{
    // 再生終了時コールバック
});

int syncHandle = Bass.BASS_ChannelSetSync(handle, BASSSync.BASS_SYNC_END, 0, proc, IntPtr.Zero);
if (syncHandle == 0) throw new InvalidOperationException("cannot set sync");

SoundManagerを書き換える

ここまでの内容でSoundManager.Playを書き換えていきます。

public void Play(string path)
{
    Queue<int> freelist;
    lock (handles)
    {
        if (!handles.ContainsKey(path)) handles.Add(path, new Queue<int>());
        freelist = handles[path];
    }

    int handle;
    lock (freelist)
    {
        if (freelist.Count > 0) handle = freelist.Dequeue(); // ハンドルを使い回す
        else
        {
            // ハンドルを新規に取得する
            handle = GetHandle(path);

            var proc = new SYNCPROC((h, channel, data, user) =>
            {
                lock (freelist) freelist.Enqueue(handle);
            });

            int syncHandle = Bass.BASS_ChannelSetSync(handle, BASSSync.BASS_SYNC_END, 0, proc, IntPtr.Zero);
            if (syncHandle == 0) throw new InvalidOperationException("cannot set sync");
        }
    }

    lock (playing) playing.Add(handle);
    Bass.BASS_ChannelPlay(handle, false);
}

一気に行数が増えましたが、ファイルに対応したキューを引っ張ってから利用できるハンドルが存在していれば使い回し、存在しなければ新規作成しています。
また、非同期に呼ばれてもスレッドセーフになるように適宜lockを挟んでいます。

GCの魔の手から逃れろ!

さて、ここまでの修正で前述した要求は満たせるようになったのですが、普通に動かしているとこんな感じになります。

何が起きたかというと、再生終了を検知するためにセットしたデリゲートが、
GCにより回収された状態で呼び出されて例外に。

これを回避するためにはデリゲートがGCに回収されないようにする必要があります。
手っ取り早くGCの対象外にするためには、デリゲートへの参照をフィールドで保持しておけばOKです。
というわけで、デリゲートの生成時にコレクションに突っ込んでおきます。

private readonly HashSet<SYNCPROC> syncProcs = new HashSet<SYNCPROC>();

public void Play(string path)
{
    // (中略)
            handle = GetHandle(path);

            var proc = new SYNCPROC((h, channel, data, user) =>
            {
                lock (freelist) freelist.Enqueue(handle);
            });

            int syncHandle = Bass.BASS_ChannelSetSync(handle, BASSSync.BASS_SYNC_END, 0, proc, IntPtr.Zero);
            if (syncHandle == 0) throw new InvalidOperationException("cannot set sync");
            lock (syncProcs) syncProcs.Add(proc); // 追加
    // (後略)
}

おわりに

BASS Audio Libraryを使っていい感じに音声を再生できるマネージャを作成しました。
ここで作成したクラスはこちらの某スライドしてヘドバンする音ゲー譜面作成ツールに組み込んで、再生プレビュー機能に使ったりしています。
このツールではもともとNAudioを採用していたのですが、プロセスを巻き込んで落ちる不具合に長いこと悩まされたことがありBASSへの移行を決めたという過去があったりします。どうあがいても原因が突き止められなくて本当に大変でした……。
ちなみにリンク先のバージョンでは若干差異がありますが、再生プレビュー実装の都合で任意のオフセット位置から再生したり、音源の長さを取得できるようになってたりします。

Leave a Comment

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です