# AI 꾸러미 — 음악 반응 시각화 (주파수 띠 · 엔벨로프 · 박 고리) — AnalyserNode · getByteFrequencyData
> 음악을 주파수 띠로 나눠 저 · 중 · 고 세기를 「오를 땐 빠르게, 내릴 땐 천천히」 따라가고 — 저음은 원이 숨쉬고, 박마다 고리가 퍼지고, 고음은 반짝인다. 영상으로 구울 땐 소리를 미리 분석해 표로.  
> 견본: https://ai-techstudio.web.app/#t/i557

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 음악 반응 시각화 (주파수 띠 · 엔벨로프 · 박 고리) — AnalyserNode · getByteFrequencyData

#### 1. 목표
리듬 게임 배경에 음악 반응 시각화를 만들어 줘 — 주파수를 저 · 중 · 고로 나눠 엔벨로프로 부드럽게 따라가고, 저음은 크게 숨쉬고, 박마다 고리가 퍼지고, 고음은 반짝이게. 분위기는 어두운 바탕에 네온 원형 막대.

#### 2. 핵심 기술 용어
- **AnalyserNode · getByteFrequencyData** — Web Audio 가 실시간으로 주는 주파수별 세기
- **Log-spaced frequency bands** — 귀처럼 낮은 쪽은 촘촘히, 높은 쪽은 넓게 묶은 띠
- **Envelope follower (attack · release)** — 오를 땐 빨리, 내릴 땐 천천히 따라가는 부드러운 값
- **Spectral flux onset** — 저음 세기가 한 프레임 사이에 훅 오르면 「박」
- **Offline analysis (OfflineAudioContext)** — 영상 굽기용 — 소리를 미리 그려 프레임마다 세기 표를 만들어 둠

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표
- 실시간: AudioContext → AnalyserNode(fftSize 2048, smoothingTimeConstant 0) → getFloatFrequencyData 로 dB 를 받아 띠로 묶기. 첫 터치 뒤에만 소리가 난다. 영상 굽기: OfflineAudioContext 로 미리 분석해 프레임별 표로.

#### 4. 조건
- AudioContext 는 사용자가 누른 뒤에 만들거나 resume 할 것 (폰 · 크롬 자동 재생 막힘)
- 세기는 dB 로 바꾼 뒤 0 ~ 1 로 — 날것 진폭을 그대로 쓰지 않기
- 띠는 로그 간격으로 — 고르게 나누면 막대 대부분이 고음에 몰려 거의 안 움직인다
- 엔벨로프는 오름 · 내림 시간을 따로. 내림(릴리스)은 0.1 ~ 0.4초
- 저 · 중 · 고에 서로 다른 움직임을 맡길 것
- 영상 굽기용이면 미리 분석한 표를 쓰고, 프레임 시각 = 소리 시각이 되게

#### 5. 완성 기준 (이게 보이면 성공)
- 킥이 칠 때마다 가운데 원이 커졌다 스르르 줄고 고리가 하나 퍼진다
- 하이햇이 칠 때 작은 반짝이가 늘고, 막대가 원 둘레로 주파수 순서대로 움직인다
- 「엔벨로프 따라가기」를 끄면 막대 · 원이 바들바들 떨리고, 켜면 부드럽다
- 「▶ 소리 듣기」를 누르면 실제 소리와 그림의 박이 맞는다
- 오른쪽 저 · 중 · 고 막대에서 날것(흰 선)과 엔벨로프(색 막대)의 차이가 보인다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 띠 나누기: 40Hz ~ 8kHz 를 로그 간격 32띠로 (fc = 40 × 200^((b+0.5)/32)). 세기는 dB 로 바꿔 −54 ~ 0 dB 를 0 ~ 1 로 — 그대로 쓰면 저음만 크고 고음은 안 보인다. 높은 띠는 조금 올려 준다.
- 엔벨로프: 값 += (새 값 − 값) × (오를 땐 1 − e^(−dt/10ms), 내릴 땐 1 − e^(−dt/릴리스)). 날것은 바들바들 떨리고, 릴리스 0.2초쯤이면 「쿵 — 스르르」 음악처럼 보인다.
- 역할 나누기: 저음 → 크기 · 숨쉬기 · 번쩍, 중음 → 회전 · 흐름 속도, 고음 → 반짝이 · 작은 입자. 같은 값을 모든 데 쓰면 화면 전체가 한 덩어리로 출렁여 어지럽다.
- 박: 저음 세기의 증가량(flux)이 문턱을 넘으면 고리 하나를 퍼뜨린다. 평균보다 큰지 보는 방식(i163)보다 박 시작에 더 빨리 반응한다.
- 영상으로 구울 땐(i82) 프레임을 실시간보다 느리게 찍으므로 AnalyserNode 를 못 쓴다 — 소리를 OfflineAudioContext 로 그려 프레임마다 띠 세기를 표로 만들고, 프레임 시각으로 표를 읽는다. 견본은 늘 이 방식이라 소리를 켜도 그림과 맞는다.

