# AI 꾸러미 — 오디오 스프라이트 — Audio sprite
> 효과음 여러 개를 틈을 두고 버퍼(파일) 하나에 이어 붙이고 시작 위치 · 길이 표를 둬서, start(때, 시작, 길이) 로 골라 재생해 받기 요청을 5번에서 1번으로 줄인다.  
> 견본: https://ai-techstudio.web.app/#t/i164

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

## 주문서

### 만들어 줘: 오디오 스프라이트 — Audio sprite

#### 1. 목표
게임 하나의 효과음 묶음을 오디오 스프라이트로 묶어 줘 — 효과음 여러 개를 틈을 두고 한 버퍼에 이어 붙이고, 이름 → 시작 · 길이 표로 골라 재생하게. 조건은 받기 요청을 가장 적게.

#### 2. 핵심 기술 용어
- **Audio sprite** — 소리 여러 개를 한 파일에 — 위치로 골라 재생
- **AudioBufferSourceNode.start(when, offset, duration)** — 버퍼의 일부만 잘라 재생
- **OfflineAudioContext rendering** — 소리를 화면 밖에서 버퍼로 미리 굽기
- **Sprite map (offset · duration table)** — 소리 이름 → 시작 · 길이 표

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

#### 4. 조건
- 소리 사이에 틈(0.1초 이상) — 앞 소리 꼬리가 다음 구간에 새어 들지 않게
- 시작 · 길이 표는 소리를 굽거나 자를 때 함께 만든다 — 손으로 적지 않는다
- 재생할 때마다 새 AudioBufferSourceNode (한 번 쓰면 다시 start 못 함), 버퍼는 공유
- 구간 끝을 duration 으로 정확히 — 안 주면 버퍼 끝까지 다 나온다

#### 5. 완성 기준 (이게 보이면 성공)
- 「▶ 딩딩」 · 「▶ 보잉」 … 단추를 누르면 그 소리만 나오고 다음 소리가 섞이지 않는다
- 긴 파형 위에 지금 재생 중인 구간이 밝아지고 재생 줄이 지나간다
- source.start(0, 시작, 길이) 값이 화면에 보인다

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

## 원리
- 작은 효과음 파일이 많으면 받기 요청이 많아져 폰 데이터망에서 느리다. 하나로 묶으면 한 번에 받는다.
- 견본은 효과음 5개(딩딩 · 보잉 · 퓨웅 · 부부 · 띵~)를 OfflineAudioContext(44.1kHz 모노)에서 0.12초 틈을 두고 차례로 구워 버퍼 하나로 만든다.
- 구우면서 소리마다 { 이름, 시작 at, 길이 dur } 를 표에 적는다.
- 재생은 새 AudioBufferSourceNode 에 같은 버퍼를 물리고 start(지금 + 0.01, at, dur) — 그 구간만 나온다.
- 파일로 내보낼 때도 같은 표(JSON)를 함께 두면 된다.

## 핵심 코드 — 효과음 묶어 굽기 + 구간 재생
(발췌: demos/demosSfx.ts makeSprite · spriteDemo play() 를 정리)
```ts
type Rec = (c: BaseAudioContext, out: AudioNode, t0: number) => void;   // 효과음 하나 그리기 함수
interface Mark { name: string; at: number; dur: number }

async function makeSprite(sounds: { name: string; rec: Rec; dur: number }[], gap = 0.12) {
  const sr = 44100;
  const total = sounds.reduce((a, s) => a + s.dur + gap, 0);
  const oc = new OfflineAudioContext(1, Math.ceil(sr * total), sr);
  const out = oc.createGain();
  out.gain.value = 1.4;
  out.connect(oc.destination);
  let at = 0.01;
  const marks: Mark[] = [];
  for (const s of sounds) {
    s.rec(oc, out, at);                                     // 차례로 굽고
    marks.push({ name: s.name, at, dur: s.dur });           // 시작 · 길이 표
    at += s.dur + gap;                                      // 틈을 두고 다음
  }
  const buf = await oc.startRendering();
  return { buf, marks };
}
function playSprite(ac: AudioContext, out: AudioNode, sp: { buf: AudioBuffer; marks: Mark[] }, name: string) {
  const m = sp.marks.find((x) => x.name === name);
  if (!m) return;
  const src = ac.createBufferSource();                      // 재생마다 새 소스, 버퍼는 공유
  src.buffer = sp.buf;
  src.connect(out);
  src.start(ac.currentTime + 0.01, m.at, m.dur);            // 그 구간만
}
```

