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

가상 피아노 9종 — 스프링 건반·해머·줄 배음·자동 연주 무대

가상 피아노는 화면에 그린 건반을 마우스·손가락·자판으로 눌러 음을 내고, 눌린 건반과 떨리는 줄을 그림으로 보여 주는 웹 페이지다. 건반 하나가 내려가면 화면 속 줄은 어떤 모양으로 떨려야 할까?

연습실 화면부터 공연 무대까지 — 건반·해머·줄이 함께 움직이는 아홉 장면

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

손대지 않으면 아홉 장면은 미리 정해 둔 음을 소리 없이 혼자 친다. 「소리 켜기」 버튼을 누른 뒤에야 사인파 네 개를 겹친 음이 나고, 건반이나 소리 버튼을 처음 누르는 순간 시연 표시가 떨어진다. 사람이 2.5초 동안 손을 떼고 있으면 시연 음이 다시 이어진다.

들어온 소리를 쪼개 막대로 그리는 오디오 비주얼라이저 와는 방향이 반대다. 거기서는 소리가 먼저 있고 그림이 따라오지만, 여기서는 건반의 깊이·해머의 각·줄의 모양을 먼저 계산하고 소리는 켰을 때만 그 계산을 따라 난다.

연습용 웹 건반과 수업 자료

화음 이름을 가르치는 수업 자료라면 05 의 화음 원과 08 의 오선이 맞는다. 도미솔·도미라·도파라는 원 위에서 세 변의 길이가 같은 삼각형이라 넓이가 셋 다 원의 37.7% 이고 기운 쪽만 달라서, 장화음과 단화음이 같은 뼈대라는 설명을 그림 한 장으로 끝낼 수 있다. 08 은 친 음이 건반에서 날아올라 오선의 줄이나 칸에 앉으므로, 악보를 막 읽기 시작한 사람이 건반 자리와 오선 높이를 짝지어 보게 된다.

공연 소개 화면과 작은 휴대폰 화면

온라인 피아노 키보드를 소개 페이지 첫 화면에 올린다면 04 의 떨어지는 막대나 09 의 원근 무대가 시선을 끈다. 다만 건반을 88개로 늘리는 순간 휴대폰 폭에서 바로 문제가 생기고, 07 이 그 문제를 푸는 방식은 아래 깨지는 장면 절에서 숫자로 다룬다.

가상 피아노 9종 — 스프링 건반·해머 지렛대·줄 배음·피아노 롤·화음 원·공명 줄·쓸어 치기·오선 음표·자동 연주 무대

장면마다 480×300 화면을 2초 동안 24장 찍고, 이웃한 두 장을 견준 23번의 차이를 모은 값이 아래 표다. 아홉 장면 모두 23번 가운데 23번 화면이 바뀌었다. 손끝이 88건반 위를 쓸고 가는 07 이 49.8% 로 가장 넓게, 맞는 줄만 빛나는 06 이 10.4% 로 가장 좁게 움직였다.

장면마다 바뀐 화면 넓이와 강도

장면 누적면적 움직인 프레임 강도 평균
01 입체 스프링 건반 19.0% 23/23 45.4
02 해머 단면 15.6% 23/23 69.9
03 줄 배음 16.6% 23/23 77.7
04 피아노 롤 44.7% 23/23 56.0
05 화음 원 23.3% 23/23 67.8
06 공명 줄 10.4% 23/23 31.1
07 글리산도 49.8% 23/23 130.8
08 오선 음표 25.6% 23/23 65.5
09 원근 무대 22.4% 23/23 40.2

피아노 치는 웹 사이트의 건반은 왜 납작한 사각형처럼 보이기 쉬운가?

가상 피아노 건반을 흰 사각형과 검은 사각형으로 번갈아 그리고 누를 때 색만 바꾸면, 눌렸다는 사실은 보여도 얼마나 깊이·얼마나 세게 눌렸는지는 안 보인다. 이 판의 건반은 색 대신 깊이를 가진다. 01 은 건반 앞 끝이 뒤 끝을 축으로 기울어 16 만큼 내려가고, 놓으면 스프링이 그 깊이를 되돌린다.

기울기와 그늘이 깊이를 만든다

