用代码造一台三角钢琴:从尺寸一路算到声音 Building a Grand Piano in Code: From Dimensions to Sound
钢琴弹响一个音,最关键的那一步你看不见。
按下琴键,琴槌往上走。离弦还剩 1.7 毫米的时候,一直托着它的顶杆被一颗毡按钮挡住,升不上去,只能转,于是从槌轴滚轮下面翻了出去。最后这 1.7 毫米没有任何东西碰琴槌,它带着刚才那点速度自己撞上去。手指再用力也追不上了。这一步叫擒纵(let-off),钢琴的触感全在这两毫米里。
看不到它,不是因为它快。从正面看,弦轴板和 236 根弦轴挡着;从上往下看,铁排盖着;顺着这排琴槌看过去,下一个琴槌就是一堵墙;从后面压低了看,弦马又挡住。所以这几个机位不是凭感觉摆的,是拿射线扫出来的:把方位角和仰角扫一遍,看哪些方向能同时够到 2 号音的顶杆和槌头,中间还不隔东西。能用的只有一小片,在低音侧锯开的那面墙上方。
所以给它的任务书只有一句话:掀掉盖子,锯掉半个琴壳;88 个琴槌排成一条越来越小的线,其中一个在离弦 1.7 毫米的地方放开顶杆。

五个机位里的第一个。想在真琴上看到这幅画面,你得整个人站到琴的上方往里探。
完整源码在 Makone:三千二百行左右的 JavaScript,九个零件,没有导入任何模型,也没用一个采样音色。下面挑真正决定这台琴的那些行来讲。
所有尺寸都是解出来的
params.js 是这台琴的地基,文件开头就把话说在前面:这里没有一个数字是「在截图上看着差不多」定下来的。它列了四组关系,每一组都能算。
黑键不在两个白键的正中间
一个八度 165 毫米,白键节距就是 165/7 = 23.571 毫米。正视图里能自由选的只有这一个数。
黑键呢?很多人以为它压在相邻两个白键的分界线上。不是。真正的规矩是另一条:白键从黑键之间伸出来的那截「颈」,同一组里必须一样宽。C-D-E 是三白两黑,F-G-A-B 是四白三黑,两组各解一次:
/** 一组白键的等颈解。whites 个白键和 whites − 1 个黑键分享这一组的宽度;
* 每条颈(白键钻进黑键之间那截)一样宽,每条缝也一样。返回颈宽。 */
const neck = (whites) => (whites * WHITE_PITCH
- (whites - 1) * KEYS.blackW - (2 * whites - 2) * KEYS.gap) / whites;
export const NECK_2 = neck(3); // C D E → 14.97mm
export const NECK_3 = neck(4); // F G A B → 13.90mm
颈宽一定,同组白键的尾巴就是等距的,每个黑键正好落在相邻两条尾巴的正中间。那套谁都眼熟、谁都说不清的不对称偏移,就这么自己出来了,没有一个是手填的:
C −4.3 C♯ −2.15 D 0.0 D♯ +2.15 E +4.3
F −4.84 F♯ −3.23 G −1.61 G♯ 0.0 A +1.61 A♯ +3.23 B +4.84 (mm)
C♯ 在 C/D 分界线左边 2.15 毫米,F♯ 在 F/G 左边 3.23,G♯ 不偏。要是把黑键均匀排开,弹过琴的人扫一眼就知道不对劲,但多半说不出哪儿不对。

正视图是这类模型最要命的角度。黑键的偏移只要错了,这一张就露馅。
一架钢琴上只有十二种琴键
琴键的头站在 23.571 毫米的键盘节距上,尾巴要站到 13.75 毫米的击弦机节距上。两套节距每个八度才对齐一次,所以每根琴键都得在黑键开始的地方横着让一步,而让多少只跟音级有关:C 永远让 4.3 毫米,F 永远让 4.84,D 和 G♯ 一步不让。
所以整架钢琴只有十二种形状的琴键,每种重复七到八次。这正好是 InstancedMesh 要的东西:十二个 geometry,88 个实例。capstan 和制动器(back check)各占一个实例化网格,因为它们在每根键上都一样,只有 x 不同。

