07入力レベルの値がバーの高さを揺らす聴き取り中波形
マイク入力の大きさを模した値をバーごとに変え、その値が上下するたびバーの高さが追従して揺れ、今まさに音を聴いている状態を見せる。
使いどころAI音声アシスタントの聴き取り中の表示、音声コマンド入力ボタン
seededfreqABASE_H

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">
<svg class="ld__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>クリックするかスペースバーを押すと一時停止または再開します</span>
</p>
<div class="ld__field" id="field" role="application" aria-label="リスニング波形テストパネル" tabindex="0">
<div class="ld__bars" id="bars"></div>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">聞き取り中</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// listening-waveform-mic — 마이크 입력 크기를 흉내 낸 합성 진폭 값을 막대마다 다르게 주고,
// 그 값이 오르내릴 때마다 막대 높이가 따라 흔들린다. 실 마이크·getUserMedia·Web Audio 없음 —
// 고정 시드(seeded) 두 사인파 합성 + rAF. @keyframes 없음.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.ld {
--bar: #{$subject-cream};
--bar-dim: #{rgba($subject-cream, .34)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ld__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.ld__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-cream; }
.ld__field {
position: relative; height: 208px;
display: flex; align-items: center; justify-content: center;
border-radius: $r-card; overflow: hidden;
background: rgba($stage-paper, .08);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .22);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.ld__bars {
display: flex; align-items: flex-end; justify-content: center;
gap: 4px; height: 100px; width: 100%; padding: 0 $sp-4;
}
.ld__bars > i {
display: block; flex: 1 1 auto; max-width: 10px;
border-radius: $r-xs;
background: var(--bar);
height: 8px;
box-shadow: 0 0 8px rgba($subject-cream, .35);
}
.ld__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($stage-ink, .48);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 11px; color: $subject-cream; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.ld__bars { height: 60px; gap: 4px; padding: 0 $sp-2; }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__badge { opacity: .92 !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);
}
.ld {
--bar: #fff7e6;
--bar-dim: rgba(255, 247, 230, 0.34);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ld__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.ld__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #fff7e6;
}
.ld__field {
position: relative;
height: 208px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.22);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ld__bars {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
height: 100px;
width: 100%;
padding: 0 16px;
}
.ld__bars > i {
display: block;
flex: 1 1 auto;
max-width: 10px;
border-radius: 4px;
background: var(--bar);
height: 8px;
box-shadow: 0 0 8px rgba(255, 247, 230, 0.35);
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.48);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 11px;
color: #fff7e6;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.ld__bars {
height: 60px;
gap: 4px;
padding: 0 8px;
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
var LOOP = 2.0;
var N = 20; // 막대 수
var BASE_H = 8; // 막대 최소 높이(px)
var MAX_H = 92; // 막대 최대 높이(px)
var root = document.getElementById('ld');
var field = document.getElementById('field');
var barsWrap = document.getElementById('bars');
var pctEl = document.getElementById('pct');
var paused = false;
var t0 = performance.now();
var raf = 0;
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function seeded(i) { var v = Math.sin(i * 12.9898) * 43758.5453; return v - Math.floor(v); }
var bars = [];
var bels = [];
for (var i = 0; i < N; i++) {
bars.push({
freqA: 2.1 + seeded(i) * 1.8, // 마이크 입력 크기 값을 흉내 낸 합성 진폭 — 기본 진동수
freqB: 4.7 + seeded(i + 50) * 2.4, // 보조 진동수(잔떨림)
phase: seeded(i + 90) * Math.PI * 2
});
var b = document.createElement('i');
barsWrap.appendChild(b);
bels.push(b);
}
function draw(sec) {
var t = sec;
for (var i = 0; i < N; i++) {
var q = bars[i];
var env = 0.5 + 0.5 * Math.sin(t * q.freqA * Math.PI * 2 + q.phase);
var jit = Math.sin(t * q.freqB * Math.PI * 2 + q.phase * 1.7) * 0.22;
var level = clamp01(env + jit);
var h = BASE_H + (MAX_H - BASE_H) * level;
bels[i].style.height = h.toFixed(1) + 'px';
bels[i].style.opacity = (0.55 + level * 0.45).toFixed(2);
}
var avg = 0;
for (var j = 0; j < N; j++) avg += parseFloat(bels[j].style.height);
avg = Math.round((avg / N - BASE_H) / (MAX_H - BASE_H) * 100);
pctEl.textContent = avg > 8 ? '聞き取り中' : '待機';
}
function togglePause() { paused = !paused; root.classList.toggle('is-paused', paused); }
function restart() { t0 = performance.now(); draw(0); }
function onKey(e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
}
field.addEventListener('click', togglePause);
field.addEventListener('keydown', onKey);
function onResize() {
if (paused || reduced()) draw((performance.now() - t0) / 1000 % LOOP);
}
window.addEventListener('resize', onResize);
function frame(now) {
if (!paused) draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
if (reduced()) {
root.classList.add('is-still');
draw(LOOP * 0.62);
} else {
raf = requestAnimationFrame(frame);
}
})();// WaveformMic — vanilla 판(run/403/demo/07-ai-processing-loading-listening-waveform-mic/index.html) 의 React 포트.
// 마이크 입력 크기를 흉내 낸 합성 진폭 값을 막대마다 다르게 주고, 그 값이 오르내릴 때마다
// 막대 높이가 따라 흔들려 지금 소리를 듣고 있는 상태를 보여준다. 실 마이크·getUserMedia 없음 —
// 고정 시드(seeded) 두 사인파 합성 + rAF.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./WaveformMic.module.scss";
export type WaveformMicProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const LOOP = 2.0;
const N = 20;
const BASE_H = 8;
const MAX_H = 92;
const FIELD_LABEL = "듣는 중 파형 실험판";
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
function seeded(i: number): number { const v = Math.sin(i * 12.9898) * 43758.5453; return v - Math.floor(v); }
type Bar = { freqA: number; freqB: number; phase: number };
const BARS: Bar[] = (() => {
const arr: Bar[] = [];
for (let i = 0; i < N; i++) {
arr.push({
freqA: 2.1 + seeded(i) * 1.8,
freqB: 4.7 + seeded(i + 50) * 2.4,
phase: seeded(i + 90) * Math.PI * 2,
});
}
return arr;
})();
export default function WaveformMic({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: WaveformMicProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const barRefs = useRef<(HTMLElement | null)[]>([]) as Box<(HTMLElement | null)[]>;
const pctRef = useRef<HTMLSpanElement | null>(null);
const pausedRef = useRef(false) as Box<boolean>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function draw(sec: number): void {
const t = sec;
let sum = 0;
for (let i = 0; i < N; i++) {
const q = BARS[i];
const env = 0.5 + 0.5 * Math.sin(t * q.freqA * Math.PI * 2 + q.phase);
const jit = Math.sin(t * q.freqB * Math.PI * 2 + q.phase * 1.7) * 0.22;
const level = clamp01(env + jit);
const h = BASE_H + (MAX_H - BASE_H) * level;
const el = barRefs.current[i];
if (el) {
el.style.height = h.toFixed(1) + "px";
el.style.opacity = (0.55 + level * 0.45).toFixed(2);
}
sum += h;
}
const avg = Math.round((sum / N - BASE_H) / (MAX_H - BASE_H) * 100);
const pctEl = pctRef.current;
if (pctEl) pctEl.textContent = avg > 8 ? "듣는 중" : "대기";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
}
function restart(): void { t0Ref.current = performance.now(); draw(0); }
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void { togglePause(); }
function onResize(): void {
if (pausedRef.current || reduced()) draw((performance.now() - t0Ref.current) / 1000 % LOOP);
}
function frame(now: number): void {
if (!pausedRef.current) draw((now - t0Ref.current) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
draw(LOOP * 0.62);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 56v56a8 8 0 0 1-16 0V91.3l-25.6 25.7A80 80 0 1 1 174 60a8 8 0 1 1-9 13.2A64 64 0 1 0 179.7 128H216a8 8 0 0 1 0 16h-56a8 8 0 0 1-8-8V80a8 8 0 0 1 16 0v20.7L192.7 76A80.1 80.1 0 0 0 64 128a8 8 0 0 1-16 0a96 96 0 0 1 163.6-68.3L224 71.3V56a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48a8 8 0 0 0-8 8v15.3l-11.7-11.6a96 96 0 1 0 27.6 51.6a8 8 0 0 0-15.8 2.5a80 80 0 1 1-23-43l-25.7 25.7A8 8 0 0 0 173 112h56a8 8 0 0 0 8-8V56a8 8 0 0 0-13-8"/></g></svg>
<span>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<div className={styles.bars}>
{BARS.map((_, i) => (
<i key={i} ref={(el: HTMLElement | null) => { barRefs.current[i] = el; }}></i>
))}
</div>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>듣는 중</span></b>
</div>
</div>
</main>
);
}07 入力レベルの値がバーの高さを揺らす聴き取り中波形 — 計測 累積面積 17.854% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · AI処理中 アイコン 9パターン — 値で動くローディング
このデモが属する記事
AI処理中 アイコン 9パターン — 値で動くローディング処理中 アイコンとは:軌道半径の値が大きいと粒子は広く散らばるが、値が0に近づくとコアに集まり、その間ハローリングの色相の値は完了色へ動き、トークン番号の値が上がるたびプレビューの文字が一つずつ増える。 この記事のデモをすべて見る

