[Android] 음성 녹음 길이를 조절해보자

윤찬·2025년 9월 22일

Android

목록 보기
22/37

문제 원인

음성 녹음 관련 기능을 구현하는 와중에 아래와 같은 문제점이 발생했다.


음성녹음의 길이가 오른쪽에 있는 시간의 화면을 잡아먹는 일이 발생했다.

반대로 음성의 길이가 너무 작으면 아래와 같이 전체를 채우지 못하는 녹음화면이 나오게 된다.

하지만 일반적으로 음성 길이가 길든 작든 녹음 화면은 전체를 채우며 시간 화면을 넘어가지 않아야 한다.

즉, 아래와 같은 형태가 이루어져야 한다는 것이다.


해결방법

먼저 음성 녹음 구현에서 받은 ui는 List<Float>를 통해 음성의 높낮이의 정보를 받고 있으며 이를 위 이미지에 맞게 UI로 보여주고 있다.

길이가 긴 값이나, 작은 값일 때에도 전체를 채우는 UI를 만들고 싶기 때문에 List의 정보의 길이를 화면의 크기에 맞게 upsample/downsample을 구현하려고 한다.

AmplitudeNormalizer

먼저 AmplitudeNormalizer를 통해 upsample과 downSample을 구현한 코드다.


object AmplitudeNormalizer {
    fun normalize(
        sourceAmplitudes: List<Float>,
        //UI에서 음성녹음이 차지하는 길이
        trackWidth: Float,
        //하나의 값이 보여주는 bar의 너비
        barWidth: Float,
        //bar 사이에 있는 공간 너비
        spacing: Float
    ): List<Float> {
    
    	//만약 UI의 너비가 0보다 작은 경우 오류발생
        require(trackWidth >= 0f) {
            "Track width must be positive"
        }
        
        //또한 barWith + spacing의 너비보다 작은 경우에도 오류 발생
        require(trackWidth >= barWidth + spacing) {
            "Track width must be at least the size of one bar and spacing."
        }
        
        //빈값인 경우 빈값 그대로 반환
        if (sourceAmplitudes.isEmpty()) {
            return emptyList()
        }

		//전체 너비에서 (barWith + spacing)을 나눈 값 -> 즉 총 그릴 수 있는 개수를 반환
        val barsCount = (trackWidth / (barWidth + spacing)).roundToInt()
        val resampled = resampleAmplitudes(sourceAmplitudes, barsCount)
        //TODO: 추후 높이 조절 작업 추가
    }


	//여기가 이제 upsample 또는 downSample 작업 진행
    private fun resampleAmplitudes(
        sourceAmplitudes: List<Float>,
        targetSize: Int
    ): List<Float> {
        return when {
        	//만약 구한 targetSize가 현재 List<Float>의 사이즈와 같다면 그냥 반환
            targetSize == sourceAmplitudes.size -> sourceAmplitudes
            //List값의 길이가 더 크면 targetSize의 길이만큼 downsample
            targetSize < sourceAmplitudes.size -> downsample(sourceAmplitudes, targetSize)
            //반대인 경우 upsample 진행
            else -> upsample(sourceAmplitudes, targetSize)
        }
    }

    //[0, 0.1, 0.2] -> [0, 0.1 ,0.2, 0.25 ,0.3]
    //[0, 0.1, 0] -> [0, 0.025 , 0.05, 0.075, 0.1, 0.075 , 0.05, 0.025, 0]  targetSize = 9
    private fun upsample(
        sourceAmplitudes: List<Float>,
        targetSize: Int
    ): List<Float> {
        val result = mutableListOf<Float>()

        val step = (sourceAmplitudes.size - 1).toFloat() / targetSize
        for (i in 0 until targetSize) {
            val pos = i * step
            val index = pos.toInt()

            val fraction = pos - index
            val value = if (index + 1 < sourceAmplitudes.size) {
                (1 - fraction) * sourceAmplitudes[index] + fraction * (sourceAmplitudes[index + 1])
            } else {
                sourceAmplitudes[index]
            }
            result.add(value)
        }
        return result
    }

    //[0.5, 0.7, 0.3,.0.4,0.3,0.8] -> [0.7, 0.4,0.8] (targetsize = 3) -> 큰 값 구하기
    private fun downsample(
        sourceAmplitudes: List<Float>,
        targetSize: Int
    ): List<Float> {
        val ratio = sourceAmplitudes.size.toFloat() / targetSize
        return List(targetSize) { index ->
            val start = (index * ratio).toInt()
            val end = ((index + 1) * ratio).toInt().coerceAtMost(sourceAmplitudes.size)

            sourceAmplitudes.subList(start, end).max()
        }
    }
}

