Skip to content

Commit 466a4c0

Browse files
RuitingMaclaude
andcommitted
Essays: split score engineering into tour + puzzles
The original five-essay scaffold treated «frozen header» and «audio sync» as standalone deep dives. After looking across all of them, the real shape of the engineering work is two voices: an architectural tour and a curated set of puzzles whose only common thread is "the first ruler I picked turned out not to be measuring what I needed." Restructure: - gun-pu-de-zha-fa — rewrite as the «tour» essay: how the component hangs together end to end (Verovio → DOM layers → ticking). Outline cues now follow that arc. - kan-cuo-de-chi — new «看错的尺» (the mismeasured rule). Five puzzles, all sharing the same shape: staff-anchor sizing, GPU tile slicing, per-axis chrome dedup, SMuFL small→full clef, audio clock retiming. Each section is self-contained. - dong-jie-de-bao-tou — deleted; content folded into the «source» and «glyph» sections of kan-cuo-de-chi. - xi-tong-pai-zhun — deleted; content folded into the «clocks» section of kan-cuo-de-chi. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9ee171b commit 466a4c0

4 files changed

Lines changed: 178 additions & 201 deletions

File tree

src/content/essays/dong-jie-de-bao-tou.mdx

Lines changed: 0 additions & 83 deletions
This file was deleted.

src/content/essays/gun-pu-de-zha-fa.mdx

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,80 +2,96 @@
22
title: 滚谱的扎法
33
date: "2026-05-04"
44
kind: ESSAY
5-
summary: 从 MEI 到一个能在网页里横向滚动、声音同步的乐谱组件
5+
summary: 一份能在网页里横向滚动、声音同步的乐谱组件,从 MEI 到 DOM 怎么一步步扎起来的
66
tint: dune
77
---
88

99
import Score from '../../components/Score.astro';
1010

1111
{/*
12-
起草大纲(按 cue 顺序):
13-
14-
why-mei — MEI / MusicXML 是什么。为什么不画 PNG。
15-
Verovio 把 MEI 渲到 SVG 是个起点,但只是起点。
16-
one-system — `breaks: 'none'` 把一首曲子排成一整条横向系统。
17-
天然给"横向滚动"提供了画布。
18-
`pageWidth: 100000, adjustPageHeight: true`。
19-
staff-anchor — viewBox 的坑:bbox 含装饰,装饰多 → staff 被压扁。
20-
解:以 staff body 为锚反推舞台高度(measure 一次,
21-
重设 --score-h,回流,继续)。
22-
本博客的 staffHeight = 40 默认。
23-
tile — Ravel 全曲 SVG 宽 ~37k px,撞 GPU 单纹理上限。
24-
沿小节切 tile(≤4096px),每片自己一个 viewBox
25-
crop,外层 GPU 各管各的。
26-
anchors — 把每个音符的 onset ms 映射到屏幕 x。线性插值 +
27-
游标缓存(O(1) 稳态)。msAtX/xAtMs 双向。
28-
loop — 多份 SVG 副本贴边铺开 + 幻影前导 tile。看到的
29-
seam 都是设计过的。
30-
recap — 整套架构是写给一个人的:作者读自己的谱子的时候
31-
不应该被装饰量影响视觉权重,不应该被 GPU 限制
32-
打到 10fps,不应该听一段需要按 5 个键。
12+
起草大纲(按 cue 顺序)—— 全流程组件思路。
13+
这篇是「形状」的故事,不是「难题」的故事;难题留给《看错的尺》。
14+
15+
empty — 起。把谱子嵌进文章不是新事,但「能跟着读、能听一段、能拖
16+
一格」的谱子是。一句话引出 MEI / MusicXML / Verovio。这篇讲
17+
的不是怎么把音符画出来——讲的是怎么把它扎成一个组件。
18+
19+
strip — 整套架构的地基:`breaks: 'none'` + 巨大 pageWidth +
20+
adjustPageHeight。一句 setOption 决定后面的一切:曲子
21+
摊成一条横向的带子。如果分页了,所有后续逻辑都不一样。
22+
23+
pipeline — MEI → Verovio → SVG → DOM 注入。每一段的职责。
24+
静态的(曲子结构、装饰、调号变化)一次性烧进 SVG;
25+
动态的(播放头位置、tempo overlay 切换)由运行时处理。
26+
所以渲染发生一次,播放发生千次。
27+
28+
layers — 舞台里的几层叠在一起,互不干扰:
29+
pan — Verovio 出的 SVG,每帧靠 translate3d 平移
30+
staff-lines — 单独画 5 条横线,单一来源避免双笔加粗
31+
chrome — 「冻结的报头」(clef/keysig/meter),假装不动
32+
playhead — 一根固定的竖线
33+
hint — "click to play"
34+
这是为什么 chrome 能假装不动而 pan 在动——它们物理上分层。
35+
36+
lifecycle — 不一上来就挂。IntersectionObserver 等到滚到视口附近才
37+
让 Verovio 醒来;离开页面(astro:before-swap)拆得干净。
38+
WASM 是大件,缓存(in-memory + IndexedDB)让重访免重渲。
39+
40+
ticking — 演奏时两只钟一起转:
41+
视觉钟 (rAF) —— 从音频钟读 ms → 锚点查 x →
42+
translate3d 设位置
43+
音频钟 (AudioContext) —— 100ms tick + 250ms lookahead
44+
独立排音符,一个游标记到哪了
45+
两只钟在同一首曲子上跳同一支舞,谁也不主导。
46+
47+
recap — 整套的形:一条带子 + 几层平行的 view + 一只眼盯着时间。
48+
这是写给「一个人想看自己谱子」的,不是「任何人想用一个
49+
通用乐谱播放器」的——所以它的边界设得很死,但里面的每
50+
一处都是为这一个用例选的。
3351
*/}
3452

