在浏览器里造一台 6 算子 FM 合成器
起因很没道理:我想试试 FM 合成,但不想装软件。
硬件 FM 合成器我摸过,那种「调一个参数要翻三层菜单」的操作把人劝退。 而浏览器里有一个现成的、低延迟的音频时钟——Web Audio。那就干脆自己写一台。
FM 到底在调什么
FM 合成听起来玄,核心其实一句话:用一个振荡器的输出,去改另一个振荡器的频率。
- 载波(Carrier):你真正听到的那个音。
- 调制器(Modulator):不发声,只负责去推载波的频率。
- 算子(Operator):一个「振荡器 + 包络」的打包。载波和调制器都是算子。
调制器推得越猛,边频(sideband)越多,音色就越亮、越金属。 这就是为什么 FM 天生擅长电钢琴、钟声、金属打击——那些频谱上一堆非谐波成分的声音。
「6 算子」意味着有六个这样的单元,可以互相调制。它们的连接方式(算法)决定了音色性格。 两个算子串起来是基础款;五个调制器全去推一个载波,就是那种又亮又吵的铜管味。
代价是明确的:8 复音 × 6 算子 = 48 个振荡器。这跟 DX7 那类真机是一个量级, 桌面浏览器扛得住,手机上就别想了。
12 种算法
算法就是一张「谁调制谁」的边表。现在有 12 种:
| 算法 | 拓扑 | |
|---|---|---|
| 0 | 串联 | 1→2→3→4→5→6 |
| 1 | 串并混合 | 6 + 1→2→3→4→5 |
| 2 | 双串 | 1→2→3 + 4→5→6(载波是 Op2 与 Op6) |
| 3 | 树形 | 1→2→6 + 3→4→6 + 5→6 |
| 4 | 全并联 | 1+2+3+4+5 → 6 |
| 5 | 平行 | 1+2+3+4 → 6(Op5 未接入) |
| 6 | 反馈 | 1→2→6 + 3→4→6 · 6↔1 |
| 7 | 环形 | 1→2→3→4→5→6→1 |
| 8 | 扇出 | 1→4+6 + 2→4 + 3→4 + 5→6 |
| 9 | 汇聚 | 1→2 + 3→2 + 2→6 + 4→6 + 5→6 |
| 10 | 深树 | 1→2→3→6 + 4→6 + 5→6 |
| 11 | 三载波 | 1→4 + 2→5 + 3→6 |
前面八种是从 4 算子继承下来的,名字和下标都没动(预设和存档按索引引用它们, 动了就等于让所有老存档集体变调),只是拓扑拉长、载波搬到了 Op6。
后四种是 6 算子才画得出来的。光「两个载波外加一个共用的调制器」就要 6 个算子, 4 算子下这个想法连拓扑都写不出来。「三载波」是管风琴音栓的加法结构—— 三组互不相干的「调制器→载波」,预设里的管风琴就靠它。
为什么要做成单文件
这个决定影响了一堆工程选择。
产物是一个自包含的 index.html:HTML、CSS、JS 全在里面,双击就能用,
丢到任何静态托管上都能跑,不需要服务器、不需要构建环境。
代价是构建配置要专门伺候。vite-plugin-singlefile 负责把资源内联进去,
但 Web Audio 必须在用户交互之后才能启动——浏览器的自动播放策略不允许页面一加载就出声。
所以「点一下才能开始」不只是设计选择,是硬约束。
顺手的好处也很实在:整个合成器可以当一个普通文件发出去。别人不需要 npm install,
双击就有一台合成器。
功能列表
| 模块 | 做了什么 |
|---|---|
| 键盘 | 25 / 37 / 49 / 61 键可切换,映射到电脑键盘 |
| 音序器 | 步进网格,每段可以有自己的速度倍率 |
| 琶音器 | 按住和弦自动拆成音符流 |
| Song 模式 | 把多个图案串成一条链,按顺序播放 |
| 音阶锁定 | 把音高吸附到选定音阶,乱按也不会跑调 |
| 导出 | 把演奏结果渲染成 WAV 下载 |
调参数时最烦的是「改一下、听一下」的循环被打断。所以 ADSR 是画出来的—— 直接拖包络曲线,而不是调六个数字。同理,力度曲线也是画的。
从 4 算子改到 6 算子
真正花时间的不是加两个算子,是把散在七八个文件里的那个「4」收拢成一个常量。
之前 4 写在引擎的建声部循环里、算法表里、预设的类型元组里、算子面板的渲染列表里、
拓扑图的列坐标里、皮肤的配色数组里。任何一处漏改都不会报错,只会变成:
面板少一张卡 / 某个算子怎么调都不出声 / 存档清洗时悄悄把两个算子的参数丢掉。
所以现在 spec.ts 里只有一个 OP_COUNT,别处一律从它推导——这本来就是那个文件
存在的理由(它是参数范围的唯一数据源),我只是把算子数也放了进去。
四个「恰好是对的」的 bug
改的过程中最有意思的部分:有四处代码写着 ops[3]。
在 4 算子下它们全都恰好是对的——Op4 恰好是当时大多数算法的载波。
到 6 算子下就全错了,而且一个都不报错:
- 试听时长读
ops[3]的包络。「双串」的载波变成了 Op2 和 Op6、「三载波」有三个, 读到的可能是根本没参与输出的中间调制器 → 点完预设音色还没响完就断了。 - 抢声判据用
ops[3].r算声部什么时候算「彻底结束」。调制器的释音比载波长时, 声部会在尾巴还在响的时候被整只抢走 → 听感上是音被硬切。 setOps不补齐。老存档只有 4 个算子,noteOn按ops[5]取值拿到undefined, 整条抛掉 → 表现是「载入老音色之后按任何键都没声音」。setAlgorithm不验表。存档被手改成越界的算法下标时,连线时去读undefined.edges→ 异常冒到noteOn外面,还是「没声音」。
这四个的共同点值得记下来:它们在旧世界里都是对的。
不是「写错了」,是「对的前提被换掉了」。所以修法不是逐个打补丁,
而是让「载波是谁」变成一个可以问出来的事实(carriersOf(algo)),
而不是一个被各处写死的下标。
不接线的算子要老实说
「平行」和「反馈」都有意不接全部算子(真机也有这种算法)。 以前这种算子在面板上照样被打上「调制器」角标,可它根本不接在音频图上—— 用户会去拧一个永远不响的旋钮,而且没有任何提示告诉他那是白拧。
现在角色多了一档 off,面板显示「未接入」,整块压暗标红。
一个不响的旋钮本身不是问题,假装它能响才是。
预设是重写的,不是补零的
最容易偷懒的做法是把旧的 4 个算子原样留着,后面补两个默认值。 但 6 算子算法里载波普遍搬到了 Op6,于是载波位会拿着调制器的参数—— 音色会明显不对,而且不报错。14 个预设全部按各自算法的 6 算子角色重写了。
守门
算法表是纯数据。改坏了不会有任何编译期报错,只会得到一个不发声或调不动的算法。
(历史上就漏过一次:「反馈」的 roles 标了载波,边表里却没有对应的边,
于是「反馈量」调多大都听不出来。)
所以补了三道:
areEdgesValid()—— 边表的自检,setAlgorithm连线前先过这一关,坏了退回串联;- 预设算子数断言 —— 元组类型已经让「少写一项」变成编译期错误,这是最后一道;
- 冒烟检查逐条核对 12 种算法的边是否真的落到了 Web Audio 图上 ——
每条调制边都要连到目标算子的
frequency,每条输出边都要连到母线, 而且标了off的算子绝不能碰到母线。
最后这条当场抓出了我自己写的两处错误:「双串」的载波我标成了 Op3 和 Op6, 实际是 Op2 和 Op6。新加的不变量测试先抓到了我自己。 这大概就是为什么值得先写测试再改表。
那些踩过的坑
写这个的过程中,真正花时间的从来不是音频算法:
- 画布塌陷:ADSR 的画布容器高度依赖父级,某次改布局之后它变成了零高。 这类问题不会报错,只是「什么都看不见」。
- 切预设时的残留:换预设之后,上一个预设的力度曲线还留在网格里。 根因是状态没跟着预设走,而不是渲染错了。
- 拖一下滑块就不再是「某个预设」:这其实是行为而不是 bug—— 只要动过任何参数,声音就不再等于那个预设了,所以标记必须清掉。 一开始我把它当 bug 修,方向就是反的。
- Windows 上的换行符:仓库里加了
.gitattributes钉死 LF。 不加的话,某些带快照的测试会在 Windows 上假失败——测试逻辑没错,是行尾在捣乱。 后来给博客仓也补了同一条:那里的受害者是deploy.sh,CRLF 会让set -euo pipefail\r在/bin/bash上报 command not found, 而且是在已经连上服务器之后才炸。
还有一条经验值得单独说:破坏性操作都要确认。 「清空音序器」「随机生成」「删除段落」这几个按钮,一开始点下去就没了。 后来全部加了二次确认,因为后悔的成本远高于多点一下的成本。
验证
音频代码没法靠肉眼验证,所以除了单元测试,还专门有个桩件冒烟检查:
npm run typecheck # tsc --noEmit
npm test # vitest run
npm run check:engine # Web Audio 桩件冒烟
check:engine 用一个假的 AudioContext 跑一遍引擎的关键路径,
专门用来抓「Node 环境里跑得通、浏览器里炸」的那类错误。
6 算子改造之后测试从 753 个涨到 794 个。新增的大头不是「新功能」,
而是不变量:每个算法的 roles / edges / visual 三者对不对得上、
每个调制器能不能沿边走到主输出、孤立算子与 off 档是否双向对上。
算法表越复杂,越需要靠不变量把它钉死。
现在能做什么
进 /synth/ 就能用,不需要注册、不需要联网。
还想加的东西:MIDI 输入、把预设真正存到 URL 里让音色可以分享一个链接、 以及给算法加一个可视化编辑器(现在只能在 12 种里选,改拓扑得动代码)。