# AI 꾸러미 — 소리 보기 (Web Audio FFT) — FFT spectrum — Web Audio AnalyserNode
> 두 음을 실제로 울려 AnalyserNode 의 FFT 로 주파수 막대를 그리고 파형을 겹쳐, 맥놀이 · 화음 비(2:3)가 눈으로 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i52

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

## 주문서

### 만들어 줘: 소리 보기 (Web Audio FFT) — FFT spectrum — Web Audio AnalyserNode

#### 1. 목표
맥놀이 · 화음 체험을 만들어 줘 — 두 음을 울려 AnalyserNode FFT 로 주파수 막대를, 위에는 두 파형과 합친 파형을 그려 맥놀이가 보이게. 그림은 밤하늘색 바탕 네온 막대.

#### 2. 핵심 기술 용어
- **FFT spectrum — Web Audio AnalyserNode** — 소리를 주파수별 세기로 나눠 보기
- **getByteFrequencyData · fftSize · frequencyBinCount** — 주파수 칸 값 읽기 · 칸 수 = fftSize ÷ 2
- **Beat frequency |f1 − f2|** — 맥놀이 — 두 음 차이만큼 초마다 커졌다 작아짐
- **Frequency ratio (perfect fifth 2:3, octave 1:2)** — 어울리는 음의 진동수 비

#### 3. 환경
- 플랫폼: Web Audio API (TypeScript) — 소리 파일 없이 OscillatorNode · GainNode · BiquadFilterNode 로 합성
- 화면: 브라우저 — PC · 폰 모두 (폰은 첫 터치 뒤에만 소리가 난다)

#### 4. 조건
- 소리는 단추를 눌러야만 켠다 — AudioContext 는 그때 만들고, 끄면 close()
- 분석기 → 주 GainNode(작게) → 스피커 순서로, 분석은 크기와 상관없게
- 막대는 칸 번호가 아니라 Hz 범위로 묶는다 — 칸 Hz = sampleRate / fftSize
- 소리를 끈 상태에서도 그림이 보이게 (계산한 가짜 막대로) — 카드에서는 소리 없이
- 음 높이를 바꿀 땐 setTargetAtTime(v, now, 0.02) 로 — 뚝 바꾸면 딸깍

#### 5. 완성 기준 (이게 보이면 성공)
- 소리를 켜면 440Hz · 466Hz 자리에 막대 둘이 솟고, 위 노란 합친 파형이 초당 26번 커졌다 작아진다
- 두 번째 음을 880 으로 올리면 「1:2 옥타브」, 660 이면 「2:3 어울림」 표시가 뜬다
- 두 음을 가깝게 하면 맥놀이가 느려지고, 같으면 멈춘다

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

## 원리
- 두 발진기(440Hz · 466Hz)를 AnalyserNode 에 잇고, 분석기에서 주 음량(0.18)을 거쳐 스피커로 보낸다.
- fftSize = 8192 이면 칸 하나 = 표본률 ÷ 8192 (약 5.4Hz). getByteFrequencyData 로 칸마다 0 ~ 255 를 받는다.
- 200 ~ 1400Hz 를 막대 64개로 나눠, 막대마다 그 범위 칸들의 가장 큰 값을 높이로 쓴다.
- 두 사인을 더하면 |cos(π(f2 − f1)t)| 덮개로 크기가 출렁인다 — 466 − 440 = 초당 26번 맥놀이. f2/f1 = 1.5 면 완전5도, 2 면 옥타브.

## 핵심 코드 — 두 음 + AnalyserNode 로 주파수 막대 읽기
(발췌: demos/demosSim.ts i52 start() · draw() 의 막대 부분을 정리)
```ts
let ctx: AudioContext | null = null, an: AnalyserNode | null = null, freq: Uint8Array | null = null;
let o2: OscillatorNode | null = null;

function start(f1 = 440, f2 = 466) {
  ctx = new AudioContext();
  an = ctx.createAnalyser();
  an.fftSize = 8192;                                  // 칸 하나 ≈ 5.4Hz (48kHz 기준)
  an.smoothingTimeConstant = 0.7;
  freq = new Uint8Array(an.frequencyBinCount);
  const master = ctx.createGain();
  master.gain.value = 0.18;
  an.connect(master).connect(ctx.destination);
  const o1 = ctx.createOscillator();
  o2 = ctx.createOscillator();
  o1.frequency.value = f1;
  o2.frequency.value = f2;
  o1.connect(an); o2.connect(an);
  o1.start(); o2.start();
}
/** 200 ~ 1400Hz 를 막대 NB 개로 — 막대마다 그 범위 칸의 가장 큰 값 (0~1) */
function readBars(NB = 64, fLo = 200, fHi = 1400): Float32Array {
  const bars = new Float32Array(NB);
  if (!ctx || !an || !freq) return bars;
  an.getByteFrequencyData(freq);
  const binHz = ctx.sampleRate / an.fftSize;
  for (let b = 0; b < NB; b++) {
    const fa = fLo + ((fHi - fLo) * b) / NB, fb = fLo + ((fHi - fLo) * (b + 1)) / NB;
    let m = 0;
    for (let k = Math.floor(fa / binHz); k <= Math.ceil(fb / binHz); k++) m = Math.max(m, freq[k] ?? 0);
    bars[b] = m / 255;
  }
  return bars;
}
const setSecond = (v: number) => { if (o2 && ctx) o2.frequency.setTargetAtTime(v, ctx.currentTime, 0.02); };
const beat = (f1: number, f2: number) => Math.abs(f2 - f1);   // 초당 맥놀이 수
```

