Windowsで鳴ってる音声をキャプチャし、androidに送って再生するプログラムを作ってみました。
#AudioStreamPlayer
WindowsのWASAPI(Windows Audio Session API)を使ってWindows上で再生している音声をキャプチャし、ソケットでandroidデバイスに送ります。データはADB(Android Debugger Bridge)のadb forwardコマンドでAndroidデバイスに転送します。受け取った音声データをAndroidのAudioTrackクラスで再生します。
そのままだとWindowsのほうからも同じ音が鳴ることになるので、オーディオジャックにダミーのプラグを挿してスピーカを無音にし、使用することを想定しています。
ソースコードはこちら。
Windows10及びSONY Walkman F886で動作確認しています。
#つかいかた
adbを使用するため、前提としてAndroid SDKがインストールされている必要があります。
- adb forward コマンドを実行します。
例:adb forward tcp:12345 tcp:9876
転送元、転送先の順で、ポート番号は任意です。 - Android側のアプリを実行し、スタートボタンを押してソケットの接続を待ち受けます。AudioTrackのバッファサイズ(実装上遅延時間とほぼ同じ)はデフォルトではgetMinBufferSize関数の返す値をそのまま使います。任意の値もミリ秒単位で設定可能ですが、getMinBufferSizeより小さい値は受け付けません(理由は後述)。
- Windows側でloopback-capture.exeを実行します。引数なしの場合、デフォルトのlocalhost:12345を使います。
※-? オプションで使用方法を出力します。任意のホスト名を使えるようにしていますが、localhostに送ったパケットをadb forwardで転送することを想定しています。
※エミュレータ画面のキャプチャです。
#モチベーション
オーディオマニアというほどではないですが、ノートパソコンを使う時とかにも手軽にいい音で聴きたいという思いがあり、手持ちのWalkman F886をUSB DACみたいにして使えないかなーとずっと思っていました。色々調べたんですが、出てくるのはAndroidデバイスのオーディオデータをUSB DACで再生するトランスポート的な使用法ばかり。そうじゃない、俺がやりたいのはその逆なんだ。ということで自分で作ってみることにしました。
検討した実装方法は以下のとおりです。
- USB DACの機能をエミュレートする
一番理想的な実装です。ただ、デフォルトでAndroid OSが持っているUSBペリフェラル機能はMass Storageやデバッグ用の機能だけです。Android OSの元であるLinuxにはUSBペリフェラルの機能を提供するUSB Gadget API というのがあるようなのですが、デフォルトでは無効でカーネルのコンパイルが必要となります。Walkmanでの実装を目指しているので、この方法は取れません。
色々調べてみると、USB Gadget ConfigFSというユーザレベルでUSBペリフェラル機能を開発できるAPIがあるようですが、Linuxのカーネル3.11以上の対応となり、手持ちのWalkman F886では対応していない・・・と思っていたので一旦この方法は見送ったんですが、Android 4.1.1なので丁度カーネル3.11になるのかな?勘違いしていたようです。気が向いた時にまた調べてみようと思います。もしアイソクロナス転送を使えるようなら非常に魅力的ですね。 - AOA(Aondroid Open Accessory)プロトコルを使う
USB AccessoryというモードでUSBペリフェラルの機能を実装する方法です。厳密にはまずUSBデバッグモードで接続し、AOAプロトコルに対応しているのを確認後USB Accessoryモードになるようです。libusb等を使って実装することになりますが、自分の理解ではWindowsではlibusbをドライバとしてインストールすると他のドライバが使えない(USBデバッグモード等が使用できなくなる)のでこの方法も使えません。 - adb forwardコマンドでデータを転送する
今回採用した方法です。adb forwardコマンドを使って、localhostに送られたデータをandroidに転送します。adbを使わなければならないのがネックですが、データ通信はソケットで行えばいいので簡単です。
#実装の概要
- WindowsにおけるWASAPIを用いた音声キャプチャ
WASAPIのIAudioCaptureClientを使用して、共有モードの音声をキャプチャします。
キャプチャ用のスレッドが音声データをキャプチャした後シングルトンのリングバッファに逐次書き込み、それをソケット通信用スレッドが読みだしてlocalhostの任意のポートに送信します。(データはadb forwardでandroidに転送します。)
キャプチャするタイミングはイベント駆動とタイマ駆動がありますが、なぜかキャプチャモードではイベントが発生しないらしくdeviceperiod(オーディオデバイスがひとまとめで処理する単位。この間隔ごとにIAudioCaptureClientのエンドバッファにデータが書き込まれる)の半分の時間でのタイマ駆動となっています。
共有モードでは、各音声データはオーディオエンジンでmixされたあとfloat32bitのデータになりますが、Android4.1.1(API LEVEL 16)ではfloat32bitのオーディオに対応していないので整数データに変換する必要があります。幸い、IAudioCaptureClient::GetMixFormatが返すWAVEFORMATEX構造体の各メンバに16bit整数データ用の値をセットすると自動で変換してくれるらしいので、それを利用しています。
ほぼ丸々MicrosoftのMatthew van Eerde氏のソースコードを使用させていただきました。ソケット通信等、必要な機能を付け足しただけです。
元のコードではIAudioCaptureClient::GetBufferがDISCONTINUITYまたはSILENTフラグを返すと終了していましたが、無視するように変更しています。氏のブログでは無音のストリームを流す回避方法が紹介されていましたが、何故かヘッドホン出力においては問答無用でSILENTフラグが返されるためです。音声データ自体はきちんと無音のデータが取得できているので問題ないかと思います。 - Androidにおける再生
受信したデータをAudioTrackクラスで再生します。ソケット通信用スレッドが受信したデータをシングルトンのリングバッファに書き込み、AudioTrack処理用スレッドが読みだしてAudioTrackに書き込み、音声を再生します。
16bitの場合、キャプチャされるデータはリトルエンディアンになるので、Android側でビッグエンディアンに変換しています。
AudioTrackにはonPeriodNotificationという再生中一定間隔のフレームに達すると呼ばれるコールバックメソッドがあるため、そこで逐次AudioTrackに音声データを書き込んでいます。間隔は、データが送られてくる間隔(=Windows側のdeviceperiod)の倍数で、AudioTrackのバッファサイズの1/3を超えない最大の値に設定しています。
最初は受信したデータをすべてHandler::sendMessageで送りつけていましたが、どうも重かったらしく時々ピーとかガーみたいな音になるので今の実装に変更しています(HandlerThreadを使用しているのはその名残です)。
#ハマったところ・Tips
- 自分が実験した限りでは、adb forwardを利用したソケット通信において任意のサイズで転送を繰り返そうとするとクラッシュしてしまいました。転送のバイト数を2のべき乗にするとうまくいったので、そのようにしています。
- onPeriodNotificationが呼ばれるためには、最低1度はgetMinBufferSizeで得られる値分データを書き込む必要があるようです。よってバッファサイズはこれ以上の値である必要があります。
- adb forwardによる転送はバルク転送と思われるので、どうしても遅延の発生を排除できません。よってAudioTrackに書き込んだデータ量を記録しておき、次のonPeriodNotificationが呼ばれる量が書きこまれるように保証しています。
- AudioTrackへの入力データは8bitの場合byte配列、16bitの場合はshort配列である必要があります。AudioTrackを16bitで初期化してbyte配列を入力してもエラーにはなりませんが音は小さくて聴こえません。面倒なので本プログラムでは16bitのみ対応です。
- WASAPIのキャプチャにおいて、float32bitからint16bitに変換を行うと、マスタボリュームがバイパスされるようです。
- Notificationについて調べるとインスタンス取得でコンストラクタを使う紹介記事が多かったですが、今はdeprecatedになっており現在はNotification.Builder(より古いAPIに対応させるなら NotificationCompat.Builder)による取得が推奨されているようです。
- 実装とは関係ないですが、AndroidStudioのinstant runという機能のおかげでデバッグのためのコードが反映されず、書いたコードとまったく違う結果が出たりそもそもブレイクポイントが無視されたりと気づくまで頭を悩ませました。きちんと使えば便利なんでしょうが。
#感想
そこそこ使えるくらいにはなったんじゃないかと思います。ただ、バルク転送のためコンディションによっては結構音飛びが発生するので、完全に実用的とは言い難いですね。バッファを大きくとればある程度改善すると思いますが、その分遅延は大きくなります。また、根本的に解決するわけではないので、やはりアイソクロナス転送を使いたいところです。
あと、adbを使わなければいけないのも面倒ですね。やはりUSB Audioの機能が実装できれば、それが一番いいと思います。
何かしらネタ提供ができていれば幸いです。