GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ブラウザ ピアノ 9種 — ばねの鍵盤・ハンマー・弦の倍音・自動演奏

ブラウザ ピアノを画面に置くとき、最初に決めるのは音より先に動きの形です。鍵盤が一つ沈んだとき、画面の弦はどんな形で震えるべきか。03番はハンマーが当たった位置で倍音ごとの大きさを決め、06番は倍音の合う別の弦まで一緒に鳴らし、02番はてこの比でハンマーが弦を打って跳ね返る軌道を求めます。

練習画面から演奏会の舞台まで — 鍵盤・ハンマー・弦が一緒に動く九つの場面

自動再生 · タイルを押すとその項目へ · すべて1つのzip

ここで扱うブラウザのピアノは、描いた鍵盤をマウス・指・パソコンの文字キーで押し、沈んだ鍵盤と震える弦を絵として見せるウェブページです。何も触らなければ、九つの場面は決められた音を無音のまま自分で弾きます。「音を出す」ボタンを押すまでは何も聞こえず、押したあとの音は一音ごとに四つの正弦波を重ねたものです。鍵盤か音のボタンに最初に触れると自動の演奏は退き、人の入力が 2.5 秒途切れると自動の音がまた入ってきます。

入ってきた音を棒に分けて描くオーディオビジュアライザーの9選とは、流れの向きが逆です。あちらは音が先にあって絵がそれを追いかけますが、ここでは鍵盤の深さ・ハンマーの角度・弦の形を先に計算し、音はスイッチを入れたときだけその計算のあとについてきます。

練習用の鍵盤とレッスン資料

和音の名前を教える資料には、05の和音の円と08の五線がいちばん近い道具です。ド・ミ・ソ、ド・ミ・ラ、ド・ファ・ラはどれも同じ長さの辺を持つ三角形なので円の 37.7% を占め、違いは寄っている向きだけになり、明るい響きの和音と暗い響きの和音が同じ大きさの枠を分け合っていることを一枚の絵で見せられます。08では弾いた音が鍵盤から飛び上がって五線の線か間に収まるので、楽譜を読み始めたばかりの人が鍵盤の位置と五線の高さを一つずつ結びつけられます。

見せるための画面と小さなスマートフォン

ランディングの最初の画面なら、04の降りてくる棒か、09の遠近の舞台で鍵盤がひとりでに沈む javascript ピアノ 自動演奏が目を引きます。ただし盤を 88 鍵に広げると、スマートフォンの幅ですぐに困ったことが起きます。07がそれをどう切り抜けるかは、壊れやすい場面の節で数字を使って追います。

ブラウザ ピアノ 9種 — ばねの鍵盤・ハンマーのてこ・弦の倍音・ピアノロール・和音の円・共鳴する弦・なでる演奏・五線の音符・自動演奏の舞台

各場面を 480×300 の大きさで 2 秒のあいだに 24 枚撮り、隣り合う 2 枚の違い 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

ブラウザ ピアノ キーボードの鍵盤は、なぜ平たい四角の列に見えやすいのか?

鍵盤を CSS だけで白と紺の箱として並べ、押したら色を替えるだけにすると、見る人には鍵盤が沈んだことは伝わっても、どれだけ深く、どれだけ強く打たれたかは伝わりません。このセットの鍵盤は、色の切り替えの代わりに深さを持たせています。01では鍵盤の手前の端が奥の端を軸に傾いて 16 単位沈み、手を離すとばねがその深さを元に戻します。

傾きと影で深さを作る

いちばん奥まで押し込むと、白鍵は 6.12°、黒鍵は 10.02° 傾きます。黒鍵は軸までの長さが白鍵の 150 に対して 92 しかないため、同じ 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 沈むあいだ、(奥の腕 120 ÷ 手前の腕 170) × (シャンク 150 ÷ 根元の腕 24) = 4.412 というてこの比によってハンマーの頭は鍵盤の 4.4 倍の速さで円弧を進み、0.27 ラジアンで放されたあとは重力だけを受けて飛びます。下の部分は弦の角 0.4334 に達したところで角速度に −0.45 を掛けて跳ね返し、届く前に向きが下へ変わったときは「弱すぎる — 弦に届かず落ちる」を一回の飛行につき一度だけ出しますが、打った鍵盤は早く離しても底まで沈むため、分かれ目は押していた時間ではなく強さで決まり、0.406 以下は届かず 0.407 以上は届きます。デモは強さ 0.95 で打って 2.36 rad/s で跳ね返り、ハンマーが受け台に戻ってから 0.3 で打って最高角 0.3566 で落ち、最後に 0.65 で打って一周 4,800ms を終え、人が押すときは強さを二乗して使うので鍵盤ボタンの上側およそ 34% を押すと弦まで届きませんが、音は強さと関係なく鳴るので、弦に届かない打鍵でも音は出ます。

