Skip to content

Commit 1b7d379

Browse files
RuitingMaclaude
andcommitted
Essays: write 滚谱的扎法 prose (all seven sections)
Replace the section TODOs with full prose. Voice tracks zhui-guang's rhythm — short sentences, dense images, technical content embedded without becoming a code dump. Each section's prose is shaped to coordinate with a future sketch state on the left stage: empty — sparse / single thread; setting up "what's missing". strip — thread lays flat across the canvas; mirrors `breaks: 'none'`. pipeline — three colored zones, the MEI → SVG → DOM transformation. layers — multiple parallel threads, stacked. lifecycle— fade-in / fade-out passes (mount on viewport, swap on nav). ticking — two perpendicular pulses, slight phase offset (rAF vs AudioContext). recap — full weave, gentle breathing. The sketch itself isn't here yet; the prose is the contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 466a4c0 commit 1b7d379

1 file changed

Lines changed: 51 additions & 49 deletions

File tree

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

Lines changed: 51 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,90 +8,92 @@ tint: dune
88

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

11-
{/*
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-
一处都是为这一个用例选的。
51-
*/}
52-
5311
<section data-cue="empty">
5412

55-
{/* TODO: 起。MEI/MusicXML/Verovio 一句带过;这篇讲的是组件。 */}
13+
把谱子嵌进文章不是新事。PDF 截图、MuseScore embed、各种笨重的方块插在文字之间,你看,或者不看。
14+
15+
我想要的是另一种:能跟着读的谱子。播放头沿五线谱往前走时,谱面在眼前横着翻过去;想停就停,想拖回去也行;声音同时响——不为演奏,是为了让你"知道这一段听起来是怎么样"。32 分音符密到眼睛跟不上时,耳朵补眼睛。
16+
17+
画这一步早就解过了。MEI 和 MusicXML 是写谱面的两种 XML 方言,Verovio 是个把它们渲到 SVG 的 WASM 工具包;解得比我能解的好。这篇讲的不是怎么画出音符——讲的是怎么把那块 SVG 扎成一个**组件**:有舞台,有状态,有生命周期,有自己的钟,能跟 SPA 路由协调着活。
5618

5719
</section>
5820

5921
<section data-cue="strip">
6022

61-
{/* TODO: `breaks:'none'` 决定一切。一句 setOption 选择了所有后续。 */}
23+
整套架构的地基是一句 setOption。
24+
25+
Verovio 渲谱面默认按 A4 / Letter 分页,超出一行换行,超出一页开新页。我把页宽设成 100,000,把 `breaks` 设成 `'none'`——它再也不分页。整首曲子摊成一条横向的带子,一直延展到自然 bbox 那么宽。Ravel 全曲就是这样一条 ~37,000 像素长的带子。
26+
27+
之后所有事都从这一句来。播放头能立得住,是因为整首曲子在水平方向是连续的;横向滚动的 translate3d 才有意义;切片才有切处;循环才能首尾相接。如果是分页布局,每一段逻辑——锚点、播放头、tile、loop wrap——都得另写一遍。
28+
29+
一句 setOption 决定的不是排版,是这个组件存在的形式。
6230

6331
<Score slug="jeux-deau" />
6432

6533
</section>
6634

6735
<section data-cue="pipeline">
6836

69-
{/* TODO: MEI → Verovio → SVG → DOM 的三步走。一次性烧 vs 运行时。 */}
37+
谱子从 MEI 文件变成能滚的乐谱,要走三步。
38+
39+
第一步:Verovio 把 MEI 渲成 SVG。这是最贵的一步——慢曲子在 Web Worker 里要 5 到 15 秒,所以渲完缓存到 IndexedDB;再访问就不重渲。出来的 SVG 里包含曲子全部的"静态形状":每个音符的位置、调号、谱号、装饰、连音线、踏板标记——一切不随播放变化的东西。
40+
41+
第二步:把这块 SVG 注入 DOM。贴进 `.score-pan` 容器里;另起一块 SVG 单独画 5 条横线作为 staff lines;frozen header(谱号 / 调号 / 拍号)也是另一块 SVG,从渲染结果里克隆出来。pan 里那大块自此不再变化——所有"动"都发生在它之外。
42+
43+
第三步是运行时:rAF 每帧读一次音频钟,把 pan 平移到对应的 x 位置;音频调度器独立排音符;播放头始终钉在屏幕同一个像素上。
44+
45+
渲染发生一次,播放发生千次。
7046

7147
</section>
7248

7349
<section data-cue="layers">
7450

