# AI 꾸러미 — 소리 장치 미리 깨우기 — AudioContext warm-up (resume on user gesture)
> 첫 터치 때 소리 장치를 만들며 0.3~0.4초 멈추던 것을, 홈에서 미리 만들어 두고 게임 카드를 누를 때 깨워 첫 터치 멈춤 · 첫 소리 늦음을 없앤다.  
> 견본: https://ai-techstudio.web.app/#t/i386

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

## 주문서

### 만들어 줘: 소리 장치 미리 깨우기 — AudioContext warm-up (resume on user gesture)

#### 1. 목표
게임 모음 사이트의 모든 게임에서 첫 터치가 멈칫하지 않게 소리 장치를 미리 깨워 줘 — 앱이 쉬는 틈에 AudioContext 를 만들어 두고(멈춘 채), 게임을 여는 누름 안에서 resume, 게임을 닫으면 suspend. 카드를 눌러 게임을 여는 홈.

#### 2. 핵심 기술 용어
- **AudioContext warm-up (resume on user gesture)** — 소리 장치 미리 깨우기
- **Autoplay policy** — 사용자 몸짓 안에서만 소리를 켤 수 있는 브라우저 규칙
- **AudioContext.state (suspended · running)** — 소리 장치 상태
- **requestIdleCallback** — 쉬는 틈에 일하기

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

#### 4. 조건
- 상자 만들기는 앱 시작 1.5초 뒤 requestIdleCallback(최대 4초 기다림) 안에서
- resume 은 게임 카드를 누르는 이벤트 처리 안에서만 (사용자 몸짓)
- 게임을 닫으면 suspend — 장치를 놓아 준다
- AudioContext 가 없거나 만들다 실패하면 조용히 넘어가기 (게임은 소리 없이)
- 효과가 있는지 첫 터치 프레임 시간을 전/후로 재서 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 「끔」 줄은 첫 터치 때 「멈칫」과 함께 첫 소리가 150ms 쯤 늦게 난다
- 「켬」 줄은 깨우기가 화면 넘김 구간에 숨고 첫 소리가 바로 난다
- 「진짜로 재기」를 누르면 이 기기의 new AudioContext() 시간과 resume() 시간이 나온다

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

## 원리
- 브라우저는 소리 장치를 처음 열 때(소리 상자가 처음 돌기 시작할 때) 멈춘다 — 윈도 크롬에서 0.3 ~ 0.4초.
- 게임이 첫 클릭 때 new AudioContext() 를 하면 그 멈춤이 첫 수와 겹쳐 「게임이 굼뜨다」로 느껴진다.
- 몸짓 없이 만든 상자는 멈춘(suspended) 채라 비용이 없다 — 쉬는 틈에 미리 만든다.
- 게임 카드를 누르는 몸짓 안에서 resume() 하면 장치가 열리는 일이 화면 넘김 뒤에 숨는다 (비동기라 누른 순간도 안 멈춤).
- 장치는 돌고 있는 상자가 하나라도 있으면 열린 채 — 게임의 새 상자는 0초.

## 핵심 코드 — 쉬는 틈에 만들고 · 카드 누를 때 깨우고 · 닫을 때 재우기
(발췌: src/game/core/audioWarm.ts)
```ts
let ctx: AudioContext | null = null;

function make(): AudioContext | null {
  if (!ctx && typeof AudioContext !== 'undefined') {
    try { ctx = new AudioContext(); } catch { ctx = null; } // 소리를 못 쓰는 환경 — 조용히
  }
  return ctx;
}

/** 앱 시작 뒤 쉬는 틈에 상자만 만들어 둔다 (몸짓 밖이라 멈춘 채 — 비용 없음) */
export function warmAudioDevice(): void {
  const idle = (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback;
  const run = (): void => {
    if (ctx) return;
    const c = make();
    if (c?.state === 'running') void c.suspend(); // 혹시 돌고 있으면 게임을 열 때까지 재운다
  };
  window.setTimeout(() => (idle ? idle(run, { timeout: 4000 }) : run()), 1500);
}

/** 게임을 열 때 — 게임 카드를 누른 이벤트 안에서 부른다 */
export function wakeAudio(): void {
  const c = make();
  if (c?.state === 'suspended') void c.resume().catch(() => undefined);
}

/** 게임을 닫을 때 — 장치를 놓아 준다 */
export function sleepAudio(): void {
  if (ctx?.state === 'running') void ctx.suspend().catch(() => undefined);
}
```

