Webhek

用 CSS grid 呈现五线谱

我经常目睹即兴音乐家在演出的高潮时,满头大汗地试图在狭小的手机屏幕上捏缩一张 A4 PDF 文件。我们需要流畅、反应灵敏的 web 乐谱呈现!

Stephen Band

在网络上,音乐符号应该像文字一样易懂、流畅;而现在却不是这样,这对我的感性来说是一种侮辱。让我们来解决这个迫切的问题。

乐谱

SVG rendered by Scribe 0.2

几年前,我制作了一个名为 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> 中,我们就能得到:

好的。没什么可看的,但仔细观察后我们会发现,现在谱表上的每一行和每个空格都有自己的音高命名网格线,以识别每一行:

Named grid rows

在五线谱上放置音高

五线谱上的任何一行都可能包含多个音高中的任何一个。例如,降 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:

Named grid rows

在节拍处放置符号

这次,我将使用 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 设置为 15 之间的节拍,将 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 为我们提供了一些很好的控制指标。现在我们的目标就是调整这些指标,以提高可读性。

谱号和时号

你可能想知道,我为什么要为垂直和水平间距使用不同的类,为什么不使用一个类呢?将两个轴分开意味着可以互换一个轴,而不用另一个轴。以旋律为例:

0.5 B3 0.2 1.5 2 D4 0.2 1 3 F#4 0.2 1 4 E4 0.2 1 5 D4 0.2 1 6 B3 0.2 0.5 6.5 G3 0.2 0.5

要在低音谱号上显示同样的旋律,可以将stave类换成bass-stave类,该类可将相同的data-pitch 属性映射到低音谱表线:


    <div class="bass-stave bar">...</div>
    
0.5 B3 0.2 1.5 2 D4 0.2 1 3 F#4 0.2 1 4 E4 0.2 1 5 D4 0.2 1 6 B3 0.2 0.5 6.5 G3 0.2 0.5

或者,如果使用 CSS 将 data-duration="5" 映射到 .bar 上的 120 个 grid-template-columns 中,同样的节拍也可以被赋予 5/4 的时间特征:


    <div class="bass-stave bar" data-duration="5">...</div>
    
0.5 B3 0.2 1.5 2 D4 0.2 1 3 F#4 0.2 1 4 E4 0.2 1 5 D4 0.2 1 6 B3 0.2 0.5 6.5 G3 0.2 0.5

显然,我忽略了一些细节。并不是所有的事情都像换 class 那么简单,一些主干和谱表线需要重新定位。

下面是一个完全重新映射音高的谱表类。一般 MIDI 将鼓和打击乐器的声音放在键盘底部八度的一组音符上,但这些音符与鼓在谱表上的位置无关。在 CSS 中可以定义一个 drums-stave 类,将这些音高映射到正确的行上:


    <div class="drums-stave bar" data-duration="4">...</div>
    

4

4


    <div class="percussion-stave bar" data-duration="4">...</div>
    

4

4

这就是非常易读的鼓谱。我对此非常满意。

和弦和歌词

CSS 网格允许我们在记谱网格内对齐其他符号。和弦和歌词、动态等都可以与时间事件对齐或跨度:

4

4

In

Amaj

the

bleak

Amaj/G

mid-

win-

C79

ter,

F-7

A7sus

Frost-

Dmaj

y

wind

B/D

made

moan–

E7sus9

C/E

但是那些梁呢?

通过将梁、弦杆和一些较长的托架的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 为单位,因此要缩放它,我们只需改变字体大小即可:

0 meter 2 1 0 E4 1 0.5 1 B4 1 0.5 2 Bb4 1 2

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>
    
0 chord Dmaj 4 0 F#5 0.2 4 0 A4 0.2 4 0 D4 0.2 4

或从其 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> 制作的《海豚之舞》可移调主乐谱:

Dolphin Dance

Herbie Hancock

