CameraXフォークを基にTHINKLETで5ch音声録音をしてみる
はじめに
こんにちは、Fairy Devices株式会社プロダクト開発部の中嶋です。
以前、当社Publicationの記事でCameraXフォークについて紹介しました。
本記事は、そのCameraXフォークを応用し、THINKLETでの動画撮影と並行して、
5ch音声データ同時に取得・保存する方法をサンプルコードを交えながら説明します。
背景
当社では、THINKLETの5chマイクをフルに活用するため、CameraXをフォークしたライブラリをリリースしました。
その後、動画撮影と同時に5chの音声データも取得したいというお声をいただきました。
一般的に、Android/AOSPの仕様上、MP4には1chまたは2chの音声しか保存できません。
そこで、CameraXフォークを応用して、5chの生音声データを別途取得できるように試作しました。
音声データの流れと処理のイメージとしては、以下の図の様になります。(※図内の矢印は音声データの流れと各処理前時でのチャンネル数を示します。)
それでは、実装例を順に見ていきましょう。
実装例
下記の「マルチマイク録画 + THINKLET Vision アプリ」に5ch音声ファイル出力機能を追加する例を示します。
概要
最初に、CameraXフォークについて説明します。
CameraXフォークでは、既存のCameraXをフォークして、新たに ThinkletMic Interface を追加し、CameraXの音声取り込み機能を外出しできるように改造しています。つまり、任意のマイクや加工した音声をCameraX Recorderの入力音声として使用することができます。
「マルチマイク録画 + THINKLET Vision アプリ」では、ライブラリとして公開しているマイクモジュール(ThinkletMics.XFE
など)を使用しています。
本実装例では、新たに5ch音声データを出力できるマイクモジュールを追加します。
具体的に追加する項目の概要は以下の通りです。
- 5ch音声出力対応版マイクモジュールの新規実装
- 5ch音声データをファイル書き込みするクラスの新規実装
-
ThinkletRecorder
クラスで新規実装した5ch音声出力対応版マイクモジュールを使うように修正 -
RecorderState
クラスで新規実装した5ch音声出力対応版マイクモジュールを使うように修正- 新たに、ビルド設定値
micType
にraw
を追加します。 - ビルド設定値
micType
がraw
である場合、5ch音声出力対応版マイクモジュールを動画撮影時の音声入力として使用します。 - ビルド設定値
micType
が5ch
またはxfe
である場合は既存の指定通りのマイクモジュールを動画撮影時の音声入力として使用します。
- 新たに、ビルド設定値
図に表すと下図のような状態となります。
最終的なアプリケーションでは、動画撮影後に以下のファイルを出力します。
- MP4形式の動画ファイルはこれまで通り1chの音声で出力される。
- ビルド設定値
micType
がraw
である時に、(動画ファイルとは別ファイルとして)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
を定義します。
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(全文)
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の活用事例としては、以下の記事でも記載があります。
ソースコード全文は以下の様になります。
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
で定義したsetCallback
をcallbackFlowに設定することで、取り出した圧縮前の音声データを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(差分)
@@ -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クラスに、RawAudioRecCaptureRepository
とThinkletAudioRecordWrapperRepository
を追加します。
変更後の差分全体は以下の通りです。
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音声データのファイルパス定義と、ビルド設定値micType
がraw
である時に、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
でビルド設定値micType
をraw
に設定します。
@@ -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を指定します。
6.Audacity上で5ch音声として再生できることを確認します。
おわりに
今回は、CameraXフォークを応用して、THINKLETでの5ch音声録音をアプリケーションに実装する方法について説明しました。動画とTHINKLET装着者視点での5ch音声を組み合わせることにより、音響解析等で役立つ場面が多々あると思われます。本記事を参考にご活用くだされば幸いです。
Discussion