Rastrum: Music Notation w/ LilyPond, MusicXML, GUIDO writers

Thank you for this!

If you continue developing Rastrum based on this demonstration, I’m beginning to feel that I might not need to take my own Notator quark much further once your Rastrum quark is complete. Your syntax is far better than mine.

I just wanted to mention that the reasons I originally started developing Notator were:

  • to notate 1/16‑tone microtonal accidentals (12.5 cents). This lies roughly around the pitch‑discrimination threshold for many listeners in studio conditions, so it feels musically meaningful without being overly intrusive. Notating 1/32‑tone steps (6.25 cents) would also be possible, but such differences are generally below the just‑noticeable difference for most listeners — and in a concert hall, even a 1/16‑tone deviation can sometimes fall below perceptibility due to reverberation, distance, and the complexity of instrumental timbre. Attempting to represent such extremely fine gradations becomes hypercomplex and musically impractical, despite being theoretically definable.
  • to generate sound from music‑score‑notation‑based code using Synth.

Anyway, congratulations — and I hope you’ll receive plenty more resonance from other users as well!

This is a fair objection. Thanks for the feedback, Stuart))

I agree with the practical point: generative pitch materials often want to be numeric material.

But I disagree when you say:

"Preserving note information, pitch not being addable, and the whole spelling-oriented approach: this can be quite limiting when you want to leave the land of functional harmony. "

I’ll try to explain why. Hol’ up!

We trynna be able to map heights, invert them, multiply intervals, quantize frequencies, apply sieves, use arrays, &c. That should not require pretending the music is functional harmony. It is valid for any genre of contemporary music that uses music notation.

Where I still draw the boundary is the score model. A notated pitch is not just a MIDI number, even in non-tonal and microtonal (24- 48 or 72-EDO) music. Once something becomes notation, it still needs staff position, note name, accidental, octave/register, and sometimes residual that we can leave to cents deviations.

Also, we can always adopt an accidental spelling policy. We can write our own rules how to spell out those raw numbers ourselves. C# and Db may be the same, but they are not the same when written in a music score. Quarter-tones and 72-EDO make this more important, not less.

The model I’ve been converging on is this one: numeric pitch material -> transform freely -> choose spelling policy -> notated score pitch

Rastrum currently emphasizes the last part. MusicPitch is a notated score value: step, alteration, octave, and cents. That’s deliberate because LilyPond, MusicXML, GUIDO, ties, validation, interval naming, and score comparison need written facts, not only sounding height.

But the numeric entry already exists, and I have not documented it well. It is a real gap, I’m just not sure it answers your point or not.

Rastrum’s pitch-taking entry points go through MusicPitch.fromSpec, which accepts a MusicPitch, a midinote number, a note-name Symbol, a compact spelling, or an array spec. That includes notes, chords, RTM pitch streams, and MusicInterval.between.

MusicPitch.asPitches([60, 64, 67]); // -> [MusicPitch("c[4]"), MusicPitch("e[4]"), MusicPitch("g[4]")]
MN(60.5, Duration.quarter);         // -> MusicNote(MusicPitch("c+[4]"), Duration(1/4))
MusicInterval.between(60, 67);      // -> perfect fifth

And when a number enters the score model, the spelling rule is explicit:

MusicPitch.fromMidinote(61, \sharps);      // c#[4]
MusicPitch.fromMidinote(61, \flats);       // db[4]
MusicPitch.fromMidinote(60.25).cents;      // -25.0
MusicPitch.fromMidinote(60.25).midinote;   // 60.25

We can even write more elaborate spelling rules. For example, if a pitch is going upward or downward, it should be spelled differently, etc.

The quantum is the snap grid, and whatever falls off it is kept as cents.

number -> spelled pitch -> number preserves the numeric value.

What can’t survive is pitch -> number -> pitch

That is: the enharmonic choice, because a number never holds that information. That’s my angle, and where I disagree on the model design. But, of course, it all depends on context and what kinds of music materials we are dealing with

Numeric operations and written spelling are related without collapsing into one operation. This is not a drop-in comparison. The models are different. The shared idea is the boundary, not the representation, not the implementation. Haskell makes those layers easier to name in the types, and SuperCollider makes the numeric material easier to play interactively. That’s all.

The tutorial warns that a midinote loses what you wrote, mentions fromMidinote once as an aside, and never presents fromSpec as the pitch protocol at all. That’s a real documentation gap.

A reader asking whether they can work numerically meets the warning unexpectedly. Maybe a good patch to apply soon in both the tutorial and the README, and make the material lane an explicit part of the API, and not feel like a hack you find out reading the code.



