#!/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")