每个零件都单独出一张图来审。整块 88 键的键盘只花了 748 个三角形、6 种材质,省下来的预算留给看得见的地方。
10 毫米键深怎么变成 46 毫米击弦行程
10 毫米键深要变成 46 毫米的击弦行程,中间串着三个比值:琴键(capstan 臂 / 触点臂)、转击器 wippen(顶杆臂 / 踵臂)、琴槌(槌头半径 / 滚轮半径)。
第三个由看得见的零件定死:槌头到轴心 125.08 毫米,除以滚轮半径 16.5 毫米,就是 7.58。前两个不能各填各的,因为 capstan 必须正好顶在 wippen 的踵下面——一个点,两根杠杆共用。所以这里是两个方程、两个未知数:
// capstanArm + heelArm = balanceZ − wippenZ (两臂分享同一段跨距)
// (capstanArm/touchArm) × (jackArm/heelArm) × hammerRatio = ratio
export const CHAIN = (() => {
const hammerRatio = reachOf(REF_NOTE) / HAMMER.knuckleR;
const touchArm = KEYS.frontZ - KEYS.touchInset - KEYS.balanceZ;
const span = KEYS.balanceZ - ACTION.wippenZ;
const k = (HAMMER.ratio / hammerRatio) * touchArm;
const heelArm = (span * ACTION.jackArm) / (ACTION.jackArm + k);
const capstanArm = span - heelArm;
const wippenRatio = ACTION.jackArm / heelArm;
const keyRatio = capstanArm / touchArm;
return {
touchArm, span, heelArm, capstanArm, hammerRatio, wippenRatio, keyRatio,
ratio: keyRatio * wippenRatio * hammerRatio, // 按构造,等于 HAMMER.ratio
capstanZ: KEYS.balanceZ - capstanArm,
keyAngle: KEYS.dip / touchArm, // 琴键转过的弧度,约 3°
/** 每单位键深换来多少顶杆行程——琴槌以下的整条链 */
perDip: keyRatio * wippenRatio,
};
})();
解出来:触点到平衡销 193 毫米,capstan 臂 116.67,踵臂 53.33,wippen 比 1.20:1,琴键比 0.605:1,按到底琴键转 2.97°。capstan 的位置是算出来的,不是挪到看着顺眼为止。
后面紧跟一句断言,写在代码里,不是写在注释里:
if (Math.abs(CHAIN.ratio - HAMMER.ratio) > 1e-9) throw new Error('params: the chain does not solve');
这里还藏着一个矛盾。任务书既要 5.5:1 的击弦机比,又要 10 毫米键深换 46 毫米行程,可 5.5 × 10 = 55,不是 46。两句话对不上。解开它的恰好就是擒纵:这条杠杆链中途会断。顶杆脱开以后,琴键还剩一两毫米可以走,这段位移手指感觉得到,可它没传到琴槌上,行话叫 after touch。低音区 2.07 毫米,中央 C 附近 1.64,最高音 1.13。少于 0.8 毫米,params.js 直接抛错,因为那意味着琴键先到底、琴槌还没够着弦,这个音根本不响。
擒纵是算出来的,不是 K 出来的
这台琴里没有一帧动画是 K 出来的。整套动作就是一个纯函数,输入只有一个:这个音被按下多久了。
先把跟时钟无关的部分算成常量:
const PRESS = 0.055; // 手指把键按到底的秒数
const G = 78; // rad/s²:作用在槌头伸出 125mm 的琴槌上的重力
const REBOUND = 0.52; // 毡还给它多少速度
function ballistics(k) {
const rate = (KEYS.dip * CHAIN.perDip) / PRESS; // 顶杆,米每秒
const tLet = PRESS * (k.dipToLetoff / KEYS.dip);
const w0 = rate / HAMMER.knuckleR; // 琴槌,弧度每秒
const aLet = k.aStrike - LETOFF.gap / k.L; // 差 1.7mm,换成弧度
// 自由飞行:w0·s − ½G·s² = 剩下那点间隙。2 毫米里 G 几乎不起作用,但它仍然是对的。
const gap = LETOFF.gap / k.L;
const s = (w0 - Math.sqrt(Math.max(0, w0 * w0 - 2 * G * gap))) / G;
return { rate, tLet, w0, aLet, tHit: tLet + s, wHit: w0 - G * s };
}
再用一个函数算出任意一刻整条链的状态:
function stroke(n, tau, releaseAt) {
const k = NOTES[n], b = BALL[n];
// ── 手指 ──
let u;
if (tau < PRESS) u = tau / PRESS;
else if (tau < releaseAt) u = 1;
else u = Math.max(0, 1 - (tau - releaseAt) / RELEASE);
// ── 顶杆,以及它从滚轮下面转出去了多少 ──
const dip = u * KEYS.dip;
const escape = Math.max(0, dip - k.dipToLetoff) / TOE_ARM;
// ── 琴槌 ──
let a, phase;
if (tau <= b.tLet) { // 还被顶杆推着
a = k.aRest + (dip * CHAIN.perDip) / HAMMER.knuckleR;
phase = 'jack on knuckle';
} else if (tau <= b.tHit) { // 没有任何东西碰着它的那 1.7mm
const s = tau - b.tLet;
a = b.aLet + b.w0 * s - 0.5 * G * s * s;
phase = 'let-off — free flight';
} else { // 撞过弦,正在落回来
const s = tau - b.tHit;
a = k.aStrike - REBOUND * b.wHit * s - 0.5 * G * s * s;
// 制动器长在琴键上,所以只有键还按着的时候它才够得着
const floor = k.aRest + (u * KEYS.dip * (KEYS.balanceZ - k.tailZ) / CHAIN.touchArm) / b.tailR;
phase = a <= floor ? 'checked' : 'rebound';
a = Math.min(k.aStrike, Math.max(a, Math.min(floor, k.aStrike)));
}
...
}
真琴的时间尺度在这儿全对得上:琴键 55 毫秒到底,第 42.1 毫秒顶杆脱开,自由飞行 1.63 毫秒,槌头以 1.04 米/秒撞上弦。演示里那段变速播放不是为了好看。按原速看,这件事要么糊成一团,要么就是一张不动的画。

左下角那行不是字幕,是从几何里量出来的:槌头顶点离弦平面还有多远,这一帧是 0.60 毫米。
撞完弦,琴槌以大约一半的速度弹回来,落在制动器(back check)上。注意上面那行 floor:制动器长在琴键上,所以只有琴键还按着的时候它才够得着琴槌。手一松,制动器跟着琴键升上去,琴槌就一路落回搁架。这句不是形容,floor 那一行算的就是这件事。

下一个瞬间:回弹,离弦 3.28 毫米。制动器接住琴槌的高度,按这个音自己的槌尾位置算,88 个各不相同。
槌轴是直的,88 个零件却各不相同
槌轴是一根直杆,可每个音的静止角不一样:A0 的槌杆低于水平 21.8°,C8 只有 13.1°,于是低音区的滚轮比高音区低了两毫米左右。
真琴靠什么吃掉这个差?88 根长短略有不同的顶杆,和 88 颗拧到不同深度的擒纵按钮。这里也一样,而且不用手调:顶杆从低音的 25.53 毫米长到高音的 27.71 毫米,擒纵按钮的下沿从 86.04 走到 86.69。
槌头只画一次:归一化成从槌杆轴线到顶端一个单位高、一个单位宽,每个音再自己缩放。
// 槌头:归一化的,所以它的 scale 就是它的尺寸,位置在槌杆末端。
// z 方向的 scale 承载的是槌头的「深度」:截面画的时候是 0.30 单位深,
// 所以除以 0.30,就把一个米制尺寸直接变成缩放系数。
head.makeScale(k.hammerW, k.rise, k.hammerD / 0.30).setPosition(0, 0, -k.shank);
local.multiplyMatrices(m, head);
mould.setMatrixAt(n, local);
under.setMatrixAt(n, local);
felt.setMatrixAt(n, local);
这里踩过一个坑。低音槌头的真实尺寸是 19.5 毫米,照着填进宽度,六十多个槌头当场互相穿模,整排渲染成一条白带子。道理很简单:击弦机的音距只有 13.75 毫米,横着排的东西不可能宽过自己的槽。19.5 毫米说的是槌头顺着弦那个方向的深度,低音槌的重量是往那个方向堆的。所以说低音槌「大」,说的其实是两个方向上的两个尺寸。
所以 params.js 末尾有一条检查,制音器和槌头共用:
/** 站在这 88 个位置里的任何东西,都只有一个槽,槽宽 SEMI。
* 两个中心距 13.75mm 的东西不可能都比 13.75mm 宽,还算是两个东西。 */
const inItsSlot = (what, w) => {
if (w > SEMI - 0.0008)
throw new Error(`params: ${what} is ${Math.round(w * 1000)}mm across on a `
+ `${Math.round(SEMI * 1000)}mm pitch — it is inside its neighbour`);
};
制音器当初就是在 13.3 毫米的音距上做了 17.5 毫米宽,68 个全嵌在邻居身体里。

