LINK · 완역 · 실행형

프로그래머를 위한 음악이론

Music theory for programmers — 소리는 시간에 따라 변하는 숫자다. 사인파 한 줄에서 12음·음계·코드·키·기보법까지 전부 코드로 유도한다. 코드 블록 27개는 이 페이지에서 바로 실행되고, 실행하면 출력 파형이 그려진다.


읽기 전에

핵심 질문: 음악이론의 규칙들(12음, 장음계, 코드, 조성, 악보)은 왜 하필 그런 모양인가 — 외워야 할 관례가 아니라, 물리와 산술에서 코드로 직접 유도해낼 수 있는가?

선행 개념

모르면 막히는 것만 추렸다. 악기 경험이나 악보 독해 능력은 전혀 필요 없다 — 저자 본인이 악기를 못 다루는 프로그래머다.

권장 읽기 경로

이 문서의 특징: 코드 블록마다 실행 버튼(Run)이 달려 있어 브라우저에서 바로 소리를 들을 수 있다. "단순한 비율이 왜 좋게 들리는가" 같은 주장을 글자가 아니라 귀로 검증하게 설계된 글이므로, 여건이 되면 실행하며 읽는 쪽이 압도적으로 이득이다.

  1. 요약만 볼 사람: 도입부 → "Doubling the frequency"(옥타브) → "Stacking fifths"(피타고라스 콤마) → "Equal temperament"(12평균율) → 마지막 두 섹션("Putting it together" 앞 문단 + "What I still do not understand"). 이 뼈대만으로 "12음이 왜 12음인가"의 논증 전체가 잡힌다. 악보 섹션은 건너뛰어도 무방하다.
  2. 완독 + 코드 실행할 사람: 처음부터 순서대로. 각 예제는 앞 예제의 결론(특히 note() 헬퍼와 midiToFreq)을 전제로 쌓이므로 건너뛰면 뒤에서 되돌아오게 된다. 특히 ①비율 6종 비교(consonance를 귀로 확인), ②5도 쌓기의 222.99Hz 오차, ③12분할 브루트포스, ④모드 = 배열 회전, ⑤마지막 40줄짜리 곡 — 이 다섯 개는 반드시 실행해볼 것. 브라우저 밖에서 돌리려면 RunJS 등에 const ctx = new AudioContext(); const out = ctx.destination; 두 줄만 얹으면 된다.

멘탈 모델

음악이론 = 물리 제약 아래 내려진 엔지니어링 결정들의 스택. 배음렬(물리)이 "단순한 비율 = 협화"를 강제하고, 3n ≠ 2m이라는 소수의 산술이 완벽한 조율 체계를 불가능하게 만들며, 12평균율·음계·코드·조성·악보는 그 오차를 어디에 버릴지에 대한 역사적 트레이드오프다. 관례처럼 보이는 모든 것에 계산 가능한 이유가 있다.

예상 소요

점검 질문

읽고 나서 아래에 답할 수 있어야 한다.

  1. 완전5도(3/2)를 12번 쌓으면 왜 시작음으로 정확히 돌아오지 못하는가? 이 오차(피타고라스 콤마)가 "수정 불가능"한 산술적 이유를 소수 2와 3으로 설명할 수 있는가?
  2. 옥타브를 하필 12등분하는 이유는 무엇인가 — 5, 7, 19등분 대비 12가 사는 트레이드오프를 브루트포스 결과로 설명할 수 있는가?
  3. 장조와 단조, 그리고 7개 선법(모드)의 관계를 "간격 배열 [2,2,1,2,2,2,1]의 회전"으로, 메이저/마이너 코드의 차이를 [0,4,7] vs [0,3,7]로 설명할 수 있는가? V→I 해결이 강하게 당기는 두 가지 물리적 이유(이끔음, 트라이톤 = √2)는?

Executive Summary

악기를 못 다루는 프로그래머 Luke Haas가 "왜 12음인가, 왜 그 패턴인가"에 대한 관례적 답("원래 그렇다")을 거부하고, Web Audio API 코드만으로 음악이론 전체를 물리와 산수에서 처음부터 유도한 글. 사인파 하나에서 출발해 12음·스케일·코드·키·화성 진행·기보법까지, 외워야 할 것 없이 전부 계산으로 도출한다. 결론: 음악이론은 자의적 관습이 아니라 소수 2와 3 사이의 제약 아래서 내려진 엔지니어링 결정들의 집합이다.

1. 소리·음색·옥타브 — 물리적 기초

2. 협화의 이유 — 단순 정수비

3. 5도 쌓기와 고칠 수 없는 버그 — 피타고라스 콤마

옥타브 다음으로 깨끗한 비율 3/2(5도)만으로 음계를 지어보면(피타고라스 방식), 220에서 출발해 5도 12번 후 222.99Hz에 착지한다 — 같은 음이 되려다 실패한, 쓸 수 없는 오차. 반올림 문제가 아니라 구조적이다:

(3/2)12 ≈ 129.746 — 5도 12번
27 = 128 — 옥타브 7번
비율 ≈ 1.0136 (오차 ≈1.36%, 약 23.46센트) = 피타고라스 콤마 — 인접 피아노 건반 간격의 약 1/4

프로그래머가 알아볼 이유로 영원히 못 고친다: 5도 n번 = 3n/2n, 옥타브 m번 = 2m. 둘이 만나려면 3n = 2n+m이어야 하는데 3과 2는 둘 다 소수라 어떤 n에서도 성립하지 않는다. 옥타브도 순정, 5도도 순정, 루프도 닫히는 시스템은 존재한 적이 없다 — 역사상 모든 조율 체계는 "오차를 어디에 버릴 것인가"의 서로 다른 선택이다.

4. 평균율 — 타협, 그리고 왜 하필 12인가

평균율 5도 오차 ≈ 0.11% (2센트 플랫) — 지구상 모든 피아노의 모든 5도에 이 느린 맥놀이가 들어 있다
MIDI 변환 한 줄: f(n) = 440 · 2(n−69)/12 — 69 = A440, 60 = 가운데 C

결정적 보상: 음이 정수가 된다. 이제부터 모든 것은 주파수가 아니라 정수 배열로 진행된다. "한 키에서만 완벽하고 나머지에서 못 쓰는 것보다, 어디서나 조금씩 틀린 게 낫다"는 집단적 결정. 피아노 건반의 흰/검 배치는 균일한 물리 위에 얹힌 역사적 우연이다.

5. 스케일 — 12음을 다 쓰지 않는 이유

6. 코드 — 3도 쌓기

7. 키 — 7개 코드가 공짜로 딸려온다

8. 긴장과 해소 — V→I의 역학

9. 기보법 — 직렬화 포맷

10. 종합과 결론

12음은 소수 2와 3 사이에서 협상된 반올림 오차다. 스케일은 간격이 불균등하도록 고른 부분집합, 코드는 배음이 이미 합의한 음들, 키는 상대 주소 지정, 기보법은 헤더 달린 직렬화 포맷 — 전부 오래전 제약 아래서 내려진 평범한 엔지니어링 결정이고, 그 이유는 찾아가면 아직 살아 있다.

팩트체크
A

수학·물리 주장 전수를 직접 재계산한 결과 핵심 수치(피타고라스 콤마, 평균율, MIDI 변환)와 표준 음악이론 사실이 모두 정확하다. 결함은 사소한 반올림 오차 1건(222.99 → 실제 223.00Hz)과 단순화 서술 2건뿐 — 기술 블로그로서 신뢰도 매우 높음.

협화 = 배음렬 정렬 (3:2면 배음이 겹친다)

직접 계산으로 확인: 220Hz의 배음은 220·440·660·880·1100·1320, 330Hz(3:2)의 배음은 330·660·990·1320 — 660 = 220×3 = 330×2, 1320 = 220×6 = 330×4로 정확히 겹친다. 옥타브 주장(440의 모든 배음이 220의 배음렬에 포함: 440k = 220×2k)도 항등식으로 참. 배음 정렬 + 비트(beating)/러프니스라는 이중 설명은 헬름홀츠 이래의 표준 심리음향학과 일치한다. 단, "협화 = 귀가 반복 패턴을 빨리 찾는 것, 그게 미스터리의 전부"는 주기성 이론 하나로 단정한 단순화 — 협화 지각에는 문화적 학습 요인도 있다는 게 현대 심리음향학의 입장이지만, 입문 글의 단순화로는 허용 범위.

