不跟手机比,跟 440Hz 比------零拍法验准的码道调音器
**一键开通华为云码道 CodeArts 代码智能体: **https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgithd
作品介绍
一个纯前端调音器:对着麦克风吹、唱、弹一个音,它实时报出音名(A4)、偏离标准音的音分、还有一张 FFT 频谱。但它跟满大街调音 app 的区别在于------它敢把「自己测得准不准」摊在桌面上:拿 58 个键 × 4 种波形的合成音当已知真值去测,232 个用例最大误差 1.44 音分;再用 FFT 和 Goertzel 两条独立路子交叉验证,结果 232/232 全对上。46 项测试全绿,零第三方依赖。
一、为什么做「会验准的调音器」,而不是又一个测音 app
调音器这东西,手机上一搜一大把。但我越看越不踏实:它告诉我「你现在是 A4、偏了 3 音分」,可它凭什么说自己测得准?没有一个调音 app 会告诉你它的测频算法误差是多少、拿什么对标的。它就是个黑箱,你只能信。
这跟我上一篇「会算题的烟花」是同一个毛病------AI 生成的代码跑起来像模像样,但你不知道它对不对。所以我这次想做个能自证的调音器:不光测你的音,还能拿国际标准反过来验自己测得准不准。
说白了,别人是「跟手机里那个 app 比」,我这个是「跟 440Hz 这个物理事实比」。
二、先跑起来看

上面这张是它测一个标准 A4 时的样子:半圆弧表盘、指针基本居中、中间大字 A4、下面一个「准」字,底部 64 根 FFT 频谱条里 A4 那一根峰值柱转白,右下角读数 440.42 Hz。



没麦克风也能玩------内置一排校准音源(A4=440 等),点一下就合成一个纯正弦喂进同一套分析链。还有个「验证」抽屉,把测得音和标准音一起放,听它俩的拍频(嗡---嗡---嗡那个起伏),拍频越接近 0 说明越准。
三、提示词:把「可验证」钉死到函数级
我没跟码道说「做个调音器」。我一开始就把判据钉死:测频必须是纯函数、必须能用合成正弦(已知精确频率)当真值去验、必须能报音分误差。
pitch.js: detectPitch(buffer, sampleRate) 纯函数,不读时钟不用随机。
scale.js: freqToNote(f) 用十二平均律 n=69+12·log2(f/440),返回 {name,midi,cents}。
groundtruth.js: 58 键平均律合成真值表,显式标注 A4=440 合成(非钢琴实测)。
测试:合成 440Hz 正弦喂 detectPitch,断言音分误差 < 5 cent。
「< 5 音分」这条是被逼出来的。第一版我写的是「误差 < 1%」,听着挺严,其实 1% ≈ 17 音分------人耳一听就知道跑调了,那还叫什么调音器。这是我拉专家评审时被技术专家当场点破的(第七节讲)。
四、架构:三锚护城河 + 单向分层
src/
pitch.js McLeod NSDF/MPM + CIP 消倍频测基频(纯函数)
scale.js 十二平均律 freqToNote / noteFreq
groundtruth.js 58 键平均律合成真值表(A4=440)
beat.js 零拍法:拍频 = |f − fref|,离线标定锚
fft.js radix-2 FFT(频谱显示 + 交叉验证)
audio.js 麦克风 + 合成校准音源(无麦兜底)
render.js 半圆弧表盘 + 音分刻度 + FFT 频谱条
app.js 状态机 + 主循环 + 零拍验证抽屉
我管它叫「三锚」:① 合成音律真值表(已知频率,验检测器);② 零拍法(测得音和标准音的拍频,一个独立于算法的物理现象);③ FFT / Goertzel 交叉验证(换一条完全不同的数学路子,看是不是同一个频率)。三条路都指向同一个答案,才敢说「测得准」。
五、核心算法:怎么测、怎么不测错八度
音名映射是十二平均律,A4=440 为基准,偏多少音分一目了然:
js
export function freqToNote(f) {
if (!f || f <= 0 || !Number.isFinite(f)) return { name: '-', midi: 0, cents: 0 };
const n = 69 + 12 * Math.log2(f / 440);
const midi = Math.round(n);
const cents = (n - midi) * 100;
const name = NOTE_NAMES[((midi % 12) + 12) % 12] + (Math.floor(midi / 12) - 1);
return { name, midi, cents };
}
测频这里有个大坑:普通自相关(ACF)会把 220Hz 的音报成 440Hz ------因为一个周期的波形和隔一个周期的波形长得一样,ACF 第一个峰经常落在倍频上。对调音器来说这是致命错误(你弹 A3 它报 A4)。所以我让码道改用 McLeod 归一化自相关函数(NSDF/MPM)+ CIP 判据:在所有过阈值的峰里选清晰度最大的、再在并列里选最短的 lag,专门治这个八度错误。
js
// NSDF: d(τ) = 2·r(τ) / m(τ),m 含 (N−τ) 无偏修正
// CIP: 过阈峰里取 clarity 最大;clarity ≥ 0.9·max 的峰里取 lag 最小(消八度)
export function detectPitch(buffer, sampleRate, options = {}) { /* ... */ }

