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

KSK·2025년 5월 16일

AudioVisualizer

목록 보기
4/4
post-thumbnail

개요

  • BaseVisualizer를 이용한 오디오 시각화 UI 구현
  • 웨이브형태의 시각화 구현

웨이브

물결 웨이브 형태의 시각화 UI를 추가로 구현할 것이다.

FillStroke

위처럼 하나는 fill, stroke 두가지 형태를 만들것이다.

Path

이전 Bar 형태 UI에선 오디오 데이터를 하나씩 순회하며 데이터마다 하나씩 drawLine 함수로 막대를 그려냈다.

물결 모양으로 그릴땐 데이터 하나마다 그리는게 불가능하기 때문에,
Path 객체에 경로를 기록후 drawPath를 사용할 것 이다.

Pathandroidx.compose.ui.graphics 패키지에 있는 인터페이스로,
Jetpack Compose에서 커스텀 벡터 그래픽(도형, 선, 곡선 등)을 그릴 때 사용하는 2D 경로 표현 클래스라고 생각하면 된다.

구현

Stroke

 
 val path = Path()
 
 ..
 
 Canvas(
        ...
    ) {
        val width = size.width
        val height = size.height

        // Calculate wave points positioned radially
        val points = audioData.mapIndexed { idx, magnitude ->
            val angle = getAudioDataAngle(idx, angleStep)
            val waveHeight = maxEffectHeight * magnitude
            getOffset(
                centerX = width / 2,
                centerY = height / 2,
                angle = angle,
                innerRadius = adjustedRadius,
                extraLength = waveHeight,
            )
        }

        // Apply Catmull-Rom spline for smooth curve interpolation
        path.catmullRomSpline(points)

        drawPath(
            path = path,
            color = color,
            style = Stroke(width = DrawSoundWaveStrokeConstants.STROKE_WIDTH)
        )
    }

Bar와 크게 다를건 없다.

오디오 데이터를 전체 순회하면서 데이터마다 표현될 위치의 각도 angle와 효과 높이 waveHeight를 계산하고

이들을 이용하여 이 데이터를 표현할 위치를 계산해 points라는 이름의 Offset 리스트로 저장한다.

그리고 DrawScope의 함수인 drawPath()path 를 넘겨 화면에 그리게 된다.

다만 그전에 catmullRomSpline 라는 함수가 보일 것이다.

Catmull-Rom Splines

굳이 이 과정을 거치지 않고 moveTolineTo로 그려도 할수는 있다.

다만 이 경우 단순히 직선을 그리는 것이기 때문에 근접한 두 값의 차이가 큰 경우 뾰족하고 부자연스러운 웨이브로 나타날 수 있다.

Catmull-Rom Spline을 적용하면 지나가는 모든 점을 매끄럽게 연결하는 곡선을 생성할 수 있다.

Catmull-Rom Spline 은 4개의 점 P0, P1, P2, P3에 대해, P1P2 사이를 부드럽게 연결하는 곡선을 생성하는 방법이다.

공식은 다음과 같다.

(자세한 수학적 이론은 여기 를 참고)


