# AI 꾸러미 — 박자 맞춰 연출 (비트 동기) — Beat-synced visuals (music clock)
> 화면 연출의 시계를 음악이 실제로 예약된 박자 자리에서 읽어, 공이 땅에 닿는 순간 · 별 터짐을 박에 딱 맞추고 제멋대로 타이머처럼 어긋나지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i438

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

## 주문서

### 만들어 줘: 박자 맞춰 연출 (비트 동기) — Beat-synced visuals (music clock)

#### 1. 목표
리듬에 맞춰 튀는 캐릭터 · 공에 박자 맞춰 연출을 넣어 줘 — 음악 엔진이 예약한 박자 자리를 시계로 써서 튀기 · 번쩍 · 등장이 박에 맞게, 누르면 다음 박에 예약되게. 느낌은 신나는 클럽 조명.

#### 2. 핵심 기술 용어
- **Beat-synced visuals (music clock)** — 음악 박자 시계에 맞춘 연출
- **AudioContext.currentTime as master clock** — 소리 시계를 기준 시계로
- **Quantized trigger (next beat / next bar)** — 다음 박 · 다음 마디에 맞춰 예약
- **Timer drift** — 따로 노는 타이머가 점점 어긋나는 것

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

#### 4. 조건
- 연출 시계는 performance.now · 프레임 dt 적분이 아니라 음악이 예약한 박자 자리에서 읽는다
- 소리가 꺼졌을 때만 dt 로 적분하는 대체 시계 (견본 Clock 클래스)
- 「누르면 터짐」 같은 사용자 요청은 다음 박 · 마디로 양자화 — 단, 반응이 늦다고 느끼지 않을 길이(반 박 ~ 한 박)로
- 박 넘김 판정은 「이전 프레임 pos 와 지금 pos 의 floor 가 다른가」로 — 프레임이 건너뛰어도 놓치지 않게

#### 5. 완성 기준 (이게 보이면 성공)
- 소리를 켜면 세 공이 각자 한 박 · 두 박 · 반 박마다 북소리에 딱 맞춰 땅에 닿는다
- 마디 첫 박마다 별이 터지고 화면이 살짝 밝아진다
- 「박자 맞춤」을 끄면 공과 별이 점점 북소리와 어긋나는 것이 들린다
- 「터뜨리기」를 누르면 다음 박에 맞춰 터진다

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

## 원리
- 음악 엔진은 칸을 예약할 때마다 (칸 번호, 시작 시각, 칸 길이)를 기록해 둔다.
- 지금 들리는 자리 pos = 지금 시각 이전에 시작한 마지막 칸 번호 + (지금 − 그 칸 시작) ÷ 칸 길이. 이 값이 연출의 시계다.
- 공 높이 = 1 − (2·frac(pos ÷ 주기) − 1)² — 주기마다 땅에 닿는 순간이 정확히 박이다. 한 박 · 두 박 · 반 박 공이 함께 맞는다.
- floor(pos ÷ 8) 가 바뀌면 마디 첫 박 → 별 터짐. 「터뜨리기」를 누르면 바로가 아니라 floor(pos ÷ 2) 가 바뀌는 다음 박에 터진다.
- 박자 맞춤을 끄면 조금 다른 빠르기(× 1.13 ~)의 제 타이머로 돌려 — 금방 어긋나는 것을 비교로 보여 준다.

