Files
midi-playlist/lib/midikit.py
T
Hermes Agent 95480abdab Rebuild as a code-first MIDI composition toolkit
Replaces the April wreckage (fifteen 70-byte 'working' MIDIs, archived)
with a proper pipeline:

- lib/midikit.py: stdlib-only SMF format-1 writer + reader/verifier
- lib/songkit.py: composition helpers (bar-grid score DSL with a 4/4
  bar linter, OPL arps, driving bass, vibrato, drum patterns)
- tunes/: three original tunes - DOS adventure theme (Dm/132),
  N64 ambient (G/72), chiptune racing (Em/152) - all with LOOP_START/
  LOOP_END markers for game engines
- tools/build_all.sh + tools/check_wav.py: generate, verify, render
  via timidity+FluidR3, fail on clipping/dead air
- tests/smoke_test.py: round-trip verified against mido

All three tunes render clean: no clipping, no dead air, durations
match, chromaticism audited against the chord chart.
2026-10-17 00:00:20 +00:00

388 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