任务书真正要的就是这一张:视线几乎贴着这排琴槌,88 个槌头叠成一道脊。它们逐个变小,所以这道脊会收窄。要是 88 个一模一样,看着就只是个工厂货架。
弦长不是每八度翻倍
C8 那根弦真正振动的一段只有 50 毫米。往下每个八度乘 1.88,不是 2——真按 2 算,A0 得有 12 米长,没人做得出这么大的壳。
算出来的理想长度还要用 p 范数往 1.92 米上压一压——不是硬砍一刀,是越靠近上限压得越狠。一台长度不够的琴,实际就是这么处理的:A0 出来 1882 毫米,中央 C 以上一点没动。
const softMin = (a, b, p) => (a ** -p + b ** -p) ** (-1 / p);
const ideal = STRINGS.c8 * STRINGS.perOctave ** ((108 - midi) / 12);
const len = softMin(ideal, STRINGS.cap, STRINGS.capP); // cap = 1.920, p = 3
const frac = 1 / lerp(STRINGS.strikeBass, STRINGS.strikeTreble, t);
击弦点从低音的 1/8 一路走到最高音的 1/17,而槌线是直的(它就是一根杆)。这两件事凑在一起,压弦钉(agraffe)的连线和弦马就只能是曲线。这两条线是算出来的,没人画过。那条一米二长的长弦马弯得很好看,可它的位置没有一个人摆过。
const dir = { x: Math.sin(cross), z: -Math.cos(cross) };
const agraffe = { x: ax - dir.x * frac * len, z: strikeZ - dir.z * frac * len };
const bridge = { x: ax + dir.x * (1 - frac) * len, z: strikeZ + dir.z * (1 - frac) * len };
236 根弦:最低的 8 个音一根,接下来 12 个音两根,其余每个音三根。低音那 26 根缠铜,走自己那条高出 20 毫米的低音弦马,路上斜跨过中音区的弦。不交叉,这就是一台方形琴;交叉了,才可能是三角琴。

