はじめに
前回の記事では、Android標準APIのMediaRecorderを使って手軽に録音する方法を紹介しました。MediaRecorderは録音からエンコード、ファイル保存までしてくれますが、生の音声データを扱うことはできません。
そこで今回は、同じく標準APIであるAudioRecordを使って、マイクからの音声を直接扱う録音処理を実装します。生のPCMデータを保存し、WAV形式に変換して再生できるようにするところまでを解説します。
前回の記事:
前提
検証環境は以下の通りです。
macOS Sequoia 15.6(Apple M4)
Android Studio Narwhal | 2025.1.1
Kotlin 2.0.21
実装していく
Android Studioを起動したらNew Project > EnptyActiviryで新しいプロジェクトを立ち上げます。設定は任意ですがここでは下記の通りとします。
- Name: MyAudioRecord
- Minimum SDK: API 24
ここでは以下の要件で録音アプリを作ります。
- サンプリングレート: 44.1kHz
- モノラル
- 16bit PCM
- WAVファイルに保存
画面作成
録音機能を試すだけなので、画面は「録音開始」と「停止」の2つのボタンだけの最小限にします。MainActivity.ktに下記をコピペでOKです。(UIの解説は割愛します)
package com.example.myaudiorecord
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SimpleRecorderUI(
onStart = { /* 後で録音処理を追加する */ },
onStop = { /* 後で停止処理を追加する */ }
)
}
}
}
@Composable
fun SimpleRecorderUI(onStart: () -> Unit, onStop: () -> Unit) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = onStart) { Text("録音開始") }
Button(onClick = onStop) { Text("停止") }
}
}
録音機能実装
UIはできたので、次は録音の仕組みを作ります。
権限周り
まずはマイクを使うために使用権限をアプリに追加します。app/src/main/AndroidManifest.xmlの<manifest>タグの直下に次の1行を追記します。
<uses-permission android:name="android.permission.RECORD_AUDIO" />
アプリが録音を開始する際に権限があるか確認したいので、録音権限のチェックと、権限リクエストの処理を作っておきます。
import androidx.core.app.ActivityCompat
import android.Manifest
import android.content.pm.PackageManager
// ...
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SimpleRecorderUI(
onStart = {
if (checkAudioPermission()) {
/* 後で録音処理を追加する */
} else {
requestAudioPermission()
}
},
onStop = { /* 後で停止処理を追加する */ }
)
}
}
// 権限チェック
private fun checkAudioPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
this,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
}
// 権限リクエスト
private fun requestAudioPermission() {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
0 // リクエストコード(今回は特に使わないので0でOK)
)
}
}
これで権限がない状態で録音開始ボタンを押すと、アプリに権限を許可するかどうかを確認するダイアログが表示されます。
AudioRecordの初期化
今回の主題である AudioRecord を利用するために、まずは初期化処理を用意します。
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import androidx.annotation.RequiresPermission
// ...
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
private fun createAudioRecord(): AudioRecord {
val sampleRate = 44100
val channelConfig = AudioFormat.CHANNEL_IN_MONO
val audioFormat = AudioFormat.ENCODING_PCM_16BIT
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
return AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize
)
}
コード解説:
-
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
このアノテーションは「この関数を呼ぶには録音権限が必要」という情報を付与します。呼び出し側で権限チェックを忘れるとIDEやLintが警告を出してくれるため、権限周りの不備を防ぐのに役立ちます。
AudioRecordについて、コンストラクタは次のようになっています。
AudioRecord(
audioSource: Int,
sampleRateInHz: Int,
channelConfig: Int,
audioFormat: Int,
bufferSizeInBytes: Int
)
それぞれの引数について、ざっくり説明するとこんな感じです。
-
audioSource
どのマイクを使うかを選びます。
今回はシンプルにMediaRecorder.AudioSource.MICを指定して、端末の普通のマイクを使います。 -
sampleRateInHz
サンプリングレート。「1秒間に音を何回切り取るか」の数値。
よく使われるのは 44,100Hz(44.1kHz)で、CD音質と同じ値なのでとりあえずこれでOK。 -
channelConfig
モノラルにするかステレオにするかを決めます。
今回はシンプルにAudioFormat.CHANNEL_IN_MONO(モノラル)を指定しています。 -
audioFormat
音声データの形式です。
AudioFormat.ENCODING_PCM_16BITを選んでおけば、非圧縮の16bit PCMで扱いやすく、標準的なフォーマットになります。 -
bufferSizeInBytes
録音中にオーディオデータが書き込まれるバッファの合計サイズのこと。(マイクから流れてくる音を一時的にためておく入れ物の大きさみたいなもの)
AudioRecord.getMinBufferSize()を使うと、AudioRecordオブジェクトを正常に作成するために必要な最小バッファサイズをバイト単位で返してくれるためこれを使います。
参考:
録音開始処理
次は録音を始める処理を追加します。
録音はメインスレッドでやるとアプリが止まってしまうので、別スレッドで実行するようにしています。
import java.io.File
import java.io.FileOutputStream
// ...
private var pcmFile: File? = null
private var isRecording = false
private lateinit var audioRecord: AudioRecord
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
private fun startRecording() {
pcmFile = File(getExternalFilesDir(null), "record_${System.currentTimeMillis()}.pcm")
audioRecord = createAudioRecord()
audioRecord.startRecording()
isRecording = true
Thread {
val buffer = ByteArray(1024)
FileOutputStream(pcmFile!!).use { outputStream ->
while (isRecording) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read > 0) {
outputStream.write(buffer, 0, read)
}
}
}
}.start()
}
コードの説明:
-
audioRecord.startRecording()
実際にマイク入力を開始するメソッド。 -
Thread { ... }.start()
録音処理を別スレッドで動かすため。 -
ByteArray(1024)
読み取る一時バッファ。ここでは1024バイト単位でデータを読み取っています。 -
audioRecord.read()
マイクからバッファに音声データを読み込む。戻り値は実際に読み込んだデータサイズ(バイト数)で、これが0より大きければファイルに書き込まれる。 -
FileOutputStream(pcmFile!!).use { ... }
指定したファイルに録音データを書き込んでいます。.use { ... }にすることで自動的にクローズされます。
録音停止処理
録音を終了する時は、isRecordingフラグをfalseに更新して、AudioRecordを正しく停止・解放します。
private fun stopRecording() {
if (this::audioRecord.isInitialized && isRecording) {
isRecording = false
audioRecord.stop()
audioRecord.release()
}
}
録音してみる
録音開始と停止の処理ができたため、未実装だったボタン押下時の処理を追加します。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SimpleRecorderUI(
onStart = {
if (checkAudioPermission()) {
startRecording() // ★追加
} else {
requestAudioPermission()
}
},
onStop = {
stopRecording() // ★追加
}
)
}
}
これでボタンを押したら録音開始・停止できるようになったため、アプリを起動して実際に録音してみてください。
録音データを確認
録音ファイルはアプリ専用のストレージに保存されます。
今回のコードでは以下のパスに出力されます。
/sdcard/Android/data/com.example.myaudiorecord/files
ファイル名はrecord_数字.pcmのような形で生成されます。
Android StudioのDevice Explorerを使うか、adbコマンドを使ってファイルをPCに取り出せます。ただ、生成されるのは生のPCMデータなので、通常の音楽プレイヤーでは再生できません。
次の章で、PCやスマホの通常の音楽プレイヤーでも再生可能なWAV形式に変換します。
PCM→WAV変換
まず「PCM」と「WAV」の違いを簡単に整理しました。
-
PCM(Pulse Code Modulation)
マイクから取り込んだ音を、そのまま数値として並べただけのデータ。
→ 生の波形データなので、再生するには追加の情報が必要 -
WAV(Waveform Audio File Format)
PCMデータの先頭に「サンプリングレート」「チャンネル数」「ビット深度」などの情報(ヘッダ)を付けたもの。
→ 音楽プレイヤーはヘッダを読んで正しく再生できる
ということで、WAVに変換する理由がわかったので実装を進めていきます。
import java.nio.ByteBuffer
import java.nio.ByteOrder
// ...
private fun pcmToWav(pcmFile: File, wavFile: File) {
val sampleRate = 44100
val channels = 1
val bitsPerSample = 16
val pcmSize = pcmFile.length().toInt()
val totalDataLen = pcmSize + 36
val byteRate = sampleRate * channels * bitsPerSample / 8
FileOutputStream(wavFile).use { out ->
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
// RIFFヘッダ
header.put("RIFF".toByteArray(Charsets.US_ASCII))
header.putInt(totalDataLen)
header.put("WAVE".toByteArray())
// fmtチャンク
header.put("fmt ".toByteArray())
header.putInt(16) // fmt chunk size
header.putShort(1) // PCMフォーマット
header.putShort(channels.toShort())
header.putInt(sampleRate)
header.putInt(byteRate)
header.putShort((channels * bitsPerSample / 8).toShort())
header.putShort(bitsPerSample.toShort())
// dataチャンク
header.put("data".toByteArray())
header.putInt(pcmSize)
out.write(header.array(), 0, 44)
// PCM本体コピー
pcmFile.inputStream().use { it.copyTo(out) }
}
}
次に、開始時にWAVファイル出力用のwavFileを初期化し、停止時にPCMからWAVに変換して書き込みます。
private var wavFile: File? = null // ★追加
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
private fun startRecording() {
// ★以下のように書き換え
val baseName = "record_${System.currentTimeMillis()}"
pcmFile = File(getExternalFilesDir(null), "$baseName.pcm")
wavFile = File(getExternalFilesDir(null), "$baseName.wav")
// ...
}
private fun stopRecording() {
if (this::audioRecord.isInitialized && isRecording) {
isRecording = false
audioRecord.stop()
audioRecord.release()
// ★追加
pcmFile?.let { pcm ->
wavFile?.let { wav ->
pcmToWav(pcm, wav)
}
}
}
}
ここまでできたらもう一度録音してみます。PCMファイルと同時にWAVファイルも生成されていると思います。WAVファイルを取り出して、通常の音楽プレイヤーでも再生できますので確認してみてください。
完成コード
以上で完成です。最後に完成系のコードを載せておきます。
// MainActivity.kt
package com.example.myaudiorecord
import android.Manifest
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.RequiresPermission
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
class MainActivity : ComponentActivity() {
private var pcmFile: File? = null
private var wavFile: File? = null
private var isRecording = false
private lateinit var audioRecord: AudioRecord
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SimpleRecorderUI(
onStart = {
if (checkAudioPermission()) {
startRecording()
} else {
requestAudioPermission()
}
},
onStop = {
stopRecording()
}
)
}
}
private fun checkAudioPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
this,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
}
private fun requestAudioPermission() {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
0
)
}
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
private fun createAudioRecord(): AudioRecord {
val sampleRate = 44100
val channelConfig = AudioFormat.CHANNEL_IN_MONO
val audioFormat = AudioFormat.ENCODING_PCM_16BIT
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
return AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize
)
}
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
private fun startRecording() {
val baseName = "record_${System.currentTimeMillis()}"
pcmFile = File(getExternalFilesDir(null), "$baseName.pcm")
wavFile = File(getExternalFilesDir(null), "$baseName.wav")
audioRecord = createAudioRecord()
audioRecord.startRecording()
isRecording = true
Thread {
val buffer = ByteArray(1024)
FileOutputStream(pcmFile!!).use { outputStream ->
while (isRecording) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read > 0) {
outputStream.write(buffer, 0, read)
}
}
}
}.start()
}
private fun stopRecording() {
if (this::audioRecord.isInitialized && isRecording) {
isRecording = false
audioRecord.stop()
audioRecord.release()
pcmFile?.let { pcm ->
wavFile?.let { wav ->
pcmToWav(pcm, wav)
}
}
}
}
private fun pcmToWav(pcmFile: File, wavFile: File) {
val sampleRate = 44100
val channels = 1
val bitsPerSample = 16
val pcmSize = pcmFile.length().toInt()
val totalDataLen = pcmSize + 36
val byteRate = sampleRate * channels * bitsPerSample / 8
FileOutputStream(wavFile).use { out ->
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
// RIFFヘッダ
header.put("RIFF".toByteArray(Charsets.US_ASCII))
header.putInt(totalDataLen)
header.put("WAVE".toByteArray())
// fmtチャンク
header.put("fmt ".toByteArray())
header.putInt(16) // fmt chunk size
header.putShort(1) // PCMフォーマット
header.putShort(channels.toShort())
header.putInt(sampleRate)
header.putInt(byteRate)
header.putShort((channels * bitsPerSample / 8).toShort())
header.putShort(bitsPerSample.toShort())
// dataチャンク
header.put("data".toByteArray())
header.putInt(pcmSize)
out.write(header.array(), 0, 44)
// PCM本体コピー
pcmFile.inputStream().use { it.copyTo(out) }
}
}
}
@Composable
fun SimpleRecorderUI(onStart: () -> Unit, onStop: () -> Unit) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = onStart) { Text("録音開始") }
Button(onClick = onStop) { Text("停止") }
}
}