Car App Library の CarAudioRecord を試す

概要

Car App Library 1.3.0 から、車のマイクを使った音声データを処理できるようになりました。 developer.android.com

面白そうなので、サンプルコードを見ながら動かしてみました。コードの大枠はサンプルコードと同じで、所々を適当に都合の良いように改変しています。

準備

Android Auto のアップデート

まず Android Auto を最新のものにアップデートします。CarAudioRecord API を利用するには、Android Auto 8 系が必要です。またこの機能を利用するために Android Auto から API Level 5 以上であるという判定をしてもらう必要があります。

当初自分は Android Auto を 8.1 以降のバージョンにしていたのですが、DHU では API Level 6 になるものの、実際の車では API Level 4 と判定されており、実車CarAudioRecord が使えませんでした。ですが 2023/01/11 にアップデートされた Android Auto 8.6.6 をインストールしたところ、実車でも API Level 5 に到達することができました。

Gradle

CarAudioRecord API を利用するコードを書くために Car App Library は 1.3.0 系にします。2023/02/03 現在だと 1.3.0-rc01 が最新です。

implementation "androidx.car.app:app:1.3.0-rc01"
implementation "androidx.car.app:app-projected:1.3.0-rc01"

AndroidManifest

次に、Android Manifest に音声を扱う旨を記述しておきます。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <application
    ...

従来のマイク録音パーミッションと同じですね。 Manifest.permission  |  Android Developers

Screen クラスの作成

Screen クラスの大枠を作っておきます。REC ボタンを押したら録音が開始されるという目論見です。

class VoiceRecodingScreen(carContext: CarContext) : Screen(carContext) {
    override fun onGetTemplate(): Template {
        val row = Row.Builder().setTitle("Voice Recoding Screen").build()
        val pane = Pane.Builder().addRow(row).build()
        return PaneTemplate.Builder(pane)
            .setHeaderAction(Action.BACK)
            .setActionStrip(
                ActionStrip.Builder().addAction(
                    Action.Builder()
                        .setTitle("REC")
                        .setOnClickListener { /*TODO*/ }
                        .build()
                ).build()
            )
            .build()
    }
}

起動して確認するとこんな感じです。

録音クラスの作成

VoiceRecorder という、録音をしてくれるクラスを作成したいと思います。

class VoiceRecorder(private val carContext: CarContext) {
  fun record() {}
}

気が早いと思いつつ、これを先ほどの Screen クラスで使用すると、以下のような感じです。

class VoiceRecodingScreen(carContext: CarContext) : Screen(carContext) {
    private val voiceRecorder = VoiceRecorder(carContext)

    private fun onRecordClicked() {
        voiceRecorder.record()
    }