끝까지 누르면 흰건반은 6.12°, 검은건반은 10.02° 기운다. 검은건반은 축까지의 거리가 92 로 흰건반(150)보다 짧아서, 같은 16 을 내려가도 더 가파르게 기운다. 이웃 흰건반의 앞 끝이 내 앞 끝보다 높으면 그 높이 차에 1.6 을 곱한 폭의 그늘 삼각형이 내 윗면에 드리우는데, 끝까지 눌린 건반 옆에 쉬는 이웃이 있으면 그 폭이 25.6 이다.

세기가 보이는 자리

마우스나 손가락으로는 건반 아래쪽을 칠수록 세기가 0.45 에서 1 로 커진다. 01 은 누를 때 세기 × 14 만큼 속도를 덤으로 얹어, 세기 0.8 로 누른 건반이 80ms 에 바닥에 닿는다. 색 바꾸기만으로는 이 차이를 보여 줄 자리가 없다.

01스프링으로 눌렸다 돌아오는 입체 건반

건반 13개를 높이 250 에서 0.7 라디안 내려다본 원근으로 놓아 앞 끝은 화면 y 228.5, 뒤 끝은 y 141.9 에 그리고, 왼쪽 위 그래프가 마지막으로 친 건반의 눌림 깊이를 1.92초어치 곡선으로 남긴다. 아래 stepSpring 은 강성 420·감쇠 16 으로 위치를 목표로 당기고, 바닥 1 을 넘으면 속도를 뒤집어 0.3 배만 남긴다. 감쇠 비 0.39 라 놓은 건반은 96ms 에 쉼 자리를 처음 지나 이동 거리의 23.9% 만큼 위로 되튀고, 384ms 부터는 쉼 자리의 ±2% 안에 머문다.

스프링으로 눌렸다 돌아오는 입체 건반
새 창에서 열기

01 · 스프링으로 눌렸다 돌아오는 입체 건반 — 측정 누적면적 19.043% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

KEY_SPRING_STIFFNESSKEY_TRAVELSHADOW_SLOPE
    function stepSpring(s, k, c, dt) {
        const a = -k * (s.x - s.target) - c * s.v;
        s.v += a * dt;
        s.x += s.v * dt;
        if (s.x > 1) {
            s.x = 1;
            s.v = -s.v * KEYBED_BOUNCE;
        }
    }

02지렛대로 줄을 치고 되튀는 해머 단면

건반 앞 끝이 12px 내려가는 동안 지렛대 비 4.412, 곧 (뒤 팔 120 ÷ 앞 팔 170) × (자루 150 ÷ 엉덩이 팔 24) 가 해머 머리를 건반 속도의 4.4 배로 원호 위로 밀고, 0.27 라디안에서 놓인 해머는 중력만 받으며 날아간다. 아래 조각에서 줄 각 0.4334 에 닿으면 각속도에 −0.45 를 곱해 되튀고, 줄에 닿지 못한 채 위로 가다 꺾이면 「너무 약함 — 줄에 못 닿고 떨어짐」 을 띄우는데, 친 건반은 손을 일찍 떼도 바닥까지 내려가므로 이 갈림은 누른 시간이 아니라 세기 하나로 정해져 0.406 까지는 못 닿고 0.407 부터 닿는다. 시연은 세기 0.95 로 줄을 쳐 2.36 rad/s 로 되튀게 하고, 해머가 내려앉으면 0.3 으로 약하게 쳐 최고 각 0.3566 에서 꺾이며 이 문구를 한 번 띄운 뒤 0.65 로 다시 치고, 손으로 칠 때는 세기를 제곱해 쓰므로 건반 버튼 위쪽 3분의 1 쯤을 누르면 해머가 줄에 못 닿지만, 소리는 세기와 상관없이 난다.

지렛대로 줄을 치고 되튀는 해머 단면
새 창에서 열기

02 · 지렛대로 줄을 치고 되튀는 해머 단면 — 측정 누적면적 15.556% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEVER_RATIOTHETA_LETOFFRESTITUTION
        const wasRising = omega > 0;
        omega -= GRAVITY / SHANK_LEN * Math.cos(theta) * dt;
        theta += omega * dt;
        apex = Math.max(apex, theta);
        if (theta >= THETA_STRING && omega > 0) {
            theta = THETA_STRING;
            const hitW = omega;
            omega = -RESTITUTION * omega;
            hitThisFlight = true;
            eventText = HIT_LABEL.replace('{w}', hitW.toFixed(1)).replace('{r}', Math.abs(omega).toFixed(1));
            eventAt = clock;
            stringAmp = Math.min(34, hitW * SHANK_LEN * STRING_GAIN);
            stringAt = clock;
        }
        // 한 비행에 판정은 한 번 — 받침에서 튀어 오른 뒤 다시 내려올 때 「너무 약함」 이 또 뜨지 않게
        if (wasRising && omega <= 0 && !hitThisFlight) {
            eventText = MISS_LABEL;
            eventAt = clock;
            hitThisFlight = true;
        }