てこで弦を打って跳ね返るハンマーの断面
新しいタブで開く

02 · てこで弦を打って跳ね返るハンマーの断面 — 計測 累積面積 15.556% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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倍音の合う弦まで一緒に鳴る共鳴弦

ド3からシ4まで半音ごとに 1 本、計 24 本の弦の束の下でド2を打つと、それぞれの弦と 1〜8 番目の倍音とのずれをセントで測り、共鳴の曲線 1 ÷ (1 + (セント ÷ 30)²) を倍音の番号で割った値のうち最大のものを残します。その結果、ド3は 0¢ の 2 番目の倍音から 0.5、ソ3は −2¢ の 3 番目から 0.332、ド4は 0.25、ミ4は 13.7¢ ずれた 5 番目から 0.166 を受け取り、0.04 を超える 7 本の弦が光ります。一緒に鳴る弦は時定数 260ms でゆっくり満ち、時定数 1,800ms の指数でゆるやかに弱まっていきます。

倍音の合う弦まで一緒に鳴る共鳴弦
新しいタブで開く

06 · 倍音の合う弦まで一緒に鳴る共鳴弦 — 計測 累積面積 10.364% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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」と「CSS の 22px」の大きいほうに決め、狭い画面では見えている窓だけが指先の方へずれていきます。

なでる速さが強さになるグリッサンド
新しいタブで開く

07 · なでる速さが強さになるグリッサンド — 計測 累積面積 49.794% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 の所に線で描いたシャープをつけるため、デモの 8 音のうちシャープを持つのはファ♯だけです。音符は高さ 70 の放物線を描いて 560ms 飛び、着地のばねで下へ 5.74px 沈み込み、528ms 以降は ±0.5px の中にとどまります。

五線の位置へ飛んで収まる音符
新しいタブで開く

08 · 五線の位置へ飛んで収まる音符 — 計測 累積面積 25.614% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 で当たり 1.41 で跳ね返り、0.45 なら 2.79 で放されても弦に着くころには 1.17 しか残らず 0.53 で跳ね返ります。音を出す設定にしてあれば、押したときの共通の処理が先に音を鳴らし始めるので音は強さと関係なく鳴り、届くかどうかで変わるのは絵の方だけです。計算は画面の時間の 0.3 倍で進むため、弦までの円弧を目で追えます。

打つ位置 p と倍音の節

倍音の大きさ sin(nπp) ÷ n は、p が 1/k のとき k の倍数の番号ですべて 0 になります。0.125 なら 8 と 16 が、0.5 なら偶数の番号すべてが消えます。棒のパネルは 0.02 未満を 0 とみなして白抜きの丸で描き、15 番目は −0.0255 なので 0 には数えません。二つの波がそのまま足し合わさる様子はリサージュ曲線と波の干渉の9選で扱っていますが、ここでは一つの打点が 16 本の倍音の大きさをまとめて決めます。

2 の 12 乗根とセント

06の弦の高さは 440 × 2^((音番号 − 69) ÷ 12) Hz なので、半音一つは 2 の 12 乗根倍、言い換えると 100 セントです。整数倍の倍音はこのはしごにぴったりとは乗らず、3 番目の倍音は平均律の音から −1.96¢、5 番目は 13.69¢、7 番目は 31.17¢ ずれます。7 番目の倍音を通して届くラ♯の弦が共鳴の幅 30¢ で 0.069 しか受け取らないのは、この 31.2¢ のずれがあるからです。

88鍵に増やした盤・十音の同時押し・音を出す前に始まった演奏 — ブラウザのピアノが壊れやすい場面

88 鍵に増やした盤

ピアノ アプリ 作り方を調べながら鍵盤を 88 に増やしたとき、最初にぶつかるのは幅です。320px の画面を 88 鍵(うち白鍵 52)で均等に割ると白鍵は 6.15px、黒鍵は 3.57px になり、指で選ぶには細すぎて音名を載せる余白も残りません。07は白鍵を CSS の 22px より細くしないので、320px では白鍵が 14.5 本、480px では 21.8 本見え、1280px なら 52 本すべてが 24.62px ずつ収まります。画面の幅が変わると resize が盤とボタンを作り直し、Tab で窓の外にある鍵盤ボタンへフォーカスが移ると窓はその鍵盤の方へ 3 秒間ずれ、外側の箱は overflow: clip なので、そのあいだも箱そのものが横にスクロールすることはありません。

