# AI 꾸러미 — 배경음악 층 쌓기 (층마다 켜고 끄기) — Vertical layering (vertical remixing)
> 북 · 베이스 · 화음 · 멜로디를 같은 박자 시계로 늘 함께 울리고 층마다 음량만 켜고 꺼서, 음악이 끊기지 않고 상황에 따라 분위기만 바뀐다.  
> 견본: https://ai-techstudio.web.app/#t/i435

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

## 주문서

### 만들어 줘: 배경음악 층 쌓기 (층마다 켜고 끄기) — Vertical layering (vertical remixing)

#### 1. 목표
평화 → 탐험 → 적 등장 → 보스 진행 게임에 층 쌓기 배경음악을 넣어 줘 — 북 · 베이스 · 화음 · 멜로디를 같은 박자로 늘 함께 돌리고, 상황마다 층의 음량만 켜고 끄게. 분위기는 밝은 신스 팝.

#### 2. 핵심 기술 용어
- **Vertical layering (vertical remixing)** — 세로 층 쌓기 — 같은 곡의 악기 층을 켜고 끄기
- **Adaptive music stems** — 상황에 따라 바뀌는 음악의 층(스템)
- **GainNode per layer · setTargetAtTime** — 층마다 게인 하나 — 부드럽게 0 ↔ 1
- **Lookahead step sequencer** — 미리 예약하는 칸 악보 재생기

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

#### 4. 조건
- 모든 층을 하나의 박자 시계(AudioContext 시간)로 — 층마다 따로 setInterval 을 두지 않는다
- 층 끄기 = 게인 0 (재생 멈춤 아님), 바꿀 땐 setTargetAtTime 시간 상수 0.05 정도
- 상황 전환은 마디 · 2마디 경계에서 — 박자 중간에 확 바뀌지 않게
- 여러 층이 겹쳐도 찢어지지 않게 끝에 압축기 하나

#### 5. 완성 기준 (이게 보이면 성공)
- 2마디마다 상황 이름이 바뀌며 층이 더해지고 빠지는데 음악은 한 번도 끊기지 않는다
- 층을 껐다 켜면 그 층이 마디 자리에 맞춰 바로 들어온다 (처음부터 다시 시작 안 함)
- 「직접 고르기」로 층 넷을 마음대로 켜고 끌 수 있고, 섞는 판 막대가 층마다 튄다

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

## 원리
- 모든 층은 한 엔진 · 한 박자 시계에서 함께 울린다. 층을 「끄는」 것은 멈추는 게 아니라 그 층 GainNode 를 0 으로 내리는 것.
- 그래서 다시 켜면 박자 · 마디 자리가 이미 맞아 있다 — 처음부터 다시 시작하지 않는다.
- 층 게인 → 저역 필터 → 덕킹 게인 → 주 음량(0.5) → 압축기(−12dB · 8:1) → 스피커. 켜고 끄기는 setTargetAtTime(v, 지금, 0.05) 로 부드럽게.
- 상황 바뀜은 박자에 맞춘다: 견본은 16칸(2마디)마다 다음 상황 (평화 = 화음만 · 탐험 = 베이스 + 화음 + 멜로디 · 적 = 북 + 베이스 + 화음 · 보스 = 넷 다).

