diff --git a/README.md b/README.md index 7115840..dd9419f 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 4a9ee40..a2e5895 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -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. diff --git a/data/expected/performance_summary.json b/data/expected/performance_summary.json index d75ff00..c6e7b13 100644 --- a/data/expected/performance_summary.json +++ b/data/expected/performance_summary.json @@ -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": [ { diff --git a/data/songs/it-could-happen-to-you.json b/data/songs/it-could-happen-to-you.json index 8f9bd76..19001a6 100644 --- a/data/songs/it-could-happen-to-you.json +++ b/data/songs/it-could-happen-to-you.json @@ -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" ] } diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 416f5f5..0cc0a6f 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -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, diff --git a/generator/score.py b/generator/score.py index 4382715..5f93973 100644 --- a/generator/score.py +++ b/generator/score.py @@ -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 [], diff --git a/tests/test_score_generator.py b/tests/test_score_generator.py index 707b084..9b852cc 100644 --- a/tests/test_score_generator.py +++ b/tests/test_score_generator.py @@ -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",