GODRICH

오디오 비주얼라이저 만들기 9종 — 막대부터 마이크까지

오디오 비주얼라이저 만들기는 소리의 세기를 화면 움직임으로 옮기는 일입니다. 여기 모은 아홉 가지는 막대 스펙트럼부터 마이크 입력·볼륨 미터까지, 브라우저 안에서 CSS 와 짧은 JS 로 만든 재생 부품이라 zip 을 열어 붙이면 그대로 돌아갑니다.

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

아홉 개를 늘어놓은 기준은 화려함이 아니라, 소리를 읽는 단위가 점점 커지는 순서입니다. 처음 셋은 소리를 화면에 옮기는 기본 세 가지로, 구간의 세기를 막대로 세우고(01), 같은 막대를 원 둘레에 돌려 붙이며(02), 주파수 대신 시간 파형을 선으로 긋습니다(03). 다음 셋은 판단이 들어가는 쪽으로, 저음이 자기 평균을 넘는 순간을 비트로 보아 입자를 터뜨리고(04), 대역마다 다른 글자를 띄우며(05), 전체 에너지를 색상각 하나로 바꿉니다(06). 마지막 셋은 소리를 그리는 쪽에서 다루는 쪽으로 건너가, 트랙 전체의 피크를 미리 뽑아 산을 그리고(07), 입력을 마이크로 바꾸며(08), RMS 숫자로 재는 미터로 끝을 맺습니다(09). 아홉 개 모두 음원 파일이 없습니다 — OscillatorNode 와 GainNode 로 네 박자마다 화음이 바뀌는 짧은 진행을 실시간 합성해서, zip 안에 mp3 는 하나도 들어가지 않습니다. 03 의 선 그리기는 SVG 그래프 그리기가, 04 의 점 흩뿌림은 CSS 파티클 배경이, 07 을 감싼 재생 UI 는 비디오 플레이어 컨트롤이 각자 더 깊게 다룹니다.

01막대 스펙트럼

주파수 구간 하나가 막대 하나가 되고, 구간의 세기만큼 scaleY 로 자라는 기본형이라 음악 플레이어의 재생 표시나 팟캐스트 헤더에 먼저 얹게 됩니다. 오른쪽 절반이 맡은 구간을 왼쪽 절반이 거울처럼 되받게 좌우 대칭으로 짰고, 막대는 바닥에 붙어 자라야 하므로 transform-origin 을 아래 변에 둡니다. 미리보기 루프 기준 누적면적 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원형 스펙트럼

01 의 막대를 원 둘레에 48개 세워 돌려 붙인 고리로, 앨범 아트를 감싸거나 라디오·라이브 페이지의 중앙 시각물로 씁니다. 회전축이 막대가 아니라 원의 중심이어야 하므로 transform-origin 을 50% 100% 에 두고 translateY(-50px) 로 반지름만큼 밀어낸 뒤 scaleY 로 자랍니다. 실제 재생에서 측정한 첫 막대 여섯 개의 --h 는 0.462·0.376·0.260·0.203·0.210·0.192 로 저음 쪽 막대가 더 크게 섭니다.

rotatetransform-origin--i
.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;
}

03파형 오실로스코프

주파수 대신 시간 파형을 getByteTimeDomainData 로 읽어 SVG polyline 의 points 로 매 프레임 쓰는데, 녹음 화면의 실시간 파형이나 음성 메모의 입력 표시에 씁니다. 캔버스가 없어 선의 색·두께·번짐이 전부 CSS 에 남고, 진폭의 최댓값을 보고 「고요·출렁·큰물결」 상태 글자를 갈아 끼웁니다. 소리가 멈추면 점 60개가 모두 가운데 한 줄(y=48.3)로 눕는 것도 실측했습니다.

polylinegetByteTimeDomainDatastroke-dasharray
function draw() {
  // 시간 도메인 — 주파수가 아니라 "지금 이 순간의 진폭" 을 그대로 읽는다.
  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);
  var st = peak < 0.18 ? 0 : peak < 0.5 ? 1 : 2;
  if (levelText.textContent !== STATE_NAMES[st]) { levelText.textContent = STATE_NAMES[st]; }
  timer = requestAnimationFrame(draw);
}

04비트 반응 입자