## 핵심 코드 — 예약 기록에서 지금 박자 자리 읽기 + 박 넘김 판정
(발췌: demos/demosMusicNet.ts Engine.pump · Engine.pos · class Clock · demoBeat 를 정리)
```ts
// 음악 엔진: 칸을 예약할 때 기록
const hist: { s: number; t: number; d: number }[] = [];
function onScheduled(step: number, T: number, d: number) {
  hist.push({ s: step, t: T, d });
  if (hist.length > 64) hist.shift();
}
/** 지금 들리는 자리 (칸 단위 소수) — 소리 시계 기준 */
function pos(ac: AudioContext): number {
  const now = ac.currentTime;
  for (let i = hist.length - 1; i >= 0; i--) {
    const e = hist[i];
    if (e.t <= now) return e.s + Math.min(1, (now - e.t) / e.d);
  }
  return (hist[0]?.s ?? 0) - 0.001;
}
// 화면: 매 프레임
let prevPos = 0, burstQueued = false;
function frame(ac: AudioContext, burst: () => void) {
  const p = pos(ac);                                   // 8분음표 칸 — 2칸 = 한 박, 8칸 = 한 마디
  const bounce = (per: number) => 1 - Math.pow(2 * ((p / per) % 1) - 1, 2); // 박마다 땅에 닿음
  void bounce;
  if (Math.floor(p / 8) !== Math.floor(prevPos / 8)) burst();               // 마디 첫 박
  if (burstQueued && Math.floor(p / 2) !== Math.floor(prevPos / 2)) { burst(); burstQueued = false; } // 다음 박에
  prevPos = p;
}
const requestBurst = () => { burstQueued = true; };   // 누르면 바로 말고 다음 박에
```

## 흔한 실수 · 확인 목록
- [ ] **setInterval · requestAnimationFrame 시계로 연출하면 음악과 점점 어긋난다** — 두 시계는 따로 논다. 연출 위상을 음악이 예약한 박자 자리(pos)에서 읽는다 — 견본의 「박자 맞춤 끄기」가 그 차이를 보여 준다.
- [ ] **「지금 박」 판정을 pos 정수와 같은지로 하면 프레임이 건너뛸 때 놓친다** — 이전 프레임과 지금의 floor 값이 다른지로 판정한다.
- [ ] **예약 시각(미래)을 그대로 쓰면 연출이 소리보다 먼저 나온다** — 음은 0.1초쯤 앞서 예약된다. 기록에서 「시작 시각 ≤ 지금」 인 칸만 써서 실제로 들리는 자리를 구한다.
- [ ] **탭이 숨었다 돌아오면 박 넘김이 한꺼번에 몰린다** — 오래 멈췄으면(pos 차이가 크면) 그 사이 연출은 건너뛰고 지금 자리부터 다시.

## 완성 기준 체크리스트
- [ ] 소리를 켜면 세 공이 각자 한 박 · 두 박 · 반 박마다 북소리에 딱 맞춰 땅에 닿는다
- [ ] 마디 첫 박마다 별이 터지고 화면이 살짝 밝아진다
- [ ] 「박자 맞춤」을 끄면 공과 별이 점점 북소리와 어긋나는 것이 들린다
- [ ] 「터뜨리기」를 누르면 다음 박에 맞춰 터진다

## 이 기술 정보
- id: `i438` · 분류: 소리 › 소리 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 기록 64개에서 지금 자리를 찾는 계산 하나 — 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i438
- 쓰면 좋을 때: 리듬 게임 · 음악에 맞춰 움직이는 배경 · 축하 연출 / 정답 소리 · 연출을 음악 박에 맞춰 더 「딱」 맞게 할 때
- 쓰지 말 때: 녹음된 곡 파일만 있고 박 정보가 없을 때 — 박자 검출(i163)로 박을 찾거나, BPM 을 알고 시작 시각에서 계산 / 박자와 상관없는 즉각 반응(단추 누름) — 늦추면 답답하다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### Clock — `src/demos/demosMusicNet.ts:475`
```ts
class Clock {
  phase = 0;
  eng: Engine | null = null;
  tick(dt: number, bpm: number): number {
    if (this.eng && this.eng.ac.state === 'running' && this.eng.hist.length) this.phase = this.eng.pos();
    else this.phase += Math.min(dt, 0.1) * (bpm / 30);
    return Math.max(0, this.phase);
  }
}
```