## 핵심 코드 — 층마다 게인 + 미리 예약하는 칸 재생기
(발췌: demos/demosMusicNet.ts class Engine (constructor · pump · setLayer) 를 정리)
```ts
class LayerEngine {
  ac = new AudioContext({ latencyHint: 'interactive' });
  lg: GainNode[] = [];                         // 0 북 · 1 베이스 · 2 화음 · 3 멜로디
  bpm = 100; step = 0; nextT: number; timer: number;
  constructor(on: number[], private play: (layer: GainNode[], s: number, T: number, d: number) => void) {
    const comp = this.ac.createDynamicsCompressor();
    comp.threshold.value = -12; comp.ratio.value = 8;
    comp.attack.value = 0.004; comp.release.value = 0.2;
    comp.connect(this.ac.destination);
    const out = this.ac.createGain();
    out.gain.value = 0.5;
    out.connect(comp);
    for (let i = 0; i < 4; i++) {
      const gn = this.ac.createGain();
      gn.gain.value = on[i] ?? 0;
      gn.connect(out);
      this.lg.push(gn);
    }
    this.nextT = this.ac.currentTime + 0.1;
    this.timer = window.setInterval(() => this.pump(), 25);
  }
  stepDur() { return 60 / this.bpm / 2; }     // 8분음표 한 칸
  pump() {                                     // 앞 0.14초까지 예약 — 모든 층이 같은 T 로
    while (this.nextT < this.ac.currentTime + 0.14) {
      this.play(this.lg, this.step % 32, this.nextT, this.stepDur());
      this.nextT += this.stepDur();
      this.step++;
    }
  }
  setLayer(i: number, v: number) {             // 끄기 = 멈춤이 아니라 음량 0
    this.lg[i]?.gain.setTargetAtTime(v, this.ac.currentTime, 0.05);
  }
  close() { clearInterval(this.timer); void this.ac.close(); }
}
// 상황표: 2마디(16칸)마다 다음 상황
const STAGES = [[0, 0, 1, 0], [0, 1, 1, 1], [1, 1, 1, 0], [1, 1, 1, 1]];
```

## 흔한 실수 · 확인 목록
- [ ] **층을 끌 때 재생을 멈추면 다시 켤 때 박자가 어긋난다** — 모든 층을 늘 같이 예약하고, 끄기는 게인 0 으로만.
- [ ] **게인을 .value 로 바로 바꾸면 「딱」 소리가 난다** — setTargetAtTime(v, 지금, 0.05) 처럼 아주 짧게 미끄러지게 바꾼다.
- [ ] **층마다 따로 타이머를 돌리면 시간이 지날수록 층끼리 어긋난다** — 타이머는 하나, 모든 층의 음을 같은 시각 T 로 예약한다.
- [ ] **층을 다 켜면 소리가 찢어진다** — 층 합을 압축기(−12dB · 8:1)에 통과시키고, 층 음량 자체도 처음부터 작게 잡는다.

## 완성 기준 체크리스트
- [ ] 2마디마다 상황 이름이 바뀌며 층이 더해지고 빠지는데 음악은 한 번도 끊기지 않는다
- [ ] 층을 껐다 켜면 그 층이 마디 자리에 맞춰 바로 들어온다 (처음부터 다시 시작 안 함)
- [ ] 「직접 고르기」로 층 넷을 마음대로 켜고 끌 수 있고, 섞는 판 막대가 층마다 튄다

