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
+1 -1
View File
@@ -95,7 +95,7 @@ Authored score files use compact JSON. Metadata stays as normal JSON fields, whi
} }
``` ```
Notation tokens are `PITCH:DURATION`, `r:DURATION`, and `(PITCH PITCH ...):DURATION` for written chord tones. Durations use `1`, `2`, `4`, `8`, `16`, `32`, plus `.` for dotted values. `[Chord]` applies to the next token, quoted text applies as a lyric to the previous note, `~` starts or ends ties, and `{N:M ...}` writes tuplets such as five quarter notes in the space of two quarter notes. Notation tokens are `PITCH:DURATION`, `r:DURATION`, and `(PITCH PITCH ...):DURATION` for written chord tones. Durations use `1`, `2`, `4`, `8`, `16`, `32`, plus `.` for dotted values. `[Chord]` applies to the next token, `[(Chord)]` marks an optional turnaround chord that is used between generated performance iterations but omitted from the final ending, quoted text applies as a lyric to the previous note, `~` starts or ends ties, and `{N:M ...}` writes tuplets such as five quarter notes in the space of two quarter notes.
Bundled third-party font assets live in `package/assets/fonts/petaluma`. Petaluma is from Steinberg and is distributed under the SIL Open Font License; the package keeps `OFL.txt` next to the font files. If the Pico bitmap font needs to be refreshed after changing the bundled UI font, run: Bundled third-party font assets live in `package/assets/fonts/petaluma`. Petaluma is from Steinberg and is distributed under the SIL Open Font License; the package keeps `OFL.txt` next to the font files. If the Pico bitmap font needs to be refreshed after changing the bundled UI font, run:
+1 -1
View File
@@ -37,7 +37,7 @@ Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchor
The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody practice sources are emitted in all 12 keys at build time. Chord practice sources are emitted once in their authored key. Generated JSON is sanitized so packaged scores do not contain tab positions or fingering fields; guitar playability logic is used only inside the arrangement generator. The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody practice sources are emitted in all 12 keys at build time. Chord practice sources are emitted once in their authored key. Generated JSON is sanitized so packaged scores do not contain tab positions or fingering fields; guitar playability logic is used only inside the arrangement generator.
Authored source files use compact JSON and are normalized by the generator before arrangement. A root-level `measures` array means compact source; legacy staff/voice JSON with `staves` is still accepted. Measure notation supports `PITCH:DURATION`, `r:DURATION`, parenthesized chord tones like `(G4 B4 F5):4`, `[Chord]` markers, quoted lyrics, `~` tie starts/stops, and tuplets as `{N:M ...}`. Tuplet normalization keeps the written duration in `display_ticks` and distributes rounded timeline ticks across the tuplet group so the measure total remains stable on the existing 16,384 ticks-per-whole-note grid. Authored source files use compact JSON and are normalized by the generator before arrangement. A root-level `measures` array means compact source; legacy staff/voice JSON with `staves` is still accepted. Measure notation supports `PITCH:DURATION`, `r:DURATION`, parenthesized chord tones like `(G4 B4 F5):4`, `[Chord]` markers, optional turnaround markers as `[(Chord)]`, quoted lyrics, `~` tie starts/stops, and tuplets as `{N:M ...}`. Tuplet normalization keeps the written duration in `display_ticks` and distributes rounded timeline ticks across the tuplet group so the measure total remains stable on the existing 16,384 ticks-per-whole-note grid.
Generator checks compare the generated performance summary against `data/expected/performance_summary.json`. The summary stores compatibility invariants such as section layout, chord-label placement, slash-section placement, and vocal timing without committing the generated score files themselves. Refresh that file only when an intentional arrangement-logic change should become the new baseline. Generator checks compare the generated performance summary against `data/expected/performance_summary.json`. The summary stores compatibility invariants such as section layout, chord-label placement, slash-section placement, and vocal timing without committing the generated score files themselves. Refresh that file only when an intentional arrangement-logic change should become the new baseline.
+3 -12
View File
@@ -7703,10 +7703,7 @@
{ {
"measure": 176, "measure": 176,
"section_label": "", "section_label": "",
"chord_symbols": [ "chord_symbols": [],
"F-7",
"Bb7"
],
"slash_count": 0 "slash_count": 0
} }
] ]
@@ -9118,10 +9115,7 @@
{ {
"measure": 176, "measure": 176,
"section_label": "", "section_label": "",
"chord_symbols": [ "chord_symbols": [],
"F-7",
"Bb7"
],
"slash_count": 0 "slash_count": 0
} }
] ]
@@ -12934,10 +12928,7 @@
{ {
"measure": 176, "measure": 176,
"section_label": "", "section_label": "",
"chord_symbols": [ "chord_symbols": [],
"F-7",
"Bb7"
],
"slash_count": 0, "slash_count": 0,
"vocal_skeleton": [ "vocal_skeleton": [
{ {
+1 -1
View File
@@ -40,6 +40,6 @@
"[F-7] Bb3:2 \"and\" Bb3:2 \"it\"", "[F-7] Bb3:2 \"and\" Bb3:2 \"it\"",
"[Bb7] Ab4:4 \"hap-\" F4:2 \"pened\" G4:4 \"to\"", "[Bb7] Ab4:4 \"hap-\" F4:2 \"pened\" G4:4 \"to\"",
"[Ebmaj7] Eb4:1 \"me.\"", "[Ebmaj7] Eb4:1 \"me.\"",
"[F-7] r:4 r:4 [Bb7] r:4 r:4" "[(F-7)] r:4 r:4 [(Bb7)] r:4 r:4"
] ]
} }
+37 -30
View File
@@ -32,11 +32,14 @@ from generator.score import (
PART_TYPES = ("acoustic", "electric", "vocal") 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] = [] result: list[str] = []
current = "" current = ""
for measure in measures: for measure in measures:
current = first_chord_symbol(measure, current) current = first_chord_symbol(measure, current, include_optional=include_optional)
result.append(current) result.append(current)
return result return result
@@ -56,7 +59,7 @@ def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol:
return note 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"))) 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_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 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) rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
notes.extend(rests) notes.extend(rests)
cursor = offset cursor = offset
active_chord = active_chord_at_offset(measure, offset, chord_symbol) active_chord = active_chord_at_offset(measure, offset, chord_symbol, include_optional=include_optional)
visible_chord = chord_symbol_at_offset(measure, offset) visible_chord = chord_symbol_at_offset(measure, offset, include_optional=include_optional)
sequence += 1 sequence += 1
notes.append(playable_chord_note(f"arr-note-{sequence}", min(hit_duration, capacity - offset), active_chord, visible_chord)) 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) cursor = offset + min(hit_duration, capacity - offset)
if cursor < capacity: if cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence) rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests) 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"))) capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = [] notes: list[dict] = []
lead_rest = min(QUARTER_TICKS // 2, capacity) lead_rest = min(QUARTER_TICKS // 2, capacity)
if lead_rest: 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) notes.extend(rests)
if lead_rest < capacity: if lead_rest < capacity:
hit = min(QUARTER_TICKS // 2, capacity - lead_rest) hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
visible_chord = chord_symbol_at_offset(measure, lead_rest) visible_chord = chord_symbol_at_offset(measure, lead_rest, include_optional=include_optional)
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol) active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol, include_optional=include_optional)
sequence += 1 sequence += 1
if source_note_attack_at(measure, lead_rest): if source_note_attack_at(measure, lead_rest):
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord)) 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)) notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
cursor = lead_rest + hit cursor = lead_rest + hit
if cursor < capacity: 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: if markers:
segment_cursor = cursor segment_cursor = cursor
for index, (start, _, chord) in enumerate(markers): for index, (start, _, chord) in enumerate(markers):
@@ -115,7 +118,7 @@ def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int) -> tupl
else: else:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence) rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests) 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]: 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 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"))) 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: if not markers:
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence) notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence)
return notes, 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): for index, (start, _, chord) in enumerate(markers):
if start > cursor: if start > cursor:
if not renderable_duration(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) rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence)
notes.extend(rests) notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
if not renderable_duration(next_start - start): 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) rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests) notes.extend(rests)
cursor = next_start cursor = next_start
if cursor < capacity: if cursor < capacity:
if not renderable_duration(capacity - cursor): 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) rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests) notes.extend(rests)
return notes, sequence 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] = [] notes: list[dict] = []
for source_note in first_voice(measure).get("notes", []): for source_note in first_voice(measure).get("notes", []):
if not isinstance(source_note, dict): if not isinstance(source_note, dict):
continue continue
sequence += 1 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: if "display_ticks" in source_note:
note["display_ticks"] = source_note["display_ticks"] note["display_ticks"] = source_note["display_ticks"]
if "tuplet_id" in source_note: 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 return notes, sequence
def melody_voice(measure: dict, *, strip_lyrics: bool) -> dict: def melody_voice(measure: dict, *, strip_lyrics: bool, include_optional: bool = True) -> dict:
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics) voice = copy_source_voice(measure, strip_lyrics=strip_lyrics, include_optional=include_optional)
voice["id"] = "voice-arranged-source" 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 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"] 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": 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": 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": 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": 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": elif section.role == "slash":
notes, sequence = slash_notes(measure, chord_symbol, sequence) notes, sequence = slash_notes(measure, chord_symbol, sequence)
else: 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 return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]: def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]:
sections = full_arrangement_sections(source_measures, part_type) 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] = [] output: list[dict] = []
sequence = 0 sequence = 0
for section in sections: 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": if part_type == "vocal" and section.role == "vocal":
apply_vocal_pickup_overlap(output, source_measures, section) apply_vocal_pickup_overlap(output, source_measures, section)
for offset in range(section.measure_count): for offset in range(section.measure_count):
source_index = section.source_measure_index + offset source_index = section.source_measure_index + offset
source_measure = source_measures[source_index] source_measure = source_measures[source_index]
chord_symbol = chords[source_index] if source_index < len(chords) else "" 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( output_measure = make_measure(
f"measure-{len(output) + 1}", f"measure-{len(output) + 1}",
source_measure, source_measure,
+43 -17
View File
@@ -35,6 +35,7 @@ FORBIDDEN_OUTPUT_FIELDS = {
"fingering", "fingering",
"left_hand_finger", "left_hand_finger",
"right_hand_finger", "right_hand_finger",
"chord_optional",
} }
@@ -116,6 +117,7 @@ class CompactMeasureParser:
self.sequence = 0 self.sequence = 0
self.tuplet_sequence = 0 self.tuplet_sequence = 0
self.pending_chord = "" self.pending_chord = ""
self.pending_chord_optional = False
self.last_note: dict[str, Any] | None = None self.last_note: dict[str, Any] | None = None
self.last_octave = 4 self.last_octave = 4
self.tuplets: list[dict[str, Any]] = [] self.tuplets: list[dict[str, Any]] = []
@@ -155,9 +157,14 @@ class CompactMeasureParser:
def parse_chord(self) -> None: def parse_chord(self) -> None:
self.index += 1 self.index += 1
self.pending_chord = self.parse_until("]", "chord symbol").strip() chord = self.parse_until("]", "chord symbol").strip()
if not self.pending_chord: 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") raise self.error("chord symbol must not be empty")
self.pending_chord = chord
self.pending_chord_optional = optional
def parse_lyric(self) -> None: def parse_lyric(self) -> None:
if self.last_note is None: if self.last_note is None:
@@ -288,7 +295,10 @@ class CompactMeasureParser:
} }
if self.pending_chord: if self.pending_chord:
note["chord_symbol"] = self.pending_chord note["chord_symbol"] = self.pending_chord
if self.pending_chord_optional:
note["chord_optional"] = True
self.pending_chord = "" self.pending_chord = ""
self.pending_chord_optional = False
if tie_from_previous: if tie_from_previous:
note["tie_from_previous"] = True note["tie_from_previous"] = True
if self.peek() == "~": if self.peek() == "~":
@@ -485,16 +495,30 @@ def first_voice(measure: dict[str, Any]) -> dict[str, Any]:
return {"id": "voice-empty", "tuplets": [], "notes": []} 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) voice = first_voice(measure)
notes = voice.get("notes") notes = voice.get("notes")
if not isinstance(notes, list): if not isinstance(notes, list):
return fallback return fallback
has_filtered_chord = False
for note in notes: for note in notes:
if isinstance(note, dict): if isinstance(note, dict):
chord = note.get("chord_symbol") if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol"):
if isinstance(chord, str) and chord: has_filtered_chord = True
chord = note_chord_symbol(note, include_optional=include_optional)
if chord:
return chord return chord
if not include_optional and has_filtered_chord:
return ""
return fallback return fallback
@@ -513,25 +537,25 @@ def timed_measure_notes(measure: dict[str, Any]) -> list[tuple[int, dict[str, An
return result 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]] = [] markers: list[tuple[int, int, str]] = []
for start_ticks, note in timed_measure_notes(measure): for start_ticks, note in timed_measure_notes(measure):
chord = note.get("chord_symbol") chord = note_chord_symbol(note, include_optional=include_optional)
if isinstance(chord, str) and chord: if chord:
markers.append((start_ticks, note_duration(note), chord)) markers.append((start_ticks, note_duration(note), chord))
return markers return markers
def chord_symbol_at_offset(measure: dict[str, Any], offset_ticks: int) -> str: 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): for start_ticks, _, chord in chord_markers(measure, include_optional=include_optional):
if start_ticks == offset_ticks: if start_ticks == offset_ticks:
return chord return chord
return "" 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 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: if start_ticks > offset_ticks:
break break
current = chord current = chord
@@ -592,8 +616,8 @@ def renderable_rest_notes(note_id_prefix: str, duration_ticks: int, sequence: in
return notes, sequence return notes, sequence
def apply_source_chord_markers(notes: list[dict[str, Any]], measure: dict[str, Any]) -> list[dict[str, Any]]: 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)] markers = [(start, chord) for start, _, chord in chord_markers(measure, include_optional=include_optional)]
if not markers: if not markers:
result = [copy.deepcopy(note) for note in notes] result = [copy.deepcopy(note) for note in notes]
for note in result: for note in result:
@@ -661,18 +685,20 @@ def sanitize_json(value: Any) -> Any:
return copy.deepcopy(value) 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) output = sanitize_json(note)
if strip_lyrics: if strip_lyrics:
output.pop("lyric", None) output.pop("lyric", None)
if not include_optional and note.get("chord_optional") is True:
output.pop("chord_symbol", None)
output.setdefault("hidden", False) output.setdefault("hidden", False)
return output 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) voice = first_voice(measure)
tuplets = sanitize_json(voice.get("tuplets", [])) 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 { return {
"id": str(voice.get("id", "voice-source")), "id": str(voice.get("id", "voice-source")),
"tuplets": tuplets if isinstance(tuplets, list) else [], "tuplets": tuplets if isinstance(tuplets, list) else [],
+54
View File
@@ -2,10 +2,21 @@ from __future__ import annotations
import unittest import unittest
from generator.arrange.generate import arrange_document
from generator.score import normalize_score_document from generator.score import normalize_score_document
class CompactScoreJsonTests(unittest.TestCase): class CompactScoreJsonTests(unittest.TestCase):
def chord_symbols(self, measure: dict) -> list[str]:
return [
note["chord_symbol"]
for note in measure["voices"][0]["notes"]
if isinstance(note.get("chord_symbol"), str) and note["chord_symbol"]
]
def measure_notes(self, measure: dict) -> list[dict]:
return measure["voices"][0]["notes"]
def test_compact_json_normalizes_notation_tokens(self) -> None: def test_compact_json_normalizes_notation_tokens(self) -> None:
document = normalize_score_document({ document = normalize_score_document({
"title": "Compact Tune", "title": "Compact Tune",
@@ -33,6 +44,49 @@ class CompactScoreJsonTests(unittest.TestCase):
self.assertTrue(first_note["tie_to_next"]) self.assertTrue(first_note["tie_to_next"])
self.assertTrue(second_measure_first_note["tie_from_previous"]) self.assertTrue(second_measure_first_note["tie_from_previous"])
def test_compact_json_marks_optional_chords(self) -> None:
document = normalize_score_document({
"title": "Optional Turnaround",
"key": "Eb",
"time": "4/4",
"measures": [
"[(F-7)] r:2 [(Bb7)] r:2",
],
})
notes = document["staves"][0]["measures"][0]["voices"][0]["notes"]
self.assertEqual(notes[0]["chord_symbol"], "F-7")
self.assertTrue(notes[0]["chord_optional"])
self.assertEqual(notes[1]["chord_symbol"], "Bb7")
self.assertTrue(notes[1]["chord_optional"])
def test_arrangements_omit_optional_turnaround_from_final_ending(self) -> None:
document = normalize_score_document({
"title": "Optional Turnaround",
"key": "Eb",
"time": "4/4",
"measures": [
"[Ebmaj7] C4:1",
"[Ab7] D4:1",
"[Ebmaj7] E4:1",
"[(F-7)] r:2 [(Bb7)] r:2",
],
})
for part_type in ("acoustic", "electric", "vocal"):
arranged = arrange_document(document, part_type)
measures = arranged["staves"][0]["measures"]
non_final_turnaround = measures[7]
final_turnaround = measures[-1]
self.assertEqual(self.chord_symbols(non_final_turnaround), ["F-7", "Bb7"])
self.assertEqual(self.chord_symbols(final_turnaround), [])
self.assertFalse(any("chord_optional" in note for measure in measures for note in self.measure_notes(measure)))
if part_type in {"acoustic", "electric"}:
self.assertTrue(all(note["kind"] == "rest" for note in self.measure_notes(final_turnaround)))
def test_compact_json_normalizes_pitch_groups_and_tuplets(self) -> None: def test_compact_json_normalizes_pitch_groups_and_tuplets(self) -> None:
document = normalize_score_document({ document = normalize_score_document({
"title": "Practice", "title": "Practice",