GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

css waveform 9가지 — 피크로 줄이고 구간을 골라 자르는 오디오 파형 장면

css waveform 은 선을 긋기 전에 표본 수만 개를 몇백 픽셀 칸에 줄여 넣는 일부터다 — 01번 최대·최소 묶음과 05번 영점 흡착이 줄이기와 고르기를 계산으로 푼다. 음악 앱이나 녹음 화면에서 가운데 선을 기준으로 위아래로 출렁이는 막대 그림이 바로 파형이다.

아홉 가지 오디오 파형을 화면에서 바로 재생하고 잘라 본다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

css waveform 9가지 — 구간 묶음·윤곽 추종·영점 흡착으로 그리는 파형

이 편의 아홉 장면에서는 소리가 나지 않는다. 신호는 시드를 정해 둔 선형 합동 난수 lcg·정수 해시 hash1·사인 합성으로 만든 숫자 배열이고, 아홉 파일 어디에도 AudioContext 나 getUserMedia 가 없어서 마이크 권한을 묻는 창도 뜨지 않는다. 시드가 같으면 파형도 같으니, 글에 적은 숫자는 zip 을 열어 같은 함수를 부르면 다시 나온다.

그림은 CSS 키프레임이 아니라 <canvas> 2D 에 직접 칠한다. 01 하나만 봐도 표본 86,400 개 가운데 화면에 보이는 43,200 개를 기둥 160 개로 접어야 하는데, 어느 표본이 어느 기둥 끝을 정하는지는 스크립트가 세어야 알 수 있다. CSS 는 무대 비율 aspect-ratio: 480 / 300, 장면마다 다른 touch-action, 초점 테두리 :focus-visible, 움직임 줄이기 미디어 쿼리처럼 바깥 틀만 맡는다.

아홉 장면은 boot() 한 벌을 나눠 쓰고 makeXxxScene 함수만 다르다. FIXED_DT 16ms 간격으로 step() 을 돌리고 프레임마다 draw() 를 부르는데, 한 프레임이 따라잡는 시간을 160ms 로 잘라 두어 탭을 오래 비웠다 돌아와도 한 번에 10스텝까지만 나아간다. 입력이 220스텝(약 3.5초) 동안 없으면 가짜 포인터가 나와 시연을 돌리고, 사람이 만지는 순간 시연은 멈춘다.

쓰임으로 출발점을 고르면 빠르다. waveform html css 로 플레이어 화면을 짜는 중이라면 타임라인 미리보기는 01, 재생 진행 표시는 03, 보이스 메모 목록은 02 부터 열어 보자. 음원이나 영상을 자르는 편집 화면은 04 와 05, 악기 튜너나 신호 측정 대시보드는 06, 음악 행사 랜딩 히어로는 07, 플레이어 상태 표시는 08, 회의 녹음 도구는 09 가 가깝다.

css waveform animation 을 기법별 코드로 본다

css waveform animation 이라는 말에서 막대가 저절로 오르내리는 장식을 떠올리기 쉽지만, 아래 조각들은 숫자 배열을 화면 칸에 어떻게 나눠 담는지를 보여 준다. 조각마다 장면 index.html 의 <script> 에서 끊김 없이 이어진 줄을 옮겼다. 01·02·03·04·07 이 부르는 공용 함수 scanRange(sig, a, b) 는 표본 a 부터 b 앞까지 훑어 가장 작은 값과 가장 큰 값을 rangeLo·rangeHi 에 남기므로, 조각에서 rangeLo 가 보이면 그 결과다.

01표본 수만 개가 최대·최소 묶음으로 줄어 픽셀 기둥이 되는 기본 파형

bucketColumns 는 화면 폭 480 을 BUCKET_PX 3 으로 나눈 160 칸에 보이는 표본 43,200 개를 똑같이 나눠, 기둥 하나가 표본 270 개의 최댓값과 최솟값만 대표하게 한다. 평균을 내면 짧게 튀는 표본이 이웃에 섞여 사라지지만 최대·최소는 그 한 표본을 기둥 끝에 그대로 남기고, 높이가 1px 에 못 미치는 조용한 구간에서도 PEAK_FLOOR 가 위아래로 1px 씩을 지켜 줄이 끊기지 않는다. 포인터나 ←/→ 로 렌즈 열을 옮기면 상태 알약에 그 기둥의 최대·최소가 뜬다.

표본 수만 개가 최대·최소 묶음으로 줄어 픽셀 기둥이 되는 기본 파형
새 창에서 열기

