# AI 꾸러미 — 소리 낮추기 (덕킹) — Audio ducking
> 안내 말소리 · 중요한 효과음이 나오는 동안만 배경음악 음량을 −14dB 쯤 쑥 낮췄다가 끝나면 천천히 되돌려, 말이 음악에 묻히지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i437

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

## 주문서

### 만들어 줘: 소리 낮추기 (덕킹) — Audio ducking

#### 1. 목표
선배 · 안내 캐릭터 대사에 소리 낮추기(덕킹)를 넣어 줘 — 말소리 · 중요한 소리가 날 때 배경음악만 빠르게 낮췄다가 끝나면 천천히 되돌리게. 느낌은 자연스럽게 살짝 물러나는.

#### 2. 핵심 기술 용어
- **Audio ducking** — 덕킹 — 중요한 소리가 날 때 다른 소리를 낮추기
- **Duck GainNode · dB → gain (10^(dB/20))** — 음악 길에만 있는 게인 · 데시벨을 배수로
- **setTargetAtTime attack / release** — 빨리 내리고 천천히 올리기
- **Sidechain compression** — (다른 방식) 말소리 크기로 음악을 눌러 주는 압축

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

#### 4. 조건
- 덕 게인은 음악 길에만 — 말소리 · 효과음 길에는 넣지 않는다
- 내리기는 빠르게(0.05), 올리기는 천천히(0.25) — 반대로 하면 펌프질처럼 들린다
- 새로 예약하기 전 cancelScheduledValues(지금) — 말이 연달아 와도 꼬이지 않게
- 깊이는 dB 로 정해 10^(dB/20) 로 바꾼다 (−14dB 기본, 조절 가능)

#### 5. 완성 기준 (이게 보이면 성공)
- 말풍선이 뜨고 말소리가 나는 동안 음악 그래프가 쑥 내려가고, 끝나면 천천히 올라온다
- 덕킹 끄기와 비교하면 켰을 때 말이 훨씬 또렷하다
- 말을 연달아 시켜도 음악이 계속 낮게 유지되다가 마지막 말 뒤에 올라온다
- 깊이를 −6dB ~ −24dB 로 바꾸면 물러나는 정도가 바뀐다

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

## 원리
- 음악 길에만 「덕 게인」을 하나 끼운다 (층 → 필터 → 덕 게인 → 주 음량). 말소리는 덕 게인을 거치지 않고 바로 주 음량으로.
- 말이 시작되기 0.05초 전부터 덕 게인을 10^(−14/20) ≈ 0.2 로 빠르게(시간 상수 0.05) 내린다.
- 말이 끝나는 시각부터 1 로 천천히(시간 상수 0.25) 되돌린다 — 빨리 내리고 천천히 올려야 자연스럽다.
- 말 길이를 미리 알면(합성 말소리 · 파일 길이) 시작 · 끝 둘 다 미리 예약한다. 새 말이 오면 cancelScheduledValues 로 앞 예약을 지운다.

## 핵심 코드 — 음악 길의 덕 게인 + 말소리 동안 낮추기
(발췌: demos/demosMusicNet.ts class Engine (duck · voice) + demoDuck say() 를 정리)
```ts
const ac = new AudioContext();
const out = ac.createGain();       out.gain.value = 0.5;  out.connect(ac.destination);
const duck = ac.createGain();      duck.connect(out);           // 음악만 이 길로
const musicIn = ac.createBiquadFilter();
musicIn.type = 'lowpass'; musicIn.frequency.value = 18000;
musicIn.connect(duck);                                           // 층들은 musicIn 에 잇는다
const voice = ac.createGain();     voice.connect(out);           // 말소리는 덕 게인을 안 거침

/** 말소리를 T 에 틀고, 그동안 음악을 depthDb 만큼 낮춘다. speak 은 말 길이(초)를 돌려준다 */
function say(speak: (T: number, dest: AudioNode) => number, depthDb = -14) {
  const T = ac.currentTime + 0.05;
  const d = speak(T, voice);
  const g = duck.gain;
  g.cancelScheduledValues(ac.currentTime);                       // 앞 예약 지우기
  g.setTargetAtTime(Math.pow(10, depthDb / 20), T - 0.05, 0.05); // 빨리 내리고
  g.setTargetAtTime(1, T + d, 0.25);                             // 천천히 되돌림
}
```