downSample

간단한 방식으로 사이즈를 줄이도록 구현했습니다. 보통 특정 인덱스 범위의 최대값 또는 평균값을 이용해 줄이는 방식을 채택하는데, 여기서는 최대값을 사용했습니다.

ratio를 통해 targetSize를 나눈 비율을 구한 뒤 해당 ratio에 맞는 범위에 있는 모든 인덱스 값의 최댓값을 구하였습니다.
end에는 해당 index가 sourceAmplitudes.size를 넘어갈 수 있기 때문에 coerceAtMost 함수를 통해 최대 sourceAmplitudes.size를 반환하도록 구현

    //[0.5, 0.7, 0.3,.0.4,0.3,0.8] -> [0.7, 0.4,0.8] (targetsize = 3) -> 큰 값 구하기
    private fun downsample(
        sourceAmplitudes: List<Float>,
        targetSize: Int
    ): List<Float> {
        val ratio = sourceAmplitudes.size.toFloat() / targetSize
        return List(targetSize) { index ->
            val start = (index * ratio).toInt()
            val end = ((index + 1) * ratio).toInt().coerceAtMost(sourceAmplitudes.size)
			
            //해당 범위에 있는 값의 최대값으로 downSample
            sourceAmplitudes.subList(start, end).max()
        }
    }

upsample

upSample은 downsample보다 조금 로직이 복잡하다.
일단 targetsize만큼 List를 늘려야하는데 이를 두 인덱스 사이에 값을 채우는 방식으로 진행하려고 한다. 다만 두 인덱스 사이에 값이 얼마나 추가되는지를 구해야 하며 이를 두 인덱스의 차이의 비율만큼 증가하거나 감소해야한다. 주석의 예시를 보면 이해하기 쉬을 것이다.

	//[0, 0.1, 0.2] -> [0, 0.1 ,0.2, 0.25 ,0.3]
    //[0, 0.1, 0] -> [0, 0.025 , 0.05, 0.075, 0.1, 0.075 , 0.05, 0.025, 0]  targetSize = 9
    private fun upsample(
        sourceAmplitudes: List<Float>,
        targetSize: Int
    ): List<Float> {
        val result = mutableListOf<Float>()

        val step = (sourceAmplitudes.size - 1).toFloat() / targetSize
        for (i in 0 until targetSize) {
            val pos = i * step
            val index = pos.toInt()

            val fraction = pos - index
            val value = if (index + 1 < sourceAmplitudes.size) {
                (1 - fraction) * sourceAmplitudes[index] + fraction * (sourceAmplitudes[index + 1])
            } else {
                sourceAmplitudes[index]
            }
            result.add(value)
        }
        return result
    }

먼저 step은 현재 source의 구간의 개수에서 타겟의 구간 개수를 나눈 것이다.

원본: [0, 0.1, 0]   (size = 3, 구간 = 2)
타겟: size = 9      (구간 = 8)

step = (sourceAmplitudes.size - 1).toFloat() / (targetSize - 1)
     = (3 - 1) / (9 - 1)
     = 2 / 8
     = 0.25

fraction을 이용해 인덱스의 차이만큼 비율을 구하고 value에서는 선형보간법을 이용해 두 인덱스간의 값을 구하는 방식을 채택했다. 만약 index가 마지막 인덱스인 경우에는 그대로 값을 가져온다.

이를 통해 테스트를 진행하면 아래와 같이 주석에 맞게 결과가 나오는 것을 볼 수 있다.

class AmplitudeNormalizerTest {

    @Test
    fun sourceLowerThanTargetSize() {
        val source = listOf<Float>(0f, 0.1f, 0f)

        val result = AmplitudeNormalizer.normalize(
            source,
            27f,
            2f,
            1f
        )

        assertEquals(result, listOf<Float>(
            0.0f, 0.025f, 0.05f, 0.075f, 0.1f, 0.075f, 0.05f, 0.025f, 0.0f
        ))
    }
}

remapAmplitudes

List의 길이를 upsample또는 downsample 진행이 완료되었다. 하지만 만약 음성녹음을 할 때 말을 안한다면 0f로 UI에서는 음성의 높이가 나오지 않게 된다.
이를 해결하기 위해 remapAmplitudes 함수를 통해 최소 Float와 최대 Float를 바꿔주는 작업을 진행했다.


