GODRICH

Audio Visualizer JavaScript: 9 Parts, No Library

An audio visualizer JavaScript project needs one thing from the browser: Web Audio's AnalyserNode, which turns live sound into numbers every frame — and

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is a build order rather than a component catalog. Mapping one frequency band to one bar that grows up from the floor (01) is the whole skill; everything after it rearranges that one idea. 02 bends the same bars around a circle, 03 stops counting frequencies and draws the wave itself, 04 collapses the spectrum into a single decision — a beat — and 05 and 06 spend that decision on letters and on color. 07 gives up live analysis on purpose, because numbers computed once draw a mountain that never shifts while a list scrolls. 08 turns the source around, from playback to input, and takes on the permission dialog along with the refusal that can follow it; 09 ends by collapsing everything into one honest number in dB. Five tiles run on the ink stage, two on orange, one on paper for the list card, and one on yellow, where someone is being asked for permission. The AnalyserNode is the only API every part touches. The wider craft of turning numbers into pictures lives in SVG chart drawing, this creative pillar's sibling is the node graph connect UI, and the player chrome these tiles drop into is video player controls.

01Bar spectrum

One bar per frequency band, each scaled with scaleY by how loud its band is; transform-origin sits on the bottom edge, so the bar rises from the floor instead of stretching at both ends. The left and right halves mirror each other — bar i and its twin read the same bin, so 32 bars cost only 16 of the analyser's 32 bins. A now-playing indicator or a podcast page header; the second-largest mover of the nine at 19.251% area, 23/23 moving frames.

scaleYtransform-origingetByteFrequencyData
// --h 는 JS 가 실제 소리에서 써 넣는 높이(0~1). 값이 없을 때의 정적 모습은 낮은 막대 줄이다.
.bs__bar {
  height: 100%; border-radius: $r-xs;
  background: linear-gradient(180deg, $subject-mint 0%, $color 60%, #ff9d2f 100%);
  transform-origin: bottom center;
  transform: scaleY(var(--h, .08));
  transition: transform $duration $ease-out;
}

02Radial spectrum

The same bars go around a circle, one every 7.5 degrees, and the pivot moves with them: transform-origin drops to the circle's center, so scaleY pushes each bar outward from the ring instead of straight up the screen. All 48 bars share one keyframe, phase-shifted by −41 ms per index through a negative animation-delay. A ring around album art or the center visual on a radio page — 3.751% area, 23/23 moving frames.

rotatetransform-origin--i
// 막대는 왼쪽·아래 50% 에 걸어 아래 끝이 원의 중심에 닿게 한다. 원점은 그 닿은 점(50% 100%).
.rs__bars { position: absolute; inset: 0; }
.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;
}

03Waveform scope

getByteTimeDomainData reads the amplitude at this instant rather than a frequency spectrum, and the loop writes the same 60 x,y pairs straight into the points attribute of two stacked SVG polylines, a dim base line and the lit one above it. With no canvas anywhere, stroke color, width, and the mint glow all stay in CSS, and the preview sends a 56 px dash flowing along the static wave. A live waveform on a recording screen, or the input display in a voice-memo app; 2.893% area, 23/23 moving frames.

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);

04Beat-reactive particles

A beat is the moment bass energy jumps above its own running average × 1.3, and only that moment fires — one of four rings bursts outward while the core stays put. A fixed threshold loses the beat when the track quiets down and double-counts when it slams; a ratio against the running average does neither, and a 220 ms minimum gap stops one kick from counting twice. A hero background on an event page or a music release landing page; 4.8% area, 23/23 moving frames.

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;

05Bass-bouncing letters

The title's letters are split across low, mid, and high bands, so G and R ride the bass while D and C wait for the highs — each letter jumps in place, origin centered, with only translateY and scale moving. The mid and high bands get display gains of 1.5 and 2.2 because sawtooth harmonics thin out as 1/n up the spectrum; that corrects the meter, not the sound. An album or single release page title, or a countdown header; 9.328% area, 20/23 moving frames.

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;
}

06Energy gradient background

