Compose 오디오 데이터 시각화 - 1.Visualizer (2)

KSK·2025년 4월 15일

AudioVisualizer

목록 보기
2/4

아이고 많은 걸 뜯어고치느라 2편이 너무 늦었다

개요

  • 오디오 데이터 전처리(필터링, 정규화 등)
  • Visualizer.OnDataCaptureListener 구현

BaseVisualizer 개선

class BaseVisualizer {
    private var visualizer: Visualizer? = null

    private val _fftFlow = MutableSharedFlow<List<Float>>(replay = 1)
    val fftFlow: SharedFlow<List<Float>> = _fftFlow.asSharedFlow()

    fun setVisualizer(audioSessionId: Int) {
        val visualizer = Visualizer(audioSessionId)
        this.visualizer = visualizer
    }

    fun setVisualizerListener() {
        visualizer?.run {
            enabled = false
            captureSize = CAPTURE_SIZE
            setDataCaptureListener(object : Visualizer.OnDataCaptureListener {
                override fun onWaveFormDataCapture(visualizer: Visualizer, bytes: ByteArray, samplingRate: Int) {
                    // NOT USED
                }

                override fun onFftDataCapture(visualizer: Visualizer, bytes: ByteArray, samplingRate: Int) {
					// DATA PROCESS
                }
            }, Visualizer.getMaxCaptureRate() / 2, true, true)
            enabled = true
        }
    }

    fun release() {
        visualizer?.release()
        visualizer = null
    }
}

이전 글에서 작성했던 BaseVisualizer 코드이다.

지난 글을 작성하고 나서 들었던 생각은
궁극적인 목표인 라이브러리화를 위해 해당 코드를 보다 더 범용적인 사용이 가능하도록 만들어야 한다는 것이다.

기존 프로젝트에서도 위 코드의 onFftDataCapture() 내부에서 동작을 정의하여 사용하였다.
이 경우 오직 FFT 데이터에 대해서만 대응되고, 내부 동작 역시 외부에서 변경할 수 없다는 문제가 있다.

class BaseVisualizer {
    private var visualizer: Visualizer? = null

    fun start(
        audioSessionId: Int,
        captureSize: Int,
        captureRate: Int = Visualizer.getMaxCaptureRate(),
        isWaveCapture: Boolean,
        isFftCapture: Boolean,
        visualizerCallbacks: VisualizerCallbacks,
    ) {
        stop()
        setVisualizer(audioSessionId)
        // 캡처 사이즈 유효성 검사
        visualizer?.captureSize =
            if (!isPowerOfTwo(captureSize) || !isValidCaptureSize(captureSize)) {
                Visualizer.getCaptureSizeRange()[1]
            } else {
                captureSize
            }

        setVisualizerListener(
            isWaveCapture,
            isFftCapture,
            captureRate,
            provideVisualizerCallbacks(visualizerCallbacks)
        )
    }

    fun isRunning(): Boolean = visualizer != null

    fun stop() {
        visualizer?.release()
        visualizer = null
    }

    private fun setVisualizer(audioSessionId: Int) {
        val visualizer = Visualizer(audioSessionId)
        this.visualizer = visualizer
    }

    private fun provideVisualizerCallbacks(
        visualizerCallbacks: VisualizerCallbacks,
    ) = object : Visualizer.OnDataCaptureListener {
        override fun onWaveFormDataCapture(visualizer: Visualizer, bytes: ByteArray, samplingRate: Int) {
            visualizerCallbacks.onWaveCaptured(bytes, samplingRate)
        }

        override fun onFftDataCapture(visualizer: Visualizer, bytes: ByteArray, samplingRate: Int) {
            visualizerCallbacks.onFftCaptured(bytes, samplingRate)
        }
    }

    private fun setVisualizerListener(
        isWaveCapture: Boolean,
        isFftCapture: Boolean,
        captureRate: Int,
        dataCaptureListener: Visualizer.OnDataCaptureListener,
    ) {
        visualizer?.run {
            enabled = false
            setDataCaptureListener(
                dataCaptureListener,
                captureRate,
                isWaveCapture,
                isFftCapture
            )
            enabled = true
        }
    }

    private fun isPowerOfTwo(n: Int): Boolean {
        return n > 0 && (n and (n - 1)) == 0
    }