object AmplitudeNormalizer {
    private const val AMPLITUDE_MIN_OUTPUT_THRESHOLD = 0.1f
    //최소값
    private const val MIN_OUTPUT = 0.25f
    //최대값
    private const val MAX_OUTPUT = 1f

    fun normalize(
        sourceAmplitudes: List<Float>,
        trackWidth: Float,
        barWidth: Float,
        spacing: Float
    ): List<Float> {
        //upsample 및 downsample 작업
        val remapped = remapAmplitudes(resampled)
        return resampled
    }

    private fun remapAmplitudes(amplitudes: List<Float>): List<Float> {
        val outputRange = MAX_OUTPUT - MIN_OUTPUT
        val scaleFactor = MAX_OUTPUT - AMPLITUDE_MIN_OUTPUT_THRESHOLD
        return amplitudes.map { amplitude ->
            if (amplitude <= AMPLITUDE_MIN_OUTPUT_THRESHOLD) {
            	//이미 amplitude가 0.1f보다 작으면 0.25f로 변환
                MIN_OUTPUT
            } else {
            	//amplitude는 0.1f 이상이므로 0.0f 시작을 맞추기 위해 AMPLITUDE_MIN_OUTPUT_THRESHOLD를 뺌
                val amplitudeRange = amplitude - AMPLITUDE_MIN_OUTPUT_THRESHOLD
                
                //MIN_OUTPUT은 0.25f이며 (amplitudeRange * outputRange/scaleFactor)는 0~0.75f의 사이다.
                //때문에 모든 결과가 0.25f ~ 1f 사이의 값이 나오게 된다.
                MIN_OUTPUT + (amplitudeRange * outputRange / scaleFactor)
            }
        }
    }

   // upsample 및 downsample 함수
}

이 결과로 위에 upsample한 테스트가 전부 0.25f로 변환되는 것을 볼 수 있다.

    @Test
    fun sourceLowerThanTargetSize() {
        val source = listOf<Float>(0f, 0.1f, 0f)

        val result = AmplitudeNormalizer.normalize(
            source,
            27f,
            2f,
            1f
        )

        assertEquals(result, listOf<Float>(
//            0.0f, 0.025f, 0.05f, 0.075f, 0.1f, 0.075f, 0.05f, 0.025f, 0.0f
            0.25f, 0.25f, 0.25f, 0.25f, 0.25f, 0.25f, 0.25f, 0.25f, 0.25f
        ))
    }

AmplitudeNormalizer 적용하기

이제 이를 UI에서 정상적으로 나올 수 있게 변경해보자

먼저 이 음성 UI가 나오는 화면에서 해당 컴포저블의 너비를 알아야 한다.
이를 그리는 UI가 EchoPlayBar라 할 때 아래와 같이 onSizeChanged를 이용해 너비가 나올 때 함수를 호출하도록 구현했다.

	EchoPlayBar(
                amplitudeBarWidth = amplitudeBarWidth,
                amplitudeBarSpacing = amplitudeBarSpacing,
                trackColor = trackColor,
                trackFillColor = trackFillColor,
                powerRatios = powerRatios,
                playerProgress = playerProgress,
                modifier = Modifier
                    .weight(1f)
                    .padding(vertical = 10.dp, horizontal = 8.dp)
                    .fillMaxHeight()
                    .onSizeChanged {
                        if(it.width > 0) {
                            onTrackSizeAvailable(
                                TrackSizeInfo(
                                    trackWidth = it.width.toFloat(),
                                    barWidth = with(density) {
                                        amplitudeBarWidth.toPx()
                                    },
                                    spacing = with(density) {
                                        amplitudeBarSpacing.toPx()
                                    }
                                )
                            )
                        }
                    }
            )

이를 출력하는 onTrackSizeAvailable을 ViewModel까지 올라와 아래 함수를 호출하면 원하는 결과가 나온다.

//ViewModel에 있는 함수

    private fun onTrackSizeAvailable(trackSizeInfo: TrackSizeInfo) {
        viewModelScope.launch(Dispatchers.Default) {
            val finalAmplitudes = AmplitudeNormalizer.normalize(
            	//음성녹음한 결과의 List<Float>
                sourceAmplitudes = recordingDetails.amplitudes,
                trackWidth = trackSizeInfo.trackWidth,
                barWidth = trackSizeInfo.barWidth,
                spacing = trackSizeInfo.spacing
            )

            _state.update { it.copy(
                playbackAmplitudes = finalAmplitudes
            ) }
        }
    }
profile
좋은 개발자가 되기까지

0개의 댓글