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

css waveform 9パターン — ピークで縮め区間を選んで切る音の波形の場面

css waveform は線を引く前に、標本数万を数百の画素列へ詰める仕事があります — 01番の最大・最小の束と05番のゼロ交差吸着が、詰めと選びを計算で解きます。音楽アプリや録音画面で、真ん中の線を境に上下へ揺れる棒の絵が、ここで扱う波形です。

九つの音の波形を画面でそのまま再生して切ってみる

自動再生 · タイルを押すとその項目へ · すべて1つの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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

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

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

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

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

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秒かかり、針が通り過ぎてから回った角度に Math.exp(-behind * AFTERGLOW) を掛けて、すぐ後ろの棒だけをピンクに明るく残します。キャンバスは0から360度を伝えるスライダーで、押したまま回すと円の中心を基準にした atan2 で針が手を追ってきます。

波形が輪の周りに巻かれ、回る再生針に沿って点灯する極座標の場面
新しいタブで開く

07 · 波形が輪の周りに巻かれ、回る再生針に沿って点灯する極座標の場面 — 計測 累積面積 22.116% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

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

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回の比較のうち7回で画面が変わりませんでした。ここから下の数字はレンダリングではなく、各場面の関数をNode.js から直接呼んで数えた値です。

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の立ち上がりと戻りを向きだけ逆にした同じ考え方です。

どこで壊れるか — 束が広いとき・端で拡大するとき・レベルが信号を超えるとき

波形がおかしく見える地点を場面ごとに拾い、ソースの関数をNode.js から呼んで数字で押さえました。ブラウザで速さを測った項目はここに含めていません。

画面外の予熱がないと左端の輪郭が半分の高さから始まる

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以内で動かすかぎり、+0.7でも−0.7でも300ステップのあいだ一度もトリガーを取り逃しません。制限を外してレベルを0.99に置くと300回すべてで越える地点が見つからず、上の段はバッファの最後の一画面を描いて、下のフリーランと同じ形になります。

拍手の直後の数列はピンクのクリップで刻まれる

09でゲインがまだ高い最初の数列は、ゲインを掛けた値が1を超えてピンクの列が刻まれ、状態表示が「クリップ」に変わります。反対に無音が長く続くと peakHold が縮み続けてゲインが上限の8まで上がり、そのときは雑音まで8倍に大きくなります。

07の上ではタッチでページをスクロールできない

07はキャンバス全体が touch-action: none なので、輪の上に指を置いたままではページが動きません。04は pan-y で縦のスクロールをブラウザに任せて横のドラッグだけをアプリが使い、06は逆に pan-x、05はつまみの要素だけが none です。長い記事の真ん中に07を置くなら、輪の周りにスクロール用の余白を残しておくほうが安全です。

遅い端末でも頂点の印が落ちる時間は変わらない

08の棒と頂点の印は16msの固定ステップでしか変わらないので、画面のリフレッシュレートが違っても、頂点から底まで留まる時間を含めて85フレームという値は同じです。タブを離れて戻っても一度に追いつくのは10ステップだけなので、そのあいだに落ちていたはずの頂点の印が一気に消えることはなく、続きから下りてきます。

九つの場面の index.html・style.scss と React 版をまとめた 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の五つで、03は押した位置へ再生位置が移り、04は目標の倍率へ一気に変わり、05のつまみは吸着先にすぐ置かれ、08は棒と頂点の印が押した高さにすぐ立ち、09は拍手のあとの30列をまとめて継ぎ足します。

audio waveform css だけで足りるか?

棒が何本か拍に関係なく上下する飾りなら、CSS だけで作れます。けれどもこの記事が扱う仕事、つまり何万もの標本から柱の端を選び、輪郭を追い、切り出す境目を0に合わせることは値を数える作業なので、スクリプトが受け持ちます。舞台を決める .wf の幅は min(92vw, calc(160vh - 12.8vw))、比率は aspect-ratio: 480 / 300 で、描画は480×300の論理座標に対して行うため、窓を広げても狭めても柱の本数は変わりません。

ブラウザの音声 API で実際の音を受けて描く方法は オーディオビジュアライザー 作り方 9選 にあり、再生ボタンと進み具合のバーを持つプレイヤーの外側は 音楽 プレイヤー デザイン 9種 と見比べると、03の再生位置をどこに載せるかの見当がつきます。05の二つのつまみの仕組みは レンジスライダー ui 9種 のつまみ二つのスライダーと似ていて、正弦波の合成で描く曲線そのものに興味があれば リサージュ曲線 作り方9選 へ続きます。requestAnimationFrame の扱いは MDN の requestAnimationFrame、柱を二色に割る仕組みは clip() の説明に沿っています。

css audio waveform animation のコードを使い回す前によくある質問

本物の録音ファイルの波形に差し替えるにはどこを直しますか?

01・02・03・04・07は、場面の関数の一行目にある synth(SAMPLE_N, 459) のような呼び出しが数の配列を作っているので、そこに自分の標本の配列を入れれば差し替わります。01は値に HALF_Y の84を掛けて高さを決めるため、−1から1の範囲に揃えた配列でないと柱が舞台からはみ出します。ファイルを読み込んで標本にほどく部分は、この記事のデモには入っていません。

css audio waveform animation で標本が数百万個あると重くなりませんか?

01の bucketColumns は描くたびに見えている標本43,200個を一度ずつ走査するので、見える区間が長くなるほど仕事もそのぶん増えます。04は上の概観の帯120列を場面を作るときに一度だけ束ねておき、その後は数え直しません。長い音源なら、このように前もって束ねた配列を倍率ごとに分けて持つのが出発点になります。

画面の幅が変わると柱の数も変わりますか?

変わりません。fit() がキャンバスの実際の幅に devicePixelRatio を掛けてバックバッファだけを取り直し、setTransform で480×300の座標を引き伸ばして描くからです。340px以下ではキャプション・ボタン・状態表示の文字が10pxに縮むだけでキャンバスの比率は変わらず、レンダリングの測定ではスマホ幅でのはみ出しは九つの場面とも見つかりませんでした。

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

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