Audio recording on Android
A walkthrough of recording audio on Android.
Permissions
Before you can record audio you need to get permission from the user. Android runtime permissions are more straightforward today than in the past.
Firstly declare the audio record permission in the app manifest.xml: <uses-permission android:name="android.permission.RECORD_AUDIO" /> then from your composable:
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
startRecording()
} else {
Toast.makeText(context, "Audio recording permission required", Toast.LENGTH_SHORT).show()
statusText = "Permission denied"
}
}
//From record button onClick():
fun onRecordClick() {
val permissionState = ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
)
if (permissionState == PackageManager.PERMISSION_GRANTED) {
startRecording()
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
AudioRecord
A barebones example showing the AudioRecord API flow. Here we write raw PCM frame data straight to file inside an oldschool while loop with an isRecording flag. All we need to do in a more complex use-case (filters, effects etc) is to manipulate that frame data before writing to file/output stream (for monitoring, or an effects processor/pedal). A simple callback passes the number of bytes recorded for display in the ui.
package yorkshire.systems.audiorecorddemo
import android.Manifest
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Process
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.atomic.AtomicBoolean
class AudioRecorder(private val context: Context) {
companion object {
const val SAMPLE_RATE = 44100
const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
}
private val isRecording = AtomicBoolean(false)
val outputFile: File
get() = File(context.cacheDir, "recording.pcm")
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
suspend fun startRecording(onBytesRecorded: (Long) -> Unit) = withContext(Dispatchers.IO) {
val minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
require(minBufferSize > 0) { "Invalid AudioRecord parameters: $minBufferSize" }
val bufferSize = maxOf(minBufferSize * 2, 4096)
val record = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AUDIO_FORMAT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(CHANNEL_CONFIG)
.build(),
)
.setBufferSizeInBytes(bufferSize)
.build()
val previousPriority = Process.getThreadPriority(Process.myTid())
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
isRecording.set(true)
record.startRecording()
try {
val buffer = ByteArray(bufferSize)
var totalBytes = 0L
FileOutputStream(outputFile).use { outputStream ->
while (isActive && isRecording.get()) {
val readBytes = record.read(buffer, 0, buffer.size)
if (readBytes > 0) {
outputStream.write(buffer, 0, readBytes)
totalBytes += readBytes
onBytesRecorded(totalBytes)
}
}
}
} finally {
isRecording.set(false)
if (record.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
record.stop()
}
record.release()
Process.setThreadPriority(previousPriority)
}
}
fun stopRecording() {
isRecording.set(false)
}
}
The challenge in any complex audio app is to make sure any processing and io in the while loop is complete before the native record buffer is full again. If the native buffer fills before you can read and process the bytes (a buffer overrun) it'll overwrite unread bytes and you end up with audible clicks and artifacts.
Thread priority
You should tell the OS that you're recording audio which prioritises the buffer reading thread and reduces buffer overrun:
record.startRecording()
withContext(Dispatchers.IO) {
val previousPriority = Process.getThreadPriority(Process.myTid())
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
then release that once recording is complete:
Process.setThreadPriority(previousPriority)
Single-thread dispatcher
The above code sets the audio priority on a shared pool of IO threads. Instead let's create our own private dedicated thread, this thread is owned by AudioRecorder so there's no risk of leaks when the process is destroyed, plus we don't need to reset the priority of the thread when we're done because it's always/only used for audio recording:
private val audioDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
Closeable and check
Implementing Closeable guarantees our thread is safely released no matter how the process is destroyed, either intentionally or from some exception.
The two check operations throw an exception if a recording error occurs or if a race condition tries to trigger a second recording.
Full updated demo (with the ui callback now passing more useful milliseconds instead of bytes):
package yorkshire.systems.audiorecorddemo
import android.Manifest
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Process
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.Closeable
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class AudioRecorder(private val context: Context) : Closeable {
companion object {
const val SAMPLE_RATE = 44100
const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
private const val BYTES_PER_SAMPLE = 2 // 16-bit PCM = 2 bytes
private const val CHANNEL_COUNT = 1 // Mono = 1 channel
private const val BYTES_PER_SECOND = SAMPLE_RATE * CHANNEL_COUNT * BYTES_PER_SAMPLE
}
private val audioDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val isRecording = AtomicBoolean(false)
val outputFile: File
get() = File(context.cacheDir, "recording.pcm")
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
suspend fun startRecording(onDurationUpdated: (Long) -> Unit) = withContext(audioDispatcher) {
val minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
require(minBufferSize > 0) { "Invalid AudioRecord parameters: $minBufferSize" }
val bufferSize = maxOf(minBufferSize * 2, 4096)
val record = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AUDIO_FORMAT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(CHANNEL_CONFIG)
.build(),
)
.setBufferSizeInBytes(bufferSize)
.build()
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
isRecording.set(true)
record.startRecording()
try {
val buffer = ByteArray(bufferSize)
var totalBytes = 0L
FileOutputStream(outputFile).use { outputStream ->
while (isActive && isRecording.get()) {
val readBytes = record.read(buffer, 0, buffer.size)
if (readBytes > 0) {
outputStream.write(buffer, 0, readBytes)
totalBytes += readBytes
val durationMs = (totalBytes * 1000L) / BYTES_PER_SECOND
onDurationUpdated(durationMs)
}
}
}
} finally {
isRecording.set(false)
if (record.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
record.stop()
}
record.release()
}
}
fun stopRecording() {
isRecording.set(false)
}
override fun close() {
stopRecording()
audioDispatcher.close()
}
}
and a full MainActivity.kt
package yorkshire.systems.audiorecorddemo
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import kotlinx.coroutines.launch
import yorkshire.systems.audiorecorddemo.ui.theme.AudioRecordDemoTheme
import java.util.Locale
class MainActivity : ComponentActivity() {
private lateinit var audioRecorder: AudioRecorder
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
audioRecorder = AudioRecorder(applicationContext)
setContent {
AudioRecordDemoTheme {
AudioRecordScreen(audioRecorder = audioRecorder)
}
}
}
override fun onDestroy() {
super.onDestroy()
if (::audioRecorder.isInitialized) {
audioRecorder.close()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecordScreen(audioRecorder: AudioRecorder) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isRecording by remember { mutableStateOf(false) }
var durationRecordedMs by remember { mutableLongStateOf(0L) }
var statusText by remember { mutableStateOf("Idle") }
fun startRecording() {
isRecording = true
durationRecordedMs = 0L
statusText = "Recording..."
scope.launch {
try {
audioRecorder.startRecording { millis ->
durationRecordedMs = millis
}
} catch (e: SecurityException) {
statusText = "Error: Permission denied (${e.localizedMessage})"
isRecording = false
} catch (e: Exception) {
statusText = "Error recording audio: ${e.localizedMessage}"
isRecording = false
}
}
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
startRecording()
} else {
Toast.makeText(context, "Audio recording permission required", Toast.LENGTH_SHORT)
.show()
statusText = "Permission denied"
}
}
fun onRecordClick() {
val permissionState = ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
)
if (permissionState == PackageManager.PERMISSION_GRANTED) {
startRecording()
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
fun onStopClick() {
audioRecorder.stopRecording()
isRecording = false
val file = audioRecorder.outputFile
statusText = if (file.exists() && file.length() > 0) {
"Audio saved to ${file.name}"
} else {
"File not saved, that's all I know"
}
}
DisposableEffect(Unit) {
onDispose {
audioRecorder.stopRecording()
}
}
Column(
modifier = Modifier.padding(16.dp)
) {
Text(text = statusText)
if (isRecording) {
Spacer(modifier = Modifier.height(8.dp))
val totalSeconds = durationRecordedMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
val formattedTime = String.format(Locale.US, "%02d:%02d", minutes, seconds)
Text(
text = "$formattedTime ($durationRecordedMs ms)"
)
}
Button(
onClick = {
when {
isRecording -> onStopClick()
else -> onRecordClick()
}
}
) {
Text(text = when {
isRecording -> "Stop"
else -> "Record"
})
}
}
}