面向程序员的音乐理论
摘要
本文从第一性原理出发,探讨如何用编程理解音乐理论,展示声音如何被表示为数字,并通过代码片段构建音阶与和弦。
<p><a href="https://lobste.rs/s/zcgvtz/music_theory_for_programmers">评论</a></p>
查看缓存全文
缓存时间: 2026/08/21 14:56
# 程序员的音乐理论指南
来源:https://runjs.app/blog/music-theory-for-programmers
我不会演奏乐器。尝试过不止一次,每次都只能制造出大致正确的声响,却始终不明白这些声响为何“正确”。问题从不在于练习,而在于几乎所有音乐理论的解释都忽略了事物存在方式与存在原因的根本逻辑。
这是一段乐谱,这些是音符。这是一个大调音阶,记住这个模式。但*为什么*是这些音符?*为什么*是这个模式?因为这就是惯例。对于一个本质上源于物理与算术的系统而言,这种教学方式相当奇怪。十二个音符的存在有其原因,大调音阶的结构有其逻辑,悦耳的和弦之所以悦耳也有其缘由——这些原因都是可计算的。
因此,我决定从零开始,通过编写代码从第一性原理学习音乐。本文便是这个过程的成果:从一个随时间变化的数字开始,你将推导出十二个音符,用数组构建音阶与和弦,并编写出一段听起来像真正音乐的和声进行。无需乐器,无需盲目接受任何规则。记谱法会出现在文末——但直到有东西需要被记录时才会登场。
## 声音是一个随时间变化的数字
声音只是空气压力的振动。扬声器通过推拉振膜发声,计算机处理音频的所有操作都归结为:每秒生成四万四千个描述振膜位置的数字列表。音频文件就是这个列表的记录,而合成器会在运行时动态生成它——只要你指定波形,浏览器就能帮你完成。最简单的波形是正弦波,以下是一个每秒重复440次的正弦波示例:
**440Hz的正弦波**
```javascript
const osc = ctx.createOscillator();
osc.frequency.value = 440;
osc.connect(out);
osc.start();
osc.stop(ctx.currentTime + 1);
```
其中`ctx`和`out`是自定义变量,其余部分均为Web Audio API(https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)的标准用法。若要在其他环境中运行此代码,需先初始化:
```javascript
const ctx = new AudioContext();
const out = ctx.destination;
```
数字440是此例中唯一具有音乐意义的参数,但它本身是任意的——它只是被约定为“A音”的频率,也是整个调音系统的基准点。将其改为300后重新播放,会得到不同的音高,且系统不会崩溃,因为在此层级上尚不存在音符概念,只有数字。频率即音高:数字越大,音高越高——这是音乐世界中最后一次如此简单的对应关系。
### 那个“咔哒”声从何而来?
你可能在结尾听到了轻微的咔哒声。这并非浏览器缺陷,而是物理法则的无情体现:振荡器在停止时正处于波形中间,振膜瞬间回到初始位置,压力突变产生了“咔哒”声。解决方案是引入第二个随时间变化的数字——它控制音量而非音高。音乐家将其变化曲线称为“包络”:
**带包络的相同音符**
```javascript
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);
```
包络是单个音符的音量曲线:从寂静到寂静的过程。起始部分的上升称为“起音”,随后的下降称为“衰减”。10毫秒的快速起音加上缓慢衰减至近零,这是测试音与悦耳音色的本质区别。若将起音延长至0.5秒,音符会从“敲击感”变为“绵长感”,而音高始终未变——此时包络的作用已超越频率本身。
不过这是简化版的包络模型,完整版称为ADSR(起音、衰减、延音、释音):延音是按键保持时的稳定音量,释音是松开按键后的渐弱过程。以下是后续示例都会用到的辅助函数:
```javascript
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);
}
```
## 音色是你未曾要求的频率
正弦波只包含单一频率,因此听起来像听力测试而非任何乐器。拨动440Hz的吉他弦时,虽然产生每秒重复440次的波形,但琴弦同时也在以1/2、1/3、1/4长度振动,产生880Hz、1320Hz、1760Hz等额外频率——这些频率叠加在基频之上,形成“泛音列”。
基频是音符的本质,泛音列则是该频率的1倍、2倍、3倍、4倍等序列:
**1x** 220Hz
**2x** 440Hz
**3x** 660Hz
**4x** 880Hz
**5x** 1100Hz
**6x** 1320Hz
**7x** 1540Hz
**8x** 1760Hz
这是220Hz的泛音列。点击柱状图可单独试听各泛音。单独听时它们都很单调,但它们总是以“组合包”形式出现——各泛音的相对响度配方决定了小提琴与小号的音色差异。这种配方就是“音色”,而音高始终相同。
浏览器内置四种波形配方:
**相同音高的四种波形**
```javascript
["sine", "triangle", "square", "sawtooth"].forEach((type, i) => {
note(220, i * 0.7, 0.6, type);
});
```
相同的220Hz,四种截然不同的性格:方波只含奇次泛音,因此带有空洞的电子感;锯齿波包含所有泛音,听起来刺耳且带有嗡嗡声。上方示波图展示了各波形的形状——这个形状*就是*泛音配方。记住泛音列的概念,因为它将解释本文所有后续内容。每个音符都会携带一整列额外的微弱音符,而这些音符的身份不由我们决定——它们由振动琴弦与空气柱的物理定律决定,在所有基于此原理的乐器上都完全相同。
## 频率加倍产生相同音符
以下是五个音符,每个音符都是前一个的两倍频率:
**一个音符,五次加倍**
```javascript
[110, 220, 440, 880, 1760].forEach((freq, i) => note(freq, i * 0.45, 0.4));
```
它们音高不同,却听起来*像同一个音符*——不是“相似”,而是“相同”。互无接触的文明都独立得出了这个结论:频率加倍会产生值得使用相同名称的声音。在西方记谱法中,上述示例中的所有频率都被称为“A”,它们之间的距离称为“八度”。这种命名绝非任意,泛音列解释了原因:440Hz的所有泛音都已存在于220Hz的泛音列中(220的泛音列是220, 440, 660, 880...;440的是440, 880, 1320, 1760...),高音并未增添低音未曾产生的频率——这不是新色彩,而是同种色彩的提亮。
220Hz与440Hz,在每个低音周期重复的波形
由此直接推导出两个结论:
1. 音高是乘法关系而非加法关系——上行八度意味着乘以2而非加某个数。110到220的差值是110Hz,880到1760的差值是880Hz,但听觉距离完全相同。频率空间是对数尺度的,音乐中的每个音程都是比值关系。
2. 我们只需解决一个八度的问题——因为频率加倍会返回相同音符,整个“哪些音高应该存在”的问题就简化为“如何分割频率与双倍频率之间的空间”。解决一次即可自动覆盖整个可听范围。
那么:如何划分一个八度?
## 简单比值悦耳,原因在于此
天真做法是平均分割后收工。没人这么做,因为我们对不同频率组合的体验并不相同:有些组合和谐,有些则像错误,这种差异立刻能被感知。
**六组比值对比同一个音符**
```javascript
const ratios = [
["2/1 八度", 2],
["3/2 五度", 3/2],
["4/3 四度", 4/3],
["5/4 大三度", 5/4],
["16/15 半音", 16/15],
["√2 尴尬值", 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");
});
```
前四组听起来像*和弦*,16/15像两个音符在争吵,最后一个像汽车警报。规律很明显:**分数越简单,听感越悦耳**。八度(2/1)最和谐,接着是五度(3/2)、四度(4/3)、大三度(5/4)...到16/15时已彻底崩坏。对于“悦耳”这种主观体验,这个算术结果显得可疑,背后有两个物理原因:
**原因一仍是泛音列**。同时弹奏220和330(3:2比值),第一个音符产生220, 440, 660, 880, 1100, 1320,第二个产生330, 660, 990, 1320——它们在660和1320处完全重合。相差五度的两个音符并非独立声音,而是高度重叠的泛音堆叠,彼此强化。
现在试听220和311(接近√2比值)——在任何泛音处都对不上,你得到两组毫无关联的频率堆。
**原因二在于“粗糙感”**。当两个频率接近但不相同时,它们的相位会不断漂移,产生音量脉动(称为“拍频”)。这正是走音音符令人不适的原因:
**拍频:从相远到相同**
```javascript
[220, 226, 223, 221, 220.5, 220].forEach((freq, i) => {
note(220, i * 1.3, 1.2);
note(freq, i * 1.3, 1.2);
});
```
随着两个频率靠拢,颤动速度减慢;完全匹配时颤动消失。颤动速率恰等于频率差值:差6Hz则每秒脉动6次。当比值复杂时,两组泛音中会存在多对相差几赫兹的频率,每对都在相互拍频——这就是“不协和音”的本质。
220Hz与440Hz,在每个低音周期重复的波形
切换上方的比值组合,观察绿色曲线(两列波形叠加的结果,模拟鼓膜接收到的声波)。2:1和3:2的组合波形几乎立刻形成重复模式,16:15需要15个周期才回归,√2永远无法形成模式(因为它是无理数)。**协和音的本质是你的耳朵快速找到了重复模式**——这就是全部奥秘。
## 五度叠加,一个无法修复的缺陷
现在我们可以构建实质内容了。已知简单比值是好的,八度之后最纯净的比值是3/2(五度)。那么如果只用八度和五度构建整个音高体系会怎样?显而易见的做法是:不断上行五度,超出八度时减半。这大致是毕达哥拉斯的方法,初期效果很好:
**八度内的五度叠加**
```javascript
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(`五度 ${i + 1}: ${freq.toFixed(3)}Hz`);
}
notes.sort((a, b) => a - b).forEach((f, i) => note(f, i * 0.22, 0.3));
```
观察首尾音符:从220Hz开始,叠加12个五度后得到222.99Hz——接近到听起来像同一个音符,但偏差大到无法使用。这种误差并非舍入问题,而是结构性缺陷,去掉八度折叠后更明显:
**无法弥合的缝隙**
```javascript
const twelveFifths = (3/2) ** 12;
const sevenOctaves = 2 ** 7;
console.log("十二个五度:", twelveFifths.toFixed(6));
console.log("七个八度:", sevenOctaves.toFixed(6));
console.log("比值:", (twelveFifths/sevenOctaves).toFixed(6));
console.log("音分差:", (1200 * Math.log2(twelveFifths/sevenOctaves)).toFixed(2));
note(220, 0, 1.5);
note((220 * twelveFifths)/sevenOctaves, 0, 1.5);
```
十二个完美五度比七个完美八度高出1.0136倍,这个差值称为“毕达哥拉斯音差”。“音分”是音高距离的度量单位,一个八度包含1200音分。23音分约为相邻钢琴键间距的四分之一,在最后两个音符的慢速刺耳拍频中清晰可辨。
这个问题无法修复——程序员应该能理解:五度叠加意味着乘以3/2,n次后得到`3^n / 2^n`;八度叠加意味着`2^m`。两者相遇需要`3^n = 2^(n+m)`,即3的幂等于2的幂。由于3和2都是质数,这在任何n值下都不可能成立。
我们想要的系统——八度纯净、五度纯净、完美闭环——从未存在过。历史上所有调音体系都是对误差分配方式的不同选择。
## 平均律是妥协的产物
现代方案既残酷又优雅:彻底放弃纯律比值。将一个八度均分为12个*等比*音程,接受除八度外所有音程都不精确的事实。一个音程的比值是2的十二次方根:
**2的十二次方根**
```javascript
const semitone = 2 ** (1/12);
console.log("一个半音 =", semitone);
let freq = 220;
for (let i = 0; i < 13; i++) {
note(freq, i * 0.2, 0.28);
freq = freq * semitone;
}
console.log("十二步后:", (220 * semitone**12).toFixed(10));
```
十二步恰好到达440Hz——因为这是根号运算的特性,八度是构造性纯净的,其他音程都是近似值。
那么为什么是十二?这不是传统,你可以在15行代码内找到答案:
**暴力测试数字十二**
```javascript
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}步: 最佳五度是第${step}步, 偏差${pct.toFixed(3)}%`);
}
// 这些误差实际听起来如何
[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);
});
```
最后四行播放5、7、12、19等分八度时能得到的最佳五度,均与220Hz基音对比。前两者有颤动,第三个干净,第四个居中——你听的是误差列。十二等分能在简洁数字(12个音符)与听觉准确性之间达到平衡,这就是它被采用的原因:并非完美,而是最不糟糕的妥协。
相似文章
和声解析:迈向音乐科学理论的进展(2012)
本文从物理学和计算的基本原理推导出大调音阶、标准和弦词典以及大三和弦与小三和弦之间的区别,提出了一种科学的和声理论,解决了亥姆霍兹(Helmholtz)和特哈特(Terhardt)早期理论中的局限性。
深入解析:构建实时和弦识别器
本文解释了实时和弦识别器的技术架构,详细介绍了使用音级位掩码、候选生成、分数归一化和音乐启发式的四阶段流水线。
面向程序员的逻辑学 v0.15,现场编程
Hillel Wayne 宣布其著作《Logic for Programmers》的 0.15 候选版本,并重点介绍了一场以 Strudel 和 CLAVIER-36 为特色的现场编程聚会,用于音乐编程。
以理论构建的视角阅读编程
本文推荐 Peter Naur 的著作《编程即理论构建》,主张编程的本质在于构建和传达对软件的心理模型,而不仅仅是编写代码。
Computer Science Off Course - 编程作为理论构建
本播客集讨论了Peter Naur的经典论文'Programming as Theory Building',探讨其对人工智能、LLM使用和计算机科学教育的影响。