EDIT: One Haskell-side note separate from the rest, since it can be a bit “off-topic” on here, though it’s a more honest reply to your point on this front:

You mentioned Note p d. I think this design is totally valid. Maybe a better default if the tool is focused on generating music materials. Keeping p open and constraining it only where needed is useful on that layer. Num p may not be the whole musical story all the way through, but it gives you the direct arithmetic you often want for sketches: add, subtract, multiply, map, all the fun stuff we do. I get that!))

I made a different tradeoff. The core is not Note p d, but more like Note Pitch Duration [Attachment]. It is the score layer. So maybe the difference is the center of gravity. A Note p d model keeps the note open and makes the generative layer easier. A concrete score model keeps notation decisions closer to the score layer. Both choices move complexity somewhere. With an open p, the backend or some later pass eventually decides how the pitch is written. With a written Pitch, the system needs a clear pre-score place where numeric material can stay numeric.

My toolbox has a lot of that pre-score material layer we are talking about. Before notation, the material can be numeric: MIDI-ish values, frequency, spectral, x-EDO heights, pitch rows/matrixes, offsets, sieves, transformations, &c. Then at the score boundary, we choose a spelling policy and realize it as a notated Pitch.

That is my main point: numeric input/generation and spelling can coexist in the same system. They simply don’t have to carry the same responsibility. Numeric material can stay numeric while we transform it. Spelling can enter when that material becomes notation.


Glad to continue the conversation here or elsewhere.

It already does that. One of the “writers” translates the Score into Events and Patterns. You also have control over which SynthDef to use, and how to interpret it (PlaybackMap).

Rastrum.events(~score);   // an Array of Events
Rastrum.pbinds(~score);   // one Pbind per timeline
Rastrum.pattern(~score);  // Ppar, score metronome included

Rastrum.play(~score, \default);
1 Like

I assume this works when a score contains only a single instrument. I tried to automatically play the synth associated with the instrument label. How do you handle scores that contain multiple instruments?

Rastrum handles playback per timeline, not as one SynthDef for the whole score. A timeline is one linear musical line inside a staff. A simple Flute staff has one timeline, a piano staff with "upper" and "lower" voices has two, etc. You can map each timeline to one SynthDef. You don’t need to use the same SynthDef for all staffs or voices.

For example, staff labels like "Flute" or "Cello" are the name written in the score. The SynthDef name could be \fluteSynth. That is the name of the SynthDef SuperCollider uses to play that timeline.

PlaybackMap sits beside an already built score and connects those two. It is an “interpretation” layer used by the Events and Patterns writer.

Assuming ~score already exists:

~routing = PlaybackMap.new
    .instrumentFor("Flute", instrument: \fluteSynth)
    .instrumentFor("Cello", instrument: \celloSynth)
    .instrumentFor("Marimba", instrument: \marimbaSynth);

~routing.events(~score);        // inspect routed Events
~routing.pattern(~score).play;  // play routed Ppar

Alternatively, use the Rastrum façade:


~profile = PlaybackProfile.new.playbackMap_(~routing);

Rastrum.events(~score, profile: ~profile);
Rastrum.pattern(~score, profile: ~profile).play;

// Or simply start playback directly:
Rastrum.play(~score, profile: ~profile);
1 Like

Rastrum’s pitch model is already fairly close to this.

MusicPitch stores step + exact alter + octave + cents, and alter is an exact rational value. So internally, the model can already represent things like 72-EDO steps. What Rastrum supports today as written accidentals is 24-EDO, with cents available for extra deviations. For the use case you describe, I think 72-EDO may be a good practical next target, maybe more so than going directly to 1/16-tone notation.

72-EDO is a known standard, it is finer than quarter-tone notation, and it includes useful divisions such as sixth-tones and third-tones. It is very close to our perception threshold. At the same time, it is still manageable as notation, and it approximates much non-EDO pitch material well enough for practical use (that’s why it is popular in non-EDO communities too). That is probably why it shows up in different contexts.

Glyph choice is still a design question, of course. But I already have a preferred glyph set from an older Haskell and music projects where I experimented with 72-EDO accidentals. On the LilyPond side, Ekmelily is probably the right tool. That part is not the hard problem.

Most of the work is getting the Rastrum model, parser, interchange format, and writers to agree on the new accidental grid. My rough estimate is just a few weeks of work for a usable implementation (depending on my free time, I do other things in life, you know hehe).

MusicXML may be able to carry the pitch alterations numerically, but the harder question is whether common MusicXML renderers and score editors will display accidentals consistently with LilyPond/Ekmelily. GUIDO is less clear to me. I would need to test it. (Or someelse here can shed some light on that? I’d appreciate it)