03친 자리로 배음이 달라지는 줄 떨림

해머가 줄 길이의 1/8 자리를 치면 n번째 배음의 크기는 sin(nπ × 0.125) ÷ n 이라 1번 0.3827, 2번 0.3536, 4번 0.25 로 줄다가 그 자리가 마디인 8번과 16번은 0 이 되고, 판 오른쪽에 「크기 0 (친 자리가 마디): 8 · 16」 이 뜬다. 감쇠 시간은 1번 2600ms 에서 8번 536ms·16번 281ms 로 짧아져, 600ms 뒤 남은 몫이 1번 0.794·8번 0.327 이 되면서 줄이 한 굽이로 정리된다. 줄 모양은 친 자리 쪽으로 쏠려, 좌우 대칭인 두 자리의 높이 차가 가장 클 때 120ms 에 45.71px, 600ms 에 24.91px 다.

친 자리로 배음이 달라지는 줄 떨림
새 창에서 열기

03 · 친 자리로 배음이 달라지는 줄 떨림 — 측정 누적면적 16.561% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STRIKE_POINTDECAY_BASE_MSpartialSizes
    function partialSizes(p) {
        const out = [];
        for (let n = 1; n <= PARTIALS; n += 1) {
            out.push(Math.sin(n * Math.PI * p) / n);
        }
        return out;
    }
    function decayMs(n) { return DECAY_BASE_MS / (1 + DECAY_SLOPE * (n - 1)); }

04건반 위로 떨어지는 피아노 롤 막대

템포 112 에서 1박은 535.71ms 이고 낙하 속도 150px/초로 1박 막대가 80.36px 이 되어, 막대는 그 음이 시작하기 1.507초 전에 화면 위에 나타난다. 아래 step 은 지난 스텝과 이번 스텝의 막대 아래 끝을 견줘 건반 선(y 226)을 넘는 순간 누르고 위 끝이 넘는 순간 떼므로, 16ms 한 스텝 사이에 선을 건너뛰어도 놓치지 않는다. 닿는 순간 빛 조각 7개가 튀고 눌린 건반 위로 150px 빛 기둥이 선다.

건반 위로 떨어지는 피아노 롤 막대
새 창에서 열기

04 · 건반 위로 떨어지는 피아노 롤 막대 — 측정 누적면적 44.665% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TEMPO_BPMFALL_PX_PER_SECbarBottom
        songPrev = songMs;
        songMs = clock - LEAD_IN_MS;
        // 막대 아래 끝이 건반 선을 넘는 순간 누르고, 위 끝이 넘는 순간 뗀다 — 지난 바퀴·이번 바퀴 둘을 견준다
        const lap = Math.floor(songMs / LOOP_MS);
        for (let k = lap - 1; k <= lap; k += 1) {
            for (let i = 0; i < SONG.length; i += 1) {
                const n = SONG[i];
                const st = n[0] * BEAT_MS + k * LOOP_MS;
                const b0 = barBottom(st, songPrev);
                const b1 = barBottom(st, songMs);
                const len = barLengthPx(n[2]);
                if (b0 - len < KEY_LINE_Y && b1 - len >= KEY_LINE_Y) {
                    release(n[1], true);
                }
                if (b0 < KEY_LINE_Y && b1 >= KEY_LINE_Y && autoAllowed()) {
                    press(n[1], n[3], true);
                }
            }
        }

05울리는 음을 이어 그리는 화음 원

열두 음을 12시의 도부터 시계 방향으로 30° 마다 놓고 지금 울리는 음을 이어 다각형을 그리며, polygonArea 의 엇곱 합으로 넓이를, 같은 엇곱으로 무게를 준 평균으로 무게중심을 낸다. 도미솔·도미라·도파라는 넓이가 모두 원의 37.7%·중심 거리 16.6px 로 같고 기운 쪽만 파·도·라 쪽으로 갈리며, 레파솔시는 59.4% 다. 화음이 바뀌면 꼭짓점이 강성 170·감쇠 11 스프링으로 옮겨 가, 도 자리에서 라 자리로 갈 때 28.95px 지나쳤다가 880ms 부터 ±0.5px 안에 머문다.

