AndroidのMediaPlayer.seekTo()の機種依存バグに困ったお話+SoundPoolでのSE再生バグの解決

Link-UのAndroidアプリエンジニアの大谷です。
面白みのある話ではないですが非常に難儀した`MediaPlayer`の機種依存バグへの対応を備忘録代わりに書こうと思います。（おまけの`SoundPool`の話は末尾にあります）
いつか誰かが似たように困ったとき参考となれれば幸いです。
ちなみに筆者は入社後プログラミングを始めて3年足らずのひよっこですので疎い面も多く、おかしな点などあればご指摘願います……。

#検証端末抜粋

* Google **Pixel** 3a : Android 10
* SHARP **AQUOS** Sense SH-01K : Android 9
* Samsung **Galaxy** A20 : Android 9
* Sony **Xperia** XZ1 : Android 8

#経緯
新規のプロジェクトで音声再生が必要となり、Androidに標準搭載であり拡張も楽なMediaPlayerクラスを使って実装を進めることにしました。
[Android Developers : MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer)

一つの音声ファイルを取得し、タップしたアイテムによってはその中の特定の部分を再生する、といった仕様だったので`seekTo()`を使って再生位置のコントロールを行いました。

```kotlin:実装(再生部分のみ)
fun MediaPlayer.playAudio(startPosition: Int) {
       seekTo(startPosition)
       if (!isPlaying) {
            start()
       }
    }
```
再生停止時のstateの遷移仕様など`MediaPlayer`について詳しく知りたい場合はリンク先のリファレンスを見てもらうとして、この記事で出てくる役者は以下です。

* `getCurrentPosition()` 現在の再生位置をミリ秒で返す
* `seekTo(long)` 再生位置をlongの値に変更する
* `start()` 再生を開始する

##機種によってseekTo()の挙動がおかしいことが判明　
実装して自端末(Google Pixel3a)では問題なく再生できていたのですが、***Galaxy***でのデバッグで**音声が尻切れになって再生される**との報告が来ました。
![スクリーンショット 2020-12-21 081347.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/927296/54745ac2-7a88-9aaf-bb87-287b26bbacfd.jpeg)
調べたところ、どうにも**`seekTo()`の関数を使って再生位置の指定をする際、`MediaPlayer.start()`の後に`currentPosition`が前倒しにずれる**事象が発生しているようだとはわかりました。`start()`前後のタイミングで`currentPosition`は`seekTo`で指定した値が返ってきており（100msの遅延後に確認するとずれた値が返る）seek自体は完了しているように見え、何より機種依存で発生している。これは困った。

一通り調べて`seekTo()`のタイミングの変更や別の関数を使うなど試してみましたが解決できなかったため、ファイルの方を変えてみることにしました。~~そしてここからが泥沼の始まりでした~~