01 · 표본 수만 개가 최대·최소 묶음으로 줄어 픽셀 기둥이 되는 기본 파형 — 측정 누적면적 38.376% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BUCKET_PXSAMPLE_NBAR_GAPPEAK_FLOOR
function bucketColumns(sig, start, view, bucketPx) {
    const n = Math.floor(W / bucketPx);
    const per = view / n;
    const lo = [];
    const hi = [];
    for (let c = 0; c < n; c += 1) {
        scanRange(sig, start + c * per, start + (c + 1) * per);
        lo.push(rangeLo);
        hi.push(rangeHi);
    }
    return { lo, hi };
}

02공격은 빠르고 해제는 느린 추종기가 소리 크기 윤곽을 매끈하게 채우는 파형

rmsAt 이 칸 끝에서 앞쪽 RMS_WIN 160 개 표본의 제곱평균 제곱근을 내면, followEnvelope 는 값이 오를 때는 차이에 ATTACK_K 0.5 를, 내릴 때는 RELEASE_K 0.06 을 곱한 만큼 칸마다 더해 그 값을 쫓는다. 그래서 0 에서 1 로 뛰는 계단에는 3칸 만에 90% 까지 붙고, 1 에서 0 으로 떨어지면 10% 아래로 가라앉기까지 38칸이 걸려 북소리 뒤 여운처럼 남는다. 보이는 첫 칸보다 WARM_COLS 30칸 앞에서 추종을 시작해 두기 때문에 화면 왼쪽 끝의 윤곽이 0 에서 솟아오르지 않는다.

공격은 빠르고 해제는 느린 추종기가 소리 크기 윤곽을 매끈하게 채우는 파형
새 창에서 열기

02 · 공격은 빠르고 해제는 느린 추종기가 소리 크기 윤곽을 매끈하게 채우는 파형 — 측정 누적면적 29.142% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ATTACK_KRELEASE_KRMS_WINFILL_ALPHA
function rmsAt(sig, end, win) {
    const to = clamp(Math.floor(end), 1, sig.length);
    const from = Math.max(0, to - win);
    let sum = 0;
    for (let i = from; i < to; i += 1) {
        sum += sig[i] * sig[i];
    }
    return Math.sqrt(sum / Math.max(1, to - from));
}
// 공격 계수와 해제 계수가 다른 지수 평활 — 오를 때는 빨리, 내릴 때는 천천히 따라간다
function followEnvelope(values, attackK, releaseK) {
    const out = [];
    let e = 0;
    for (let i = 0; i < values.length; i += 1) {
        const v = values[i];
        e += (v > e ? attackK : releaseK) * (v - e);
        out.push(e);
    }
    return out;
}

03재생 위치 한 줄이 기둥을 반 픽셀 단위로 갈라 지난 부분과 남은 부분의 색을 바꾸는 파형

재생 위치는 halfPixel 이 Math.round(px * 2) / 2 로 0.5px 격자에 맞추고, 그 위치가 폭 4px 짜리 기둥 몸통 안에 떨어지면 clip() 영역 둘로 같은 사각형을 두 번 칠해 왼쪽은 분홍(PLAYED_HUE 340), 오른쪽은 옅은 잉크(UNPLAYED_ALPHA 0.28)가 된다. 경계가 기둥 사이 틈으로 건너뛰지 않고 기둥 속을 지나가므로 기둥 한 칸 폭 6px 보다 잘게 재생 위치가 읽힌다. 캔버스는 0~6초를 알리는 role="slider" 이고 ←/→ 한 번에 0.5초씩 움직이며, 끌어서 옮기면 스텝마다 목표까지 남은 차이의 SCRUB_EASE 0.25 배씩 다가간다.

재생 위치 한 줄이 기둥을 반 픽셀 단위로 갈라 지난 부분과 남은 부분의 색을 바꾸는 파형
새 창에서 열기

03 · 재생 위치 한 줄이 기둥을 반 픽셀 단위로 갈라 지난 부분과 남은 부분의 색을 바꾸는 파형 — 측정 누적면적 13.158% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PLAYHEAD_WPLAYED_HUEUNPLAYED_ALPHASCRUB_EASE
                if (x1 <= ph) {
                    ctx.fillStyle = played;
                    ctx.fillRect(x0, top, x1 - x0, bot - top);
                }
                else if (x0 >= ph) {
                    ctx.fillStyle = rest;
                    ctx.fillRect(x0, top, x1 - x0, bot - top);
                }
                else {
                    // 재생 위치가 걸친 기둥 — 클립 영역 둘로 나눠 왼쪽은 진하게, 오른쪽은 옅게
                    ctx.save();
                    ctx.beginPath();
                    ctx.rect(x0, 0, ph - x0, H);
                    ctx.clip();
                    ctx.fillStyle = played;
                    ctx.fillRect(x0, top, x1 - x0, bot - top);
                    ctx.restore();
                    ctx.save();
                    ctx.beginPath();
                    ctx.rect(ph, 0, x1 - ph, H);
                    ctx.clip();
                    ctx.fillStyle = rest;
                    ctx.fillRect(x0, top, x1 - x0, bot - top);
                    ctx.restore();
                }

