# AI 꾸러미 — 채널 버스 · 음량 조절 — Mixer buses (submix groups)
> 모든 소리를 음악 · 효과음 · 목소리 · 환경음 버스로 나눠 주 음량 → 가벼운 리미터로 모으고, 버스마다 음량을 조절 · 저장해 소리 균형을 사용자가 맞추게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i165

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

## 주문서

### 만들어 줘: 채널 버스 · 음량 조절 — Mixer buses (submix groups)

#### 1. 목표
게임 설정 화면 (소리)에 채널 버스와 음량 조절을 넣어 줘 — 음악 · 효과음 · 목소리 · 환경음 버스, 버스마다 음량 · 저장, 끝에 가벼운 리미터. 화면은 믹서 페이더처럼.

#### 2. 핵심 기술 용어
- **Mixer buses (submix groups)** — 채널 버스 — 소리 종류별로 모으는 길
- **Master gain · limiter (DynamicsCompressorNode)** — 주 음량 · 끝에서 찢어짐 막는 압축기
- **Volume settings persistence (localStorage)** — 음량 설정 저장
- **AnalyserNode level meter** — 버스마다 소리 크기 막대

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

#### 4. 조건
- 모든 소리는 반드시 어느 버스를 지나게 — 스피커(destination)에 바로 잇는 코드를 금지
- 끝에 리미터(압축기) 하나 — 여러 소리가 겹쳐도 찢어지지 않게
- 음량 바꿈은 setTargetAtTime 으로 (딸깍 없이), 저장 · 읽기는 try/catch (저장소 막힘 대비)
- 저장 키에 버전을 붙인다 (예: …-v1) — 나중에 구조가 바뀌어도 꼬이지 않게

#### 5. 완성 기준 (이게 보이면 성공)
- 네 버스를 켜면 음악 · 동전 소리 · 말소리 · 비 소리가 함께 나고, 버스마다 크기 막대가 움직인다
- 효과음 막대를 0 으로 내리면 동전 소리만 사라지고 나머지는 그대로다
- 새로고침해도 음량 설정이 그대로 남는다 (「저장됨 ✓」)
- 모두 최대로 올려도 소리가 찢어지지 않는다

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

## 원리
- 소리를 내는 곳은 스피커에 바로 잇지 않고 자기 종류의 버스(GainNode)에 잇는다.
- 버스 넷 → 주 음량(0.35) → 리미터(압축기: −10dB · knee 6 · 12:1 · 어택 3ms · 놓기 0.15초) → 분석기 → 스피커.
- 사용자가 버스 막대를 움직이면 그 버스 게인을 setTargetAtTime(v, 지금, 0.03) 로 바꾸고 바로 저장한다.
- 기본값: 음악 0.6 · 효과음 0.9 · 목소리 1 · 환경음 0.5 — 말이 가장 잘 들리게. 0 이면 그 버스는 꺼짐.

## 핵심 코드 — 버스 넷 + 주 음량 + 리미터 + 저장
(발췌: demos/demosSfx.ts audio() · BUSES · loadVols · busDemo 의 start · setVol 을 정리)
```ts
const ac = new AudioContext({ latencyHint: 'interactive' });
const master = ac.createGain();
master.gain.value = 0.35;
const limit = ac.createDynamicsCompressor();          // 끝에서 찢어짐 막기
limit.threshold.value = -10; limit.knee.value = 6; limit.ratio.value = 12;
limit.attack.value = 0.003; limit.release.value = 0.15;
master.connect(limit).connect(ac.destination);

const KEY = 'sfx-bus-v1';
type BusKey = 'music' | 'sfx' | 'voice' | 'amb';
function loadVols(): Record<BusKey, number> {
  const d = { music: 0.6, sfx: 0.9, voice: 1, amb: 0.5 };
  try { const s = localStorage.getItem(KEY); if (s) Object.assign(d, JSON.parse(s)); } catch { /* 저장소 막힘 */ }
  return d;
}
const vols = loadVols();
const bus = {} as Record<BusKey, GainNode>;
for (const k of ['music', 'sfx', 'voice', 'amb'] as BusKey[]) {
  bus[k] = ac.createGain();
  bus[k].gain.value = vols[k];
  bus[k].connect(master);
}
function setVol(k: BusKey, v: number) {
  vols[k] = v;
  bus[k].gain.setTargetAtTime(v, ac.currentTime, 0.03);
  try { localStorage.setItem(KEY, JSON.stringify(vols)); } catch { /* 무시 */ }
}
// 소리를 낼 때: coin(ac, bus.sfx, t) · music.connect(bus.music) · babble(ac, bus.voice, t, '좋아요')
```