The “safe” design would be to keep 24-EDO as the default portable grid, and add an explicit 72-EDO grid that admits rational alterations in 1/12th of semitone. Then we need to decide the writer policy. Either 72-EDO starts as a LilyPond/Ekmelily-only feature, with MusicXML and GUIDO refusing clearly when they cannot represent it, or Rastrum relaxes its current rule that the core accidental grid should be portable across the main notation backends.

My preference: 72-EDO explicit, keep the current default stable, and let unsupported writers fail until their behavior is proven.

I have no idea if this is a desired feature in the SuperCollider community, and how much. That’s why I’m reluctant to implement it now. If you would benefit from it, lemme know,

1 Like

I think your design direction is generally excellent. If I remember correctly, implementing microtonal notation smaller than 1/4‑tone is not straightforward. When I worked on my quark years ago, I couldn’t find a good solution in LilyPond — though it’s possible I simply missed something.

I feel that the community has relatively little interest in music notation within SC. Many advanced users tend to work across multiple programming languages. When I posted about implementing notation in SC—because a language for algorithmic composition should also be able to notate the sounds it generates—I often received suggestions to use other environments or SimpleMIDIFile instead. However, I find it interesting that FOSC was introduced in the second edition of The SuperCollider Book . This suggests that key figures in the SC community do consider notation important. Regardless of the community’s current level of interest, I believe your idea is valuable and should be implemented in SC, especially since SC can compose music outside 12‑EDO and freely use non‑standard pitches. That capability is powerful, but the lack of notation support for such sounds feels unbalanced.

I understand. In fact, that’s my case. I introduced ScoreJSON as a two-way exchange format. One can save a score (in Rastrum right now) and read it back. Or, in my case, send it from/to another programming language project (in Haskell in my case, but there is nothing language-specific in the ScoreJSON format, and that’s the point). I documented it in docs/interchange.md, and why MusicXML would not work for that.

I have a slightly different perspective. The final stage of notation is usually done in a score editor rather than directly in LilyPond or other languages. So I think MusicXML is particularly important at the moment.

I think I was unclear earlier. Yes, of course I agree: MusicXML export already works in Rastrum. It’s one of its writers, and you can open the output in a score editor today. After LilyPond and MusicXML, I added a GUIDO writer without touching the core model. That’s useful on its own, since some tools read GUIDO, but also confirmed the project premise: adding a backend doesn’t require changing the model.

My point earlier was about something else: MusicXML works well as an export target, but it’s not what one would want as the wire between two composition toolkits, because it has its own conventions and doesn’t carry everything cleanly. Besides, it is a heavy schema, and most of it has to do with editorial details (mark positioning and things like that), while ScoreJSON is the only writer that preserves the model completely. If Rastrum talked to another toolkit through MusicXML, I’d have to accept those losses and ignore a lot of the schema that would be useless for this purpose. That’s the point of ScoreJSON. It’s not “competing” with MusicXML because they do different jobs.

Rastrum exports four formats at this time: LilyPond, MusicXML, GUIDO (export only) and ScoreJSON (that you write, read back, two-way wire with another toolkit, etc).

2 Likes

How about the following shortcuts?

"c-[5]".pitch;   // -> MusicPitch("c-[5]")
"4.".dur;        // -> Duration(3/8)
"5/8[2+3]".meter;     // -> Meter(5/8, [2, 3])
"c-4:mf:staccato".mn; // -> MusicNote(MusicPitch("c-[4]"), Duration(1/4))
"<c+ e g>2:ff".chord; // -> [MusicNote(MusicPitch("g[4]"), Duration(1/4)), MusicNote(MusicPitch("f[4]"), Duration(1/4)),  MusicNote(MusicPitch("e[4]"), Dur(1/2))]

Thanks for the suggestion. I considered that before, but I deliberately avoided extending SC core classes such as String. In sclang, those methods are global, so they can easily collide with other quarks and make it less obvious which parser is being used. I would merely avoid it by design given the context. For now, I prefer to keep Rastrum’s core library on named entry points.

That said, users who prefer this style can add it locally with a small extension file in their Extensions folder. It could define these String methods and forward them to the existing named parsing entry points. I would rather keep that as optional local syntax sugar than make it part of the core API.

1 Like

That makes sense, I guess where I get confused is e.g. expressing music ideas pre-transformation, for example can I use your quasiquoter’s lilypond-like syntax to make a motif and then run transforms on it, and ideally log/debug with that syntax? Maybe that’s not Rastrum’s job, as specified, which is totally legit.

