# AI 꾸러미 — 배경음악 넣기 (반복 · 페이드 · 켜고 끄기) — Background music (looping HTMLAudioElement)
> 게임마다 곡을 정해 이음새 없이 반복하고, 열 때 서서히 · 장면이 바뀌면 부드럽게 갈아 끼우고 · 탭을 숨기면 멈추고 · 소리 단추로 끈다.  
> 견본: https://ai-techstudio.web.app/#t/u77

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

## 주문서

### 만들어 줘: 배경음악 넣기 (반복 · 페이드 · 켜고 끄기) — Background music (looping HTMLAudioElement)

#### 1. 목표
이야기가 있는 게임 (장면마다 곡)에 배경음악을 넣어 줘 — 게임 → 곡 표, 열 때 서서히 커지고, 장면이 바뀌면 곡을 부드럽게 갈아 끼우고, 탭을 숨기면 멈추고, 소리 단추로 끄고 켜게. 분위기는 작고 잔잔하게 깔리는.

#### 2. 핵심 기술 용어
- **Background music (looping HTMLAudioElement)** — 반복 재생하는 배경음악
- **Crossfade (fade in · fade out)** — 나가는 곡은 줄이며 멈추고 새 곡은 서서히
- **Autoplay policy · user gesture** — 자동 재생 막힘 — 첫 터치 뒤에 다시
- **Page Visibility API (visibilitychange)** — 탭이 숨으면 멈추고 보이면 다시

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 첫 터치 전에는 소리가 안 난다는 것을 전제로 — play() 실패 시 pointerdown 한 번에 다시
- 곡마다 따로 페이드 타이머 (나가는 곡이 줄어드는 동안 새 곡이 커져도 서로 끊지 않게)
- visibilitychange 로 탭이 숨으면 멈춤 — 백그라운드에서 계속 울리지 않게
- iOS 는 코드로 volume 을 못 줄인다 — 곡 파일 자체를 효과음보다 작게(약 −30 LUFS) 만들어 둔다
- 게임 창을 연 직후엔 곡 받기를 미루고, 그림을 다 받은 뒤 (최대 5초 안에) 튼다

#### 5. 완성 기준 (이게 보이면 성공)
- 게임을 열면 곡이 1.4초에 걸쳐 서서히 커지고, 닫으면 0.5초에 줄며 멈춘다
- 장면 신호(예: 3일차 시작)에 곡이 끊김 없이 바뀌고, 같은 곡이면 처음부터 다시 시작하지 않는다
- 탭을 다른 곳으로 옮기면 음악이 멈추고 돌아오면 다시 커진다
- 폰에서 첫 화면이 곡 때문에 늦게 뜨지 않는다

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

## 원리
- 게임 id → 곡 이름 표(TRACK_OF)를 두고, 게임 창이 열 때 play · 닫을 때 stop 을 부른다. 표에 없는 게임은 조용히.
- 곡을 바꿀 때 나가는 곡은 0.5초에 걸쳐 줄인 뒤 멈추고, 새 곡은 0 에서 1.4초에 걸쳐 키운다. 같은 곡이면 그대로 둔다 (메뉴 ↔ 판).
- 브라우저는 사용자가 누르기 전엔 소리를 막는다 — play() 가 실패하면 다음 pointerdown 한 번에 다시 시도한다.
- 탭이 숨으면(document.hidden) pause, 다시 보이면 서서히 다시 튼다. 소리 단추는 0.25초에 줄인 뒤 pause.

## 핵심 코드 — 곡 갈아 끼우기 · 페이드 · 자동 재생 막힘 처리
(발췌: src/game/core/Bgm.ts fadeTo · start · switchTo 를 정리 (늦게 받기 SETTLE 은 뺌))
```ts
const FADE_IN = 1.4, FADE_OUT = 0.5;
let audio: HTMLAudioElement | null = null, track = '', muted = false;
const fadeTimers = new WeakMap<HTMLAudioElement, number>();   // 곡마다 따로

function fadeTo(el: HTMLAudioElement, to: number, sec: number, done?: () => void) {
  window.clearInterval(fadeTimers.get(el));
  const from = el.volume, t0 = performance.now();
  const timer = window.setInterval(() => {
    const k = Math.min(1, (performance.now() - t0) / (sec * 1000));
    el.volume = from + (to - from) * k;
    if (k >= 1) { window.clearInterval(timer); done?.(); }
  }, 40);
  fadeTimers.set(el, timer);
}
function start() {
  if (!audio || muted || document.hidden) return;
  audio.volume = 0;
  void audio.play().then(
    () => audio && fadeTo(audio, 1, FADE_IN),
    () => window.addEventListener('pointerdown', start, { once: true }), // 자동 재생 막힘 → 다음 터치에
  );
}
document.addEventListener('visibilitychange', () => {
  if (!audio) return;
  if (document.hidden) audio.pause(); else start();
});
function switchTo(name: string, url?: string) {
  if (name === track && audio) { start(); return; }             // 같은 곡이면 그대로
  const old = audio;
  audio = null; track = '';
  window.removeEventListener('pointerdown', start);
  if (old) fadeTo(old, 0, FADE_OUT, () => { old.pause(); old.src = ''; });
  if (!url) return;
  track = name;
  audio = new Audio(url);
  audio.loop = true;
  audio.preload = 'auto';
  start();
}
```