    override fun onGetTemplate(): Template {
        ...
                ActionStrip.Builder().addAction(
                    Action.Builder()
                        .setTitle("REC")
                        .setOnClickListener { onRecordClicked() }
                        .build()
           ...
}

record() の実装

CarAudioRecordrecord() メソッドを実装して、車のマイクから音声を録音し、データを端末に保存してみましょう!

RECORD_AUDIO 権限チェック

早速 CarAudioRecordインスタンスを作成して録音を実装したいところですが、その前に録音権限チェックを通しておく必要があります。CarAudioRecord のインタンスを作成する create() static 関数は、 @RequiresPermission(RECORD_AUDIO) が付いているからです。

    @RequiresPermission(RECORD_AUDIO)
    @NonNull
    public static CarAudioRecord create(@NonNull CarContext carContext) {

今回はここで権限チェックはせずに、使う側にチェックをお願いしたく @RequiresPermission(permission.RECORD_AUDIO) をつけておきました。これでこのメソッドが呼ばれる時には権限チェックが通っていることが(IDEの警告を無視しなければ)保証されるはずです。

@RequiresPermission(permission.RECORD_AUDIO)
    fun record() {
            val carAudioRecord = CarAudioRecord.create(carContext)
            ....

フォーカスをもらう

developer.android.com Audio Focus をもらいます。どういうことかというと、現在再生中の音楽を止めてフォーカスをもらうコードです。Android で音楽を再生中に「OK Google」というと音楽が止まってマイクから音を拾ってくれると思いますがまさにあれです。

fun record() {
    val carAudioRecord = CarAudioRecord.create(carContext)
    // audio のフォーカスを貰う。
    // 音楽が鳴っていてもここで静音にし録音できる
    val audioAttributes = AudioAttributes.Builder()
            .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
            .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
            .build()
    val audioFocusRequest =
            AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
                .setAudioAttributes(audioAttributes)
                .setOnAudioFocusChangeListener { state: Int ->
                    if (state == AudioManager.AUDIOFOCUS_LOSS) {
                        // 他のアプリによってなど、何かしらの理由でフォーカスが外れた時、録音を終了させる
                        carAudioRecord.stopRecording()
                    }
                }
        .build()
    if (carContext.getSystemService(AudioManager::class.java)
        .requestAudioFocus(audioFocusRequest)
        != AudioManager.AUDIOFOCUS_REQUEST_GRANTED
    ) {
            return
    }

録音を実装する

録音を開始するには、CarAudioRecord クラスの startRecording() メソッドを呼びます。これが呼ばれると、車の画面には録音中の UI が強制的に表示されます。

carAudioRecord.startRecording()

このメソッドがスレッドをブロックすることはありません。

CarAudioRecord クラスの read() メソッドを使って、音声データを読み取って保存してみます。

fun record() {
...
        val bytes: MutableList<Byte> = ArrayList()
        val byteArray = ByteArray(CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE)
        // 録音が停止されない限り、carAudioRecord は音声データを提供し続ける
        while (carAudioRecord.read(byteArray, 0, CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE) >= 0) {
            byteArray.forEach {
                bytes.add(it)
            }
        }

        try {
            val outputStream: OutputStream =
                carContext.openFileOutput("voice.wav", Context.MODE_PRIVATE)
            addHeader(outputStream, bytes.size)
            bytes.forEach {
                outputStream.write(it.toInt())
            }
            outputStream.flush()
            outputStream.close()
        } catch (e: IOException) {
            throw IllegalStateException(e)
        } finally {
            carAudioRecord.stopRecording()
        }

自力で wave フォーマットとして保存するために、wave フォーマットのヘッダを自分でつけています。

private fun addHeader(outputStream: OutputStream, totalAudioLen: Int) {
        val totalDataLen = totalAudioLen + 36
        val header = ByteArray(44)
        val channels = 1
        val dataElementSize = 16
        val blockAlign = (channels * dataElementSize / 8).toLong()
        val longSampleRate = CarAudioRecord.AUDIO_CONTENT_SAMPLING_RATE.toLong() // 16000 なので 16kHz
        val byteRate = longSampleRate * blockAlign

        // RIFF 識別子(4byte)。それぞれを R I F F 文字にする
        header[0] = 'R'.code.toByte()
        header[1] = 'I'.code.toByte()
        header[2] = 'F'.code.toByte()
        header[3] = 'F'.code.toByte()

        // チャンクサイズ(4byte)。
        header[4] = (totalAudioLen and 0xff).toByte() // ファイルサイズ
        header[5] = (totalDataLen shr 8 and 0xff).toByte()
        header[6] = (totalDataLen shr 16 and 0xff).toByte()
        header[7] = (totalDataLen shr 24 and 0xff).toByte()

        // フォーマット(4byte)。wave を表したいので W A V E と入れる。
        header[8] = 'W'.code.toByte()
        header[9] = 'A'.code.toByte()
        header[10] = 'V'.code.toByte()
        header[11] = 'E'.code.toByte()

        // フォーマットチャンク(4byte)。f m t の3文字でよくて、残り余っている 1byte は空白で。
        header[12] = 'f'.code.toByte()
        header[13] = 'm'.code.toByte()
        header[14] = 't'.code.toByte()
        header[15] = ' '.code.toByte()

        // フォーマットサイズ(4byte)。ここではリニア PCM だけを考えればよく、16 を 1byte 目に入れて、残りは 0 で。
        header[16] = 16
        header[17] = 0
        header[18] = 0
        header[19] = 0

        // オーディオフォーマット(2byte)。非圧縮リニア PCM であるため 1 を入れて残りは 0 で。
        header[20] = 1
        header[21] = 0

        // チャンネル数(2byte)。モノラルは 1、ステレオは 2。ここではモノラルを指定している。
        header[22] = channels.toByte()
        header[23] = 0

        // サンプリングレート(4byte)。
        header[24] = (longSampleRate and 0xff).toByte()
        header[25] = (longSampleRate shr 8 and 0xff).toByte()
        header[26] = (longSampleRate shr 16 and 0xff).toByte()
        header[27] = (longSampleRate shr 24 and 0xff).toByte()

        // バイトレート(4byte)。
        header[28] = (byteRate and 0xff).toByte()
        header[29] = (byteRate shr 8 and 0xff).toByte()
        header[30] = (byteRate shr 16 and 0xff).toByte()
        header[31] = (byteRate shr 24 and 0xff).toByte()

        // ブロックのサイズ
        header[32] = blockAlign.toByte()
        header[33] = 0

        // ビットサンプル。
        header[34] = (dataElementSize and 0xff).toByte()
        header[35] = (dataElementSize shr 8 and 0xff).toByte()
        header[36] = 'd'.code.toByte()
        header[37] = 'a'.code.toByte()
        header[38] = 't'.code.toByte()
        header[39] = 'a'.code.toByte()
        header[40] = (totalAudioLen and 0xff).toByte()
        header[41] = (totalAudioLen shr 8 and 0xff).toByte()
        header[42] = (totalAudioLen shr 16 and 0xff).toByte()
        header[43] = (totalAudioLen shr 24 and 0xff).toByte()
        outputStream.write(header, 0, 44)
    }

そして、このままだとスレッドをブロックしてしまうのでコルーチンの利用を考えます。とりあえず丸っと record() 関数を suspend にしてしまいます。

suspend fun record() = runInterruptible {....

Screen で録音する関数を呼び出す

先立って録音ボタンのコールバック onRecordClicked() を仮実装していましたが、ここできちんと実装します。まず gradle に androidx.lifecycle:lifecycle-runtime-ktx を導入してください。

implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"

次に Screen を実装してみます。

class VoiceRecodingScreen(carContext: CarContext) : Screen(carContext) {
...
  private fun onRecordClicked() {
            if (carContext.checkSelfPermission(permission.RECORD_AUDIO)
                != PackageManager.PERMISSION_GRANTED
            ) {
                CarToast.makeText(
                    carContext, "マイクを使用するには権限が必要です",
                    CarToast.LENGTH_LONG
                ).show()
                carContext.requestPermissions(listOf(permission.RECORD_AUDIO)) { grantedPermissions, _ ->
                    if (grantedPermissions.contains(permission.RECORD_AUDIO)) {
                        onRecordClicked()
                    }
                }
                return
            }
        lifecycleScope.launch {
            withContext(Dispatchers.IO) {
                runCatching {
                    voiceRecorder.record()
                }.fold(
                    onSuccess = {
                        CarToast.makeText(
                            carContext, "録音が完了しました",
                            CarToast.LENGTH_LONG
                        ).show()
                    },
                    onFailure = {
                        CarToast.makeText(
                            carContext, "録音に失敗しました",
                            CarToast.LENGTH_LONG
                        ).show()
                    }
                )
            }
        }
    }

順を追って説明していくと、まずは権限があるかどうかをチェックしています。ここで権限がない場合は、ユーザに付与をお願いするダイアログを出すことになります。ダイアログはCarContext.requestPermissions() を使うことで簡単に実装できます。

Using the Android for Cars App Library  |  Android Developers

権限があることが確認できたら voiceRecorder.record() を呼ぶことができます。

class VoiceRecodingScreen(carContext: CarContext) : Screen(carContext) {
...
  lifecycleScope.launch {
            withContext(Dispatchers.IO) {
                runCatching {
                    voiceRecorder.record()
                }.fold(
                    onSuccess = {
                        CarToast.makeText(
                            carContext, "録音が完了しました",
                            CarToast.LENGTH_LONG
                        ).show()
                    },
                    onFailure = {
                        CarToast.makeText(
                            carContext, "録音に失敗しました",
                            CarToast.LENGTH_LONG
                        ).show()
                    }
                )
            }
        }

ここで lifecycleScope が登場しています。Android Auto の画面に投影する UI の Screen クラスは、Activity や Fragment などと同じく LifecycleOwner インターフェースを実装しています。そのため androidx.lifecyclelifecycleScope が使えるのです。

実行

DHU で実行してみます。

できていそう。

実際にファイルができているか、Android StudioDevice File Explorer で確認してみましょう。

できてますね。PC に転送して聞いてみましょう。

ここでちゃんと聞けていたら成功です。なお再生に失敗したり、砂嵐の音声になっている場合は wave ヘッダの記述ミスを疑ってみてください。公式のサンプル(https://github.com/android/car-samples)では wave ヘッダが間違っているらしく、PC で再生すると砂嵐の音声を聴かされることになります。プルリクを投げてみましたがまだみられていない模様です。

これを実車で動かす場合は、Play Console を使って内部テスト版にして端末にインストールしましょう。Android Auto が車からのマイク入力に対応していれば動くと思います。どの車で動くのかは情報が全然ないので、自分で試してみるのが一番手っ取り早いです。なお筆者の車は MAZDA3 で、動作することを確認しています。

まとめ

  • API Level 5 から、ドライバーの音声を処理できるという面白い API を利用できるようになりました。
  • 従来の Android アプリと同じく、音声パーミッションが必要です。権限のチェック及び付与を求めるダイアログ実装は簡単です。
  • ファイルに保存するコードは自力で書く必要があります。

ここにサンプルコードも作ってみたのでよかったら見てみてください。

github.com