Public-ready: JSON tune specs, first-class loop API, licence, AGENTS.md
- MidiFile.set_loop(): one call writes LOOP_START/END, RPR LOOP START/END (Reaper) and LoopBegin/End markers - every engine finds its dialect; reader now parses markers and the smoke test asserts the round-trip - tools/make_tune.py: declarative JSON spec -> fully arranged MIDI with style presets (dos_adventure, n64_ambient, chiptune_racing); the agent-friendly front door, no Python required - tunes/specs/: demo tune 04 'Tea Break at the Terminal' (Am/112, loop bars 4-20) generated entirely from JSON - AGENTS.md: spec schema, score grammar, verification loop, looping conventions for game BGM - MIT LICENSE; history audited clean of credentials
This commit is contained in:
1 parent
c3cd91d04f
commit
633eb92352
15 files changed
+387
-9
No files matched your search
@@ -15,6 +15,11 @@ for t in tunes/*.py; do
|
||||
python3 "$t"
|
||||
done
|
||||
|
||||
for s in tunes/specs/*.json; do
|
||||
echo "== $s"
|
||||
python3 tools/make_tune.py "$s"
|
||||
done
|
||||
|
||||
echo "== verifying MIDI"
|
||||
python3 - <<'EOF'
|
||||
import sys, glob
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
make_tune.py — generate a fully-arranged retro MIDI from a JSON spec.
|
||||
|
||||
This is the agent-friendly front door: no Python needed, just describe
|
||||
the tune. The style preset handles arrangement (instruments, accompaniment
|
||||
patterns, drums, vibrato, mix levels); you supply chords and a melody.
|
||||
|
||||
Usage:
|
||||
python3 tools/make_tune.py tunes/specs/my_tune.json
|
||||
python3 tools/make_tune.py tunes/specs/my_tune.json --render # + WAV
|
||||
|
||||
Spec format (see tunes/specs/ for working examples):
|
||||
{
|
||||
"name": "04_tea_break_at_the_terminal", -> midi/<name>.mid
|
||||
"style": "dos_adventure", # dos_adventure | n64_ambient | chiptune_racing
|
||||
"bpm": 112,
|
||||
"key": ["A", "minor"], # for the key signature meta
|
||||
"chords": [["A","m"], ["F",""], ...], # one [root, quality] per bar
|
||||
"melody_start_bar": 4,
|
||||
"melody": "A4-8 C5-8 E5-4 A5-2 | ...", # bar-grid, '|' separators (linted 4/4)
|
||||
"loop": {"start_bar": 4, "end_bar": 20}, # optional; LOOP_* markers
|
||||
"counter": "...", # optional 2nd melody line (style-dependent)
|
||||
"fills_at": [11, 19] # optional: drum fill at end of these bars
|
||||
}
|
||||
|
||||
Chord qualities: maj m dim aug 7 maj7 m7 m7b5 6 m6 9 add9 sus2 sus4 sus
|
||||
"""
|
||||
import argparse, json, os, sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "lib"))
|
||||
|
||||
from midikit import MidiFile, Track, MPQN, verify, note as N
|
||||
from songkit import (bar, melody, arp, bass_drive, drums, vibrato, crash,
|
||||
parse_score, QUALITIES, TPQ)
|
||||
|
||||
KEY_PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||
# circle of fifths lookup for key signature meta (flats negative, sharps positive)
|
||||
MAJOR_KEYS = {"C": 0, "G": 1, "D": 2, "A": 3, "E": 4, "B": 5, "F#": 6, "C#": 7,
|
||||
"F": -1, "Bb": -2, "Eb": -3, "Ab": -4, "Db": -5, "Gb": -6}
|
||||
|
||||
|
||||
def key_sig_for(tonic, mode):
|
||||
if mode in ("minor", "m"):
|
||||
# relative major is 3 semitones up
|
||||
pc = (KEY_PC[tonic.upper()] + 3) % 12
|
||||
name = {0: "C", 2: "D", 4: "E", 5: "F", 7: "G", 9: "A", 11: "B"}.get(pc)
|
||||
if name is None: # sharp-side minor keys
|
||||
name = {1: "Db", 3: "Eb", 6: "F#", 8: "Ab", 10: "Bb"}[pc]
|
||||
return MAJOR_KEYS[name], True
|
||||
return MAJOR_KEYS[tonic.capitalize() if len(tonic) == 1 else tonic], False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Style presets: (lead patch, accompaniment recipe)
|
||||
# ---------------------------------------------------------------------------
|
||||
def style_dos_adventure(mf, spec, chords):
|
||||
n = len(chords)
|
||||
lead = Track("Lead (square)", channel=0)
|
||||
lead.program(0, "square lead"); lead.cc(0, 7, 100); lead.cc(0, 10, 54)
|
||||
bass = Track("Bass", channel=2)
|
||||
bass.program(0, "synth bass 1"); bass.cc(0, 7, 88); bass.cc(0, 10, 30)
|
||||
acomp = Track("Arp (OPL)", channel=3)
|
||||
acomp.program(0, "electric piano 1"); acomp.cc(0, 7, 64); acomp.cc(0, 10, 20)
|
||||
d = Track("Drums", channel=9); d.cc(0, 7, 92)
|
||||
mf.add_track(lead); mf.add_track(bass); mf.add_track(acomp); mf.add_track(d)
|
||||
return dict(lead=lead, bass=bass, acomp=acomp, drums=d,
|
||||
vibrato=(0.55, 5.5), arp_pattern="012321", arp_vel=70,
|
||||
drums_pattern="rock", fill="dos_roll")
|
||||
|
||||
|
||||
def style_n64_ambient(mf, spec, chords):
|
||||
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, 56); pad.cc(0, 10, 40)
|
||||
harp = Track("Harp", channel=2)
|
||||
harp.program(0, "orchestral harp"); harp.cc(0, 7, 70); harp.cc(0, 10, 84)
|
||||
bass = Track("Cello", channel=3)
|
||||
bass.program(0, "cello"); bass.cc(0, 7, 78); bass.cc(0, 10, 34)
|
||||
mf.add_track(lead); mf.add_track(pad); mf.add_track(harp); mf.add_track(bass)
|
||||
# whole-note pads + root bass
|
||||
for i, (root, qual) in enumerate(chords):
|
||||
third = 3 if qual.startswith("m") or qual.startswith("dim") else 4
|
||||
for iv in (0, third, 7):
|
||||
pad.note(bar(i), N(root + "3") + iv, TPQ * 4, 55)
|
||||
bass.note(bar(i), N(root + "2"), TPQ * 4, 70)
|
||||
return dict(lead=lead, bass=bass, acomp=harp, drums=None,
|
||||
vibrato=(0.4, 4.5), arp_pattern="0121", arp_vel=56,
|
||||
subdivision=2, arp_dur=TPQ, drums_pattern=None, fill=None)
|
||||
|
||||
|
||||
def style_chiptune_racing(mf, spec, chords):
|
||||
lead = Track("Square lead", channel=0)
|
||||
lead.program(0, "square lead"); lead.cc(0, 7, 90); lead.cc(0, 10, 52)
|
||||
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)
|
||||
mf.add_track(lead); mf.add_track(stabs); mf.add_track(bass); mf.add_track(d)
|
||||
for i, (root, qual) in enumerate(chords):
|
||||
for b in (0.5, 1.5, 2.5, 3.5):
|
||||
for iv in QUALITIES[qual]:
|
||||
stabs.note(bar(i, b), N(root + "4") + iv, TPQ // 2 - 40, 78)
|
||||
return dict(lead=lead, bass=bass, acomp=None, drums=d,
|
||||
vibrato=(0.45, 6.5), arp_pattern=None, arp_vel=0,
|
||||
drums_pattern="drive", fill="dos_roll")
|
||||
|
||||
|
||||
STYLES = {"dos_adventure": style_dos_adventure,
|
||||
"n64_ambient": style_n64_ambient,
|
||||
"chiptune_racing": style_chiptune_racing}
|
||||
|
||||
|
||||
def build(spec):
|
||||
chords = [(r, q) for r, q in spec["chords"]]
|
||||
n = len(chords)
|
||||
mf = MidiFile(tpq=TPQ)
|
||||
mf.time_sig = (4, 2)
|
||||
tonic, mode = spec.get("key", ["C", "major"])
|
||||
mf.key_sig = key_sig_for(tonic, mode)
|
||||
mf.tempos = [MPQN.from_bpm(0, spec["bpm"])]
|
||||
|
||||
st = STYLES[spec["style"]](mf, spec, chords)
|
||||
|
||||
start = spec.get("melody_start_bar", 0)
|
||||
if spec.get("melody"):
|
||||
melody(st["lead"], start, spec["melody"])
|
||||
end_bar = start + len([s for s in spec["melody"].split("|") if s.strip()])
|
||||
vibrato(st["lead"], bar(start), bar(min(end_bar, n)),
|
||||
tempo_bpm=spec["bpm"], depth=st["vibrato"][0], rate=st["vibrato"][1])
|
||||
|
||||
if st.get("acomp") and st.get("arp_pattern"):
|
||||
arp(st["acomp"], 0, chords, pattern=st["arp_pattern"],
|
||||
velocity=st["arp_vel"],
|
||||
subdivision=st.get("subdivision", 4),
|
||||
duration=st.get("arp_dur"))
|
||||
if st.get("bass") is not None and spec["style"] != "n64_ambient":
|
||||
bass_drive(st["bass"], 0, chords, subdivision=2)
|
||||
|
||||
if st.get("drums") is not None and st.get("drums_pattern"):
|
||||
drums(st["drums"], 0, n, st["drums_pattern"])
|
||||
for b in spec.get("fills_at", []):
|
||||
drums(st["drums"], b, 1, st["fill"] or "dos_roll", velocity_scale=0.85)
|
||||
crash(st["drums"], bar(n - 1))
|
||||
|
||||
if spec.get("counter") and spec["style"] == "dos_adventure":
|
||||
counter = Track("Counter (EP2)", channel=1)
|
||||
counter.program(0, "electric piano 2")
|
||||
counter.cc(0, 7, 80); counter.cc(0, 10, 90)
|
||||
melody(counter, start, spec["counter"])
|
||||
mf.add_track(counter)
|
||||
|
||||
if spec.get("loop"):
|
||||
mf.set_loop(bar(spec["loop"]["start_bar"]), bar(spec["loop"]["end_bar"]))
|
||||
return mf
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("spec", help="path to tune spec JSON")
|
||||
ap.add_argument("--render", action="store_true", help="also render WAV via timidity")
|
||||
args = ap.parse_args()
|
||||
|
||||
spec = json.load(open(args.spec))
|
||||
mf = build(spec)
|
||||
out = os.path.join(os.path.dirname(HERE), "midi", spec["name"] + ".mid")
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
mf.write(out)
|
||||
d = verify(out)
|
||||
markers = [m for t in d["tracks"] for m in t["markers"]]
|
||||
print("OK:", out)
|
||||
print(" %.1fs | %d notes | %d tracks | loop: %s" %
|
||||
(d["duration_sec"], sum(len(t["notes"]) for t in d["tracks"]),
|
||||
len(d["tracks"]),
|
||||
"bars %d-%d" % (spec["loop"]["start_bar"], spec["loop"]["end_bar"])
|
||||
if spec.get("loop") else "none"))
|
||||
if markers:
|
||||
print(" markers:", ", ".join(sorted(set(m[1] for m in markers))))
|
||||
if args.render:
|
||||
import subprocess
|
||||
wav = os.path.join(os.path.dirname(HERE), "build", spec["name"] + ".wav")
|
||||
os.makedirs(os.path.dirname(wav), exist_ok=True)
|
||||
subprocess.run(["timidity", "-Ow", "-s", "44", out, "-o", wav],
|
||||
check=True, capture_output=True)
|
||||
subprocess.run(["python3", os.path.join(HERE, "check_wav.py"), wav], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user