1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

AudioTrackでバックグラウンドでも音飛びせずに音楽をストリーミング再生する方法

Posted at

はじめに

昨年(2021)末、Androidで独自に波形生成した音楽を再生してバックグラウンド再生にも対応するアプリ(東方BGM on VGS)を作り直した際に、波形データの再生にAudioTrackを使ってみたのですが、似たようなアプリを開発する方のために参考情報として要点を記します。

「独自に波形生成した音楽を再生」するアプリを作る人は稀だと思います。というのも、私が東方VGSを最初に公開した2013年春先以来(そろそろだいたい丸9年間ぐらい?)、少なくとも私が知る範囲では類似アプリは存在しなかったので。

ですが、バックグラウンドで音楽をストリーミング再生するアプリを作っておられる方々にとっては若干有益な情報かもしれません。

なお、アプリの公開が完了したらソースコードも全部OSSで公開しようと思っています。このアプリをコードベースにすれば、色々なVGS楽曲アプリの開発が低コストでできるので、あとは楽曲データ作成に掛かる作業コスト分をペイできる程度のシロモノであれば楽曲の版権を抑えている各社で自由にお小遣い稼ぎして頂ければ良いかな...という青写真を勝手に描いているのですが、その辺の妄想はとりあえずさておき、現実的な諸般の事情(米国税務関連の審査や一度プログラムポリシー違反で削除されたアプリの再審査対応等)で、公開まで少し時間が掛かりそうなので、先に要素技術だけ解説してみます。(10周年を迎える前には何とか再公開できるようにがんばります :bow:

AudioTrackの使い方

以下、AudioTrackを使っている部分の実装をかいつまんで掲載します。
要点毎の細かい解説を後述します。

MusicManager.kt
// : (一部省略)
class MusicManager {
    // : (一部省略)
    private var audioTrack: AudioTrack? = null
    // 【要点1】 バッファリングサイズの設定
    private val basicBufferSize = 4096
    private var decodeAudioBuffers = arrayOf(
        ByteArray(basicBufferSize),
        ByteArray(basicBufferSize)
    )
    private var decodeAudioBufferFirst = ByteArray(basicBufferSize * 2)
    private var decodeFirst = true

    // : (一部省略)

    private fun createAudioTrack() {
        audioTrack?.release()
        val attributes = AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_MEDIA)
            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
            .build()
        val format = AudioFormat.Builder()
            .setSampleRate(22050)
            .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
            .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
            .build()
        audioTrack = AudioTrack.Builder()
            .setAudioAttributes(attributes)
            .setAudioFormat(format)
            .setBufferSizeInBytes(basicBufferSize * 2)
            .setTransferMode(AudioTrack.MODE_STREAM)
            .build()
        audioTrack?.positionNotificationPeriod = basicBufferSize / 2 // 【要点2】コールバック間隔
        audioTrack?.setPlaybackPositionUpdateListener(object :
            AudioTrack.OnPlaybackPositionUpdateListener {
            override fun onMarkerReached(audioTrack: AudioTrack?) {
            }

            override fun onPeriodicNotification(p0: AudioTrack?) {
                // 【要点4】バッファリング処理
                audioTrack?.write(
                    decodeAudioBuffers[decodeAudioBufferLatch],
                    0,
                    decodeAudioBuffers[decodeAudioBufferLatch].size
                )
                decodeAudioBufferLatch = 1 - decodeAudioBufferLatch
                decode(decodeAudioBuffers[decodeAudioBufferLatch])
            }
        })
        // 【要点3】初回バッファリング
        decode(decodeAudioBufferFirst)
        audioTrack?.write(decodeAudioBufferFirst, 0, decodeAudioBufferFirst.size)
        audioTrack?.play()

        // 次回バッファを事前にデコード
        decodeAudioBufferLatch = 0
        decode(decodeAudioBuffers[decodeAudioBufferLatch])
    }
}

decode というメソッドの実装を省略してますが、コレは JNI で、指定した ByteArray サイズ分の波形をデコードするものです。

以下、要点ごとの解説です。

【要点1】 バッファリングサイズ

    private val basicBufferSize = 4096
    private var decodeAudioBuffers = arrayOf(
        ByteArray(basicBufferSize),
        ByteArray(basicBufferSize)
    )
    private var decodeAudioBufferFirst = ByteArray(basicBufferSize * 2)
    private var decodeFirst = true