## 흔한 실수 · 확인 목록
- [ ] **막대를 칸 번호로 나누면 낮은 음 쪽이 몇 칸에 몰린다** — 칸은 Hz 로 고르게 나뉘어 있다. 보여 줄 Hz 범위를 정하고, 막대마다 그 범위 칸을 묶는다.
- [ ] **fftSize 를 작게 두면 440 · 466 이 막대 하나로 붙는다** — 칸 너비 = 표본률 ÷ fftSize. 26Hz 차이를 나누려면 8192 정도가 필요하다.
- [ ] **견본 카드가 많은 화면에서 저절로 소리가 난다** — 소리는 「크게 보기」의 단추로만 켠다. 카드에서는 계산한 막대로 그림만.
- [ ] **끄고 켤 때마다 AudioContext 를 새로 만들고 안 닫으면 쌓인다** — 끌 때 ctx.close() — 브라우저마다 동시에 열 수 있는 수가 정해져 있다.

## 완성 기준 체크리스트
- [ ] 소리를 켜면 440Hz · 466Hz 자리에 막대 둘이 솟고, 위 노란 합친 파형이 초당 26번 커졌다 작아진다
- [ ] 두 번째 음을 880 으로 올리면 「1:2 옥타브」, 660 이면 「2:3 어울림」 표시가 뜬다
- [ ] 두 음을 가깝게 하면 맥놀이가 느려지고, 같으면 멈춘다

