09Level meter with peak hold
RMS lights the segment LEDs from the bottom up, and a thin line lingers for a moment on the highest segment reached. On and off must have no in-between, so steps(1, end) cuts them.
Best forInput level in recording software, meter on a broadcast screen
steps(1, end)grid-template-rowstransition

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-speaker-high" viewBox="0 0 256 256" fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58m28-27a80 80 0 0 1 0 106.88a8 8 0 0 1-12-10.64a64 64 0 0 0 0-85.6a8 8 0 0 1 12-10.64"/></symbol>
<symbol id="ico-play" viewBox="0 0 256 256" fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.88 15.88 0 0 0 0-27.02M80 215.94V40l143.83 88Z"/></symbol>
<symbol id="ico-pause" viewBox="0 0 256 256" fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40zM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40z"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="lm is-demo" id="lm">
<p class="lm__hint"><svg class="lm__ico" aria-hidden="true"><use href="#ico-speaker-high"/></svg><span>Press play and it moves with real sound</span></p>
<div class="lm__body">
<div class="lm__colWrap" id="colWrap" aria-hidden="true">
<div class="lm__col lm__col--off"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="lm__lit"><div class="lm__col lm__col--on"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<i class="lm__peak" id="peak"></i>
</div>
<p class="lm__read">
<span class="lm__readLabel">Input level</span>
<b class="lm__db">
<span class="lm__dbText" id="dbText" aria-live="polite">-12 dB</span>
<span class="lm__loop" aria-hidden="true"><i>-12 dB</i><i>-4 dB</i><i>-8 dB</i></span>
</b>
</p>
</div>
<div class="lm__foot">
<button class="lm__play" id="play" type="button" aria-pressed="false">
<svg class="lm__ico lm__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="lm__ico lm__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">Play</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 볼륨 미터와 피크 홀드 — RMS 로 세그먼트 LED 를 아래부터 켜고, 가장 높았던 칸에 선이 잠시 머문다.
// 켜진 세그먼트는 clip-path 로 아래부터 드러난다 — 칸이 12개라 채움이 지날 때마다 한 칸씩 점등된다.
// 피크 선은 transform(translateY) 으로만 움직인다. 내려올 때만 transition 이 걸리고
// 새 봉우리는 is-snap 으로 transition 을 끊어 즉시 따라올라간다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.lm { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; }
.lm__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $color;
}
.lm__ico { width: 16px; height: 16px; color: $color; }
.lm__body { display: flex; align-items: center; gap: $sp-5; }
.lm__colWrap { position: relative; width: 36px; height: 120px; padding: 0; }
// 세로 12칸 — grid-template-rows: repeat(12, 1fr) 로 균등하게 쪼갠다(칸 폭 오탐도 피한다).
.lm__col {
display: grid; grid-template-rows: repeat(12, 1fr); gap: $sp-1;
height: 100%; width: 100%;
}
.lm__col i { display: block; border-radius: $r-xs; }
.lm__col--off i { background: rgba($subject-ink, .18); }
// 켜진 쪽 — 아래부터 (--fill)% 만큼 드러난다. 칸 단위라 점등이 점프한다.
.lm__lit {
position: absolute; inset: 0;
clip-path: inset(calc(100% - var(--fill, 30%)) 0 0 0);
}
.lm__col--on i { background: $subject-ink; }
.lm__col--on i:nth-child(n + 11) { background: $subject-cream; } // 위 두 칸은 클리핑 존 — 크림으로 경고
// 피크 홀드 선 — translateY 로만. 새 봉우리는 즉시(snap), 하강만 600ms transition.
.lm__peak {
position: absolute; left: -4px; right: -4px; bottom: 0; height: 4px; margin-bottom: -2px;
border-radius: $r-pill; background: $stage-yellow;
box-shadow: 0 0 0 2px rgba($subject-ink, .55);
transform: translateY(calc(-1 * var(--pkPx, 36px)));
transition: transform 600ms cubic-bezier(.2, .7, .3, 1);
}
.lm__peak.is-snap { transition: none; }
.lm__read { margin: 0; display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.lm__readLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: $subject-ink; } // 반투명(80%) 잉크는 주황 위 4.3:1 로 미달 — 온전한 잉크로 5.5:1
.lm__db {
position: relative; display: grid; place-items: center;
min-width: 64px; height: 26px; border-radius: $r-xs; padding: 0 $sp-2;
background: rgba($subject-cream, .3); color: $subject-ink;
font-weight: 800; font-size: clamp(13px, 3.6vw, 15px); line-height: 1;
}
.lm__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.lm__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
.lm__foot { display: flex; align-items: center; justify-content: center; width: 100%; }
.lm__play {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $subject-ink; color: $subject-cream; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.lm__ico--pause { display: none; }
.lm__play[aria-pressed="true"] .lm__ico--play { display: none; }
.lm__play[aria-pressed="true"] .lm__ico--pause { display: inline-block; }
.lm__play .lm__ico { color: $subject-cream; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 채움은 30 → 92 → 58 → 30 (%). LED 점등은 칸 단위로 점프하지만 밝은 색이 고정이라 점멸이 아니다.
// 피크 선은 봉우리를 찍은 뒤 천천히 내려온다 — 컷만 쓰면 움직인 프레임이 모자란다(252 실측).
.lm.is-demo .lm__lit {
animation-name: lmFillLoop; animation-duration: $dur-loop; animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes lmFillLoop {
0% { clip-path: inset(calc(100% - 30%) 0 0 0); }
36% { clip-path: inset(calc(100% - 92%) 0 0 0); }
42% { clip-path: inset(calc(100% - 92%) 0 0 0); }
86%, 100% { clip-path: inset(calc(100% - 30%) 0 0 0); }
}
.lm.is-demo .lm__peak {
animation-name: lmPeakLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite;
transition: none;
}
@keyframes lmPeakLoop {
0% { transform: translateY(-36px); }
36% { transform: translateY(-110px); }
46% { transform: translateY(-110px); }
86%, 100% { transform: translateY(-36px); }
}
// dB 숫자는 -12 → -4 → -8 로 컷 교체. 지연 구간에 fill-mode 를 두지 않는다(작성 상태가 숨김).
.lm.is-demo .lm__dbText { color: transparent; }
.lm.is-demo .lm__loop { visibility: visible; }
.lm.is-demo .lm__loop i {
animation-name: lmNumLoop; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lm.is-demo .lm__loop i:nth-child(1) { animation-delay: 0ms; }
.lm.is-demo .lm__loop i:nth-child(2) { animation-delay: 666ms; }
.lm.is-demo .lm__loop i:nth-child(3) { animation-delay: 1332ms; }
@keyframes lmNumLoop {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.lm { gap: $sp-2; }
.lm__body { gap: $sp-4; }
.lm__colWrap { width: 28px; height: 96px; }
.lm__col { gap: 0; }
.lm__col i { border-radius: 0; }
.lm.is-demo .lm__peak { animation: none !important; }
.lm__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 루프를 멈춘다 — 채움 30% 의 정지 미터와 dB 숫자가 남는다
@media (prefers-reduced-motion: reduce) {
.lm.is-demo .lm__lit,
.lm.is-demo .lm__peak,
.lm.is-demo .lm__loop i { animation: none !important; }
.lm.is-demo .lm__lit { clip-path: inset(calc(100% - 30%) 0 0 0) !important; }
.lm.is-demo .lm__peak { transform: translateY(-36px) !important; }
.lm.is-demo .lm__dbText { color: $subject-ink !important; }
.lm.is-demo .lm__loop { visibility: hidden !important; }
.lm__peak { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lm {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.lm__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.lm__ico {
width: 16px;
height: 16px;
color: #17141a;
}
.lm__body {
display: flex;
align-items: center;
gap: 20px;
}
.lm__colWrap {
position: relative;
width: 36px;
height: 120px;
padding: 0;
}
.lm__col {
display: grid;
grid-template-rows: repeat(12, 1fr);
gap: 4px;
height: 100%;
width: 100%;
}
.lm__col i {
display: block;
border-radius: 4px;
}
.lm__col--off i {
background: rgba(23, 20, 26, 0.18);
}
.lm__lit {
position: absolute;
inset: 0;
clip-path: inset(calc(100% - var(--fill, 30%)) 0 0 0);
}
.lm__col--on i {
background: #17141a;
}
.lm__col--on i:nth-child(n+11) {
background: #fff7e6;
}
.lm__peak {
position: absolute;
left: -4px;
right: -4px;
bottom: 0;
height: 4px;
margin-bottom: -2px;
border-radius: 999px;
background: #ffd23f;
box-shadow: 0 0 0 2px rgba(23, 20, 26, 0.55);
transform: translateY(calc(-1 * var(--pkPx, 36px)));
transition: transform 600ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.lm__peak.is-snap {
transition: none;
}
.lm__read {
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.lm__readLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: #17141a;
}
.lm__db {
position: relative;
display: grid;
place-items: center;
min-width: 64px;
height: 26px;
border-radius: 4px;
padding: 0 8px;
background: rgba(255, 247, 230, 0.3);
color: #17141a;
font-weight: 800;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1;
}
.lm__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.lm__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.lm__foot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.lm__play {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.lm__ico--pause {
display: none;
}
.lm__play[aria-pressed=true] .lm__ico--play {
display: none;
}
.lm__play[aria-pressed=true] .lm__ico--pause {
display: inline-block;
}
.lm__play .lm__ico {
color: #fff7e6;
}
.lm.is-demo .lm__lit {
animation-name: lmFillLoop;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes lmFillLoop {
0% {
clip-path: inset(70% 0 0 0);
}
36% {
clip-path: inset(8% 0 0 0);
}
42% {
clip-path: inset(8% 0 0 0);
}
86%, 100% {
clip-path: inset(70% 0 0 0);
}
}
.lm.is-demo .lm__peak {
animation-name: lmPeakLoop;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
transition: none;
}
@keyframes lmPeakLoop {
0% {
transform: translateY(-36px);
}
36% {
transform: translateY(-110px);
}
46% {
transform: translateY(-110px);
}
86%, 100% {
transform: translateY(-36px);
}
}
.lm.is-demo .lm__dbText {
color: transparent;
}
.lm.is-demo .lm__loop {
visibility: visible;
}
.lm.is-demo .lm__loop i {
animation-name: lmNumLoop;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lm.is-demo .lm__loop i:nth-child(1) {
animation-delay: 0ms;
}
.lm.is-demo .lm__loop i:nth-child(2) {
animation-delay: 666ms;
}
.lm.is-demo .lm__loop i:nth-child(3) {
animation-delay: 1332ms;
}
@keyframes lmNumLoop {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.lm {
gap: 8px;
}
.lm__body {
gap: 16px;
}
.lm__colWrap {
width: 28px;
height: 96px;
}
.lm__col {
gap: 0;
}
.lm__col i {
border-radius: 0;
}
.lm.is-demo .lm__peak {
animation: none !important;
}
.lm__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.lm.is-demo .lm__lit,
.lm.is-demo .lm__peak,
.lm.is-demo .lm__loop i {
animation: none !important;
}
.lm.is-demo .lm__lit {
clip-path: inset(70% 0 0 0) !important;
}
.lm.is-demo .lm__peak {
transform: translateY(-36px) !important;
}
.lm.is-demo .lm__dbText {
color: #17141a !important;
}
.lm.is-demo .lm__loop {
visibility: hidden !important;
}
.lm__peak {
transition: none !important;
}
}var root = document.getElementById('lm');
var colWrap = document.getElementById('colWrap');
var peak = document.getElementById('peak');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var dbText = document.getElementById('dbText');
var SEGS = 12;
var ctx = null, analyser = null, wave = null, timer = 0, playing = false;
var peakFrac = 0.3, peakHoldUntil = 0;
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.5;
wave = new Uint8Array(analyser.fftSize);
var out = ctx.createGain();
out.gain.value = 0.06;
out.connect(analyser);
analyser.connect(ctx.destination);
// 외부 음원 파일 없이 코드 진행을 직접 합성한다 — 네 박자마다 화음이 바뀐다.
[196, 246.94, 293.66, 392].forEach(function (hz, n) {
var osc = ctx.createOscillator();
var amp = ctx.createGain();
osc.type = n === 3 ? 'triangle' : 'sawtooth';
osc.frequency.value = hz;
amp.gain.value = 0;
for (var beat = 0; beat < 64; beat++) {
var at = ctx.currentTime + 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(out); osc.start();
});
}
function draw() {
// 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 는 미터 눈금 보정 — Play 음량은 바꾸지 않는다
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');
var db = Math.max(-40, Math.round(20 * Math.log(Math.max(rms, 0.001)) / Math.log(10)));
var txt = db + ' dB';
if (dbText.textContent !== txt) { dbText.textContent = txt; }
timer = requestAnimationFrame(draw);
}
playBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!ctx) { buildAudio(); }
playing = !playing;
playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false');
playLabel.textContent = playing ? 'Stop' : 'Play';
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (playing) { ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// LevelMeter — vanilla 판(run/291/demo/09-audio-viz-level-meter/index.html) 의 React 포트.
// RMS 로 세그먼트 LED 를 아래부터 켜고, 가장 높았던 칸에 선이 잠시 머문다(피크 홀드).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LevelMeter.module.scss";
export type LevelMeterProps = {
/** 격자 미리보기용 2초 자동 루프. 재생을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** ambient 타입에 useRef<T> 의 null 합집합이 없어 로컬로 좁힌다 */
type Box<T> = { current: T };
const SEGS = 12;
export default function LevelMeter({
demo = true,
duration = 220,
easing = "ease-out",
color = "#17141a",
}: LevelMeterProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [fill, setFill] = useState(30);
const [db, setDb] = useState(-12);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
const peakBox = useRef(0.3) as Box<number>;
const holdBox = useRef(0) as Box<number>;
const wrapBox = useRef<HTMLDivElement | null>(null);
const [pkPx, setPkPx] = useState(36);
const [snap, setSnap] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
return () => {
cancelAnimationFrame(frameBox.current);
if (ctxBox.current) {
ctxBox.current.close();
}
};
}, []);
function buildAudio() {
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.5;
const out = ctx.createGain();
out.gain.value = 0.06;
out.connect(analyser);
analyser.connect(ctx.destination);
// 외부 음원 파일 없이 코드 진행을 직접 합성한다 — 네 박자마다 화음이 바뀐다.
[196, 246.94, 293.66, 392].forEach((hz, n) => {
const osc = ctx.createOscillator();
const amp = ctx.createGain();
osc.type = n === 3 ? "triangle" : "sawtooth";
osc.frequency.value = hz;
amp.gain.value = 0;
for (let beat = 0; beat < 64; beat++) {
const at = ctx.currentTime + 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(out);
osc.start();
});
ctxBox.current = ctx;
nodeBox.current = analyser;
}
function draw() {
const analyser = nodeBox.current;
if (!analyser) {
return;
}
// RMS — 제곱 평균 제곱근. 진폭의 평균이 아니라 에너지의 평균이라 미터의 눈금과 맞다.
const wave = new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(wave);
let sum = 0;
for (let k = 0; k < wave.length; k++) {
const v = (wave[k] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / wave.length);
const frac = Math.min(1, rms * 3.4); // ×3.4 는 미터 눈금 보정 — 재생 음량은 바꾸지 않는다
setFill(frac * 100);
// 피크 홀드 — 더 높은 순간이 오면 즉시 올라가 900ms 붙어 있다가 서서히 내려온다.
const now = performance.now();
if (frac >= peakBox.current) {
peakBox.current = frac;
holdBox.current = now + 900;
setSnap(true);
} else if (now > holdBox.current) {
peakBox.current = Math.max(frac, peakBox.current - 0.01);
setSnap(false);
}
if (wrapBox.current) {
setPkPx(peakBox.current * wrapBox.current.clientHeight);
}
setDb(Math.max(-40, Math.round(20 * Math.log(Math.max(rms, 0.001)) / Math.log(10))));
frameBox.current = requestAnimationFrame(draw);
}
function toggle() {
setLive(false);
if (!ctxBox.current) {
buildAudio();
}
const ctx = ctxBox.current;
if (!ctx) {
return;
}
const next = !playing;
setPlaying(next);
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (next) {
ctx.resume();
frameBox.current = requestAnimationFrame(draw);
} else {
ctx.suspend();
cancelAnimationFrame(frameBox.current);
}
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const rootVars: Vars = { ...vars, "--fill": fill.toFixed(1) + "%" };
const segs = Array.from({ length: SEGS }, (_, i) => <i key={i} />);
return (
<main className={styles.stage} style={rootVars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-speaker-high" viewBox="0 0 256 256" fill="currentColor">
<path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2" />
<path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58m28-27a80 80 0 0 1 0 106.88a8 8 0 0 1-12-10.64a64 64 0 0 0 0-85.6a8 8 0 0 1 12-10.64" />
</symbol>
<symbol id="ico-play" viewBox="0 0 256 256" fill="currentColor">
<path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2" />
<path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.88 15.88 0 0 0 0-27.02M80 215.94V40l143.83 88Z" />
</symbol>
<symbol id="ico-pause" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2" />
<path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40zM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40z" />
</symbol>
</defs>
</svg>
<div className={cls}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true">
<use href="#ico-speaker-high" />
</svg>
<span>재생을 누르면 실제 소리로 움직입니다</span>
</p>
<div className={styles.body}>
<div className={styles.colWrap} ref={wrapBox} aria-hidden="true">
<div className={styles.colOff}>{segs}</div>
<div className={styles.lit}>
<div className={styles.colOn}>{segs}</div>
</div>
<i
className={[styles.peak, snap ? styles.peakSnap : ""].join(" ")}
style={{ transform: `translateY(-${pkPx.toFixed(1)}px)` }}
/>
</div>
<p className={styles.read}>
<span className={styles.readLabel}>입력 레벨</span>
<b className={styles.db}>
<span className={styles.dbText} aria-live="polite">
{db} dB
</span>
<span className={styles.loop} aria-hidden="true">
<i>-12 dB</i>
<i>-4 dB</i>
<i>-8 dB</i>
</span>
</b>
</p>
</div>
<div className={styles.foot}>
<button className={styles.play} type="button" aria-pressed={playing} onClick={toggle}>
<svg className={[styles.ico, playing ? styles.icoHidden : ""].join(" ")} aria-hidden="true">
<use href="#ico-play" />
</svg>
<svg className={[styles.ico, playing ? "" : styles.icoHidden].join(" ")} aria-hidden="true">
<use href="#ico-pause" />
</svg>
<span>{playing ? "정지" : "재생"}</span>
</button>
</div>
</div>
</main>
);
}09 Level meter with peak hold — Measured area 2.922% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · Audio Visualizer JavaScript: 9 Parts, No Library
The post this demo lives in
Audio Visualizer JavaScript: 9 Parts, No LibraryAn audio visualizer JavaScript project needs one thing from the browser: Web Audio's AnalyserNode, which turns live soun See all demos in this post