## 흔한 실수 · 확인 목록
- [ ] **게임을 열자마자 곡을 받으면 폰에서 첫 화면이 2 ~ 3초 늦게 뜬다** — 0.9MB 곡이 게임 코드 · 그림보다 먼저 받아졌다 (수학 검문소에서 측정). 게임이 다 뜨고 그림을 받은 뒤, 늦어도 5초 안에 튼다.
- [ ] **iOS 에서 음량 페이드가 안 먹는다** — iOS 는 HTMLAudioElement.volume 을 무시한다. 곡 파일을 미리 작게 다듬어 두고, 코드에서는 켜고 끄기만 믿는다.
- [ ] **페이드 타이머 하나를 같이 쓰면 곡을 빨리 바꿀 때 소리가 뚝 끊긴다** — 곡(오디오 요소)마다 WeakMap 으로 타이머를 따로 둔다.
- [ ] **탭을 숨겨도 음악이 계속 울린다** — visibilitychange 에서 pause, 보이면 start 로 다시 서서히.

## 완성 기준 체크리스트
- [ ] 게임을 열면 곡이 1.4초에 걸쳐 서서히 커지고, 닫으면 0.5초에 줄며 멈춘다
- [ ] 장면 신호(예: 3일차 시작)에 곡이 끊김 없이 바뀌고, 같은 곡이면 처음부터 다시 시작하지 않는다
- [ ] 탭을 다른 곳으로 옮기면 음악이 멈추고 돌아오면 다시 커진다
- [ ] 폰에서 첫 화면이 곡 때문에 늦게 뜨지 않는다

## 이 기술 정보
- id: `u77` · 분류: 소리 › 소리 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 곡 파일 약 0.9MB 하나. 게임 창을 열자마자 받으면 폰 데이터망에서 첫 화면이 2 ~ 3초 늦어진다 — 화면이 다 뜬 뒤(최대 5초) 받는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u77
- 쓰면 좋을 때: 세계 · 이야기가 있는 게임에 분위기를 깔 때 / 장면(메뉴 · 날마다 · 보스)마다 곡을 바꿀 때
- 쓰지 말 때: 집중해야 하는 수읽기 보드게임 · 고전 퍼즐 — 이 사이트는 일부러 효과음만 둔다 / 박자 · 층을 실시간으로 바꿔야 할 때 — 파일 재생 대신 Web Audio 층 쌓기(i435)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### tone — `src/demos/demosSystem.ts:249`
```ts
function tone(o: ToneOpt, dest?: AudioNode): void {
  const c = ac();
  const t0 = c.currentTime + (o.delay ?? 0);
  const osc = c.createOscillator();
  const gn = c.createGain();
  osc.type = o.type ?? 'sine';
  osc.frequency.setValueAtTime(o.freq, t0);
  if (o.to) osc.frequency.exponentialRampToValueAtTime(o.to, t0 + o.dur);
  gn.gain.setValueAtTime(0.0001, t0);
  gn.gain.exponentialRampToValueAtTime(o.vol ?? 0.15, t0 + 0.01);
  gn.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur);
  osc.connect(gn).connect(dest ?? c.destination);
  osc.start(t0);
  osc.stop(t0 + o.dur + 0.05);
}
```

