Skip to content

Latest commit

 

History

56 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MuseCode

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.


Design Principles

  1. The IR is small and orthogonal. Five core constructors. Everything else is sugar built from them.
  2. 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.
  3. Time is rational. No floating-point drift. A 7:5 polyrhythm over 32 bars is exact.
  4. Subtrees are content-addressable. Every fragment has a Blake3 hash. Cache lookup is O(1). Re-rendering only touches changed subtrees.
  5. Backend hints live alongside, not inside. The IR is what the music is. Hints are how a specific backend should interpret it.
  6. No clever encoding tricks at the type level. Enums for ADTs, traits for backends, no GAT acrobatics.

Quick Start

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)));

Macros

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 / Chord

Combinators

melody
    .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

swing and humanize are declared but not implemented: calling either panics with todo!(). They are scheduled for milestone M3 (see Roadmap).


Architecture

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).

The five-constructor ADT

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.

Polymorphic pitch

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.


Worked Example: ii-V-I in F minor

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.


Crate Layout

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)

Open Design Questions

These are unresolved choices that affect the API surface before backends are written:

  1. Modify with multiple controls? Currently Modify(Control, body) and you nest. Simpler: Modify(Vec<Control>, body). Saves tree depth at a slight loss of canonical form.

  2. Par alignment semantics. Resolved (M1, ADR-003): a Par lasts 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.

  3. 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-level Score { voices: Map<VoiceId, InstrumentId> } type?

  4. Tie semantics. tie_to_next: bool on NoteAttrs works for simple cases. Ties across Seq boundaries and into Par branches may need a tree-rewrite pass at render time instead of a stored flag.

  5. Persistent data structures. Phrase content-addresses immutable trees. Editing means rebuilding, which is fine for batch rendering but expensive for live editing of long pieces. im::Vector is worth exploring later.


What This IR Does Not Yet Handle

Honest list of deferred scope:

  • Microtonal pitches. Adding Microtonal { cents: f32 } to PitchClass is straightforward but propagates through every backend.
  • Continuous tempo curves. Tempo::Ramp is sketched in but LilyPond mostly wants discrete markings.
  • Conditional / generative structures. Music::Choose(Vec<(Weight, Music)>) or a Composer monad.
  • Lyrics. Vocal music needs syllable-to-note alignment.
  • Notation-only events. Caesuras, breath marks, double barlines: probably a Music::Mark variant.
  • Absolute timestamps. The IR uses rational beats; backends convert to milliseconds. Live applications would want a separate "performed" representation.

Roadmap

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 through fluidsynth.
  • 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:

  1. Property tests. Associativity of Seq/Par, identity laws, transpose(0) is identity, augment(h()).diminish(h()) is identity.
  2. LilyPond backend. Tree walk producing text. Resolve scale degrees. Handle ties, articulations, basic dynamics. Target: the Piazzolla sketch above renders to a readable PDF.
  3. Theory layer. Chord-symbol parser (nom crate). Voicings as functions Chord -> Voicing -> i8 -> Music. Round-trip test: music to chord analysis to symbol and back.
  4. MIDI backend. midly crate handles file format. Resolve pitches to MIDI numbers, durations to ticks.
  5. Tier-1 audio. oxisynth + a soundfont. The feedback loop (edit, render, hear) is now closed.

License

TBD.

About

Music as Code — a Rust DSL for composing, transforming, and rendering music from source

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages