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:
Hermes Agent committed 2026-10-17 06:59:04 +00:00
1 parent c3cd91d04f
commit 633eb92352
15 files changed
+387 -9

No files matched your search

+26 -1
View File
@@ -253,6 +253,22 @@ class MidiFile:
self.tempos = [] # list[MPQN]
self.key_sig = None # (flats_sharps, minor_bool)
self.time_sig = None # (num, den_power)
self.loop = None # (start_tick, end_tick)
def set_loop(self, start_tick, end_tick):
"""Declare the loopable section (game BGM: intro plays once, then
the engine jumps back). Writes markers in every convention that
matters, so whichever player/engine you point at the file, it finds
the loop point:
LOOP_START / LOOP_END - our convention, plain SMF markers
RPR LOOP START / RPR LOOP END - Reaper (and anything reading RPR)
LoopBegin / LoopEnd - various game engines & trackers
All times are absolute ticks; use songkit.bar() to think in bars.
"""
self.loop = (int(start_tick), int(end_tick))
return self
def add_track(self, track):
self.tracks.append(track)
@@ -271,6 +287,12 @@ class MidiFile:
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.loop:
start, end = self.loop
for tick, names in ((start, ("LOOP_START", "RPR LOOP START", "LoopBegin")),
(end, ("LOOP_END", "RPR LOOP END", "LoopEnd"))):
for nm in names:
conductor.meta(tick, 0x06, nm.encode("latin-1"))
if self.time_sig:
conductor.meta(0, 0x58, bytes([self.time_sig[0], self.time_sig[1], 24, 8]))
if self.key_sig:
@@ -303,7 +325,8 @@ def read(path_or_bytes):
tlen = struct.unpack(">I", data[pos + 4:pos + 8])[0]
pos += 8
end = pos + tlen
track = {"notes": [], "tempos": [], "name": None, "programs": {}, "cc": [], "bends": []}
track = {"notes": [], "tempos": [], "name": None, "programs": {},
"cc": [], "bends": [], "markers": []}
tick = 0
running = None
while pos < end:
@@ -325,6 +348,8 @@ def read(path_or_bytes):
track["tempos"].append((tick, int.from_bytes(mdata, "big")))
elif mtype == 0x03:
track["name"] = mdata.decode("latin-1")
elif mtype == 0x06:
track["markers"].append((tick, mdata.decode("latin-1")))
elif mtype == 0x2F:
break
elif status in (0xF0, 0xF7):