GODRICH

オーディオビジュアライザー 作り方 9選 — 棒からマイクまで

オーディオビジュアライザー 作り方とは、鳴っている音の強さ・高さ・リズムを画面の動きに変える UI を、Web Audio API と CSS だけで作ることです。ここにある9点は棒スペクトラムからマイク入力まで、音源ファイルもライブラリもなしで、貼ればそのまま動きます。

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

9点の並び順は部品の種類ではなく、音が画面へ変わっていく順です。まず同じスペクトラムを3つの読み方で — 棒の列で読み(01)、円周に並べ直し(02)、周波数を捨てて時間波形の線で読みます(03)。次は音が引き金になる2つ — 低音の跳ねでビートを捕まえて粒を弾けさせ(04)、帯域ごとに違う文字を跳ねさせます(05)。最後の4つは画面を染めて外とつなぐ — 全エネルギーで背景を染め(06)、トラック全体の山を数値として固定し(07)、マイクという外部入力を迎え(08)、数値のメーターで締めます(09)。ステージ色はインク5・オレンジ2・紙1・イエロー1で回して9マスの明るさが偏らないように合わせました。音を扱う隣人は動画プレイヤーコントロール9選、データを線で引く側はSVGグラフ描画9選、入力端子を線で繋ぐ操作はノードグラフ接続 UI 9選が担います。

01棒スペクトラム

周波数の区間ごとに棒が一本立ち、その区間の強さだけ scaleY で伸びます。棒は床に付いたまま伸びる要素なので transform-origin を下辺に置き、左半分を右半分の鏡像にした32本が負の遅延で順に波打ちます。画面のほとんどが毎フレーム動くので累積面積 19.251% は 06 に次ぐ大きさで、動いたフレームも 23/23 と満点になり、音楽プレーヤーの再生表示やポッドキャストのヘッダーにそのまま使えます。

scaleYtransform-origingetByteFrequencyData
function draw() {
  analyser.getByteFrequencyData(freq);
  var sums = [0, 0, 0];
  for (var i = 0; i < bars.length; i++) {
    var band = Number(bars[i].getAttribute('data-band'));
    var v = freq[band + 1] / 255;
    bars[i].style.setProperty('--h', (0.06 + v * 0.94).toFixed(3));
    sums[Math.min(2, Math.floor(band / 6))] += v;
  }
  var top = sums.indexOf(Math.max(sums[0], sums[1], sums[2]));
  if (levelText.textContent !== BAND_NAMES[top]) { levelText.textContent = BAND_NAMES[top]; }
  timer = requestAnimationFrame(draw);
}

02円形スペクトラム

同じ棒を48本、円周に立てて等分の角度ずつ回して並べます。回転の中心は棒ではなく円の中心なので transform-origin を半径のぶん下へずらし、scaleY は円に接した点から外側へだけ伸びます。実測では若い番号の棒ほど高く(先頭から 0.462・0.376・0.260・0.203)、内側に山が残る輪郭になるので、アルバムアートの周りに置いても中央が読み取れます。

rotatetransform-origin--i
.rs__bar {
  position: absolute; left: 50%; bottom: 50%; width: 4px; height: 24px; margin-left: -2px;
  border-radius: $r-xs;
  background: linear-gradient(180deg, $stage-yellow 0%, $color 100%);
  transform-origin: 50% 100%;
  transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(var(--h, .12));
  transition: transform $duration $ease-out;
}

03波形オシロスコープ

周波数ではなく時間波形をそのまま読み、重ねた2本の SVG polyline の points に毎フレーム書き込みます。canvas を使わない線なので色も太さも影も CSS のまま、プレビューでは固定波形の上を 56px のダッシュが一巡します。動きが細い線に限られるのでフレーム当たりの変化の最大は 0.273% と9点で最も小さく、録音画面のリアルタイム波形や音声メモの入力表示に向きます。

polylinegetByteTimeDomainDatastroke-dasharray
  analyser.getByteTimeDomainData(wave);
  var step = Math.floor(wave.length / N);
  var line = [];
  var peak = 0;
  for (var i = 0; i < N; i++) {
    var v = (wave[i * step] - 128) / 128;
    peak = Math.max(peak, Math.abs(v));
    line.push(((i / (N - 1)) * W).toFixed(1) + ',' + (MID - v * (H / 2 - 6)).toFixed(1));
  }
  var p = line.join(' ');
  baseLine.setAttribute('points', p);
  flowLine.setAttribute('points', p);

04ビート反応パーティクル

低音のエネルギーが自分の移動平均の1.3倍を超えた瞬間をビートとみなし、そのときだけ粒の層が一つ外へ弾けます。閾値を固定値でなく平均比で取るのは曲が静かになってもビートを取りこぼさないためで、220ms の最短間隔が一回のキックを二回数えるのを防ぎます。4秒の実再生で5回のビートを拾い4つの層全部が順に弾けました — コアは常にその場に残るので、粒が散っても画面の主役は欠けません。

scaleopacityrequestAnimationFrame
  // 비트 판정: 저음 에너지가 직전 구간의 이동 평균 × 1.3 을 넘는 순간이다.
  // 고정 문턱(예: "에너지 0.5 이상")은 곡이 조용해지면 비트를 아예 못 잡고 시끄러워지면 매 순간을 비트로 만든다 —
  // 평균 대비 계수여야 셈여림이 바뀌어도 비트를 놓치지 않는다. 220ms 최소 간격은 한 번 친 킥을 두 번 세는 것을 막는다.
  if (bass > bassAvg * 1.3 && bass > 0.14 && now - lastBeatAt > 220) {
    lastBeatAt = now;
    beats += 1;
    beatText.textContent = String(beats);
    fire(rings[ringTurn % RING_COUNT], 'bp__ring--hit');
    fire(core, 'bp__core--hit');
    ringTurn += 1;
  }
  bassAvg += (bass - bassAvg) * 0.12;

05低音で跳ねる文字

タイトルの文字 G・O・D・R・I・C・H を低・中・高の3帯域に振り分け、帯域ごとに別の文字が translateYscale で跳ねます。実測では低音 0.357・中音 0.271・高音 0.216 と帯域の差がそのまま持ち上がりの差になり、文字は自分の位置で大きくなるので原点は中央のままです。文字は大きくなっても色は変えず位置だけが動くので、明るさの反転は起きません。

translateYanimation-delayanimation-fill-mode
.tb__l {
  display: inline-block; font-size: clamp(44px, 12vw, 60px); line-height: 1; color: $color;
  transform-origin: 50% 50%;
  transform: translateY(calc(-18px * var(--j, 0))) scale(calc(1 + var(--j, 0) * .16));
  transition: transform $duration $easing;
}

06エネルギーグラデーション背景

全エネルギーを色相角ひとつに変換し、@property<angle> として登録した --hue を 210→340 度で回して背景を染めます。登録なしではカスタムプロパティが文字列扱いになり、グラデーションがぶつ切りに切り替わります — 実再生では強さ 63%・メーター 0.673 のとき背景は rgb(47,14,88) 系の紫へ傾きました。画面の大部分がゆっくり動くので累積面積 54.718% は9点最大で、明度は 20%〜13% の狭い帯に収め、色相だけが動きます。

@propertylinear-gradienthsl
@property --hue {
  syntax: '<angle>';
  inherits: false;
  initial-value: 210deg;
}

@include stage($stage-ink);

.ge {
  position: relative; display: flex; flex-direction: column; align-items: center; gap: $sp-4;
  width: 100%; max-width: 360px;
  padding: $sp-4; border-radius: $r-card;
  // 그라디언트는 --hue 를 쓰는 이 판 자신에 깔린다 — inherits: false 로 등록했으므로
  // 값을 쓰는 요소가 그 값을 직접 가져야 한다 (262 실측: 부모에 걸면 안 먹는다).
  background: linear-gradient(135deg, hsl(var(--hue) 72% 20%) 0%, hsl(calc(var(--hue) + 48deg) 62% 13%) 100%);
  transition: --hue $duration $ease-out;
}