## 흔한 실수 · 확인 목록
- [ ] **소리 사이에 틈이 없으면 앞 소리 꼬리가 뒤 소리 앞에 묻어 나온다** — 0.1초 이상 조용한 틈을 둔다 (견본 0.12초).
- [ ] **mp3 로 내보내면 구간 위치가 조금 밀린다** — mp3 인코딩은 앞에 짧은 빈 소리가 붙을 수 있다. 틈을 넉넉히 두고, 내보낸 파일로 다시 들어 구간을 확인한다.
- [ ] **같은 소스 노드를 다시 start 하면 오류가 난다** — AudioBufferSourceNode 는 한 번만 쓴다. 재생마다 새로 만들고 버퍼만 같이 쓴다.

## 완성 기준 체크리스트
- [ ] 「▶ 딩딩」 · 「▶ 보잉」 … 단추를 누르면 그 소리만 나오고 다음 소리가 섞이지 않는다
- [ ] 긴 파형 위에 지금 재생 중인 구간이 밝아지고 재생 줄이 지나간다
- [ ] source.start(0, 시작, 길이) 값이 화면에 보인다

## 이 기술 정보
- id: `i164` · 분류: 소리 › 효과음 (SFX) · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 버퍼 하나를 메모리에 둔다 (5개 약 3.5초 = 15만 표본). 재생은 소스 노드 하나.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i164
- 쓰면 좋을 때: 효과음이 많고 각각 짧은 게임 (폰 첫 실행 속도) / 여러 소리를 한 번에 미리 받아 두고 싶을 때
- 쓰지 말 때: 긴 배경음악 — 따로 파일로 스트리밍(u77) / 소리가 두세 개뿐 — 묶는 수고가 더 크다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeSprite — `src/demos/demosSfx.ts:4268`
```ts
function makeSprite(): Promise<Sprite> {
  if (SPRITE) return SPRITE;
  SPRITE = (async (): Promise<Sprite> => {
    const sr = 44100;
    const gap = 0.12;
    const total = SPRITE_SOUNDS.reduce((a, s) => a + s.dur + gap, 0);
    const oc = new OfflineAudioContext(1, Math.ceil(sr * total), sr);
    const out = oc.createGain();
    out.gain.value = 1.4;
    out.connect(oc.destination);
    let at = 0.01;
    const marks: Sprite['marks'] = [];
    for (const s of SPRITE_SOUNDS) {
      s.rec(oc, out, at, s.p, rng(9));
      marks.push({ name: s.name, color: s.color, at, dur: s.dur });
      at += s.dur + gap;
    }
    const buf = await oc.startRendering();
    return { buf, marks, peaks: peaksOf(buf.getChannelData(0)) };
  })();
  return SPRITE;
}
```