internal fun Path.catmullRomSpline(points: List<Offset>, steps: Int = 10) {
    if (points.size < 2) return

    val paddedPoints = listOf(points.last()) + points + listOf(points.first(), points[1])

    for (i in 0 until paddedPoints.size - 3) {
        val p0 = paddedPoints[i]
        val p1 = paddedPoints[i + 1]
        val p2 = paddedPoints[i + 2]
        val p3 = paddedPoints[i + 3]

        for (t in 0..steps) {
            val s = t / steps.toFloat()
            val s2 = s * s
            val s3 = s2 * s

            val x = 0.5f * ((2 * p1.x) +
                    (-p0.x + p2.x) * s +
                    (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * s2 +
                    (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * s3)

            val y = 0.5f * ((2 * p1.y) +
                    (-p0.y + p2.y) * s +
                    (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * s2 +
                    (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * s3)

            if (i == 0 && t == 0) {
                moveTo(x, y)
            } else {
                lineTo(x, y)
            }
        }
    }
    close()
}

steps 값은 각 구간의 정밀도로 높을수록 더 정밀하고 부드러운 곡선으로 표현이 가능하다.

첫 포인트와 끝 포인트가 이어지도록 하기 위해 points의 앞뒤에 패딩을 추가하여 paddedPoints 라는 변수로 저장한다.

이를 순회하는데 위의 공식처럼 점 4개씩 접근하면서 p1p2 사이의 부드러운 곡선을 이루는 x, y 좌표들을 계산한다.

이를 lineTo() 함수를 이용해 Path 객체에 경로를 기록하는 것이다.


Fill

이제 채워진 물결효과를 그려볼 차례이다.

역시 Stroke와 크게 다르지 않다.

drawPath(
	path = path,
    style = Fill,
    ...
)

위처럼 drawPath()의 파라미터 중 styleFill로 해주면 된다.
(기본값이 Fill 이다)

다만 이 상태에서 그대로 실행할 경우

위와 같이 중앙부분까지 채워진 채로 그려질 것이다.

중앙에 제외되어야할 원을 계산할 필요가 있다.

이를 위해

/** Path used to define the circular center hole (to clip out) */
var holePath by remember { mutableStateOf(Path()) }

위와 같은 제외될 영역의 경로를 기록할 State 변수를 추가하고,

Canvas(
	modifier = modifier
    	.fillMaxSize()
        .onSizeChanged { canvasSize ->
        ...
        holePath = holePath.apply {
        	addOval(
        		Rect(
            		center = Offset(x = canvasSize.width / 2f, y = canvasSize.height / 2f),
                	radius = adjustedRadius + 1f
                )
            )
        }
    }
) 

캔버스의 사이즈가 계산될때, holePath 역시 계산하여 제외될 영역을 설정한다.

addOval(rect: Rect)

addOval()Path에 타원형 경로를 추가하는데,
이때 파라미터로 넘기는 Rect가 정사각형일 경우 원이 된다.
반대로 가로세로 비율이 다른 직사각형이라면 타원 경로를 추가한다.


이후 그릴땐 clipPath 를 이용해 클리핑 영역을 제외하도록 한다.

inline fun DrawScope.clipPath(
    path: Path,
    clipOp: ClipOp = ClipOp.Intersect,
    block: DrawScope.() -> Unit
)

clipOp 파라미터를 ClipOp.Difference로 설정하면, 지정된 경로(path 파라미터) 를 제외한 부분만을 그리기가 가능하다.

block에 그리는 DrawScope의 draw 함수를 넣으면 이 함수는 path의 영역만 제외하고 그려진다는 것이다.

// Apply Catmull-Rom spline for smooth curve interpolation
path.catmullRomSpline(points)

// Clip the central hole area and draw the wave around it
clipPath(holePath, clipOp = ClipOp.Difference) {
	drawPath(path)
}

clipPath() 에 제외될 영역인 holePath를 넣는다.
그리고 drawPath() 를 실행하면 우리가 원하는 형태인

를 그려낼 수 있다.

참고로 이 방법을 사용하기 전 여러 방법을 탐색하면서 pathholePathPath.combine() 로 합쳐서 그리는 것도 사용해보았는데,

이 방식으로 구현할 경우 메인스레드가 과부하되어 메인스레드의 프레임드랍이 심각해지는 문제가 있었다. (그냥 기록용...)




이상으로 시각화 효과를 그리는 과정을 (드디어) 마친다.

사실 구현은 이미 모두 끝난 상황으로 구현과 정리를 동시에 진행했어야 하지만 뒤늦게라도 정리를 다시 작성했다.

다음은 그라데이션 적용과 라이브러리화를 위한 여러가지 사전 작업들을 정리할것이다.

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

0개의 댓글