Files
midi-playlist/lib/songkit.py
T

219 lines
8.2 KiB
Python
Raw Normal View History

"""
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