低音弦马比长弦马高 20 毫米。交叉能成立,全靠这 20 毫米。
声音跟几何用的是同一组数字
这台琴会响,而且一个采样都没用。一来这个项目不许带外部素材,二来就算带了,那也是别的钢琴的声音,不是屏幕上这一台的。
sound.js 全是加法合成,参数直接取自 params.js。让它听起来像钢琴、而不是像风琴,靠的是三件事。其中两件,直接用了这台琴本来就有的几何数据。
一、非谐性(inharmonicity)。 真实的弦有刚度,分音不是基频的整数倍:
f_k = k · f₀ · √(1 + B·k²)
B 越往高音越大,高音的弦又短又粗又硬:
/** 弦越长响得越久,每个分音都比它下面那个死得更快。 */
const decayOf = (n) => 14.5 * Math.exp(-n / 34) + 0.55;
/** 刚度。真实的 B 从低音的约 3e-5 到最高音的约 5e-3。 */
const inharmOf = (n) => 3.2e-5 * Math.exp(n / 17.5);
代进去:A0 的 B 是 3.2e-5,C8 是 4.6e-3。中央 C 附近第 8 分音已经高了 20 音分,最高音的第 5 分音高了 95 音分,差不多一个半音。钢琴要拉伸调律、管风琴从来不用,根子就在这一行。衰减时间也顺带出来了:A0 能响 15 秒,C8 只有 1.7 秒。
二、击弦点。 敲在弦的哪个位置,就决定了哪些振型起得来:正好敲在某个振型的节点上,这个振型一点也激发不出来。所以每个分音的幅度都带一个梳状因子 |sin(kπ·frac)|。低音敲在 1/8 处,第 8 个分音就是零;高音敲在 1/17 处。这个 frac 不是我给合成器调的参数,它就是 NOTES[n].frac,跟压弦钉连线和弦马曲线用的是同一个数。音色沿着音域一路变化,只因为一根杆装在了那个位置。
// 弹得越重,不只是越响:是更多分音活了下来
const partials = Math.max(4, Math.round((16 - n / 8) * (0.55 + 0.55 * v)));
for (let p = 1; p <= partials; p++) {
const f = p * f0 * Math.sqrt(1 + B * p * p);
if (f > 15000) break;
// 敲在节点上的振型,给不了这根弦
const comb = Math.abs(Math.sin(p * Math.PI * k.frac));
const amp = (comb / p ** 1.08) * v * (0.35 + 0.65 * v) ** (p / 4);
if (amp < 0.0009) continue;
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(amp, t + 0.003);
g.gain.exponentialRampToValueAtTime(0.00006, t + decay / (1 + 0.42 * (p - 1)));
o.connect(g).connect(gain);
o.start(t);
o.stop(t + decay + 0.4);
}
三、制音器。 松开琴键,毡落回弦上,声音大概十分之一秒就停了。除非踏板踩着,或者这个音编号在 68 以上——最高的 20 个音压根没有制音器。这个差别耳朵听得出来。而合成器里的这条判断,和制音器那个零件依据的是同一件事。
function release(n) {
const v = live.get(n);
if (!v) return;
// 68 号以上没有制音器,踏板踩着的时候一个也不落下
if (v.damped && !pedalDown) stop(n, true);
}
弦之外还有一层声音:毡打在钢丝上的那一下。一小段带通噪声,弹得越重越亮。
/** 毡撞钢丝的那记敲击:一小段滤过的噪声,力度越大越亮。 */
function thump(t, k, vel) {
const src = ctx.createBufferSource();
src.buffer = noiseBuf;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 380 + k.n * 46 + vel * 900;
bp.Q.value = 0.9;
const g = ctx.createGain();
g.gain.setValueAtTime(0.9 * vel * vel, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.055 + 0.05 * (1 - k.n / 88));
src.connect(bp).connect(g).connect(out);
src.start(t);
src.stop(t + 0.3);
}
整台琴共用一个压缩器:十个音同时按下去不能削顶,真钢琴靠弦马和音板也在做类似的事。复音上限 22 个。
还有一条跟音乐无关、纯属工程的事:AudioContext 一直等到有人真的弹了才建。浏览器本来就不许无手势启动它,而无头 Chromium 更麻烦,它会为一台根本不存在的设备开一条音频线程,要花 2.6 秒。这两秒多足够让自动化测试等控件的那一步超时,最后报告说这个世界的控件点不动。批量截图的时候没人听得见声音,那就一秒钟也不该等:
if (globalThis.location && new URLSearchParams(location.search).get('capture') === '1') return false;
让它自己弹
tunes.js 里有四首:一首夏天味道的原创小曲,加上巴赫 C 大调前奏曲、贝多芬《致爱丽丝》、帕赫贝尔《卡农》的开头(都是公有领域)。每首三十秒左右。
它们不是用音名写的,是用音符编号写的:0 是 A0,39 是中央 C,跟这台琴里其他东西的编号方式一致。每首分左右手两轨。这么写,一半是因为音乐本来就这样——左手管和声和踏板,右手走旋律;另一半是因为画面:低音槌是整排里最大的那几个,站在最远端,旋律又小又快,在中间。两只手的差别看得见。
每条记录是 [拍点, 音符, 长度(拍), 力度]:
const C = [C3, [E3, G3], G3]; // 每小节一条:[第1拍低音, 第2和第4拍和弦, 第3拍低音]
const chords = [C, Am, F, G, C, Am, F, C, F, G, Em, Am, F, C];
chords.forEach((ch, b) => {
const t = b * 4;
const last = b === chords.length - 1;
lh.push([t, [ch[0]], last ? 3.6 : 0.9, 0.80]);
lh.push([t + 1, ch[1], last ? 3.0 : 0.7, 0.46]);
if (last) return;
// 第 7 和第 13 小节中途换和声——往家里推的那一下
const half = (b === 6 || b === 12) ? G : ch;
lh.push([t + 2, [half[2]], 0.9, 0.66]);
lh.push([t + 3, half[1], 0.7, 0.44]);
});
再编译成一串按时间排好的 press / release / pedal 事件。跟一双手弹出来的事件完全一样,所以下游没有任何东西分得清是曲子在自己弹,还是有人在弹:
function compile(piece) {
const spb = 60 / piece.bpm;
const per = piece.beatsPerBar ?? 4;
const ev = [];
for (const [beat, notes, len, vel] of [...piece.lh, ...piece.rh]) {
for (const n of notes) {
ev.push({ t: beat * spb, press: n, vel });
ev.push({ t: (beat + len) * spb, release: n });
}
}
if (piece.pedal === 'bar') {
for (let b = 0; b < piece.bars; b++) {
// 小节线前抬起、之后再踩下去——一个和声的余韵,一只脚真正在做的事
ev.push({ t: Math.max(0, b * per * spb - 0.04), pedal: false });
ev.push({ t: b * per * spb + 0.05, pedal: true });
}
}
// 曲子结束的时刻,是最后一根弦被松开的时刻,不是最后一条小节线走过去的时刻。
const lastOff = ev.reduce((m, e) => (e.release !== undefined ? Math.max(m, e.t) : m), 0);
ev.push({ t: lastOff + 0.25, pedal: false });
ev.push({ t: lastOff + 1.3, end: true });
return ev.sort((a, b) => a.t - b.t || (a.release !== undefined ? -1 : 1));
}
最后那段注释背后是个真实的 bug:一开始按小节数判断结束,可末尾那个和弦是拖着不放的,于是有一首曲子后面挂了五秒钟的静音。
选曲也不是凭喜好。夏日小曲是这台机器最难干的活,一小节十六个槌,踏板还在底下不停地换。《致爱丽丝》是唯一一首完全不用踏板的,所以你看得见的每一个制音器,都会在琴键抬起来的一瞬间落回自己的弦上,68 个一起落。《卡农》一小节才一个低音,整台琴最大的那几个槌自己慢慢地动,小的在上面忙。巴赫那首干脆没有旋律,一个分解和弦翻来覆去,槌子于是像水波一样一个接一个涌上来,而不是一个一个地敲。
一个时钟,两个入口
这台琴既是一段影片,也是一件乐器,但它不是两套代码:两边都只往同一张 voices 表里写。
const voices = new Map(); // 音符 → { tau, releaseAt, vel }
影片那边把时间轴上的位置换算成机构自己的时间,然后写这张表;你的手指是直接写这张表。表下面那套机构分不清是谁在弹。所以这东西从「一段能拖进度条的影片」变成「一件能弹的乐器」,九个零件一行都没改。
也正因为这样,seekTo(t) 和 play() 不可能对不上:它们是同一个纯函数,只是时钟值不同。唯一的坑写在注释里,值得抄一遍:
// 读的时候不能写 `clock % DURATION`:那会把时间线的末尾映射到开头,
// 于是 seekTo(0) 和 seekTo(1) 是同一帧——检查工具会说这是一条你看不见的时间线,而它说得对。
const u = Math.min(1, Math.max(0, clock / DURATION));
自动测试那边守同一条规矩:pilot.js 只许碰 observe()、getState() 和 act(),不许 import main.js。它走的路跟真人一模一样,所以 act({press: n}) 要是和真正动起来的琴槌差了一个音,或者哪个键按下去再也没抬起来,都会在这儿现形。
乐器没有「通关」这回事,所以它自己定义了什么算成功:
// 它弹了真正数量的音,而且演出结束时什么都没挂着——没有键还按着,
// 没有琴槌离开搁架,没有制音器还抬着。卡住的琴键是键盘唯一一种截图永远看不出来的失败。
export const succeeds = (s) => s.struck >= 20 && s.sounding.length === 0 && s.atRest === true;
数字,和几条经验
整台琴 143,328 个三角形、159 次 draw call。有几处优化是量出来的,不是猜的。
把生成好的环境贴图直接交给 scene.environment,等于让物体里每一个片元都做一次 IBL 查表。可毡和云杉根本不反射房间,只有黄铜、钢和那块漆过的铸铁排需要。在检查工具用的软件渲染器里,光这一行就吃掉半帧:带着它 490 毫秒,去掉 243 毫秒。 于是环境贴图改成手动只发给金属材质,剩下的交给三盏灯。
阴影贴图 1024 覆盖 3 米视锥,是 2.9 毫米一个纹素,而这个物体要在 1.3 × 2.2 米上分辨出 10 毫米的键深。结果键盘上长出一片三角形的阴影痤疮(shadow acne),看着像是低音区被什么挡住了,其实全是它。把视锥收到物体的实际范围,再上 2048,变成 0.9 毫米一个纹素。
共面(z-fighting)踩了三次,同一个错误的三种长相:白键的木体顶面和象牙面都在 62.0 毫米;黑键帽跟它自己的木体同一个平面;槌头的内毡和外毡画成一样大。两个面在同一个平面上,深度测试给不出答案,只能逐像素逐帧随便挑一个——于是键盘闪烁,槌头长麻点。修法都很笨:让下面那层沉下去 0.4 毫米,或者缩小 4%。修起来不难,找出来才难。
做完这台琴,有几条下次还用得上。
尺寸能算出来,就别用手填。 capstan 的位置、每根顶杆的长度、擒纵按钮的高度、弦马的曲线,在这台琴里全是解出来的。好处很直接:改一个参数,相关的零件跟着一起动,不会出现这边改完了、那边还留在旧坐标上的情况。
一排零件挤在一起,先算清楚每个能占多宽。 击弦机的音距 13.75 毫米,谁也宽不过它。这句话写成一行断言比写在注释里管用得多——制音器那次就是这么抓出来的:17.5 毫米宽的头做在 13.3 毫米的间距上,68 个全嵌进了邻居身体里,而画面上只是白花花一片,看不出哪儿不对。
动作能用公式算,就别去 K 帧。 这台琴的整套动作是一个函数,输入只有「这个音按下多久了」。所以拖进度条停在某一帧,和你自己按键弹出来的那一帧,不可能对不上——本来就是同一个函数算的。省下的还不只是对不上的风险:想看第 42 毫秒发生了什么,直接把那个数喂进去就行。
声音和模型可以共用一份参数。 哪些分音存在由击弦点决定,而击弦点在模型里和在合成器里是同一个 frac。反过来说也成立:要是合成器的参数跟模型的尺寸对不上,那它们描述的其实是两台不同的琴。另一篇写了从零合成音乐的完整链路,那边是离线渲染成 WAV,这边是在浏览器里边弹边算。
测试代码不要 import 被测的模块。 pilot.js 只调 act()、observe()、getState(),跟真人用鼠标和键盘走的是同一条路。伸进去直接调内部函数的测试没有这个好处:被测的东西有什么毛病,它就跟着有什么毛病,测了等于白测。
The most important step a piano takes is the one nobody ever sees.
Press a key and the hammer rises. About 1.7 mm short of the string, the jack that has been pushing it is stopped by a felted button; it can no longer rise, only turn, so it slides out from under the hammer’s knuckle. The hammer crosses that last 1.7 mm with nothing touching it, at whatever speed it already had. Push harder and you cannot catch it. This is let-off, and the whole of piano touch lives in those two millimetres.
“Nobody sees it” is not a figure of speech. From the front, the pinblock and 236 tuning pins are in the way. From directly above, the plate is. Along the row, the next hammer is a wall. From behind at a low angle, the bridge. The camera stations in this world were not placed by taste; they were found by raycasting — a sweep over azimuth and elevation asking which directions reach note 2’s jack and its crown with nothing in between. What survives is a narrow cone in over the sawn-down bass wall.
So the brief is one sentence: the lid is off and half the case is cut away; eighty-eight hammers in a shrinking line, and one of them lets go of its jack two millimetres short of the string.