75-
{/* TODO: pan / chrome / staff-lines / playhead / hint 各管各。 */}
51+
舞台不是一块,是好几块叠在一起的。
52+
53+
最底是 staff lines 那一层——5 条横线,单独画,单一来源。中间是 pan,盛着 Verovio 出的整块 SVG,每一帧靠 translate3d 横向平移。pan 上面盖一层 frozen header,也就是"假装不动"的谱号区域;它确实不动——pan 在它下面滑过而它本身物理上在另一个 DOM 节点里,永远固定在最左边。最上面是播放头那根竖线,加上"click to play"的提示文字。
54+
55+
这样分层是有理由的。让 pan 的同一块 SVG 同时承担"流动的音符"和"固定的报头",每次跨过谱号变化时——克隆的报头要跟着 pan 平移、再用 transform 抵消平移、再交叉淡入淡出——又复杂又容易抖。物理分层之后,pan 滚 pan 的,header 替 header 的,每一层只对自己的事情负责。
56+
57+
谁先画、谁压在谁上、谁有 mask、谁有 backdrop-filter,都是 z-index + position 的常规活。但前提是它们必须先分层。
7658

7759
</section>
7860

7961
<section data-cue="lifecycle">
8062

81-
{/* TODO: IntersectionObserver 挂载、astro:before-swap 拆除、缓存。 */}
63+
不是一进页面就把 Verovio 醒过来。
64+
65+
WASM 是大件,一首曲子可能有几千个音符要解析、几万个 SVG 节点要渲染——主线程容易卡。所以 IntersectionObserver 监视每个 Score 容器,等它滚到视口附近 200 像素再开始 fetch + render。一页放几个 Score 也好,看不到的那些就不醒。
66+
67+
Astro 的 ClientRouter 切页面时会发 `astro:before-swap`。正在播的暂停,正在跑的 rAF 取消,listeners 摘除,AudioContext 关。新页面挂上自己的 score 实例,老实例的清理早就执行过了。SPA 切换像翻页,谱子也跟着翻干净。
68+
69+
缓存分两层:内存 LRU 装最近 12 个,IndexedDB 装持久化(schema 版本化,schema 改一次旧的全清)。访问过的曲子下次再来——直接绕过那 5–15 秒的二度渲染,瞬间出图。
8270

8371
</section>
8472

8573
<section data-cue="ticking">
8674

87-
{/* TODO: rAF 钟 + AudioContext 钟。两只钟同跳一支舞。 */}
75+
演奏的时候,组件里有两只钟。
76+
77+
视觉走 rAF。每一帧从音频钟读当前 ms,去 anchors 表里查这一刻播放头该在哪个像素,算出 pan 该平移多少,写 translate3d。这只钟跑屏幕刷新率,60Hz、120Hz 都行——但它不是音频时钟。浏览器 rAF 偶尔会跳帧,切到后台 tab 还会停。
78+
79+
声音走 AudioContext。音频调度器每 100ms 醒一次,往前看 250ms,把这段时间窗里还没排进去的音符全 schedule 出去;一个 cursor 记着排到哪了,下次接着往后排。这只钟是硬件采样钟——独立于显示线程,准且稳。
80+
81+
两只钟之间没有谁同步谁。视觉钟问音频钟现在几点,但不试图改它;音频钟自顾自往前走,音符甚至已经排到 250ms 之后了。它们在同一首曲子上跳同一支舞——一只看着对方的脚步,但谁也不主导。
8882

8983
<Score slug="jeux-deau" loop />
9084

9185
</section>
9286

9387
<section data-cue="recap">
9488

95-
{/* TODO: 收。整套是为一个人读自己谱子写的。 */}
89+
整套的形是这样的:
90+
91+
一条横向的带子。
92+
几层平行的 view,互不干扰。
93+
两只钟在同一首曲子上各跑各的。
94+
95+
写这个组件不是为了做通用乐谱播放器。它是为了让"一个人想看自己手写的几小节"这件事舒服一点。所以每一处选择都很死:单一系统、固定播放头位置、目标 staff 高度由作者指定、循环只为短句反复倾听。任何超出这个用例的事,组件都不擅长。
96+
97+
但也正因为边界这么死,里面每一处选择才能彼此咬合。`breaks: 'none'` 让横向滚动有意义,分层让 chrome 假装不动,rAF + AudioContext 的双钟让看和听各自准。每一块都是为这一个用例选的——所以它们能扎得这么紧。
9698

9799
</section>

0 commit comments

Comments
 (0)