diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cc25161 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f6d771 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 2ada587..e0202f8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/midikit.py b/lib/midikit.py index f5d4bcc..d0f2015 100644 --- a/lib/midikit.py +++ b/lib/midikit.py @@ -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): diff --git a/midi/01_service_station_2am.mid b/midi/01_service_station_2am.mid index 0753344..01b8317 100644 Binary files a/midi/01_service_station_2am.mid and b/midi/01_service_station_2am.mid differ diff --git a/midi/02_kestrel_over_the_fens.mid b/midi/02_kestrel_over_the_fens.mid index 1b976f0..4f1620a 100644 Binary files a/midi/02_kestrel_over_the_fens.mid and b/midi/02_kestrel_over_the_fens.mid differ diff --git a/midi/03_late_equaliser.mid b/midi/03_late_equaliser.mid index ae2efd9..56e1791 100644 Binary files a/midi/03_late_equaliser.mid and b/midi/03_late_equaliser.mid differ diff --git a/midi/04_tea_break_at_the_terminal.mid b/midi/04_tea_break_at_the_terminal.mid new file mode 100644 index 0000000..01c4231 Binary files /dev/null and b/midi/04_tea_break_at_the_terminal.mid differ diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 674a82f..cd1bdca 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -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 diff --git a/tools/build_all.sh b/tools/build_all.sh index 45511cd..b6eeea3 100755 --- a/tools/build_all.sh +++ b/tools/build_all.sh @@ -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 diff --git a/tools/make_tune.py b/tools/make_tune.py new file mode 100644 index 0000000..022b84d --- /dev/null +++ b/tools/make_tune.py @@ -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/.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() diff --git a/tunes/01_service_station_2am.py b/tunes/01_service_station_2am.py index 16096bc..51858e8 100644 --- a/tunes/01_service_station_2am.py +++ b/tunes/01_service_station_2am.py @@ -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 diff --git a/tunes/02_kestrel_over_the_fens.py b/tunes/02_kestrel_over_the_fens.py index be45550..8287c19 100644 --- a/tunes/02_kestrel_over_the_fens.py +++ b/tunes/02_kestrel_over_the_fens.py @@ -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 diff --git a/tunes/03_late_equaliser.py b/tunes/03_late_equaliser.py index fc9b9f2..e226fcf 100644 --- a/tunes/03_late_equaliser.py +++ b/tunes/03_late_equaliser.py @@ -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 diff --git a/tunes/specs/04_tea_break_at_the_terminal.json b/tunes/specs/04_tea_break_at_the_terminal.json new file mode 100644 index 0000000..feb2904 --- /dev/null +++ b/tunes/specs/04_tea_break_at_the_terminal.json @@ -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" +}