[Android] 013. AudioRecord

Jetpack Compose向けにリアルタイムバッファ処理用途での録音実装したことなかったので実装してみました
callback頻度を100ms程度としてます

<uses-permission android:name="android.permission.RECORD_AUDIO" />
class AudioRecorder {
    companion object {
        const val AUDIO_SOURCE = MediaRecorder.AudioSource.MIC
        const val SAMPLE_RATE = 16000
        const val CHANNEL = AudioFormat.CHANNEL_IN_MONO
        const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
    }

    lateinit var audioRecord: AudioRecord
        private set
    val _isRecording = MutableStateFlow(false)
    val isRecording = _isRecording.asStateFlow()

    private val bufferSize = AudioRecord.getMinBufferSize(
        SAMPLE_RATE,
        CHANNEL,
        AUDIO_FORMAT
    )
    // 100ms/1frameとする
    private val frameSize = SAMPLE_RATE * Short.SIZE_BYTES / 10
    private var frame = ByteArray(0)
    private var index = 0L

    fun start(context: Context, callback: (buffer: ByteArray, size: Int, index: Long) -> Unit): Boolean {
        stop()

        if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
            audioRecord = AudioRecord(
                AUDIO_SOURCE,
                SAMPLE_RATE,
                CHANNEL,
                AUDIO_FORMAT,
                bufferSize
            )

            CoroutineScope(Job() + Dispatchers.IO).launch {
                capture(callback)
            }
            return true
        }
        return false
    }

    fun stop() {
        if (isRecording.value) {
            audioRecord.stop()
            audioRecord.release()
        }
    }

    private suspend fun capture(callback: (buffer: ByteArray, size: Int, index: Long) -> Unit) = coroutineScope {
        audioRecord.startRecording()
        _isRecording.update { true }
        index = 0L
        frame = ByteArray(0)

        while (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
            val buffer = ByteArray(bufferSize)
            val size = audioRecord.read(buffer, 0, bufferSize)
            if (size == 0) continue

            val copy = if (frameSize - frame.size > size) size else frameSize - frame.size
            frame = frame + buffer.copyOfRange(0, copy)
            if (frame.size < frameSize) continue

            callback(frame, frame.size, index++)
            frame = if (copy >= size) ByteArray(0) else buffer.copyOfRange(copy, size)
        }

        if (frame.isNotEmpty()) callback(frame, frame.size, index)
        _isRecording.update { false }
    }
}
@Composable
fun Screen(modifier: Modifier = Modifier) {
    val recorder = AudioRecorder()
    val context = LocalContext.current
    val isRecording by recorder.isRecording.collectAsState()

    Row(modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
        Button(onClick = {
            if (!recorder.isRecording.value) {
                recorder.start(context) { buffer, size, index ->
                    Log.d("AudioRecorder", "$index $size")
                }
            }
            else {
                recorder.stop()
            }
        }) {
            Text(if (isRecording) "stop" else "start")
        }
    }
}

ぱーみっしょんちぇっく こぴぺこーど
※ Manifest.permission.RECORD_AUDIOではなくandroid.Manifest.permission.RECORD_AUDIOを参照

val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted ->
    Log.d("RequestPermission", "$isGranted")
}

if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
    Log.d("checkSelfPermission", "ok!")
} else {
    requestPermissionLauncher.launch(android.Manifest.permission.RECORD_AUDIO)
}

Android Studio Koala 2024.1.1 Patch 1 built on July 11, 2024