피타고라스 콤마: 12개 5도 ≠ 7개 옥타브, 비율 ≈ 1.0136

직접 계산: (3/2)¹² = 531441/4096 = 129.746338, 2⁷ = 128, 비율 = 531441/524288 = 1.0136433 ≈ 23.46센트. 본문의 "factor of 1.0136", "23 cents ≈ 반음의 약 1/4" 모두 정확하며, 외부 문헌의 정의(12개 완전5도가 7옥타브를 초과하는 양)와 일치. "3ⁿ = 2ⁿ⁺ᵐ은 소수 2·3의 비가환성 때문에 영원히 불가능"이라는 구조적 논증도 수론적으로 참이다. 유일한 흠: 옥타브 접기 후 도착값을 "222.99Hz"라 썼는데 재계산하면 220 × 531441/524288 = 223.0015Hz — 0.01Hz 수준의 표기 오차로 논지에는 영향 없음. "roughly what Pythagoras did"라는 귀속은 관용적 표현이며, 문헌상 콤마의 최초 기술은 기원전 300년경 위(僞)-에우클레이데스의 Divisions of the Canon이다.

Wikipedia: Pythagorean tuning Xenharmonic Wiki: Pythagorean comma
평균율: 반음 = 2^(1/12), 평균율 5도는 순정 5도보다 약 2센트 낮다

직접 계산: 2^(1/12) = 1.0594631, 2^(7/12) = 1.4983071. 순정 3/2 = 1.5 대비 1200·log₂(1.4983071/1.5) = −1.955센트(≈ 0.113% 낮음) — 본문의 "two cents flat"과 "about a tenth of a percent" 모두 정확. "12단계 후 정확히 440에 도달"도 (2^(1/12))¹² = 2이므로 구성상 참.

MIDI: note 69 = A4 = 440Hz, f = 440·2^((n−69)/12), 60 = 미들C ≈ 261.63Hz

MIDI 튜닝 표준과 일치: A4(440Hz, ISO 16 표준 피치) = note 69, 미들C(C4) = note 60은 그보다 9반음 아래. 직접 계산: 440 × 2^(−9/12) = 261.6256Hz — 본문의 261.63 반올림 정확. 예시의 note 81 = 880Hz(옥타브 위)도 2^(12/12) = 2로 참.

Wikipedia: MIDI tuning standard
왜 12음인가: 12가 5도 근사의 실용적 스윗스팟

본문의 브루트포스 결과를 재계산으로 검증: 12-EDO 최선 5도(7스텝) 오차 0.113%. 12 미만의 최선은 7-EDO의 0.93%(5-EDO 1.05%, 9-EDO 2.0%…) — "3배 이상 우수"는 실제 8배로 오히려 과소 표현이라 참. "29와 41에 가서야 5도가 개선된다"도 정확: 29-EDO 5도 오차 +1.49센트 < 12-EDO의 −1.96센트이며, 41은 log₂(3/2) = 0.58496…의 연분수 수렴렬 1/2, 3/5, 7/12, 24/41, 31/53의 다음 항이다. 본문은 연분수를 명시하지 않고 브루트포스로 같은 결론에 도달하는데, 수학적으로 동일한 결과다.

표준 음악이론 사실들 (음계 패턴·3화음·도미넌트 7th·모드)

전수 대조 결과 모두 표준과 일치: 장음계 [2,2,1,2,2,2,1](합 12), 자연 단음계 = 장음계의 5회전 [2,1,2,2,1,2,2](에올리안), 장3화음 [0,4,7], 단3화음 [0,3,7], 감 [0,3,6], 증 [0,4,8], 도미넌트 7th [0,4,7,10]. 장3화음 비율 1 : 2^(4/12) : 2^(7/12) = 1 : 1.2599 : 1.4983 ≈ 4:5:6도 정확. 다이어토닉 7화음의 장3·단3·감1 분포, G7의 트라이톤(B–F, 6반음 = √2)이 반음씩 반대 방향으로 C–E에 해소되는 서술, 리딩톤 B→C 모두 화성학 교과서 그대로다. 코드 로직(quality 판별 분기 포함)도 검산상 결함 없음.

🟡 기보법 = 직렬화 포맷 (오선·조표·박자표 서술)

핵심 사실은 정확: 오선의 세로축이 반음계가 아닌 온음계(diatonic) 축이라는 지적, 높은음자리표 = G의 양식화(G선을 감음)·낮은음자리표 = F의 양식화(두 점이 F선을 낌), 조표의 DRY 비유, 음길이 = 2의 거듭제곱 + 점 = ×1.5(겹점 ×1.75), 박자표의 강박 의미론 — 모두 표준 기보법과 일치. 다만 "두 개의 음자리표로 충분해서 둘뿐"은 단순화: 비올라의 알토 음자리표, 첼로·트롬본의 테너 음자리표 등 C 음자리표가 현역으로 쓰인다. 비유의 틀 안에서는 무해하지만 사실로서는 불완전.


원본 (완역)

프로그래머를 위한 음악이론

Luke Haas · 2026-08-17 · runjs.app/blog 원문

Music theory for programmers

나는 악기를 다룰 줄 모른다. 몇 번이나 시도해 봤지만, 매번 대충 맞는 소리를 낼 수 있게 되는 데까지만 갔을 뿐, 그게 왜 맞는 소리인지는 끝내 이해하지 못했다.

문제는 연습이 아니었다. 음악이론에 대한 모든 설명이, 어떻게 그리고 왜 지금의 모습이 되었는지에 대한 근본적인 이유를 빼먹는 것 같다는 게 문제였다. 여기 오선이 있다. 여기 그 위의 음표들이 있다. 이것이 장음계다, 패턴을 외워라. 왜 음들인가? 왜 패턴인가? 그것이 관례이기 때문이다.

본질적으로 물리학과 산수에서 나온 시스템을 가르치는 방식치고는 이상한 방식이다. 음이 12개인 데는 이유가 있다. 장음계가 지금의 모양을 갖게 된 데도 이유가 있다. 좋게 들리는 코드(화음)가 좋게 들리는 데도 이유가 있고, 그 이유는 계산할 수 있다.

그래서 나는 처음부터 시작해 제1원리로부터 음악을 배우고 싶었고, 그 여정을 코드를 쓰는 것으로 시작했다.

이 글은 그 결과물이다. 시간에 따라 변하는 숫자 하나에서 출발해서, 따라오다 보면 12개의 음을 유도하고, 배열로 음계와 코드를 쌓아 올리고, 실제 음악처럼 들리는 코드 진행을 쓰게 된다. 악기는 필요 없고, 믿음으로 받아들여야 하는 것도 없다. 악보 표기법도 등장하긴 하지만, 그것이 표기할 대상이 생긴 맨 마지막에 가서야 나온다.

소리는 시간에 따라 변하는 숫자다

소리는 그저 공기 압력의 흔들림이다. 스피커는 콘을 밀고 당겨서 소리를 내고, 컴퓨터가 오디오로 하는 모든 일은 결국 그 콘이 어디에 있어야 하는지를 초당 4만 4천 번 기술하는 숫자 목록을 만들어내는 일로 귀결된다.

오디오 파일은 그 목록을 적어 놓은 것이다. 신시사이저는 그 목록을 즉석에서 만들어내는데, 어떤 모양을 원하는지만 말해주면 그 부분은 브라우저가 대신 해준다. 가장 단순한 모양은 사인파이니, 여기 초당 440번 반복하는 사인파가 있다:

440Hz 사인파

const osc = ctx.createOscillator();
osc.frequency.value = 440;
osc.connect(out);
osc.start();
osc.stop(ctx.currentTime + 1);
Web Audio · 브라우저에서 직접 실행

ctxout은 브라우저 것이 아니라 내가 만든 것이다. 나머지는 전부 Web Audio API 그대로다. 이 스니펫을 다른 곳에서 실행하려면 다음으로 시작하면 된다:

const ctx = new AudioContext();
const out = ctx.destination;
Web Audio · 브라우저에서 직접 실행

숫자 440은 저기서 유일하게 음악적 의미를 지니는 것인데, 그마저도 임의적이다. 누군가가 "A"라고 부르기로 합의한 주파수이고, 시스템의 나머지 전체가 고정되어 있는 소리굽쇠다. 300으로 바꿔서 다시 실행해 보라. 다른 음높이가 나올 뿐 아무것도 망가지지 않는다. 이 수준에서는 아직 음표라는 게 없고, 그냥 숫자만 있기 때문이다.