04휠로 확대해도 포인터 아래 순간이 제자리에 머물고 묶음이 다시 계산되는 파형

휠 한 칸이 오면 notch 가 포인터 아래 표본 번호를 anchorT 로 먼저 적어 두고 목표 배율만 ZOOM_STEP 1.5 배로 바꾸며, 배율이 z * Math.pow(r, 0.2) 로 다가가는 동안 viewStartFor 가 anchorT - anchorPx / W * span 으로 시작점을 거꾸로 구해 같은 표본을 같은 화면 x 에 붙잡는다. 구간은 그릴 때마다 새 배율로 다시 묶이고, 픽셀당 표본이 1.6 개 밑으로 내려가면 기둥 대신 표본 점과 선으로 바뀐다. 배율이 1 이나 96 에 닿아 더는 바뀌지 않는 휠 입력은 막지 않으므로 그때는 페이지가 스크롤된다.

휠로 확대해도 포인터 아래 순간이 제자리에 머물고 묶음이 다시 계산되는 파형
새 창에서 열기

04 · 휠로 확대해도 포인터 아래 순간이 제자리에 머물고 묶음이 다시 계산되는 파형 — 측정 누적면적 35.085% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ZOOM_MINZOOM_MAXZOOM_STEPVIEW_START
    function recompute() {
        span = SAMPLE_N / z;
        start = viewStartFor(anchorT, anchorPx, span);
    }
    // 휠 한 칸 — 포인터가 가리키는 표본을 앵커로 잡고 목표 배율만 바꾼다
    function notch(dir, px) {
        const next = clamp(zt * (dir > 0 ? ZOOM_STEP : 1 / ZOOM_STEP), ZOOM_MIN, ZOOM_MAX);
        if (next === zt) {
            return false;
        }
        anchorT = start + px / W * span;
        anchorPx = px;
        zt = next;
        if (h.reduce) {
            z = zt;
            recompute();
        }
        return true;
    }

05자를 구간의 손잡이가 가장 가까운 영점 교차로 끌려가 붙는 구간 선택기

zeroCrossings 는 이웃한 두 표본의 부호가 바뀌는 곳마다 선형 보간으로 값이 0 이 되는 x 를 소수 px 까지 구하고, 손잡이를 놓으면 snapTarget 이 SNAP_RADIUS 6px 안에서 가장 가까운 교차를 골라 남은 거리의 SNAP_EASE 0.25 배씩 끌어당긴다. 자른 경계의 값이 0 이라서 잘라 낸 구간 앞뒤를 이어 붙여도 값이 계단처럼 튀지 않고, 반경 안에 교차가 없으면 손잡이는 놓은 자리에 남은 채 상태 알약이 반경 안에 영점 교차 없음 으로 바뀐다. 손잡이 둘은 role="slider" 인 <div> 라 Tab 으로 잡히고, ←/→ 를 누르면 다음·이전 영점 교차로 한 번에 건너뛴다.

자를 구간의 손잡이가 가장 가까운 영점 교차로 끌려가 붙는 구간 선택기
새 창에서 열기

05 · 자를 구간의 손잡이가 가장 가까운 영점 교차로 끌려가 붙는 구간 선택기 — 측정 누적면적 17.933% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SNAP_RADIUSHANDLE_WMIN_SPANSNAP_EASE
function zeroCrossings(sig) {
    const xs = [];
    for (let i = 0; i < sig.length - 1; i += 1) {
        const a = sig[i];
        const b = sig[i + 1];
        if ((a < 0 && b >= 0) || (a >= 0 && b < 0)) {
            xs.push((i + a / (a - b)) / (sig.length - 1) * W);
        }
    }
    return xs;
}
// 놓은 자리 x 에서 반경 안의 가장 가까운 영점 교차로 — 없으면 제자리
function snapTarget(x, crossings, radius) {
    let best = x;
    let bestD = radius + 0.0001;
    for (let i = 0; i < crossings.length; i += 1) {
        const d = Math.abs(crossings[i] - x);
        if (d < bestD) {
            bestD = d;
            best = crossings[i];
        }
    }
    return best;
}

06상승 구간에서 트리거가 걸려 흔들림 없이 멈춰 보이는 흐르는 오실로스코프

findTrigger 는 버퍼 2,048 표본에서 레벨 TRIG_LEVEL 0.15 를 아래에서 위로 넘는 지점을 찾고, 위 칸은 그 지점부터 480 표본을 그려 흐르는 신호를 매 프레임 같은 자리에서 시작하게 한다. 입력에는 빠른 잔물결 0.09 * Math.sin(n * 0.83) 이 섞여 한 주기에 레벨을 두 번 넘기도 하는데, HOLDOFF 40 이 채택한 에지에서 40 표본 안쪽의 다음 에지를 버린다. 아래 칸은 트리거 없이 스텝마다 9 표본씩 흘러가는 프리런이라 두 칸을 나란히 보면 트리거가 하는 일이 바로 보인다.