## 흔한 실수 · 확인 목록
- [ ] **한 군데라도 destination 에 바로 이으면 그 소리는 음량 막대로 안 줄어든다** — 소리 함수는 늘 「어디로 보낼지(out)」를 받게 하고, 버스만 넘긴다.
- [ ] **저장소가 막힌 사생활 보호 창에서 오류로 소리가 안 난다** — localStorage 읽기 · 쓰기를 try/catch 로 감싸고, 실패하면 기본값으로.
- [ ] **리미터를 너무 세게 걸면 소리가 납작하고 펌프질한다** — 「가벼운」 리미터 — 문턱 −10dB, 평소엔 거의 안 걸리고 겹칠 때만.

## 완성 기준 체크리스트
- [ ] 네 버스를 켜면 음악 · 동전 소리 · 말소리 · 비 소리가 함께 나고, 버스마다 크기 막대가 움직인다
- [ ] 효과음 막대를 0 으로 내리면 동전 소리만 사라지고 나머지는 그대로다
- [ ] 새로고침해도 음량 설정이 그대로 남는다 (「저장됨 ✓」)
- [ ] 모두 최대로 올려도 소리가 찢어지지 않는다

## 이 기술 정보
- id: `i165` · 분류: 소리 › 효과음 (SFX) · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — GainNode 몇 개 + 압축기 1 + 분석기. 거의 공짜.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i165
- 쓰면 좋을 때: 음악과 효과음을 따로 줄이고 싶은 사용자가 있는 모든 게임 / 여러 게임 · 체험이 한 소리 엔진을 같이 쓸 때
- 쓰지 말 때: 소리가 한 종류뿐인 아주 작은 화면 — 주 음량 단추 하나면 된다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### audio — `src/demos/demosSfx.ts:51`
```ts
function audio(): AudioContext {
  if (!AC) {
    AC = new AudioContext({ latencyHint: 'interactive' });
    MASTER = AC.createGain();
    MASTER.gain.value = 0.35;
    LIMIT = AC.createDynamicsCompressor();
    LIMIT.threshold.value = -10;
    LIMIT.knee.value = 6;
    LIMIT.ratio.value = 12;
    LIMIT.attack.value = 0.003;
    LIMIT.release.value = 0.15;
    ANA = AC.createAnalyser();
    ANA.fftSize = 2048;
    ANA.smoothingTimeConstant = 0.6;
    MASTER.connect(LIMIT);
    LIMIT.connect(ANA);
    ANA.connect(AC.destination);
  }
  if (AC.state === 'suspended') void AC.resume();
  return AC;
}
```

