# AI 꾸러미 — 긴장에 따라 바뀌는 음악 (적응형) — Adaptive music (intensity parameter)
> 긴장 값 하나(0 평온 ~ 1 위기)로 빠르기 · 필터 밝기 · 음 높이 · 층 수를 함께 바꿔, 남은 시간이 줄수록 음악이 빨라지고 밝아지고 올라간다.  
> 견본: https://ai-techstudio.web.app/#t/i436

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

## 주문서

### 만들어 줘: 긴장에 따라 바뀌는 음악 (적응형) — Adaptive music (intensity parameter)

#### 1. 목표
시간 제한 퀴즈 · 퍼즐에 긴장에 따라 바뀌는 음악을 넣어 줘 — 긴장 값 하나로 빠르기 · 필터 · 음 높이 · 층 수가 함께 바뀌게. 분위기는 점점 조여 오는 긴장.

#### 2. 핵심 기술 용어
- **Adaptive music (intensity parameter)** — 적응형 음악 — 긴장 값 하나로 여러 매개변수
- **Tempo (BPM) change** — 빠르기 바꾸기
- **Low-pass filter cutoff sweep** — 저역 필터 열기 — 먹먹함 → 또렷함
- **Transposition at bar boundary** — 마디 첫 박에서만 음 높이 올리기

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

#### 4. 조건
- 모든 음악 매개변수를 긴장 값 하나의 함수로 (params(k)) — 따로따로 조절하지 않는다
- 긴장 값은 지수 평활로 쫓아가게 — 매 프레임 확 바뀌지 않게
- 음 높이(조) 바꿈은 마디 첫 박에서만
- 필터는 setTargetAtTime(cut, 지금, 0.1) 로 — 매 프레임 .value 대입 금지

#### 5. 완성 기준 (이게 보이면 성공)
- 남은 초가 줄수록 빠르기(92 → 152 BPM)가 오르고 먹먹하던 소리가 또렷해진다
- 긴장 0.72 를 넘으면 마디 첫 박에서 음악이 2반음 올라가고, 0.8 을 넘으면 경보음이 섞인다
- 손잡이로 긴장을 0 ↔ 1 로 오가도 소리가 튀지 않고 부드럽게 따라온다

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

## 원리
- 긴장 k 하나(남은 시간 30초 → 0 이면 0 → 1)에서 모든 값을 계산한다: 빠르기 = 92 + 60k BPM, 필터 = 700 + 11000 · k^1.6 Hz.
- k 가 문턱을 넘으면 층이 더해진다: 북 > 0.22 · 멜로디 > 0.48 · 찰랑이 16분 > 0.62 · 음 +2반음 > 0.72 · 경보음 > 0.8.
- k 는 바로 쓰지 않고 lerp(지금, 목표, 1 − e^(−5·dt)) 로 쫓아가게 해 갑자기 튀지 않는다.
- 음 높이 올리기는 「바라는 값」만 정해 두고 실제로는 마디 첫 칸(s % 8 === 0)에서만 바꾼다 — 가락 중간에 조가 바뀌지 않게.

## 핵심 코드 — 긴장 값 → 음악 매개변수
(발췌: demos/demosMusicNet.ts demoTension params() · draw() 앞부분을 정리)
```ts
/** 긴장 k (0 평온 ~ 1 위기) → 음악 값 한 묶음 */
const params = (k: number) => ({
  bpm: Math.round(92 + 60 * k),
  cut: Math.round(700 + 11000 * Math.pow(k, 1.6)),   // 낮으면 먹먹, 높으면 또렷
  trans: k > 0.72 ? 2 : 0,                            // +2 반음 (마디 첫 박에서만 적용)
  layers: [k > 0.22 ? 1 : 0, 1, 1, k > 0.48 ? 1 : 0, k > 0.8 ? 1 : 0], // 북 · 베이스 · 화음 · 멜로디 · 경보
  h16: k > 0.62,                                      // 찰랑이 16분음표
});

let ten = 0, timeLeft = 30;
function update(eng: { bpm: number; wantTrans: number; hats16: boolean; alarm: boolean; ac: AudioContext;
                       filt: BiquadFilterNode; setLayer(i: number, v: number): void }, dt: number) {
  timeLeft -= Math.min(dt, 0.1);
  const want = Math.min(1, Math.max(0, 1 - Math.max(0, timeLeft) / 30));
  ten += (want - ten) * (1 - Math.exp(-Math.min(dt, 0.1) * 5));   // 부드럽게 쫓아가기
  const P = params(ten);
  eng.bpm = P.bpm;                                    // 다음에 예약하는 칸부터 새 빠르기
  eng.wantTrans = P.trans;                            // 엔진이 s % 8 === 0 에서 trans = wantTrans
  eng.hats16 = P.h16;
  eng.alarm = P.layers[4] === 1;
  eng.filt.frequency.setTargetAtTime(P.cut, eng.ac.currentTime, 0.1);
  for (let i = 0; i < 5; i++) eng.setLayer(i, P.layers[i]);
}
```

## 흔한 실수 · 확인 목록
- [ ] **음 높이를 바로 바꾸면 가락 중간에 조가 틀어져 이상하게 들린다** — 바라는 값(wantTrans)만 적어 두고 엔진이 마디 첫 칸에서만 반영한다.
- [ ] **빠르기를 바꾸니 이미 예약한 음과 겹쳐 박이 흔들린다** — 이미 예약한 앞 0.14초는 그대로 두고, 그다음 칸부터 새 칸 길이(60/bpm/2)로 예약한다.
- [ ] **필터를 0 ~ 12000Hz 로 곧게 바꾸면 처음엔 너무 빨리 밝아진다** — 귀는 높이를 비율로 듣는다. k^1.6 처럼 휜 곡선으로 낮은 쪽을 천천히.
- [ ] **위기 내내 빨간 번쩍 · 경보가 계속되면 아이가 불안해한다** — 경보 · 화면 번쩍은 긴장 0.6 ~ 0.8 넘어서만, 짧게.

## 완성 기준 체크리스트
- [ ] 남은 초가 줄수록 빠르기(92 → 152 BPM)가 오르고 먹먹하던 소리가 또렷해진다
- [ ] 긴장 0.72 를 넘으면 마디 첫 박에서 음악이 2반음 올라가고, 0.8 을 넘으면 경보음이 섞인다
- [ ] 손잡이로 긴장을 0 ↔ 1 로 오가도 소리가 튀지 않고 부드럽게 따라온다