    private fun isValidCaptureSize(captureSize: Int): Boolean {
        val range = Visualizer.getCaptureSizeRange()
        return captureSize in range[0]..range[1]
    }
}

일단 BaseVisualizer를 사용하는 외부에선 start()와 stop() 함수만 호출이 가능하다.

start()

인자로 audioSessionId를 받아 Visualizer 인스턴스를 생성한다.
생성 전 stop() 을 호출하여 이미 생성된 Visualizer가 있다면 해제를 선행함으로써 안전성을 보장한다.

이후 인자로 받은 captureSize의 유효성에 따라 Visualizer.captureSize를 할당한다.
만약 2의 거듭제곱이 아니거나, Visualizer.getCaptureSizeRange() 내에 해당하지 않을 경우 Visualizer.getCaptureSizeRange()[1] (= 시스템이 지원하는 최대값) 로 자동 할당한다.

  • isWaveCapture,isFftCapture -> OnDataCaptureListener의 각 콜백함수의 사용 여부를 나타내는 변수

  • onWaveCaptured onFftCaptured -> OnDataCaptureListener 콜백 내부 동작을 파라미터로 받아서 정의할 수 있음

이 함수에 넘길 파라미터로 콜백을 외부에서 정의해서 넘기고, Visualizer를 만들고 할당하는 동작을 start() 함수로 구현한 것이다.

이렇게 두 함수로 UI에서 사용될 Visualizer API를 정의하였다.

VisualizerCallbacks

data class VisualizerCallbacks(
    val onWaveCaptured: (Visualizer, ByteArray, Int) -> Unit = { _, _, _ -> },
    val onFftCaptured: (Visualizer, ByteArray, Int) -> Unit = { _, _, _ -> },
)

콜백을 클래스로 캡슐화하여 분리하였다.


핵심은 BaseVisualizer 클래스는 오직 캡처 설정과 전달만 책임지고 UI나 시각화 방식에는 전혀 관여하지 않는다는 것이다.

기존 코드는 캡처된 데이터가 처리될 콜백이나 captureRate 등의 파라미터를 이 클래스 내부에 고정된 값 혹은 함수로 정의해놓았기 때문에 이를 호출하는 외부에서 임의로 바꿀 수 없었다.

라이브러리화를 위해 최대한 유연성과 재사용성이 높도록 개선하였다.

FftDataProcessor

BaseVisualizer 에서 일부 수행하던 데이터 처리는 모두 이 클래스에서 수행하도록 Fft 데이터를 처리하는 로직을 갖고 있는 클래스를 분리하였다.

이전글의 AudioDataProcessor에서 몇가지를 수정하고 이름을 변경하였다.

class FftDataProcessor {

  /**
     * **This function must be used before applying any other preprocessing functions.**
     *
     * Calculates the FFT (Fast Fourier Transform) magnitude spectrum from raw FFT byte data.
     * ..
     */
    fun calculateFftMagnitude(bytes: ByteArray): List<Float> {
        ..
    }

    /**
     * Filters the given frequency spectrum data to include only the components within a specified frequency range.
     * ..
     */
    fun filterFrequency(
        audioData: List<Float>,
        samplingRate: Int,
        captureSize: Int,
        minFreq: Int,
        maxFreq: Int
    ): List<Float> {
        ..
    }

    /**
     * Applies scaling weights to audio frequency data based on predefined frequency range ratios.
     * ..
     */
    fun scaleFrequencies(
        audioData: List<Float>,
        frequencyScales: List<FrequencyScale>
    ): List<Float> {
        ..
    }

    /**
     * Applies a logarithmic scale transformation to the given audio magnitude data.
     * for compressing large dynamic ranges in audio signals,
     * ..
     */
    fun applyLogScale(
        audioData: List<Float>,
        base: Float = 10f,
        scaleFactor: Float = 1f
    ): List<Float> {
        ..
    }

    /**
     * Applies dynamic range compression using square root scaling.
     * ..
     */
    fun compressDynamicRangeRoot(audioData: List<Float>): List<Float> {
        ..
    }

    /**
     * Applies Z-Score normalization to the given audio data.
     * ..
     */
    fun normalizeByZScore(audioData: List<Float>): List<Float> {
        ..
    }

