Compose 오디오 데이터 시각화 - 2. Compose 시각화 UI 구현 (1)

KSK·2025년 4월 20일

AudioVisualizer

목록 보기
3/4

개요

  • BaseVisualizer를 이용한 오디오 시각화 UI 구현

CircleVisualizer

여태 만든 클래스들을 이용해 원형으로 그려지는 시각화 UI를 만들 것이다.

개발 요구사항에 따르면 스크린 중앙에 앨범커버가 원형으로 존재하고
시각화는 그 커버 이미지를 둘러싸도록 그려져야했기때문이다.

설계

구현할 컴포저블 함수는 BaseVisualizer 인스턴스를 내부에 가지고 있을 것이다.
따라서 BaseVisualizer에 필요한 AudioSessionId 가 필요하다.

또한 시각화 효과의 색상은 애플뮤직 API에서 받아온 노래 별 색상을 그대로 적용할 것이기 때문에 Color 값 역시 필요할 것이다.

그리고 앨범커버 이미지를 둘러싼채로 시각화 효과가 그려질 것이기 때문에 그 반지름 정보 역시 필요하다.

@Composable
fun CircleVisualizer(
    audioSessionId: Int,
    radius: Dp,
    color: Color,
    radiusRatio: Float = 1.0f,
    modifier: Modifier = Modifier
)

파라미터로 받은 audioSessionId를 이용해 BaseVisualizer 를 생성한다.

val visualizer = remember { BaseVisualizer() }

리컴포지션마다 인스턴스가 계속 생성되지 않도록 remember 키워드를 사용한다.


`BaseVisualizer`를 통해 캡처된 데이터를 저장할 변수도 필요하다. 이 데이터로 UI를 그려야하기 때문이다.
val magnitudes = remember { mutableStateOf<List<Float>>(emptyList()) }

state 로 선언하여 이 값이 변경될때마다 리컴포지션이 일어나고 UI를 다시 그리면서 시각화가 변할 것이다.


이제 BaseVisualzierstart()를 호출하여 audioSessionId의 오디오 데이터를 캡처해야한다.

LaunchedEffect(audioSessionId) {
	visualizer.start(
		audioSessionId = audioSessionId,
		captureSize = CAPTURE_SIZE,
		isWaveCapture = false,
		isFftCapture = true,
		visualizerCallbacks = VisualizerCallbacks(
			onFftCaptured = { visualizer, bytes, samplingRate ->
				...
			}
		)
	)
}

다만 이때 BaseVisualizer.start() 에 넘겨줄 visualizerCallbacks 를 정의하면서
Visualizer.onDataCaptureListener.onFftDataCaptured() 에서 FFT 데이터를 캡처할때마다 행할 동작을 정의해주어야한다.

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

그 동작은 위와 같다.
onFftDataCaptured() 에서 캡처된 ByteArray에, 이전 포스트에서 만든 FftDataProcessor의 전처리들을 적용한다.

visualizerCallbacks = VisualizerCallbacks(
	onFftCaptured = { _, bytes, samplingRate ->
		fftMagnitudes.value = preProcessFftData(bytes, samplingRate)
	}
)

이렇게 하면 fftMagnitudes 는 전처리가 적용된 데이터로 계속 업데이트되면서,
UI를 그리는데 필요한 값을 제공한다.

원래 이걸 BaseVisualizer에서 flow 타입 변수를 만들어두고, 이 flow를 통해 계속 UI를 업데이트를 하게 만들었지만, 전 포스트에서 언급했다시피 BaseVisualizer의 역할을 단순히 Visualizer의 설정과 사용으로 한정하고 싶었기 때문에 UI 업데이트와 관련된 변수는 두고싶지 않았다.

따라서 위와 같은 방식으로 변경하였다. 이렇게 하면 flow를 쓸 필요도 없고 말이다.


아무튼 여기까지 정리하자면,
Visualizer 가 FFT 데이터를 캡처할때마다 전처리를 거쳐 CircleVisualizerfftMagnitudes 가 계속 바뀐다는 것이다.

이제 이 변수가 바뀔때마다 UI를 그려주기만 하면 된다.

다만 그 전에

DisposableEffect(audioSessionId) {
	onDispose {
    	visualizer.stop()
    }
}

DisposableEffect 부터 정의하자.
왜냐하면 Visualizer는 사용하지 않는다면 꼭 메모리에서 해제를 해줘야하기 때문이다.
안그러면 누수나서 앱이 죽을 수 있다.

