用 CSS grid 呈现五线谱
我经常目睹即兴音乐家在演出的高潮时,满头大汗地试图在狭小的手机屏幕上捏缩一张 A4 PDF 文件。我们需要流畅、反应灵敏的 web 乐谱呈现!
Stephen Band
在网络上,音乐符号应该像文字一样易懂、流畅;而现在却不是这样,这对我的感性来说是一种侮辱。让我们来解决这个迫切的问题。
乐谱
几年前,我制作了一个名为 Scribe 的音乐渲染器原型,它可以从 JSON 输出 SVG。最初的目标是制作一个响应式乐谱生成器。这是一个很好的演示,但为了取得进展,我必须编写一个复杂的多通道布局引擎,而且,其他事情也阻碍了我的工作。
制作完成后不久,我正忙着在 Cruncher 的项目中采用 Grid,当时它的一些东西让我觉得很熟悉,我在想它是不是可以解决我在 Scribe 中遇到的一些布局问题。
The class .stave
五线谱是网格状的。音高沿纵轴向上绘制,时间沿横轴从左到右绘制。我将在两个独立的类中定义这两个轴。定义网格行的纵轴将称为 .stave
。我们稍后将讨论时间轴。
.stave
具有固定大小的网格行,以标准音高名称命名,并具有绘制五线谱的背景图片。因此,高音谱表的行图可能如下所示:
.stave {
display: grid;
row-gap: 0;
grid-template-rows:
[A5] 0.25em [G5] 0.25em [F5] 0.25em [E5] 0.25em
[D5] 0.25em [C5] 0.25em [B4] 0.25em [A4] 0.25em
[G4] 0.25em [F4] 0.25em [E4] 0.25em [D4] 0.25em
[C4] 0.25em ;
background-image: url('/path/to/stave.svg');
background-repeat: no-repeat;
background-size: 100% 2.25em;
background-position: 0 50%;
}
将其应用到 <div>
中,我们就能得到:
好的。没什么可看的,但仔细观察后我们会发现,现在谱表上的每一行和每个空格都有自己的音高命名网格线,以识别每一行:
在五线谱上放置音高
五线谱上的任何一行都可能包含多个音高中的任何一个。例如,降 G、G 和 G♯ 必须都位于 G 音阶线上。
为了将代表这些音高的 DOM 元素放置在正确的行中,我将把音高名称放在数据音高属性中,并使用 CSS 将数据音高值映射到谱表行上。
.stave > [data-pitch^="G"][data-pitch$="4"] { grid-row-start: G4; }
这条规则捕捉以 "G
"开头、以 "4
"结尾的音高,因此它将音高 "G♭4
"、"G4
"和 "G♯4
"(以及双平音 "G𝄫4
"和双升音 "G𝄪4
")分配到 G4
行。每一行都需要这样做:
.stave > [data-pitch^="A"][data-pitch$="5"] { grid-row-start: A5; }
.stave > [data-pitch^="G"][data-pitch$="5"] { grid-row-start: G5; }
.stave > [data-pitch^="F"][data-pitch$="5"] { grid-row-start: F5; }
.stave > [data-pitch^="E"][data-pitch$="5"] { grid-row-start: E5; }
.stave > [data-pitch^="D"][data-pitch$="5"] { grid-row-start: D5; }
...
.stave > [data-pitch^="D"][data-pitch$="4"] { grid-row-start: D4; }
.stave > [data-pitch^="C"][data-pitch$="4"] { grid-row-start: C4; }
这应该足以让我们开始在乐谱上放置符号了!我有一堆为 Scribe 原型准备的 SVG 符号,让我们试着在谱表上放置几个:
<div class="stave">
<svg data-pitch="G4" class="head">
<use href="#head[2]"></use>
</svg>
<svg data-pitch="E5" class="head">
<use href="#head[2]"></use>
</svg>
</div>
看起来很有希望。下一个,时间
类 .bar
及其节拍
节奏的处理可能比较棘手。没有一种一目了然的最小节奏划分可以支持所有类型的节奏。必须判断网格内应支持的最小音符长度和交叉节奏。
每拍 24 列的方法支持节拍的划分,可均匀分布八分音符(12 列)、十六分音符(6 列)、三十二分音符(3 列)以及这些音符的三连音值。这是一个很好的起点。
下面是一个 4 拍小节,定义为 4 × 24 = 96 列网格,加上开头一列和结尾一列:
.bar {
column-gap: 0.03125em;
grid-template-columns:
[bar-begin]
max-content
repeat(96, minmax(max-content, auto))
max-content
[bar-end];
}
添加几个小节行作为 ::before
和 ::after
的内容,并在其中放置一个五线谱符号,以五线谱为中心,data-pitch="B4"
,我们就得到了:
<div class="stave bar">
<svg data-pitch="B4" class="treble-clef">
<use href="#treble-clef"></use>
</svg>
</div>
检查后我们会发现,谱号已下降到第一列,共有 96 列零宽度列,每拍 24 列,每列之间有一个小 column-gap
:
在节拍处放置符号
这次,我将使用 data-beat
属性为元素指定节拍,并使用 CSS 规则将节拍映射到网格列。CSS 映射看起来像这样,每 1/24 拍就有一条规则:
.bar > [data-beat^="1"] { grid-column-start: 2; }
.bar > [data-beat^="1.04"] { grid-column-start: 3; }
.bar > [data-beat^="1.08"] { grid-column-start: 4; }
.bar > [data-beat^="1.12"] { grid-column-start: 5; }
.bar > [data-beat^="1.16"] { grid-column-start: 6; }
.bar > [data-beat^="1.20"] { grid-column-start: 7; }
.bar > [data-beat^="1.25"] { grid-column-start: 8; }
...
.bar > [data-beat^="4.95"] { grid-column-start: 97; }
属性 ^= starts-with
选择器使规则具有容错性。在某些情况下,非四舍五入或浮点数不可避免地会出现在data-beat
中。小数点后两位数足以识别 1/24 拍的网格列
将其与我们的 stave
类放在一起,通过将 data-beat
设置为 1
至 5
之间的节拍,将 data-pitch
设置为音符名称,我们就能按节拍和音高定位符号。这样,包含这些符号的节拍列就会随之增长:
<div class="stave bar">
<svg class="clef" data-pitch="B4">…</svg>
<svg class="flat" data-beat="1" data-pitch="Bb4">…</svg>
<svg class="head" data-beat="1" data-pitch="Bb4">…</svg>
<svg class="head" data-beat="2" data-pitch="D4">…</svg>
<svg class="head" data-beat="3" data-pitch="G5">…</svg>
<svg class="rest" data-beat="4" data-pitch="B4">…</svg>
</div>
Ooo. 音束?
Yup. 小尾巴?
是的。尾巴间距还可以改进(用边距就可以实现),但定位是有效的。
流畅的响应式记谱法
将一大堆这样的条形图粘贴在一个包裹起来的 flexbox 容器中,我们就能看到响应式乐谱了:
<figure class="flex">
<div class="treble-stave stave bar">…</div>
<div class="treble-stave stave bar">…</div>
<div class="treble-stave stave bar">…</div>
…
</figure>
虽然还有很多不足之处,但这是一个很好的起点。它已经比我见过的在线音乐渲染器包装得更优雅了。
音符之间的空间
暂且忽略这些横梁,请注意,在渲染时,音符之间的距离会稍稍拉近:
这是一种微妙的、刻意的效果,是由小的column-gap
造成的,它就像一种时间 "ether",将符号元素插入其中。除非其中有音符头,否则音柱本身的宽度为零,但音柱间距更大(每拍 24 个),间隔更远,因此距离更远。
恒定的间距可以通过调整符号的边距来控制。为了获得更恒定的间距,我们可以减少 column-gap
,同时增加音符头的边距:
但是,唉,这看起来很糟糕,因为 head 间距让读者无法了解节奏有多快。问题的关键在于,CSS 为我们提供了一些很好的控制指标。现在我们的目标就是调整这些指标,以提高可读性。
谱号和时号
你可能想知道,我为什么要为垂直和水平间距使用不同的类,为什么不使用一个类呢?将两个轴分开意味着可以互换一个轴,而不用另一个轴。以旋律为例:
要在低音谱号上显示同样的旋律,可以将stave
类换成bass-stave
类,该类可将相同的data-pitch
属性映射到低音谱表线:
<div class="bass-stave bar">...</div>
或者,如果使用 CSS 将 data-duration="5"
映射到 .bar
上的 120 个 grid-template-columns
中,同样的节拍也可以被赋予 5/4
的时间特征:
<div class="bass-stave bar" data-duration="5">...</div>
显然,我忽略了一些细节。并不是所有的事情都像换 class 那么简单,一些主干和谱表线需要重新定位。
下面是一个完全重新映射音高的谱表类。一般 MIDI 将鼓和打击乐器的声音放在键盘底部八度的一组音符上,但这些音符与鼓在谱表上的位置无关。在 CSS 中可以定义一个 drums-stave
类,将这些音高映射到正确的行上:
<div class="drums-stave bar" data-duration="4">...</div>
<div class="percussion-stave bar" data-duration="4">...</div>
这就是非常易读的鼓谱。我对此非常满意。
和弦和歌词
CSS 网格允许我们在记谱网格内对齐其他符号。和弦和歌词、动态等都可以与时间事件对齐或跨度:
但是那些梁呢?
通过将梁、弦杆和一些较长的托架的data-duration
属性映射到grid-column-end
跨度值,可以使它们跨柱:
.stave > [data-duration="0.25"] { grid-column-end: span 6; }
.stave > [data-duration="0.5"] { grid-column-end: span 12; }
.stave > [data-duration="0.75"] { grid-column-end: span 18; }
.stave > [data-duration="1"] { grid-column-end: span 24; }
.stave > [data-duration="1.25"] { grid-column-end: span 30; }
...
Simple as, bru.
调整大小
最后,整个系统的大小以 em
为单位,因此要缩放它,我们只需改变字体大小即可:
Flex 和 Grid 的局限性
它是完美的系统吗?老实说,我对它能如此出色地工作感到非常惊讶,但如果我们要寻找注意事项的话......1.CSS 不能自动将新的谱号/调号定位在每一行的开头,或者 2.3. 有角度的音梁本身就是一个故事;1/16 和 1/32 音的音梁很难对齐,因为在网格布局之前,我们无法准确知道它们的音头在哪里:
因此,还需要对 JavaScript 进行一些整理才能完成全部工作,但 CSS 承担了这里大部分的布局工作,这意味着 JavaScript 中的布局工作要少得多。
让我知道你的想法
如果你喜欢这个 CSS 作品或这篇博文,或者知道如何改进,请务必告诉我 I'm on Bluesky @stephen.band, Mastodon @stephband@front-end.social, and Twitter (still, just about) @stephband. 或者和我一起改进 Scribe repo。
<scribe-music>
用于呈现乐谱的自定义元素
我围绕这个新的 CSS 系统编写了一个解释器,并将其封装在 <scribe-music>
元素中。虽然它还远未达到可投入生产的程度,但由于它已经能够渲染反应灵敏的乐谱和记谱鼓,我认为它既有趣又有用。
它是什么?
The <scribe-music>
元素根据其内容中的数据渲染音乐符号:
<scribe-music type="sequence">
0 chord D maj 4
0 F#5 0.2 4
0 A4 0.2 4
0 D4 0.2 4
</scribe-music>
或从其 src
属性获取的文件中获取,例如此 JSON 文件:
<scribe-music
clef="drums"
type="application/json"
src="/static/blog/printing-music/data/caravan.json">
</scribe-music>
或来自元素 .data
属性上设置的 JS 对象。
README 中有一些关于这些方面的基本文档。
试用
你可以在网页中导入这些文件,试试当前的开发版本:
<link rel="stylesheet" href="https://stephen.band/scribe/scribe-music/module.css" />
<script type="module" src="https://stephen.band/scribe/scribe-music/module.js"></script>
正如我所说,它正在开发中。除了对 Scribe 0.3 的一些即时改进(如调整自动音阶器、修复 1/16 音束以及检测和显示小音符)外,我还想研究一些长期功能:
- 支持 SMuFL fonts 字体--更改记号符号使用的字体。到目前为止,我还无法跨浏览器可靠地显示其扩展字符集。
- 支持嵌套序列--支持多声部曲调。
- 分割谱表渲染--在一个谱表上放置多个乐段。这方面的机制已经建立了一半--鼓谱和钢琴谱目前可按音高自动分割。
- 多谱表渲染--将多个声部放在多个对齐的谱表上。
下面是由 <scribe-music>
制作的《海豚之舞》可移调主乐谱: