Android で録画アプリを実装してみよう

明けましておめでとうございます!

今年も OPTiM TECH BLOG を宜しくお願いします。


お久しぶりです、医療ユニットの山口です。
2022 年初の記事になります。(恐れ多くも、去年に引き続き新年一発目の記事担当となりました。)

さて、オンライン診療ポケットドクターは 11月末に v1.11 をリリースしており、いくつかの機能追加を行いました。
その中でも私はビデオ通話中の録画機能を主に担当したので、今回は Android の録画機能についての実装解説になります。

サンプルアプリのソースコードは GitHub に上げており、このサンプルアプリの実装をもとに説明していきます。

機能概要

もともとオンライン診療ポケットドクターでは、ビデオ通話中に医療機関側で音声のみ録音する機能が存在しました。
今回の v1.11 ではその録音機能を拡張して、音声ありの録画も取得できるようになりました。
音声録音にするか or 録画にするか or レコードしないかは、各医療機関ごとに任意に定めることが可能です。

実装解説

今回はレコード機能本体の実装にフォーカスを当てたいので、音声バッファーや動画フレームの取得部分の実装解説は割愛します。

まず、レコード機能を実装するにあたり、使用する Android API は MediaCodec *1 と MediaMuxer *2 になります。
MediaCodec は低レベルなメディアコーデック(エンコーダー・デコーダーコンポーネント)にアクセスするために使用するものであり、Android の低レベルなマルチメディアサポートインフラの一部です。
大抵の場合 MediaCodec はメディアに関するクラスとセットで使用するものであり、今回のケースだと MediaMuxer がそれにあたります。
MediaMuxer はストリームの合成に使用するものであり、MP4 や Webm、3GP ファイルをサポートしています。

今回の場合やりたいことをこれらの API 踏まえてざっくり要約すると「音声と映像それぞれのストリームに対して MediaCodec でコーデックのエンコードをかけて、それを MediaMuxer で合成してファイルに書き出す」です。
音声録音のみしたい場合は音声用の MediaCodec のみ、音声なしで録画したい場合は映像用の MediaCodec のみ、音声ありの動画にしたい場合は音声映像それぞれ用の MediaCodec を用意すればよいです。(MediaMuxer は合成用なので1つです。)
サンプルではオンライン診療ポケットドクターの仕様に合わせて、音声録音と音声ありの録画、2パターンを選べるようにしています。

f:id:optim-tech:20211227102321p:plain

また、サンプルのレコード機能全体のクラス図も以下に示します。
(すべてのフィールドやメソッドを載せると見づらくなるため重要なもののみピックアップしており、そこまで重要でないものは省いています。)

f:id:optim-tech:20220106101027p:plain

パラメータの設定 - Prepare

まずはパラメータの設定です。
サンプルでは MediaRecordManager#prepare() の処理解説になります。

パラメータを設定して MediaCodec と MediaMuxer を初期化します。
初期化(特に映像用の MediaCodec)には些か時間がかかるため、該当処理はサブスレッドで行うようにしましょう。

まずは音声用の MediaCodec のフォーマット。コーデックは AAC(MIME Type: audio/mp4a-latm)を指定します。
必要なパラメータはいくつか存在しますが、実装時に調整する必要があるのはサンプルレートとビットレートです。
これらのパラメータによって、音声の質やファイルサイズが左右されます。

次に動画用の MediaCodec のフォーマット。コーデックは H.264(MIME Type: video/avc)を指定します。
動画の質やファイルサイズに左右されるパラメータはフレームサイズとビットレート、フレームレート、Iフレーム間隔です。
MediaCodec の設定後に MediaCodec#createInputSurface() を呼び出してサーフェイスを生成しておきましょう。
このサーフェイスは生の映像フレーム(静止画)をネイティブに処理するためのものであり、 データ書き込み時に使います。

最後に MediaMuxer。出力フォーマットには MPEG-4 を指定します。
ファイル拡張子は音声録音ならば .m4a、録画ならば .mp4 になります。

レコード開始 - Start

次にレコードの開始です。
サンプルでは MediaRecordManager#start() の処理解説になります。

まずはそれぞれの MediaCodec に対して MediaCodec#start() を呼び出してコーデック開始のリクエストを送ります。
それと同時にサブスレッドを立ち上げてコーデックデキュー用のループを開始します。
このループ内では、MediaCodec#dequeueOutputBuffer() を定期的に呼び出すことで、コーデックからエンコード済のデータを取得するためのものであり、レコード中はずっと裏で起動している状態になります。
コーデック処理開始後、MediaCodec#dequeueOutputBuffer() の戻り値(ステータス)が MediaCodec.INFO_OUTPUT_FORMAT_CHANGED になったら、コーデック準備が整ったことになります。

