안드로이드 앱 개발 중에 음성녹음을 이용해 자신의 하루를 기록하는 앱을 만드는 중이다.
그래서 음성녹음에 관련된 기능들을 구현하면서 어떻게 구현했는지 기록하기 위해 작성했다.
MediaRecorder는 안드로이드에서 오디오·비디오 녹음을 간단히 구현할 수 있는 고수준 API입니다.
빠르게 파일로 저장하는 데 적합하지만, 실시간 오디오 처리나 커스텀 인코딩에는 제약이 있습니다.
관련 공식 가이드는 아래 링크를 참조해보면 된다.
https://developer.android.com/media/platform/mediarecorder?utm_source=chatgpt.com&hl=ko
data class RecordingDetails(
//시간 기록용
val durations: Duration = Duration.ZERO,
//음성의 크기를 Float 형식의 리스트로 저장.
val amplitudes: List<Float> = emptyList(),
//해당 음성파일의 위치(절대경로)
val filePath: String? = null
)
기본적으로 변경된 데이터 정보를 가지는 것과 음성녹음에 필요한 시작/정지/중지/재개/취소 기능들이 있는 인터페이스를 작성했다.
interface VoiceRecorder {
val recordingDetails: StateFlow<RecordingDetails>
fun start()
fun pause()
fun stop()
fun resume()
fun cancel()
}
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidVoiceRecorder(
private val context: Context,
private val applicationScope: CoroutineScope
) : VoiceRecorder {
companion object {
//파일의 앞이름
private const val TEMP_FILE_PREFIX = "temp_recording"
//최대 크기 지정. 확인해보니 보통 10000~32000 사이라 26000지정
private const val MAX_AMPLITUDE_VALUE = 26_000L
}
//싱글 스레드로 접근 제한을 하기 위한 디스패처 구현
private val singleThreadDispatcher = Dispatchers.Default.limitedParallelism(1)
//데이터 저장관련 기능
private val _recordingDetails = MutableStateFlow(RecordingDetails())
override val recordingDetails: StateFlow<RecordingDetails> = _recordingDetails.asStateFlow()
//여기는 파일 파라미터로 File객체를 가져온다.
private var tempFile = generateTempFile()
//음성 녹음 관련 파라미터
private var recorder: MediaRecorder? = null
private var isRecording: Boolean = false
private val amplitudes = mutableListOf<Float>()
private var isPaused: Boolean = false
//음성이 시작되고 작업할 Job들을 지정(각각 시간, 목소리 크기 작업 진행)
private var durationJob: Job? = null
private var amplitudeJob: Job? = null
//시작 관련 기능
override fun start() {
if (isRecording) {
return
}
try {
//기본 초기화 작업 진행
resetSession()
//새로운 파일 생성
tempFile = generateTempFile()
//MediaRecorder 관련 속성 지정
recorder = newMediaRecorder().apply {
//오디오 설정 - 마이크로 진행
setAudioSource(MediaRecorder.AudioSource.MIC)
//오디오 출력 파일의 컨테이너 포맷을 지정합니다.
//MPEG_4는 .mp4 파일 컨테이너를 사용.
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
//오디오 코덱(인코더) 지정
//AAC는 고음질, 높은 압축 효율을 가진 코덱으로 MP4와 잘 호환된다.
/*
다른 값 예시:
AMR_NB → 8kHz, 전화 음질 수준
AMR_WB → 16kHz, 조금 더 선명한 전화 음질
OPUS → 고음질, 안드로이드 10+ 지원
*/
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
//비트레이트를 설정합니다. (여기서는 128 kbps)
//비트레이트가 높을수록 음질은 좋아지지만 파일 크기도 커진다.
//음악 녹음용은 128~256kbs
//음성만 녹음할 경우 64~96kbps 권장
setAudioEncodingBitRate(128 * 1000)
//샘플링 레이트를 설정합니다. (여기서는 44.1 kHz)
//초당 몇번 소리를 샘플링하는지 나타낸다.
//44,100Hz는 CD 품질 음질이며, 음악/고품질 음성에 적합
//전화 수준 음질은 8000~16000 사이
setAudioSamplingRate(44100)
//녹음 결과가 저장될 파일 경로를 지정한다.
//절대 경로로 지정해야하며, 권한이 없는 경로를 지정하면 예외 발생
//주로 cacheDir, filesDir, 외부 저장소 경로 사용
setOutputFile(tempFile.absolutePath)
//설정값 적용되고 내부 리소스 준비
prepare()
//녹음 시작
start()
}
//관련 속성 변경 및 Job 실행하기
isRecording = true
isPaused = false
startTrackingDuration()
startTrackingAmplitudes()
} catch (e: IOException) {
//에러시 recorder 종료
Timber.e(e, "Failed to start recording")
recorder?.release()
recorder = null
}
}
//시간 관련 Job 시작 10L 마다 시간을 더해준다.
private fun startTrackingDuration() {
durationJob = applicationScope.launch {
var lastTime = System.currentTimeMillis()
while (isRecording && !isPaused) {
delay(10L)
val currentTime = System.currentTimeMillis()
val elapsedTime = currentTime - lastTime
_recordingDetails.update {
it.copy(
durations = it.durations + elapsedTime.milliseconds
)
}
lastTime = System.currentTimeMillis()
}
}
}
//음질 Float형식의 값을 가져와 List<Float>에 추가하는 작업
private fun startTrackingAmplitudes() {
amplitudeJob = applicationScope.launch {
while (isRecording) {
val amplitude = getAmplitude()
//이 부분은 추가에 중복이 되지 않기 위해 싱글 스레드 처리
withContext(singleThreadDispatcher) {
amplitudes.add(amplitude)
}
delay(10L)
}
}
}
//Float값 구하는 과정
private fun getAmplitude(): Float {
return if (isRecording) {
try {
val maxAmplitude = recorder?.maxAmplitude
val amplitudeRatio = maxAmplitude?.takeIf { it > 0f }?.run {
(this / MAX_AMPLITUDE_VALUE.toFloat()).coerceIn(0f, 1f)
}
amplitudeRatio ?: 0f
} catch (e: Exception) {
Timber.e(e, "Faile to retrieve current amplitude.")
0f
}
} else 0f
}
//파일 생성(위치는 cacheDir)
private fun generateTempFile(): File {
val id = UUID.randomUUID().toString()
return File(
context.cacheDir,
"${TEMP_FILE_PREFIX}_${id}.mp4"
)
}
//MediaRecorder생성
private fun newMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
//초기화 함수
private fun resetSession() {
_recordingDetails.update { RecordingDetails() }
applicationScope.launch(singleThreadDispatcher) {
amplitudes.clear()
cleanup()
}
}
//초기화 함수2
private fun cleanup() {
Timber.d("Cleaning up voice recorder resources")
recorder = null
isRecording = false
isPaused = false
amplitudeJob?.cancel()
durationJob?.cancel()
}
//멈췄을 때
override fun pause() {
if (!isRecording || isPaused) {
return
}
recorder?.pause()
isPaused = true
durationJob?.cancel()
amplitudeJob?.cancel()
}
//중지
override fun stop() {
try {
recorder?.apply {
stop()
release()
}
} catch (e: Exception) {
Timber.e(e, "Failed to stop recording")
} finally {
_recordingDetails.update {
it.copy(
amplitudes = amplitudes.toList(),
filePath = tempFile.absolutePath
)
}
cleanup()
}
}
//재개
override fun resume() {
if (!isRecording || !isPaused) {
return
}
recorder?.resume()
isPaused = false
startTrackingDuration()
startTrackingAmplitudes()
}
//취소
override fun cancel() {
stop()
tempFile.delete() // 임시 파일 폐기
resetSession()
}
}
코드가 상당히 복잡하지만 하나씩 읽어보면 단순히 MediaRecorder 동작과 그에 맞게 Job들이 실행되는 방식이다.
MainActivity에 아래와 같이 코드를 작성
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val recorder = AndroidVoiceRecorder(
context = applicationContext,
applicationScope = (application as EchoJournalApp).applicationScope
)
enableEdgeToEdge()
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
0
)
setContent {
EchoJournalTheme {
Row(
modifier = Modifier
.statusBarsPadding()
) {
Button(
onClick = {
recorder.start()
}
) {
Text("Start")
}
Button(
onClick = {
recorder.pause()
}
) {
Text("Pause")
}
Button(
onClick = {
recorder.resume()
}
) {
Text("Resume")
}
Button(
onClick = {
recorder.stop()
}
) {
Text("Stop")
}
}
}
}
}
}
정상적으로 동작이 되는지 확인해보자

위와 같이 4개의 버튼을 만들고 정상적으로 음성녹음이 되는지 확인해본다.
Stop을 누르고 나서 해당 디바이스의 Open in Device Explorer를 누르면 아래와 같이 정보들이 나온다.


여기서 data -> data -> 자기 프로젝트 패키지 -> cache를 보면 temp_recording이라는 파일이 보일 것이다.

이 파일이 음성녹음 파일이다. 정상적으로 동작되고 저장이 된 것을 볼 수 있다.