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 オリジナル · SCSS と React は zip に同梱 · 音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI
このデモが属する記事
音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI音楽 プレイヤー デザイン 9種のうち、1つは複数のサイン波を重ねてバーの高さを先に計算しイコライザーのように揺らし、1つは角速度の値を徐々に落としてレコード盤を慣性で止め、1つはタイムライン値で歌詞のハイライト行を探して中央へスクロールし この記事のデモをすべて見る