Draw

그리는 함수는 CircleVisualizer 내부가 아니라 따로 만들 것이다.
시각화 효과를 여러가지로 만들어 선택하여 적용할 수 있도록 하기 위함이다.

원형 UI를 그리는 데 필요한 데이터는 List<Float> 타입의 전처리된 오디오 데이터이고,
색상과 반지름 역시 필요하다.

@Composable
fun SoundBar(
    audioData: List<Float>,
    color: Color,
    clipRadius: Dp = 0.dp,
    clipRadiusRatio: Float = 1.0f,
    modifier: Modifier = Modifier
) 

시각화가 그려지지 않는 중앙 hole 공간 반지름에 대한 변수가 clipRadiusclipRadiusRatio 두 개가 있는데,
하나는 Dp 값으로 직접 반지름을 넘겨주는 경우고,
만약 화면크기에 비례해 비율로 반지름을 조정하고 싶다면 clipRadiusRatio를 사용하면 된다.


val angleStep = 360f / audioData.size

audioData의 각 요소가 시각화 막대로 그려질 예정이기 때문에 각 막대가 위치할 각도를 계산해야한다.
angleStep 변수는 막대 별 각도 차이를 의미한다.

val START_ANGLE = Math.toRadians(-90.0)

또한 12시 방향부터 작은 주파수부터 그려낼 것 이기 때문에
시작 각도가 될 위치 변수를 START_ANGLE 이라는 이름의 상수로 정의한다.


이제 Canvas를 이용해서 그려볼 것이다.

Canvas(
    modifier = modifier.fillMaxSize()
) {
    val width = size.width
    val height = size.height

    audioData.forEachIndexed { i, magnitude ->
        val angle = Math.toRadians((i * angleStep).toDouble()) + OFFSET_ANGLE
        val barHeight = maxEffectHeight * magnitude

        val startOffset = getOffset(
            centerX = width / 2,
            centerY = height / 2,
            angle = angle,
            innerRadius = adjustedRadius,
            extraLength = 0f
        )

        val endOffset = getOffset(
            centerX = width / 2,
            centerY = height / 2,
            angle = angle,
            innerRadius = adjustedRadius,
            extraLength = barHeight.toFloat()
        )
        
        drawLine(
            color = color,
            start = startOffset,
            end = endOffset,
            strokeWidth = STROKE_WIDTH
        )
    }
}

audioData를 순회하면서 하나씩 drawLine을 하는 방법이다.

(Path 객체에 경로를 기록하고 drawPath() 로 한번에 그릴 수도 있다)

  • maxEffectHeight : 그려질 시각화 효과의 최대 높이
    여기에 0-1 로 정규화 된 audioData의 각 요소를 곱해 높이 barHeight 를 지정한다.
    (maxEffectHeight가 어떻게 결정되는지는 후술)

  • angle : 이전에 정의했던 angleStep에 인덱스를 곱하여 audioData의 각 요소가 시각화로 표현될 위치(각도)를 설정한다.

  • startOffset endOffset : 그려질 시각화 막대의 시작 위치와 끝 위치
    Offset 을 구하는데 사용한 함수는 다음과 같다

internal fun getOffset(
    centerX: Float,
    centerY: Float,
    angle: Float,
    innerRadius: Float,
    extraLength: Float,
): Offset {
    return Offset(
        (centerX + (radius + extraLength) * cos(angle)),
        (centerY + (radius + extraLength) * sin(angle))
    )
}

중심좌표에서 x와 y축 방향으로 (innerRadius + extraLength) 만큼 떨어진 거리에서,
극좌표계 공식을 이용해 위치 Offset 을 구한다.

이제 이 함수를 CircleVisualizer에서 호출하면 된다.

CanvasSize

Canvas 내에선 size 변수를 통해 현재 그릴 수 있는 전체 영역의 크기를 나타내는 Size 객체를 호출할 수 있다.

이걸 어디서 쓰냐면 maxEffectHeight를 계산할 때 사용하는데,
시각화 효과의 최대 높이는 Canvas에서 내에서 그릴 수 있는 최대 높이보다 작은 값이어야
한 화면 내에서 잘리지 않고 그려질 수 있기 때문이다.

/** The radius from the center to the start of the bars */
var adjustedRadius by remember { mutableFloatStateOf(0f) }