basicBufferSize

まずバッファリングサイズの基本となるコールバック毎のバッファリングサイズは 4096 バイトにしました。
これが短すぎると、バックグラウンドへ移行した際に音飛びします。

フォアグラウンド前提であれば 736 バイト (22050Hz 1ch 16bit で約1/60秒間隔) 程度まで下げても大丈夫でしたが、バックグラウンド対応するのであれば 2048 バイト(約46.44ms)程度必要で、安全を見てその倍(4096 バイト ≒ 92.88ms)にしてみました。

これでだいたいの環境で大丈夫...な筈。

知人から Android 11, 12 (実機) を借りてテストした限り、バックグラウンドでも音飛びせずにストリーミング再生できていたのですが、リリースするとどうなるかは未知数...この辺のサイズ設定が「いくつぐらいなら安定する」みたいなテクニカル情報が公式SDKドキュメントに書かれていなくて、数字を色々と細かく弄りながらトライアンドエラーで調べるしかなく面倒でした。(ググってみたところ幾らか情報はありましたが何故そのサイズなのかという根拠というか、本当に機種依存せずに音飛びしないと確証できる情報は見つかりませんでした)

decodeAudioBuffers

実際にコールバック時のバッファリング用のバッファはダブルバッファにしています。
この点は要点3〜4の解説で詳述します。

【要点2】 コールバック間隔

        audioTrack?.positionNotificationPeriod = basicBufferSize / 2

コールバック間隔は positionNotificationPeriod でポジション単位での指定になります。
つまり、16bit PCM であれば ÷2、ステレオであれば更に ÷2 をする必要があります。
今回は 16bit モノラル の PCM なので ÷ 2 にしています。

【要点3】 初回バッファリング

        // 【要点3】初回バッファリング
        decode(decodeAudioBufferFirst)
        audioTrack?.write(decodeAudioBufferFirst, 0, decodeAudioBufferFirst.size)
        audioTrack?.play()

初回のバッファリングでは、通常のバッファ×2のサイズをバッファリングしてから play します。

    private var decodeAudioBufferFirst = ByteArray(basicBufferSize * 2)

こうしないとバッファリング処理のタイミングでホワイトノイズが入ってしまいます。

ただし、バッファリングが多すぎても色々と問題があります。ちょうど良い塩梅だったのが x2 〜 x3 ぐらいという感じだったので、なんとなく x2 を採用。(AudioTrackで同じような手順を解説しているサイトだと x3 を採用していたので、x3でも問題無いかも)

また、play後、即座に次回バッファリング用のデータをデコードしておきます。

        // 次回バッファを事前にデコード
        decodeAudioBufferLatch = 0
        decode(decodeAudioBuffers[decodeAudioBufferLatch])

このようにしておくことで、コールバック処理で即座にバッファ書き込み(write)ができるので、音飛びしなくなります。

デコード処理の重さに依存する所ではありますが、コールバック即バッファリングがストリーミング再生の鉄則のようなものなので、ダブルバッファは基本ですね。

【要点4】 バッファリング処理

            override fun onPeriodicNotification(p0: AudioTrack?) {
                // 【要点4】バッファリング処理
                audioTrack?.write(
                    decodeAudioBuffers[decodeAudioBufferLatch],
                    0,
                    decodeAudioBuffers[decodeAudioBufferLatch].size
                )
                decodeAudioBufferLatch = 1 - decodeAudioBufferLatch
                decode(decodeAudioBuffers[decodeAudioBufferLatch])
            }

要点3の最後で解説した通り、コールバック後即、事前にデコード済みのデータをバッファリング(write)します。
その後、ラッチを切り替えて次回バッファリング用のデータをデコードしておきます。

OpenSL/ESよりも良いところ

以前、上述のロジックとほぼ同じ形の処理を OpenSL/ES (C++) で実装していたのですが、AudioTrack にしたところ OS からのringing(電話着信)のタイミング〜通話が切れるまでの間、自動的にミュートしてくれました。(Androidの電話は持っていないのでエミュレータで確認)

image.png

OpenSL/ES だと、PhoneStateListener (deprecated) なりで着信監視して自動ポーズする実装を入れる必要があったのですが、AudioTrackならその必要は無さそうです。着信監視のようなキケンな permission は極力入れたくなかったので助かります。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?