# AI 꾸러미 — 박자 검출 · 반응 그림 — Beat detection (energy-based onset detection)
> 음악의 150Hz 아래 저음 에너지를 매 프레임 재서 최근 평균의 1.35배를 넘으면 「박」으로 잡아, 그림이 음악에 맞춰 통통 튀게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i163

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

## 주문서

### 만들어 줘: 박자 검출 · 반응 그림 — Beat detection (energy-based onset detection)

#### 1. 목표
음악에 맞춰 튀는 캐릭터 · 배경에 박자 검출을 넣어 줘 — 음악의 저음 에너지가 최근 평균보다 확 커지면 박으로 잡아 그림이 튀게. 느낌은 통통 튀는 귀여운.

#### 2. 핵심 기술 용어
- **Beat detection (energy-based onset detection)** — 박자 검출 — 저음 에너지가 갑자기 커지는 순간
- **Low-frequency energy · AnalyserNode** — 낮은 주파수 칸 세기 합
- **Moving average threshold** — 최근 평균 × 민감도 — 넘으면 박
- **Refractory period** — 박 하나 뒤 잠깐(0.2초) 안 받기 — 겹침 막기

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

#### 4. 조건
- 분석기 평활(smoothingTimeConstant)은 0 — 평활하면 박이 뭉개진다
- dB 값을 그대로 더하지 말고 10^(dB/10) 로 세기로 바꿔 더한다
- 박 사이 최소 간격(0.2초) — 한 박이 두세 번 잡히지 않게
- 민감도 손잡이 (1.1 ~ 2.5) — 곡마다 맞는 값이 다르다
- 소리 없이 볼 때를 위한 미리 계산한 그래프

#### 5. 완성 기준 (이게 보이면 성공)
- 음악을 켜면 저음 에너지 그래프와 평균 × 민감도 점선이 흐르고, 넘는 곳마다 분홍 「박」 막대가 선다
- 박마다 친구가 튀고 고리가 퍼지며 막대가 솟는다
- 민감도를 올리면 큰 박만, 내리면 작은 소리에도 반응한다

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

## 원리
- 북(킥)은 낮은 소리라, 150Hz 아래 세기가 박마다 확 커진다.
- AnalyserNode(fftSize 1024 · 평활 0)의 getFloatFrequencyData 는 dB 값 — 칸마다 10^(dB/10) 로 세기로 바꿔 150Hz 아래 칸만 더한다.
- 최근 40 프레임 평균 × 민감도(1.35)보다 크고, 앞 박에서 0.2초 넘게 지났으면 「박」.
- 박이 오면 그림의 bump = 1, 0.3초 동안 0 으로 줄어든다 — 튀기 · 고리 · 막대가 bump 를 쓴다.
- 소리를 켜기 전에는 같은 음악을 OfflineAudioContext 로 미리 그려 둔 에너지 · 박으로 그림만 보여 준다.

## 핵심 코드 — 저음 에너지 > 최근 평균 × 민감도 = 박
(발췌: demos/demosSfx.ts beatDemo start() · draw() 의 실시간 분석 부분을 정리)
```ts
const ana = ctx.createAnalyser();
ana.fftSize = 1024;
ana.smoothingTimeConstant = 0;                         // 평활하면 박이 뭉개진다
musicOut.connect(ana);                                 // 음악 길에서 갈라 듣기
const fbuf = new Float32Array(ana.frequencyBinCount);
const hist: number[] = [];
let lastBeatT = -9, bump = 0;

/** 매 프레임: t = 초 */
function detect(t: number, sens = 1.35): boolean {
  ana.getFloatFrequencyData(fbuf);                     // dB 값
  const binHz = ctx.sampleRate / ana.fftSize;
  let s = 0;
  for (let i = 1; i * binHz < 150; i++) s += Math.pow(10, fbuf[i] / 10); // 150Hz 아래 세기 합
  const recent = hist.slice(-40);
  const avg = recent.length ? recent.reduce((a, b) => a + b, 0) / recent.length : s;
  const isBeat = s > avg * sens && t - lastBeatT > 0.2 && s > 1e-6;
  if (isBeat) lastBeatT = t;
  hist.push(s);
  if (hist.length > 160) hist.shift();
  bump = Math.max(0, 1 - (t - lastBeatT) / 0.3);       // 그림 튀기: 0.3초에 사라짐
  return isBeat;
}
```