상승 구간에서 트리거가 걸려 흔들림 없이 멈춰 보이는 흐르는 오실로스코프
새 창에서 열기

06 · 상승 구간에서 트리거가 걸려 흔들림 없이 멈춰 보이는 흐르는 오실로스코프 — 측정 누적면적 32.302% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TRIG_LEVELHOLDOFFTRACE_GLOWTIME_DIV
// 입력 신호 — 주파수가 천천히 떠도는 비대칭 파형에 빠른 잔물결을 얹었다. 표본 번호만으로 값이 정해진다
function scopeSample(n) {
    const phase = PI2 * BASE_FREQ * (n + 285.7 * (1 - Math.cos(0.0007 * n)));
    return 0.62 * Math.sin(phase) + 0.28 * Math.sin(2 * phase + 0.9) + 0.09 * Math.sin(n * 0.83) + (hash1(n, 7) - 0.5) * 0.05;
}
// 최근 표본에서 레벨을 아래에서 위로 가로지르는 지점을 찾고, HOLDOFF 안쪽의 다음 에지는 버린다
function findTrigger(buf, level, holdoff, show) {
    let kept = -1;
    let found = -1;
    for (let j = 1; j + show <= buf.length; j += 1) {
        if (buf[j - 1] < level && buf[j] >= level) {
            if (kept < 0 || j - kept >= holdoff) {
                kept = j;
                found = j;
            }
        }
    }
    return found;
}

07파형이 둥근 고리 둘레에 말려 돌아가는 재생 바늘을 따라 켜지는 극좌표 장면

원 둘레를 BAR_COUNT 96 칸으로 나눠 칸마다 표본 450 개의 최댓값은 바깥으로, 최솟값은 안쪽으로 세우고, 길이를 진폭의 0.7 제곱으로 눌러 작은 소리의 막대도 눈에 띄게 늘린다. 바늘은 초당 SWEEP_SPEED 2.2 라디안으로 돌아 한 바퀴에 약 2.86초가 걸리고, 바늘에서 뒤처진 각도 behind 만큼 Math.exp(-behind * AFTERGLOW) 로 밝기를 줄여 바로 뒤의 막대만 분홍으로 밝게 남긴다. 캔버스는 0~360도를 알리는 슬라이더이고, 누른 채 돌리면 원 중심 기준 atan2 로 바늘이 손을 따라온다.

파형이 둥근 고리 둘레에 말려 돌아가는 재생 바늘을 따라 켜지는 극좌표 장면
새 창에서 열기

07 · 파형이 둥근 고리 둘레에 말려 돌아가는 재생 바늘을 따라 켜지는 극좌표 장면 — 측정 누적면적 22.116% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RING_RBAR_COUNTSWEEP_SPEEDAFTERGLOW
            ctx.lineWidth = PI2 * RING_R / BAR_COUNT * 0.74;
            const cur = Math.floor(theta / PI2 * BAR_COUNT) % BAR_COUNT;
            for (let i = 0; i < BAR_COUNT; i += 1) {
                const a = (i + 0.5) / BAR_COUNT * PI2;
                // 바늘이 지나간 뒤 얼마나 돌았는지 — 가까울수록 밝다
                const behind = ((theta - a) % PI2 + PI2) % PI2;
                const glow = Math.exp(-behind * AFTERGLOW);
                const outer = RING_R + Math.pow(Math.max(bars.hi[i], 0.03), 0.7) * BAR_LEN;
                const inner = RING_R - Math.pow(Math.max(-bars.lo[i], 0.03), 0.7) * BAR_LEN * 0.8;
                const s = Math.sin(a - Math.PI / 2);
                const c = Math.cos(a - Math.PI / 2);
                ctx.strokeStyle = rgba(mix(LILAC, PINK, glow), 0.42 + 0.58 * glow);
                ctx.beginPath();
                ctx.moveTo(CX + c * inner, CY + s * inner);
                ctx.lineTo(CX + c * outer, CY + s * outer);
                ctx.stroke();
            }

08막대는 즉시 솟고 꼭대기 표시는 잠시 머문 뒤 중력처럼 떨어지는 이퀄라이저

대역 24 개마다 stepBand 가 목표가 막대보다 크면 그 값으로 바로 올리고, 작으면 차이의 BAR_FALL_K 0.12 배씩만 내려 10% 아래까지 19프레임이 걸리게 한다. 꼭대기 표시는 막대가 닿을 때마다 HOLD_FRAMES 28프레임 머문 뒤 프레임마다 속도에 CAP_FALL_G 0.0006 을 더하며 가속해 떨어지고, 막대 아래로 파고들면 막대 높이에서 속도 0 으로 다시 선다. 대역을 누르면 누른 높이까지 밀어 올리고, ←/→ 로 대역을 고른 뒤 Enter·Space 를 누르면 0.95 까지 올라간다.

