TERMINAL FIELD个人站
FM-SYNTH3023 字约 8 分钟

在浏览器里造一台 6 算子 FM 合成器

发布 2026-10-15web-audio合成器react

起因很没道理:我想试试 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 种里选,改拓扑得动代码)。

BACK 回到文章列表