### demoBeat — `src/demos/demosMusicNet.ts:1174`
```ts
function demoBeat(): Made {
  const clock = new Clock();
  let eng: Engine | null = null;
  let sync = true;
  let prevPos = 0;
  let freePh = [0, 0, 0];
  let simT = 0;
  let nextBurst = 1;
  const sparks: Spark[] = [];
  const marks: { at: number; err: number }[] = [];
  let flash = 0;
  const balls = [
    { per: 2, col: '#ff6b8a', name: '한 박' },
    { per: 4, col: '#ffb547', name: '두 박' },
    { per: 1, col: '#4fd1ff', name: '반 박' },
  ];
  const impact = [0, 0, 0];
  const start = (): void => {
    if (eng) return;
    eng = new Engine([1, 1, 1, 1, 0]);
    clock.eng = eng;
  };
  const stop = (): void => {
    eng?.close();
    eng = null;
    clock.eng = null;
  };
  let W = 280;
  let H = 175;
  const burst = (x: number, y: number, big: number): void => {
    const cols = ['#ffd36b', '#ff6b8a', '#4fd1ff', '#b08cff', '#7dffb2'];
    for (let i = 0; i < 26; i++) {
      const a = (i / 26) * Math.PI * 2 + Math.random() * 0.2;
      const sp = (40 + Math.random() * 60) * big;
      sparks.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: 1, col: cols[i % cols.length]! });
    }
    if (sparks.length > 200) sparks.splice(0, sparks.length - 200);
  };
  return {
    controls: [
      soundButton(start, stop, () => !!eng),
      { type: 'toggle', label: '박자 맞춤 (끄면 제멋대로 타이머)', value: true, on: (v) => (sync = v) },
      { type: 'button', label: '★ 터뜨리기 (다음 박에 예약)', on: () => (nextBurst = -1) },
    ],
    draw(g, w, h, t, dt) {
      reset(g);
      W = w;
      H = h;
      const L = lay(w, h);
      const { u, pad, big } = L;
      const dd = Math.min(dt, 0.1);
      simT += dd;
      const pos = clock.tick(dd, 100);
      const sPerStep = 0.3;
      backdrop(g, w, h, '#120d26', '#07060f', 'rgba(255,120,200,0.12)', u);
      if (flash > 0) {
        g.fillStyle = `rgba(255,220,150,${flash * 0.12})`;
        g.fillRect(0, 0, w, h);
      }
      flash = Math.max(0, flash - dd * 3);
      soundBadge(g, w, L, !!eng, t);
      // 박자 등 4개
      const beatF = pos / 2;
      const beat = Math.floor(beatF) % 4;
      const bx0 = pad;
      const by = pad + (big ? 8 : 6) * u;
      txt(g, '박자', bx0, by, (big ? 8 : 6.6) * u, '#9aa6c4', 'left', 700);
      for (let i = 0; i < 4; i++) {
        const cx = bx0 + (big ? 34 : 24) * u + i * (big ? 18 : 13) * u;
        const on = i === beat;
        const fr = on ? 1 - (beatF % 1) : 0;
        glowDot(g, cx, by, (2.6 + (on ? 1.4 * fr : 0)) * u, i === 0 ? '#ffd36b' : '#ff6b8a', on ? 0.6 + 0.4 * fr : 0.22);
      }
      chip(g, bx0 + (big ? 110 : 80) * u, by - (big ? 7 : 5.5) * u, sync ? '박자 시계로 예약' : '따로 노는 타이머', sync ? '#7dffb2' : '#ff6b8a', (big ? 8.5 : 6.4) * u);
      // 무대
      const floorY = h - pad - (big ? 46 : 28) * u;
      const stTop = by + (big ? 16 : 10) * u;
      g.strokeStyle = 'rgba(255,255,255,0.15)';
      g.lineWidth = 1;
      g.beginPath();
      g.moveTo(pad, floorY);
      g.lineTo(w - pad, floorY);
      g.stroke();
      const jump = floorY - stTop - 10 * u;
      const r = (big ? 9 : 6.5) * u;
      // 공: 박자 맞춤이면 음악 자리, 아니면 조금 다른 빠르기의 제 시계
      const area = w * 0.55;
      for (let i = 0; i < 3; i++) {
        const b = balls[i]!;
        let ph: number;
        if (sync) ph = pos / b.per;
        else {
          freePh[i] = (freePh[i] ?? 0) + (dd / (b.per * sPerStep)) * (1.13 + i * 0.07);
          ph = freePh[i]!;
        }
        const prevPh = sync ? prevPos / b.per : ph - (dd / (b.per * sPerStep)) * (1.13 + i * 0.07);
        if (Math.floor(ph) !== Math.floor(prevPh)) {
          impact[i] = 1;
          // 박과의 어긋남 (ms)
          const near = Math.round(pos / 2) * 2;
          const err = ((pos - near) * sPerStep * 1000) | 0;
          marks.push({ at: simT, err: sync ? 0 : err });
          if (marks.length > 40) marks.shift();
        }
        impact[i] = Math.max(0, impact[i]! - dd * 5);
        const f = ph % 1;
        const hgt = 1 - Math.pow(2 * f - 1, 2);
        const x = pad + area * ((i + 0.5) / 3);
        const y = floorY - r - hgt * jump * (i === 2 ? 0.45 : i === 1 ? 1 : 0.7);
        const sq = impact[i]! * 0.35;
        // 그림자
        g.fillStyle = `rgba(0,0,0,${0.35 * (1 - hgt * 0.6)})`;
        g.beginPath();
        g.ellipse(x, floorY + 1.5 * u, r * (1 - hgt * 0.4), r * 0.25, 0, 0, Math.PI * 2);
        g.fill();
        if (impact[i]! > 0) {
          g.strokeStyle = hexA(b.col, impact[i]! * 0.8);
          g.lineWidth = 1.5 * u;
          g.beginPath();
          g.ellipse(x, floorY, r * (1.2 + (1 - impact[i]!) * 1.6), r * 0.35 * (1.2 + (1 - impact[i]!)), 0, 0, Math.PI * 2);
          g.stroke();
        }
        const rg = g.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.1, x, y, r);
        rg.addColorStop(0, mixHex(b.col, '#ffffff', 0.6));
        rg.addColorStop(1, b.col);
        g.fillStyle = rg;
        g.beginPath();
        g.ellipse(x, y + r * sq * 0.5, r * (1 + sq), r * (1 - sq), 0, 0, Math.PI * 2);
        g.fill();
        txt(g, b.name, x, floorY + (big ? 10 : 7) * u, (big ? 8 : 6.2) * u, '#8d97b8', 'center');
      }
      // 별 터짐: 마디 첫 박 (또는 예약)
      const sx = pad + area + (w - pad * 2 - area) / 2;
      const sy = stTop + (floorY - stTop) * 0.45;
      const bar = Math.floor(pos / 8);
      const prevBar = Math.floor(prevPos / 8);
      if (sync) {
        if (bar !== prevBar) {
          burst(sx, sy, u);
          flash = 1;
        }
        if (nextBurst === -1 && Math.floor(pos / 2) !== Math.floor(prevPos / 2)) {
          burst(sx, sy, u * 0.7);
          nextBurst = 1;
        }
      } else {
        nextBurst -= dd;
        if (nextBurst <= 0 || nextBurst < -0.5) {
          burst(sx, sy, u);
          flash = 1;
          nextBurst = 1.6 + Math.random() * 1.4;
        }
      }
      prevPos = pos;
      // 별 모양 가운데
      const pul = 1 + flash * 0.3;
      g.save();
      g.translate(sx, sy);
      g.rotate(t * 0.6);
      g.beginPath();
      for (let i = 0; i < 10; i++) {
        const rr2 = (i % 2 ? 4 : 10) * u * pul;
        const a = (i / 10) * Math.PI * 2 - Math.PI / 2;
        if (i === 0) g.moveTo(Math.cos(a) * rr2, Math.sin(a) * rr2);
        else g.lineTo(Math.cos(a) * rr2, Math.sin(a) * rr2);
      }
      g.closePath();
      g.fillStyle = '#ffd36b';
      g.shadowColor = '#ffd36b';
      g.shadowBlur = 12 * u * (0.4 + flash);
      g.fill();
      g.restore();
      g.globalCompositeOperation = 'lighter';
      for (const s of sparks) {
        s.life -= dd * 1.3;
        s.x += s.vx * dd;
        s.y += s.vy * dd;
        s.vy += 60 * u * dd;
        s.vx *= 0.97;
        s.vy *= 0.97;
        if (s.life <= 0) continue;
        g.fillStyle = hexA(s.col, s.life);
        g.beginPath();
        g.arc(s.x, s.y, 1.8 * u * s.life + 0.5, 0, Math.PI * 2);
        g.fill();
      }
      g.globalCompositeOperation = 'source-over';
      for (let i = sparks.length - 1; i >= 0; i--) if (sparks[i]!.life <= 0 || sparks[i]!.x < 0 || sparks[i]!.x > W || sparks[i]!.y > H) sparks.splice(i, 1);
      // 아래: 박 격자 + 일어난 일 표시
      const ty = floorY + (big ? 22 : 14) * u;
      const tw = w - pad * 2;
      const span = 4;
      g.strokeStyle = 'rgba(255,255,255,0.1)';
      g.beginPath();
      g.moveTo(pad, ty);
      g.lineTo(pad + tw, ty);
      g.stroke();
      // 박 눈금 (박자 시계 기준, 오른쪽 끝이 지금)
      const secPerBeat = 0.6;
      const nowBeat = pos / 2;
      for (let b = Math.ceil(nowBeat - span / secPerBeat); b <= nowBeat; b++) {
        const x = pad + tw - ((nowBeat - b) * secPerBeat * tw) / span;
        g.fillStyle = b % 4 === 0 ? 'rgba(255,211,107,0.7)' : 'rgba(255,255,255,0.3)';
        g.fillRect(x - 0.5, ty - 4 * u, 1, 8 * u);
      }
      for (const m of marks) {
        const age = simT - m.at;
        if (age > span) continue;
        const x = pad + tw - (age * tw) / span;
        const good = Math.abs(m.err) < 30;
        g.fillStyle = good ? '#7dffb2' : '#ff6b8a';
        g.beginPath();
        g.arc(x, ty, 1.8 * u, 0, Math.PI * 2);
        g.fill();
      }
      const recent = marks.filter((m) => simT - m.at < span);
      const avg = recent.length ? recent.reduce((a, m) => a + Math.abs(m.err), 0) / recent.length : 0;
      txt(g, sync ? '어긋남 0 ms — 모두 박 위에' : `평균 어긋남 ±${Math.round(avg)} ms`, pad, ty + (big ? 12 : 8) * u, (big ? 8.5 : 6.6) * u, sync ? '#7dffb2' : '#ff8aa5', 'left', 700);
      if (big) txt(g, '노란 눈금 = 마디 첫 박 · 점 = 공이 땅에 닿은 순간 (초록 = 박 위, 빨강 = 어긋남)', w - pad, ty + 12 * u, 8 * u, '#8d97b8', 'right');
    },
    dispose() {
      stop();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [배경음악 층 쌓기 (층마다 켜고 끄기)](https://ai-techstudio.web.app/ai/t/i435.md) `i435`
- 다음에 해 볼 기술: [박자 검출 · 반응 그림](https://ai-techstudio.web.app/ai/t/i163.md) `i163`
