# AI 꾸러미 — 절차 음악 · 소리 (파일 없음) — Procedural / generative music (Web Audio)
> 도리아 음계 위에서 가락을 그때그때 지어 수금처럼 뜯고 낮은 드론을 깔아, 소리 파일 하나 없이 끝나지 않는 배경음악을 실시간으로 합성한다.  
> 견본: https://ai-techstudio.web.app/#t/i77

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

## 주문서

### 만들어 줘: 절차 음악 · 소리 (파일 없음) — Procedural / generative music (Web Audio)

#### 1. 목표
고대 지중해풍 모험 게임에 쓸 배경음악을 소리 파일 없이 Web Audio 로 실시간 합성해 줘 — 음계 위에서 가락을 짓고, 드론을 깔고, 미리 예약하는 박자 시계로. 분위기는 수금 · 드론의 고대풍.

#### 2. 핵심 기술 용어
- **Procedural / generative music (Web Audio)** — 코드로 짓는 음악 — 파일 없음
- **Dorian mode** — 도리아 음계 (0 2 3 5 7 9 10) — 고대 · 중세풍
- **Lookahead scheduler (setInterval + AudioContext.currentTime)** — 조금 앞을 미리 예약하는 박자 시계
- **Plucked tone · drone · LFO** — 뜯는 소리 · 길게 끄는 바닥 음 · 느린 흔들림

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

#### 4. 조건
- 박자는 AudioContext.currentTime 기준 미리 예약 (setInterval 50ms · 앞 0.25초) — requestAnimationFrame 으로 음을 치지 않는다
- 소리는 단추로만 켠다 (자동 재생 금지), 끌 땐 드론을 setTargetAtTime 으로 줄인 뒤 stop
- 빠르기를 바꿀 땐 지금 자리를 기준점(anchor)으로 다시 잡아 박자가 튀지 않게
- 주 음량 0.5 + 메아리(0.33초 · 되먹임 0.32 · 섞기 0.35) 하나로 공간감 — 잔향을 겹겹이 쌓지 않는다

#### 5. 완성 기준 (이게 보이면 성공)
- ▶ 를 누르면 드론이 1.2초에 걸쳐 깔리고 그 위로 수금 가락이 끊이지 않고 이어진다
- 구절 끝마다 가락이 으뜸음으로 돌아와 「끝맺는」 느낌이 들고, 두 마디마다 드론 음이 바뀐다
- 빠르기 손잡이를 돌려도 박자가 튀거나 음이 겹치지 않는다
- 탭을 바꾸고 와도 박자가 흐트러지지 않는다

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

## 원리
- 가락은 음계의 「도수」로 짓는다: 72% 는 한 칸 위나 아래, 나머지는 ±3칸 안에서 뛰기, 쉼표 12%, 16칸(한 구절) 끝 두 칸은 으뜸음 쪽(7 또는 4)으로 끝낸다.
- 도수 → MIDI = 62(레) + 옥타브 × 12 + 도리아[0 2 3 5 7 9 10], MIDI → Hz = 440 × 2^((m − 69)/12).
- 뜯는 소리: 세모파 + 2.01배 사인(0.35) → 저역 필터가 음높이 × 8 에서 × 1.5 로 0.4초에 닫힘 → 6ms 어택 · 지수 감쇠.
- 드론: 톱니파 둘(으뜸 − 12 · 으뜸 − 5, 즉 낮은 5도)을 420Hz 저역 필터로, 0.13Hz 느린 흔들림(±180Hz)을 필터에. 32칸마다 레 → 레 → 도 → 솔.
- 50ms 마다 「지금 + 0.25초」 앞까지의 음을 AudioContext 시계로 예약 — 화면이 버벅여도 박자가 안 흔들린다.

