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.
This commit is contained in:
1 parent
7ddabaff2a
commit
95480abdab
31 files changed
+1222
-31
No files matched your search
Binary file not shown.
Binary file not shown.
+387
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
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
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
songkit — composition helpers built on midikit.
|
||||
|
||||
The patterns that make MIDI sound like a 1993 sound card rather than a
|
||||
MIDI file: OPL-style 16th arpeggios, square-lead pitch-bend vibrato,
|
||||
driving 8th-note bass, velocity humanising, and bar-based song structure.
|
||||
|
||||
All times are in bars.beats as floats: bar(t, beat=0) with 4/4 and your
|
||||
file's tpq. E.g. with tpq=480, bar(2.5) == start of beat 3 of bar 3.
|
||||
"""
|
||||
|
||||
from midikit import Track, GM, DRUMS, note as N
|
||||
|
||||
TPQ = 480 # ticks per quarter; songs should agree with this
|
||||
|
||||
|
||||
def bar(t, beat=0.0, tpq=TPQ):
|
||||
"""Absolute tick for bar t (0-based float), beat within bar (0..4)."""
|
||||
return int(round((t * 4 + beat) * tpq))
|
||||
|
||||
|
||||
def q(tpq=TPQ):
|
||||
return tpq
|
||||
|
||||
|
||||
def parse_score(text, tpq=TPQ):
|
||||
"""Whitespace-separated note grid -> list of (tick, pitch, dur, vel).
|
||||
|
||||
Grammar per token: PITCH[-dur[-vel]]
|
||||
pitch: C0..C9 with #/b/suffixes (e.g. D4, A#5, Bb3)
|
||||
dur: 1=whole 2=half 4=quarter (default) 8=eighth 16=sixteenth
|
||||
vel: 1-127 (default 100)
|
||||
rests: r4 / r8 / r2 / r16 / r1
|
||||
|
||||
Bars may be separated with '|'. If any '|' is present, every bar
|
||||
segment must total exactly 4 quarter notes — a free bar-length
|
||||
linter that has saved me from more off-by-one-eighth bugs than
|
||||
I care to admit.
|
||||
|
||||
Example: 'D4 A4-8 D5-8 F5-8 E5-4 D5-2'
|
||||
"""
|
||||
out = []
|
||||
tick = 0
|
||||
bar_ticks = 0
|
||||
bar_no = 1
|
||||
for tok in text.split():
|
||||
if tok == "|":
|
||||
if bar_ticks != tpq * 4:
|
||||
raise ValueError("bar %d totals %d ticks, expected %d (4/4)"
|
||||
% (bar_no, bar_ticks, tpq * 4))
|
||||
bar_ticks = 0
|
||||
bar_no += 1
|
||||
continue
|
||||
if tok.startswith("r"):
|
||||
dur = {"r1": tpq * 4, "r2": tpq * 2, "r4": tpq, "r8": tpq // 2,
|
||||
"r16": tpq // 4}.get(tok, tpq // 4)
|
||||
tick += dur
|
||||
bar_ticks += dur
|
||||
continue
|
||||
parts = tok.split("-")
|
||||
pitch = N(parts[0])
|
||||
dur = tpq // 4
|
||||
vel = 100
|
||||
if len(parts) > 1 and parts[1]:
|
||||
dur = {"1": tpq * 4, "2": tpq * 2, "4": tpq, "8": tpq // 2,
|
||||
"16": tpq // 4}[parts[1]]
|
||||
if len(parts) > 2 and parts[2]:
|
||||
vel = int(parts[2])
|
||||
out.append((tick, pitch, dur, vel))
|
||||
tick += dur
|
||||
bar_ticks += dur
|
||||
return out
|
||||
|
||||
|
||||
def melody(track, start_bar, score, channel=0, tpq=TPQ):
|
||||
"""Lay a parse_score() grid onto a track starting at a bar."""
|
||||
base = bar(start_bar, tpq=tpq)
|
||||
for t, p, d, v in parse_score(score, tpq):
|
||||
track.note(base + t, p, d, v, channel)
|
||||
return track
|
||||
|
||||
|
||||
def chord(track, tick, root_name, quality, octave, velocity=80, duration=TPQ * 4, channel=0):
|
||||
"""Play a chord: chord(t, 'D', 'm', 3) -> D3 F3 A3."""
|
||||
root = N(root_name + str(octave))
|
||||
for iv in QUALITIES[quality]:
|
||||
track.note(tick, root + iv, duration, velocity, channel)
|
||||
return track
|
||||
|
||||
|
||||
QUALITIES = {
|
||||
"": (0, 4, 7), "maj": (0, 4, 7), "m": (0, 3, 7), "dim": (0, 3, 6),
|
||||
"aug": (0, 4, 8), "sus2": (0, 2, 7), "sus4": (0, 5, 7),
|
||||
"7": (0, 4, 7, 10), "maj7": (0, 4, 7, 11), "m7": (0, 3, 7, 10),
|
||||
"m7b5": (0, 3, 6, 10), "6": (0, 4, 7, 9), "m6": (0, 3, 7, 9),
|
||||
"9": (0, 4, 7, 10, 14), "add9": (0, 4, 7, 14), "sus": (0, 5, 7),
|
||||
}
|
||||
|
||||
|
||||
def arp(track, start_bar, chords, pattern="0121", subdivision=4, velocity=70,
|
||||
octave=4, duration=None, channel=0, tpq=TPQ, raise_bass_octave=True):
|
||||
"""OPL/Sound Blaster staple: 16th-note arpeggios following a chord chart.
|
||||
|
||||
chords: list of (root, quality) per bar, e.g. [('D','m'), ('Bb',''), ...]
|
||||
pattern: indices into the chord tones (bass octave bump on index 0).
|
||||
subdivision: notes per beat (4 = sixteenths).
|
||||
"""
|
||||
per_bar = 4 * subdivision
|
||||
if isinstance(pattern, str):
|
||||
pattern = tuple(int(c) for c in pattern)
|
||||
for i, (root, qual) in enumerate(chords):
|
||||
tones = [N(root + str(octave)) + iv for iv in QUALITIES[qual]]
|
||||
for s in range(per_bar):
|
||||
idx = pattern[s % len(pattern)] % len(tones)
|
||||
pitch = tones[idx]
|
||||
if idx == 0 and raise_bass_octave:
|
||||
pitch = tones[0] - 12 if tones[0] - 12 >= 36 else tones[0]
|
||||
t = bar(start_bar + i, s / subdivision, tpq)
|
||||
track.note(t, pitch, duration or tpq // subdivision, velocity, channel)
|
||||
return track
|
||||
|
||||
|
||||
def bass_drive(track, start_bar, chords, subdivision=2, velocity=95,
|
||||
octave=2, approach=True, channel=0, tpq=TPQ):
|
||||
"""Driving bass: repeated roots per bar (DOS adventure / racing staple),
|
||||
optional chromatic approach note on the last subdivision."""
|
||||
per_bar = 4 * subdivision
|
||||
for i, (root, _qual) in enumerate(chords):
|
||||
rootn = N(root + str(octave))
|
||||
for s in range(per_bar):
|
||||
pitch = rootn
|
||||
if approach and s == per_bar - 1:
|
||||
pitch = rootn - 1 # chromatic approach from below
|
||||
t = bar(start_bar + i, s / subdivision, tpq)
|
||||
track.note(t, pitch, int(tpq / subdivision * 0.9), velocity, channel)
|
||||
return track
|
||||
|
||||
|
||||
def vibrato(track, start_tick, end_tick, channel=0, depth=0.6, rate=5.5,
|
||||
tpq=TPQ, tempo_bpm=120, bend_range=2):
|
||||
"""Pitch-bend vibrato for square leads — the difference between a beep
|
||||
and an OPL voice. One bend per cycle; always resets at end_tick."""
|
||||
import math
|
||||
sec_per_tick = 60.0 / tempo_bpm / tpq
|
||||
ticks_per_cycle = 1.0 / rate / sec_per_tick
|
||||
t = start_tick
|
||||
phase = 0.0
|
||||
while t < end_tick:
|
||||
val = math.sin(phase) * depth * 8191
|
||||
track.bend_to(t, val / 8191 * bend_range, channel, bend_range)
|
||||
t += max(1, int(ticks_per_cycle / 2))
|
||||
phase += math.pi
|
||||
track.bend_reset(end_tick, channel)
|
||||
return track
|
||||
|
||||
|
||||
def humanize(vel, seed=0, amount=8):
|
||||
"""Deterministic pseudo-random velocity jitter — machine-gun velocities
|
||||
are the #1 tell of amateur MIDI."""
|
||||
x = (seed * 2654435761) & 0xFFFFFFFF
|
||||
x ^= x >> 13
|
||||
jitter = ((x * 1103515245) >> 16) % (2 * amount + 1) - amount
|
||||
return max(1, min(127, vel + jitter))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drum patterns (channel 9). Each returns list of (beat_offset, drum, vel).
|
||||
# ---------------------------------------------------------------------------
|
||||
def pattern_fill(name):
|
||||
return PATTERNS[name]
|
||||
|
||||
|
||||
PATTERNS = {
|
||||
# beat offsets within one bar (0..4)
|
||||
"dos_roll": [ # the classic DOS game fill: snare 16ths, crash on 1
|
||||
(i * 0.25, "snare", 90 + (i % 2) * 10) for i in range(16)
|
||||
],
|
||||
"rock": [
|
||||
(0, "bass1", 110), (1.5, "bass1", 95), (2.5, "bass1", 100),
|
||||
(1, "snare", 110), (3, "snare", 115),
|
||||
(0, "closedhat", 80), (0.5, "closedhat", 70), (1, "closedhat", 80),
|
||||
(1.5, "closedhat", 70), (2, "closedhat", 80), (2.5, "closedhat", 70),
|
||||
(3, "closedhat", 80), (3.5, "closedhat", 70),
|
||||
],
|
||||
"drive": [ # 150bpm racing: four-on-floor, 8th hats, snare 2&4
|
||||
(0, "bass1", 115), (1, "bass1", 110), (2, "bass1", 115), (3, "bass1", 110),
|
||||
(1, "snare", 115), (3, "snare", 120),
|
||||
(0, "closedhat", 85), (0.5, "closedhat", 75), (1, "closedhat", 85),
|
||||
(1.5, "closedhat", 75), (2, "closedhat", 85), (2.5, "closedhat", 75),
|
||||
(3, "closedhat", 85), (3.5, "closedhat", 75),
|
||||
],
|
||||
"halftime": [ # ambient/N64: sparse kick + soft shaker
|
||||
(0, "bass1", 70), (2, "bass1", 55),
|
||||
(0.5, "shaker", 45), (1.5, "shaker", 40), (2.5, "shaker", 45), (3.5, "shaker", 40),
|
||||
],
|
||||
"break": [ # busy 16th tom/snare break
|
||||
(0, "hightom", 105), (0.25, "midtom", 100), (0.5, "lowtom", 105),
|
||||
(0.75, "snare", 100), (1, "midtom", 105), (1.25, "hightom", 100),
|
||||
(1.5, "snare", 110), (2, "crash", 110),
|
||||
],
|
||||
"shufflesnare": [
|
||||
(i * 0.5 + (0.16 if i % 2 else 0), "snare", 85 + (i % 3) * 8) for i in range(8)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def drums(track, start_bar, bars, pattern, channel=9, tpq=TPQ, velocity_scale=1.0):
|
||||
"""Lay a named pattern across N bars."""
|
||||
for b in range(bars):
|
||||
for beat_off, drum, vel in PATTERNS[pattern]:
|
||||
vel = max(1, min(127, int(vel * velocity_scale)))
|
||||
track.note(bar(start_bar + b, beat_off, tpq), DRUMS[drum],
|
||||
tpq // 4, vel, channel)
|
||||
return track
|
||||
|
||||
|
||||
def crash(track, tick, vel=110, channel=9):
|
||||
track.note(tick, DRUMS["crash"], TPQ, vel, channel)
|
||||
return track
|
||||
Reference in new issue
Block a user