## 이 기술 정보
- id: `i436` · 분류: 소리 › 소리 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 층 쌓기 엔진(i435) 위에 매 프레임 값 몇 개를 계산해 AudioParam 에 넘길 뿐 — 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i436
- 쓰면 좋을 때: 시간 제한 · 위기가 있는 게임에서 「서둘러!」를 소리로 알릴 때 / 같은 곡으로 평온 ~ 위기를 모두 덮고 싶을 때
- 쓰지 말 때: 차분히 생각해야 하는 퍼즐 — 빨라지는 음악이 방해가 된다 / 곡 분위기가 완전히 달라져야 할 때 — 곡 갈아 끼우기(u77)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoTension — `src/demos/demosMusicNet.ts:775`
```ts
function demoTension(): Made {
  const clock = new Clock();
  let eng: Engine | null = null;
  let auto = true;
  let manualT = 0.5;
  let ten = 0;
  let timeLeft = 30;
  let flash = 0;
  let lastBeat = -1;
  const start = (): void => {
    if (eng) return;
    eng = new Engine([0, 1, 1, 0, 0]);
    clock.eng = eng;
  };
  const stop = (): void => {
    eng?.close();
    eng = null;
    clock.eng = null;
  };
  const params = (k: number) => ({
    bpm: Math.round(92 + 60 * k),
    cut: Math.round(700 + 11000 * Math.pow(k, 1.6)),
    trans: k > 0.72 ? 2 : 0,
    layers: [k > 0.22 ? 1 : 0, 1, 1, k > 0.48 ? 1 : 0, k > 0.8 ? 1 : 0],
    h16: k > 0.62,
  });
  return {
    controls: [
      soundButton(start, stop, () => !!eng),
      { type: 'toggle', label: '시간 따라 자동 (끄면 아래 손잡이)', value: true, on: (v) => (auto = v) },
      {
        type: 'range',
        label: '긴장 (0 평온 → 1 위기)',
        min: 0,
        max: 1,
        step: 0.01,
        value: 0.5,
        on: (v) => {
          manualT = v;
          auto = false;
        },
      },
    ],
    draw(g, w, h, t, dt) {
      reset(g);
      const L = lay(w, h);
      const { u, pad, big } = L;
      // 남은 시간 30 → 0 (8초에 한 바퀴)
      if (auto) {
        timeLeft -= Math.min(dt, 0.1) * 3.75;
        if (timeLeft < -2) timeLeft = 30;
      }
      const want = auto ? clamp(1 - Math.max(0, timeLeft) / 30, 0, 1) : manualT;
      ten = lerp(ten, want, 1 - Math.exp(-Math.min(dt, 0.1) * 5));
      const P = params(ten);
      if (eng) {
        eng.bpm = P.bpm;
        eng.wantTrans = P.trans;
        eng.hats16 = P.h16;
        eng.alarm = P.layers[4] === 1;
        eng.filt.frequency.setTargetAtTime(P.cut, eng.ac.currentTime, 0.1);
        for (let i = 0; i < 5; i++) eng.setLayer(i, P.layers[i]!);
      }
      const pos = clock.tick(dt, P.bpm);
      const hot = mixHex('#4fb6ff', '#ff4d5e', ease(ten));
      backdrop(g, w, h, mixHex('#0b1430', '#2a0a14', ten), '#06070d', hexA('#ff3050', 0.04 + 0.2 * ten), u);
      const beat = Math.floor(pos / 2);
      if (beat !== lastBeat) {
        lastBeat = beat;
        if (ten > 0.6) flash = 1;
      }
      flash = Math.max(0, flash - Math.min(dt, 0.1) * 4);
      if (flash > 0) {
        g.strokeStyle = hexA('#ff3b55', flash * 0.6 * ten);
        g.lineWidth = 6 * u;
        g.strokeRect(0, 0, w, h);
      }
      // 왼쪽: 남은 시간 고리 + 긴장 막대
      const lw = (big ? 150 : 92) * u;
      const cx = pad + lw / 2;
      const R = Math.min(lw * 0.36, (h - pad * 2) * 0.27);
      const cy = pad + R + (big ? 14 : 8) * u;
      g.lineWidth = 5 * u;
      g.strokeStyle = 'rgba(255,255,255,0.08)';
      g.beginPath();
      g.arc(cx, cy, R, 0, Math.PI * 2);
      g.stroke();
      const frac = auto ? clamp(timeLeft / 30, 0, 1) : 1 - ten;
      g.strokeStyle = hot;
      g.lineCap = 'round';
      g.shadowColor = hot;
      g.shadowBlur = 8 * u;
      g.beginPath();
      g.arc(cx, cy, R, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * frac);
      g.stroke();
      g.shadowBlur = 0;
      g.lineCap = 'butt';
      const sec = Math.max(0, Math.ceil(timeLeft));
      const pulse = ten > 0.6 ? 1 + 0.08 * flash : 1;
      txt(g, auto ? `${sec}` : `${Math.round(ten * 100)}`, cx, cy - 1 * u, R * 0.72 * pulse, '#ffffff', 'center', 800);
      txt(g, auto ? '남은 초' : '긴장 %', cx, cy + R * 0.52, R * 0.24, '#9aa6c4', 'center', 600);
      // 긴장 막대
      const by = cy + R + (big ? 16 : 10) * u;
      const bw = lw - 6 * u;
      txt(g, '긴장', pad + 3 * u, by, 7 * u, '#9aa6c4');
      const bx0 = pad + 3 * u;
      const bh = 5 * u;
      rr(g, bx0, by + 5 * u, bw, bh, bh / 2);
      g.fillStyle = 'rgba(255,255,255,0.08)';
      g.fill();
      const lg2 = g.createLinearGradient(bx0, 0, bx0 + bw, 0);
      lg2.addColorStop(0, '#4fb6ff');
      lg2.addColorStop(0.6, '#ffb547');
      lg2.addColorStop(1, '#ff4d5e');
      rr(g, bx0, by + 5 * u, bw * ten, bh, bh / 2);
      g.fillStyle = lg2;
      g.fill();
      // 읽은 값
      const ry = by + bh + (big ? 16 : 12) * u;
      const rows: [string, string][] = [
        ['빠르기', `${P.bpm} BPM`],
        ['밝기(필터)', P.cut >= 1000 ? `${(P.cut / 1000).toFixed(1)}kHz` : `${P.cut}Hz`],
        ['음높이', P.trans ? '+2 반음' : '그대로'],
      ];
      const rh = (big ? 15 : 10.5) * u;
      rows.forEach(([a, b], i) => {
        if (ry + i * rh > h - pad) return;
        txt(g, a, pad + 3 * u, ry + i * rh, (big ? 8.5 : 6.8) * u, '#8d97b8');
        txt(g, b, pad + lw - 3 * u, ry + i * rh, (big ? 9 : 7.2) * u, '#eef2ff', 'right', 700);
      });
      soundBadge(g, w, L, !!eng, t);
      // 오른쪽: 흘러가는 악보 (긴장할수록 빨리 · 붉게 · 또렷하게)
      const rx = pad + lw + 8 * u;
      const top = pad + (big ? 30 : 4) * u;
      const rw = w - rx - pad;
      const rh2 = h - top - pad - (big ? 22 : 16) * u;
      const on = [P.layers[0]!, 1, 1, P.layers[3]!];
      drawRoll(g, rx, top, rw, rh2, pos, on, u, big, { win: 16, tint: ten > 0.05 ? (ten > 0.5 ? '#ff4d5e' : '#4fb6ff') : null });
      // 필터 안개: 긴장이 낮으면 소리가 먹먹 → 그림도 뿌옇게
      const fog = (1 - ten) * 0.38;
      if (fog > 0.01) {
        const fg = g.createLinearGradient(rx, 0, rx + rw, 0);
        fg.addColorStop(0, `rgba(30,50,90,${fog * 0.2})`);
        fg.addColorStop(1, `rgba(30,50,90,${fog})`);
        g.fillStyle = fg;
        rr(g, rx + (big ? 46 : 30) * u, top, rw - (big ? 46 : 30) * u, rh2, 3 * u);
        g.fill();
      }
      // 아래 칩
      const cyb = top + rh2 + (big ? 6 : 4) * u;
      let xx = rx + (big ? 46 : 30) * u;
      const cp = (big ? 8.5 : 6.4) * u;
      xx += chip(g, xx, cyb, P.h16 ? '찰랑이 16분' : '찰랑이 8분', '#ff6b8a', cp) + 4 * u;
      xx += chip(g, xx, cyb, P.layers[3] ? '멜로디 켬' : '멜로디 끔', '#b08cff', cp) + 4 * u;
      if (xx < w - pad - 30 * u) chip(g, xx, cyb, P.layers[4] ? '경보음 켬' : '경보음 끔', '#ffd15c', cp);
    },
    dispose() {
      stop();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [배경음악 층 쌓기 (층마다 켜고 끄기)](https://ai-techstudio.web.app/ai/t/i435.md) `i435`
- 다음에 해 볼 기술: [소리 낮추기 (덕킹)](https://ai-techstudio.web.app/ai/t/i437.md) `i437`
