# AI 꾸러미 — 그레인 합성 — Granular synthesis
> 짧은 소리를 20 ~ 80ms 알갱이로 잘라 한 개씩 부드러운 창을 씌워 초당 수십 개 흩뿌려, 짧은 녹음으로도 끝없이 이어지는 신비한 환경음을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i169

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

## 주문서

### 만들어 줘: 그레인 합성 — Granular synthesis

#### 1. 목표
짧은 녹음으로 만드는 긴 배경음을 그레인 합성으로 만들어 줘 — 짧은 소리를 20~80ms 알갱이로 잘라 부드러운 창을 씌우고 초당 수십 개 흩뿌려서. 분위기는 반짝이는 신비한 구름.

#### 2. 핵심 기술 용어
- **Granular synthesis** — 그레인 합성 — 소리를 알갱이로 잘라 흩뿌리기
- **Grain size · density · position · spread** — 알갱이 길이 · 빽빽함 · 읽는 위치 · 흩뿌림 폭
- **Hann window (setValueCurveAtTime)** — 알갱이마다 씌우는 둥근 크기 곡선 — 딸깍 없음
- **Lookahead scheduler** — 조금 앞까지 알갱이를 미리 예약

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

#### 4. 조건
- 알갱이마다 반드시 창(크기 곡선)을 씌운다 — 안 씌우면 딸깍 잡음 덩어리
- 알갱이 예약은 setInterval(40ms) + AudioContext 시계로 앞 0.15초까지
- 읽는 위치는 버퍼 끝을 넘지 않게 (길이 − 알갱이 × 2 까지)
- 원본 버퍼는 한 번만 만들어 돌려 쓴다
- 알갱이 길이 · 빽빽함 · 읽는 위치 · 흩뿌림 폭을 손잡이로

#### 5. 완성 기준 (이게 보이면 성공)
- 켜면 짧은 노랫소리가 끊김 없이 길게 이어지는 반짝이는 소리 구름이 된다
- 알갱이 길이를 20ms 로 줄이면 지글지글 · 80ms 로 늘리면 원래 소리 결이 더 들린다
- 읽는 위치를 끌면 원본의 다른 부분(다른 음 · 종소리)으로 바뀐다
- 빽빽함을 5 로 내리면 알갱이 하나하나가 따로 들린다

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

## 원리
- 원본(견본: 코드로 계산한 1.6초 「아~」 노랫소리 + 종소리)에서 아주 짧은 조각(알갱이, 기본 50ms)을 떼어 낸다.
- 알갱이마다 32점 한(Hann) 창 sin²(πi/31) 을 크기 곡선으로 씌운다 — 양 끝이 0 이라 이어 붙여도 딸깍이 없다.
- 읽는 위치 = 정한 자리(0.4) ± 흩뿌림 폭(0.2)의 무작위, 음 높이 = ±2반음 무작위, 좌우 = ±0.6 무작위.
- 알갱이 간격 = (1 / 빽빽함) × (0.6 ~ 1.4 무작위). 초당 30개면 알갱이들이 겹쳐 끊김 없는 「구름」이 된다.
- 40ms 마다 「지금 + 0.15초」까지의 알갱이를 미리 예약한다.

## 핵심 코드 — 알갱이 흩뿌리기 (한 창 + 무작위 위치 · 높이 · 좌우)
(발췌: demos/demosSfx.ts HANN · grains · class Ahead 를 정리)
```ts
const st2 = (n: number) => Math.pow(2, n / 12);
const HANN = new Float32Array(32).map((_, i) => Math.max(0.0001, Math.sin((Math.PI * i) / 31) ** 2));

/** from ~ to 시간 사이의 알갱이를 예약 */
function grains(c: AudioContext, out: AudioNode, buf: AudioBuffer, from: number, to: number,
                v = { size: 50, dens: 30, pos: 0.4, spread: 0.2, pitch: 2 }) {
  const size = v.size / 1000;
  for (let t = from; t < to; t += (1 / v.dens) * (0.6 + Math.random() * 0.8)) {
    const s = c.createBufferSource();
    s.buffer = buf;
    s.playbackRate.value = st2((Math.random() - 0.5) * 2 * v.pitch);  // ±2 반음
    const g = c.createGain();
    g.gain.setValueAtTime(0, t);
    g.gain.setValueCurveAtTime(HANN, t, size);                          // 둥근 창 — 딸깍 없음
    const pn = c.createStereoPanner();
    pn.pan.value = (Math.random() - 0.5) * 1.2;
    const half = c.createGain();
    half.gain.value = 0.5;
    s.connect(g).connect(half).connect(pn).connect(out);
    const k = Math.max(0, Math.min(0.98, v.pos + (Math.random() - 0.5) * v.spread));
    s.start(t, k * (buf.duration - size * 2), size * 2);               // 원본의 어느 자리에서
    s.stop(t + size + 0.01);
  }
}
/** 40ms 마다 깨어나 앞 0.15초까지 예약 */
function startCloud(c: AudioContext, out: AudioNode, buf: AudioBuffer) {
  let next = c.currentTime + 0.05;
  const tick = () => { const to = c.currentTime + 0.15; if (to > next) { grains(c, out, buf, next, to); next = to; } };
  tick();
  const id = window.setInterval(tick, 40);
  return () => clearInterval(id);
}
```

