DirectSoundとRubyのプログラミング

サウンドAPIは色々あるが、バッファをロックして書き込んで再生すれば音が出るという部分についてはどれも大きな違いは無い。WindowsVista以降ならWASAPIというオーディオAPIがあり、色んな機能があって優秀なのだが、このへんのOS上ではDirectSoundはWASAPIをラップしたDirectSoundエミュレータという形で実装されているので簡単なものならDirectSoundを使っても問題は無い。むしろWineでどこまでWASAPIが実装されているのかがよくわからないのでDirectSound使っておいたほうが心配事が減る。
今回はDirectSoundを使って音を出すRubyの拡張ライブラリを作ってみようという企画である。

途中まで書いて疲れてきたので途切れているが、せっかく書いたのでアップしておく。

とりあえず作る

こんな感じに最低限の部分を作る。soundtest.cとする。

#include "ruby.h"
#include "dsound.h"

/* mingw64になぜか定義されてないので自前で定義 */
#ifndef DS3DALG_DEFAULT
GUID DS3DALG_DEFAULT = {0};
#endif

static VALUE mSoundTest;
static LPDIRECTSOUND8 pDSound;
static LPDIRECTSOUNDBUFFER8 pDSBuffer8;

static unsigned long datasize;

static VALUE SoundTest_play(VALUE klass)
{
    return Qnil;
}

static void Init_soundtest_module(void)
{
    /* SoundTestモジュール生成 */
    mSoundTest = rb_define_module("SoundTest");
    rb_define_singleton_method(mSoundTest, "play", SoundTest_play, 0);
}

static void SoundTest_shutdown(VALUE obj)
{
    pDSBuffer8->lpVtbl->Stop(pDSBuffer8);
    pDSBuffer8->lpVtbl->Release(pDSBuffer8);
    pDSound->lpVtbl->Release(pDSound);
    CoUninitialize();
}

void Init_soundtest(void)
{
    HWND hWnd;
    HINSTANCE hInstance;
    WNDCLASSEX wcex;
    DSBUFFERDESC desc;
    WAVEFORMATEX pcmwf;
    LPDIRECTSOUNDBUFFER pDSBuffer;

    /* COM初期化 */
    CoInitialize(NULL);

    /* ウィンドウクラス設定 */
    hInstance = (HINSTANCE)GetModuleHandle(NULL);
    wcex.cbSize        = sizeof(WNDCLASSEX);
    wcex.style         = 0;
    wcex.lpfnWndProc   = DefWindowProc;
    wcex.cbClsExtra    = 0;
    wcex.cbWndExtra    = 0;
    wcex.hInstance     = hInstance;
    wcex.hIcon         = 0;
    wcex.hIconSm       = 0;
    wcex.hCursor       = 0;
    wcex.hbrBackground = 0;
    wcex.lpszMenuName  = NULL;
    wcex.lpszClassName = "SoundTest";

    /* ウィンドウ生成 */
    RegisterClassEx(&wcex);
    hWnd = CreateWindow("SoundTest", "", 0, 0, 0, 0, 0, 0, NULL, hInstance, NULL);

    /* DirectSoundオブジェクト生成 */
    DirectSoundCreate8(&DSDEVID_DefaultPlayback, &pDSound, NULL);

    /* 協調レベル設定 */
    pDSound->lpVtbl->SetCooperativeLevel(pDSound, hWnd, DSSCL_PRIORITY);

    /* フォーマット設定 */
    pcmwf.wFormatTag = WAVE_FORMAT_PCM;
    pcmwf.nChannels = 1;                                              /* モノラル */
    pcmwf.nSamplesPerSec = 44100;                                     /* 44.1kHz */
    pcmwf.wBitsPerSample = 16;                                        /* 16bit */
    pcmwf.nBlockAlign = pcmwf.nChannels * pcmwf.wBitsPerSample / 8;   /* 1sampleで2byte */
    pcmwf.nAvgBytesPerSec = pcmwf.nSamplesPerSec * pcmwf.nBlockAlign; /* 1secに必要なbyte数 */
    pcmwf.cbSize = 0;

    /* DirectSoundバッファ生成 */
    desc.dwSize = sizeof(desc);
    desc.dwFlags = DSBCAPS_GLOBALFOCUS | DSBCAPS_GETCURRENTPOSITION2;
    desc.dwBufferBytes = pcmwf.nAvgBytesPerSec * 1; /* 1secぶんのバッファを要求 */
    desc.dwReserved = 0;
    desc.lpwfxFormat = &pcmwf;
    desc.guid3DAlgorithm = DS3DALG_DEFAULT;

    pDSound->lpVtbl->CreateSoundBuffer(pDSound, &desc, &pDSBuffer, NULL);
    pDSBuffer->lpVtbl->QueryInterface(pDSBuffer, &IID_IDirectSoundBuffer8, (void**)&pDSBuffer8);
    pDSBuffer->lpVtbl->Release(pDSBuffer);

    datasize = desc.dwBufferBytes;

    /* SoundTestモジュール生成 */
    Init_soundtest_module();

    /* 終了時に実行する関数 */
    rb_set_end_proc(SoundTest_shutdown, Qnil);
}