막대는 즉시 솟고 꼭대기 표시는 잠시 머문 뒤 중력처럼 떨어지는 이퀄라이저
새 창에서 열기

08 · 막대는 즉시 솟고 꼭대기 표시는 잠시 머문 뒤 중력처럼 떨어지는 이퀄라이저 — 측정 누적면적 18.564% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BAND_NHOLD_FRAMESCAP_FALL_GBAR_FALL_K
function stepBand(s, target, fallK, holdFrames, gravity) {
    if (target > s.bar) {
        s.bar = target;
    }
    else {
        s.bar += (target - s.bar) * fallK;
    }
    if (s.bar >= s.cap) {
        s.cap = s.bar;
        s.hold = holdFrames;
        s.vel = 0;
    }
    else if (s.hold > 0) {
        s.hold -= 1;
    }
    else {
        s.vel += gravity;
        s.cap -= s.vel;
        if (s.cap < s.bar) {
            s.cap = s.bar;
            s.vel = 0;
        }
    }
}

09녹음 중인 소리가 오른쪽에서 이어 붙고 자동 이득이 작은 소리를 끌어올리는 파형

append 는 스텝마다 표본 80 개의 최대·최소를 열 하나로 오른쪽 끝에 붙이고 맨 왼쪽 열을 빼서, 2px 폭 240 열이 약 3.8초에 화면을 가로지르게 한다. 같은 자리에서 peakHold 는 스텝마다 0.996 배씩 곱해져 줄어드는 최댓값 기록이 되고, stepGain 은 GAIN_TARGET / peakHold 를 0.5~8 사이로 자른 값을 향해 오를 때는 GAIN_RISE 0.012, 내릴 때는 GAIN_FALL 0.35 비율로 이득을 옮긴다. 캔버스를 누르거나 Enter·Space 를 누르면 박수처럼 큰 소리가 들어와 이득이 급히 내려가는 장면을 볼 수 있다.

녹음 중인 소리가 오른쪽에서 이어 붙고 자동 이득이 작은 소리를 끌어올리는 파형
새 창에서 열기

09 · 녹음 중인 소리가 오른쪽에서 이어 붙고 자동 이득이 작은 소리를 끌어올리는 파형 — 측정 누적면적 33.41% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GAIN_TARGETGAIN_RISEGAIN_FALLSCROLL_PX
    function append() {
        const lv = level();
        let lo = 1e9;
        let hi = -1e9;
        for (let i = 0; i < PER_STEP; i += 1) {
            const ph = (n + i) * 0.19;
            const v = lv * (Math.sin(ph) * 0.8 + Math.sin(ph * 2.3 + 0.5) * 0.25) + (hash1(n + i, 21) - 0.5) * 0.04;
            if (v < lo) {
                lo = v;
            }
            if (v > hi) {
                hi = v;
            }
        }
        n += PER_STEP;
        const stepPeak = Math.max(Math.abs(lo), Math.abs(hi));
        peakHold = Math.max(stepPeak, peakHold * PEAK_DECAY);
        gain = stepGain(gain, peakHold, GAIN_TARGET, GAIN_RISE, GAIN_FALL);
        rawLo.shift();
        rawHi.shift();
        gains.shift();
        rawLo.push(lo);
        rawHi.push(hi);
        gains.push(gain);
    }

값이 파형의 인상을 바꾸는 곳 — 묶음 폭·해제 계수·흡착 반경

브라우저에서 아홉 장면을 마지막으로 통째 렌더했을 때 프레임 24장 사이의 픽셀 변화로 잰 결과부터 적는다. 누적면적은 한 번이라도 바뀐 픽셀의 비율이고, 움직인 프레임은 이웃한 두 장 사이 23번의 비교에서 변화가 잡힌 횟수다.

장면 누적면적 강도 평균 움직인 프레임
01 최대·최소 기둥 38.4% 67.9 23/23
02 제곱평균 윤곽 29.1% 78.5 23/23
03 재생 위치 가름 13.2% 99.5 23/23
04 휠 확대 앵커 35.1% 80.8 16/23
05 영점 흡착 자르기 17.9% 78.5 23/23
06 상승 에지 트리거 32.3% 69.8 23/23
07 극좌표 고리 22.1% 59.6 23/23
08 꼭대기 유지 이퀄라이저 18.6% 133.9 23/23
09 녹음 자동 이득 33.4% 110.4 23/23

03 은 재생 위치가 지나간 기둥만 색이 바뀌는 장면이라 면적이 13.2% 로 가장 작고, 04 는 23번의 비교 가운데 일곱 번은 화면이 그대로였다. 아래 숫자는 렌더가 아니라 각 장면의 함수를 노드에서 직접 불러 센 값이다.

