아이고 많은 걸 뜯어고치느라 2편이 너무 늦었다
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() 함수만 호출이 가능하다.
인자로 audioSessionId를 받아 Visualizer 인스턴스를 생성한다.
생성 전 stop() 을 호출하여 이미 생성된 Visualizer가 있다면 해제를 선행함으로써 안전성을 보장한다.
이후 인자로 받은 captureSize의 유효성에 따라 Visualizer.captureSize를 할당한다.
만약 2의 거듭제곱이 아니거나, Visualizer.getCaptureSizeRange() 내에 해당하지 않을 경우 Visualizer.getCaptureSizeRange()[1] (= 시스템이 지원하는 최대값) 로 자동 할당한다.
isWaveCapture,isFftCapture -> OnDataCaptureListener의 각 콜백함수의 사용 여부를 나타내는 변수
onWaveCaptured onFftCaptured -> OnDataCaptureListener 콜백 내부 동작을 파라미터로 받아서 정의할 수 있음
이 함수에 넘길 파라미터로 콜백을 외부에서 정의해서 넘기고, Visualizer를 만들고 할당하는 동작을 start() 함수로 구현한 것이다.
이렇게 두 함수로 UI에서 사용될 Visualizer API를 정의하였다.
data class VisualizerCallbacks(
val onWaveCaptured: (Visualizer, ByteArray, Int) -> Unit = { _, _, _ -> },
val onFftCaptured: (Visualizer, ByteArray, Int) -> Unit = { _, _, _ -> },
)
콜백을 클래스로 캡슐화하여 분리하였다.
핵심은 BaseVisualizer 클래스는 오직 캡처 설정과 전달만 책임지고 UI나 시각화 방식에는 전혀 관여하지 않는다는 것이다.
기존 코드는 캡처된 데이터가 처리될 콜백이나 captureRate 등의 파라미터를 이 클래스 내부에 고정된 값 혹은 함수로 정의해놓았기 때문에 이를 호출하는 외부에서 임의로 바꿀 수 없었다.
라이브러리화를 위해 최대한 유연성과 재사용성이 높도록 개선하였다.
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> {
..
}
}
Visualizer.OnDataCaptureListener 의 onFftDataCapture() 에서 캡처된 [실수, 허수, 실수, 허수 ... ] 형태의 ByteArray를 받아서 주파수 대역 별 진폭으로 바꾸어 반환한다.
다른 전처리 함수를 사용하기 전에 필수로 선적용되어야한다.
진폭 데이터 중 지정된 minFreq - maxFreq 사이의 데이터만 잘라서 필터링한 결과를 반환한다.
주파수 범위 별로 가중치를 부가한다.
data class FrequencyScale(
val rangeRatio: ClosedFloatingPointRange<Float>,
val weight: Float
)
FrequencyScale 은 지정할 영역의 비율(0.0~1.0)과 해당 영역에 부가할 가중치를 나타내는 Data Class 이다.
이전 글의 기존 함수에선 범위와 가중치를 내 임의대로 정해놓고 사용할 수 밖에 없었는데, 이 함수를 호출하면서 파라미터로 원하는 범위와 가중치를 넘길 수 있도록 수정하였다.
로그 스케일을 적용하여 큰 값과 작은 값의 차이가 큰 경우 그 차이를 줄일 수 있다.
기존 함수에선 base를 10으로 고정된 log10()만을 사용하였는데,
현재는 파라미터 base를 받아서 로그의 밑 역시 바꾸어 적용할 수 있도록 하였다.
scaleFactor는 로그를 취한 결과에 곱해져서 전체 값을 키울 수 있는 파라미터이다.
다이나믹 레인지 란 가장 작은 신호에서 가장 큰 신호까지의 범위를 의미한다.
이를 compress한다는 것은 가장 큰 값과 가장 작은 값의 차이를 줄인다는 것이다.
Z-Score 정규화를 적용한다.
Z-Score는 일반적으로 가장 큰 값과 가장 작은 값을 각각 1과 0으로 두는 Min-Max 정규화와 달리 평균값과 표준편차를 이용하여 -1 ~ 1 사이의 값으로 변환한다.
기존 정규화 함수를 사용할 경우, 캡처된 데이터 스트림 마다 최대값과 최솟값을 기준으로 정규화가 적용되었기 때문에
최대값만이 매우 큰 경우 그 주파수 대역만 강조되고 나머지 대역은 거의 보이지 않던 문제가 있었다.
이를 방지하기 위해 평균을 기준으로 정규화를 적용하는 Z-Score 정규화 함수를 추가하였다.
최대값과 최솟값을 기준으로 정규화를 적용하는 함수이다.
이렇게 데이터 전처리를 담당할 클래스를 만들었으나 문제가 있다.
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 구현 이후 배포 전 마지막에 추가해야겠다. 일단 난 안쓸거니까...
다음은 여기까지 구현한 BaseVisualizer와 FftDataProcessor를 가지고 컴포즈 UI를 구현해보자.