주파수가 곧 음높이다: 숫자가 높으면 음도 높다. 그것이 매핑의 전부이며, 음악에 관해 이렇게 단순한 것은 이것이 마지막이다.

그 음이 왜 딸깍거렸나

방금 소리 끝에서 작은 딸깍 소리를 들었을지 모른다. 그것은 브라우저의 버그가 아니라, 물리학이 가차 없는 것이다.

오실레이터가 파형 중간에서 멈췄기 때문에, 스피커 콘이 이동 범위의 가장자리 어딘가에 나가 있다가 순간적으로 제자리로 튕겨 돌아온 것이다. 압력의 순간적인 도약, 그것이 바로 딸깍 소리라는 것이다.

해결책은 시간에 따라 변하는 두 번째 숫자인데, 이번에는 음높이가 아니라 음량을 제어한다. 음악가들은 그 모양을 엔벨로프라고 부른다:

엔벨로프를 입힌 같은 음

const osc = ctx.createOscillator();
osc.frequency.value = 440;
const env = ctx.createGain();
const t = ctx.currentTime;
env.gain.setValueAtTime(0, t);
env.gain.linearRampToValueAtTime(0.3, t + 0.01);
env.gain.exponentialRampToValueAtTime(0.001, t + 1);
osc.connect(env).connect(out);
osc.start(t);
osc.stop(t + 1);
Web Audio · 브라우저에서 직접 실행

엔벨로프는 음 하나의 음량 곡선으로, 침묵에서 출발해 다시 침묵으로 돌아간다. 앞쪽의 상승이 어택(attack)이고 그 뒤의 하강이 디케이(decay)다.

10밀리초에 걸쳐 페이드인한 다음, 거의 무음까지 천천히 감쇠한다. 그것이 테스트 톤과, 두 번은 들어줄 만한 소리의 차이다.

어택을 0.5초 근처까지 끌어 늘리면 음이 도착하기를 멈추고 부풀어 오르기 시작한다. 더는 두드려서 낸 소리가 아니라 활로 켠 소리이고, 음높이는 단 1헤르츠도 움직이지 않았다. 여기서는 엔벨로프가 주파수보다 더 많은 일을 하고 있다.

다만 이것은 단순화된 엔벨로프다. 완전판은 ADSR, 즉 어택·디케이·서스테인·릴리즈인데, 서스테인은 건반을 누르고 있는 동안 음이 유지되는 레벨이고, 릴리즈는 손을 뗀 뒤 소리가 어떻게 사라지는가다.

아래 함수는 이후의 모든 예제가 사용하는 헬퍼다:

function note(freq, start = 0, length = 0.5, type = "sine") {
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(0.3, t + 0.01);
env.gain.exponentialRampToValueAtTime(0.001, t + length);
osc.connect(env).connect(out);
osc.start(t);
osc.stop(t + length);
}
Web Audio · 브라우저에서 직접 실행

음색은 당신이 요청하지 않은 주파수들이다

사인파는 단일 주파수이고 그 외엔 아무것도 없다. 그래서 청력 검사처럼 들리고, 그 어떤 악기와도 다르게 들린다. 440Hz로 조율된 기타 줄을 튕기면 초당 440번 반복하는 파동이 나오긴 하지만, 그 줄은 동시에 절반 단위로도, 3분의 1 단위로도, 4분의 1 단위로도 진동하고 있다. 그것이 880, 1320, 1760, 그리고 그 위로 계속 이어지는 추가 주파수들이며, 전부 당신이 요청한 그 주파수 위에 얹혀 있다.

그 더미를 배음렬(harmonic series)이라고 부른다. 당신이 요청한 음이 기본음이고, 배음렬은 그 주파수에 1, 2, 3, 4, 5…를 곱해 나간 것이다:

막대들을 클릭해 보라. 하나하나만 놓고 보면 꽤 심심하다. 중요한 것은 그것들이 패키지로 도착한다는 점이고, 각 배음이 서로에 비해 얼마나 큰가 하는 레시피야말로 바이올린을 트럼펫이 아니라 바이올린처럼 들리게 만드는 것이다. 음악가들은 그것을 음색(timbre)이라고 부르며, 어느 쪽이든 같은 음이다.

브라우저에는 그런 레시피 네 가지가 기성품으로 실려 있다:

같은 음높이, 네 가지 파형

["sine", "triangle", "square", "sawtooth"].forEach((type, i) => {
note(220, i * 0.7, 0.6, type);
});
Web Audio · 브라우저에서 직접 실행

같은 220Hz인데 성격이 아주 다른 넷이다. 사각파는 홀수 배음만 담고 있어서 공허하고 살짝 전자음처럼 들린다. 톱니파는 배음을 전부 담고 있어서 거칠고 윙윙거린다. 위의 스코프는 각 파형이 재생될 때의 모양을 보여주는데, 그 모양이 배음 레시피다.

배음렬을 기억해 두라. 이제부터 이 글의 나머지 전체를 설명해 줄 참이니까. 당신이 연주하는 모든 음은 조용한 여분의 음 더미를 함께 끌고 다니는데, 그 음들이 어떤 음인지는 우리가 정하는 게 아니다. 그것은 진동하는 줄과 공기 기둥의 물리학이 고정해 놓은 산수이며, 그 위에 만들어진 모든 악기에서 똑같이 나온다.

주파수를 두 배로 하면 같은 음이 나온다

여기 다섯 개의 음이 있다. 각각은 바로 앞 음의 두 배 주파수다.

한 음, 다섯 번

[110, 220, 440, 880, 1760].forEach((freq, i) =>
note(freq, i * 0.45, 0.4),
);
Web Audio · 브라우저에서 직접 실행

서로 다른 음높이인데도, 같은 음처럼 들린다. 그냥 비슷한 게 아니라, 같다. 서로 접촉이 전혀 없던 문화들이 독립적으로 이 결론에 도달했다: 주파수를 두 배로 하면 같은 이름을 붙여 마땅할 만큼 닮은 무언가가 나온다. 서양 기보법에서 위 예제의 이 주파수들은 모두 A라고 불리며, 그 사이의 거리가 옥타브다.

이 명명은 임의적이지 않으며, 배음렬이 그 이유를 설명해 준다. 440의 모든 배음은 이미 220의 배음렬 안에 들어앉아 있다. 220의 배음렬은 220, 440, 660, 880, 1100이고 440의 것은 440, 880, 1320, 1760이기 때문이다. 높은 음은 낮은 음이 이미 만들어내고 있지 않던 주파수를 하나도 추가하지 않는다. 새로운 색이 아니라, 같은 색이 더 밝아진 것이다.

거기서 두 가지가 곧장 따라 나온다.

음높이는 덧셈이 아니라 곱셈이다. 옥타브를 올라간다는 것은 무언가를 더한다는 뜻이 아니라 2를 곱한다는 뜻이다. 110에서 220까지의 간격은 110Hz이고 880에서 1760까지의 간격은 880Hz인데, 둘은 정확히 같은 거리로 들린다. 주파수 공간은 로그적이며, 음악의 모든 음정은 비율이다.

우리는 옥타브 하나만 풀면 된다. 두 배로 하면 같은 음으로 돌아오기 때문에, "어떤 음높이들이 존재해야 하는가"라는 문제 전체가 "한 주파수와 그 두 배 사이의 공간을 어떻게 나눌 것인가"로 축소된다. 한 번만 풀면 그 답이 가청 범위 전체에 공짜로 타일처럼 깔린다.

그래서: 옥타브를 어떻게 나눌 것인가?

단순한 비율이 좋게 들린다, 그리고 그 이유는 이렇다

순진한 답은 균등하게 나누고 퇴근하는 것이다. 아무도 그렇게 하지 않는데, 우리가 모든 주파수 쌍을 같은 방식으로 경험하지는 않는다는 사실이 드러나기 때문이다. 어떤 조합은 안정되게 들리고 어떤 조합은 실수처럼 들리며, 그 차이는 즉시 들린다.

같은 음에 대한 여섯 가지 비율