ひとまず[公式](https://developer.android.com/guide/topics/media/media-formats) で対応している形式を候補に、元の`opus`形式のファイルから、`aac/mp4`やCBR(固定ビットレート)モードの`opus`などいくつかの形式へ変換し再生を試していったところ、`opus/mkv`のファイルは***Galaxy***でも正常に再生されることを確認しました。

しかしこの形式に変えてみると今度は***AQUOS***での再生位置ずれが発生していることが判明しました。ただずれるだけではなく、**seekToで0以外でcurrentPositionの位置より前の値を指定するとなぜか先の位置へ進んでゆく**、という別種のバグ。困る。また***Xperia***では**seekToを行うと再生位置が0に戻る**という新手の現象も……。

##音声再生クラスをExoPlayerに変更、しかし……
できる限りAndroidフレームワーク内のコンポーネントで実装したい気持ちがあったのですが、背に腹は代えられないため`ExoPlayer`を使ってみることにしました。
`ExoPlayer`はOSSのメディア再生ライブラリで、Androidフレームワーク外のライブラリではありますがGoogle謹製です。`MediaPlayer`では対応していない形式もサポートしていたりします。詳しくは[公式リファレンス](https://exoplayer.dev/hello-world.html)ほか、Qiitaにもいっぱい記事があったりするのでそちらで。

実装上での`MediaPlayer`との違いは多々ありますが、再生と停止が`playWhenReady`の`true/false`のみで管理される点が大きいかなと思います。
基本的に`MediaPlayer`で出来ることはほとんど可能で、こちらも`seekTo(position:long)`で再生位置の変更ができます。

```kotlin:
fun createSimpleExoPlayer(context: Context, filePath: String): SimpleExoPlayer {
    val factory = DefaultDataSourceFactory(
        context,
        Util.getUserAgent(context, context.getString(R.string.app_name))
    )
    return SimpleExoPlayer.Builder(context)
        .setTrackSelector(DefaultTrackSelector(context))
        .setLoadControl(DefaultLoadControl())
        .build().apply {
            playWhenReady = false
            setSeekParameters(SeekParameters.EXACT)
            prepare(
                ProgressiveMediaSource.Factory(
                    factory,
                    DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true)
                ).createMediaSource(filePath.toUri())
            )
        }
}

//再生
simpleExoPlayer.seekTo(position)
simpleExoPlayer.playWhenReady = true
```

ところが困ったことにこの実装では今度は***pixel***でseekの処理が行われませんでした。（**`seekTo()`の引数を変更してもすべて`currentPosition`が0にseekされる**という挙動）。調べて出てくる方法を一通り試してみても解決はできず（そもそも全然類似の情報が出てこない）、とりあえず再び[ExoPlayerの対応形式](https://exoplayer.dev/progressive.html#enabling-constant-bitrate-seeking)を参考にいくつかのファイル形式に変換してテストをしました。

| コーデック | コンテナ | 結果 |
|:-:|:-:|:-:|
| opus | opus  |  元のファイル。***Galaxy***にてseek先の位置が少し前にずれる。 |
| vorbis  | ogg  | 同上。  |
| vorbis  | mkv  | 同上。加えて***AQUOS***にて再生位置を前に戻すことができない。***Xperia***で再生位置の大幅なずれが発生。  |
| HE-AACv2  | m4a  | ***pixel***でのみseek先の位置が後ろになるほどわずかにずれが発生（今回使用したファイルでは100ms以下）。  |


といった結果となり、ひとまず一番影響が少ないAACのフォーマットを採用して様子見する方向に。

しかしながらデバッグを進めると***Galaxy***にて**初回の`seekTo()`は正常に作動しているが、同じ処理の`seekTo()`を二度三度と走らせるとseek後の`currentPosition`が少しずつ後ろにずれる**というこれまたよくわからない挙動が判明。~~<font color="grey">おのれサムスン</font>~~


##そもそもメディアファイルは正確なseekができる仕様ではない？
調べたところそもそもメディアファイルに対するseekという再生位置の探索は正確性の担保が難しいらしく、またファイル形式によっても変わることがわかりました。
音声とは違いますが、動画ファイルにはseek方法が複数あり、

* `key-frame accurate seeking`：画像を連続して表示することで映像を動かしておりその表示される一枚一枚をフレームと呼びますが、ファイルごとに**キーフレーム**と呼ばれる情報を合間合間に挟んでいます。この方法でseekする場合はseek先の位置に最も近いキーフレームから開始されてしまうため、正確性は薄いようです。そのためこのキーフレームが増えれば正確性は高まりますが、その分データ量も大きくなってしまいます。
* `frame accurate seeking`：**pts**（フレームごとに持っている動画中で自身が表示される時間の情報）をもとに再生位置を見つけ出してseekする形です。こちらもseekしたい位置と同値のptsを持つフレームがない限り、指定した位置とはずれてしまいます。

音声ファイルの場合、可変ビットレート（VBR）のファイルは位置によって情報量が異なり、（全体の平均ビットレート\*seekするms）分のデータをスキップして再生を行うためseekは正確にならない。
一方、**固定ビットレート（CBR）で作られたものは単位時間あたりの情報量が一定となるため、VBRと同様の方法でも正確にシークが可能である**、らしいです。
参考：https://www.stream.co.jp/resources/blog/2014/06/13/571/
　　　https://medium.com/@takusemba/deep-understanding-of-seek-4e10079165ec
　　　https://stackoverflow.com/questions/6845161/accuracy-of-mediaplayer-seektoint-msecs

これらのように仕様的な問題で難しいのかなと思ったりしましたが、同じファイルでも正確にseekできる機種とできない機種があったり、CBRでは理論上正確にseekできるはずでも機種によってはCBRの形式である`opus`と`vorbis`でもずれてしまったりと謎が深まるばかりでした。

#結末
最終的には最初のバグが舞い戻ってきてこれは実装の変更やらで機種依存を吸収するのは難しい、ということに。
0.1ms以下の正確性が求められる部分再生の実現にはseekを使って一つの音声ファイルの再生位置を変えることをやめ、個別でファイルを用意して再生に使うことにしました。幸いにも今回のプロジェクトでは別で使う仕様上、分割した音声ファイルも用意してあったため、対応コストは少なく済みました。
通信するデータは増えてしまいましたが現状は問題なく動いています。

##まとめ
どうもメディア再生の処理についてはハードに寄る部分が大きく、メーカーごとに相性のいいファイル形式が違っていたり挙動が違ってくることがあるようです。特に`seekTo`の処理については実装コードの変更だけでは端末依存は完全に吸収することができませんでした。ひとまずExoPlayerではMediaPlayer利用時より端末依存のバグは軽減されたため、**メディア再生機能を実装する必要がある際には基本的にExoPlayerを使っていく**方がよさそうです。

一応ファイル形式の変更で変わる部分も多かったので**機種依存の場合は音声フォーマットを変更してみると幸せになれるかもしれない**、という知見を得ました。今回はサーバーから送る関係上データサイズの問題で候補に入れなかった`wav`形式の場合はデータ量が多いためうまくいくかもしれません。もしくは音質の問題から候補から外れた`mp3`もかなりスタンダードな形式なので大抵のメーカーでは対応してくれているかも？


あちらを立てればこちらが立たずといった具合に次々湧いてくる**<font color="red">七</font><font color="orange">色</font><font color="gold">の</font><font color="green">依</font><font color="blue">存</font></font><font color="indigo">バ</font><font color="darkviolet">グ</font>**には絶望を感じました。そもそもどれも公式でサポートしていると明言されている形式なのになぜ挙動が変わってくるのか。
結局バッドノウハウ的な方法でしか解決できなかったのは悔しいところ……いろいろこねくり回して時間をかなり使ってしまったのも反省点です。これが仮に動画ファイルだったりした場合はファイルサイズが大きくなることもありまた別の方法を考えないといけない気がしますのでうまく機種依存を吸収する実装などの例があれば知りたいところです。

今まで機種依存に悩まされることはほぼ皆無だったので今回Androidの闇と触れ合えたのは良い経験になった気もします。Google先生には早くpixelで世界を統一してほしい。

##おまけ：SoundPoolで機種によって再生音がバグる問題の解決
MediaPlayerの方を書き始めて碌な解決をしていないことに気づいてしまって（こんなのを出すのか……？）とつらい気持ちになったのですが、バッドノウハウで終わりだといささか据わりが悪いので機種依存に関連して効果音再生実装時に発生した機種依存バグを解決した話も書いておきます。

###SoundPool
Android標準コンポーネントの音声再生クラス。短い音声ファイル、SEなどの再生に適している。
[Android Developers : SoundPool](https://developer.android.com/reference/android/media/SoundPool)
使用した実装コードは以下です。

```kotlin
val audioAttributes = AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_MEDIA)
            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
            .build()

val soundPool = SoundPool.Builder()
            .setAudioAttributes(audioAttributes)
            .setMaxStreams(100)
            .build()

//利用したいSE
val tapSound = soundPool.load(requireContext(), R.raw.tap_sound, 1)

//再生
soundpool.play(tapSound, 1f, 1f, 0, 0, 1f)
```
###発生したバグ
***AQUOS***他いくつかの端末で効果音再生時に異音が発生してしまう事象が起きていました。
ギャイイイイィィッィィィィキィィィィィィィみたいなものすごい**音割れ**に変化していました。恐怖。
###原因と解決
**SE音源がハイレゾ（24bit）で作られていたことが原因でした。**
Android9以前のバージョンのAndroidOSに標準搭載のミキサーはサンプリングレートが48kHz/16bit以上のメディアファイルの再生が仕様的に制限されているようです。（参考:https://ascii.jp/elem/000/004/014/4014936/)
ということで利用するファイルを16bitに落としてもらったところ、正常に再生されるようになりました。
16bit以上のものが来たところで勝手に16bitに落として再生される端末が大半のようですが、今回のように対応してくれない端末もあるようなので基本的に48kHz/16bitで作った音源を使用するのが吉です。（そもそもハイレゾのSEを用意するほうが稀な気もしますが）

欠片でもどなたかの参考になれば幸いです。