diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/README.md b/README.md index 42855e0..2ada587 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,81 @@ -# DOS MIDI Playlist +# MIDI Playlist — retro game music, built from code -Repository for my early 90's DOS gaming MIDI music collection. +Kevin Hermes' toolkit for composing early-90s flavoured MIDI: DOS +Sound Blaster/OPL themes, N64-era ambient, chiptune. Built for retro +game development (loop markers, multi-track GM arrangements) and podcast +audio beds. -## MIDI Files +Everything is **code-first**: tunes are Python scripts that generate +verified `.mid` files. No DAW, no pip dependencies in the library, no +hand-edited hex. If a tune is wrong, you fix a bar of text and rebuild. -### Castles_Proper.mid -**A proper, working DOS Castle theme!** +## The tunes -- **Style**: Medieval Castle Corridor (95 BPM) -- **Key**: D minor -- **Duration**: ~1 minute -- **Format**: Standard MIDI Type 0 with proper header -- **Features**: - - Correct MIDI binary structure - - Variable-length delta time encoding - - Tempo metadata events - - Note on/off events with velocities - - 480 ticks per quarter note -- **Playable in**: QuickTime Player, web browsers, any MIDI player +| # | Tune | Style | Key/BPM | Length | +|---|------|-------|---------|--------| +| 01 | Service Station at 2 AM | DOS adventure main theme (LucasArts-meets-OPL) | Dm / 132 | ~58s | +| 02 | Kestrel Over The Fens | N64 ambient exploration theme | G / 72 | ~82s | +| 03 | The U's Late Equaliser | chiptune sports/racing theme | Em / 152 | ~44s | -**Download**: [midi/Castles_Proper.mid](midi/Castles_Proper.mid) -**View Online**: https://repos.retroweb.dev/hermes-playground/midi-playlist/midi/Castles_Proper.mid +All files are Standard MIDI File **format 1**, 480 tpq, General MIDI +instruments, with `LOOP_START` / `LOOP_END` markers so a game engine can +skip the intro and loop the gameplay section. -## Technical Details +## Quick start -### MIDI Format Specification -- Header: MThd ("MThd") + 6 bytes + format 0 + 1 track -- Tempo: 600000 microseconds/quarter (100 BPM) -- Delta time: Encoded as variable-length quantities -- Events: Note on (0x90) / off (0x80) with velocities +```bash +./tools/build_all.sh # regenerate, verify, render WAVs, audio checks +python3 tests/smoke_test.py # library self-test +``` -### Notes in the Castle Theme -D4, E4, G4, A4 - D minor arpeggio pattern repeated -Each note lasts a full quarter note (480 ticks) +Rendering needs `timidity` + a GM soundfont (`FluidR3_GM.sf2`). Note: +Debian's timidity build has no `-O2` mode — use `-Ow -s 44`. -## Generated Using -- Pure Python with struct module -- Correct MIDI binary protocol encoding -- Valid MIDI Type 0 file format +## Layout + +``` +lib/midikit.py SMF writer + reader/verifier (stdlib only) +lib/songkit.py composition helpers: bar grids, OPL arps, vibrato, drums +tunes/*.py one script per tune; each self-verifies on run +tools/build_all.sh full pipeline: generate -> verify -> render -> check +tools/check_wav.py WAV sanity: clipping, loudness, dead air +tests/smoke_test.py library round-trip test (cross-checked with mido) +midi/ generated .mid files +archive/ the April 2026 wreckage (70-byte "working" MIDIs, rest in peace) +``` + +## Composing a new tune + +Copy a tune script. Melodies are written as bar grids: + +```python +MELODY = """ +D4-8 F4-8 A4-4 A4-8 Bb4-8 A4-4 | F4-8 A4-8 Bb4-4 A4-8 F4-8 D4-4 +""" +``` + +Tokens are `PITCH-dur-vel` (`D4-8`, `F5-16-80`, rests `r8`); `|` separates +bars and **every bar must total 4/4** — the parser lints you. Accompaniment +comes from the helpers: `arp()` (OPL-style 16ths), `bass_drive()` (driving +8ths with chromatic approaches), `drums()` (named patterns), `vibrato()` +(pitch-bend, the difference between a beep and an OPL voice). + +Mix levels: set CC7 (volume) and CC10 (pan) per track at tick 0, and +**check the render for clipping** — `tools/check_wav.py` fails the build +on a peak of 32767. + +## Gotchas learned the hard way + +- Format 1 with a dedicated Conductor track for tempo/key/time sigs. +- Sort events by absolute tick at write time; compose in any order. +- Note-off must match note-on channel+pitch exactly; no running status + in generated files (simpler, universally safe). +- Pitch bends count toward file duration — a vibrato tail past the last + note extends the song. +- Velocity-0 note-ons are legal note-offs, but 0x80 is clearer. +- GM2 instruments (ocarina!) are unreliable on GM1 synths — use pan + flute (76) for ocarina-ish leads. +- timidity's "Last N MIDI events are ignored" warning is harmless + (trailing bends/resets), but "Notes lost totally" is not. +- A bar-length linter in the score parser catches more composition bugs + than any amount of staring at note lists. diff --git a/midi/Castles_Final.mid b/archive/2026-04-april-wreckage/midi/Castles_Final.mid similarity index 100% rename from midi/Castles_Final.mid rename to archive/2026-04-april-wreckage/midi/Castles_Final.mid diff --git a/midi/Castles_Proper.mid b/archive/2026-04-april-wreckage/midi/Castles_Proper.mid similarity index 100% rename from midi/Castles_Proper.mid rename to archive/2026-04-april-wreckage/midi/Castles_Proper.mid diff --git a/midi/Castles_QuickTime_Playable.mid b/archive/2026-04-april-wreckage/midi/Castles_QuickTime_Playable.mid similarity index 100% rename from midi/Castles_QuickTime_Playable.mid rename to archive/2026-04-april-wreckage/midi/Castles_QuickTime_Playable.mid diff --git a/midi/Castles_Valid.mid b/archive/2026-04-april-wreckage/midi/Castles_Valid.mid similarity index 100% rename from midi/Castles_Valid.mid rename to archive/2026-04-april-wreckage/midi/Castles_Valid.mid diff --git a/midi/Castles_Valid.wav b/archive/2026-04-april-wreckage/midi/Castles_Valid.wav similarity index 100% rename from midi/Castles_Valid.wav rename to archive/2026-04-april-wreckage/midi/Castles_Valid.wav diff --git a/midi/CorrectCastles.mid b/archive/2026-04-april-wreckage/midi/CorrectCastles.mid similarity index 100% rename from midi/CorrectCastles.mid rename to archive/2026-04-april-wreckage/midi/CorrectCastles.mid diff --git a/midi/FinalCorrect.mid b/archive/2026-04-april-wreckage/midi/FinalCorrect.mid similarity index 100% rename from midi/FinalCorrect.mid rename to archive/2026-04-april-wreckage/midi/FinalCorrect.mid diff --git a/midi/OneMinTune.mid b/archive/2026-04-april-wreckage/midi/OneMinTune.mid similarity index 100% rename from midi/OneMinTune.mid rename to archive/2026-04-april-wreckage/midi/OneMinTune.mid diff --git a/midi/OneMinTune.wav b/archive/2026-04-april-wreckage/midi/OneMinTune.wav similarity index 100% rename from midi/OneMinTune.wav rename to archive/2026-04-april-wreckage/midi/OneMinTune.wav diff --git a/midi/OneMinuteCastle.mid b/archive/2026-04-april-wreckage/midi/OneMinuteCastle.mid similarity index 100% rename from midi/OneMinuteCastle.mid rename to archive/2026-04-april-wreckage/midi/OneMinuteCastle.mid diff --git a/midi/Proper60Sec.mid b/archive/2026-04-april-wreckage/midi/Proper60Sec.mid similarity index 100% rename from midi/Proper60Sec.mid rename to archive/2026-04-april-wreckage/midi/Proper60Sec.mid diff --git a/midi/Proper60Sec.wav b/archive/2026-04-april-wreckage/midi/Proper60Sec.wav similarity index 100% rename from midi/Proper60Sec.wav rename to archive/2026-04-april-wreckage/midi/Proper60Sec.wav diff --git a/midi/ProperCastle.mid b/archive/2026-04-april-wreckage/midi/ProperCastle.mid similarity index 100% rename from midi/ProperCastle.mid rename to archive/2026-04-april-wreckage/midi/ProperCastle.mid diff --git a/midi/ProperCastle.wav b/archive/2026-04-april-wreckage/midi/ProperCastle.wav similarity index 100% rename from midi/ProperCastle.wav rename to archive/2026-04-april-wreckage/midi/ProperCastle.wav diff --git a/midi/ValidCastles.mid b/archive/2026-04-april-wreckage/midi/ValidCastles.mid similarity index 100% rename from midi/ValidCastles.mid rename to archive/2026-04-april-wreckage/midi/ValidCastles.mid diff --git a/midi/WorkingCastle.mid b/archive/2026-04-april-wreckage/midi/WorkingCastle.mid similarity index 100% rename from midi/WorkingCastle.mid rename to archive/2026-04-april-wreckage/midi/WorkingCastle.mid diff --git a/lib/__pycache__/midikit.cpython-314.pyc b/lib/__pycache__/midikit.cpython-314.pyc new file mode 100644 index 0000000..b556c0a Binary files /dev/null and b/lib/__pycache__/midikit.cpython-314.pyc differ diff --git a/lib/__pycache__/songkit.cpython-314.pyc b/lib/__pycache__/songkit.cpython-314.pyc new file mode 100644 index 0000000..69b02e5 Binary files /dev/null and b/lib/__pycache__/songkit.cpython-314.pyc differ diff --git a/lib/midikit.py b/lib/midikit.py new file mode 100644 index 0000000..f5d4bcc --- /dev/null +++ b/lib/midikit.py @@ -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 diff --git a/lib/songkit.py b/lib/songkit.py new file mode 100644 index 0000000..12c3941 --- /dev/null +++ b/lib/songkit.py @@ -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 diff --git a/midi/01_service_station_2am.mid b/midi/01_service_station_2am.mid new file mode 100644 index 0000000..0753344 Binary files /dev/null and b/midi/01_service_station_2am.mid differ diff --git a/midi/02_kestrel_over_the_fens.mid b/midi/02_kestrel_over_the_fens.mid new file mode 100644 index 0000000..1b976f0 Binary files /dev/null and b/midi/02_kestrel_over_the_fens.mid differ diff --git a/midi/03_late_equaliser.mid b/midi/03_late_equaliser.mid new file mode 100644 index 0000000..ae2efd9 Binary files /dev/null and b/midi/03_late_equaliser.mid differ diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 0000000..674a82f --- /dev/null +++ b/tests/smoke_test.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Smoke test: does midikit+songkit produce a valid, parseable, playable file? +Run from repo root: python3 tests/smoke_test.py""" +import os, sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) +from midikit import MidiFile, Track, MPQN, note, note_name, read, verify +from songkit import bar, melody, arp, bass_drive, drums, vibrato, humanize, parse_score + +assert note("A4") == 69 and note("C#5") == 73 and note("Bb3") == 58 +assert note_name(69) == "A4" and note_name(60) == "C4" + +mf = MidiFile(tpq=480) +mf.time_sig = (4, 2) +mf.key_sig = (-1, True) # D minor +lead = Track("Lead", channel=0) +lead.program(0, "square lead") +melody(lead, 0, "D4 A4-8 D5-8 F5-8 E5-4 D5-2") +vibrato(lead, bar(0), bar(4), channel=0, tempo_bpm=120) +bass = Track("Bass", channel=1) +bass.program(0, "synth bass 1") +bass_drive(bass, 0, [("D", "m"), ("Bb", "")]) +acomp = Track("Arp", channel=2) +acomp.program(0, "electric piano 1") +arp(acomp, 0, [("D", "m"), ("Bb", "")]) +d = Track("Drums", channel=9) +drums(d, 0, 2, "rock") +mf.add_track(lead); mf.add_track(bass); mf.add_track(acomp); mf.add_track(d) +mf.tempos = [MPQN.from_bpm(0, 120)] +path = "/tmp/midikit_smoke.mid" +mf.write(path) + +d = verify(path, min_sec=1, max_sec=30) +print("OK:", path, d["duration_sec"], "sec,", + sum(len(t["notes"]) for t in d["tracks"]), "notes,", + len(d["tracks"]), "tracks") + +# cross-check with mido if present (third-party parser as second opinion) +try: + import mido + m = mido.MidiFile(path) + print("mido agrees:", m.length, "sec,", len(m.tracks), "tracks") + assert abs(m.length - d["duration_sec"]) < 0.5 +except ImportError: + print("(mido not installed; skipping cross-check)") diff --git a/tools/build_all.sh b/tools/build_all.sh new file mode 100755 index 0000000..45511cd --- /dev/null +++ b/tools/build_all.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# build_all.sh — regenerate every tune, verify the MIDI, render WAVs, +# and run audio sanity checks. One command, from repo root: +# +# ./tools/build_all.sh +# +# Requires: timidity (with a GM soundfont, e.g. FluidR3_GM.sf2). +# WAVs land in build/ (gitignored); MIDI files land in midi/. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p midi build + +for t in tunes/*.py; do + echo "== $t" + python3 "$t" +done + +echo "== verifying MIDI" +python3 - <<'EOF' +import sys, glob +sys.path.insert(0, "lib") +from midikit import verify +for f in sorted(glob.glob("midi/*.mid")): + d = verify(f) + print(" %-40s %6.1fs %4d notes %d tracks" % + (f, d["duration_sec"], sum(len(t["notes"]) for t in d["tracks"]), + len(d["tracks"]))) +EOF + +echo "== rendering WAV (timidity)" +for f in midi/*.mid; do + out="build/$(basename "${f%.mid}").wav" + timidity -Ow -s 44 "$f" -o "$out" 2>/dev/null + python3 tools/check_wav.py "$out" +done +echo "ALL OK" diff --git a/tools/check_wav.py b/tools/check_wav.py new file mode 100644 index 0000000..5959c78 --- /dev/null +++ b/tools/check_wav.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +check_wav.py — sanity-check a rendered WAV: duration, peak (clipping), +RMS loudness, and dead-air (long silent stretches that usually mean a +MIDI track failed to render). + +Usage: python3 tools/check_wav.py file.wav [expected_sec] +Exit 0 = pass, 1 = fail. Designed for CI / cron use. +""" +import sys, wave, struct + + +def analyse(path): + w = wave.open(path) + n, ch, sw = w.getnframes(), w.getnchannels(), w.getsampwidth() + rate = w.getframerate() + assert sw == 2, "expected 16-bit PCM, got %d-bit" % (sw * 8) + samples = struct.unpack("<%dh" % (n * ch), w.readframes(n)) + peak = max(abs(s) for s in samples) + rms = (sum(s * s for s in samples) / len(samples)) ** 0.5 + # dead air: longest run of frames where every channel < 64 + silent_run = worst = 0 + for i in range(0, len(samples), ch): + if max(abs(s) for s in samples[i:i + ch]) < 64: + silent_run += 1 + worst = max(worst, silent_run) + else: + silent_run = 0 + return {"sec": n / rate, "rate": rate, "peak": peak, "rms": rms, + "max_silence_sec": worst / rate} + + +def main(): + path = sys.argv[1] + expected = float(sys.argv[2]) if len(sys.argv) > 2 else None + a = analyse(path) + fails = [] + if a["peak"] >= 32767: + fails.append("CLIPPING (peak %d)" % a["peak"]) + if a["rms"] < 300: + fails.append("too quiet (rms %.0f)" % a["rms"]) + if a["max_silence_sec"] > 8: + fails.append("dead air: %.1fs silent stretch" % a["max_silence_sec"]) + if expected and abs(a["sec"] - expected) > expected * 0.15 + 2: + fails.append("duration %.1fs, expected ~%.1fs" % (a["sec"], expected)) + print("%s: %.1fs peak=%d rms=%.0f max_silence=%.1fs" % + (path, a["sec"], a["peak"], a["rms"], a["max_silence_sec"])) + for f in fails: + print(" FAIL:", f) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tunes/01_service_station_2am.py b/tunes/01_service_station_2am.py new file mode 100644 index 0000000..16096bc --- /dev/null +++ b/tunes/01_service_station_2am.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +"Service Station at 2 AM" — DOS adventure main theme +Style: early-90s point-and-click / LucasArts-meets-OPL. D minor, 132 BPM. +Structure: 4-bar intro, A (8), B (8), A' with counter-melody (8), outro (4). + +The DOS fingerprints: + - OPL-style 16th-note arpeggios on electric piano + - driving 8th-note synth bass with chromatic approach notes + - square lead with pitch-bend vibrato on held notes + - the G# in bar 4 (harmonic-minor leading tone — instant 1993) +""" +import os, sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) +from midikit import MidiFile, Track, MPQN, verify +from songkit import (bar, melody, arp, bass_drive, drums, vibrato, crash, + parse_score, TPQ) + +BPM = 132 +OUT = os.path.join(os.path.dirname(__file__), "..", "midi", + "01_service_station_2am.mid") + +# Chord chart, one (root, quality) per bar, 32 bars +CHORDS = ( + [("D", "m"), ("Bb", ""), ("G", "m"), ("A", "")] + # intro + [("D", "m"), ("Bb", ""), ("G", "m"), ("A", ""), + ("D", "m"), ("Bb", ""), ("G", "m"), ("D", "m")] + # A + [("Bb", ""), ("C", ""), ("D", "m"), ("Bb", ""), + ("G", "m"), ("D", "m"), ("Bb", ""), ("A", "")] + # B + [("D", "m"), ("Bb", ""), ("G", "m"), ("A", ""), + ("D", "m"), ("Bb", ""), ("G", "m"), ("D", "m")] + # A' + [("D", "m"), ("Bb", ""), ("A", ""), ("D", "m")] # outro +) + +# --- melodies (bar grids) ------------------------------------------------- +MELODY_A = """ +D4-8 F4-8 A4-4 A4-8 Bb4-8 A4-4 | F4-8 A4-8 Bb4-4 A4-8 F4-8 D4-4 | +G4-8 Bb4-8 D5-8 C5-8 Bb4-4 A4-4 | B4-8 A4-8 G#4-8 A4-8 E4-2 | +D5-8 C5-8 D5-8 F5-8 E5-4 D5-4 | C5-8 Bb4-8 C5-8 D5-8 Bb4-2 | +A4-8 Bb4-8 C5-4 E5-4 D5-4 | D5-2 r2 +""" +MELODY_B = """ +F5-4 F5-8 Eb5-8 F5-4 D5-4 | E5-4 G5-8 F5-8 E5-4 C5-4 | +D5-8 F5-8 A5-4 G5-8 F5-8 E5-4 | F5-2 D5-4 Bb4-8 C5-8 | +Bb5-4 A5-8 G5-8 F5-4 D5-4 | E5-4 F5-8 E5-8 D5-4 A4-4 | +Bb4-8 C5-8 D5-4 E5-8 F5-8 G#5-4 | A5-2 G#5-4 E5-4 +""" +MELODY_OUTRO = """ +A4-8 D5-8 F5-8 A5-8 D6-2 | C6-4 Bb5-4 F5-4 D5-4 | +E5-4 D5-4 C#5-4 A4-4 | D5-1 +""" +PICKUP = "r2 A4-8 Bb4-8 A4-8 G4-8" # intro bar 4 pickup +# A' bars: octave-ish answer underneath the main melody +COUNTER_A = """ +r2 D5-8 F5-8 E5-4 | r2 F5-8 D5-8 Bb4-4 | +r2 G5-8 F5-8 D5-4 | r2 E5-8 G5-8 F5-8 E5-8 | +r4 A5-8 G5-8 F5-4 A5-4 | r4 F5-8 A5-8 D5-4 F5-4 | +r2 C6-4 Bb5-4 | r2 F5-4 D5-4 r4 +""" + + +def build(): + mf = MidiFile(tpq=TPQ) + mf.time_sig = (4, 2) + mf.key_sig = (-1, True) # D minor + mf.tempos = [MPQN.from_bpm(0, BPM)] + + lead = Track("Lead (square)", channel=0) + lead.program(0, "square lead") + lead.cc(0, 7, 104); lead.cc(0, 10, 54) # vol, pan centre + counter = Track("Counter (EP2)", channel=1) + counter.program(0, "electric piano 2") + counter.cc(0, 7, 80); counter.cc(0, 10, 90) + bass = Track("Bass", channel=2) + bass.program(0, "synth bass 1") + bass.cc(0, 7, 92); bass.cc(0, 10, 30) + acomp = Track("Arp (OPL)", channel=3) + acomp.program(0, "electric piano 1") + acomp.cc(0, 7, 66); acomp.cc(0, 10, 20) + d = Track("Drums", channel=9) + d.cc(0, 7, 100) + + # intro: arp + bass only, pickup lead in bar 4 + arp(acomp, 0, CHORDS[0:4], pattern="012321", velocity=64) + bass_drive(bass, 0, CHORDS[0:4], velocity=80) + melody(lead, 3, PICKUP) + drums(d, 3, 1, "shufflesnare", velocity_scale=0.8) + + # A (bars 4-11) + arp(acomp, 4, CHORDS[4:12], pattern="012321", velocity=70) + bass_drive(bass, 4, CHORDS[4:12]) + melody(lead, 4, MELODY_A) + vibrato(lead, bar(4), bar(12), tempo_bpm=BPM, depth=0.55) + drums(d, 4, 8, "rock") + drums(d, 6, 1, "dos_roll", velocity_scale=0.7) + drums(d, 11, 1, "dos_roll", velocity_scale=0.9) + crash(d, bar(12)) + + # B (bars 12-19) + arp(acomp, 12, CHORDS[12:20], pattern="021321", velocity=72) + bass_drive(bass, 12, CHORDS[12:20]) + melody(lead, 12, MELODY_B) + vibrato(lead, bar(12), bar(20), tempo_bpm=BPM, depth=0.6) + drums(d, 12, 8, "rock") + drums(d, 15, 1, "dos_roll", velocity_scale=0.7) + drums(d, 19, 1, "break", velocity_scale=0.9) + crash(d, bar(20)) + + # A' (bars 20-27): same melody + counter-melody, busier hats + arp(acomp, 20, CHORDS[20:28], pattern="012321", velocity=74) + bass_drive(bass, 20, CHORDS[20:28]) + melody(lead, 20, MELODY_A) + vibrato(lead, bar(20), bar(28), tempo_bpm=BPM, depth=0.65) + melody(counter, 20, COUNTER_A) + drums(d, 20, 8, "rock") + drums(d, 23, 1, "dos_roll", velocity_scale=0.7) + drums(d, 27, 1, "dos_roll") + crash(d, bar(28)) + + # outro + arp(acomp, 28, CHORDS[28:32], pattern="0121", velocity=66) + bass_drive(bass, 28, CHORDS[28:32], subdivision=1, velocity=75) + melody(lead, 28, MELODY_OUTRO) + vibrato(lead, bar(28), bar(32), tempo_bpm=BPM, depth=0.5) + crash(d, bar(28)) + drums(d, 28, 1, "break", velocity_scale=0.8) + # final sustained chord (Dm) on counter + from midikit import note as N + for p in ("D3", "A3", "D4", "F4", "A4", "D5"): + counter.note(bar(31), N(p), TPQ * 4, 70) + + mf.add_track(lead); mf.add_track(counter); mf.add_track(bass) + mf.add_track(acomp); mf.add_track(d) + # loop points for game engines: intro plays once, A-B-A' loops + lead.marker(bar(4), "LOOP_START") + lead.marker(bar(28), "LOOP_END") + return mf + + +if __name__ == "__main__": + mf = build() + mf.write(OUT) + d = verify(OUT, min_sec=40, max_sec=120) + print("OK:", OUT) + print(" duration:", d["duration_sec"], "s | notes:", + sum(len(t["notes"]) for t in d["tracks"]), + "| tracks:", len(d["tracks"])) diff --git a/tunes/02_kestrel_over_the_fens.py b/tunes/02_kestrel_over_the_fens.py new file mode 100644 index 0000000..be45550 --- /dev/null +++ b/tunes/02_kestrel_over_the_fens.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +"Kestrel Over The Fens" — N64-era ambient exploration theme +Style: Ocarina-of-Time-adjacent. G major with Lydian sparkle, 72 BPM. +Structure: 4-bar intro, phrase 1 (8), phrase 2 higher (8), coda (4). + +The N64 fingerprints: + - pan flute lead, long breathy phrases, gentle vibrato + - warm pad chords breathing underneath (whole notes, low velocity) + - harp arpeggios instead of the DOS 16ths — everything floats + - one C#6 in bar 13 (Lydian #11) that does the emotional heavy lifting +""" +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, vibrato, TPQ + +BPM = 72 +OUT = os.path.join(os.path.dirname(__file__), "..", "midi", + "02_kestrel_over_the_fens.mid") + +CHORDS = ( + [("G", "add9"), ("D", "7"), ("E", "m7"), ("C", "maj7")] + # intro + [("G", "add9"), ("D", "7"), ("E", "m7"), ("C", "maj7"), + ("A", "m7"), ("D", "sus"), ("G", "add9"), ("C", "add9")] + # P1 + [("C", "add9"), ("G", "add9"), ("E", "m7"), ("D", "sus"), + ("C", "add9"), ("G", "add9"), ("A", "m7"), ("D", "sus")] + # P2 + [("G", "add9"), ("C", "add9"), ("G", "add9"), ("G", "")] # coda +) +# D7/F# feel via bass line; Dsus resolves the phrase. + +PHRASE_1 = """ +D5-4 G5-4 A5-2 | F#5-4 E5-4 D5-2 | E5-4 G5-4 F#5-2 | E5-4 D5-4 C5-2 | +A4-8 C5-8 E5-4 A5-2 | D5-4 E5-4 D5-2 | G5-1 | r4 B5-4 A5-4 G5-4 +""" +PHRASE_2 = """ +E5-4 G5-4 D5-4 E5-4 | B5-4 C#6-4 B5-2 | D6-4 B5-4 G5-4 E5-4 | +F#5-4 A5-4 G5-2 | E5-8 G5-8 A5-4 G5-4 E5-4 | D5-4 G5-4 B5-2 | +C6-4 B5-4 A5-4 E5-4 | D6-2 A5-4 F#5-4 +""" +CODA = """ +G5-1 | E5-2 D5-2 | D5-2 B4-2 | G5-1 +""" + + +def build(): + mf = MidiFile(tpq=TPQ) + mf.time_sig = (4, 2) + mf.key_sig = (1, False) # G major + mf.tempos = [MPQN.from_bpm(0, BPM)] + + lead = Track("Pan flute", channel=0) + lead.program(0, "pan flute") + lead.cc(0, 7, 92); lead.cc(0, 10, 58) + pad = Track("Warm pad", channel=1) + pad.program(0, "warm pad") + pad.cc(0, 7, 58); pad.cc(0, 10, 40) + harp = Track("Harp", channel=2) + harp.program(0, "orchestral harp") + harp.cc(0, 7, 72); harp.cc(0, 10, 84) + bass = Track("Cello", channel=3) + bass.program(0, "cello") + bass.cc(0, 7, 80); bass.cc(0, 10, 34) + + def pad_chords(start, count): + for i in range(count): + root, qual = CHORDS[start + i] + for iv in (0, 4 if qual not in ("m", "m7", "m7b5") else 3, 7): + pad.note(bar(start + i), N(root + "3") + iv, TPQ * 4, 55) + + def bass_roots(start, count): + for i in range(count): + bass.note(bar(start + i), N(CHORDS[start + i][0] + "2"), + TPQ * 4, 70) + + # intro: harp + pad only + arp(harp, 0, CHORDS[0:4], pattern="0121", subdivision=2, + velocity=52, octave=4, duration=TPQ) + pad_chords(0, 4); bass_roots(0, 4) + + # phrase 1 + melody(lead, 4, PHRASE_1) + vibrato(lead, bar(4), bar(12), tempo_bpm=BPM, depth=0.4, rate=4.5) + arp(harp, 4, CHORDS[4:12], pattern="0121", subdivision=2, + velocity=56, octave=4, duration=TPQ) + pad_chords(4, 8); bass_roots(4, 8) + + # phrase 2: harp moves up, pad swells + melody(lead, 12, PHRASE_2) + vibrato(lead, bar(12), bar(20), tempo_bpm=BPM, depth=0.45, rate=5.0) + arp(harp, 12, CHORDS[12:20], pattern="012321", subdivision=2, + velocity=62, octave=4, duration=TPQ) + pad_chords(12, 8); bass_roots(12, 8) + + # coda: everything lets go + melody(lead, 20, CODA) + vibrato(lead, bar(20), bar(24), tempo_bpm=BPM, depth=0.35, rate=4.0) + arp(harp, 20, CHORDS[20:24], pattern="0121", subdivision=2, + velocity=48, octave=4, duration=TPQ) + pad_chords(20, 4); bass_roots(20, 4) + # final G(add9), long, soft + for p in ("G2", "D3", "G3", "B3", "D4", "A4"): + pad.note(bar(23), N(p), TPQ * 6, 45) + harp.note(bar(23), N(p), TPQ * 6, 40) + + mf.add_track(lead); mf.add_track(pad); mf.add_track(harp); mf.add_track(bass) + lead.marker(bar(4), "LOOP_START") + lead.marker(bar(20), "LOOP_END") + return mf + + +if __name__ == "__main__": + mf = build() + mf.write(OUT) + d = verify(OUT, min_sec=50, max_sec=150) + print("OK:", OUT) + print(" duration:", d["duration_sec"], "s | notes:", + sum(len(t["notes"]) for t in d["tracks"]), + "| tracks:", len(d["tracks"])) diff --git a/tunes/03_late_equaliser.py b/tunes/03_late_equaliser.py new file mode 100644 index 0000000..fc9b9f2 --- /dev/null +++ b/tunes/03_late_equaliser.py @@ -0,0 +1,143 @@ +#!/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"]))