Harden for agent users: spec validation, key-sig accidentals, N64 chord qualities
- make_tune: validate_spec() reports every spec problem in one run (missing keys, unknown style, bad qualities, bpm<=0, out-of-range loop/fills, melody longer than chart, unsafe name) — no raw KeyErrors - make_tune: key_sig_for handles accidentals in minor and major keys, any case (Bb minor, F# minor, bb major); real spellings kept (C# major) - make_tune: n64 pad uses QUALITIES table (dim/aug/sus now correct) - make_tune: WARNING on stderr for fields a style can't use (counter for non-DOS, fills_at for n64) instead of silent drops - make_tune: name sanitised ([A-Za-z0-9][A-Za-z0-9._-]*), no traversal - make_tune: render errors actionable (timidity presence check, stderr surfaced, sys.executable instead of bare python3) - songkit: parse_score lints the FINAL bar too (was unchecked); this caught a real 5-beat bar in 01_service_station_2am COUNTER_A (fixed) - build_all.sh: dependency + soundfont checks, removes stale midi/wav outputs with no current source, fails if nothing was produced - smoke_test: runs every spec, checks loop marker tick positions, feeds malformed bars to the linter, covers validation + key sigs - AGENTS.md: documents 0-based bars and exclusive end_bar, validation behaviour; SKILL.md added so the repo loads as an agent skill
This commit is contained in:
1 parent
f5bd7ae13c
commit
810f127d01
7 files changed
+481
-49
No files matched your search
@@ -33,14 +33,36 @@ You supply the music theory: a per-bar chord chart and a melody.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Indexing convention (read this once, then never guess)
|
||||||
|
|
||||||
|
- **Bars are 0-based everywhere**: bar 0 is the first bar of the chord chart.
|
||||||
|
- **`loop.end_bar` is EXCLUSIVE**: `{"start_bar": 4, "end_bar": 20}` loops
|
||||||
|
bars 4..19 — 16 bars. `start_bar` is inclusive.
|
||||||
|
- The melody must fit the chart: `melody_start_bar + (number of '|' bars)
|
||||||
|
<= len(chords)`. Bars past the melody (e.g. chords 20–23 above) are an
|
||||||
|
outro the engine plays before jumping back to `start_bar`.
|
||||||
|
- `fills_at` entries are 0-based bar numbers, `0 <= bar < len(chords)`.
|
||||||
|
|
||||||
|
## Validation behaviour
|
||||||
|
|
||||||
|
`make_tune.py` validates the whole spec up front and reports **every**
|
||||||
|
problem in one go (missing keys, unknown style, bad chord quality,
|
||||||
|
`bpm <= 0`, out-of-range loop/fill bars, melody longer than the chart,
|
||||||
|
unsafe `name`) — no raw `KeyError`s, no silently wrong output. Fields a
|
||||||
|
style can't use (e.g. `counter` for `n64_ambient`, `fills_at` for styles
|
||||||
|
without drums) print `WARNING:` to stderr and are skipped, never dropped
|
||||||
|
in silence. `name` must match `[A-Za-z0-9][A-Za-z0-9._-]*` — it becomes
|
||||||
|
`midi/<name>.mid`, so no `/`, no `..`.
|
||||||
|
|
||||||
## Score grammar (melody strings)
|
## Score grammar (melody strings)
|
||||||
|
|
||||||
- Tokens: `PITCH-dur-vel` — `A4-8-90` (A4 eighth, vel 90). Durations:
|
- Tokens: `PITCH-dur-vel` — `A4-8-90` (A4 eighth, vel 90). Durations:
|
||||||
`1` whole, `2` half, `4` quarter (default), `8` eighth, `16` sixteenth.
|
`1` whole, `2` half, `4` quarter (default), `8` eighth, `16` sixteenth.
|
||||||
- Rests: `r4`, `r8`, `r16`, `r2`, `r1`.
|
- Rests: `r4`, `r8`, `r16`, `r2`, `r1`.
|
||||||
- `|` separates bars. **If you use `|`, every bar must total exactly 4/4**
|
- `|` separates bars. **If you use `|`, every bar — including the final
|
||||||
— the parser raises `ValueError: bar N totals X ticks` and tells you which
|
one — must total exactly 4/4** — the parser raises
|
||||||
bar is wrong. Use it; it is the single most useful bug-catcher here.
|
`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`.
|
- Pitches: letter + optional `#`/`b` + octave, e.g. `Bb3`, `F#5`.
|
||||||
|
|
||||||
## Verification loop (do this, don't skip it)
|
## Verification loop (do this, don't skip it)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: midi-playlist-repo
|
||||||
|
description: Compose retro game MIDI in /root/midi-playlist via JSON specs. Use when writing, fixing, or rendering chiptune/DOS/N64-style tunes.
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# midi-playlist: retro MIDI from JSON specs
|
||||||
|
|
||||||
|
Repo: `/root/midi-playlist` (stdlib-only Python; `lib/midikit.py`, `lib/songkit.py`).
|
||||||
|
|
||||||
|
## The workflow
|
||||||
|
|
||||||
|
1. Copy `tunes/specs/04_tea_break_at_the_terminal.json`, change `name`,
|
||||||
|
`style`, `bpm`, `key`, `chords`, `melody`, `loop`.
|
||||||
|
2. `python3 tools/make_tune.py tunes/specs/<name>.json --render`
|
||||||
|
3. Never declare success without a passing run: the command re-parses the
|
||||||
|
MIDI, renders WAV via timidity, and checks clipping / dead air / duration.
|
||||||
|
4. Whole-repo check: `python3 tests/smoke_test.py` then `./tools/build_all.sh`.
|
||||||
|
|
||||||
|
## Conventions an agent must not guess
|
||||||
|
|
||||||
|
- Bars are **0-based**; `loop.end_bar` is **EXCLUSIVE**.
|
||||||
|
- `melody_start_bar + number of '|' bars <= len(chords)`.
|
||||||
|
- If the melody uses `|`, every bar (including the last) must total 4/4 —
|
||||||
|
the linter names the offending bar.
|
||||||
|
- Styles: `dos_adventure` (OPL-ish, supports `counter` + `fills_at`),
|
||||||
|
`n64_ambient` (pads/flute, no drums), `chiptune_racing` (stabs, no `counter`).
|
||||||
|
- Validation reports every spec problem at once; style-incompatible fields
|
||||||
|
warn on stderr instead of being dropped silently.
|
||||||
|
|
||||||
|
Full details, score grammar and rendering options: `AGENTS.md` in the repo.
|
||||||
+8
-3
@@ -33,9 +33,9 @@ def parse_score(text, tpq=TPQ):
|
|||||||
rests: r4 / r8 / r2 / r16 / r1
|
rests: r4 / r8 / r2 / r16 / r1
|
||||||
|
|
||||||
Bars may be separated with '|'. If any '|' is present, every bar
|
Bars may be separated with '|'. If any '|' is present, every bar
|
||||||
segment must total exactly 4 quarter notes — a free bar-length
|
segment — including the final one — must total exactly 4 quarter
|
||||||
linter that has saved me from more off-by-one-eighth bugs than
|
notes — a free bar-length linter that has saved me from more
|
||||||
I care to admit.
|
off-by-one-eighth bugs than I care to admit.
|
||||||
|
|
||||||
Example: 'D4 A4-8 D5-8 F5-8 E5-4 D5-2'
|
Example: 'D4 A4-8 D5-8 F5-8 E5-4 D5-2'
|
||||||
"""
|
"""
|
||||||
@@ -43,6 +43,7 @@ def parse_score(text, tpq=TPQ):
|
|||||||
tick = 0
|
tick = 0
|
||||||
bar_ticks = 0
|
bar_ticks = 0
|
||||||
bar_no = 1
|
bar_no = 1
|
||||||
|
saw_sep = False
|
||||||
for tok in text.split():
|
for tok in text.split():
|
||||||
if tok == "|":
|
if tok == "|":
|
||||||
if bar_ticks != tpq * 4:
|
if bar_ticks != tpq * 4:
|
||||||
@@ -50,6 +51,7 @@ def parse_score(text, tpq=TPQ):
|
|||||||
% (bar_no, bar_ticks, tpq * 4))
|
% (bar_no, bar_ticks, tpq * 4))
|
||||||
bar_ticks = 0
|
bar_ticks = 0
|
||||||
bar_no += 1
|
bar_no += 1
|
||||||
|
saw_sep = True
|
||||||
continue
|
continue
|
||||||
if tok.startswith("r"):
|
if tok.startswith("r"):
|
||||||
dur = {"r1": tpq * 4, "r2": tpq * 2, "r4": tpq, "r8": tpq // 2,
|
dur = {"r1": tpq * 4, "r2": tpq * 2, "r4": tpq, "r8": tpq // 2,
|
||||||
@@ -69,6 +71,9 @@ def parse_score(text, tpq=TPQ):
|
|||||||
out.append((tick, pitch, dur, vel))
|
out.append((tick, pitch, dur, vel))
|
||||||
tick += dur
|
tick += dur
|
||||||
bar_ticks += dur
|
bar_ticks += dur
|
||||||
|
if saw_sep and bar_ticks != tpq * 4:
|
||||||
|
raise ValueError("bar %d totals %d ticks, expected %d (4/4)"
|
||||||
|
% (bar_no, bar_ticks, tpq * 4))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+138
-9
@@ -1,10 +1,27 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Smoke test: does midikit+songkit produce a valid, parseable, playable file?
|
"""Smoke test: midikit+songkit+make_tune end-to-end.
|
||||||
Run from repo root: python3 tests/smoke_test.py"""
|
Run from repo root: python3 tests/smoke_test.py
|
||||||
import os, sys
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
|
Covers:
|
||||||
|
- note math and a hand-built demo file (round-trip verify)
|
||||||
|
- loop marker NAMES and TICK POSITIONS
|
||||||
|
- the bar-length linter rejecting a malformed bar
|
||||||
|
- every spec in tunes/specs/ building cleanly through make_tune.build()
|
||||||
|
- spec validation: unknown style, bad quality, bpm<=0, out-of-range loop,
|
||||||
|
path-traversal name, melody outrunning the chord chart
|
||||||
|
- key signatures with accidentals (Bb minor, F# minor, bb major)
|
||||||
|
- n64 pad honouring chord quality (dim triad) and warning on ignored fills_at
|
||||||
|
"""
|
||||||
|
import glob, io, json, os, sys, contextlib
|
||||||
|
|
||||||
|
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "lib"))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "tools"))
|
||||||
|
|
||||||
from midikit import MidiFile, Track, MPQN, note, note_name, read, verify
|
from midikit import MidiFile, Track, MPQN, note, note_name, read, verify
|
||||||
from songkit import bar, melody, arp, bass_drive, drums, vibrato, humanize, parse_score
|
from songkit import bar, melody, arp, bass_drive, drums, vibrato, humanize, parse_score, TPQ
|
||||||
|
import make_tune
|
||||||
|
from make_tune import SpecError, build, key_sig_for, validate_spec
|
||||||
|
|
||||||
assert note("A4") == 69 and note("C#5") == 73 and note("Bb3") == 58
|
assert note("A4") == 69 and note("C#5") == 73 and note("Bb3") == 58
|
||||||
assert note_name(69) == "A4" and note_name(60) == "C4"
|
assert note_name(69) == "A4" and note_name(60) == "C4"
|
||||||
@@ -35,12 +52,122 @@ print("OK:", path, d["duration_sec"], "sec,",
|
|||||||
sum(len(t["notes"]) for t in d["tracks"]), "notes,",
|
sum(len(t["notes"]) for t in d["tracks"]), "notes,",
|
||||||
len(d["tracks"]), "tracks")
|
len(d["tracks"]), "tracks")
|
||||||
|
|
||||||
# loop markers must survive the round-trip in all three conventions
|
# 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"]))
|
# at the right TICK positions (bar 0 -> 0, bar 2 -> 2*4*TPQ)
|
||||||
|
all_markers = [(m[0], m[1]) for t in d["tracks"] for m in t["markers"]]
|
||||||
|
names = sorted(set(n for _, n in all_markers))
|
||||||
expected = sorted(["LOOP_START", "LOOP_END", "RPR LOOP START", "RPR LOOP END",
|
expected = sorted(["LOOP_START", "LOOP_END", "RPR LOOP START", "RPR LOOP END",
|
||||||
"LoopBegin", "LoopEnd"])
|
"LoopBegin", "LoopEnd"])
|
||||||
assert markers == expected, "loop markers wrong: %s" % markers
|
assert names == expected, "loop markers wrong: %s" % names
|
||||||
print("loop markers OK:", ", ".join(markers))
|
for tick, name in all_markers:
|
||||||
|
is_start = "START" in name.upper() or "BEGIN" in name.upper()
|
||||||
|
want = 0 if is_start else bar(2)
|
||||||
|
assert tick == want, "loop marker %r at tick %d, expected %d" % (name, tick, want)
|
||||||
|
print("loop markers OK (names + tick positions):", ", ".join(names))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# the bar-length linter must reject a malformed bar (once '|' is used,
|
||||||
|
# every segment including the last must total 4/4)
|
||||||
|
for bad in ("C4-4 C4-4 C4-4 | C4-4 C4-4 C4-4 C4-4", # bar 1 short
|
||||||
|
"C4-4 C4-4 C4-4 C4-4 | C4-4", # final bar short
|
||||||
|
"C4-4 C4-4 C4-4 C4-4 C4-4 | C4-4 C4-4 C4-4 C4-4", # bar 1 long
|
||||||
|
"C4-4 C4-4 C4-4 C4-4 | C4-4-8"): # final bar short (eighth)
|
||||||
|
try:
|
||||||
|
parse_score(bad)
|
||||||
|
raise AssertionError("linter accepted malformed bar: %r" % bad)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# separator-free scores are intentionally not linted
|
||||||
|
assert parse_score("C4-4 C4-4 C4-4")
|
||||||
|
print("linter rejects malformed bars OK")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# key signatures with accidentals, any case
|
||||||
|
assert key_sig_for("Bb", "minor") == (-5, True) # Bb minor -> relative major Db
|
||||||
|
assert key_sig_for("F#", "minor") == (3, True) # rel. major A
|
||||||
|
assert key_sig_for("bb", "major") == (-2, False) # case-insensitive
|
||||||
|
assert key_sig_for("f", "major") == (-1, False)
|
||||||
|
assert key_sig_for("C#", "major") == (7, False)
|
||||||
|
print("key sigs with accidentals OK")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# validation: every classic agent mistake must raise SpecError, not KeyError
|
||||||
|
def expect_error(spec, needle):
|
||||||
|
try:
|
||||||
|
build(spec, warn=False)
|
||||||
|
raise AssertionError("spec accepted, expected error containing %r" % needle)
|
||||||
|
except SpecError as e:
|
||||||
|
assert needle in str(e), "error %r did not contain %r" % (str(e), needle)
|
||||||
|
|
||||||
|
base = {"name": "t", "style": "dos_adventure", "bpm": 120,
|
||||||
|
"chords": [["A", "m"], ["F", ""], ["C", ""], ["G", ""]]}
|
||||||
|
|
||||||
|
for missing in ("name", "style", "bpm", "chords"):
|
||||||
|
s = dict(base); del s[missing]
|
||||||
|
expect_error(s, "missing required key %r" % missing)
|
||||||
|
expect_error(dict(base, style="nope"), "unknown style")
|
||||||
|
expect_error(dict(base, bpm=0), "bpm must be 1..300")
|
||||||
|
expect_error(dict(base, chords=[["A", "m"], ["H", ""]]), "not a note letter")
|
||||||
|
expect_error(dict(base, chords=[["A", "maj9"]]), "quality 'maj9' unknown")
|
||||||
|
expect_error(dict(base, loop={"start_bar": 0, "end_bar": 9}), "loop bars out of range")
|
||||||
|
expect_error(dict(base, loop={"start_bar": 2, "end_bar": 2}), "loop bars out of range")
|
||||||
|
expect_error(dict(base, fills_at=[99]), "fills_at bar 99 out of range")
|
||||||
|
expect_error(dict(base, name="../evil"), "not a safe file name")
|
||||||
|
expect_error(dict(base, name="a/b"), "not a safe file name")
|
||||||
|
print("validation errors OK")
|
||||||
|
|
||||||
|
# melody outrunning the chart: 3 melody bars starting at bar 2 in a 4-bar chart
|
||||||
|
expect_error(dict(base, melody="C5-4 C5-4 C5-4 C5-4 | C5-4 C5-4 C5-4 C5-4 | C5-2 C5-2",
|
||||||
|
melody_start_bar=2), "runs past the chord chart")
|
||||||
|
|
||||||
|
# malformed melody is caught by the linter through validation
|
||||||
|
expect_error(dict(base, melody="C5-4 C5-4 C5-4 | C5-4 C5-4 C5-4 C5-4"), "melody:")
|
||||||
|
|
||||||
|
# warnings, not silence, for fields a style can't use
|
||||||
|
w, e = validate_spec(dict(base, style="n64_ambient", fills_at=[1]))
|
||||||
|
assert e == [] and any("fills_at" in x for x in w), (w, e)
|
||||||
|
w, e = validate_spec(dict(base, style="chiptune_racing", counter="C5-4 C5-4 C5-4 C5-4"))
|
||||||
|
assert e == [] and any("counter" in x for x in w), (w, e)
|
||||||
|
print("ignored-field warnings OK")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# n64 pad must honour chord quality: a dim chord gets a dim triad (0,3,6)
|
||||||
|
n64 = {"name": "t_dim", "style": "n64_ambient", "bpm": 90,
|
||||||
|
"chords": [["B", "dim"], ["E", "m"], ["A", ""], ["F#", "aug"]]}
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(buf):
|
||||||
|
mf2 = build(n64)
|
||||||
|
p = "/tmp/midikit_n64.mid"
|
||||||
|
mf2.write(p)
|
||||||
|
dd = verify(p)
|
||||||
|
pad_notes = [n for t in dd["tracks"] if t["name"] == "Warm pad" for n in t["notes"]]
|
||||||
|
pitches_bar0 = sorted(n["pitch"] % 12 for n in pad_notes if n["tick"] == 0)
|
||||||
|
assert pitches_bar0 == sorted((59 + iv) % 12 for iv in (0, 3, 6)), \
|
||||||
|
"dim pad wrong: %s" % pitches_bar0 # B3=59: B dim triad pcs 11,2,5
|
||||||
|
print("n64 chord quality OK (dim triad present)")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# every real spec in tunes/specs/ must build, and its loop markers must land
|
||||||
|
# at the documented bar positions (0-based, end exclusive)
|
||||||
|
specs = sorted(glob.glob(os.path.join(ROOT, "tunes", "specs", "*.json")))
|
||||||
|
assert specs, "no specs found to test"
|
||||||
|
for sp in specs:
|
||||||
|
spec = json.load(open(sp))
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(buf):
|
||||||
|
m = build(spec)
|
||||||
|
q = "/tmp/spec_%s.mid" % spec["name"]
|
||||||
|
m.write(q)
|
||||||
|
dq = verify(q, min_sec=1, max_sec=600)
|
||||||
|
pos = {}
|
||||||
|
for t in dq["tracks"]:
|
||||||
|
for tick0, name0 in t["markers"]:
|
||||||
|
pos[name0] = tick0
|
||||||
|
if spec.get("loop"):
|
||||||
|
assert pos.get("LOOP_START") == bar(spec["loop"]["start_bar"]), sp
|
||||||
|
assert pos.get("LOOP_END") == bar(spec["loop"]["end_bar"]), sp
|
||||||
|
print(" spec OK: %-40s %.1fs" % (os.path.basename(sp), dq["duration_sec"]))
|
||||||
|
print("all %d specs build with correct loop positions" % len(specs))
|
||||||
|
|
||||||
# cross-check with mido if present (third-party parser as second opinion)
|
# cross-check with mido if present (third-party parser as second opinion)
|
||||||
try:
|
try:
|
||||||
@@ -50,3 +177,5 @@ try:
|
|||||||
assert abs(m.length - d["duration_sec"]) < 0.5
|
assert abs(m.length - d["duration_sec"]) < 0.5
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("(mido not installed; skipping cross-check)")
|
print("(mido not installed; skipping cross-check)")
|
||||||
|
|
||||||
|
print("SMOKE TEST PASSED")
|
||||||
+68
-7
@@ -4,18 +4,71 @@
|
|||||||
#
|
#
|
||||||
# ./tools/build_all.sh
|
# ./tools/build_all.sh
|
||||||
#
|
#
|
||||||
# Requires: timidity (with a GM soundfont, e.g. FluidR3_GM.sf2).
|
# Requires: python3, timidity (with a GM soundfont, e.g. FluidR3_GM.sf2).
|
||||||
# WAVs land in build/ (gitignored); MIDI files land in midi/.
|
# WAVs land in build/ (gitignored); MIDI files land in midi/.
|
||||||
|
#
|
||||||
|
# Stale outputs are removed: any midi/*.mid or build/*.wav that no longer
|
||||||
|
# has a corresponding source (tunes/*.py or tunes/specs/*.json) is deleted,
|
||||||
|
# so "ALL OK" can never refer to a tune whose source has gone.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
mkdir -p midi build
|
mkdir -p midi build
|
||||||
|
|
||||||
for t in tunes/*.py; do
|
# ---- dependency checks (fail fast with an actionable message) ----
|
||||||
|
missing=0
|
||||||
|
for cmd in python3 timidity; do
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: '$cmd' not found on PATH." >&2
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$missing" -eq 1 ]; then
|
||||||
|
echo "Debian/Ubuntu: apt install python3 timidity fluid-soundfont-gm" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# soundfont: timidity needs one; check its config or the usual FluidR3 path
|
||||||
|
if ! timidity -iA --help >/dev/null 2>&1; then :; fi # presence already checked
|
||||||
|
if [ ! -f /usr/share/sounds/sf2/FluidR3_GM.sf2 ] && \
|
||||||
|
! grep -qsE '^[[:space:]]*dir[[:space:]]|^[[:space:]]*file[[:space:]]' /etc/timidity/timidity.cfg 2>/dev/null; then
|
||||||
|
echo "WARNING: no obvious GM soundfont found (checked /usr/share/sounds/sf2/FluidR3_GM.sf2 and /etc/timidity/timidity.cfg)." >&2
|
||||||
|
echo " WAV rendering may fail or sound wrong. apt install fluid-soundfont-gm" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- collect sources and the set of names they produce ----
|
||||||
|
sources_py=(tunes/*.py)
|
||||||
|
sources_json=(tunes/specs/*.json)
|
||||||
|
expected=()
|
||||||
|
for t in "${sources_py[@]}"; do
|
||||||
|
[ -e "$t" ] && expected+=("$(basename "${t%.py}")")
|
||||||
|
done
|
||||||
|
for s in "${sources_json[@]}"; do
|
||||||
|
[ -e "$s" ] && expected+=("$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['name'])" "$s")")
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- remove stale outputs (no current source) ----
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in midi/*.mid; do
|
||||||
|
n="$(basename "${f%.mid}")"
|
||||||
|
keep=0
|
||||||
|
for e in "${expected[@]}"; do [ "$e" = "$n" ] && keep=1 && break; done
|
||||||
|
if [ "$keep" -eq 0 ]; then
|
||||||
|
echo "stale: removing $f (no source)"
|
||||||
|
rm -f "$f" "build/$n.wav"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for f in build/*.wav; do
|
||||||
|
[ -e "midi/$(basename "${f%.wav}").mid" ] || { echo "stale: removing $f"; rm -f "$f"; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- build ----
|
||||||
|
for t in "${sources_py[@]}"; do
|
||||||
|
[ -e "$t" ] || continue
|
||||||
echo "== $t"
|
echo "== $t"
|
||||||
python3 "$t"
|
python3 "$t"
|
||||||
done
|
done
|
||||||
|
|
||||||
for s in tunes/specs/*.json; do
|
for s in "${sources_json[@]}"; do
|
||||||
|
[ -e "$s" ] || continue
|
||||||
echo "== $s"
|
echo "== $s"
|
||||||
python3 tools/make_tune.py "$s"
|
python3 tools/make_tune.py "$s"
|
||||||
done
|
done
|
||||||
@@ -25,7 +78,9 @@ python3 - <<'EOF'
|
|||||||
import sys, glob
|
import sys, glob
|
||||||
sys.path.insert(0, "lib")
|
sys.path.insert(0, "lib")
|
||||||
from midikit import verify
|
from midikit import verify
|
||||||
for f in sorted(glob.glob("midi/*.mid")):
|
files = sorted(glob.glob("midi/*.mid"))
|
||||||
|
assert files, "no MIDI files were produced — nothing to verify"
|
||||||
|
for f in files:
|
||||||
d = verify(f)
|
d = verify(f)
|
||||||
print(" %-40s %6.1fs %4d notes %d tracks" %
|
print(" %-40s %6.1fs %4d notes %d tracks" %
|
||||||
(f, d["duration_sec"], sum(len(t["notes"]) for t in d["tracks"]),
|
(f, d["duration_sec"], sum(len(t["notes"]) for t in d["tracks"]),
|
||||||
@@ -33,9 +88,15 @@ for f in sorted(glob.glob("midi/*.mid")):
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "== rendering WAV (timidity)"
|
echo "== rendering WAV (timidity)"
|
||||||
for f in midi/*.mid; do
|
shopt -s nullglob
|
||||||
|
mids=(midi/*.mid)
|
||||||
|
[ "${#mids[@]}" -gt 0 ] || { echo "ERROR: no MIDI to render" >&2; exit 1; }
|
||||||
|
for f in "${mids[@]}"; do
|
||||||
out="build/$(basename "${f%.mid}").wav"
|
out="build/$(basename "${f%.mid}").wav"
|
||||||
timidity -Ow -s 44 "$f" -o "$out" 2>/dev/null
|
if ! timidity -Ow -s 44 "$f" -o "$out"; then
|
||||||
|
echo "ERROR: timidity failed on $f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
python3 tools/check_wav.py "$out"
|
python3 tools/check_wav.py "$out"
|
||||||
done
|
done
|
||||||
echo "ALL OK"
|
echo "ALL OK (${#mids[@]} tunes)"
|
||||||
+210
-26
@@ -20,13 +20,23 @@ Spec format (see tunes/specs/ for working examples):
|
|||||||
"melody_start_bar": 4,
|
"melody_start_bar": 4,
|
||||||
"melody": "A4-8 C5-8 E5-4 A5-2 | ...", # bar-grid, '|' separators (linted 4/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
|
"loop": {"start_bar": 4, "end_bar": 20}, # optional; LOOP_* markers
|
||||||
"counter": "...", # optional 2nd melody line (style-dependent)
|
"counter": "...", # optional 2nd melody line (dos_adventure only)
|
||||||
"fills_at": [11, 19] # optional: drum fill at end of these bars
|
"fills_at": [11, 19] # optional: drum fill at end of these bars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Bar indexing convention: bars are 0-BASED throughout. A loop's end_bar is
|
||||||
|
EXCLUSIVE — {"start_bar": 4, "end_bar": 20} loops bars 4..19 (16 bars).
|
||||||
|
The melody must fit the chord chart: melody_start_bar + (number of melody
|
||||||
|
bars) must be <= len(chords).
|
||||||
|
|
||||||
Chord qualities: maj m dim aug 7 maj7 m7 m7b5 6 m6 9 add9 sus2 sus4 sus
|
Chord qualities: maj m dim aug 7 maj7 m7 m7b5 6 m6 9 add9 sus2 sus4 sus
|
||||||
|
|
||||||
|
Validation: the spec is checked up front and every problem is reported
|
||||||
|
together (one run tells you everything wrong), instead of a raw KeyError
|
||||||
|
halfway through. Fields a style cannot use (e.g. counter for n64_ambient)
|
||||||
|
produce a warning, never silence.
|
||||||
"""
|
"""
|
||||||
import argparse, json, os, sys
|
import argparse, json, os, re, shutil, subprocess, sys
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, os.path.join(HERE, "..", "lib"))
|
sys.path.insert(0, os.path.join(HERE, "..", "lib"))
|
||||||
@@ -35,21 +45,157 @@ from midikit import MidiFile, Track, MPQN, verify, note as N
|
|||||||
from songkit import (bar, melody, arp, bass_drive, drums, vibrato, crash,
|
from songkit import (bar, melody, arp, bass_drive, drums, vibrato, crash,
|
||||||
parse_score, QUALITIES, TPQ)
|
parse_score, QUALITIES, TPQ)
|
||||||
|
|
||||||
KEY_PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
LETTER_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)
|
# 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,
|
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}
|
"F": -1, "Bb": -2, "Eb": -3, "Ab": -4, "Db": -5, "Gb": -6, "Cb": -7}
|
||||||
|
# canonical spelling per pitch class, preferring names present in MAJOR_KEYS
|
||||||
|
PC_TO_KEY = {0: "C", 1: "Db", 2: "D", 3: "Eb", 4: "E", 5: "F", 6: "F#",
|
||||||
|
7: "G", 8: "Ab", 9: "A", 10: "Bb", 11: "B"}
|
||||||
|
|
||||||
|
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||||
|
TONIC_RE = re.compile(r"^([A-Ga-g])([#bf]?)$")
|
||||||
|
|
||||||
|
|
||||||
|
class SpecError(ValueError):
|
||||||
|
"""Raised with the full list of problems found in a spec."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tonic(tonic):
|
||||||
|
"""'Bb', 'f#', 'A' -> pitch class, or None if unparseable."""
|
||||||
|
m = TONIC_RE.match(str(tonic).strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
pc = LETTER_PC[m.group(1).upper()]
|
||||||
|
pc += m.group(2).count("#") + m.group(2).count("s")
|
||||||
|
pc -= m.group(2).count("b") + m.group(2).count("f")
|
||||||
|
return pc % 12
|
||||||
|
|
||||||
|
|
||||||
def key_sig_for(tonic, mode):
|
def key_sig_for(tonic, mode):
|
||||||
if mode in ("minor", "m"):
|
"""(accidentals, is_minor) for the key-signature meta event.
|
||||||
# relative major is 3 semitones up
|
|
||||||
pc = (KEY_PC[tonic.upper()] + 3) % 12
|
Accepts accidentals in either case: 'Bb minor', 'F# minor', 'bb major'.
|
||||||
name = {0: "C", 2: "D", 4: "E", 5: "F", 7: "G", 9: "A", 11: "B"}.get(pc)
|
A tonic whose own spelling is a real key (C# major, Db major) keeps its
|
||||||
if name is None: # sharp-side minor keys
|
own signature; otherwise the canonical enharmonic spelling is used.
|
||||||
name = {1: "Db", 3: "Eb", 6: "F#", 8: "Ab", 10: "Bb"}[pc]
|
"""
|
||||||
|
pc = parse_tonic(tonic)
|
||||||
|
if pc is None:
|
||||||
|
raise SpecError("unparseable key tonic %r (expected letter + optional #/b)" % tonic)
|
||||||
|
t = str(tonic).strip()
|
||||||
|
spelling = t[0].upper() + t[1:]
|
||||||
|
if str(mode).lower() in ("minor", "m"):
|
||||||
|
name = PC_TO_KEY[(pc + 3) % 12] # relative major is 3 semitones up
|
||||||
return MAJOR_KEYS[name], True
|
return MAJOR_KEYS[name], True
|
||||||
return MAJOR_KEYS[tonic.capitalize() if len(tonic) == 1 else tonic], False
|
name = spelling if spelling in MAJOR_KEYS else PC_TO_KEY[pc]
|
||||||
|
return MAJOR_KEYS[name], False
|
||||||
|
|
||||||
|
|
||||||
|
def _melody_bar_count(score):
|
||||||
|
"""Number of '|'-separated bars in a score (0 if no separators)."""
|
||||||
|
segs = [s for s in score.split("|") if s.strip()]
|
||||||
|
return len(segs) if "|" in score else 0
|
||||||
|
|
||||||
|
|
||||||
|
def validate_spec(spec):
|
||||||
|
"""Return (warnings, errors). Errors block the build; warnings don't."""
|
||||||
|
errors, warnings = [], []
|
||||||
|
|
||||||
|
def need(key, typ=(str,)):
|
||||||
|
if key not in spec:
|
||||||
|
errors.append("missing required key %r" % key)
|
||||||
|
return None
|
||||||
|
v = spec[key]
|
||||||
|
if typ and not isinstance(v, typ):
|
||||||
|
errors.append("%r must be %s, got %r" % (key, "/".join(t.__name__ for t in typ), v))
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
name = need("name", (str,))
|
||||||
|
if name is not None:
|
||||||
|
if not NAME_RE.match(name) or ".." in name or "/" in name or os.sep in name:
|
||||||
|
errors.append("name %r is not a safe file name (letters, digits, '.', '_', '-' only; "
|
||||||
|
"no path separators or '..')" % name)
|
||||||
|
|
||||||
|
style = need("style", (str,))
|
||||||
|
if style is not None and style not in STYLES:
|
||||||
|
errors.append("unknown style %r (choose: %s)" % (style, ", ".join(sorted(STYLES))))
|
||||||
|
|
||||||
|
bpm = need("bpm", (int, float))
|
||||||
|
if bpm is not None and not (1 <= bpm <= 300):
|
||||||
|
errors.append("bpm must be 1..300, got %r" % bpm)
|
||||||
|
|
||||||
|
if "key" in spec:
|
||||||
|
key = spec["key"]
|
||||||
|
if not (isinstance(key, list) and len(key) == 2):
|
||||||
|
errors.append("key must be [tonic, mode], got %r" % (key,))
|
||||||
|
else:
|
||||||
|
if parse_tonic(key[0]) is None:
|
||||||
|
errors.append("key tonic %r is not a note letter with optional #/b" % key[0])
|
||||||
|
if str(key[1]).lower() not in ("major", "minor", "m"):
|
||||||
|
errors.append("key mode %r must be 'major' or 'minor'" % key[1])
|
||||||
|
|
||||||
|
chords = need("chords", (list,))
|
||||||
|
n_chords = 0
|
||||||
|
if chords is not None:
|
||||||
|
if not chords:
|
||||||
|
errors.append("chords must be a non-empty list")
|
||||||
|
for i, c in enumerate(chords):
|
||||||
|
if not (isinstance(c, list) and len(c) == 2):
|
||||||
|
errors.append("chords[%d] must be [root, quality], got %r" % (i, c))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
N(str(c[0]) + "4")
|
||||||
|
except (KeyError, ValueError, IndexError):
|
||||||
|
errors.append("chords[%d] root %r is not a note letter with optional #/b" % (i, c[0]))
|
||||||
|
if c[1] not in QUALITIES:
|
||||||
|
errors.append("chords[%d] quality %r unknown (choose: %s)"
|
||||||
|
% (i, c[1], " ".join(sorted(QUALITIES))))
|
||||||
|
n_chords = len(chords)
|
||||||
|
|
||||||
|
start = spec.get("melody_start_bar", 0)
|
||||||
|
if not isinstance(start, int) or start < 0:
|
||||||
|
errors.append("melody_start_bar must be a non-negative int, got %r" % start)
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
if spec.get("melody"):
|
||||||
|
try:
|
||||||
|
parse_score(spec["melody"]) # bar-length lint raises here
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
errors.append("melody: %s" % e)
|
||||||
|
else:
|
||||||
|
nb = _melody_bar_count(spec["melody"])
|
||||||
|
if nb and n_chords and start + nb > n_chords:
|
||||||
|
errors.append("melody runs past the chord chart: starts at bar %d, %d bars "
|
||||||
|
"long, but the chart has only %d bars" % (start, nb, n_chords))
|
||||||
|
|
||||||
|
if spec.get("counter"):
|
||||||
|
try:
|
||||||
|
parse_score(spec["counter"])
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
errors.append("counter: %s" % e)
|
||||||
|
if style is not None and style != "dos_adventure":
|
||||||
|
warnings.append("style %r has no counter line — 'counter' will be ignored" % style)
|
||||||
|
|
||||||
|
if "loop" in spec:
|
||||||
|
loop = spec["loop"]
|
||||||
|
if not (isinstance(loop, dict) and isinstance(loop.get("start_bar"), int)
|
||||||
|
and isinstance(loop.get("end_bar"), int)):
|
||||||
|
errors.append("loop must be {\"start_bar\": int, \"end_bar\": int}, got %r" % (loop,))
|
||||||
|
else:
|
||||||
|
s, e = loop["start_bar"], loop["end_bar"]
|
||||||
|
if not (0 <= s < e <= n_chords):
|
||||||
|
errors.append("loop bars out of range: need 0 <= start(%d) < end(%d) <= "
|
||||||
|
"len(chords)(%d) [bars are 0-based, end_bar is EXCLUSIVE]"
|
||||||
|
% (s, e, n_chords))
|
||||||
|
|
||||||
|
for b in spec.get("fills_at", []):
|
||||||
|
if not isinstance(b, int) or not (0 <= b < n_chords):
|
||||||
|
errors.append("fills_at bar %r out of range 0..%d" % (b, n_chords - 1))
|
||||||
|
if spec.get("fills_at") and style == "n64_ambient":
|
||||||
|
warnings.append("style 'n64_ambient' has no drums — 'fills_at' will be ignored")
|
||||||
|
|
||||||
|
return warnings, errors
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -80,10 +226,10 @@ def style_n64_ambient(mf, spec, chords):
|
|||||||
bass = Track("Cello", channel=3)
|
bass = Track("Cello", channel=3)
|
||||||
bass.program(0, "cello"); bass.cc(0, 7, 78); bass.cc(0, 10, 34)
|
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)
|
mf.add_track(lead); mf.add_track(pad); mf.add_track(harp); mf.add_track(bass)
|
||||||
# whole-note pads + root bass
|
# whole-note pads + root bass; the pad honours the chord quality
|
||||||
|
# (dim/aug/sus included) via the shared QUALITIES table
|
||||||
for i, (root, qual) in enumerate(chords):
|
for i, (root, qual) in enumerate(chords):
|
||||||
third = 3 if qual.startswith("m") or qual.startswith("dim") else 4
|
for iv in QUALITIES[qual][:3]:
|
||||||
for iv in (0, third, 7):
|
|
||||||
pad.note(bar(i), N(root + "3") + iv, TPQ * 4, 55)
|
pad.note(bar(i), N(root + "3") + iv, TPQ * 4, 55)
|
||||||
bass.note(bar(i), N(root + "2"), TPQ * 4, 70)
|
bass.note(bar(i), N(root + "2"), TPQ * 4, 70)
|
||||||
return dict(lead=lead, bass=bass, acomp=harp, drums=None,
|
return dict(lead=lead, bass=bass, acomp=harp, drums=None,
|
||||||
@@ -114,7 +260,16 @@ STYLES = {"dos_adventure": style_dos_adventure,
|
|||||||
"chiptune_racing": style_chiptune_racing}
|
"chiptune_racing": style_chiptune_racing}
|
||||||
|
|
||||||
|
|
||||||
def build(spec):
|
def build(spec, warn=True):
|
||||||
|
"""Build a MidiFile from a validated spec. Raises SpecError listing every
|
||||||
|
problem; style-incompatible fields produce warnings on stderr."""
|
||||||
|
warnings, errors = validate_spec(spec)
|
||||||
|
if errors:
|
||||||
|
raise SpecError("spec invalid:\n - " + "\n - ".join(errors))
|
||||||
|
if warn:
|
||||||
|
for w in warnings:
|
||||||
|
print("WARNING:", w, file=sys.stderr)
|
||||||
|
|
||||||
chords = [(r, q) for r, q in spec["chords"]]
|
chords = [(r, q) for r, q in spec["chords"]]
|
||||||
n = len(chords)
|
n = len(chords)
|
||||||
mf = MidiFile(tpq=TPQ)
|
mf = MidiFile(tpq=TPQ)
|
||||||
@@ -158,34 +313,63 @@ def build(spec):
|
|||||||
return mf
|
return mf
|
||||||
|
|
||||||
|
|
||||||
|
def midi_path_for(name):
|
||||||
|
"""midi/<name>.mid under the repo root; name must already be validated."""
|
||||||
|
return os.path.join(os.path.dirname(HERE), "midi", name + ".mid")
|
||||||
|
|
||||||
|
|
||||||
|
def render_wav(mid_path, name):
|
||||||
|
"""Render WAV via timidity and run the audio checks. Clear errors,
|
||||||
|
stderr surfaced, interpreter-agnostic (sys.executable, not bare python3)."""
|
||||||
|
if shutil.which("timidity") is None:
|
||||||
|
raise SystemExit("ERROR: timidity not found on PATH. "
|
||||||
|
"Debian/Ubuntu: apt install timidity fluid-soundfont-gm")
|
||||||
|
wav = os.path.join(os.path.dirname(HERE), "build", name + ".wav")
|
||||||
|
os.makedirs(os.path.dirname(wav), exist_ok=True)
|
||||||
|
try:
|
||||||
|
subprocess.run(["timidity", "-Ow", "-s", "44", mid_path, "-o", wav],
|
||||||
|
check=True, capture_output=True, text=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise SystemExit("ERROR: timidity failed (exit %d):\n%s\n%s"
|
||||||
|
% (e.returncode, e.stderr or "", e.stdout or ""))
|
||||||
|
subprocess.run([sys.executable, os.path.join(HERE, "check_wav.py"), wav], check=True)
|
||||||
|
return wav
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
ap.add_argument("spec", help="path to tune spec JSON")
|
ap.add_argument("spec", help="path to tune spec JSON")
|
||||||
ap.add_argument("--render", action="store_true", help="also render WAV via timidity")
|
ap.add_argument("--render", action="store_true", help="also render WAV via timidity")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
spec = json.load(open(args.spec))
|
try:
|
||||||
|
with open(args.spec) as f:
|
||||||
|
spec = json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise SystemExit("ERROR: %s is not valid JSON: %s" % (args.spec, e))
|
||||||
|
|
||||||
|
try:
|
||||||
mf = build(spec)
|
mf = build(spec)
|
||||||
out = os.path.join(os.path.dirname(HERE), "midi", spec["name"] + ".mid")
|
except SpecError as e:
|
||||||
|
raise SystemExit("ERROR: %s: %s" % (args.spec, e))
|
||||||
|
|
||||||
|
out = midi_path_for(spec["name"])
|
||||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||||
mf.write(out)
|
mf.write(out)
|
||||||
d = verify(out)
|
d = verify(out)
|
||||||
markers = [m for t in d["tracks"] for m in t["markers"]]
|
markers = [m for t in d["tracks"] for m in t["markers"]]
|
||||||
|
loop = spec.get("loop")
|
||||||
print("OK:", out)
|
print("OK:", out)
|
||||||
print(" %.1fs | %d notes | %d tracks | loop: %s" %
|
print(" %.1fs | %d notes | %d tracks | loop: %s" %
|
||||||
(d["duration_sec"], sum(len(t["notes"]) for t in d["tracks"]),
|
(d["duration_sec"], sum(len(t["notes"]) for t in d["tracks"]),
|
||||||
len(d["tracks"]),
|
len(d["tracks"]),
|
||||||
"bars %d-%d" % (spec["loop"]["start_bar"], spec["loop"]["end_bar"])
|
"bars %d-%d" % (int(loop["start_bar"]), int(loop["end_bar"]))
|
||||||
if spec.get("loop") else "none"))
|
if loop else "none"))
|
||||||
if markers:
|
if markers:
|
||||||
print(" markers:", ", ".join(sorted(set(m[1] for m in markers))))
|
print(" markers:", ", ".join(sorted(set(m[1] for m in markers))))
|
||||||
if args.render:
|
if args.render:
|
||||||
import subprocess
|
render_wav(out, spec["name"])
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ COUNTER_A = """
|
|||||||
r2 D5-8 F5-8 E5-4 | r2 F5-8 D5-8 Bb4-4 |
|
r2 D5-8 F5-8 E5-4 | r2 F5-8 D5-8 Bb4-4 |
|
||||||
r2 G5-8 F5-8 D5-4 | r2 E5-8 G5-8 F5-8 E5-8 |
|
r2 G5-8 F5-8 D5-4 | r2 E5-8 G5-8 F5-8 E5-8 |
|
||||||
r4 A5-8 G5-8 F5-4 A5-4 | r4 F5-8 A5-8 D5-4 F5-4 |
|
r4 A5-8 G5-8 F5-4 A5-4 | r4 F5-8 A5-8 D5-4 F5-4 |
|
||||||
r2 C6-4 Bb5-4 | r2 F5-4 D5-4 r4
|
r2 C6-4 Bb5-4 | r2 F5-4 D5-4
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user