02합성 파형 값으로 흔들리는 이퀄라이저 막대
실제 오디오 분석 없이 여러 개의 사인 곡선을 겹쳐 막대마다 다른 높이 값을 미리 계산해두고 재생 중에는 그 값을 시간에 따라 흔들어 이퀄라이저처럼 보이게 한다 — 291 편의 실제 음성 분석 막대와 달리 이 항목은 장식용 합성 데이터만 쓴다.
쓰임미니 플레이어·배경 장식
makeLcgsynthBAR_COUNT

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-play" viewBox="0 0 256 256"><g 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.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></symbol>
<symbol id="ico-pause" viewBox="0 0 256 256"><g 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"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="eq is-demo is-playing" id="eq">
<p class="eq__hint"><span>재생을 누르면 사인 곡선으로 미리 계산한 높이 값이 막대마다 다르게 흔들립니다</span></p>
<div class="eq__panel">
<button class="eq__play" id="play" type="button" aria-label="재생">
<svg class="eq__ico eq__ico--play" aria-hidden="true"><use href="#ico-play"></use></svg>
<svg class="eq__ico eq__ico--pause" aria-hidden="true"><use href="#ico-pause"></use></svg>
</button>
<div class="eq__bars" id="bars" aria-hidden="true"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(사인 합성)은 index.html 의 synth()/render() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.eq {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.eq__hint {
margin: 0;
color: rgba($subject-ink, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.eq__panel {
width: 100%;
border-radius: $r-card;
background: rgba($subject-ink, .06);
box-shadow: 0 0 0 1px rgba($subject-ink, .14);
padding: $sp-6 $sp-5;
display: flex;
align-items: center;
gap: $sp-5;
}
.eq__play {
flex: 0 0 auto;
width: $sp-10;
height: $sp-10;
border-radius: $r-pill;
border: none;
background: $color;
color: $stage-yellow;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform $duration $easing;
}
.eq__play:hover { transform: scale(1.06); }
.eq__play:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.eq__play:active { transform: scale(.96); }
.eq__ico { width: 18px; height: 18px; }
.eq__ico--pause { display: none; }
.is-playing .eq__ico--play { display: none; }
.is-playing .eq__ico--pause { display: block; }
.eq__bars {
flex: 1 1 auto;
height: 88px;
display: flex;
align-items: flex-end;
gap: 4px;
}
.eq__bar {
flex: 1 1 auto;
height: 100%;
border-radius: $r-xs $r-xs 0 0;
background: $color;
opacity: .82;
transform: scaleY(.14);
transform-origin: bottom center; // 막대는 바닥이 실제 앵커 — 중심 기준이 아니다
}
@media (max-width: 340px) {
.eq__panel { padding: $sp-4 $sp-3; gap: $sp-3; }
.eq__play { width: $sp-8; height: $sp-8; }
.eq__ico { width: 15px; height: 15px; }
.eq__bars { height: 64px; }
}
@media (prefers-reduced-motion: reduce) {
.eq__play { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.eq {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.eq__hint {
margin: 0;
color: rgba(23, 20, 26, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.eq__panel {
width: 100%;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.14);
padding: 24px 20px;
display: flex;
align-items: center;
gap: 20px;
}
.eq__play {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 999px;
border: none;
background: #17141a;
color: #ffd23f;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 300ms ease-out;
}
.eq__play:hover {
transform: scale(1.06);
}
.eq__play:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.eq__play:active {
transform: scale(0.96);
}
.eq__ico {
width: 18px;
height: 18px;
}
.eq__ico--pause {
display: none;
}
.is-playing .eq__ico--play {
display: none;
}
.is-playing .eq__ico--pause {
display: block;
}
.eq__bars {
flex: 1 1 auto;
height: 88px;
display: flex;
align-items: flex-end;
gap: 4px;
}
.eq__bar {
flex: 1 1 auto;
height: 100%;
border-radius: 4px 4px 0 0;
background: #17141a;
opacity: 0.82;
transform: scaleY(0.14);
transform-origin: bottom center;
}
@media (max-width: 340px) {
.eq__panel {
padding: 16px 12px;
gap: 12px;
}
.eq__play {
width: 32px;
height: 32px;
}
.eq__ico {
width: 15px;
height: 15px;
}
.eq__bars {
height: 64px;
}
}
@media (prefers-reduced-motion: reduce) {
.eq__play {
transition: none;
}
}(function () {
var root = document.getElementById('eq');
var playBtn = document.getElementById('play');
var barsEl = document.getElementById('bars');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 막대별 위상·진폭을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var BAR_COUNT = 20;
var rng = makeLcg(2909);
var bars = [];
for (var i = 0; i < BAR_COUNT; i++) {
bars.push({
el: null,
// 사인 곡선 두 겹을 겹쳐 막대마다 다른 위상·주파수로 높이를 합성한다 — 291 편처럼 AudioContext/AnalyserNode 로
// 실제 소리를 분석해 얻는 값이 아니라, 여기서는 사인 합성만으로 만든 장식용 값이다.
f1: 0.55 + rng() * 0.35,
p1: rng() * Math.PI * 2,
f2: 1.4 + rng() * 0.6,
p2: rng() * Math.PI * 2,
w2: 0.25 + rng() * 0.2,
idle: 0.14 + rng() * 0.08
});
}
for (var i = 0; i < BAR_COUNT; i++) {
var b = document.createElement('div');
b.className = 'eq__bar';
barsEl.appendChild(b);
bars[i].el = b;
}
function synth(bar, tSec) {
var s1 = Math.sin(tSec * bar.f1 * Math.PI * 2 + bar.p1);
var s2 = Math.sin(tSec * bar.f2 * Math.PI * 2 + bar.p2) * bar.w2;
var v = (s1 + s2) / (1 + bar.w2); // -1..1 범위로 정규화
return 0.5 + v * 0.5; // 0..1
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var playing = true, t0 = 0;
function render(now, playingNow) {
var tSec = (now - t0) / 1000;
for (var i = 0; i < BAR_COUNT; i++) {
var bar = bars[i];
var h = playingNow ? synth(bar, tSec) : bar.idle;
// 막대는 바닥이 기준점이라 아래쪽 원점으로 세로로만 자란다 — 중심 기준이 아닌 실제 앵커 지점
bar.el.style.transform = 'scaleY(' + Math.max(0.06, h) + ')';
}
}
function setPlaying(next) {
playing = next;
root.classList.toggle('is-playing', next);
}
function onPlayClick() { human(); setPlaying(!playing); }
function onPlayKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); setPlaying(!playing); }
}
function frame(now) {
if (!t0) t0 = now;
render(now, isDemo ? true : playing);
requestAnimationFrame(frame);
}
if (reduce) {
root.classList.add('is-still');
t0 = performance.now();
render(t0, false);
playBtn.addEventListener('click', function () {
human(); setPlaying(!playing);
render(performance.now(), playing);
});
playBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); human(); setPlaying(!playing);
render(performance.now(), playing);
}
});
} else {
playBtn.addEventListener('click', onPlayClick);
playBtn.addEventListener('keydown', onPlayKeydown);
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./WaveformBars.module.scss";
type Bar = { el: HTMLDivElement | null; f1: number; p1: number; f2: number; p2: number; w2: number; idle: number };
export default function WaveformBars() {
const rootRef = useRef<HTMLDivElement | null>(null);
const playRef = useRef<HTMLButtonElement | null>(null);
const barsElRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const playBtn = playRef.current;
const barsEl = barsElRef.current;
if (!root || !playBtn || !barsEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 막대별 위상·진폭을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const BAR_COUNT = 20;
const rng = makeLcg(2909);
const bars: Bar[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
bars.push({
el: null,
// 사인 곡선 두 겹을 겹쳐 막대마다 다른 위상·주파수로 높이를 합성한다 — 291 편처럼 AudioContext/AnalyserNode 로
// 실제 소리를 분석해 얻는 값이 아니라, 여기서는 사인 합성만으로 만든 장식용 값이다.
f1: 0.55 + rng() * 0.35,
p1: rng() * Math.PI * 2,
f2: 1.4 + rng() * 0.6,
p2: rng() * Math.PI * 2,
w2: 0.25 + rng() * 0.2,
idle: 0.14 + rng() * 0.08,
});
}
for (let i = 0; i < BAR_COUNT; i++) {
const b = document.createElement("div");
b.className = styles.bar;
barsEl.appendChild(b);
bars[i].el = b;
}
function synth(bar: Bar, tSec: number) {
const s1 = Math.sin(tSec * bar.f1 * Math.PI * 2 + bar.p1);
const s2 = Math.sin(tSec * bar.f2 * Math.PI * 2 + bar.p2) * bar.w2;
const v = (s1 + s2) / (1 + bar.w2); // -1..1 범위로 정규화
return 0.5 + v * 0.5; // 0..1
}
function human() { if (isDemo) { root!.classList.remove(styles["is-demo"]); isDemo = false; } }
let playing = true, t0 = 0;
function render(now: number, playingNow: boolean) {
const tSec = (now - t0) / 1000;
for (let i = 0; i < BAR_COUNT; i++) {
const bar = bars[i];
const h = playingNow ? synth(bar, tSec) : bar.idle;
// 막대는 바닥이 기준점이라 아래쪽 원점으로 세로로만 자란다 — 중심 기준이 아닌 실제 앵커 지점
bar.el!.style.transform = "scaleY(" + Math.max(0.06, h) + ")";
}
}
function setPlaying(next: boolean) {
playing = next;
root!.classList.toggle(styles["is-playing"], next);
}
function onPlayClick() { human(); setPlaying(!playing); }
function onPlayKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); setPlaying(!playing); }
}
let raf = 0;
function frame(now: number) {
if (!t0) t0 = now;
render(now, isDemo ? true : playing);
raf = requestAnimationFrame(frame);
}
function onReduceClick() { human(); setPlaying(!playing); render(performance.now(), playing); }
function onReduceKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); human(); setPlaying(!playing); render(performance.now(), playing);
}
}
if (reduce) {
root.classList.add(styles["is-still"]);
t0 = performance.now();
render(t0, false);
playBtn.addEventListener("click", onReduceClick);
playBtn.addEventListener("keydown", onReduceKeydown);
} else {
playBtn.addEventListener("click", onPlayClick);
playBtn.addEventListener("keydown", onPlayKeydown);
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
if (reduce) {
playBtn.removeEventListener("click", onReduceClick);
playBtn.removeEventListener("keydown", onReduceKeydown);
} else {
playBtn.removeEventListener("click", onPlayClick);
playBtn.removeEventListener("keydown", onPlayKeydown);
}
barsEl.innerHTML = "";
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.eq + " " + styles["is-demo"] + " " + styles["is-playing"]} ref={rootRef}>
<p className={styles.hint}><span>재생을 누르면 사인 곡선으로 미리 계산한 높이 값이 막대마다 다르게 흔들립니다</span></p>
<div className={styles.panel}>
<button className={styles.play} ref={playRef} type="button" aria-label="재생">
<svg className={`${styles.ico} ${styles.icoPlay}`} aria-hidden="true"><use href="#ico-play" /></svg>
<svg className={`${styles.ico} ${styles.icoPause}`} aria-hidden="true"><use href="#ico-pause" /></svg>
</button>
<div className={styles.bars} ref={barsElRef} aria-hidden="true" />
</div>
</div>
</main>
);
}02 합성 파형 값으로 흔들리는 이퀄라이저 막대 — 측정 누적면적 14.217% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UI
이 데모가 속한 글
css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UIcss 오디오 플레이어 디자인 아홉 가지 가운데 하나는 여러 사인 곡선을 겹쳐 막대 높이를 미리 계산해 이퀄라이저처럼 흔들고, 하나는 각속도 값을 서서히 줄여 앨범판이 관성으로 멈추게 하며, 하나는 타임라인 값으로 이 글의 데모 전부 보기


