From dd3b7bd03ea1addbaf1565f04af39dae11156f4f Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 04:36:41 -0400 Subject: [PATCH] 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) --- pytheory/__init__.py | 5 +- pytheory/play.py | 107 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 673c623..a4e5676 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -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", ] diff --git a/pytheory/play.py b/pytheory/play.py index fb45ede..5f9f0e2 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -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)