+363
-1
@@ -43,7 +43,7 @@ def load_json(path: Path) -> dict[str, Any]:
|
||||
document = json.load(stream)
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError(f"{path} is not a score document")
|
||||
return document
|
||||
return normalize_score_document(document, path)
|
||||
|
||||
|
||||
def write_json(path: Path, document: dict[str, Any]) -> None:
|
||||
@@ -65,6 +65,368 @@ def human_label(value: str) -> str:
|
||||
return " ".join(part.capitalize() for part in text.split()) or "General"
|
||||
|
||||
|
||||
class CompactScoreError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
DURATION_TICKS = {
|
||||
"1": WHOLE_TICKS,
|
||||
"2": HALF_TICKS,
|
||||
"4": QUARTER_TICKS,
|
||||
"8": EIGHTH_TICKS,
|
||||
"16": SIXTEENTH_TICKS,
|
||||
"32": THIRTY_SECOND_TICKS,
|
||||
}
|
||||
|
||||
|
||||
def score_field(document: dict[str, Any], metadata: dict[str, Any], *names: str, fallback: str = "") -> str:
|
||||
for name in names:
|
||||
value = document.get(name)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
value = metadata.get(name)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
def parse_duration_token(text: str, label: str) -> int:
|
||||
dotted = text.endswith(".")
|
||||
base_text = text[:-1] if dotted else text
|
||||
if base_text not in DURATION_TICKS:
|
||||
raise CompactScoreError(f"{label}: unsupported duration {text!r}")
|
||||
ticks = DURATION_TICKS[base_text]
|
||||
return ticks + ticks // 2 if dotted else ticks
|
||||
|
||||
|
||||
def compact_pitch(letter: str, accidental: str, octave: int) -> dict[str, Any]:
|
||||
return {
|
||||
"letter": letter.lower(),
|
||||
"accidental": accidental or "n",
|
||||
"octave": octave,
|
||||
}
|
||||
|
||||
|
||||
class CompactMeasureParser:
|
||||
def __init__(self, text: str, *, measure_number: int, time_signature: str) -> None:
|
||||
self.text = text
|
||||
self.index = 0
|
||||
self.measure_number = measure_number
|
||||
self.time_signature = time_signature
|
||||
self.sequence = 0
|
||||
self.tuplet_sequence = 0
|
||||
self.pending_chord = ""
|
||||
self.last_note: dict[str, Any] | None = None
|
||||
self.last_octave = 4
|
||||
self.tuplets: list[dict[str, Any]] = []
|
||||
|
||||
def label(self) -> str:
|
||||
return f"measure {self.measure_number}"
|
||||
|
||||
def error(self, message: str) -> CompactScoreError:
|
||||
return CompactScoreError(f"{self.label()}, column {self.index + 1}: {message}")
|
||||
|
||||
def peek(self) -> str:
|
||||
return self.text[self.index] if self.index < len(self.text) else ""
|
||||
|
||||
def skip_space(self) -> None:
|
||||
while self.peek() and self.peek().isspace():
|
||||
self.index += 1
|
||||
|
||||
def parse_until(self, close: str, label: str) -> str:
|
||||
start = self.index
|
||||
escaped = False
|
||||
while self.index < len(self.text):
|
||||
char = self.text[self.index]
|
||||
if escaped:
|
||||
escaped = False
|
||||
self.index += 1
|
||||
continue
|
||||
if char == "\\":
|
||||
escaped = True
|
||||
self.index += 1
|
||||
continue
|
||||
if char == close:
|
||||
value = self.text[start:self.index]
|
||||
self.index += 1
|
||||
return bytes(value, "utf-8").decode("unicode_escape")
|
||||
self.index += 1
|
||||
raise self.error(f"unterminated {label}")
|
||||
|
||||
def parse_chord(self) -> None:
|
||||
self.index += 1
|
||||
self.pending_chord = self.parse_until("]", "chord symbol").strip()
|
||||
if not self.pending_chord:
|
||||
raise self.error("chord symbol must not be empty")
|
||||
|
||||
def parse_lyric(self) -> None:
|
||||
if self.last_note is None:
|
||||
raise self.error("lyric must follow a note")
|
||||
self.index += 1
|
||||
lyric = self.parse_until('"', "lyric")
|
||||
self.last_note["lyric"] = lyric
|
||||
|
||||
def parse_tuplet_header(self) -> tuple[int, int]:
|
||||
self.skip_space()
|
||||
match = re.match(r"(\d+)\s*:\s*(\d+)", self.text[self.index:])
|
||||
if match is None:
|
||||
raise self.error("tuplet must start with N:M")
|
||||
self.index += match.end()
|
||||
note_count = int(match.group(1))
|
||||
space_count = int(match.group(2))
|
||||
if note_count <= 0 or space_count <= 0:
|
||||
raise self.error("tuplet counts must be positive")
|
||||
return note_count, space_count
|
||||
|
||||
def parse_tuplet(self) -> list[dict[str, Any]]:
|
||||
self.index += 1
|
||||
note_count, space_count = self.parse_tuplet_header()
|
||||
self.tuplet_sequence += 1
|
||||
tuplet_id = f"tuplet-{self.measure_number}-{self.tuplet_sequence}"
|
||||
notes: list[dict[str, Any]] = []
|
||||
while True:
|
||||
self.skip_space()
|
||||
if not self.peek():
|
||||
raise self.error("unterminated tuplet")
|
||||
if self.peek() == "}":
|
||||
self.index += 1
|
||||
break
|
||||
if self.peek() == "{":
|
||||
raise self.error("nested tuplets are not supported")
|
||||
notes.extend(self.parse_item())
|
||||
if len(notes) != note_count:
|
||||
raise self.error(f"tuplet {tuplet_id} contains {len(notes)} notes/rests, expected {note_count}")
|
||||
self.apply_tuplet_duration(notes, note_count, space_count)
|
||||
for note in notes:
|
||||
note["tuplet_id"] = tuplet_id
|
||||
self.tuplets.append({
|
||||
"id": tuplet_id,
|
||||
"note_count": note_count,
|
||||
"space_count": space_count,
|
||||
})
|
||||
return notes
|
||||
|
||||
def apply_tuplet_duration(self, notes: list[dict[str, Any]], note_count: int, space_count: int) -> None:
|
||||
numerators = [int(note["duration_ticks"]) * space_count for note in notes]
|
||||
floors = [value // note_count for value in numerators]
|
||||
target_total = sum(numerators) // note_count
|
||||
remainder = target_total - sum(floors)
|
||||
order = sorted(range(len(notes)), key=lambda index: numerators[index] % note_count, reverse=True)
|
||||
actual = floors[:]
|
||||
for index in order[:remainder]:
|
||||
actual[index] += 1
|
||||
for note, ticks in zip(notes, actual):
|
||||
display = int(note["duration_ticks"])
|
||||
note["duration_ticks"] = max(1, ticks)
|
||||
note["display_ticks"] = display
|
||||
|
||||
def parse_pitch_atom(self) -> dict[str, Any]:
|
||||
start = self.index
|
||||
char = self.peek()
|
||||
if char.lower() not in "abcdefg":
|
||||
raise self.error("expected pitch")
|
||||
self.index += 1
|
||||
accidental = ""
|
||||
if self.peek() in {"#", "b", "n"}:
|
||||
accidental = self.peek()
|
||||
self.index += 1
|
||||
octave_start = self.index
|
||||
while self.peek().isdigit():
|
||||
self.index += 1
|
||||
if self.index > octave_start:
|
||||
self.last_octave = int(self.text[octave_start:self.index])
|
||||
if self.index == start:
|
||||
raise self.error("expected pitch")
|
||||
return compact_pitch(char, accidental, self.last_octave)
|
||||
|
||||
def parse_pitch_group(self) -> list[dict[str, Any]]:
|
||||
self.index += 1
|
||||
pitches: list[dict[str, Any]] = []
|
||||
while True:
|
||||
self.skip_space()
|
||||
if not self.peek():
|
||||
raise self.error("unterminated pitch group")
|
||||
if self.peek() == ")":
|
||||
self.index += 1
|
||||
break
|
||||
pitches.append(self.parse_pitch_atom())
|
||||
if not pitches:
|
||||
raise self.error("pitch group must contain at least one pitch")
|
||||
return pitches
|
||||
|
||||
def parse_note(self, tie_from_previous: bool = False) -> dict[str, Any]:
|
||||
start = self.index
|
||||
char = self.peek()
|
||||
if not char:
|
||||
raise self.error("expected note or rest")
|
||||
kind = "rest" if char.lower() == "r" else "note"
|
||||
if kind == "rest":
|
||||
self.index += 1
|
||||
pitches = [copy.deepcopy(REST_PITCH)]
|
||||
elif char == "(":
|
||||
pitches = self.parse_pitch_group()
|
||||
else:
|
||||
pitches = [self.parse_pitch_atom()]
|
||||
if self.peek() != ":":
|
||||
raise self.error(f"expected ':' after {self.text[start:self.index]!r}")
|
||||
self.index += 1
|
||||
duration_start = self.index
|
||||
while self.peek().isdigit():
|
||||
self.index += 1
|
||||
if self.peek() == ".":
|
||||
self.index += 1
|
||||
duration_text = self.text[duration_start:self.index]
|
||||
if not duration_text:
|
||||
raise self.error("duration is required")
|
||||
self.sequence += 1
|
||||
note: dict[str, Any] = {
|
||||
"id": f"note-{self.measure_number}-{self.sequence}",
|
||||
"kind": kind,
|
||||
"duration_ticks": parse_duration_token(duration_text, self.label()),
|
||||
"pitches": pitches,
|
||||
"hidden": False,
|
||||
}
|
||||
if self.pending_chord:
|
||||
note["chord_symbol"] = self.pending_chord
|
||||
self.pending_chord = ""
|
||||
if tie_from_previous:
|
||||
note["tie_from_previous"] = True
|
||||
if self.peek() == "~":
|
||||
note["tie_to_next"] = True
|
||||
self.index += 1
|
||||
self.last_note = note
|
||||
return note
|
||||
|
||||
def parse_item(self) -> list[dict[str, Any]]:
|
||||
self.skip_space()
|
||||
char = self.peek()
|
||||
if not char:
|
||||
return []
|
||||
if char == "[":
|
||||
self.parse_chord()
|
||||
return []
|
||||
if char == '"':
|
||||
self.parse_lyric()
|
||||
return []
|
||||
if char == "{":
|
||||
return self.parse_tuplet()
|
||||
if char == "}":
|
||||
return []
|
||||
if char == "~":
|
||||
self.index += 1
|
||||
self.skip_space()
|
||||
next_char = self.peek()
|
||||
if next_char and (next_char.lower() in "abcdefgr" or next_char == "("):
|
||||
return [self.parse_note(tie_from_previous=True)]
|
||||
if self.last_note is None:
|
||||
raise self.error("tie marker must attach to a note")
|
||||
self.last_note["tie_to_next"] = True
|
||||
return []
|
||||
if char.lower() in "abcdefgr" or char == "(":
|
||||
return [self.parse_note()]
|
||||
raise self.error("expected note, rest, chord, lyric, tie, or tuplet")
|
||||
|
||||
def parse(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
notes: list[dict[str, Any]] = []
|
||||
while True:
|
||||
self.skip_space()
|
||||
if not self.peek():
|
||||
break
|
||||
notes.extend(self.parse_item())
|
||||
if self.pending_chord:
|
||||
raise self.error(f"chord {self.pending_chord!r} is not followed by a note or rest")
|
||||
total = sum(int(note.get("duration_ticks", 0)) for note in notes)
|
||||
capacity = measure_capacity_ticks(self.time_signature)
|
||||
if total > capacity:
|
||||
raise CompactScoreError(f"{self.label()}: duration total {total} exceeds {self.time_signature} capacity {capacity}")
|
||||
return notes, self.tuplets
|
||||
|
||||
|
||||
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str]]:
|
||||
if isinstance(value, str):
|
||||
return value, {}
|
||||
if isinstance(value, dict):
|
||||
text = value.get("notes", value.get("notation", ""))
|
||||
if not isinstance(text, str):
|
||||
raise CompactScoreError(f"measure {index + 1}: notes must be a string")
|
||||
overrides: dict[str, str] = {}
|
||||
for source, target in (
|
||||
("section", "section_label"),
|
||||
("section_label", "section_label"),
|
||||
("key", "key_signature"),
|
||||
("key_signature", "key_signature"),
|
||||
("time", "time_signature"),
|
||||
("time_signature", "time_signature"),
|
||||
):
|
||||
field = value.get(source)
|
||||
if isinstance(field, str):
|
||||
overrides[target] = field
|
||||
return text, overrides
|
||||
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
|
||||
measures_value = document.get("measures")
|
||||
if not isinstance(measures_value, list):
|
||||
return document
|
||||
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"))
|
||||
composer = score_field(document, metadata, "composer")
|
||||
key_signature = score_field(document, metadata, "key_signature", "key", fallback="C")
|
||||
time_signature = score_field(document, metadata, "time_signature", "time", fallback="4/4")
|
||||
clef = score_field(document, metadata, "clef", fallback="treble")
|
||||
part = score_field(document, metadata, "part", "name", fallback="Vocal")
|
||||
role = score_field(document, metadata, "role", fallback="vocal")
|
||||
|
||||
output_metadata = sanitize_json(metadata)
|
||||
if not isinstance(output_metadata, dict):
|
||||
output_metadata = {}
|
||||
output_metadata.update({
|
||||
"title": title,
|
||||
"composer": composer,
|
||||
"key_signature": key_signature,
|
||||
"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"}:
|
||||
continue
|
||||
output_metadata[key] = sanitize_json(value)
|
||||
|
||||
measures: list[dict[str, Any]] = []
|
||||
for index, value in enumerate(measures_value):
|
||||
text, overrides = 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({
|
||||
"id": f"measure-{index + 1}",
|
||||
"section_label": overrides.get("section_label", ""),
|
||||
"time_signature": measure_time,
|
||||
"key_signature": measure_key,
|
||||
"voices": [{
|
||||
"id": f"voice-{index + 1}",
|
||||
"notes": notes,
|
||||
"tuplets": tuplets,
|
||||
}],
|
||||
})
|
||||
|
||||
return {
|
||||
"version": int(document.get("version", 1)) if isinstance(document.get("version", 1), int) else 1,
|
||||
"metadata": output_metadata,
|
||||
"staves": [{
|
||||
"id": "staff-source",
|
||||
"name": part,
|
||||
"clef": clef,
|
||||
"role": role,
|
||||
"measures": measures,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def source_staff(document: dict[str, Any]) -> dict[str, Any]:
|
||||
staves = document.get("staves")
|
||||
if not isinstance(staves, list) or not staves:
|
||||
|
||||
Reference in New Issue
Block a user