BUCKET_PX 를 3 에서 8 로 넓히면 기둥이 160 개에서 60 개가 된다

기둥 하나가 맡는 표본이 270 개에서 720 개로 늘어난다. 1px 마다 하나씩 480 열로 묶은 윤곽을 정답으로 두고 견주면 평균 오차가 0.030 에서 0.083 으로, 가장 큰 오차가 0.62 에서 0.82 로 커져 큰 소리 바로 옆의 얇은 소리가 그 기둥에 먹힌다. 막대가 굵어 보이는 대신 세부가 사라지는 맞바꿈이라, 편집 화면처럼 세부가 중요한 곳은 3 쪽이 낫다.

RELEASE_K 를 0.25 로 올리면 여운이 38칸에서 9칸으로 짧아진다

합성 신호에서 순간 제곱평균과 윤곽 사이의 평균 간격은 0.026 에서 0.008 로 좁아져 윤곽이 순간값에 바짝 붙는다. 이 계수는 칸마다 곱하는 값이라 시간 상수는 칸이 담는 표본 수 60 개에 묶여 있고, 보이는 구간을 넓혀 한 칸이 표본을 더 많이 담으면 같은 0.06 이라도 여운이 더 길게 느껴진다.

SNAP_RADIUS 를 40 으로 키우면 놓은 자리를 전부 끌어가지만 최대 11.7px 까지 옮긴다

05 의 신호에는 영점 교차가 23 개 있고 간격은 평균 20.2px(가장 좁은 곳 13.6, 가장 넓은 곳 23.5)이다. 손잡이를 10px 부터 470px 까지 1px 간격으로 놓아 보면 반경 6 은 놓은 자리의 60% 만 끌어가며 평균 1.8px(최대 6.0px)을 옮기고, 반경 40 은 100% 를 끌어가며 평균 5.1px, 최대 11.7px 을 옮긴다. 시연 각본의 두 번째 놓기 329px 는 가장 가까운 교차 317.2·340.7 이 각각 11.8px·11.7px 밖이라 반경 6 에서는 제자리에 남는다.

작은 소리를 키우는 데는 2.8초, 박수 뒤 이득을 내리는 데는 11스텝이 걸린다

09 에서 입력 최댓값이 0.12 이면 목표 이득은 0.8 / 0.12 = 6.67 이고, GAIN_RISE 0.012 로 그 90% 에 닿기까지 178스텝(약 2.8초)이 걸린다. 반대로 최댓값 0.95 짜리 박수가 들어오면 GAIN_FALL 0.35 덕분에 이득이 6.5 에서 0.9 아래로 11스텝 만에 내려온다. 오를 때는 느리게, 내릴 때는 빠르게 둔 것은 02 의 공격·해제와 같은 발상을 방향만 뒤집은 것이다.

어디서 깨지는가 — 묶음이 넓을 때·가장자리에서 확대할 때·레벨이 신호를 넘을 때

파형이 이상해 보이는 지점을 장면별로 골라 소스 함수를 노드에서 불러 숫자로 짚었다. 브라우저에서 속도를 재 본 항목은 여기에 없다.

화면 밖 예열이 없으면 왼쪽 끝 윤곽이 절반 높이에서 시작한다

02 의 followEnvelope 는 let e = 0 에서 출발해 이전 칸 값을 이어받는다. WARM_COLS 30칸을 화면 밖에서 먼저 돌려 둔 지금은 화면 첫 칸 윤곽이 0.242 인데, 예열을 빼면 그 절반쯤인 0.116 에서 시작하고 처음 40칸 안에서 차이가 최대 0.126 까지 벌어져 화면 왼쪽에 신호에는 없는 경사가 생긴다.

화면 가장자리에서 확대하면 포인터 아래 표본이 밀린다

포인터를 330px 이나 오른쪽 끝에 가까운 460px 에 두고 확대해도 배율 4·16·96 에서 앵커가 정확히 그 자리에 머문다. 그런데 파형 앞머리에 가까운 표본 300 번을 화면 20px 에 두고 확대하면 시작점이 0 아래로 갈 수 없어서 앵커의 화면 위치가 배율 1·4·16·96 에서 3.3 → 13.3 → 20 → 20px 로 움직인다. 처음 몇 단계 동안 포인터 아래 순간이 옆으로 밀리는 것은 viewStartFor 의 clamp 때문이고, 끝을 넘어 빈 구간을 보여 주지 않으려면 이 밀림은 받아들여야 한다.

재생 위치가 기둥 틈에 떨어지면 가를 것이 없다

