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
@@ -0,0 +1,83 @@
|
||||
# Notes for AI agents
|
||||
|
||||
This repo is designed to be driven by agents. Everything is deterministic,
|
||||
self-verifying, and dependency-light.
|
||||
|
||||
## Fastest path: generate a tune from a JSON spec
|
||||
|
||||
```bash
|
||||
python3 tools/make_tune.py tunes/specs/04_tea_break_at_the_terminal.json --render
|
||||
```
|
||||
|
||||
Copy that spec, change `name`, `bpm`, `key`, `chords`, `melody`, `loop`.
|
||||
The style preset (`dos_adventure`, `n64_ambient`, `chiptune_racing`) handles
|
||||
arrangement: instruments, accompaniment patterns, drums, vibrato, mix levels.
|
||||
You supply the music theory: a per-bar chord chart and a melody.
|
||||
|
||||
## Spec schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "file_name_no_extension",
|
||||
"style": "dos_adventure | n64_ambient | chiptune_racing",
|
||||
"bpm": 112,
|
||||
"key": ["A", "minor"], // "major" or "minor"
|
||||
"chords": [["A","m"], ["F",""], ...], // one per bar; qualities:
|
||||
// maj m dim aug 7 maj7 m7 m7b5 6 m6 9 add9 sus2 sus4 sus
|
||||
"melody_start_bar": 4,
|
||||
"melody": "A4-8 C5-8 E5-4 A5-2 | ...", // see score grammar below
|
||||
"loop": {"start_bar": 4, "end_bar": 20}, // optional; writes LOOP_START/END,
|
||||
// RPR LOOP START/END, LoopBegin/End markers
|
||||
"counter": "...", // optional 2nd line (dos_adventure only)
|
||||
"fills_at": [11, 19] // optional drum-fill bars (styles with drums)
|
||||
}
|
||||
```
|
||||
|
||||
## Score grammar (melody strings)
|
||||
|
||||
- Tokens: `PITCH-dur-vel` — `A4-8-90` (A4 eighth, vel 90). Durations:
|
||||
`1` whole, `2` half, `4` quarter (default), `8` eighth, `16` sixteenth.
|
||||
- Rests: `r4`, `r8`, `r16`, `r2`, `r1`.
|
||||
- `|` separates bars. **If you use `|`, every bar must total exactly 4/4**
|
||||
— the parser raises `ValueError: bar N totals X ticks` and tells you which
|
||||
bar is wrong. Use it; it is the single most useful bug-catcher here.
|
||||
- Pitches: letter + optional `#`/`b` + octave, e.g. `Bb3`, `F#5`.
|
||||
|
||||
## Verification loop (do this, don't skip it)
|
||||
|
||||
1. `python3 tools/make_tune.py spec.json --render` — writes MIDI, re-parses
|
||||
it, renders WAV, checks for clipping / dead air / wrong duration.
|
||||
2. If a check fails, fix the spec and re-run. Never declare success without
|
||||
a passing run.
|
||||
3. Optional theory audit: parse the lead track with `midikit.read()`, flag
|
||||
notes outside the key scale and outside the current bar's chord tones,
|
||||
and treat each flag as either intentional colour or a bug.
|
||||
|
||||
## Looping for game background music
|
||||
|
||||
`loop` in the spec (or `MidiFile.set_loop(start_tick, end_tick)` in Python)
|
||||
writes markers in three conventions so any engine/DAW finds them:
|
||||
`LOOP_START/LOOP_END`, `RPR LOOP START/RPR LOOP END` (Reaper),
|
||||
`LoopBegin/LoopEnd`. Convention: intro bars sit *before* the loop start so
|
||||
the engine plays the intro once, then jumps back to loop start and returns
|
||||
to loop end.
|
||||
|
||||
## Library use (Python)
|
||||
|
||||
`lib/midikit.py` and `lib/songkit.py` are stdlib-only — drop them into any
|
||||
project. Key entry points: `MidiFile`, `Track`, `MPQN.from_bpm`,
|
||||
`set_loop`, `verify`; songkit: `bar`, `melody`, `arp`, `bass_drive`,
|
||||
`drums`, `vibrato`, `parse_score`. Times are ticks; `bar(n)` converts bars
|
||||
to ticks at 480 tpq.
|
||||
|
||||
## Rendering
|
||||
|
||||
Needs `timidity` + a GM soundfont (e.g. FluidR3_GM.sf2, not bundled —
|
||||
licensing). Debian's timidity: use `-Ow -s 44` (no `-O2` mode).
|
||||
|
||||
## Known gotchas
|
||||
|
||||
- Pitch bends extend file duration (vibrato tails count).
|
||||
- No comments inside melody strings — `#` gets parsed as a note.
|
||||
- GM2 patches (ocarina etc.) are unreliable on GM1 synths; use pan flute.
|
||||
- Five hot tracks sum to clipping; lower CC7 volumes, not velocities.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Kevin Hermes (an AI agent) and Steve Calcott
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -16,34 +16,58 @@ hand-edited hex. If a tune is wrong, you fix a bar of text and rebuild.
|
||||
| 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 |
|
||||
| 04 | Tea Break at the Terminal | DOS puzzle-game theme (from JSON spec) | Am / 112 | ~51s |
|
||||
|
||||
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.
|
||||
instruments, with loop markers (`LOOP_START/END`, `RPR LOOP START/END`,
|
||||
`LoopBegin/End`) so a game engine can skip the intro and loop the
|
||||
gameplay section. See [Looping](#looping) below.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
./tools/build_all.sh # regenerate, verify, render WAVs, audio checks
|
||||
python3 tests/smoke_test.py # library self-test
|
||||
python3 tools/make_tune.py tunes/specs/04_tea_break_at_the_terminal.json --render
|
||||
```
|
||||
|
||||
Rendering needs `timidity` + a GM soundfont (`FluidR3_GM.sf2`). Note:
|
||||
Debian's timidity build has no `-O2` mode — use `-Ow -s 44`.
|
||||
|
||||
**AI agents**: see [AGENTS.md](AGENTS.md) — the JSON spec format is the
|
||||
agent-friendly front door; no Python required to make a tune.
|
||||
|
||||
## 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
|
||||
tunes/specs/*.json declarative tune specs -> tools/make_tune.py
|
||||
tools/build_all.sh full pipeline: generate -> verify -> render -> check
|
||||
tools/make_tune.py JSON spec -> fully arranged MIDI (style presets)
|
||||
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)
|
||||
```
|
||||
|
||||
## Looping
|
||||
|
||||
Game background music convention: the intro bars play once, then the
|
||||
engine jumps back to the loop point. Declare it once and every marker
|
||||
dialect gets written:
|
||||
|
||||
```python
|
||||
mf.set_loop(bar(4), bar(28)) # Python
|
||||
```
|
||||
```json
|
||||
"loop": {"start_bar": 4, "end_bar": 20} // JSON spec
|
||||
```
|
||||
|
||||
Markers written: `LOOP_START/LOOP_END` (ours), `RPR LOOP START/END`
|
||||
(Reaper), `LoopBegin/LoopEnd` (various engines/trackers).
|
||||
|
||||
## Composing a new tune
|
||||
|
||||
Copy a tune script. Melodies are written as bar grids:
|
||||
@@ -79,3 +103,8 @@ on a peak of 32767.
|
||||
(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.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — see [LICENSE](LICENSE). Soundfonts are not bundled (licensing);
|
||||
install FluidR3_GM.sf2 or your favourite GM soundfont separately.
|
||||
+26
-1
@@ -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):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -26,6 +26,7 @@ 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)]
|
||||
mf.set_loop(bar(0), bar(2)) # loop the whole 2-bar demo
|
||||
path = "/tmp/midikit_smoke.mid"
|
||||
mf.write(path)
|
||||
|
||||
@@ -34,6 +35,13 @@ print("OK:", path, d["duration_sec"], "sec,",
|
||||
sum(len(t["notes"]) for t in d["tracks"]), "notes,",
|
||||
len(d["tracks"]), "tracks")
|
||||
|
||||
# loop markers must survive the round-trip in all three conventions
|
||||
markers = sorted(set(m[1] for t in d["tracks"] for m in t["markers"]))
|
||||
expected = sorted(["LOOP_START", "LOOP_END", "RPR LOOP START", "RPR LOOP END",
|
||||
"LoopBegin", "LoopEnd"])
|
||||
assert markers == expected, "loop markers wrong: %s" % markers
|
||||
print("loop markers OK:", ", ".join(markers))
|
||||
|
||||
# cross-check with mido if present (third-party parser as second opinion)
|
||||
try:
|
||||
import mido
|
||||
|
||||
@@ -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()
|
||||
@@ -132,8 +132,7 @@ def build():
|
||||
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")
|
||||
mf.set_loop(bar(4), bar(28))
|
||||
return mf
|
||||
|
||||
|
||||
|
||||
@@ -104,8 +104,7 @@ def build():
|
||||
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")
|
||||
mf.set_loop(bar(4), bar(20))
|
||||
return mf
|
||||
|
||||
|
||||
|
||||
@@ -128,8 +128,7 @@ def build():
|
||||
|
||||
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")
|
||||
mf.set_loop(bar(2), bar(26))
|
||||
return mf
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "04_tea_break_at_the_terminal",
|
||||
"style": "dos_adventure",
|
||||
"bpm": 112,
|
||||
"key": ["A", "minor"],
|
||||
"loop": {"start_bar": 4, "end_bar": 20},
|
||||
"fills_at": [11, 19],
|
||||
"melody_start_bar": 4,
|
||||
"chords": [
|
||||
["A", "m"], ["F", ""], ["G", ""], ["A", "m"],
|
||||
["A", "m"], ["A", "m"], ["F", ""], ["F", ""],
|
||||
["G", ""], ["G", ""], ["A", "m"], ["A", "m"],
|
||||
["A", "m"], ["A", "m"], ["F", ""], ["G", ""],
|
||||
["A", "m"], ["G", ""], ["A", "m"], ["A", "m"],
|
||||
["A", "m"], ["F", ""], ["G", ""], ["A", "m"]
|
||||
],
|
||||
"melody": "A4-8 C5-8 E5-4 A5-2 | G4-8 A4-8 C5-4 E5-2 | F5-8 E5-8 F5-8 A5-8 G5-4 F5-4 | E5-4 C5-4 F5-2 | D5-8 E5-8 G5-4 B5-2 | A5-8 B5-8 A5-8 G5-8 D5-4 G5-4 | C6-8 B5-8 C6-8 E6-8 D6-4 C6-4 | A5-2 E5-4 A5-4 | E5-8 G5-8 A5-4 C6-2 | B5-4 A5-4 E5-2 | A5-8 C6-8 A5-8 F5-8 E5-4 F5-4 | G5-8 A5-8 B5-4 D6-2 | C6-1 | B5-8 A5-8 G5-4 D5-2 | A5-8 G5-8 A5-8 C6-8 E6-4 D6-4 | A5-1"
|
||||
}
|
||||
Reference in new issue
Block a user