## 흔한 실수 · 확인 목록
- [ ] **창 없이 알갱이를 자르면 딸깍딸깍 잡음 덩어리가 된다** — 알갱이마다 한 창(양 끝 0)을 크기 곡선으로 — setValueCurveAtTime(HANN, t, size).
- [ ] **알갱이를 규칙적인 간격으로 놓으면 「웅—」 하는 음이 새로 생긴다** — 간격이 일정하면 그 간격이 진동수처럼 들린다. 간격 · 위치 · 높이를 조금씩 무작위로.
- [ ] **읽는 위치가 버퍼 끝을 넘으면 알갱이가 소리 없이 비어 버린다** — 시작 위치를 (버퍼 길이 − 알갱이 × 2) 까지로 묶는다.
- [ ] **빽빽함을 크게 올리면 폰에서 소리가 끊긴다** — 알갱이마다 노드 4개 — 초당 수백 개는 무겁다. 폰은 30 안팎.

## 완성 기준 체크리스트
- [ ] 켜면 짧은 노랫소리가 끊김 없이 길게 이어지는 반짝이는 소리 구름이 된다
- [ ] 알갱이 길이를 20ms 로 줄이면 지글지글 · 80ms 로 늘리면 원래 소리 결이 더 들린다
- [ ] 읽는 위치를 끌면 원본의 다른 부분(다른 음 · 종소리)으로 바뀐다
- [ ] 빽빽함을 5 로 내리면 알갱이 하나하나가 따로 들린다

## 이 기술 정보
- id: `i169` · 분류: 소리 › 효과음 (SFX) · 공통 · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 알갱이 하나 = 버퍼 소스 1 + 게인 2 + 판너 1. 초당 30개 = 초당 노드 120개를 만들고 버린다 — 폰은 빽빽함 30 안팎으로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i169
- 쓰면 좋을 때: 짧은 소리 하나로 길게 이어지는 환경음이 필요할 때 / 신비한 · 몽환적인 분위기 (마법 · 우주 · 꿈)
- 쓰지 말 때: 또렷한 박자 · 가락이 필요한 음악 — 알갱이는 뭉개진다 / 아주 느린 기기에서 빽빽함 60 이상 — 노드가 너무 많아진다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### grains — `src/demos/demosSfx.ts:3379`
```ts
function grains(c: BA, out: AudioNode, from: number, to: number, v: Record<string, number>, r: Rnd): void {
  const buf = grainSource(c);
  const size = (v['size'] ?? 50) / 1000;
  const dens = v['dens'] ?? 30;
  const pos = v['pos'] ?? 0.4;
  const spread = v['spread'] ?? 0.2;
  const pitch = v['pitch'] ?? 2;
  for (let t = from; t < to; t += (1 / dens) * (0.6 + r() * 0.8)) {
    const s = c.createBufferSource();
    s.buffer = buf;
    s.playbackRate.value = st2((r() - 0.5) * 2 * pitch);
    const g = c.createGain();
    g.gain.setValueAtTime(0, t);
    g.gain.setValueCurveAtTime(HANN, t, size);
    const pn = pan(c, (r() - 0.5) * 1.2);
    chain(s, g, gainN(c, 0.5), pn, out);
    const at = clamp(pos + (r() - 0.5) * spread, 0, 0.98) * (buf.duration - size * 2);
    s.start(t, at, size * 2);
    s.stop(t + size + 0.01);
  }
}
```

### grainSource — `src/demos/demosSfx.ts:3357`
```ts
function grainSource(c: BA): AudioBuffer {
  const hit = GSRC.get(c);
  if (hit) return hit;
  const sr = c.sampleRate;
  const len = Math.floor(sr * 1.6);
  const b = c.createBuffer(1, len, sr);
  const d = b.getChannelData(0);
  const form = (f: number): number => Math.exp(-(((f - 750) / 260) ** 2)) + 0.6 * Math.exp(-(((f - 1250) / 300) ** 2)) + 0.25 * Math.exp(-(((f - 2600) / 400) ** 2));
  let ph = 0;
  for (let i = 0; i < len; i++) {
    const t = i / sr;
    const f0 = 220 * st2(Math.floor(t / 0.4) * 2 + 3 * (t > 1.2 ? 1 : 0)) * (1 + 0.012 * Math.sin(TAU * 5.5 * t));
    ph += f0 / sr;
    let v = 0;
    for (let hN = 1; hN <= 14; hN++) v += (form(f0 * hN) / hN) * Math.sin(TAU * ph * hN);
    const bell = Math.sin(TAU * 1320 * t) * Math.exp(-((t % 0.4) * 9)) * 0.25;
    d[i] = (v * 0.5 + bell) * Math.min(1, t * 20, (1.6 - t) * 20);
  }
  GSRC.set(c, b);
  return b;
}
```

### Ahead — `src/demos/demosSfx.ts:2892`
```ts
class Ahead {
  private id = 0;
  next = 0;
  constructor(
    private ac: AudioContext,
    private fn: (from: number, to: number) => void,
    private ahead = 0.2,
  ) {}
  start(t0: number): void {
    this.next = t0;
    this.tick();
    this.id = window.setInterval(() => this.tick(), 40);
  }
  private tick(): void {
    const to = this.ac.currentTime + this.ahead;
    if (to > this.next) {
      this.fn(this.next, to);
      this.next = to;
    }
  }
  stop(): void {
    clearInterval(this.id);
  }
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [방 크기 잔향](https://ai-techstudio.web.app/ai/t/i159.md) `i159`
- 다음에 해 볼 기술: [절차 음악 · 소리 (파일 없음)](https://ai-techstudio.web.app/ai/t/i77.md) `i77`
- 참고 문서: [Wikipedia — Granular synthesis](https://en.wikipedia.org/wiki/Granular_synthesis)
