Optional Chords
ci / checks (push) Successful in 38m32s

This commit is contained in:
khodowany
2026-09-08 00:34:44 -05:00
parent 900604b9fb
commit 62abb2d3f6
7 changed files with 140 additions and 62 deletions
+43 -17
View File
@@ -35,6 +35,7 @@ FORBIDDEN_OUTPUT_FIELDS = {
"fingering",
"left_hand_finger",
"right_hand_finger",
"chord_optional",
}
@@ -116,6 +117,7 @@ class CompactMeasureParser:
self.sequence = 0
self.tuplet_sequence = 0
self.pending_chord = ""
self.pending_chord_optional = False
self.last_note: dict[str, Any] | None = None
self.last_octave = 4
self.tuplets: list[dict[str, Any]] = []
@@ -155,9 +157,14 @@ class CompactMeasureParser:
def parse_chord(self) -> None:
self.index += 1
self.pending_chord = self.parse_until("]", "chord symbol").strip()
if not self.pending_chord:
chord = self.parse_until("]", "chord symbol").strip()
optional = chord.startswith("(") and chord.endswith(")")
if optional:
chord = chord[1:-1].strip()
if not chord:
raise self.error("chord symbol must not be empty")
self.pending_chord = chord
self.pending_chord_optional = optional
def parse_lyric(self) -> None:
if self.last_note is None:
@@ -288,7 +295,10 @@ class CompactMeasureParser:
}
if self.pending_chord:
note["chord_symbol"] = self.pending_chord
if self.pending_chord_optional:
note["chord_optional"] = True
self.pending_chord = ""
self.pending_chord_optional = False
if tie_from_previous:
note["tie_from_previous"] = True
if self.peek() == "~":
@@ -485,16 +495,30 @@ def first_voice(measure: dict[str, Any]) -> dict[str, Any]:
return {"id": "voice-empty", "tuplets": [], "notes": []}
def first_chord_symbol(measure: dict[str, Any], fallback: str = "") -> str:
def note_chord_symbol(note: dict[str, Any], *, include_optional: bool = True) -> str:
chord = note.get("chord_symbol")
if not isinstance(chord, str) or not chord:
return ""
if not include_optional and note.get("chord_optional") is True:
return ""
return chord
def first_chord_symbol(measure: dict[str, Any], fallback: str = "", *, include_optional: bool = True) -> str:
voice = first_voice(measure)
notes = voice.get("notes")
if not isinstance(notes, list):
return fallback
has_filtered_chord = False
for note in notes:
if isinstance(note, dict):
chord = note.get("chord_symbol")
if isinstance(chord, str) and chord:
if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol"):
has_filtered_chord = True
chord = note_chord_symbol(note, include_optional=include_optional)
if chord:
return chord
if not include_optional and has_filtered_chord:
return ""
return fallback
@@ -513,25 +537,25 @@ def timed_measure_notes(measure: dict[str, Any]) -> list[tuple[int, dict[str, An
return result
def chord_markers(measure: dict[str, Any]) -> list[tuple[int, int, str]]:
def chord_markers(measure: dict[str, Any], *, include_optional: bool = True) -> list[tuple[int, int, str]]:
markers: list[tuple[int, int, str]] = []
for start_ticks, note in timed_measure_notes(measure):
chord = note.get("chord_symbol")
if isinstance(chord, str) and chord:
chord = note_chord_symbol(note, include_optional=include_optional)
if chord:
markers.append((start_ticks, note_duration(note), chord))
return markers
def chord_symbol_at_offset(measure: dict[str, Any], offset_ticks: int) -> str:
for start_ticks, _, chord in chord_markers(measure):
def chord_symbol_at_offset(measure: dict[str, Any], offset_ticks: int, *, include_optional: bool = True) -> str:
for start_ticks, _, chord in chord_markers(measure, include_optional=include_optional):
if start_ticks == offset_ticks:
return chord
return ""
def active_chord_at_offset(measure: dict[str, Any], offset_ticks: int, fallback: str = "") -> str:
def active_chord_at_offset(measure: dict[str, Any], offset_ticks: int, fallback: str = "", *, include_optional: bool = True) -> str:
current = fallback
for start_ticks, _, chord in chord_markers(measure):
for start_ticks, _, chord in chord_markers(measure, include_optional=include_optional):
if start_ticks > offset_ticks:
break
current = chord
@@ -592,8 +616,8 @@ def renderable_rest_notes(note_id_prefix: str, duration_ticks: int, sequence: in
return notes, sequence
def apply_source_chord_markers(notes: list[dict[str, Any]], measure: dict[str, Any]) -> list[dict[str, Any]]:
markers = [(start, chord) for start, _, chord in chord_markers(measure)]
def apply_source_chord_markers(notes: list[dict[str, Any]], measure: dict[str, Any], *, include_optional: bool = True) -> list[dict[str, Any]]:
markers = [(start, chord) for start, _, chord in chord_markers(measure, include_optional=include_optional)]
if not markers:
result = [copy.deepcopy(note) for note in notes]
for note in result:
@@ -661,18 +685,20 @@ def sanitize_json(value: Any) -> Any:
return copy.deepcopy(value)
def sanitized_note(note: dict[str, Any], *, strip_lyrics: bool) -> dict[str, Any]:
def sanitized_note(note: dict[str, Any], *, strip_lyrics: bool, include_optional: bool = True) -> dict[str, Any]:
output = sanitize_json(note)
if strip_lyrics:
output.pop("lyric", None)
if not include_optional and note.get("chord_optional") is True:
output.pop("chord_symbol", None)
output.setdefault("hidden", False)
return output
def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool) -> dict[str, Any]:
def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool, include_optional: bool = True) -> dict[str, Any]:
voice = first_voice(measure)
tuplets = sanitize_json(voice.get("tuplets", []))
notes = [sanitized_note(note, strip_lyrics=strip_lyrics) for note in measure_notes(measure)]
notes = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in measure_notes(measure)]
return {
"id": str(voice.get("id", "voice-source")),
"tuplets": tuplets if isinstance(tuplets, list) else [],