## 흔한 실수 · 확인 목록
- [ ] **분석기 평활을 기본값(0.8)으로 두면 박을 거의 못 잡는다** — 값이 천천히 변해 「갑자기 커짐」이 사라진다. 박 검출용 분석기는 smoothingTimeConstant = 0.
- [ ] **dB 를 그대로 더하면 조용한 칸이 큰 음수로 결과를 흔든다** — 10^(dB/10) 로 세기로 바꿔 더한다.
- [ ] **한 박이 연달아 두세 번 잡힌다** — 앞 박에서 0.2초 안은 받지 않는다.
- [ ] **평균을 너무 길게 잡으면 곡이 조용해졌다 커질 때 한동안 반응이 없다** — 최근 40 프레임(약 0.7초) 정도로 짧게.

## 완성 기준 체크리스트
- [ ] 음악을 켜면 저음 에너지 그래프와 평균 × 민감도 점선이 흐르고, 넘는 곳마다 분홍 「박」 막대가 선다
- [ ] 박마다 친구가 튀고 고리가 퍼지며 막대가 솟는다
- [ ] 민감도를 올리면 큰 박만, 내리면 작은 소리에도 반응한다

## 이 기술 정보
- id: `i163` · 분류: 소리 › 효과음 (SFX) · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 매 프레임 FFT 칸 512개 읽기 + 최근 40개 평균 — 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i163
- 쓰면 좋을 때: 박 정보가 없는 곡 파일 · 바깥 소리에 맞춰 반응할 때 / 「박자란 무엇인가」를 그래프로 보여 줄 때
- 쓰지 말 때: 우리가 직접 예약하는 음악 — 이미 박 자리를 알고 있으니 박자 시계(i438)가 정확하다 / 정확한 박자(BPM) 측정 — 이 방식은 반응용이라 몇십 ms 흔들린다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### beatDemo — `src/demos/demosSfx.ts:4117`
```ts
const beatDemo: Demo2D = {
  kind: '2d',
  caption: '저음(150Hz 아래) 에너지가 최근 평균보다 1.35배 크면 「박」 — 그림이 음악에 맞춰 튐',
  make() {
    let pre: BeatPre | null = null;
    void beatPre().then((p) => (pre = p));
    const bus = new Bus();
    let music: Music | null = null;
    let ana: AnalyserNode | null = null;
    const hist: { e: number; avg: number; beat: boolean }[] = [];
    let lastBeat = -1;
    let bump = 0;
    let lastBeatT = -9;
    let sens = 1.35;
    const fbuf = new Float32Array(512);
    const start = (): void => {
      const ac = audio();
      const out = bus.node();
      ana = ac.createAnalyser();
      ana.fftSize = 1024;
      ana.smoothingTimeConstant = 0;
      const mix = gainN(ac, 1);
      mix.connect(out);
      mix.connect(ana);
      music = new Music(ac, { pad: mix, bass: mix, drums: mix });
      music.start();
    };
    const stop = (): void => {
      music?.stop();
      music = null;
      ana = null;
      bus.kill();
    };
    return {
      controls: [
        { type: 'toggle', label: '▶ 음악 켜기 / ■ 끄기', value: false, on: (v) => (v ? start() : stop()) },
        { type: 'range', label: '민감도 (평균의 몇 배면 박)', min: 1.1, max: 2.5, step: 0.05, value: 1.35, on: (v) => (sens = v) },
      ],
      draw(g, w, h, t, dt) {
        const real = !!(music && ana);
        let series: { e: number; avg: number; beat: boolean }[] = [];
        if (real && ana && AC) {
          ana.getFloatFrequencyData(fbuf);
          const binHz = AC.sampleRate / 1024;
          let s = 0;
          for (let i = 1; i * binHz < 150; i++) s += Math.pow(10, fbuf[i]! / 10);
          const prevAvg = hist.length ? hist.slice(-40).reduce((a, b) => a + b.e, 0) / Math.min(40, hist.length) : s;
          const isB = s > prevAvg * sens && t - lastBeatT > 0.2 && s > 1e-6;
          if (isB) lastBeatT = t;
          hist.push({ e: s, avg: prevAvg, beat: isB });
          if (hist.length > 160) hist.shift();
          const mx = Math.max(1e-9, ...hist.map((x) => x.e));
          series = hist.map((x) => ({ e: x.e / mx, avg: x.avg / mx, beat: x.beat }));
        } else if (pre) {
          const f = Math.floor((t % pre.dur) / pre.hop);
          for (let i = f - 120; i <= f; i++) {
            const j = ((i % pre.e.length) + pre.e.length) % pre.e.length;
            series.push({ e: pre.e[j]!, avg: pre.avg[j]!, beat: pre.beat[j] === 1 });
          }
          const cur = Math.floor((t % pre.dur) / pre.hop);
          for (let i = lastBeat + 1; i <= cur && lastBeat >= 0 && cur >= lastBeat; i++) if (pre.beat[i] === 1) lastBeatT = t;
          lastBeat = cur;
        }
        bump = Math.max(0, 1 - (t - lastBeatT) / 0.3);
        void dt;
        frame(
          g,
          w,
          h,
          t,
          '#9fe870',
          () => {
            txt(g, '박자 검출 · 반응 그림', 12, 13, 11, '#d6ffc0', 'left', 900);
            chip(g, real ? '실제 소리 분석 중' : '미리 계산한 음악', 240, 13, 7.5, real ? '#3aa860' : 'rgba(255,255,255,0.15)');
            // 에너지 그래프
            const gx = 12;
            const gw = 170;
            const gy0 = 28;
            const gh = 96;
            vpanel(g, gx, gy0, gw, gh, '#9fe870');
            const n = series.length;
            if (n > 1) {
              g.beginPath();
              series.forEach((p, i) => g.lineTo(gx + (i / (n - 1)) * gw, gy0 + gh - 4 - Math.sqrt(p.e) * (gh - 12)));
              g.lineTo(gx + gw, gy0 + gh);
              g.lineTo(gx, gy0 + gh);
              g.closePath();
              g.fillStyle = 'rgba(159,232,112,0.35)';
              g.fill();
              g.strokeStyle = '#ffd84d';
              g.lineWidth = 1.4;
              g.setLineDash([3, 2]);
              g.beginPath();
              series.forEach((p, i) => g.lineTo(gx + (i / (n - 1)) * gw, gy0 + gh - 4 - Math.sqrt(p.avg * (real ? sens : 1.35)) * (gh - 12)));
              g.stroke();
              g.setLineDash([]);
              series.forEach((p, i) => {
                if (!p.beat) return;
                const x = gx + (i / (n - 1)) * gw;
                g.fillStyle = '#ff5c8a';
                g.fillRect(x - 1, gy0 + 3, 2, gh - 6);
                circ(g, x, gy0 + 6, 2.5);
                g.fill();
              });
            }
            txt(g, '저음 에너지', gx + 4, gy0 + 8, 6.5, '#9fe870', 'left');
            txt(g, '-- 평균 × 민감도', gx + 50, gy0 + 8, 6.5, '#ffd84d', 'left');
            txt(g, '| 박', gx + 118, gy0 + 8, 6.5, '#ff5c8a', 'left');
            // 반응 그림: 튀는 친구 + 고리
            const cx = 230;
            const cy = 92;
            for (let i = 0; i < 3; i++) {
              const ph = clamp((t - lastBeatT) / 0.6 - i * 0.15);
              if (ph <= 0 || ph >= 1) continue;
              circ(g, cx, cy, 14 + ph * 34);
              g.strokeStyle = `rgba(159,232,112,${0.7 * (1 - ph)})`;
              g.lineWidth = 2;
              g.stroke();
            }
            shadow(g, cx, 122, 16 - bump * 4);
            blob(g, cx, cy - bump * 16, 16, '#9fe870', 1 + bump * 0.12, 1 - bump * 0.1 + bump * 0.2, bump > 0.3 ? 'happy' : 'calm');
            for (let i = 0; i < 5; i++) {
              const hgt = 6 + bump * 20 * (0.5 + hash(i + Math.floor(t * 4)) * 0.5);
              rr(g, 200 + i * 13, 140 - hgt, 9, hgt, 2);
              g.fillStyle = rgba(['#9fe870', '#7cc8ff', '#ffb84d', '#ff7cc4', '#c08bff'][i]!, 0.85);
              g.fill();
            }
          },
          real ? '실시간 소리 · AnalyserNode' : '실시간 소리 · AnalyserNode — ▶ 음악 켜기',
        );
      },
      dispose: stop,
    };
  },
};
```

