Rebuild as a code-first MIDI composition toolkit
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.
This commit is contained in:
1 parent
7ddabaff2a
commit
95480abdab
31 files changed
+1222
-31
No files matched your search
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -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.
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
Binary file not shown.
+387
@@ -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
|
||||
+219
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)")
|
||||
Executable
+36
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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"]))
|
||||
@@ -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"]))
|
||||
@@ -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"]))
|
||||
Reference in new issue
Block a user