コーデックの準備完了後、MediaMuxer#addTrack() で MediaMuxer に必要なストリームを記録させます。
戻り値のトラック ID は MediaCodec とセットで保持しておきましょう。データ書き込みで使います。 音声映像それぞれのストリームを認識させたら MediaMuxer も準備完了です。MediaMuxer#start() で開始します。
なお、MediaMuxer が開始した後にストリームの記録を行うとクラッシュするので、すべてのコーデックを認識させてから開始するようにしてください。

データの書き込み - Write Data

データの書き込みです。最初に出てきた図解の実装になります。
サンプルでは MediaRecordManager#inputAudioBytes(), MediaRecordManager#inputVideoBitmap() の処理解説になります。

コーデックエンキュー処理は音声と映像でそれぞれ異なります。
音声の場合、バイト配列(ByteArray)をバイトバッファー(ByteBuffer)に変換して MediaCodec#queueInputBuffer() を呼び出すことでエンキューします。
タイムスタンプも引数として必要であるため、バッファーサイズとサンプリングレート、1サンプルあたりのバイト数から計算しています。
映像の場合、映像フレーム(Bitmap)をパラメータの設定のときに作成したサーフェイスに描画することでエンキューします。
Surface#lockCanvas() でキャンバスをロックすると同時に取得し、キャンバスに映像フレームを描画します。
あとは Surface#unlockCanvasAndPost() を呼び出してロック解除すると同時に描画したものを反映させます。

コーデックエンキュー後はエンコード済のデータをデキューして取得します。デキュー処理以降は共通です。
デキュー用のループにて MediaCodec#dequeueOutputBuffer() の戻り値が 0 以上の場合バッファーインデックスであるため、MediaCodec#getOutputBuffer() でバッファーを取得します。
タイムスタンプは BufferInfo.presentationTimeUs から取得可能です。

あとはこれらの情報を Muxer#writeSampleData() で書き込みしてください。開始時に取得したトラック ID も付加情報として必要です。
書き込みまで一通り終わったら MediaCodec#releaseOutputBuffer でバッファーを解放しましょう。

レコード停止 - Stop

最後にレコードの停止です。
サンプルでは MediaRecordManager#stop() の処理解説になります。

まずはエンドストリームをコーデックにエンキューします。
音声の場合、MediaCodec#queueInputBuffer()MediaCodec.BUFFER_FLAG_END_OF_STREAM を入れます。
映像の場合、MediaCodec#signalEndOfInputStream() を呼び出します。

デキュー側は通常の書き込み処理と同様に処理を行うと同時に、BufferInfo のフラグが MediaCodec.BUFFER_FLAG_END_OF_STREAM だった場合、コーデック処理が終了したとみなしループを直ちに抜け出します。
ループを抜け出たら後始末処理です。
MediaCodec#stop(), MediaCodec#release() を呼び出してコーデックをリリースします。
音声映像それぞれのコーデックリリース処理が完了次第、MediaMuxer#stop() を呼び出してレコード処理は完了です。

問題が特に発生しなければ再生可能な音声ファイル or 動画ファイルの出来上がります。
なお、一連のお作法で終了しないとファイルが壊れて再生できなくなってしまうので注意しましょう。

その他

実装解説の最初に音声バッファーや動画フレームの取得部分の実装は割愛すると記載していましたが、理由はサンプル実装とオンライン診療ポケットドクターのビデオ通話とで実装が異なるためです。
サンプルでは音声バッファー取得に AudioRecord *3、動画フレーム取得に Camera2 ライブラリ *4 を使用しています。
しかし実際のオンライン診療ポケットドクターのビデオ通話では、音声バッファーには WebRTC *5 から自分と相手の音声バッファーをそれぞれ取得して、それらをバイナリレベルで合成したものを使用しています。
また動画フレームには OPTiM 独自開発の Communication SDK (In-App Remote SDK) を用いて、医療機関側の端末画面そのものをキャプチャしています。
オンライン診療ポケットドクターで実装しているものの解説を行いたかったため、関係のない部分は今回割愛をさせていただきました。

ちなみにですが複数ストリームの観点から言うと、実装当初の MediaMuxer は音声と動画のストリームそれぞれ 1 つずつしか記録できなかったのですが、Android O (API 26) 以降では音声と動画ストリームを複数記録できるようになったようです。*6
ただしオンライン診療ポケットドクターでは現状 Android L (API 21) を最小サポートとしているため、自分と相手の音声ストリームをそれぞれ MediaMuxer に記録することなく、あえて自力実装で自分と相手の音声をバイナリレベルで合成して 1 つの音声ストリームにしています。

まとめ

今回は Android の録画機能実装についての解説になりました。
MediaCodec がネイティブに近い低レベル API である故か、お作法通りに実装しないとクラッシュしたりファイルが壊れたりすることがあり、実装調査中にはそこそこ苦労しました。
もし Android で録画機能を実装してみたいという方がいらっしゃれば、参考になれば幸いです。


オンライン診療ポケットドクターはお手持ちのスマホの AppStore もしくは GooglePlay から無料でダウンロードすることが可能です。

オプティムでは共に開発を行うエンジニアを募集しています。