零拍法是个很「物理」的锚:
js
export function beatCalibrate(f, fref) {
const beatHz = Math.abs(f - fref); // 拍频,人耳能直接听出来
const cents = 1200 * Math.log2(f / fref); // 对应音分
return { beatHz, cents };
}
两个几乎同频的音一起放,会听到「嗡---嗡---」的拍,拍频就是它们的差。这是模拟的、独立的、连算法都不用的验证------虽然严格说它还是拿合成真值当基准,不是凭空多一个第三方,这点我在文章里也不吹。
六、可验证:232 个用例,最大 1.44 音分
这是整个项目我最满意的地方。evidence 脚本拿 58 个键(C2 到某个高音)× 4 种波形(纯正弦、含 2/3 次谐波、锯齿、类单簧管混合)合成已知频率,喂进测频器,对答案:
=== Evidence Report ===
Keys: 58 × 4 wave types = 232 cases
Max cents error: 1.443
|cents|<5 pass: true
FFT consistent: 232/232 (100.0%) pass=true
Goertzel consistent: 232/232 (100.0%) pass=true
Deterministic: true
SHA-256: 7d67796c48cc4468ac310ad95d81b824767aa01f7ecf33bcc03478a04228b0d4
232 个用例,最大音分误差 1.44,全部 <5;FFT 和 Goertzel 两条独立路子 232/232 都和主测频对上;同参数跑两遍逐帧一致(确定性);带一个 SHA-256 指纹,参数一改指纹就变。关键是它不只测纯正弦------谐波、锯齿、类单簧管这些真实乐器波形都测,否则就是拿最简单的情形糊弄测试。

七、真实的坑:三轮专家评审把我骂醒了
这项目最值钱的不是代码,是我给它拉了个 UI / 技术 / 产品三个 AI 专家组成的评审团,连审三轮。第一轮打分惨不忍睹:技术 62、UI 18、产品 83。
技术专家一上来就给了我一闷棍:「你的 ACF 会把 220Hz 报成 440Hz,我实测复现了。」 这就是我上面说的八度错误,第一版根本没测、真犯了。它还点破:clarity 是伪置信(噪声下测偏 5.8% 却显示高置信)、1% 容差=17 音分根本不算准、测试只喂纯正弦是躲开真实失效模式。产品专家说你和烟花「同源」(都是对拍真值),得区分------烟花是「自己对自己」,调音器要「跟世界对答案」。UI 专家说渲染层压根还没做,18 分。
第二轮我按这些改(MPM+CIP、<5音分、多波形真值、三锚、零拍、半圆弧表盘),分数爬到 85/86/88。第三轮又逼出更细的:零拍法低频要长窗不能追实时、真钢琴有不谐波和拉伸调律(所以对标合成真值而非实测钢琴)、稳态门(颤音/起音那几帧别当真)。最后 88/90/88,一致「可发布」。



说实话,没有这三轮互骂,我大概率会带着「ACF 报倍频」这个致命 bug 就发出去了。AI 写代码快,但「它到底对不对」这种问题,得有人(或另一个 AI)专门来挑刺。
八、测试:46 项全绿
测频、音名映射、倍频回归、真值对标、零拍、FFT、坏输入不抛,46 项,每一轮我都 clone 到本地独立 node --test 复核。

九、提效数据
| 环节 | 纯手写估摸 | 码道实际 |
|---|---|---|
| 测频 DSP + 音律 + 46 测试 | 两三天 | 约 3 小时(五轮) |
| FFT + Goertzel + 零拍 + evidence | 一天 | 一轮 |
| 半圆弧表盘 + 频谱 UI | 一天 | 一轮 |
我逐字敲的代码不到一成,但真正的功夫在验收和那三轮评审------倍频 bug、容差口径、自证循环,全是评审揪出来我才改的。
十、还没做好的地方(不装)
-
零拍法目前是离线标定锚,低频音分精度要秒级窗,没做到实时;
-
真钢琴的不谐波和拉伸调律没建模,对标的是平均律合成真值,不是「上台给真钢琴调音」;
-
颤音、起音瞬态的稳态门是粗判,复杂真实演奏下置信度还会抖。
十一、五维自检
架构:三锚 + 单向分层,测频/音律/真值/验证解耦;代码:46 测试 + 232 例最大 1.44 音分 + 确定性 + 指纹;安全:麦克风音频只在本地 AnalyserNode 分析、绝不上传、零联网零依赖;UI:克制深色 + 半圆弧表盘 + FFT 频谱 + 零拍抽屉;内容:真实截图 + 真实代码 + 真跑出来的误差 + 三轮评审的诚实复盘。
十二、本地怎么跑


git clone https://atomgit.com/azhiqiu/tuner-pro.git
cd tuner-pro
node --test # 46 项全绿
node tools/evidence.cjs # 232 例测频对标真值 + 交叉验证 + 指纹
npm run serve # 打开 http://localhost:8082
打开后建议亲手试三下:点「校准音源」放个 A4 看指针居中标「准」;开「验证」抽屉听零拍;对着麦克风唱个音看它报音名和音分。
写在最后
上一篇烟花我学会的是「让程序自己算答案再对答案」;这一篇我多学了一招------让另一群 AI 来挑你的刺。三轮 UI/技术/产品互评,把一个带着致命倍频 bug 的半成品,逼成了一个敢把误差摊在桌上的调音器。AI 负责快和像模像样,「对不对」这件事,永远得有个较真的人(或另一个 AI)盯着。
数据与致谢:音律基准 A4=440Hz、十二平均律;开发工具 华为云码道 CodeArts 代码智能体(AtomGit)。仓库 MIT 开源,地址就是上面 clone 命令那个。
你做工具类项目时,有没有想过「怎么证明它自己是对的」?还是跟我一样,被 AI 专家评审团骂了三轮才想明白?评论区聊聊。