### beatPre — `src/demos/demosSfx.ts:4064`
```ts
function beatPre(): Promise<BeatPre> {
  if (BEAT_PRE) return BEAT_PRE;
  BEAT_PRE = (async (): Promise<BeatPre> => {
    const sr = 22050;
    const spb = 60 / 120 / 4;
    const dur = spb * 64;
    const oc = new OfflineAudioContext(1, Math.ceil(sr * dur), sr);
    const out = oc.createGain();
    out.connect(oc.destination);
    const r = rng(3);
    for (let s = 0; s < 64; s++) musicStep(oc, { pad: out, bass: out, drums: out }, s * spb, s, spb, r);
    const d = (await oc.startRendering()).getChannelData(0);
    // 저역 통과 (한 극) → 프레임 에너지
    const hopN = 441; // 20ms
    const n = Math.floor(d.length / hopN);
    const e = new Float32Array(n);
    let lp = 0;
    const a = 1 - Math.exp((-TAU * 150) / sr);
    for (let f = 0; f < n; f++) {
      let s2 = 0;
      for (let i = 0; i < hopN; i++) {
        lp += a * (d[f * hopN + i]! - lp);
        s2 += lp * lp;
      }
      e[f] = s2 / hopN;
    }
    const avg = new Float32Array(n);
    const beat = new Uint8Array(n);
    let last = -100;
    const W = 43;
    for (let f = 0; f < n; f++) {
      let s = 0;
      let c = 0;
      for (let j = Math.max(0, f - W); j <= f; j++) {
        s += e[j]!;
        c++;
      }
      avg[f] = s / c;
      if (e[f]! > avg[f]! * 1.35 && f - last > 10 && e[f]! > 1e-5) {
        beat[f] = 1;
        last = f;
      }
    }
    let mx = 0;
    for (let f = 0; f < n; f++) mx = Math.max(mx, e[f]!);
    for (let f = 0; f < n; f++) {
      e[f] = e[f]! / mx;
      avg[f] = avg[f]! / mx;
    }
    return { e, avg, beat, hop: hopN / sr, dur };
  })();
  return BEAT_PRE;
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [소리 보기 (Web Audio FFT)](https://ai-techstudio.web.app/ai/t/i52.md) `i52`
- 다음에 해 볼 기술: [박자 맞춰 연출 (비트 동기)](https://ai-techstudio.web.app/ai/t/i438.md) `i438`
- 참고 문서: [MDN — AnalyserNode.getFloatFrequencyData()](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/getFloatFrequencyData)