07ミニプレーヤーの波形バー

ページが開くとき OfflineAudioContext で4秒のバッファを一度だけレンダリングし、40列それぞれの最大振幅を先に引き出して山形を描きます。実測の先頭6列は 0.538・0.225・0.140・0.140・0.140・0.511 で、0.140 は下限として敷いた床の値です。山はリアルタイム解析ではなく数値の配列なのでスクロールしても崩れず、再生位置は role="progressbar"aria-valuenow にも同じ数字で入ります。

clip-patharia-valuenowgrid-auto-flow
offline.startRendering().then(function (buf) {
  var data = buf.getChannelData(0);
  var slice = Math.floor(data.length / COLS);
  for (var c = 0; c < COLS; c++) {
    var peak = 0;
    for (var k = c * slice; k < (c + 1) * slice; k++) {
      var v = Math.abs(data[k]);
      if (v > peak) { peak = v; }
    }
    peaks.push(Math.max(.14, peak));
  }
  for (var i = 0; i < COLS; i++) {
    var a = document.createElement('i');
    a.className = 'tp__bar';
    a.style.setProperty('--h', peaks[i].toFixed(3));
    baseBars.appendChild(a);
    var b = document.createElement('i');
    b.className = 'tp__bar';
    b.style.setProperty('--h', peaks[i].toFixed(3));
    litBars.appendChild(b);
  }
});

08マイク入力の可視化

getUserMedia で許可を求め、許されれば声が、拒否されれば 220Hz の合成音が同じリングを動かします。実測では許可時の棒は 0.121〜0.446 と粗く揺れ、拒否時は 0.831 を頂点に一本調子で、音源が変わったことが輪郭だけで分かります。入力をスピーカーへ戻さないのはハウリング防止で、許可待ち・入力中・拒否の3状態は aria-live の文で読めます。

