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:
Hermes Agent committed 2026-10-17 15:59:46 +00:00
1 parent f5bd7ae13c
commit 810f127d01
7 files changed
+482 -50

No files matched your search

+138 -9
View File
@@ -1,10 +1,27 @@
#!/usr/bin/env python3
"""Smoke test: does midikit+songkit produce a valid, parseable, playable file?
Run from repo root: python3 tests/smoke_test.py"""
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
"""Smoke test: midikit+songkit+make_tune end-to-end.
Run from repo root: python3 tests/smoke_test.py
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 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_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,",
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"]))
# loop markers must survive the round-trip in all three conventions,
# 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",
"LoopBegin", "LoopEnd"])
assert markers == expected, "loop markers wrong: %s" % markers
print("loop markers OK:", ", ".join(markers))
assert names == expected, "loop markers wrong: %s" % names
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)
try:
@@ -50,3 +177,5 @@ try:
assert abs(m.length - d["duration_sec"]) < 0.5
except ImportError:
print("(mido not installed; skipping cross-check)")
print("SMOKE TEST PASSED")