from __future__ import annotations import json from pathlib import Path from typing import Any from generator.score import load_json, measure_notes from generator.transposition import TARGET_KEYS def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]: staves = document.get("staves") if not isinstance(staves, list) or not staves or not isinstance(staves[0], dict): raise ValueError("generated score document must contain an arranged staff") return staves[0] def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_number: int) -> dict[str, Any]: notes = measure_notes(measure) summary: dict[str, Any] = { "measure": measure_number, "section_label": str(measure.get("section_label", "")), "chord_symbols": [str(note.get("chord_symbol", "")) for note in notes if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol")], "slash_count": sum(1 for note in notes if note.get("kind", "note") == "note" and note.get("notehead") == "slash"), } if staff.get("arrangement_part_type") == "vocal": summary["vocal_skeleton"] = [ { "kind": str(note.get("kind", "note")), "duration_ticks": int(note.get("duration_ticks", 0)), "display_ticks": int(note["display_ticks"]) if isinstance(note.get("display_ticks"), int) else None, "has_lyric": isinstance(note.get("lyric"), str) and bool(note.get("lyric")), } for note in notes ] return summary def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]: metadata = document.get("metadata") if not isinstance(metadata, dict): metadata = {} staff = _arranged_staff(document) measures = staff.get("measures") if not isinstance(measures, list): measures = [] measure_summaries = [ _measure_summary(staff, measure, index + 1) for index, measure in enumerate(measures) if isinstance(measure, dict) ] return { "title": str(metadata.get("title", "")), "composer": str(metadata.get("composer", "")), "key_signature": str(metadata.get("key_signature", "")), "part_type": str(staff.get("arrangement_part_type", "")), "measure_count": len(measure_summaries), "section_labels": [ {"measure": item["measure"], "label": item["section_label"]} for item in measure_summaries if item["section_label"] ], "measures": measure_summaries, } def performance_tree_summary(root: Path) -> dict[str, Any]: return { path.name: performance_document_summary(load_json(path)) for path in sorted(root.glob("*.json")) } def validate_expected_performance_summary(expected_path: Path, generated_songs_dir: Path) -> None: if not expected_path.exists(): raise ValueError(f"missing generator compatibility summary: {expected_path}") with expected_path.open("r", encoding="utf-8") as stream: expected = json.load(stream) actual = performance_tree_summary(generated_songs_dir) if actual == expected: return expected_names = set(expected.keys()) if isinstance(expected, dict) else set() actual_names = set(actual.keys()) missing = sorted(expected_names - actual_names) extra = sorted(actual_names - expected_names) if missing or extra: raise ValueError(f"performance generator drift: missing={missing}, extra={extra}") for name in sorted(actual_names): if actual[name] != expected[name]: raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally") raise ValueError("performance generator drift") def validate_practice_variants(generated_practice_dir: Path) -> None: if not generated_practice_dir.exists(): return melody_root = generated_practice_dir / "melody" if melody_root.exists(): expected_names = {key.lower().replace("#", "sharp").replace("b", "b") + ".json" for key in TARGET_KEYS} for exercise_dir in sorted(path for path in melody_root.rglob("*") if path.is_dir()): json_names = {path.name for path in exercise_dir.glob("*.json")} if not json_names: continue if json_names != expected_names: raise ValueError(f"melody practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}") chord_root = generated_practice_dir / "chord" if chord_root.exists(): for exercise_dir in sorted(path for path in chord_root.rglob("*") if path.is_dir()): json_names = [path.name for path in exercise_dir.glob("*.json")] if not json_names: continue if len(json_names) != 1: raise ValueError(f"chord practice generator drift in {exercise_dir}: expected one authored-key variant, found {json_names}")