## 이 기술 정보
- id: `i52` · 분류: 소리 › 소리 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — FFT 8192 는 분석기가 알아서 계산 — 한 프레임에 배열 하나 읽기. 막대 64개 그리기는 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i52
- 쓰면 좋을 때: 소리 · 진동수 · 화음을 눈으로 설명할 때 / 음악에 맞춰 움직이는 막대 · 배경
- 쓰지 말 때: 박자만 알고 싶을 때 — 저음 에너지로 박 찾기(i163)가 더 단순하다 / 정확한 음 높이 측정 (튜너) — FFT 칸이 거칠어 자기상관 같은 다른 방법이 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i52 견본 항목 — `src/demos/demosSim.ts:4407`
```ts
  i52: {
    kind: '2d',
    caption: '두 음(분홍 · 파랑)을 더하면 노란 물결이 커졌다 작아졌다 — 맥놀이. 아래는 FFT 주파수 막대. 크게 보기에서 실제 소리를 켤 수 있어요',
    make() {
      let f1 = 440;
      let f2 = 466;
      let ctx: AudioContext | null = null;
      let o1: OscillatorNode | null = null;
      let o2: OscillatorNode | null = null;
      let an: AnalyserNode | null = null;
      let freq: Uint8Array<ArrayBuffer> | null = null;
      const stop = (): void => {
        if (ctx) void ctx.close();
        ctx = null;
        o1 = o2 = null;
        an = null;
      };
      const start = (): void => {
        const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
        ctx = new AC();
        an = ctx.createAnalyser();
        an.fftSize = 8192;
        an.smoothingTimeConstant = 0.7;
        freq = new Uint8Array(an.frequencyBinCount);
        const master = ctx.createGain();
        master.gain.value = 0.18;
        an.connect(master);
        master.connect(ctx.destination);
        o1 = ctx.createOscillator();
        o2 = ctx.createOscillator();
        o1.frequency.value = f1;
        o2.frequency.value = f2;
        o1.connect(an);
        o2.connect(an);
        o1.start();
        o2.start();
      };
      const bars = new Float32Array(64);
      return {
        draw(g, w, h, t) {
          bg(g, w, h, '#16133a', '#2c2462');
          const fs = clamp(h * 0.05, 9, 18);
          // 위: 파형
          const top = h * 0.12;
          const wh = h * 0.42;
          const mid = top + wh / 2;
          const win = 2 / Math.max(4, Math.abs(f2 - f1));
          const shift = t * 0.15;
          const Wv = (f: number, x: number): number => Math.sin(TAU * f * (shift + (x / w) * win));
          const amp = wh * 0.22;
          const lineOf = (fn: (x: number) => number, col: string, lw: number, yOff: number, a: number): void => {
            g.strokeStyle = col;
            g.lineWidth = lw;
            g.globalAlpha = a;
            g.beginPath();
            for (let x = 0; x <= w; x += 1) {
              const y = yOff + fn(x);
              if (x === 0) g.moveTo(x, y);
              else g.lineTo(x, y);
            }
            g.stroke();
            g.globalAlpha = 1;
          };
          lineOf((x) => Wv(f1, x) * amp * 0.45, '#ff7aa8', 1, top + wh * 0.08, 0.8);
          lineOf((x) => Wv(f2, x) * amp * 0.45, '#6ac0ff', 1, top + wh * 0.24, 0.8);
          lineOf((x) => (Wv(f1, x) + Wv(f2, x)) * amp * 0.5, '#ffd84a', 1.3, mid + wh * 0.2, 1);
          // 덮개 (맥놀이)
          g.setLineDash([4, 4]);
          lineOf((x) => -Math.abs(Math.cos(Math.PI * (f2 - f1) * (shift + (x / w) * win))) * amp, 'rgba(255,255,255,0.7)', 1.2, mid + wh * 0.2, 1);
          lineOf((x) => Math.abs(Math.cos(Math.PI * (f2 - f1) * (shift + (x / w) * win))) * amp, 'rgba(255,255,255,0.7)', 1.2, mid + wh * 0.2, 1);
          g.setLineDash([]);
          // 아래: 주파수 막대
          const by = h * 0.94;
          const bh = h * 0.3;
          const NB = bars.length;
          const fLo = 200;
          const fHi = 1400;
          if (an && freq) {
            an.getByteFrequencyData(freq);
            const binHz = ctx!.sampleRate / an.fftSize;
            for (let b = 0; b < NB; b++) {
              const fa = fLo + ((fHi - fLo) * b) / NB;
              const fb = fLo + ((fHi - fLo) * (b + 1)) / NB;
              let m = 0;
              for (let k = Math.floor(fa / binHz); k <= Math.ceil(fb / binHz); k++) m = Math.max(m, freq[k] ?? 0);
              bars[b] = m / 255;
            }
          } else {
            for (let b = 0; b < NB; b++) {
              const fc = fLo + ((fHi - fLo) * (b + 0.5)) / NB;
              const bw = (fHi - fLo) / NB;
              const pk = (f: number): number => Math.exp(-(((fc - f) / (bw * 0.7)) ** 2));
              const target = Math.min(1, pk(f1) + pk(f2) + 0.05 + 0.03 * Math.sin(t * 9 + b));
              bars[b] = lerp(bars[b]!, target * (0.85 + 0.15 * Math.sin(t * 6 + b * 0.7)), 0.25);
            }
          }
          const bw = w / NB;
          for (let b = 0; b < NB; b++) {
            const v = bars[b]!;
            const col = new THREE.Color().setHSL(0.75 - (b / NB) * 0.6, 0.85, 0.6);
            g.fillStyle = `#${col.getHexString()}`;
            g.fillRect(b * bw + 1, by - v * bh, bw - 2, v * bh);
          }
          g.fillStyle = 'rgba(255,255,255,0.4)';
          g.fillRect(0, by, w, 1);
          const ratio = f2 / f1;
          const harm = Math.abs(ratio - 1.5) < 0.01 ? ' · 2:3 어울림(완전5도)' : Math.abs(ratio - 2) < 0.01 ? ' · 1:2 옥타브' : '';
          pill(g, `${f1}Hz + ${f2}Hz → 맥놀이 ${Math.abs(f2 - f1)}번/초${harm}`, 8, fs * 1.3, fs, 'rgba(0,0,0,0.4)');
          text(g, ctx ? '🔊 실제 소리 분석 중' : '그림만 (소리 끔)', w - 8, by - bh - fs * 0.6, fs * 0.85, ctx ? '#7affa0' : '#a8b0e0', 'right');
        },
        controls: [
          { type: 'button', label: '소리 켜기 / 끄기', on: () => (ctx ? stop() : start()) },
          {
            type: 'range',
            label: '두 번째 음 (Hz)',
            min: 440,
            max: 880,
            step: 2,
            value: 466,
            on: (v) => {
              f2 = v;
              if (o2 && ctx) o2.frequency.setTargetAtTime(v, ctx.currentTime, 0.02);
            },
          },
        ],
        dispose: stop,
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [박자 검출 · 반응 그림](https://ai-techstudio.web.app/ai/t/i163.md) `i163`
- 참고 문서: [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) · [Bartosz Ciechanowski — Sound](https://ciechanow.ski/sound/)