저음 에너지가 자기 이동 평균의 1.3배를 넘는 순간을 비트로 보고 그때만 점 여덟 개짜리 겹이 바깥으로 흩어져, 이벤트 히어로 배경이나 음악 발매 랜딩에 씁니다. 문턱을 고정값이 아니라 평균 대비로 잡아 곡이 조용해져도 비트를 놓치지 않으며, 220ms 최소 간격이 한 번 친 킥을 두 번 세는 것을 막습니다. 실제 재생 2.35초 동안 비트 다섯 개를 잡았고 불이 붙은 겹은 네 개라, 다섯째 비트가 첫 겹을 다시 씁니다.

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저음에 튀는 글자

제목 글자마다 data-band 로 저·중·고 대역을 붙이고 그 대역 에너지를 --j 로 넘겨 글자가 제자리에서 튀는, 발매 페이지 타이틀용 부품입니다. 글자는 중심 원점 그대로 translateYscale 만 쓰고, 톱니파 고조파가 고음에서 성기게 줄어드는 만큼 중·고음에는 표시 게인 1.5·2.2배를 줍니다. 재생 중 어느 순간 일곱 글자의 --j 는 0.357·0.271·0.216·0.357·0.271·0.216·0.271 로 저음 글자가 가장 높이 떠 있었습니다.

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에너지 그라디언트 배경

전체 에너지를 색상각 하나로 바꿔 배경 그라디언트를 천천히 물들이는, 스트리밍 앱 재생 화면 배경입니다. --hue@property<angle> 타입으로 등록해 두었기 때문에 키프레임 사이가 이어져 보간되고, 등록이 없으면 커스텀 속성이 문자열로 취급되어 색이 뚝뚝 뜁니다. 재생 직후 측정 순간의 세기는 63%(채움 scaleX(0.673))였고, 판 전체가 물드는 만큼 누적면적 54.718% 로 아홉 개 중 가장 크게 움직입니다.

@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 로 미리 렌더해 구간마다 피크를 뽑고 그 숫자 40개로 산 모양을 그리는, 팟캐스트 목록·미리듣기 카드용 부품입니다. 실시간 분석이 아니라 숫자 배열이라 스크롤·리사이즈에도 파형이 흔들리지 않고, 채움은 clip-path 로 재생 위치까지만 색을 얹습니다. 피크 첫 여섯 칸은 0.538·0.225·0.140·0.140·0.140·0.511 로 다섯 칸마다 봉우리가 돌아오고, 종이 무대라 강도 평균 184.9 로 아홉 개 중 가장 밝습니다.

clip-patharia-valuenowgrid-auto-flow
var offline = new OfflineAudioContext(1, 44100 * TRACK_SEC, 44100);
var oOut = offline.createGain();
oOut.gain.value = 0.5;
oOut.connect(offline.destination);
[196, 246.94, 293.66, 392].forEach(function (hz, n) {
  var osc = offline.createOscillator();
  var amp = offline.createGain();
  osc.type = n === 3 ? 'triangle' : 'sawtooth';
  osc.frequency.value = hz;
  amp.gain.value = 0;
  for (var beat = 0; beat < TRACK_SEC * 2; beat++) {
    var at = beat * 0.5;
    amp.gain.setValueAtTime(beat % 4 === n % 4 ? 0.9 : 0.25, at);
    amp.gain.exponentialRampToValueAtTime(0.02, at + 0.45);
  }
  osc.connect(amp); amp.connect(oOut); osc.start(0);
});

08마이크 입력 시각화

getUserMedia 로 마이크 권한을 물어서 허락되면 목소리가, 거절되면 220Hz 합성음이 같은 고리 막대 24개를 움직이는, 녹음 시작 화면·마이크 점검용 부품입니다. 거절을 catch 로 잡는 정상 경로로 다뤄 화면이 비지 않게 했고, 마이크 입력을 스피커로 돌려주지 않아 하울링이 일어나지 않습니다. 실측에서 허용할 때 막대가 0.121~0.446, 거절 폴백에서 0.100~0.831 로 두 경로 모두 살아 움직였습니다.

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 로 세그먼트 LED 12칸을 아래부터 켜고 가장 높았던 칸에 노란 선이 900ms 머무는, 녹음·송출 화면의 미터입니다. 켜진 칸은 clip-path 가 칸 단위로 드러내 뚝뚝 끊어 오르고, 피크 선은 새 봉우리에서 transition 을 끊어 즉시 따라올라가며 하강만 천천히 내려옵니다. 박자와 박자 사이 감쇠 구간에서는 -36dB(채움 5.4%)까지 내려가는데, 미터가 죽은 것이 아니라 음악이 쉬고 있는 것입니다.

