117 lines
4.6 KiB
Python
117 lines
4.6 KiB
Python
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}")
|