Music as Code. A Rust DSL for composing, transforming, and rendering music from source.
Write a Piazzolla-flavored ii-V-I in F minor, render it to LilyPond PDF, MIDI, and audio, all from the same source file. Change the key, reharmonize, invert the melody, add a canon voice. The same tree produces every output.
- The IR is small and orthogonal. Five core constructors. Everything else is sugar built from them.
- Pitch is polymorphic but resolves to chromatic. Scale degrees and intervals exist for composition, but every backend sees fully-resolved chromatic pitches with preserved enharmonic spelling.
- Time is rational. No floating-point drift. A 7:5 polyrhythm over 32 bars is exact.
- Subtrees are content-addressable. Every fragment has a Blake3 hash. Cache lookup is O(1). Re-rendering only touches changed subtrees.
- Backend hints live alongside, not inside. The IR is what the music is. Hints are how a specific backend should interpret it.
- No clever encoding tricks at the type level. Enums for ADTs, traits for backends, no GAT acrobatics.
use musecode_core::prelude::*;
// Three ways to write the same C major triad
let explicit = seq![n(C4, q()), n(E4, q()), n(G4, h())];
let by_degree = seq![n(d!(1), q()), n(d!(3), q()), n(d!(5), h())];
let by_chord = chord([C4, E4, G4], h());
// Operators: + chains, | stacks voices, * repeats
let melody = n(C4, q()) + n(E4, q()) + n(G4, h());
let bass = n(C3, h()) + n(G3, h());
let duo = melody | bass; // two voices, simultaneous
let twice = melody.clone() * 2; // repeat twice
// Wrap a section in context modifiers
let piece = duo
.modify(Control::Key(Key::minor(pc!(F))))
.modify(Control::TimeSignature(TimeSig::common()))
.modify(Control::Tempo(Tempo::bpm(96)));seq![n(C4, q()), n(E4, q()), n(G4, h())] // sequential composition
par![melody, bass, inner_voice] // parallel composition
d!(1) // tonic
d!(b 3) // flat third (modal mixture)
d!(# 7) // raised seventh
pc!(F) // F natural PitchClass, for use in Key / Chordmelody
.transpose(5) // up a perfect fourth
.diatonic_transpose(2) // up two scale steps
.augment(h()) // double all durations
.retrograde() // time-reverse
.invert(C4.midi()) // pitch-invert around C4
.pipe(canon(vec![(q(), 7)])) // add a canon voice at a fifth
.pipe(humanize(42, 0.05)) // subtle timing/velocity jitterThe codebase is organized in seven conceptual layers, each building on the previous:
| Layer | Module | What it provides |
|---|---|---|
| 1. Pitch | pitch |
Letter, Accidental, PitchClass, ChromaticPitch, Degree, Interval, Pitch |
| 2. Time | time |
Beats = Rational32, Tempo, TimeSig, Dynamics, duration helpers |
| 3. Core ADT | music |
Music (5 constructors), Note, NoteAttrs, operator overloads |
| 4. Theory | theory |
Key, Scale, Mode, Chord, ChordQuality, Voicing |
| 5. Combinators | combinators |
transpose, augment, retrograde, invert, canon, map_notes |
| 6. Backend hints | backends/hints |
BackendHint, LilypondHint, MidiHint, AudioHint |
| 7. Phrases | phrase |
Phrase (Arc + Blake3 hash), RenderCache |
Supporting modules: attrs (shared ID newtypes and NoteAttrs), control (Control enum), prelude (re-exports everything).
Everything playable is a tree of exactly these five node types:
pub enum Music {
Note(Note), // a sounded event
Rest(Beats), // silence
Seq(Vec<Music>), // play in order
Par(Vec<Music>), // play simultaneously
Modify(Control, Box<Music>), // apply context to a subtree
}Seq and Par flatten on composition: a + b + c + d produces Seq([a,b,c,d]), not a nested tree. Modify propagates context (key, tempo, transposition) down through its subtree without touching siblings.
pub enum Pitch {
Chromatic(ChromaticPitch), // C4, Eb5: explicit
Degree(Degree), // 1, b3, #7: relative to Key context
Interval(Interval), // M3, P5: relative to previous note
}A melody written with Degree pitches reharmonizes freely: wrap the same Music tree in Modify(Control::Key(new_key), _) and the melody moves with it. Backends always receive resolved ChromaticPitch values.
A Piazzolla-flavored four-bar sketch showing bass, comping, and melody together:
use musecode_core::prelude::*;
fn main() {
let key = Key::minor(pc!(F));
// Tango bass: dotted-eighth + 16th + 8th + 8th + quarter + quarter
let tango = |root: ChromaticPitch| seq![
n(root, dot(e())), n(root, s()),
n(root, e()), n(root, e()),
n(root, q()), n(root, q()),
];
// Shell-voiced comping on beats 2 and 4
let comp_rest = r(q()) + r(dot(q()));
// Melody in scale-degree space: descends 5 b5 4 b3
let melody = seq![
n(d!(5), q()), n(d!(b 5), e()), n(d!(4), e()), n(d!(b 3), h()),
];
// Assemble bars: bass | comp | melody
let bar1 = tango(G3) | comp_rest.clone() | melody.clone();
let bar2 = tango(C3) | comp_rest.clone() | melody.diatonic_transpose(-1);
let bar3 = tango(F3) | comp_rest | seq![n(d!(1), w())];
let piece = seq![bar1, bar2, bar3.clone(), bar3]
.modify(Control::Key(key))
.modify(Control::TimeSignature(TimeSig::common()))
.modify(Control::Tempo(Tempo::bpm(96)));
// Same source tree, three outputs (backends not yet implemented):
// LilypondBackend::default().render(&piece) -> score PDF
// MidiBackend::default().render(&piece) -> .mid file
// FluidsynthBackend::new("FluidR3.sf2").render(&piece) -> .wav
}The melody is in scale-degree space: change pc!(F) to pc!(C) and the tango bass notes are the only thing that needs manual updating. The descending line follows automatically.
musecode_core/src/
├── lib.rs crate root, module declarations
├── prelude.rs re-exports the full public API
├── pitch.rs Layer 1: pitch types and constants
├── time.rs Layer 2: Beats, Tempo, TimeSig, Dynamics
├── attrs.rs shared: VoiceId, InstrumentId, Articulation, NoteAttrs
├── music.rs Layer 3: Music ADT, Note, smart constructors, operators
├── control.rs Layer 4 support: Control enum
├── theory.rs Layer 4: Key, Scale, Mode, Chord, Voicing
├── combinators.rs Layer 5: transpose, augment, retrograde, invert, canon
├── phrase.rs Layer 7: Phrase (content-hashed), RenderCache
└── backends/
├── mod.rs
└── hints.rs Layer 6: BackendHint, LilypondHint, MidiHint, AudioHint
These are unresolved choices that affect the API surface before backends are written:
-
Modifywith multiple controls? CurrentlyModify(Control, body)and you nest. Simpler:Modify(Vec<Control>, body). Saves tree depth at a slight loss of canonical form. -
Paralignment semantics. Unequal-length children: truncate the shorter, loop it, or pad with silence? Different idioms want different defaults. Probably explicit:Par::Truncate/Par::Loop/Par::Pad. -
First-class voices.
Control::Voice(VoiceId)handles most cases. Is it enough for engraving (where "the violin part" must stay contiguous), or do we need a top-levelScore { voices: Map<VoiceId, InstrumentId> }type? -
Tie semantics.
tie_to_next: boolonNoteAttrsworks for simple cases. Ties acrossSeqboundaries and intoParbranches may need a tree-rewrite pass at render time instead of a stored flag. -
Persistent data structures.
Phrasecontent-addresses immutable trees. Editing means rebuilding, which is fine for batch rendering but expensive for live editing of long pieces.im::Vectoris worth exploring later.
Honest list of deferred scope:
- Microtonal pitches. Adding
Microtonal { cents: f32 }toPitchClassis straightforward but propagates through every backend. - Continuous tempo curves.
Tempo::Rampis sketched in but LilyPond mostly wants discrete markings. - Conditional / generative structures.
Music::Choose(Vec<(Weight, Music)>)or aComposermonad. - Lyrics. Vocal music needs syllable-to-note alignment.
- Notation-only events. Caesuras, breath marks, double barlines: probably a
Music::Markvariant. - Absolute timestamps. The IR uses rational beats; backends convert to milliseconds. Live applications would want a separate "performed" representation.
Concrete next steps, each a natural weekend project:
- Property tests. Associativity of
Seq/Par, identity laws,transpose(0)is identity,augment(h()).diminish(h())is identity. - LilyPond backend. Tree walk producing text. Resolve scale degrees. Handle ties, articulations, basic dynamics. Target: the Piazzolla sketch above renders to a readable PDF.
- Theory layer. Chord-symbol parser (
nomcrate). Voicings as functionsChord -> Voicing -> i8 -> Music. Round-trip test: music to chord analysis to symbol and back. - MIDI backend.
midlycrate handles file format. Resolve pitches to MIDI numbers, durations to ticks. - Tier-1 audio.
oxisynth+ a soundfont. The feedback loop (edit, render, hear) is now closed.
TBD.