"""validate_grid.py — READ-ONLY linter for chorus_target grids. Flags note segmentation / voice-demux artifacts so we know which grids are dirty BEFORE any cleaning. Mutates nothing — pair with a separate refinement step. Artifact categories (a real sung note has a pitch, lasts long enough to sing, and is mostly voiced): IMPOSSIBLE type in {2,3} (sung) but pitch == 0 -> definitionally bogus MICRO sung note shorter than MIN_NOTE -> over-segmentation fragment NOISE sung note with f0-voiced fraction < F0_FRAC -> breath/bleed, not sung Usage: python backstage/validate_grid.py # all songs (assets/*/chorus_target.json) python backstage/validate_grid.py ... # specific songs """ from __future__ import annotations import json, sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MIN_NOTE = 0.12 # s; below this a "note" is a fragment F0_FRAC = 0.50 # min fraction of a sung note's frames that must be voiced def analyze(tgt: Path): d = json.loads(tgt.read_text())[0] ds = [float(x) for x in d["duration"].split()] ty = d["note_type"].split() pit = [int(x) for x in d["note_pitch"].split()] f0 = [float(x) for x in d["f0"].split()] fps = len(f0) / sum(ds) if sum(ds) else 0 imp, micro, noise, sung = [], [], [], 0 t = 0.0 for i, (dd, nt, p) in enumerate(zip(ds, ty, pit)): if nt in ("2", "3"): sung += 1 a, b = int(t * fps), int((t + dd) * fps) seg = f0[a:b] vf = (sum(1 for x in seg if x > 0) / len(seg)) if seg else 0.0 if p == 0: imp.append((i, round(t, 2), dd)) elif dd < MIN_NOTE: micro.append((i, round(t, 2), dd)) elif vf < F0_FRAC: noise.append((i, round(t, 2), round(vf, 2))) t += dd return sung, imp, micro, noise def main(): args = sys.argv[1:] songs = args or sorted(p.parent.name for p in (ROOT / "assets").glob("*/chorus_target.json")) print(f"{'song':26s} {'sung':>4} {'IMPOSSIBLE':>10} {'MICRO':>6} {'NOISE':>6} verdict") for s in songs: tgt = ROOT / "assets" / s / "chorus_target.json" if not tgt.exists(): print(f"{s:26s} (no chorus_target.json)"); continue sung, imp, micro, noise = analyze(tgt) dirty = len(imp) + len(micro) + len(noise) verdict = "CLEAN" if dirty == 0 else (f"DIRTY ({dirty})" if (len(imp) or dirty > sung * 0.25) else "minor") print(f"{s:26s} {sung:>4} {len(imp):>10} {len(micro):>6} {len(noise):>6} {verdict}") if imp: print(f" IMPOSSIBLE (sung but pitch 0): {imp}") print(f"\nthresholds: MIN_NOTE={MIN_NOTE}s F0_FRAC={F0_FRAC}") if __name__ == "__main__": main()