울리는 음을 이어 그리는 화음 원
새 창에서 열기

05 · 울리는 음을 이어 그리는 화음 원 — 측정 누적면적 23.285% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

VERTEX_STIFFNESSpolygonAreapolygonCentroid
    function pcAngle(pc) { return -Math.PI / 2 + pc * TAU / 12; }
    function pcPoint(pc) { return [CIRCLE_X + CIRCLE_R * Math.cos(pcAngle(pc)), CIRCLE_Y + CIRCLE_R * Math.sin(pcAngle(pc))]; }
    // 신발끈 공식 — 꼭짓점을 차례로 돌며 x·y 엇곱을 더한 값의 절반이 넓이, 같은 엇곱으로 무게를 준 평균이 무게중심
    function polygonArea(ps) {
        let a = 0;
        for (let i = 0; i < ps.length; i += 1) {
            const p = ps[i];
            const q = ps[(i + 1) % ps.length];
            a += p[0] * q[1] - q[0] * p[1];
        }
        return a / 2;
    }

06배음이 맞는 줄까지 함께 우는 공명 줄

반음마다 하나씩 놓은 줄 24개(도3~시4) 아래 건반으로 도2 를 치면, 줄마다 배음 1~8 과의 높이 차를 센트로 재고 공명 곡선 1 ÷ (1 + (센트 ÷ 30)²) 를 배음 번호로 나눈 값 가운데 가장 큰 것을 받는다. 그래서 도3 은 2배음 0¢ 로 0.5, 솔3 은 3배음 −2¢ 로 0.332, 도4 는 0.25, 미4 는 5배음 13.7¢ 로 0.166 을 받고, 0.04 를 넘는 줄 7개가 빛난다. 따라 우는 줄은 시간 상수 260ms 로 천천히 차오르고 시간 상수 1,800ms 로 서서히 잦아든다.

배음이 맞는 줄까지 함께 우는 공명 줄
새 창에서 열기

06 · 배음이 맞는 줄까지 함께 우는 공명 줄 — 측정 누적면적 10.364% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RESONANCE_WIDTHcentsBetweenresponseOf
    function centsBetween(f1, f2) { return 1200 * Math.log(f1 / f2) / Math.LN2; }
    function resonanceCurve(cents) { const k = cents / RESONANCE_WIDTH; return 1 / (1 + k * k); }
    // 줄 하나가 친 음에서 받는 떨림 — 배음마다 (1/n) × 공명 곡선을 재고 가장 큰 배음을 고른다
    function responseOf(stringMidi, srcMidi) {
        const fs = freqOf(stringMidi);
        const f0 = freqOf(srcMidi);
        let best = { amp: 0, n: 1, cents: 0 };
        for (let n = 1; n <= OVERTONES; n += 1) {
            const c = centsBetween(fs, f0 * n);
            const a = resonanceCurve(c) / n;
            if (a > best.amp) {
                best = { amp: a, n: n, cents: c };
            }
        }
        return best;
    }

07쓸어 치는 속도가 세기가 되는 글리산도

88건반 위를 지나는 손끝의 속도를 스텝마다 차이의 0.15 만큼씩 좁혀 따라가 30 으로 나눈 값(0.15~1)이 세기가 되고, 흰건반 경계를 넘을 때마다 그 건반에 세기 × 10 의 속도를 줘 이웃끼리 스프링으로 이어진 사슬에 눌림이 퍼진다. 한 번 민 건반은 최고 0.182, 두 칸 옆은 0.082, 다섯 칸 옆은 0.029 로 약해지고, 시연 한 바퀴에 건반이 43번 눌린다. 아래 keyWidthFor 는 흰건반 폭을 판 폭 ÷ 52 와 22 CSS px 가운데 큰 쪽으로 정해, 좁은 화면에서는 보이는 창만 손끝 쪽으로 민다.

쓸어 치는 속도가 세기가 되는 글리산도
새 창에서 열기

07 · 쓸어 치는 속도가 세기가 되는 글리산도 — 측정 누적면적 49.794% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

