🔈

CameraXフォークを基にTHINKLETで5ch音声録音をしてみる

に公開

はじめに

こんにちは、Fairy Devices株式会社プロダクト開発部の中嶋です。

以前、当社Publicationの記事でCameraXフォークについて紹介しました。

https://zenn.dev/fairydevices/articles/653be5d32cec55

本記事は、そのCameraXフォークを応用し、THINKLETでの動画撮影と並行して、
5ch音声データ同時に取得・保存する方法をサンプルコードを交えながら説明します。

背景

当社では、THINKLETの5chマイクをフルに活用するため、CameraXをフォークしたライブラリをリリースしました。
その後、動画撮影と同時に5chの音声データも取得したいというお声をいただきました。
一般的に、Android/AOSPの仕様上、MP4には1chまたは2chの音声しか保存できません。
そこで、CameraXフォークを応用して、5chの生音声データを別途取得できるように試作しました。
音声データの流れと処理のイメージとしては、以下の図の様になります。(※図内の矢印は音声データの流れと各処理前時でのチャンネル数を示します。)

それでは、実装例を順に見ていきましょう。

実装例

下記の「マルチマイク録画 + THINKLET Vision アプリ」に5ch音声ファイル出力機能を追加する例を示します。

https://github.com/FairyDevicesRD/thinklet.video.recorder

概要

最初に、CameraXフォークについて説明します。

CameraXフォークでは、既存のCameraXをフォークして、新たに ThinkletMic Interface を追加し、CameraXの音声取り込み機能を外出しできるように改造しています。つまり、任意のマイクや加工した音声をCameraX Recorderの入力音声として使用することができます。
「マルチマイク録画 + THINKLET Vision アプリ」では、ライブラリとして公開しているマイクモジュール(ThinkletMics.XFEなど)を使用しています。
本実装例では、新たに5ch音声データを出力できるマイクモジュールを追加します。

具体的に追加する項目の概要は以下の通りです。

  • 5ch音声出力対応版マイクモジュールの新規実装
  • 5ch音声データをファイル書き込みするクラスの新規実装
  • ThinkletRecorderクラスで新規実装した5ch音声出力対応版マイクモジュールを使うように修正
  • RecorderStateクラスで新規実装した5ch音声出力対応版マイクモジュールを使うように修正
    • 新たに、ビルド設定値micTyperawを追加します。
    • ビルド設定値micTyperawである場合、5ch音声出力対応版マイクモジュールを動画撮影時の音声入力として使用します。
    • ビルド設定値micType5chまたはxfeである場合は既存の指定通りのマイクモジュールを動画撮影時の音声入力として使用します。

図に表すと下図のような状態となります。

最終的なアプリケーションでは、動画撮影後に以下のファイルを出力します。

  • MP4形式の動画ファイルはこれまで通り1chの音声で出力される。
  • ビルド設定値micTyperawである時に、(動画ファイルとは別ファイルとして)5ch音声データをRAWファイルとして出力する。

ディレクトリ構成

app/src/main/java/com/example/fd/video/recorderを基準とした、最終的なファイル構成は以下の通りです。
本実装例で新たに追加するファイルには(新規)、追加・変更をして差分が生じるファイルには(改修)と記載しています。変更箇所の無いディレクトリは省略しています。

.
├── camerax
│   ├── impl (新規)
│   │   └── ThinkletAudioRecordWrapperRepositoryImpl.kt (新規)
│   ├── RawAudioRecCaptureRepository.kt (新規)
│   ├── ThinkletAudioRecordWrapperRepository.kt (新規)
│   └── ThinkletRecorder.kt (改修)
├── MainActivity.kt
├── RecorderState.kt (改修)

5ch音声出力対応版マイクモジュールの実装

CameraXフォークのMultiChannelAudioRecordWrapperを基に、5ch音声出力対応版マイクモジュールの実装をしていきます。
圧縮前の5ch音声データをListenerに渡すように改造します。
まずは、interfaceである、ThinkletAudioRecordWrapperRepository.ktを定義します。

