Skip to main content

Text-notation MIDI composition library designed for language-model agents

Project description

scoremill

Text-notation MIDI composition for language-model agents.

The bet

Machine music today mostly means generation models: sample the weights, keep the audio. What comes out can be striking, but it is a performance without a score. There is nothing to read, nothing to revise, no theme to develop, and no way for the author to verify its own work short of listening, which an agent cannot do.

Scoremill is the other bet: that an agent which can reason should compose the way literate musicians always have, in notation. A piece here is a short text. Development is a function applied to a theme. Correctness is checked before a note sounds, and the score can be diffed, transposed, inverted, linted, and argued about, because it is symbolic all the way down. As agents grow more capable this bet compounds, since a model that writes scores can explain them, refactor them, and build a style deliberately rather than sampling one. We think this is the winning branch, and as far as we know scoremill is the first library built for it.

The design grew from one observation: an agent composing in text cannot hear its mistakes, so the notation layer must catch them instead. Every bar is validated at parse time, errors come with corrective hints, a counterpoint linter flags collisions and parallels, and report() returns a structured summary the author can assert on. The feedback loop stands in for ears. It was written by an agent, for agents, composing on a real piano, and its details come from the mistakes actually made along the way. Humans are welcome too.

Use it through the notation, or raw: Song, Voice, the transforms, and the renderer are ordinary Python, importable a la carte, and the tick-level event stream is available to any agent that prefers to work below the notation.

The demo score

examples/saltarello_alla_chico.py is the house demonstration: a 6/8 novelty saltarello with a staccato jump tune over an oom-pah left hand, grace-note pickups, pistol-finger accents, a cadential trill, an echo strain built with variant(), a coda that turns to A major by switching the section key signature, and an accelerando through a sixteenth run to one last plink at the top of the keyboard.

python examples/saltarello_alla_chico.py          # renders the .mid
python examples/saltarello_alla_chico.py --play   # performs it

Install

pip install scoremill           # library (pulls mido)
pip install scoremill[play]     # + python-rtmidi for real-time ports

Or copy scoremill.py into your project — it is a single file — or pip install -e . from a clone.

Sixty seconds

from scoremill import Song, shift

s = Song(tempo=96, time="4/4", key="Am", humanize=1)

MOTIF = "a4e c5e e5q d5e c5e"               # three beats of material
A = s.section("A")
A.voice("rh", vel=52).bars(
    f"!mp {MOTIF} b4q | {shift(MOTIF, -1)} a4q |"
    "  e5q {d5 c5 b4}q a4h |"               # triplet on beat two
    "  [a3 c4 e4]w^& |")                    # rolled final chord, fermata
A.voice("lh", vel=36).harmony(
    "Am G Am E7", style="broken", voicing="smooth")
A.pedal("bar")
s.ritardando("A", 3, 4, 70)
s.arrange("A")

s.describe()                # printed summary
s.lint()                    # counterpoint findings
s.save("evening.mid")       # render
s.play()                    # or perform on the first MIDI output

If a bar does not add up, the parse fails immediately and says so:

voice 'A.rh' bar 2: has 3.0 beats, expected 4.0 — short by 1.0 beats (a 'q').
    bar was: d4e b4e c5q g4q

Notation

Element Syntax Notes
Pitch c d e f g a b + # b n + octave octave is sticky per voice; key signature applies (key="F" makes b mean B-flat, bn natural); minor keys (Am, Dm, ...) supported
Duration trailing w h q e s t, optional . sticky; r = rest
Chord [c4 e g]h shared duration
Tuplet {c4 d4 e4}q, {[c4 e4] d4}q members divide the span equally; a member may be a chord
Grace +d5 sounds just before the next note; stackable
Tie c5h~ the next note must repeat the pitch (validated); a tie on a voice's last note is laissez vibrer
Marks > accent · ' staccato · _ legato · ^ fermata · & roll · % trill after the duration; fermata length and trill rate are configurable on Song
Dynamics !ppp !pp !p !mp !mf !f !ff !fff, cresc, dim sticky; cresc/dim interpolate to the next mark, which must exist (validated)
Barline | asserts the bar is exactly full

Motif transforms

Development as string-to-string functions. Write a subject once and derive the rest:

shift(frag, 2)              # diatonic sequence up two steps
invert(frag, axis="g4")     # mirror about an axis pitch
retro(frag)                 # retrograde
stretch(frag, 2)            # augmentation (0.5 for diminution)
rebar(frag, 3)              # re-insert barlines every 3 beats