### u77 견본 항목 — `src/demos/demosSystem.ts:783`
```ts
  u77: {
    kind: '2d',
    caption: '게임을 열면 그 게임의 곡이 서서히 · 장면 신호에 곡 갈아 끼우기 · 탭을 숨기면 멈춤',
    make() {
      const P = 12;
      const vA = (x: number): number => (x < 1 ? 0 : x < 2 ? x - 1 : x < 4 ? 1 : x < 5 ? 5 - x : 0);
      const vB = (x: number): number => {
        let v = x < 4 ? 0 : x < 5 ? x - 4 : 1;
        if (x >= 7 && x < 9) v = Math.max(0, 1 - (x - 7) * 2);
        if (x >= 9 && x < 10) v = x - 9;
        if (x >= 11) v = Math.max(0, 1 - (x - 11) * 2);
        return v;
      };
      const EV: [number, string][] = [
        [1, '게임 열기'],
        [4, '장면 신호'],
        [7, '탭 숨김'],
        [9, '탭 보임'],
        [11, '닫기'],
      ];
      let rot = 0;
      return {
        draw(g, w, h, t, dt) {
          stage(g, w, h, PEACH);
          const x = t % P;
          const a = vA(x);
          const b = vB(x);
          const hidden = x >= 7 && x < 9;
          rot += dt * 3 * Math.max(a, b);
          // 게임 → 곡
          box(g, 12, 12, 92, 30, 8, '#fff', '#ffb27a', 1.5);
          txt(g, '수학 검문소', 58, 21, 9, '#8a4a1a', 'center', 800);
          txt(g, x < 4 ? '' : '· 3일차', 58, 33, 8, '#b06a2a', 'center', 700);
          arrow(g, 108, 27, 130, 27, '#c96a2a', 2);
          // 판
          const cur = b > a ? 'mystery' : 'quirky-fun';
          const col = b > a ? '#5b6cff' : '#ff7a3d';
          g.save();
          g.translate(170, 46);
          g.rotate(rot);
          circle(g, 0, 0, 30, '#2a2230');
          for (let r = 12; r < 29; r += 4) circle(g, 0, 0, r, null, 'rgba(255,255,255,0.12)', 1);
          circle(g, 0, 0, 11, col);
          circle(g, 0, -6, 2, '#fff');
          circle(g, 0, 0, 2, '#2a2230');
          g.restore();
          txt(g, cur, 170, 86, 9, col, 'center', 800, MONO);
          // 음표
          if (Math.max(a, b) > 0.05)
            for (let i = 0; i < 3; i++) {
              const k = (t * 0.6 + i / 3) % 1;
              g.globalAlpha = (1 - k) * Math.max(a, b);
              txt(g, '♪', 210 + k * 30, 40 - k * 26 + Math.sin(k * 8 + i) * 4, 13, col, 'center', 800);
              g.globalAlpha = 1;
            }
          // 탭
          box(g, 246, 14, 64, 44, 6, hidden ? '#d6d0cc' : '#fff', '#c9a88a', 1.5);
          box(g, 246, 14, 64, 10, 3, hidden ? '#b8b0aa' : '#ffcf9e');
          txt(g, hidden ? '탭 숨김' : '탭 보임', 278, 42, 9, hidden ? '#7a6f6a' : '#8a4a1a', 'center', 800);
          // 음량 그래프
          const gx = 18;
          const gy = 108;
          const gw = 286;
          const gh = 58;
          box(g, gx - 6, gy - 8, gw + 12, gh + 30, 8, '#fffaf4', '#f0c9a0', 1.2);
          for (const [fn, c] of [
            [vA, '#ff7a3d'],
            [vB, '#5b6cff'],
          ] as [(x: number) => number, string][]) {
            g.beginPath();
            for (let q = 0; q <= 120; q++) {
              const xx = (q / 120) * P;
              const yy = gy + gh - fn(xx) * gh;
              if (q) g.lineTo(gx + (xx / P) * gw, yy);
              else g.moveTo(gx, yy);
            }
            g.strokeStyle = c;
            g.lineWidth = 2.2;
            g.stroke();
          }
          for (const [ex, lab] of EV) {
            const px = gx + (ex / P) * gw;
            line(g, px, gy, px, gy + gh, 'rgba(120,80,40,0.3)', 1, [2, 2]);
            txt(g, lab, px, gy + gh + 11, 7.5, Math.abs(x - ex) < 0.8 ? '#c0400a' : '#9a7a5a', 'center', 800);
          }
          const px = gx + (x / P) * gw;
          line(g, px, gy - 4, px, gy + gh, '#2a2230', 2);
          circle(g, px, gy + gh - Math.max(a, b) * gh, 3.5, '#2a2230');
          txt(g, '음량', gx, gy - 1, 7.5, '#9a7a5a', 'left', 800);
        },
        controls: [
          {
            type: 'button',
            label: '곡 맛보기 (마림바)',
            on: () => {
              const notes = [523, 659, 784, 659, 880, 784, 659, 587, 523, 587, 659, 523];
              notes.forEach((f, i) => tone({ freq: f, dur: 0.35, type: 'sine', vol: 0.14, delay: i * 0.22 }));
              [262, 330, 220, 262].forEach((f, i) => tone({ freq: f, dur: 0.8, type: 'triangle', vol: 0.08, delay: i * 0.66 }));
            },
          },
        ],
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [배경음악 층 쌓기 (층마다 켜고 끄기)](https://ai-techstudio.web.app/ai/t/i435.md) `i435` · [소리 낮추기 (덕킹)](https://ai-techstudio.web.app/ai/t/i437.md) `i437`
- 참고 문서: [MDN — HTMLMediaElement.play()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play) · [MDN — Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)