ThinkletAudioRecordWrapperRepository.kt
package com.example.fd.video.recorder.camerax

import ai.fd.thinklet.camerax.ThinkletAudioRecordWrapperFactory

interface ThinkletAudioRecordWrapperRepository : ThinkletAudioRecordWrapperFactory {
    fun interface Listener {
        fun onRawAudioData(data: ByteArray)
    }

    fun setCallback(listener: Listener?)
}

以下2つのメソッドを定義しています。
圧縮前の5ch音声データを受け取るListener
Listenerから受け取ったデータを読み出すsetCallback

次に、ThinkletAudioRecordWrapperRepository Interface を実装した ThinkletAudioRecordWrapperRepositoryImpl を作成します。
全文は以下のようになります。

impl/ThinkletAudioRecordWrapperRepositoryImpl.kt(全文)
impl/ThinkletAudioRecordWrapperRepositoryImpl.kt
package com.example.fd.video.recorder.camerax.impl

import ai.fd.thinklet.camerax.mic.multichannel.MultiChannelAudioCompressor
import ai.fd.thinklet.sdk.audio.MultiChannelAudioRecord
import ai.fd.thinklet.sdk.audio.MultiChannelAudioRecord.Channel
import ai.fd.thinklet.sdk.audio.MultiChannelAudioRecord.SampleRate
import android.Manifest
import android.media.AudioManager.AudioRecordingCallback
import android.media.AudioRecord
import android.media.AudioRecordingConfiguration
import android.media.AudioTimestamp
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import androidx.camera.video.audio.wrapper.AudioRecordWrapper
import com.example.fd.video.recorder.camerax.ThinkletAudioRecordWrapperRepository
import com.example.fd.video.recorder.util.Logging
import java.nio.ByteBuffer
import java.util.concurrent.Executor
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.min

internal class ThinkletAudioRecordWrapperRepositoryImpl(
    private val sourceChannelCount: Channel = Channel.CHANNEL_FIVE,
) : ThinkletAudioRecordWrapperRepository {

    private val listenerLock = ReentrantLock()
    private var listener: ThinkletAudioRecordWrapperRepository.Listener? = null

    override fun setCallback(listener: ThinkletAudioRecordWrapperRepository.Listener?) {
        listenerLock.withLock { this.listener = listener }
    }

    @RequiresPermission(Manifest.permission.RECORD_AUDIO)
    override fun create(
        audioSource: Int, audioFormat: Int, channelCount: Int,
        sampleRate: Int
    ): AudioRecordWrapper {
        val audioRecordWithBufferSize = MultiChannelAudioRecord().get(
            sourceChannelCount,
            getMultiChannelAudioRecordSampleRate(sampleRate)
        )
        return MultiChannelAudioRecordWrapper(
            { listenerLock.withLock { listener } },
            audioRecordWithBufferSize.audioRecord,
            sourceChannelCount,
            audioRecordWithBufferSize.bufferSize,
            channelCount == 2
        )
    }

    private fun getMultiChannelAudioRecordSampleRate(sampleRate: Int): SampleRate =
        when (sampleRate) {
            16000 -> SampleRate.SAMPLING_RATE_16000
            32000 -> SampleRate.SAMPLING_RATE_32000
            48000 -> SampleRate.SAMPLING_RATE_48000
            else -> throw IllegalArgumentException("Unsupported sample rate. $sampleRate")
        }
}

