티스토리 뷰

1. 안드로이드 음성 녹음 어플 만들어보기

안드로이드 폰에서 음석 녹음을 할 수 있는 어플리케이션을 코틀린으로 만들어보았습니다. 간단하게 녹음을 진행하고 녹음되는 음성 대역폭에 따라 시각화 해서 보여주고 녹음된 내용은 재생해 볼 수 있는 기본적인 녹음기를 만들어봤습니다.

주요 기능

  • 음성 녹음 기능
  • 녹음 되고 있는 내용, 재생 내용 시각화
  • 녹음 된 시간, 재생 시간 타임 스탬프 표시
  • Reset 버튼으로 리셋

사용 기술

  • Request runtime permissions (마이크 권한 런타임에 얻기)
  • CustomView (음성 시각화, 타임 스탭프, 녹음 버튼)
  • MediaRecorder (녹음)
  • MediaPlayer (재생)

1.1. 음성 녹음을 위해 마이크 권한 얻기

음성 녹음을 위해 마이크 권한이 필요하며, 이는 위험 권한으로 사용자의 동의가 있어야지만 사용이 가능합니다. 따라서 권한 요청하는 코드를 따로 작성해서 사용자의 동의를 얻을 필요가 있습니다.

<uses-permission android:name="android.permission.RECORD_AUDIO"/>

메니 페스트에 위와 같은 속성을 추가해서 오디오 기능을 사용하겠다고 명시합니다.

    private val requiredPermissions = arrayOf(
        android.Manifest.permission.RECORD_AUDIO
    )

이후에 요청할 권한들을 담을 배열에 음성 녹음 관련 권한을 담아줍니다.

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        requestAudioPermission()
        initViews()
        bindViews()
        initVariables()
    }

 

    companion object {
        private const val REQUEST_RECORD_AUDIO_PERMISSION = 201
    }

상수로 우리가 요청할 오디오 권한의 코드를 따로 정의해둡니다. 이는 이후 결과처리에 사용됩니다.

private fun requestAudioPermission() {
        requestPermissions(requiredPermissions, REQUEST_RECORD_AUDIO_PERMISSION)
    }

이후 onCreate()로 앱 시작 초입에서 requesetPermissions 를 통해 얻을 권한을 담은 리스트를 넘겨주면서 권한을 요청할 수 있습니다. 이에 따른 사용자의 답변은 onRequestPermissionsResult 를 재정의 하면서 처리할 수 있습니다.

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        // 요청한 권한에 대한 결과

        val audioRecordPermissionGranted =
            requestCode == REQUEST_RECORD_AUDIO_PERMISSION &&
                    grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED

        if (!audioRecordPermissionGranted) {
            finish() // 거절 할 경우 앱 종료
        }
    }

사용자가 우리가 보낸 권한요청에 수락을 했는지 파악하여 따로 처리해줍니다. 그리고 현재는 동의를 하지 않은 경우 finish()를 통해서 앱을 바로 종료하도록 했습니다. 때문에 무조건 수락을 해야 앱이 계속해서 실행됩니다.

3. 상태에 따라 버튼 아이콘 다르게 보여주기

녹음 전 상태에서는 녹음을 시작하는 빨간 동그라미의 버튼을 보여주고 녹음 중에는 녹음을 그만둘 수 있는 정지버튼을 보여주어야합니다. 또 정지한 경우에는 녹음한 내용을 재생해볼 수 있는 재생버튼을 보여주도록 했습니다.

enum class State {
    BEFORE_RECORDING,
    ON_RECORDING,
    AFTER_RECORDING,
    ON_PLAYING
}

현재의 녹음기 어플 상태를 나타내는 enum class를 정의해주었습니다. 녹음전, 녹음중, 녹음후, 재생중 이렇게 총 4개의 상태를 갖게 할 것이므로 위와 같이 정의해주었습니다. 상태에 따라서 버튼을 적절하게 보여주면 되겠죠?

class RecordButton(
    context: Context,
    attrs: AttributeSet
) : AppCompatImageButton(context, attrs) {

    init {
        setBackgroundResource(R.drawable.shape_oval_button)
    }

    fun updateIconWithState(state: State) {
        when (state) {
            State.BEFORE_RECORDING -> {
                setImageResource(R.drawable.ic_recorde)
            }
            State.ON_RECORDING -> {
                setImageResource(R.drawable.ic_stop)
            }
            State.AFTER_RECORDING -> {
                setImageResource(R.drawable.ic_play)
            }
            State.ON_PLAYING -> {
                setImageResource(R.drawable.ic_stop)
            }
        }
    }
}

