@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user