Total energy becomes a single hue angle that slowly stains the whole panel, and @property is what makes it continuous — registered as an <angle> (Chrome 85, Safari 16.4, Firefox 128), --hue interpolates, so the gradient slides between colors instead of snapping. It is the largest mover of the nine at 54.718% area with 23/23 moving frames, and the safest for photosensitive readers: lightness stays locked inside a 13–20% band while hue alone rotates. A now-playing screen background in a streaming app, or a live stream page.

@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;
}

07Mini player peak bars

OfflineAudioContext renders the same four-chord loop once when the page opens; each of 40 columns stores its loudest sample, and clip-path colors the mountain only as far as the playhead has reached. Because the shape is an array of numbers rather than live analysis, the waveform never shifts while the list scrolls or the viewport resizes. Podcast episode lists and track preview cards; 5.867% area, 22/23 moving frames, and at an average intensity of 184.9 the most intense loop of the set.

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);
  }
});

08Microphone input visualizer

getUserMedia asks exactly once: allow it and your voice drives the 24-bar ring; deny it and a synthesized 220 Hz tone drives the same ring — refusal is a designed path rather than an error, so the screen never sits empty. Measured in a real run, spoken input drove the bars to 0.446 while the fallback tone drove them to 0.831, so the handover is visible, not theoretical. Recording start screens, or the mic check before joining a video call; 2.504% area, 23/23 moving frames.

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);
  });

09Level meter with peak hold

RMS — the square root of the mean square, an energy average rather than an amplitude average — lights the 12 segment LEDs from the bottom up, and a peak line snaps to the highest segment reached, holds 900 ms, then eases back down at 0.01 per frame. steps(1, end) cuts every readout swap, so the dB number changes in a single frame with nothing in between, and the is-snap class drops the transition for one frame so a new peak follows instantly. An input level in recording software, or a broadcast meter; 19/23 moving frames at 2.922% area.

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');
  }

Where it breaks — the trap

The trap that shaped every tile here is that an AnalyserNode only produces numbers while audio is actually running. The renderer that captures this site's still frames drives the animation clock itself through the Web Animations API, so anything drawn inside a requestAnimationFrame callback — which is how every real visualizer works — never lands in a captured frame; the capture sees silence. Each grid preview (the .is-demo class) is therefore a puppet on honest strings: a spectrum sequence computed ahead of time and played as CSS keyframes, while the play button strips the class and hands those same elements to live Web Audio. What you see before pressing play is a promise about shape; what you see after is the sound.

The second trap only turned up in headless testing. Item 08's fallback tone was perfectly audible and completely invisible: at fftSize 64 in a 48 kHz context the bins are 750 Hz wide, so a 220 Hz sine falls into bin 0 — the DC bin the ring never reads, since the bars map freq[band + 1] with band running 0 through 8. fftSize 256 narrows the bins to 187.5 Hz, the tone moves into bin 1, and the first bar wakes up. When a tone you can hear draws nothing, check where the bin edges fall before touching a single line of the drawing code.

The third is reading a live meter too literally. Measured during real playback, item 04 counted 5 beats in its first four seconds and fired 4 ring bursts, and item 09's meter sagged to −36 dB at 5.4% fill between beats — that is the music resting between kicks, not the meter dying; a meter that never sags is either lying or normalized. The same run caught item 07's progressbar at aria-valuenow 24 while its time label still read 0:00, because the position updates every frame and the label only ticks each second.

Text that rides a colored stage was measured per element, worst background included, and the weak spots were moved rather than darkened: item 05's 57.6 px title letters hold 5.5:1 as ink on the #ff4d1f stage, item 08's status sentence reads 7.72:1 as #453a21 on #ffd23f, item 09's dB readout 7.37:1 as ink on #ff805b, and item 07's play button 4.53:1 as white on #2f6df6 — each pair recorded with its background hex in the contrast sheet. The zip below carries all nine parts — HTML, SCSS, and the driving JS, and not one media file — and 33t2w53s is the password that opens it.

Three variations