The first of five stations. You are standing over the instrument looking down into it — which is the only posture from which this picture exists in real life.
The complete source is in Makone: about 3,200 lines of JavaScript, nine parts, no imported model and not one sample. What follows is the code that actually decides what this piano is.
Dimensions are solved, not typed
params.js is the foundation of this world, and its opening comment says what it is for: nothing here is a number that looked right on a screenshot. Four chains, and all four are algebra.
The black keys are not centred between the whites
An octave is 165 mm, so a white key pitch is 165/7 = 23.571 mm. That is the only free number in the front view.
What about the black keys? Most people assume they sit on the boundary between their two neighbouring naturals. They do not. The real constraint is something else entirely: the exposed “necks” of the white keys — the narrow part that runs back between the sharps — must be equal within a group. C-D-E is three naturals and two sharps; F-G-A-B is four and three. Solve each group:
/** Equal-neck solution for one group of white keys. `whites` naturals and `whites - 1` blacks
* share the group's width; every neck is the same, and so is every gap. Returns the neck. */
const neck = (whites) => (whites * WHITE_PITCH
- (whites - 1) * KEYS.blackW - (2 * whites - 2) * KEYS.gap) / whites;
export const NECK_2 = neck(3); // C D E → 14.97 mm
export const NECK_3 = neck(4); // F G A B → 13.90 mm
Once the necks are equal, the key tails in a group are evenly spaced and every sharp lands exactly midway between two of them. The asymmetry everyone recognises and nobody can name falls out — not one offset is typed by hand:
C −4.3 C♯ −2.15 D 0.0 D♯ +2.15 E +4.3
F −4.84 F♯ −3.23 G −1.61 G♯ 0.0 A +1.61 A♯ +3.23 B +4.84 (mm)
C♯ sits 2.15 mm left of the C/D boundary, F♯ 3.23 mm left of F/G, G♯ exactly on G/A. Space the blacks evenly instead and every player who looks at it says something is wrong, while almost none of them can say what.

Front-on is the cruel angle for this kind of model. If the sharp offsets are wrong, this single frame is where it shows.
There are only twelve distinct keys on a piano
A key’s head sits on the keyboard’s 23.571 mm pitch; its tail sits on the action’s 13.75 mm pitch. The two only agree once per octave, so every key steps sideways where the sharps begin — and the step is a constant for each pitch class: C always 4.3 mm, F always 4.84, D and G♯ not at all.
So a piano has exactly twelve distinct key shapes, each appearing seven or eight times. Which is precisely what an InstancedMesh wants: twelve geometries, 88 instances, plus two more instanced meshes for the capstan and the back check, because those are the same object on every key and only their x changes.

