""" 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) self.loop = None # (start_tick, end_tick) def set_loop(self, start_tick, end_tick): """Declare the loopable section (game BGM: intro plays once, then the engine jumps back). Writes markers in every convention that matters, so whichever player/engine you point at the file, it finds the loop point: LOOP_START / LOOP_END - our convention, plain SMF markers RPR LOOP START / RPR LOOP END - Reaper (and anything reading RPR) LoopBegin / LoopEnd - various game engines & trackers All times are absolute ticks; use songkit.bar() to think in bars. """ self.loop = (int(start_tick), int(end_tick)) return self 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.loop: start, end = self.loop for tick, names in ((start, ("LOOP_START", "RPR LOOP START", "LoopBegin")), (end, ("LOOP_END", "RPR LOOP END", "LoopEnd"))): for nm in names: conductor.meta(tick, 0x06, nm.encode("latin-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": [], "markers": []} 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 == 0x06: track["markers"].append((tick, 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