Web Audio · 작곡 실험

Web Audio로 흉내 낸 아홉 곡

대항해시대 풍, 80년대 8비트, 최신 K-pop 풍 — 「프로그래머를 위한 음악이론」의 빌딩블록만으로 세 스타일이 실제로 갈라지는지 실험했다. 테마당 완곡 3개, 총 아홉 곡 전부 이 페이지에서 바로 재생되고, 재생하면 파형이 그려진다.


어떻게 만들었나

얼마 전 RunJS의 음악이론 글을 완역해 올렸다(한국어 완역본). 그 글의 결론은 "음악이론은 물리 제약 아래 내려진 엔지니어링 결정들의 스택"이라는 것이었는데, 읽고 나니 당연한 다음 질문이 남았다 — 그 부품만 들고 실제 스타일이 나오나?

그래서 글이 만든 부품을 그대로 가져왔다. midiToFreq(MIDI 번호를 주파수로), build(간격 배열을 음계로), 3도 쌓기(배열이 곧 코드), note()(오실레이터 + 게인 엔벨로프). 여기에 원문에 없는 부품은 딱 두 개만 추가했다: 노이즈 버퍼(드럼 — 원문도 "타악기는 다루지 않았다"고 했다)와 로우패스 필터 한 개(K-pop 플럭과 킥의 본체). 나머지는 전부 원문 문법이다.

각 스타일은 작곡 전에 실제 곡들을 리서치해서 파라미터(키, BPM, 코드 진행, 리듬, 파형)로 환원했고, 그 파라미터로 오리지널 멜로디를 썼다. 기존 곡의 멜로디는 한 소절도 베끼지 않았다 — 스타일이 파라미터에서 나오는지가 실험의 요점이기 때문이다.

흥미로운 발견 하나. 세 스타일을 가르는 축은 결국 네 개뿐이었다: ①동시발음 제약이 있는가(8비트는 4보이스 고정) ②3도를 몇 번 쌓는가(트라이어드 vs 7th) ③드럼 패턴(없음 / 노이즈 4비트 / 포온플로어) ④템포 대역. 이 네 축만 돌려도 장르가 갈라진다.


1. 대항해시대 풍

대항해시대 1·2(코에이, 1990·1993)의 음악은 칸노 요코 작곡이다. 평은 대체로 "바람 부는, 청량한"(FOND/SOUND) — 장조의 개방감, 넓은 도약으로 여는 팡파레, 파도처럼 일렁이는 아르페지오 반주, 그리고 현악 패드의 레이어가 골격이다.

원문 개념으로의 매핑: 진행은 I–V–vi–IV(F–C–Dm–Bb)를 1마디 1코드로 느긋하게 — 원문이 말한 "키를 정하면 7개 코드가 공짜"에서 가장 순한 네 개다. 멜로디는 완전4도 도약으로 열고(원문의 "단순 정수비 = 협화"에서 4도는 4:3) 순차 하행으로 해소한다. 하프 아르페지오는 1-5-8-10 패턴, 패드는 사와(sawtooth) 두 대를 ±8센트 디튠해 겹쳤다 — 원문의 맥놀이(beating) 섹션이 설명한 그 현상을 거꾸로 "풍성함"으로 쓰는 것이다.

1-1. 출항 — 팡파레와 파도 (75초)

F장조 96BPM 4/4, Intro–A–B–A′–Coda의 가요 형식. A는 I–V–vi–IV, B는 IV에서 시작해 순환하다 반종지로 A′를 부른다. 팡파레 동기(4도 도약)가 두 번째 바퀴에서 한 옥타브 위로 이조되는 것이 클라이맥스다.

// ── 원문의 부품: midiToFreq · build · note (osc → gain 엔벨로프 → out) ──
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, pattern) =>
  pattern.reduce((ns, s) => [...ns, ns.at(-1) + s], [root]);
function note(freq, start = 0, len = 0.5, type = "sine", vol = 0.2, atk = 0.01) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol, atk) => note(midiToFreq(midi), at, len, type, vol, atk);
const M = (r) => [r, r + 4, r + 7];        // 장3화음 = 3도 쌓기 [0,4,7]
const mi = (r) => [r, r + 3, r + 7];       // 단3화음 = [0,3,7]

// 오케스트라 성부 3종 — 전부 note()의 배치일 뿐이다
function harp(c, at, beat, n = 8, vol = 0.13) {   // 1-5-8-10 물결 아르페지오
  const seq = [c[0], c[2], c[0] + 12, c[1] + 12, c[0] + 12, c[2], c[0], c[2]];
  for (let k = 0; k < n; k++)
    N(seq[k % 8], at + k * beat / 2, beat * 0.9, "triangle", vol, 0.008);
}
function pad(c, at, len, vol = 0.045) {           // 사와 ±8센트 디튠 = 현악 스택
  [c[1], c[2]].forEach(mm => {
    const f = midiToFreq(mm);
    note(f * 2 ** (-8 / 1200), at, len, "sawtooth", vol, 0.4);
    note(f * 2 ** (8 / 1200), at, len, "sawtooth", vol, 0.4);
  });
}
function bass(c, at, len, vol = 0.22) { N(c[0] - 12, at, len, "sine", vol, 0.03); }
// 멜로디 시퀀서: [midi, 시작(박), 길이(박), 볼륨?]
function play(events, at, beat, type = "sawtooth", vol = 0.11, atk = 0.05) {
  events.forEach(([mm, st, len, v]) =>
    N(mm, at + st * beat, len * beat * 1.03, type, v ?? vol, atk));
}

// ── 곡 1 「출항」 — F장조 · 96 BPM · 4/4 · Intro–A–B–A′–Coda (30마디, 75초) ──
{
  const bpm = 96, beat = 60 / bpm, bar = 4 * beat;
  const F = M(53), C = M(60), Dm = mi(62), Bb = M(58), Gm = mi(55), Am = mi(57);

  const secs = [ // [진행, 멜로디, 옵션]
    { prog: [F, F], mel: [[65,0,.5],[69,.5,.5],[72,1,.5],[77,1.5,2.5],[77,4,3.5]], harp: [8], intro: true },
    { prog: [F, C, Dm, Bb, F, C, Bb, F], mel: [
      [72,0,1],[77,1,2.5],[76,3.5,.5],[74,4,1.5],[72,5.5,.5],[74,6,2],
      [70,8,1],[74,9,2],[72,11,1],[69,12,1.5],[67,13.5,.5],[69,14,1],[65,15,1],
      [77,16,1],[81,17,2.5],[79,19.5,.5],[77,20,1.5],[76,21.5,.5],[77,22,2],
      [74,24,1],[77,25,2],[76,27,1],[72,28,1.5],[70,29.5,.5],[69,30,1],[65,31,1]] },
    { prog: [Bb, C, Am, Dm, Gm, C, F, C], mel: [
      [70,0,2],[74,2,1],[77,3,1],[79,4,2],[77,6,1],[76,7,1],[77,8,2],[74,10,2],
      [74,12,1],[76,13,1],[77,14,2],[79,16,2],[77,18,1],[74,19,1],[76,20,2],[72,22,2],
      [69,24,2],[65,26,2],[67,28,1],[69,29,1],[72,30,2]] },
    { prog: [F, C, Dm, Bb, F, C, Bb, F], mel: [
      [72,0,1],[77,1,2.5],[76,3.5,.5],[74,4,1.5],[72,5.5,.5],[74,6,2],
      [70,8,1],[74,9,2],[72,11,1],[69,12,1.5],[67,13.5,.5],[69,14,1],[65,15,1],
      [77,16,1],[81,17,2.5],[79,19.5,.5],[77,20,1.5],[76,21.5,.5],[77,22,2],
      [74,24,1],[77,25,2],[76,27,1],[72,28,1.5],[70,29.5,.5],[69,30,1],[65,31,1]], oct: true },
    { prog: [Bb, F, Bb, F], mel: [
      [77,0,3],[74,3,1],[72,4,2],[69,6,2],[70,8,2],[67,10,2],[65,12,4]], calm: true },
  ];

  let at = 0;
  secs.forEach(sec => {
    sec.prog.forEach((ch, i) => {
      const t0 = at + i * bar;
      pad(ch, t0, bar * 1.05);
      bass(ch, t0, bar);
      if (sec.intro) { if (i === 1) harp(ch, t0, beat); }
      else harp(ch, t0, beat, 8, sec.calm ? 0.09 : 0.13);
    });
    play(sec.mel, at, beat, "sawtooth", sec.calm ? 0.09 : 0.11);
    if (sec.oct) play(sec.mel, at, beat, "sawtooth", 0.045, 0.05); // 한 옥타브 아래 더블링 대신 약음 유니즌
    at += sec.prog.length * bar;
  });
  console.log(`출항 — F장조 96BPM, Intro-A-B-A'-Coda, ${Math.round(at)}초`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

1-2. 항구의 아침 — 3/4 왈츠 (59초)

C장조 80BPM 3/4. 류트 역할의 사각파가 마디당 여섯 개의 8분음표로 r-5-8-10-8-5를 굴리고, 리코더 같은 삼각파가 노래한다. A–B–A′ 뒤 두 마디 꼬리에서 마지막 화음을 하프 롤로 편다. 재현부(A′)에는 한 옥타브 위 에코를 반박 늦게 겹쳤다.

// ── 원문의 부품: midiToFreq · build · note (osc → gain 엔벨로프 → out) ──
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, pattern) =>
  pattern.reduce((ns, s) => [...ns, ns.at(-1) + s], [root]);
function note(freq, start = 0, len = 0.5, type = "sine", vol = 0.2, atk = 0.01) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol, atk) => note(midiToFreq(midi), at, len, type, vol, atk);
const M = (r) => [r, r + 4, r + 7];        // 장3화음 = 3도 쌓기 [0,4,7]
const mi = (r) => [r, r + 3, r + 7];       // 단3화음 = [0,3,7]

