https://cruncher.ch/blog/printing-music-with-css-grid/
Cruncher
Show navigation
Printing music with CSS Grid
Too often have I witnessed the improvising musician sweaty-handedly
attempting to pinch-zoom an A4 pdf on a tiny mobile screen at the
climax of a gig. We need fluid and responsive music rendering for the
web!
Stephen Band 24 Apr 2024
Music notation should be as accessible and as fluid as text is, on
the web; that it is not, yet, is something of an afront to my
sensibilities. Let us fix this pressing problem.
The Scribe prototype
[scribe-020] SVG rendered by Scribe 0.2
Some years ago I prototyped a music renderer I called Scribe that
outputs SVG from JSON. The original goal was to produce a responsive
music renderer. It was a good demo, but to progress I was going to
have to write a complex multi-pass layout engine, and, well, other
things got in the way.
Shortly after making that I was busy adopting Grid into our projects
at Cruncher when something about it struck me as familiar, and I
wondered if it might not be an answer to some of the layout problems
I had been tackling in Scribe.
The class .stave
The musical staff is grid-like. Pitch is plotted up the vertical axis
and time runs left to right along the horizontal axis. I am going to
define these two axes in two seperate classes. The vertical axis,
defining grid rows, will be called .stave. We'll get to the time axis
in a bit.
A .stave has fixed-size grid rows named with standard pitch names,
and a background image that draws the staff. So for a treble clef
stave the row map might look like this:
.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%;
}
Which, applied to a
gives us:
Ok. Not much to see, but on inspecting it we do see that each line
and each space on the stave now has its own pitch-named grid line to
identify each row:
Named grid rows
Placing pitches up the stave
Any given row on a stave may contain any of several pitches. The
pitches G, G and G# must all sit on the G stave line, for example.
To place DOM elements that represent those pitches in their correct
rows I am going to put pitch names in data-pitch attributes and use
CSS to map data-pitch values to stave rows.
.stave > [data-pitch^="G"][data-pitch$="4"] { grid-row-start: G4; }
This rule captures pitches that start with 'G' and end with '4', so
it assigns pitches 'G4', 'G4' and 'G#4' (and double flat 'G4' and
double sharp 'G4') to the G4 row. That does need to be done for
every stave row:
.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; }
That should give us enough to begin placing symbols on a stave! I
have a bunch of SVG symbols that were prepared for the Scribe
prototype, so let's try placing a couple on a stave:
That looks promising. Next, time.
The class .bar and its beats
Rhythm is perhaps a little trickier to handle. There is not one
immediately obvious smallest rhythmic division to adopt that will
support all kinds of rhythms. A judgement call must be made about
what minimum note lengths and what cross-rhythms to support inside a
grid.
A 24-column-per-beat approach supports beat divisions to evenly lay
out eighth notes (12 columns), sixteenth notes (6 columns) 32nd notes
(3 columns) as well as triplet values of those notes. It's a good
starting point.
Here is a 4 beat bar defined as 4 x 24 = 96 grid columns, plus a
column at the beginning and one at the end:
.bar {
column-gap: 0.03125em;
grid-template-columns:
[bar-begin]
max-content
repeat(96, minmax(max-content, auto))
max-content
[bar-end];
}
Add a couple of bar lines as ::before and ::after content, and put a
clef symbol in there centred on the stave with data-pitch="B4", and
we get:
Inspect that and we see that the clef has dropped into the first
column, and there are 96 zero-width columns, 24 per beat, each
seperated by a small column-gap:
Named grid rows
Placing symbols at beats
This time I am going to use data-beat attributes to assign elements a
beat, and CSS rules to map beats to grid columns. The CSS map looks
like this, with a rule for each 1/24th of a beat:
.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; }
The attribute ^= starts-with selector makes the rule error-tolerant.
At some point, inevitably, unrounded or floating point numbers will
be rendered into data-beat. Two of their decimal places is enough to
identify a 1/24th-of-a-beat grid column.
Put that together with our stave class and we are able to position
symbols by beat and pitch by setting data-beat to a beat between 1
and 5, and data-pitch to a note name. As we do, the beat columns
containing those symbols grow to accommodate them:
Ooo. Stems?
Yup. Tails?
Yup. The tail spacing can be improved (which should be achievable
with margins) - but the positioning works.
Fluid and responsive notation
Stick a whole bunch of bars like these together in a flexbox
container that wraps and we start to see responsive music:
...
...
...
...
There are clearly a bunch of things missing from this, but this is a
great base to start from. It already wraps more gracefully than I
have yet seen an online music renderer do.
The space between the notes
Ignoring these beams for a moment, notice that note heads that occur
closer in time to one another are rendered slightly closer together:
It's a subtle, deliberate effect created by the small column-gap,
which serves as a sort of time 'ether' into which symbol elements
slot. Columns themselves are zero width unless there is a note head
in them, but there are more column-gaps - 24 per beat - between
events that are further apart in beats, and so more distance.
Constant spacing can be controlled by adjusting margins on the
symbols. To get a more constant spacing here we would reduce the
column-gap while increasing the margin of note heads:
But ugh, that looks bad, because the head spacings give the reader no
clue as to how rapid the rhythm is. The point is, CSS is giving us
some nice control over the metrics. And the aim now is to tweak those
metrics for readability.
Clefs and time signatures
You may be wondering why I employed seperate classes for vertical and
horizontal spacing, why not just one? Seperating the axes means that
one can be swapped out without the other. Take the melody:
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
To display this same melody on a bass clef, the stave class can be
swapped out for a bass-stave class that maps the same data-pitch
attributes to bass stave lines:
...
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
Or, with CSS that mapped data-duration="5" to 120
grid-template-columns on .bar, the same stave could be given a time
signature of 5/4:
...
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
Clearly I am glossing over a few details. Not everything is as simple
as a class change, and a few stems and ledger lines need
repositioned.
Here's a stave class that remaps pitches entirely. General MIDI
places drum and percussion voices on a bunch of notes in the bottom
octaves of a keyboard, but those notes are not related to where drums
are printed on a stave. In CSS a drums-stave class can be defined
that maps those pitches to their correct rows:
...
4
4
...
4
4
That's some very readable drum notation. I'm pretty pleased with
that.
Chords and lyrics
CSS Grid allows us to align other symbols inside the notation grid
too. Chords and lyrics, dynamics and so on can be lined up with, and
span, timed events:
4
4
In
A[maj]
the
bleak
A[maj]/G
mid-
win-
C^7^9
ter,
F-^7
A^7[sus]
Frost-
D[maj]
y
wind
B/D
made
moan-
E^7[sus]^9
C/E
But what about those beams?
Beams, chords and some of the longer rests are made to span columns
by mapping their data-duration attributes to grid-column-end span
values:
.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.
Sizing
Lastly, the whole system is sized in em, so to scale it we simply
change the font-size:
0 meter 2 1 0 E4 1 0.5 1 B4 1 0.5 2 Bb4 1 2
Limits of Flex and Grid
Is it the perfect system? Honestly, I'm quietly gobsmacked that it
works so well, but if we are looking for caveats... 1. CSS cannot
automatically position a new clef/key signature at the beginning of
each wrapped line, or 2. tie a head to a new head on a new line. And
3., angled beams are a whole story onto themselves; 1/16th and 1/32nd
note beams are hard to align because we cannot know precisely where
their stems are until after the Grid has laid them out:
So it's going to need a bit of tidy-up JavaScript to finish the job
completely, but CSS shoulders the bulk of the layout work here, and
that means far less layout work to do in JavaScript.
Let me know what you think
If you like this CSS system or this blog post, or if you can see how
to make improvements, please do let me know. I'm on Bluesky
@stephen.band, Mastodon @stephband@front-end.social, and Twitter
(still, just about) @stephband. Or join me in making this in the
Scribe repo...
A custom element for rendering music
The scribe logo: a pink, cartoon-ish ink splodge writing on
manuscript.
Scribe
Code repo
github.com/stephband/scribe/
Sequence JSON
Scribe's data format
github.com/soundio/music-json/
I have written an interpreter around this new CSS system and wrapped
that up in the element . It's nowhere near
production-ready, but as it is already capable of rendering a
responsive lead sheet and notating drums I think it's interesting and
useful.
Whazzitdo?
The element renders music notation from data found in
it's content:
0 chord D maj 4
0 F#5 0.2 4
0 A4 0.2 4
0 D4 0.2 4
0 chord Dmaj 4 0 F#5 0.2 4 0 A4 0.2 4 0 D4 0.2 4
Or from a file fetched by its src attribute, such as this JSON:
Or from a JS object set on the element's .data property.
There's some basic documentation about all that in the README.
Try it out
You can try the current development build by importing these files in
a web page:
As I said, it's in development. Asides from some immediate
improvements I can make to Scribe 0.3, like tuning the autospeller,
fixing the 1/16th-note beams and detecting and displaying tuplets,
some longer-term features I would like to investigate are:
* Support for SMuFL fonts - changing the font used for notation
symbols. So far I have not been able to display their extended
character sets reliably cross-browser.
* Support for nested sequences - enabling multi-part tunes.
* Split-stave rendering - placing multiple parts on one stave. The
mechanics for this are already half in place - the drums stave
and piano stave currently auto-split by pitch.
* Multi-stave rendering - placing multiple parts on multiple,
aligned, staves.
I leave you with a transposable lead sheet for Dolphin Dance,
rendered by :
Dolphin Dance
Herbie Hancock
Transpose: [0 ]
[ [0, "chord", "C", "[?]", 4], [4, "chord", "G", "-", 4], [8, "chord",
"C", "[?]", 4], [12, "chord", "B", "o", 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", "7sus9", 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
hello@cruncher.ch +41 21 546 68 00
* Home
* Projects
* Blog
* Labs
* Team
Hide navigation
* email: hello@cruncher.ch
* Telephone: +41 21 546 68 00
* Address: Chemin du Petit Chateau 4
CH - 1005 Lausanne