@@ -76,6 +76,27 @@ python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --
|
||||
|
||||
The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
|
||||
|
||||
Authored score files use compact JSON. Metadata stays as normal JSON fields, while each measure is a readable notation string:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Autumn Leaves",
|
||||
"key": "F",
|
||||
"time": "4/4",
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"measures": [
|
||||
"[G-7] Bb4:1~ \"leaves\"",
|
||||
"[C7] ~Bb4:4 C4:4 \"drift\" D4:4 \"by\" E4:4 \"my\"",
|
||||
"{5:2 C4:4 D4:4 E4:4 F4:4 G4:4}",
|
||||
"[G7] (G4 B4 F5):4 r:4"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -37,6 +37,8 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
## Pico Runtime
|
||||
|
||||
@@ -1,448 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"title": "01 Cycles",
|
||||
"composer": "",
|
||||
"key_signature": "C",
|
||||
"time_signature": "4/4",
|
||||
"style": "Med. Swing"
|
||||
},
|
||||
"staves": [
|
||||
{
|
||||
"id": "staff-vocal",
|
||||
"name": "Vocal",
|
||||
"clef": "treble",
|
||||
"role": "vocal",
|
||||
"measures": [
|
||||
{
|
||||
"id": "measure-1",
|
||||
"time_signature": "4/4",
|
||||
"key_signature": "C",
|
||||
"voices": [
|
||||
{
|
||||
"id": "voice-2vg7k4p1",
|
||||
"notes": [
|
||||
{
|
||||
"id": "note-ekasof25",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "g",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "b",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "f",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"chord_symbol": "G7",
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-1fafh8eh",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "g",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "b",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "f",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-46dyougy",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "c",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "b",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "e",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"chord_symbol": "C7",
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-qsxuq33n",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "c",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "b",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "e",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"hidden": false
|
||||
}
|
||||
],
|
||||
"tuplets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "measure-tn8h5n0h",
|
||||
"time_signature": "4/4",
|
||||
"key_signature": "C",
|
||||
"voices": [
|
||||
{
|
||||
"id": "voice-8hwk9b7q",
|
||||
"notes": [
|
||||
{
|
||||
"id": "note-64c6gqr5",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "f",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "a",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "e",
|
||||
"accidental": "b",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"chord_symbol": "F7",
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-hbgzjjm4",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "f",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "a",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "e",
|
||||
"accidental": "b",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-bpc3jl26",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "b",
|
||||
"accidental": "b",
|
||||
"octave": 3
|
||||
},
|
||||
{
|
||||
"letter": "a",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "d",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"chord_symbol": "Bb7",
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-tph2glun",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "b",
|
||||
"accidental": "b",
|
||||
"octave": 3
|
||||
},
|
||||
{
|
||||
"letter": "a",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "d",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"hidden": false
|
||||
}
|
||||
],
|
||||
"tuplets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "measure-toxu9etb",
|
||||
"time_signature": "4/4",
|
||||
"key_signature": "C",
|
||||
"voices": [
|
||||
{
|
||||
"id": "voice-rz7qmbk5",
|
||||
"notes": [
|
||||
{
|
||||
"id": "note-rfziub9v",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "e",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "g",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "d",
|
||||
"accidental": "b",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"chord_symbol": "Eb7",
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-q6he4t8j",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "e",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "g",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "d",
|
||||
"accidental": "b",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-bwh485jp",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "a",
|
||||
"accidental": "b",
|
||||
"octave": 3
|
||||
},
|
||||
{
|
||||
"letter": "g",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "c",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"chord_symbol": "Ab7",
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-rjm4vz5k",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "a",
|
||||
"accidental": "b",
|
||||
"octave": 3
|
||||
},
|
||||
{
|
||||
"letter": "g",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "c",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"hidden": false
|
||||
}
|
||||
],
|
||||
"tuplets": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "measure-vj7x9zvk",
|
||||
"time_signature": "4/4",
|
||||
"key_signature": "C",
|
||||
"voices": [
|
||||
{
|
||||
"id": "voice-9sgs888d",
|
||||
"notes": [
|
||||
{
|
||||
"id": "note-v1y57u53",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "d",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "f",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "c",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"chord_symbol": "Dbmaj7",
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-qvbgpked",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "d",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "f",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "c",
|
||||
"accidental": "n",
|
||||
"octave": 5
|
||||
}
|
||||
],
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-gy3ukk29",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "d",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "f",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "b",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
}
|
||||
],
|
||||
"chord_symbol": "Db6",
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"id": "note-0vcz65hi",
|
||||
"kind": "note",
|
||||
"duration_ticks": 4096,
|
||||
"pitches": [
|
||||
{
|
||||
"letter": "d",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "f",
|
||||
"accidental": "n",
|
||||
"octave": 4
|
||||
},
|
||||
{
|
||||
"letter": "b",
|
||||
"accidental": "b",
|
||||
"octave": 4
|
||||
}
|
||||
],
|
||||
"hidden": false
|
||||
}
|
||||
],
|
||||
"tuplets": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"title": "01 Cycles",
|
||||
"composer": "",
|
||||
"key": "C",
|
||||
"time": "4/4",
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"style": "Med. Swing",
|
||||
"measures": [
|
||||
"[G7] (G4 B4 F5):4 (G4 B4 F5):4 [C7] (C4 Bb4 E5):4 (C4 Bb4 E5):4",
|
||||
"[F7] (F4 A4 Eb5):4 (F4 A4 Eb5):4 [Bb7] (Bb3 Ab4 D5):4 (Bb3 Ab4 D5):4",
|
||||
"[Eb7] (Eb4 G4 Db5):4 (Eb4 G4 Db5):4 [Ab7] (Ab3 Gb4 C5):4 (Ab3 Gb4 C5):4",
|
||||
"[Dbmaj7] (Db4 F4 C5):4 (Db4 F4 C5):4 [Db6] (Db4 F4 Bb4):4 (Db4 F4 Bb4):4"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+43
-1864
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+363
-1
@@ -43,7 +43,7 @@ def load_json(path: Path) -> dict[str, Any]:
|
||||
document = json.load(stream)
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError(f"{path} is not a score document")
|
||||
return document
|
||||
return normalize_score_document(document, path)
|
||||
|
||||
|
||||
def write_json(path: Path, document: dict[str, Any]) -> None:
|
||||
@@ -65,6 +65,368 @@ def human_label(value: str) -> str:
|
||||
return " ".join(part.capitalize() for part in text.split()) or "General"
|
||||
|
||||
|
||||
class CompactScoreError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
DURATION_TICKS = {
|
||||
"1": WHOLE_TICKS,
|
||||
"2": HALF_TICKS,
|
||||
"4": QUARTER_TICKS,
|
||||
"8": EIGHTH_TICKS,
|
||||
"16": SIXTEENTH_TICKS,
|
||||
"32": THIRTY_SECOND_TICKS,
|
||||
}
|
||||
|
||||
|
||||
def score_field(document: dict[str, Any], metadata: dict[str, Any], *names: str, fallback: str = "") -> str:
|
||||
for name in names:
|
||||
value = document.get(name)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
value = metadata.get(name)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
def parse_duration_token(text: str, label: str) -> int:
|
||||
dotted = text.endswith(".")
|
||||
base_text = text[:-1] if dotted else text
|
||||
if base_text not in DURATION_TICKS:
|
||||
raise CompactScoreError(f"{label}: unsupported duration {text!r}")
|
||||
ticks = DURATION_TICKS[base_text]
|
||||
return ticks + ticks // 2 if dotted else ticks
|
||||
|
||||
|
||||
def compact_pitch(letter: str, accidental: str, octave: int) -> dict[str, Any]:
|
||||
return {
|
||||
"letter": letter.lower(),
|
||||
"accidental": accidental or "n",
|
||||
"octave": octave,
|
||||
}
|
||||
|
||||
|
||||
class CompactMeasureParser:
|
||||
def __init__(self, text: str, *, measure_number: int, time_signature: str) -> None:
|
||||
self.text = text
|
||||
self.index = 0
|
||||
self.measure_number = measure_number
|
||||
self.time_signature = time_signature
|
||||
self.sequence = 0
|
||||
self.tuplet_sequence = 0
|
||||
self.pending_chord = ""
|
||||
self.last_note: dict[str, Any] | None = None
|
||||
self.last_octave = 4
|
||||
self.tuplets: list[dict[str, Any]] = []
|
||||
|
||||
def label(self) -> str:
|
||||
return f"measure {self.measure_number}"
|
||||
|
||||
def error(self, message: str) -> CompactScoreError:
|
||||
return CompactScoreError(f"{self.label()}, column {self.index + 1}: {message}")
|
||||
|
||||
def peek(self) -> str:
|
||||
return self.text[self.index] if self.index < len(self.text) else ""
|
||||
|
||||
def skip_space(self) -> None:
|
||||
while self.peek() and self.peek().isspace():
|
||||
self.index += 1
|
||||
|
||||
def parse_until(self, close: str, label: str) -> str:
|
||||
start = self.index
|
||||
escaped = False
|
||||
while self.index < len(self.text):
|
||||
char = self.text[self.index]
|
||||
if escaped:
|
||||
escaped = False
|
||||
self.index += 1
|
||||
continue
|
||||
if char == "\\":
|
||||
escaped = True
|
||||
self.index += 1
|
||||
continue
|
||||
if char == close:
|
||||
value = self.text[start:self.index]
|
||||
self.index += 1
|
||||
return bytes(value, "utf-8").decode("unicode_escape")
|
||||
self.index += 1
|
||||
raise self.error(f"unterminated {label}")
|
||||
|
||||
def parse_chord(self) -> None:
|
||||
self.index += 1
|
||||
self.pending_chord = self.parse_until("]", "chord symbol").strip()
|
||||
if not self.pending_chord:
|
||||
raise self.error("chord symbol must not be empty")
|
||||
|
||||
def parse_lyric(self) -> None:
|
||||
if self.last_note is None:
|
||||
raise self.error("lyric must follow a note")
|
||||
self.index += 1
|
||||
lyric = self.parse_until('"', "lyric")
|
||||
self.last_note["lyric"] = lyric
|
||||
|
||||
def parse_tuplet_header(self) -> tuple[int, int]:
|
||||
self.skip_space()
|
||||
match = re.match(r"(\d+)\s*:\s*(\d+)", self.text[self.index:])
|
||||
if match is None:
|
||||
raise self.error("tuplet must start with N:M")
|
||||
self.index += match.end()
|
||||
note_count = int(match.group(1))
|
||||
space_count = int(match.group(2))
|
||||
if note_count <= 0 or space_count <= 0:
|
||||
raise self.error("tuplet counts must be positive")
|
||||
return note_count, space_count
|
||||
|
||||
def parse_tuplet(self) -> list[dict[str, Any]]:
|
||||
self.index += 1
|
||||
note_count, space_count = self.parse_tuplet_header()
|
||||
self.tuplet_sequence += 1
|
||||
tuplet_id = f"tuplet-{self.measure_number}-{self.tuplet_sequence}"
|
||||
notes: list[dict[str, Any]] = []
|
||||
while True:
|
||||
self.skip_space()
|
||||
if not self.peek():
|
||||
raise self.error("unterminated tuplet")
|
||||
if self.peek() == "}":
|
||||
self.index += 1
|
||||
break
|
||||
if self.peek() == "{":
|
||||
raise self.error("nested tuplets are not supported")
|
||||
notes.extend(self.parse_item())
|
||||
if len(notes) != note_count:
|
||||
raise self.error(f"tuplet {tuplet_id} contains {len(notes)} notes/rests, expected {note_count}")
|
||||
self.apply_tuplet_duration(notes, note_count, space_count)
|
||||
for note in notes:
|
||||
note["tuplet_id"] = tuplet_id
|
||||
self.tuplets.append({
|
||||
"id": tuplet_id,
|
||||
"note_count": note_count,
|
||||
"space_count": space_count,
|
||||
})
|
||||
return notes
|
||||
|
||||
def apply_tuplet_duration(self, notes: list[dict[str, Any]], note_count: int, space_count: int) -> None:
|
||||
numerators = [int(note["duration_ticks"]) * space_count for note in notes]
|
||||
floors = [value // note_count for value in numerators]
|
||||
target_total = sum(numerators) // note_count
|
||||
remainder = target_total - sum(floors)
|
||||
order = sorted(range(len(notes)), key=lambda index: numerators[index] % note_count, reverse=True)
|
||||
actual = floors[:]
|
||||
for index in order[:remainder]:
|
||||
actual[index] += 1
|
||||
for note, ticks in zip(notes, actual):
|
||||
display = int(note["duration_ticks"])
|
||||
note["duration_ticks"] = max(1, ticks)
|
||||
note["display_ticks"] = display
|
||||
|
||||
def parse_pitch_atom(self) -> dict[str, Any]:
|
||||
start = self.index
|
||||
char = self.peek()
|
||||
if char.lower() not in "abcdefg":
|
||||
raise self.error("expected pitch")
|
||||
self.index += 1
|
||||
accidental = ""
|
||||
if self.peek() in {"#", "b", "n"}:
|
||||
accidental = self.peek()
|
||||
self.index += 1
|
||||
octave_start = self.index
|
||||
while self.peek().isdigit():
|
||||
self.index += 1
|
||||
if self.index > octave_start:
|
||||
self.last_octave = int(self.text[octave_start:self.index])
|
||||
if self.index == start:
|
||||
raise self.error("expected pitch")
|
||||
return compact_pitch(char, accidental, self.last_octave)
|
||||
|
||||
def parse_pitch_group(self) -> list[dict[str, Any]]:
|
||||
self.index += 1
|
||||
pitches: list[dict[str, Any]] = []
|
||||
while True:
|
||||
self.skip_space()
|
||||
if not self.peek():
|
||||
raise self.error("unterminated pitch group")
|
||||
if self.peek() == ")":
|
||||
self.index += 1
|
||||
break
|
||||
pitches.append(self.parse_pitch_atom())
|
||||
if not pitches:
|
||||
raise self.error("pitch group must contain at least one pitch")
|
||||
return pitches
|
||||
|
||||
def parse_note(self, tie_from_previous: bool = False) -> dict[str, Any]:
|
||||
start = self.index
|
||||
char = self.peek()
|
||||
if not char:
|
||||
raise self.error("expected note or rest")
|
||||
kind = "rest" if char.lower() == "r" else "note"
|
||||
if kind == "rest":
|
||||
self.index += 1
|
||||
pitches = [copy.deepcopy(REST_PITCH)]
|
||||
elif char == "(":
|
||||
pitches = self.parse_pitch_group()
|
||||
else:
|
||||
pitches = [self.parse_pitch_atom()]
|
||||
if self.peek() != ":":
|
||||
raise self.error(f"expected ':' after {self.text[start:self.index]!r}")
|
||||
self.index += 1
|
||||
duration_start = self.index
|
||||
while self.peek().isdigit():
|
||||
self.index += 1
|
||||
if self.peek() == ".":
|
||||
self.index += 1
|
||||
duration_text = self.text[duration_start:self.index]
|
||||
if not duration_text:
|
||||
raise self.error("duration is required")
|
||||
self.sequence += 1
|
||||
note: dict[str, Any] = {
|
||||
"id": f"note-{self.measure_number}-{self.sequence}",
|
||||
"kind": kind,
|
||||
"duration_ticks": parse_duration_token(duration_text, self.label()),
|
||||
"pitches": pitches,
|
||||
"hidden": False,
|
||||
}
|
||||
if self.pending_chord:
|
||||
note["chord_symbol"] = self.pending_chord
|
||||
self.pending_chord = ""
|
||||
if tie_from_previous:
|
||||
note["tie_from_previous"] = True
|
||||
if self.peek() == "~":
|
||||
note["tie_to_next"] = True
|
||||
self.index += 1
|
||||
self.last_note = note
|
||||
return note
|
||||
|
||||
def parse_item(self) -> list[dict[str, Any]]:
|
||||
self.skip_space()
|
||||
char = self.peek()
|
||||
if not char:
|
||||
return []
|
||||
if char == "[":
|
||||
self.parse_chord()
|
||||
return []
|
||||
if char == '"':
|
||||
self.parse_lyric()
|
||||
return []
|
||||
if char == "{":
|
||||
return self.parse_tuplet()
|
||||
if char == "}":
|
||||
return []
|
||||
if char == "~":
|
||||
self.index += 1
|
||||
self.skip_space()
|
||||
next_char = self.peek()
|
||||
if next_char and (next_char.lower() in "abcdefgr" or next_char == "("):
|
||||
return [self.parse_note(tie_from_previous=True)]
|
||||
if self.last_note is None:
|
||||
raise self.error("tie marker must attach to a note")
|
||||
self.last_note["tie_to_next"] = True
|
||||
return []
|
||||
if char.lower() in "abcdefgr" or char == "(":
|
||||
return [self.parse_note()]
|
||||
raise self.error("expected note, rest, chord, lyric, tie, or tuplet")
|
||||
|
||||
def parse(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
notes: list[dict[str, Any]] = []
|
||||
while True:
|
||||
self.skip_space()
|
||||
if not self.peek():
|
||||
break
|
||||
notes.extend(self.parse_item())
|
||||
if self.pending_chord:
|
||||
raise self.error(f"chord {self.pending_chord!r} is not followed by a note or rest")
|
||||
total = sum(int(note.get("duration_ticks", 0)) for note in notes)
|
||||
capacity = measure_capacity_ticks(self.time_signature)
|
||||
if total > capacity:
|
||||
raise CompactScoreError(f"{self.label()}: duration total {total} exceeds {self.time_signature} capacity {capacity}")
|
||||
return notes, self.tuplets
|
||||
|
||||
|
||||
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str]]:
|
||||
if isinstance(value, str):
|
||||
return value, {}
|
||||
if isinstance(value, dict):
|
||||
text = value.get("notes", value.get("notation", ""))
|
||||
if not isinstance(text, str):
|
||||
raise CompactScoreError(f"measure {index + 1}: notes must be a string")
|
||||
overrides: dict[str, str] = {}
|
||||
for source, target in (
|
||||
("section", "section_label"),
|
||||
("section_label", "section_label"),
|
||||
("key", "key_signature"),
|
||||
("key_signature", "key_signature"),
|
||||
("time", "time_signature"),
|
||||
("time_signature", "time_signature"),
|
||||
):
|
||||
field = value.get(source)
|
||||
if isinstance(field, str):
|
||||
overrides[target] = field
|
||||
return text, overrides
|
||||
raise CompactScoreError(f"measure {index + 1}: compact measure must be a string or object")
|
||||
|
||||
|
||||
def normalize_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
|
||||
if isinstance(document.get("staves"), list):
|
||||
return document
|
||||
measures_value = document.get("measures")
|
||||
if not isinstance(measures_value, list):
|
||||
return document
|
||||
metadata_value = document.get("metadata", {})
|
||||
metadata = metadata_value if isinstance(metadata_value, dict) else {}
|
||||
title = score_field(document, metadata, "title", fallback=human_label(path.stem if path is not None else "score"))
|
||||
composer = score_field(document, metadata, "composer")
|
||||
key_signature = score_field(document, metadata, "key_signature", "key", fallback="C")
|
||||
time_signature = score_field(document, metadata, "time_signature", "time", fallback="4/4")
|
||||
clef = score_field(document, metadata, "clef", fallback="treble")
|
||||
part = score_field(document, metadata, "part", "name", fallback="Vocal")
|
||||
role = score_field(document, metadata, "role", fallback="vocal")
|
||||
|
||||
output_metadata = sanitize_json(metadata)
|
||||
if not isinstance(output_metadata, dict):
|
||||
output_metadata = {}
|
||||
output_metadata.update({
|
||||
"title": title,
|
||||
"composer": composer,
|
||||
"key_signature": key_signature,
|
||||
"time_signature": time_signature,
|
||||
})
|
||||
for key, value in document.items():
|
||||
if key in {"title", "composer", "key", "key_signature", "time", "time_signature", "clef", "part", "name", "role", "measures", "metadata"}:
|
||||
continue
|
||||
output_metadata[key] = sanitize_json(value)
|
||||
|
||||
measures: list[dict[str, Any]] = []
|
||||
for index, value in enumerate(measures_value):
|
||||
text, overrides = compact_measure_text(value, index)
|
||||
measure_time = overrides.get("time_signature", time_signature)
|
||||
measure_key = overrides.get("key_signature", key_signature)
|
||||
parser = CompactMeasureParser(text, measure_number=index + 1, time_signature=measure_time)
|
||||
notes, tuplets = parser.parse()
|
||||
measures.append({
|
||||
"id": f"measure-{index + 1}",
|
||||
"section_label": overrides.get("section_label", ""),
|
||||
"time_signature": measure_time,
|
||||
"key_signature": measure_key,
|
||||
"voices": [{
|
||||
"id": f"voice-{index + 1}",
|
||||
"notes": notes,
|
||||
"tuplets": tuplets,
|
||||
}],
|
||||
})
|
||||
|
||||
return {
|
||||
"version": int(document.get("version", 1)) if isinstance(document.get("version", 1), int) else 1,
|
||||
"metadata": output_metadata,
|
||||
"staves": [{
|
||||
"id": "staff-source",
|
||||
"name": part,
|
||||
"clef": clef,
|
||||
"role": role,
|
||||
"measures": measures,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def source_staff(document: dict[str, Any]) -> dict[str, Any]:
|
||||
staves = document.get("staves")
|
||||
if not isinstance(staves, list) or not staves:
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <expected>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
|
||||
#include <luma.hpp>
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <vamp_protocol/page_control.hpp>
|
||||
#include <vamp_zero/config.hpp>
|
||||
#include <vamp_zero/types.hpp>
|
||||
|
||||
@@ -25,17 +30,31 @@ struct ui_state {
|
||||
|
||||
class zero_ui {
|
||||
public:
|
||||
zero_ui(boost::asio::any_io_executor executor, luma::application_config config);
|
||||
using command_handler = std::function<void(vamp_protocol::page_command)>;
|
||||
|
||||
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, command_handler command_handler = {});
|
||||
auto run() -> std::expected<void, modulus::error>;
|
||||
auto stop() -> void;
|
||||
auto publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error>;
|
||||
auto snapshot() const -> vamp_zero::ui_state;
|
||||
private:
|
||||
auto handle_touch(luma::touch_event const& event) -> std::bool_t;
|
||||
auto send_touch_command(vamp_protocol::page_command command) -> void;
|
||||
auto arm_pending_tap() -> void;
|
||||
auto cancel_pending_tap_locked() -> void;
|
||||
auto make_root(vamp_zero::ui_state const& state) -> std::expected<std::unique_ptr<luma::control>, modulus::error>;
|
||||
boost::asio::any_io_executor executor_;
|
||||
boost::asio::steady_timer tap_timer_;
|
||||
luma::application application_;
|
||||
command_handler command_handler_{};
|
||||
mutable std::mutex mutex_{};
|
||||
vamp_zero::ui_state state_{};
|
||||
mutable std::mutex touch_mutex_{};
|
||||
std::optional<std::int32_t> active_touch_id_{};
|
||||
std::chrono::steady_clock::time_point touch_down_time_{};
|
||||
std::chrono::steady_clock::time_point last_tap_time_{};
|
||||
std::bool_t hold_sent_{};
|
||||
std::bool_t pending_tap_{};
|
||||
};
|
||||
|
||||
auto make_ui_state(vamp_zero::config const& config, vamp_zero::app_state app) -> vamp_zero::ui_state;
|
||||
|
||||
@@ -38,6 +38,10 @@ auto render(vamp_pico::display& display, link_status status, vamp_pico::remote_d
|
||||
display.show_remote(color_for(status), remote.title, remote.part, remote.page, remote.pages, remote.active);
|
||||
}
|
||||
|
||||
auto clear_remote(vamp_pico::remote_display_state& remote) -> void {
|
||||
remote = vamp_pico::remote_display_state{};
|
||||
}
|
||||
|
||||
auto connect_wifi(vamp_pico::display& display) -> std::bool_t {
|
||||
auto empty = vamp_pico::remote_display_state{};
|
||||
render(display, link_status::CONNECTING, empty);
|
||||
@@ -58,6 +62,7 @@ auto connect_wifi(vamp_pico::display& display) -> std::bool_t {
|
||||
auto send_command(vamp_protocol::page_command command, link_status& status, vamp_pico::remote_display_state& remote, vamp_pico::display& display) -> void {
|
||||
if (!vamp_pico::post_remote_command(command, remote)) {
|
||||
status = link_status::RETRYING;
|
||||
clear_remote(remote);
|
||||
render(display, status, remote);
|
||||
return;
|
||||
}
|
||||
@@ -136,6 +141,7 @@ auto main() -> int {
|
||||
status = link_status::CONNECTED;
|
||||
} else {
|
||||
status = link_status::RETRYING;
|
||||
clear_remote(remote);
|
||||
}
|
||||
render(display, status, remote);
|
||||
}
|
||||
|
||||
@@ -168,7 +168,9 @@ auto run_display(vamp_zero::config const& config, vamp_zero::control_state& stat
|
||||
if (!endpoint) {
|
||||
return std::unexpected(endpoint.error());
|
||||
}
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display};
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, [&state](vamp_protocol::page_command command) {
|
||||
static_cast<void>(state.apply(command));
|
||||
}};
|
||||
state.set_observer([&ui, &config](vamp_zero::app_state app) {
|
||||
auto status = ui.publish(vamp_zero::make_ui_state(config, std::move(app)));
|
||||
if (!status) {
|
||||
|
||||
+247
-28
@@ -86,10 +86,15 @@ constexpr auto tie_thickness_spaces = std::float32_t{0.13F};
|
||||
constexpr auto normal_stem_length_spaces = std::float32_t{3.50F};
|
||||
constexpr auto preamble_vertical_correction_spaces = std::float32_t{0.50F};
|
||||
constexpr auto stem_head_horizontal_overlap_spaces = std::float32_t{0.06F};
|
||||
constexpr auto stem_head_vertical_overlap_spaces = std::float32_t{0.20F};
|
||||
constexpr auto measure_right_guard_spaces = std::float32_t{1.50F};
|
||||
constexpr auto stem_head_vertical_overlap_spaces = std::float32_t{0.38F};
|
||||
constexpr auto measure_right_guard_spaces = std::float32_t{2.05F};
|
||||
constexpr auto chord_above_staff_spaces = std::float32_t{3.65F};
|
||||
constexpr auto chord_clearance_spaces = std::float32_t{0.45F};
|
||||
constexpr auto lyric_below_staff_spaces = std::float32_t{6.15F};
|
||||
constexpr auto lyric_clearance_spaces = std::float32_t{0.75F};
|
||||
constexpr auto tie_endpoint_gap_spaces = std::float32_t{0.28F};
|
||||
constexpr auto tie_note_offset_spaces = std::float32_t{0.50F};
|
||||
constexpr auto tie_arch_spaces = std::float32_t{0.72F};
|
||||
|
||||
auto to_raylib(luma::color color) -> Color {
|
||||
return {
|
||||
@@ -832,6 +837,26 @@ struct beam_group_layout {
|
||||
std::float32_t beam_y{};
|
||||
};
|
||||
|
||||
struct laid_out_tie_head {
|
||||
vamp_zero::score_pitch pitch{};
|
||||
luma::rect bounds{};
|
||||
std::float32_t center_y{};
|
||||
};
|
||||
|
||||
struct laid_out_note_ref {
|
||||
std::size_t system_index{};
|
||||
std::size_t measure_index{};
|
||||
std::size_t note_index{};
|
||||
luma::rect notation_bounds{};
|
||||
std::bool_t stem_up{true};
|
||||
std::vector<laid_out_tie_head> heads{};
|
||||
};
|
||||
|
||||
struct laid_out_measure_ref {
|
||||
std::size_t measure_index{};
|
||||
luma::rect notation_bounds{};
|
||||
};
|
||||
|
||||
auto layout_staff(
|
||||
vamp_zero::score_page_layout& layout,
|
||||
std::float32_t x,
|
||||
@@ -1679,8 +1704,6 @@ auto layout_note_symbols(
|
||||
note_layout const& note,
|
||||
std::float32_t staff_top,
|
||||
std::float32_t spacing,
|
||||
std::float32_t next_x,
|
||||
std::float32_t measure_right_x,
|
||||
std::size_t system_index,
|
||||
std::size_t measure_index
|
||||
) -> void {
|
||||
@@ -1714,25 +1737,6 @@ auto layout_note_symbols(
|
||||
note.note_index);
|
||||
}
|
||||
}
|
||||
if (note.source != nullptr && note.source->tie_to_next) {
|
||||
auto tie_y = note.low_y + spacing * 0.62F;
|
||||
auto tie_start_x = note.visual_right - spacing * 0.45F;
|
||||
auto tie_end_x = std::min(next_x - spacing * 0.45F, measure_right_x - spacing * 0.30F);
|
||||
if (tie_end_x <= tie_start_x + spacing * 0.50F) {
|
||||
return;
|
||||
}
|
||||
add_curve(
|
||||
layout,
|
||||
vamp_zero::score_layout_item_role::TIE,
|
||||
{.x = tie_start_x, .y = tie_y},
|
||||
{.x = (tie_start_x + tie_end_x) * 0.5F, .y = tie_y + spacing * 0.78F},
|
||||
{.x = tie_end_x, .y = tie_y},
|
||||
at_least_one_pixel(scaled(tie_thickness_spaces, spacing)),
|
||||
score_ink(),
|
||||
system_index,
|
||||
measure_index,
|
||||
note.note_index);
|
||||
}
|
||||
}
|
||||
|
||||
auto note_x(std::float32_t start_x, std::float32_t width, std::int64_t total_ticks, std::int64_t elapsed_ticks, std::int64_t duration_ticks) -> std::float32_t {
|
||||
@@ -1837,6 +1841,23 @@ auto measure_notation_top_y(
|
||||
return top_y;
|
||||
}
|
||||
|
||||
auto measure_notation_bottom_y(std::vector<note_layout> const& notes, std::vector<beam_group_layout> const& beam_groups, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t {
|
||||
auto bottom_y = staff_top + spacing * 4.0F;
|
||||
for (auto const& note : notes) {
|
||||
if (!note.hidden) {
|
||||
bottom_y = std::max(bottom_y, note.visual_bottom);
|
||||
}
|
||||
}
|
||||
for (auto const& group : beam_groups) {
|
||||
bottom_y = std::max(bottom_y, group.beam_y + scaled(beam_thickness_spaces, spacing) * 0.5F);
|
||||
}
|
||||
return bottom_y;
|
||||
}
|
||||
|
||||
auto lyric_y(std::float32_t staff_top, std::float32_t spacing, std::float32_t notation_bottom_y) -> std::float32_t {
|
||||
return std::max(staff_top + scaled(lyric_below_staff_spaces, spacing), notation_bottom_y + scaled(lyric_clearance_spaces, spacing));
|
||||
}
|
||||
|
||||
auto chord_symbol_y(std::float32_t staff_top, std::float32_t spacing, std::float32_t notation_top_y) -> std::float32_t {
|
||||
auto y = staff_top - scaled(chord_above_staff_spaces, spacing);
|
||||
auto const bottom_limit = notation_top_y - scaled(chord_clearance_spaces, spacing);
|
||||
@@ -1846,6 +1867,189 @@ auto chord_symbol_y(std::float32_t staff_top, std::float32_t spacing, std::float
|
||||
return y;
|
||||
}
|
||||
|
||||
auto same_pitch(vamp_zero::score_pitch const& left, vamp_zero::score_pitch const& right) -> std::bool_t {
|
||||
return normalized_letter(left.letter) == normalized_letter(right.letter)
|
||||
&& left.octave == right.octave
|
||||
&& pitch_alteration(left.accidental) == pitch_alteration(right.accidental);
|
||||
}
|
||||
|
||||
auto make_laid_out_note_ref(note_layout const& note, luma::rect notation_bounds, std::size_t system_index, std::size_t measure_index) -> laid_out_note_ref {
|
||||
auto ref = laid_out_note_ref{
|
||||
.system_index = system_index,
|
||||
.measure_index = measure_index,
|
||||
.note_index = note.note_index,
|
||||
.notation_bounds = notation_bounds,
|
||||
.stem_up = note.stem_up,
|
||||
};
|
||||
if (note.source == nullptr || note.hidden || note.rest) {
|
||||
return ref;
|
||||
}
|
||||
ref.heads.reserve(note.heads.size());
|
||||
for (auto const& head : note.heads) {
|
||||
if (head.source_pitch_index >= note.source->pitches.size()) {
|
||||
continue;
|
||||
}
|
||||
ref.heads.push_back({
|
||||
.pitch = note.source->pitches[head.source_pitch_index],
|
||||
.bounds = head.bounds,
|
||||
.center_y = head.center_y,
|
||||
});
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
auto find_note_ref(std::vector<laid_out_note_ref> const& refs, std::size_t measure_index, std::size_t note_index) -> laid_out_note_ref const* {
|
||||
auto found = std::find_if(refs.begin(), refs.end(), [&](auto const& ref) {
|
||||
return ref.measure_index == measure_index && ref.note_index == note_index;
|
||||
});
|
||||
return found == refs.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto find_measure_ref(std::vector<laid_out_measure_ref> const& refs, std::size_t measure_index) -> laid_out_measure_ref const* {
|
||||
auto found = std::find_if(refs.begin(), refs.end(), [&](auto const& ref) {
|
||||
return ref.measure_index == measure_index;
|
||||
});
|
||||
return found == refs.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto find_tie_head(laid_out_note_ref const& ref, vamp_zero::score_pitch const& pitch) -> laid_out_tie_head const* {
|
||||
auto found = std::find_if(ref.heads.begin(), ref.heads.end(), [&](auto const& head) {
|
||||
return same_pitch(head.pitch, pitch);
|
||||
});
|
||||
return found == ref.heads.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
struct tied_note_destination {
|
||||
std::size_t measure_index{};
|
||||
std::size_t note_index{};
|
||||
};
|
||||
|
||||
auto find_tied_destination(vamp_zero::score_document const& score, std::size_t measure_index, std::size_t note_index, vamp_zero::score_pitch const& pitch) -> std::optional<tied_note_destination> {
|
||||
for (auto current_measure = measure_index; current_measure < score.measures.size(); ++current_measure) {
|
||||
auto const& measure = score.measures[current_measure];
|
||||
auto first_note = current_measure == measure_index ? note_index + 1U : 0U;
|
||||
for (auto current_note = first_note; current_note < measure.notes.size(); ++current_note) {
|
||||
auto const& note = measure.notes[current_note];
|
||||
if (note.hidden || note.kind == "rest") {
|
||||
continue;
|
||||
}
|
||||
auto matching_pitch = std::find_if(note.pitches.begin(), note.pitches.end(), [&](auto const& candidate) {
|
||||
return same_pitch(candidate, pitch);
|
||||
});
|
||||
if (matching_pitch == note.pitches.end()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (note.tie_from_previous) {
|
||||
return tied_note_destination{.measure_index = current_measure, .note_index = current_note};
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto add_tie_curve(
|
||||
vamp_zero::score_page_layout& layout,
|
||||
luma::point from,
|
||||
luma::point to,
|
||||
std::bool_t below_note,
|
||||
std::float32_t spacing,
|
||||
std::size_t system_index,
|
||||
std::size_t measure_index,
|
||||
std::size_t note_index
|
||||
) -> void {
|
||||
if (to.x <= from.x + spacing * 0.40F) {
|
||||
return;
|
||||
}
|
||||
auto direction = below_note ? std::float32_t{1.0F} : std::float32_t{-1.0F};
|
||||
auto control = luma::point{
|
||||
.x = (from.x + to.x) * 0.5F,
|
||||
.y = (from.y + to.y) * 0.5F + direction * scaled(tie_arch_spaces, spacing),
|
||||
};
|
||||
add_curve(
|
||||
layout,
|
||||
vamp_zero::score_layout_item_role::TIE,
|
||||
from,
|
||||
control,
|
||||
to,
|
||||
at_least_one_pixel(scaled(tie_thickness_spaces, spacing)),
|
||||
score_ink(),
|
||||
system_index,
|
||||
measure_index,
|
||||
note_index);
|
||||
}
|
||||
|
||||
auto layout_page_ties(
|
||||
vamp_zero::score_page_layout& layout,
|
||||
vamp_zero::score_document const& score,
|
||||
std::vector<laid_out_note_ref> const& note_refs,
|
||||
std::vector<laid_out_measure_ref> const& measure_refs,
|
||||
std::float32_t spacing
|
||||
) -> void {
|
||||
for (auto measure_index = std::size_t{}; measure_index < score.measures.size(); ++measure_index) {
|
||||
auto const& measure = score.measures[measure_index];
|
||||
for (auto note_index = std::size_t{}; note_index < measure.notes.size(); ++note_index) {
|
||||
auto const& note = measure.notes[note_index];
|
||||
if (!note.tie_to_next || note.hidden || note.kind == "rest") {
|
||||
continue;
|
||||
}
|
||||
auto const* start_ref = find_note_ref(note_refs, measure_index, note_index);
|
||||
for (auto const& pitch : note.pitches) {
|
||||
auto destination = find_tied_destination(score, measure_index, note_index, pitch);
|
||||
auto const* destination_ref = destination ? find_note_ref(note_refs, destination->measure_index, destination->note_index) : nullptr;
|
||||
auto const* start_head = start_ref == nullptr ? nullptr : find_tie_head(*start_ref, pitch);
|
||||
auto const* destination_head = destination_ref == nullptr ? nullptr : find_tie_head(*destination_ref, pitch);
|
||||
|
||||
if (start_ref != nullptr && start_head != nullptr && destination_ref != nullptr && destination_head != nullptr && start_ref->system_index == destination_ref->system_index) {
|
||||
auto below = start_ref->stem_up;
|
||||
auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F);
|
||||
add_tie_curve(
|
||||
layout,
|
||||
{.x = start_head->bounds.x + start_head->bounds.width + scaled(tie_endpoint_gap_spaces, spacing), .y = start_head->center_y + y_offset},
|
||||
{.x = destination_head->bounds.x - scaled(tie_endpoint_gap_spaces, spacing), .y = destination_head->center_y + y_offset},
|
||||
below,
|
||||
spacing,
|
||||
start_ref->system_index,
|
||||
start_ref->measure_index,
|
||||
start_ref->note_index);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (start_ref != nullptr && start_head != nullptr) {
|
||||
auto below = start_ref->stem_up;
|
||||
auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F);
|
||||
add_tie_curve(
|
||||
layout,
|
||||
{.x = start_head->bounds.x + start_head->bounds.width + scaled(tie_endpoint_gap_spaces, spacing), .y = start_head->center_y + y_offset},
|
||||
{.x = start_ref->notation_bounds.x + start_ref->notation_bounds.width, .y = start_head->center_y + y_offset},
|
||||
below,
|
||||
spacing,
|
||||
start_ref->system_index,
|
||||
start_ref->measure_index,
|
||||
start_ref->note_index);
|
||||
}
|
||||
if (destination && destination_ref != nullptr && destination_head != nullptr) {
|
||||
auto const* measure_ref = find_measure_ref(measure_refs, destination->measure_index);
|
||||
if (measure_ref == nullptr) {
|
||||
continue;
|
||||
}
|
||||
auto below = destination_ref->stem_up;
|
||||
auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F);
|
||||
add_tie_curve(
|
||||
layout,
|
||||
{.x = measure_ref->notation_bounds.x, .y = destination_head->center_y + y_offset},
|
||||
{.x = destination_head->bounds.x - scaled(tie_endpoint_gap_spaces, spacing), .y = destination_head->center_y + y_offset},
|
||||
below,
|
||||
spacing,
|
||||
destination_ref->system_index,
|
||||
destination_ref->measure_index,
|
||||
destination_ref->note_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto layout_measure(
|
||||
vamp_zero::score_page_layout& layout,
|
||||
vamp_zero::score_document const& score,
|
||||
@@ -1855,7 +2059,9 @@ auto layout_measure(
|
||||
std::size_t system_index,
|
||||
std::size_t measure_index,
|
||||
std::float32_t staff_top,
|
||||
std::float32_t spacing
|
||||
std::float32_t spacing,
|
||||
std::vector<laid_out_note_ref>& note_refs,
|
||||
std::vector<laid_out_measure_ref>& measure_refs
|
||||
) -> void {
|
||||
add_text(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER, bounds.x + spacing * 0.20F, bounds.y + spacing * 0.05F, std::to_string(measure_number), 1, score_muted_ink(), spacing * 4.0F, system_index, measure_index);
|
||||
|
||||
@@ -1863,6 +2069,10 @@ auto layout_measure(
|
||||
auto notation_right_x = bounds.x + bounds.width - scaled(measure_right_guard_spaces, spacing);
|
||||
auto notation_width = std::max<std::float32_t>(spacing * 3.0F, notation_right_x - notation_x);
|
||||
auto notation_bounds = luma::rect{.x = notation_x, .y = bounds.y, .width = notation_width, .height = bounds.height};
|
||||
measure_refs.push_back({
|
||||
.measure_index = measure_index,
|
||||
.notation_bounds = notation_bounds,
|
||||
});
|
||||
auto total_ticks = measure_ticks(measure);
|
||||
auto elapsed = std::int64_t{};
|
||||
auto accidentals = make_accidental_state(effective_key_signature(score, measure));
|
||||
@@ -1889,7 +2099,12 @@ auto layout_measure(
|
||||
}
|
||||
|
||||
auto const notation_top_y = measure_notation_top_y(measure, notes, beam_groups, staff_top, spacing);
|
||||
auto const notation_bottom_y = measure_notation_bottom_y(notes, beam_groups, staff_top, spacing);
|
||||
auto const chord_y = chord_symbol_y(staff_top, spacing, notation_top_y);
|
||||
auto const lyrics_y = lyric_y(staff_top, spacing, notation_bottom_y);
|
||||
for (auto const& note : notes) {
|
||||
note_refs.push_back(make_laid_out_note_ref(note, notation_bounds, system_index, measure_index));
|
||||
}
|
||||
if (!measure.section_label.empty()) {
|
||||
auto section_y = std::min(staff_top - spacing * 4.70F, chord_y - spacing * 0.65F);
|
||||
add_text(layout, vamp_zero::score_layout_item_role::SECTION_LABEL, bounds.x + spacing * 2.10F, section_y, measure.section_label, 2, score_accent_ink(), bounds.width - spacing * 2.6F, system_index, measure_index);
|
||||
@@ -1948,14 +2163,13 @@ auto layout_measure(
|
||||
add_flag(layout, note, spacing, system_index, measure_index);
|
||||
}
|
||||
for (auto index = std::size_t{}; index < notes.size(); ++index) {
|
||||
auto next_x = index + 1U < notes.size() ? notes[index + 1U].x : notes[index].x + spacing * 3.0F;
|
||||
layout_note_symbols(layout, notes[index], staff_top, spacing, next_x, notation_bounds.x + notation_bounds.width, system_index, measure_index);
|
||||
layout_note_symbols(layout, notes[index], staff_top, spacing, system_index, measure_index);
|
||||
if (notes[index].source != nullptr && !notes[index].hidden && !notes[index].source->lyric.empty()) {
|
||||
add_text(
|
||||
layout,
|
||||
vamp_zero::score_layout_item_role::LYRIC,
|
||||
notes[index].x - spacing,
|
||||
staff_top + spacing * 5.35F,
|
||||
lyrics_y,
|
||||
notes[index].source->lyric,
|
||||
1,
|
||||
score_muted_ink(),
|
||||
@@ -2027,6 +2241,8 @@ auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document c
|
||||
auto const content_width = std::max<std::float32_t>(spacing * 18.0F, bounds.width - margin * 2.0F);
|
||||
auto const system_height = (bounds.height - 108.0F) / static_cast<std::float32_t>(vamp_zero::score_systems_per_page);
|
||||
auto const first_measure = page_index * vamp_zero::score_measures_per_page;
|
||||
auto note_refs = std::vector<laid_out_note_ref>{};
|
||||
auto measure_refs = std::vector<laid_out_measure_ref>{};
|
||||
|
||||
for (auto system = std::size_t{}; system < vamp_zero::score_systems_per_page; ++system) {
|
||||
auto const system_y = content_y + static_cast<std::float32_t>(system) * system_height;
|
||||
@@ -2052,7 +2268,9 @@ auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document c
|
||||
system,
|
||||
measure_index,
|
||||
staff_top,
|
||||
spacing);
|
||||
spacing,
|
||||
note_refs,
|
||||
measure_refs);
|
||||
}
|
||||
|
||||
auto const bar_x = x + measure_width;
|
||||
@@ -2069,6 +2287,7 @@ auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document c
|
||||
}
|
||||
}
|
||||
|
||||
layout_page_ties(layout, score, note_refs, measure_refs, spacing);
|
||||
add_stroke_rect(layout, vamp_zero::score_layout_item_role::BORDER, bounds, 1.1F, score_border());
|
||||
return layout;
|
||||
}
|
||||
|
||||
+116
-3
@@ -11,6 +11,7 @@
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
|
||||
#include <luma.hpp>
|
||||
|
||||
@@ -22,6 +23,11 @@
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto touch_hold_duration = std::chrono::milliseconds{700};
|
||||
constexpr auto touch_double_tap_duration = std::chrono::milliseconds{220};
|
||||
|
||||
using touch_handler = std::function<std::bool_t(luma::touch_event const&)>;
|
||||
|
||||
auto bind_port(vamp_zero::config const& config) -> std::uint16_t {
|
||||
auto const& bind = config.control.endpoint.bind;
|
||||
auto colon = bind.rfind(':');
|
||||
@@ -404,7 +410,7 @@ auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_s
|
||||
|
||||
class vamp_root_control final : public luma::control {
|
||||
public:
|
||||
explicit vamp_root_control(vamp_zero::ui_state state) : state_{std::move(state)} {}
|
||||
explicit vamp_root_control(vamp_zero::ui_state state, touch_handler on_touch) : state_{std::move(state)}, on_touch_{std::move(on_touch)} {}
|
||||
auto measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size override {
|
||||
return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits);
|
||||
}
|
||||
@@ -438,13 +444,117 @@ public:
|
||||
draw_main_menu(context, content, state_.app);
|
||||
}
|
||||
}
|
||||
auto handle_touch(luma::touch_event const& event) -> std::bool_t override {
|
||||
return on_touch_ ? on_touch_(event) : false;
|
||||
}
|
||||
private:
|
||||
vamp_zero::ui_state state_{};
|
||||
touch_handler on_touch_{};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config) : executor_{std::move(executor)}, application_{std::move(config)} {}
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::zero_ui::command_handler command_handler)
|
||||
: executor_{std::move(executor)}, tap_timer_{executor_}, application_{std::move(config)}, command_handler_{std::move(command_handler)} {}
|
||||
|
||||
auto vamp_zero::zero_ui::send_touch_command(vamp_protocol::page_command command) -> void {
|
||||
if (!command_handler_) {
|
||||
return;
|
||||
}
|
||||
boost::asio::post(executor_, [handler = command_handler_, command] {
|
||||
handler(command);
|
||||
});
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::cancel_pending_tap_locked() -> void {
|
||||
pending_tap_ = false;
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::arm_pending_tap() -> void {
|
||||
tap_timer_.expires_after(touch_double_tap_duration);
|
||||
tap_timer_.async_wait([this](boost::system::error_code const& error) {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
auto send_next = false;
|
||||
{
|
||||
auto lock = std::scoped_lock{touch_mutex_};
|
||||
send_next = pending_tap_;
|
||||
pending_tap_ = false;
|
||||
}
|
||||
if (send_next) {
|
||||
send_touch_command(vamp_protocol::page_command::NEXT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::handle_touch(luma::touch_event const& event) -> std::bool_t {
|
||||
if (!command_handler_ || event.points.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto consumed = false;
|
||||
auto commands = std::vector<vamp_protocol::page_command>{};
|
||||
auto arm_tap = false;
|
||||
{
|
||||
auto lock = std::scoped_lock{touch_mutex_};
|
||||
for (auto const& point : event.points) {
|
||||
if (point.phase == luma::touch_phase::DOWN && !active_touch_id_) {
|
||||
active_touch_id_ = point.id;
|
||||
touch_down_time_ = event.time;
|
||||
hold_sent_ = false;
|
||||
tap_timer_.cancel();
|
||||
consumed = true;
|
||||
continue;
|
||||
}
|
||||
if (!active_touch_id_ || point.id != *active_touch_id_) {
|
||||
continue;
|
||||
}
|
||||
consumed = true;
|
||||
if (point.phase == luma::touch_phase::CANCEL) {
|
||||
active_touch_id_.reset();
|
||||
hold_sent_ = false;
|
||||
cancel_pending_tap_locked();
|
||||
tap_timer_.cancel();
|
||||
continue;
|
||||
}
|
||||
if (point.phase == luma::touch_phase::MOVE) {
|
||||
if (!hold_sent_ && event.time - touch_down_time_ >= touch_hold_duration) {
|
||||
hold_sent_ = true;
|
||||
cancel_pending_tap_locked();
|
||||
tap_timer_.cancel();
|
||||
commands.push_back(vamp_protocol::page_command::SELECT);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (point.phase == luma::touch_phase::UP) {
|
||||
active_touch_id_.reset();
|
||||
if (!hold_sent_ && event.time - touch_down_time_ >= touch_hold_duration) {
|
||||
commands.push_back(vamp_protocol::page_command::SELECT);
|
||||
} else if (!hold_sent_) {
|
||||
if (pending_tap_ && event.time - last_tap_time_ <= touch_double_tap_duration) {
|
||||
cancel_pending_tap_locked();
|
||||
tap_timer_.cancel();
|
||||
commands.push_back(vamp_protocol::page_command::PREVIOUS);
|
||||
} else {
|
||||
pending_tap_ = true;
|
||||
last_tap_time_ = event.time;
|
||||
arm_tap = true;
|
||||
}
|
||||
}
|
||||
hold_sent_ = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto command : commands) {
|
||||
send_touch_command(command);
|
||||
}
|
||||
if (arm_tap) {
|
||||
arm_pending_tap();
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::run() -> std::expected<void, modulus::error> {
|
||||
auto state = snapshot();
|
||||
@@ -459,6 +569,7 @@ auto vamp_zero::zero_ui::run() -> std::expected<void, modulus::error> {
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::stop() -> void {
|
||||
tap_timer_.cancel();
|
||||
application_.request_stop();
|
||||
}
|
||||
|
||||
@@ -482,7 +593,9 @@ auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state {
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::make_root(vamp_zero::ui_state const& state) -> std::expected<std::unique_ptr<luma::control>, modulus::error> {
|
||||
return std::make_unique<vamp_root_control>(state);
|
||||
return std::make_unique<vamp_root_control>(state, [this](luma::touch_event const& event) {
|
||||
return handle_touch(event);
|
||||
});
|
||||
}
|
||||
|
||||
auto vamp_zero::make_ui_state(vamp_zero::config const& config, vamp_zero::app_state app) -> vamp_zero::ui_state {
|
||||
|
||||
@@ -10,10 +10,16 @@ target_link_libraries(product-vamp-test PRIVATE
|
||||
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
|
||||
add_test(NAME product-vamp COMMAND product-vamp-test)
|
||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
|
||||
add_test(
|
||||
NAME product-vamp-generator
|
||||
COMMAND ${Python3_EXECUTABLE} -m unittest tests.test_score_generator
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
)
|
||||
set_tests_properties(product-vamp-generator PROPERTIES TIMEOUT 60)
|
||||
add_custom_target(check
|
||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||
--test-dir ${CMAKE_BINARY_DIR}
|
||||
-R ^product-vamp$
|
||||
-R ^product-vamp
|
||||
--output-on-failure
|
||||
--timeout 60
|
||||
USES_TERMINAL
|
||||
|
||||
@@ -158,6 +158,10 @@ auto right_edge(vamp_zero::score_layout_item const& item) -> std::float32_t {
|
||||
return item.bounds.x + item.bounds.width;
|
||||
}
|
||||
|
||||
auto bottom_edge(vamp_zero::score_layout_item const& item) -> std::float32_t {
|
||||
return item.bounds.y + item.bounds.height;
|
||||
}
|
||||
|
||||
auto near(std::float32_t left, std::float32_t right) -> std::bool_t {
|
||||
return std::abs(left - right) < 0.02F;
|
||||
}
|
||||
@@ -448,6 +452,99 @@ BOOST_AUTO_TEST_CASE(score_layout_keeps_dense_notation_inside_measure_guard) {
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_layout_places_lyrics_below_low_vocal_notation) {
|
||||
auto low = note({pitch('c', "n", 3)});
|
||||
low.lyric = "low";
|
||||
auto score = score_with_notes({std::move(low)});
|
||||
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
||||
|
||||
auto lyrics = layout_items(layout, vamp_zero::score_layout_item_role::LYRIC, 0U, 0U, 0U);
|
||||
BOOST_REQUIRE_EQUAL(lyrics.size(), 1U);
|
||||
|
||||
auto notation_bottom = std::numeric_limits<std::float32_t>::lowest();
|
||||
auto roles = {
|
||||
vamp_zero::score_layout_item_role::NOTEHEAD,
|
||||
vamp_zero::score_layout_item_role::ACCIDENTAL,
|
||||
vamp_zero::score_layout_item_role::STEM,
|
||||
vamp_zero::score_layout_item_role::FLAG,
|
||||
vamp_zero::score_layout_item_role::DOT,
|
||||
vamp_zero::score_layout_item_role::STAFF_LINE,
|
||||
};
|
||||
for (auto role : roles) {
|
||||
for (auto const* item : layout_items(layout, role, 0U, 0U, 0U)) {
|
||||
notation_bottom = std::max(notation_bottom, bottom_edge(*item));
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_TEST(lyrics.front()->bounds.y >= notation_bottom + layout.staff_spacing * 0.70F);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_layout_ties_to_the_actual_next_measure_notehead) {
|
||||
auto tied = note_with_ticks({pitch('b', "b")}, 16384);
|
||||
tied.tie_to_next = true;
|
||||
auto target = note_with_ticks({pitch('b', "b")}, 4096);
|
||||
target.tie_from_previous = true;
|
||||
auto score = score_with_measure({
|
||||
.time_signature = "4/4",
|
||||
.key_signature = "F",
|
||||
.notes = {std::move(tied)},
|
||||
}, "F");
|
||||
score.measures.push_back({
|
||||
.time_signature = "4/4",
|
||||
.key_signature = "F",
|
||||
.notes = {std::move(target)},
|
||||
});
|
||||
|
||||
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
||||
|
||||
auto ties = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 0U, 0U);
|
||||
auto start_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U);
|
||||
auto target_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 1U, 0U);
|
||||
BOOST_REQUIRE_EQUAL(ties.size(), 1U);
|
||||
BOOST_REQUIRE_EQUAL(start_heads.size(), 1U);
|
||||
BOOST_REQUIRE_EQUAL(target_heads.size(), 1U);
|
||||
|
||||
BOOST_TEST(ties.front()->from.x > right_edge(*start_heads.front()));
|
||||
BOOST_TEST(ties.front()->to.x < target_heads.front()->bounds.x);
|
||||
BOOST_TEST(ties.front()->to.x > right_edge(*start_heads.front()));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_layout_draws_tie_continuations_across_system_breaks) {
|
||||
auto measures = std::vector<vamp_zero::score_measure>{};
|
||||
for (auto index = std::size_t{}; index < 5U; ++index) {
|
||||
auto value = note_with_ticks({pitch('a', "n")}, 16384);
|
||||
if (index == 3U) {
|
||||
value.tie_to_next = true;
|
||||
}
|
||||
if (index == 4U) {
|
||||
value.tie_from_previous = true;
|
||||
}
|
||||
measures.push_back({
|
||||
.time_signature = "4/4",
|
||||
.key_signature = "C",
|
||||
.notes = {std::move(value)},
|
||||
});
|
||||
}
|
||||
auto score = vamp_zero::score_document{
|
||||
.title = "Break Tie",
|
||||
.composer = "Composer",
|
||||
.key_signature = "C",
|
||||
.time_signature = "4/4",
|
||||
.clef = "treble",
|
||||
.part = "Lead",
|
||||
.measures = std::move(measures),
|
||||
};
|
||||
|
||||
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
||||
auto outgoing = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 3U, 0U);
|
||||
auto incoming = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 1U, 4U, 0U);
|
||||
|
||||
BOOST_REQUIRE_EQUAL(outgoing.size(), 1U);
|
||||
BOOST_REQUIRE_EQUAL(incoming.size(), 1U);
|
||||
BOOST_TEST(outgoing.front()->to.x > outgoing.front()->from.x);
|
||||
BOOST_TEST(incoming.front()->to.x > incoming.front()->from.x);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_layout_raises_chord_symbols_above_high_notation) {
|
||||
auto high = note({pitch('c', "n", 6)});
|
||||
high.chord_symbol = "Cmaj7";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from generator.score import normalize_score_document
|
||||
|
||||
|
||||
class CompactScoreJsonTests(unittest.TestCase):
|
||||
def test_compact_json_normalizes_notation_tokens(self) -> None:
|
||||
document = normalize_score_document({
|
||||
"title": "Compact Tune",
|
||||
"composer": "Composer",
|
||||
"key": "F",
|
||||
"time": "4/4",
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"measures": [
|
||||
"[G-7] Bb4:1~ \"leaves\"",
|
||||
"[C7] ~Bb4:4 C4:4 \"drift\" D4:4 \"by\" E4:4 \"my\"",
|
||||
],
|
||||
})
|
||||
|
||||
staff = document["staves"][0]
|
||||
first_note = staff["measures"][0]["voices"][0]["notes"][0]
|
||||
second_measure_first_note = staff["measures"][1]["voices"][0]["notes"][0]
|
||||
|
||||
self.assertEqual(document["metadata"]["key_signature"], "F")
|
||||
self.assertEqual(staff["name"], "Vocal")
|
||||
self.assertEqual(first_note["duration_ticks"], 16384)
|
||||
self.assertEqual(first_note["chord_symbol"], "G-7")
|
||||
self.assertEqual(first_note["lyric"], "leaves")
|
||||
self.assertTrue(first_note["tie_to_next"])
|
||||
self.assertTrue(second_measure_first_note["tie_from_previous"])
|
||||
|
||||
def test_compact_json_normalizes_pitch_groups_and_tuplets(self) -> None:
|
||||
document = normalize_score_document({
|
||||
"title": "Practice",
|
||||
"key": "C",
|
||||
"time": "4/4",
|
||||
"measures": [
|
||||
"[G7] (G4 B4 F5):4 r:4",
|
||||
"{5:2 C4:4 D4:4 E4:4 F4:4 G4:4}",
|
||||
],
|
||||
})
|
||||
|
||||
first_measure_notes = document["staves"][0]["measures"][0]["voices"][0]["notes"]
|
||||
second_voice = document["staves"][0]["measures"][1]["voices"][0]
|
||||
tuplet_notes = second_voice["notes"]
|
||||
|
||||
self.assertEqual(len(first_measure_notes[0]["pitches"]), 3)
|
||||
self.assertEqual(first_measure_notes[1]["kind"], "rest")
|
||||
self.assertEqual(second_voice["tuplets"][0]["note_count"], 5)
|
||||
self.assertEqual(second_voice["tuplets"][0]["space_count"], 2)
|
||||
self.assertEqual(sum(note["duration_ticks"] for note in tuplet_notes), 8192)
|
||||
self.assertTrue(all(note["display_ticks"] == 4096 for note in tuplet_notes))
|
||||
|
||||
def test_compact_json_rejects_overfull_measure(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_score_document({
|
||||
"title": "Bad",
|
||||
"time": "4/4",
|
||||
"measures": ["C4:1 D4:4"],
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user