여태 만든 클래스들을 이용해 원형으로 그려지는 시각화 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 키워드를 사용한다.
val magnitudes = remember { mutableStateOf<List<Float>>(emptyList()) }
state 로 선언하여 이 값이 변경될때마다 리컴포지션이 일어나고 UI를 다시 그리면서 시각화가 변할 것이다.
이제 BaseVisualzier의 start()를 호출하여 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 데이터를 캡처할때마다 전처리를 거쳐 CircleVisualizer의 fftMagnitudes 가 계속 바뀐다는 것이다.
이제 이 변수가 바뀔때마다 UI를 그려주기만 하면 된다.
다만 그 전에
DisposableEffect(audioSessionId) {
onDispose {
visualizer.stop()
}
}
DisposableEffect 부터 정의하자.
왜냐하면 Visualizer는 사용하지 않는다면 꼭 메모리에서 해제를 해줘야하기 때문이다.
안그러면 누수나서 앱이 죽을 수 있다.
그리는 함수는 CircleVisualizer 내부가 아니라 따로 만들 것이다.
시각화 효과를 여러가지로 만들어 선택하여 적용할 수 있도록 하기 위함이다.
원형 UI를 그리는 데 필요한 데이터는 List<Float> 타입의 전처리된 오디오 데이터이고,
색상과 반지름 역시 필요하다.
@Composable
fun SoundBar(
audioData: List<Float>,
color: Color,
clipRadius: Dp = 0.dp,
clipRadiusRatio: Float = 1.0f,
modifier: Modifier = Modifier
)
시각화가 그려지지 않는 중앙 hole 공간 반지름에 대한 변수가 clipRadius 와 clipRadiusRatio 두 개가 있는데,
하나는 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에서 호출하면 된다.
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의 사이즈에 따라 적절한 시각화 제외 반지름과 최대 효과 높이를 계산하여 adjustedRadius와 maxEffectHeight에 저장한다.

여기까지 한 상태에서 실행하면 위처럼 보일텐데,
문제는 시각화가 굉장히 끊겨보인다는 것이다.
이를 자연스러운 애니메이션으로 만들 필요가 있다.
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
)
}
이걸 실제로 쓰진 않았지만 이론만 설명하자면
새로운 캡처 데이터가 들어왔을때, 그걸 바로 사용하지 않고 이전 오디오 데이터와 비교해 그 중간정도(여기선 40%)로 보간하여 그 데이터를 사용하는 것이다.
이는 각 시각화 막대의 급격한 변화를 방지해 더 부드러운 시각화 효과를 만들 수 있다.
그러나 여전히 끊겨보일것이다. (그 증거를 넣고싶지만 찍어놓은게 없어서 ㅜ)
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)
}
targetValue 를 newValue로 부드럽게 변화시킴/** 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()를 통해 애니메이션을 적용해 부드러운 값 변화를 가능케한다.
이를 통해 자연스럽고 연속적인 시각적 변화를 표현할 수 있다.

animateMagnitudes 를 DrawSoundBar 컴포저블의 파라미터로 넣어주면
이제 위처럼 애니메이션이 적용된 자연스러운 시각화를 그려낼 수 있다.
다음에는 막대형태가 아닌 웨이브폼의 효과를 만드는 과정을 정리해볼것이다.