steps(1, end)grid-template-rowstransition
  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 의 성질 하나입니다. 분석기는 실제로 소리가 흐르는 동안에만 값을 내므로, 데모 렌더러가 Web Animations API 로 애니메이션 시계를 직접 맞춰 가며 24장을 뽑는 동안 requestAnimationFrame 이 그린 값은 한 장도 찍히지 않습니다. 그래서 아홉 개를 전부 두 경로로 지었습니다 — 격자에서 처음 도는 루프는 소리를 미리 계산해 둔 시퀀스를 CSS 키프레임으로 재생하는 .is-demo 미리보기이고, 사람이 재생 버튼을 누르는 순간 이 클래스를 떼고 진짜 Web Audio 값으로 갈아탑니다. 재생 버튼이 갈림길이 된 데에는 자동재생 정책도 겹칩니다 — AudioContext 는 사용자 제스처 안에서 resume() 해야 소리가 나므로, 어차피 첫 소리는 버튼 뒤에 올 수밖에 없습니다. 실측으로 확인했습니다 — 정지 상태에서 matrix(1, 0, 0, 0.08, 0, 0) 로 바닥에 눌려 있던 01 의 막대가, 재생 0.95초 뒤에는 앞 여섯 개 모두 0.060~0.123 으로 살아났습니다.

두 번째는 08 의 거절 폴백이 고리를 못 움직이던 일입니다. 폴백은 220Hz 사인파 한 개인데 fftSize 를 64 로 두면 빈 간격이 750Hz(샘플레이트 48000 을 64 로 나눈 값)라 사인파가 막대에 물린 빈 어디에도 들어가지 못해, headless 로 재현해 보니 고리가 죽어 있었습니다. fftSize 를 256(빈 간격 187.5Hz)으로 올려 220Hz 가 낮은 빈에 잡히게 하니 run/291/_probe/debug08.py 실측에서 막대가 0.62~0.83 으로 돌아왔고, 권한 거절 경로의 최종 실측값(막대 0.100~0.831)도 같은 결과를 보여 줍니다.

세 번째는 09 미터가 죽어 보이는 순간입니다. RMS 는 박자를 친 뒤 감쇠하는 구간을 그대로 따라가서, 실측 순간에 -36dB(채움 5.4%)까지 내려가 있었습니다. 고장이 아니라 음악이 쉬고 있는 것이고, 피크 홀드 선이 그 자리를 지키고 있어 화면을 읽는 사람은 숨 쉬는 미터로 받아들입니다.

화면 글자 대비도 서른여덟 조합 전부를 다시 쟀습니다. 노랑 무대에 크림 글자를 올리면 본문 기준 4.5:1 에 못 미쳐 08 의 글자를 어두운 잉크 쪽으로 돌렸고, 옐로 배경 #ffd23f 위 잉크 글자가 12.63:1, 한 단 어두운 상태 문장이 7.72:1 입니다. 주황 무대(05·09)는 잉크가 5.5:1, 밝은 주황 알약 #ff805b 위 잉크가 7.37:1 을 지켰고, 07 의 파랑 버튼 #2f6df6 위 흰 글자는 4.53:1 로 턱걸이입니다. 전체 표는 run/291/_probe/_대비실측.json 에 남겼습니다.

이 함정들을 지나며 쓴 기술의 지원 범위는 2026-09-12 실측(caniuse·MDN 브라우저 호환 데이터) 값만 적습니다.

기술 전역 지원 Chrome Firefox Safari Edge 비고
Web Audio API 96.41% 14 25 6 12 구형은 webkitAudioContext 접두 필요 — 데모가 둘 중 있는 쪽을 받습니다
getUserMedia 96.05%(부분 0.34%) 53 36 11 12 https 또는 localhost 에서만 동작
@property 85 128 16.4 85 06 이 사용
OfflineAudioContext 35 25 14.1 12 07 이 사용