KEY_MIN_WIDTHSPEED_LERPK_COUPLE
    // 판 폭에 들어갈 건반 폭 — 화면 폭으로 다 보이면 그 폭, 최소값보다 좁아지면 최소값(판 좌표로 바꿔서)
    function keyWidthFor(boardW, cssW, totalWhite, minCss) {
        const minBoard = minCss * boardW / Math.max(1, cssW);
        return Math.max(boardW / totalWhite, minBoard);
    }
    function easeInOut(x) { const c = clamp(x, 0, 1); return c < 0.5 ? 2 * c * c : 1 - 2 * (1 - c) * (1 - c); }

08오선 위 자리로 날아가 앉는 음표

음 번호를 12 로 나눈 몫 × 7 에 도·레·미·파·솔·라·시 자리(0~6)를 더해 칠음 순서 번호를 만들고, 맨 아래 줄 미(64)의 번호 37 과의 차 × 7px 로 높이를 정해 도(60)는 y 146, 미는 y 132, 높은 도(72)는 y 97 에 앉는다. 검은건반 음은 아래 흰건반 자리에 앉히고 왼쪽 17px 에 올림표를 선으로 그리므로, 시연 선율 여덟 음 가운데 파♯ 하나가 올림표를 단다. 음표는 560ms 동안 높이 70 의 포물선을 그리며 날아가고, 착지 스프링으로 아래로 5.74px 내려갔다가 528ms 부터 ±0.5px 안에 머문다.

오선 위 자리로 날아가 앉는 음표
새 창에서 열기

08 · 오선 위 자리로 날아가 앉는 음표 — 측정 누적면적 25.614% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LETTER_INDEXdiatonicStepLAND_STIFFNESS
    // 칠음 순서 — 음 번호 mod 12 를 도·레·미·파·솔·라·시 자리(0~6)로. 반음(검은건반)은 아래 흰건반 자리 + 올림표
    const LETTER_INDEX = [0, 0, 1, 1, 2, 3, 3, 4, 4, 5, 5, 6];
    const SLOT_X0 = 96;
    const SLOT_GAP = 46;
    const SLOTS = 8;
    // 비행 — FLIGHT_MS 동안 건반에서 자리까지 직선 + 높이 ARC_HEIGHT 의 포물선, 앉으면 착지 스프링
    const FLIGHT_MS = 560;
    const ARC_HEIGHT = 70;
    const LAND_STIFFNESS = 260;
    const LAND_DAMPING = 10;
    const LAND_KICK = 160;
    const BEAT_MS = 260;
    const LOOP_MS = 12 * BEAT_MS;
    const CLEAR_MS = 10.4 * BEAT_MS;
    // 시연 선율 — 도 미 레 파♯ 솔 시 라 도 (직접 지은 여덟 음)
    const MELODY = [60, 64, 62, 66, 67, 71, 69, 72];
    const SEQ = MELODY.map(function (m, i) { return [0.8 + i, m, 0.7, 0.8]; });
    const STEP_LABEL = '자리 = 옥타브 × 7 + 음이름 순서';
    let keys = [];
    let notes = [];
    let nextSlot = 0;
    let fade = 1;
    function diatonicStep(m) { return Math.floor(m / 12) * 7 + LETTER_INDEX[pcOf(m)]; }
    const BOTTOM_STEP = diatonicStep(64);
    function staffY(m) { return STAFF_BOTTOM_Y - (diatonicStep(m) - BOTTOM_STEP) * HALF_SPACE; }

09원근 무대 위에서 혼자 연주하는 피아노

건반 25개를 원근 투영으로 놓아 앞 끝은 y 280, 뒤 끝은 y 213.6, 길 끝(깊이 900)은 y 89.7 에 오고, 04 의 시간 매핑을 높이 대신 깊이로 옮겨 막대가 시작 1.99초 전에 길 끝에서 다가온다. 막대 앞 끝이 건반 뒤 끝 깊이를 넘으면 누르고, 눌림은 01 과 같은 강성 420 스프링이 앞 끝만 6 만큼 내린다. 뚜껑에는 같은 막대를 수평선에서 뒤집어 0.26 배로 누르고 밝기를 0.75 × e^(−거리 ÷ 70) 로 줄여 비추므로, 수평선에서 70 떨어진 반사는 0.276 이다.

원근 무대 위에서 혼자 연주하는 피아노
새 창에서 열기

