@@ -144,7 +144,7 @@ All generated monophonic guitar material uses frets 1–12 without open strings.
|
|||||||
|
|
||||||
There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged.
|
There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged.
|
||||||
|
|
||||||
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
|
Normal browsing uses two music systems per page, with a single system only when the score ends on an odd system. Playback uses overlapping pairs (1–2, 2–3, 3–4): each new active line moves to the top, revealing the next below. The final line moves to the top with an empty lower half. Measure numbers sit above their starting barlines in a clear lane; playback outlines the active measure number in the accent color. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
|
||||||
|
|
||||||
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The performance arrangements, including the held Ending, remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass.
|
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The performance arrangements, including the held Ending, remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass.
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ Audio comes exclusively from supplied WAV files: `package/assets/sounds/beat1.wa
|
|||||||
|
|
||||||
Tempo choices are 60, 70, 80, 90, 100, 110, and 120 BPM. With Backing on, choices are the tempos available for every catalog song; currently that is 120 BPM. Enabling Backing selects the closest available tempo, preferring the slower choice on a tie. If no tempo is shared, Backing is unavailable. Add recordings at the other tempos and rebuild to enable them.
|
Tempo choices are 60, 70, 80, 90, 100, 110, and 120 BPM. With Backing on, choices are the tempos available for every catalog song; currently that is 120 BPM. Enabling Backing selects the closest available tempo, preferring the slower choice on a tie. If no tempo is shared, Backing is unavailable. Add recordings at the other tempos and rebuild to enable them.
|
||||||
|
|
||||||
Backing recordings include one bar of count-in, the complete arrangement including its three-bar held ending, and decay. The existing WAVs are copied unchanged. The recorded count-in plays alone. Metronome clicks stop at the start of the written Ending section; those three measures and the decay remain unclicked. Metronome-only mode supplies its own count-in, waits silently through the ending, and then stops. Pages follow the rendered audio clock through all 171 measures; the final page stays visible through the recording decay.
|
Backing recordings include one bar of count-in, the complete arrangement including its three-bar held ending, and decay. The existing WAVs are copied unchanged. The recorded count-in plays alone. Metronome clicks stop at the start of the written Ending section; those three measures and the decay remain unclicked. Metronome-only mode supplies its own count-in, waits silently through the ending, and then stops. Playback line turns and measure highlighting follow the rendered audio clock through all 171 measures, including rests and ties; the final line stays visible through the recording decay. Preparation and count-in have no highlighted measure.
|
||||||
|
|
||||||
Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files.
|
Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files.
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -167,9 +167,11 @@ The existing recordings contain one bar of count-in, 168 main-form bars, three h
|
|||||||
|
|
||||||
Score timing includes `sustain_start`, an exact measure boundary, as well as the total written duration. Ending measures carry `ending_sustain`; Python and C++ validate a contiguous tail and consistent timing. Older scores without the new timing field retain their previous full-duration click behavior. Both toggles play the recorded count-in alone, then backing plus sample clicks until `sustain_start`. No clicks sound over the ending. Metronome-only supplies its own count-in and completes after the three silent held bars. Backing-only plays the complete file and decay. Both off enables manual navigation; hold exits and natural completion returns to the part menu. The final page remains visible through decay.
|
Score timing includes `sustain_start`, an exact measure boundary, as well as the total written duration. Ending measures carry `ending_sustain`; Python and C++ validate a contiguous tail and consistent timing. Older scores without the new timing field retain their previous full-duration click behavior. Both toggles play the recorded count-in alone, then backing plus sample clicks until `sustain_start`. No clicks sound over the ending. Metronome-only supplies its own count-in and completes after the three silent held bars. Backing-only plays the complete file and decay. Both off enables manual navigation; hold exits and natural completion returns to the part menu. The final page remains visible through decay.
|
||||||
|
|
||||||
Preparation runs on a worker: checksums and samples are loaded before publishing a Sonus recording transport. Sonus streams backing audio through miniaudio’s background resource manager and mixes preloaded stereo clicks on the same rendered-frame clock. Callback work uses preallocated buffers, and resource retirement occurs on control threads. Stream failures stop progress and surface an error. Absolute rational beat positions are rounded to frames at 48 kHz once per position, avoiding accumulated rounding drift. The UI polls that audio clock for page boundaries and Count-in/Playing/Sustain status; wall-clock timers never determine musical progress.
|
Preparation runs on a worker: checksums and samples are loaded before publishing a Sonus recording transport. Sonus streams backing audio through miniaudio’s background resource manager and mixes preloaded stereo clicks on the same rendered-frame clock. Callback work uses preallocated buffers, and resource retirement occurs on control threads. Stream failures stop progress and surface an error. Absolute rational beat positions are rounded to frames at 48 kHz once per position, avoiding accumulated rounding drift. The UI polls that audio clock for system and measure boundaries and Count-in/Playing/Sustain status; wall-clock timers never determine musical progress.
|
||||||
|
|
||||||
Page metadata comes from actual system pagination and is preserved and revalidated in engraving caches. The UI prepares the current and next page before playback starts and retains a two-texture cache. Session and catalog identities reject stale preparation, page updates and completion after cancellation. Missing or corrupt pages/audio leave a visible error and permit hold/back. `--once` validates packaged recording checksums and all score pages without opening audio/display hardware.
|
Page metadata comes from actual system pagination and is preserved and revalidated in engraving caches. `presentation.pages` partitions the music into normal two-system pages. `presentation.playback_pages` holds one view per system: that system and its successor, or a final single top system. Both reuse the same system breaks, horizontal geometry, and part-wide scale, fitted against every adjacent pair. Each descriptor carries system measure ranges, exact beat boundaries, and page-coordinate rectangles for its measure labels. Normal systems partition the score; overlapping playback views must match that canonical partition. Bundles missing playback metadata require regeneration.
|
||||||
|
|
||||||
|
Measure numbers are centered over their starting barlines (the staff’s left edge at system start), in a reserved lane above notation and below harmony/section labels. The display outlines the current label using the page texture’s letterbox transform; count-in, preparation, exit, and playback error clear it. Measure updates publish even when the line stays unchanged. Normal catalog page counts remain unchanged; the loader reads the separate playback count from bundle metadata. During automatic playback, navigation bounds, the header’s Line counter, and Pico’s page/pages fields refer to overlapping views. Manual browsing uses the normal sequence. The UI prepares the current and next page before playback starts and retains a two-texture cache. Session and catalog identities reject stale preparation, page updates and completion after cancellation. Missing or corrupt pages/audio leave a visible error and permit hold/back. `--once` validates packaged recording checksums and both normal and playback score pages without opening audio/display hardware.
|
||||||
|
|
||||||
### Authored Repertoire and Learned Solos
|
### Authored Repertoire and Learned Solos
|
||||||
|
|
||||||
@@ -263,7 +265,7 @@ Voices retain separate stems and collision offsets; chord tones retain their own
|
|||||||
|
|
||||||
Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm, rests, and tuplets; TAB carries positions, solo slashes, and tied continuations without rest glyphs. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment.
|
Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm, rests, and tuplets; TAB carries positions, solo slashes, and tied continuations without rest glyphs. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment.
|
||||||
|
|
||||||
Every part uses two music systems per page; only an odd final system occupies a row alone. A compact title/composer/part header reserves the same height on every page, wrapping credits if necessary. Pagination fits the two systems using their individual heights and distributes extra whitespace around them while retaining minimum system and header/footer clearances. One uniform scale applies throughout the entire part, reducing the ordinary notation size only when required by the tallest pair. Notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel minimum gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. Tempo markings consume neither ink nor spacing; their semantic data is preserved. Existing `.pages/*.png` preview files are the exact packaged page textures.
|
Normal pages use two music systems; only an odd final system occupies a row alone. Playback pages use overlapping adjacent pairs and always finish with the last system alone on top. A compact title/composer/part header reserves the same height on every page, wrapping credits if necessary. Pagination fits the two systems using their individual heights and distributes extra whitespace around them while retaining minimum system and header/footer clearances. One uniform scale applies throughout the entire part, reducing the ordinary notation size only when required by the tallest adjacent pair, including transition pages. Notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel minimum gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. Tempo markings consume neither ink nor spacing; their semantic data is preserved. Existing `.pages/*.png` preview files are the exact packaged page textures.
|
||||||
|
|
||||||
Line breaks occur only at measure boundaries. Each line fits as many complete measures as readable spacing allows; it never fills remaining space with a partial measure. A complete measure exceeding the available width or height fails with source and measure context instead of being split or squeezed to fit. Authored ties, beams, tuplets, and lyric spans can continue across whole-measure line breaks. Column measurement and fit trials reuse a score index and bounded column cache. Page placement then re-spaces columns for the final height-fitting scale, keeping every notation/TAB staff at the same physical left and right margins without stretching glyphs. Non-final lines are justified; a short final line retains natural note spacing while staff lines extend to the same right margin. Layout audits enforce these margins as well as musical coverage.
|
Line breaks occur only at measure boundaries. Each line fits as many complete measures as readable spacing allows; it never fills remaining space with a partial measure. A complete measure exceeding the available width or height fails with source and measure context instead of being split or squeezed to fit. Authored ties, beams, tuplets, and lyric spans can continue across whole-measure line breaks. Column measurement and fit trials reuse a score index and bounded column cache. Page placement then re-spaces columns for the final height-fitting scale, keeping every notation/TAB staff at the same physical left and right margins without stretching glyphs. Non-final lines are justified; a short final line retains natural note spacing while staff lines extend to the same right margin. Layout audits enforce these margins as well as musical coverage.
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
|||||||
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
||||||
# noteheads, accidentals and text in the same lane must remain separate.
|
# noteheads, accidentals and text in the same lane must remain separate.
|
||||||
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric', 'scale', 'section')]
|
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric', 'scale', 'section')]
|
||||||
|
for label in (p for p in system.primitives if p.role == 'measure_number'):
|
||||||
|
if any(label.box.overlaps(p.box, 3 * system.scale) for p in system.primitives if p is not label):
|
||||||
|
raise generator.engraving.types.EngravingError(f'{label.sources}: measure number overlaps notation')
|
||||||
for i, item in enumerate(checked):
|
for i, item in enumerate(checked):
|
||||||
for other in checked[i + 1:]:
|
for other in checked[i + 1:]:
|
||||||
if set(item.sources) & set(other.sources) and (item.role != 'tab' or other.role != 'tab'):
|
if set(item.sources) & set(other.sources) and (item.role != 'tab' or other.role != 'tab'):
|
||||||
@@ -141,3 +144,29 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
|||||||
for span in index.staff.get('octave_shifts', []):
|
for span in index.staff.get('octave_shifts', []):
|
||||||
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
|
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
|
||||||
raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted')
|
raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted')
|
||||||
|
|
||||||
|
def audit_playback(pages, playback, style, fonts):
|
||||||
|
canonical = [system for page in pages for system in page.systems]
|
||||||
|
if len(playback) != len(canonical):
|
||||||
|
raise generator.engraving.types.EngravingError('playback must have one view per system')
|
||||||
|
for i, page in enumerate(playback):
|
||||||
|
audit_primitives(page.primitives, page.width, page.height, fonts)
|
||||||
|
expected = canonical[i:i + 2]
|
||||||
|
if len(page.systems) != len(expected):
|
||||||
|
raise generator.engraving.types.EngravingError('invalid playback system pairing')
|
||||||
|
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
|
||||||
|
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page_number'])
|
||||||
|
if page.systems[0].box.top < header.bottom + style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - style.page_clearance + 0.01:
|
||||||
|
raise generator.engraving.types.EngravingError('playback overlaps header or footer')
|
||||||
|
for a, b in zip(page.systems, page.systems[1:]):
|
||||||
|
if a.box.bottom + style.system_gap > b.box.top + 0.01:
|
||||||
|
raise generator.engraving.types.EngravingError('playback systems overlap')
|
||||||
|
for source, actual in zip(expected, page.systems):
|
||||||
|
if (source.start, source.end, source.scale, source.fragments) != (actual.start, actual.end, actual.scale, actual.fragments):
|
||||||
|
raise generator.engraving.types.EngravingError('playback changes system content')
|
||||||
|
dy = actual.box.top - source.box.top
|
||||||
|
if len(source.primitives) != len(actual.primitives):
|
||||||
|
raise generator.engraving.types.EngravingError('playback changes primitives')
|
||||||
|
for a, b in zip(source.primitives, actual.primitives):
|
||||||
|
if (a.kind, a.role, a.sources, a.value, a.size) != (b.kind, b.role, b.sources, b.value, b.size) or len(a.points) != len(b.points) or any(abs(x - u) > .001 or abs(y + dy - v) > .001 for (x, y), (u, v) in zip(a.points, b.points)):
|
||||||
|
raise generator.engraving.types.EngravingError('playback changes notation geometry')
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
|
|||||||
return None
|
return None
|
||||||
destination.mkdir(parents=True, exist_ok=True)
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
for index, page in enumerate(pages, 1):
|
for index, page in enumerate(pages, 1):
|
||||||
if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
|
if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat', 'systems', 'playback'} or type(page['playback']) is not bool or not isinstance(page['path'], str) or not re.fullmatch(r'(?:playback-)?[0-9]{4,5}\.png', page['path']) or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
|
||||||
return None
|
return None
|
||||||
path = entry / page['path']
|
path = entry / page['path']
|
||||||
if path.is_symlink() or not path.is_file():
|
if path.is_symlink() or not path.is_file():
|
||||||
@@ -88,7 +88,7 @@ def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict
|
|||||||
shutil.copyfile(destination / page['path'], staged / page['path'])
|
shutil.copyfile(destination / page['path'], staged / page['path'])
|
||||||
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
|
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
|
||||||
# Identical keys have identical pages; publish complete files, then the manifest.
|
# Identical keys have identical pages; publish complete files, then the manifest.
|
||||||
for name in [page['path'] for page in pages] + ['pages.json']:
|
for name in list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json']:
|
||||||
target = entry / name
|
target = entry / name
|
||||||
if target.is_dir() and not target.is_symlink():
|
if target.is_dir() and not target.is_symlink():
|
||||||
shutil.rmtree(target, ignore_errors=True)
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
|
|||||||
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
|
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
|
||||||
items.extend(generator.engraving.scales.column(index, time, start, fonts))
|
items.extend(generator.engraving.scales.column(index, time, start, fonts))
|
||||||
if time == measure_start:
|
if time == measure_start:
|
||||||
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure_number', (measure['id'],)))
|
|
||||||
if label := measure.get('section_label'):
|
if label := measure.get('section_label'):
|
||||||
items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',)))
|
items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',)))
|
||||||
return items, anchors, fragments
|
return items, anchors, fragments
|
||||||
@@ -103,6 +102,10 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
|
|||||||
for owner, ci, change in index.attributes_by_onset.get(time, []):
|
for owner, ci, change in index.attributes_by_onset.get(time, []):
|
||||||
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
|
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
|
||||||
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
|
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
|
||||||
|
if time == measure_start:
|
||||||
|
# Anchor labels to the barline, not to the first note's time column.
|
||||||
|
bar_x = cursor - generator.engraving.types.bounds(leading).left if leading else 0
|
||||||
|
items.append(fonts.text(str(m['number']), bar_x, 0, 12, 'measure_number', (m['id'],), True))
|
||||||
if leading:
|
if leading:
|
||||||
width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14
|
width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14
|
||||||
items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading)
|
items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading)
|
||||||
@@ -162,7 +165,7 @@ def fit_part_systems(systems, index, available_height, fonts, style):
|
|||||||
|
|
||||||
def fitting_scale(items):
|
def fitting_scale(items):
|
||||||
scale = 1
|
scale = 1
|
||||||
for first in range(0, len(items), 2):
|
for first in range(len(items)):
|
||||||
pair = items[first:first + 2]
|
pair = items[first:first + 2]
|
||||||
height = sum(s.box.bottom - s.box.top for s in pair)
|
height = sum(s.box.bottom - s.box.top for s in pair)
|
||||||
scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height)
|
scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height)
|
||||||
@@ -214,30 +217,36 @@ def part_header(document, fonts, style):
|
|||||||
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
|
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[generator.engraving.types.Page, ...]:
|
def page_views(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[tuple[generator.engraving.types.Page, ...], tuple[generator.engraving.types.Page, ...]]:
|
||||||
pages = []
|
|
||||||
index = index or generator.engraving.notation.ScoreIndex(document)
|
index = index or generator.engraving.notation.ScoreIndex(document)
|
||||||
header = part_header(document, fonts, style)
|
header = part_header(document, fonts, style)
|
||||||
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
||||||
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page_number', (), True) for number in range(1, (len(systems) + 1) // 2 + 1)]
|
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page_number', (), True) for number in range(1, len(systems) + 1)]
|
||||||
bottom = min(p.box.top for p in footers) - style.page_clearance
|
bottom = min(p.box.top for p in footers) - style.page_clearance
|
||||||
if bottom - top <= style.system_gap:
|
if bottom - top <= style.system_gap:
|
||||||
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
|
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
|
||||||
fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
|
fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
|
||||||
for first in range(0, len(systems), 2):
|
|
||||||
number = len(pages) + 1
|
def place(stride):
|
||||||
pair = fitted[first:first + 2]
|
pages = []
|
||||||
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
|
for first in range(0, len(fitted), stride):
|
||||||
spacing = slack / 3 if len(pair) == 2 else 0
|
number = len(pages) + 1
|
||||||
cursor = top + spacing
|
pair = fitted[first:first + 2]
|
||||||
placed = []
|
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
|
||||||
for system in pair:
|
spacing = slack / 3 if len(pair) == 2 else 0
|
||||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
cursor = top + spacing
|
||||||
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
|
placed = []
|
||||||
cursor = placed[-1].box.bottom + style.system_gap + spacing
|
for system in pair:
|
||||||
footer = footers[first // 2]
|
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||||
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
|
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
|
||||||
return tuple(pages)
|
cursor = placed[-1].box.bottom + style.system_gap + spacing
|
||||||
|
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footers[number - 1]])))
|
||||||
|
return tuple(pages)
|
||||||
|
|
||||||
|
return place(2), place(1)
|
||||||
|
|
||||||
|
def paginate(systems, document, fonts, style, *, index=None):
|
||||||
|
return page_views(systems, document, fonts, style, index=index)[0]
|
||||||
|
|
||||||
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
|
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
|
||||||
if index is None:
|
if index is None:
|
||||||
|
|||||||
@@ -101,14 +101,19 @@ def clear_pages(destination):
|
|||||||
shutil.rmtree(destination)
|
shutil.rmtree(destination)
|
||||||
|
|
||||||
def write_bundle(path, document, pages, fingerprint):
|
def write_bundle(path, document, pages, fingerprint):
|
||||||
entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages]
|
entries = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
|
||||||
|
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
|
||||||
|
playback = [entry for entry, page in zip(entries, pages) if page['playback']]
|
||||||
music = document
|
music = document
|
||||||
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
|
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
|
||||||
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': entries}}
|
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': normal, 'playback_pages': playback}}
|
||||||
generator.performance.validate_pages(document, pages)
|
validate_cached_views(document, pages)
|
||||||
bundle['performance'] = generator.performance.timing(document)
|
bundle['performance'] = generator.performance.timing(document)
|
||||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||||
|
|
||||||
|
def validate_cached_views(document, pages):
|
||||||
|
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
|
||||||
|
|
||||||
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
|
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
|
||||||
family = generator.engraving.fonts.Fonts(font_path)
|
family = generator.engraving.fonts.Fonts(font_path)
|
||||||
style = generator.engraving.types.Style()
|
style = generator.engraving.types.Style()
|
||||||
@@ -140,19 +145,25 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
|
|||||||
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
|
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
|
||||||
result.timings['layout'] += time.monotonic() - started
|
result.timings['layout'] += time.monotonic() - started
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
pages_layout = generator.engraving.layout.paginate(systems, document, family, style, index=index)
|
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
|
||||||
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
|
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
|
||||||
if placement not in audited:
|
if placement not in audited:
|
||||||
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
|
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
|
||||||
generator.engraving.audit.audit_layout(document, layout, family)
|
generator.engraving.audit.audit_layout(document, layout, family)
|
||||||
|
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
|
||||||
audited.add(placement)
|
audited.add(placement)
|
||||||
else:
|
else:
|
||||||
for page in pages_layout:
|
for page in pages_layout + playback_layout:
|
||||||
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
|
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
|
||||||
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
|
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
|
||||||
result.timings['pagination'] += time.monotonic() - started
|
result.timings['pagination'] += time.monotonic() - started
|
||||||
pages = []
|
pages = []
|
||||||
for page in pages_layout:
|
rendered = {}
|
||||||
|
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
|
||||||
|
if page.primitives in rendered:
|
||||||
|
name, digest = rendered[page.primitives]
|
||||||
|
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
|
||||||
|
continue
|
||||||
bands = generator.engraving.raster.page_bands(page)
|
bands = generator.engraving.raster.page_bands(page)
|
||||||
if bands is None:
|
if bands is None:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
@@ -161,12 +172,14 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
|
|||||||
body = bodies.get(notation, page, bands[1], family)
|
body = bodies.get(notation, page, bands[1], family)
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
image = generator.engraving.raster.compose_page(page, family, bands, body)
|
image = generator.engraving.raster.compose_page(page, family, bands, body)
|
||||||
target = destination / f'{page.number:04}.png'
|
target = destination / (('playback-' if is_playback else '') + f'{page.number:04}.png')
|
||||||
with image:
|
with image:
|
||||||
image.save(target, format='PNG')
|
image.save(target, format='PNG')
|
||||||
result.timings['raster'] += time.monotonic() - started
|
result.timings['raster'] += time.monotonic() - started
|
||||||
result.renderedpages += 1
|
result.renderedpages += 1
|
||||||
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), **generator.performance.page_timing(document, page)})
|
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||||
|
rendered[page.primitives] = target.name, digest
|
||||||
|
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
|
||||||
scores[score_key] = (destination, pages)
|
scores[score_key] = (destination, pages)
|
||||||
write_bundle(path, document, pages, fingerprint)
|
write_bundle(path, document, pages, fingerprint)
|
||||||
result.scores.append((path, score_key, pages))
|
result.scores.append((path, score_key, pages))
|
||||||
@@ -213,7 +226,7 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
|
|||||||
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
|
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
|
||||||
if pages is not None:
|
if pages is not None:
|
||||||
try:
|
try:
|
||||||
generator.performance.validate_pages(document, pages)
|
validate_cached_views(document, pages)
|
||||||
except (ValueError, TypeError, KeyError):
|
except (ValueError, TypeError, KeyError):
|
||||||
pages = None
|
pages = None
|
||||||
if pages is None:
|
if pages is None:
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
|
|||||||
y = anchor.y - anchor.direction * 8
|
y = anchor.y - anchor.direction * 8
|
||||||
if anchor.x - 2 > left:
|
if anchor.x - 2 > left:
|
||||||
result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
|
result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
|
||||||
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'tempo')), default=0))
|
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'tempo', 'measure_number')), default=0))
|
||||||
tuplet_lanes = []
|
tuplet_lanes = []
|
||||||
for span in index.staff.get('tuplets', []):
|
for span in index.staff.get('tuplets', []):
|
||||||
a, b = index.by_id[span['start']], index.by_id[span['end']]
|
a, b = index.by_id[span['start']], index.by_id[span['end']]
|
||||||
@@ -203,11 +203,11 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
|
|||||||
return items + result
|
return items + result
|
||||||
|
|
||||||
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
|
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
|
||||||
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale')]
|
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'measure_number')]
|
||||||
box = generator.engraving.types.bounds(music)
|
box = generator.engraving.types.bounds(music)
|
||||||
result = list(music)
|
result = list(music)
|
||||||
top = box.top - 12
|
top = box.top - 12
|
||||||
for role in ('harmony', 'scale', 'section'):
|
for role in ('measure_number', 'harmony', 'scale', 'section'):
|
||||||
lane = [p for p in items if p.role == role]
|
lane = [p for p in items if p.role == role]
|
||||||
if lane:
|
if lane:
|
||||||
extent = generator.engraving.types.bounds(lane)
|
extent = generator.engraving.types.bounds(lane)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import fractions
|
import fractions
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -23,7 +24,63 @@ def page_timing(document, page):
|
|||||||
times = boundaries(document)
|
times = boundaries(document)
|
||||||
start, end = page.systems[0].start, page.systems[-1].end
|
start, end = page.systems[0].start, page.systems[-1].end
|
||||||
first, last = times.index(start), times.index(end)
|
first, last = times.index(start), times.index(end)
|
||||||
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end))
|
measures = document['staves'][0]['measures']
|
||||||
|
systems = []
|
||||||
|
for system in page.systems:
|
||||||
|
a, b = times.index(system.start), times.index(system.end)
|
||||||
|
labels = {p.sources[0]: p for p in system.primitives if p.role == 'measure_number'}
|
||||||
|
boxes = []
|
||||||
|
for i in range(a, b):
|
||||||
|
box = labels[measures[i]['id']].box
|
||||||
|
boxes.append(dict(measure=i + 1, bounds=[box.left, box.top, box.right, box.bottom]))
|
||||||
|
systems.append(dict(first_measure=a + 1, measure_count=b - a, start_beat=generator.model.rational(system.start), end_beat=generator.model.rational(system.end), labels=boxes))
|
||||||
|
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end), systems=systems)
|
||||||
|
|
||||||
|
def validate_views(document, pages, playback_pages):
|
||||||
|
validate_pages(document, pages)
|
||||||
|
times = boundaries(document)
|
||||||
|
canonical = []
|
||||||
|
|
||||||
|
def system_range(system):
|
||||||
|
a, count = system['first_measure'], system['measure_count']
|
||||||
|
if type(a) is not int or type(count) is not int or a < 1 or count < 1 or a + count > len(times):
|
||||||
|
raise ValueError('invalid system measure coverage')
|
||||||
|
if generator.model.fraction(system['start_beat']) != times[a - 1] or generator.model.fraction(system['end_beat']) != times[a + count - 1]:
|
||||||
|
raise ValueError('invalid system timing')
|
||||||
|
if [label['measure'] for label in system['labels']] != list(range(a, a + count)):
|
||||||
|
raise ValueError('invalid measure label coverage')
|
||||||
|
for label in system['labels']:
|
||||||
|
box = label['bounds']
|
||||||
|
if not isinstance(box, list) or len(box) != 4 or any(type(v) not in (int, float) or not math.isfinite(v) for v in box) or not (4 <= box[0] < box[2] <= 972 and 4 <= box[1] < box[3] <= 480):
|
||||||
|
raise ValueError('invalid measure label bounds')
|
||||||
|
return a, count
|
||||||
|
|
||||||
|
for page in pages:
|
||||||
|
systems = page.get('systems', [])
|
||||||
|
if len(systems) != 2 and (page is not pages[-1] or len(systems) != 1):
|
||||||
|
raise ValueError('invalid page systems')
|
||||||
|
cursor = page['first_measure']
|
||||||
|
for system in systems:
|
||||||
|
a, count = system_range(system)
|
||||||
|
if a != cursor:
|
||||||
|
raise ValueError('system gap or overlap')
|
||||||
|
cursor += count
|
||||||
|
canonical.append(system)
|
||||||
|
if cursor != page['first_measure'] + page['measure_count']:
|
||||||
|
raise ValueError('systems disagree with page coverage')
|
||||||
|
if not isinstance(playback_pages, list) or len(playback_pages) != len(canonical):
|
||||||
|
raise ValueError('missing playback pages; regenerate score assets')
|
||||||
|
for i, page in enumerate(playback_pages):
|
||||||
|
expected = canonical[i:i + 2]
|
||||||
|
systems = page.get('systems', [])
|
||||||
|
if len(systems) != len(expected):
|
||||||
|
raise ValueError('invalid playback systems')
|
||||||
|
for actual, source in zip(systems, expected):
|
||||||
|
system_range(actual)
|
||||||
|
if any(actual[k] != source[k] for k in ('first_measure', 'measure_count', 'start_beat', 'end_beat')):
|
||||||
|
raise ValueError('playback changes system boundaries')
|
||||||
|
if any(page[k] != expected[0][k] for k in ('first_measure', 'start_beat')) or page['end_beat'] != expected[-1]['end_beat'] or page['measure_count'] != sum(s['measure_count'] for s in expected):
|
||||||
|
raise ValueError('invalid playback page timing')
|
||||||
|
|
||||||
def validate_pages(document, pages):
|
def validate_pages(document, pages):
|
||||||
times = boundaries(document)
|
times = boundaries(document)
|
||||||
|
|||||||
+2
-2
@@ -24,10 +24,10 @@ def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str]
|
|||||||
raise ValueError('invalid score bundle; regenerate score assets')
|
raise ValueError('invalid score bundle; regenerate score assets')
|
||||||
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
|
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
|
||||||
raise ValueError('unsupported score presentation; regenerate score assets')
|
raise ValueError('unsupported score presentation; regenerate score assets')
|
||||||
generator.performance.validate_pages(document['music'], presentation['pages'])
|
generator.performance.validate_views(document['music'], presentation['pages'], presentation.get('playback_pages'))
|
||||||
if document.get('performance') != generator.performance.timing(document['music']):
|
if document.get('performance') != generator.performance.timing(document['music']):
|
||||||
raise ValueError('invalid performance timing')
|
raise ValueError('invalid performance timing')
|
||||||
for page in presentation['pages']:
|
for page in presentation['pages'] + presentation['playback_pages']:
|
||||||
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
|
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
|
||||||
raise ValueError('invalid score page manifest')
|
raise ValueError('invalid score page manifest')
|
||||||
name = page['path']
|
name = page['path']
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public:
|
|||||||
auto refresh_connection() -> std::optional<vamp_zero::app_state>;
|
auto refresh_connection() -> std::optional<vamp_zero::app_state>;
|
||||||
auto set_observer(std::function<void(vamp_zero::app_state)> observer) -> void;
|
auto set_observer(std::function<void(vamp_zero::app_state)> observer) -> void;
|
||||||
auto finish_playback(std::uint64_t session, std::string error = {}, std::shared_ptr<vamp_zero::catalog const> catalog = {}) -> void;
|
auto finish_playback(std::uint64_t session, std::string error = {}, std::shared_ptr<vamp_zero::catalog const> catalog = {}) -> void;
|
||||||
auto update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog) -> void;
|
auto update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog, std::optional<std::size_t> measure = {}) -> void;
|
||||||
private:
|
private:
|
||||||
auto pico_connected_locked() const -> std::bool_t;
|
auto pico_connected_locked() const -> std::bool_t;
|
||||||
auto snapshot_locked() const -> vamp_zero::app_state;
|
auto snapshot_locked() const -> vamp_zero::app_state;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ auto prepare_performance(vamp_zero::score_document const &score, vamp_zero::app_
|
|||||||
struct performance_position {
|
struct performance_position {
|
||||||
std::size_t page{};
|
std::size_t page{};
|
||||||
std::string phase{};
|
std::string phase{};
|
||||||
|
std::optional<std::size_t> measure{};
|
||||||
};
|
};
|
||||||
|
|
||||||
auto performance_at(vamp_zero::score_document const &score, std::uint64_t frames, std::uint32_t bpm) -> vamp_zero::performance_position;
|
auto performance_at(vamp_zero::score_document const &score, std::uint64_t frames, std::uint32_t bpm) -> vamp_zero::performance_position;
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ private:
|
|||||||
std::vector<entry> entries_{};
|
std::vector<entry> entries_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
auto score_page_count(vamp_zero::score_document const &score) -> std::size_t;
|
auto score_page_count(vamp_zero::score_document const &score, bool playback = false) -> std::size_t;
|
||||||
auto validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected<std::string, modulus::error>;
|
auto validated_page_path(vamp_zero::score_document const &score, std::size_t index, bool playback = false) -> std::expected<std::string, modulus::error>;
|
||||||
auto validate_score_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected<void, modulus::error>;
|
auto validate_score_page(vamp_zero::score_document const &score, std::size_t index, bool playback = false) -> std::expected<void, modulus::error>;
|
||||||
|
auto page_rectangle(luma::rect bounds) -> luma::rect;
|
||||||
|
auto measure_highlight(vamp_zero::score_page const &page, std::optional<std::size_t> measure, luma::rect image) -> std::optional<luma::rect>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <array>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -23,6 +25,18 @@ struct score_page {
|
|||||||
std::size_t measure_count{};
|
std::size_t measure_count{};
|
||||||
vamp_zero::beat_position start_beat{};
|
vamp_zero::beat_position start_beat{};
|
||||||
vamp_zero::beat_position end_beat{};
|
vamp_zero::beat_position end_beat{};
|
||||||
|
struct measure_label {
|
||||||
|
std::size_t measure{};
|
||||||
|
std::array<double, 4> bounds{};
|
||||||
|
};
|
||||||
|
struct system {
|
||||||
|
std::size_t first_measure{};
|
||||||
|
std::size_t measure_count{};
|
||||||
|
vamp_zero::beat_position start_beat{};
|
||||||
|
vamp_zero::beat_position end_beat{};
|
||||||
|
std::vector<measure_label> labels{};
|
||||||
|
};
|
||||||
|
std::vector<system> systems{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct score_document {
|
struct score_document {
|
||||||
@@ -41,6 +55,8 @@ struct score_document {
|
|||||||
vamp_zero::beat_position duration{};
|
vamp_zero::beat_position duration{};
|
||||||
vamp_zero::beat_position sustain_start{};
|
vamp_zero::beat_position sustain_start{};
|
||||||
std::uint32_t count_in_beats{4};
|
std::uint32_t count_in_beats{4};
|
||||||
|
std::vector<vamp_zero::score_page> playback_pages{};
|
||||||
|
std::vector<vamp_zero::beat_position> measure_boundaries{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct library_part {
|
struct library_part {
|
||||||
@@ -54,6 +70,7 @@ struct library_part {
|
|||||||
std::size_t total_pages{};
|
std::size_t total_pages{};
|
||||||
std::string title_image{};
|
std::string title_image{};
|
||||||
std::string song_id{};
|
std::string song_id{};
|
||||||
|
std::size_t playback_pages{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct song_group {
|
struct song_group {
|
||||||
@@ -116,6 +133,7 @@ struct app_state {
|
|||||||
std::uint64_t score_session{};
|
std::uint64_t score_session{};
|
||||||
std::uint64_t input_revision{};
|
std::uint64_t input_revision{};
|
||||||
std::bool_t pico_connected{false};
|
std::bool_t pico_connected{false};
|
||||||
|
std::optional<std::size_t> active_measure{};
|
||||||
};
|
};
|
||||||
|
|
||||||
auto to_string(vamp_zero::app_mode value) -> std::string;
|
auto to_string(vamp_zero::app_mode value) -> std::string;
|
||||||
|
|||||||
@@ -180,10 +180,12 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf
|
|||||||
if (!score) {
|
if (!score) {
|
||||||
co_return std::unexpected(score.error());
|
co_return std::unexpected(score.error());
|
||||||
}
|
}
|
||||||
for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score); ++page) {
|
for (auto playback : {false, true}) {
|
||||||
auto valid = vamp_zero::validate_score_page(*score, page);
|
for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score, playback); ++page) {
|
||||||
if (!valid) {
|
auto valid = vamp_zero::validate_score_page(*score, page, playback);
|
||||||
co_return std::unexpected(valid.error());
|
if (!valid) {
|
||||||
|
co_return std::unexpected(valid.error());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
co_return std::expected<void, modulus::error>{};
|
co_return std::expected<void, modulus::error>{};
|
||||||
@@ -301,7 +303,7 @@ private:
|
|||||||
}
|
}
|
||||||
if (loaded.document) {
|
if (loaded.document) {
|
||||||
if (auto position = audio_->position(*loaded.document, app.audio.tempo_bpm)) {
|
if (auto position = audio_->position(*loaded.document, app.audio.tempo_bpm)) {
|
||||||
state_.update_performance(app.score_session, position->page, position->phase, app.catalog);
|
state_.update_performance(app.score_session, position->page, position->phase, app.catalog, position->measure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (vamp_zero::automatic(app.audio) && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) {
|
} else if (vamp_zero::automatic(app.audio) && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) {
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
|
|||||||
} else {
|
} else {
|
||||||
state_.audio.playback_error = std::move(error);
|
state_.audio.playback_error = std::move(error);
|
||||||
state_.audio.phase = "Error";
|
state_.audio.phase = "Error";
|
||||||
|
state_.active_measure.reset();
|
||||||
}
|
}
|
||||||
updated = snapshot_locked();
|
updated = snapshot_locked();
|
||||||
observer = observer_;
|
observer = observer_;
|
||||||
@@ -280,7 +281,7 @@ auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_ro
|
|||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::control_state::update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog) -> void {
|
auto vamp_zero::control_state::update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog, std::optional<std::size_t> measure) -> void {
|
||||||
std::function<void(vamp_zero::app_state)> observer;
|
std::function<void(vamp_zero::app_state)> observer;
|
||||||
vamp_zero::app_state updated;
|
vamp_zero::app_state updated;
|
||||||
{
|
{
|
||||||
@@ -288,11 +289,12 @@ auto vamp_zero::control_state::update_performance(std::uint64_t session, std::si
|
|||||||
if (!vamp_zero::automatic(state_.audio) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE || state_.score_session != session || state_.catalog != catalog || !state_.audio.playback_error.empty()) {
|
if (!vamp_zero::automatic(state_.audio) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE || state_.score_session != session || state_.catalog != catalog || !state_.audio.playback_error.empty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state_.page_index == page && state_.audio.phase == phase) {
|
if (state_.page_index == page && state_.audio.phase == phase && state_.active_measure == measure) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state_.page_index = page;
|
state_.page_index = page;
|
||||||
state_.audio.phase = std::move(phase);
|
state_.audio.phase = std::move(phase);
|
||||||
|
state_.active_measure = measure;
|
||||||
++state_.input_revision;
|
++state_.input_revision;
|
||||||
updated = snapshot_locked();
|
updated = snapshot_locked();
|
||||||
observer = observer_;
|
observer = observer_;
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
|
|||||||
co_return std::unexpected(connected.error());
|
co_return std::unexpected(connected.error());
|
||||||
}
|
}
|
||||||
auto selected =
|
auto selected =
|
||||||
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image, json_extract(source_json, '$.music.metadata.song_id') FROM "
|
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image, json_extract(source_json, '$.music.metadata.song_id'), json_array_length(source_json, '$.presentation.playback_pages') FROM "
|
||||||
"songs ORDER BY title, composer, part, id");
|
"songs ORDER BY title, composer, part, id");
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
auto close = co_await connection.async_close();
|
auto close = co_await connection.async_close();
|
||||||
@@ -180,8 +180,8 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
auto const &row = **next;
|
auto const &row = **next;
|
||||||
if (row.size() != 10U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) {
|
if (row.size() != 11U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || count_value(row[10]) == 0U || !valid_title_image(row[8])) {
|
||||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
|
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata; regenerate score assets"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
auto source = resolve_source_path(assets_path, text_value(row[4]));
|
auto source = resolve_source_path(assets_path, text_value(row[4]));
|
||||||
@@ -200,6 +200,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
|
|||||||
.total_pages = count_value(row[7]),
|
.total_pages = count_value(row[7]),
|
||||||
.title_image = text_value(row[8]),
|
.title_image = text_value(row[8]),
|
||||||
.song_id = text_value(row[9]),
|
.song_id = text_value(row[9]),
|
||||||
|
.playback_pages = count_value(row[10]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
auto audio_assets = std::expected<std::vector<vamp_zero::audio_asset>, modulus::error>{};
|
auto audio_assets = std::expected<std::vector<vamp_zero::audio_asset>, modulus::error>{};
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ auto vamp_zero::draw_header(luma::draw_context &context, luma::rect bounds, vamp
|
|||||||
context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
|
context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
|
||||||
draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink);
|
draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink);
|
||||||
if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) {
|
if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) {
|
||||||
auto summary = state.audio.phase + " " + std::to_string(state.audio.tempo_bpm) + " BPM " + std::to_string(state.page_index + 1U) + "/" + std::to_string(part->total_pages);
|
auto summary = state.audio.phase + " " + std::to_string(state.audio.tempo_bpm) + " BPM " + (vamp_zero::automatic(state.audio) ? "Line " : "Page ") + std::to_string(state.page_index + 1U) + "/" + std::to_string(vamp_zero::active_pages(state));
|
||||||
draw_label(context, {bounds.x + 310.0F, bounds.y + 46.0F, bounds.width - 550.0F, 26.0F}, "Hold to exit", 15.0F, vamp_style::muted);
|
draw_label(context, {bounds.x + 310.0F, bounds.y + 46.0F, bounds.width - 550.0F, 26.0F}, "Hold to exit", 15.0F, vamp_style::muted);
|
||||||
draw_label(context, {bounds.x + 310.0F, bounds.y + 7.0F, bounds.width - 550.0F, 38.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger);
|
draw_label(context, {bounds.x + 310.0F, bounds.y + 7.0F, bounds.width - 550.0F, 38.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
#include <vamp_zero/navigation.hpp>
|
#include <vamp_zero/navigation.hpp>
|
||||||
|
#include <vamp_zero/performance.hpp>
|
||||||
|
|
||||||
|
|
||||||
auto vamp_zero::selected_group(vamp_zero::app_state const &state) -> vamp_zero::song_group const * {
|
auto vamp_zero::selected_group(vamp_zero::app_state const &state) -> vamp_zero::song_group const * {
|
||||||
@@ -40,7 +41,7 @@ auto vamp_zero::active_part(vamp_zero::app_state const &state) -> vamp_zero::lib
|
|||||||
auto vamp_zero::active_pages(vamp_zero::app_state const &state) -> std::size_t {
|
auto vamp_zero::active_pages(vamp_zero::app_state const &state) -> std::size_t {
|
||||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||||
auto const *part = vamp_zero::active_part(state);
|
auto const *part = vamp_zero::active_part(state);
|
||||||
return part == nullptr ? 0U : part->total_pages;
|
return part == nullptr ? 0U : (automatic(state.audio) ? part->playback_pages : part->total_pages);
|
||||||
}
|
}
|
||||||
return 0U;
|
return 0U;
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-31
@@ -1,5 +1,6 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cmath>
|
||||||
#include <expected>
|
#include <expected>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -141,42 +142,120 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
|
|||||||
} catch (...) {
|
} catch (...) {
|
||||||
return invalid("missing performance timing; regenerate score assets");
|
return invalid("missing performance timing; regenerate score assets");
|
||||||
}
|
}
|
||||||
auto cursor = std::size_t{};
|
try {
|
||||||
for (auto const &item : *pages) {
|
for (auto const &time : times) {
|
||||||
if (!item.is_object()) {
|
document.measure_boundaries.push_back({numerator(time).convert_to<std::uint64_t>(), denominator(time).convert_to<std::uint64_t>()});
|
||||||
return invalid("page must be an object");
|
|
||||||
}
|
}
|
||||||
auto path = string_field(item.as_object(), "path");
|
auto const *playback = array_field(*presentation, "playback_pages");
|
||||||
auto hash = string_field(item.as_object(), "sha256");
|
if (!playback || playback->empty() || playback->size() > 10000) {
|
||||||
if (!path || !hash) {
|
return invalid("missing playback pages; regenerate score assets");
|
||||||
return invalid("page requires path and sha256 strings");
|
|
||||||
}
|
}
|
||||||
auto relative = std::filesystem::path{*path};
|
auto parse_page = [&](boost::json::value const &item) -> score_page {
|
||||||
if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos ||
|
if (!item.is_object()) {
|
||||||
relative.is_absolute() || relative.extension() != ".png" ||
|
throw std::invalid_argument("page must be an object");
|
||||||
std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) {
|
}
|
||||||
return invalid("page paths must be safe relative PNG paths");
|
auto path = string_field(item.as_object(), "path");
|
||||||
}
|
auto hash = string_field(item.as_object(), "sha256");
|
||||||
if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) {
|
if (!path || !hash) {
|
||||||
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
|
throw std::invalid_argument("page requires path and sha256 strings");
|
||||||
})) {
|
}
|
||||||
return invalid("page sha256 must be 64 lowercase hexadecimal digits");
|
auto relative = std::filesystem::path{*path};
|
||||||
}
|
if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos ||
|
||||||
try {
|
relative.is_absolute() || relative.extension() != ".png" ||
|
||||||
auto first = item.at("first_measure").to_number<std::size_t>();
|
std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) {
|
||||||
auto count = item.at("measure_count").to_number<std::size_t>();
|
throw std::invalid_argument("page paths must be safe relative PNG paths");
|
||||||
auto start = vamp_zero::parse_beat(item.at("start_beat")), end = vamp_zero::parse_beat(item.at("end_beat"));
|
}
|
||||||
if (first != cursor + 1 || count == 0 || count > measures->size() - cursor || !start || !end || rational(*start) != times[cursor] || rational(*end) != times[cursor + count]) {
|
if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) {
|
||||||
|
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
|
||||||
|
})) {
|
||||||
|
throw std::invalid_argument("page sha256 must be 64 lowercase hexadecimal digits");
|
||||||
|
}
|
||||||
|
auto parse_range = [&](boost::json::value const &value, auto &range) {
|
||||||
|
range.first_measure = value.at("first_measure").to_number<std::size_t>();
|
||||||
|
range.measure_count = value.at("measure_count").to_number<std::size_t>();
|
||||||
|
auto a = range.first_measure, count = range.measure_count;
|
||||||
|
auto start = parse_beat(value.at("start_beat")), end = parse_beat(value.at("end_beat"));
|
||||||
|
if (!a || a > measures->size() || !count || count > measures->size() - (a - 1) || !start || !end || rational(*start) != times[a - 1] || rational(*end) != times[a + count - 1]) {
|
||||||
|
throw std::invalid_argument("invalid page/system timing or coverage");
|
||||||
|
}
|
||||||
|
range.start_beat = *start;
|
||||||
|
range.end_beat = *end;
|
||||||
|
};
|
||||||
|
auto result = score_page{};
|
||||||
|
result.path = std::move(*path);
|
||||||
|
result.sha256 = std::move(*hash);
|
||||||
|
parse_range(item, result);
|
||||||
|
auto const &systems = item.at("systems").as_array();
|
||||||
|
if (systems.empty() || systems.size() > 2) {
|
||||||
|
throw std::invalid_argument("invalid page systems");
|
||||||
|
}
|
||||||
|
auto cursor = result.first_measure;
|
||||||
|
for (auto const &value : systems) {
|
||||||
|
auto system = score_page::system{};
|
||||||
|
parse_range(value, system);
|
||||||
|
if (system.first_measure != cursor) {
|
||||||
|
throw std::invalid_argument("system gap or overlap");
|
||||||
|
}
|
||||||
|
auto const &labels = value.at("labels").as_array();
|
||||||
|
if (labels.size() != system.measure_count) {
|
||||||
|
throw std::invalid_argument("invalid measure label coverage");
|
||||||
|
}
|
||||||
|
for (auto const &label : labels) {
|
||||||
|
auto parsed = score_page::measure_label{};
|
||||||
|
parsed.measure = label.at("measure").to_number<std::size_t>();
|
||||||
|
auto const &bounds = label.at("bounds").as_array();
|
||||||
|
if (parsed.measure != cursor++ || bounds.size() != 4) {
|
||||||
|
throw std::invalid_argument("invalid measure label");
|
||||||
|
}
|
||||||
|
for (std::size_t k = 0; k < 4; ++k) {
|
||||||
|
parsed.bounds[k] = bounds[k].to_number<double>();
|
||||||
|
if (!std::isfinite(parsed.bounds[k])) {
|
||||||
|
throw std::invalid_argument("non-finite label bounds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto const &r = parsed.bounds;
|
||||||
|
if (!(4 <= r[0] && r[0] < r[2] && r[2] <= 972 && 4 <= r[1] && r[1] < r[3] && r[3] <= 480)) {
|
||||||
|
throw std::invalid_argument("invalid label bounds");
|
||||||
|
}
|
||||||
|
system.labels.push_back(parsed);
|
||||||
|
}
|
||||||
|
result.systems.push_back(std::move(system));
|
||||||
|
}
|
||||||
|
if (cursor != result.first_measure + result.measure_count) {
|
||||||
|
throw std::invalid_argument("systems disagree with page coverage");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
auto cursor = std::size_t{1};
|
||||||
|
auto canonical = std::vector<score_page::system>{};
|
||||||
|
for (auto const &item : *pages) {
|
||||||
|
auto page = parse_page(item);
|
||||||
|
if (page.first_measure != cursor || (page.systems.size() != 2 && &item != &pages->back())) {
|
||||||
return invalid("invalid page measure coverage");
|
return invalid("invalid page measure coverage");
|
||||||
}
|
}
|
||||||
document.pages.push_back({std::move(*path), std::move(*hash), first, count, *start, *end});
|
cursor += page.measure_count;
|
||||||
cursor += count;
|
canonical.insert(canonical.end(), page.systems.begin(), page.systems.end());
|
||||||
} catch (...) {
|
document.pages.push_back(std::move(page));
|
||||||
return invalid("missing page timing; regenerate score assets");
|
|
||||||
}
|
}
|
||||||
}
|
if (cursor != measures->size() + 1 || playback->size() != canonical.size()) {
|
||||||
if (cursor != measures->size()) {
|
return invalid("incomplete normal or playback coverage");
|
||||||
return invalid("incomplete page measure coverage");
|
}
|
||||||
|
for (std::size_t i = 0; i < playback->size(); ++i) {
|
||||||
|
auto page = parse_page((*playback)[i]);
|
||||||
|
if (page.systems.size() != std::min(std::size_t{2}, canonical.size() - i)) {
|
||||||
|
return invalid("invalid playback system pairing");
|
||||||
|
}
|
||||||
|
for (std::size_t j = 0; j < page.systems.size(); ++j) {
|
||||||
|
auto const &a = canonical[i + j];
|
||||||
|
auto const &b = page.systems[j];
|
||||||
|
if (a.first_measure != b.first_measure || a.measure_count != b.measure_count) {
|
||||||
|
return invalid("playback changes system boundaries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.playback_pages.push_back(std::move(page));
|
||||||
|
}
|
||||||
|
} catch (std::exception const &error) {
|
||||||
|
return invalid(std::string{"invalid playback presentation: "} + error.what() + "; regenerate score assets");
|
||||||
}
|
}
|
||||||
document.id = std::filesystem::path{source_path}.stem().string();
|
document.id = std::filesystem::path{source_path}.stem().string();
|
||||||
document.source_path = std::move(source_path);
|
document.source_path = std::move(source_path);
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
|||||||
: vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
: vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||||
state.active_part_id.clear();
|
state.active_part_id.clear();
|
||||||
state.page_index = 0;
|
state.page_index = 0;
|
||||||
|
state.active_measure.reset();
|
||||||
}
|
}
|
||||||
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
|
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
|
||||||
state.audio.tempo_editing = false;
|
state.audio.tempo_editing = false;
|
||||||
@@ -98,6 +99,10 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
|||||||
state.page_index = std::min(state.page_index, pages - 1U);
|
state.page_index = std::min(state.page_index, pages - 1U);
|
||||||
} else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
} else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||||
state.page_index = 0;
|
state.page_index = 0;
|
||||||
|
state.active_measure.reset();
|
||||||
|
}
|
||||||
|
if (!automatic(state.audio)) {
|
||||||
|
state.active_measure.reset();
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -179,9 +184,11 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
|||||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||||
state.active_part_id.clear();
|
state.active_part_id.clear();
|
||||||
state.page_index = 0;
|
state.page_index = 0;
|
||||||
|
state.active_measure.reset();
|
||||||
} else if (auto const *part = selected_part(state); part != nullptr) {
|
} else if (auto const *part = selected_part(state); part != nullptr) {
|
||||||
state.active_part_id = part->id;
|
state.active_part_id = part->id;
|
||||||
state.page_index = 0;
|
state.page_index = 0;
|
||||||
|
state.active_measure.reset();
|
||||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||||
++state.score_session;
|
++state.score_session;
|
||||||
state.audio.playback_error.clear();
|
state.audio.playback_error.clear();
|
||||||
@@ -202,6 +209,7 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
|||||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||||
state.active_part_id.clear();
|
state.active_part_id.clear();
|
||||||
state.page_index = 0;
|
state.page_index = 0;
|
||||||
|
state.active_measure.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return clamp_state(std::move(state));
|
return clamp_state(std::move(state));
|
||||||
@@ -223,7 +231,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
|
|||||||
title_image = selected->title_image;
|
title_image = selected->title_image;
|
||||||
part = selected->part;
|
part = selected->part;
|
||||||
page = state.page_index + 1U;
|
page = state.page_index + 1U;
|
||||||
pages = selected->total_pages;
|
pages = active_pages(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto focus = menu_row(state, menu_selection(state)).label;
|
auto focus = menu_row(state, menu_selection(state)).label;
|
||||||
@@ -286,7 +294,7 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
|
|||||||
if (part == nullptr) {
|
if (part == nullptr) {
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score mode requires an active part"));
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score mode requires an active part"));
|
||||||
}
|
}
|
||||||
if (part->total_pages == 0U || state.page_index >= part->total_pages) {
|
if (active_pages(state) == 0U || state.page_index >= active_pages(state)) {
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score page index is out of range"));
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score page index is out of range"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,10 +201,14 @@ auto vamp_zero::performance_at(vamp_zero::score_document const &score, std::uint
|
|||||||
}
|
}
|
||||||
auto beat_frames = frames - count_in;
|
auto beat_frames = frames - count_in;
|
||||||
auto page = std::size_t{};
|
auto page = std::size_t{};
|
||||||
while (page + 1 < score.pages.size() && frame_at(score.pages[page + 1].start_beat, bpm) <= beat_frames) {
|
while (page + 1 < score.playback_pages.size() && frame_at(score.playback_pages[page + 1].start_beat, bpm) <= beat_frames) {
|
||||||
++page;
|
++page;
|
||||||
}
|
}
|
||||||
return {page, beat_frames < frame_at(score.sustain_start, bpm) ? "Playing" : "Sustain"};
|
auto measure = std::size_t{1};
|
||||||
|
while (measure < score.measure_count && frame_at(score.measure_boundaries[measure], bpm) <= beat_frames) {
|
||||||
|
++measure;
|
||||||
|
}
|
||||||
|
return {page, beat_frames < frame_at(score.sustain_start, bpm) ? "Playing" : "Sustain", measure};
|
||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
|
auto vamp_zero::validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
|
||||||
|
|||||||
+38
-10
@@ -43,8 +43,35 @@ auto vamp_zero::score_page_cache::size() const -> std::size_t {
|
|||||||
return entries_.size();
|
return entries_.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::score_page_count(vamp_zero::score_document const &score) -> std::size_t {
|
auto vamp_zero::score_page_count(vamp_zero::score_document const &score, bool playback) -> std::size_t {
|
||||||
return score.pages.size();
|
return (playback ? score.playback_pages : score.pages).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto vamp_zero::page_rectangle(luma::rect bounds) -> luma::rect {
|
||||||
|
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F);
|
||||||
|
return {.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F,
|
||||||
|
.y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F,
|
||||||
|
.width = 976.0F * scale,
|
||||||
|
.height = 484.0F * scale};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto vamp_zero::measure_highlight(vamp_zero::score_page const &page, std::optional<std::size_t> measure, luma::rect image) -> std::optional<luma::rect> {
|
||||||
|
if (!measure) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
for (auto const &system : page.systems) {
|
||||||
|
for (auto const &label : system.labels) {
|
||||||
|
if (label.measure == *measure) {
|
||||||
|
auto scale = image.width / 976.0F;
|
||||||
|
auto const &b = label.bounds;
|
||||||
|
return luma::rect{.x = image.x + static_cast<float>(b[0] - 3) * scale,
|
||||||
|
.y = image.y + static_cast<float>(b[1] - 3) * scale,
|
||||||
|
.width = static_cast<float>(b[2] - b[0] + 6) * scale,
|
||||||
|
.height = static_cast<float>(b[3] - b[1] + 6) * scale};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -54,11 +81,12 @@ struct page_file {
|
|||||||
std::vector<std::uchar_t> bytes;
|
std::vector<std::uchar_t> bytes;
|
||||||
};
|
};
|
||||||
|
|
||||||
auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected<page_file, modulus::error> {
|
auto read_page(vamp_zero::score_document const &score, std::size_t index, bool playback) -> std::expected<page_file, modulus::error> {
|
||||||
auto fail = [&](std::string message) -> std::expected<page_file, modulus::error> {
|
auto fail = [&](std::string message) -> std::expected<page_file, modulus::error> {
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, score.source_path + ": " + std::move(message) + "; regenerate score page assets"));
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, score.source_path + ": " + std::move(message) + "; regenerate score page assets"));
|
||||||
};
|
};
|
||||||
if (index >= score.pages.size()) {
|
auto const &pages = playback ? score.playback_pages : score.pages;
|
||||||
|
if (index >= pages.size()) {
|
||||||
return fail("page index outside score");
|
return fail("page index outside score");
|
||||||
}
|
}
|
||||||
auto error = std::error_code{};
|
auto error = std::error_code{};
|
||||||
@@ -66,7 +94,7 @@ auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std
|
|||||||
if (error) {
|
if (error) {
|
||||||
return fail("score asset directory is missing");
|
return fail("score asset directory is missing");
|
||||||
}
|
}
|
||||||
auto path = std::filesystem::canonical(root / score.pages[index].path, error);
|
auto path = std::filesystem::canonical(root / pages[index].path, error);
|
||||||
if (error) {
|
if (error) {
|
||||||
return fail("score page is missing");
|
return fail("score page is missing");
|
||||||
}
|
}
|
||||||
@@ -95,7 +123,7 @@ auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std
|
|||||||
hash.push_back(digits[byte >> 4U]);
|
hash.push_back(digits[byte >> 4U]);
|
||||||
hash.push_back(digits[byte & 15U]);
|
hash.push_back(digits[byte & 15U]);
|
||||||
}
|
}
|
||||||
if (hash != score.pages[index].sha256) {
|
if (hash != pages[index].sha256) {
|
||||||
return fail("score page checksum mismatch");
|
return fail("score page checksum mismatch");
|
||||||
}
|
}
|
||||||
return page_file{path.string(), std::move(bytes)};
|
return page_file{path.string(), std::move(bytes)};
|
||||||
@@ -103,16 +131,16 @@ auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected<std::string, modulus::error> {
|
auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std::size_t index, bool playback) -> std::expected<std::string, modulus::error> {
|
||||||
auto page = read_page(score, index);
|
auto page = read_page(score, index, playback);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return std::unexpected(page.error());
|
return std::unexpected(page.error());
|
||||||
}
|
}
|
||||||
return std::move(page->path);
|
return std::move(page->path);
|
||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::validate_score_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected<void, modulus::error> {
|
auto vamp_zero::validate_score_page(vamp_zero::score_document const &score, std::size_t index, bool playback) -> std::expected<void, modulus::error> {
|
||||||
auto page = read_page(score, index);
|
auto page = read_page(score, index, playback);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return std::unexpected(page.error());
|
return std::unexpected(page.error());
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-13
@@ -23,6 +23,7 @@
|
|||||||
#include <vamp_zero/navigation.hpp>
|
#include <vamp_zero/navigation.hpp>
|
||||||
#include <vamp_zero/menu_view.hpp>
|
#include <vamp_zero/menu_view.hpp>
|
||||||
#include <vamp_zero/score.hpp>
|
#include <vamp_zero/score.hpp>
|
||||||
|
#include <vamp_zero/performance.hpp>
|
||||||
#include <vamp_zero/score_loader.hpp>
|
#include <vamp_zero/score_loader.hpp>
|
||||||
#include <vamp_zero/touch_input.hpp>
|
#include <vamp_zero/touch_input.hpp>
|
||||||
|
|
||||||
@@ -46,8 +47,9 @@ struct score_draw_cache {
|
|||||||
if (!score) {
|
if (!score) {
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "score no longer selected"));
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "score no longer selected"));
|
||||||
}
|
}
|
||||||
auto found = std::find_if(score->pages.begin(), score->pages.end(), [&](auto const &page) { return page.path == name; });
|
auto const &pages = playback ? score->playback_pages : score->pages;
|
||||||
auto path = vamp_zero::validated_page_path(*score, static_cast<std::size_t>(std::distance(score->pages.begin(), found)));
|
auto found = std::find_if(pages.begin(), pages.end(), [&](auto const &page) { return page.path == name; });
|
||||||
|
auto path = vamp_zero::validated_page_path(*score, static_cast<std::size_t>(std::distance(pages.begin(), found)), playback);
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return std::unexpected(path.error());
|
return std::unexpected(path.error());
|
||||||
}
|
}
|
||||||
@@ -60,30 +62,34 @@ struct score_draw_cache {
|
|||||||
textures.clear();
|
textures.clear();
|
||||||
document = score;
|
document = score;
|
||||||
}
|
}
|
||||||
auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index) -> std::expected<void, modulus::error> {
|
auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index, bool automatic, std::optional<std::size_t> measure) -> std::expected<void, modulus::error> {
|
||||||
auto score = document.lock();
|
auto score = document.lock();
|
||||||
if (!score || index >= score->pages.size()) {
|
playback = automatic;
|
||||||
|
if (!score || index >= vamp_zero::score_page_count(*score, playback)) {
|
||||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score"));
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score"));
|
||||||
}
|
}
|
||||||
auto texture = textures.get(score->pages[index].path);
|
auto const &pages = playback ? score->playback_pages : score->pages;
|
||||||
|
auto texture = textures.get(pages[index].path);
|
||||||
if (!texture) {
|
if (!texture) {
|
||||||
return std::unexpected(texture.error());
|
return std::unexpected(texture.error());
|
||||||
}
|
}
|
||||||
if (index + 1 < score->pages.size()) {
|
if (index + 1 < pages.size()) {
|
||||||
auto next = textures.get(score->pages[index + 1].path);
|
auto next = textures.get(pages[index + 1].path);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
return std::unexpected(next.error());
|
return std::unexpected(next.error());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F);
|
auto page = vamp_zero::page_rectangle(bounds);
|
||||||
auto page = luma::rect{.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F,
|
|
||||||
.y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F,
|
|
||||||
.width = 976.0F * scale,
|
|
||||||
.height = 484.0F * scale};
|
|
||||||
context.fill_rect(page, vamp_zero::to_luma(vamp_style::background));
|
context.fill_rect(page, vamp_zero::to_luma(vamp_style::background));
|
||||||
context.draw_texture(**texture, {.x = 0, .y = 0, .width = 976, .height = 484}, page, {.r = 255, .g = 255, .b = 255, .a = 255});
|
context.draw_texture(**texture, {.x = 0, .y = 0, .width = 976, .height = 484}, page, {.r = 255, .g = 255, .b = 255, .a = 255});
|
||||||
|
if (playback) {
|
||||||
|
if (auto highlight = vamp_zero::measure_highlight(pages[index], measure, page)) {
|
||||||
|
context.stroke_rect(*highlight, std::max(1.0F, 1.5F * page.width / 976.0F), vamp_zero::to_luma(vamp_style::accent));
|
||||||
|
}
|
||||||
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
bool playback{};
|
||||||
vamp_zero::score_page_cache textures;
|
vamp_zero::score_page_cache textures;
|
||||||
std::weak_ptr<vamp_zero::score_document const> document{};
|
std::weak_ptr<vamp_zero::score_document const> document{};
|
||||||
};
|
};
|
||||||
@@ -108,7 +114,7 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
|
|||||||
.width = bounds.width - 48.0F,
|
.width = bounds.width - 48.0F,
|
||||||
.height = bounds.height - 36.0F,
|
.height = bounds.height - 36.0F,
|
||||||
};
|
};
|
||||||
auto status = score_cache.draw(context, paper_bounds, state.page_index);
|
auto status = score_cache.draw(context, paper_bounds, state.page_index, vamp_zero::automatic(state.audio), state.active_measure);
|
||||||
loader.pages_prepared(loaded.document, status);
|
loader.pages_prepared(loaded.document, status);
|
||||||
if (!status) {
|
if (!status) {
|
||||||
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger);
|
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ inline auto add_timing(boost::json::value &value) -> void {
|
|||||||
music.at("metadata").as_object()["song_id"] = "fixture";
|
music.at("metadata").as_object()["song_id"] = "fixture";
|
||||||
auto &measures = music.at("staves").as_array().front().at("measures").as_array();
|
auto &measures = music.at("staves").as_array().front().at("measures").as_array();
|
||||||
auto &pages = value.at("presentation").at("pages").as_array();
|
auto &pages = value.at("presentation").at("pages").as_array();
|
||||||
while (measures.size() < pages.size()) {
|
while (measures.size() < 2 * pages.size() - 1) {
|
||||||
measures.push_back(boost::json::object{});
|
measures.push_back(boost::json::object{});
|
||||||
}
|
}
|
||||||
auto beat = [](std::size_t n) { return boost::json::object{{"numerator", n}, {"denominator", 1}}; };
|
auto beat = [](std::size_t n) { return boost::json::object{{"numerator", n}, {"denominator", 1}}; };
|
||||||
@@ -32,15 +32,43 @@ inline auto add_timing(boost::json::value &value) -> void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
std::size_t cursor{};
|
std::size_t cursor{};
|
||||||
|
auto systems = boost::json::array{};
|
||||||
for (std::size_t i = 0; i < pages.size(); ++i) {
|
for (std::size_t i = 0; i < pages.size(); ++i) {
|
||||||
auto count = i + 1 == pages.size() ? measures.size() - cursor : 1U;
|
auto count = i + 1 == pages.size() ? measures.size() - cursor : 2U;
|
||||||
auto &page = pages[i].as_object();
|
auto &page = pages[i].as_object();
|
||||||
page["first_measure"] = cursor + 1;
|
page["first_measure"] = cursor + 1;
|
||||||
page["measure_count"] = count;
|
page["measure_count"] = count;
|
||||||
page["start_beat"] = beat(cursor * 4);
|
page["start_beat"] = beat(cursor * 4);
|
||||||
page["end_beat"] = beat((cursor + count) * 4);
|
page["end_beat"] = beat((cursor + count) * 4);
|
||||||
cursor += count;
|
auto members = boost::json::array{};
|
||||||
|
for (std::size_t j = 0; j < (i + 1 == pages.size() ? 1U : 2U); ++j) {
|
||||||
|
auto n = i + 1 == pages.size() ? count : 1U;
|
||||||
|
auto labels = boost::json::array{};
|
||||||
|
for (std::size_t k = 0; k < n; ++k) {
|
||||||
|
labels.push_back(boost::json::object{{"measure", cursor + k + 1}, {"bounds", boost::json::array{30 + k * 30, 70 + j * 220, 42 + k * 30, 82 + j * 220}}});
|
||||||
|
}
|
||||||
|
auto system = boost::json::object{{"first_measure", cursor + 1}, {"measure_count", n}, {"start_beat", beat(cursor * 4)}, {"end_beat", beat((cursor + n) * 4)}, {"labels", labels}};
|
||||||
|
members.push_back(system);
|
||||||
|
systems.push_back(system);
|
||||||
|
cursor += n;
|
||||||
|
}
|
||||||
|
page["systems"] = members;
|
||||||
}
|
}
|
||||||
|
auto playback = boost::json::array{};
|
||||||
|
for (std::size_t i = 0; i < systems.size(); ++i) {
|
||||||
|
auto page = pages[std::min(i / 2, pages.size() - 1)].as_object();
|
||||||
|
auto members = boost::json::array{systems[i]};
|
||||||
|
if (i + 1 < systems.size()) {
|
||||||
|
members.push_back(systems[i + 1]);
|
||||||
|
}
|
||||||
|
page["systems"] = members;
|
||||||
|
page["first_measure"] = members.front().at("first_measure");
|
||||||
|
page["start_beat"] = members.front().at("start_beat");
|
||||||
|
page["end_beat"] = members.back().at("end_beat");
|
||||||
|
page["measure_count"] = members.back().at("first_measure").to_number<std::size_t>() + members.back().at("measure_count").to_number<std::size_t>() - members.front().at("first_measure").to_number<std::size_t>();
|
||||||
|
playback.push_back(page);
|
||||||
|
}
|
||||||
|
value.at("presentation").as_object()["playback_pages"] = playback;
|
||||||
value.as_object()["performance"] = boost::json::object{{"song_id", "fixture"}, {"duration", beat(measures.size() * 4)}, {"count_in_beats", 4}, {"clicks", clicks}};
|
value.as_object()["performance"] = boost::json::object{{"song_id", "fixture"}, {"duration", beat(measures.size() * 4)}, {"count_in_beats", 4}, {"clicks", clicks}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
+2
-2
@@ -245,7 +245,7 @@ BOOST_AUTO_TEST_CASE(empty_catalog_navigation_and_main_menu_wrap) {
|
|||||||
BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display) {
|
BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display) {
|
||||||
auto parts = std::vector<vamp_zero::library_part>{};
|
auto parts = std::vector<vamp_zero::library_part>{};
|
||||||
for (auto name : {"Acoustic", "Electric", "Vocal"}) {
|
for (auto name : {"Acoustic", "Electric", "Vocal"}) {
|
||||||
parts.push_back({.id = name, .title = "Song", .composer = "Composer", .part = name, .total_pages = 3});
|
parts.push_back({.id = name, .title = "Song", .composer = "Composer", .part = name, .total_pages = 3, .playback_pages = 5});
|
||||||
}
|
}
|
||||||
auto state = vamp_zero::app_state{
|
auto state = vamp_zero::app_state{
|
||||||
.catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::group_ordered_parts(std::move(parts))),
|
.catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::group_ordered_parts(std::move(parts))),
|
||||||
@@ -264,7 +264,7 @@ BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display
|
|||||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
||||||
BOOST_TEST(remote.active);
|
BOOST_TEST(remote.active);
|
||||||
BOOST_TEST(std::string{remote.part} == name);
|
BOOST_TEST(std::string{remote.part} == name);
|
||||||
BOOST_TEST(remote.pages == 3U);
|
BOOST_TEST(remote.pages == 5U);
|
||||||
for (auto index = std::uint32_t{}; index < 4U; ++index) {
|
for (auto index = std::uint32_t{}; index < 4U; ++index) {
|
||||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
|
|||||||
// Complete cached metadata must not require parsing or retaining the score body.
|
// Complete cached metadata must not require parsing or retaining the score body.
|
||||||
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
|
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
|
||||||
"key_signature, measure_count, total_pages, title_image) "
|
"key_signature, measure_count, total_pages, title_image) "
|
||||||
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000')");
|
"VALUES('tune', 'Metadata Song', 'Composer', '{\"presentation\":{\"playback_pages\":[{},{},{}]}}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000')");
|
||||||
auto songs = db.rows("songs");
|
auto songs = db.rows("songs");
|
||||||
for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) {
|
for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) {
|
||||||
auto performance = db.performance();
|
auto performance = db.performance();
|
||||||
@@ -295,6 +295,7 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
|
|||||||
BOOST_TEST(performance->parts[0].key_signature == "F");
|
BOOST_TEST(performance->parts[0].key_signature == "F");
|
||||||
BOOST_TEST(performance->parts[0].measure_count == 3U);
|
BOOST_TEST(performance->parts[0].measure_count == 3U);
|
||||||
BOOST_TEST(performance->parts[0].total_pages == 2U);
|
BOOST_TEST(performance->parts[0].total_pages == 2U);
|
||||||
|
BOOST_TEST(performance->parts[0].playback_pages == 3U);
|
||||||
}
|
}
|
||||||
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
|
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
|
||||||
auto song = db.score("tune");
|
auto song = db.score("tune");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import generator.engraving.types
|
|||||||
import generator.guitar
|
import generator.guitar
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.musicxml
|
import generator.musicxml
|
||||||
|
import generator.performance
|
||||||
import tests.score_events
|
import tests.score_events
|
||||||
import tests.arrangement_fixtures
|
import tests.arrangement_fixtures
|
||||||
import tests.test_accompaniment
|
import tests.test_accompaniment
|
||||||
@@ -139,6 +140,56 @@ class EngravingTests(unittest.TestCase):
|
|||||||
actual = generator.engraving.raster.rasterize_page(layout.pages[0], self.fonts)
|
actual = generator.engraving.raster.rasterize_page(layout.pages[0], self.fonts)
|
||||||
with PIL.Image.open('tests/fixtures/notation_python.png') as expected:
|
with PIL.Image.open('tests/fixtures/notation_python.png') as expected:
|
||||||
self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox())
|
self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox())
|
||||||
|
def test_playback_windows_reuse_complete_systems_and_finish_on_top(self):
|
||||||
|
for count in (1, 2, 3, 4):
|
||||||
|
with self.subTest(systems=count):
|
||||||
|
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * count))
|
||||||
|
style = generator.engraving.types.Style()
|
||||||
|
index = generator.engraving.notation.ScoreIndex(source)
|
||||||
|
systems = [generator.engraving.layout.make_system(index, i * 4, (i + 1) * 4, self.fonts, style) for i in range(count)]
|
||||||
|
pages, playback = generator.engraving.layout.page_views(systems, source, self.fonts, style, index=index)
|
||||||
|
generator.engraving.audit.audit_layout(source, generator.engraving.types.LayoutDocument(pages, style, ''), self.fonts)
|
||||||
|
generator.engraving.audit.audit_playback(pages, playback, style, self.fonts)
|
||||||
|
self.assertEqual(len(pages), (count + 1) // 2)
|
||||||
|
self.assertEqual(len(playback), count)
|
||||||
|
self.assertEqual([[s.start for s in p.systems] for p in playback], [[i * 4, (i + 1) * 4] if i + 1 < count else [i * 4] for i in range(count)])
|
||||||
|
self.assertLess(playback[-1].systems[0].box.top, style.height / 2)
|
||||||
|
for sequence in (pages, playback):
|
||||||
|
for page in sequence:
|
||||||
|
self.assertEqual(len({s.scale for s in page.systems}), 1)
|
||||||
|
normal = [generator.performance.page_timing(source, p) for p in pages]
|
||||||
|
sliding = [generator.performance.page_timing(source, p) for p in playback]
|
||||||
|
generator.performance.validate_views(source, normal, sliding)
|
||||||
|
def test_transition_pair_of_tall_systems_fits_at_shared_scale(self):
|
||||||
|
short = tests.test_musicxml.xml_note()
|
||||||
|
tall = tests.test_musicxml.xml_note('C', 2, 'half', octave=2) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7)
|
||||||
|
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([short, tall, tall, short]))
|
||||||
|
style = generator.engraving.types.Style()
|
||||||
|
index = generator.engraving.notation.ScoreIndex(source)
|
||||||
|
systems = [generator.engraving.layout.make_system(index, i * 4, (i + 1) * 4, self.fonts, style) for i in range(4)]
|
||||||
|
pages, playback = generator.engraving.layout.page_views(systems, source, self.fonts, style)
|
||||||
|
generator.engraving.audit.audit_playback(pages, playback, style, self.fonts)
|
||||||
|
self.assertEqual(playback[1].systems[0].scale, pages[0].systems[0].scale)
|
||||||
|
self.assertLess(playback[1].systems[1].box.bottom, style.height - style.margin_y)
|
||||||
|
def test_measure_numbers_clear_high_notes_and_follow_barlines(self):
|
||||||
|
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note('C', octave=7)] * 3))
|
||||||
|
for i, measure in enumerate(source['staves'][0]['measures']):
|
||||||
|
measure['number'] = str(169 + i)
|
||||||
|
layout = self.layout(source)
|
||||||
|
for page in layout.pages:
|
||||||
|
for system in page.systems:
|
||||||
|
labels = [p for p in system.primitives if p.role == 'measure_number']
|
||||||
|
self.assertEqual(len({round(p.points[0][1], 6) for p in labels}), 1)
|
||||||
|
bars = [p for p in system.primitives if p.role == 'barline']
|
||||||
|
for i, label in enumerate(labels):
|
||||||
|
center = (label.box.left + label.box.right) / 2
|
||||||
|
if i == 0:
|
||||||
|
self.assertAlmostEqual(center, layout.style.margin_x + 1, places=2)
|
||||||
|
else:
|
||||||
|
self.assertTrue(any(abs(center - p.points[0][0]) < system.scale for p in bars))
|
||||||
|
for other in system.primitives:
|
||||||
|
if other is not label:
|
||||||
|
self.assertFalse(label.box.overlaps(other.box, 3 * system.scale))
|
||||||
def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self):
|
def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self):
|
||||||
tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>')
|
tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>')
|
||||||
content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
|
content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <vamp_zero/notation.hpp>
|
#include <vamp_zero/notation.hpp>
|
||||||
#include <vamp_zero/page_control.hpp>
|
#include <vamp_zero/page_control.hpp>
|
||||||
#include <vamp_zero/performance.hpp>
|
#include <vamp_zero/performance.hpp>
|
||||||
|
#include <vamp_zero/score.hpp>
|
||||||
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -29,7 +30,7 @@ auto state(bool metronome = false, bool backing = false) -> vamp_zero::app_state
|
|||||||
auto audio = vamp_zero::parse_audio_assets(read("manifest.audio"));
|
auto audio = vamp_zero::parse_audio_assets(read("manifest.audio"));
|
||||||
BOOST_REQUIRE(audio);
|
BOOST_REQUIRE(audio);
|
||||||
catalog->audio_assets = *audio;
|
catalog->audio_assets = *audio;
|
||||||
catalog->parts.push_back({.id = "score", .title = "Fixture", .part = "Vocal", .total_pages = 2, .song_id = "fixture"});
|
catalog->parts.push_back({.id = "score", .title = "Fixture", .part = "Vocal", .total_pages = 2, .song_id = "fixture", .playback_pages = 3});
|
||||||
catalog->groups.push_back({.title = "Fixture", .parts = {0}});
|
catalog->groups.push_back({.title = "Fixture", .parts = {0}});
|
||||||
auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE};
|
auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE};
|
||||||
result.selected_song_index = 1;
|
result.selected_song_index = 1;
|
||||||
@@ -139,6 +140,94 @@ BOOST_AUTO_TEST_CASE(stale_performance_updates_do_not_navigate_or_restart) {
|
|||||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(playback_advances_one_system_and_highlights_each_measure) {
|
||||||
|
auto document = score();
|
||||||
|
BOOST_TEST(document.pages.size() == 2U);
|
||||||
|
BOOST_TEST(document.playback_pages.size() == 3U);
|
||||||
|
for (auto bpm : {60U, 90U, 120U}) {
|
||||||
|
auto count_in = vamp_zero::frame_at({4, 1}, bpm);
|
||||||
|
BOOST_CHECK(!vamp_zero::performance_at(document, count_in - 1, bpm).measure);
|
||||||
|
for (std::size_t m = 0; m < document.measure_count; ++m) {
|
||||||
|
auto frame = count_in + vamp_zero::frame_at(document.measure_boundaries[m], bpm);
|
||||||
|
auto now = vamp_zero::performance_at(document, frame, bpm);
|
||||||
|
BOOST_REQUIRE(now.measure);
|
||||||
|
BOOST_TEST(*now.measure == m + 1);
|
||||||
|
BOOST_TEST(now.page == std::min(m, std::size_t{2}));
|
||||||
|
if (m) {
|
||||||
|
BOOST_TEST(*vamp_zero::performance_at(document, frame - 1, bpm).measure == m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BOOST_TEST(document.playback_pages.back().systems.size() == 1U);
|
||||||
|
auto app = state(true);
|
||||||
|
auto control = vamp_zero::control_state{{}, app};
|
||||||
|
control.update_performance(1, 2, "Sustain", app.catalog, 3);
|
||||||
|
auto revision = control.snapshot().input_revision;
|
||||||
|
control.update_performance(1, 2, "Sustain", app.catalog, 4);
|
||||||
|
BOOST_TEST(*control.snapshot().active_measure == 4U);
|
||||||
|
BOOST_TEST(control.snapshot().input_revision == revision + 1);
|
||||||
|
auto remote = vamp_zero::remote_display_text(control.snapshot());
|
||||||
|
BOOST_TEST(remote.find("page=3\npages=3") != std::string::npos);
|
||||||
|
control.finish_playback(1, "failed", app.catalog);
|
||||||
|
BOOST_CHECK(!control.snapshot().active_measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(fractional_pickups_and_unequal_measures_use_exact_boundaries) {
|
||||||
|
auto document = score();
|
||||||
|
document.measure_boundaries = {{0, 1}, {1, 2}, {7, 2}, {41, 6}, {65, 6}, {89, 6}};
|
||||||
|
document.playback_pages[1].start_beat = {1, 2};
|
||||||
|
document.playback_pages[2].start_beat = {7, 2};
|
||||||
|
document.duration = {89, 6};
|
||||||
|
for (auto bpm : {60U, 110U, 120U}) {
|
||||||
|
for (std::size_t i = 1; i < document.measure_count; ++i) {
|
||||||
|
auto frame = vamp_zero::frame_at({4, 1}, bpm) + vamp_zero::frame_at(document.measure_boundaries[i], bpm);
|
||||||
|
BOOST_TEST(*vamp_zero::performance_at(document, frame - 1, bpm).measure == i);
|
||||||
|
BOOST_TEST(*vamp_zero::performance_at(document, frame, bpm).measure == i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(highlight_geometry_follows_letterboxed_page_and_active_view) {
|
||||||
|
auto document = score();
|
||||||
|
for (auto bounds : {luma::rect{0, 0, 976, 484}, luma::rect{10, 20, 488, 484}, luma::rect{10, 20, 1952, 968}}) {
|
||||||
|
auto image = vamp_zero::page_rectangle(bounds);
|
||||||
|
auto a = vamp_zero::measure_highlight(document.playback_pages[0], 2, image);
|
||||||
|
auto b = vamp_zero::measure_highlight(document.playback_pages[1], 2, image);
|
||||||
|
BOOST_REQUIRE(a);
|
||||||
|
BOOST_REQUIRE(b);
|
||||||
|
auto scale = image.width / 976.F;
|
||||||
|
BOOST_TEST(b->x == image.x + 27.F * scale);
|
||||||
|
BOOST_TEST(b->y == image.y + 67.F * scale);
|
||||||
|
BOOST_TEST(a->y - b->y == 220.F * scale);
|
||||||
|
BOOST_TEST(b->width == 18.F * scale);
|
||||||
|
BOOST_CHECK(!vamp_zero::measure_highlight(document.playback_pages[0], {}, image));
|
||||||
|
BOOST_CHECK(!vamp_zero::measure_highlight(document.playback_pages[0], 5, image));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(playback_parser_requires_complete_views_and_safe_label_geometry) {
|
||||||
|
auto original = boost::json::parse(read("score.json"));
|
||||||
|
for (auto fault : {"missing", "pair", "label", "bounds", "time", "path"}) {
|
||||||
|
auto value = original;
|
||||||
|
auto &presentation = value.at("presentation").as_object();
|
||||||
|
auto &views = presentation.at("playback_pages").as_array();
|
||||||
|
if (std::string_view{fault} == "missing") {
|
||||||
|
presentation.erase("playback_pages");
|
||||||
|
} else if (std::string_view{fault} == "pair") {
|
||||||
|
views[1] = views[0];
|
||||||
|
} else if (std::string_view{fault} == "label") {
|
||||||
|
views[0].at("systems").as_array()[0].at("labels").as_array()[0].at("measure") = 99;
|
||||||
|
} else if (std::string_view{fault} == "bounds") {
|
||||||
|
views[0].at("systems").as_array()[0].at("labels").as_array()[0].at("bounds").as_array()[2] = 1000;
|
||||||
|
} else if (std::string_view{fault} == "time") {
|
||||||
|
views[1].at("start_beat").at("numerator") = 3;
|
||||||
|
} else {
|
||||||
|
views[1].at("path") = "../outside.png";
|
||||||
|
}
|
||||||
|
BOOST_CHECK_MESSAGE(!vamp_zero::parse_notation_document(boost::json::serialize(value), "score.json"), fault);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BOOST_AUTO_TEST_CASE(recording_transport_cancellation_does_not_advance_clock) {
|
BOOST_AUTO_TEST_CASE(recording_transport_cancellation_does_not_advance_clock) {
|
||||||
auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
|
auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
|
||||||
BOOST_REQUIRE(prepared);
|
BOOST_REQUIRE(prepared);
|
||||||
|
|||||||
@@ -19,9 +19,19 @@ def fixture(root):
|
|||||||
music['staves'][0]['measures'].append(copy.deepcopy(first))
|
music['staves'][0]['measures'].append(copy.deepcopy(first))
|
||||||
music['staves'][0]['measures'].extend({**copy.deepcopy(first), 'ending_sustain': True} for _ in range(3))
|
music['staves'][0]['measures'].extend({**copy.deepcopy(first), 'ending_sustain': True} for _ in range(3))
|
||||||
music['metadata']['song_id'] = 'fixture'
|
music['metadata']['song_id'] = 'fixture'
|
||||||
pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1 if i == 0 else 4,
|
|
||||||
start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational(4 if i == 0 else 20)) for i in range(2)]
|
def system(first, count, y):
|
||||||
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), performance=generator.performance.timing(music))
|
return dict(first_measure=first, measure_count=count, start_beat=generator.model.rational((first - 1) * 4),
|
||||||
|
end_beat=generator.model.rational((first + count - 1) * 4),
|
||||||
|
labels=[dict(measure=m, bounds=[30 + 40 * (m - first), y, 42 + 40 * (m - first), y + 12]) for m in range(first, first + count)])
|
||||||
|
|
||||||
|
def page(index, systems):
|
||||||
|
return dict(path=f'{index}.png', sha256='0' * 64, first_measure=systems[0]['first_measure'],
|
||||||
|
measure_count=sum(s['measure_count'] for s in systems), start_beat=systems[0]['start_beat'], end_beat=systems[-1]['end_beat'], systems=systems)
|
||||||
|
|
||||||
|
pages = [page(1, [system(1, 1, 70), system(2, 1, 290)]), page(2, [system(3, 3, 70)])]
|
||||||
|
playback = [pages[0], page(3, [system(2, 1, 70), system(3, 3, 290)]), pages[1]]
|
||||||
|
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages, playback_pages=playback), performance=generator.performance.timing(music))
|
||||||
(root / 'score.json').write_text(json.dumps(bundle))
|
(root / 'score.json').write_text(json.dumps(bundle))
|
||||||
entries = []
|
entries = []
|
||||||
# Constant PCM makes accents, recorded count-in and the silent click tail observable.
|
# Constant PCM makes accents, recorded count-in and the silent click tail observable.
|
||||||
@@ -79,6 +89,28 @@ class PerformanceTests(unittest.TestCase):
|
|||||||
bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3)
|
bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3)
|
||||||
with self.assertRaisesRegex(ValueError, 'timing'):
|
with self.assertRaisesRegex(ValueError, 'timing'):
|
||||||
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
|
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
|
||||||
|
def test_playback_metadata_rejects_missing_views_wrong_pairs_and_bad_labels(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = pathlib.Path(temporary)
|
||||||
|
fixture(root)
|
||||||
|
bundle = json.loads((root / 'score.json').read_text())
|
||||||
|
pages = bundle['presentation']['pages']
|
||||||
|
playback = bundle['presentation']['playback_pages']
|
||||||
|
generator.performance.validate_views(bundle['music'], pages, playback)
|
||||||
|
for fault in ('missing', 'pair', 'bounds', 'label', 'timing'):
|
||||||
|
altered = copy.deepcopy(playback)
|
||||||
|
if fault == 'missing':
|
||||||
|
altered.pop()
|
||||||
|
elif fault == 'pair':
|
||||||
|
altered[1] = copy.deepcopy(altered[0])
|
||||||
|
elif fault == 'bounds':
|
||||||
|
altered[0]['systems'][0]['labels'][0]['bounds'][0] = -1
|
||||||
|
elif fault == 'label':
|
||||||
|
altered[0]['systems'][0]['labels'][0]['measure'] = 2
|
||||||
|
else:
|
||||||
|
altered[1]['start_beat'] = generator.model.rational(3)
|
||||||
|
with self.subTest(fault=fault), self.assertRaises(ValueError):
|
||||||
|
generator.performance.validate_views(bundle['music'], pages, altered)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
|||||||
@@ -213,8 +213,10 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
page.write_bytes(data)
|
page.write_bytes(data)
|
||||||
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
|
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
|
||||||
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
|
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
|
||||||
times = generator.performance.boundaries(document)
|
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
|
||||||
bundle['presentation']['pages'][0].update(first_measure=1, measure_count=len(times) - 1, start_beat=generator.model.rational(0), end_beat=generator.model.rational(times[-1]))
|
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts)
|
||||||
|
bundle['presentation']['pages'][0].update(generator.performance.page_timing(document, layout.pages[0]))
|
||||||
|
bundle['presentation']['playback_pages'] = copy.deepcopy(bundle['presentation']['pages'])
|
||||||
bundle['performance'] = generator.performance.timing(document)
|
bundle['performance'] = generator.performance.timing(document)
|
||||||
path = assets / 'score.json'
|
path = assets / 'score.json'
|
||||||
path.write_text(json.dumps(bundle))
|
path.write_text(json.dumps(bundle))
|
||||||
|
|||||||
+28
-3
@@ -14,6 +14,10 @@
|
|||||||
#include <vamp_pico/screen.hpp>
|
#include <vamp_pico/screen.hpp>
|
||||||
#include <vamp_zero/menu_view.hpp>
|
#include <vamp_zero/menu_view.hpp>
|
||||||
#include <vamp_zero/page_control.hpp>
|
#include <vamp_zero/page_control.hpp>
|
||||||
|
#include <vamp_zero/notation.hpp>
|
||||||
|
#include <vamp_zero/performance.hpp>
|
||||||
|
#include <vamp_zero/score.hpp>
|
||||||
|
#include <vamp_zero/color.hpp>
|
||||||
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -133,12 +137,21 @@ auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pi
|
|||||||
auto context = image_context{};
|
auto context = image_context{};
|
||||||
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
|
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
|
||||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||||
auto source = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
|
auto source = vamp_zero::parse_notation_document(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"), "score.json");
|
||||||
auto path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / std::string{source.at("presentation").at("pages").as_array().front().at("path").as_string()};
|
BOOST_REQUIRE(source);
|
||||||
|
auto playback = vamp_zero::automatic(state.audio);
|
||||||
|
auto const &pages = playback ? source->playback_pages : source->pages;
|
||||||
|
auto const &page = pages.at(state.page_index);
|
||||||
|
auto path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / page.path;
|
||||||
auto sheet = LoadImage(path.c_str());
|
auto sheet = LoadImage(path.c_str());
|
||||||
BOOST_REQUIRE(sheet.data);
|
BOOST_REQUIRE(sheet.data);
|
||||||
ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE);
|
ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE);
|
||||||
UnloadImage(sheet);
|
UnloadImage(sheet);
|
||||||
|
if (playback) {
|
||||||
|
if (auto highlight = vamp_zero::measure_highlight(page, state.active_measure, {24, 98, 976, 484})) {
|
||||||
|
context.stroke_rect(*highlight, 1.5F, vamp_zero::to_luma(vamp_style::accent));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
|
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
|
||||||
}
|
}
|
||||||
@@ -184,7 +197,7 @@ BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays)
|
|||||||
auto catalog = std::make_shared<vamp_zero::catalog>();
|
auto catalog = std::make_shared<vamp_zero::catalog>();
|
||||||
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
|
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
|
||||||
auto title = std::string{score.at("presentation").at("title_image").as_string()};
|
auto title = std::string{score.at("presentation").at("title_image").as_string()};
|
||||||
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = score.at("presentation").at("pages").as_array().size(), .title_image = title, .song_id = "autumn_leaves"});
|
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = score.at("presentation").at("pages").as_array().size(), .title_image = title, .song_id = "autumn_leaves", .playback_pages = score.at("presentation").at("playback_pages").as_array().size()});
|
||||||
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
|
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
|
||||||
catalog->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120});
|
catalog->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120});
|
||||||
state.catalog = catalog;
|
state.catalog = catalog;
|
||||||
@@ -206,9 +219,21 @@ BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays)
|
|||||||
state.active_part_id = "electric";
|
state.active_part_id = "electric";
|
||||||
for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) {
|
for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) {
|
||||||
state.audio.phase = phase;
|
state.audio.phase = phase;
|
||||||
|
state.audio.backing_enabled = std::string_view{phase} != "Manual";
|
||||||
|
state.audio.metronome_enabled = state.audio.backing_enabled;
|
||||||
|
state.active_measure = std::string_view{phase} == "Playing" ? std::optional<std::size_t>{1} : std::nullopt;
|
||||||
preview(state, phase);
|
preview(state, phase);
|
||||||
}
|
}
|
||||||
|
state.audio.phase = "Playing";
|
||||||
|
state.page_index = 1;
|
||||||
|
state.active_measure = score.at("presentation").at("playback_pages").as_array()[1].at("first_measure").to_number<std::size_t>();
|
||||||
|
preview(state, "transition");
|
||||||
|
state.page_index = catalog->parts[0].playback_pages - 1;
|
||||||
|
state.active_measure = score.at("music").at("staves").as_array()[0].at("measures").as_array().size();
|
||||||
|
state.audio.phase = "Sustain";
|
||||||
|
preview(state, "final_line");
|
||||||
state.audio.phase = "Error";
|
state.audio.phase = "Error";
|
||||||
|
state.active_measure.reset();
|
||||||
state.audio.playback_error = "Recording unavailable";
|
state.audio.playback_error = "Recording unavailable";
|
||||||
preview(state, "audio_error");
|
preview(state, "audio_error");
|
||||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||||
|
|||||||
Reference in New Issue
Block a user