Every part is reviewed alone on its own sheet. The complete 88-note keyboard costs 748 triangles and six materials — the budget it saves goes where you can see it.
The lever chain: three ratios multiplied, two unknowns
Ten millimetres of key dip has to become 46 mm of hammer travel through three ratios that multiply: the key (capstan arm / touch arm), the wippen (jack arm / heel arm), and the hammer (crown radius / knuckle radius).
The third one is fixed by parts you can see: 125.08 / 16.5 = 7.58. The other two cannot be chosen independently, because the capstan has to stand under the wippen heel — one point, shared by two levers. So it is really two equations in two unknowns:
// capstanArm + heelArm = balanceZ − wippenZ (they share the span)
// (capstanArm/touchArm) × (jackArm/heelArm) × hammerRatio = ratio
export const CHAIN = (() => {
const hammerRatio = reachOf(REF_NOTE) / HAMMER.knuckleR;
const touchArm = KEYS.frontZ - KEYS.touchInset - KEYS.balanceZ;
const span = KEYS.balanceZ - ACTION.wippenZ;
const k = (HAMMER.ratio / hammerRatio) * touchArm;
const heelArm = (span * ACTION.jackArm) / (ACTION.jackArm + k);
const capstanArm = span - heelArm;
const wippenRatio = ACTION.jackArm / heelArm;
const keyRatio = capstanArm / touchArm;
return {
touchArm, span, heelArm, capstanArm, hammerRatio, wippenRatio, keyRatio,
ratio: keyRatio * wippenRatio * hammerRatio, // = HAMMER.ratio, by construction
capstanZ: KEYS.balanceZ - capstanArm,
keyAngle: KEYS.dip / touchArm, // radians the key turns, ~3°
/** jack lift per unit of key dip — the whole chain below the hammer */
perDip: keyRatio * wippenRatio,
};
})();
It comes out at: 193 mm from the touch point to the balance pin, a 116.67 mm capstan arm, a 53.33 mm heel arm, a wippen ratio of 1.20:1, a key ratio of 0.605:1, and 2.97° of rotation at the bottom of the dip. The capstan’s position is derived, not nudged until it lined up.
And then an assertion, in code rather than in a comment:
if (Math.abs(CHAIN.ratio - HAMMER.ratio) > 1e-9) throw new Error('params: the chain does not solve');
There is a lovely contradiction hiding here. The brief asks for a 5.5:1 action ratio and for 10 mm of dip to make 46 mm of blow — but 5.5 × 10 is 55, not 46. Those are not the same claim. What resolves it is let-off itself: the chain stops being rigid. After the jack escapes, the key still has a millimetre or two to travel, and that travel is felt by the finger and delivered to nobody. It is called after touch: 2.07 mm in the bass, 1.64 near middle C, 1.13 at the top. Below 0.8 mm params.js throws, because that means the key bottoms out before the hammer arrives and the note never sounds at all.
The escapement as arithmetic, not animation
Nothing in this world is keyframed. The whole stroke is a pure function of one number: how long ago this note’s finger started down.
First the part that does not depend on the clock:
const PRESS = 0.055; // seconds for the finger to take the key to the bottom
const G = 78; // rad/s²: gravity on a hammer whose crown is 125 mm out
const REBOUND = 0.52; // how much of its speed the felt gives back
function ballistics(k) {
const rate = (KEYS.dip * CHAIN.perDip) / PRESS; // jack, metres per second
const tLet = PRESS * (k.dipToLetoff / KEYS.dip);
const w0 = rate / HAMMER.knuckleR; // hammer, radians per second
const aLet = k.aStrike - LETOFF.gap / k.L; // 1.7 mm short, in radians
// free flight: w0·s − ½G·s² = the last gap. G barely matters over 2 mm, and it is still right.
const gap = LETOFF.gap / k.L;
const s = (w0 - Math.sqrt(Math.max(0, w0 * w0 - 2 * G * gap))) / G;
return { rate, tLet, w0, aLet, tHit: tLet + s, wHit: w0 - G * s };
}
And then one function that accounts for the entire chain at any instant:
function stroke(n, tau, releaseAt) {
const k = NOTES[n], b = BALL[n];
// ── the finger ──
let u;
if (tau < PRESS) u = tau / PRESS;
else if (tau < releaseAt) u = 1;
else u = Math.max(0, 1 - (tau - releaseAt) / RELEASE);
// ── the jack, and how far it has turned out from under the knuckle ──
const dip = u * KEYS.dip;
const escape = Math.max(0, dip - k.dipToLetoff) / TOE_ARM;
// ── the hammer ──
let a, phase;
if (tau <= b.tLet) { // driven by the jack
a = k.aRest + (dip * CHAIN.perDip) / HAMMER.knuckleR;
phase = 'jack on knuckle';
} else if (tau <= b.tHit) { // the 1.7 mm nobody is touching
const s = tau - b.tLet;
a = b.aLet + b.w0 * s - 0.5 * G * s * s;
phase = 'let-off — free flight';
} else { // struck, and falling back
const s = tau - b.tHit;
a = k.aStrike - REBOUND * b.wHit * s - 0.5 * G * s * s;
// the back check is bolted to the key, so it only reaches while the key is down
const floor = k.aRest + (u * KEYS.dip * (KEYS.balanceZ - k.tailZ) / CHAIN.touchArm) / b.tailR;
phase = a <= floor ? 'checked' : 'rebound';
a = Math.min(k.aStrike, Math.max(a, Math.min(floor, k.aStrike)));
}
...
}
The timescales of a real instrument all land: the key reaches the bottom in 55 ms, the jack escapes at 42.1 ms, free flight lasts 1.63 ms, and the crown arrives at the string doing 1.04 m/s. The film warps time not for prettiness but out of necessity — at any single rate the subject is either a blur or a still.

That line in the corner is not a subtitle. It is read off the geometry: the crown’s distance to the string plane, 0.60 mm on this frame.
After the strike the hammer rebounds at about half speed and falls onto the back check. Note the floor line above: the back check is bolted to the key, so it can only catch the hammer while the key is still down. Let go and it rises with the key, and the hammer drops back to its rest rail. That is not a description; it is that one line.

The next moment of the same machine: rebound, 3.28 mm from the string. The height at which the check catches it is read off this note’s own hammer tail, and no two of the eighty-eight are the same.
One straight rail, eighty-eight different parts
The hammer flange rail is straight — it is a rail. But the rest angles are graduated: A0’s shank hangs 21.8° below horizontal, C8’s only 13.1°. So the knuckle sits about 2 mm lower in the bass than in the treble.
How does a real piano absorb that? With eighty-eight jacks of slightly different length, and eighty-eight let-off buttons screwed to slightly different depths. Same here, and it falls out rather than being placed: the jacks run 25.53 mm in the bass to 27.71 mm at the top; the underside of the buttons walks from 86.04 to 86.69 mm.
The head itself is drawn once, normalised — one unit from the shank line to the crown, one unit wide — and scaled per note:
// the head: normalised, so its scale is its size, and it sits on the end of the shank.
// The z scale carries the head's DEPTH: the profile is drawn 0.30 units deep, so dividing
// by that turns a metre measurement straight into a scale factor.
head.makeScale(k.hammerW, k.rise, k.hammerD / 0.30).setPosition(0, 0, -k.shank);
local.multiplyMatrices(m, head);
mould.setMatrixAt(n, local);
under.setMatrixAt(n, local);
felt.setMatrixAt(n, local);
There is a trap here worth writing down. A real bass hammer head measures 19.5 mm — put that in the width and sixty of the eighty-eight interpenetrate their neighbours and the whole row renders as a solid white band. The reason is simple: the action’s note pitch is 13.75 mm, and nothing in the row can ever be wider than its own slot. The 19.5 mm is the head’s depth along the string, which is where a bass hammer’s mass actually goes. Two different sizes with one name.
So params.js ends with a check that hammers and dampers share:
/** Anything that stands in the row of 88 has one slot, and it is SEMI wide. Two things at
* 13.75mm centres cannot both be wider than that and still be two things. */
const inItsSlot = (what, w) => {
if (w > SEMI - 0.0008)
throw new Error(`params: ${what} is ${Math.round(w * 1000)}mm across on a `
+ `${Math.round(SEMI * 1000)}mm pitch — it is inside its neighbour`);
};
The dampers were 17.5 mm across on a 13.3 mm pitch — all sixty-eight of them inside their neighbours — which is what this check found the day it was written.