09 · 원근 무대 위에서 혼자 연주하는 피아노 — 측정 누적면적 22.363% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

barFrontZZ_SPEEDLID_FALLOFF
    function projX(x, z) { return VANISH_X + x * Z0 / (z + Z0); }
    function projY(h, z) { return HORIZON_Y + (DEPTH_K - h * Z0) / (z + Z0); }
    function keyOf(m) {
        for (let i = 0; i < keys9.length; i += 1) {
            if (keys9[i].midi === m) {
                return keys9[i];
            }
        }
        return null;
    }
    // 막대 앞 끝의 깊이 — 시작 시각까지 남은 초 × Z_SPEED 만큼 건반 뒤 끝(KEY_LEN)보다 멀다
    function barFrontZ(startMs, nowMs) { return KEY_LEN + (startMs - nowMs) / 1000 * Z_SPEED; }

깊이는 스프링으로, 소리 결은 배음으로 — 눌림 감쇠·지렛대 비·친 자리별 배음 크기·반음 비율 공명을 재는 계산

아홉 장면의 움직임은 키프레임이 아니라 매 16ms 스텝마다 푸는 식에서 나온다. 스타일시트의 @keyframes 는 아홉 개 모두 0 이고, 화면 시계도 16ms 고정 스텝으로만 늘어 같은 시각에는 같은 그림이 나온다.

감쇠 비 0.39 가 남기는 한 번의 되튐

01 의 건반은 가속도 = −420 × (위치 − 목표) − 16 × 속도 로 움직인다. 감쇠 비는 16 ÷ (2 × √420) 로 0.39 라 1 보다 작고, 그래서 놓은 건반이 쉼 자리를 한 번 지나쳤다 돌아온다. 이 지나침이 눌렸다 돌아오는 손맛이 되고, 왼쪽 위 그래프에서는 쉼 선 위로 솟는 작은 봉우리로 보인다.

4.4 배로 키운 지렛대와 중력

02 해머는 놓인 뒤 각가속도 −(3000 ÷ 150) × cos θ, 곧 −20·cos θ rad/s² 만 받는다. 세기 1 이면 놓는 순간 6.22 rad/s 로 떠나 줄에 5.68 로 닿고 2.56 으로 되튀지만, 세기 0.65 면 4.04 로 떠나 3.14 로 닿는다. 계산 시간을 화면 시간의 0.3 배로 늦춰 두었기 때문에 줄까지 날아가는 원호가 눈에 보인다.

친 자리 p 와 배음의 마디

배음 n 의 크기 sin(nπp) ÷ n 은 p 가 1/k 이면 k 의 배수에서 0 이 된다. 0.125 는 8 과 16, 0.5 는 짝수 전부가 그렇다. 막대 판은 0.02 미만을 0 으로 보고 빈 동그라미로 그리는데, 15번은 −0.0255 라 0 이 아니다. 두 파동이 겹치는 모양 자체는 파동 간섭 편 에서 다뤘고, 여기서는 해머가 친 자리 하나가 열여섯 배음의 크기를 한꺼번에 정한다.

반음 비율 2의 12제곱근과 센트

06 의 줄 높이는 440 × 2^((음 번호 − 69) ÷ 12) Hz 라, 반음 하나가 2의 12제곱근 배이고 100센트다. 정수배 배음은 이 계단에 딱 맞지 않아 3배음은 평균율 음보다 −1.96¢, 5배음은 13.69¢, 7배음은 31.17¢ 어긋난다. 공명 폭 30¢ 에서 7배음에 걸친 라♯ 줄이 0.069 만 받는 까닭이 이 31.2¢ 다.

가상 피아노가 깨지기 쉬운 때 — 88건반으로 늘린 판·한꺼번에 누른 열 음·소리를 켜기 전에 시작한 연주

88건반으로 늘린 판

320px 화면에 88건반(흰건반 52개)을 그냥 나누면 흰건반 6.15px·검은건반 3.57px 이라, 손가락으로 하나를 고르기 어렵고 음 이름도 실을 수 없다. 07 은 흰건반 폭을 22 CSS px 밑으로 줄이지 않아 320px 에서 흰건반 14.5개, 480px 에서 21.8개만 보이고, 1280px 에서는 24.62px 로 52개가 다 들어간다. 화면 폭이 바뀌면 resize 가 판과 버튼을 다시 만들고, 화면 밖 건반 버튼에 Tab 으로 포커스가 가면 3초 동안 창을 그 건반 쪽으로 옮긴다. 바깥 상자가 overflow: clip 이라 이때 상자가 옆으로 스크롤되지 않는다.

