オーディオビジュアライザー 作り方 9選 — 棒からマイクまで
オーディオビジュアライザー 作り方とは、鳴っている音の強さ・高さ・リズムを画面の動きに変える UI を、Web Audio API と CSS だけで作ることです。ここにある9点は棒スペクトラムからマイク入力まで、音源ファイルもライブラリもなしで、貼ればそのまま動きます。
01 audio-viz-bar-spectrum
02 audio-viz-radial-spectrum
03 audio-viz-waveform-scope
04 audio-viz-beat-particles
05 audio-viz-text-bounce
06 audio-viz-gradient-energy
07 audio-viz-track-peaks
08 audio-viz-mic-input
09 audio-viz-level-meter
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 棒スペクトラム
- 02 円形スペクトラム
- 03 波形オシロスコープ
- 04 ビート反応パーティクル
- 05 低音で跳ねる文字
- 06 エネルギーグラデーション背景
- 07 ミニプレーヤーの波形バー
- 08 マイク入力の可視化
- 09 音量メーターとピークホールド
9点の並び順は部品の種類ではなく、音声 波形 表示 が画面へ変わっていく順です。まず同じスペクトラムを3つの読み方で — 棒の列で読み(01)、円周に並べ直し(02)、周波数を捨てて時間波形の線で読みます(03)。次は音が引き金になる2つ — 低音の跳ねでビートを捕まえて粒を弾けさせ(04)、帯域ごとに違う文字を跳ねさせます(05)。最後の4つは画面を染めて外とつなぐ — 全エネルギーで背景を染め(06)、トラック全体の山を数値として固定し(07)、マイクという外部入力を迎え(08)、数値のメーターで締めます(09)。ステージ色はインク5・オレンジ2・紙1・イエロー1で回して9マスの明るさが偏らないように合わせました。音を扱う隣人は動画プレイヤーコントロール9選、データを線で引く側はSVGグラフ描画9選、入力端子を線で繋ぐ操作はノードグラフ接続 UI 9選が担います。同じcanvasを手が描く側へ向ければサインとキャンバス描画になり、04の粒に重力と摩擦を与えると物理シミュレーションの側へ渡ります。
01棒スペクトラム
何が見えるか
周波数の区間ごとに棒が一本立ち、その区間の強さだけ scaleY で伸びます。棒は床に付いたまま伸びる要素なので transform-origin を下辺に置き、左半分を右半分の鏡像にした32本が負の遅延で順に波打ちます。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor"><path d="M208 96v64H48V96Z" opacity=".2"/><path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8"/></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="bs is-demo" id="bs">
<p class="bs__hint"><svg class="bs__ico" aria-hidden="true"><use href="#ico-waveform"/></svg><span>再生を押すと実際の音で動きます</span></p>
<div class="bs__bars" id="bars" aria-hidden="true"></div>
<div class="bs__foot">
<button class="bs__play" id="play" type="button" aria-pressed="false">
<svg class="bs__ico bs__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="bs__ico bs__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">再生</span>
</button>
<p class="bs__level">
<span class="bs__levelLabel">強い帯域</span>
<b class="bs__levelBox">
<span class="bs__levelText" id="levelText" aria-live="polite">低音</span>
<span class="bs__loop" aria-hidden="true"><i>低音</i><i>中音</i><i>高音</i></span>
</b>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 막대 스펙트럼 — AnalyserNode 의 주파수 구간 하나가 막대 하나가 된다.
// 막대는 바닥에 붙어 자라는 요소라 transform-origin 을 아래 변에 둔다 (blog-demo 규칙 8 의 "붙는 지점" 예외).
// 격자 미리보기(.is-demo)는 사전에 정해 둔 스펙트럼 시퀀스를 CSS 키프레임으로 재생한다 —
// AnalyserNode 는 실제 재생 중에만 값이 나와 렌더러의 애니메이션 시계로는 잡히지 않는다.
// 사람이 재생 버튼을 누르면 JS 가 .is-demo 를 떼고 진짜 Web Audio 값으로 --h 를 쓴다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
.bs { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 400px; }
.bs__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-cream;
}
.bs__ico { width: 16px; height: 16px; color: $color; }
.bs__bars {
display: grid; grid-template-columns: repeat(32, 1fr); gap: $sp-1;
align-items: end; width: 100%; height: 104px;
}
// --h 는 JS 가 실제 소리에서 써 넣는 높이(0~1). 값이 없을 때의 정적 모습은 낮은 막대 줄이다.
.bs__bar {
height: 100%; border-radius: $r-xs;
background: linear-gradient(180deg, $subject-mint 0%, $color 60%, #cdb599 100%);
transform-origin: bottom center;
transform: scaleY(var(--h, .08));
transition: transform $duration $ease-out;
}
.bs__foot { display: flex; align-items: center; justify-content: center; gap: $sp-4; width: 100%; }
.bs__play {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $color; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.bs__ico--pause { display: none; }
.bs__play[aria-pressed="true"] .bs__ico--play { display: none; }
.bs__play[aria-pressed="true"] .bs__ico--pause { display: inline-block; }
.bs__play .bs__ico { color: $subject-ink; }
.bs__level { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.bs__levelLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-cream, .82); }
.bs__levelBox {
position: relative; display: grid; place-items: center;
min-width: 40px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-cream, .16); color: $subject-cream;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.bs__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.bs__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 한 키프레임을 32개 막대가 공유하고, 음수 지연으로 구간 번호만큼 위상을 민다.
// 음수 지연이라 2초 루프가 끝에서 닫힌다 (양수 지연은 캡처 창 끝에서 막대가 순간이동한다 — 285 실측).
// 봉우리는 2초에 세 번(1.5Hz)이라 WCAG 2.3.1 의 초당 3회 한계 아래다.
.bs.is-demo .bs__bar {
animation-name: bsWave; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -56ms);
transition: none;
}
@keyframes bsWave {
0% { transform: scaleY(.10); }
14% { transform: scaleY(.92); }
28% { transform: scaleY(.30); }
42% { transform: scaleY(.68); }
56% { transform: scaleY(.18); }
70% { transform: scaleY(.80); }
84% { transform: scaleY(.24); }
100% { transform: scaleY(.10); }
}
// 대역 이름도 같은 박자로 바뀐다. 글자 교체는 steps(1, end) 로 한 프레임에 컷 —
// 미리보기 전용 글자는 color: transparent 로만 감춘다 (display: none 은 칸 폭까지 없앤다, 274 실측).
.bs.is-demo .bs__levelText { color: transparent; }
.bs.is-demo .bs__loop { visibility: visible; }
.bs.is-demo .bs__loop i {
animation-name: bsBandName; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
// 여기만 fill-mode 를 두지 않는다 — 양수 지연 구간에 backwards 를 쓰면 세 글자가 한꺼번에 보인다.
// 작성 상태가 이미 visibility: hidden 이라 지연 동안 그대로 숨어 있는 게 맞다 (실측: poster 에 세 글자가 겹쳐 찍혔다)
}
.bs.is-demo .bs__loop i:nth-child(1) { animation-delay: 0ms; }
.bs.is-demo .bs__loop i:nth-child(2) { animation-delay: 666ms; }
.bs.is-demo .bs__loop i:nth-child(3) { animation-delay: 1332ms; }
@keyframes bsBandName {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.bs { gap: $sp-2; }
.bs__bars { height: 76px; gap: 0; }
.bs__bar { border-radius: 0; }
.bs__foot { gap: $sp-2; }
.bs__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 루프를 멈추되 막대를 눕히지 않는다 — 구간 번호로 대칭 스펙트럼 한 장을 세워 둔다
@media (prefers-reduced-motion: reduce) {
.bs.is-demo .bs__bar,
.bs.is-demo .bs__loop i { animation: none !important; }
.bs.is-demo .bs__bar { transform: scaleY(calc(.25 + var(--i) * .045)) !important; }
.bs.is-demo .bs__levelText { color: $subject-cream !important; }
.bs.is-demo .bs__loop { visibility: hidden !important; }
.bs__bar { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bs {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 400px;
}
.bs__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #fff7e6;
}
.bs__ico {
width: 16px;
height: 16px;
color: #ffd23f;
}
.bs__bars {
display: grid;
grid-template-columns: repeat(32, 1fr);
gap: 4px;
align-items: end;
width: 100%;
height: 104px;
}
.bs__bar {
height: 100%;
border-radius: 4px;
background: linear-gradient(180deg, #4cd4a6 0%, #ffd23f 60%, #cdb599 100%);
transform-origin: bottom center;
transform: scaleY(var(--h, 0.08));
transition: transform 220ms ease-out;
}
.bs__foot {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
}
.bs__play {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.bs__ico--pause {
display: none;
}
.bs__play[aria-pressed=true] .bs__ico--play {
display: none;
}
.bs__play[aria-pressed=true] .bs__ico--pause {
display: inline-block;
}
.bs__play .bs__ico {
color: #17141a;
}
.bs__level {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.bs__levelLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.bs__levelBox {
position: relative;
display: grid;
place-items: center;
min-width: 40px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.bs__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.bs__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.bs.is-demo .bs__bar {
animation-name: bsWave;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -56ms);
transition: none;
}
@keyframes bsWave {
0% {
transform: scaleY(0.1);
}
14% {
transform: scaleY(0.92);
}
28% {
transform: scaleY(0.3);
}
42% {
transform: scaleY(0.68);
}
56% {
transform: scaleY(0.18);
}
70% {
transform: scaleY(0.8);
}
84% {
transform: scaleY(0.24);
}
100% {
transform: scaleY(0.1);
}
}
.bs.is-demo .bs__levelText {
color: transparent;
}
.bs.is-demo .bs__loop {
visibility: visible;
}
.bs.is-demo .bs__loop i {
animation-name: bsBandName;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bs.is-demo .bs__loop i:nth-child(1) {
animation-delay: 0ms;
}
.bs.is-demo .bs__loop i:nth-child(2) {
animation-delay: 666ms;
}
.bs.is-demo .bs__loop i:nth-child(3) {
animation-delay: 1332ms;
}
@keyframes bsBandName {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.bs {
gap: 8px;
}
.bs__bars {
height: 76px;
gap: 0;
}
.bs__bar {
border-radius: 0;
}
.bs__foot {
gap: 8px;
}
.bs__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bs.is-demo .bs__bar,
.bs.is-demo .bs__loop i {
animation: none !important;
}
.bs.is-demo .bs__bar {
transform: scaleY(calc(0.25 + var(--i) * 0.045)) !important;
}
.bs.is-demo .bs__levelText {
color: #fff7e6 !important;
}
.bs.is-demo .bs__loop {
visibility: hidden !important;
}
.bs__bar {
transition: none !important;
}
}var root = document.getElementById('bs');
var barBox = document.getElementById('bars');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var levelText = document.getElementById('levelText');
var BAR_COUNT = 32, HALF = BAR_COUNT / 2;
var bars = [];
// 좌우 대칭: 오른쪽 절반은 0..15, 왼쪽 절반은 그 거울상이라 두 막대가 같은 주파수 구간을 쓴다.
// --i 는 그 구간 번호이자 미리보기 루프의 음수 지연 배수다.
for (var i = 0; i < BAR_COUNT; i++) {
var bar = document.createElement('span');
var band = i < HALF ? HALF - 1 - i : i - HALF;
bar.className = 'bs__bar';
bar.style.setProperty('--i', String(band));
bar.setAttribute('data-band', String(band));
barBox.appendChild(bar);
bars.push(bar);
}
var ctx = null, analyser = null, freq = null, timer = 0, playing = false;
var BAND_NAMES = ['低音', '中音', '高音'];
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 64;
analyser.smoothingTimeConstant = 0.72;
freq = new Uint8Array(analyser.frequencyBinCount);
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() {
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);
}
playBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!ctx) { buildAudio(); }
playing = !playing;
playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false');
playLabel.textContent = playing ? '停止' : '再生';
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (playing) { ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// BarSpectrum — vanilla 판(run/291/demo/01-audio-viz-bar-spectrum/index.html) 의 React 포트.
// 주파수 구간 하나가 막대 하나. 재생 버튼을 누르면 합성 음원을 만들어 AnalyserNode 값으로 막대를 세운다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BarSpectrum.module.scss";
export type BarSpectrumProps = {
/** 격자 미리보기용 2초 자동 루프. 재생을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BarVars = CSSProperties & { "--i"?: string; "--h"?: string };
/** ambient 타입에 useRef<T> 의 null 합집합이 없어 로컬로 좁힌다 */
type Box<T> = { current: T };
const BAR_COUNT = 32;
const HALF = BAR_COUNT / 2;
const BAND_NAMES = ["저음", "중음", "고음"];
/** 좌우 대칭: 오른쪽 절반은 0..15, 왼쪽 절반은 그 거울상이라 두 막대가 같은 구간을 쓴다 */
const BANDS: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
BANDS.push(i < HALF ? HALF - 1 - i : i - HALF);
}
export default function BarSpectrum({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffd23f",
}: BarSpectrumProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [heights, setHeights] = useState<number[]>([]);
const [band, setBand] = useState(BAND_NAMES[0]);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
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 = 64;
analyser.smoothingTimeConstant = 0.72;
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;
}
const freq = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(freq);
const sums = [0, 0, 0];
const next = BANDS.map((b) => {
const v = freq[b + 1] / 255;
sums[Math.min(2, Math.floor(b / 6))] += v;
return 0.06 + v * 0.94;
});
setHeights(next);
setBand(BAND_NAMES[sums.indexOf(Math.max(sums[0], sums[1], sums[2]))]);
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(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 96v64H48V96Z" opacity=".2" />
<path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8" />
</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-waveform" />
</svg>
<span>재생을 누르면 실제 소리로 움직입니다</span>
</p>
<div className={styles.bars} aria-hidden="true">
{BANDS.map((b, i) => {
const barVars: BarVars = { "--i": String(b) };
if (heights[i] !== undefined) {
barVars["--h"] = heights[i].toFixed(3);
}
return <span className={styles.bar} key={i} style={barVars} />;
})}
</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>
<p className={styles.level}>
<span className={styles.levelLabel}>센 대역</span>
<b className={styles.levelBox}>
<span className={styles.levelText} aria-live="polite">
{band}
</span>
<span className={styles.loop} aria-hidden="true">
<i>저음</i>
<i>중음</i>
<i>고음</i>
</span>
</b>
</p>
</div>
</div>
</main>
);
}01 · 棒スペクトラム — 計測 累積面積 19.231% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
画面のほとんどが毎フレーム動くので累積面積 19.231% は 06 に次ぐ大きさで、動いたフレームも 23/23 と満点になり、音楽プレーヤーの再生表示やポッドキャストのヘッダーにそのまま使えます。
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円形スペクトラム
音声 波形 表示 円形 はどう描くか
同じ棒を48本、円周に立てて等分の角度ずつ回して並べます。回転の中心は棒ではなく円の中心なので transform-origin を半径のぶん下へずらし、scaleY は円に接した点から外側へだけ伸びます。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor"><path d="M208 96v64H48V96Z" opacity=".2"/><path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8"/></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="rs is-demo" id="rs">
<p class="rs__hint"><svg class="rs__ico" aria-hidden="true"><use href="#ico-waveform"/></svg><span>再生を押すと実際の音で動きます</span></p>
<div class="rs__dial" aria-hidden="true">
<div class="rs__bars" id="bars"></div>
<div class="rs__hub"><svg class="rs__hubIco" aria-hidden="true"><use href="#ico-waveform"/></svg></div>
</div>
<div class="rs__foot">
<button class="rs__play" id="play" type="button" aria-pressed="false">
<svg class="rs__ico rs__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="rs__ico rs__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">再生</span>
</button>
<p class="rs__level">
<span class="rs__levelLabel">強い帯域</span>
<b class="rs__levelBox">
<span class="rs__levelText" id="levelText" aria-live="polite">低音</span>
<span class="rs__loop" aria-hidden="true"><i>低音</i><i>中音</i><i>高音</i></span>
</b>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 원형 스펙트럼 — 막대 48개를 원 둘레에 세운다. 각도는 --i * 1turn / 48.
// 막대의 transform-origin 은 그 막대의 아래 끝(원의 중심점)이다 — rotate 의 회전축이 원 중심이 되고,
// scaleY 는 막대가 원에 닿은 지점에서 바깥으로만 자란다. 규칙 8 의 "붙는 지점" 정당 사례.
// 격자 미리보기(.is-demo)는 사전 시퀀스를 CSS 키프레임으로 재생하고, 재생을 누르면 진짜 Web Audio 값이 온다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.rs { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.rs__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-cream;
}
.rs__ico { width: 16px; height: 16px; color: $color; }
.rs__dial { position: relative; width: 156px; height: 156px; }
// 막대는 왼쪽·아래 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;
}
.rs__hub {
position: absolute; left: 50%; top: 50%; width: 60px; height: 60px; margin: -30px 0 0 -30px;
display: grid; place-items: center; border-radius: 50%;
background: #211d26; border: 4px solid rgba($subject-cream, .14);
}
.rs__hubIco { width: 24px; height: 24px; color: $color; }
.rs__foot { display: flex; align-items: center; justify-content: center; gap: $sp-4; width: 100%; }
.rs__play {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $stage-yellow; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.rs__ico--pause { display: none; }
.rs__play[aria-pressed="true"] .rs__ico--play { display: none; }
.rs__play[aria-pressed="true"] .rs__ico--pause { display: inline-block; }
.rs__play .rs__ico { color: $subject-ink; }
.rs__level { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.rs__levelLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-cream, .82); }
.rs__levelBox {
position: relative; display: grid; place-items: center;
min-width: 40px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-cream, .16); color: $subject-cream;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.rs__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.rs__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 01 과 같은 파도 시퀀스를 원에 얹는다. 음수 지연으로 등분 번호만큼 위상을 민다(양수는 창 끝에서 순간이동).
// 회전(rot) 은 넣지 않는다 — 파도가 도는 것과 나는 것이 겹치면 무엇을 보여주는지 알 수 없다(한 데모 한 동작).
// 봉우리는 2초에 세 번(1.5Hz)이라 WCAG 2.3.1 의 초당 3회 한계 아래다.
.rs.is-demo .rs__bar {
animation-name: rsWave; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -41ms);
transition: none;
}
@keyframes rsWave {
0% { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(.12); }
14% { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(.94); }
28% { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(.34); }
42% { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(.70); }
56% { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(.20); }
70% { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(.82); }
84% { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(.28); }
100% { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(.12); }
}
// 대역 이름도 같은 박자로 바뀐다. 글자 교체는 steps(1, end) 컷 — 지연 구간에 fill-mode 를 두지 않는다
// (작성 상태가 이미 숨김이라 backwards 를 주면 세 글자가 겹쳐 보인다, 01 실측).
.rs.is-demo .rs__levelText { color: transparent; }
.rs.is-demo .rs__loop { visibility: visible; }
.rs.is-demo .rs__loop i {
animation-name: rsBandName; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__loop i:nth-child(1) { animation-delay: 0ms; }
.rs.is-demo .rs__loop i:nth-child(2) { animation-delay: 666ms; }
.rs.is-demo .rs__loop i:nth-child(3) { animation-delay: 1332ms; }
@keyframes rsBandName {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.rs { gap: 0; }
.rs__dial { width: 120px; height: 120px; }
.rs__bar { height: 18px; width: 4px; }
.rs__hub { width: 48px; height: 48px; margin: -24px 0 0 -24px; }
// 좁은 화면에서 막대의 바깥 반경이 무대를 넘기지 않게 반지름도 함께 줄인다 (translateY 와 세트)
.rs__bar { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-38px) scaleY(var(--h, .12)); }
.rs.is-demo .rs__bar { animation: none !important; transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-38px) scaleY(calc(.3 + var(--i) * .01)); }
.rs__foot { gap: $sp-2; }
.rs__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 루프를 멈추되 빈 고리로 두지 않는다 — 등분 번호로 언덕 모양 스펙트럼 한 바퀴를 세워 둔다
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__bar,
.rs.is-demo .rs__loop i { animation: none !important; }
.rs.is-demo .rs__bar { transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(calc(.22 + sin(var(--i) / 48 * 3.14159) * .6)) !important; }
.rs.is-demo .rs__levelText { color: $subject-cream !important; }
.rs.is-demo .rs__loop { visibility: hidden !important; }
.rs__bar { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rs {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.rs__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #fff7e6;
}
.rs__ico {
width: 16px;
height: 16px;
color: #4cd4a6;
}
.rs__dial {
position: relative;
width: 156px;
height: 156px;
}
.rs__bars {
position: absolute;
inset: 0;
}
.rs__bar {
position: absolute;
left: 50%;
bottom: 50%;
width: 4px;
height: 24px;
margin-left: -2px;
border-radius: 4px;
background: linear-gradient(180deg, #ffd23f 0%, #4cd4a6 100%);
transform-origin: 50% 100%;
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(var(--h, 0.12));
transition: transform 220ms ease-out;
}
.rs__hub {
position: absolute;
left: 50%;
top: 50%;
width: 60px;
height: 60px;
margin: -30px 0 0 -30px;
display: grid;
place-items: center;
border-radius: 50%;
background: #211d26;
border: 4px solid rgba(255, 247, 230, 0.14);
}
.rs__hubIco {
width: 24px;
height: 24px;
color: #4cd4a6;
}
.rs__foot {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
}
.rs__play {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.rs__ico--pause {
display: none;
}
.rs__play[aria-pressed=true] .rs__ico--play {
display: none;
}
.rs__play[aria-pressed=true] .rs__ico--pause {
display: inline-block;
}
.rs__play .rs__ico {
color: #17141a;
}
.rs__level {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.rs__levelLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.rs__levelBox {
position: relative;
display: grid;
place-items: center;
min-width: 40px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.rs__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.rs__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.rs.is-demo .rs__bar {
animation-name: rsWave;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -41ms);
transition: none;
}
@keyframes rsWave {
0% {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(0.12);
}
14% {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(0.94);
}
28% {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(0.34);
}
42% {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(0.7);
}
56% {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(0.2);
}
70% {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(0.82);
}
84% {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(0.28);
}
100% {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(0.12);
}
}
.rs.is-demo .rs__levelText {
color: transparent;
}
.rs.is-demo .rs__loop {
visibility: visible;
}
.rs.is-demo .rs__loop i {
animation-name: rsBandName;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__loop i:nth-child(1) {
animation-delay: 0ms;
}
.rs.is-demo .rs__loop i:nth-child(2) {
animation-delay: 666ms;
}
.rs.is-demo .rs__loop i:nth-child(3) {
animation-delay: 1332ms;
}
@keyframes rsBandName {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.rs {
gap: 0;
}
.rs__dial {
width: 120px;
height: 120px;
}
.rs__bar {
height: 18px;
width: 4px;
}
.rs__hub {
width: 48px;
height: 48px;
margin: -24px 0 0 -24px;
}
.rs__bar {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-38px) scaleY(var(--h, 0.12));
}
.rs.is-demo .rs__bar {
animation: none !important;
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-38px) scaleY(calc(0.3 + var(--i) * 0.01));
}
.rs__foot {
gap: 8px;
}
.rs__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__bar,
.rs.is-demo .rs__loop i {
animation: none !important;
}
.rs.is-demo .rs__bar {
transform: rotate(calc(var(--i) * 1turn / 48)) translateY(-50px) scaleY(calc(0.22 + sin(var(--i) / 48 * 3.14159) * 0.6)) !important;
}
.rs.is-demo .rs__levelText {
color: #fff7e6 !important;
}
.rs.is-demo .rs__loop {
visibility: hidden !important;
}
.rs__bar {
transition: none !important;
}
}var root = document.getElementById('rs');
var barBox = document.getElementById('bars');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var levelText = document.getElementById('levelText');
var BAR_COUNT = 48;
var bars = [];
// 원 둘레를 48 등분해 한 구간씩 맡긴다. --i 는 그 등분 번호이고, 각도는 1turn / 48 로 계산된다.
// 좌우 대칭 없이 순서대로 돌린다 — 원형에서는 거울보다 시계 방향이 자연스럽다.
for (var i = 0; i < BAR_COUNT; i++) {
var bar = document.createElement('span');
bar.className = 'rs__bar';
bar.style.setProperty('--i', String(i));
bar.setAttribute('data-band', String(i % 16));
barBox.appendChild(bar);
bars.push(bar);
}
var ctx = null, analyser = null, freq = null, timer = 0, playing = false;
var BAND_NAMES = ['低音', '中音', '高音'];
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 64;
analyser.smoothingTimeConstant = 0.72;
freq = new Uint8Array(analyser.frequencyBinCount);
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() {
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.08 + v * 0.92).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);
}
playBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!ctx) { buildAudio(); }
playing = !playing;
playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false');
playLabel.textContent = playing ? '停止' : '再生';
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (playing) { ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// RadialSpectrum — vanilla 판(run/291/demo/02-audio-viz-radial-spectrum/index.html) 의 React 포트.
// 주파수 구간 하나가 원 둘레의 막대 하나. 재생을 누르면 합성 음원의 AnalyserNode 값으로 막대가 자란다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RadialSpectrum.module.scss";
export type RadialSpectrumProps = {
/** 격자 미리보기용 2초 자동 루프. 재생을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BarVars = CSSProperties & { "--i"?: string; "--h"?: string };
/** ambient 타입에 useRef<T> 의 null 합집합이 없어 로컬로 좁힌다 */
type Box<T> = { current: T };
const BAR_COUNT = 48;
const BAND_NAMES = ["저음", "중음", "고음"];
export default function RadialSpectrum({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#4cd4a6",
}: RadialSpectrumProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [heights, setHeights] = useState<number[]>([]);
const [band, setBand] = useState(BAND_NAMES[0]);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
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 = 64;
analyser.smoothingTimeConstant = 0.72;
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;
}
const freq = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(freq);
const sums = [0, 0, 0];
const next: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
const b = i % 16;
const v = freq[b + 1] / 255;
sums[Math.min(2, Math.floor(b / 6))] += v;
next.push(0.08 + v * 0.92);
}
setHeights(next);
setBand(BAND_NAMES[sums.indexOf(Math.max(sums[0], sums[1], sums[2]))]);
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(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 96v64H48V96Z" opacity=".2" />
<path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8" />
</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-waveform" />
</svg>
<span>재생을 누르면 실제 소리로 움직입니다</span>
</p>
<div className={styles.dial} aria-hidden="true">
<div className={styles.bars}>
{Array.from({ length: BAR_COUNT }, (_, i) => {
const barVars: BarVars = { "--i": String(i) };
if (heights[i] !== undefined) {
barVars["--h"] = heights[i].toFixed(3);
}
return <span className={styles.bar} key={i} style={barVars} />;
})}
</div>
<div className={styles.hub}>
<svg className={styles.hubIco} aria-hidden="true">
<use href="#ico-waveform" />
</svg>
</div>
</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>
<p className={styles.level}>
<span className={styles.levelLabel}>센 대역</span>
<b className={styles.levelBox}>
<span className={styles.levelText} aria-live="polite">
{band}
</span>
<span className={styles.loop} aria-hidden="true">
<i>저음</i>
<i>중음</i>
<i>고음</i>
</span>
</b>
</p>
</div>
</div>
</main>
);
}02 · 円形スペクトラム — 計測 累積面積 3.751% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
実測では若い番号の棒ほど高く(先頭から 0.462・0.376・0.260・0.203)、内側に山が残る輪郭になるので、アルバムアートの周りに置いても中央が読み取れます。
.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波形オシロスコープ
どんな動きか
周波数ではなく時間波形をそのまま読み、重ねた2本の SVG polyline の points に毎フレーム書き込みます。canvas を使わない線なので色も太さも影も CSS のまま、プレビューでは固定波形の上を 56px のダッシュが一巡します。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor"><path d="M208 96v64H48V96Z" opacity=".2"/><path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8"/></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="ws is-demo" id="ws">
<p class="ws__hint"><svg class="ws__ico" aria-hidden="true"><use href="#ico-waveform"/></svg><span>再生を押すと実際の音で動きます</span></p>
<div class="ws__scope">
<svg class="ws__svg" viewBox="0 0 360 96" preserveAspectRatio="none" aria-hidden="true">
<!-- 바닥 파형: 미리 계산해 둔 정적 시퀀스. 실제 재생을 시작하면 JS 가 points 를 진짜 파형으로 매 프레임 바꾼다. -->
<polyline class="ws__base" id="baseLine" points=""/>
<!-- 흐르는 하이라이트: 같은 파형 위를 짧은 대시가 지나간다 — 미리보기 루프의 몸통. -->
<polyline class="ws__flow" id="flowLine" points=""/>
</svg>
</div>
<div class="ws__foot">
<button class="ws__play" id="play" type="button" aria-pressed="false">
<svg class="ws__ico ws__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="ws__ico ws__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">再生</span>
</button>
<p class="ws__level">
<span class="ws__levelLabel">今の波形</span>
<b class="ws__levelBox">
<span class="ws__levelText" id="levelText" aria-live="polite">静か</span>
<span class="ws__loop" aria-hidden="true"><i>静か</i><i>揺れ</i><i>大きな波</i></span>
</b>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 파형 오실로스코프 — 캔버스 없이 SVG polyline 하나. 색·두께·그림자는 전부 CSS 가 가진다.
// 미리보기(.is-demo)는 정적 파형 위를 stroke-dasharray 대시가 흐르는 루프 — points 는 CSS 로 움직일 수 없어서
// "같은 파형을 따라 흐르는 하이라이트"로 2초 루프를 만든다. 재생을 누르면 JS 가 points 를 진짜 파형으로 바꾼다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-ink);
.ws { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 400px; }
.ws__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-cream;
}
.ws__ico { width: 16px; height: 16px; color: $color; }
.ws__scope {
box-sizing: border-box; width: 100%; padding: $sp-2 $sp-2 0;
border-radius: $r-card; background: #211d26;
border: 1px solid rgba($subject-cream, .12);
}
.ws__svg { display: block; width: 100%; height: 104px; }
.ws__base {
fill: none; stroke: rgba($subject-cream, .28); stroke-width: 2;
stroke-linejoin: round; stroke-linecap: round;
}
.ws__flow {
fill: none; stroke: $color; stroke-width: 3;
stroke-linejoin: round; stroke-linecap: round;
filter: drop-shadow(0 0 6px rgba(76, 212, 166, .45));
}
.ws__foot { display: flex; align-items: center; justify-content: center; gap: $sp-4; width: 100%; }
.ws__play {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $stage-yellow; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.ws__ico--pause { display: none; }
.ws__play[aria-pressed="true"] .ws__ico--play { display: none; }
.ws__play[aria-pressed="true"] .ws__ico--pause { display: inline-block; }
.ws__play .ws__ico { color: $subject-ink; }
.ws__level { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.ws__levelLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-cream, .82); }
.ws__levelBox {
position: relative; display: grid; place-items: center;
min-width: 48px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-cream, .16); color: $subject-cream;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.ws__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.ws__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 56px 대시가 파형 위를 왼쪽에서 오른쪽으로 흐른다. dashoffset 의 총 이동량 = 대시 주기(56+124=180)라
// 루프가 정확히 닫힌다. 하이라이트가 한 바퀴 도는 동안 상태 글자도 세 단계로 바뀐다(steps 컷).
.ws.is-demo .ws__flow {
stroke-dasharray: 56 124;
animation-name: wsFlow; animation-duration: $dur-loop; animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes wsFlow {
from { stroke-dashoffset: 180; }
to { stroke-dashoffset: 0; }
}
.ws.is-demo .ws__levelText { color: transparent; }
.ws.is-demo .ws__loop { visibility: visible; }
.ws.is-demo .ws__loop i {
animation-name: wsStateName; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ws.is-demo .ws__loop i:nth-child(1) { animation-delay: 0ms; }
.ws.is-demo .ws__loop i:nth-child(2) { animation-delay: 666ms; }
.ws.is-demo .ws__loop i:nth-child(3) { animation-delay: 1332ms; }
@keyframes wsStateName {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.ws { gap: $sp-2; }
.ws__scope { padding: $sp-1 $sp-1 0; }
.ws__svg { height: 76px; }
.ws__foot { gap: $sp-2; }
.ws__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 흐르는 대시를 멈춘다 — 정적 파형이 그대로 남아 화면이 비지 않는다
@media (prefers-reduced-motion: reduce) {
.ws.is-demo .ws__flow {
animation: none !important;
stroke-dasharray: none;
}
.ws.is-demo .ws__loop i { animation: none !important; }
.ws.is-demo .ws__levelText { color: $subject-cream !important; }
.ws.is-demo .ws__loop { visibility: hidden !important; }
.ws__flow { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ws {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 400px;
}
.ws__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #fff7e6;
}
.ws__ico {
width: 16px;
height: 16px;
color: #4cd4a6;
}
.ws__scope {
box-sizing: border-box;
width: 100%;
padding: 8px 8px 0;
border-radius: 16px;
background: #211d26;
border: 1px solid rgba(255, 247, 230, 0.12);
}
.ws__svg {
display: block;
width: 100%;
height: 104px;
}
.ws__base {
fill: none;
stroke: rgba(255, 247, 230, 0.28);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.ws__flow {
fill: none;
stroke: #4cd4a6;
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
filter: drop-shadow(0 0 6px rgba(76, 212, 166, 0.45));
}
.ws__foot {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
}
.ws__play {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.ws__ico--pause {
display: none;
}
.ws__play[aria-pressed=true] .ws__ico--play {
display: none;
}
.ws__play[aria-pressed=true] .ws__ico--pause {
display: inline-block;
}
.ws__play .ws__ico {
color: #17141a;
}
.ws__level {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.ws__levelLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.ws__levelBox {
position: relative;
display: grid;
place-items: center;
min-width: 48px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.ws__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.ws__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.ws.is-demo .ws__flow {
stroke-dasharray: 56 124;
animation-name: wsFlow;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes wsFlow {
from {
stroke-dashoffset: 180;
}
to {
stroke-dashoffset: 0;
}
}
.ws.is-demo .ws__levelText {
color: transparent;
}
.ws.is-demo .ws__loop {
visibility: visible;
}
.ws.is-demo .ws__loop i {
animation-name: wsStateName;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ws.is-demo .ws__loop i:nth-child(1) {
animation-delay: 0ms;
}
.ws.is-demo .ws__loop i:nth-child(2) {
animation-delay: 666ms;
}
.ws.is-demo .ws__loop i:nth-child(3) {
animation-delay: 1332ms;
}
@keyframes wsStateName {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.ws {
gap: 8px;
}
.ws__scope {
padding: 4px 4px 0;
}
.ws__svg {
height: 76px;
}
.ws__foot {
gap: 8px;
}
.ws__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ws.is-demo .ws__flow {
animation: none !important;
stroke-dasharray: none;
}
.ws.is-demo .ws__loop i {
animation: none !important;
}
.ws.is-demo .ws__levelText {
color: #fff7e6 !important;
}
.ws.is-demo .ws__loop {
visibility: hidden !important;
}
.ws__flow {
transition: none !important;
}
}var root = document.getElementById('ws');
var baseLine = document.getElementById('baseLine');
var flowLine = document.getElementById('flowLine');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var levelText = document.getElementById('levelText');
var W = 360, H = 96, MID = H / 2, N = 60;
// 바닥 파형 — 사인 두 개를 더한 정적 시퀀스. 난수가 아니라 식이라 같은 그림이 항상 재현된다.
var pts = [];
for (var i = 0; i < N; i++) {
var x = (i / (N - 1)) * W;
var y = MID - Math.sin(i / N * Math.PI * 6) * 18 - Math.sin(i / N * Math.PI * 2) * 8;
pts.push(x.toFixed(1) + ',' + y.toFixed(1));
}
baseLine.setAttribute('points', pts.join(' '));
flowLine.setAttribute('points', pts.join(' '));
var ctx = null, analyser = null, wave = null, timer = 0, playing = false;
var STATE_NAMES = ['静か', '揺れ', '大きな波'];
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0;
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() {
// 시간 도메인 — 주파수가 아니라 "지금 이 순간의 진폭" 을 그대로 읽는다.
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);
}
playBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!ctx) { buildAudio(); }
playing = !playing;
playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false');
playLabel.textContent = playing ? '停止' : '再生';
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (playing) { ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// WaveformScope — vanilla 판(run/291/demo/03-audio-viz-waveform-scope/index.html) 의 React 포트.
// 시간 도메인 파형을 SVG polyline 의 points 에 그대로 쓴다. 캔버스 없이 선 하나.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WaveformScope.module.scss";
export type WaveformScopeProps = {
/** 격자 미리보기용 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 W = 360;
const H = 96;
const MID = H / 2;
const N = 60;
const STATE_NAMES = ["고요", "출렁", "큰물결"];
/** 바닥 파형 — 사인 두 개를 더한 정적 시퀀스. 난수가 아니라 식이라 같은 그림이 항상 재현된다. */
const BASE_PTS: string[] = [];
for (let i = 0; i < N; i++) {
const x = (i / (N - 1)) * W;
const y = MID - Math.sin((i / N) * Math.PI * 6) * 18 - Math.sin((i / N) * Math.PI * 2) * 8;
BASE_PTS.push(x.toFixed(1) + "," + y.toFixed(1));
}
const BASE_LINE = BASE_PTS.join(" ");
export default function WaveformScope({
demo = true,
duration = 220,
easing = "ease-out",
color = "#4cd4a6",
}: WaveformScopeProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [line, setLine] = useState(BASE_LINE);
const [state, setState] = useState(STATE_NAMES[0]);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
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;
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;
}
// 시간 도메인 — 주파수가 아니라 "지금 이 순간의 진폭" 을 그대로 읽는다.
const wave = new Uint8Array(analyser.fftSize);
analyser.getByteTimeDomainData(wave);
const step = Math.floor(wave.length / N);
const next: string[] = [];
let peak = 0;
for (let i = 0; i < N; i++) {
const v = (wave[i * step] - 128) / 128;
peak = Math.max(peak, Math.abs(v));
next.push(((i / (N - 1)) * W).toFixed(1) + "," + (MID - v * (H / 2 - 6)).toFixed(1));
}
setLine(next.join(" "));
setState(STATE_NAMES[peak < 0.18 ? 0 : peak < 0.5 ? 1 : 2]);
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(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 96v64H48V96Z" opacity=".2" />
<path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8" />
</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-waveform" />
</svg>
<span>재생을 누르면 실제 소리로 움직입니다</span>
</p>
<div className={styles.scope}>
<svg className={styles.svg} viewBox="0 0 360 96" preserveAspectRatio="none" aria-hidden="true">
<polyline className={styles.base} points={line} />
<polyline className={styles.flow} points={line} />
</svg>
</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>
<p className={styles.level}>
<span className={styles.levelLabel}>지금 파형</span>
<b className={styles.levelBox}>
<span className={styles.levelText} aria-live="polite">
{state}
</span>
<span className={styles.loop} aria-hidden="true">
<i>고요</i>
<i>출렁</i>
<i>큰물결</i>
</span>
</b>
</p>
</div>
</div>
</main>
);
}03 · 波形オシロスコープ — 計測 累積面積 2.893% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
動きが細い線に限られるのでフレーム当たりの変化の最大は 0.273% と9点で最も小さく、録音画面のリアルタイム波形や音声メモの入力表示に向きます。
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);
04ビート反応パーティクル
何が見えるか
低音のエネルギーが自分の移動平均の1.3倍を超えた瞬間をビートとみなし、そのときだけ粒の層が一つ外へ弾けます。閾値を固定値でなく平均比で取るのは曲が静かになってもビートを取りこぼさないためで、220ms の最短間隔が一回のキックを二回数えるのを防ぎます。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor"><path d="M208 96v64H48V96Z" opacity=".2"/><path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8"/></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="bp is-demo" id="bp">
<p class="bp__hint"><svg class="bp__ico" aria-hidden="true"><use href="#ico-waveform"/></svg><span>再生を押すと実際の音で動きます</span></p>
<div class="bp__field" id="field" aria-hidden="true"><div class="bp__core" id="core"><svg class="bp__coreIco" aria-hidden="true"><use href="#ico-waveform"/></svg></div></div>
<div class="bp__foot">
<button class="bp__play" id="play" type="button" aria-pressed="false">
<svg class="bp__ico bp__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="bp__ico bp__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">再生</span>
</button>
<p class="bp__count">
<span class="bp__countLabel">拾ったビート</span>
<b class="bp__countBox">
<span class="bp__countText" id="beatText" aria-live="polite">0</span>
<span class="bp__loop" aria-hidden="true"><i>1</i><i>3</i><i>5</i><i>7</i></span>
</b>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 비트 반응 입자 — 저음 에너지가 자기 이동 평균을 넘는 순간(=비트)에만 겹 하나가 터진다.
// 코어는 늘 그 자리에 있는다 — 입자의 퇴장이 주인공이 되면 대표 이미지가 빈 화면이 된다(255 실측).
// 미리보기(.is-demo)는 2초에 네 번 비트가 도는 CSS 루프. 재생을 누르면 진짜 판정이 겹에 불을 붙인다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-yellow;
@include stage($stage-ink);
.bp { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.bp__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-cream;
}
.bp__ico { width: 16px; height: 16px; color: $color; }
// --rest 는 점이 쉬는 반경, --fly 는 비트에 터지는 반경. 좁은 화면에서는 이 값만 줄인다(루프는 그대로).
.bp__field { position: relative; width: 172px; height: 132px; --rest: 52px; --fly: 84px; }
.bp__ring { position: absolute; inset: 0; }
.bp__dot {
position: absolute; left: 50%; top: 50%; width: 8px; height: 8px; margin: -4px 0 0 -4px;
border-radius: 50%; background: rgba($subject-cream, .38);
transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(1);
}
// 코어 — 입자가 흩어져도 화면의 주인공으로 남는다. 커질 땐 자기 중심에서(규칙 8).
.bp__core {
position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; margin: -26px 0 0 -26px;
display: grid; place-items: center; border-radius: 50%;
background: #211d26; border: 4px solid $color;
}
.bp__coreIco { width: 20px; height: 20px; color: $color; }
// 실제 비트가 오면 JS 가 클래스를 붙인다 — 일회성 transform 이지 루프가 아니다
.bp__ring--hit .bp__dot { animation: bpBurst $easing 480ms 1 both; }
.bp__core--hit { animation: bpThump $easing 480ms 1 both; }
@keyframes bpBurst {
0% { transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(1); opacity: 1; }
100% { transform: rotate(var(--a)) translateY(calc(-1 * var(--fly))) scale(.4); opacity: 0; }
}
@keyframes bpThump {
0% { transform: scale(1); }
35% { transform: scale(1.18); }
100% { transform: scale(1); }
}
.bp__foot { display: flex; align-items: center; justify-content: center; gap: $sp-4; width: 100%; }
.bp__play {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $color; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.bp__ico--pause { display: none; }
.bp__play[aria-pressed="true"] .bp__ico--play { display: none; }
.bp__play[aria-pressed="true"] .bp__ico--pause { display: inline-block; }
.bp__play .bp__ico { color: $subject-ink; }
.bp__count { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.bp__countLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-cream, .82); }
.bp__countBox {
position: relative; display: grid; place-items: center;
min-width: 28px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-cream, .16); color: $subject-cream;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.bp__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.bp__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 2초에 비트 네 번(2Hz) — 4번은 점멸이 아니라 파동이다(점들은 부드러운 감쇠 곡선으로 사라진다).
// 겹 넷은 서로 500ms 간격. animation 단축을 안 쓰고 낱개 속성만(240 실측).
.bp.is-demo .bp__dot {
animation-name: bpLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * -500ms);
}
// 미리보기에서 쉬는 상태의 점은 옅은 헤일로 — 폭 0 이 아니라 반투명 링으로 대기한다
@keyframes bpLoop {
0% { transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(.92); opacity: .5; }
2% { transform: rotate(var(--a)) translateY(calc(-1 * var(--rest) - 2px)) scale(1.5); opacity: 1; }
30% { transform: rotate(var(--a)) translateY(calc(-1 * var(--fly))) scale(.4); opacity: 0; }
46% { transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(.92); opacity: .5; }
100% { transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(.92); opacity: .5; }
}
.bp.is-demo .bp__core {
animation-name: bpCoreLoop; animation-duration: 500ms; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes bpCoreLoop {
0% { transform: scale(1); }
22% { transform: scale(1.16); }
100% { transform: scale(1); }
}
// 잡은 비트 수도 1 → 3 → 5 → 7 로 컷. 지연 구간에 fill-mode 를 두지 않는다(작성 상태가 이미 숨김).
.bp.is-demo .bp__countText { color: transparent; }
.bp.is-demo .bp__loop { visibility: visible; }
.bp.is-demo .bp__loop i {
animation-name: bpBeatNum; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bp.is-demo .bp__loop i:nth-child(1) { animation-delay: 0ms; }
.bp.is-demo .bp__loop i:nth-child(2) { animation-delay: 500ms; }
.bp.is-demo .bp__loop i:nth-child(3) { animation-delay: 1000ms; }
.bp.is-demo .bp__loop i:nth-child(4) { animation-delay: 1500ms; }
@keyframes bpBeatNum {
0%, 24% { visibility: visible; }
25%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.bp { gap: 0; }
.bp__field { width: 140px; height: 104px; --rest: 40px; --fly: 58px; }
.bp__dot { width: 6px; height: 6px; margin: -3px 0 0 -3px; }
.bp__core { width: 44px; height: 44px; margin: -22px 0 0 -22px; }
.bp__foot { gap: $sp-2; }
.bp__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 루프와 실제 반응을 다 끈다 — 코어와 헤일로만 남아 화면이 비지 않는다
@media (prefers-reduced-motion: reduce) {
.bp.is-demo .bp__dot,
.bp.is-demo .bp__core,
.bp.is-demo .bp__loop i { animation: none !important; }
.bp.is-demo .bp__countText { color: $subject-cream !important; }
.bp.is-demo .bp__loop { visibility: hidden !important; }
.bp__ring--hit .bp__dot,
.bp__core--hit { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.bp__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #fff7e6;
}
.bp__ico {
width: 16px;
height: 16px;
color: #ffd23f;
}
.bp__field {
position: relative;
width: 172px;
height: 132px;
--rest: 52px;
--fly: 84px;
}
.bp__ring {
position: absolute;
inset: 0;
}
.bp__dot {
position: absolute;
left: 50%;
top: 50%;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.38);
transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(1);
}
.bp__core {
position: absolute;
left: 50%;
top: 50%;
width: 52px;
height: 52px;
margin: -26px 0 0 -26px;
display: grid;
place-items: center;
border-radius: 50%;
background: #211d26;
border: 4px solid #ffd23f;
}
.bp__coreIco {
width: 20px;
height: 20px;
color: #ffd23f;
}
.bp__ring--hit .bp__dot {
animation: bpBurst cubic-bezier(0.2, 1.4, 0.4, 1) 480ms 1 both;
}
.bp__core--hit {
animation: bpThump cubic-bezier(0.2, 1.4, 0.4, 1) 480ms 1 both;
}
@keyframes bpBurst {
0% {
transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(1);
opacity: 1;
}
100% {
transform: rotate(var(--a)) translateY(calc(-1 * var(--fly))) scale(0.4);
opacity: 0;
}
}
@keyframes bpThump {
0% {
transform: scale(1);
}
35% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
.bp__foot {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
}
.bp__play {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.bp__ico--pause {
display: none;
}
.bp__play[aria-pressed=true] .bp__ico--play {
display: none;
}
.bp__play[aria-pressed=true] .bp__ico--pause {
display: inline-block;
}
.bp__play .bp__ico {
color: #17141a;
}
.bp__count {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.bp__countLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.bp__countBox {
position: relative;
display: grid;
place-items: center;
min-width: 28px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.bp__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.bp__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.bp.is-demo .bp__dot {
animation-name: bpLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * -500ms);
}
@keyframes bpLoop {
0% {
transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(0.92);
opacity: 0.5;
}
2% {
transform: rotate(var(--a)) translateY(calc(-1 * var(--rest) - 2px)) scale(1.5);
opacity: 1;
}
30% {
transform: rotate(var(--a)) translateY(calc(-1 * var(--fly))) scale(0.4);
opacity: 0;
}
46% {
transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(0.92);
opacity: 0.5;
}
100% {
transform: rotate(var(--a)) translateY(calc(-1 * var(--rest))) scale(0.92);
opacity: 0.5;
}
}
.bp.is-demo .bp__core {
animation-name: bpCoreLoop;
animation-duration: 500ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes bpCoreLoop {
0% {
transform: scale(1);
}
22% {
transform: scale(1.16);
}
100% {
transform: scale(1);
}
}
.bp.is-demo .bp__countText {
color: transparent;
}
.bp.is-demo .bp__loop {
visibility: visible;
}
.bp.is-demo .bp__loop i {
animation-name: bpBeatNum;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bp.is-demo .bp__loop i:nth-child(1) {
animation-delay: 0ms;
}
.bp.is-demo .bp__loop i:nth-child(2) {
animation-delay: 500ms;
}
.bp.is-demo .bp__loop i:nth-child(3) {
animation-delay: 1000ms;
}
.bp.is-demo .bp__loop i:nth-child(4) {
animation-delay: 1500ms;
}
@keyframes bpBeatNum {
0%, 24% {
visibility: visible;
}
25%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.bp {
gap: 0;
}
.bp__field {
width: 140px;
height: 104px;
--rest: 40px;
--fly: 58px;
}
.bp__dot {
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
}
.bp__core {
width: 44px;
height: 44px;
margin: -22px 0 0 -22px;
}
.bp__foot {
gap: 8px;
}
.bp__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp.is-demo .bp__dot,
.bp.is-demo .bp__core,
.bp.is-demo .bp__loop i {
animation: none !important;
}
.bp.is-demo .bp__countText {
color: #fff7e6 !important;
}
.bp.is-demo .bp__loop {
visibility: hidden !important;
}
.bp__ring--hit .bp__dot,
.bp__core--hit {
animation: none !important;
}
}var root = document.getElementById('bp');
var field = document.getElementById('field');
var core = document.getElementById('core');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var beatText = document.getElementById('beatText');
var RING_COUNT = 4, DOT_COUNT = 8;
var rings = [];
// 겹 넷 × 점 여덟. 겹마다 시작 각을 11.25도 민다 — 쉬는 상태에서는 점 서른두 개가 동그란 헤일로가 되고,
// 터질 때는 겹마다 다른 방향으로 흩어진다. --i 는 겹 번호이자 미리보기 루프의 음수 지연 배수다.
for (var r = 0; r < RING_COUNT; r++) {
var ring = document.createElement('div');
ring.className = 'bp__ring';
for (var d = 0; d < DOT_COUNT; d++) {
var dot = document.createElement('i');
dot.className = 'bp__dot';
dot.style.setProperty('--a', (((d * 45 + r * 11.25) % 360)).toFixed(2) + 'deg');
dot.style.setProperty('--i', String(r));
ring.appendChild(dot);
}
field.insertBefore(ring, core);
rings.push(ring);
}
var ctx = null, analyser = null, freq = null, timer = 0, playing = false;
var bassAvg = 0, lastBeatAt = 0, beats = 0, ringTurn = 0;
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 128;
analyser.smoothingTimeConstant = 0.5;
freq = new Uint8Array(analyser.frequencyBinCount);
var out = ctx.createGain();
out.gain.value = 0.08;
out.connect(analyser);
analyser.connect(ctx.destination);
// 킥 — 낮은 사인파를 0.5초마다 한 번씩 쳐서 2초에 네 번 비트(120BPM). 低音 대역을 명확히 치고 빠진다.
var kick = ctx.createOscillator();
var kickAmp = ctx.createGain();
kick.type = 'sine';
kick.frequency.value = 70;
kickAmp.gain.value = 0;
for (var b = 0; b < 128; b++) {
var at = ctx.currentTime + b * 0.5;
kickAmp.gain.setValueAtTime(1, at);
kickAmp.gain.exponentialRampToValueAtTime(0.004, at + 0.3);
}
kick.connect(kickAmp); kickAmp.connect(out); kick.start();
// 패드 — "평균보다 큰 순간"이 성립하려면 바닥 에너지가 필요하다. 킥이 없는 사이 저음의 평균을 만든다.
[110, 164.81, 220].forEach(function (hz) {
var pad = ctx.createOscillator();
var padAmp = ctx.createGain();
pad.type = 'sawtooth';
pad.frequency.value = hz;
padAmp.gain.value = 0.035;
pad.connect(padAmp); padAmp.connect(out); pad.start();
});
}
function fire(el, hitClass) {
el.classList.remove(hitClass);
void el.offsetWidth; // 같은 클래스를 다시 걸어 일회성 애니메이션을 처음부터 돌린다
el.classList.add(hitClass);
}
function draw() {
analyser.getByteFrequencyData(freq);
var bass = 0;
for (var k = 0; k < 4; k++) { bass += freq[k]; }
bass = bass / (4 * 255);
var now = performance.now();
// 비트 판정: 低音 에너지가 직전 구간의 이동 평균 × 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;
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 ? '停止' : '再生';
// 자동재생 정책: AudioContext 는 사용자 제스처 안에서 resume() 해야 소리가 난다.
if (playing) { ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// BeatParticles — vanilla 판(run/291/demo/04-audio-viz-beat-particles/index.html) 의 React 포트.
// 저음 에너지가 자기 이동 평균을 넘는 순간(비트)에 겹 하나가 터진다. 코어는 늘 그 자리에 남는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BeatParticles.module.scss";
export type BeatParticlesProps = {
/** 격자 미리보기용 2초 자동 루프. 재생을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DotVars = CSSProperties & { "--a"?: string; "--i"?: string };
/** ambient 타입에 useRef<T> 의 null 합집합이 없어 로컬로 좁힌다 */
type Box<T> = { current: T };
const RING_COUNT = 4;
const DOT_COUNT = 8;
export default function BeatParticles({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#ffd23f",
}: BeatParticlesProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [beats, setBeats] = useState(0);
const [hitRing, setHitRing] = useState(-1);
const [coreHit, setCoreHit] = useState(false);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
const avgBox = useRef(0) as Box<number>;
const lastBeatBox = useRef(0) as Box<number>;
const turnBox = useRef(0) as Box<number>;
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 = 128;
analyser.smoothingTimeConstant = 0.5;
const out = ctx.createGain();
out.gain.value = 0.08;
out.connect(analyser);
analyser.connect(ctx.destination);
// 킥 — 낮은 사인파를 0.5초마다 한 번씩 쳐서 2초에 네 번 비트(120BPM).
const kick = ctx.createOscillator();
const kickAmp = ctx.createGain();
kick.type = "sine";
kick.frequency.value = 70;
kickAmp.gain.value = 0;
for (let b = 0; b < 128; b++) {
const at = ctx.currentTime + b * 0.5;
kickAmp.gain.setValueAtTime(1, at);
kickAmp.gain.exponentialRampToValueAtTime(0.004, at + 0.3);
}
kick.connect(kickAmp);
kickAmp.connect(out);
kick.start();
// 패드 — "평균보다 큰 순간"이 성립하려면 바닥 에너지가 필요하다.
[110, 164.81, 220].forEach((hz) => {
const pad = ctx.createOscillator();
const padAmp = ctx.createGain();
pad.type = "sawtooth";
pad.frequency.value = hz;
padAmp.gain.value = 0.035;
pad.connect(padAmp);
padAmp.connect(out);
pad.start();
});
ctxBox.current = ctx;
nodeBox.current = analyser;
}
function draw() {
const analyser = nodeBox.current;
if (!analyser) {
return;
}
const freq = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(freq);
let bass = 0;
for (let k = 0; k < 4; k++) {
bass += freq[k];
}
bass = bass / (4 * 255);
const now = performance.now();
// 비트 판정: 저음 에너지가 직전 구간의 이동 평균 × 1.3 을 넘는 순간이다(고정 문턱은 셈여림에 약하다).
if (bass > avgBox.current * 1.3 && bass > 0.14 && now - lastBeatBox.current > 220) {
lastBeatBox.current = now;
turnBox.current += 1;
setBeats(turnBox.current);
setHitRing(turnBox.current % RING_COUNT);
setCoreHit(true);
window.setTimeout(() => setCoreHit(false), 460);
}
avgBox.current += (bass - avgBox.current) * 0.12;
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 rings = Array.from({ length: RING_COUNT }, (_, r) => (
<div className={[styles.ring, hitRing === r ? styles.ringHit : ""].join(" ")} key={r}>
{Array.from({ length: DOT_COUNT }, (_, d) => {
const dotVars: DotVars = {
"--a": (((d * 45 + r * 11.25) % 360)).toFixed(2) + "deg",
"--i": String(r),
};
return <i className={styles.dot} key={d} style={dotVars} />;
})}
</div>
));
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 96v64H48V96Z" opacity=".2" />
<path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8" />
</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-waveform" />
</svg>
<span>재생을 누르면 실제 소리로 움직입니다</span>
</p>
<div className={styles.field} aria-hidden="true">
{rings}
<div className={[styles.core, coreHit ? styles.coreHit : ""].join(" ")}>
<svg className={styles.coreIco} aria-hidden="true">
<use href="#ico-waveform" />
</svg>
</div>
</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>
<p className={styles.count}>
<span className={styles.countLabel}>잡은 비트</span>
<b className={styles.countBox}>
<span className={styles.countText} aria-live="polite">
{beats}
</span>
<span className={styles.loop} aria-hidden="true">
<i>1</i>
<i>3</i>
<i>5</i>
<i>7</i>
</span>
</b>
</p>
</div>
</div>
</main>
);
}04 · ビート反応パーティクル — 計測 累積面積 4.8% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
4秒の実再生で5回のビートを拾い4つの層全部が順に弾けました — コアは常にその場に残るので、粒が散っても画面の主役は欠けません。
// ビート判定: 低音のエネルギーが直前区間の移動平均 × 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低音で跳ねる文字
画面で起きること
タイトルの文字 G・O・D・R・I・C・H を低・中・高の3帯域に振り分け、帯域ごとに別の文字が translateY と scale で跳ねます。実測では低音 0.357・中音 0.271・高音 0.216 と帯域の差がそのまま持ち上がりの差になり、文字は自分の位置で大きくなるので原点は中央のままです。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor"><path d="M208 96v64H48V96Z" opacity=".2"/><path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8"/></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="tb is-demo" id="tb">
<p class="tb__hint"><svg class="tb__ico" aria-hidden="true"><use href="#ico-waveform"/></svg><span>再生を押すと実際の音で動きます</span></p>
<h2 class="tb__title" aria-label="GODRICH">
<!-- 글자마다 data-band 로 대역을 나눈다: G·R 는 低音, O·I·H 는 中音, D·C 는 高音. 브랜드 낱말이라 로케일을 안 탄다. -->
<b class="tb__l" data-band="0">G</b><b class="tb__l" data-band="1">O</b><b class="tb__l" data-band="2">D</b><b class="tb__l" data-band="0">R</b><b class="tb__l" data-band="1">I</b><b class="tb__l" data-band="2">C</b><b class="tb__l" data-band="1">H</b>
</h2>
<ul class="tb__legend" aria-hidden="true">
<li class="tb__band tb__band--low">低音</li>
<li class="tb__band tb__band--mid">中音</li>
<li class="tb__band tb__band--high">高音</li>
</ul>
<div class="tb__foot">
<button class="tb__play" id="play" type="button" aria-pressed="false">
<svg class="tb__ico tb__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="tb__ico tb__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">再生</span>
</button>
<p class="tb__level">
<span class="tb__levelLabel">強い帯域</span>
<b class="tb__levelBox">
<span class="tb__levelText" id="levelText" aria-live="polite">低音</span>
<span class="tb__loop" aria-hidden="true"><i>低音</i><i>中音</i><i>高音</i></span>
</b>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 저음에 튀는 글자 — 제목의 글자를 저·중·고 대역에 나눠 건다. 대역마다 다른 글자가 튄다.
// 글자는 자기 자리에서 커져야 하므로(규칙 8) transform-origin 은 중심 그대로 — translateY 와 scale 만 쓴다.
// --j 는 실제 소리에서 온 대역 에너지(0~1). 재생을 누르면 JS 가 매 프레임 쓴다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.tb { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; }
.tb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-ink;
}
.tb__ico { width: 16px; height: 16px; color: $subject-ink; }
.tb__title { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-weight: 900; }
.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;
}
// 대역 범례 — 어떤 글자가 어느 대역에 걸렸는지 색으로 보여준다(소리 없는 독자용 보조)
.tb__legend { margin: 0; padding: 0; list-style: none; display: flex; gap: $sp-1; }
.tb__band {
border-radius: $r-pill; padding: 0 $sp-2; height: 18px; display: grid; place-items: center;
font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1; color: $subject-ink;
border: 2px solid rgba($subject-ink, .5);
}
.tb__band--low::before, .tb__band--mid::before, .tb__band--high::before {
content: ""; width: 8px; height: 8px; border-radius: 50%; margin-right: $sp-1;
}
.tb__band--low::before { background: #905a47; }
.tb__band--mid::before { background: $subject-ink; }
.tb__band--high::before { background: rgba($subject-ink, .38); }
.tb__foot { display: flex; align-items: center; justify-content: center; gap: $sp-4; width: 100%; }
.tb__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;
}
.tb__ico--pause { display: none; }
.tb__play[aria-pressed="true"] .tb__ico--play { display: none; }
.tb__play[aria-pressed="true"] .tb__ico--pause { display: inline-block; }
.tb__play .tb__ico { color: $subject-cream; }
.tb__level { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.tb__levelLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: $subject-ink; }
// 주황 무대 위 작은 글자 — 반투명 크림판을 깔아 밝힌 뒤 잉크 글자로 (잉크/주황 직접 조합은 4.5:1 에 아슬)
.tb__levelBox {
position: relative; display: grid; place-items: center;
min-width: 40px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-cream, .3); color: $subject-ink;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.tb__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.tb__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 여섯 글자가 160ms 간격으로 차례로 튄다(양수 지연 + backwards — 지연 구간엔 0% 정지 자세가 유지된다).
// 글자는 크지만 색상은 그대로고 위치 이동만 있어 밝기 반전은 없다 — 광과민성 한계와 무관하다.
.tb.is-demo .tb__l {
animation-name: tbJump; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--n) * 140ms);
transition: none;
}
// 순서값 --n — 일곱 글자가 왼쪽부터 140ms 간격으로 도는 파도가 된다(7 × 140ms = 980ms < 2s)
.tb.is-demo .tb__l:nth-child(1) { --n: 0; }
.tb.is-demo .tb__l:nth-child(2) { --n: 1; }
.tb.is-demo .tb__l:nth-child(3) { --n: 2; }
.tb.is-demo .tb__l:nth-child(4) { --n: 3; }
.tb.is-demo .tb__l:nth-child(5) { --n: 4; }
.tb.is-demo .tb__l:nth-child(6) { --n: 5; }
.tb.is-demo .tb__l:nth-child(7) { --n: 6; }
@keyframes tbJump {
0%, 8% { transform: translateY(0) scale(1); }
16%, 30% { transform: translateY(-18px) scale(1.16); }
44%, 100% { transform: translateY(0) scale(1); }
}
.tb.is-demo .tb__levelText { color: transparent; }
.tb.is-demo .tb__loop { visibility: visible; }
.tb.is-demo .tb__loop i {
animation-name: tbBandName; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tb.is-demo .tb__loop i:nth-child(1) { animation-delay: 0ms; }
.tb.is-demo .tb__loop i:nth-child(2) { animation-delay: 666ms; }
.tb.is-demo .tb__loop i:nth-child(3) { animation-delay: 1332ms; }
@keyframes tbBandName {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.tb { gap: $sp-1; }
.tb__l { font-size: 34px; }
.tb__foot { gap: $sp-2; }
.tb__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 루프를 끈다 — 글자는 제자리에 그대로 보이고, 범례가 대역 구분을 대신 설명한다
@media (prefers-reduced-motion: reduce) {
.tb.is-demo .tb__l,
.tb.is-demo .tb__loop i { animation: none !important; }
.tb.is-demo .tb__levelText { color: $subject-ink !important; }
.tb.is-demo .tb__loop { visibility: hidden !important; }
.tb__l { 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);
}
.tb {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.tb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.tb__ico {
width: 16px;
height: 16px;
color: #17141a;
}
.tb__title {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-weight: 900;
}
.tb__l {
display: inline-block;
font-size: clamp(44px, 12vw, 60px);
line-height: 1;
color: #17141a;
transform-origin: 50% 50%;
transform: translateY(calc(-18px * var(--j, 0))) scale(calc(1 + var(--j, 0) * 0.16));
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tb__legend {
margin: 0;
padding: 0;
list-style: none;
display: flex;
gap: 4px;
}
.tb__band {
border-radius: 999px;
padding: 0 8px;
height: 18px;
display: grid;
place-items: center;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
color: #17141a;
border: 2px solid rgba(23, 20, 26, 0.5);
}
.tb__band--low::before, .tb__band--mid::before, .tb__band--high::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.tb__band--low::before {
background: #905a47;
}
.tb__band--mid::before {
background: #17141a;
}
.tb__band--high::before {
background: rgba(23, 20, 26, 0.38);
}
.tb__foot {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
}
.tb__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;
}
.tb__ico--pause {
display: none;
}
.tb__play[aria-pressed=true] .tb__ico--play {
display: none;
}
.tb__play[aria-pressed=true] .tb__ico--pause {
display: inline-block;
}
.tb__play .tb__ico {
color: #fff7e6;
}
.tb__level {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.tb__levelLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: #17141a;
}
.tb__levelBox {
position: relative;
display: grid;
place-items: center;
min-width: 40px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 247, 230, 0.3);
color: #17141a;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.tb__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.tb__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.tb.is-demo .tb__l {
animation-name: tbJump;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--n) * 140ms);
transition: none;
}
.tb.is-demo .tb__l:nth-child(1) {
--n: 0;
}
.tb.is-demo .tb__l:nth-child(2) {
--n: 1;
}
.tb.is-demo .tb__l:nth-child(3) {
--n: 2;
}
.tb.is-demo .tb__l:nth-child(4) {
--n: 3;
}
.tb.is-demo .tb__l:nth-child(5) {
--n: 4;
}
.tb.is-demo .tb__l:nth-child(6) {
--n: 5;
}
.tb.is-demo .tb__l:nth-child(7) {
--n: 6;
}
@keyframes tbJump {
0%, 8% {
transform: translateY(0) scale(1);
}
16%, 30% {
transform: translateY(-18px) scale(1.16);
}
44%, 100% {
transform: translateY(0) scale(1);
}
}
.tb.is-demo .tb__levelText {
color: transparent;
}
.tb.is-demo .tb__loop {
visibility: visible;
}
.tb.is-demo .tb__loop i {
animation-name: tbBandName;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tb.is-demo .tb__loop i:nth-child(1) {
animation-delay: 0ms;
}
.tb.is-demo .tb__loop i:nth-child(2) {
animation-delay: 666ms;
}
.tb.is-demo .tb__loop i:nth-child(3) {
animation-delay: 1332ms;
}
@keyframes tbBandName {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.tb {
gap: 4px;
}
.tb__l {
font-size: 34px;
}
.tb__foot {
gap: 8px;
}
.tb__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.tb.is-demo .tb__l,
.tb.is-demo .tb__loop i {
animation: none !important;
}
.tb.is-demo .tb__levelText {
color: #17141a !important;
}
.tb.is-demo .tb__loop {
visibility: hidden !important;
}
.tb__l {
transition: none !important;
}
}var root = document.getElementById('tb');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var levelText = document.getElementById('levelText');
var letters = Array.prototype.slice.call(document.querySelectorAll('.tb__l'));
var ctx = null, analyser = null, freq = null, timer = 0, playing = false;
var BAND_NAMES = ['低音', '中音', '高音'];
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 64;
analyser.smoothingTimeConstant = 0.6;
freq = new Uint8Array(analyser.frequencyBinCount);
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() {
analyser.getByteFrequencyData(freq);
// 대역 세 개의 에너지를 0~1 로 만들어 글자의 --j 로 넣는다. 대역마다 다른 글자가 튄다.
// 경계는 低音 1~2번 빈, 中音 3~5번, 高音 6~10번 — 톱니파 고조파는 1/n 으로 줄어 성김문제로
// 중·고음에는 표시 게인(1.5·2.2배)을 준다. 미터 눈금 보정이지 소리를 키우는 게 아니다.
var GAIN = [1, 1.5, 2.2];
var bands = [0, 0, 0];
for (var k = 1; k <= 10; k++) {
var b = k <= 2 ? 0 : k <= 5 ? 1 : 2;
bands[b] += freq[k] / 255 / (b === 0 ? 2 : b === 1 ? 3 : 5);
}
for (var j = 0; j < 3; j++) { bands[j] = Math.min(1, bands[j] * GAIN[j]); }
for (var i = 0; i < letters.length; i++) {
var b = Number(letters[i].getAttribute('data-band'));
letters[i].style.setProperty('--j', bands[b].toFixed(3));
}
var top = bands.indexOf(Math.max(bands[0], bands[1], bands[2]));
if (levelText.textContent !== BAND_NAMES[top]) { levelText.textContent = BAND_NAMES[top]; }
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 ? '停止' : '再生';
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (playing) { ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// TextBounce — vanilla 판(run/291/demo/05-audio-viz-text-bounce/index.html) 의 React 포트.
// 제목의 글자를 저·중·고 대역에 나눠 건다. 대역마다 다른 글자가 튄다(글자는 자기 중심에서 커진다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TextBounce.module.scss";
export type TextBounceProps = {
/** 격자 미리보기용 2초 자동 루프. 재생을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type LetterVars = CSSProperties & { "--j"?: string };
/** ambient 타입에 useRef<T> 의 null 합집합이 없어 로컬로 좁힌다 */
type Box<T> = { current: T };
const BAND_NAMES = ["저음", "중음", "고음"];
/** 브랜드 낱말이라 로케일을 안 탄다. data-band 로 저·중·고에 나눠 건다 */
const LETTERS: { ch: string; band: number }[] = [
{ ch: "G", band: 0 },
{ ch: "O", band: 1 },
{ ch: "D", band: 2 },
{ ch: "R", band: 0 },
{ ch: "I", band: 1 },
{ ch: "C", band: 2 },
{ ch: "H", band: 1 },
];
export default function TextBounce({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#17141a",
}: TextBounceProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [bands, setBands] = useState<number[]>([0, 0, 0]);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
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 = 64;
analyser.smoothingTimeConstant = 0.6;
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;
}
const freq = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(freq);
// 대역 세 개의 에너지를 0~1 로 만들어 글자의 --j 로 넣는다(중·고음 게인 보정은 vanilla 주석 참조).
const GAIN = [1, 1.5, 2.2];
const next = [0, 0, 0];
for (let k = 1; k <= 10; k++) {
const b = k <= 2 ? 0 : k <= 5 ? 1 : 2;
next[b] += freq[k] / 255 / (b === 0 ? 2 : b === 1 ? 3 : 5);
}
for (let j = 0; j < 3; j++) {
next[j] = Math.min(1, next[j] * GAIN[j]);
}
setBands(next);
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 top = bands.indexOf(Math.max(bands[0], bands[1], bands[2]));
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 96v64H48V96Z" opacity=".2" />
<path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8" />
</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-waveform" />
</svg>
<span>재생을 누르면 실제 소리로 움직입니다</span>
</p>
<h2 className={styles.title} aria-label="GODRICH">
{LETTERS.map((l, i) => {
const letterVars: LetterVars = { "--j": bands[l.band].toFixed(3) };
return (
<b className={styles.l} key={i} style={letterVars}>
{l.ch}
</b>
);
})}
</h2>
<ul className={styles.legend} aria-hidden="true">
<li className={styles.bandLow}>저음</li>
<li className={styles.bandMid}>중음</li>
<li className={styles.bandHigh}>고음</li>
</ul>
<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>
<p className={styles.level}>
<span className={styles.levelLabel}>센 대역</span>
<b className={styles.levelBox}>
<span className={styles.levelText} aria-live="polite">
{BAND_NAMES[top]}
</span>
<span className={styles.loop} aria-hidden="true">
<i>저음</i>
<i>중음</i>
<i>고음</i>
</span>
</b>
</p>
</div>
</div>
</main>
);
}05 · 低音で跳ねる文字 — 計測 累積面積 9.328% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
文字は大きくなっても色は変えず位置だけが動くので、明るさの反転は起きません。
.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エネルギーグラデーション背景
どんな動きか
全エネルギーを色相角ひとつに変換し、@property で <angle> として登録した --hue を 210→340 度で回して背景を染めます。登録なしではカスタムプロパティが文字列扱いになり、グラデーションがぶつ切りに切り替わります — 実再生では強さ 63%・メーター 0.673 のとき背景は rgb(47,14,88) 系の紫へ傾きました。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor"><path d="M208 96v64H48V96Z" opacity=".2"/><path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8"/></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="ge is-demo" id="ge">
<p class="ge__hint"><svg class="ge__ico" aria-hidden="true"><use href="#ico-waveform"/></svg><span>再生を押すと実際の音で動きます</span></p>
<div class="ge__panel">
<p class="ge__label">全体のエネルギー</p>
<div class="ge__meter" aria-hidden="true"><i class="ge__fill" id="fill"></i></div>
<p class="ge__read">
<span class="ge__readLabel">今の強さ</span>
<b class="ge__readBox">
<span class="ge__readText" id="energyText" aria-live="polite">32%</span>
<span class="ge__loop" aria-hidden="true"><i>32%</i><i>74%</i><i>51%</i></span>
</b>
</p>
</div>
<div class="ge__foot">
<button class="ge__play" id="play" type="button" aria-pressed="false">
<svg class="ge__ico ge__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="ge__ico ge__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">再生</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 에너지 그라디언트 배경 — 전체 에너지를 색상각 하나로 바꿔 무대를 물들인다.
// --hue 를 @property 로 <angle> 로 등록했기 때문에 키프레임에서 연속으로 보간된다.
// 등록 없이는 커스텀 속성이 문자열로 취급되어 그라디언트가 뚝뚝 끊겨 바뀐다 — 이 항목의 교육 포인트.
// 광과민성: 밝기(lightness)는 20%·13% 두 정지점에 고정하고 색상각만 210→340 도로 돌린다.
// 명도가 고정되면 큰 면적의 "밝기 반전"은 생기지 않는다 — 2초에 왕복 한 번(0.5Hz), WCAG 2.3.1 한계(초당 3회) 안.
$duration: $dur-base;
$easing: $ease-out;
$color: $stage-yellow;
@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;
}
.ge__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-cream;
}
.ge__ico { width: 16px; height: 16px; color: $color; }
.ge__panel {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; padding: $sp-4; border-radius: $r-sm;
background: rgba($subject-ink, .45);
}
.ge__label { margin: 0; font-weight: 800; font-size: clamp(12px, 3.2vw, 13px); color: $subject-cream; }
.ge__meter {
width: 100%; height: 10px; border-radius: $r-pill; overflow: hidden;
background: rgba($subject-cream, .16);
}
.ge__fill {
display: block; height: 100%; width: 100%; border-radius: $r-pill;
background: linear-gradient(90deg, $subject-mint 0%, $color 100%);
transform-origin: left center;
transform: scaleX(var(--e, .3));
transition: transform $duration $ease-out;
}
.ge__read { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.ge__readLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-cream, .82); }
.ge__readBox {
position: relative; display: grid; place-items: center;
min-width: 44px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba($subject-cream, .16); color: $subject-cream;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.ge__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.ge__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
.ge__foot { display: flex; align-items: center; justify-content: center; width: 100%; }
.ge__play {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $color; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.ge__ico--pause { display: none; }
.ge__play[aria-pressed="true"] .ge__ico--play { display: none; }
.ge__play[aria-pressed="true"] .ge__ico--pause { display: inline-block; }
.ge__play .ge__ico { color: $subject-ink; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 색상각이 210 → 340 → 210 으로 2초에 한 번 왕복(0.5Hz). 에너지 막대와 숫자가 같은 박자로 붙는다.
.ge.is-demo {
animation-name: geHueLoop; animation-duration: $dur-loop; animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
transition: none;
}
@keyframes geHueLoop {
0% { --hue: 210deg; }
50% { --hue: 340deg; }
100% { --hue: 210deg; }
}
.ge.is-demo .ge__fill {
animation-name: geFillLoop; animation-duration: $dur-loop; animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
transition: none;
}
@keyframes geFillLoop {
0% { transform: scaleX(.32); }
50% { transform: scaleX(.78); }
100% { transform: scaleX(.32); }
}
// 세기 숫자는 32 → 74 → 51 → 32 로 컷 교체. 지연 구간에 fill-mode 를 두지 않는다(작성 상태가 숨김).
.ge.is-demo .ge__readText { color: transparent; }
.ge.is-demo .ge__loop { visibility: visible; }
.ge.is-demo .ge__loop i {
animation-name: geNumLoop; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ge.is-demo .ge__loop i:nth-child(1) { animation-delay: 0ms; }
.ge.is-demo .ge__loop i:nth-child(2) { animation-delay: 666ms; }
.ge.is-demo .ge__loop i:nth-child(3) { animation-delay: 1332ms; }
@keyframes geNumLoop {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.ge { gap: $sp-2; padding: $sp-3; }
.ge__panel { padding: $sp-3; gap: $sp-1; }
.ge__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 색이 도는 것도 멈춘다 — 초기값 색(210deg) 한 장으로 판이 서 있는다
@media (prefers-reduced-motion: reduce) {
.ge.is-demo,
.ge.is-demo .ge__fill,
.ge.is-demo .ge__loop i { animation: none !important; }
.ge.is-demo .ge__readText { color: $subject-cream !important; }
.ge.is-demo .ge__loop { visibility: hidden !important; }
.ge { transition: none !important; }
.ge__fill { transition: none !important; }
}@property --hue {
syntax: "<angle>";
inherits: false;
initial-value: 210deg;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ge {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
max-width: 360px;
padding: 16px;
border-radius: 16px;
background: linear-gradient(135deg, hsl(var(--hue), 72%, 20%) 0%, hsl(calc(var(--hue) + 48deg), 62%, 13%) 100%);
transition: --hue 300ms ease-out;
}
.ge__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #fff7e6;
}
.ge__ico {
width: 16px;
height: 16px;
color: #ffd23f;
}
.ge__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
padding: 16px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.45);
}
.ge__label {
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 13px);
color: #fff7e6;
}
.ge__meter {
width: 100%;
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 247, 230, 0.16);
}
.ge__fill {
display: block;
height: 100%;
width: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #4cd4a6 0%, #ffd23f 100%);
transform-origin: left center;
transform: scaleX(var(--e, 0.3));
transition: transform 300ms ease-out;
}
.ge__read {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.ge__readLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.ge__readBox {
position: relative;
display: grid;
place-items: center;
min-width: 44px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.ge__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.ge__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.ge__foot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.ge__play {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.ge__ico--pause {
display: none;
}
.ge__play[aria-pressed=true] .ge__ico--play {
display: none;
}
.ge__play[aria-pressed=true] .ge__ico--pause {
display: inline-block;
}
.ge__play .ge__ico {
color: #17141a;
}
.ge.is-demo {
animation-name: geHueLoop;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
transition: none;
}
@keyframes geHueLoop {
0% {
--hue: 210deg;
}
50% {
--hue: 340deg;
}
100% {
--hue: 210deg;
}
}
.ge.is-demo .ge__fill {
animation-name: geFillLoop;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
transition: none;
}
@keyframes geFillLoop {
0% {
transform: scaleX(0.32);
}
50% {
transform: scaleX(0.78);
}
100% {
transform: scaleX(0.32);
}
}
.ge.is-demo .ge__readText {
color: transparent;
}
.ge.is-demo .ge__loop {
visibility: visible;
}
.ge.is-demo .ge__loop i {
animation-name: geNumLoop;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ge.is-demo .ge__loop i:nth-child(1) {
animation-delay: 0ms;
}
.ge.is-demo .ge__loop i:nth-child(2) {
animation-delay: 666ms;
}
.ge.is-demo .ge__loop i:nth-child(3) {
animation-delay: 1332ms;
}
@keyframes geNumLoop {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.ge {
gap: 8px;
padding: 12px;
}
.ge__panel {
padding: 12px;
gap: 4px;
}
.ge__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ge.is-demo,
.ge.is-demo .ge__fill,
.ge.is-demo .ge__loop i {
animation: none !important;
}
.ge.is-demo .ge__readText {
color: #fff7e6 !important;
}
.ge.is-demo .ge__loop {
visibility: hidden !important;
}
.ge {
transition: none !important;
}
.ge__fill {
transition: none !important;
}
}var root = document.getElementById('ge');
var fill = document.getElementById('fill');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var energyText = document.getElementById('energyText');
var ctx = null, analyser = null, freq = null, timer = 0, playing = false;
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 64;
analyser.smoothingTimeConstant = 0.7;
freq = new Uint8Array(analyser.frequencyBinCount);
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() {
analyser.getByteFrequencyData(freq);
var sum = 0;
for (var k = 0; k < freq.length; k++) { sum += freq[k]; }
var e = Math.min(1, (sum / (freq.length * 255)) * 2.6); // ×2.6 은 미터 눈금 보정 — 소리 자체는 바꾸지 않는다
fill.style.setProperty('--e', e.toFixed(3));
// --hue 는 이 그라디언트 판 자신의 등록된 속성이라 여기(그 판의 부모 .ge) 에 쓴다 —
// inherits: false 로 등록했으니 값을 쓰는 요소가 스스로 가져야 한다 (262 실측).
root.style.setProperty('--hue', (200 + e * 140).toFixed(1) + 'deg');
var pct = Math.round(e * 100);
if (energyText.textContent !== pct + '%') { energyText.textContent = pct + '%'; }
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 ? '停止' : '再生';
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (playing) { ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// GradientEnergy — vanilla 판(run/291/demo/06-audio-viz-gradient-energy/index.html) 의 React 포트.
// 전체 에너지를 색상각 하나로 바꿔 판을 물들인다. --hue 는 @property 로 등록돼 있어 연속 보간된다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GradientEnergy.module.scss";
export type GradientEnergyProps = {
/** 격자 미리보기용 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 };
export default function GradientEnergy({
demo = true,
duration = 300,
easing = "ease-out",
color = "#ffd23f",
}: GradientEnergyProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [energy, setEnergy] = useState(0.32);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
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 = 64;
analyser.smoothingTimeConstant = 0.7;
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;
}
const freq = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(freq);
let sum = 0;
for (let k = 0; k < freq.length; k++) {
sum += freq[k];
}
setEnergy(Math.min(1, (sum / (freq.length * 255)) * 2.6)); // ×2.6 은 미터 눈금 보정
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(" ");
// --hue 는 이 판 자신의 등록된 속성 — inherits: false 라 값을 쓰는 요소가 스스로 가져야 한다 (262 실측).
const rootVars: Vars = { ...vars, "--hue": (200 + energy * 140).toFixed(1) + "deg" };
return (
<main className={styles.stage} style={rootVars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 96v64H48V96Z" opacity=".2" />
<path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8" />
</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-waveform" />
</svg>
<span>재생을 누르면 실제 소리로 움직입니다</span>
</p>
<div className={styles.panel}>
<p className={styles.label}>전체 에너지</p>
<div className={styles.meter} aria-hidden="true">
<i className={styles.fill} style={{ transform: `scaleX(${energy.toFixed(3)})` }} />
</div>
<p className={styles.read}>
<span className={styles.readLabel}>지금 세기</span>
<b className={styles.readBox}>
<span className={styles.readText} aria-live="polite">
{Math.round(energy * 100)}%
</span>
<span className={styles.loop} aria-hidden="true">
<i>32%</i>
<i>74%</i>
<i>51%</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>
);
}06 · エネルギーグラデーション背景 — 計測 累積面積 54.717% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
画面の大部分がゆっくり動くので累積面積 54.718% は9点最大で、明度は 20%〜13% の狭い帯に収め、色相だけが動きます。
@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 で4秒のバッファを一度だけレンダリングし、40列それぞれの最大振幅を先に引き出して山形を描きます。実測の先頭6列は 0.538・0.225・0.140・0.140・0.140・0.511 で、0.140 は下限として敷いた床の値です。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor"><path d="M208 96v64H48V96Z" opacity=".2"/><path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8"/></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="tp is-demo" id="tp">
<p class="tp__hint"><svg class="tp__ico" aria-hidden="true"><use href="#ico-waveform"/></svg><span>山形は実際の音から取っています</span></p>
<div class="tp__card">
<p class="tp__label">試聴トラック</p>
<div class="tp__wave" id="wave" role="progressbar" aria-label="再生位置" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="tp__row tp__row--base" id="baseBars" aria-hidden="true"></div>
<div class="tp__lit" id="litWrap" aria-hidden="true"><div class="tp__row tp__row--lit" id="litBars"></div></div>
<i class="tp__head" id="head" aria-hidden="true"></i>
</div>
<p class="tp__time"><span class="tp__now" id="timeText">0:00</span><span class="tp__sep">/</span><span>0:04</span></p>
</div>
<div class="tp__foot">
<button class="tp__play" id="play" type="button" aria-pressed="false">
<svg class="tp__ico tp__ico--play" aria-hidden="true"><use href="#ico-play"/></svg>
<svg class="tp__ico tp__ico--pause" aria-hidden="true"><use href="#ico-pause"/></svg>
<span id="playLabel">再生</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 미니 플레이어 파형 막대 — 트랙 전체의 피크를 미리 뽑아 산 모양으로 그려 두고, 재생 위치까지만 색을 채운다.
// 실시간 분석이 아니라 숫자 배열이라 스크롤·리사이즈에도 파형이 절대 흔들리지 않는다 — 이 항목의 교육 포인트.
// 채움은 캔버스가 아니라 clip-path: inset(0 calc(100% - var(--p)) 0 0) — 오른쪽부터 가리는 방식이라
// 막대를 두 벌 그릴 필요 없이 같은 산 위에 색만 얹는다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
// --x 를 등록해야 키프레임에서 연속으로 움직인다 — 등록 없는 커스텀 속성은 키프레임 경계에서 뚝 뛴다.
@property --x {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
@include stage($stage-paper);
.tp { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 360px; }
.tp__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-ink;
}
.tp__ico { width: 16px; height: 16px; color: $color; }
.tp__card {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; padding: $sp-3; border-radius: $r-card;
background: #f4f1ea; border: 1px solid rgba($subject-ink, .12);
box-shadow: $shadow-raised;
}
.tp__label { margin: 0; font-weight: 800; font-size: clamp(11px, 3vw, 12px); color: $subject-ink; }
.tp__wave { position: relative; width: 100%; height: 64px; }
// grid-auto-flow: column — 칸이 왼쪽에서 오른쪽으로 흐르는 한 줄. 칸 폭은 1fr 로 균등.
.tp__row {
display: grid; grid-template-rows: 1fr; grid-auto-flow: column; grid-auto-columns: 1fr;
gap: $sp-1; align-items: end; height: 100%;
}
.tp__bar {
display: block; border-radius: $r-xs;
height: calc(var(--h, .3) * 100%);
transform-origin: bottom center;
}
.tp__row--base .tp__bar { background: rgba($subject-ink, .18); }
// 채워진 쪽 산 — 포장지를 --p 지점에서 오른쪽으로 자른다.
.tp__lit {
position: absolute; inset: 0;
clip-path: inset(0 calc(100% - var(--p, 0%)) 0 0);
}
.tp__row--lit .tp__bar { background: linear-gradient(180deg, $subject-mint 0%, $color 100%); }
// 재생 머리 — 산 위를 좌에서 우로 간다. 위로 솟은 핀 모양.
.tp__head {
position: absolute; left: calc(var(--x, 0%)); top: -4px; bottom: -4px; width: 4px; margin-left: -2px;
border-radius: $r-pill; background: $stage-orange;
}
.tp__time { margin: 0; display: flex; align-items: center; gap: $sp-1; font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-ink; }
.tp__now { font-weight: 800; color: $subject-blue-deep; } // 밝은 카드 위 파란 글자 — 5.65:1
.tp__sep { color: rgba($subject-ink, .72); } // AA 4.5 (.4 는 2.52:1 이었다)
.tp__foot { display: flex; align-items: center; justify-content: center; width: 100%; }
.tp__play {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $color; color: #ffffff; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.tp__ico--pause { display: none; }
.tp__play[aria-pressed="true"] .tp__ico--play { display: none; }
.tp__play[aria-pressed="true"] .tp__ico--pause { display: inline-block; }
.tp__play .tp__ico { color: #ffffff; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 4초 트랙을 2초에 두 바퀴 돌린다(주기 2초의 약수 규칙): 0→90% 까지 채우고 90% 안에 되돌아온다.
// clip-path 와 재생 머리가 같은 이징이라 산과 핀이 어긋나지 않는다.
.tp.is-demo .tp__lit {
animation-name: tpFillLoop; animation-duration: $dur-loop; animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes tpFillLoop {
0% { clip-path: inset(0 100% 0 0); }
86% { clip-path: inset(0 8% 0 0); }
90% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
.tp.is-demo .tp__head {
animation-name: tpHeadLoop; animation-duration: $dur-loop; animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes tpHeadLoop {
0% { --x: 0%; }
86% { --x: 92%; }
88% { --x: 0%; }
100% { --x: 0%; }
}
@media (max-width: 340px) {
.tp { gap: $sp-2; }
.tp__card { padding: $sp-2; gap: $sp-1; }
.tp__wave { height: 48px; }
.tp__row { gap: 0; }
.tp__bar { border-radius: 0; }
.tp__play { padding: $sp-1 $sp-3; }
}
// reduce 에서는 채움 루프를 멈춘다 — 산은 60% 까지 칠해지고 재생 머리도 그 칠해진 끝(60%)에 선다
@media (prefers-reduced-motion: reduce) {
.tp.is-demo .tp__lit,
.tp.is-demo .tp__head { animation: none !important; }
.tp.is-demo .tp__lit { clip-path: inset(0 40% 0 0) !important; }
.tp.is-demo .tp__head { --x: 60% !important; }
}@property --x {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tp {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 360px;
}
.tp__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.tp__ico {
width: 16px;
height: 16px;
color: #2f6df6;
}
.tp__card {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 12px;
border-radius: 16px;
background: #f4f1ea;
border: 1px solid rgba(23, 20, 26, 0.12);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.tp__label {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.tp__wave {
position: relative;
width: 100%;
height: 64px;
}
.tp__row {
display: grid;
grid-template-rows: 1fr;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 4px;
align-items: end;
height: 100%;
}
.tp__bar {
display: block;
border-radius: 4px;
height: calc(var(--h, 0.3) * 100%);
transform-origin: bottom center;
}
.tp__row--base .tp__bar {
background: rgba(23, 20, 26, 0.18);
}
.tp__lit {
position: absolute;
inset: 0;
clip-path: inset(0 calc(100% - var(--p, 0%)) 0 0);
}
.tp__row--lit .tp__bar {
background: linear-gradient(180deg, #4cd4a6 0%, #2f6df6 100%);
}
.tp__head {
position: absolute;
left: calc(var(--x, 0%));
top: -4px;
bottom: -4px;
width: 4px;
margin-left: -2px;
border-radius: 999px;
background: #ff4d1f;
}
.tp__time {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.tp__now {
font-weight: 800;
color: #0a4fe8;
}
.tp__sep {
color: rgba(23, 20, 26, 0.72);
}
.tp__foot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.tp__play {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #2f6df6;
color: #ffffff;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.tp__ico--pause {
display: none;
}
.tp__play[aria-pressed=true] .tp__ico--play {
display: none;
}
.tp__play[aria-pressed=true] .tp__ico--pause {
display: inline-block;
}
.tp__play .tp__ico {
color: #ffffff;
}
.tp.is-demo .tp__lit {
animation-name: tpFillLoop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes tpFillLoop {
0% {
clip-path: inset(0 100% 0 0);
}
86% {
clip-path: inset(0 8% 0 0);
}
90% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
.tp.is-demo .tp__head {
animation-name: tpHeadLoop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes tpHeadLoop {
0% {
--x: 0%;
}
86% {
--x: 92%;
}
88% {
--x: 0%;
}
100% {
--x: 0%;
}
}
@media (max-width: 340px) {
.tp {
gap: 8px;
}
.tp__card {
padding: 8px;
gap: 4px;
}
.tp__wave {
height: 48px;
}
.tp__row {
gap: 0;
}
.tp__bar {
border-radius: 0;
}
.tp__play {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo .tp__lit,
.tp.is-demo .tp__head {
animation: none !important;
}
.tp.is-demo .tp__lit {
clip-path: inset(0 40% 0 0) !important;
}
.tp.is-demo .tp__head {
--x: 60% !important;
}
}var root = document.getElementById('tp');
var wave = document.getElementById('wave');
var baseBars = document.getElementById('baseBars');
var litBars = document.getElementById('litBars');
var litWrap = document.getElementById('litWrap');
var head = document.getElementById('head');
var playBtn = document.getElementById('play');
var playLabel = document.getElementById('playLabel');
var timeText = document.getElementById('timeText');
var COLS = 40, TRACK_SEC = 4;
// 파형 산 모양: 페이지가 열릴 때 OfflineAudioContext 로 합성 버퍼를 한 번 렌더해
// 구간마다 최대 진폭(피크)을 뽑아 둔다. 난수가 아니라 이 트랙의 실제 데이터다.
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);
});
var peaks = [];
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);
}
});
var ctx = null, timer = 0, playing = false, startedAt = 0;
function buildAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)();
var out = ctx.createGain();
out.gain.value = 0.06;
out.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() {
// 再生位置 — 트랙 길이로 나눈 나머지라 4초마다 처음으로 돌아간다.
var t = (ctx.currentTime - startedAt) % TRACK_SEC;
var pct = t / TRACK_SEC;
litWrap.style.setProperty('--p', (pct * 100).toFixed(1) + '%');
head.style.setProperty('--x', (pct * 100).toFixed(1) + '%');
wave.setAttribute('aria-valuenow', String(Math.round(pct * 100)));
var sec = Math.floor(t);
var txt = '0:0' + Math.min(sec, TRACK_SEC - 1);
if (timeText.textContent !== txt) { timeText.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 ? '停止' : '再生';
// 자동재생 정책: AudioContext 는 사용자 제스처 뒤 resume() 해야 소리가 난다.
if (playing) { startedAt = ctx.currentTime; ctx.resume(); timer = requestAnimationFrame(draw); }
else { ctx.suspend(); cancelAnimationFrame(timer); }
});// TrackPeaks — vanilla 판(run/291/demo/07-audio-viz-track-peaks/index.html) 의 React 포트.
// 트랙 전체의 피크를 OfflineAudioContext 로 미리 뽑아 산 모양으로 그리고, 재생 위치까지만 색을 채운다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TrackPeaks.module.scss";
export type TrackPeaksProps = {
/** 격자 미리보기용 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 COLS = 40;
const TRACK_SEC = 4;
/** 파형 산 모양 — 페이지가 열릴 때 오프라인 렌더로 합성 버퍼에서 뽑는다. 난수가 아니다. */
async function renderPeaks(): Promise<number[]> {
const offline = new OfflineAudioContext(1, 44100 * TRACK_SEC, 44100);
const oOut = offline.createGain();
oOut.gain.value = 0.5;
oOut.connect(offline.destination);
[196, 246.94, 293.66, 392].forEach((hz, n) => {
const osc = offline.createOscillator();
const amp = offline.createGain();
osc.type = n === 3 ? "triangle" : "sawtooth";
osc.frequency.value = hz;
amp.gain.value = 0;
for (let beat = 0; beat < TRACK_SEC * 2; beat++) {
const 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);
});
const buf = await offline.startRendering();
const data = buf.getChannelData(0);
const slice = Math.floor(data.length / COLS);
const peaks: number[] = [];
for (let c = 0; c < COLS; c++) {
let peak = 0;
for (let k = c * slice; k < (c + 1) * slice; k++) {
peak = Math.max(peak, Math.abs(data[k]));
}
peaks.push(Math.max(0.14, peak));
}
return peaks;
}
export default function TrackPeaks({
demo = true,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: TrackPeaksProps) {
const [live, setLive] = useState(demo);
const [playing, setPlaying] = useState(false);
const [peaks, setPeaks] = useState<number[]>([]);
const [pos, setPos] = useState(0);
const ctxBox = useRef(null) as Box<AudioContext | null>;
const frameBox = useRef(0) as Box<number>;
const startedBox = useRef(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
renderPeaks().then(setPeaks);
return () => {
cancelAnimationFrame(frameBox.current);
if (ctxBox.current) {
ctxBox.current.close();
}
};
}, []);
function buildAudio() {
const ctx = new AudioContext();
const out = ctx.createGain();
out.gain.value = 0.06;
out.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;
}
function draw() {
const ctx = ctxBox.current;
if (!ctx) {
return;
}
// 재생 위치 — 트랙 길이로 나눈 나머지라 4초마다 처음으로 돌아간다.
const t = (ctx.currentTime - startedBox.current) % TRACK_SEC;
setPos(t / TRACK_SEC);
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) {
startedBox.current = ctx.currentTime;
ctx.resume();
frameBox.current = requestAnimationFrame(draw);
} else {
ctx.suspend();
cancelAnimationFrame(frameBox.current);
}
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const pct = (pos * 100).toFixed(1) + "%";
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-waveform" viewBox="0 0 256 256" fill="currentColor">
<path d="M208 96v64H48V96Z" opacity=".2" />
<path d="M56 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0m32-72a8 8 0 0 0-8 8v192a8 8 0 0 0 16 0V32a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v128a8 8 0 0 0 16 0V64a8 8 0 0 0-8-8m40 32a8 8 0 0 0-8 8v64a8 8 0 0 0 16 0V96a8 8 0 0 0-8-8m40-16a8 8 0 0 0-8 8v96a8 8 0 0 0 16 0V72a8 8 0 0 0-8-8" />
</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-waveform" />
</svg>
<span>트랙 전체의 산 모양이 진짜 소리에서 나왔습니다</span>
</p>
<div className={styles.card}>
<p className={styles.label}>미리듣기 트랙</p>
<div
className={styles.wave}
role="progressbar"
aria-label="재생 위치"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pos * 100)}
>
<div className={styles.rowBase} aria-hidden="true">
{peaks.map((h, i) => (
<i className={styles.bar} key={i} style={{ height: `calc(${h.toFixed(3)} * 100%)` }} />
))}
</div>
<div className={styles.lit} style={{ "--p": pct } as CSSProperties} aria-hidden="true">
<div className={styles.rowLit}>
{peaks.map((h, i) => (
<i className={styles.bar} key={i} style={{ height: `calc(${h.toFixed(3)} * 100%)` }} />
))}
</div>
</div>
<i className={styles.head} style={{ "--x": pct } as CSSProperties} aria-hidden="true" />
</div>
<p className={styles.time}>
<span className={styles.now}>0:0{Math.min(Math.floor(pos * TRACK_SEC), TRACK_SEC - 1)}</span>
<span className={styles.sep}>/</span>
<span>0:04</span>
</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>
);
}07 · ミニプレーヤーの波形バー — 計測 累積面積 5.867% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
山はリアルタイム解析ではなく数値の配列なのでスクロールしても崩れず、再生位置は role="progressbar" の aria-valuenow にも同じ数字で入ります。
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);
}
});
08マイク入力の可視化
画面で起きること
getUserMedia で許可を求め、許されれば声が、拒否されれば 220Hz の合成音が同じリングを動かします。実測では許可時の棒は 0.121〜0.446 と粗く揺れ、拒否時は 0.831 を頂点に一本調子で、音源が変わったことが輪郭だけで分かります。

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-microphone" viewBox="0 0 256 256" fill="currentColor"><path d="M168 64v64a40 40 0 0 1-40 40a40 40 0 0 1-40-40V64a40 40 0 0 1 40-40a40 40 0 0 1 40 40" opacity=".2"/><path d="M128 176a48.05 48.05 0 0 0 48-48V64a48 48 0 0 0-96 0v64a48.05 48.05 0 0 0 48 48M96 64a32 32 0 0 1 64 0v64a32 32 0 0 1-64 0Zm40 143.6V240a8 8 0 0 1-16 0v-32.4A80.11 80.11 0 0 1 48 128a8 8 0 0 1 16 0a64 64 0 0 0 128 0a8 8 0 0 1 16 0a80.11 80.11 0 0 1-72 79.6"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="mi is-demo" id="mi">
<p class="mi__hint"><svg class="mi__ico" aria-hidden="true"><use href="#ico-microphone"/></svg><span>マイクは許可を取って使います — 拒否しても画面は生きます</span></p>
<div class="mi__dial" aria-hidden="true"><div class="mi__bars" id="bars"></div></div>
<button class="mi__btn" id="micBtn" type="button" aria-pressed="false">
<svg class="mi__btnIco" aria-hidden="true"><use href="#ico-microphone"/></svg>
<span id="micLabel">マイクをオン</span>
</button>
<p class="mi__status" aria-live="polite"><span id="status">ボタンを押すとマイクの許可を求めます</span><span class="mi__statusLoop" aria-hidden="true"><i>マイクの許可を待っています</i><i>マイク入力中 — 話してみてください</i><i>許可されません — 合成音に切り替えました</i></span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 마이크 입력 시각화 — getUserMedia 로 권한을 물어 허락되면 목소리가, 거절되면 합성음이 같은 고리를 움직인다.
// 거절은 catch 로 잡는 정상 경로다 — 여기서 화면이 비면 "권한 거절 = 고장"이라는 인상을 준다.
// 노랑 무대 위 글자는 전부 잉크($subject-ink) — 크림 글자는 3.1:1 로 미달이다(285 실측).
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.mi { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.mi__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $color;
}
.mi__ico { width: 16px; height: 16px; color: $color; }
.mi__dial { position: relative; width: 156px; height: 156px; border-radius: 50%; background: rgba($subject-cream, .4); }
.mi__bars { position: absolute; inset: 0; }
// 고리 막대 — 01 원형과 같은 원리: 아래 끝이 원의 중심에 닿고, 반지름만큼 나간 자리에서 바깥으로 자란다.
.mi__bar {
position: absolute; left: 50%; bottom: 50%; width: 4px; height: 20px; margin-left: -2px;
border-radius: $r-xs;
background: $color;
transform-origin: 50% 100%;
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(var(--h, .14));
transition: transform $duration $ease-out;
}
.mi__btn {
display: inline-flex; align-items: center; gap: $sp-2;
border: 0; border-radius: $r-pill; padding: $sp-2 $sp-5;
background: $color; color: $stage-yellow; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(12px, 3.4vw, 14px); line-height: 1;
}
.mi__btnIco { width: 18px; height: 18px; color: $stage-yellow; }
.mi__status { position: relative; margin: 0; min-height: 16px;
font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-ink, .8);
}
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 고리가 파도치고(음수 지연 위상), 상태 글자는 「권한을 기다립니다」→「마이크 입력 중」→「권한 거부 — 합성음으로 대체했습니다」
// 로 컷 교체 — 미리보기가 실사용의 세 상태를 그대로 보여준다. 컷만으로는 프레임이 모자라니 막대 파도가 채운다.
// 막대는 잉크 단색이라 밝기 반전이 없다 — 광과민성과 무관하다.
.mi.is-demo .mi__bar {
animation-name: miWave; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -83ms);
transition: none;
}
@keyframes miWave {
0% { transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(.14); }
14% { transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(.95); }
28% { transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(.32); }
42% { transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(.72); }
56% { transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(.2); }
70% { transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(.84); }
84% { transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(.28); }
100% { transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(.14); }
}
.mi.is-demo .mi__status { color: transparent; }
.mi.is-demo .mi__statusLoop { visibility: visible; }
// 같은 격자 칸에 세 상태를 겹쳐 두고 steps 로 하나씩 보여준다(01 대역 이름과 같은 패턴)
.mi__statusLoop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.mi.is-demo .mi__statusLoop i {
grid-area: 1 / 1; font-style: normal; visibility: hidden; text-align: center;
font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba($subject-ink, .8);
animation-name: miStatusLoop; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mi.is-demo .mi__statusLoop i:nth-child(1) { animation-delay: 0ms; }
.mi.is-demo .mi__statusLoop i:nth-child(2) { animation-delay: 666ms; }
.mi.is-demo .mi__statusLoop i:nth-child(3) { animation-delay: 1332ms; }
@keyframes miStatusLoop {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.mi { gap: $sp-1; }
.mi__dial { width: 96px; height: 96px; }
.mi__bar { height: 13px; width: 3px; margin-left: -1.5px; transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-34px) scaleY(var(--h, .14)); }
.mi__btn { padding: $sp-1 $sp-3; }
}
// reduce 에서는 파도를 멈춘다 — 고리와 버튼, 상태 글자가 남는다
@media (prefers-reduced-motion: reduce) {
.mi.is-demo .mi__bar,
.mi.is-demo .mi__statusLoop i { animation: none !important; }
.mi.is-demo .mi__status { color: rgba($subject-ink, .8) !important; }
.mi.is-demo .mi__statusLoop { visibility: hidden !important; }
.mi__bar { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mi {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.mi__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.mi__ico {
width: 16px;
height: 16px;
color: #17141a;
}
.mi__dial {
position: relative;
width: 156px;
height: 156px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.4);
}
.mi__bars {
position: absolute;
inset: 0;
}
.mi__bar {
position: absolute;
left: 50%;
bottom: 50%;
width: 4px;
height: 20px;
margin-left: -2px;
border-radius: 4px;
background: #17141a;
transform-origin: 50% 100%;
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(var(--h, 0.14));
transition: transform 220ms ease-out;
}
.mi__btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
border-radius: 999px;
padding: 8px 20px;
background: #17141a;
color: #ffd23f;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
}
.mi__btnIco {
width: 18px;
height: 18px;
color: #ffd23f;
}
.mi__status {
position: relative;
margin: 0;
min-height: 16px;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(23, 20, 26, 0.8);
}
.mi.is-demo .mi__bar {
animation-name: miWave;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * -83ms);
transition: none;
}
@keyframes miWave {
0% {
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(0.14);
}
14% {
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(0.95);
}
28% {
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(0.32);
}
42% {
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(0.72);
}
56% {
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(0.2);
}
70% {
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(0.84);
}
84% {
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(0.28);
}
100% {
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-53px) scaleY(0.14);
}
}
.mi.is-demo .mi__status {
color: transparent;
}
.mi.is-demo .mi__statusLoop {
visibility: visible;
}
.mi__statusLoop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.mi.is-demo .mi__statusLoop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
text-align: center;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(23, 20, 26, 0.8);
animation-name: miStatusLoop;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mi.is-demo .mi__statusLoop i:nth-child(1) {
animation-delay: 0ms;
}
.mi.is-demo .mi__statusLoop i:nth-child(2) {
animation-delay: 666ms;
}
.mi.is-demo .mi__statusLoop i:nth-child(3) {
animation-delay: 1332ms;
}
@keyframes miStatusLoop {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.mi {
gap: 4px;
}
.mi__dial {
width: 96px;
height: 96px;
}
.mi__bar {
height: 13px;
width: 3px;
margin-left: -1.5px;
transform: rotate(calc(var(--i) * 1turn / 24)) translateY(-34px) scaleY(var(--h, 0.14));
}
.mi__btn {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mi.is-demo .mi__bar,
.mi.is-demo .mi__statusLoop i {
animation: none !important;
}
.mi.is-demo .mi__status {
color: rgba(23, 20, 26, 0.8) !important;
}
.mi.is-demo .mi__statusLoop {
visibility: hidden !important;
}
.mi__bar {
transition: none !important;
}
}var root = document.getElementById('mi');
var barBox = document.getElementById('bars');
var micBtn = document.getElementById('micBtn');
var micLabel = document.getElementById('micLabel');
var statusEl = document.getElementById('status');
var BAR_COUNT = 24;
var bars = [];
// 고리 막대 24개 — 마이크가 허락되면 목소리가, 거절되면 합성음이 **같은 막대**를 움직인다.
// 두 경로가 같은 그림을 공유해야 "거절 = 실패 화면"이 아니라 "거절 = 다른 음원" 이 된다.
for (var i = 0; i < BAR_COUNT; i++) {
var bar = document.createElement('span');
bar.className = 'mi__bar';
bar.style.setProperty('--i', String(i));
bar.setAttribute('data-band', String(Math.abs((i % 16) - 8)));
barBox.appendChild(bar);
bars.push(bar);
}
var ctx = null, analyser = null, freq = null, timer = 0, running = false, micOn = false;
function ensureCtx() {
if (ctx) { return; }
ctx = new (window.AudioContext || window.webkitAudioContext)();
analyser = ctx.createAnalyser();
analyser.fftSize = 256; // 256 → 빈 간격 187.5Hz — 폴백 사인파(220Hz)와 목소리 기본주파수가 맵핑된 빈에 들어온다
analyser.smoothingTimeConstant = 0.7;
freq = new Uint8Array(analyser.frequencyBinCount);
analyser.connect(ctx.destination);
}
function fallbackTone() {
// 거절 폴백 — 낮은 사인파 하나. 목소리보다 조용하고 규칙적이라 "합성음으로 돌아갔다"가 한눈에 보인다.
var osc = ctx.createOscillator();
var amp = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 220;
amp.gain.value = 0;
for (var beat = 0; beat < 128; beat++) {
var at = ctx.currentTime + beat * 0.5;
amp.gain.setValueAtTime(0.5, at);
amp.gain.exponentialRampToValueAtTime(0.02, at + 0.4);
}
osc.connect(amp); amp.connect(analyser); osc.start();
}
function draw() {
analyser.getByteFrequencyData(freq);
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.1 + v * 0.9).toFixed(3));
}
timer = requestAnimationFrame(draw);
}
micBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (running) { stopAll(); return; }
ensureCtx();
// 자동재생 정책: AudioContext 는 사용자 제스처 안에서 resume() 해야 한다.
ctx.resume();
micBtn.setAttribute('aria-pressed', 'true');
micLabel.textContent = 'オフ';
statusEl.textContent = 'マイクの許可を待っています';
micOn = false;
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);
});
});
function stopAll() {
cancelAnimationFrame(timer);
if (ctx) { ctx.suspend(); }
running = false;
micBtn.setAttribute('aria-pressed', 'false');
micLabel.textContent = 'マイクをオン';
statusEl.textContent = micOn ? 'マイクを止めました' : '合成音を止めました';
}// MicInput — vanilla 판(run/291/demo/08-audio-viz-mic-input/index.html) 의 React 포트.
// getUserMedia 로 권한을 물어 허락되면 목소리가, 거절되면 합성음이 같은 고리를 움직인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MicInput.module.scss";
export type MicInputProps = {
/** 격자 미리보기용 2초 자동 루프. 마이크를 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BarVars = CSSProperties & { "--i"?: string; "--h"?: string };
/** ambient 타입에 useRef<T> 의 null 합집합이 없어 로컬로 좁힌다 */
type Box<T> = { current: T };
const BAR_COUNT = 24;
export default function MicInput({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: MicInputProps) {
const [live, setLive] = useState(demo);
const [running, setRunning] = useState(false);
const [heights, setHeights] = useState<number[]>([]);
const [status, setStatus] = useState("버튼을 누르면 마이크 권한을 물어봅니다");
const [micLabel, setMicLabel] = useState("마이크 켜기");
const ctxBox = useRef(null) as Box<AudioContext | null>;
const nodeBox = useRef(null) as Box<AnalyserNode | null>;
const frameBox = useRef(0) as Box<number>;
const micOnBox = useRef(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
return () => {
cancelAnimationFrame(frameBox.current);
if (ctxBox.current) {
ctxBox.current.close();
}
};
}, []);
function ensureCtx() {
if (ctxBox.current) {
return;
}
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
analyser.fftSize = 256; // 256 → 빈 간격 187.5Hz — 폴백 사인파(220Hz)와 목소리 기본주파수가 맵핑된 빈에 들어온다
analyser.smoothingTimeConstant = 0.7;
analyser.connect(ctx.destination);
ctxBox.current = ctx;
nodeBox.current = analyser;
}
function fallbackTone() {
const ctx = ctxBox.current;
const analyser = nodeBox.current;
if (!ctx || !analyser) {
return;
}
// 거절 폴백 — 낮은 사인파 하나. "합성음으로 돌아갔다"가 한눈에 보인다.
const osc = ctx.createOscillator();
const amp = ctx.createGain();
osc.type = "sine";
osc.frequency.value = 220;
amp.gain.value = 0;
for (let beat = 0; beat < 128; beat++) {
const at = ctx.currentTime + beat * 0.5;
amp.gain.setValueAtTime(0.5, at);
amp.gain.exponentialRampToValueAtTime(0.02, at + 0.4);
}
osc.connect(amp);
amp.connect(analyser);
osc.start();
}
function draw() {
const analyser = nodeBox.current;
if (!analyser) {
return;
}
const freq = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(freq);
const next: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
const band = Math.abs((i % 16) - 8);
next.push(0.1 + (freq[band + 1] / 255) * 0.9);
}
setHeights(next);
frameBox.current = requestAnimationFrame(draw);
}
function toggle() {
setLive(false);
if (running) {
stopAll();
return;
}
ensureCtx();
const ctx = ctxBox.current;
if (!ctx) {
return;
}
// 자동재생 정책: AudioContext 는 사용자 제스처 안에서 resume() 해야 한다.
ctx.resume();
setRunning(true);
setMicLabel("끄기");
setStatus("마이크 권한을 기다립니다");
micOnBox.current = false;
const p =
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
? navigator.mediaDevices.getUserMedia({ audio: true })
: Promise.reject(new Error("no mediaDevices"));
p.then((stream) => {
micOnBox.current = true;
const src = ctx.createMediaStreamSource(stream);
src.connect(nodeBox.current as AnalyserNode); // 스피커로 돌려주지 않는다 — 하울링이 일어난다
setStatus("마이크 입력 중 — 말해 보세요");
frameBox.current = requestAnimationFrame(draw);
}).catch(() => {
// 거절은 정상 경로다 — 화면이 비지 않게 합성음이 같은 고리를 잇는다.
micOnBox.current = false;
fallbackTone();
setStatus("권한 거부 — 합성음으로 대체했습니다");
frameBox.current = requestAnimationFrame(draw);
});
}
function stopAll() {
cancelAnimationFrame(frameBox.current);
if (ctxBox.current) {
ctxBox.current.suspend();
}
setRunning(false);
setMicLabel("마이크 켜기");
setStatus(micOnBox.current ? "마이크를 멈췄습니다" : "합성음을 멈췄습니다");
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-microphone" viewBox="0 0 256 256" fill="currentColor">
<path d="M168 64v64a40 40 0 0 1-40 40a40 40 0 0 1-40-40V64a40 40 0 0 1 40-40a40 40 0 0 1 40 40" opacity=".2" />
<path d="M128 176a48.05 48.05 0 0 0 48-48V64a48 48 0 0 0-96 0v64a48.05 48.05 0 0 0 48 48M96 64a32 32 0 0 1 64 0v64a32 32 0 0 1-64 0Zm40 143.6V240a8 8 0 0 1-16 0v-32.4A80.11 80.11 0 0 1 48 128a8 8 0 0 1 16 0a64 64 0 0 0 128 0a8 8 0 0 1 16 0a80.11 80.11 0 0 1-72 79.6" />
</symbol>
</defs>
</svg>
<div className={cls}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true">
<use href="#ico-microphone" />
</svg>
<span>마이크는 물어보고 씁니다 — 거절해도 화면은 삽니다</span>
</p>
<div className={styles.dial} aria-hidden="true">
<div className={styles.bars}>
{Array.from({ length: BAR_COUNT }, (_, i) => {
const barVars: BarVars = { "--i": String(i) };
if (heights[i] !== undefined) {
barVars["--h"] = heights[i].toFixed(3);
}
return <span className={styles.bar} key={i} style={barVars} />;
})}
</div>
</div>
<button className={styles.btn} type="button" aria-pressed={running} onClick={toggle}>
<svg className={styles.btnIco} aria-hidden="true">
<use href="#ico-microphone" />
</svg>
<span>{micLabel}</span>
</button>
<p className={styles.status} aria-live="polite">
<span>{status}</span>
<span className={styles.statusLoop} aria-hidden="true">
<i>마이크 권한을 기다립니다</i>
<i>마이크 입력 중 — 말해 보세요</i>
<i>권한 거부 — 합성음으로 대체했습니다</i>
</span>
</p>
</div>
</main>
);
}08 · マイク入力の可視化 — 計測 累積面積 2.504% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
入力をスピーカーへ戻さないのはハウリング防止で、許可待ち・入力中・拒否の3状態は aria-live の文で読めます。
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 を測って12段のセグメント LED を下から点灯し、いちばん高く到達した段には線が一本 900ms とどまります。セグメントは12分割の grid で、その上を clip-path が下から満たしていくので、満ちる量が段を越えるたびに1段ずつ点灯し、数値は steps(1, end) のカットで切り替わります。

<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>再生を押すと実際の音で動きます</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">入力レベル</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">再生</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 는 미터 눈금 보정 — 再生 음량은 바꾸지 않는다
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 ? '停止' : '再生';
// 자동재생 정책: 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 · 音量メーターとピークホールド — 計測 累積面積 2.922% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
動きは満ちていく区間と線が下りる区間に限られるので動いたフレームは 19/23 と9点で最も少なく、録音ソフトの入力レベルや配信画面のメーターに合います。
// 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');
}
peak.style.setProperty('--pkPx', (peakFrac * colWrap.clientHeight).toFixed(1) + 'px');
どこで壊れるか — 落とし穴
ここでいちばん時間を食ったのは、AnalyserNode が実際の再生中しか値を出さないという仕様そのものです。だからこの9点はどれも二つの経路を持っています — グリッドのプレビュー(.is-demo)は事前に計算したスペクトルの並びを CSS キーフレームで回し、再生ボタンが押された瞬間に JS がそのクラスを外して本物の Web Audio へ切り替わります。分けて書く理由は実測にあります — このサイトのレンダラーは Web Animations API でアニメーションの時計を直接進めて24枚を撮るので、requestAnimationFrame が描く値は一枚も撮られないのです。本物の音は人が押したときに鳴らし、撮られる静止画は CSS に任せる、という分担になりました。音源ファイルを置かないのも同じ設計から来ています — mp3 を配る代わりに OscillatorNode 4本(196・246.94・293.66・392 Hz)と GainNode で4拍ごとに和音が変わるコード進行をその場で合成するので、zip は軽いままです。なお AudioContext はユーザー操作の中で resume() しないと鳴らないので、再生ボタンの click の中で呼びます。
2つ目は 08 のフォールバックの音程と分解能の食い違いです。権限を拒否されたとき鳴らす 220Hz の正弦波は、fftSize 64 ではビンの幅が 750Hz になって棒に割り当てたビンに入らず、リングが沈んだままでした。fftSize を 256 に上げてビン幅 187.5Hz にすると 220Hz は読むビンに収まり、声の基本周波数も同じ範囲に来ます。
3つ目は 09 のメーターが止まって見える瞬間です。拍と拍の間は GainNode の包絡が毎回 0.02 まで減衰する区間なので、実測でも満ちる量 5.4%・表示 -36 dB まで下がります。壊れたのではなく音楽が休んでいるだけだと読めるよう、dB の数値を画面に残してあります。
もう一つ、プレビューのループの閉じ方にも実測が効いています。棒の波は負の遅延で位相をずらしています — 遅延が負なら2秒のループが端で自然に閉じますが、正の遅延だと撮影窓の終わりで棒が一斉に瞬間移動して、その継ぎ目が1枚に写り込みます。05 の文字だけは逆に正の遅延へ backwards を組み合わせ、遅延の間は 0% の静止姿勢を保たせて左から右へ波が走るようにしました。帯域ラベルやビート数の文字の交代は steps(1, end) のカットですが、ここへ fill-mode を付けると遅延の間に三つの文字が同じ枠に重なって写ります — 01 では実際に三枚の重なりがポスターに焼き付いたので、fill-mode を外して一枚に収めました。03 の流れるダッシュも、動かす総量を周期(56+124=180)と等しくして端で継ぎ目が出ないように閉じています。
08のマイク入力のように外から届いた信号を次の仕事へ渡す席は、n8n ワークフロー サンプルがノードでつないでいます。こうして二つの経路で揃えた9点のオリジナルは下の zip にあり、開くパスワードは 33t2w53s です。
3つのバリエーション
| 名前 | 変える値 | 感じ |
|---|---|---|
| 広い飛散 | 04 の --fly: 84px を 110px に |
ビートの粒が輪の外まで大きく広がり、ヒーロー背景の余白が生きます |
| 細かいビン | 08 の analyser.fftSize = 256 を 512 に |
ビン幅が 93.75Hz になり、声の起伏がもっと多くの棒に現れます |
| 長いピーク | 09 の now + 900 を now + 1500 に |
最高到達の線が長く残り、配信中の目標レベルが見やすくなります |
AIプロンプト
バイブコーディングで可視化を1つ足すときは、見えるもの・二つの経路・制約を一度に固定すると書き直しが減ります。下は 01 を頼んだときの断片です。
棒スペクトラムを1枚作る。
見えるもの: 暗い舞台の上に bs__bar を32本、左半分は右の鏡像。棒は下辺を transform-origin に scaleY で伸びる。配色は linear-gradient(180deg, $subject-mint 0%, $stage-yellow 60%, #ff9d2f 100%)。
動き: プレビューは bsWave キーフレームを animation-delay: calc(var(--i) * -56ms) で位相をずらして回す。再生が押されたら .is-demo を外し、getByteFrequencyData の値で --h に 0.06〜1.0 を書く。
制約: 音源ファイルは置かない。OscillatorNode 4本(196・246.94・293.66・392 Hz)で4拍ごとに和音を変える。AudioContext はクリックの中で resume() する。
禁止: AnalyserNode が鳴っていない間に値を取ること。棒が上辺から伸びること。
位相をずらして を 同じ位相で に置き換えると32本が一斉に伸び縮みして波が消えるので、棒が揃ってしまったらまずこの1語を疑います。
アクセシビリティ
prefers-reduced-motion: reduce では9点すべてのループを止めて、状態の読み取れる1枚を残します。01 は区間番号で対称のスペクトルを1枚立て、02 は sin() の山形の輪を1周分、03 は流れるダッシュを止めた固定波形を見せます。04 は薄いハローとコアだけ、05 は跳ねない文字と帯域の凡例、06 は初期値 210deg の一色、07 は 60% まで塗った山と、その塗り終わりに立つピン、08 は止まった輪と状態文、09 は 30% の静止メーターと dB 表示で立ちます。音の聞こえない人には、01・02・05 が「低音・中音・高音」の帯域ラベル、03 が「静か・揺れ・大きな波」の状態、04 が拾ったビート数、06 がエネルギー%、07 が aria-valuenow の再生位置、09 が dB の数値を aria-live か固定テキストで出します。08 のマイク権限の拒否は catch の正常経路で、合成音が同じ輪を継ぐので画面が空になりません。光過敏への配慮として、プレビューの明暗の山は2秒に3回(1.5Hz)まで — 06 は明度を 20%〜13% の帯に収めたまま色相だけを2秒で1往復させ、WCAG 2.3.1 の閾値の内側に置きます。ラベル類の実測コントラストは、インク舞台でクリーム文字が 17.11:1・値の箱で 10.78:1、イエロー舞台でインク文字が 12.63:1、オレンジ舞台でインクが 5.5:1・dB 値の箱が 7.37:1、07 の青い再生ボタンの白文字が 4.53:1、08 の状態文がイエローの上で 7.72:1 です。07 の再生時刻表示も同じ物差しで再計測し、現在時刻の数字は本文の青ではなく一段濃い #0a4fe8(5.65:1) に、コロン区切りはインク 72%(6.85:1) に上げました。
FAQ
音源ファイルは本当に要りませんか
要りません。この9点は mp3 を1つも読まず、OscillatorNode 4本と GainNode でコード進行をその場で合成し(4拍ごとに和音が変わる)、07 の山形だけは OfflineAudioContext で同じ進行を先にレンダリングした数値から作ります。自分の曲に差し替えるときは fetch と decodeAudioData で AudioBufferSourceNode を analyser へ繋ぐだけで、描画側はそのまま動きます。音を鳴らさずリズムだけを匂わせたい場所なら、CSS キーフレーム アニメーションの短い動きで足ります。
マイクの許可ダイアログが出ないのはなぜですか
getUserMedia は HTTPS か localhost のような安全なコンテキストでしか動きません。実測では Chrome 53・Firefox 36・Safari 11・Edge 12・iOS Safari 11.0 から対応で、全世界サポートは 96.05%(部分対応 0.34%)です。file:// で開いたページでは navigator.mediaDevices 自体が無いので、08 はその場合も拒否と同じ経路で合成音に落ち、画面が空になることはありません。
音楽 ビジュアライザー は Safari でも動きますか
Web Audio API 自体は全世界 96.41% が完全対応ですが、Safari は 6 が初出で長らく webkit 接頭辞が必要でした — デモは window.AudioContext || window.webkitAudioContext の2段で受けます。気をつけるのは周辺機能で、06 の @property は Safari 16.4・Firefox 128 から、07 の OfflineAudioContext は Safari 14.1 からです(caniuse の Audio API・MDN 実測 2026-09-12)。