새로운 클래스를 추가해서 우리가 만들 녹음 버튼을 만들어줍시다. 버튼을 상속 받아서 커스터 마이징 하는 방식으로 진행합니다. 뷰를 만들 때는 최소한 context 와 attributeset 객체를 매개변수로 하는 생성자를 제공해야하기 때문에 코드는 위와 같이 작성해주세요.

updateIconWithState : 해당 함수를 정의해서 상태가 바뀔 때마다 State를 넘겨 버튼 이미지 리소스를 각각 알맞는 아이콘으로 변경되도록 해줍니다.

AppCompat

전에도 그랬고 여기서도 AppCompat 이 붙은 버튼을 사용하는데 이는 무엇이냐하면 아시다시피 안드로이는 거의 매년 새 버전이 출시되고 있고 이에 따라 이전 구버전의 호환성을 제공해야합니다. 그래서 AppCompat 클래스를 사용하는데 그러면 기존 클래스를 래핑하여 이전 버전에서도 새 버전 출시된 것을 정상적으로 동작하게 해주게됩니다.

// MainActivity

    private var state = State.BEFORE_RECORDING
        set(value) { // setter 설정
            field = value // 실제 프로퍼티에 대입
            resetButton.isEnabled = (value == State.AFTER_RECORDING || value == State.ON_PLAYING)
            recordButton.updateIconWithState(value)
        }

본론으로 넘어와서 메인 엑티비티에서 위와 같이 state 변수를 하나 만들어주고 우리가 정의했던 enum 클래스로 초기화를 해줍니다. 이때 세터를 따로 설정해서 값이 설정될 때 마다 먼저 정의 했던 updateIconWithState 를 호출하면 끝.

추가적으로 resetButton 의 활성화 시점도 녹음후, 재생중 일 때만 가능하도록 적용 해주어야 말이 되기 때문에 위 코드 에서 또한 적용해주었습니다.

4. MediaRecorder 사용 하기

음성 녹음을 할 때 사용하는 MediaRecorder는 사용자의 마이크를 통해서 녹음이 가능하도록 도와줍니다.

    private var recorder: MediaRecorder? = null // 사용 하지 않을 때는 메모리해제 및  null 처리

미디어레코더를 선언하고 null을 대입. 그리고 이후에도 사용하지 않을 때는 무조건 메모리를 해제하고 null 시켜 놓는 것이 좋은데 이는 메모리를 차지하기 때문에 앱 성능 저하가 일어날 수 있기 때문이다. 🥴

4.1. 초기화 및 녹음 시작하기

녹음 시작 버튼을 누르면 MediaRecorder 를 생성해서 녹음 준비를 한뒤 바로 녹음을 진행하고 녹음 중지시 메모리에서 바로 해제 해주도록 합니다.

    private fun startRecoding() {
        // 녹음 시작 시 초기화
        recorder = MediaRecorder()
            .apply {
                setAudioSource(MediaRecorder.AudioSource.MIC)
                setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP) // 포멧
                setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB) // 엔코더
                setOutputFile(recordingFilePath) // 우리는 저장 x 캐시에
                prepare()
            }
        recorder?.start()
        recordTimeTextView.startCountup()
        soundVisualizerView.startVisualizing(false)
        state = State.ON_RECORDING
    }

공식 홈페이지에서 안내하고 있는 대로 오디오 소스 설정 후 포멧과 오디오 인코더를 정해주고 파일 저장 경로를 설정해주면 준비가 완료됩니다. 이 앱에서는 저장 장소에 영구 저장이 아닌 캐시에 저장하도록 할 것이므로 캐시 위치로 설정해주었습니다.

    private val recordingFilePath: String by lazy {
        "${externalCacheDir?.absolutePath}/recording.3gp"
    }

.3gp 확장자를 가지는 파일명으로 캐시 디렉토리에 저장되도록 경로를 설정해주었습니다.

    private fun stopRecording() {
        recorder?.run {
            stop()
            release()
        }
        recorder = null
        soundVisualizerView.stopVisualizing()
        recordTimeTextView.stopCountup()
        state = State.AFTER_RECORDING
    }

