- 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
182 lines
8.1 KiB
Python
182 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""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, 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"
|
|
|
|
mf = MidiFile(tpq=480)
|
|
mf.time_sig = (4, 2)
|
|
mf.key_sig = (-1, True) # D minor
|
|
lead = Track("Lead", channel=0)
|
|
lead.program(0, "square lead")
|
|
melody(lead, 0, "D4 A4-8 D5-8 F5-8 E5-4 D5-2")
|
|
vibrato(lead, bar(0), bar(4), channel=0, tempo_bpm=120)
|
|
bass = Track("Bass", channel=1)
|
|
bass.program(0, "synth bass 1")
|
|
bass_drive(bass, 0, [("D", "m"), ("Bb", "")])
|
|
acomp = Track("Arp", channel=2)
|
|
acomp.program(0, "electric piano 1")
|
|
arp(acomp, 0, [("D", "m"), ("Bb", "")])
|
|
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)
|
|
|
|
d = verify(path, min_sec=1, max_sec=30)
|
|
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,
|
|
# 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 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:
|
|
import mido
|
|
m = mido.MidiFile(path)
|
|
print("mido agrees:", m.length, "sec,", len(m.tracks), "tracks")
|
|
assert abs(m.length - d["duration_sec"]) < 0.5
|
|
except ImportError:
|
|
print("(mido not installed; skipping cross-check)")
|
|
|
|
print("SMOKE TEST PASSED")
|