Android 4.4で加わったメディアAPIの進捗を確認する

2013年11月14日木曜日

本記事はTechBoosterさんによる Android 4.4 KitKat 冬コミ原稿リレーを開催 という企画の11/12の寄稿記事です。11/12の寄稿記事です。

日本の皆さん、こんばんは。頭のなかだけアメリカ西海岸に居ます。11/13の18時を回ったところです。夏時間終わったので日本からは-17時間です。日本の皆さんは既に13日のとみーさんの記事をご覧頂いているかと思います。素晴らしいデザイン系まとめですね。恐縮してしまいます。それでは12日の記事です。

Android 4.4では、マルチメディア機能が大きく強化されました。動画の再生中に解像度変更を伴う動的なストリーム変更ができるようになったり、HTTP越しのライブストリーミング機能が強化されていたりします。



そんな中で今回主に扱うのは、AV同期のためのオーディオタイムスタンプ取得機能です。
http://developer.android.com/about/versions/kitkat.html では
Audio timestamps for improved AV sync
The audio framework can now report presentation timestamps from the audio output HAL to applications, for better audio-video synchronization. Audio timestamps let your app determine when a specific audio frame will be (or was) presented off-device to the user; you can use the timestamp information to more accurately synchronize audio with video frames.
と紹介されています。そのまま訳すと以下の通りです。

AV同期のためのオーディオタイムスタンプ取得機能
オーディオフレームワークにおいて、より良い映像/音声同期のために現在の再生タイムスタンプをオーディオ出力HAL(ハードウェア抽象化レイヤ) からアプリケーションに対してレポートできるようになりました。オーディオタイムスタンプを利用することで、特定のオーディオフレームがどのタイミングで端末からユーザに対して発せられたか(または、られるか)を知ることができます。この情報を利用して、映像と音声をより良く同期させることが可能です。
= 機能の扱い方 =
扱い方の概要はhttp://developer.android.com/about/versions/android-4.4.html#AudioTimestampに記載されています。
割と簡単なAPIで、予めタイムスタンプ取得用に作成したandroid.media.AudioTimestampのインスタンスをAudioTrack#getTimestamp(android.media.AudioTimestamp)として渡すことで、ナノ秒単位のモノトニックなタイムスタンプを取得できます。
モノトニックなタイムスタンプは、それ自体では少々扱いづらいため、オーディオの再生を開始するタイミングなどで記録したSystem#nanoTime()との比較にて処理するのが良いでしょう。

実際に特性を調査していく前に、android.media.AudioTimestampの定義をもう少々読んでみましょう。nanoTimeがナノ秒単位のタイムスタンプを表すのは明らかですが、framePositionは何でしょう。
これは、指定AudioTrackインスタンスの持つバッファ中での現在再生位置を示します。結果、バッファを使い切った際の値更新挙動に差が生じます。例えば

  • AudioTrackのバッファサイズを小さめに確保する
  • AudioTrack#setNotificationMarkerPosition(int)でAudioTrackのバッファサイズを超える位置を指定する
  • onMarkerReached()でのコールバックにてAudioTrack#stop()を呼び出し、再生を停止する
とすると、発音がAudioTrackのバッファサイズ相当の箇所で停止すると共にframePositionの値も更新が止まるのに対し、nanoTimeの値は増加し続けます。このため、AudioTimestampを利用したアプリケーションの開発時には「nanoTimeの値が更新されている=発音が続いている」わけではないことに注意が必要です。


さて、この機能があると何が嬉しいのか、といった点について考えてみます。


= ほいで、ゲーム開発などに使えそう?→調べてみる =

タイムスタンプが取得できたところで、有効に利用できなければ意味が薄いです。今回は、ゲーム開発などのシーンにおいて活躍してくれそうな機能か否かを検討するべく、いくつかの負荷条件下での比較をおこなってみました。

使うサウンドは、先日のPlaygroundハッカソンで@mhidakaさんが作ってくれた「進捗だめです.mp3」(2216ミリ秒程度)です。進捗だめでした。
オーディオ再生、タイムスタンプ取得周辺のスニペットは以下のとおりです。


今回は単純化のために、AudioTrackをSTREAMモードで利用し、事前にbyte列へ読み込んだwavデータ(lame --decodeにてMP3からデコードしたリニアPCM; 16bit-mono)を投入しています。最後のサンプルまで再生されたらリスナでイベントを受け取り、AudioTrackの停止をおこないます。これに割と雑な定期タイマーを組み合わせて(なるべく)定期的にログを取得します。

これをNexus 5で実行すると、以下のようにタイムスタンプが更新されていきます。




なお、AOSPベースの4.4を焼いたGalaxy Nexusではタイムスタンプ取得機能がサポートされておらず、AudioTrack#getTimestamp(AudioTimestamp)がfalseを返します。これがハードウェア上の制約によるものか、ドライバの作り込みによって回避できるものかについては調べられていません。


これを単純に4回計測してデータ処理した結果は以下のとおりです(Nexus 5, 4.4/KRT16M, 単位はミリ秒, 以下同)。

平均サンプリング成功回数
85
最大値(最も性能が良い)
-1
最小値(最も性能が悪い)
-23
最大値-最小値の平均
19.75
中央値の平均
-12
すごい! 優秀ですね。特に最大値と最小値の差が22ms程度に収まっているのはなかなか良いです。そして、今回の計測系では理想的に実行した場合85回程度の処理がおこなわれることも分かります。

この結果が理想的なパターンでのものですが、実際のAndroid端末では

  • 同プロセス内で複数のスレッドが実行されている
  • 他のプロセスにて処理が実行されている

のが普通です。これらがどのような特性変化をもたらすのか、もう少し調べてみましょう。

まずはプロセス内に重い処理(ゲーム実行本体などを想定)を抱える場合を想定し、以下のコードを追加してみます。

プロセス内(Dalvik)に重い処理がある場合
平均サンプリング成功回数
60
最大値(最も性能が良い)
0
最小値(最も性能が悪い)
-21
最大値-最小値の平均
19
中央値の平均
-10


スレッド間のスイッチが発生するためかサンプリング回数は低下していますが、基本的には最初の結果と同様の傾向を示しています。

それでは、外部に重い処理を追加してみます。

= 外部の負荷源を導入する =

今回は手軽に負荷を発生させるために、どこのご家庭にもあるLuaのJITコンパイル対応版実装のLuajitを利用しました。


1|shell@hammerhead:/data/local/tmp $ ./luajit -e "i = 1 while true do i = i + 1 end" &
このようにして無限ループを作り、バックグラウンドで6プロセス走らせます。
CPUをぶん回してますね。この状態でホーム画面などを操作するとかなり引っかかりがあり、使い勝手がかなり落ちています。
「あー端末重いわー、つらいわー、再起動したいわー」という状態よりも更に行き過ぎている感がしますが、古めの端末ではよく見かける状況なので気にせず進みます。

プロセス外に重い処理がある場合
平均サンプリング成功回数
56.75
最大値(最も性能が良い)
0
最小値(最も性能が悪い)
-50
最大値-最小値の平均
32
中央値の平均
-11.75


明らかにオーディオここで"最大値-最小値の平均"に注目してください。さきのふたつの結果よりも明らかに増えています。一方で"中央値の平均"はあまり変わっていません。これは「概ね良い結果を出しているが、時折大きく外れた結果につながる」ことを意味します。
ただし、これはAudioTrack#getTimestamp(AudioTimestamp)の性能が悪いことを意味するものではありません。「Androidでは、アクティブなアプリケーション以外がCPUを一定以上消費している際に、アクティブなアプリケーションの反応も一定以上悪化するケースが多い」ことを示すのみです。とはいえ、これ自体がゲームなどの実行環境として不向きには違いありませんが…。
ちなみに定性的なものですが、計測中に可聴ノイズは特にありませんでした。オーディオを出力するという基本機能はしっかりと維持しています。

さて、最後にプロセス内が重くプロセス外も重いという二重苦状態も試してみます。

プロセス外に重い処理があり、プロセス内(Dalvik)にも重いスレッドがある場合
平均サンプリング成功回数
4.25
最大値(最も性能が良い)
-1
最小値(最も性能が悪い)
-100
最大値-最小値の平均
33
中央値の平均
-12.25


サンプリング成功回数の激減からお察し下さいという感じがしていますが、かなり重い状態です。ここで初めて計測中に音が飛ぶ形の可聴ノイズが数回確認できました。明らかにCPU時間不足です。今回の実装においてユーザ側からは全てのオーディオサンプルを事前にプラットフォームへ渡してあり、バッファキュー追加処理類はおこなっていないため、この挙動は少々不可解です。Androidプラットフォームの実装に優先度制御の甘い箇所があるのかもしれません。
ここで特筆すべきは"最大値-最小値の平均"と"中央値の平均"が共にひとつ前のものとほとんど変わらないことです。これは「CPU性能が足りず音飛びが発生するような環境においても、一定以上の精度でタイムスタンプ取得が可能」という特性を意味します。表中で最も性能の悪いケースで100ミリ秒程度の差が生じているのは、おそらくタイムスタンプ取得後に比較用のSystem#nanoTime()実行までの間にDalvik内のスレッド切り替えがおこなわれたなどの事情によるものでしょう。