getUserMediaaria-livecreateOscillator
  var p = (navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
    ? navigator.mediaDevices.getUserMedia({ audio: true })
    : Promise.reject(new Error('no mediaDevices'));
  p.then(function (stream) {
    micOn = true;
    var src = ctx.createMediaStreamSource(stream);
    src.connect(analyser); // 스피커로 돌려주지 않는다 — 하울링이 일어난다
    statusEl.textContent = '마이크 입력 중 — 말해 보세요';
    running = true;
    timer = requestAnimationFrame(draw);
  }).catch(function () {
    // 거절은 정상 경로다 — 화면이 비지 않게 합성음이 같은 고리를 잇는다.
    micOn = false;
    fallbackTone();
    statusEl.textContent = '권한 거부 — 합성음으로 대체했습니다';
    running = true;
    timer = requestAnimationFrame(draw);
  });

09音量メーターとピークホールド

RMS を測って12段のセグメント LED を下から点灯し、いちばん高く到達した段には線が一本 900ms とどまります。セグメントは12分割の grid で、その上を clip-path が下から満たしていくので、満ちる量が段を越えるたびに1段ずつ点灯し、数値は steps(1, end) のカットで切り替わります。動きは満ちていく区間と線が下りる区間に限られるので動いたフレームは 19/23 と9点で最も少なく、録音ソフトの入力レベルや配信画面のメーターに合います。

steps(1, end)grid-template-rowstransition
  // RMS — 제곱 평균 제곱근. 진폭의 평균이 아니라 에너지의 평균이라 미터의 눈금과 맞다.
  analyser.getByteTimeDomainData(wave);
  var sum = 0;
  for (var k = 0; k < wave.length; k++) {
    var v = (wave[k] - 128) / 128;
    sum += v * v;
  }
  var rms = Math.sqrt(sum / wave.length);
  var frac = Math.min(1, rms * 3.4); // ×3.4 는 미터 눈금 보정 — 재생 음량은 바꾸지 않는다
  root.style.setProperty('--fill', (frac * 100).toFixed(1) + '%');
  // 피크 홀드 — 더 높은 순간이 오면 즉시 올라가 900ms 붙어 있다가 서서히 내려온다.
  var now = performance.now();
  if (frac >= peakFrac) {
    peakFrac = frac;
    peakHoldUntil = now + 900;
    peak.classList.add('is-snap');
  } else if (now > peakHoldUntil) {
    peakFrac = Math.max(frac, peakFrac - 0.01);
    peak.classList.remove('is-snap');
  }
  peak.style.setProperty('--pkPx', (peakFrac * colWrap.clientHeight).toFixed(1) + 'px');

どこで壊れるか — 落とし穴

ここでいちばん時間を食ったのは、AnalyserNode が実際の再生中しか値を出さないという仕様そのものです。だからこの9点はどれも二つの経路を持っています — グリッドのプレビュー(.is-demo)は事前に計算したスペクトルの並びを CSS キーフレームで回し、再生ボタンが押された瞬間に JS がそのクラスを外して本物の Web Audio へ切り替わります。分けて書く理由は実測にあります — このサイトのレンダラーは Web Animations API でアニメーションの時計を直接進めて24枚を撮るので、requestAnimationFrame が描く値は一枚も撮られないのです。本物の音は人が押したときに鳴らし、撮られる静止画は CSS に任せる、という分担になりました。音源ファイルを置かないのも同じ設計から来ています — mp3 を配る代わりに OscillatorNode 4本(196・246.94・293.66・392 Hz)と GainNode で4拍ごとに和音が変わるコード進行をその場で合成するので、zip は軽いままです。なお AudioContext はユーザー操作の中で resume() しないと鳴らないので、再生ボタンの click の中で呼びます。

2つ目は 08 のフォールバックの音程と分解能の食い違いです。権限を拒否されたとき鳴らす 220Hz の正弦波は、fftSize 64 ではビンの幅が 750Hz になって棒に割り当てたビンに入らず、リングが沈んだままでした。fftSize を 256 に上げてビン幅 187.5Hz にすると 220Hz は読むビンに収まり、声の基本周波数も同じ範囲に来ます。

3つ目は 09 のメーターが止まって見える瞬間です。拍と拍の間は GainNode の包絡が毎回 0.02 まで減衰する区間なので、実測でも満ちる量 5.4%・表示 -36 dB まで下がります。壊れたのではなく音楽が休んでいるだけだと読めるよう、dB の数値を画面に残してあります。

もう一つ、プレビューのループの閉じ方にも実測が効いています。棒の波は負の遅延で位相をずらしています — 遅延が負なら2秒のループが端で自然に閉じますが、正の遅延だと撮影窓の終わりで棒が一斉に瞬間移動して、その継ぎ目が1枚に写り込みます。05 の文字だけは逆に正の遅延へ backwards を組み合わせ、遅延の間は 0% の静止姿勢を保たせて左から右へ波が走るようにしました。帯域ラベルやビート数の文字の交代は steps(1, end) のカットですが、ここへ fill-mode を付けると遅延の間に三つの文字が同じ枠に重なって写ります — 01 では実際に三枚の重なりがポスターに焼き付いたので、fill-mode を外して一枚に収めました。03 の流れるダッシュも、動かす総量を周期(56+124=180)と等しくして端で継ぎ目が出ないように閉じています。

こうして二つの経路で揃えた9点のオリジナルは下の zip にあり、開くパスワードは 33t2w53s です。

3つのバリエーション

名前 変える値 感じ
広い飛散 04 の --fly: 84px110px ビートの粒が輪の外まで大きく広がり、ヒーロー背景の余白が生きます
細かいビン 08 の analyser.fftSize = 256512 ビン幅が 93.75Hz になり、声の起伏がもっと多くの棒に現れます
長いピーク 09 の now + 900now + 1500 最高到達の線が長く残り、配信中の目標レベルが見やすくなります

AIプロンプト

バイブコーディングで可視化を1つ足すときは、見えるもの・二つの経路・制約を一度に固定すると書き直しが減ります。下は 01 を頼んだときの断片です。

棒スペクトラムを1枚作る。
見えるもの: 暗い舞台の上に bs__bar を32本、左半分は右の鏡像。棒は下辺を transform-origin に scaleY で伸びる。配色は linear-gradient(180deg, $subject-mint 0%, $stage-yellow 60%, #ff9d2f 100%)。
動き: プレビューは bsWave キーフレームを animation-delay: calc(var(--i) * -56ms) で位相をずらして回す。再生が押されたら .is-demo を外し、getByteFrequencyData の値で --h に 0.06〜1.0 を書く。
制約: 音源ファイルは置かない。OscillatorNode 4本(196・246.94・293.66・392 Hz)で4拍ごとに和音を変える。AudioContext はクリックの中で resume() する。
禁止: AnalyserNode が鳴っていない間に値を取ること。棒が上辺から伸びること。

位相をずらして同じ位相で に置き換えると32本が一斉に伸び縮みして波が消えるので、棒が揃ってしまったらまずこの1語を疑います。

アクセシビリティ

prefers-reduced-motion: reduce では9点すべてのループを止めて、状態の読み取れる1枚を残します。01 は区間番号で対称のスペクトルを1枚立て、02 は sin() の山形の輪を1周分、03 は流れるダッシュを止めた固定波形を見せます。04 は薄いハローとコアだけ、05 は跳ねない文字と帯域の凡例、06 は初期値 210deg の一色、07 は 60% まで塗った山と、その塗り終わりに立つピン、08 は止まった輪と状態文、09 は 30% の静止メーターと dB 表示で立ちます。音の聞こえない人には、01・02・05 が「低音・中音・高音」の帯域ラベル、03 が「静か・揺れ・大きな波」の状態、04 が拾ったビート数、06 がエネルギー%、07 が aria-valuenow の再生位置、09 が dB の数値を aria-live か固定テキストで出します。08 のマイク権限の拒否は catch の正常経路で、合成音が同じ輪を継ぐので画面が空になりません。光過敏への配慮として、プレビューの明暗の山は2秒に3回(1.5Hz)まで — 06 は明度を 20%〜13% の帯に収めたまま色相だけを2秒で1往復させ、WCAG 2.3.1 の閾値の内側に置きます。ラベル類の実測コントラストは、インク舞台でクリーム文字が 17.11:1・値の箱で 10.78:1、イエロー舞台でインク文字が 12.63:1、オレンジ舞台でインクが 5.5:1・dB 値の箱が 7.37:1、07 の青い再生ボタンの白文字が 4.53:1、08 の状態文がイエローの上で 7.72:1 です。

FAQ

音源ファイルは本当に要りませんか

要りません。この9点は mp3 を1つも読まず、OscillatorNode 4本と GainNode でコード進行をその場で合成し(4拍ごとに和音が変わる)、07 の山形だけは OfflineAudioContext で同じ進行を先にレンダリングした数値から作ります。自分の曲に差し替えるときは fetch と decodeAudioData で AudioBufferSourceNode を analyser へ繋ぐだけで、描画側はそのまま動きます。

マイクの許可ダイアログが出ないのはなぜですか

getUserMedia は HTTPS か localhost のような安全なコンテキストでしか動きません。実測では Chrome 53・Firefox 36・Safari 11・Edge 12・iOS Safari 11.0 から対応で、全世界サポートは 96.05%(部分対応 0.34%)です。file:// で開いたページでは navigator.mediaDevices 自体が無いので、08 はその場合も拒否と同じ経路で合成音に落ち、画面が空になることはありません。

Safari でも動きますか

Web Audio API 自体は全世界 96.41% が完全対応ですが、Safari は 6 が初出で長らく webkit 接頭辞が必要でした — デモは window.AudioContext || window.webkitAudioContext の2段で受けます。気をつけるのは周辺機能で、06 の @property は Safari 16.4・Firefox 128 から、07 の OfflineAudioContext は Safari 14.1 からです(caniuse の Audio API・MDN 実測 2026-09-12)。

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

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