35-
<section data-cue="why-mei">
53+
<section data-cue="empty">
3654

37-
{/* TODO: MEI/MusicXML 简介,Verovio 是什么*/}
55+
{/* TODO: 起。MEI/MusicXML/Verovio 一句带过;这篇讲的是组件*/}
3856

3957
</section>
4058

41-
<section data-cue="one-system">
59+
<section data-cue="strip">
4260

43-
{/* TODO: `breaks: 'none'` 的决定 */}
61+
{/* TODO: `breaks:'none'` 决定一切。一句 setOption 选择了所有后续。 */}
4462

4563
<Score slug="jeux-deau" />
4664

4765
</section>
4866

49-
<section data-cue="staff-anchor">
67+
<section data-cue="pipeline">
5068

51-
{/* TODO: viewBox 含装饰的问题 + staff-anchor 的解*/}
69+
{/* TODO: MEI → Verovio → SVG → DOM 的三步走。一次性烧 vs 运行时*/}
5270

5371
</section>
5472

55-
<section data-cue="tile">
73+
<section data-cue="layers">
5674

57-
{/* TODO: GPU 纹理上限 + tile 切片。 */}
58-
59-
<Score slug="jeux-deau-ravel" />
75+
{/* TODO: pan / chrome / staff-lines / playhead / hint 各管各。 */}
6076

6177
</section>
6278

63-
<section data-cue="anchors">
79+
<section data-cue="lifecycle">
6480

65-
{/* TODO: msAtX/xAtMs,游标缓存*/}
81+
{/* TODO: IntersectionObserver 挂载、astro:before-swap 拆除、缓存*/}
6682

6783
</section>
6884

69-
<section data-cue="loop">
85+
<section data-cue="ticking">
7086

71-
{/* TODO: 多副本 + 幻影 leader 的实现*/}
87+
{/* TODO: rAF 钟 + AudioContext 钟。两只钟同跳一支舞*/}
7288

7389
<Score slug="jeux-deau" loop />
7490

7591
</section>
7692

7793
<section data-cue="recap">
7894

79-
{/* TODO: 收。整套是为读自己的谱子写的*/}
95+
{/* TODO: 收。整套是为一个人读自己谱子写的*/}
8096

