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 fifthswing and humanize are declared but not implemented: calling either panics with todo!(). They are scheduled for milestone M3 (see Roadmap).
The 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 |
| 6. MIDI backend | backends/midi |
render_midi, write_midi, MidiOptions, MidiError |
| 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. The bass rhythm is a value of its own (rhythm::tresillo()), pitched at the call site:
use musecode_core::prelude::*;
fn main() {
let key = Key::minor(pc!(F));
// Nuevo tango bass: the tresillo (3+3+2) on the root of each bar. The
// rhythm comes from `rhythm::tresillo`; only the pitch is decided here.
let tango = |root: ChromaticPitch| tresillo().on(root);
// 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 3 (C Cb Bb Ab in F minor)
let melody = seq![
n(d!(5), q()), n(d!(b 5), e()), n(d!(4), e()), n(d!(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)));
// Read it, analyse it, hear it:
println!("{piece}"); // the notation, one bar per line
print!("{}", summary(&piece).unwrap()); // range, pitch classes, intervals
write_midi(&piece, "target/tango.mid", &MidiOptions::default()).unwrap();
}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.
This is musecode_core/examples/tango.rs. Run just play tango to render it and hear it through fluidsynth, or just render tango to write target/tango.mid without playing. Two knobs are Justfile variables, and just wants them before the recipe name: just gain=0.5 soundfont=/path/to/other.sf2 play tango. gain defaults to 1 because fluidsynth's own default of 0.2 peaks at about 5% of full scale and is easy to mistake for silence; an empty soundfont picks the first one in /usr/share/sounds/sf2. LilyPond output is milestone M2.
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
├── resolve.rs Layer 5: resolve() from Music to a flat Event list
├── analysis.rs Layer 5: range, pitch-class set, intervals, slices, triads, summary
├── display.rs Layer 3: impl Display for Music (docs/NOTATION.md)
├── phrase.rs Layer 7: Phrase (content-hashed), RenderCache
└── backends/
├── mod.rs
├── hints.rs Layer 6: BackendHint, LilypondHint, MidiHint, AudioHint
└── midi.rs Layer 6: MIDI file export (render_midi, write_midi)
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. Resolved (M1, ADR-003): aParlasts as long as its longest child and shorter children are padded with silence, never truncated or looped.Music::duration()implements this. Truncate and loop can arrive later as explicit combinators without changing the constructor. -
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.
Work is tracked on the MuseCode project board in three milestones:
- M1 "Hear the tango":
duration(), the resolver,Display, structural analysis, MIDI export, and the tango example playing throughfluidsynth. - M2: LilyPond backend, parsed text syntax, the open design questions below.
- M3:
swing,humanize, voicings, chord-symbol parser, in-process audio.
The original list, 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.