이후 녹음 정지 시에는 stop 과 release를 순차적으로 호출해서 녹음을 중지하고 메모리에서 해제 해줍니다.

4.2. 음성 오디오 시각화 하기

커스텀 뷰를 따로 정의 해서 음성을 시각화 해서 보여주도록 합시다. 간단하게 긴 막대를 사용해서 표시하고 음폭에 따라서 이를 크거나 작게 표시해서 녹음 시작시 오른쪽에서 왼쪽으로 흘러가는 것 처럼 보이도록 해주면 됩니다.

커스텀 뷰에서는 Canvas로 무엇을 그릴지 그리고 Paint로 어떻게 그릴 것인지 설정해주면 됩니다.

    companion object {
        private const val LINE_WIDTH = 10F
        private const val LINE_SPACE = 15F

        // 오디오 레코더의 get max amplitude(진폭, 볼륨) 음성의 최대값의 short 타입 최대값임.
        private const val MAX_AMPLITUDE = Short.MAX_VALUE.toFloat() // Float로 미리 타입 변환

        private const val ACTION_INTERVAL = 20L // 20밀리초
    }

먼저 전역 상수로 우리가 사용할 상수 값들을 정의 해줍니다. 이때 음성 진폭은 shot 값의 최대 값이어서 위와 같이 코드를 작성했습니다.

    private val amplitudePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = context.getColor(R.color.purple_500)
        strokeWidth = LINE_WIDTH
        strokeCap = Paint.Cap.ROUND // 라인의 양 끄투머리 동그랗게
    }

어떤 식으로 그려줄지 Paint 객체를 하나 정의 해주고 이를 사용해서 아래에서 라인을 그려주도록 하겠습니다.

사이즈 변경될 시 알맞은 사이즈 가져오기

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        drawingWidth = w
        drawingHeight = h
    }

onSizeChanged를 재정의해서 사이즈가 변경되는 경우 해당 너비와 높이를 가져와서 재 설정해주도록 할 수 있습니다.

onDraw

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        // canvas 가 null 인 경우 반환환
        canvas ?: return

        val centerY = drawingHeight / 2f // 뷰의 중앙 높이
        var offsetX = drawingWidth.toFloat() // 오른 쪽 부터

        // 어떤 걸 그릴지
        // 진폭값을 배열로 넣어두고 오른쪽 부터 왼쪽으로 그려지게
        drawingAmplitudes
            .let { amplitudes ->
                if (isReplaying) {
                    amplitudes.takeLast(replayingPosition) // 가장 뒤 부터 리플레이 포지션 까지
                } else {
                    amplitudes
                }
            }
            .forEach { amplitude ->
                // 그릴려는 높이 대비 몇퍼로 그릴지 (80%)
                val lineLength = amplitude / MAX_AMPLITUDE * drawingHeight * 0.8F

                // offset 계산 다시
                // 뷰의 우측 부터 그릴 것임.
                offsetX -= LINE_SPACE

                // 진폭 배열이 많이 쌓여 뷰를 초과하는 경우
                // 보이지 않는 경우 그리지 않아야함
                if (offsetX < 0) return@forEach

                // amplitude 그리기 (좌상단이 0,0 우하단이 w,h)
                canvas.drawLine(
                    offsetX, // 시작
                    centerY - lineLength / 2F,
                    offsetX,
                    centerY + lineLength / 2F,
                    amplitudePaint
                )
            }
    }

onDraw 에서는 실제 페인트 객체를 사용해서 우리가 원하는 막대를 그리게 되는데 이때 진폭에 대한 배열을 가지고 있다가 그려줄 때 순서대로 우측에서 부터 그려지도록 해야하기 때문에 우측 좌표를 offsetX로 가지고 있다가 하나씩 그리면서 LINE_SPACE 만큼 빼주면서 그리게 됩니다.

이때 뷰 영역에서 넘어가는 경우 (0 보다 작아지는 경우)는 return 해서 더이상 그리지 않도록 해줍니다. 그릴 때 위에서 정의해준 amplitudePaint를 쓰는 것을 알 수 있고 재생 시에는 역으로 보여주어야 하기 때문에 takeLast를 사용해서 역순으로 보여지도록 설정했습니다.

