# AI 꾸러미 — 공간 소리 (PannerNode) — Spatial audio — Web Audio PannerNode
> PannerNode 로 소리에 3D 자리를 주고 그 자리를 시간에 따라 옮겨, 공이 왼쪽에서 오른쪽으로 날면 소리도 왼쪽 귀에서 오른쪽 귀로 옮겨 간다.  
> 견본: https://ai-techstudio.web.app/#t/i31

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

## 주문서

### 만들어 줘: 공간 소리 (PannerNode) — Spatial audio — Web Audio PannerNode

#### 1. 목표
날아가는 공 소리에 공간 소리를 넣어 줘 — 소리 나는 물체의 자리를 PannerNode 로 옮겨 왼쪽 · 오른쪽 · 앞뒤에서 들리게. 분위기는 이어폰으로 또렷한 입체.

#### 2. 핵심 기술 용어
- **Spatial audio — Web Audio PannerNode** — 공간 소리 — 소리에 3D 자리 주기
- **HRTF panning model** — 머리 · 귀 모양을 흉내 낸 입체 소리 (이어폰에서 또렷)
- **AudioParam automation (linearRampToValueAtTime)** — 자리를 시간에 따라 미리 예약해 옮기기
- **StereoPannerNode** — 간단한 왼쪽 ↔ 오른쪽만 (−1 ~ 1)

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

#### 4. 조건
- AudioContext 는 사용자가 누를 때 만들거나 resume() — 그 전엔 소리가 안 난다
- 자리는 매 프레임 value 로 바꾸지 말고 setValueAtTime · linearRampToValueAtTime 으로 예약 (지지직 막기)
- 화면 좌표 → 소리 좌표 비율을 정해 두기 (견본: 화면 끝 = X ±3, 앞 Z −1)
- 이어폰을 권하는 안내 — 스피커로는 차이가 작다

#### 5. 완성 기준 (이게 보이면 성공)
- 「들어 보기」를 누르면 띵똥 소리가 2.4초에 걸쳐 왼쪽 귀에서 오른쪽 귀로 옮겨 간다 (이어폰)
- 화면의 공 · 왼쪽 오른쪽 음량 막대가 소리 자리와 맞게 움직인다
- HRTF 와 equalpower 를 바꿔 비교하면 HRTF 가 더 「바깥에서」 들린다

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

## 원리
- 듣는 사람은 가운데(0, 0, 0)에 있고, PannerNode 의 positionX · Y · Z 가 소리 나는 자리다. X 음수 = 왼쪽, 양수 = 오른쪽.
- panningModel = 'HRTF' 로 두면 머리 · 귀를 지나는 소리를 흉내 내어 이어폰에서 방향이 또렷하다.
- 자리는 AudioParam 이라 linearRampToValueAtTime 으로 「2.4초 동안 −3 → +3」 처럼 미리 예약하면 화면 프레임과 상관없이 매끄럽게 움직인다.
- 여러 소리를 한 PannerNode 에 이으면 모두 같은 자리에서 난다 — 견본은 「띵똥」 12번을 하나의 움직이는 판너에 잇는다.

## 핵심 코드 — 왼쪽 → 오른쪽으로 옮겨 가는 소리 (HRTF)
(발췌: demos/demosSystem.ts i31 「들어 보기」 단추 + tone() 을 정리)
```ts
let AC: AudioContext | null = null;
function ac(): AudioContext {
  if (!AC) AC = new AudioContext();
  if (AC.state === 'suspended') void AC.resume();            // 첫 터치 뒤에 풀림
  return AC;
}
function tone(freq: number, dur: number, delay: number, dest: AudioNode) {
  const c = ac(), t0 = c.currentTime + delay;
  const osc = c.createOscillator(), gn = c.createGain();
  osc.type = 'triangle';
  osc.frequency.setValueAtTime(freq, t0);
  gn.gain.setValueAtTime(0.0001, t0);
  gn.gain.exponentialRampToValueAtTime(0.18, t0 + 0.01);
  gn.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
  osc.connect(gn).connect(dest);
  osc.start(t0);
  osc.stop(t0 + dur + 0.05);
}
function flyBy() {
  const c = ac(), pn = c.createPanner();
  pn.panningModel = 'HRTF';                                    // 이어폰에서 방향이 또렷
  const t0 = c.currentTime;
  pn.positionX.setValueAtTime(-3, t0);                         // 왼쪽에서
  pn.positionX.linearRampToValueAtTime(3, t0 + 2.4);           // 오른쪽으로 2.4초
  pn.positionZ.setValueAtTime(-1, t0);                         // 살짝 앞
  pn.connect(c.destination);
  for (let i = 0; i < 12; i++) tone(660 + (i % 2) * 220, 0.16, i * 0.2, pn);
}
```

## 흔한 실수 · 확인 목록
- [ ] **자리를 매 프레임 .value 로 바꾸면 지지직 잡음이 난다** — 값이 계단처럼 뛴다. setTargetAtTime · linearRampToValueAtTime 으로 예약하거나 짧게 미끄러지게.
- [ ] **폰 스피커에서는 차이가 거의 안 들린다** — 스피커가 하나이거나 가깝다. 이어폰을 권하고, 거리는 소리 크기로도 함께 표현한다.
- [ ] **옛 브라우저에서 positionX 가 없다** — 아주 옛 사파리는 setPosition(x, y, z) 만 있다. 없으면 StereoPannerNode 로 좌우만.
- [ ] **모든 소리를 3D 로 두면 정답 · 단추 소리가 한쪽에서 들린다** — 화면 소리(UI)는 판너 없이 가운데로, 세계 안 물체 소리만 판너로.