03 의 기둥은 몸통 4px 에 틈 2px 이다. 0.5px 간격으로 놓을 수 있는 960 지점 가운데 몸통 안쪽이라 클립 둘로 가르는 경우는 58.3% 이고, 나머지는 틈이나 경계라 한쪽 색으로만 칠한다. 좌표가 논리 좌표라서 장치 픽셀비가 1 인 화면에서는 0.5px 경계가 안티앨리어싱으로 번져 보이고, 2 인 화면에서 장치 픽셀에 딱 맞는다.

두 손잡이는 24px 안으로 붙지 못한다

05 의 MIN_SPAN 24px 는 흡착이 끝난 뒤 limit() 로 한 번 더 걸린다. 영점 교차의 평균 간격 20px 보다 넓어서, 바로 이웃한 두 교차에 손잡이를 하나씩 붙이는 아주 짧은 구간 선택은 이 값을 줄여야 만들 수 있다.

홀드오프 40 으로도 트리거의 되튐은 0 이 되지 않는다

06 을 400스텝 돌리며 트리거 지점이 60 표본 안에서 되튄 횟수를 세면 HOLDOFF 0 에서 22번, 40 에서 9번이다. 트리거가 잡은 창의 첫 표본은 0.143 폭 안에 머물지만 프리런 창의 첫 표본은 1.676 폭을 오가니, 위 칸이 아래 칸보다 훨씬 고요해 보이는 까닭이 이 차이에 있다.

레벨이 신호 최댓값을 넘으면 트리거는 프리런과 같은 창으로 물러난다

06 의 입력은 최댓값이 0.73 이라 레벨을 LEVEL_LIMIT 0.7 안에서 움직이면 300스텝 동안 한 번도 트리거를 놓치지 않는다(+0.7 과 −0.7 모두). 한계를 풀어 레벨을 0.99 에 두면 300번 모두 넘는 지점을 못 찾고, 위 칸은 버퍼의 마지막 한 화면을 그려 아래 프리런과 같은 모양이 된다.

박수 직후 몇 열은 분홍 넘침으로 찍힌다

09 의 이득이 아직 높은 첫 몇 열에서는 이득을 곱한 값이 1 을 넘어 분홍 열이 찍히고 상태 알약이 넘침 으로 바뀐다. 반대로 무음이 오래 가면 peakHold 가 계속 줄어 이득이 상한 8 까지 올라가고, 그때는 잡음까지 여덟 배로 커진다.

07 위에서는 터치로 페이지를 스크롤할 수 없다

07 은 캔버스 전체가 touch-action: none 이라 고리 위에 손가락을 올린 채로는 페이지가 움직이지 않는다. 04 는 pan-y 라 세로 스크롤을 브라우저에 맡기고 가로 끌기만 앱이 쓰며, 06 은 반대로 pan-x 이고 05 는 손잡이 요소만 none 이다. 긴 글 한가운데 07 을 넣는다면 고리 둘레에 스크롤할 여백을 남겨 두는 편이 안전하다.

느린 기기에서도 꼭대기 표시의 낙하 시간은 같다

08 의 막대와 꼭대기 표시는 16ms 고정 스텝으로만 바뀌므로 화면 주사율이 달라도 머무는 시간을 포함해 꼭대기에서 바닥까지 85프레임이라는 값은 같다. 탭을 비웠다 돌아오면 한 번에 10스텝만 따라잡으니, 그동안 떨어졌어야 할 꼭대기 표시가 한꺼번에 사라지지 않고 이어서 내려온다.

아홉 장면의 index.html·style.scss 와 리액트 판을 묶은 zip 은 k2eeh529 을 넣어야 열리고, 위 상수를 직접 바꿔 보려면 그 파일이 있어야 한다.

변형 세 가지와 파형 장면의 접근성 — 키보드·화면 읽기·움직임 줄이기

이름 값 느낌
묶음 폭 바꾸기 01 BUCKET_PX 3 → 8 기둥이 160 개에서 60 개로 줄고 평균 오차가 0.030 에서 0.083 으로 커져, 막대는 굵어지지만 얇은 소리가 큰 이웃에 묻힌다.
해제 계수 바꾸기 02 RELEASE_K 0.06 → 0.25 10% 아래로 가라앉는 데 38칸 걸리던 여운이 9칸으로 짧아져 윤곽이 순간값을 바짝 따라 떨린다.
흡착 반경 바꾸기 05 SNAP_RADIUS 6 → 40 놓은 자리의 60% 만 끌려가던 손잡이가 100% 끌려가고, 대신 평균 5.1px·최대 11.7px 까지 손을 벗어나 옮겨진다.

세 줄의 숫자도 앞의 값 절처럼 함수를 직접 불러 센 것이다.