    /**
     * Applies Max-Min Normalization to the given audio data.
     * ..
     */
    fun normalize(audioData: List<Float>): List<Float> {
        ..
    }
}

calculateFftMagnitude()

Visualizer.OnDataCaptureListeneronFftDataCapture() 에서 캡처된 [실수, 허수, 실수, 허수 ... ] 형태의 ByteArray를 받아서 주파수 대역 별 진폭으로 바꾸어 반환한다.

다른 전처리 함수를 사용하기 전에 필수로 선적용되어야한다.

filterFrequency()

진폭 데이터 중 지정된 minFreq - maxFreq 사이의 데이터만 잘라서 필터링한 결과를 반환한다.

scaleFrequencies()

주파수 범위 별로 가중치를 부가한다.

data class FrequencyScale(
    val rangeRatio: ClosedFloatingPointRange<Float>,
    val weight: Float
)

FrequencyScale 은 지정할 영역의 비율(0.0~1.0)과 해당 영역에 부가할 가중치를 나타내는 Data Class 이다.

이전 글의 기존 함수에선 범위와 가중치를 내 임의대로 정해놓고 사용할 수 밖에 없었는데, 이 함수를 호출하면서 파라미터로 원하는 범위와 가중치를 넘길 수 있도록 수정하였다.

applyLogScale()

로그 스케일을 적용하여 큰 값과 작은 값의 차이가 큰 경우 그 차이를 줄일 수 있다.

기존 함수에선 base를 10으로 고정된 log10()만을 사용하였는데,
현재는 파라미터 base를 받아서 로그의 밑 역시 바꾸어 적용할 수 있도록 하였다.

scaleFactor는 로그를 취한 결과에 곱해져서 전체 값을 키울 수 있는 파라미터이다.

compressDynamicRangeRoot()

다이나믹 레인지 란 가장 작은 신호에서 가장 큰 신호까지의 범위를 의미한다.
이를 compress한다는 것은 가장 큰 값과 가장 작은 값의 차이를 줄인다는 것이다.

normalizeByZScore()

Z-Score 정규화를 적용한다.

Z-Score는 일반적으로 가장 큰 값과 가장 작은 값을 각각 1과 0으로 두는 Min-Max 정규화와 달리 평균값과 표준편차를 이용하여 -1 ~ 1 사이의 값으로 변환한다.

기존 정규화 함수를 사용할 경우, 캡처된 데이터 스트림 마다 최대값과 최솟값을 기준으로 정규화가 적용되었기 때문에
최대값만이 매우 큰 경우 그 주파수 대역만 강조되고 나머지 대역은 거의 보이지 않던 문제가 있었다.

이를 방지하기 위해 평균을 기준으로 정규화를 적용하는 Z-Score 정규화 함수를 추가하였다.

normalize()

최대값과 최솟값을 기준으로 정규화를 적용하는 함수이다.


이렇게 데이터 전처리를 담당할 클래스를 만들었으나 문제가 있다.

calculateFftMagnitude()onFftDataCapture()의 ByteArray에 가장 먼저 적용되어야하고, 다른 함수들은 그 이후에 적용되어야 한다.

이를 주석에도 충분히 남겨놓았지만, 오용을 대비해 사용순서를 강제할 필요가 있지 않을까 생각했다.

결국 또 한번 클래스를 변경하게 되는데..

class FftDataProcessor(rawFftBytes: ByteArray) {
    private var processedData: List<Float> = emptyList()

    init {
        processedData = calculateFftMagnitude(rawFftBytes)
    }
    
    /**
     진폭계산
     */
    fun calculateFftMagnitude(bytes: ByteArray): List<Float> {
        val audioData = bytes.drop(2).map { it.toDouble() }

        val size = audioData.size / 2
        val magnitudes = FloatArray(size)

        for (i in 0 until size) {
            val real = audioData.getOrNull(2 * i) ?: 0.0
            val imaginary = audioData.getOrNull(2 * i + 1) ?: 0.0
            magnitudes[i] = hypot(real, imaginary).toFloat()
        }

        return magnitudes.toList()
    }

