Add MIDI export via save_midi()

Zero-dependency Standard MIDI File writer for tones, chords, and progressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 04:36:41 -04:00
parent 62111de2da
commit dd3b7bd03e
2 changed files with 110 additions and 2 deletions
+3 -2
View File
@@ -9,10 +9,11 @@ from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, Fingering, charts_for_fretboard
try:
from .play import play, save, play_progression, Synth, Envelope
from .play import play, save, save_midi, play_progression, Synth, Envelope
except OSError:
play = None
save = None
save_midi = None
play_progression = None
Synth = None
Envelope = None
@@ -25,5 +26,5 @@ __all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "save", "play_progression", "Synth", "Envelope",
"play", "save", "save_midi", "play_progression", "Synth", "Envelope",
]
+107
View File
@@ -238,3 +238,110 @@ def play_progression(chords, *, t=1000, synth=Synth.SINE, gap=100,
play(chord, synth=synth, t=t, envelope=envelope)
if gap > 0 and i < len(chords) - 1:
time.sleep(gap / 1000.0)
# ── MIDI export ─────────────────────────────────────────────────────────────
def _vlq(value):
"""Encode an integer as MIDI variable-length quantity bytes."""
result = []
result.append(value & 0x7F)
value >>= 7
while value:
result.append((value & 0x7F) | 0x80)
value >>= 7
return bytes(reversed(result))
def save_midi(tone_or_chords, path, *, t=500, velocity=100, bpm=120, gap=0):
"""Save a tone, chord, or progression as a Standard MIDI File.
Writes a Type 0 (single-track) MIDI file that any DAW, notation
software, or MIDI player can open. Far more useful than WAV for
musicians — you can edit the notes, change the tempo, transpose,
and assign any instrument.
Args:
tone_or_chords: A Tone, Chord, or list of Tones/Chords.
A single Tone or Chord is written as one event.
A list is written as a sequence (progression).
path: Output file path (e.g. ``"progression.mid"``).
t: Duration of each note/chord in milliseconds (default 500).
velocity: MIDI velocity 1-127 (default 100).
bpm: Tempo in beats per minute (default 120).
gap: Silence between chords in milliseconds (default 0).
Example::
>>> from pytheory import Key, save_midi
>>> chords = Key("C", "major").progression("I", "V", "vi", "IV")
>>> save_midi(chords, "pop.mid", t=500, bpm=120)
>>> save_midi(Tone.from_string("C4"), "middle_c.mid", t=1000)
"""
import struct
ticks_per_beat = 480
us_per_beat = int(60_000_000 / bpm)
ticks_per_ms = ticks_per_beat * bpm / 60_000
# Normalize input to a list of items
if isinstance(tone_or_chords, list):
items = tone_or_chords
else:
items = [tone_or_chords]
# Build track events
events = bytearray()
# Tempo meta event: FF 51 03 <3 bytes of microseconds per beat>
events += _vlq(0) # delta time
events += b'\xFF\x51\x03'
events += struct.pack('>I', us_per_beat)[1:] # 3 bytes
duration_ticks = int(t * ticks_per_ms)
gap_ticks = int(gap * ticks_per_ms)
for item in items:
# Get MIDI note numbers
if hasattr(item, 'tones'):
notes = [tone.midi for tone in item.tones if tone.midi is not None]
else:
midi = item.midi
notes = [midi] if midi is not None else []
if not notes:
continue
# Note On events (delta=0 for all)
for note in notes:
events += _vlq(0)
events += bytes([0x90, note & 0x7F, velocity & 0x7F])
# Note Off events after duration
for i, note in enumerate(notes):
delta = duration_ticks if i == 0 else 0
events += _vlq(delta)
events += bytes([0x80, note & 0x7F, 0])
# Gap between chords
if gap_ticks > 0:
events += _vlq(gap_ticks)
events += bytes([0x90, 0, 0]) # silent note-on as spacer
events += _vlq(0)
events += bytes([0x80, 0, 0])
# End of track
events += _vlq(0)
events += b'\xFF\x2F\x00'
# Write MIDI file
with open(path, 'wb') as f:
# Header: MThd, length=6, format=0, tracks=1, ticks_per_beat
f.write(b'MThd')
f.write(struct.pack('>I', 6))
f.write(struct.pack('>HHH', 0, 1, ticks_per_beat))
# Track chunk
f.write(b'MTrk')
f.write(struct.pack('>I', len(events)))
f.write(events)