#!/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/.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()