const ratios = [
["2/1 octave", 2],
["3/2 fifth", 3 / 2],
["4/3 fourth", 4 / 3],
["5/4 major third", 5 / 4],
["16/15 semitone", 16 / 15],
["√2 the awkward one", Math.SQRT2],
];
ratios.forEach(([label, ratio], i) => {
note(220, i * 1.4, 1.2);
note(220 * ratio, i * 1.4, 1.2);
console.log(label, "->", (220 * ratio).toFixed(2) + "Hz");
});
Web Audio · 브라우저에서 직접 실행

처음 넷은 화음처럼 들린다. 16/15는 두 음이 말다툼하는 것처럼 들린다. 마지막 것은 자동차 경보음처럼 들린다. 그리고 한번 보고 나면 패턴은 전혀 미묘하지 않다: 분수가 단순할수록 좋게 들린다. 2/1 옥타브, 그다음 3/2 완전5도, 그다음 4/3 완전4도, 그다음 5/4 장3도, 그리고 16/15에 이를 즈음엔 완전히 무너져 있다.

"듣기 좋다"처럼 주관적인 것치고는 수상할 만큼 산술적인 결과인데, 여기엔 두 가지 물리적 이유가 있다.

첫째는 또다시 배음렬이다. 220과 330을 같이 연주해 보라. 3:2 비율이다. 첫 음은 220, 440, 660, 880, 1100, 1320을 만들어낸다. 두 번째 음은 330, 660, 990, 1320을 만들어내니, 660과 1320을 정확히 공유한다. 완전5도 떨어진 두 음은 사실 두 개의 분리된 소리가 아니라, 서로를 강화하는 심하게 겹쳐진 두 개의 더미다. 이번엔 220과 311을 시도해 보라. √2에 가까운 비율이다. 어떤 배음에서도 아무것도 맞아떨어지지 않는다. 서로 아무 상관 없는 두 개의 완전한 주파수 더미만 얻게 된다.

두 번째 이유는 거칢(roughness)이다. 두 주파수가 가깝지만 동일하지 않으면 위상이 어긋났다 맞았다를 반복하고, 음량이 맥동하는 것이 들린다. 그것이 맥놀이(beating)이고, 조율이 안 된 음을 조율 안 된 것처럼 들리게 만드는 바로 그것이다:

맥놀이 — 크게 벌어진 상태에서 동일해질 때까지

[220, 226, 223, 221, 220.5, 220].forEach((freq, i) => {
note(220, i * 1.3, 1.2);
note(freq, i * 1.3, 1.2);
});
Web Audio · 브라우저에서 직접 실행

두 주파수가 수렴하면서 흔들림이 느려지고 일치하는 순간 사라지는데, 그 흔들림의 속도는 정확히 둘 사이의 차이다. 6헤르츠 차이면 초당 6번 맥동. 분수가 복잡할 때는 두 배음 더미가 몇 헤르츠씩 차이 나는 쌍들로 어질러져 있고, 그 쌍 하나하나가 나머지 모두를 상대로 맥놀이를 일으키고 있다. 그것이 불협화음의 정체다.

위의 비율들 사이를 전환해 가며 초록 선을 보라. 두 파동을 더한 것으로, 당신의 고막이 더하는 방식 그대로다. 2:1과 3:2에서는 합쳐진 모양이 거의 즉시 반복 패턴으로 안착한다. 16:15에서는 한 바퀴 돌아오는 데 15주기가 걸린다. √2에서는 영원히 돌아오지 않는다. √2는 무리수라서, 귀가 붙잡을 패턴 자체가 아예 없기 때문이다.

협화음이란 귀가 반복 패턴을 빨리 찾아내는 것이다. 미스터리의 전부가 그것이다.

완전5도 쌓기, 그리고 고칠 수 없는 버그

이제 실제로 뭔가를 만들 수 있다. 단순한 비율이 좋은 것들이라는 걸 알았고, 옥타브 자체를 제외하면 물리학에서 가장 깨끗한 비율은 3/2, 완전5도다.

그렇다면 옥타브와 완전5도만 가지고 음악 알파벳 전체를 만들려고 하면 어떻게 될까?

뻔한 것을 해 보자: 완전5도씩 계속 올라가되, 옥타브를 벗어날 때마다 반으로 나눈다. 이것이 대략 피타고라스가 한 일이고, 한동안은 아름답게 작동한다:

옥타브 하나 안에 완전5도 쌓기

let freq = 220;
const notes = [220];
for (let i = 0; i < 12; i++) {
freq = (freq * 3) / 2;
while (freq >= 440) freq = freq / 2;
notes.push(freq);
console.log(`fifth ${i + 1}: ${freq.toFixed(3)}Hz`);
}
notes.sort((a, b) => a - b).forEach((f, i) => note(f, i * 0.22, 0.3));
Web Audio · 브라우저에서 직접 실행

첫 음과 마지막 음을 보라. 220에서 시작해 완전5도를 12번 적용했더니 222.99에 도착했다. 220이 아니다. 같은 음이 되려고 애쓰는 게 들릴 만큼은 가깝고, 쓸 수 없을 만큼은 어긋나 있다.

이 오차는 반올림이 아니라 구조적인 무언가이며, 옥타브 접기를 빼고 보면 더 잘 보인다:

닫을 수 없는 간극

const twelveFifths = (3 / 2) ** 12;
const sevenOctaves = 2 ** 7;
console.log("twelve fifths:", twelveFifths.toFixed(6));
console.log("seven octaves:", sevenOctaves.toFixed(6));
console.log("ratio:", (twelveFifths / sevenOctaves).toFixed(6));
console.log(
"in cents:",
(1200 * Math.log2(twelveFifths / sevenOctaves)).toFixed(2),
);
note(220, 0, 1.5);
note((220 * twelveFifths) / sevenOctaves, 0, 1.5);
Web Audio · 브라우저에서 직접 실행

완전5도 12개는 완전 옥타브 7개를 1.0136배만큼 초과한다. 그 간극을 피타고라스 콤마라고 부른다. 센트(cent)는 음높이 거리를 재는 단위로, 옥타브 하나에 1200센트가 있으며, 코드 안의 그 숫자가 거기서 나온 것이다. 그러니까 23센트는 인접한 두 피아노 건반 사이 간격의 약 4분의 1이고, 마지막 음 쌍에서 느리고 추한 맥놀이로 그것을 들을 수 있다.

그리고 이것은 고칠 수 없는데, 프로그래머라면 알아볼 이유 때문이다. 완전5도를 쌓는다는 것은 3/2를 곱한다는 뜻이므로, n번의 5도 뒤에는 3n / 2n에 있다. 옥타브를 쌓는 것은 2m이다. 둘이 언젠가 만나려면 3n = 2(n+m)이 필요한데, 이는 3의 거듭제곱이 2의 거듭제곱과 같아져야 한다는 뜻이다. 3과 2는 둘 다 소수다. 어떤 n에 대해서도, 영원히, 일어나지 않는다.

우리가 원하는 시스템, 즉 옥타브도 순정이고 5도들도 순정이고 모든 것이 깔끔한 고리로 닫히는 시스템은 존재하지 않으며 존재한 적도 없다. 역사상 모든 조율 시스템은 그 오차를 어디에 버릴 것인가에 대한 서로 다른 선택이다.

평균율이라는 타협

현대의 답은 무자비하면서 우아하다. 순정 비율을 완전히 포기한다. 옥타브를 가져다 12개의 균등한 곱셈 스텝으로 나누고, 옥타브를 제외한 그 무엇도 다시는 정확히 맞지 않으리라는 사실을 받아들인다.

한 스텝은 2의 12제곱근, 21/12이다:

2의 12제곱근

const semitone = 2 ** (1 / 12);
console.log("one semitone =", semitone);
let freq = 220;
for (let i = 0; i < 13; i++) {
note(freq, i * 0.2, 0.28);
freq = freq * semitone;
}
console.log(
"twelve steps later:",
(220 * semitone ** 12).toFixed(10),
);
Web Audio · 브라우저에서 직접 실행

12스텝을 밟으면 정확히 440에 착지한다. 제곱근이란 원래 그렇게 작동하니까. 그래서 옥타브는 구조상 완벽하고, 나머지 전부는 근사된다.

뻔한 질문은 왜 12인가다. 전통 때문이 아니고, 15줄 정도의 코드면 답을 직접 찾을 수 있다. 그럴듯한 모든 n에 대해 옥타브를 n개의 균등한 스텝으로 나눈 다음, 사용 가능한 최선의 스텝이 진짜 3/2 완전5도에 얼마나 가까운지 확인해 보라:

