Go言語で DirectSound を Cgo なしで使えるようにした

Go言語の Windows 版で DirectSound を使うためのラッパーを Cgo を使わずに拵えてみた話。

最近だとゲーム用途なら XAudio2、ASIO 的なものを求めたりステレオミキサー的なものを使いたい場合は Windows Core Audio を使うのがナウいらしいのだが、取りあえず Go のバイナリが動く環境全てでサポートされている DirectSound に手を付けてみることにした。MME でもよかった気もするけど。
XAudio2 は XP と Vista 以降で使えるバージョンが若干違うっぽくて面倒そうだったのでひとまず避けた。

アプローチとしてはC言語用ヘッダファイルをある程度そのまま使えると思われる Cgo 経由が簡単でいいんだろうと思うけど、Windows 版では syscall から LoadLibrary を直接読んだりできるのであえてその方向で進めてみることにした。

https://github.com/oov/directsound-go
http://godoc.org/github.com/oov/directsound-go/dsound
使うのだけが目的だったらこちらからどうぞ。

DirectSound を使うまでの流れ
  1. dsound.dll を LoadLibrary で読み込む
  2. DirectSoundCreate のアドレスを GetProcAddress で取得
  3. DirectSoundCreate を呼んで IDirectSound のインターフェイスを取得
  4. IDirectSound からプライマリ/セカンダリバッファ(IDirectSoundBuffer)を作成
  5. 好きに使う
1. dsound.dll を LoadLibrary で読み込む

syscall.LoadLibrary を直接呼ぶのもいいけど、その場合 syscall.Syscall や syscall.Syscall6 など引数の個数に応じて呼ぶメソッドを変えたり、引数の個数自体も渡さないといけなかったりして少し煩雑な感じもある。

以下は user32.dll にある GetDesktopWindow を呼び出す例。
package main

import (
 "fmt"
 "syscall"
)

func viaSyscall() {
 user32, err := syscall.LoadLibrary("user32")
 if err != nil {
  panic(err)
 }

 GetDesktopWindow, err := syscall.GetProcAddress(user32, "GetDesktopWindow")
 if err != nil {
  panic(err)
 }

 // ここの呼び出し時のパラメータが煩雑
 h, _, err := syscall.Syscall(GetDesktopWindow, 0, 0, 0, 0)
 fmt.Println("Syscall: Handle:", h, "Error:", err)
}

func viaCall() {
 user32, err := syscall.LoadDLL("user32")
 if err != nil {
  panic(err)
 }

 GetDesktopWindow, err := user32.FindProc("GetDesktopWindow")
 if err != nil {
  panic(err)
 }

 // こっちだと少しスッキリ
 h, _, err := GetDesktopWindow.Call()
 fmt.Println("Call: Handle:", h, "Error:", err)
}

func main() {
 viaSyscall()
 viaCall()
}
少しのオーバーヘッドでこれを解決してくれる syscall.DLL や遅延ロードができる syscall.LazyDLL があって、こっちでは syscall.Proc という型にある Call というメソッドが可変長引数で引数の個数に関連する面倒な問題を解決してくれるので、今回はこっちを使うことにする。
あとで出てくるインターフェイスのメソッドも uintptr ではなく実際には comProc という型にして内部ではほぼ同じ感じで使っている。

2. DirectSoundCreate のアドレスを GetProcAddress で取得

ここは特に難しいことはなくて上の例とほぼ同じ流れ。
なんで項目分けたんだろう。

3. DirectSoundCreate を呼んで IDirectSound のインターフェイスを取得

DirectX 関連は基本的には COM の仕様に基いて実装されているのでこれに従って呼び出す必要があるのだが、この辺あまり詳しくはないものの dsound.dll から直接 DirectSoundCreate などを呼ぶ場合は CoCreateInstance を経由しないので、恐らく CoInitialize や CoUninitialize も必要なく、実際のところ呼ばなくても動いているので今回は初期化はすっ飛ばした。
(どうせ初期化が必要だとしてもライブラリを使う側のコードでやるべきだと思われる)

で、初期化を省略すると残るのは DLL 内のメソッドを呼んでポインタを取得するだけになるのでまた上の例と似た感じになる。ただし受け取るのは IDirectSound のインターフェイスへのポインタになっているのでインターフェイスのメソッドを呼べるようにするためにはもう一手間掛かる。
その辺に関しては次の項目に書く。

4. IDirectSound からプライマリ/セカンダリバッファ(IDirectSoundBuffer)を作成

go-ole の IUnknown 周辺見ると凄くわかりやすいけど、基本的に COM のインターフェイスは vtable へのポインタを持った構造体がまずあって、vtable の中にはひたすら関数ポインタが予め定義されているインターフェイスの順番に沿って並んでいるだけで、これ自体はあまり複雑な構造ではない。
type IUnknown struct {
 lpVtbl *pIUnknownVtbl
}

type pIUnknownVtbl struct {
 pQueryInterface uintptr
 pAddRef         uintptr
 pRelease        uintptr
}
ただこれだけだとそれぞれの関数の呼び出しに必要な引数の数や型の情報は一切ないので、その辺は補う必要がある。タイプライブラリのインポートが Go 言語でできれば COM 周りの呼び出しはある程度楽になるんだろうけど(昔 Delphi でインポートした時はわりと感動したし)、現状そういったものはないと思うしひとまず手書きで頑張る。

まず COM のインターフェイスを呼び出す時の第一引数は this 相当のポインタを渡す必要がある。
今回で言えば DirectSoundCreate の戻り値だし、上の例だと *IUnknown が this 相当になる。
これが原因で実際にメソッドに渡さなければいけない引数の個数はリファレンスマニュアルに書かれている引数のリスト +1 が正しい数になるので、syscall.Syscall で呼び出していると個数を間違いやすく面倒くさいし実際何度か間違ったりもしたので syscall.LoadDLL を選んだのだった。

あと Go 言語は戻り値を複数返せる関係上、インターフェイスの元々の仕様ではポインタ渡しになっている引数の多くは戻り値として返せたり、配列の長さとポインタを別々に渡すような箇所もスライスで渡したい部分なのでこの辺は Go 言語らしく呼べるように少し整理した。
cbSize に構造体の大きさを sizeof で取得した値を代入してから関数を呼ぶようなものに関してもできる限りラッパー側で必要な値を入れて呼ぶ形にしてみた。

http://godoc.org/github.com/oov/directsound-go/dsound#IDirectSound.CreateSoundBuffer
こんな感じのパラメータリストになった。

実際の使いかたは DirectSound のリファレンスマニュアルを読めばわかると思う。

5. 好きに使う

https://github.com/oov/directsound-go/blob/master/example/main.go
サンプルとして IDirectSoundNotify を使ってストリーミングで Wave ファイルを無限ループ再生しつつ、そこの同じバッファ上に適当なアルペジエータで生成したサイン波の音を合成して再生する処理を書いてみた。
Wave ファイルは全然真面目に読んでないし長時間聴いてると多分誤差の蓄積で少しずつ発音タイミングずれてくるのであんまり長く聴かないでください。お願いします。お願いします。

GOMAXPROCS が 1 でも WaitForMultipleObjects で止まっている時間が短めでたまにメイン処理に戻ってくるので取りあえずは問題はないみたいだけど、真面目に使うなら GOMAXPROCS は 2 以上にした方がいいのかも知れない。

一応 exe ファイルも用意した。これは試しに Ubuntu 上でクロスコンパイルしたもの。
http://test.oov.ch/junk/dsound_example.zip

----

キャプチャ周りは未着手だけど再生周りとほとんど同じだったような気がするので気が向いたらやりたい。