## 핵심 코드 — 실시간 띠 세기 + 엔벨로프 + 박
(발췌: 새로 씀 (demos/demosMotionE.ts i557 은 같은 식을 미리 분석한 표에 씀))
```ts
const ana = ac.createAnalyser();
ana.fftSize = 2048;
ana.smoothingTimeConstant = 0;            // 부드럽게는 직접 (오름 · 내림 따로)
source.connect(ana);
const db = new Float32Array(ana.frequencyBinCount);
const NB = 32, env = new Float32Array(NB);
let prevBass = 0;

function frame(dt: number) {
  ana.getFloatFrequencyData(db);         // 칸마다 dB (−∞ ~ 0)
  const hz = ac.sampleRate / ana.fftSize; // 칸 하나의 폭
  const up = 1 - Math.exp(-dt / 0.01), dn = 1 - Math.exp(-dt / 0.22);
  for (let b = 0; b < NB; b++) {
    const lo = 40 * Math.pow(200, b / NB), hi = 40 * Math.pow(200, (b + 1) / NB);
    let s = -100, n = 0;
    for (let k = Math.floor(lo / hz); k <= Math.ceil(hi / hz); k++) { s = Math.max(s, db[k]); n++; }
    const v = Math.min(1, Math.max(0, (s + 54 + b * 0.45) / 54));
    env[b] += (v - env[b]) * (v > env[b] ? up : dn);
  }
  const bass = (env[0] + env[1] + env[2] + env[3] + env[4] + env[5]) / 6;
  if (bass - prevBass > 0.08) spawnRing();  // 박 = 저음이 훅 오름
  prevBass = bass;
}
```

## 흔한 실수 · 확인 목록
- [ ] **막대가 대부분 안 움직이고 왼쪽 몇 개만 춤춘다** — FFT 칸은 주파수가 고르게 나뉘어 음악 에너지가 몰린 저음 쪽은 몇 칸뿐이다. 로그 간격 띠로 묶고 dB 로 바꾼다.
- [ ] **그림이 바들바들 떨려 어지럽다** — smoothingTimeConstant 하나로는 오름도 느려진다. 오름 10ms · 내림 0.2초짜리 엔벨로프를 직접 둔다.
- [ ] **구운 영상에서 그림과 소리가 어긋난다** — 프레임 찍기는 실시간보다 느려 AnalyserNode 가 다른 순간을 준다. OfflineAudioContext 로 미리 분석한 표를 프레임 시각으로 읽는다.
- [ ] **폰에서 소리 · 그림이 안 움직인다** — AudioContext 가 suspended 상태다. 첫 터치에서 ac.resume().
- [ ] **바탕 번쩍임이 너무 잦다** — 박 사이 최소 간격(0.15초)을 두고 번쩍 세기를 낮춘다 — 광과민 안전.

## 완성 기준 체크리스트
- [ ] 킥이 칠 때마다 가운데 원이 커졌다 스르르 줄고 고리가 하나 퍼진다
- [ ] 하이햇이 칠 때 작은 반짝이가 늘고, 막대가 원 둘레로 주파수 순서대로 움직인다
- [ ] 「엔벨로프 따라가기」를 끄면 막대 · 원이 바들바들 떨리고, 켜면 부드럽다
- [ ] 「▶ 소리 듣기」를 누르면 실제 소리와 그림의 박이 맞는다
- [ ] 오른쪽 저 · 중 · 고 막대에서 날것(흰 선)과 엔벨로프(색 막대)의 차이가 보인다

