Recording Raw PCM on Android With AudioRecord (and Playing It Back)
Android offers two main ways to record audio. MediaRecorder gives you a finished, compressed file, such as AAC in an MP4 container, and hides the samples from you. AudioRecord gives you the raw samples as they arrive from the microphone. That is what you need for speech recognition, audio analysis, custom codecs, streaming to a server or anything else that processes sound in real time.
The cost is that AudioRecord produces headerless PCM. If you write those bytes to a file, you get a .pcm file that no media player will open. This guide covers capturing it correctly, the most common bug that corrupts it, and how to check the result.
Settings That Work on Every Device
Android hardware varies widely, and AudioRecord will refuse configurations a device does not support. The Android documentation guarantees only a small set:
- Sample rate: 44,100 Hz is the only rate guaranteed on all devices. 48,000, 16,000, 22,050 and 11,025 Hz work on most devices but are not promised.
- Channel:
AudioFormat.CHANNEL_IN_MONOis guaranteed. Stereo input depends on the device having more than one microphone exposed. - Encoding:
AudioFormat.ENCODING_PCM_16BITis guaranteed.ENCODING_PCM_8BIT(unsigned) andENCODING_PCM_FLOAT(API 23 and later) are optional.
If you need 16 kHz for a speech engine and want it to work everywhere, the robust approach is to record at 44,100 Hz and resample in your own code. The alternative is to try 16,000 Hz and fall back when getMinBufferSize returns an error or the recorder's state is not STATE_INITIALIZED.
Pick the audio source deliberately as well. MediaRecorder.AudioSource.MIC may apply the device's own processing. VOICE_RECOGNITION is meant to give a cleaner signal for speech engines. UNPROCESSED (API 24 and later, where supported) skips processing entirely.
A Minimal Recording Loop
This Kotlin sketch records 16-bit mono PCM at 44.1 kHz to a file in the app's external files directory. It assumes the RECORD_AUDIO permission has already been granted and that it runs on a background thread:
val sampleRate = 44100
val channel = AudioFormat.CHANNEL_IN_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioRecord.getMinBufferSize(sampleRate, channel, encoding)
val recorder = AudioRecord(
MediaRecorder.AudioSource.MIC, sampleRate, channel, encoding, minBuffer * 2
)
val buffer = ByteArray(minBuffer)
val file = File(context.getExternalFilesDir(null), "capture.pcm")
FileOutputStream(file).use { out ->
recorder.startRecording()
while (isRecording) {
val read = recorder.read(buffer, 0, buffer.size)
if (read > 0) out.write(buffer, 0, read)
}
recorder.stop()
}
recorder.release()Two details matter here. First, the internal buffer is set to twice the minimum size, which gives the loop some slack. If the loop falls behind, the hardware buffer overflows and samples are lost. You hear this as regular clicks or tiny gaps. Second, the bytes from read(ByteArray, ...) are written exactly as they arrive. That turns out to be important.
The Byte-Order Bug That Turns Recordings Into Static
AudioRecord.read can also fill a ShortArray, which is convenient when you want to inspect the samples. The problem starts when that array is written to a file with DataOutputStream.writeShort():
// Produces BIG-endian output
val shorts = ShortArray(minBuffer / 2)
recorder.read(shorts, 0, shorts.size)
for (s in shorts) dataOut.writeShort(s.toInt())Java's DataOutputStream always writes big-endian, which is network byte order. Android devices, like almost every desktop tool, are little-endian. The file looks normal and has the right size, but any player that expects standard little-endian 16-bit PCM plays it as loud static with the real audio faintly audible underneath. This is the classic symptom of swapped bytes.
There are three ways to fix it:
- Read into a
ByteArrayand write it unchanged, as in the earlier example. The bytes stay in the device's native order. - Convert the shorts with a
ByteBufferset toByteOrder.LITTLE_ENDIANbefore writing. - If you already have big-endian files, convert them with
ffmpeg -f s16be -ar 44100 -ac 1 -i capture.pcm fixed.wav.
Getting the File Off the Device and Checking It
Files in getExternalFilesDir(null) can be pulled over ADB without root:
adb pull /sdcard/Android/data/your.package.name/files/capture.pcmCheck the size first. At 44,100 Hz, 16-bit mono, the file grows by 88,200 bytes per second. A 10-second test recording should be about 882 KB. A much smaller file means the loop was dropping data or stopped early. A file of exactly the right size that is all zeros means the microphone delivered silence. That usually points to a missing permission or another app holding the microphone, and on newer Android versions the system may silence capture while the app is in the background.
Then listen. Open the file in the PCM Drop player with 44,100 Hz, 16-bit and mono. If it sounds right, your capture code is correct. If it plays as static, check the byte order. If it plays at the wrong speed, check that the sample rate you passed to AudioRecord matches what you think you recorded. Some devices quietly fail to initialise unsupported rates, and the fallback code may pick a different one.
Writing a WAV Instead
If people will open the recordings, consider writing a WAV header. Write 44 placeholder bytes when recording starts. When recording stops and the total length is known, seek back to the start with a RandomAccessFile and fill in the RIFF size and data size. All values in the header are little-endian. Our PCM vs WAV article lists every field and its meaning. The result opens on any phone or computer, and code that expects raw PCM can still skip the first 44 bytes.
Summary
AudioRecord is straightforward once you stay within the guaranteed settings (44.1 kHz, mono, 16-bit), keep the bytes in little-endian order, and drain the buffer quickly enough. Most "my recording is just noise" reports come down to DataOutputStream and its big-endian output. When in doubt, pull the file and listen to it. A raw PCM player tells you within seconds whether the capture code is working.