숫자 12를 브루트포스로 찾기

function bestFifth(n) {
let error = Infinity;
let step = 0;
for (let candidate = 1; candidate < n; candidate++) {
const off = Math.abs(2 ** (candidate / n) - 1.5);
if (off < error) {
error = off;
step = candidate;
}
}
return { step, error };
}
for (let n = 5; n <= 25; n++) {
const { step, error } = bestFifth(n);
const pct = (error / 1.5) * 100;
console.log(
`${n} steps: best fifth is step ${step}, off by ${pct.toFixed(3)}%`,
);
}
// And here is what those errors actually sound like.
[5, 7, 12, 19].forEach((n, i) => {
const { step } = bestFifth(n);
note(220, i * 1.7, 1.5);
note(220 * 2 ** (step / n), i * 1.7, 1.5);
});
Web Audio · 브라우저에서 직접 실행

마지막 넉 줄은 5, 7, 12, 19 분할이 만들어낼 수 있는 최선의 완전5도를 각각 같은 220Hz에 대고 연주한다. 처음 둘은 흔들리고, 셋째는 깨끗하고, 넷째는 그 중간쯤에 앉아 있다. 당신은 지금 오차 열(column)을 듣고 있는 것이다.

12는 완전5도를 약 0.1퍼센트 수준까지 맞추는 첫 번째 분할이며, 그 아래의 어떤 것보다도 세 배 이상 낫다. 24는 동점이지만, 24스텝이란 12스텝의 각 쌍 사이에 여분의 음을 하나씩 끼워 넣은 것일 뿐이라 진짜 경쟁자가 아니다. 5도가 이보다 더 좋아지려면 29와 41까지 가야 하는데, 옥타브당 건반 41개짜리 키보드를 만들 사람은 없다. 12는 설득력 있는 완전5도를 사주는 가장 저렴한 음 개수이고, 5도가 가까워지면 완전4도와 3도들도 덩달아 따라온다.

얼마나 가까운지 보자:

진짜 완전5도 대 타협안

const pure = (220 * 3) / 2;
const tempered = 220 * 2 ** (7 / 12);
console.log("pure fifth: ", pure.toFixed(4) + "Hz");
console.log("tempered fifth: ", tempered.toFixed(4) + "Hz");
console.log(
"difference: ",
(1200 * Math.log2(tempered / pure)).toFixed(2),
"cents",
);
note(220, 0, 1.5);
note(pure, 0, 1.5);
note(220, 2, 1.5);
note(tempered, 2, 1.5);
Web Audio · 브라우저에서 직접 실행

2센트 낮다. 귀 기울이면 두 번째 쌍에서 느린 맥놀이가 들리는데, 그 맥놀이는 지구상 모든 피아노에서 연주되는 모든 완전5도에 존재한다. 우리 모두는, 한 키(조)에서 완벽하고 다른 키들에서 못 쓰게 되는 것보다 모든 곳에서 조금씩 틀린 편이 낫다고 결정한 것이다.

그 보상은 음이 이제 정수가 되었다는 것이다. 아무 음이나 0번으로 고르면, 다른 모든 음은 거기서 정수 개의 반음만큼 떨어져 있다. 관례는 MIDI 번호 체계로, 69가 우리의 440Hz A이고, 변환은 한 줄이다:

이제 음은 정수다

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
console.log("60 (middle C):", midiToFreq(60).toFixed(2));
console.log("69 (the A above it):", midiToFreq(69).toFixed(2));
console.log("81 (an octave up):", midiToFreq(81).toFixed(2));
[60, 62, 64, 65, 67, 69, 71, 72].forEach((n, i) =>
note(midiToFreq(n), i * 0.25, 0.4),
);
Web Audio · 브라우저에서 직접 실행

저 마지막 줄이 장음계인데, 우리는 아직 음계가 무엇인지 정의하지도 않았다. 그저 정수 배열일 뿐이다. 여기서부터 이 글의 모든 것은 배열로 이루어지며, 나는 주파수에 대해 생각할 필요가 완전히 없어졌다.

저 키보드는 잠깐 들여다볼 가치가 있다. 그 배치는 구조인 척하는 역사적 우연이기 때문이다. 옥타브당 균등 간격의 음이 12개 있다. 그중 7개는 문자와 큰 흰 건반을 받았고, 5개는 샤프 기호와 작은 검은 건반을 받았으며, 흰 건반을 받은 그 7개가 정확히 방금 연주한 그 음계다. 밑에 깔린 물리학은 완전히 균일한데, 키보드는 그렇지 않다.

왜 12음 전부가 아니라 일부인가

음이 12개 있다고 해서 12개를 다 쓴다는 뜻은 아니다. 전부 순서대로 연주해 보면 놀랄 만큼 비음악적이다:

12음 전부, 순서대로

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
for (let n = 60; n <= 72; n++) {
note(midiToFreq(n), (n - 60) * 0.18, 0.25);
}
Web Audio · 브라우저에서 직접 실행

곡조가 아니라 효과음처럼 들린다. 모든 스텝이 동일해서 아무것도 도드라지지 않고, 아무것도 집처럼 들리지 않으며, 지금 어디쯤 와 있는지 알 방법이 없다. 그것은 멜로디가 아니라 자(ruler)다.

음악은 부분집합을 고른다. 거의 언제나 12개 중 7개를, 음 사이의 간격이 고르지 않도록 고르는데, 바로 그것이 귀로 음을 서로 구별할 수 있게 만드는 요인이다. 그 부분집합이 음계이고, 음계는 음이 아니라 음 사이의 스텝으로 적는 것이 최선이다:

음계는 간격의 목록이다

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const major = [2, 2, 1, 2, 2, 2, 1];
function buildScale(root, pattern) {
return pattern.reduce(
(notes, step) => [...notes, notes.at(-1) + step],
[root],
);
}
const cMajor = buildScale(60, major);
console.log("C major:", cMajor);
cMajor.forEach((n, i) => note(midiToFreq(n), i * 0.25, 0.4));
Web Audio · 브라우저에서 직접 실행

스텝은 2 2 1 2 2 2 1이고, 합이 12라서 패턴이 옥타브를 정확히 닫는다. 그것이 장음계, 서양 음악에서 가장 친숙한 단 하나의 소리이며, 원소 7개짜리 배열이다.

숫자 하나만 바꾸면 완전히 다른 분위기가 된다:

숫자 하나가 기쁨과 슬픔의 차이다

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, pattern) =>
pattern.reduce(
(notes, step) => [...notes, notes.at(-1) + step],
[root],
);
const patterns = {
major: [2, 2, 1, 2, 2, 2, 1],
naturalMinor: [2, 1, 2, 2, 1, 2, 2],
majorPenta: [2, 2, 3, 2, 3],
minorPenta: [3, 2, 2, 3, 2],
blues: [3, 2, 1, 1, 3, 2],
};
let when = 0;
Object.entries(patterns).forEach(([name, pattern]) => {
const scale = build(60, pattern);
console.log(name.padEnd(13), scale.join(" "));
scale.forEach((n, i) => note(midiToFreq(n), when + i * 0.22, 0.35));
when += scale.length * 0.22 + 0.5;
});
Web Audio · 브라우저에서 직접 실행

장음계와 자연 단음계는 간격만 뒤섞어 놓은 같은 7음 아이디어다. 펜타토닉 음계들은 음 두 개를 떨어뜨리는데, 그래서 그 안에서는 틀린 음을 치기가 그렇게나 어렵고, 모든 기타 입문 레슨이 거기서 시작하는 것이다. 블루스 음계는 일부러 어색한 음 하나를 도로 집어넣는다.

그리고 이제, 나를 벌떡 일으켜 앉힌 것. 외워야 할 일곱 개의 이국적인 그리스 이름으로만 제시되는 걸 늘 봐 왔던 모드(mode)가, 사실은 같은 배열을 회전시킨 것이다. 첫 스텝을 앞에서 떼어 뒤에 붙이면 다음 모드가 된다:

모드는 배열 회전이다

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, pattern) =>
pattern.reduce(
(notes, step) => [...notes, notes.at(-1) + step],
[root],
);
const major = [2, 2, 1, 2, 2, 2, 1];
const names = [
"Ionian",
"Dorian",
"Phrygian",
"Lydian",
"Mixolydian",
"Aeolian",
"Locrian",
];
const rotate = (arr, by) =>
arr.map((_, i) => arr[(i + by) % arr.length]);
names.forEach((name, i) => {
const pattern = rotate(major, i);
console.log(name.padEnd(11), pattern.join(" "));
build(60, pattern).forEach((n, j) =>
note(midiToFreq(n), i * 2 + j * 0.2, 0.3),
);
});
Web Audio · 브라우저에서 직접 실행

일곱 개의 모드, 하나의 배열, 일곱 번의 회전. 이오니안이 장음계이고 에올리안이 자연 단음계인데, 이는 "장조"와 "단조"가 두 개의 별개 시스템이 아니라 같은 것의 회전 0과 회전 5라는 뜻이다. 리디안은 몽환적으로 들리고 프리지안은 스페인풍으로 들리고 로크리안은 고장 난 것처럼 들리는데, 그 모든 것이 시작음 기준으로 어느 간격이 어디에 놓이는가를 옮기는 데서 나온다.

여기가 내가 음악이론이 자의적이라는 느낌을 그만 받게 된 지점이다.

코드는 3도로 쌓은 음들이다

코드(화음)는 동시에 울리는 둘 이상의 음이다. 대단한 정의는 못 되는 게, 대부분의 조합은 끔찍하게 들리기 때문이다. 유용한 질문은 어떤 조합이 그렇지 않은가다.

우리는 이미 비율에서 답을 알고 있다: 배음이 겹치는 음들이다. 음계 안에서 그 조건에 맞는 음들은 음계 도수로 두 단계 떨어진 음들인데, 그 간격을 음악가들은 3도라고 부른다. 그러니 음계를 가져다 시작 도수를 하나 고르고, 하나 걸러 음을 집어라:

3화음은 [0, 2, 4]다

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, p) =>
p.reduce((n, s) => [...n, n.at(-1) + s], [root]);
const cMajor = build(60, [2, 2, 1, 2, 2, 2, 1]);
const triad = [0, 2, 4].map((i) => cMajor[i]);
console.log("scale:", cMajor.join(" "));
console.log("triad:", triad.join(" "));
console.log("gaps:", triad[1] - triad[0], "and", triad[2] - triad[1]);
triad.forEach((n) => note(midiToFreq(n), 0, 2));
Web Audio · 브라우저에서 직접 실행

그것이 C 메이저 코드다. 세 개의 음이, 코드가 세워진 음 — 음악가들이 루트(root)라고 부르는 것 — 으로부터 0, 4, 7 반음 위에 있다. 주파수로는 1 : 1.26 : 1.50, 거의 정확히 4 : 5 : 6이다. 도처에서 배음을 공유하는 세 개의 단순한 비율. 산수가 견고하기 때문에 소리도 견고하게 들리는 것이다.

3도는 반음 4개(장3도)이거나 반음 3개(단3도)이고, 둘을 쌓으면 어느 쪽이든 반음 7개가 된다: 메이저는 4 + 3, 마이너는 3 + 4다.

이제 가운데 음을 반음 하나 내려 보자:

반음 하나, 완전히 다른 감정

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const chord = (shape, root, at) =>
shape.forEach((s) => note(midiToFreq(root + s), at, 1.6));
const shapes = {
major: [0, 4, 7],
minor: [0, 3, 7],
diminished: [0, 3, 6],
augmented: [0, 4, 8],
};
Object.entries(shapes).forEach(([name, shape], i) => {
console.log(name.padEnd(11), shape.join(" "));
chord(shape, 60, i * 2);
});
Web Audio · 브라우저에서 직접 실행

메이저에서 마이너로 가는 것은 음 하나가 반음 하나 움직이는 것이다. 그것이 서양 음악의 두 감정 극단 사이의 차이 전부이며, [0,4,7][0,3,7]이다. 디미니시드는 두 간격을 모두 눌러 짜부라뜨려 해소되지 않은, 불안한 소리를 낸다. 오그먼티드는 둘 다 잡아 늘려서, 영화에서 뭔가 잘못되기 직전 같은 소리를 낸다.

이 발견은 진심으로 짜증스러웠다 — 좋은 의미에서. 나는 메이저와 마이너가 심오한 범주라는 관념을 흡수하며 살아왔다. 그런데 그것들은 배열 원소 하나가 1만큼 다른 것이었다.

네 번째 음을, 3도 하나 더 위로 얹으면 7th 코드가 되는데, 여기서부터 음악이 찬송가보다는 일부러 찾아 들을 만한 무언가처럼 들리기 시작한다:

7th 코드들

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const shapes = {
"major 7th": [0, 4, 7, 11],
"minor 7th": [0, 3, 7, 10],
"dominant 7th": [0, 4, 7, 10],
};
Object.entries(shapes).forEach(([name, shape], i) => {
console.log(name.padEnd(13), shape.join(" "));
shape.forEach((s) => note(midiToFreq(60 + s), i * 2.2, 1.8));
});
Web Audio · 브라우저에서 직접 실행

메이저 7th는 재즈 카페 코드다. 마이너 7th는 부드럽고 살짝 우울하다. 도미넌트 7th가 흥미로운 놈이다. 도미넌트란 그저 음계 5번째 도수의 전통적 이름일 뿐인데, 이 코드가 이제 곧 많은 일을 하게 된다.

키 하나가 코드 일곱 개를 공짜로 준다

코드 차트가 마침내 상형문자로 보이지 않게 된 대목이 여기다.

1번 도수에서 시작하는 것에 특별할 것은 없다. 일곱 개의 도수 각각에서 차례로, 옥타브를 감아 돌며 같은 일을 하면, 오직 그 음계의 일곱 음만으로 지어진 코드 일곱 개를 얻는다:

음계 하나에서 나오는 코드 일곱 개

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, p) =>
p.reduce((n, s) => [...n, n.at(-1) + s], [root]);
const scale = build(60, [2, 2, 1, 2, 2, 2, 1]).slice(0, 7);
const names = ["C", "D", "E", "F", "G", "A", "B"];
const chordOn = (degree) =>
[0, 2, 4].map((step) => {
const i = degree + step;
return scale[i % 7] + Math.floor(i / 7) * 12;
});
names.forEach((name, degree) => {
const notes = chordOn(degree);
const shape = notes.map((n) => n - notes[0]);
const quality =
shape[1] - shape[0] === 4
? "major"
: shape[2] - shape[0] === 6
? "diminished"
: "minor";
console.log(`${name} ${quality.padEnd(11)} ${notes.join(" ")}`);
notes.forEach((n) => note(midiToFreq(n), degree * 1.5, 1.3));
});
Web Audio · 브라우저에서 직접 실행

저 성질들은 아무도 고른 적이 없다. 셋은 메이저로, 셋은 마이너로, 마지막 하나는 디미니시드로 나오는데, 그 패턴은 음계의 고르지 않은 간격이 강제한 것이다. 이웃 간격이 4 다음 3으로 벌어진 도수에서 하나-걸러-줍기를 시작하면 메이저 코드가 나온다. 3 다음 4면 마이너가 나온다.

음악가들은 그 일곱을 로마 숫자로 적는다: 메이저는 대문자, 마이너는 소문자, 디미니시드에는 작은 동그라미.

I ii iii IV V vi vii°

이 표기법은 유용한 일을 하고 있는데, 그게 무엇인지 알아채는 데 부끄러울 만큼 오래 걸렸다. 코드를 이름이 아니라 음계 안에서의 위치로 기술하는 것이다. V 코드는 어느 키에 있든 "5번째 도수 위에 지어진 코드"이며, 이것은 상대 주소 지정(relative addressing)이다. 로마 숫자로 쓰인 코드 차트는 키 독립적인 소스 코드이고, 조옮김은 상수 하나를 더하는 것이다:

로마 숫자는 상대 주소 지정이다

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, p) =>
p.reduce((n, s) => [...n, n.at(-1) + s], [root]);
function chordsInKey(root) {
const scale = build(root, [2, 2, 1, 2, 2, 2, 1]).slice(0, 7);
return (degree) =>
[0, 2, 4].map((step) => {
const i = degree - 1 + step;
return scale[i % 7] + Math.floor(i / 7) * 12;
});
}
// The same four numerals, played in two different keys.
const progression = [1, 5, 6, 4];
[60, 65].forEach((key, k) => {
const chord = chordsInKey(key);
progression.forEach((numeral, i) => {
chord(numeral).forEach((n) =>
note(midiToFreq(n), k * 7 + i * 1.6, 1.5),
);
});
});
Web Audio · 브라우저에서 직접 실행