十音の同時押し

上限を決めずに音ごとに発振器 4 つとゲインノードを作ると、手のひらで鍵盤を押さえるたびにノードが積み上がっていきます。ここでは一音に、音全体のゲイン 1 つと発振器ごとのゲイン 1 つずつ、合わせて 5 つのゲインが要るので、九つの場面は 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 の状態と押されたときの光を同時に失います。押されたときの光も同じリストから読むので、描く量も 6 音で頭打ちになります。05の和音の円は角が 4 つまでなので、違う音が 5 つ以上鳴ると、ドを 0 とした十二の音の番号で小さいほうから四つだけが結ばれます。

音を出す前に始まった演奏

ページを読み込むあいだに Web Audio API の AudioContext を作ってしまうと、利用者の操作がまだないので音が止められていて最初の音が鳴らないことがあり、画面の時計と音の時計が別々に進んで絵と音がずれることもあります。九つの場面は「音を出す」ボタンの click ハンドラーの中の 1 行でしか AudioContext を作りません。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 にきちんと戻ります。音を出す前に二つの時計がどれだけずれるかは無音のキャプチャーでは測れませんでしたが、音を出すときに揃え直していることはコードから読み取れます。この三つの守りを組み込んだ九つの vanilla の場面と tsx で書いた React 版は一つの圧縮ファイルに入れてあり、パスワード 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.3827 から 1 に上がります。片側に寄っていた弦は、左右の差が 45.71px から 0px の対称な弧になります
共鳴の幅を狭く 06 RESONANCE_WIDTH 30 → 10 ド2を打ったとき大きさ 0.1 以上で震える弦が 5 本から 4 本に減り、ミの弦は 0.166 から 0.07、ラ♯の弦は 0.069 から 0.013 に静まって、光る弦は 7 本から 5 本になります

音がなくても追えるように — 音名の読み上げ・キーボードで弾く鍵盤・動きを減らす設定のアクセシビリティ

音名を読み上げる live の行

キャンバスの横にある見えない 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 のクリーム色の帯を重ねているため、象牙色の鍵盤の上でも紺の鍵盤の上でも見失いません。

動きを減らす設定で残る一枚

OS で動きを減らす設定をオンにすると、九つの場面はアニメーションのループを止めて最後の一枚だけを描きます(prefers-reduced-motion)。01はド・ミ・ソを押し込んだまま、02は平らな弦の下でハンマーが受け止めの位置に休んだ姿、05はド・ミ・ソの三角形、08は五つの音符が収まった五線を残します。その状態で鍵盤を押すと変わるのは押された色と音名だけで、揺れ・飛行・跳ね返りは起きません。

練習用の鍵盤やレッスン資料にピアノ webアプリを載せるときのよくある質問

スマートフォンの指でもこのブラウザ ピアノ キーボードは弾けますか?

鍵盤ボタンは pointerdown に反応し、touch-action: none を指定しているので指で弾けます。押したまま隣の鍵盤へ滑らせるとその鍵盤も押され、なでるような弾き方になります。88 鍵すべてが必要なら、07の白鍵の最小 22px と指先を追う窓を一緒に持っていけば、320px でも鍵盤を一つずつ選べます。

音のファイルを使わずに、どうやって鳴らしているのですか?

サンプルのファイルも外部のアドレスも使っていません。音の高さの 1・2・3・4 倍の正弦波 4 つを大きさ 1・0.42・0.18・0.08 で重ね、0.006 秒で立ち上げてから 1.6 秒かけて指数的に小さくしています。録音した音の波形を切り分けて編集する画面なら音の波形の9パターンの方が合い、ここの合成音は絵が主役の画面や短い練習の音に向いていると考えられます。

ピアノロールと舞台の曲はどこで差し替えますか?

04と09では SONG 配列の一行が [始まりの拍、音番号、拍で数えた長さ、強さ] になっていて、テンポは TEMPO_BPM だけで決まります。そこだけを書き換えれば棒の長さと押す時刻は同じ時計で追いかけてきて、09ではふたに映る棒も一緒に変わります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。