/** The maximum possible bar height based on canvas size */
var maxEffectHeight by remember { mutableFloatStateOf(0f) }

따라서 위와 같은 State를 추가하고

Canvas(
	modifier = modifier
		.fillMaxSize()
		.onSizeChanged { canvasSize ->
            // Recalculate radius and effect height when the canvas size changes
            onCanvasSizeChanged(
                width = canvasSize.width,
                height = canvasSize.height,
                onRadiusCalculated = { adjustedRadius = it },
                onMaxEffectHeightCalculated = { maxEffectHeight = it }
            )
        }
) {
	...
}

onCanvasSizeChanged()Canvas의 사이즈에 따라 적절한 시각화 제외 반지름과 최대 효과 높이를 계산하여 adjustedRadiusmaxEffectHeight에 저장한다.

Animation

여기까지 한 상태에서 실행하면 위처럼 보일텐데,
문제는 시각화가 굉장히 끊겨보인다는 것이다.

이를 자연스러운 애니메이션으로 만들 필요가 있다.

1. 보간법

fun lerpMaginutude(newMagnitudes: List<Float>, oldMagnitudes: List<Float>, t: Float): List<Float> =
    oldMagnitudes.mapIndexed { i, oldMagnitude ->
        lerp(
            start = oldMagnitude,
            stop = newMagnitudes[i],
            fraction = 0.4f
        )
    }
  • lerp() : 선형 보간에 사용하는 함수, 두 값 사이 비례적인 중간 값을 계산
  • fraction = 보간계수, A와 B 사이 중간값을 정하는 계수

이걸 실제로 쓰진 않았지만 이론만 설명하자면

새로운 캡처 데이터가 들어왔을때, 그걸 바로 사용하지 않고 이전 오디오 데이터와 비교해 그 중간정도(여기선 40%)로 보간하여 그 데이터를 사용하는 것이다.

이는 각 시각화 막대의 급격한 변화를 방지해 더 부드러운 시각화 효과를 만들 수 있다.

그러나 여전히 끊겨보일것이다. (그 증거를 넣고싶지만 찍어놓은게 없어서 ㅜ)

2. Animatable

Jetpack Compose의 Animatable 를 사용하면 이 문제를 해결할 수 있다.

// Animatable.kt
suspend fun animateTo(
	targetValue: T,
    animationSpec: AnimationSpec<T> = defaultSpringSpec,
    initialVelocity: T = velocity,
    block: (Animatable<T, V>.() -> Unit)? = null
): AnimationResult<T, V> {
	val anim = TargetBasedAnimation(
    	animationSpec = animationSpec,
    	initialValue = value,
    	targetValue = targetValue,
    	typeConverter = typeConverter,
    	initialVelocity = initialVelocity
    )
    return runAnimation(anim, initialVelocity, block)
}
  • 애니메이션을 통해 값을 부드럽게 업데이트 하는데에 사용됨
  • targetValuenewValue로 부드럽게 변화시킴


/** Animated version of the magnitudes for smooth transitions in visualization */
val animateMagnitudes = remember { mutableStateOf<List<Animatable<Float, AnimationVector1D>>>(emptyList()) }

위와같은 변수를 CircleVisualizer에 추가하고,

// Animate changes in magnitude values for smoother rendering transitions
LaunchedEffect(magnitudes.value) {
	if (animateMagnitudes.value.isEmpty()) {
    	animateMagnitudes.value = magnitudes.value.map { Animatable(it) }
    } else {
        magnitudes.value.forEachIndexed { i, magnitude ->
        	launch {
        		animateMagnitudes.value[i].animateTo(
              		targetValue = magnitude,
                    animationSpec = tween(
            			durationMillis = 120,
            			easing = LinearEasing
                	)
            	)
        	}
    	}
    }
}

오디오데이터가 캡처되어 magnitudes 가 바뀔때마다,
magnitudes의 각 값과 animateMagnitudes의 각 Animatable 객체를 매칭시켜, animateTo()를 통해 애니메이션을 적용해 부드러운 값 변화를 가능케한다.

이를 통해 자연스럽고 연속적인 시각적 변화를 표현할 수 있다.

animateMagnitudesDrawSoundBar 컴포저블의 파라미터로 넣어주면
이제 위처럼 애니메이션이 적용된 자연스러운 시각화를 그려낼 수 있다.



다음에는 막대형태가 아닌 웨이브폼의 효과를 만드는 과정을 정리해볼것이다.

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

0개의 댓글