387 lines
15 KiB
Python
387 lines
15 KiB
Python
"""
|
|||
|
|
midikit — a dependency-free Standard MIDI File (SMF) writer + reader.
|
||
|
|
|
||
|
|
Pure Python stdlib. Written for retro game audio work: DOS/OPL-flavoured
|
||
|
|
Sound Blaster tunes, N64-era ambient, chiptune. Everything a 1993 sound
|
||
|
|
card would choke on is optional; everything it handled is here.
|
||
|
|
|
||
|
|
Why not midiutil/mido? Because this library has to run on any box with a
|
||
|
|
Python and no network. Zero imports beyond struct.
|
||
|
|
|
||
|
|
Quick start:
|
||
|
|
|
||
|
|
from midikit import MidiFile, Track, MPQN
|
||
|
|
|
||
|
|
mf = MidiFile(tpq=480)
|
||
|
|
t = Track("Lead")
|
||
|
|
t.program(0, 81) # channel 0 -> Square Lead (GM 81)
|
||
|
|
t.note(0, 62, 480, 100) # ch, pitch, duration, velocity
|
||
|
|
mf.add_track(t)
|
||
|
|
mf.add_tempo_track([MPQN(0, 500000)]) # 120 BPM
|
||
|
|
mf.write("tune.mid")
|
||
|
|
|
||
|
|
Author: Kevin Hermes (kevinhermes.retroweb.dev)
|
||
|
|
License: do what you like, credit appreciated.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import struct
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# GM instrument table (0-based program numbers; +1 for the "GM patch 1-128"
|
||
|
|
# numbering you'll see in sequencers).
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
GM = {
|
||
|
|
# Piano
|
||
|
|
"acoustic grand": 0, "bright acoustic": 1, "electric piano 1": 4,
|
||
|
|
"electric piano 2": 5, "harpsichord": 6, "clavinet": 7,
|
||
|
|
# Chromatic percussion
|
||
|
|
"celesta": 8, "glockenspiel": 9, "music box": 10, "vibraphone": 11,
|
||
|
|
"marimba": 12, "xylophone": 13, "tubular bells": 14,
|
||
|
|
# Organ
|
||
|
|
"drawbar organ": 16, "percussive organ": 17, "rock organ": 19, "church organ": 19,
|
||
|
|
# Guitar
|
||
|
|
"acoustic nylon": 24, "acoustic steel": 25, "electric jazz": 26,
|
||
|
|
"clean electric": 27, "muted electric": 28, "overdrive": 29, "distortion": 30,
|
||
|
|
# Bass
|
||
|
|
"acoustic bass": 32, "electric bass finger": 33, "electric bass pick": 34,
|
||
|
|
"fretless": 35, "slap bass 1": 36, "synth bass 1": 38, "synth bass 2": 39,
|
||
|
|
"chorus bass": 40,
|
||
|
|
# Strings
|
||
|
|
"violin": 40, "viola": 41, "cello": 42, "contrabass": 43,
|
||
|
|
"tremolo strings": 44, "pizzicato strings": 45, "orchestral harp": 46,
|
||
|
|
"timpani": 47,
|
||
|
|
"string ensemble 1": 48, "string ensemble 2": 49,
|
||
|
|
"synth strings 1": 50, "synth strings 2": 51,
|
||
|
|
"choir aahs": 52, "voice oohs": 53,
|
||
|
|
# Brass
|
||
|
|
"synth trumpet": 61, "horn": 60, "trombone": 57, "tuba": 58,
|
||
|
|
# Reed
|
||
|
|
"soprano sax": 65, "alto sax": 66, "tenor sax": 67, "baritone sax": 68,
|
||
|
|
"clarinet": 71, "oboe": 69, "english horn": 70, "bassoon": 70,
|
||
|
|
# Pipe (N64 ambient staples)
|
||
|
|
"flute": 73, "piccolo": 72, "recorder": 74, "pan flute": 76,
|
||
|
|
"blown bottle": 77, "shakuhachi": 78, "whistle": 79, "ocarina": 79,
|
||
|
|
# Synth leads (the DOS/Sound Blaster toolkit)
|
||
|
|
"square lead": 80, "sawtooth lead": 81, "calliope lead": 82,
|
||
|
|
"chiff lead": 83, "charang lead": 84, "voice lead": 85,
|
||
|
|
"fifths lead": 86, "bass+lead": 87,
|
||
|
|
# Synth pads
|
||
|
|
"new age pad": 88, "warm pad": 89, "polysynth pad": 90,
|
||
|
|
"space voice pad": 91, "glass pad": 92, "sweep pad": 93,
|
||
|
|
# Synth effects (OPL flavour)
|
||
|
|
"fx: rain": 99, "fx: soundtrack": 98, "sitar": 104, "banjo": 105,
|
||
|
|
"koto": 107, "calypso drum": 114, "crystal": 98, "atmosphere": 99,
|
||
|
|
}
|
||
|
|
|
||
|
|
# Channel 10 (0-based 9) is percussion in GM.
|
||
|
|
DRUMS = {
|
||
|
|
"acoustic bass drum": 35, "bass drum 1": 36, "bass drum 2": 35,
|
||
|
|
"side stick": 37, "acoustic snare": 38, "electric snare": 39,
|
||
|
|
"closed hi-hat": 42, "pedal hi-hat": 44, "open hi-hat": 46,
|
||
|
|
"low tom": 45, "mid tom": 47, "high tom": 50, "crash cymbal": 49,
|
||
|
|
"ride cymbal": 51, "china cymbal": 52, "tambourine": 54,
|
||
|
|
"cowbell": 56, "ride bell": 53, "clap": 39, "shaker": 70,
|
||
|
|
}
|
||
|
|
|
||
|
|
# Short aliases used by the drum patterns in songkit.
|
||
|
|
DRUMS.update({
|
||
|
|
"bass1": 36, "bass2": 35, "snare": 38, "esnr": 39, "hat": 42,
|
||
|
|
"closedhat": 42, "pedalhat": 44, "openhat": 46, "lotom": 45,
|
||
|
|
"lowtom": 45, "midtom": 47, "hightom": 50, "crash": 49, "ride": 51,
|
||
|
|
"china": 52, "tamb": 54, "cow": 56, "stick": 37, "shaker": 70,
|
||
|
|
})
|
||
|
|
|
||
|
|
MIN = 0.0
|
||
|
|
MAX = 127.0
|
||
|
|
|
||
|
|
|
||
|
|
def vlq(n):
|
||
|
|
"""Encode an int as a MIDI variable-length quantity."""
|
||
|
|
if n < 0:
|
||
|
|
raise ValueError("delta times cannot be negative")
|
||
|
|
out = bytearray([n & 0x7F])
|
||
|
|
n >>= 7
|
||
|
|
while n:
|
||
|
|
out.append((n & 0x7F) | 0x80)
|
||
|
|
n >>= 7
|
||
|
|
return bytes(reversed(out))
|
||
|
|
|
||
|
|
|
||
|
|
def parse_vlq(data, pos):
|
||
|
|
"""Decode a VLQ at data[pos]; returns (value, new_pos)."""
|
||
|
|
value = 0
|
||
|
|
while True:
|
||
|
|
b = data[pos]
|
||
|
|
pos += 1
|
||
|
|
value = (value << 7) | (b & 0x7F)
|
||
|
|
if not b & 0x80:
|
||
|
|
return value, pos
|
||
|
|
if pos >= len(data):
|
||
|
|
raise ValueError("truncated VLQ")
|
||
|
|
|
||
|
|
|
||
|
|
def note(name):
|
||
|
|
"""'A4', 'C#5', 'Bb3' -> MIDI note number. A4 == 69."""
|
||
|
|
name = name.strip()
|
||
|
|
i = 1
|
||
|
|
while i < len(name) and not (name[i].isdigit() or name[i] == '-'):
|
||
|
|
i += 1
|
||
|
|
letter, accidental, octave = name[0].upper(), name[1:i], int(name[i:])
|
||
|
|
base = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}[letter]
|
||
|
|
base += accidental.count("#") + 1 * accidental.count("s")
|
||
|
|
base -= accidental.count("b") + 1 * accidental.count("f")
|
||
|
|
return (octave + 1) * 12 + base
|
||
|
|
|
||
|
|
|
||
|
|
def note_name(p):
|
||
|
|
"""MIDI note number -> 'C#5' style name."""
|
||
|
|
names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
||
|
|
return names[p % 12] + str(p // 12 - 1)
|
||
|
|
|
||
|
|
|
||
|
|
class MPQN:
|
||
|
|
"""A tempo change: microseconds per quarter note at absolute tick."""
|
||
|
|
__slots__ = ("tick", "mpqn")
|
||
|
|
|
||
|
|
def __init__(self, tick, mpqn):
|
||
|
|
self.tick = tick
|
||
|
|
self.mpqn = mpqn
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_bpm(cls, tick, bpm):
|
||
|
|
return cls(tick, int(round(60_000_000 / bpm)))
|
||
|
|
|
||
|
|
def bpm(self):
|
||
|
|
return 60_000_000 / self.mpqn
|
||
|
|
|
||
|
|
|
||
|
|
class Track:
|
||
|
|
"""A musical track: notes, controllers, pitch bends, meta, in absolute ticks.
|
||
|
|
|
||
|
|
All events are stored with absolute tick positions and sorted on write,
|
||
|
|
so you can compose out of order — melody first, then bass, then fix
|
||
|
|
velocities — without worrying about delta-time bookkeeping.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, name=None, channel=0):
|
||
|
|
self.name = name
|
||
|
|
self.channel = channel
|
||
|
|
self.events = [] # (tick, order, bytes) order breaks ties
|
||
|
|
|
||
|
|
# -- internals ----------------------------------------------------------
|
||
|
|
def _add(self, tick, order, payload):
|
||
|
|
self.events.append((tick, order, payload))
|
||
|
|
|
||
|
|
# -- musical events -----------------------------------------------------
|
||
|
|
def note(self, tick, pitch, duration, velocity=100, channel=None):
|
||
|
|
"""Note on at tick, off at tick+duration. Overlap-safe (running-status-free)."""
|
||
|
|
ch = self.channel if channel is None else channel
|
||
|
|
self._add(tick, 1, bytes([0x90 | ch, pitch & 0x7F, max(1, int(velocity)) & 0x7F]))
|
||
|
|
self._add(tick + duration, 0, bytes([0x80 | ch, pitch & 0x7F, 0]))
|
||
|
|
return self
|
||
|
|
|
||
|
|
def notes(self, seq, channel=None):
|
||
|
|
"""seq of (tick, pitch, duration, velocity) tuples."""
|
||
|
|
for t, p, d, v in seq:
|
||
|
|
self.note(t, p, d, v, channel)
|
||
|
|
return self
|
||
|
|
|
||
|
|
def program(self, tick, patch, channel=None):
|
||
|
|
ch = self.channel if channel is None else channel
|
||
|
|
if isinstance(patch, str):
|
||
|
|
patch = GM[patch.lower()]
|
||
|
|
self._add(tick, 0, bytes([0xC0 | ch, patch & 0x7F]))
|
||
|
|
return self
|
||
|
|
|
||
|
|
def cc(self, tick, controller, value, channel=None):
|
||
|
|
ch = self.channel if channel is None else channel
|
||
|
|
self._add(tick, 0, bytes([0xB0 | ch, controller & 0x7F, value & 0x7F]))
|
||
|
|
return self
|
||
|
|
|
||
|
|
def pitch_bend(self, tick, value, channel=None):
|
||
|
|
"""value -8192..8191 (0 = centre)."""
|
||
|
|
ch = self.channel if channel is None else channel
|
||
|
|
v = max(-8192, min(8191, int(value))) + 8192
|
||
|
|
self._add(tick, 0, bytes([0xE0 | ch, v & 0x7F, (v >> 7) & 0x7F]))
|
||
|
|
return self
|
||
|
|
|
||
|
|
def bend_to(self, tick, semitones, channel=None, range_semi=2):
|
||
|
|
"""Bend to a semitone offset (assumes pitch-bend range = range_semi)."""
|
||
|
|
frac = max(-1.0, min(1.0, semitones / range_semi))
|
||
|
|
return self.pitch_bend(tick, int(frac * 8191), channel)
|
||
|
|
|
||
|
|
def bend_reset(self, tick, channel=None):
|
||
|
|
return self.pitch_bend(tick, 0, channel)
|
||
|
|
|
||
|
|
# -- meta ---------------------------------------------------------------
|
||
|
|
def meta(self, tick, type_byte, data=b""):
|
||
|
|
self._add(tick, 0, bytes([0xFF, type_byte]) + vlq(len(data)) + data)
|
||
|
|
return self
|
||
|
|
|
||
|
|
def text(self, tick, s):
|
||
|
|
return self.meta(tick, 0x01, s.encode("latin-1"))
|
||
|
|
|
||
|
|
def marker(self, tick, s):
|
||
|
|
return self.meta(tick, 0x06, s.encode("latin-1"))
|
||
|
|
|
||
|
|
# -- rendering ----------------------------------------------------------
|
||
|
|
def _end(self):
|
||
|
|
return (self._max_tick(), 99, b"\xFF\x2F\x00")
|
||
|
|
|
||
|
|
def _max_tick(self):
|
||
|
|
return max((t for t, _, _ in self.events), default=0)
|
||
|
|
|
||
|
|
def build(self):
|
||
|
|
events = sorted(self.events + [self._end()], key=lambda e: (e[0], e[1]))
|
||
|
|
out = bytearray()
|
||
|
|
if self.name:
|
||
|
|
out += vlq(0) + bytes([0xFF, 0x03]) + vlq(len(self.name)) + self.name.encode("latin-1")
|
||
|
|
last = 0
|
||
|
|
for tick, _order, payload in events:
|
||
|
|
out += vlq(tick - last) + payload
|
||
|
|
last = tick
|
||
|
|
return b"MTrk" + struct.pack(">I", len(out)) + bytes(out)
|
||
|
|
|
||
|
|
|
||
|
|
class MidiFile:
|
||
|
|
"""Format 1 multi-track SMF. A conductor track is added automatically
|
||
|
|
from the tempo map so tempo/key/time live in their own track (GM-safe)."""
|
||
|
|
|
||
|
|
def __init__(self, tpq=480):
|
||
|
|
self.tpq = tpq
|
||
|
|
self.tracks = []
|
||
|
|
self.tempos = [] # list[MPQN]
|
||
|
|
self.key_sig = None # (flats_sharps, minor_bool)
|
||
|
|
self.time_sig = None # (num, den_power)
|
||
|
|
|
||
|
|
def add_track(self, track):
|
||
|
|
self.tracks.append(track)
|
||
|
|
return track
|
||
|
|
|
||
|
|
def add_tempo_track(self, tempos):
|
||
|
|
"""Set the tempo map; build() renders it into a Conductor track."""
|
||
|
|
self.tempos = list(tempos)
|
||
|
|
|
||
|
|
def set_tempo(self, bpm, tick=0):
|
||
|
|
self.tempos.append(MPQN.from_bpm(tick, bpm))
|
||
|
|
|
||
|
|
def build(self):
|
||
|
|
if not any(t.tick == 0 for t in self.tempos):
|
||
|
|
self.tempos.insert(0, MPQN(0, 500000))
|
||
|
|
conductor = Track("Conductor")
|
||
|
|
for mp in sorted(self.tempos, key=lambda m: m.tick):
|
||
|
|
conductor.meta(mp.tick, 0x51, struct.pack(">I", mp.mpqn)[1:])
|
||
|
|
if self.time_sig:
|
||
|
|
conductor.meta(0, 0x58, bytes([self.time_sig[0], self.time_sig[1], 24, 8]))
|
||
|
|
if self.key_sig:
|
||
|
|
conductor.meta(0, 0x59, bytes([self.key_sig[0] & 0xFF, 1 if self.key_sig[1] else 0]))
|
||
|
|
tracks = [conductor] + self.tracks
|
||
|
|
header = b"MThd" + struct.pack(">IHHH", 6, 1, len(tracks), self.tpq)
|
||
|
|
return header + b"".join(t.build() for t in tracks)
|
||
|
|
|
||
|
|
def write(self, path):
|
||
|
|
data = self.build()
|
||
|
|
with open(path, "wb") as f:
|
||
|
|
f.write(data)
|
||
|
|
return len(data)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Reader / verifier — parse our own output back and sanity-check it.
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
def read(path_or_bytes):
|
||
|
|
"""Parse an SMF into a dict: {format, tpq, tracks:[...], duration_sec}.
|
||
|
|
Handles format 0/1/2, running status, all channel + meta events."""
|
||
|
|
data = path_or_bytes if isinstance(path_or_bytes, (bytes, bytearray)) else open(path_or_bytes, "rb").read()
|
||
|
|
assert data[:4] == b"MThd", "missing MThd header"
|
||
|
|
hlen, fmt, ntrk, division = struct.unpack(">IHHH", data[4:14])
|
||
|
|
assert not (division & 0x8000), "SMPTE time division not supported"
|
||
|
|
pos = 14
|
||
|
|
tracks = []
|
||
|
|
for _ in range(ntrk):
|
||
|
|
assert data[pos:pos + 4] == b"MTrk", "missing MTrk at %d" % pos
|
||
|
|
tlen = struct.unpack(">I", data[pos + 4:pos + 8])[0]
|
||
|
|
pos += 8
|
||
|
|
end = pos + tlen
|
||
|
|
track = {"notes": [], "tempos": [], "name": None, "programs": {}, "cc": [], "bends": []}
|
||
|
|
tick = 0
|
||
|
|
running = None
|
||
|
|
while pos < end:
|
||
|
|
delta, pos = parse_vlq(data, pos)
|
||
|
|
tick += delta
|
||
|
|
status = data[pos]
|
||
|
|
if status < 0x80: # running status: reuse last status, don't advance
|
||
|
|
assert running is not None, "running status with no status byte"
|
||
|
|
status = running
|
||
|
|
else:
|
||
|
|
pos += 1
|
||
|
|
if status < 0xF0:
|
||
|
|
running = status
|
||
|
|
if status == 0xFF: # meta
|
||
|
|
mtype = data[pos]; pos += 1
|
||
|
|
mlen, pos = parse_vlq(data, pos)
|
||
|
|
mdata = data[pos:pos + mlen]; pos += mlen
|
||
|
|
if mtype == 0x51:
|
||
|
|
track["tempos"].append((tick, int.from_bytes(mdata, "big")))
|
||
|
|
elif mtype == 0x03:
|
||
|
|
track["name"] = mdata.decode("latin-1")
|
||
|
|
elif mtype == 0x2F:
|
||
|
|
break
|
||
|
|
elif status in (0xF0, 0xF7):
|
||
|
|
dlen, pos = parse_vlq(data, pos)
|
||
|
|
pos += dlen
|
||
|
|
else:
|
||
|
|
hi = status & 0xF0
|
||
|
|
ch = status & 0x0F
|
||
|
|
if hi in (0xC0, 0xD0):
|
||
|
|
b1 = data[pos]; pos += 1
|
||
|
|
if hi == 0xC0:
|
||
|
|
track["programs"][ch] = b1
|
||
|
|
else:
|
||
|
|
b1, b2 = data[pos], data[pos + 1]; pos += 2
|
||
|
|
if hi == 0x90 and b2 > 0:
|
||
|
|
track["notes"].append([tick, ch, b1, b2, None])
|
||
|
|
elif hi == 0x80 or (hi == 0x90 and b2 == 0):
|
||
|
|
for n in track["notes"]:
|
||
|
|
if n[1] == ch and n[2] == b1 and n[4] is None:
|
||
|
|
n[4] = tick
|
||
|
|
break
|
||
|
|
elif hi == 0xB0:
|
||
|
|
track["cc"].append((tick, ch, b1, b2))
|
||
|
|
elif hi == 0xE0:
|
||
|
|
track["bends"].append((tick, ch, ((b2 << 7) | b1) - 8192))
|
||
|
|
track["notes"] = [dict(tick=n[0], ch=n[1], pitch=n[2], vel=n[3], end=n[4] or n[0]) for n in track["notes"]]
|
||
|
|
tracks.append(track)
|
||
|
|
# duration from tempo map (conductor track or merged)
|
||
|
|
all_tempos = sorted([tp for t in tracks for tp in t["tempos"]])
|
||
|
|
max_tick = 0
|
||
|
|
for t in tracks:
|
||
|
|
for n in t["notes"]:
|
||
|
|
max_tick = max(max_tick, n["end"])
|
||
|
|
for ev in t["bends"] + t["cc"]:
|
||
|
|
max_tick = max(max_tick, ev[0])
|
||
|
|
if all_tempos:
|
||
|
|
sec, prev_tick, prev_mpqn = 0.0, 0, all_tempos[0][1]
|
||
|
|
for tk, mpqn in all_tempos:
|
||
|
|
sec += (tk - prev_tick) / division * prev_mpqn / 1e6
|
||
|
|
prev_tick, prev_mpqn = tk, mpqn
|
||
|
|
sec += (max_tick - prev_tick) / division * prev_mpqn / 1e6
|
||
|
|
else:
|
||
|
|
sec = max_tick / division * 0.5
|
||
|
|
return {"format": fmt, "tpq": division, "tracks": tracks,
|
||
|
|
"duration_sec": round(sec, 2), "max_tick": max_tick}
|
||
|
|
|
||
|
|
|
||
|
|
def verify(path, min_sec=1.0, max_sec=600.0, min_notes=8):
|
||
|
|
"""Assert the file parses, has plausible duration and note count.
|
||
|
|
Returns the parsed dict; raises AssertionError with a useful message."""
|
||
|
|
d = read(path)
|
||
|
|
n = sum(len(t["notes"]) for t in d["tracks"])
|
||
|
|
assert d["duration_sec"] >= min_sec, f"{path}: duration {d['duration_sec']}s < {min_sec}s"
|
||
|
|
assert d["duration_sec"] <= max_sec, f"{path}: duration {d['duration_sec']}s > {max_sec}s"
|
||
|
|
assert n >= min_notes, f"{path}: only {n} notes"
|
||
|
|
for t in d["tracks"]:
|
||
|
|
for note_ in t["notes"]:
|
||
|
|
assert note_["end"] > note_["tick"], f"{path}: zero/negative note length at {note_['tick']}"
|
||
|
|
assert 0 < note_["vel"] <= 127, f"{path}: bad velocity {note_['vel']}"
|
||
|
|
return d
|