## 이 기술 정보
- id: `i435` · 분류: 소리 › 소리 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 꺼진 층도 발진기는 돈다 — 층 4 + 경보 1, 8분음표마다 노드 몇 개. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i435
- 쓰면 좋을 때: 게임 진행 · 긴장도에 따라 음악을 바꾸되 끊김은 없어야 할 때 / 같은 곡을 오래 들어도 덜 질리게 할 때
- 쓰지 말 때: 장면마다 완전히 다른 곡이 필요할 때 — 곡 갈아 끼우기 · 크로스페이드(u77) / 빠르기 · 조까지 바꿔야 할 때 — 적응형 음악(i436)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### Engine — `src/demos/demosMusicNet.ts:202`
```ts
class Engine {
  ac: AudioContext;
  out: GainNode;
  duck: GainNode;
  filt: BiquadFilterNode;
  lg: GainNode[] = [];
  voice: GainNode;
  noise: AudioBuffer;
  bpm = 100;
  trans = 0;
  wantTrans = 0;
  hats16 = false;
  alarm = false;
  step = 0;
  nextT: number;
  hist: { s: number; t: number; d: number }[] = [];
  timer: number;
  onStep: ((s: number, T: number) => void) | null = null;

  constructor(on: number[]) {
    const ac = new AudioContext({ latencyHint: 'interactive' });
    this.ac = ac;
    const comp = ac.createDynamicsCompressor();
    comp.threshold.value = -12;
    comp.ratio.value = 8;
    comp.attack.value = 0.004;
    comp.release.value = 0.2;
    comp.connect(ac.destination);
    this.out = ac.createGain();
    this.out.gain.value = 0.5;
    this.out.connect(comp);
    this.duck = ac.createGain();
    this.duck.connect(this.out);
    this.filt = ac.createBiquadFilter();
    this.filt.type = 'lowpass';
    this.filt.frequency.value = 18000;
    this.filt.Q.value = 0.7;
    this.filt.connect(this.duck);
    this.voice = ac.createGain();
    this.voice.gain.value = 1;
    this.voice.connect(this.out);
    for (let i = 0; i < 5; i++) {
      const gn = ac.createGain();
      gn.gain.value = on[i] ?? 0;
      gn.connect(this.filt);
      this.lg.push(gn);
    }
    // 멜로디 메아리
    const dl = ac.createDelay(1);
    dl.delayTime.value = 0.45;
    const fb = ac.createGain();
    fb.gain.value = 0.3;
    const wet = ac.createGain();
    wet.gain.value = 0.22;
    this.lg[3]!.connect(dl);
    dl.connect(fb);
    fb.connect(dl);
    dl.connect(wet);
    wet.connect(this.filt);
    const n = ac.sampleRate;
    this.noise = ac.createBuffer(1, n, n);
    const d = this.noise.getChannelData(0);
    for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
    if (ac.state === 'suspended') void ac.resume();
    this.nextT = ac.currentTime + 0.1;
    this.timer = window.setInterval(() => this.pump(), 25);
    this.pump();
  }
  stepDur(): number {
    return 60 / this.bpm / 2;
  }
  pump(): void {
    const ac = this.ac;
    while (this.nextT < ac.currentTime + 0.14) {
      const s = this.step % STEPS;
      const d = this.stepDur();
      this.play(s, this.nextT, d);
      this.hist.push({ s: this.step, t: this.nextT, d });
      if (this.hist.length > 64) this.hist.shift();
      this.nextT += d;
      this.step++;
    }
  }
  /** 지금 들리는 자리 (칸, 소수) */
  pos(): number {
    const now = this.ac.currentTime;
    for (let i = this.hist.length - 1; i >= 0; i--) {
      const e = this.hist[i]!;
      if (e.t <= now) return e.s + Math.min(1, (now - e.t) / e.d);
    }
    return (this.hist[0]?.s ?? 0) - 0.001;
  }
  setLayer(i: number, v: number): void {
    const gn = this.lg[i];
    if (gn) gn.gain.setTargetAtTime(v, this.ac.currentTime, 0.05);
  }
  private env(gn: GainNode, T: number, peak: number, a: number, dcy: number): void {
    gn.gain.setValueAtTime(0.0001, T);
    gn.gain.linearRampToValueAtTime(peak, T + a);
    gn.gain.exponentialRampToValueAtTime(0.0001, T + a + dcy);
  }
  private osc(type: OscillatorType, f: number, T: number, end: number, dest: AudioNode, detune = 0): OscillatorNode {
    const o = this.ac.createOscillator();
    o.type = type;
    o.frequency.setValueAtTime(f, T);
    o.detune.value = detune;
    o.connect(dest);
    o.start(T);
    o.stop(end);
    return o;
  }
  private noiseAt(T: number, dur: number, dest: AudioNode): void {
    const s = this.ac.createBufferSource();
    s.buffer = this.noise;
    s.connect(dest);
    s.start(T, Math.random() * 0.5, dur + 0.05);
  }
  kick(T: number): void {
    const gn = this.ac.createGain();
    gn.connect(this.lg[0]!);
    this.env(gn, T, 0.95, 0.004, 0.32);
    const o = this.osc('sine', 155, T, T + 0.4, gn);
    o.frequency.exponentialRampToValueAtTime(44, T + 0.13);
  }
  snare(T: number): void {
    const gn = this.ac.createGain();
    gn.connect(this.lg[0]!);
    this.env(gn, T, 0.36, 0.002, 0.17);
    const bp = this.ac.createBiquadFilter();
    bp.type = 'bandpass';
    bp.frequency.value = 1900;
    bp.Q.value = 0.8;
    bp.connect(gn);
    this.noiseAt(T, 0.2, bp);
    const tg = this.ac.createGain();
    tg.connect(this.lg[0]!);
    this.env(tg, T, 0.22, 0.002, 0.09);
    this.osc('triangle', 188, T, T + 0.12, tg);
  }
  hat(T: number, acc: boolean): void {
    const gn = this.ac.createGain();
    gn.connect(this.lg[0]!);
    this.env(gn, T, acc ? 0.13 : 0.07, 0.001, 0.04);
    const hp = this.ac.createBiquadFilter();
    hp.type = 'highpass';
    hp.frequency.value = 7800;
    hp.connect(gn);
    this.noiseAt(T, 0.06, hp);
  }
  bass(T: number, m: number, len: number): void {
    const gn = this.ac.createGain();
    gn.connect(this.lg[1]!);
    this.env(gn, T, 0.24, 0.005, len);
    const lp = this.ac.createBiquadFilter();
    lp.type = 'lowpass';
    lp.Q.value = 6;
    lp.frequency.setValueAtTime(1500, T);
    lp.frequency.exponentialRampToValueAtTime(260, T + 0.2);
    lp.connect(gn);
    this.osc('sawtooth', mtof(m), T, T + len + 0.05, lp);
    this.osc('sine', mtof(m - 12), T, T + len + 0.05, gn);
  }
  pad(T: number, ms: number[], len: number): void {
    const gn = this.ac.createGain();
    gn.connect(this.lg[2]!);
    gn.gain.setValueAtTime(0.0001, T);
    gn.gain.linearRampToValueAtTime(0.05, T + 0.12);
    gn.gain.setValueAtTime(0.05, T + len - 0.05);
    gn.gain.exponentialRampToValueAtTime(0.0001, T + len + 0.35);
    const lp = this.ac.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 1500;
    lp.connect(gn);
    for (const m of ms) {
      const f = mtof(m + this.trans);
      this.osc('sawtooth', f, T, T + len + 0.4, lp, -7);
      this.osc('sawtooth', f, T, T + len + 0.4, lp, 7);
    }
  }
  lead(T: number, m: number, len: number): void {
    const gn = this.ac.createGain();
    gn.connect(this.lg[3]!);
    this.env(gn, T, 0.13, 0.008, len * 0.95 + 0.18);
    const lp = this.ac.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 3400;
    lp.connect(gn);
    const f = mtof(m);
    this.osc('triangle', f, T, T + len + 0.3, lp);
    const sq = this.ac.createGain();
    sq.gain.value = 0.35;
    sq.connect(lp);
    this.osc('square', f, T, T + len + 0.3, sq, 4);
  }
  beep(T: number, m: number): void {
    const gn = this.ac.createGain();
    gn.connect(this.lg[4]!);
    this.env(gn, T, 0.06, 0.002, 0.09);
    this.osc('square', mtof(m), T, T + 0.12, gn);
  }
  play(s: number, T: number, d: number): void {
    const k = s & 7;
    if (k === 0) this.trans = this.wantTrans;
    const P = PROG[s >> 3]!;
    const tr = this.trans;
    if (KICK[k] || (this.hats16 && k === 7)) this.kick(T);
    if (SNARE[k]) this.snare(T);
    this.hat(T, k % 2 === 0);
    if (this.hats16) this.hat(T + d / 2, false);
    const bo = BASSP[k]!;
    if (bo >= 0) this.bass(T, P.root + bo + tr, d * 1.5);
    if (k === 0) this.pad(T, P.ch, d * 8);
    const mm = MEL[s]!;
    if (mm >= 0) {
      let len = 1;
      while (len < 3 && s + len < STEPS && MEL[s + len] === -1) len++;
      this.lead(T, mm + tr, len * d);
    }
    if (this.alarm && k % 2 === 0) this.beep(T, (k % 4 === 0 ? 88 : 84) + tr);
    if (this.onStep) this.onStep(s, T);
  }
  /** 말소리 흉내 — 모음 두 공명(포먼트)을 지난 톱니파 음절들. 길이(초)를 돌려준다 */
  speak(T: number): number {
    const V = [
      [800, 1200],
      [500, 1900],
      [300, 2300],
      [500, 900],
      [350, 800],
      [650, 1700],
    ];
    let t = T;
    const n = 7;
    for (let i = 0; i < n; i++) {
      const dur = 0.1 + Math.random() * 0.08;
      const v = V[Math.floor(Math.random() * V.length)]!;
      const f0 = 230 * (1.12 - (i / n) * 0.25) * (1 + (Math.random() - 0.5) * 0.08);
      const gn = this.ac.createGain();
      gn.connect(this.voice);
      gn.gain.setValueAtTime(0.0001, t);
      gn.gain.linearRampToValueAtTime(0.55, t + 0.02);
      gn.gain.setValueAtTime(0.5, t + dur - 0.03);
      gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
      for (let j = 0; j < 2; j++) {
        const bp = this.ac.createBiquadFilter();
        bp.type = 'bandpass';
        bp.frequency.value = v[j]!;
        bp.Q.value = j === 0 ? 5 : 8;
        bp.connect(gn);
        const o = this.osc('sawtooth', f0, t, t + dur + 0.02, bp);
        o.frequency.linearRampToValueAtTime(f0 * 0.93, t + dur);
      }
      if (i % 2 === 0) {
        const cg = this.ac.createGain();
        cg.connect(this.voice);
        this.env(cg, t, 0.12, 0.002, 0.03);
        const hp = this.ac.createBiquadFilter();
        hp.type = 'bandpass';
        hp.frequency.value = 3500;
        hp.connect(cg);
        this.noiseAt(t, 0.04, hp);
      }
      t += dur + (i === 3 ? 0.12 : 0.03);
    }
    return t - T;
  }
  close(): void {
    clearInterval(this.timer);
    void this.ac.close();
  }
}
```