As for tradeoffs of where the boundary is crossed into scoring land, my goal was to keep the final result transformable, but in practice, I have found that once the code turns to engraving the transforms are done. The “final engraving step” approach is quite solid operationally I’m sure.

A question concerning extensibility w/r/t engraving backends: how does Rastrum expose e.g. MusicXML specifics like double-square fermata shapes or agogo metal pictograms? Put another way, how does Rastrum avoid being a kitchen sink of every possible engraving technique offered by this or that backend? Lilypond has its scheme escapes, whereas MusicXML seeks to be absurdly exhaustive. For my lib, I pinned to MusicXML with a machine-generated model of the entire schema just so that I could get fanned beams or whatever with a few more lines of userland trainwreck haskell.

Yea, the notation syntax comes before the writer, not after it. It builds precisely the same objects that the core data type constructors build, so what you get back can be selected, edited, transformed, diffed, played, exported, rendered, etc. It isn’t a LilyPond string with a writer attached at the end. Quite the contrary, the compact notation is just one of the interface surfaces (a more friendly user-facing syntax) to describe the same tree.

Here is a score I posted before:

~m1 = Measure("4/4",
    "cresc[c4:pp:tenuto 3:2[d8 eb8 f+8] <d e-'>4:text{pizz.}:f] "
    "<e' d#+'>16:ff:stac r8.")
    .metronome("4", 96, text: "Allegro");
~m2 = Measure.proportions("5/8[3+2]", "(1 (1 (1 1 1)) -1 2)",
    "c:mp:text{ord.} d e- g f#+:ppp:tenuto");
~phrase = ScoreSelection(~m2).runs.first;
Spanner.slur(Spanner.beam(~phrase));
Spanner.diminuendo(ScoreSelection(~m2).pitched);
~score = MusicScore.oneStaff([~m1, ~m2], "Violin", \treble, "Rastrum");

~score.postcs hands back the core data types constructor form. It’s regular evaluable sclang code that would be equally valid to write:

MusicScore([
    Staff([
        Measure(Meter(4, 4), [
            MusicNote(MusicPitch("c[4]"), Duration(1, 4))
                .markings_([Marking.dynamic(\pp), Marking.articulation(\tenuto)])
                .spanners_([Spanner.hairpinStart(\crescendo, 1)]),
            Tuplet.ratio(3, 2, [
                MusicNote(MusicPitch("d[4]"), Duration(1, 8)),
                MusicNote(MusicPitch("eb[4]"), Duration(1, 8)),
                MusicNote(MusicPitch("f+[4]"), Duration(1, 8))
            ]),
            Chord([MusicPitch("d[4]"), MusicPitch("e-[5]")], Duration(1, 4))
                .markings_([Marking.text("pizz.", \above), Marking.dynamic(\f)])
                .spanners_([Spanner.hairpinStop(1)]),
            Chord([MusicPitch("e[5]"), MusicPitch("d#+[5]")], Duration(1, 16))
                .markings_([Marking.dynamic(\ff), Marking.articulation(\staccato)]),
            MusicRest(Duration(3, 16))
        ])
            .directions_([
                Direction.tempo("Allegro", unit: Duration(1, 4), perMinute: 96)
            ]),
        Measure(Meter.grouped(5, 8, [3, 2]), [
            MusicNote(MusicPitch("c[4]"), Duration(1, 8))
                .markings_([Marking.dynamic(\mp), Marking.text("ord.", \above)])
                .spanners_([
                    Spanner.beamStart(1),
                    Spanner.slurStart(1),
                    Spanner.hairpinStart(\diminuendo, 1)
                ]),
            Tuplet.ratio(3, 2, [
                MusicNote(MusicPitch("d[4]"), Duration(1, 16)),
                MusicNote(MusicPitch("e-[4]"), Duration(1, 16)),
                MusicNote(MusicPitch("g[4]"), Duration(1, 16))
                    .spanners_([Spanner.beamStop(1), Spanner.slurStop(1)])
            ]),
            MusicRest(Duration(1, 8)),
            MusicNote(MusicPitch("f#+[4]"), Duration(1, 4))
                .markings_([Marking.dynamic(\ppp), Marking.articulation(\tenuto)])
                .spanners_([Spanner.hairpinStop(1)])
        ])
    ], "Violin")
], "Rastrum")

And ScoreDiff returns an empty delta between them:

~src = ~score.asCompileString;
ScoreDiff.between(~score, ~src.interpret).isEmpty // -> true

~score.treeString gives a visual representation of that same tree:

-> [] Score "Rastrum" staves: 1
|
`- [0] Staff "Violin" clef: treble bars: 2
   |
   +- [0,0] Measure 4/4 holds: 1 offset: 0 dirs: tempo "Allegro" 1/4=96
   |  |
   |  +- [0,0,0] Note c[4] 1/4 marks: pp, tenuto spanners: hairpin:start#1 crescendo
   |  |
   |  +- [0,0,1] Tuplet 3:2 3/8=>1/4
   |  |  |
   |  |  +- [0,0,1,0] Note d[4] 1/8=>1/12
   |  |  |
   |  |  +- [0,0,1,1] Note eb[4] 1/8=>1/12
   |  |  |
   |  |  `- [0,0,1,2] Note f+[4] 1/8=>1/12
   |  |
   |  +- [0,0,2] Chord <d[4] e-[5]> 1/4 marks: "pizz.", f spanners: hairpin:stop#1
   |  |
   |  +- [0,0,3] Chord <e[5] d#+[5]> 1/16 marks: ff, staccato
   |  |
   |  `- [0,0,4] Rest 3/16
   |
   `- [0,1] Measure 5/8 holds: 5/8 offset: 0
      |
      +- [0,1,0] Note c[4] 1/8 marks: mp, "ord." spanners: beam:start#1, slur:start#1, hairpin:start#1 diminuendo
      |
      +- [0,1,1] Tuplet 3:2 3/16=>1/8
      |  |
      |  +- [0,1,1,0] Note d[4] 1/16=>1/24
      |  |
      |  +- [0,1,1,1] Note e-[4] 1/16=>1/24
      |  |
      |  `- [0,1,1,2] Note g[4] 1/16=>1/24 spanners: beam:stop#1, slur:stop#1
      |
      +- [0,1,2] Rest 1/8
      |
      `- [0,1,3] Note f#+[4] 1/4 marks: ppp, tenuto spanners: hairpin:stop#1

once the code turns to engraving the transforms are done

Matter of fact, it might not be really about a design disagreement at all. It is about how each holds the material. If your composition process keeps notation as the final engraving step, then one enters the music score vicinities earlier than other composers would.

My workflow is a bit different. I move back and forth between the material and the score. I generate material, spell it, inspect or edit it as a score, transform it again, and render whenever useful. Then in the score editor, I still do more editing. For my workflow, rendering isn’t the end of the process. .render prepares and writes a copy without mutating the source tree. Only the preparation layer splits material across barlines, carry markings and spanners, and stuff like that. That’s the model work.

Your <fermata> example points to the rule I’m using. I don’t want the toolkit to mirror every MusicXML element. The vocabulary expresses (portable and non-editorial) score semantics, some reflect writer policy, and some make sense for a particular backend. The idea is to model the first, leave the second to the writers, and not deal with the third by default.

That doesn’t mean the toolkits can’t add the more detailed fermata support you mentioned. If a fermata shape makes sense, one can give it a place in the model and implement it in the writers that can represent it (or any other musical element for that matter). If only one writer can represent it, turn it into a backend-specific extension, no problema.

Feature by feature, that’s my element. Swallow a backend’s vocabulary whole and one morning one wakes up with a model with LilyPond’s face trying out MusicXML’s clothes

There’s also a practical point: covering the whole musicxml schema is much more practical in languages like Haskell, not sclang. There are xml libraries, schema and code-generation tools, and a dependency ecosystem to support this kind of work. I did experiment with generating a Haskell module directly from the musicxml 4.0 schema. But I never used it for my work. The module felt too “heavy” to adopt. A lot of the schema has to do with editorial details, not the musical semantics I wanted to work directly in this back-and-forth workflow I described.

Even with all the tools, I chose the lighter approach anyway. I’m sure it made more sense for your context and workflow, of course.

In sclang terrain, the lighter approach isn’t even a preference. The context is more modest, the tools were never built.

You were totally right in your first message. Those kinds of project are opinionated. Because we are. Artist, composer, programmer, them not different people taking shifts, but a totallity. Science wants generality. Art, more about aura vibes, that thing happening once. We are between those worlds. A tool with no clear opinion doesn’t offer neutrality, maybe it offers the illusion of it. It’s our ten cents, the two cents is free.

my one hope here is that this can happen entirely in user-land. the whole reason I went nuts with encoding all of musicxml was not wanting to deal with cabal hell (this was circa 2018 lol) every time I needed some unimplemented thing having to release a new minor build. Not as bad with sclang (or cabal.project <3 ), but the point stands that empowering the user to extend in their own env is badass.