このコードはRubyの拡張ライブラリとして作ってあるのでRubyInstallerでRubyをインストールしてDevkitも入れればmingw64でビルドできる。mingw64にはDirectXのライブラリ群も組み込まれている。便利である。
DirectSoundはウィンドウハンドルを要求するので見えないウィンドウを適当に作っている。んでDirectSoundオブジェクトを作って、1秒分のバッファを作って、RubyのSoundTestモジュールを作る。終了時に解放する。SoundTestモジュールはplayメソッドを持ってはいるが、今のところ呼んでも何も起こらない。面倒だったのでエラーチェックを省いているが、DXRubyで音が出る環境なら動くんじゃないかと思う。
以下のソースをextconf.rbとして保存して、ruby extconf.rbとしてmakeすればsoundtest.soが出来上がる。

require "mkmf"

SYSTEM_LIBRARIES = [
  "dxguid",
  "dsound",
  "gdi32",
  "ole32",
  "user32",
  "kernel32",
]

SYSTEM_LIBRARIES.each do |lib|
  have_library(lib)
end

have_header("dsound.h")

create_makefile("soundtest")

同じディレクトリに以下のRubyコードを置いて実行できる。

require_relative 'soundtest'
p SoundTest.play #=> nil

この段階では初期化して終了するだけだが、例えばSoundTest_playメソッドを以下のように書き換えてやるとSoundTest.playで音が出るようになる。

static VALUE SoundTest_play(VALUE klass)
{
    unsigned short *block1 = NULL;
    unsigned short *block2 = NULL;
    unsigned long blockSize1 = 0;
    unsigned long blockSize2 = 0;
    unsigned long i;

    /* バッファロック */
    pDSBuffer8->lpVtbl->Lock(pDSBuffer8, 0, datasize, (void**)&block1, &blockSize1, (void**)&block2, &blockSize2, DSBLOCK_ENTIREBUFFER); 

    for(i = 0; i < datasize / 2; i++) {
        *(block1 + i) = sin(2 * 3.14 * i / (44100.0f / 440.0f)) * 32767;
    }

    /* バッファアンロック */
    pDSBuffer8->lpVtbl->Unlock(pDSBuffer8, block1, blockSize1, block2, 0);
    pDSBuffer8->lpVtbl->Play(pDSBuffer8, 0, 0, 0);
    return Qnil;
}
require_relative 'soundtest'
SoundTest.play
sleep(1)

Rubyで波形を作る

上記のコードは別にRubyから呼ぶようにしなくても、音を出すだけならCのプログラムとして作ればちょっとだけ簡単になるはずなのだが、わざわざRubyの拡張ライブラリとして作った理由は、Rubyで波形データを作るためである。
SoundTest_playを以下のように書き換えて、Rubyからブロックを受け取るようにする。

static VALUE SoundTest_play(VALUE klass)
{
    unsigned short *block1 = NULL;
    unsigned short *block2 = NULL;
    unsigned long blockSize1 = 0;
    unsigned long blockSize2 = 0;
    unsigned long i;

    /* バッファロック */
    pDSBuffer8->lpVtbl->Lock(pDSBuffer8, 0, datasize, (void**)&block1, &blockSize1, (void**)&block2, &blockSize2, DSBLOCK_ENTIREBUFFER);

    for(i = 0; i < datasize / 2; i++) {
        *(block1 + i) = NUM2DBL(rb_yield(INT2NUM(i))) * 32767; /* ここが違う */
    }

    /* バッファアンロック */
    pDSBuffer8->lpVtbl->Unlock(pDSBuffer8, block1, blockSize1, block2, 0);
    pDSBuffer8->lpVtbl->Play(pDSBuffer8, 0, 0, 0);
    return Qnil;
}

ループ変数のiをRubyのブロックに渡し、返ってきた値をdoubleにして32767倍してバッファに書き込む。バッファに書くデータをRubyのブロックからもらう形だ。
Ruby側はSoundTest.playにブロックを渡し、ブロック引数でカウントiを受け取って、そのタイミングのデータを計算して-1〜1で返してやる。
こんな感じ。

require_relative 'soundtest'
SoundTest.play do |i|
  Math.sin(2 * 3.14 * i / (44100.0 / 440.0))
end
sleep(1)

以下のようにすると矩形波を出力できる。

require_relative 'soundtest'
SoundTest.play do |i|
  if i % 100 > 50
    1
  else
    -1
  end
end
sleep(1)

拡張ライブラリ化するとこういうのが楽になるわけだな。
この例の拡張ライブラリは単純なモジュールにしているが、これをクラスにしてオブジェクトごとにデータを持つようにしたのがDXRubyのSoundEffectである。
ところでこのコードの実行時間を見てみると1秒分のデータ生成で25ms程度なので、簡単な音ならリアルタイムで生成できるかもしれない。

バッファとストリーミングの話

44.1kHz16bitモノラルのサウンドデータを1秒分作ると44100*2=88200byteとなる。音楽は分単位の長さになると生データで数十MBとかいうサイズになってくる。こういう場合、ある程度のサイズのバッファを作っておいて、半分にカットし、再生が半分まで進んだら終わった部分を捨てて新しいデータを書き込む、ということをする。サウンドAPIはバッファをループ再生できるのでチクタクバンバン(古い)のように先のデータを随時生成してやることで小さなバッファでも大きな音楽を再生することができる。
半分まで進んだということを検知するためには、ポーリングとイベントという2種類の手法があり、ポーリングはなんかのタイミングでどこまで進んだかを見に行くやり方で、イベントは別スレッドを作って眠らせておいて、サウンドAPIからのイベント送信で起こす、ということをする。

でもめんどくさくなってきたのでここまで。続きはたぶん書かないと思う。