Name Changed value Feel
Finer bands 01 analyser.fftSize = 64 to 256 Each bar reads a 187.5 Hz slice instead of 750, so chord changes flicker through the low bars separately
Softer onsets 04 bassAvg * 1.3 to * 1.15 Half-whispered kicks still burst a ring; dense tracks may over-trigger
Longer hold 09 peakHoldUntil = now + 900 to + 1600 The peak line rides above the segments broadcast-style between beats

AI prompt

When vibe-coding one of these from scratch, pinning down the audio graph, the trigger, and the limits in one pass saves you the rewrite. This fragment produced the beat particles.

Build a beat-reactive particle field.
Audio: Web Audio only — a 70 Hz sine kick every 500 ms over three sawtooth pads (110, 164.81, 220 Hz); no audio file, AudioContext.resume() inside the click.
Trigger: a beat is bass energy above its own running average × 1.3, minimum 220 ms apart; each hit fires one of four rings of 8 dots, start angles offset 11.25 deg per ring.
Motion: dots rest at --rest: 52px and burst to --fly: 84px in 480 ms while fading; the 52px core (#211d26 with a 4px #ffd23f ring) scales to 1.18 and never leaves.
Limits: the beat counter is text with aria-live="polite"; under prefers-reduced-motion the rings stop and the halo stays.
Never: a fixed energy threshold, particle exit as the hero shot, or sound starting without a user gesture.

If particles fire on every frame, your threshold is a number where it should be a ratio — swap energy > 0.5 for energy > average * 1.3 and it calms down.

Accessibility (reduced-motion)

A visualizer is decoration over sound, so every part carries a text alternative a silent reader can use, and each one updates as the sound does: aria-live announces the loudest band in 01·02·05, the wave state in 03, the beat count in 04, the energy percent in 06, the permission state in 08, and the dB value in 09, while 07 reports the playhead position through aria-valuenow on a progressbar, spoken as a number. The measured contrast backs the words: band labels read 11.68:1 on the ink stage, with the value chips beside them at 10.78:1. Under prefers-reduced-motion: reduce every loop stops and leaves a readable still: 01 settles into one symmetric spectrum frame, tall at the edges and low in the center, 02 keeps a hill-shaped ring drawn from a sine of the index, 03 freezes the static wave with the dash removed, 04 leaves the halo resting around its core, 05 leaves the letters standing with the legend explaining which band each rides, 06 parks at its initial 210-degree blue, 07 keeps the mountain colored to 60% with the head pin parked at that colored edge, 08 keeps the ring and its invitation, 09 holds a 30% fill beside −12 dB. Photosensitivity stays under the WCAG 2.3.1 ceiling of three flashes per second: the wave previews peak three times per two seconds, item 04's four bursts per two seconds are decaying swells rather than blinks, and item 06 cannot invert brightness at all because only hue moves.

FAQ

Why does nothing react until I press play?

Two reasons stack. An AudioContext starts suspended and only a user gesture calling resume() unlocks it — the autoplay policy every browser enforces. And before audio runs, an AnalyserNode returns silence, a flat array of zeros, so there is genuinely nothing to draw; that is exactly why the previews run precomputed CSS loops instead of pretending to be live. Web Audio measures 96.41% global support on caniuse (September 2026), and old Safari needed the webkitAudioContext prefix, which the demos still accept.

Can I plug a real track or stream into these?

Yes. Replace buildAudio()'s oscillators with ctx.createMediaElementSource() on an <audio> element, and everything downstream — analyser, loops, meters — is unchanged. The demos synthesize their four-chord loop (196, 246.94, 293.66, 392 Hz, rotating every four beats) precisely so the zip carries no media file; the microphone path in 08 needs a secure context, https or localhost, where getUserMedia measures 96.05% global support. One caveat: 07's pre-render leans on OfflineAudioContext, which Safari only shipped at 14.1.

Is a flashing visualizer a photosensitivity risk?

The threshold in WCAG 2.3.1 is three flashes per second, and the previews stay at three peaks per two seconds — half the ceiling. Item 06 is the only large-area effect and it cannot invert brightness: lightness is locked in a 13–20% band while hue alone rotates. Under prefers-reduced-motion every loop stops and leaves a still you can read a state from, so movement is never the only channel.

Enter the archive password

The password is inside this article. You will find it as you read.