한꺼번에 누른 열 음

상한 없이 음마다 오실레이터 4개와 게인 노드 5개(음 전체 1 + 오실레이터마다 1)를 만들면 손바닥으로 건반을 덮을 때마다 노드가 쌓인다. 아홉 장면은 MAX_VOICES 6 을 두고, 새 음이 들어올 때 남은 음이 6 이상이면 가장 오래된 음부터 놓는다.

    const MAX_VOICES = 6;
    const voices = [];
    function allocVoice(pool, midi, startMs, auto) {
        const freed = [];
        for (let i = pool.length - 1; i >= 0; i -= 1) {
            if (pool[i].midi === midi) {
                freed.push(pool[i]);
                pool.splice(i, 1);
            }
        }
        while (pool.length >= MAX_VOICES) {
            const old = pool.shift();
            if (old) {
                freed.push(old);
            }
        }
        pool.push({ midi: midi, startMs: startMs, auto: auto, nodes: null, gain: null });
        return freed;
    }

60~69 열 음을 차례로 누르면 64~69 여섯이 남고, 60~63 넷은 소리·aria-pressed·눌림 빛이 함께 꺼진다. 눌림 빛도 같은 목록을 보므로 그리는 양이 여섯으로 고정된다. 05 의 화음 원은 꼭짓점이 4개라, 서로 다른 음이 5개 이상 울리면 도를 0 으로 센 열두 음 번호 순으로 앞의 넷만 이어진다.

소리를 켜기 전에 시작한 연주

페이지를 열자마자 Web Audio API 의 AudioContext 를 만들면, 사용자 동작 전이라 소리가 막혀 첫 음이 안 나기 쉽고, 화면 시계와 오디오 시계가 따로 흘러 그림과 소리가 어긋날 수 있다. 아홉 장면은 AudioContext 를 「소리 켜기」 버튼의 click 핸들러 안 한 줄에서만 만든다. 2.5초 시연 동안 생성은 0번, 켜기를 누르면 1번이고, 다시 눌러 꺼도 1번 그대로이며 다시 켜면 처음 만든 것을 그대로 쓴다.

    soundBtn.addEventListener('click', function () {
        if (!audio) {
            audio = new AudioContext();
            master = audio.createGain();
            master.gain.value = MASTER_GAIN;
            master.connect(audio.destination);
        }
        soundOn = !soundOn;
        soundBtn.setAttribute('aria-pressed', soundOn ? 'true' : 'false');
        soundBtn.textContent = soundOn ? SOUND_OFF_LABEL : SOUND_ON_LABEL;
        if (soundOn) {
            syncClocks();
            audio.resume().then(syncClocks);
        }
        else {
            for (let i = 0; i < voices.length; i += 1) {
                synthStop(voices[i], clock);
            }
            screenTarget = clock;
        }
        wake();
    }, { signal: off.signal });

켜는 순간 syncClocks 가 오디오 시각과 화면 시각을 한 쌍으로 적어 두고, 음을 낼 때 그 음의 화면 시각을 오디오 시각으로 바꿔 예약한다. 쌍이 12.5초와 3,000ms 라면 화면 3,500ms 는 오디오 13초가 되고, 4,321ms 를 오디오로 바꿨다 되돌리면 4321 그대로다. 소리를 켜기 전에 두 시계가 실제로 얼마나 벌어지는지는 무음 측정이라 재지 못했고, 켤 때 다시 맞춘다는 것은 코드로 확인한 사실이다. 이 세 가지 함정을 막는 처리가 든 바닐라 판 아홉 개와 tsx 로 옮긴 리액트 판은 압축 파일 하나에 들어 있고, 그 파일은 hqre6xd3 으로 열린다.

건반을 무르게, 치는 자리를 줄 한가운데로, 공명 폭을 좁게 — 한 값만 바꾼 세 가지 소리 결