여기까지의 수리가 그대로 반영된 아홉 벌의 원본은 zip 에 들어 있고, 압축을 여는 비밀번호는 33t2w53s 입니다.

접근성

화면 움직임을 줄이는 prefers-reduced-motion: reduce 설정에서는 아홉 개의 루프를 전부 끄되, 빈 화면 대신 읽히는 한 장을 남깁니다. 01 은 구간 번호에 비례한 대칭 스펙트럼 한 장을 세우고, 02 는 사인 곡선으로 이룬 언덕 한 바퀴를, 03 은 대시가 멈춘 정적 파형을 보여 줍니다. 04 는 코어와 옅은 헤일로만 남겨 미리보기와 실제 비트 반응을 함께 끄고, 05 는 글자를 제자리에 둔 채 범례 칩이 대역을 대신 설명합니다. 06 은 초기 색(210deg) 한 장으로, 07 은 산을 60% 채우고 재생 머리를 그 칠해진 끝에 세운 모습으로, 08 은 고리와 버튼·상태 글자로, 09 는 채움 30% 의 정지 미터로 멈춥니다. 소리를 듣지 못하는 사람을 위해 상태는 글자로도 나옵니다 — 센 대역(01·02·05), 파형 상태(03), 잡은 비트 수(04), 세기 백분율(06), 권한 상태(08), dB 숫자(09)를 aria-live 로 읽히고, 07 의 재생 위치는 role="progressbar"aria-valuenow 로 알리며, 05 의 범례 칩은 어떤 글자가 어느 대역에 걸렸는지 색으로 알려 줍니다. 광과민성 기준(WCAG 2.3.1 — 큰 면적의 밝기 반전은 초당 3회 이하)은 봉우리 수를 소스 키프레임에서 직접 세어 맞췄습니다. 미리보기 파도는 2초에 봉우리 세 개(1.5Hz)이고, 06 은 명도를 좁은 대역에 묶은 채 색상각만 2초에 한 번 왕복하며, 04 의 입자는 점멸이 아니라 감쇠 곡선으로 사라집니다. 마이크 권한을 거절한 경우도 빈 화면이 아니라 합성음과 상태 문장으로 이어지므로, 거절이 곧 고장으로 보이지 않습니다.

FAQ

음원 파일(mp3)을 쓰려면 어떻게 바꾸나요

이번 데모는 OscillatorNode 와 GainNode 로 코드 진행을 실시간 합성해서 zip 안에 음원이 없습니다. 실제 파일을 쓰려면 audio 요소의 소스를 createMediaElementSource 로 AnalyserNode 에 연결하면 되고, 막대에 값을 쓰는 draw 함수는 그대로 둡니다. 다른 도메인의 음원은 crossorigin 설정 없이는 분석이 막히니 같은 origin 이나 CDN 헤더 확인이 먼저입니다.

Safari 나 오래된 브라우저에서는 어떻게 되나요

Web Audio API 전역 지원은 96.41% 이고 데모는 window.AudioContext || window.webkitAudioContext 로 받아 접두가 필요하던 시절의 브라우저까지 챙깁니다. 08 마이크의 getUserMedia 는 전역 96.05%(부분 지원 0.34%)에 https 나 localhost 에서만 물어볼 수 있고, 07 의 OfflineAudioContext 는 Safari 14.1 부터, 06 의 @property 는 Safari 16.4·Firefox 128 부터입니다. @property 가 없으면 06 은 색이 계단처럼 뜨지만 멈추지는 않습니다.

재생을 누르기 전에 움직이는 미리보기는 가짜인가요

가짜와 진짜라기보다 두 경로입니다. 미리보기는 소리를 미리 계산해 둔 시퀀스를 CSS 키프레임으로 돌리는 .is-demo 상태이고, 재생 버튼을 누르는 순간 이 클래스가 떨어지면서 AnalyserNode 의 실시간 값으로 바뀝니다. AnalyserNode 는 실제 재생 중에만 값을 내기 때문에, 한 경로로는 정지 화면과 살아 있는 재생 화면을 동시에 채울 수 없습니다.

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

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