// 오케스트라 성부 3종 — 전부 note()의 배치일 뿐이다
function harp(c, at, beat, n = 8, vol = 0.13) {   // 1-5-8-10 물결 아르페지오
  const seq = [c[0], c[2], c[0] + 12, c[1] + 12, c[0] + 12, c[2], c[0], c[2]];
  for (let k = 0; k < n; k++)
    N(seq[k % 8], at + k * beat / 2, beat * 0.9, "triangle", vol, 0.008);
}
function pad(c, at, len, vol = 0.045) {           // 사와 ±8센트 디튠 = 현악 스택
  [c[1], c[2]].forEach(mm => {
    const f = midiToFreq(mm);
    note(f * 2 ** (-8 / 1200), at, len, "sawtooth", vol, 0.4);
    note(f * 2 ** (8 / 1200), at, len, "sawtooth", vol, 0.4);
  });
}
function bass(c, at, len, vol = 0.22) { N(c[0] - 12, at, len, "sine", vol, 0.03); }
// 멜로디 시퀀서: [midi, 시작(박), 길이(박), 볼륨?]
function play(events, at, beat, type = "sawtooth", vol = 0.11, atk = 0.05) {
  events.forEach(([mm, st, len, v]) =>
    N(mm, at + st * beat, len * beat * 1.03, type, v ?? vol, atk));
}

