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:
+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.
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.
+3 -12
View File
@@ -7703,10 +7703,7 @@
{
"measure": 176,
"section_label": "",
"chord_symbols": [
"F-7",
"Bb7"
],
"chord_symbols": [],
"slash_count": 0
}
]
@@ -9118,10 +9115,7 @@
{
"measure": 176,
"section_label": "",
"chord_symbols": [
"F-7",
"Bb7"
],
"chord_symbols": [],
"slash_count": 0
}
]
@@ -12934,10 +12928,7 @@
{
"measure": 176,
"section_label": "",
"chord_symbols": [
"F-7",
"Bb7"
],
"chord_symbols": [],
"slash_count": 0,
"vocal_skeleton": [
{
+1 -1
View File
@@ -40,6 +40,6 @@
"[F-7] Bb3:2 \"and\" Bb3:2 \"it\"",
"[Bb7] Ab4:4 \"hap-\" F4:2 \"pened\" G4:4 \"to\"",
"[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")
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 [],
+54
View File
@@ -2,10 +2,21 @@ from __future__ import annotations
import unittest
from generator.arrange.generate import arrange_document
from generator.score import normalize_score_document
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:
document = normalize_score_document({
"title": "Compact Tune",
@@ -33,6 +44,49 @@ class CompactScoreJsonTests(unittest.TestCase):
self.assertTrue(first_note["tie_to_next"])
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:
document = normalize_score_document({
"title": "Practice",