## 이 기술 정보
- id: `i557` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 실시간 분석은 브라우저가 해 줘 거의 공짜. 그리기는 막대 64개 · 원 몇 개. 미리 분석(4초, 프레임 240 × 띠 32)은 처음 한 번 수십 ms.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i557
- 쓰면 좋을 때: 리듬 게임 · 음악 플레이어 · 배경 연출 / 음악에 맞춘 소개 영상 · 쇼츠 — 미리 분석해 굽기
- 쓰지 말 때: 게임 판 자체가 출렁이면 안 될 때 — 배경 층에만 / 깜박임에 민감한 사람 — 바탕 번쩍은 세기를 낮추고 1초에 3번 넘게 번쩍이지 않게

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### musicViz — `src/demos/demosMotionE.ts:252`
```ts
function musicViz(): ReturnType<Demo2D['make']> {
  const o = { rel: 0.22, gain: 1, smooth: true };
  let sp: Spec557 | null = null;
  void spec557().then((s) => (sp = s));
  let ac: AudioContext | null = null;
  let src: AudioBufferSourceNode | null = null;
  let t0 = 0;
  const sm = new Float32Array(NB);
  const env3 = [0, 0, 0];
  let prevBass = 0;
  let ring = 9;
  let clock = 0;
  const RANGES: [number, number][] = [
    [0, 6],
    [6, 20],
    [20, 32],
  ];
  const stop = (): void => {
    src?.stop();
    src = null;
  };
  const play = (): void => {
    if (!sp) return;
    if (src) return stop();
    ac ??= new AudioContext();
    void ac.resume();
    src = ac.createBufferSource();
    src.buffer = sp.buf;
    src.loop = true;
    src.connect(ac.destination);
    t0 = ac.currentTime + 0.05;
    src.start(t0);
  };

  return {
    draw(g: G, w: number, h: number, _t: number, dt: number) {
      g.fillStyle = '#07080f';
      g.fillRect(0, 0, w, h);
      if (!sp) {
        g.fillStyle = '#8890b0';
        g.font = `600 ${Math.max(9, h / 16)}px ${F}`;
        g.textAlign = 'center';
        g.fillText('소리 분석 중…', w / 2, h / 2);
        g.textAlign = 'left';
        return;
      }
      clock += Math.min(dt, 0.1);
      // 소리를 켜면 재생 시계, 아니면 화면 시계 — 둘 다 같은 미리 잰 표를 읽어 그림과 소리가 맞는다
      const tt = src && ac ? Math.max(0, ac.currentTime - t0) : clock;
      const ff = ((tt % sp.dur) / sp.dur) * sp.frames;
      const f0 = Math.floor(ff) % sp.frames;
      const f1 = (f0 + 1) % sp.frames;
      const fk = ff - Math.floor(ff);
      const raw = new Float32Array(NB);
      for (let b = 0; b < NB; b++) raw[b] = clamp01(lerp(sp.bands[f0 * NB + b]!, sp.bands[f1 * NB + b]!, fk) * o.gain);
      // 엔벨로프 따라가기: 오를 땐 빠르게(10ms), 내릴 땐 천천히(rel)
      const d = Math.min(dt, 0.1);
      const up = 1 - Math.exp(-d / 0.01);
      const dn = 1 - Math.exp(-d / o.rel);
      for (let b = 0; b < NB; b++) {
        const v = raw[b]!;
        sm[b] = o.smooth ? sm[b]! + (v - sm[b]!) * (v > sm[b]! ? up : dn) : v;
      }
      const rawG = RANGES.map(([a, b]) => {
        let s = 0;
        for (let i = a; i < b; i++) s += raw[i]!;
        return s / (b - a);
      });
      for (let i = 0; i < 3; i++) {
        const v = rawG[i]!;
        env3[i] = o.smooth ? env3[i]! + (v - env3[i]!) * (v > env3[i]! ? up : dn) : v;
      }
      // 박 = 저음이 한 프레임 사이에 훅 오름 (변화량 flux)
      const flux = rawG[0]! - prevBass;
      prevBass = rawG[0]!;
      if (flux > 0.08) ring = 0;
      ring += d;
      const [bass, mid, high] = env3 as [number, number, number];

      const cx = w * 0.42;
      const cy = h * 0.5;
      const R = Math.min(w, h) * 0.2;
      // 박마다 퍼지는 고리 + 바탕 번쩍
      if (ring < 0.6) {
        const k = ring / 0.6;
        g.fillStyle = `rgba(255,90,140,${(0.12 * (1 - k)).toFixed(3)})`;
        g.fillRect(0, 0, w, h);
        g.strokeStyle = `rgba(255,140,190,${(0.8 * (1 - k)).toFixed(3)})`;
        g.lineWidth = 2;
        g.beginPath();
        g.arc(cx, cy, R * (1.1 + k * 1.6), 0, Math.PI * 2);
        g.stroke();
      }
      // 고음 = 반짝이 별
      for (let i = 0; i < 40; i++) {
        const a = clamp01(high * 1.6 - hsh(i * 7.7 + Math.floor(clock * 8)) * 0.9);
        if (a <= 0) continue;
        g.fillStyle = `rgba(200,230,255,${a.toFixed(2)})`;
        const s = 1 + hsh(i * 3.3) * 1.5;
        g.fillRect(hsh(i * 1.9) * w * 0.84, hsh(i * 5.1) * h, s, s);
      }
      // 띠 막대를 원 둘레로 (좌우 거울) — 중음이 클수록 천천히 돈다
      const rot = clock * 0.15 + mid * 0.6;
      g.lineCap = 'round';
      g.lineWidth = Math.max(1.5, R * 0.09);
      for (let b = 0; b < NB; b++) {
        const v = sm[b]!;
        const len = R * 0.12 + v * R * 1.25;
        g.strokeStyle = `hsl(${(200 + b * 4.5).toFixed(0)},90%,${(45 + v * 25).toFixed(0)}%)`;
        for (const sgn of [1, -1]) {
          const a = rot + sgn * ((b + 0.5) / NB) * Math.PI - Math.PI / 2;
          const r0 = R * (1 + bass * 0.3) + 3;
          g.beginPath();
          g.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0);
          g.lineTo(cx + Math.cos(a) * (r0 + len), cy + Math.sin(a) * (r0 + len));
          g.stroke();
        }
      }
      g.lineCap = 'butt';
      // 저음 = 가운데 원이 숨쉬듯 커짐
      const rr = R * (0.85 + bass * 0.3);
      const gr = g.createRadialGradient(cx, cy, rr * 0.1, cx, cy, rr);
      gr.addColorStop(0, '#ffd1e6');
      gr.addColorStop(1, `hsl(330,85%,${(35 + bass * 25).toFixed(0)}%)`);
      g.fillStyle = gr;
      g.beginPath();
      g.arc(cx, cy, rr, 0, Math.PI * 2);
      g.fill();
      // 오른쪽: 날것(가는 선) 과 엔벨로프(굵은 막대)
      const mx = w * 0.84;
      const mw = w * 0.035;
      const mh = h * 0.62;
      const my = h * 0.16;
      const fs = Math.max(8, h / 20);
      g.font = `600 ${fs}px ${F}`;
      g.textAlign = 'center';
      ['저', '중', '고'].forEach((lab, i) => {
        const x = mx + (i - 1) * mw * 1.9;
        g.fillStyle = 'rgba(255,255,255,0.08)';
        g.fillRect(x - mw / 2, my, mw, mh);
        g.fillStyle = ['#ff5a8c', '#59c3ff', '#d6ecff'][i]!;
        const e = env3[i]!;
        g.fillRect(x - mw / 2, my + mh * (1 - e), mw, mh * e);
        g.fillStyle = '#fff';
        g.fillRect(x - mw / 2 - 2, my + mh * (1 - rawG[i]!) - 1, mw + 4, 2);
        g.fillStyle = '#aab';
        g.fillText(lab, x, my + mh + fs * 1.2);
      });
      g.textAlign = 'left';
      if (src) {
        g.fillStyle = '#ffd34d';
        g.fillText('♪ 재생 중', 6, fs * 1.3);
      }
    },
    controls: [
      { type: 'button', label: '▶ 소리 듣기 / 멈춤', on: play },
      { type: 'toggle', label: '엔벨로프 따라가기 (끄면 날것)', value: o.smooth, on: (v) => (o.smooth = v) },
      { type: 'range', label: '내려오는 시간(초)', min: 0.03, max: 0.8, step: 0.01, value: o.rel, on: (v) => (o.rel = v) },
      { type: 'range', label: '반응 세기', min: 0.5, max: 1.6, step: 0.05, value: o.gain, on: (v) => (o.gain = v) },
    ],
    dispose() {
      stop();
      void ac?.close();
    },
  };
}
```

### i557 견본 항목 — `src/demos/demosMotionE.ts:802`
```ts
  i557: { kind: '2d', caption: '미리 잰 32개 주파수 띠 → 저 · 중 · 고 엔벨로프: 저음은 원이 숨쉬고, 박마다 고리, 고음은 반짝이', make: musicViz }
```

## 관련 기술
- 먼저 알면 좋은 기술: [소리 보기 (Web Audio FFT)](https://ai-techstudio.web.app/ai/t/i52.md) `i52` · [박자 검출 · 반응 그림](https://ai-techstudio.web.app/ai/t/i163.md) `i163`
- 다음에 해 볼 기술: [박자 맞춰 연출 (비트 동기)](https://ai-techstudio.web.app/ai/t/i438.md) `i438` · [영상으로 굽기 (헤드리스 브라우저 → mp4)](https://ai-techstudio.web.app/ai/t/i82.md) `i82`
- 참고 문서: [MDN — AnalyserNode](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode) · [MDN — Visualizations with Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Visualizations_with_Web_Audio_API) · [MDN — OfflineAudioContext](https://developer.mozilla.org/en-US/docs/Web/API/OfflineAudioContext)
