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