8197
</section>
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
title: 看错的尺
3+
date: "2026-05-04"
4+
kind: ESSAY
5+
summary: 做这个谱子组件,撞到几面看似无关的墙——后来发现每一面的形状都一样:第一直觉的尺子量不出真正想测的东西。
6+
tint: rust
7+
---
8+
9+
import Score from '../../components/Score.astro';
10+
11+
{/*
12+
起草大纲(按 cue 顺序)—— 几个工程难题,按"形状"排列。
13+
整篇的脊梁是:每一节都是「我以为对的单位 → 实际该用的单位」的故事。
14+
这个一致性比任何单一的难题都更值得说出来。
15+
16+
opener — 引:写组件时撞到的几面墙,一开始看起来八竿子打不着——
17+
一个是 SVG 的 viewBox,一个是 GPU 纹理上限,一个是 MEI
18+
的元数据,一个是 SMuFL 的字形编码。后来发现它们形状是
19+
一样的:每次都是我第一次挑的尺子,量的不是真正想测的东西。
20+
21+
ruler — 谁来当尺:staff body,不是 bbox。
22+
一开始 SVG 用 `height: 100%` 撑舞台。Verovio 的 bbox
23+
包含上下所有装饰(ledger、dynamics、harm、slurs),所以
24+
装饰多的曲子 staff 反而被挤扁——视觉权重在曲子之间漂移。
25+
真正该量的是 staff body 的 5 线像素跨度。
26+
解:先用 200px 临时舞台量第一条 staff 的 5 线,再算
27+
比例反推 `--score-h`。多谱表自然变高,不需要额外补偿。
28+
29+
ceiling — 谁先撞到天花板:纹理上限,不是带宽。
30+
Ravel 全曲 SVG 宽 37,000 像素。Intel 集成显卡单纹理上限
31+
8,192px;现代独显 16,384px。超过 → 浏览器掉到 CPU 光栅化
32+
→ 每一帧拖累整页 → ~10fps。
33+
不是网络不够、不是 SVG 太复杂;是那个东西塞不进一张 GPU
34+
纹理里。
35+
解:沿小节切 tile,每片 ≤4096px,外层 viewBox 做 crop,
36+
内层 `definition-scale` viewBox 不动。每片各自上 GPU。
37+
38+
source — 信哪个真理源:渲染结果,不是 MEI 元数据。
39+
冻结报头在切换 clef/keysig/meter 时,本来想从 MEI 元数据
40+
推:解析 `<clef>` 的 shape 和 line,组合签名,用作 dedup
41+
key。结果发现 MEI 里 `clef sameas`、courtesy clef 引用、
42+
octave displacement、cancel naturals 全是边界情况,每补
43+
一个就漏一个。
44+
改向下游真理找:把 Verovio 渲染出来的 `<g class="clef">`
45+
里的 `<use xlink:href>` 列表(剥掉 cancel naturals 后)
46+
作为视觉签名。同样的字形组合 → 同样的签名 → dedup。
47+
同时把 (谱表 × clef/keysig/meter) 拆成独立 track,每个
48+
独自 binary-search + opacity crossfade,钢琴上层换谱号
49+
下层不闪。
50+
51+
glyph — 真理源也有边界:SMuFL 大小变体的小坑。
52+
即便信渲染结果,Verovio 给 mid-piece 的是 SMuFL "clef
53+
change" 小变体(E07A/E07B/E07C),高度只有正版的 ~70%。
54+
克隆到 chrome overlay 里就看到谱号每次跨 mid-piece 切换
55+
都缩一圈。
56+
解:三键映射(E07A→E050, E07B→E05C, E07C→E062),SMuFL
57+
规范保证小写大写共享 reference point,所以替换后五线对位
58+
不变。
59+
这一节是上一节的小注脚——「视觉签名」作为真理源也有它
60+
自己的边界,需要再向下走一步看到字形编码层。
61+
62+
clocks — 时间也是一把容易看错的尺:rAF 不等于 AudioContext。
63+
播放头要走得准,音符要响得准,两边对的是同一个时间——
64+
但它们用的不是同一只钟。视觉走 rAF(屏幕刷新率),声音
65+
走 AudioContext(硬件采样钟),两只会缓慢漂移。
66+
另一个具体的版本:MEI 的 `<tempo>` tstamp 在小节起点,
67+
glyph 在小节中段才出现。按 tstamp 触发 overlay 切换 →
68+
文字在还没到播放头时就换,几百 ms 的滞后。
69+
解:retiming——算 glyph 的实际 x 位置,反推它过播放头
70+
的那一刻的 ms,用那个 ms 触发切换。
71+
72+
recap — 这些之后留下的不是技术总结,是一种习惯:先怀疑自己
73+
用的尺。直到把"我以为在测什么"和"我实际在测什么"对
74+
齐了,所有后续修补都是在错的层面打补丁。
75+
*/}
76+
77+
<section data-cue="opener">
78+
79+
{/* TODO: 引。每个难题的形状都一样:第一直觉的单位错了。 */}
80+
81+
</section>
82+
83+
<section data-cue="ruler">
84+
85+
{/* TODO: staff-anchor 缩放。bbox 不是合理的尺。 */}
86+
87+
<Score slug="jeux-deau-ravel" />
88+
89+
</section>
90+
91+
<section data-cue="ceiling">
92+
93+
{/* TODO: GPU tile 切片。37k 像素 vs 8,192 上限。 */}
94+
95+
</section>
96+
97+
<section data-cue="source">
98+
99+
{/* TODO: per-axis chrome + visual signature dedup。 */}
100+
101+
<Score slug="all-keys" />
102+
103+
</section>
104+
105+
<section data-cue="glyph">
106+
107+
{/* TODO: small→full 谱号映射。SMuFL E07A→E050。 */}
108+
109+
<Score slug="chaos" />
110+
111+
</section>
112+
113+
<section data-cue="clocks">
114+
115+
{/* TODO: rAF 钟 vs AudioContext 钟,tempo overlay retiming。 */}
116+
117+
<Score slug="jeux-deau" />
118+
119+
</section>
120+
121+
<section data-cue="recap">
122+
123+
{/* TODO: 收。先怀疑自己用的尺。 */}
124+
125+
</section>

0 commit comments

Comments
 (0)