internal class MultiChannelAudioRecordWrapper(
    private val listener: () -> ThinkletAudioRecordWrapperRepository.Listener?,
    private val audioRecord: AudioRecord,
    private val sourceChannelCount: Channel,
    private val sourceBufferSize: Int,
    private val isStereoOutput: Boolean
) : AudioRecordWrapper() {

    override fun getState(): Int = audioRecord.state

    override fun release() = audioRecord.release()

    override fun startRecording() = audioRecord.startRecording()

    override fun getRecordingState(): Int = audioRecord.recordingState

    override fun stop() = audioRecord.stop()

    override fun read(byteBuffer: ByteBuffer, bufferSize: Int): Int {
        val sourceBuffer = ByteArray(sourceBufferSize)
        val readCount = audioRecord.read(sourceBuffer, 0, sourceBufferSize)
        if (readCount > 0) {
            listener()?.onRawAudioData(sourceBuffer)
            return compressChannel(
                sourceBuffer, sourceChannelCount, isStereoOutput, byteBuffer,
                bufferSize
            )
        }
        Logging.w("Read data from multi channel audio is empty!")
        return 0
    }

    override fun getAudioSessionId(): Int = audioRecord.audioSessionId

    override fun getTimestamp(audioTimestamp: AudioTimestamp, timeBase: Int): Int =
        audioRecord.getTimestamp(audioTimestamp, timeBase)

    @RequiresApi(29)
    override fun getActiveRecordingConfiguration(): AudioRecordingConfiguration? =
        audioRecord.activeRecordingConfiguration

    @RequiresApi(29)
    override fun registerAudioRecordingCallback(
        executor: Executor,
        callback: AudioRecordingCallback
    ) = audioRecord.registerAudioRecordingCallback(executor, callback)

    @RequiresApi(29)
    override fun unregisterAudioRecordingCallback(callback: AudioRecordingCallback) =
        audioRecord.unregisterAudioRecordingCallback(callback)

    companion object {
        private fun compressChannel(
            sourceByteArray: ByteArray,
            sourceChannelCount: Channel,
            isStereoOutput: Boolean,
            outByteBuffer: ByteBuffer,
            outBufferSize: Int
        ): Int {
            val compressedData = MultiChannelAudioCompressor
                .compressPcm16bitAudio(sourceByteArray, sourceChannelCount, isStereoOutput)
            if (compressedData == null) {
                Logging.w("Compressed data is empty!")
                return 0
            }
            val writtenDataSize = min(compressedData.size, outBufferSize)
            outByteBuffer.put(compressedData, 0, writtenDataSize)
            return writtenDataSize
        }
    }
}

要点を抜粋すると、read()コールバック内にて、収録された音声が圧縮処理される前段でListenerに圧縮前の音声データを渡します。

    override fun read(byteBuffer: ByteBuffer, bufferSize: Int): Int {
        val sourceBuffer = ByteArray(sourceBufferSize)
        val readCount = audioRecord.read(sourceBuffer, 0, sourceBufferSize)
        if (readCount > 0) {
            listener()?.onRawAudioData(sourceBuffer)
            return compressChannel(
                sourceBuffer, sourceChannelCount, isStereoOutput, byteBuffer,
                bufferSize
            )
        }
        Logging.w("Read data from multi channel audio is empty!")
        return 0
    }

5ch音声データをファイル書き込みするクラスの実装

RawAudioRecCaptureRepositoryクラスを作成し、動画撮影時に5ch音声データをRAWファイルに書き込む処理を実装します。
Flowを使用することで、非同期ストリーム処理でファイル書き込みを行います。
Flowの活用事例としては、以下の記事でも記載があります。

https://zenn.dev/fairydevices/articles/b2bb6932918fb1#kotlin-flow-の活用

ソースコード全文は以下の様になります。

RawAudioRecCaptureRepository.kt (全文)
RawAudioRecCaptureRepository.kt
package com.example.fd.video.recorder.camerax

import com.example.fd.video.recorder.util.Logging
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException

internal class RawAudioRecCaptureRepository(
    private val coroutineScope: CoroutineScope,
    private val audioRecordWrapperRepository: ThinkletAudioRecordWrapperRepository
) {
    private var recordingJob: Job? = null

    fun startRecording(
        outputFile: File,
    ) {
        stopRecording()
        if (!outputFile.createNewFile()) throw IOException("Failed to create output file: ${outputFile.absolutePath}")

        recordingJob = coroutineScope.launch {
            try {
                val audioDataFlow = createAudioDataFlow()
                audioDataFlow.collect { writeToFile(outputFile, it) }
            } catch (e: CancellationException) {
                throw e
            } catch (e: IOException) {
                Logging.e("Recording failed $e")
            }
        }
    }

    fun stopRecording() {
        recordingJob?.cancel()
        recordingJob = null
        audioRecordWrapperRepository.setCallback(null)
    }

    private fun createAudioDataFlow(): Flow<ByteArray> = callbackFlow {
        audioRecordWrapperRepository.setCallback { data ->
            if (data.isNotEmpty()) {
                trySend(data.copyOf())
            }
        }

        awaitClose {
            audioRecordWrapperRepository.setCallback(null)
        }
    }

    private fun writeToFile(outputFile: File, data: ByteArray) {
        try {
            if (outputFile.exists()) {
                outputFile.appendBytes(data)
            }
        } catch (e: IOException) {
            Logging.e("Failed to write audio data $e")
        }
    }
}

要点を抜粋すると、先ほどThinkletAudioRecordWrapperRepositoryで定義したsetCallbackcallbackFlowに設定することで、取り出した圧縮前の音声データをFlowに送信しています。

    private fun createAudioDataFlow(): Flow<ByteArray> = callbackFlow {
        audioRecordWrapperRepository.setCallback { data ->
            if (data.isNotEmpty()) {
                trySend(data.copyOf())
            }
        }

        awaitClose {
            audioRecordWrapperRepository.setCallback(null)
        }
    }

そして、Flowからファイル書き込みのストリーム処理を行います。

        recordingJob = coroutineScope.launch {
            try {
                val audioDataFlow = createAudioDataFlow()
                audioDataFlow.collect { writeToFile(outputFile, it) }
            } catch (e: CancellationException) {
                throw e
            } catch (e: IOException) {
                Logging.e("Recording failed $e")
            }
        }

ThinkletRecorderクラスに5ch音声データ出力機能を追加する

ThinkletRecorderクラスに先ほど実装したRawAudioRecCaptureRepositoryを追加し、動画撮影に合わせて5ch音声ファイル出力もされるように改修します。
こちらは変更箇所の差分を折りたたみ表示しますので、適宜ご参照ください。