같은 모양, 서로 다른 두 개의 시작음 — 그런데 귀는 그것을 같은 음악으로 알아듣는다. 이 표기법이 존재하는 이유 전부가 그것이다.

긴장과 해소

코드 하나는 그냥 소리다. 음악은 그것들을 순서대로 놓을 때 생기는 것이고, 그 순서가 중요하다. 어떤 시퀀스는 도착한 느낌을 주고, 어떤 시퀀스는 질문처럼 느껴진다.

시스템 전체에서 가장 강한 끌림은 V에서 I로 되돌아가는 것이며, 거기엔 두 가지 구체적인 이유가 있다.

V가 I로 돌아가려는 끌림

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const play = (notes, at, len = 1.6) =>
notes.forEach((n) => note(midiToFreq(n), at, len));
const C = [60, 64, 67]; // I
const G7 = [55, 59, 62, 65]; // V7
play(G7, 0, 1.8);
play(C, 2, 2.2);
console.log("B is", 59, "and C is", 60, "- one semitone apart");
console.log("F is", 65, "and E is", 64, "- one semitone apart");
Web Audio · 브라우저에서 직접 실행

첫째, B음은 V 코드 안에 앉아 있으면서 I의 루트보다 반음 아래에 있다. 목적지에 그렇게까지 가까운 음은 문에 기대고 있는 것처럼 들린다. 음악가들은 그것을 이끔음(leading tone)이라고 부르며, 클리프행어와 똑같은 일을 하고 있다.

둘째, 도미넌트 7th 코드는 B와 F를 둘 다 담고 있는데, 둘은 반음 여섯 개 떨어져 있다. 반음 여섯 개는 트라이톤, 정확히 옥타브의 절반이다: 2 ** (6/12)는 2의 제곱근, 앞서 들었던 바로 그 무리수 비율이다. 사용 가능한 것 중 가장 불안정한 음정이 코드 안에 들어앉아 있고, I로 움직일 때 그 두 음이 서로 반대 방향으로 반음씩 해소된다. 이 긴장은 은유가 아니다. 특정한 무리수 비율이 단순한 비율들로 대체되는 것이다.

긴장과 이완의 언어 전체가 그 메커니즘 위에 세워져 있다. 당신이 만 번은 들어 봤을 진행 몇 개와 함께 보자:

이미 알고 있는 네 가지 진행

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, p) =>
p.reduce((n, s) => [...n, n.at(-1) + s], [root]);
const scale = build(60, [2, 2, 1, 2, 2, 2, 1]).slice(0, 7);
const chord = (degree) =>
[0, 2, 4].map((step) => {
const i = degree - 1 + step;
return scale[i % 7] + Math.floor(i / 7) * 12;
});
const progressions = {
"I V vi IV": [1, 5, 6, 4],
"ii V I": [2, 5, 1],
"I vi IV V": [1, 6, 4, 5],
"vi IV I V": [6, 4, 1, 5],
};
let when = 0;
Object.entries(progressions).forEach(([name, degrees]) => {
console.log(
name,
"->",
degrees.map((d) => chord(d).join("/")).join(" "),
);
degrees.forEach((d, i) => {
chord(d).forEach((n) => note(midiToFreq(n), when + i * 1.1, 1));
});
when += degrees.length * 1.1 + 0.9;
});
Web Audio · 브라우저에서 직접 실행

I V vi IV는 팝 음악의 실로 놀라운 비율이 그 위에 지어져 있는 네 개의 코드다. ii V I는 재즈의 등뼈다. vi IV I V는 첫 번째 것과 같은 네 코드를, 좀 더 슬픈 지점에서 시작하도록 회전한 것이다.

배열을 직접 편집해 보라. 1부터 7까지의 거의 어떤 수열이든 그럭저럭 어울릴 텐데, 모든 코드가 같은 일곱 음으로 지어졌기 때문이다. 키는 오답에 도달하기 힘든 제약된 공간을 사준다. 1로 끝나면 끝난 것처럼 들리고, 5로 끝나면 다음 소절이 있는 것처럼 들리고, 7로 끝나면 뭔가 잘못된 것처럼 들린다.

기보법은 직렬화 포맷이다

여기까지 오는 동안 오선이 필요한 것은 하나도 없었다. 위의 모든 것은 정수 배열들과, 그것을 주파수로 바꾸는 함수 하나다.

하지만 기보법은 존재하고, 서양 음악의 문헌 전체가 저장되어 있는 포맷이며, 그것이 무엇을 인코딩하는지 이미 알고 나면 매우 오래된 제약들을 짊어진 꽤 합리적인 설계임이 드러난다. 그것은 직렬화 포맷이다. 인쇄가 저렴해지기 전에 만들어졌고, 손이 바쁜 사람이 실시간으로 읽는 상황에 최적화되었으며, 설치 기반(install base)이 너무 커서 한 번도 개정되지 못했다.

여기 C 장음계, 앞서와 똑같은 일곱 개의 정수가 있다:

세로축은 음높이지만, 선형이 아니다. 각 줄과 각 칸이 음계의 한 스텝 위라서, 연속한 두 위치는 어떤 때는 반음 두 개, 어떤 때는 한 개 떨어져 있다. 이 축은 반음계적(chromatic)이 아니라 온음계적(diatonic)이다: 12음 전체가 아니라 음계를 따라 밟아 올라간다. 즉, 음높이로 차려입은 음계 도수를 보여주고 있는 것이다. 그래서 장음계가 페이지 위에서는 지루한 일직선 상행처럼 보이면서 세상에서 가장 자연스러운 순서처럼 들리는 것이다. 포맷은 자신이 기대하는 케이스에 최적화되어 있다.

음자리표(clef)는 원점을 선언한다. 오선은 어떤 주파수에도 고정되어 있지 않은 다섯 줄이라서, 앞머리의 기호가 지금 어디인지 알려준다. 높은음자리표는 양식화된 G이고, 그 소용돌이가 G를 뜻하는 줄을 감싼다. 낮은음자리표는 F 줄을 사이에 둔 두 개의 점이 있는 양식화된 F다. 여백에 원점이 표시된 좌표계인 것이다:

페이지 위의 모양은 같은데 원점이 달라서, 한 옥타브 낮게 재생된다. 두 개의 음자리표가 사람들이 실제로 노래하고 연주하는 음역을 커버하며, 그래서 스무 개가 아니라 두 개인 것이다.

임시표(accidental)는 손실 인코딩을 패치한다. 옥타브당 세로 위치는 7개인데 표현할 음은 12개다. 샤프·플랫·내추럴 기호가 탈출구이며, 그 하나하나가 그 위치가 원래 뜻했을 음을 이동시키라는 명령이다:

조표(key signature)는 DRY다. 곡이 D 장조라면 그 음계에는 F 샤프와 C 샤프가 들어 있고, 곡 안의 모든 F와 C 하나하나 옆에 샤프 기호가 필요할 것이다. 그래서 대신 매 줄의 앞머리에서 한 번만 선언하고, 다른 지시가 나올 때까지 계속 적용된다. 파일 맨 위로 호이스팅된 상수다:

두 개의 샤프는 여전히 연주되고 있다는 점에 유의하라. 각 음표 옆에 적혀 있지 않을 뿐이다. 이것이 악보가 무언가를 연주하기도 전에 키부터 알려주는 이유이고, 음악가들이 곡이 어떤 키 "안에(in)" 있다고 말하는 이유이기도 하다. 키는 본문이 아니라 헤더에 있다.

음길이는 2의 거듭제곱이다. 온음표, 2분음표, 4분음표, 8분음표, 16분음표. 각각은 직전 것의 절반이고, 기보법은 그 지수를 시각적으로 인코딩한다: 속이 빈 머리, 그다음 기둥, 그다음 반감할 때마다 꼬리 하나. 이진 지수의 단항(unary) 인코딩인데, 숫자를 저장하는 방식으로는 무척 중세적이지만 한눈에 잘못 읽기가 불가능하다:

2의 거듭제곱으로 꽤 멀리 가지만 어디까지나 갈 수 있는 것은 아니라서, 연산자가 하나 더 있다: 음표 머리 뒤의 점 하나는 그 길이를 1.5배로 만든다. 점 두 개는 1.75배다. 구두점으로 쓴 이진 분수인 셈이다.

다만 이 음길이들 중 어느 것도 시간이 아니다. 그것들은 박(beat)이며, 박은 템포를 고정해야만 초가 된다:

당신이 정하기 전까지 박은 초가 아니다

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const bpm = 120;
const beat = 60 / bpm;
// [midi note, length in beats]
const melody = [
[60, 1],
[62, 1],
[64, 2],
[65, 0.5],
[67, 0.5],
[69, 3],
];
let when = 0;
melody.forEach(([n, beats]) => {
note(midiToFreq(n), when, beats * beat * 0.95);
when += beats * beat;
});
console.log("total:", when.toFixed(2), "seconds at", bpm, "bpm");
Web Audio · 브라우저에서 직접 실행

bpm을 200으로 바꾸면 같은 배열이 같은 곡을, 더 빠르게 연주한다. 그 분리가 악보가 이식 가능한 이유의 전부다. 악보는 상대적 길이를 저장하고, 시계는 연주자가 공급한다.

박자표(time signature)는 박을 묶는다. 4/4는 한 마디에 4분음표 박이 4개, 3/4는 3개라는 뜻이고, 세로 마디줄들은 눈이 페이지에서 제자리를 찾을 수 있도록 있는 것이다. 대체로는 가독성 기능이지만, 진짜 음악적 주장 하나도 담고 있다. 각 묶음의 첫 박이 강박이라는 것이다. 같은 여섯 개의 음을 셋씩 묶어 연주하는 것과 둘씩 묶어 연주하는 것은 서로 다른 곡이 된다.

이게 정말로 전부다. 원점과 탈출구가 달린 온음계적 축 위의 음높이, 2의 음수 거듭제곱으로서의 음길이, 그리고 헤더 필드 몇 개. 악보 한 페이지 위의 그 밖의 모든 것은 그 위에 얹힌 연주 지시다: 얼마나 크게, 얼마나 부드럽게, 어느 손가락으로.

한데 모으기

위의 모든 것을 한자리에 모았다. 키 하나, 그 키의 다이어토닉 코드들, 진행 하나, 그 코드들을 한 번에 한 음씩 쪼갠 아르페지오, 그리고 음계를 벗어나지 않는 멜로디. 약 40줄, 라이브러리 없음. 그리고 이것은 내가 코드로 만든 것 중에서, 시연이 아니라 음악이라고 부를 만한 첫 번째 것이다:

키 하나, 진행 하나, 그리고 곡조 하나

const midiToFreq = (n) => 440 * 2 ** ((n - 69) / 12);
const build = (root, p) =>
p.reduce((n, s) => [...n, n.at(-1) + s], [root]);
const key = 57; // A
const scale = build(key, [2, 1, 2, 2, 1, 2, 2]).slice(0, 7); // natural minor
const bpm = 104;
const beat = 60 / bpm;
const chord = (degree) =>
[0, 2, 4].map((step) => {
const i = degree - 1 + step;
return scale[i % 7] + Math.floor(i / 7) * 12;
});
const progression = [1, 6, 3, 7];
const melody = [0, 2, 4, 2, 3, 2, 1, 0, 4, 3, 2, 1, 0, 2, 1, 0];
progression.forEach((degree, bar) => {
const at = bar * 4 * beat;
const notes = chord(degree);
// Bass note on the downbeat.
note(midiToFreq(notes[0] - 12), at, beat * 3.6, "triangle");
// Arpeggio: up, down, up, across the bar.
[0, 1, 2, 1, 0, 1, 2, 1].forEach((which, i) => {
note(midiToFreq(notes[which]), at + i * beat * 0.5, beat * 0.45);
});
// Melody, four notes per bar, always from the scale.
melody.slice(bar * 4, bar * 4 + 4).forEach((step, i) => {
note(
midiToFreq(scale[step % 7] + 12),
at + i * beat,
beat * 0.9,
"triangle",
);
});
});
Web Audio · 브라우저에서 직접 실행

저 안의 모든 숫자는 우리가 유도해 낸 무언가를 뜻한다. 57이 A인 것은 2의 12제곱근과 소리굽쇠 하나 때문이다. [2,1,2,2,1,2,2]가 단음계인 것은 장음계를 다섯 자리 회전시킨 것이기 때문이다. [0,2,4]가 코드인 것은 음계 도수로 둘 떨어진 음들의 배음이 겹치기 때문이다. [1,6,3,7]이 어딘가로 향해 가는 것처럼 들리는 것은 긴장이 어디에 앉아 있는가 때문이다.

키를 60으로 바꾸면 곡이 통째로 이동한다. 음계 패턴을 [2,2,1,2,2,2,1]로 바꾸면 같은 곡조가 명랑해진다. 멜로디 배열을 그 무엇으로 바꾸든 여전히 어울릴 텐데, 주파수를 고르는 게 아니라 음계에 인덱싱하고 있기 때문이며, 바로 그 제약이 이론이 존재하는 이유인 일 전부를 해내고 있다.

내가 아직 이해하지 못하는 것

꽤 많다. 이 글은 음높이를 다뤘고 그 외에는 거의 아무것도 다루지 않았는데, 음높이는 어쩌면 쉬운 절반일지도 모른다.

리듬은 거의 건드리지 못했고, 내가 읽은 모든 것이 리듬은 보기보다 깊다고 시사한다. 성부 진행(voice leading), 즉 코드 사이를 여기저기 널뛰는 대신 가능한 최소 거리로 움직이는 일은 악보에 적힌 음악이 그저 올바른 수준을 넘어 좋게 들리기 시작하는 지점인데, 나는 그 규칙을 말할 수는 있어도 그것이 왜 통하는지는 아직 귀로 듣지 못한다. 멜로디가 왜 그 착지 지점에 착지하고 싶어 하는지는 여전히 내게 대부분 불투명하다. 그리고 위의 모든 것은 하나의 전통이 내놓은 답이다. 옥타브를 다르게 나누는 음악도, 아예 옥타브를 단위로 취급하지 않는 음악도 많으며, 그중 어느 것도 틀리지 않았다.

하지만 나는 더 이상 잡학을 암기하라고 요구받는 기분이 들지 않는다. 12개의 음은 소수 2와 3 사이에서 협상된 반올림 오차다. 음계는 간격이 길을 찾을 수 있을 만큼 고르지 않게 고른 부분집합이다. 코드는 배음이 이미 합의하고 있는 음들이다. 키는 상대 주소 지정이다. 기보법은 헤더가 달린 직렬화 포맷이다. 이 하나하나가 오래전, 제약 아래에서 내려진 평범한 엔지니어링 결정이고, 찾아 나서기만 하면 그 이유들은 지금도 살아남아 있다.

더 나아가고 싶다면, 내가 이 글을 쓰기 시작하게 만든 두 가지는 Eevee의 Music Theory for Nerds(훌륭한 읽을거리다)와, 이 주제에 관해 인터넷에서 가장 아름다운 것인 LightNote였다. API 쪽으로는 MDN의 Web Audio 문서가 이례적으로 좋다.

이 페이지의 모든 예제는 의존성 없는 순수 JavaScript라서, 브라우저 엔진이 있는 곳이라면 어디서든 전부 돌아간다. 이 실을 계속 당겨 보고 싶다면, RunJS는 아무 설정 없이 이런 코드를 아주 쉽게 실험해 볼 수 있는 JavaScript 플레이그라운드다. Web Audio API가 기본으로 제공된다. 위의 아무 스니펫이나 붙여넣고, 맨 위에 const ctx = new AudioContext()const out = ctx.destination을 추가한 다음, 거기서부터 이어 가면 된다.

(이 번역본에서는 각 코드 블록의 "실행" 버튼으로 브라우저에서 즉시 소리를 들을 수 있다 — ctx/out 준비는 자동이다.)


원본 링크 · 인용

원문에서 연결된 자료

위 링크들은 본문 완역 안의 원위치에도 그대로 유지되어 있다. 이 목록은 보조 색인이다.


기타

용어 대응

고지

원문: runjs.app/blog/music-theory-for-programmers · Luke Haas · 2026-08-17 · 한국어 완역·요약·팩트체크 재구성 · dosi.dev