Styles
ci / checks (push) Successful in 39m31s

This commit is contained in:
khodowany
2026-09-08 22:07:31 -05:00
parent 6e7db6fd97
commit fa7485d404
28 changed files with 522 additions and 292 deletions
+81 -21
View File
@@ -6,6 +6,8 @@ import re
from pathlib import Path
from typing import Any
from generator.styles import canonical_style
WHOLE_TICKS = 16384
HALF_TICKS = WHOLE_TICKS // 2
QUARTER_TICKS = WHOLE_TICKS // 4
@@ -28,13 +30,6 @@ RENDERABLE_TICKS = (
REST_PITCH = {"letter": "b", "accidental": "n", "octave": 4}
FORBIDDEN_OUTPUT_FIELDS = {
"tab_positions",
"tab_frets",
"finger",
"fingers",
"fingering",
"left_hand_finger",
"right_hand_finger",
"chord_optional",
}
@@ -91,6 +86,35 @@ def score_field(document: dict[str, Any], metadata: dict[str, Any], *names: str,
return fallback
def canonicalize_score_metadata(metadata: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
output = sanitize_json(metadata)
if not isinstance(output, dict):
output = {}
style_source = output.get("style")
try:
output["style"] = canonical_style(style_source)
except ValueError as error:
location = f"{path}: " if path is not None else ""
raise CompactScoreError(f"{location}{error}") from error
return output
def normalize_full_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
output = copy.deepcopy(document)
output.pop("version", None)
metadata_value = output.get("metadata", {})
if metadata_value is not None and not isinstance(metadata_value, dict):
location = f"{path}: " if path is not None else ""
raise CompactScoreError(f"{location}metadata must be an object")
metadata = copy.deepcopy(metadata_value) if isinstance(metadata_value, dict) else {}
for key in ("style",):
if key in output and key not in metadata:
metadata[key] = output[key]
output.pop(key, None)
output["metadata"] = canonicalize_score_metadata(metadata, path)
return output
def parse_duration_token(text: str, label: str) -> int:
dotted = text.endswith(".")
base_text = text[:-1] if dotted else text
@@ -352,9 +376,31 @@ class CompactMeasureParser:
return notes, self.tuplets
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str]]:
COMPACT_MEASURE_CONTROL_FIELDS = {
"notes",
"notation",
"section",
"section_label",
"key",
"key_signature",
"time",
"time_signature",
"id",
"voices",
}
GENERATED_MEASURE_FIELDS = {
"id",
"section_label",
"time_signature",
"key_signature",
"voices",
}
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str], dict[str, Any]]:
if isinstance(value, str):
return value, {}
return value, {}, {}
if isinstance(value, dict):
text = value.get("notes", value.get("notation", ""))
if not isinstance(text, str):
@@ -371,16 +417,21 @@ def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str]]:
field = value.get(source)
if isinstance(field, str):
overrides[target] = field
return text, overrides
extra_fields = {
key: sanitize_json(extra_value)
for key, extra_value in value.items()
if key not in COMPACT_MEASURE_CONTROL_FIELDS
}
return text, overrides, extra_fields
raise CompactScoreError(f"measure {index + 1}: compact measure must be a string or object")
def normalize_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
if isinstance(document.get("staves"), list):
return document
return normalize_full_score_document(document, path)
measures_value = document.get("measures")
if not isinstance(measures_value, list):
return document
return normalize_full_score_document(document, path)
metadata_value = document.get("metadata", {})
metadata = metadata_value if isinstance(metadata_value, dict) else {}
title = score_field(document, metadata, "title", fallback=human_label(path.stem if path is not None else "score"))
@@ -401,18 +452,19 @@ def normalize_score_document(document: dict[str, Any], path: Path | None = None)
"time_signature": time_signature,
})
for key, value in document.items():
if key in {"title", "composer", "key", "key_signature", "time", "time_signature", "clef", "part", "name", "role", "measures", "metadata"}:
if key in {"version", "title", "composer", "key", "key_signature", "time", "time_signature", "clef", "part", "name", "role", "measures", "metadata"}:
continue
output_metadata[key] = sanitize_json(value)
output_metadata = canonicalize_score_metadata(output_metadata, path)
measures: list[dict[str, Any]] = []
for index, value in enumerate(measures_value):
text, overrides = compact_measure_text(value, index)
text, overrides, extra_fields = compact_measure_text(value, index)
measure_time = overrides.get("time_signature", time_signature)
measure_key = overrides.get("key_signature", key_signature)
parser = CompactMeasureParser(text, measure_number=index + 1, time_signature=measure_time)
notes, tuplets = parser.parse()
measures.append({
measure = {
"id": f"measure-{index + 1}",
"section_label": overrides.get("section_label", ""),
"time_signature": measure_time,
@@ -422,10 +474,11 @@ def normalize_score_document(document: dict[str, Any], path: Path | None = None)
"notes": notes,
"tuplets": tuplets,
}],
})
}
measure.update(extra_fields)
measures.append(measure)
return {
"version": int(document.get("version", 1)) if isinstance(document.get("version", 1), int) else 1,
"metadata": output_metadata,
"staves": [{
"id": "staff-source",
@@ -706,6 +759,14 @@ def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool, include_op
}
def measure_notation_metadata(source: dict[str, Any]) -> dict[str, Any]:
return {
key: sanitize_json(value)
for key, value in source.items()
if key not in GENERATED_MEASURE_FIELDS
}
def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str, Any]], *, section_label: str = "", tuplets: list[Any] | None = None) -> dict[str, Any]:
measure: dict[str, Any] = {
"id": measure_id,
@@ -719,15 +780,14 @@ def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str,
}
if section_label:
measure["section_label"] = section_label
measure.update(measure_notation_metadata(source))
return measure
def make_arranged_document(source_document: dict[str, Any], staff: dict[str, Any], *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict[str, Any]]) -> dict[str, Any]:
metadata = sanitize_json(source_document.get("metadata", {}))
if not isinstance(metadata, dict):
metadata = {}
source_metadata = source_document.get("metadata", {})
metadata = canonicalize_score_metadata(source_metadata if isinstance(source_metadata, dict) else {})
output: dict[str, Any] = {
"version": int(source_document.get("version", 1)) if isinstance(source_document.get("version", 1), int) else 1,
"metadata": metadata,
"staves": [{
"id": staff_id,
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
DEFAULT_STYLE = "Medium Swing"
BUILT_IN_STYLES = (
# Jazz
"Afro 12/8",
"Ballad Double Time Feel",
"Ballad Even",
"Ballad Melodic",
"Ballad Swing",
"Blue Note",
"Bossa Nova",
"Doo Doo Cats",
"Double Time Swing",
"Even 8ths",
"Even 8ths Open",
"Even 16ths",
"Guitar Trio",
"Gypsy Jazz",
"Latin",
"Latin/Swing",
"Long Notes",
"Medium Swing",
"Medium Up Swing",
"Medium Up Swing 2",
"New Orleans Swing",
"Second Line",
"Slow Swing",
"Swing Two/Four",
"Trad Jazz",
"Up Tempo Swing",
"Up Tempo Swing 2",
# Latin
"Argentina: Tango",
"Brazil: Bossa Acoustic",
"Brazil: Bossa Electric",
"Brazil: Samba",
"Cuba: Bolero",
"Cuba: Cha Cha Cha",
"Cuba: Son Montuno 2-3",
"Cuba: Son Montuno 3-2",
# Pop
"Bluegrass",
"Country",
"Disco",
"Funk",
"Glam Funk",
"House",
"Reggae",
"RnB",
"Rock",
"Rock 12/8",
"Shuffle",
"Slow Rock",
"Smooth",
"Soul",
"Virtual Funk",
)
BUILT_IN_STYLE_SET = frozenset(BUILT_IN_STYLES)
STYLE_ALIASES = {
"Med.": DEFAULT_STYLE,
"Med. Swing": DEFAULT_STYLE,
"Medium": DEFAULT_STYLE,
}
def canonical_style(value: object) -> str:
if value is None:
return DEFAULT_STYLE
if not isinstance(value, str):
raise ValueError("style must be a string")
style = value.strip()
if not style:
return DEFAULT_STYLE
style = STYLE_ALIASES.get(style, style)
if style not in BUILT_IN_STYLE_SET:
raise ValueError(f"style {value!r} is not a supported player style")
return style