## 흔한 실수 · 확인 목록
- [ ] **덕 게인을 주 음량 뒤에 두면 말소리까지 같이 작아진다** — 덕 게인은 음악 길에만 끼우고, 말소리는 그 뒤(주 음량)로 바로 보낸다.
- [ ] **말이 연달아 오면 앞 말의 「되돌리기」가 끼어들어 음악이 들썩인다** — 새 말을 예약하기 전에 cancelScheduledValues(지금) 로 앞 예약을 지우고 다시 건다.
- [ ] **올리기를 빠르게 하면 말 끝마다 음악이 「훅」 튀어나온다** — 되돌리기 시간 상수를 0.25 정도로 길게.
- [ ] **말 길이를 모르는 TTS 에서는 끝을 예약할 수 없다** — 말 시작 이벤트에서 내리고, onend 이벤트에서 되돌리기를 예약한다.

## 완성 기준 체크리스트
- [ ] 말풍선이 뜨고 말소리가 나는 동안 음악 그래프가 쑥 내려가고, 끝나면 천천히 올라온다
- [ ] 덕킹 끄기와 비교하면 켰을 때 말이 훨씬 또렷하다
- [ ] 말을 연달아 시켜도 음악이 계속 낮게 유지되다가 마지막 말 뒤에 올라온다
- [ ] 깊이를 −6dB ~ −24dB 로 바꾸면 물러나는 정도가 바뀐다