## 핵심 코드 — 도리아 가락 짓기 + 뜯는 소리 + 미리 예약
(발췌: demos/demosEpic.ts composeMelody · degToMidi · makeI77 의 pluck · schedule 을 정리)
```ts
const DORIAN = [0, 2, 3, 5, 7, 9, 10];
const degToMidi = (d: number) => 62 + Math.floor(d / 7) * 12 + DORIAN[((d % 7) + 7) % 7];
const midiHz = (m: number) => 440 * Math.pow(2, (m - 69) / 12);

function compose(r: () => number, steps: number) {
  const out: { step: number; deg: number; len: number }[] = [];
  let deg = 4, s = 0;
  while (s < steps) {
    const inPhrase = s % 16;
    if (inPhrase !== 0 && r() < 0.12) { s += 1; continue; }            // 쉼표
    const jump = r() < 0.72 ? (r() < 0.5 ? -1 : 1) : Math.round((r() - 0.5) * 6);
    deg = Math.max(0, Math.min(11, deg + jump));
    let len = r() < 0.65 ? 1 : 2;
    if (inPhrase >= 14) { deg = r() < 0.6 ? 7 : 4; len = 16 - inPhrase; } // 구절 끝은 으뜸음 쪽
    out.push({ step: s, deg, len });
    s += len;
  }
  return out;
}
function pluck(ac: AudioContext, out: AudioNode, time: number, hz: number, dur: number) {
  const o1 = ac.createOscillator(), o2 = ac.createOscillator();
  const f = ac.createBiquadFilter(), gn = ac.createGain(), g2 = ac.createGain();
  o1.type = 'triangle'; o2.type = 'sine';
  o1.frequency.value = hz; o2.frequency.value = hz * 2.01;          // 살짝 어긋난 배음
  f.type = 'lowpass';
  f.frequency.setValueAtTime(hz * 8, time);
  f.frequency.exponentialRampToValueAtTime(hz * 1.5, time + 0.4);   // 뜯은 뒤 어두워짐
  gn.gain.setValueAtTime(0.0001, time);
  gn.gain.exponentialRampToValueAtTime(0.28, time + 0.006);
  gn.gain.exponentialRampToValueAtTime(0.0001, time + Math.max(0.6, dur * 2.2));
  g2.gain.value = 0.35;
  o1.connect(f); o2.connect(g2).connect(f); f.connect(gn).connect(out);
  o1.start(time); o2.start(time);
  o1.stop(time + dur * 2.4 + 0.7); o2.stop(time + dur * 2.4 + 0.7);
}
// 50ms 마다: 「지금 + 0.25초」 앞까지 아직 안 친 걸음을 예약
// const ahead = anchorStep + (ac.currentTime - anchorT) / stepDur + 0.25 / stepDur + 1;
// while (scheduled < ahead) { at = anchorT + (scheduled - anchorStep) * stepDur; …pluck(ac, master, at, hz, len * stepDur); scheduled++; }
```

## 흔한 실수 · 확인 목록
- [ ] **requestAnimationFrame 에서 음을 치면 박자가 들쭉날쭉하다** — 화면 프레임은 흔들린다. setInterval 로 깨어나 AudioContext 시계로 조금 앞을 예약하는 「두 시계」 방식을 쓴다.
- [ ] **빠르기를 바꾸는 순간 음이 몰려 나오거나 건너뛴다** — 바꾸기 직전 자리를 anchorStep · anchorT 로 다시 잡고, 그 뒤 걸음만 새 빠르기로 계산한다.
- [ ] **완전히 무작위인 음은 음악처럼 안 들린다** — 대개 한 칸씩 걷고 가끔 뛰며, 구절 끝에 으뜸음으로 돌아오게 — 규칙이 「곡」을 만든다.
- [ ] **소리 견본이 카드에서 저절로 울린다** — 소리는 크게 보기의 ▶ 단추로만, 카드에서는 피아노 롤 · 파형 그림만.

## 완성 기준 체크리스트
- [ ] ▶ 를 누르면 드론이 1.2초에 걸쳐 깔리고 그 위로 수금 가락이 끊이지 않고 이어진다
- [ ] 구절 끝마다 가락이 으뜸음으로 돌아와 「끝맺는」 느낌이 들고, 두 마디마다 드론 음이 바뀐다
- [ ] 빠르기 손잡이를 돌려도 박자가 튀거나 음이 겹치지 않는다
- [ ] 탭을 바꾸고 와도 박자가 흐트러지지 않는다