이때 drawingAmplitudes 리스트에 있는 진폭 값들을 하나씩 가져오는데 이 진폭값은 콜백함수를 통해 Main Activity 에서 녹음하는 동안 계속해서 받아와야합니다.

    var onRequestCurrentAmplitude: (() -> Int)? = null
    private fun bindViews() {

        soundVisualizerView.onRequestCurrentAmplitude = {
            recorder?.maxAmplitude ?: 0
        }

뷰 바인딩을 하는 부분에서 해당 콜백 함수를 정의 해주는데 recorder 가 null 이 아니면 최대 Amplitude를 리턴해줍니다. 최대 진폭값이 null 이면 0을 리턴합니다.

    private val visualizeRepeatAction: Runnable = object : Runnable {
        override fun run() {
            if (!isReplaying) {
                // Amplitude를 가져오고, Draw를 요청

                // Amplitude 값 가져오기
                val currentAmplitude = onRequestCurrentAmplitude?.invoke() ?: 0
                // 오른 쪽 부터 순차적으로 그리기
                drawingAmplitudes = listOf(currentAmplitude) + drawingAmplitudes
            } else {
                replayingPosition++
            }
            invalidate() // 드로잉 처리

            handler?.postDelayed(this, ACTION_INTERVAL)
        }
    }

녹음을 시작하면 오디오 시각화를 실시간으로 해줄 runnable 객체를 post 해서 일정 인터벌 당 계속해서 호출하도록 설정해야합니다. 위 코드를 사용해서 진폭값을 실시간으로 가져와서 현재 리스트의 가장 앞에 붙여서 순차적으로 그려질 수 있게 합니다.

이때 리스트를 변경했으면 invalidate()를 호출해서 onDraw가 일어나도록 해주어야합니다. MFC와 상당히 비슷한 형태로 작성되네요.

4.3. 오디오 타임 스탬프 구현

타임 스탬프를 통해서 현재 녹음이 몇 초간 진행되고 있는 지 확인하게 하여 유저 편의성을 도울 수 있습니다. 재생 시에도 어디를 재생하고 있는지 보여줄 수 있으면 더욱 좋겠죠. 아래와 같이 구현해주시면 되겠습니다.

class CountUpView(
    context: Context,
    attributeSet: AttributeSet? = null
) : AppCompatTextView(context, attributeSet) {

    private var startTimeStamp: Long = 0L

    private val countUpAction: Runnable = object : Runnable {
        override fun run() {
            // 시작했을 때 타임 트탬프를 계산
            val currentTimeStamp = SystemClock.elapsedRealtime()

            val countTimeSeconds = ((currentTimeStamp - startTimeStamp)/1000L).toInt() // 얼마의 시간 차이가 나는지
            updateCountTime(countTimeSeconds)
            handler?.postDelayed(this, 1000L)
        }
    }

    fun startCountup() {
        startTimeStamp = SystemClock.elapsedRealtime()
        handler?.post(countUpAction)
    }

    fun stopCountup(){
        handler?.removeCallbacks(countUpAction)
    }

    fun clearCountTime(){
        updateCountTime(0)
    }

    private fun updateCountTime(countTimeSeconds: Int){
        val minutes = countTimeSeconds / 60
        val seconds = countTimeSeconds % 60
        text = "%02d:%02d".format(minutes, seconds)
    }
}

클래스를 추가해서 뷰를 하나 정의해주시고 카운트 시작을 한 기준 시간을 통해서 얼마나 시간이 흘렀는지 1초간 반복해 호출하면서 갱신하는 코드를 위와 같이 작성합니다. 이로인해 1초마다 textView의 text가 updateCountTime 호출을 통해서 갱신되는 것을 구현할 수 있습니다.

또 카운트를 하지 않는 경우에는 handler의 removeCallBacks를 통해서 우리가 넘겨주었던 콜백함수를 제거해주어야 계속해서 실행되는 것을 방지할 수 있습니다.

보다 자세한 코드와 프로젝트 전체 파일은 저의 깃허브 저장소에서 전부 확인할 수 있습니다. 학습에 도움이 되셨다면 공감버튼 꾸욱 부탁드리겠습니다.

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함