### spriteDemo — `src/demos/demosSfx.ts:4290`
```ts
const spriteDemo: Demo2D = {
  kind: '2d',
  caption: '효과음 5개를 버퍼 하나에 이어 붙이고(OfflineAudioContext), 시작 위치 · 길이로 골라 재생',
  make() {
    let sp: Sprite | null = null;
    void makeSprite().then((s) => (sp = s));
    const bus = new Bus();
    let playing = -1;
    let playAt = 0;
    const play = (i: number): void => {
      if (!sp) return;
      const ac = audio();
      const m = sp.marks[i]!;
      const src = ac.createBufferSource();
      src.buffer = sp.buf;
      src.connect(bus.node());
      src.start(ac.currentTime + 0.01, m.at, m.dur);
      playing = i;
      playAt = performance.now();
    };
    return {
      controls: SPRITE_SOUNDS.map((s, i): Control => ({ type: 'button', label: `▶ ${s.name}`, on: () => play(i) })),
      draw(g, w, h, t) {
        let cur = -1;
        let k = 0;
        const real = playing >= 0 && sp && performance.now() - playAt < sp.marks[playing]!.dur * 1000 + 300;
        if (real && sp) {
          cur = playing;
          k = (performance.now() - playAt) / 1000 / sp.marks[playing]!.dur;
        } else {
          const per = 1.3;
          cur = Math.floor(t / per) % SPRITE_SOUNDS.length;
          k = (t % per) / per / 0.7;
        }
        frame(g, w, h, t, '#ffc93a', () => {
          txt(g, '오디오 스프라이트 — 파일 하나 · 소리 다섯', 12, 13, 11, '#ffe6a0', 'left', 900);
          // 파일 아이콘
          rr(g, 12, 28, 30, 36, 4);
          g.fillStyle = '#f4f0ff';
          g.fill();
          g.fillStyle = '#c9c0e8';
          g.beginPath();
          g.moveTo(32, 28);
          g.lineTo(42, 38);
          g.lineTo(32, 38);
          g.fill();
          txt(g, 'sfx', 27, 52, 8, '#5a4adf', 'center', 900, MONO);
          txt(g, '.mp3 1개', 27, 72, 7, 'rgba(255,255,255,0.6)');
          // 긴 파형
          const gx = 50;
          const gw = 218;
          const gy = 30;
          const gh = 46;
          vpanel(g, gx, gy, gw, gh, '#ffc93a');
          if (sp) {
            const total = sp.buf.duration;
            sp.marks.forEach((m, i) => {
              const x0 = gx + (m.at / total) * gw;
              const x1 = gx + ((m.at + m.dur) / total) * gw;
              rr(g, x0, gy + 1, x1 - x0, gh - 2, 3);
              g.fillStyle = rgba(m.color, i === cur ? 0.28 : 0.08);
              g.fill();
              if (i === cur) {
                g.strokeStyle = m.color;
                g.lineWidth = 1.5;
                g.stroke();
              }
            });
            g.save();
            wavePath(g, { x: gx, y: gy, w: gw, h: gh }, sp.peaks, gx, gy + gh / 2, gh * 0.42);
            g.fillStyle = 'rgba(255,255,255,0.75)';
            g.fill();
            g.restore();
            sp.marks.forEach((m, i) => {
              const x0 = gx + (m.at / total) * gw;
              const x1 = gx + ((m.at + m.dur) / total) * gw;
              txt(g, m.name, (x0 + x1) / 2, gy + gh + 9, 8, i === cur ? m.color : 'rgba(255,255,255,0.55)', 'center', 800);
              if (i === cur && k <= 1) {
                const px = lerp(x0, x1, clamp(k));
                g.strokeStyle = '#fff';
                g.lineWidth = 1.5;
                g.beginPath();
                g.moveTo(px, gy - 3);
                g.lineTo(px, gy + gh + 3);
                g.stroke();
              }
            });
            const m = sp.marks[cur]!;
            vpanel(g, 50, 98, 218, 40, m.color, 0.06);
            txt(g, `source.start(0, ${m.at.toFixed(2)}, ${m.dur.toFixed(2)})`, 159, 112, 9.5, m.color, 'center', 800, MONO);
            txt(g, `시작 ${m.at.toFixed(2)}초 · 길이 ${m.dur.toFixed(2)}초만 잘라 재생`, 159, 128, 7.5, 'rgba(255,255,255,0.6)');
          } else txt(g, '버퍼 굽는 중…', gx + gw / 2, gy + gh / 2, 9, 'rgba(255,255,255,0.5)');
          txt(g, '요청 1번', 27, 112, 8, '#9fe870', 'center', 900);
          txt(g, '(5번 → 1번)', 27, 124, 6.5, 'rgba(255,255,255,0.5)');
        });
      },
      dispose: () => bus.kill(),
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [코드로 효과음 합성 (견본판 70가지)](https://ai-techstudio.web.app/ai/t/i499.md) `i499`
- 다음에 해 볼 기술: [채널 버스 · 음량 조절](https://ai-techstudio.web.app/ai/t/i165.md) `i165`
- 참고 문서: [MDN — AudioBufferSourceNode.start()](https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/start) · [MDN — OfflineAudioContext](https://developer.mozilla.org/en-US/docs/Web/API/OfflineAudioContext)