## 흔한 실수 · 확인 목록
- [ ] **게임마다 첫 클릭에 소리 상자를 만들면 첫 수가 0.3~0.4초 언다** — 이 사이트 모든 게임이 그랬다 — core/audioWarm.ts 로 홈에서 만들고 카드 누를 때 깨우게 고쳤다.
- [ ] **몸짓 밖에서 resume 하면 아무 일도 안 일어난다** — 자동 재생 규칙 때문 — 반드시 클릭 · 터치 이벤트 처리 함수 안에서 부른다.
- [ ] **await resume() 을 기다린 뒤 화면을 넘기면 그만큼 늦어진다** — resume 은 void 로 던져 두고 화면은 바로 넘긴다 (비동기).

## 완성 기준 체크리스트
- [ ] 「끔」 줄은 첫 터치 때 「멈칫」과 함께 첫 소리가 150ms 쯤 늦게 난다
- [ ] 「켬」 줄은 깨우기가 화면 넘김 구간에 숨고 첫 소리가 바로 난다
- [ ] 「진짜로 재기」를 누르면 이 기기의 new AudioContext() 시간과 resume() 시간이 나온다

## 이 기술 정보
- id: `i386` · 분류: 게임 시스템 · AI › 속도 기법 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 멈춘 상자 하나는 비용 0. 게임이 떠 있는 동안 장치를 열어 두는 전기만 든다 (닫을 때 suspend).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i386
- 쓰면 좋을 때: 어느 게임이든 첫 클릭 · 첫 수가 0.3초쯤 멈칫할 때 / 효과음이 첫 터치보다 늦게 날 때
- 쓰지 말 때: 소리가 전혀 없는 화면 — 장치를 열 이유가 없다 / 홈 화면이 뜨자마자 resume — 몸짓 밖이라 막히고 경고만 남는다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demo386 — `src/demos/demosPerf.ts:694`
```ts
function demo386() {
  let rec = { create: 150, resume: 20 };
  let measured = false;
  let msg = '';
  const CYC = 3.4;
  const MAXT = 2.4;
  const CARD = 0.2;
  const OPEN = 0.85;
  const TAP = 1.35;
  const lane = (g: G, y: number, kind: Kind, t: number): void => {
    const bad = kind === 'bad';
    const fr = rec.create / 1000;
    const bars: Bar[] = [
      { t0: CARD, t1: OPEN, row: 0, color: '#6c7aa8', label: '화면 열림' },
    ];
    if (bad) bars.push({ t0: TAP, t1: TAP + fr, row: 1, color: C.red, label: '' });
    else bars.push({ t0: CARD, t1: CARD + fr, row: 1, color: C.violet, label: '' });
    const X = ganttLane(g, y, kind, bad ? '첫 터치 때 AudioContext 를 만든다' : '게임 카드를 누를 때 깨워 둔다', bars, t, MAXT);
    txt(g, bad ? `소리 장치 만들기 ${rec.create.toFixed(0)}ms` : `깨우기 ${rec.create.toFixed(0)}ms (화면 넘김에 숨음)`, bad ? X(TAP + fr) + 3 : X(CARD + fr) + 3, y + 34, 5.4, bad ? C.red : C.violet, 'left', 700);
    // 손가락 표시
    for (const [tt, s] of [[CARD, '카드 누름'], [TAP, '첫 터치']] as [number, string][]) {
      g.fillStyle = C.amber;
      g.beginPath();
      g.arc(X(tt), y + 47, 1.6, 0, Math.PI * 2);
      g.fill();
      txt(g, s, X(tt) + 3, y + 44.5, 5, C.amber, 'left', 700);
    }
    const sound = bad ? TAP + fr : TAP;
    dashV(g, X(sound), y + 16, y + 50, bad ? C.red : C.green);
    // 미니 폰
    const px = 14;
    const py = y + 16;
    rr(g, px, py, 52, 44, 5);
    g.fillStyle = '#05070f';
    g.fill();
    g.strokeStyle = '#3b4470';
    g.lineWidth = 1;
    g.stroke();
    if (t < OPEN) {
      for (let i = 0; i < 4; i++) {
        const cx = px + 5 + (i % 2) * 22;
        const cy = py + 5 + Math.floor(i / 2) * 18;
        rr(g, cx, cy, 20, 15, 2.5);
        g.fillStyle = i === 1 ? '#3a4a8a' : '#1f2747';
        g.fill();
      }
      ripple(g, px + 38, py + 12, (t - CARD) / 0.4, C.amber);
    } else {
      g.fillStyle = '#1d3a2e';
      rr(g, px + 4, py + 4, 44, 36, 3);
      g.fill();
      g.strokeStyle = 'rgba(160,230,190,0.25)';
      g.lineWidth = 0.6;
      for (let k = 1; k < 4; k++) {
        g.beginPath();
        g.moveTo(px + 4 + k * 11, py + 4);
        g.lineTo(px + 4 + k * 11, py + 40);
        g.moveTo(px + 4, py + 4 + k * 9);
        g.lineTo(px + 48, py + 4 + k * 9);
        g.stroke();
      }
      const stall = bad && t >= TAP && t < TAP + fr + 0.25;
      ripple(g, px + 22, py + 22, stall ? 0.15 : (t - TAP) / 0.4, C.amber);
      if (t >= TAP - 0.3 && t < TAP + 0.4) finger(g, px + 22, py + 22, 9);
      if (stall) txt(g, '멈칫', px + 26, py + 8, 6.5, C.red, 'center', 900);
    }
    const son = t >= sound ? (t - sound) : 0;
    speaker(g, px + 44, py + 38, 8, son > 0 && son < 0.9 ? son : 0, bad ? '#ffb0b8' : C.green);
    txt(g, bad ? `첫 소리 ${(fr * 1000).toFixed(0)}ms 늦음` : '첫 소리 바로', 268, y + 8.3, 5.8, bad ? '#ffb0b8' : '#a9f2d2', 'right', 700);
  };
  return {
    draw(g: G, w: number, h: number, tt: number) {
      const t = Math.min(tt % CYC, MAXT);
      view(g, w, h, isBig(w) ? 194 : 175);
      txt(g, '소리 장치는 사용자 몸짓 안에서만 켜진다', 8, 10, 7.2, C.text, 'left', 800);
      pill(g, measured ? `실측 ${rec.create.toFixed(0)}ms` : `기록 ${rec.create.toFixed(0)}ms`, 272, 10, 6, measured ? '#1d6b52' : '#2b3560', measured ? '#bdf7df' : C.cyan, 'right');
      lane(g, 21, 'bad', t);
      lane(g, 103, 'good', t);
      if (isBig(w)) resultStrip(g, msg || (measured ? `실측 — new AudioContext() ${rec.create.toFixed(1)}ms (화면이 멈춘 시간) · resume() ${rec.resume.toFixed(1)}ms` : '「진짜로 재기」 단추 → 이 기기에서 실제로 잰 값이 여기에 나와요'), measured && !msg);
      g.restore();
    },
    controls: [
      {
        type: 'button', label: '진짜로 재기 (짧은 삑 소리)', on: () => {
          const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
          if (!AC) { msg = '이 브라우저엔 AudioContext 가 없음'; return; }
          const t0 = now();
          const ac = new AC();
          const t1 = now();
          ac.resume().then(() => {
            const t2 = now();
            rec = { create: t1 - t0, resume: t2 - t1 };
            measured = true;
            msg = '';
            const o = ac.createOscillator();
            const gn = ac.createGain();
            o.frequency.value = 880;
            gn.gain.setValueAtTime(0.06, ac.currentTime);
            gn.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + 0.12);
            o.connect(gn).connect(ac.destination);
            o.start();
            o.stop(ac.currentTime + 0.13);
            setTimeout(() => void ac.close(), 400);
          }).catch(() => { msg = '소리 장치를 켤 수 없음'; });
        },
      },
    ] as Control[],
  };
}
```

### i386 견본 항목 — `src/demos/demosPerf.ts:1613`
```ts
  i386: { kind: '2d', caption: '첫 터치 때 소리 장치를 만들면 멈칫 · 소리 늦음 vs 카드 누를 때 미리 깨우기', make: demo386 }
```

## 관련 기술
- 다음에 해 볼 기술: [긴 작업 찾기 (longtask · 프레임 그래프)](https://ai-techstudio.web.app/ai/t/i394.md) `i394`
- 참고 문서: [MDN — AudioContext.resume()](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/resume)
