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.
144 lines
5.2 KiB
Python
144 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
"The U's Late Equaliser" — fast chiptune sports/fighting theme
|
|
Style: Game Boy racing / fighting-game stage select. E minor, 152 BPM.
|
|
Structure: 2-bar intro, A (8), B (8), A' with harmony (8), tag (2).
|
|
|
|
The chiptune fingerprints:
|
|
- square lead hook in 8th-note runs, nothing sustains
|
|
- 16th-note synth bass, octave pops
|
|
- off-beat chord stabs (the "skank" that makes 152 BPM feel faster)
|
|
- drum fills every 4 bars, crash on the downbeat of every section
|
|
"""
|
|
import os, sys
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
|
|
from midikit import MidiFile, Track, MPQN, verify, note as N
|
|
from songkit import (bar, melody, arp, bass_drive, drums, vibrato, crash,
|
|
chord, parse_score, QUALITIES as QUAL, TPQ)
|
|
|
|
BPM = 152
|
|
OUT = os.path.join(os.path.dirname(__file__), "..", "midi",
|
|
"03_late_equaliser.mid")
|
|
|
|
CHORDS = (
|
|
[("E", "m"), ("E", "m")] + # intro
|
|
[("E", "m"), ("E", "m"), ("C", ""), ("C", ""),
|
|
("G", ""), ("G", ""), ("D", ""), ("D", "")] + # A
|
|
[("C", ""), ("D", ""), ("E", "m"), ("E", "m"),
|
|
("C", ""), ("D", ""), ("E", "m"), ("E", "m")] + # B
|
|
[("E", "m"), ("E", "m"), ("C", ""), ("C", ""),
|
|
("G", ""), ("G", ""), ("D", ""), ("D", "")] + # A'
|
|
[("E", "m"), ("E", "m")] # tag
|
|
)
|
|
|
|
HOOK_A = """
|
|
E5-8 G5-8 B5-8 A5-8 G5-8 E5-8 G5-4 | B5-8 A5-8 G5-8 E5-8 F#5-4 G5-4 |
|
|
G5-8 A5-8 G5-8 E5-8 C5-2 | E5-8 G5-8 C6-8 B5-8 A5-4 G5-4 |
|
|
B5-8 A5-8 B5-8 D6-8 B5-4 G5-4 | D6-8 B5-8 A5-8 G5-8 A5-4 B5-4 |
|
|
A5-8 B5-8 A5-8 F#5-8 D5-4 F#5-4 | G5-8 A5-8 B5-8 C6-8 B5-2
|
|
"""
|
|
HOOK_B = """
|
|
C6-8 B5-8 A5-8 G5-8 A5-4 E5-4 | B5-8 A5-8 G5-8 F#5-8 A5-4 D5-4 |
|
|
E5-8 G5-8 B5-8 E6-8 D6-4 B5-4 | E6-2 B5-4 G5-4 |
|
|
C6-8 D6-8 C6-8 A5-8 G5-4 E5-4 | F#5-8 A5-8 D6-8 C6-8 B5-4 A5-4 |
|
|
G5-8 B5-8 E6-8 G6-8 F#6-4 E6-4 | E6-1
|
|
"""
|
|
INTRO_LICK = "B4-8 E5-8 G5-8 B5-8 A5-8 G5-8 F#5-8 G5-8"
|
|
|
|
|
|
def build():
|
|
mf = MidiFile(tpq=TPQ)
|
|
mf.time_sig = (4, 2)
|
|
mf.key_sig = (-1, True) # E minor
|
|
mf.tempos = [MPQN.from_bpm(0, BPM)]
|
|
|
|
lead = Track("Square lead", channel=0)
|
|
lead.program(0, "square lead")
|
|
lead.cc(0, 7, 90); lead.cc(0, 10, 52)
|
|
harm = Track("Harmony 3rd", channel=1)
|
|
harm.program(0, "square lead")
|
|
harm.cc(0, 7, 62); harm.cc(0, 10, 78)
|
|
stabs = Track("Stabs", channel=2)
|
|
stabs.program(0, "electric piano 1")
|
|
stabs.cc(0, 7, 62); stabs.cc(0, 10, 24)
|
|
bass = Track("Bass", channel=3)
|
|
bass.program(0, "synth bass 2")
|
|
bass.cc(0, 7, 84); bass.cc(0, 10, 36)
|
|
d = Track("Drums", channel=9)
|
|
d.cc(0, 7, 88)
|
|
|
|
def stab_chart(start, count):
|
|
"""Off-beat chord stabs: hit on the 'and' of every beat."""
|
|
for i in range(count):
|
|
root, qual = CHORDS[start + i]
|
|
for b in (0.5, 1.5, 2.5, 3.5):
|
|
for iv in QUAL[qual]:
|
|
stabs.note(bar(start + i, b), N(root + "4") + iv,
|
|
TPQ // 2 - 40, 78)
|
|
|
|
for start, count in ((0, 2), (2, 8), (10, 8), (18, 8), (26, 2)):
|
|
stab_chart(start, count)
|
|
|
|
# intro
|
|
bass_drive(bass, 0, CHORDS[0:2], subdivision=2, velocity=90)
|
|
melody(lead, 0, INTRO_LICK)
|
|
melody(lead, 1, INTRO_LICK)
|
|
drums(d, 1, 1, "dos_roll", velocity_scale=0.8)
|
|
|
|
# A
|
|
bass_drive(bass, 2, CHORDS[2:10], subdivision=2)
|
|
melody(lead, 2, HOOK_A)
|
|
vibrato(lead, bar(2), bar(10), tempo_bpm=BPM, depth=0.4, rate=6.5)
|
|
drums(d, 2, 8, "drive")
|
|
drums(d, 5, 1, "dos_roll", velocity_scale=0.7)
|
|
drums(d, 9, 1, "break")
|
|
crash(d, bar(10))
|
|
|
|
# B
|
|
bass_drive(bass, 10, CHORDS[10:18], subdivision=2)
|
|
melody(lead, 10, HOOK_B)
|
|
vibrato(lead, bar(10), bar(18), tempo_bpm=BPM, depth=0.45, rate=6.5)
|
|
drums(d, 10, 8, "drive")
|
|
drums(d, 13, 1, "dos_roll", velocity_scale=0.7)
|
|
drums(d, 17, 1, "dos_roll")
|
|
crash(d, bar(18))
|
|
|
|
# A' with third-harmony
|
|
bass_drive(bass, 18, CHORDS[18:26], subdivision=2)
|
|
melody(lead, 18, HOOK_A)
|
|
vibrato(lead, bar(18), bar(26), tempo_bpm=BPM, depth=0.5, rate=6.5)
|
|
# harmony a diatonic third below, velocity down
|
|
for t, p, dur, v in parse_score(HOOK_A):
|
|
harm.note(bar(18) + t, p - 4 if p % 12 in (0, 1) else p - 3,
|
|
dur, max(40, v - 25))
|
|
drums(d, 18, 8, "drive")
|
|
drums(d, 21, 1, "dos_roll", velocity_scale=0.7)
|
|
drums(d, 25, 1, "dos_roll")
|
|
crash(d, bar(26))
|
|
|
|
# tag: cadence hit
|
|
bass_drive(bass, 26, CHORDS[26:28], subdivision=2, velocity=100)
|
|
for p in ("E2", "E3", "B3"):
|
|
bass.note(bar(27), N(p), TPQ * 4, 100)
|
|
for p in ("E4", "G4", "B4", "E5"):
|
|
stabs.note(bar(27), N(p), TPQ * 4, 85)
|
|
lead.note(bar(27), N("E6"), TPQ * 4, 95)
|
|
crash(d, bar(26))
|
|
drums(d, 26, 1, "break")
|
|
|
|
mf.add_track(lead); mf.add_track(harm); mf.add_track(stabs)
|
|
mf.add_track(bass); mf.add_track(d)
|
|
lead.marker(bar(2), "LOOP_START")
|
|
lead.marker(bar(26), "LOOP_END")
|
|
return mf
|
|
|
|
|
|
if __name__ == "__main__":
|
|
mf = build()
|
|
mf.write(OUT)
|
|
dd = verify(OUT, min_sec=30, max_sec=120)
|
|
print("OK:", OUT)
|
|
print(" duration:", dd["duration_sec"], "s | notes:",
|
|
sum(len(t["notes"]) for t in dd["tracks"]),
|
|
"| tracks:", len(dd["tracks"]))
|