Explicit alterations travel with their scale degree under shift and are mirrored under invert (a raised degree inverts to a lowered one). retro insists the fragment contain no barlines, dynamics, or ties; apply those around the result. A grace note travels with the note it ornaments, tuplet members reverse within their tuplet, and sticky octaves and durations are written out first so the reversal cannot change what a token means. stretch changes durations and therefore the barring, so pair it with rebar, which re-inserts barlines at a chosen bar length and errors if a note would straddle one.

To move a finished piece to another key, song.transpose(semitones) shifts every entered note in place and relabels the keys, raising if a note would leave the instrument range. This is chromatic transposition of the whole song, distinct from the diatonic shift on fragments.

Harmony

voice.harmony("C Am7 F G7", style="stride", voicing="smooth",
              avoid=melody)

Twenty-six chord qualities (m 7 maj7 m7 6 m6 dim dim7 m7b5 aug sus2 sus4 9 maj9 m9 add9 mmaj7 m11 7sus4 9sus4 7b5 7#5 7b9 7#9 11 13), slash basses (C/G), and eight accompaniment styles (block root fifth waltz alberti arp broken stride; waltz, stride, and broken fill fractional meters). voicing= chooses how each chord is spelled: plain (close position), smooth (inversions that minimize movement between chords), shell (root, third, and seventh), rootless (drop the root for a comping color), or drop2 (lower the second voice from the top an octave, a wider open spread). A slash bass is never disturbed. harmony() takes its own octave argument for the register of the chord roots, independent of the octave the voice uses for melodic input.

avoid=<voice> makes the accompaniment melody-aware: chord tones that would double the named voice's pitch classes on a shared onset are dropped, and single figure tones that would collide at the exact unison move an octave away. When the song declares a pickup and the accompaniment voice is still empty, harmony() inserts the pickup rest itself.

Expression

Song(swing=0.62, swing_unit="sixteenth", humanize=2, expressive=True,
     fermata=1.6, trill_rate=0.125)
section.rubato(0.05, phrase=2, shape="arch")   # or "cradle"
section.pedal("bar")                # "half", or a number of beats
section.soft()                      # una corda for the section
s.tempo_change("A", bar=5, bpm=80)  # step change
s.ritardando("A", 7, 8, 60)         # linear ramp; a faster target
                                    # produces an accelerando

expressive adds downbeat lean, melodic-contour shading, and top-note voicing inside chords; humanize adds slight timing and velocity variation. swing_unit swings eighths or sixteenths; fermata sets how far a ^ note overshoots its written length; trill_rate sets a % trill's alternation speed. Rubato is an "arch" that presses forward and relaxes, or a "cradle" that broadens mid-phrase.

Analysis

s.lint()      # collisions and parallels, located by bar and beat
s.report()    # dict: sections, voices, ranges, density, duration, lint

lint() reports two things, each located by bar and beat: collisions, where two voices sound the same pitch at once, whether struck together or struck against a held note; and consecutive parallel fifths or octaves, checked on both the top and the bottom line of each voice pair. report() exists so an agent can check its own work programmatically, and its duration integrates the full tempo map:

assert s.report()["duration_s"] < 180
assert not s.report()["lint"]

The linter is advisory. Styles that double the tune and the accompaniment on strong beats will trip the parallel checks on purpose; read the findings, keep the ones that are idiom, fix the ones that are accidents. When a texture doubles by design, pass lint(mode="homophonic") to keep only the collisions.

Raw access

Notation is the front door, not the only one. song.events() returns the fully expressive event stream as sorted (tick, kind, channel, a, b) tuples at 480 ticks per beat, exactly what save() and play() render, for agents that prefer to work below the notation:

for tick, kind, ch, a, b in song.events():
    ...            # kind in {"on", "off", "cc64", "cc67", "tempo"}

Song, Voice, the transforms, and the renderer are ordinary Python, importable a la carte; a Voice's notes list accepts hand-built Note objects, which bypass notation validation.

Two query helpers answer "what notes are in this?" without a Song, so an agent can reason about harmony directly: chord_pitches("Cmaj9") returns the MIDI pitches of a chord symbol (slash bass first when present), and scale_pitches("Am") returns the seven pitches of a key's diatonic scale, ascending from the tonic.

Playback

play() streams in real time through mido (requires python-rtmidi). It picks the first hardware output, or match one by substring: s.play(port="FluidSynth"). play(count_in=4) taps four beats before the music; play(progress=fn) calls fn with each message as it goes out. Playback releases all notes and both pedals on exit, so an interrupt leaves nothing hanging. Without hardware, render with save() and use any soft synth, for example:

fluidsynth -a pulseaudio soundfont.sf2 piece.mid

A pitched-instrument range guard rejects notes a piano cannot play; widen it for synths with Song(pitch_range=(0, 127)).

Examples

File Demonstrates
examples/saltarello_alla_chico.py the demo score: variants, per-section keys, grace notes, trill, accelerando
examples/dynamo_rag.py a full multi-strain ragtime: stride bass, written syncopation, subdominant trio
examples/silver_dollar_saloon.py a frontier two-step: oom-pah, honky-tonk grace slides, shave-and-a-haircut tag
examples/nickelodeon.py a player-piano novelty roll: generated figuration, secondary-rag accents, whole-tone runs
examples/player_piano_studies.py five Nancarrow-style studies built through the raw Note API: tempo canon, coprime ostinati, acceleration, full-keyboard cascades, tutti
examples/minuet_small_computer.py sections, waltz harmony, arrangement
examples/blues_416_megabytes.py swing, grace notes, stride, rubato
examples/invention_two_processes.py motif transforms, two-voice counterpoint, lint
examples/orrery.py process music: prime-period orbits, overtone pitches

Running an example writes its .mid next to it; add --play to perform it on a connected MIDI output.

Jukebox

jukebox.py plays a whole folder of scores on a MIDI output. It renders each script once (the "running a script writes its .mid" contract, so a script that builds several songs contributes several tracks) and plays the results.

With no flag it opens the GUI (tkinter): a searchable track list, play/stop/auto/loop, tempo/volume/voice, and a Local/Remote toggle that sends output to a port on this machine or, over the forwarder, to an instrument on another host. The rest are headless, for agents and automation:

python jukebox.py                 # GUI
python jukebox.py --list          # print the playlist and exit
python jukebox.py --track 3       # play one track and exit
python jukebox.py --all           # play the playlist in order
python jukebox.py --dir myscores --port "FluidSynth"
python jukebox.py --library ~/midi  # a folder of MIDI files, not scripts

A --library DIR source browses a directory of existing MIDI files instead of rendering scoremill scripts; its subfolders become named playlists (the GUI's Genre and Category menus).

It prefers a real instrument port and warns when only a MIDI loopback is available (which makes no sound). Real output needs python-rtmidi. Re-launching is instant, since scores already rendered are not rebuilt.

To play an instrument attached to another machine, run the jukebox on the far side too:

python jukebox.py --forward           # on the host with the instrument
python jukebox.py --remote HOST       # on the host driving playback

The --remote side streams each MIDI message over TCP to the --forward side, which relays it to a local port; the driving machine needs no MIDI hardware or backend, only mido to parse the scores. The forwarder re-selects the instrument on each connection, so it may start before the instrument is powered on, and it releases every note if the client drops.

License

MIT.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

scoremill-0.5.0.tar.gz (73.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

scoremill-0.5.0-py3-none-any.whl (41.8 kB view details)

Uploaded Python 3

File details

Details for the file scoremill-0.5.0.tar.gz.

File metadata

  • Download URL: scoremill-0.5.0.tar.gz
  • Upload date:
  • Size: 73.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.2

File hashes

Hashes for scoremill-0.5.0.tar.gz
Algorithm Hash digest
SHA256 f69453593014114580447b91f64a2796d6565502bf8fd825a09f48400c44382c
MD5 e21995c0551de42dfa852a260985702c
BLAKE2b-256 b2a1020eda1d4e87f2d1bcde601a14dba43d6b46c7f9baac7419f1bca0f061cc

See more details on using hashes here.

File details

Details for the file scoremill-0.5.0-py3-none-any.whl.

File metadata

  • Download URL: scoremill-0.5.0-py3-none-any.whl
  • Upload date:
  • Size: 41.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.2

File hashes

Hashes for scoremill-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4ee8bf6b62fdc994d7b9a7a01d2cc601a371f21c6de8d711aa4c0f894618b396
MD5 18af78a8f7aa435cdc95b5b3f2284c5d
BLAKE2b-256 8f462526d1ae6f30031968b6ef2a9b1f5cc2375e7c8745d05f862736a79ef519

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page