= まとめ =
以上の4つの計測結果をまとめると以下のようになります。
状況
理想的
内部高負荷
外部高負荷
内外高負荷
平均サンプリング成功回数
85
60
56.75
4.25
最大値(最も性能が良い)
-1
0
0
-1
最小値(最も性能が悪い)
-23
-21
-50
-100
最大値-最小値の平均
19.75
19
32
33
中央値の平均
-12
-10
-11.75
-12.25


  • 基本的に、取得できるタイムスタンプは信用していい
  • アプリ内・アプリ外が共に高負荷である場合、Androidのサウンド出力自体はまだ甘いらしく、アプリ側での補正が必要
  • 新しいアーキテクチャの低スペック端末などで音飛び/フレーム飛びを補正する上での手軽な策として割と使えそう

= NDKに恩恵は…? =
NDKでバリバリ書かれたものについてはどうなるんだろう、何か効果あるのかな?ということも気になりますね。
ありません。

そもそもJava側のAudioTrack専用の機能なので、Low Latency Audio出力などを視野に入れたOpenSL|ESでの実装系では提供されていません(Android NDK r9b時点)。
そもそも、NDKベースで開発する場合には、ある程度自由なスレッド優先度制御が元々可能であること、バッファキューを利用する場合にはそれなりの精度(概ね10ms未満程度)で現在処理中のバッファ位置を推測できることから、あまり使い道が大きいとも言えないのが実際のところです。
とはいえ「バッファキューへ複数のバッファを積んでいる状態でどこまでが実際にスピーカなどから出力されたか」という点は従来提供されてきた機能では推測することしかできなかったため、将来的にNDKに類似機能が提供されると一定のユーザ体感改善へ寄与するものと考えられます。

以上、「AV同期のためのオーディオタイムスタンプ取得機能」について述べました。

以下は蛇足です。

まず、本機能が実装された背景を少々推測して書いてみます。
まず、端末性能の全体的な向上が挙げられます。これによりDSPにデータを流すだけでなく、一定はOSでの処理を介在させてもユーザ体感を損なわない下地ができてきたのでしょう。また、OS自体の最適化により余裕が生まれたということも考えられます。特にサウンドまわりは、4.1からだいぶ良くなってきたので、サウンドチップ周辺からユーザランドへ情報を戻すような機能を実装しやすくなったのではないかと考えられます。

「動画の再生中に解像度変更を伴う動的なストリーム変更ができるようになった」件
そういえば、前半で書いていた本件について少々触れておきます。
本機能を調べ始めた@muo_jp氏(28歳)によると
「この機能を使うにあたってはプラットフォームにサポート有無を問い合わせる必要があります。当初エミュレータで調べていたところ、以下のような調査コードが
String[] types = codecInfo.getSupportedTypes();
for (int j = 0; j < types.length; j++) {
  MediaCodecInfo.CodecCapabilities ccp = codecInfo.
    getCapabilitiesForType(types[j]);
  boolean isAdaptivePlaybackSupported = ccp.isFeatureSupported(
    MediaCodecInfo.CodecCapabilities.FEATURE_AdaptivePlayback);
  String supportStatus = "adaptive playback is " +
    (!isAdaptivePlaybackSupported ? "not " : "") +
    "supported for " + types[j];
  Log.d(TAG, supportStatus);
}
平然とfalseを返してきました。おっふ…。さすがにエミュレータだとメディア系が諸々弱そうだから使えないか。

それではAOSPベースのGalaxyNexusではどうかなーと考えてhttp://blog.sola-dolphin-1.net/archives/4566598.html で配布されていたものを利用してみたのですが同じくfalse。
そうだよねー、メディア系は割とAOSP実装と商用実装で各種コーデックが異なるとかあるよねー。
こういうのはちゃんと商用実装で確認しよう的なやつだ。

ということでNexus 5(4.4, KRT16M)で試した結果…
adaptive playback is not supported for video/mp4v-es
adaptive playback is not supported for video/mp4v-es
adaptive playback is not supported for video/3gpp
adaptive playback is not supported for video/avc
adaptive playback is not supported for video/x-vnd.on2.vp8
adaptive playback is not supported for audio/3gpp
adaptive playback is not supported for audio/mp4a-latm
adaptive playback is not supported for audio/amr-wb
adaptive playback is not supported for audio/flac
adaptive playback is not supported for video/x-vnd.on2.vp8
adaptive playback is not supported for audio/mp4a-latm
最新開発者向けフラッグシップ端末も平然とfalseを返してきました。oh... 詰んだオワタ、まだ人類には早すぎた。

Xperia系とか、メディア周りの実装がしっかりした端末が4.4にちゃんと対応しないとこれ無理なんでないかという感がしている」とのことです。