## 이 기술 정보
- id: `i437` · 분류: 소리 › 소리 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — GainNode 하나와 예약 두 번 — 거의 공짜.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i437
- 쓰면 좋을 때: 배경음악 위로 안내 말 · 대사가 나올 때 / 정답 · 보상처럼 꼭 들려야 하는 효과음
- 쓰지 말 때: 모든 효과음마다 덕킹 — 음악이 계속 들썩인다. 말소리 · 큰 알림에만 / 음악이 원래 아주 작으면 필요 없다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoDuck — `src/demos/demosMusicNet.ts:941`
```ts
function demoDuck(): Made {
  const clock = new Clock();
  let eng: Engine | null = null;
  let ducking = true;
  let depthDb = -14;
  let vt0 = -10;
  let vt1 = -10;
  let syl: { a: number; b: number }[] = [];
  let line = 0;
  let cyc = 0;
  let simT = 0;
  const hist: { m: number; v: number; a: number }[] = [];
  let histAcc = 0;
  const start = (): void => {
    if (eng) return;
    eng = new Engine([1, 1, 1, 1, 0]);
    clock.eng = eng;
  };
  const stop = (): void => {
    eng?.close();
    eng = null;
    clock.eng = null;
  };
  const say = (): void => {
    line = (line + 1) % LINES.length;
    if (eng) {
      const T = eng.ac.currentTime + 0.05;
      const d = eng.speak(T);
      const off = simT - eng.ac.currentTime;
      vt0 = T + off;
      vt1 = T + d + off;
      if (ducking) {
        const dg = eng.duck.gain;
        const gv = Math.pow(10, depthDb / 20);
        dg.cancelScheduledValues(eng.ac.currentTime);
        dg.setTargetAtTime(gv, T - 0.05, 0.05);
        dg.setTargetAtTime(1, T + d, 0.25);
      }
    } else {
      vt0 = simT + 0.05;
      vt1 = vt0 + 1.3;
    }
    // 음절 막대 (그림용)
    syl = [];
    let a = vt0;
    const n = 7;
    const span = vt1 - vt0;
    for (let i = 0; i < n; i++) {
      const d = (span / n) * (0.7 + 0.25 * Math.sin(i * 2.3));
      syl.push({ a, b: a + d });
      a += span / n;
    }
  };
  return {
    controls: [
      soundButton(start, stop, () => !!eng),
      { type: 'button', label: '🗣 말하기 (안내 음성 흉내)', on: say },
      { type: 'toggle', label: '덕킹 켬 (말할 때 음악 줄이기)', value: true, on: (v) => (ducking = v) },
      { type: 'range', label: '줄이는 깊이 (dB)', min: -30, max: -3, step: 1, value: -14, on: (v) => (depthDb = v) },
    ],
    draw(g, w, h, t, dt) {
      reset(g);
      const L = lay(w, h);
      const { u, pad, big } = L;
      const dd = Math.min(dt, 0.1);
      simT += dd;
      const pos = clock.tick(dd, 100);
      // 카드: 3.6초마다 저절로 말하기
      cyc += dd;
      if (cyc > 3.6) {
        cyc = 0;
        if (!eng) say();
      }
      // 음악 음량 곡선 (붙을 때 0.05초, 풀릴 때 0.25초)
      const dg = Math.pow(10, depthDb / 20);
      let m = 1;
      if (ducking) {
        if (simT >= vt0 - 0.05 && simT < vt1) m = lerp(1, dg, 1 - Math.exp(-(simT - vt0 + 0.05) / 0.05));
        else if (simT >= vt1) {
          const m1 = dg;
          m = lerp(m1, 1, 1 - Math.exp(-(simT - vt1) / 0.25));
        }
      }
      let v = 0;
      for (const s of syl) if (simT >= s.a && simT < s.b) v = 0.6 + 0.4 * Math.sin(((simT - s.a) / (s.b - s.a)) * Math.PI);
      const speaking = simT >= vt0 && simT < vt1 + 0.15;
      histAcc += dd;
      while (histAcc > 1 / 60) {
        histAcc -= 1 / 60;
        const ph = pos;
        const kickE = Math.exp(-(((ph % 2) + 2) % 2) * 2.2);
        const a = clamp(0.3 + 0.5 * kickE + 0.25 * Math.abs(Math.sin(ph * 7.3)) * Math.abs(Math.sin(ph * 2.1 + 1)), 0, 1);
        hist.push({ m, v: v > 0 ? v * (0.55 + 0.45 * Math.abs(Math.sin(simT * 41))) : 0, a });
        if (hist.length > 300) hist.shift();
      }
      backdrop(g, w, h, '#0c1324', '#06080f', 'rgba(80,200,255,0.12)', u);
      soundBadge(g, w, L, !!eng, t);
      // 위: 안내 캐릭터 + 말풍선
      const ay = pad + (big ? 34 : 18) * u;
      const ar = (big ? 15 : 11) * u;
      const ax = pad + ar + 2 * u;
      const bounce = speaking ? Math.abs(Math.sin(simT * 14)) * 1.5 * u : 0;
      g.fillStyle = '#ffd36b';
      g.beginPath();
      g.arc(ax, ay - bounce, ar, 0, Math.PI * 2);
      g.fill();
      g.fillStyle = '#2a2140';
      g.beginPath();
      g.arc(ax - ar * 0.35, ay - ar * 0.15 - bounce, ar * 0.12, 0, Math.PI * 2);
      g.arc(ax + ar * 0.35, ay - ar * 0.15 - bounce, ar * 0.12, 0, Math.PI * 2);
      g.fill();
      g.beginPath();
      const mo = speaking ? 0.12 + v * 0.2 : 0.06;
      g.ellipse(ax, ay + ar * 0.35 - bounce, ar * 0.25, ar * mo, 0, 0, Math.PI * 2);
      g.fillStyle = '#7a2b3a';
      g.fill();
      if (speaking) {
        const k = clamp((simT - vt0) / 0.15, 0, 1);
        const bx = ax + ar + 6 * u;
        const text = LINES[line]!;
        const fp = (big ? 11 : 8) * u;
        g.font = font(fp, 700);
        const tw = Math.min(g.measureText(text).width, w - bx - pad * 2 - 10 * u);
        g.globalAlpha = k;
        rr(g, bx, ay - fp * 1.1, tw + fp * 1.4, fp * 2.2, fp * 0.9);
        g.fillStyle = '#f4f7ff';
        g.fill();
        g.beginPath();
        g.moveTo(bx + 1, ay - 3 * u);
        g.lineTo(bx - 5 * u, ay + 2 * u);
        g.lineTo(bx + 1, ay + 4 * u);
        g.fill();
        g.save();
        g.beginPath();
        g.rect(bx, ay - fp * 1.1, tw + fp * 1.2, fp * 2.2);
        g.clip();
        txt(g, text, bx + fp * 0.7, ay, fp, '#1a2140', 'left', 700);
        g.restore();
        g.globalAlpha = 1;
      } else txt(g, '(조용 — 음악만)', ax + ar + 8 * u, ay, (big ? 9 : 7) * u, '#5d6787');
      // 가운데: 음악 파형 (덕킹되면 작아짐) + 말소리 파형
      const wh = (big ? 70 : 34) * u;
      const wy = ay + ar + (big ? 12 : 5) * u + wh / 2;
      const wx = pad;
      const ww = w - pad * 2;
      panel(g, wx, wy - wh / 2, ww, wh, 5 * u);
      txt(g, '배경음악', wx + 5 * u, wy - wh / 2 + 6 * u, 6.6 * u * (big ? 1.2 : 1), '#7fdcff', 'left', 700);
      g.save();
      rr(g, wx, wy - wh / 2, ww, wh, 5 * u);
      g.clip();
      const N = big ? 150 : 70;
      const bw2 = ww / N;
      for (let i = 0; i < N; i++) {
        const hi = hist[Math.floor(((i + 0.5) / N) * (hist.length - 1))];
        if (!hi) continue;
        const xx = wx + i * bw2;
        const ma = hi.a * hi.m * wh * 0.42;
        g.fillStyle = hi.m < 0.9 ? mixHex('#4fd1ff', '#2a5a80', 1 - hi.m) : '#4fd1ff';
        g.fillRect(xx + bw2 * 0.15, wy - ma, bw2 * 0.7, ma * 2);
        if (hi.v > 0) {
          const va = hi.v * wh * 0.4;
          g.fillStyle = 'rgba(255,211,107,0.92)';
          g.fillRect(xx + bw2 * 0.3, wy - va, bw2 * 0.4, va * 2);
        }
      }
      g.fillStyle = 'rgba(255,255,255,0.12)';
      g.fillRect(wx, wy - 0.5, ww, 1);
      g.restore();
      // 아래: 음량 곡선 (dB)
      const gy = wy + wh / 2 + (big ? 12 : 6) * u;
      const gh = h - gy - pad - (big ? 16 : 0) * u;
      if (gh > 14 * u) {
        panel(g, wx, gy, ww, gh, 5 * u);
        const lab = (big ? 7.5 : 6) * u;
        txt(g, '음악 음량', wx + 5 * u, gy + 6 * u, lab, '#9aa6c4', 'left', 700);
        txt(g, '0 dB', wx + ww - 4 * u, gy + 6 * u, lab, '#6f7a99', 'right');
        txt(g, `${depthDb} dB`, wx + ww - 4 * u, gy + gh - 6 * u, lab, '#6f7a99', 'right');
        const top = gy + 11 * u;
        const bot = gy + gh - 4 * u;
        const yOf = (lin: number): number => {
          const db = 20 * Math.log10(Math.max(lin, 1e-4));
          return lerp(top, bot, clamp(db / Math.min(-3, depthDb), 0, 1));
        };
        // 말소리 칸
        for (let i = 0; i < hist.length; i++) {
          const hh = hist[i]!;
          if (hh.v > 0) {
            const xx = wx + (i / 299) * ww;
            g.fillStyle = 'rgba(255,211,107,0.18)';
            g.fillRect(xx, top, ww / 299 + 0.5, bot - top);
          }
        }
        const x0 = wx + ((300 - hist.length) / 299) * ww;
        g.beginPath();
        g.moveTo(x0, bot);
        hist.forEach((hh, i) => g.lineTo(wx + ((i + 300 - hist.length) / 299) * ww, yOf(hh.m)));
        g.lineTo(wx + ww, bot);
        g.closePath();
        const fg = g.createLinearGradient(0, top, 0, bot);
        fg.addColorStop(0, 'rgba(79,209,255,0.35)');
        fg.addColorStop(1, 'rgba(79,209,255,0.02)');
        g.fillStyle = fg;
        g.fill();
        g.beginPath();
        hist.forEach((hh, i) => {
          const xx = wx + ((i + 300 - hist.length) / 299) * ww;
          if (i === 0) g.moveTo(xx, yOf(hh.m));
          else g.lineTo(xx, yOf(hh.m));
        });
        g.strokeStyle = '#4fd1ff';
        g.lineWidth = 1.5 * u;
        g.stroke();
        if (!ducking && speaking) chip(g, wx + ww / 2, gy + gh / 2 - 6 * u, '덕킹 꺼짐 — 말이 음악에 묻혀요', '#ff6b8a', (big ? 9 : 6.6) * u, 'center');
      }
      if (big) txt(g, '말이 시작되면 0.05초 만에 줄이고, 끝나면 0.25초에 걸쳐 천천히 되돌린다 (빨리 붙고 · 천천히 풀림)', pad, h - pad * 0.6, 8.5 * u, '#8d97b8');
    },
    dispose() {
      stop();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [채널 버스 · 음량 조절](https://ai-techstudio.web.app/ai/t/i165.md) `i165`
- 다음에 해 볼 기술: [긴장에 따라 바뀌는 음악 (적응형)](https://ai-techstudio.web.app/ai/t/i436.md) `i436`
- 참고 문서: [MDN — AudioParam.setTargetAtTime()](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime)