### busDemo — `src/demos/demosSfx.ts:4410`
```ts
const busDemo: Demo2D = {
  kind: '2d',
  caption: '주 음량 → 음악 · 효과음 · 목소리 · 환경음 버스 — 버스마다 음량 · 저장 + 끝에 가벼운 리미터',
  make() {
    const vols = loadVols();
    let savedAt = -9;
    const bus = new Bus();
    let nodes: Record<string, { g: GainNode; a: AnalyserNode }> = {};
    let music: Music | null = null;
    let sch: Ahead | null = null;
    let amb: AmbBed | null = null;
    const tbuf = new Float32Array(512);
    const save = (): void => {
      try {
        localStorage.setItem(BUS_KEY, JSON.stringify(vols));
        savedAt = performance.now() / 1000;
      } catch {
        /* 무시 */
      }
    };
    const start = (): void => {
      const ac = audio();
      const out = bus.node();
      nodes = {};
      for (const b of BUSES) {
        const g = gainN(ac, vols[b.key] ?? 1);
        const a = ac.createAnalyser();
        a.fftSize = 512;
        g.connect(a);
        g.connect(out);
        nodes[b.key] = { g, a };
      }
      music = new Music(ac, { pad: nodes['music']!.g, bass: nodes['music']!.g });
      music.bpm = 96;
      music.start();
      const t0 = ac.currentTime + 0.05;
      amb = ambBed(ac, nodes['amb']!.g, t0, 0, Math.random);
      let nextSfx = t0 + 0.5;
      let nextVoice = t0 + 1.2;
      sch = new Ahead(ac, (a, b) => {
        ambEvents(ac, nodes['amb']!.g, 0, a, b, Math.random, []);
        while (nextSfx < b) {
          recCoin(ac, nodes['sfx']!.g, nextSfx, { pitch: Math.floor(Math.random() * 3) * 2, tail: 0.3 }, Math.random);
          nextSfx += 1.1;
        }
        while (nextVoice < b) {
          babble(ac, nodes['voice']!.g, nextVoice, '좋아요 잘했어요', 300, 1);
          nextVoice += 3.2;
        }
      });
      sch.start(t0);
    };
    const stop = (): void => {
      music?.stop();
      sch?.stop();
      for (const s of amb?.srcs ?? []) s.stop();
      music = null;
      sch = null;
      amb = null;
      nodes = {};
      bus.kill();
    };
    const setVol = (k: string, v: number): void => {
      vols[k] = v;
      const n = nodes[k];
      if (n && AC) n.g.gain.setTargetAtTime(v, AC.currentTime, 0.03);
      save();
    };
    return {
      controls: [
        { type: 'toggle', label: '▶ 네 버스 모두 켜기 / ■ 끄기', value: false, on: (v) => (v ? start() : stop()) },
        ...BUSES.map((b): Control => ({ type: 'range', label: `${b.name} 버스`, min: 0, max: 1, step: 0.05, value: vols[b.key] ?? 1, on: (v) => setVol(b.key, v) })),
      ],
      draw(g, w, h, t) {
        const real = !!music;
        frame(
          g,
          w,
          h,
          t,
          '#7cc8ff',
          () => {
            txt(g, '채널 버스 · 음량 조절', 12, 13, 11, '#cfe8ff', 'left', 900);
            if (performance.now() / 1000 - savedAt < 1.5) chip(g, '저장됨 ✓', 246, 13, 7.5, '#3aa860');
            BUSES.forEach((b, i) => {
              const x = 16 + i * 46;
              vpanel(g, x, 24, 40, 112, b.color, 0.05);
              txt(g, b.name, x + 20, 32, 8, b.color, 'center', 900);
              // 계량기
              let lvl: number;
              const n = nodes[b.key];
              if (real && n) {
                n.a.getFloatTimeDomainData(tbuf);
                let m = 0;
                for (let j = 0; j < tbuf.length; j++) m = Math.max(m, Math.abs(tbuf[j]!));
                lvl = clamp(m * 2.2);
              } else {
                const v = vols[b.key] ?? 1;
                lvl = v * (0.35 + 0.35 * Math.abs(Math.sin(t * (2 + i) + i)) + (i === 1 ? 0.3 * Math.max(0, Math.sin(t * 5)) : 0));
              }
              const mh = 80;
              for (let s = 0; s < 16; s++) {
                const on = s / 16 < lvl;
                rr(g, x + 5, 126 - (s + 1) * (mh / 16), 7, mh / 16 - 1.2, 1);
                g.fillStyle = on ? (s > 13 ? '#ff5c6c' : s > 10 ? '#ffd84d' : '#6ef0a0') : 'rgba(255,255,255,0.07)';
                g.fill();
              }
              // 페이더
              const v = vols[b.key] ?? 1;
              rr(g, x + 25, 44, 3, mh, 1.5);
              g.fillStyle = 'rgba(0,0,0,0.5)';
              g.fill();
              const fy = 44 + mh - v * mh;
              rr(g, x + 19, fy - 4, 15, 8, 2);
              g.fillStyle = '#e8e4ff';
              g.fill();
              g.fillStyle = b.color;
              g.fillRect(x + 20, fy - 0.6, 13, 1.2);
              txt(g, `${Math.round(v * 100)}`, x + 26, 131, 6.5, 'rgba(255,255,255,0.6)', 'center', 700, MONO);
            });
            // 흐름: 버스 → 주 음량 → 리미터 → 스피커
            const fx = 204;
            const items = ['주 음량 0.35', '리미터', '스피커'];
            items.forEach((s, i) => {
              const y = 40 + i * 34;
              vpanel(g, fx, y - 10, 64, 20, '#c9a0ff', 0.08);
              txt(g, s, fx + 32, y, 8, '#e6d8ff', 'center', 800);
              if (i < 2) {
                g.strokeStyle = 'rgba(255,255,255,0.4)';
                g.lineWidth = 1.2;
                g.beginPath();
                g.moveTo(fx + 32, y + 10);
                g.lineTo(fx + 32, y + 24);
                g.stroke();
              }
            });
            g.strokeStyle = 'rgba(255,255,255,0.25)';
            g.beginPath();
            g.moveTo(198, 80);
            g.lineTo(204, 40);
            g.stroke();
            txt(g, 'localStorage 에 저장', fx + 32, 140, 6.5, 'rgba(255,255,255,0.5)');
          },
          real ? '실시간 소리 · 주 출력' : '실시간 소리 · ▶ 네 버스 모두 켜기',
        );
      },
      dispose: stop,
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [오디오 스프라이트](https://ai-techstudio.web.app/ai/t/i164.md) `i164`
- 다음에 해 볼 기술: [소리 낮추기 (덕킹)](https://ai-techstudio.web.app/ai/t/i437.md) `i437`
- 참고 문서: [MDN — DynamicsCompressorNode](https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode)