### demoLayers — `src/demos/demosMusicNet.ts:638`
```ts
function demoLayers(): Made {
  const clock = new Clock();
  let eng: Engine | null = null;
  let auto = true;
  const manual = [1, 1, 1, 1];
  const cur = [0, 0, 1, 0];
  let stT = 0;
  const lvl = [0, 0, 0, 0];
  const start = (): void => {
    if (eng) return;
    eng = new Engine([...cur, 0]);
    clock.eng = eng;
  };
  const stop = (): void => {
    eng?.close();
    eng = null;
    clock.eng = null;
  };
  const controls: Control[] = [
    soundButton(start, stop, () => !!eng),
    { type: 'toggle', label: '상황 따라 자동으로 (꺼면 직접 고르기)', value: true, on: (v) => (auto = v) },
  ];
  for (let i = 0; i < 4; i++)
    controls.push({
      type: 'toggle',
      label: `${LAYER_NAME[i]} 층`,
      value: true,
      on: (v) => {
        manual[i] = v ? 1 : 0;
        auto = false;
      },
    });
  return {
    controls,
    draw(g, w, h, t, dt) {
      reset(g);
      const L = lay(w, h);
      const { u, pad, big } = L;
      backdrop(g, w, h, '#0d1226', '#070912', 'rgba(110,90,255,0.14)', u);
      const pos = clock.tick(dt, 100);
      // 상황: 2마디(16칸)마다 다음 상황 — 박자에 맞춰 바뀐다
      const stage = Math.floor(pos / 16) % 4;
      const st = STAGES[stage]!;
      const target = auto ? st.on : manual;
      for (let i = 0; i < 4; i++) {
        const k = 1 - Math.exp(-Math.min(dt, 0.1) * 7);
        const tv = target[i]!;
        if (eng && Math.abs(cur[i]! - tv) > 0.001 && Math.abs(cur[i]! - tv) > 0.3) eng.setLayer(i, tv);
        cur[i] = lerp(cur[i]!, tv, k);
        if (Math.abs(cur[i]! - tv) < 0.002) cur[i] = tv;
      }
      if (eng) for (let i = 0; i < 4; i++) eng.setLayer(i, target[i]!);
      stT = (pos % 16) / 16;
      // 머리: 상황 이름 + 진행
      const hx = pad;
      const hy = pad;
      txt(g, auto ? '지금 상황' : '직접 고른 층', hx, hy + 5 * u, (big ? 8 : 6.6) * u, '#8d97b8', 'left', 600);
      const pw = chip(g, hx, hy + 10 * u, auto ? st.name : '수동', auto ? st.col : '#c9d2ee', (big ? 11 : 8.4) * u, 'left', true);
      if (auto) {
        const bx = hx + pw + 6 * u;
        const bw = (big ? 120 : 64) * u;
        for (let i = 0; i < 4; i++) {
          const x0 = bx + i * (bw / 4);
          rr(g, x0, hy + 16 * u, bw / 4 - 2 * u, 3 * u, 1.5 * u);
          g.fillStyle = i < stage ? hexA(STAGES[i]!.col, 0.8) : i === stage ? hexA(st.col, 0.25) : 'rgba(255,255,255,0.08)';
          g.fill();
          if (i === stage) {
            rr(g, x0, hy + 16 * u, (bw / 4 - 2 * u) * stT, 3 * u, 1.5 * u);
            g.fillStyle = st.col;
            g.fill();
          }
        }
      }
      soundBadge(g, w, L, !!eng, t);
      // 섞는 단추판 (오른쪽) + 악보 (왼쪽)
      const top = hy + (big ? 34 : 27) * u;
      const mixW = (big ? 88 : 52) * u;
      const rollW = w - pad * 2 - mixW - 8 * u;
      const rollH = h - top - pad - (big ? 18 : 12) * u;
      drawRoll(g, pad, top, rollW, rollH, pos, cur, u, big);
      // 박자 점
      const beat = Math.floor(pos / 2) % 4;
      for (let i = 0; i < 4; i++) {
        const cx = pad + (big ? 46 : 30) * u + (rollW - (big ? 46 : 30) * u) * ((i + 0.5) / 4) - (rollW - (big ? 46 : 30) * u) * 0.0;
        const on = i === beat;
        glowDot(g, cx, top + rollH + (big ? 10 : 7) * u, (on ? 2.6 : 1.8) * u, on ? '#ffffff' : '#56607e', on ? 1 : 0.6);
      }
      // 섞는 판
      const mx = w - pad - mixW;
      panel(g, mx, top, mixW, rollH, 5 * u);
      const fw = mixW / 4;
      for (let i = 0; i < 4; i++) {
        const cx = mx + fw * (i + 0.5);
        const ty = top + 6 * u;
        const bh = rollH - (big ? 26 : 20) * u;
        // 소리 크기(음표가 울릴 때 튐)
        let hit = 0;
        const p = pos % STEPS;
        for (const n of NOTES[i]!) {
          let d = p - n.s;
          if (d < 0) d += STEPS;
          if (d < n.len + 1.5) hit = Math.max(hit, 1 - d / (n.len + 1.5));
        }
        lvl[i] = Math.max(hit * cur[i]!, (lvl[i] ?? 0) - Math.min(dt, 0.1) * 2.5);
        const lv = lvl[i]!;
        rr(g, cx - 3 * u, ty, 6 * u, bh, 3 * u);
        g.fillStyle = 'rgba(0,0,0,0.35)';
        g.fill();
        const fh = bh * (0.15 + 0.85 * lv);
        const lg2 = g.createLinearGradient(0, ty + bh, 0, ty);
        lg2.addColorStop(0, LAYER_COL[i]!);
        lg2.addColorStop(1, mixHex(LAYER_COL[i]!, '#ffffff', 0.5));
        rr(g, cx - 3 * u, ty + bh - fh, 6 * u, fh, 3 * u);
        g.fillStyle = lg2;
        g.globalAlpha = 0.15 + 0.85 * cur[i]!;
        g.fill();
        g.globalAlpha = 1;
        // 켬 단추
        const ky = ty + bh + 7 * u;
        g.beginPath();
        g.arc(cx, ky, 3.6 * u, 0, Math.PI * 2);
        g.fillStyle = cur[i]! > 0.5 ? LAYER_COL[i]! : 'rgba(255,255,255,0.08)';
        g.fill();
        g.strokeStyle = hexA(LAYER_COL[i]!, 0.7);
        g.lineWidth = 1;
        g.stroke();
      }
      if (big) txt(g, '같은 박자 위에서 층을 더하고 빼면 음악이 끊기지 않고 분위기만 바뀐다 (2마디마다 다음 상황)', pad, h - pad * 0.6, 8.5 * u, '#8d97b8');
    },
    dispose() {
      stop();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [배경음악 넣기 (반복 · 페이드 · 켜고 끄기)](https://ai-techstudio.web.app/ai/t/u77.md) `u77`
- 다음에 해 볼 기술: [긴장에 따라 바뀌는 음악 (적응형)](https://ai-techstudio.web.app/ai/t/i436.md) `i436` · [박자 맞춰 연출 (비트 동기)](https://ai-techstudio.web.app/ai/t/i438.md) `i438`
- 참고 문서: [MDN — Advanced techniques: creating and sequencing audio](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Advanced_techniques)