[ [0, "chord", "C", "∆", 4], [4, "chord", "G", "-", 4], [8, "chord", "C", "∆", 4], [12, "chord", "B", "ø", 2], [14, "chord", "E", "7alt", 2], [16, "chord", "A", "-", 4], [20, "chord", "F", "∆(♯11)", 2], [22, "chord", "E", "7alt", 2], [24, "chord", "A", "-", 4], [28, "chord", "F♯", "-7", 2], [30, "chord", "B", "7", 2], [32, "chord", "E", "∆", 4], [36, "chord", "F", "-7", 4], [40, "chord", "D", "-7", 6], [46, "chord", "E", "7alt", 2], [48, "chord", "A", "-7", 8], [56, "chord", "F♯", "-7", 4], [60, "chord", "B", "7", 4], [64, "chord", "E", "∆", 4], [68, "chord", "E", "7sus", 4], [72, "chord", "E", "∆♯11", 4], [76, "chord", "E", "7sus", 4], [80, "chord", "D", "7sus", 4], [84, "chord", "D", "∆♯11", 4], [88, "chord", "D", "7sus", 4], [92, "chord", "C♯", "-7", 2], [94, "chord", "F♯", "7", 2], [96, "chord", "C", "7♯11", 4], [100, "chord", "F♯", "-7", 2], [102, "chord", "B", "7", 2], [104, "chord", "G♯", "-7", 2], [108, "chord", "C♯", "7", 2], [110, "chord", "B", "-7", 2], [112, "chord", "B♭", "-7", 4], [116, "chord", "E♭", "7", 4], [120, "chord", "C♯", "-7", 4], [124, "chord", "C♯", "-♭6", 4], [128, "chord", "C♯", "-7", 4], [132, "chord", "C♯", "-♭6", 4], [136, "chord", "C", "7sus", 4], [140, "chord", "C", "∆♭6", 4], [144, "chord", "C", "7sus♭9", 4], [148, "chord", "E", "7alt", 4], [2, "note", 76, 0.25, 0.5], [2.5, "note", 77, 0.25, 0.5], [3, "note", 79, 0.25, 0.5], [3.5, "note", 74, 0.25, 3.5], [10, "note", 76, 0.25, 0.5], [10.5, "note", 77, 0.25, 0.5], [11, "note", 79, 0.25, 0.5], [11.5, "note", 74, 0.25, 3.5], [18, "note", 72, 0.25, 0.5], [18.5, "note", 74, 0.25, 1], [19.5, "note", 76, 0.25, 0.5], [20, "note", 71, 0.25, 1], [21, "note", 71, 0.25, 2], [26, "note", 72, 0.25, 0.5], [26.5, "note", 74, 0.25, 0.5], [27, "note", 76, 0.25, 0.5], [27.5, "note", 71, 0.25, 3.5], [31, "note", 69, 0.25, 1], [32, "note", 68, 0.25, 1.5], [33.5, "note", 75, 0.25, 2.5], [36, "note", 75, 0.25, 1.5], [37.5, "note", 75, 0.25, 0.5], [38, "note", 77, 0.25, 0.5], [38.5, "note", 75, 0.25, 0.5], [39, "note", 77, 0.25, 0.5], [39.5, "note", 79, 0.25, 4.5], [48, "note", 76, 0.25, 1.5], [49.5, "note", 79, 0.25, 2.5], [52, "note", 79, 0.25, 1], [53, "note", 79, 0.25, 0.5], [53.5, "note", 79, 0.25, 0.5], [54, "note", 81, 0.25, 0.5], [54.5, "note", 79, 0.25, 0.5], [55, "note", 81, 0.25, 0.5], [55.5, "note", 83, 0.25, 4.5], [66, "note", 80, 0.25, 0.5], [66.5, "note", 81, 0.25, 0.5], [67, "note", 83, 0.25, 0.5], [67.5, "note", 78, 0.25, 3.5], [74, "note", 76, 0.25, 0.5], [74.5, "note", 78, 0.25, 0.5], [75, "note", 80, 0.25, 0.5], [75.5, "note", 74, 0.25, 3.5], [82, "note", 72, 0.25, 0.5], [82.5, "note", 74, 0.25, 1], [83.5, "note", 76, 0.25, 0.5], [84, "note", 71, 0.25, 1], [85, "note", 71, 0.25, 2], [90, "note", 72, 0.25, 0.5], [90.5, "note", 74, 0.25, 1], [91, "note", 76, 0.25, 0.5], [91.5, "note", 78, 0.25, 4.5], [96, "note", 78, 0.25, 0.5], [96.5, "note", 79, 0.25, 0.5], [97.5, "note", 78, 0.25, 0.25], [97.75, "note", 77, 0.25, 0.25], [98, "note", 78, 0.25, 1], [99, "note", 78, 0.25, 0.5], [99.5, "note", 83, 0.25, 0.5], [100.5, "note", 80, 0.25, 3.5], [104, "note", 80, 0.25, 0.5], [104.5, "note", 82, 0.25, 0.5], [105.5, "note", 80, 0.25, 0.25], [105.75, "note", 78, 0.25, 0.25], [106, "note", 80, 0.25, 1], [107, "note", 80, 0.25, 0.5], [107.5, "note", 85, 0.25, 2.5], [110, "note", 86, 0.25, 2], [112, "note", 87, 0.25, 1.5], [113.5, "note", 85, 0.25, 1], [114.5, "note", 80, 0.25, 1], [115.5, "note", 77, 0.25, 0.5], [116, "note", 84, 0.25, 3], [119, "note", 75, 0.25, 0.5], [119.5, "note", 80, 0.25, 8.5], [138, "note", 81, 0.25, 0.5], [138.5, "note", 82, 0.25, 0.5], [139, "note", 84, 0.25, 0.5], [139.5, "note", 79, 0.25, 3.5], [146, "note", 76, 0.25, 0.5], [146.5, "note", 77, 0.25, 0.5], [147, "note", 79, 0.25, 0.5], [147.5, "note", 74, 0.25, 3.5] ]

Make your website sing