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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file scoremill-0.3.0.tar.gz.
File metadata
- Download URL: scoremill-0.3.0.tar.gz
- Upload date:
- Size: 62.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
62414ea5bcf68e18d075681479c300e403f00c41201b528feb8e9a58b8ec8246
|
|
| MD5 |
94e7cea791fddb00f3a2b7d430a785f9
|
|
| BLAKE2b-256 |
fc13eab24643a6d2fbc93f329d952b6fbbcfe4a3e895faba09c7958655186a8a
|
File details
Details for the file scoremill-0.3.0-py3-none-any.whl.
File metadata
- Download URL: scoremill-0.3.0-py3-none-any.whl
- Upload date:
- Size: 31.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
63b1637ee5b8d7af2f4aa985c28b8c460223144e592eccd0d0849dad855728ab
|
|
| MD5 |
0ae9c6df56495b4dd555f7ff29024d81
|
|
| BLAKE2b-256 |
dcfe5655ae84189f4e47827a42221e586daf9b22ff38b0b6079326d87b25dd18
|