키보드로는 아홉 장면을 다 다룰 수 있다. 캔버스마다 tabindex="0" 이 붙어 있고 05 는 손잡이 둘에도 따로 붙어 있으며, preventDefault() 는 장면이 처리하는 방향키·Home·End·+/-·Enter·Space 에서만 부르고 04 는 배율이 실제로 바뀌는 휠 입력에서만 부른다. 슬라이더 역할은 세 장면에 있다. 03 캔버스는 aria-valuetext 를 3.0 / 6.0 s 꼴로, 07 캔버스는 77 deg 꼴로 30스텝마다 또는 입력 즉시 갱신하고, 05 손잡이는 표본 번호 0~1919 를 값이 바뀔 때만 적는다.

상태 알약 <p> 는 HTML 에서 aria-live="polite" 로 시작하지만, 시연이 혼자 도는 동안에는 off 로 바뀌어 화면 읽기 프로그램이 가짜 포인터의 움직임을 읽지 않는다. 사람이 만지거나 움직임 줄이기 설정이 켜져 있으면 다시 polite 가 되고, 250ms 안에 몰린 갱신은 건너뛴다. 초점 테두리는 아홉 장면의 style.css 에 :focus-visible 로 들어 있다.

prefers-reduced-motion 이 켜진 화면에서는 requestAnimationFrame 루프를 시작하지 않는다. 처음에 40스텝만 진행해 첫 화면을 만들고, 멈춤 버튼 자리의 다음 순간 을 누를 때마다 900ms 분량인 56스텝을 진행한 뒤 한 번 그린다. 입력은 보간 없이 바로 반영돼서 03 은 누른 위치로 재생 위치가 옮겨 가고, 04 는 목표 배율로 곧장 바뀌며, 05 손잡이는 흡착 목표에 바로 놓이고, 08 은 막대와 꼭대기 표시가 누른 높이에 곧바로 서며, 09 는 박수 뒤 서른 열을 한꺼번에 이어 붙인다.

audio waveform css 만으로 충분한가?

막대 몇 개가 박자 없이 오르내리는 장식이라면 CSS 만으로 된다. 하지만 이 편이 다루는 일, 곧 수만 개 표본에서 기둥 끝을 고르고 윤곽을 따라가고 잘라 낼 경계를 0 에 맞추는 일은 값을 세는 일이라 스크립트가 맡는다. CSS 쪽 몫은 무대 크기가 거의 전부인데, .wf 는 width: min(92vw, calc(160vh - 12.8vw)) 에 aspect-ratio: 480 / 300 이고 좌표는 480×300 논리 좌표라 창 크기가 바뀌어도 기둥 수는 그대로다.

브라우저 오디오 API 로 실제 소리를 받아 그리는 쪽은 오디오 비주얼라이저 만들기 에 있고, 재생 버튼과 진행 막대가 있는 플레이어 껍데기는 css 오디오 플레이어 디자인 과 같이 보면 03 의 재생 위치를 어디에 얹을지 감이 온다. 05 의 두 손잡이 구조는 범위 슬라이더 편의 두 엄지 슬라이더와 닮았고, 사인 합성으로 그리는 곡선 자체가 궁금하면 리사주 곡선 으로 이어진다. 그리는 방식의 근거는 requestAnimationFrame 과 clip() 문서에서 확인했다.

css audio waveform animation 코드를 가져다 쓰기 전에 자주 묻는 질문

진짜 녹음 파일의 파형으로 바꾸려면 어디를 고치나?

01·02·03·04·07 은 장면 함수 첫 줄의 synth(SAMPLE_N, 시드) 가 숫자 배열을 만드니, 그 자리에 내 표본 배열을 넣으면 된다. 01 은 값에 HALF_Y 84 를 곱해 높이를 정하므로 −1~1 범위로 맞춘 배열이어야 기둥이 무대 안에 들어온다. 파일을 읽어 표본으로 푸는 부분은 이 편의 데모에 없다.

css audio waveform animation 에 표본이 수백만 개면 무거워지지 않나?

01 의 bucketColumns 는 그릴 때마다 보이는 표본 43,200 개를 한 번씩 훑으니, 보이는 구간이 길어질수록 그만큼 일이 늘어난다. 04 는 위쪽 개요 띠 120칸을 장면이 만들어질 때 한 번만 묶어 두고 다시 세지 않는데, 긴 음원이라면 이처럼 미리 묶은 배열을 배율별로 따로 두는 쪽이 출발점이다.

화면 폭이 바뀌면 기둥 수도 바뀌나?

바뀌지 않는다. fit() 이 캔버스 실제 폭에 devicePixelRatio 를 곱해 백버퍼만 다시 잡고 setTransform 으로 480×300 좌표를 늘려 그리기 때문이다. 340px 이하에서는 캡션·버튼·상태 알약 글자가 10px 로 줄 뿐 캔버스 비율은 그대로이고, 렌더 측정에서 폰 폭 넘침은 아홉 장면 모두 없었다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.