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.
220 lines
8.2 KiB
Python
220 lines
8.2 KiB
Python
"""
|
|
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
|