The shot the brief asks for. The line of heads tapers, so the ridge narrows as it recedes — a row of eighty-eight identical hammers would only read as a factory rack.
Strings do not double per octave
C8 speaks 50 mm. Every octave down multiplies by 1.88, not 2 — a true doubling would need a 12 m bass string, and nobody has that case.
The ideal length is then soft-clipped against a 1.92 m cap with a p-norm, which is exactly what a real scale does in a piano that is not long enough: A0 comes out at 1882 mm and everything above middle C is untouched.
const softMin = (a, b, p) => (a ** -p + b ** -p) ** (-1 / p);
const ideal = STRINGS.c8 * STRINGS.perOctave ** ((108 - midi) / 12);
const len = softMin(ideal, STRINGS.cap, STRINGS.capP); // cap = 1.920, p = 3
const frac = 1 / lerp(STRINGS.strikeBass, STRINGS.strikeTreble, t);
The strike point walks from 1/8 of the length in the bass to 1/17 at the top. The hammer line is straight, because it is a rail. Multiply those two facts together and the agraffe line and the bridge can only be curves — they are consequences, not drawings. That 1.2 m of sweeping long bridge is a curve nobody placed.
const dir = { x: Math.sin(cross), z: -Math.cos(cross) };
const agraffe = { x: ax - dir.x * frac * len, z: strikeZ - dir.z * frac * len };
const bridge = { x: ax + dir.x * (1 - frac) * len, z: strikeZ + dir.z * (1 - frac) * len };
236 strings: one each for the lowest eight notes, two for the next twelve, three for the rest. The bottom twenty-six are copper-wound, run to a bass bridge of their own standing 20 mm taller, and lean 8° across the tenor strings on the way. Un-cross them and this is a square piano; cross them and it can only be a grand.