## 완성 기준 체크리스트
- [ ] 「들어 보기」를 누르면 띵똥 소리가 2.4초에 걸쳐 왼쪽 귀에서 오른쪽 귀로 옮겨 간다 (이어폰)
- [ ] 화면의 공 · 왼쪽 오른쪽 음량 막대가 소리 자리와 맞게 움직인다
- [ ] HRTF 와 equalpower 를 바꿔 비교하면 HRTF 가 더 「바깥에서」 들린다

## 이 기술 정보
- id: `i31` · 분류: 소리 › 소리 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — HRTF 판너는 일반 판너보다 무겁지만 동시에 몇십 개는 괜찮다. 수백 개면 'equalpower' 나 StereoPannerNode 로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i31
- 쓰면 좋을 때: 소리 나는 물체가 화면에서 움직일 때 (공 · 차 · 상대) / 화면 밖 일을 소리로 알려 줄 때 (「왼쪽에서 뭔가 온다」)
- 쓰지 말 때: 폰 스피커 하나로만 듣는 게임 — 좌우 차이가 거의 안 들린다, 크기 · 높이 변화가 더 잘 들린다 / 단추 · 정답 같은 화면 소리 — 가운데가 맞다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i31 견본 항목 — `src/demos/demosSystem.ts:2081`
```ts
  i31: {
    kind: '2d',
    caption: 'PannerNode — 공이 왼쪽에서 오른쪽으로 날면 소리도 왼쪽 귀 → 오른쪽 귀로 (이어폰 끼고 단추로 듣기)',
    make() {
      return {
        draw(g, w, h, t) {
          stage(g, w, h, SKY);
          const p = pingpong(t, 4);
          const pan = p * 2 - 1;
          const bx = 40 + p * 240;
          const by = 60 - Math.sin(p * Math.PI) * 30;
          // 공 자국
          for (let i = 1; i < 8; i++) {
            const q = clamp(p - (i * 0.02 * (Math.floor(t / 2) % 2 ? -1 : 1)));
            circle(g, 40 + q * 240, 60 - Math.sin(q * Math.PI) * 30, 8 - i * 0.6, `rgba(255,255,255,${0.5 - i * 0.06})`);
          }
          circle(g, bx, by, 11, '#fff', '#c94a3a', 1.5);
          g.beginPath();
          g.arc(bx - 9, by, 9, -0.9, 0.9);
          g.strokeStyle = '#e8453c';
          g.lineWidth = 1.4;
          g.stroke();
          g.beginPath();
          g.arc(bx + 9, by, 9, Math.PI - 0.9, Math.PI + 0.9);
          g.stroke();
          // 소리 고리
          for (let i = 0; i < 3; i++) {
            const k = (t * 1.6 + i / 3) % 1;
            circle(g, bx, by, 12 + k * 30, null, `rgba(91,108,255,${0.6 * (1 - k)})`, 1.5);
          }
          // 머리 (위에서)
          circle(g, 160, 150, 24, '#ffd9c0', '#c9895a', 1.8);
          g.beginPath();
          g.arc(160, 150, 24, Math.PI * 1.05, Math.PI * 1.95);
          g.fillStyle = '#5a3a2a';
          g.fill();
          const L = clamp(0.5 - pan * 0.5 + 0.1);
          const R = clamp(0.5 + pan * 0.5 + 0.1);
          circle(g, 134, 152, 6 + L * 4, '#7ab0ff');
          circle(g, 186, 152, 6 + R * 4, '#ff7a9a');
          txt(g, '듣는 사람', 160, 186, 8.5, '#3a4f8c', 'center', 800);
          // 음량 막대
          const bar = (x: number, v: number, c: string, lab: string): void => {
            box(g, x, 110, 16, 70, 5, '#fff', '#c8d6ee', 1.2);
            box(g, x + 2, 178 - v * 66, 12, v * 66, 4, c);
            txt(g, lab, x + 8, 102, 9, c, 'center', 800);
          };
          bar(70, L, '#5b8cff', '왼쪽');
          bar(234, R, '#ff5a8a', '오른쪽');
          pill(g, `pan ${pan >= 0 ? '+' : ''}${pan.toFixed(2)}`, 160, 106, '#3a4f8c', '#fff', 8.5);
        },
        controls: [
          {
            type: 'button',
            label: '들어 보기 (이어폰)',
            on: () => {
              const c = ac();
              const pn = c.createPanner();
              pn.panningModel = 'HRTF';
              const t0 = c.currentTime;
              pn.positionX.setValueAtTime(-3, t0);
              pn.positionX.linearRampToValueAtTime(3, t0 + 2.4);
              pn.positionZ.setValueAtTime(-1, t0);
              pn.connect(c.destination);
              for (let i = 0; i < 12; i++) tone({ freq: 660 + (i % 2) * 220, dur: 0.16, type: 'triangle', vol: 0.18, delay: i * 0.2 }, pn);
            },
          },
        ],
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [도플러 「니이이용」](https://ai-techstudio.web.app/ai/t/i157.md) `i157` · [가림 저역 필터 (벽 뒤 소리)](https://ai-techstudio.web.app/ai/t/i158.md) `i158`
- 참고 문서: [MDN — PannerNode](https://developer.mozilla.org/en-US/docs/Web/API/PannerNode) · [MDN — Web audio spatialization basics](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Web_audio_spatialization_basics)
