[Android] MediaRecorder를 이용해 음성녹음을 해보자

윤찬·2025년 8월 8일

Android

목록 보기
15/37

개요

안드로이드 앱 개발 중에 음성녹음을 이용해 자신의 하루를 기록하는 앱을 만드는 중이다.
그래서 음성녹음에 관련된 기능들을 구현하면서 어떻게 구현했는지 기록하기 위해 작성했다.

MediaRecorder란?

MediaRecorder는 안드로이드에서 오디오·비디오 녹음을 간단히 구현할 수 있는 고수준 API입니다.
빠르게 파일로 저장하는 데 적합하지만, 실시간 오디오 처리나 커스텀 인코딩에는 제약이 있습니다.

관련 공식 가이드는 아래 링크를 참조해보면 된다.

https://developer.android.com/media/platform/mediarecorder?utm_source=chatgpt.com&hl=ko


구현 방법

1. RecordingDetails 데이터 클래스 구현


data class RecordingDetails(
	//시간 기록용
    val durations: Duration = Duration.ZERO,
    //음성의 크기를 Float 형식의 리스트로 저장.
    val amplitudes: List<Float> = emptyList(),
    //해당 음성파일의 위치(절대경로)
    val filePath: String? = null
)

2. VoiceRecorder 인터페이스 작성

기본적으로 변경된 데이터 정보를 가지는 것과 음성녹음에 필요한 시작/정지/중지/재개/취소 기능들이 있는 인터페이스를 작성했다.


interface VoiceRecorder {
    val recordingDetails: StateFlow<RecordingDetails>
    fun start()
    fun pause()
    fun stop()
    fun resume()
    fun cancel()
}

3. VoicRecorder 구현체 (AndroidVoiceRecorder)

@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이라는 파일이 보일 것이다.

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

profile
좋은 개발자가 되기까지

0개의 댓글