이름 바꾼 값 느낌
건반을 무르게 01 KEY_SPRING_STIFFNESS 420 → 240 감쇠 비가 0.39 에서 0.516 으로 올라 쉼 자리를 처음 지나는 시각이 96ms 에서 160ms 로 늦고 되튐은 23.9% 에서 12.4% 로 준다. 끝까지 머무는 정착은 384ms 에서 352ms 로 비슷하고, 누를 때 바닥에 닿는 시각은 80ms 에서 112ms 로 늦어진다
치는 자리를 줄 한가운데로 03 STRIKE_POINT 0.125 → 0.5 크기 0 인 배음이 8·16 둘에서 짝수 여덟 개로 늘고, 1번 배음 크기가 0.383 에서 1 로 커진다. 한쪽으로 쏠렸던 줄 모양은 좌우 높이 차 45.7px 에서 0 인 대칭 굽이가 된다
공명 폭을 좁게 06 RESONANCE_WIDTH 30 → 10 도2 를 쳤을 때 0.1 이상 받는 줄이 5개에서 4개로, 미 줄은 0.166 에서 0.07 로, 라♯ 줄은 0.069 에서 0.013 으로 줄어 빛나는 줄이 7개에서 5개가 된다

소리 없이도 따라갈 수 있게 — 음 이름 알림·자판으로 치는 건반·움직임 줄이기 접근성

음 이름을 읽어 주는 알림 줄

캔버스 옆에 숨긴 aria-live="polite" 알림 줄은 사람이 친 음을 250ms 안에 든 이름끼리 「 · 」 로 이어 바로 쓰고, 자동 시연 음은 2초 마디에 한 번 「자동 연주: 도 · 미 · 솔」 꼴로 묶어 쓴다. 건반 버튼의 이름은 옥타브 번호까지 붙은 「도4」 꼴이고, 누르는 동안 aria-pressed 가 true 가 된다. 알림 줄 동작은 aria-live 의 polite 규칙을 따라, 낭독 중이면 다음 줄이 앞 줄을 덮을 수 있어 자동 음을 마디로 묶었다.

자판으로 치는 건반

캔버스 위 건반은 모두 투명한 <button> 이라 Tab 으로 옮겨 Enter·Space 로 누르고, 자판 A S D F G H J K 는 흰건반, W E T Y U 는 검은건반을 친다. 포커스 링은 남색 2px 테두리 안쪽에 크림색 4px 띠를 겹쳐, 상아 건반과 남색 건반 어느 쪽 위에서도 보인다.

움직임 줄이기에서 남는 한 장

운영체제에서 움직임 줄이기를 켜면 아홉 장면 모두 애니메이션 루프를 멈추고 마지막 장면 한 장만 그린다(prefers-reduced-motion). 01 은 도·미·솔이 눌린 건반, 02 는 받침에 멈춘 해머와 평평한 줄, 05 는 도·미·솔 삼각형, 08 은 다섯 음이 자리에 앉은 오선이 남는다. 그 상태에서 건반을 누르면 눌린 색과 음 이름만 바뀌고 떨림·비행·되튐은 없다.

연습용 웹 건반이나 수업 자료에 가상 피아노를 넣을 때 자주 받는 질문

피아노 건반 웹 사이트에 올리면 휴대폰에서도 칠 수 있나요?

건반 버튼은 pointerdown 으로 누르고 touch-action: none 을 둬 손가락으로 칠 수 있으며, 누른 채 옆 건반으로 들어가면 그 건반도 눌려 쓸어 치기가 된다. 88건반이 필요하면 07 처럼 흰건반 22px 최소 폭과 따라 움직이는 창을 함께 가져가야 320px 폭에서도 건반 하나를 고를 수 있다.

소리 파일 없이 어떻게 음이 나나요?

샘플 파일이나 외부 주소 없이, 1·2·3·4배 사인파 네 개를 크기 1·0.42·0.18·0.08 비율로 겹쳐 0.006초에 올리고 1.6초에 걸쳐 지수로 줄인다. 녹음한 소리의 파형을 잘라 다루는 화면이 필요하면 오디오 파형 장면 쪽이 맞고, 이 판의 합성음은 그림이 주인공인 화면이나 짧은 연습 음에 어울리는 것으로 보인다.

자동 연주 곡을 바꾸려면 어디를 고치나요?

04 와 09 는 SONG 배열의 한 줄이 [시작 박, 음 번호, 길이 박, 세기] 이고, 템포는 TEMPO_BPM 하나로 정한다. 그 두 값만 바꾸면 막대 길이와 눌리는 시각이 같은 시계로 따라오고, 09 에서는 뚜껑에 비친 막대까지 함께 바뀐다.

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

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