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

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

実装(再生部分のみ)
fun MediaPlayer.playAudio(startPosition: Int) {
       seekTo(startPosition)
       if (!isPlaying) {
            start()
       }
    }

再生停止時のstateの遷移仕様などMediaPlayerについて詳しく知りたい場合はリンク先のリファレンスを見てもらうとして、この記事で出てくる役者は以下です。

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

機種によってseekTo()の挙動がおかしいことが判明 

実装して自端末(Google Pixel3a)では問題なく再生できていたのですが、Galaxyでのデバッグで音声が尻切れになって再生されるとの報告が来ました。

調べたところ、どうにもseekTo()の関数を使って再生位置の指定をする際、MediaPlayer.start()の後にcurrentPositionが前倒しにずれる事象が発生しているようだとはわかりました。start()前後のタイミングでcurrentPositionseekToで指定した値が返ってきており(100msの遅延後に確認するとずれた値が返る)seek自体は完了しているように見え、何より機種依存で発生している。これは困った。

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

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

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

音声再生クラスをExoPlayerに変更、しかし……

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

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

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の対応形式を参考にいくつかのファイル形式に変換してテストをしました。

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

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

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

そもそもメディアファイルは正確なseekができる仕様ではない?

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

  • key-frame accurate seeking:画像を連続して表示することで映像を動かしておりその表示される一枚一枚をフレームと呼びますが、ファイルごとにキーフレームと呼ばれる情報を合間合間に挟んでいます。この方法でseekする場合はseek先の位置に最も近いキーフレームから開始されてしまうため、正確性は薄いようです。そのためこのキーフレームが増えれば正確性は高まりますが、その分データ量も大きくなってしまいます。
  • frame accurate seekingpts(フレームごとに持っている動画中で自身が表示される時間の情報)をもとに再生位置を見つけ出して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の形式であるopusvorbisでもずれてしまったりと謎が深まるばかりでした。

結末

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

まとめ

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

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

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

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

おまけ:SoundPoolで機種によって再生音がバグる問題の解決

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

SoundPool

Android標準コンポーネントの音声再生クラス。短い音声ファイル、SEなどの再生に適している。
Android Developers : SoundPool
使用した実装コードは以下です。

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を用意するほうが稀な気もしますが)

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