ThinkletRecorder.kt(差分)
ThinkletRecorder.kt
@@ -41,6 +41,7 @@ internal class ThinkletRecorder private constructor(
     private val context: Context,
     private val recorder: Recorder,
     private val recordEventListener: (VideoRecordEvent) -> Unit,
+    private val rawAudioRecCaptureRepository: RawAudioRecCaptureRepository,
     private val recorderListenerExecutor: ExecutorService = Executors.newSingleThreadExecutor(),
     private val fileSize: Long = BuildConfig.FILE_SIZE
 ) {
@@ -50,7 +51,7 @@ internal class ThinkletRecorder private constructor(
     private var recording: Recording? = null

     @RequiresPermission(Manifest.permission.RECORD_AUDIO)
-    fun startRecording(outputFile: File): Boolean = recordingLock.withLock {
+    fun startRecording(outputFile: File, outputAudioFile: File): Boolean = recordingLock.withLock {
         if (recording != null) {
             Logging.w("already recording")
             return false
@@ -75,6 +76,7 @@ internal class ThinkletRecorder private constructor(
             e.printStackTrace()
             return false
         }
+        rawAudioRecCaptureRepository.startRecording(outputAudioFile)
         return true
     }
@@ -90,6 +92,7 @@ internal class ThinkletRecorder private constructor(
     fun requestStop() {
         recordingLock.withLock {
             recording?.close()
+            rawAudioRecCaptureRepository.stopRecording()
         }
     }

@@ -107,6 +110,7 @@ internal class ThinkletRecorder private constructor(
          * @param analyzer カメラAnalyzer
          * @param previewSurfaceProvider プレビューを表示する[PreviewView]から取得した[Preview.SurfaceProvider]
          * @param recordEventListener CameraX側からの[VideoRecordEvent]イベントを受け取るリスナー
+         * @param rawAudioRecCaptureRepository 5ch音声の録音を行う[RawAudioRecCaptureRepository]
          * @param recorderExecutor [recordEventListener]の実行スレッドを指定する[ExecutorService]
          */
         @MainThread
@@ -117,6 +121,7 @@ internal class ThinkletRecorder private constructor(
             analyzer: ImageAnalysis.Analyzer?,
             previewSurfaceProvider: Preview.SurfaceProvider? = null,
             recordEventListener: (VideoRecordEvent) -> Unit = {},
+            rawAudioRecCaptureRepository: RawAudioRecCaptureRepository,
             recorderExecutor: ExecutorService = Executors.newSingleThreadExecutor()
         ): ThinkletRecorder? {
             CameraXPatch.apply()
@@ -150,7 +155,12 @@ internal class ThinkletRecorder private constructor(

             val cameraProvider = ProcessCameraProvider.getInstance(context).await()
             bind(cameraProvider, lifecycleOwner, useCaseGroup)
-            return ThinkletRecorder(context, recorder, recordEventListener)
+            return ThinkletRecorder(
+                context,
+                recorder,
+                recordEventListener,
+                rawAudioRecCaptureRepository
+            )
         }

         @MainThread

RecorderStateクラスに5ch音声出力対応版マイクモジュール選択機能を追加する

RecorderStateクラスに、RawAudioRecCaptureRepositoryThinkletAudioRecordWrapperRepositoryを追加します。
変更後の差分全体は以下の通りです。

RecorderState.kt(差分)
RecorderState.kt
@@ -1,5 +1,7 @@
 package com.example.fd.video.recorder

+import ai.fd.thinklet.camerax.ThinkletAudioRecordWrapperFactory
+import ai.fd.thinklet.camerax.ThinkletAudioSettingsPatcher
 import ai.fd.thinklet.camerax.ThinkletMic
 import ai.fd.thinklet.camerax.mic.ThinkletMics
 import ai.fd.thinklet.camerax.mic.multichannel.FiveCh
@@ -26,7 +28,10 @@ import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
+import com.example.fd.video.recorder.camerax.RawAudioRecCaptureRepository
 import com.example.fd.video.recorder.camerax.ThinkletRecorder
+import com.example.fd.video.recorder.camerax.impl.ThinkletAudioRecordWrapperRepositoryImpl
+import com.example.fd.video.recorder.util.Logging
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.awaitCancellation
 import kotlinx.coroutines.launch
@@ -69,6 +74,12 @@ class RecorderState(

     private val vision: Vision? = if (enableVision) Vision() else null

+    private val thinkletAudioRecordWrapperRepository = ThinkletAudioRecordWrapperRepositoryImpl()
+    private val rawAudioRecCaptureRepository = RawAudioRecCaptureRepository(
+        coroutineScope = lifecycleOwner.lifecycleScope,
+        audioRecordWrapperRepository = thinkletAudioRecordWrapperRepository,
+    )
+
     init {
         lifecycleOwner.lifecycleScope.launch {
             lifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
@@ -120,7 +131,8 @@ class RecorderState(
                     mic = micType(),
                     analyzer = vision,
                     previewSurfaceProvider = surfaceProvider,
-                    recordEventListener = ::handleRecordEvent
+                    rawAudioRecCaptureRepository = rawAudioRecCaptureRepository,
+                    recordEventListener = ::handleRecordEvent,
                 )
             }
         }
@@ -154,7 +166,11 @@ class RecorderState(
         withContext(Dispatchers.Main) {
             Toast.makeText(context, "StartRecord: ${file.absoluteFile}", Toast.LENGTH_LONG).show()
         }
-        this.startRecording(file)
+        val audioFile = File(
+            context.getExternalFilesDir(null),
+            "${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.JAPAN).format(Date())}.raw"
+        )
+        this.startRecording(file, audioFile)
     }

     @WorkerThread
@@ -199,10 +215,23 @@ class RecorderState(
         return when (mic) {
             "5ch" -> ThinkletMics.FiveCh
             "xfe" -> ThinkletMics.Xfe(checkNotNull(context.getSystemService<AudioManager>()))
+            "raw" -> buildThinkletMic(thinkletAudioRecordWrapperRepository)
             else -> null
         }
     }

+    private fun buildThinkletMic(
+        thinkletAudioRecordWrapperRepository: ThinkletAudioRecordWrapperFactory,
+    ): ThinkletMic {
+        Logging.d("RawAudioEnabled!")
+        return object : ThinkletMic {
+            override fun getAudioSettingsPatcher(): ThinkletAudioSettingsPatcher? = null
+
+            override fun getAudioRecordWrapperFactory(): ThinkletAudioRecordWrapperFactory? =
+                thinkletAudioRecordWrapperRepository
+        }
+    }
+
     companion object {
         /**
          * カメラが横向きかどうかを判定する

要点を抜粋すると、5ch音声データのファイルパス定義と、ビルド設定値micTyperawである時に、5ch音声出力対応版マイクモジュールを使用する設定を追加します。

         return when (mic) {
             "5ch" -> ThinkletMics.FiveCh
             "xfe" -> ThinkletMics.Xfe(checkNotNull(context.getSystemService<AudioManager>()))
+            "raw" -> buildThinkletMic(thinkletAudioRecordWrapperRepository)
             else -> null
         }
     }

+    private fun buildThinkletMic(
+        thinkletAudioRecordWrapperRepository: ThinkletAudioRecordWrapperFactory,
+    ): ThinkletMic {
+        Logging.d("RawAudioEnabled!")
+        return object : ThinkletMic {
+            override fun getAudioSettingsPatcher(): ThinkletAudioSettingsPatcher? = null
+
+            override fun getAudioRecordWrapperFactory(): ThinkletAudioRecordWrapperFactory? =
+                thinkletAudioRecordWrapperRepository
+        }
+    }

ビルド設定値の指定

app/build.gradle.ktsでビルド設定値micTyperawに設定します。

app/build.gradle.kts
@@ -21,7 +21,7 @@ android {
         // 録画に使うマイクのタイプ
-        val micType = "xfe" // or "5ch" or "xfe" or "normal"
+        val micType = "raw" // or "5ch" or "xfe" or "raw" or "normal"

動作確認方法

1.実装したアプリケーションをビルドし、THINKLETへインストールします。

以下のコマンドまたはAndroidStudioから実行してください。

#コマンドでビルドとインストールを行う場合
./gradlew installDebug

2.インストールしたアプリケーションで録画を行います。

3.THINKLET内の動画ファイル(.mp4)と同じディレクトリ階層に、5ch音声ファイル(.raw)が存在することを確認します。

#コマンド例
adb shell ls /sdcard/Android/data/com.example.fd.video.recorder/files/

4.adbコマンドで、5ch音声ファイル(.raw)をPC側にコピーします。

#コマンド例
adb pull /sdcard/Android/data/com.example.fd.video.recorder/files/ {PC側で保存したいディレクトリ名}

5.Audacityで5ch音声ファイル(.raw)をインポートします。この時、チャンネル数5チャンネル、サンプリング周波数48000Hzを指定します。

https://www.audacityteam.org/

6.Audacity上で5ch音声として再生できることを確認します。

おわりに

今回は、CameraXフォークを応用して、THINKLETでの5ch音声録音をアプリケーションに実装する方法について説明しました。動画とTHINKLET装着者視点での5ch音声を組み合わせることにより、音響解析等で役立つ場面が多々あると思われます。本記事を参考にご活用くだされば幸いです。

フェアリーデバイセズ公式

Discussion