The bass bridge stands 20 mm above the long one, and those 20 mm are the entire reason the crossing works.
Sound, from the same numbers as the geometry
This piano makes noise, and none of it is sampled. Two reasons: a recording would be an external asset, and it would be a different piano from the one on screen.
sound.js is additive synthesis driven straight from params.js. Of the three things that make it sound like a piano rather than an organ, two are geometry this world already had lying around.
One: inharmonicity. A real string is stiff, so its partials are not integer multiples of the fundamental:
f_k = k · f₀ · √(1 + B·k²)
B rises toward the treble, where the wire is short, thick and stiff:
/** Longer strings ring longer, and every partial dies faster than the one below it. */
const decayOf = (n) => 14.5 * Math.exp(-n / 34) + 0.55;
/** Stiffness. Real B runs from about 3e-5 in the bass to 5e-3 at the top. */
const inharmOf = (n) => 3.2e-5 * Math.exp(n / 17.5);
Put numbers through it: B is 3.2e-5 at A0 and 4.6e-3 at C8. Near middle C the 8th partial is already 20 cents sharp; at the top of the keyboard the 5th partial is 95 cents sharp — very nearly a semitone. That single line is why a piano has to be tuned stretched and an organ never does. The decay times come along for the ride: A0 rings for 15 seconds, C8 for 1.7.
Two: the strike point. A string cannot be given a mode it was struck at the node of, so every partial carries a comb factor of |sin(kπ·frac)|. Struck at 1/8, the 8th partial is missing; at the top it is struck at 1/17. That frac is not a synth parameter — it is NOTES[n].frac, the very number the agraffe line and the bridge curve came out of. The timbre walks across the compass because of where a rail happens to be.
// harder playing does not only mean louder: it means more partials survive the hammer
const partials = Math.max(4, Math.round((16 - n / 8) * (0.55 + 0.55 * v)));
for (let p = 1; p <= partials; p++) {
const f = p * f0 * Math.sqrt(1 + B * p * p);
if (f > 15000) break;
// the string cannot be given a mode it was struck at the node of
const comb = Math.abs(Math.sin(p * Math.PI * k.frac));
const amp = (comb / p ** 1.08) * v * (0.35 + 0.65 * v) ** (p / 4);
if (amp < 0.0009) continue;
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(amp, t + 0.003);
g.gain.exponentialRampToValueAtTime(0.00006, t + decay / (1 + 0.42 * (p - 1)));
o.connect(g).connect(gain);
o.start(t);
o.stop(t + decay + 0.4);
}
Three: the damper. Release a key and felt drops onto the string, so the note stops in about a tenth of a second — unless the pedal is down, or unless the note is above number 68, because the top twenty have no damper at all. That is audible, and it is the same fact the damper part is built from.
function release(n) {
const v = live.get(n);
if (!v) return;
// no damper above note 68, and none of them are down while the pedal is
if (v.damped && !pedalDown) stop(n, true);
}
On top of the strings there is one sound that is not a string: felt hitting wire. A short filtered burst of noise, brighter the harder you play.
/** The knock of felt on wire: a short filtered burst, brighter the harder you play. */
function thump(t, k, vel) {
const src = ctx.createBufferSource();
src.buffer = noiseBuf;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 380 + k.n * 46 + vel * 900;
bp.Q.value = 0.9;
const g = ctx.createGain();
g.gain.setValueAtTime(0.9 * vel * vel, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.055 + 0.05 * (1 - k.n / 88));
src.connect(bp).connect(g).connect(out);
src.start(t);
src.stop(t + 0.3);
}
One compressor serves the whole instrument — ten notes at once must not clip, and a real piano does its own version of this through the bridge and the board — with twenty-two voices of polyphony.
And one detail that is engineering rather than music: the AudioContext is not created until somebody actually plays. Browsers will not start one without a gesture anyway, but headless Chromium is worse — it builds an audio thread for a device that is not there, and it takes 2.6 seconds to do it, long enough that the test harness’s own locators time out and report a world whose controls could not be driven. Nothing in a capture run can hear anything, so nothing in a capture run should pay for it:
if (globalThis.location && new URLSearchParams(location.search).get('capture') === '1') return false;
Letting it play itself
tunes.js holds four pieces: a summer-ish original, plus the openings of Bach’s Prelude in C, Beethoven’s Für Elise and Pachelbel’s Canon in D (all public domain). Each runs about thirty seconds.
They are written in note numbers, not pitch names — 0 is A0 and 39 is middle C, the same index everything else in this world is addressed by. Each piece has two tracks, for a musical reason and a visual one: the left hand holds the harmony and the pedal while the right carries the line, and the bass hammers are the big ones at the far end of the row while the melody is small and quick in the middle. You can see the difference.
Each entry is [beat, notes, lengthInBeats, velocity]:
const C = [C3, [E3, G3], G3]; // one entry per bar: [bass on 1, chord on 2 and 4, bass on 3]
const chords = [C, Am, F, G, C, Am, F, C, F, G, Em, Am, F, C];
chords.forEach((ch, b) => {
const t = b * 4;
const last = b === chords.length - 1;
lh.push([t, [ch[0]], last ? 3.6 : 0.9, 0.80]);
lh.push([t + 1, ch[1], last ? 3.0 : 0.7, 0.46]);
if (last) return;
// bar 7 and bar 13 change harmony half way through — the push home
const half = (b === 6 || b === 12) ? G : ch;
lh.push([t + 2, [half[2]], 0.9, 0.66]);
lh.push([t + 3, half[1], 0.7, 0.44]);
});
The compiler turns that into a flat, time-ordered list of press / release / pedal events — the same events a pair of hands would produce, so nothing downstream can tell a piece playing itself from a person playing it:
function compile(piece) {
const spb = 60 / piece.bpm;
const per = piece.beatsPerBar ?? 4;
const ev = [];
for (const [beat, notes, len, vel] of [...piece.lh, ...piece.rh]) {
for (const n of notes) {
ev.push({ t: beat * spb, press: n, vel });
ev.push({ t: (beat + len) * spb, release: n });
}
}
if (piece.pedal === 'bar') {
for (let b = 0; b < piece.bars; b++) {
// up on the bar line and down again just after it — one harmony's worth of ring,
// which is a foot doing exactly what a foot does
ev.push({ t: Math.max(0, b * per * spb - 0.04), pedal: false });
ev.push({ t: b * per * spb + 0.05, pedal: true });
}
}
// The piece is over when its last string has been let go of, not when its last bar line
// goes by: a held final chord runs past the bar.
const lastOff = ev.reduce((m, e) => (e.release !== undefined ? Math.max(m, e.t) : m), 0);
ev.push({ t: lastOff + 0.25, pedal: false });
ev.push({ t: lastOff + 1.3, end: true });
return ev.sort((a, b) => a.t - b.t || (a.release !== undefined ? -1 : 1));
}
That last comment has a real bug behind it. Counting bars instead of releases left five seconds of silence on the end of one of these, because its final chord is held past the bar line.
The repertoire is not a matter of taste either. The summer piece is the hardest thing this object can be asked to do — sixteen hammers a bar with the pedal changing under all of them. Für Elise is the one piece here with no pedal at all, so every damper you can see drops back onto its own strings the instant the key comes up, sixty-eight of them. The Canon puts one note a bar in the far bass, so the biggest hammers in the instrument move on their own, slowly, while the small ones hurry above them. And the Bach has no melody whatsoever — one broken chord turning over and over, which is what makes the hammers come up in a running ripple instead of one at a time.
One clock, two ways in
This world is a film and it is an instrument, and they are not two pieces of code. Both of them write to the same map:
const voices = new Map(); // note → { tau, releaseAt, vel }
The film converts screen time into the mechanism’s own time and writes the map. Your fingers write the map. The mechanism downstream cannot tell which — which is why turning a scrubbable film into a playable instrument cost the nine parts no change at all.
It is also why seekTo(t) and play() cannot disagree: both are the same pure function given a different value of one clock. The single trap is documented in place, and it is worth copying out:
// NOT `clock % DURATION` on the read: that maps the end of the timeline onto its start,
// and then seekTo(0) and seekTo(1) are the same frame — which verify calls a timeline
// you cannot see, correctly.
const u = Math.min(1, Math.max(0, clock / DURATION));
The automation side follows the same rule. pilot.js may only touch observe(), getState() and act(), and may not import main.js. The reasoning is blunt: an autopilot living inside main.js shares its bugs and proves nothing. It exercises the published interface, so if act({press: n}) and the hammer that moves are ever off by a note, or a key never comes back up, this is what finds it.
An instrument cannot be won, so it defines success for itself:
// It played a real number of notes, and when the performance ended NOTHING was left hanging —
// no key still down, no hammer off its rest rail, no damper still up. A stuck key is the one
// failure a keyboard can have that a screenshot will never show you.
export const succeeds = (s) => s.struck >= 20 && s.sounding.length === 0 && s.atRest === true;
Numbers, and a few things worth keeping
The whole instrument comes to 143,328 triangles and 159 draw calls. Several of the optimisations were measured rather than guessed.
Handing the generated environment map to scene.environment gives every fragment in the object an image-based lighting lookup — but felt and spruce do not reflect a room. Only the brass, the steel and a painted cast-iron plate do. In the software renderer the harness measures with, that one line was half the frame: 490 ms with it, 243 ms without. So the environment map goes onto the metals by hand, and everything else is lit by three lights, which is also the more honest description of what a piece of hammer felt does with light.
A 1024 shadow map over a 3 m frustum is 2.9 mm a texel, and this object has to resolve a 10 mm key dip across 1.3 × 2.2 m. The keyboard came out in triangular patches of acne, which read as “the bass end is hidden” and was entirely this. A frustum cut to what the object actually occupies, at 2048, is 0.9 mm a texel.
And coplanar faces bit three times, the same mistake wearing three faces: the white key’s wood top and its ivory both at 62.0 mm; the sharp’s cap sharing a plane with its own stick; the hammer’s under felt drawn at exactly the same size as its outer felt. The depth test has no answer for two surfaces in one plane, so it picks per pixel and per frame — a flickering keyboard, a speckled hammer head. The fixes are all crude: sink the lower layer 0.4 mm, or shrink it 4%. Nothing about them is subtle; finding them is the work.
A few things I would carry into the next one.
If a dimension can be solved, do not type it. The capstan’s position, the length of each jack, the height of each let-off button, the bridge curve — in this piano they are all solutions to equations. The payoff is practical: change one parameter and everything downstream moves with it, instead of leaving some part standing at an old coordinate.
When parts stand in a row, work out how wide each one is allowed to be first. The action’s note pitch is 13.75 mm and nothing can be wider. Written as an assertion that is worth far more than written as a comment — it is how the dampers were caught: 17.5 mm heads on a 13.3 mm pitch, all sixty-eight inside their neighbours, and on screen it was just an undifferentiated white smear.
If the motion can be computed, do not keyframe it. The whole stroke here is one function whose only input is how long ago the key went down. So a frame you scrub to and a frame you play cannot disagree — they are the same function. And it buys more than consistency: to see what happens 42 ms in, you feed in 42 ms.
Sound and geometry can share one set of parameters. Which partials exist is decided by the strike point, and the strike point is the same frac in the model and in the synthesiser. Read it the other way round: if your synth parameters and your model dimensions disagree, they are describing two different instruments. There is another post on synthesising music from scratch — that one renders to a WAV offline, this one computes in the browser as you play.
Do not let the test import the module under test. pilot.js calls only act(), observe() and getState(), which is the same path a person takes with a mouse and a keyboard. Import main.js and reach into its functions, and whatever is wrong in there is wrong in the test too.