## 이 기술 정보
- id: `i77` · 분류: 소리 › 소리 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 음 하나 = 발진기 2 + 필터 1 + 게인 2, 짧게 쓰고 버린다. 드론 발진기 3개는 계속. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i77
- 쓰면 좋을 때: 배경음악 파일이 없거나 용량을 줄여야 할 때 / 빠르기 · 음계를 실시간으로 바꾸는 체험 (박자 · 비율)
- 쓰지 말 때: 완성도 높은 곡이 필요한 이야기 게임 — 다듬은 곡 파일(u77)이 낫다 / 아주 많은 악기 층 — 층 쌓기 엔진(i435)처럼 미리 짠 악보가 관리하기 쉽다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeI77 — `src/demos/demosEpic.ts:1513`
```ts
function makeI77() {
  const STEPS = 256;
  const mel = composeMelody(7, STEPS);
  // 드론: 두 마디마다 레 ↔ 도 ↔ 솔 (낮은 5도와 함께)
  const DRONE = [50, 50, 48, 55];
  const droneAt = (step: number): number => DRONE[Math.floor(step / 32) % DRONE.length]!;
  let stepDur = 0.3;
  let pos = 0; // 지금 걸음 (소수)
  // ── 소리 (단추로만)
  let ac: AudioContext | null = null;
  let master: GainNode | null = null;
  let analyser: AnalyserNode | null = null;
  let droneOsc: OscillatorNode[] = [];
  let droneGain: GainNode | null = null;
  let timer = 0;
  let anchorT = 0;
  let anchorStep = 0;
  let scheduled = 0;
  let playing = false;
  let wave: Uint8Array<ArrayBuffer> | null = null;
  const pluck = (time: number, hz: number, dur: number): void => {
    if (!ac || !master) return;
    const o1 = ac.createOscillator();
    const o2 = ac.createOscillator();
    const f = ac.createBiquadFilter();
    const gn = ac.createGain();
    o1.type = 'triangle';
    o2.type = 'sine';
    o1.frequency.value = hz;
    o2.frequency.value = hz * 2.01;
    f.type = 'lowpass';
    f.frequency.setValueAtTime(hz * 8, time);
    f.frequency.exponentialRampToValueAtTime(hz * 1.5, time + 0.4);
    gn.gain.setValueAtTime(0.0001, time);
    gn.gain.exponentialRampToValueAtTime(0.28, time + 0.006);
    gn.gain.exponentialRampToValueAtTime(0.0001, time + Math.max(0.6, dur * 2.2));
    const g2 = ac.createGain();
    g2.gain.value = 0.35;
    o1.connect(f);
    o2.connect(g2).connect(f);
    f.connect(gn).connect(master);
    o1.start(time);
    o2.start(time);
    o1.stop(time + dur * 2.4 + 0.7);
    o2.stop(time + dur * 2.4 + 0.7);
  };
  const audioPos = (): number => (ac ? anchorStep + (ac.currentTime - anchorT) / stepDur : pos);
  const schedule = (): void => {
    if (!ac || !playing) return;
    const ahead = audioPos() + 0.25 / stepDur + 1;
    while (scheduled < ahead) {
      const s = scheduled;
      const at = anchorT + (s - anchorStep) * stepDur;
      const wrapped = ((s % STEPS) + STEPS) % STEPS;
      for (const n of mel) if (n.step === wrapped && at > ac.currentTime - 0.01) pluck(at, midiHz(degToMidi(n.deg)), n.len * stepDur);
      if (wrapped % 32 === 0 && droneOsc.length) {
        const root = droneAt(wrapped);
        droneOsc[0]!.frequency.setTargetAtTime(midiHz(root - 12), at, 0.4);
        droneOsc[1]!.frequency.setTargetAtTime(midiHz(root - 5), at, 0.4);
      }
      scheduled++;
    }
  };
  const play = (): void => {
    if (playing) return;
    try {
      if (!ac) {
        ac = new AudioContext();
        master = ac.createGain();
        master.gain.value = 0.5;
        const delay = ac.createDelay(1);
        delay.delayTime.value = 0.33;
        const fb = ac.createGain();
        fb.gain.value = 0.32;
        const wet = ac.createGain();
        wet.gain.value = 0.35;
        master.connect(delay);
        delay.connect(fb).connect(delay);
        delay.connect(wet);
        analyser = ac.createAnalyser();
        analyser.fftSize = 1024;
        wave = new Uint8Array(new ArrayBuffer(analyser.fftSize));
        master.connect(analyser);
        wet.connect(analyser);
        analyser.connect(ac.destination);
      }
      void ac.resume();
      // 드론: 톱니 둘을 낮은 통과 필터로 · 느린 흔들림
      droneGain = ac.createGain();
      droneGain.gain.setValueAtTime(0.0001, ac.currentTime);
      droneGain.gain.exponentialRampToValueAtTime(0.06, ac.currentTime + 1.2);
      const lp = ac.createBiquadFilter();
      lp.type = 'lowpass';
      lp.frequency.value = 420;
      const lfo = ac.createOscillator();
      const lfoG = ac.createGain();
      lfo.frequency.value = 0.13;
      lfoG.gain.value = 180;
      lfo.connect(lfoG).connect(lp.frequency);
      const root = droneAt(Math.floor(pos) % STEPS);
      droneOsc = [ac.createOscillator(), ac.createOscillator(), lfo];
      droneOsc[0]!.type = 'sawtooth';
      droneOsc[1]!.type = 'sawtooth';
      droneOsc[0]!.frequency.value = midiHz(root - 12);
      droneOsc[1]!.frequency.value = midiHz(root - 5);
      droneOsc[0]!.connect(lp);
      droneOsc[1]!.connect(lp);
      lp.connect(droneGain).connect(master!);
      for (const o of droneOsc) o.start();
      anchorT = ac.currentTime + 0.05;
      anchorStep = Math.ceil(pos);
      scheduled = anchorStep;
      playing = true;
      timer = window.setInterval(schedule, 50);
      schedule();
    } catch {
      playing = false;
    }
  };
  const stop = (): void => {
    if (!playing || !ac) return;
    pos = audioPos();
    playing = false;
    window.clearInterval(timer);
    const now = ac.currentTime;
    if (droneGain) droneGain.gain.setTargetAtTime(0.0001, now, 0.15);
    const os = droneOsc;
    for (const o of os) o.stop(now + 0.8);
    droneOsc = [];
  };
  const controls: Control[] = [
    { type: 'button', label: '▶ 소리 듣기', on: play },
    { type: 'button', label: '■ 멈춤', on: stop },
    {
      type: 'range',
      label: '빠르기',
      min: 0.14,
      max: 0.4,
      step: 0.01,
      value: 0.24,
      on: (v) => {
        if (playing && ac) {
          anchorStep = audioPos();
          anchorT = ac.currentTime;
          scheduled = Math.ceil(anchorStep);
        }
        stepDur = 0.54 - v;
      },
    },
  ];
  const NOTE_COL = ['#ff6f91', '#ff9f5a', '#ffd23f', '#7be07a', '#4fd1c5', '#5aa9ff', '#a98bff'];
  return {
    controls,
    dispose() {
      stop();
      window.clearInterval(timer);
      if (ac) void ac.close();
      ac = null;
    },
    draw(g: G, w: number, h: number, _t: number, dt: number) {
      reset(g);
      const u = scaleOf(w, h);
      if (playing && ac) pos = audioPos();
      else pos += Math.min(dt, 0.1) / stepDur;
      bg(g, w, h, '#2a2160', '#0e0c2a');
      // ── 피아노 롤
      const top = 26 * u;
      const rollH = h * 0.56;
      const rows = 12;
      const rowH = rollH / rows;
      const left = 26 * u;
      const pxPerStep = 9 * u;
      const playX = left + (w - left) * 0.28;
      for (let r = 0; r < rows; r++) {
        const y = top + rollH - (r + 1) * rowH;
        g.fillStyle = r % 7 === 0 ? 'rgba(255,255,255,.08)' : r % 2 ? 'rgba(255,255,255,.03)' : 'rgba(255,255,255,.015)';
        g.fillRect(left, y, w - left, rowH);
        if (rowH > 7 * u || r % 2 === 0) txt(g, NOTE_NAMES[r % 7]!, left - 4 * u, y + rowH / 2, Math.min(8 * u, rowH * 0.9), r % 7 === 0 ? '#ffd23f' : '#a99ee0', 'right', F, 800);
      }
      // 마디 줄
      const firstStep = Math.floor(pos - (playX - left) / pxPerStep);
      for (let s = firstStep; s < pos + (w - playX) / pxPerStep + 1; s++) {
        if (s % 8) continue;
        const x = playX + (s - pos) * pxPerStep;
        g.fillStyle = s % 16 ? 'rgba(255,255,255,.07)' : 'rgba(255,255,255,.16)';
        g.fillRect(x, top, 1, rollH);
      }
      // 음표 (반복 곡이라 STEPS 로 감기)
      const base = Math.floor(pos / STEPS) * STEPS;
      const voices: { hz: number; age: number; deg: number }[] = [];
      for (const loop of [base - STEPS, base, base + STEPS])
        for (const n of mel) {
          const s = n.step + loop;
          const x = playX + (s - pos) * pxPerStep;
          if (x > w || x + n.len * pxPerStep < left) continue;
          const y = top + rollH - (n.deg + 1) * rowH;
          const on = pos >= s && pos < s + n.len;
          const age = (pos - s) * stepDur;
          if (age >= 0 && age < 1.2) voices.push({ hz: midiHz(degToMidi(n.deg)), age, deg: n.deg });
          rr(g, x + 1, y + 1, n.len * pxPerStep - 2, rowH - 2, 3 * u);
          g.fillStyle = NOTE_COL[n.deg % 7]!;
          g.globalAlpha = x + n.len * pxPerStep < playX ? 0.45 : 1;
          g.fill();
          g.globalAlpha = 1;
          if (on) {
            g.strokeStyle = '#fff';
            g.lineWidth = 2 * u;
            g.stroke();
            const k = clamp01(age / 0.5);
            g.strokeStyle = `rgba(255,255,255,${(1 - k).toFixed(2)})`;
            g.beginPath();
            g.arc(playX, y + rowH / 2, 4 * u + k * 16 * u, 0, Math.PI * 2);
            g.stroke();
          }
        }
      // 드론 띠
      const droneY = top + rollH + 3 * u;
      for (let s = Math.floor(firstStep / 32) * 32; s < pos + (w - playX) / pxPerStep; s += 32) {
        const x = playX + (s - pos) * pxPerStep;
        const root = droneAt(((s % STEPS) + STEPS) % STEPS);
        rr(g, x + 1, droneY, 32 * pxPerStep - 2, 8 * u, 4 * u);
        g.fillStyle = 'rgba(160,140,255,.35)';
        g.fill();
        txt(g, `드론 ${root === 50 ? '레' : root === 48 ? '도' : '솔'}`, Math.max(x + 18 * u, left + 18 * u), droneY + 4 * u, 6.5 * u, '#d8d0ff', 'center', F, 800);
      }
      g.fillStyle = '#ffd23f';
      g.fillRect(playX - 1 * u, top - 4 * u, 2 * u, rollH + 16 * u);
      // ── 파형
      const wy = droneY + 12 * u + (h - droneY - 12 * u) / 2;
      const amp = (h - droneY - 16 * u) / 2;
      g.strokeStyle = 'rgba(255,255,255,.12)';
      g.beginPath();
      g.moveTo(8 * u, wy);
      g.lineTo(w - 8 * u, wy);
      g.stroke();
      g.strokeStyle = '#7ff0d0';
      g.lineWidth = 1.8 * u;
      const N = 140;
      const vals: number[] = [];
      const real = playing && analyser && wave;
      if (real) analyser!.getByteTimeDomainData(wave!);
      const root = midiHz(droneAt(((Math.floor(pos) % STEPS) + STEPS) % STEPS) - 12);
      for (let k = 0; k <= N; k++) {
        let v: number;
        if (real) v = (wave![Math.floor((k / N) * (wave!.length - 1))]! - 128) / 128 * 2.2;
        else {
          // 소리 없이도 같은 합성식으로 그린 파형 (줄 뜯는 소리 = 세모파가 빨리 사라짐)
          const tt = k / N / 90;
          v = Math.sin(tt * root * Math.PI * 2 + _t * 2) * 0.12 + Math.sin(tt * root * 1.5 * Math.PI * 2) * 0.08;
          for (const vo of voices) {
            const ph = (tt * vo.hz + vo.age * 3) % 1;
            const tri = 4 * Math.abs(ph - 0.5) - 1;
            v += tri * Math.exp(-vo.age * 2.2) * 0.8 + Math.sin(tt * vo.hz * 2 * Math.PI * 2) * Math.exp(-vo.age * 4) * 0.3;
          }
        }
        vals.push(v);
      }
      // 크기 맞춤 (조용할 때도 모양이 보이게)
      let mx = 0.3;
      for (const v of vals) mx = Math.max(mx, Math.abs(v));
      g.beginPath();
      vals.forEach((v, k) => {
        const x = 8 * u + ((w - 16 * u) * k) / N;
        const y = wy - (v / mx) * amp * 0.85;
        if (k) g.lineTo(x, y);
        else g.moveTo(x, y);
      });
      g.stroke();
      pill(g, playing ? '♪ 지금 실시간 합성 중' : '소리 꺼짐 · 크게 보기의 ▶ 로 듣기', 8 * u, 12 * u, 7.5 * u, playing ? '#ff6fa8' : 'rgba(255,255,255,.14)', '#fff', 'left');
      txt(g, '파일 0개 · 레 도리아', w - 8 * u, 12 * u, 7.5 * u, '#a99ee0', 'right', F, 800);
    },
  };
}
```

### i77 견본 항목 — `src/demos/demosEpic.ts:2111`
```ts
  i77: {
    kind: '2d',
    caption: '레 도리아 음계로 그때그때 짓는 수금 가락 + 드론 — 피아노 롤과 파형 (소리는 크게 보기의 ▶ 단추로만)',
    make: makeI77,
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [코드로 효과음 합성 (견본판 70가지)](https://ai-techstudio.web.app/ai/t/i499.md) `i499`
- 다음에 해 볼 기술: [배경음악 층 쌓기 (층마다 켜고 끄기)](https://ai-techstudio.web.app/ai/t/i435.md) `i435` · [박자 맞춰 연출 (비트 동기)](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) · [Epicurius (참고 작품)](https://aibreakfast.itch.io/epicurius)
