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
+37 -30
View File
@@ -32,11 +32,14 @@ from generator.score import (
PART_TYPES = ("acoustic", "electric", "vocal")
def propagated_chords(measures: list[dict]) -> list[str]:
FINAL_SECTION_INDEX = 6
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[str]:
result: list[str] = []
current = ""
for measure in measures:
current = first_chord_symbol(measure, current)
current = first_chord_symbol(measure, current, include_optional=include_optional)
result.append(current)
return result
@@ -56,7 +59,7 @@ def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol:
return note
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
hit_offsets = (0, QUARTER_TICKS + QUARTER_TICKS // 2, QUARTER_TICKS * 2, QUARTER_TICKS * 2 + QUARTER_TICKS // 2, QUARTER_TICKS * 3 + QUARTER_TICKS // 2)
hit_duration = QUARTER_TICKS // 2
@@ -69,28 +72,28 @@ def rhythm_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
notes.extend(rests)
cursor = offset
active_chord = active_chord_at_offset(measure, offset, chord_symbol)
visible_chord = chord_symbol_at_offset(measure, offset)
active_chord = active_chord_at_offset(measure, offset, chord_symbol, include_optional=include_optional)
visible_chord = chord_symbol_at_offset(measure, offset, include_optional=include_optional)
sequence += 1
notes.append(playable_chord_note(f"arr-note-{sequence}", min(hit_duration, capacity - offset), active_chord, visible_chord))
cursor = offset + min(hit_duration, capacity - offset)
if cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure), sequence
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
lead_rest = min(QUARTER_TICKS // 2, capacity)
if lead_rest:
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0))
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0, include_optional=include_optional))
notes.extend(rests)
if lead_rest < capacity:
hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
visible_chord = chord_symbol_at_offset(measure, lead_rest)
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol)
visible_chord = chord_symbol_at_offset(measure, lead_rest, include_optional=include_optional)
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol, include_optional=include_optional)
sequence += 1
if source_note_attack_at(measure, lead_rest):
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord))
@@ -98,7 +101,7 @@ def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int) -> tupl
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
cursor = lead_rest + hit
if cursor < capacity:
markers = [marker for marker in chord_markers(measure) if marker[0] >= cursor]
markers = [marker for marker in chord_markers(measure, include_optional=include_optional) if marker[0] >= cursor]
if markers:
segment_cursor = cursor
for index, (start, _, chord) in enumerate(markers):
@@ -115,7 +118,7 @@ def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int) -> tupl
else:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure), sequence
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
@@ -130,9 +133,9 @@ def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[d
return notes, sequence
def rest_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
def rest_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
markers = chord_markers(measure)
markers = chord_markers(measure, include_optional=include_optional)
if not markers:
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence)
return notes, sequence
@@ -142,30 +145,31 @@ def rest_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[di
for index, (start, _, chord) in enumerate(markers):
if start > cursor:
if not renderable_duration(start - cursor):
return source_rest_skeleton_notes(measure, sequence)
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
if not renderable_duration(next_start - start):
return source_rest_skeleton_notes(measure, sequence)
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
cursor = next_start
if cursor < capacity:
if not renderable_duration(capacity - cursor):
return source_rest_skeleton_notes(measure, sequence)
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return notes, sequence
def source_rest_skeleton_notes(measure: dict, sequence: int) -> tuple[list[dict], int]:
def source_rest_skeleton_notes(measure: dict, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
notes: list[dict] = []
for source_note in first_voice(measure).get("notes", []):
if not isinstance(source_note, dict):
continue
sequence += 1
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), str(source_note.get("chord_symbol", "")))
chord = "" if not include_optional and source_note.get("chord_optional") is True else str(source_note.get("chord_symbol", ""))
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), chord)
if "display_ticks" in source_note:
note["display_ticks"] = source_note["display_ticks"]
if "tuplet_id" in source_note:
@@ -177,10 +181,10 @@ def source_rest_skeleton_notes(measure: dict, sequence: int) -> tuple[list[dict]
return notes, sequence
def melody_voice(measure: dict, *, strip_lyrics: bool) -> dict:
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics)
def melody_voice(measure: dict, *, strip_lyrics: bool, include_optional: bool = True) -> dict:
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics, include_optional=include_optional)
voice["id"] = "voice-arranged-source"
voice["notes"] = [sanitized_note(note, strip_lyrics=strip_lyrics) for note in first_voice(measure).get("notes", []) if isinstance(note, dict)]
voice["notes"] = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in first_voice(measure).get("notes", []) if isinstance(note, dict)]
return voice
@@ -197,35 +201,38 @@ def apply_vocal_pickup_overlap(output: list[dict], source_measures: list[dict],
output[-1]["voices"][0]["notes"] = voice["notes"]
def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: str, sequence: int) -> tuple[dict, int]:
def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[dict, int]:
if section.role == "vocal":
return melody_voice(measure, strip_lyrics=False), sequence
return melody_voice(measure, strip_lyrics=False, include_optional=include_optional), sequence
if section.role == "melody":
return melody_voice(measure, strip_lyrics=True), sequence
return melody_voice(measure, strip_lyrics=True, include_optional=include_optional), sequence
if section.role == "rhythm":
notes, sequence = rhythm_notes(measure, chord_symbol, sequence)
notes, sequence = rhythm_notes(measure, chord_symbol, sequence, include_optional=include_optional)
elif section.role == "accompaniment":
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence)
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence, include_optional=include_optional)
elif section.role == "slash":
notes, sequence = slash_notes(measure, chord_symbol, sequence)
else:
notes, sequence = rest_notes(measure, chord_symbol, sequence)
notes, sequence = rest_notes(measure, chord_symbol, sequence, include_optional=include_optional)
return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]:
sections = full_arrangement_sections(source_measures, part_type)
chords = propagated_chords(source_measures)
all_chords = propagated_chords(source_measures)
required_chords = propagated_chords(source_measures, include_optional=False)
output: list[dict] = []
sequence = 0
for section in sections:
include_optional = section.section_index != FINAL_SECTION_INDEX
chords = all_chords if include_optional else required_chords
if part_type == "vocal" and section.role == "vocal":
apply_vocal_pickup_overlap(output, source_measures, section)
for offset in range(section.measure_count):
source_index = section.source_measure_index + offset
source_measure = source_measures[source_index]
chord_symbol = chords[source_index] if source_index < len(chords) else ""
voice, sequence = voice_for_section(section, source_measure, chord_symbol, sequence)
voice, sequence = voice_for_section(section, source_measure, chord_symbol, sequence, include_optional=include_optional)
output_measure = make_measure(
f"measure-{len(output) + 1}",
source_measure,
+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 [],