    /**
     * 주파수 대역 필터링
     */
    fun filterFrequency(
        samplingRate: Int,
        captureSize: Int,
        minFreq: Int,
        maxFreq: Int
    ): FftDataProcessor {
        val resolution = ((samplingRate / 2.0) / (captureSize / 2 - 1))
        val startIndex = (minFreq / resolution).toInt().coerceIn(0, processedData.lastIndex)
        val endIndex = (maxFreq / resolution).toInt().coerceIn(startIndex, processedData.lastIndex)

        processedData = processedData.slice(startIndex..endIndex)

        return this
    }

    /**
     * 대역 별 가중치
     */
    fun scaleFrequencies(
        frequencyScales: List<FrequencyScale>
    ): FftDataProcessor {
        val size = processedData.size

        processedData = processedData.mapIndexed { index, value ->
            val ratio = index.toFloat() / size
            val scale = frequencyScales.find { ratio in it.rangeRatio }?.weight ?: 1.0f
            value * scale
        }

        return this
    }

    /**
     * 로그스케일
     */
    fun applyLogScale(
        base: Float = 10f,
        scaleFactor: Float = 1f
    ): FftDataProcessor {
        require(base > 0f && base != 1f) { "Logarithm base must be greater than 0 and not equal to 1." }

        val epsilon = 1e-6f // to avoid log(0)
        val minValue = processedData.minOrNull() ?: 0f
        val offset = if (minValue < 1f) 1f - minValue else 0f // move the minimum value to 1

        processedData = processedData.map { value ->
            val shifted = value + offset + epsilon
            val safeValue = if (shifted <= 0f || shifted.isNaN()) epsilon else shifted
            scaleFactor * log(safeValue, base)
        }

        return this
    }

    /**
     * 다이내믹 레인지 압축
     */
    fun compressDynamicRangeRoot(): FftDataProcessor {
        processedData = processedData.map { value ->
            sqrt(value.coerceAtLeast(0f))
        }
        return this
    }

    /**
     * Z-Score 정규화
     */
    fun normalizeByZScore(): FftDataProcessor {
        if (processedData.isEmpty()) return this

        val mean = processedData.average().toFloat()
        val stdDev = sqrt(processedData.map { (it - mean).pow(2) }.average()).toFloat()

        // if stdDev is 0 or NaN, return a list of zeros
        if (stdDev == 0f || stdDev.isNaN()) {
            processedData = List(processedData.size) { 0f }
        } else {
            processedData = processedData.map { (it - mean) / stdDev }
        }

        return this
    }

    /**
     * 정규화
     */
    fun normalize(): FftDataProcessor {
        val max = processedData.maxOrNull() ?: 1f
        val min = processedData.minOrNull() ?: 0f
        processedData = if (max - min <= 1f) List(processedData.size) { 0f } else processedData.map { (it - min) / (max - min) }

        return this
    }

    fun result(): List<Float> = processedData
}

아무튼 결국 최종 완성된 FftDataProcessor 클래스는 이렇다.

생성자로 ByteArray를 받아서 init 을 통해 calculateFftMagnitude() 를 가장 먼저 적용하도록 하였다.

또한 모든 함수의 리턴타입을 FftDataProcessor 로 바꾸고 자기 자신을 리턴하게 한다.

전처리가 적용된 Fft 스트림은 processedData 라는 이름의 변수로 저장되며,
result() 를 통해 그 결과를 받을 수 있다.

이렇게 변경한 이유는

private fun preProcessFftData(bytes: ByteArray, samplingRate: Int): List<Float> {
    return FftDataProcessor(bytes)
        .filterFrequency(
            samplingRate / 1000,
            CAPTURE_SIZE,
            MIN_FREQ,
            MAX_FREQ
        )
        .applyLogScale()
        .normalizeByZScore()
        .normalize()
        .result()
}

호출부에서 위처럼 함수를 Chaining하여 전처리를 실행하도록 하기 위함이다.


일단 이렇게 비주얼라이저 구현을 위한 기초작업은 끝났다.

현재 데이터 처리가 Fft 데이터에 대해서만 가능하고, WaveForm은 따로 안 만들었지만 이건 UI 구현 이후 배포 전 마지막에 추가해야겠다. 일단 난 안쓸거니까...

다음은 여기까지 구현한 BaseVisualizerFftDataProcessor를 가지고 컴포즈 UI를 구현해보자.

profile
그런게어딨어그냥하는거지

0개의 댓글