// ── 곡 2 「항구의 아침」 — C장조 · 80 BPM · 3/4 왈츠 · A–B–A′–꼬리 (26마디, 59초) ──
{
  const bpm = 80, beat = 60 / bpm, bar = 3 * beat, e8 = beat / 2;
  const C = M(48), G = M(55), Am = mi(57), F = M(53), Dm = mi(50), Em = mi(52);

  function lute(c, at, vol = 0.07) {   // 류트: 3/4 한 마디 = 8분음표 6개 r-5-8-10-8-5
    const seq = [c[0], c[2], c[0] + 12, c[1] + 12, c[0] + 12, c[2]];
    seq.forEach((mm, k) => N(mm, at + k * e8, e8 * 1.7, "square", vol, 0.006));
  }

  const melA = [
    [76,0,1],[77,1,1],[79,2,1],[79,3,2],[76,5,1],[74,6,1],[76,7,1],[77,8,1],[76,9,3],
    [79,12,1],[81,13,1],[83,14,1],[84,15,2],[79,17,1],[77,18,1],[76,19,1],[74,20,1],[72,21,3]];
  const melB = [
    [81,0,2],[83,2,1],[84,3,2],[81,5,1],[77,6,1],[79,7,1],[81,8,1],[79,9,3],
    [74,12,1],[76,13,1],[77,14,1],[79,15,2],[76,17,1],[76,18,2],[74,20,1],[76,21,3]];

  const secs = [
    { prog: [C, G, Am, F, C, F, G, C], mel: melA },
    { prog: [Am, Em, F, C, Dm, G, Em, G], mel: melB },
    { prog: [C, G, Am, F, C, F, G, C], mel: melA, echo: true },
    { prog: [F, C], mel: [[74,0,1],[76,1,1],[72,2,1],[72,3,3]], tail: true },
  ];

  let at = 0;
  secs.forEach(sec => {
    sec.prog.forEach((ch, i) => {
      const t0 = at + i * bar;
      lute(ch, t0);
      bass(ch, t0, bar, 0.16);
      if (sec.tail && i === 1) harp(ch, t0, beat, 6, 0.08); // 마지막 화음 롤
    });
    play(sec.mel, at, beat, "triangle", 0.14, 0.02);        // 리코더 같은 삼각파 멜로디
    if (sec.echo) play(sec.mel.map(([m,s,l]) => [m + 12, s + 0.5, l]), at, beat, "triangle", 0.035, 0.02);
    at += sec.prog.length * bar;
  });
  console.log(`항구의 아침 — C장조 80BPM 3/4, A-B-A'-꼬리, ${Math.round(at)}초`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

1-3. 폭풍의 바다 — 안달루시아 진행 (73초)

D단조 132BPM. 진행은 Dm–C–Bb–A의 하행(안달루시아 카덴차) — 원문이 다룬 화성단조의 리딩톤(C#)이 A장화음 마디마다 멜로디에 나타난다. 16분음표 폭풍 아르페지오가 쉼 없이 돌고, 저음은 8분음표 파고 + 마디 머리 팀파니. Intro의 볼륨 상승 램프로 폭풍이 다가온다.

// ── 원문의 부품: midiToFreq · build · note (osc → gain 엔벨로프 → out) ──
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, pattern) =>
  pattern.reduce((ns, s) => [...ns, ns.at(-1) + s], [root]);
function note(freq, start = 0, len = 0.5, type = "sine", vol = 0.2, atk = 0.01) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol, atk) => note(midiToFreq(midi), at, len, type, vol, atk);
const M = (r) => [r, r + 4, r + 7];        // 장3화음 = 3도 쌓기 [0,4,7]
const mi = (r) => [r, r + 3, r + 7];       // 단3화음 = [0,3,7]

// 오케스트라 성부 3종 — 전부 note()의 배치일 뿐이다
function harp(c, at, beat, n = 8, vol = 0.13) {   // 1-5-8-10 물결 아르페지오
  const seq = [c[0], c[2], c[0] + 12, c[1] + 12, c[0] + 12, c[2], c[0], c[2]];
  for (let k = 0; k < n; k++)
    N(seq[k % 8], at + k * beat / 2, beat * 0.9, "triangle", vol, 0.008);
}
function pad(c, at, len, vol = 0.045) {           // 사와 ±8센트 디튠 = 현악 스택
  [c[1], c[2]].forEach(mm => {
    const f = midiToFreq(mm);
    note(f * 2 ** (-8 / 1200), at, len, "sawtooth", vol, 0.4);
    note(f * 2 ** (8 / 1200), at, len, "sawtooth", vol, 0.4);
  });
}
function bass(c, at, len, vol = 0.22) { N(c[0] - 12, at, len, "sine", vol, 0.03); }
// 멜로디 시퀀서: [midi, 시작(박), 길이(박), 볼륨?]
function play(events, at, beat, type = "sawtooth", vol = 0.11, atk = 0.05) {
  events.forEach(([mm, st, len, v]) =>
    N(mm, at + st * beat, len * beat * 1.03, type, v ?? vol, atk));
}

// ── 곡 3 「폭풍의 바다」 — D단조 · 132 BPM · 4/4 · Intro–A–B–A′–B′–Outro (40마디, 73초) ──
{
  const bpm = 132, beat = 60 / bpm, bar = 4 * beat, s16 = beat / 4;
  const Dm = mi(50), C = M(48), Bb = M(46), A = M(45), Gm = mi(43), Fj = M(41);

  function storm(c, at, vol = 0.12) {  // 폭풍 아르페지오: 16분음표 r-5-8-5 쉼 없이
    const seq = [c[0], c[2], c[0] + 12, c[2]];
    for (let k = 0; k < 16; k++)
      N(seq[k % 4] + 12, at + k * s16, s16 * 1.6, "triangle", vol, 0.004);
  }
  function surge(c, at) {              // 저음 8분음표 파고 + 마디 머리 팀파니
    for (let k = 0; k < 8; k++)
      N(c[0], at + k * beat / 2, beat * 0.42, "sawtooth", 0.10, 0.01);
    N(c[0] - 12, at, beat * 1.5, "sine", 0.3, 0.005);
    N(c[0] - 12, at + 2 * beat, beat, "sine", 0.22, 0.005);
  }

  const melA = [ // C#(73) = 화성단조의 리딩톤 — A장화음 마디에서만
    [74,0,2],[72,2,1],[70,3,1],[69,4,3],[70,7,1],[67,8,2],[69,10,1],[70,11,1],
    [69,12,2],[73,14,2],[74,16,2],[77,18,2],[76,20,3],[74,23,1],[70,24,2],[72,26,1],
    [74,27,1],[73,28,3],[69,31,1]];
  const melB = [
    [67,0,2],[70,2,2],[74,4,2],[72,6,1],[70,7,1],[67,8,2],[65,10,2],[73,12,2],[69,14,2],
    [70,16,2],[74,18,2],[69,20,2],[65,22,2],[67,24,2],[70,26,2],[73,28,2],[74,30,2]];

  const secs = [
    { prog: [Dm, Dm, Dm, Dm], mel: [[62,0,4],[65,4,4],[69,8,4],[74,12,4]], rise: true },
    { prog: [Dm, C, Bb, A, Dm, C, Bb, A], mel: melA },
    { prog: [Gm, Dm, Gm, A, Bb, Fj, Gm, A], mel: melB },
    { prog: [Dm, C, Bb, A, Dm, C, Bb, A], mel: melA.map(([m,s,l]) => [m, s, l]) },
    { prog: [Gm, Dm, Gm, A, Bb, Fj, Gm, A], mel: melB, oct: true },
    { prog: [Dm, Bb, Gm, Dm], mel: [[74,0,2],[70,2,2],[69,4,2],[65,6,2],[62,8,8]], calm: true },
  ];

  let at = 0;
  secs.forEach(sec => {
    sec.prog.forEach((ch, i) => {
      const t0 = at + i * bar;
      storm(ch, t0, sec.rise ? 0.05 + 0.02 * i : sec.calm ? 0.07 : 0.12);
      if (!sec.rise && !sec.calm) surge(ch, t0);
      if (sec.calm) { bass(ch, t0, bar, 0.2); pad(ch, t0, bar); }
    });
    play(sec.mel, at, beat, "sawtooth", sec.rise ? 0.07 : 0.12, 0.03);
    if (sec.oct) play(sec.mel.map(([m,s,l]) => [m + 12, s, l]), at, beat, "sawtooth", 0.04, 0.03);
    at += sec.prog.length * bar;
  });
  console.log(`폭풍의 바다 — D단조 132BPM, 안달루시아 진행+화성단조 V, ${Math.round(at)}초`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

2. 80년대 8비트

NES(패미컴)의 음원칩 리코 2A03은 펄스 2채널 + 삼각파 1채널 + 노이즈 1채널이 전부다. 그래서 이 시대 음악의 문법은 제약이 만들었다: 한 채널이 코드톤을 고속으로 순환하며 화음을 흉내 내고(Ludomusicology의 제한 폴리포니 분석), 볼륨 조절이 안 되는 삼각파는 옥타브를 점프하는 베이스가 되고, 노이즈가 드럼 전부를 맡는다. 진행은 메가맨2 계열 분석이 보여주듯 극히 관용적인 단조 순환(i–bVI–bVII–i)이 표준이다.

원문 개념으로의 매핑: 멜로디는 1마디 모티프를 코드 루트 따라 이조하는 시퀀스 — 원문의 "로마 숫자 = 상대 주소 지정"을 그대로 코드로 쓴 것이다(모티프는 루트 기준 오프셋 배열이라 r + off 한 줄로 이조된다). 동시발음은 정확히 4개로 제한했다. 화음처럼 들리는 것은 전부 16분음표 아르페지오 한 채널이다.

2-1. 스테이지 1 (54초)

A단조 160BPM, Intro–A–B–브릿지–A′–Fin. A는 모티프 이조, B는 새 멜로디가 상행으로 열리고, 브릿지에서는 멜로디 채널이 쉬는 동안 아르페지오 채널이 두 옥타브를 오르내리며 리드를 맡는다(채널 부족을 역할 교대로 푸는 NES 문법). 재현부 끝은 한 옥타브 위로, 마지막은 유니즌 히트.

// ── 원문의 부품: midiToFreq · build · note ──────────────────────────
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, pattern) =>
  pattern.reduce((ns, s) => [...ns, ns.at(-1) + s], [root]);
function note(freq, start = 0, len = 0.5, type = "square", vol = 0.11, atk = 0.002) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol) => note(midiToFreq(midi), at, len, type, vol);

// 원문에 없는 부품: 노이즈 — NES의 4번째 채널
const noiseBuf = (() => {
  const b = ctx.createBuffer(1, ctx.sampleRate * 0.3, ctx.sampleRate);
  const d = b.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
  return b;
})();
function noise(start, len, ftype, freq, vol) {
  const t = ctx.currentTime + start;
  const src = ctx.createBufferSource();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  src.buffer = noiseBuf; flt.type = ftype; flt.frequency.value = freq;
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  src.connect(flt).connect(env).connect(out);
  src.start(t); src.stop(t + len);
}
// NES 규율: 동시발음 4 — pulse1 멜로디 / pulse2 아르페지오 / triangle 베이스 / noise 드럼
function drums(at, beat, style) {          // k=킥 s=스네어 h=해트 c=크래시
  for (let b = 0; b < 4; b++) {
    const t = at + b * beat;
    if (style === "drive" || style === "half" && b % 2 === 0)
      noise(t, 0.10, "lowpass", 220, b % 2 === 0 ? 0.5 : 0.35);
    if (b % 2 === 1) noise(t, 0.09, "bandpass", 1800, style === "half" ? 0.25 : 0.35);
    noise(t + beat / 2, 0.03, "highpass", 7000, 0.16);
    if (style === "drive") noise(t + beat * 0.75, 0.02, "highpass", 9000, 0.10);
  }
}
function bassPump(root, at, beat) {        // 삼각파 옥타브 점프 (볼륨 고정 채널의 문법)
  for (let k = 0; k < 8; k++)
    N(root - 12 + (k % 2) * 12, at + k * beat / 2, beat * 0.45, "triangle", 0.30);
}
function arp135(c, at, s16, vol = 0.05) {  // 채널 하나로 화음 흉내 — 코드톤 고속 순환
  for (let k = 0; k < 16; k++)
    N(c[k % 3] + 12, at + k * s16, s16 * 0.8, "square", vol);
}
function play16(events, at, s16, vol = 0.11) {  // 멜로디: [midi, 시작(16분), 길이(16분)]
  events.forEach(([m, st, len, v]) => N(m, at + st * s16, len * s16 * 0.92, "square", v ?? vol));
}

// ── 곡 1 「스테이지 1」 — A단조 · 160 BPM · Intro–A–B–C–A′–Fin (36마디, 54초 루프형) ──
{
  const bpm = 160, beat = 60 / bpm, bar = 4 * beat, s16 = beat / 4;
  const Am = [57, 60, 64], F = [53, 57, 60], G = [55, 59, 62], C = [60, 64, 67], E = [52, 56, 59];

  // A 멜로디: 1마디 모티프를 코드 루트 따라 이조 (r = 코드 루트)
  const motif = (r, lift = 0) => [
    [r+12+lift, 0, 2], [r+10+lift, 2, 2], [r+12+lift, 4, 2], [r+15+lift, 6, 2],
    [r+12+lift, 8, 2], [r+10+lift, 10, 2], [r+7+lift, 12, 4]];

  // B 멜로디: 절대 음정 8마디 (16분 그리드) — 상행으로 열고 리딩톤으로 되돌아온다
  const melB = [
    [72,0,6],[76,6,2],[74,8,6],[72,14,2],  [71,16,6],[67,22,2],[69,24,8],
    [69,32,4],[72,36,4],[74,40,6],[76,46,2],  [76,48,4],[74,52,2],[71,54,2],[68,56,8],
    [72,64,4],[74,68,2],[76,70,2],[79,72,8],  [79,80,4],[76,84,2],[74,86,2],[71,88,8],
    [74,96,4],[72,100,2],[69,102,2],[72,104,8],  [68,112,4],[71,116,4],[69,120,8]];

  let at = 0;
  // Intro 2마디: 베이스+드럼만 (판 깔기)
  for (let i = 0; i < 2; i++) { bassPump(57, at + i * bar, beat); drums(at + i * bar, beat, "drive"); }
  at += 2 * bar;
  // A (8): Am F G Am ×2
  [Am, F, G, Am, Am, F, G, Am].forEach((ch, i) => {
    const t0 = at + i * bar;
    play16(motif(ch[0]), t0, s16);
    arp135(ch, t0, s16); bassPump(ch[0], t0, beat); drums(t0, beat, "drive");
  });
  at += 8 * bar;
  // B (8): C G Am E / C G F E — 새 멜로디
  const progB = [C, G, Am, E, C, G, F, E];
  progB.forEach((ch, i) => {
    const t0 = at + i * bar;
    arp135(ch, t0, s16); bassPump(ch[0], t0, beat); drums(t0, beat, "drive");
  });
  play16(melB, at, s16);
  at += 8 * bar;
  // C (8) 브릿지: 멜로디 쉼, 아르페지오 채널이 리드 (두 옥타브 상행 랩)
  [F, G, Am, Am, F, G, E, E].forEach((ch, i) => {
    const t0 = at + i * bar;
    const seq = [ch[0], ch[1], ch[2], ch[0]+12, ch[1]+12, ch[2]+12, ch[1]+12, ch[0]+12];
    for (let k = 0; k < 16; k++) N(seq[k % 8] + 12, t0 + k * s16, s16 * 0.85, "square", 0.09);
    bassPump(ch[0], t0, beat);
    for (let b = 0; b < 4; b += 2) noise(t0 + b * beat, 0.10, "lowpass", 220, 0.45); // 킥 절반
  });
  at += 8 * bar;
  // A' (8): 재현부 — 마지막 두 마디 한 옥타브 위로
  [Am, F, G, Am, Am, F, G, Am].forEach((ch, i) => {
    const t0 = at + i * bar;
    play16(motif(ch[0], i >= 6 ? 12 : 0), t0, s16);
    arp135(ch, t0, s16); bassPump(ch[0], t0, beat); drums(t0, beat, "drive");
  });
  at += 8 * bar;
  // Fin 2마디: 유니즌 히트
  [0, 1, 2].forEach(k => { N(57 + k * 12, at + k * beat / 2, beat, "square", 0.12);
                           N(45, at + k * beat / 2, beat, "triangle", 0.3); });
  noise(at, 0.5, "highpass", 5000, 0.3);
  N(69, at + 2 * beat, 2 * beat, "square", 0.12); N(57, at + 2 * beat, 2 * beat, "triangle", 0.3);
  at += 2 * bar;
  console.log(`스테이지 1 — A단조 160BPM, Intro-A-B-브릿지-A'-Fin, ${Math.round(at)}초 (4보이스 규율)`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

2-2. 보스전 (41초)

E단조 176BPM. 인트로는 저음 처깅에 트라이톤(원문이 "가장 불협하는 음정"으로 다룬 √2 비율) 악센트를 박은 경보 리프 — 보스전의 문법이다. 전 박 킥과 리딩톤(D#)이 계속 해소를 미루며 압박하고, 마지막은 세 번의 유니즌 강타로 끝난다.

// ── 원문의 부품: midiToFreq · build · note ──────────────────────────
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, pattern) =>
  pattern.reduce((ns, s) => [...ns, ns.at(-1) + s], [root]);
function note(freq, start = 0, len = 0.5, type = "square", vol = 0.11, atk = 0.002) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol) => note(midiToFreq(midi), at, len, type, vol);

// 원문에 없는 부품: 노이즈 — NES의 4번째 채널
const noiseBuf = (() => {
  const b = ctx.createBuffer(1, ctx.sampleRate * 0.3, ctx.sampleRate);
  const d = b.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
  return b;
})();
function noise(start, len, ftype, freq, vol) {
  const t = ctx.currentTime + start;
  const src = ctx.createBufferSource();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  src.buffer = noiseBuf; flt.type = ftype; flt.frequency.value = freq;
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  src.connect(flt).connect(env).connect(out);
  src.start(t); src.stop(t + len);
}
// NES 규율: 동시발음 4 — pulse1 멜로디 / pulse2 아르페지오 / triangle 베이스 / noise 드럼
function drums(at, beat, style) {          // k=킥 s=스네어 h=해트 c=크래시
  for (let b = 0; b < 4; b++) {
    const t = at + b * beat;
    if (style === "drive" || style === "half" && b % 2 === 0)
      noise(t, 0.10, "lowpass", 220, b % 2 === 0 ? 0.5 : 0.35);
    if (b % 2 === 1) noise(t, 0.09, "bandpass", 1800, style === "half" ? 0.25 : 0.35);
    noise(t + beat / 2, 0.03, "highpass", 7000, 0.16);
    if (style === "drive") noise(t + beat * 0.75, 0.02, "highpass", 9000, 0.10);
  }
}
function bassPump(root, at, beat) {        // 삼각파 옥타브 점프 (볼륨 고정 채널의 문법)
  for (let k = 0; k < 8; k++)
    N(root - 12 + (k % 2) * 12, at + k * beat / 2, beat * 0.45, "triangle", 0.30);
}
function arp135(c, at, s16, vol = 0.05) {  // 채널 하나로 화음 흉내 — 코드톤 고속 순환
  for (let k = 0; k < 16; k++)
    N(c[k % 3] + 12, at + k * s16, s16 * 0.8, "square", vol);
}
function play16(events, at, s16, vol = 0.11) {  // 멜로디: [midi, 시작(16분), 길이(16분)]
  events.forEach(([m, st, len, v]) => N(m, at + st * s16, len * s16 * 0.92, "square", v ?? vol));
}

// ── 곡 2 「보스전」 — E단조 · 176 BPM · 경보 Intro–A–B–A′–Outro (30마디, 41초) ──
{
  const bpm = 176, beat = 60 / bpm, bar = 4 * beat, s16 = beat / 4;
  const Em = [52, 55, 59], Cj = [48, 52, 55], D = [50, 54, 57], Am = [57, 60, 64],
        G = [55, 59, 62], B7 = [47, 51, 54];

  function bossDrums(at) {               // 전 박 킥 + 뒤박 스네어 — 압박
    for (let b = 0; b < 4; b++) {
      noise(at + b * beat, 0.09, "lowpass", 240, 0.5);
      if (b % 2 === 1) noise(at + b * beat, 0.10, "bandpass", 1700, 0.4);
      for (let h = 0; h < 4; h++) noise(at + b * beat + h * s16, 0.02, "highpass", 8000, 0.09);
    }
  }

  // 경보 리프: 저음 8분 처깅 + 트라이톤(악마의 음정) 악센트
  const chug = (at) => [52,52,52,58,52,52,58,52].forEach((m, k) =>
    N(m, at + k * beat / 2, beat * 0.4, "square", 0.12));

  const melA = [ // 8분 그리드, D#(75) = 이 조의 리딩톤
    [76,0,1],[75,1,1],[76,2,1],[71,3,1],[67,4,2],[71,6,2],
    [72,8,1],[71,9,1],[72,10,1],[76,11,1],[79,12,2],[76,14,2],
    [74,16,1],[72,17,1],[74,18,1],[78,19,1],[81,20,2],[78,22,2],
    [76,24,2],[79,26,2],[76,28,1],[75,29,1],[76,30,2],
    [76,32,1],[75,33,1],[76,34,1],[71,35,1],[67,36,2],[64,38,2],
    [72,40,1],[74,41,1],[76,42,2],[79,44,2],[81,46,2],
    [83,48,2],[81,50,1],[79,51,1],[78,52,2],[74,54,2],
    [76,56,1],[79,57,1],[83,58,4],[75,62,2]];
  const melB = [
    [69,0,2],[72,2,2],[76,4,2],[74,6,2],[71,8,2],[67,10,2],[71,12,4],
    [69,16,2],[72,18,2],[76,20,2],[79,22,2],[75,24,4],[78,28,2],[75,30,2],
    [72,32,2],[76,34,2],[79,36,4],[74,40,2],[71,42,2],[74,44,4],
    [69,48,2],[72,50,2],[75,52,2],[78,54,2],[79,56,4],[75,60,2],[71,62,2]];
  const play8 = (ev, at) => ev.forEach(([m, st, len]) =>
    N(m, at + st * beat / 2, len * beat / 2 * 0.92, "square", 0.11));

  let at = 0;
  for (let i = 0; i < 4; i++) {          // Intro 4마디: 경보
    chug(at + i * bar); bassPump(52, at + i * bar, beat);
    if (i >= 2) bossDrums(at + i * bar);
    noise(at + i * bar, 0.3, "bandpass", 900, 0.2);
  }
  at += 4 * bar;
  const secA = [Em, Cj, D, Em, Em, Cj, D, Em], secB = [Am, Em, Am, B7, Cj, G, Am, B7];
  [[secA, melA], [secB, melB], [secA, melA]].forEach(([prog, mel]) => {
    prog.forEach((ch, i) => {
      const t0 = at + i * bar;
      arp135(ch, t0, s16, 0.06); bassPump(ch[0], t0, beat); bossDrums(t0);
    });
    play8(mel, at);
    at += 8 * bar;
  });
  // Outro 2마디: E 유니즌 강타 3연 + 잔향
  [0, 0.5, 1].forEach(off => { N(52, at + off * beat, beat * 0.4, "square", 0.14);
                               N(40, at + off * beat, beat * 0.4, "triangle", 0.32);
                               noise(at + off * beat, 0.08, "lowpass", 250, 0.5); });
  N(64, at + 2 * beat, 5 * beat, "square", 0.10); N(52, at + 2 * beat, 5 * beat, "triangle", 0.28);
  at += 2 * bar;
  console.log(`보스전 — E단조 176BPM, 트라이톤 경보+리딩톤 압박, ${Math.round(at)}초`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

2-3. 엔딩 크레딧 (72초)

C장조 100BPM 분산화음 발라드. 볼륨 고정인 삼각파가 온음표 베이스로 깔리고, 재현부에서 아르페지오가 한 옥타브 위에 겹치며 별빛이 뿌려진다. 스테이지 곡과 같은 4채널, 같은 파형인데 템포와 엔벨로프만 바꿔도 완전히 다른 감정이 나온다 — 원문의 "음색은 요청하지 않은 주파수들" 섹션의 실습이기도 하다.

// ── 원문의 부품: midiToFreq · build · note ──────────────────────────
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, pattern) =>
  pattern.reduce((ns, s) => [...ns, ns.at(-1) + s], [root]);
function note(freq, start = 0, len = 0.5, type = "square", vol = 0.11, atk = 0.002) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol) => note(midiToFreq(midi), at, len, type, vol);

// 원문에 없는 부품: 노이즈 — NES의 4번째 채널
const noiseBuf = (() => {
  const b = ctx.createBuffer(1, ctx.sampleRate * 0.3, ctx.sampleRate);
  const d = b.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
  return b;
})();
function noise(start, len, ftype, freq, vol) {
  const t = ctx.currentTime + start;
  const src = ctx.createBufferSource();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  src.buffer = noiseBuf; flt.type = ftype; flt.frequency.value = freq;
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  src.connect(flt).connect(env).connect(out);
  src.start(t); src.stop(t + len);
}
// NES 규율: 동시발음 4 — pulse1 멜로디 / pulse2 아르페지오 / triangle 베이스 / noise 드럼
function drums(at, beat, style) {          // k=킥 s=스네어 h=해트 c=크래시
  for (let b = 0; b < 4; b++) {
    const t = at + b * beat;
    if (style === "drive" || style === "half" && b % 2 === 0)
      noise(t, 0.10, "lowpass", 220, b % 2 === 0 ? 0.5 : 0.35);
    if (b % 2 === 1) noise(t, 0.09, "bandpass", 1800, style === "half" ? 0.25 : 0.35);
    noise(t + beat / 2, 0.03, "highpass", 7000, 0.16);
    if (style === "drive") noise(t + beat * 0.75, 0.02, "highpass", 9000, 0.10);
  }
}
function bassPump(root, at, beat) {        // 삼각파 옥타브 점프 (볼륨 고정 채널의 문법)
  for (let k = 0; k < 8; k++)
    N(root - 12 + (k % 2) * 12, at + k * beat / 2, beat * 0.45, "triangle", 0.30);
}
function arp135(c, at, s16, vol = 0.05) {  // 채널 하나로 화음 흉내 — 코드톤 고속 순환
  for (let k = 0; k < 16; k++)
    N(c[k % 3] + 12, at + k * s16, s16 * 0.8, "square", vol);
}
function play16(events, at, s16, vol = 0.11) {  // 멜로디: [midi, 시작(16분), 길이(16분)]
  events.forEach(([m, st, len, v]) => N(m, at + st * s16, len * s16 * 0.92, "square", v ?? vol));
}

// ── 곡 3 「엔딩 크레딧」 — C장조 · 100 BPM · Intro–A–B–A′–Outro (30마디, 72초) ──
{
  const bpm = 100, beat = 60 / bpm, bar = 4 * beat;
  const C = [60, 64, 67], Am = [57, 60, 64], F = [53, 57, 60], G = [55, 59, 62],
        Em = [52, 55, 59], Dm = [50, 53, 57];

  function broken(c, at, vol = 0.06) {   // 부드러운 8분 분산화음 (엔딩의 물결)
    const seq = [c[0], c[1], c[2], c[1], c[0], c[1], c[2], c[1]];
    seq.forEach((m, k) => N(m + 12, at + k * beat / 2, beat * 0.85, "square", vol));
  }
  const play4 = (ev, at) => ev.forEach(([m, st, len]) =>
    N(m, at + st * beat, len * beat * 1.02, "square", 0.10));

  const melA = [
    [76,0,3],[74,3,1],[72,4,3],[69,7,1],[69,8,2],[72,10,2],[74,12,4],
    [76,16,2],[79,18,2],[81,20,3],[76,23,1],[77,24,2],[74,26,2],[71,28,2],[74,30,2]];
  const melB = [
    [77,0,3],[76,3,1],[74,4,3],[71,7,1],[71,8,2],[76,10,2],[72,12,4],
    [74,16,2],[77,18,2],[76,20,2],[71,22,2],[69,24,2],[72,26,2],[74,28,4]];

  let at = 0;
  [C, G].forEach((ch, i) => { broken(ch, at + i * bar); N(ch[0] - 12, at + i * bar, bar, "triangle", 0.22); });
  at += 2 * bar;
  const progA = [C, Am, F, G, C, Am, F, G], progB = [F, G, Em, Am, Dm, Em, F, G];
  [[progA, melA, false], [progB, melB, false], [progA, melA, true]].forEach(([prog, mel, sparkle]) => {
    prog.forEach((ch, i) => {
      const t0 = at + i * bar;
      broken(ch, t0, sparkle ? 0.05 : 0.06);
      if (sparkle) broken(ch.map(x => x + 12), t0 + beat / 4, 0.02);  // 한 옥타브 위 별빛
      N(ch[0] - 12, t0, bar, "triangle", 0.22);
      noise(t0, 0.02, "highpass", 9000, 0.05); noise(t0 + 2 * beat, 0.02, "highpass", 9000, 0.05);
    });
    play4(mel, at);
    at += 8 * bar;
  });
  // Outro 4마디: 하행 종지 + 마지막 롤
  [F, G, C, C].forEach((ch, i) => {
    const t0 = at + i * bar;
    broken(ch, t0, 0.05); N(ch[0] - 12, t0, bar, "triangle", 0.2);
  });
  play4([[76,0,2],[74,2,2],[72,4,4],[67,8,2],[72,10,2],[76,12,1],[79,13,1],[84,14,2]], at);
  at += 4 * bar;
  console.log(`엔딩 크레딧 — C장조 100BPM, 분산화음 발라드, ${Math.round(at)}초`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

3. K-pop 풍

최신 K-pop 쪽은 실측 데이터가 있다. 아일릿 Magnetic은 Hooktheory 분석 기준 Bb마이너 130–131 BPM에 진행이 i7–bVIIm7–bVImaj7–V7 — 전부 7th를 얹은 4코드 루프다(코드 복잡도 91점). 르세라핌 UNFORGIVEN은 E마이너 103–104 BPM, 도리안 IV와 화성단조 V를 섞는다. 공통 문법: 마이너 키, 4마디 무한 루프, 플럭 신스 리프가 훅을 겸하고, 보컬 훅은 좁은 음역의 펜타토닉 챈트다.

원문 개념으로의 매핑이 이 테마에서 제일 직접적이다. 원문은 "코드는 3도를 쌓은 배열"이라 했는데, 한 번 더 쌓으면(7th) 그대로 K-pop 문법이 된다[0,3,7,10] 네 칸짜리 배열이 Bbm7이다. 플럭은 note()에 로우패스 필터 감쇠 한 겹을 얹은 것이고, 킥은 사인파 피치를 110→40Hz로 떨어뜨린 것이다.

3-1. 네온 타이드 — 하우스 풀폼 (81초)

Bb단조 130BPM. Intro(필터 잠긴 플럭)–Verse–PreChorus(라이저)–Chorus(챈트 훅)–Verse2(드럼 뺀 숨 고르기)–PreChorus–Chorus(스탭 추가)–Outro(페이드). 훅은 5음만 남긴 펜타토닉(원문의 "부분집합 고르기") 2마디 반복, 서브 베이스는 킥 사이 오프비트 배치로 사이드체인 그루브를 흉내 낸다. 프리코러스 끝의 상승 노이즈(라이저)가 드랍을 예고하는 것까지가 하우스 문법이다.

// ── 원문의 부품: midiToFreq + 3도 쌓기 (7th까지 한 번 더 = K-pop 문법) ──
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const stack = (root, ivs) => ivs.map(iv => root + iv);
function tone(freq, start, len, type, vol, atk = 0.005) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol, atk) => tone(midiToFreq(midi), at, len, type, vol, atk);

// 원문에 없는 부품 둘: 로우패스 플럭 + 노이즈
function pluck(midi, start, len, vol = 0.14) {   // 필터가 120ms 만에 닫힌다 — 플럭의 본체
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  osc.type = "square"; osc.frequency.value = midiToFreq(midi);
  flt.type = "lowpass";
  flt.frequency.setValueAtTime(4000, t);
  flt.frequency.exponentialRampToValueAtTime(300, t + 0.12);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(flt).connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
function kick(start, vol = 0.7) {                // 사인파 피치 낙하 = 킥
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.frequency.setValueAtTime(110, t);
  osc.frequency.exponentialRampToValueAtTime(40, t + 0.10);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + 0.25);
}
const noiseBuf = (() => {
  const b = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);
  const d = b.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
  return b;
})();
function noise(start, len, ftype, f1, vol, f2) { // f2를 주면 필터가 f1→f2로 쓸려 올라간다(라이저)
  const t = ctx.currentTime + start;
  const src = ctx.createBufferSource();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  src.buffer = noiseBuf; src.loop = true;
  flt.type = ftype;
  flt.frequency.setValueAtTime(f1, t);
  if (f2) flt.frequency.exponentialRampToValueAtTime(f2, t + len);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  src.connect(flt).connect(env).connect(out);
  src.start(t); src.stop(t + len);
}

// ── 곡 1 「네온 타이드」 — Bb단조 · 130 BPM · 하우스 풀폼 (52마디, 96초) ──
// Intro – Verse – PreChorus(라이저) – Chorus – Verse2 – PreChorus – Chorus – Outro
{
  const bpm = 130, beat = 60 / bpm, bar = 4 * beat, s16 = beat / 4, e8 = beat / 2;
  const prog = [ stack(58, [0,3,7,10]), stack(56, [0,3,7,10]),
                 stack(54, [0,4,7,11]), stack(53, [0,4,7,10]) ]; // Bbm7 Abm7 Gbmaj7 F7
  const pent = [70, 73, 75, 77, 80];               // 훅용 5음 (어딜 밟아도 안전한 부분집합)
  const hook = [[2,0,1],[2,2,1],[3,4,1],[1,6,2],[0,10,2],
                [2,16,1],[2,18,1],[4,20,1],[3,22,2],[1,26,3]];

  function pluckRiff(ch, at, vol) {
    [0, 3, 6, 10, 12, 14].forEach((p, k) => pluck(ch[k % 4] + 12, at + p * s16, 0.28, vol));
  }
  function houseBar(at, opts) {                    // 드럼+서브 한 마디
    for (let b = 0; b < 4; b++) {
      if (opts.kick) kick(at + b * beat);
      if (opts.clap && b % 2 === 1) noise(at + b * beat, 0.12, "bandpass", 2200, 0.3);
      if (opts.hats) for (let h = 0; h < 4; h++)
        noise(at + b * beat + h * s16, h === 2 ? 0.08 : 0.03, "highpass", 8000, h === 2 ? 0.15 : 0.09);
      if (opts.sub) N(opts.sub - 24, at + b * beat + e8, beat * 0.42, "sine", 0.4, 0.01);
    }
  }
  const playMel = (ev, at, vol = 0.10) => ev.forEach(([m, st, len]) =>
    N(m, at + st * e8, len * e8 * 1.3, "sawtooth", vol, 0.01));

  // 벌스/프리코러스 멜로디 (8분 그리드, 자연단음계 저역 — 절제)
  const melV = [[70,0,3],[68,6,2],[70,8,3],[73,14,2],[70,16,3],[68,22,2],[66,24,3],[65,30,2],
                [63,32,4],[65,38,2],[66,40,3],[68,46,2],[70,48,4],[68,54,2],[65,56,6]];
  const melPC = [[63,0,2],[65,2,2],[66,4,4],[65,8,2],[66,10,2],[68,12,4],[66,16,2],[68,18,2],
                 [70,20,4],[68,24,2],[70,26,2],[72,28,4],[70,32,2],[72,34,2],[73,36,4],
                 [75,40,4],[77,44,4],[77,48,12]];

  let at = 0;
  // Intro 4마디: 플럭만 (필터 열리기 전 새벽)
  prog.forEach((ch, i) => pluckRiff(ch, at + i * bar, 0.08));
  at += 4 * bar;
  // Verse 8마디: 킥+서브 절제, 멜로디 저역
  for (let r = 0; r < 2; r++) prog.forEach((ch, i) => {
    const t0 = at + (r * 4 + i) * bar;
    pluckRiff(ch, t0, 0.09);
    houseBar(t0, { kick: true, sub: ch[0], hats: r === 1 });
  });
  playMel(melV, at);
  at += 8 * bar;
  // PreChorus 8마디: 클랩+해트 풀 + 상행 멜로디 + 마지막 2마디 라이저
  for (let r = 0; r < 2; r++) prog.forEach((ch, i) => {
    const t0 = at + (r * 4 + i) * bar;
    pluckRiff(ch, t0, 0.11);
    houseBar(t0, { kick: true, clap: true, hats: true, sub: ch[0] });
  });
  playMel(melPC, at, 0.11);
  noise(at + 6 * bar, 2 * bar, "bandpass", 500, 0.10, 9000);   // 라이저
  at += 8 * bar;
  // Chorus 8마디 ×(2회 등장): 챈트 훅 + 풀 편성
  function chorus(at0, big) {
    for (let r = 0; r < 2; r++) prog.forEach((ch, i) => {
      const t0 = at0 + (r * 4 + i) * bar;
      pluckRiff(ch, t0, 0.13);
      houseBar(t0, { kick: true, clap: true, hats: true, sub: ch[0] });
      if (big) pluck(ch[2] + 24, t0 + 2 * beat, 0.5, 0.06);     // 상성부 스탭
    });
    for (let rep = 0; rep < 2; rep++)
      hook.forEach(([d, st, len]) =>
        N(pent[d], at0 + rep * 4 * bar + st * s16 * 2, len * s16 * 2 * 1.4, "sawtooth", 0.10, 0.01));
  }
  chorus(at, false); at += 8 * bar;
  // Verse2 8마디 (짧게 4마디로 숨 고르기): 킥 빼고 플럭+서브
  prog.forEach((ch, i) => {
    const t0 = at + i * bar;
    pluckRiff(ch, t0, 0.09);
    houseBar(t0, { sub: ch[0], hats: true });
  });
  noise(at + 3 * bar, bar, "bandpass", 500, 0.08, 8000);
  at += 4 * bar;
  chorus(at, true); at += 8 * bar;
  // Outro 4마디: 플럭 페이드 + 마지막 스탭
  prog.forEach((ch, i) => pluckRiff(ch, at + i * bar, 0.10 - i * 0.02));
  prog[0].forEach(m => pluck(m + 12, at + 4 * bar - e8, 1.2, 0.10));
  at += 4 * bar;
  console.log(`네온 타이드 — Bb단조 130BPM 하우스 풀폼, ${Math.round(at)}초`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

3-2. 미드나잇 콜 — 하프타임 (102초)

E단조 104BPM, UNFORGIVEN 계열 문법(도리안 IV + 화성단조 V를 7th 보이싱으로). 킥이 1박과 3.5박, 스네어가 3박에만 오는 하프타임 그루브라 같은 BPM인데 절반 속도로 느껴진다 — 검출기마다 BPM이 배수로 갈리는 이유다. 브릿지는 드럼을 전부 빼고 벨 하나만 남긴다.

// ── 원문의 부품: midiToFreq + 3도 쌓기 (7th까지 한 번 더 = K-pop 문법) ──
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const stack = (root, ivs) => ivs.map(iv => root + iv);
function tone(freq, start, len, type, vol, atk = 0.005) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol, atk) => tone(midiToFreq(midi), at, len, type, vol, atk);

// 원문에 없는 부품 둘: 로우패스 플럭 + 노이즈
function pluck(midi, start, len, vol = 0.14) {   // 필터가 120ms 만에 닫힌다 — 플럭의 본체
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  osc.type = "square"; osc.frequency.value = midiToFreq(midi);
  flt.type = "lowpass";
  flt.frequency.setValueAtTime(4000, t);
  flt.frequency.exponentialRampToValueAtTime(300, t + 0.12);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(flt).connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
function kick(start, vol = 0.7) {                // 사인파 피치 낙하 = 킥
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.frequency.setValueAtTime(110, t);
  osc.frequency.exponentialRampToValueAtTime(40, t + 0.10);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + 0.25);
}
const noiseBuf = (() => {
  const b = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);
  const d = b.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
  return b;
})();
function noise(start, len, ftype, f1, vol, f2) { // f2를 주면 필터가 f1→f2로 쓸려 올라간다(라이저)
  const t = ctx.currentTime + start;
  const src = ctx.createBufferSource();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  src.buffer = noiseBuf; src.loop = true;
  flt.type = ftype;
  flt.frequency.setValueAtTime(f1, t);
  if (f2) flt.frequency.exponentialRampToValueAtTime(f2, t + len);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  src.connect(flt).connect(env).connect(out);
  src.start(t); src.stop(t + len);
}

// ── 곡 2 「미드나잇 콜」 — E단조 · 104 BPM · 하프타임 (44마디, 102초) ──
// Intro – Verse – Chorus – Verse2 – Chorus – Bridge – Chorus
{
  const bpm = 104, beat = 60 / bpm, bar = 4 * beat, e8 = beat / 2, s16 = beat / 4;
  const prog = [ stack(52, [0,3,7,10]), stack(57, [0,4,7,10]),
                 stack(50, [0,4,7,11]), stack(47, [0,4,7,10]) ]; // Em7 A7 Dmaj7 B7

  function halfBar(at, opts) {           // 하프타임: 킥 1박+3.5박, 스네어 3박
    kick(at, 0.6); kick(at + 2.5 * beat, 0.5);
    if (opts.snare) noise(at + 2 * beat, 0.14, "bandpass", 2000, 0.32);
    if (opts.hats) {
      for (let h = 0; h < 8; h++) noise(at + h * e8, 0.03, "highpass", 8500, 0.08);
      for (let r = 0; r < 4; r++) noise(at + 3.5 * beat + r * s16 / 2, 0.02, "highpass", 9000, 0.10);
    }
    if (opts.sub) { N(opts.sub - 24, at, beat * 0.9, "sine", 0.38, 0.01);
                    N(opts.sub - 24, at + 2.5 * beat, beat * 0.7, "sine", 0.34, 0.01); }
  }
  function chords7(ch, at, vol = 0.10) { // 7th 보이싱 플럭 촙: 1박·2.5박·4박
    [0, 2.5, 3.5].forEach(b => ch.forEach(m => pluck(m + 12, at + b * beat, 0.35, vol / 2)));
  }
  const playMel = (ev, at, vol = 0.11) => ev.forEach(([m, st, len]) =>
    N(m, at + st * e8, len * e8 * 1.25, "sawtooth", vol, 0.02));

  const melV = [[64,0,2],[67,2,2],[69,4,4],[67,10,2],[64,12,4],[62,20,2],[64,22,2],[67,24,4],
                [64,30,2],[71,32,3],[69,38,2],[67,40,4],[64,46,2],[62,48,3],[64,54,2],[64,56,6]];
  const melC = [[76,0,3],[74,4,2],[71,6,2],[74,8,4],[71,14,2],[69,16,4],[71,22,2],[74,24,4],
                [76,30,2],[78,32,4],[76,38,2],[74,40,4],[71,46,2],[74,48,6],[71,56,4]];

  let at = 0;
  prog.forEach((ch, i) => chords7(ch, at + i * bar, 0.08));                 // Intro 4
  at += 4 * bar;
  for (let r = 0; r < 2; r++) prog.forEach((ch, i) => {                     // Verse 8
    const t0 = at + (r * 4 + i) * bar;
    chords7(ch, t0); halfBar(t0, { snare: true, sub: ch[0], hats: r === 1 });
  });
  playMel(melV, at);
  at += 8 * bar;
  function chorus(at0) {
    for (let r = 0; r < 2; r++) prog.forEach((ch, i) => {
      const t0 = at0 + (r * 4 + i) * bar;
      chords7(ch, t0, 0.12); halfBar(t0, { snare: true, sub: ch[0], hats: true });
    });
    playMel(melC, at0, 0.12);
  }
  chorus(at); at += 8 * bar;
  for (let r = 0; r < 1; r++) prog.forEach((ch, i) => {                     // Verse2 4
    const t0 = at + i * bar;
    chords7(ch, t0); halfBar(t0, { snare: true, sub: ch[0], hats: true });
  });
  playMel(melV.slice(0, 8), at);
  at += 4 * bar;
  chorus(at); at += 8 * bar;
  prog.forEach((ch, i) => {                                                 // Bridge 4: 드럼 뺌
    const t0 = at + i * bar;
    ch.forEach(m => pluck(m + 12, t0, bar * 0.9, 0.05));
    N(ch[2] + 24, t0 + beat, 2 * beat, "triangle", 0.06, 0.1);              // 벨
  });
  noise(at + 3 * bar, bar, "bandpass", 400, 0.08, 7000);
  at += 4 * bar;
  chorus(at); at += 8 * bar;
  console.log(`미드나잇 콜 — E단조 104BPM 하프타임, 7th 보이싱, ${Math.round(at)}초`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

3-3. 버블 점프 — 바운스 팝 (75초)

C장조 140BPM. 플럭이 정박이 아니라 전부 오프비트("&")에만 오는 것이 바운스의 본체다. 벌스는 마이너에서 시작해(Am) 코러스에서 장조(C)로 뒤집는 밝기 스위치, 훅은 사각파+사와 더블링에 삼각파 벨 스파클. 아일릿 쪽 밝은 트랙들의 문법이다.

// ── 원문의 부품: midiToFreq + 3도 쌓기 (7th까지 한 번 더 = K-pop 문법) ──
const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const stack = (root, ivs) => ivs.map(iv => root + iv);
function tone(freq, start, len, type, vol, atk = 0.005) {
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  env.gain.setValueAtTime(0, t);
  env.gain.linearRampToValueAtTime(vol, t + atk);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
const N = (midi, at, len, type, vol, atk) => tone(midiToFreq(midi), at, len, type, vol, atk);

// 원문에 없는 부품 둘: 로우패스 플럭 + 노이즈
function pluck(midi, start, len, vol = 0.14) {   // 필터가 120ms 만에 닫힌다 — 플럭의 본체
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  osc.type = "square"; osc.frequency.value = midiToFreq(midi);
  flt.type = "lowpass";
  flt.frequency.setValueAtTime(4000, t);
  flt.frequency.exponentialRampToValueAtTime(300, t + 0.12);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  osc.connect(flt).connect(env).connect(out);
  osc.start(t); osc.stop(t + len);
}
function kick(start, vol = 0.7) {                // 사인파 피치 낙하 = 킥
  const t = ctx.currentTime + start;
  const osc = ctx.createOscillator();
  const env = ctx.createGain();
  osc.frequency.setValueAtTime(110, t);
  osc.frequency.exponentialRampToValueAtTime(40, t + 0.10);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
  osc.connect(env).connect(out);
  osc.start(t); osc.stop(t + 0.25);
}
const noiseBuf = (() => {
  const b = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);
  const d = b.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
  return b;
})();
function noise(start, len, ftype, f1, vol, f2) { // f2를 주면 필터가 f1→f2로 쓸려 올라간다(라이저)
  const t = ctx.currentTime + start;
  const src = ctx.createBufferSource();
  const flt = ctx.createBiquadFilter();
  const env = ctx.createGain();
  src.buffer = noiseBuf; src.loop = true;
  flt.type = ftype;
  flt.frequency.setValueAtTime(f1, t);
  if (f2) flt.frequency.exponentialRampToValueAtTime(f2, t + len);
  env.gain.setValueAtTime(vol, t);
  env.gain.exponentialRampToValueAtTime(0.001, t + len);
  src.connect(flt).connect(env).connect(out);
  src.start(t); src.stop(t + len);
}

// ── 곡 3 「버블 점프」 — C장조 · 140 BPM · 바운스 팝 (44마디, 75초) ──
// Intro – Verse – PreChorus – Chorus – Verse2 – PreChorus – Chorus – Outro
{
  const bpm = 140, beat = 60 / bpm, bar = 4 * beat, e8 = beat / 2, s16 = beat / 4;
  const C = stack(60, [0,4,7]), G = stack(55, [0,4,7]), Am = stack(57, [0,3,7]),
        F = stack(53, [0,4,7]), Em = stack(52, [0,3,7]);
  const progV = [Am, F, C, G], progC = [C, G, Am, F], progP = [F, G, F, G];

  function bounceBar(ch, at, opts) {
    for (let b = 0; b < 4; b++) {
      if (opts.kick) kick(at + b * beat, 0.55);
      if (opts.clap && b % 2 === 1) noise(at + b * beat, 0.10, "bandpass", 2500, 0.28);
      if (opts.hat) noise(at + b * beat + e8, 0.05, "highpass", 9000, 0.13);   // 오프비트만
    }
    // 플럭은 전부 오프비트("&") — 바운스의 본체
    for (let b = 0; b < 4; b++) ch.forEach(m => pluck(m + 12, at + b * beat + e8, 0.2, 0.05));
    if (opts.sub) for (let b = 0; b < 4; b++)
      N(ch[0] - 12, at + b * beat, beat * 0.45, "sine", 0.3, 0.01);
  }
  const playMel = (ev, at, type = "square", vol = 0.10) => ev.forEach(([m, st, len]) =>
    N(m, at + st * e8, len * e8 * 1.1, type, vol, 0.005));

  const melV = [[72,0,2],[74,2,2],[76,4,4],[74,10,2],[72,12,2],[69,14,2],[69,16,2],[72,18,2],
                [74,20,4],[72,26,2],[74,28,2],[76,30,2],[79,32,4],[76,38,2],[74,40,2],[72,42,2],
                [74,44,4],[72,50,2],[71,52,2],[72,54,2],[67,56,6]];
  const melP = [[77,0,2],[79,2,2],[81,4,4],[79,10,2],[77,12,2],[79,14,2],[81,16,4],[83,22,2],[84,24,8]];
  const hookC = [[79,0,1],[79,2,1],[81,4,2],[76,6,2],[74,8,2],[76,10,1],[74,11,1],[72,12,4],
                 [79,16,1],[79,18,1],[81,20,2],[84,22,2],[81,24,2],[79,26,1],[76,27,1],[79,28,4]];

  let at = 0;
  progC.slice(0, 2).forEach((ch, i) => bounceBar(ch, at + i * bar, { hat: true }));   // Intro 2
  at += 2 * bar;
  function verse(at0) {
    for (let r = 0; r < 2; r++) progV.forEach((ch, i) =>
      bounceBar(ch, at0 + (r * 4 + i) * bar, { kick: true, hat: true, sub: r === 1, clap: r === 1 }));
    playMel(melV, at0);
  }
  function pre(at0) {
    progP.forEach((ch, i) => bounceBar(ch, at0 + i * bar, { kick: true, clap: true, hat: true, sub: true }));
    playMel(melP, at0, "square", 0.11);
    noise(at0 + 3 * bar, bar, "bandpass", 600, 0.09, 8000);
  }
  function chorus(at0) {
    for (let r = 0; r < 2; r++) progC.forEach((ch, i) => {
      const t0 = at0 + (r * 4 + i) * bar;
      bounceBar(ch, t0, { kick: true, clap: true, hat: true, sub: true });
      N(ch[2] + 24, t0 + 2 * beat + e8, beat, "triangle", 0.05, 0.005);      // 벨 스파클
    });
    for (let rep = 0; rep < 2; rep++) {
      playMel(hookC, at0 + rep * 4 * bar, "square", 0.11);                    // 훅 더블링
      playMel(hookC.map(([m,s,l]) => [m, s, l]), at0 + rep * 4 * bar, "sawtooth", 0.04);
    }
  }
  verse(at); at += 8 * bar;
  pre(at); at += 4 * bar;
  chorus(at); at += 8 * bar;
  verse(at); at += 8 * bar;
  pre(at); at += 4 * bar;
  chorus(at); at += 8 * bar;
  progC.slice(0, 1).forEach(ch => {                                           // Outro 2
    bounceBar(ch, at, { hat: true });
    ch.forEach(m => pluck(m + 12, at + bar, 1.0, 0.08));
    N(84, at + bar, 2 * beat, "triangle", 0.06, 0.005);
  });
  at += 2 * bar;
  console.log(`버블 점프 — C장조 140BPM 바운스 팝, ${Math.round(at)}초`);
}
Web Audio · 브라우저에서 직접 실행 · 실행하면 아래에 파형이 그려진다

한계와 다음

제작 과정: 원문 완역 → 부품 추출(midiToFreq/build/3도 쌓기/note) → 스타일별 리서치(Hooktheory·Wikipedia·Ludomusicology 등) → 작곡 스펙(키·BPM·진행·리듬·파형) 확정 → 코드 작곡 → 브라우저 실행 검증. 전 과정에서 추가된 신규 부품은 노이즈 버퍼와 BiquadFilter 둘뿐이다.

zhugehyuk · 2026-08-21 · 원문 부품 출처: 프로그래머를 위한 음악이론 (완역) · dosi.dev