@@ -87,7 +87,9 @@ python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --
|
||||
The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
|
||||
The pinned build-only dependencies are in `generator/requirements.txt`. Configure/build automatically refresh the project build image when this file changes; direct builds must install it in their Python environment.
|
||||
|
||||
The build-time pipeline is MuseScore -> `.xml` -> musical model -> automatic arranger/transposer -> schema-v3 JSON and engraved PNG pages. The Pi displays pages without interpreting notation. Performance sources produce acoustic, electric, and vocal parts; melody and chord exercises produce twelve tonic variants relative to their authored key. Independent voices and exact pitch/rhythm remain in the semantic JSON. Chord practice preserves the authored voicings, shifting the entire exercise by an octave only when needed for guitar. Chord practice and generated guitar voicings use standard tuning, frets 0-15 inclusive, and a maximum four-fret span excluding open strings.
|
||||
The build-time pipeline is MuseScore -> `.xml` -> musical model -> automatic arranger/transposer -> schema-v3 JSON, engraved PNG pages, and generated-tone audio assets. The Pi displays pages without interpreting notation. Performance sources produce acoustic, electric, and vocal parts; melody and chord exercises produce twelve tonic variants relative to their authored key. Independent voices and exact pitch/rhythm remain in the semantic JSON. Chord practice preserves the authored voicings, shifting the entire exercise by an octave only when needed for guitar. Chord practice and generated guitar voicings use standard tuning, frets 0-15 inclusive, and a maximum four-fret span excluding open strings.
|
||||
|
||||
Practice notation supports repeat bars and numbered endings. Python expands the performed order for playback and before automatic performance arrangement. The main menu's **Playback** toggle plays the selected key variant or performance part at the menu BPM, suppresses the metronome, and returns to its selection menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. No SoundFonts or runtime synthesizer are required. The practice category layout is unchanged.
|
||||
|
||||
Code style is managed by Modulus. 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.
|
||||
|
||||
|
||||
+27
-3
@@ -65,8 +65,9 @@ The pipeline is MuseScore -> uncompressed `.xml` -> musical model -> automatic a
|
||||
| Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Their positions never split or shorten notes. |
|
||||
| Text | Title, composer, part name, multiple lyric verses with syllabification, elisions, and extenders, rehearsal labels at measure starts, and Vamp directives. |
|
||||
| Tempo | Metronome directions and positive quarter-note BPM from `<sound tempo="...">`, either within a direction or directly in a measure. Playback/navigation attributes on `sound` are rejected. |
|
||||
| Repeats | Left forward and right backward bar repeats, explicit repeat counts, implicit start at the beginning, sequential/nested repeat groups, and contiguous numbered endings. |
|
||||
|
||||
Multiple staves/parts, repeats/navigation, slurs, dynamics, articulations, and expressive ornaments are outside the product contract and rejected. Unknown musical elements also fail. Serialized times and alterations use reduced signed-64-bit numerators and positive denominators; pitch octaves are -4 through 16, clef/octave shifts span at most three octaves, and meter counts are 1-1024 with denominator units 1-4096. These resource bounds produce diagnostics, not clamping or truncation.
|
||||
Multiple staves/parts, D.C./D.S./coda/fine navigation, slurs, dynamics, articulations, and expressive ornaments are outside the product contract and rejected. Unknown musical elements also fail. Serialized times and alterations use reduced signed-64-bit numerators and positive denominators; pitch octaves are -4 through 16, clef/octave shifts span at most three octaves, and meter counts are 1-1024 with denominator units 1-4096. These resource bounds produce diagnostics, not clamping or truncation.
|
||||
|
||||
Layout/exporter information such as page defaults, credits, print layout, font/color/position attributes, and source pagination does not determine the small-screen layout. Engraving is regenerated. Beam groups and written note values are retained; unrelated engraving metadata does not authorize ignoring musical descendants. `defusedxml==0.7.1` forbids XML entities and external access. Ordinary MusicXML DOCTYPE declarations are allowed without fetching their DTDs.
|
||||
|
||||
@@ -84,13 +85,22 @@ The default is Medium Swing; `Med.`, `Med. Swing`, and `Medium` remain aliases.
|
||||
|
||||
Place staff text `vamp:optional-chord` at the same onset as exactly one harmony to mark a turnaround omitted from the final arrangement section. The text may be hidden. Parenthesizing an ordinary chord does not make it optional. Orphaned/duplicate markers and unknown `vamp:` properties are errors; consumed directives never enter runtime JSON.
|
||||
|
||||
### Repeats
|
||||
|
||||
`generator/repeats.py` is the shared repeat analyzer and expander. Practice keeps compact authored bars and ending brackets; the engraver draws repeat dots, combined boundaries, repeat-count labels, and brackets continued across systems. Performance sources unfold before the existing seven-section arranger. Playback and guitar-feasibility checks use that same performed order, so skipped endings do not become extra notes.
|
||||
|
||||
Ending numbers are comma-separated positive passes, such as `1,2` followed by `3`. The first ending terminates at the backward repeat; following endings are contiguous, each pass belongs to exactly one ending, and the final ending contains the last pass. `stop` closes the bracket; `discontinue` leaves it open. Hidden brackets still affect performance. Explicit repeat counts must agree with ending passes. Crossing or unterminated groups, overlapping endings, middle-measure repeat marks, winged repeats, and navigation attributes are rejected. Nesting is bounded to 16 levels and unfolding to 10,000 measures.
|
||||
|
||||
Repeated notation retains separate sounding `<tie>` and printed `<tied>` markers. `time-only` selects passes in the innermost repeat, and each active sounding endpoint must match on the performed path. Use pass-specific ties when tying across a backward jump; dangling or unreachable endpoints fail rather than sustaining the wrong pitch. Expansion assigns fresh IDs and remaps ties, tuplets, and paired tremolos. A jump that splits a paired tremolo is rejected. Backward jumps restore staff and harmony state; skipped endings do not leak key changes into later passes. See the MusicXML references for [repeats](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/repeat/) and [sounding ties](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tie/).
|
||||
|
||||
### Runtime Schema
|
||||
|
||||
A bundle has three top-level fields:
|
||||
A newly generated bundle has four top-level fields:
|
||||
|
||||
- `schema_version: 3`.
|
||||
- `music`: metadata and a single staff containing measures, independent voice lanes, timed harmonies/attributes/tempos, instruments, tuplets, and octave spans.
|
||||
- `presentation`: engraver/version, style checksum, fixed width/height, and ordered relative PNG paths with SHA-256 checksums.
|
||||
- `playback`: versioned availability, exact performed beat duration/events, shared PCM sample descriptors, and a generated release envelope. Unavailable interpretations carry a reason instead of an approximate timeline.
|
||||
|
||||
Practice metadata includes optional `authored_key_signature`, captured before transposition and shared by all twelve variants. The menu's `SOURCE` label uses this value, not the first variant. Existing bundles without it remain supported and omit the source label. Explicit key-cancellation signatures transpose with the same interval as the music; the engraver cancels only altered pitches that actually change.
|
||||
|
||||
@@ -104,6 +114,20 @@ For the fretboard check, ordinary unmarked treble notation uses the project's wr
|
||||
|
||||
Automatic performance arrangements require a pitched, non-TAB lead. Percussion and tablature remain available in the model and melody-practice pipeline, but conversion into guitar/vocal parts fails explicitly rather than reinterpreting percussion positions or fret numbers as ordinary treble-staff notation. Chord practice requires pitched guitar notation. Practice transposition retains authored TAB strings and never invents a new fingering; chord practice additionally checks that the resulting exact integer frets lie within 0-15. Melody practice retains its existing nonnegative-fret behavior.
|
||||
|
||||
### Playback Boundary
|
||||
|
||||
All musical compilation and tone synthesis run in Python (`generator/playback.py`). Each transformed score receives a sorted timeline of independent tone events with rational quarter-note `onset` and `end` positions. Ties become one continuous attack. Rests and spacers remain silence, including trailing silence. Harmony labels are not auto-accompaniment: only pitches actually present in the selected arrangement or exercise sound. Cue notes are silent, hidden pitched notes sound, and pitchless rhythm slashes are silent.
|
||||
|
||||
The `soft-struck-1` profile generates shared mono 48 kHz PCM16 WAVs with a short attack/decay and a seamless sustain loop, plus a shared 50 ms release envelope. Exact sounding pitches, including supported microtones, determine samples. Explicit instrument transposition is applied once; chord practice and generated guitar parts use the existing written-guitar convention. Samples are shared across all scores and voices by content identity, not duplicated per complete performance. There are no SoundFonts, instrument sample bundles, oscillators, or MusicXML parsing at runtime.
|
||||
|
||||
Grace groups honor explicit steal-time-previous/following or make-time; unspecified groups share 25 percent of the following event. Conflicting grace modes, trailing grace groups, unsupported unpitched sound, tied measured tremolo, and incompatible paired tremolo make playback explicitly unavailable while retaining the notation. Measured tremolo uses written subdivision and tuplet ratios. The tone range is 20-18,000 Hz. Asset validation checks containment, SHA-256, PCM format/length, loop endpoints, envelope shape, event ordering, references, and rational timing. Generation remains transactional if validation fails.
|
||||
|
||||
At runtime the shared on-demand score loader serves both display and audio. A background worker validates and loads only the selected score's samples, converts absolute beat positions to rounded sample frames at the menu BPM, and prepares a Sonus sequence. Pitch does not change with BPM, and rounding absolute positions avoids cumulative timing drift. The I/O loop publishes the prepared sequence and polls completion without doing disk I/O or musical interpretation. Sonus mixes preallocated voices from immutable PCM and retires them on control threads; the callback never synthesizes tones or loads files. Limits are one million events, 512 distinct samples, 64 MiB decoded samples per selection, 64 simultaneous voices including releases, and 24 hours at the selected BPM. Exceeding them reports an error rather than dropping notes.
|
||||
|
||||
`audio.playback_enabled` defaults to false and replaces the old inert `track_enabled` setting. The old config name is accepted for migration, but conflicting values fail. Playback suppresses the metronome without changing its stored toggle. Authored tempo marks remain notation; the menu BPM is constant throughout a session. Selecting a practice variant or performance part starts a new session. Manual page changes never restart or seek. Natural completion, including trailing silence/release, returns to the corresponding key/part menu with its selection preserved. Leaving the score cancels playback; stale load/completion results cannot close a newer score. Errors stay in the score view and `/api/status` rather than being mistaken for completion. Existing bundles without `playback` still display but require regeneration to play.
|
||||
|
||||
`--once` additionally validates referenced audio without opening display/audio hardware. Runtime packages contain WAVs alongside pages/JSON; the source XML and Python dependencies remain build-only.
|
||||
|
||||
### Engraving Boundary
|
||||
|
||||
`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `engrave_tree(root, fonts)` retains the asset-pipeline entry point. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving.
|
||||
@@ -120,7 +144,7 @@ Pages are 976x484 pixels with 12-pixel staff spacing, 28-pixel horizontal margin
|
||||
|
||||
### Validation And Migration
|
||||
|
||||
Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete, including when the compatibility `--clean` flag is omitted; deleted sources cannot leave stale JSON or pages. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain semantic JSON, PNG pages, and the OTF UI text font, not MusicXML, MuseScore, or Python dependencies. Python test discovery runs model/import/export tests; the normal asset build additionally qualifies and audits every catalogue score.
|
||||
Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete, including when the compatibility `--clean` flag is omitted; deleted sources cannot leave stale JSON, pages, or audio. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain semantic JSON, PNG pages, generated PCM WAVs, and the OTF UI text font, not MusicXML, MuseScore, or Python dependencies. Python test discovery runs model/import/export tests; the normal asset build additionally qualifies and audits every catalogue score.
|
||||
|
||||
Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element <slur>`, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`.
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ set(VAMP_CORE_SOURCES
|
||||
src/vamp_zero/navigation.cpp
|
||||
src/vamp_zero/notation.cpp
|
||||
src/vamp_zero/page_control.cpp
|
||||
src/vamp_zero/playback.cpp
|
||||
src/vamp_zero/portal.cpp
|
||||
src/vamp_zero/score.cpp
|
||||
src/vamp_zero/score_loader.cpp
|
||||
|
||||
@@ -11,6 +11,7 @@ from generator.score import count_json_files, validate_generated_tree
|
||||
from generator.summary import validate_expected_performance_summary, validate_practice_variants
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.engraving import engrave_tree
|
||||
from generator.playback import compile_tree
|
||||
|
||||
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
||||
|
||||
@@ -47,6 +48,7 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
if args.check:
|
||||
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||
validate_practice_variants(staged / 'practice')
|
||||
compile_tree(staged)
|
||||
engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma')
|
||||
validate_generated_tree(staged)
|
||||
backup = Path(temporary) / 'previous'
|
||||
|
||||
@@ -90,7 +90,8 @@ def arrange_document(document: dict, part_type: str) -> dict:
|
||||
specialized_notes = any('unpitched' in pitch or 'tablature' in pitch for note in measure_notes(measure) for pitch in note['pitches'])
|
||||
if specialized_staff or specialized_notes:
|
||||
raise ValueError(f'{measure["id"]}: automatic performance arranging requires a pitched non-TAB lead; percussion/TAB conversion is not qualified')
|
||||
document = concert_document(document)
|
||||
from generator.repeats import expand_document
|
||||
document = concert_document(expand_document(document))
|
||||
staff = source_staff(document)
|
||||
source = staff['measures']
|
||||
sections = full_arrangement_sections(source, part_type)
|
||||
|
||||
@@ -52,8 +52,10 @@ def engrave_tree(root: Path, fonts: Path) -> None:
|
||||
rasterize_page(page, family).save(target, format='PNG')
|
||||
check_page(target)
|
||||
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
music = {k: v for k, v in document.items() if k != 'schema_version'}
|
||||
music = {k: v for k, v in document.items() if k not in ('schema_version', 'playback')}
|
||||
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'style_sha256': layout.fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
|
||||
if 'playback' in document:
|
||||
bundle['playback'] = document['playback']
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
except ValueError as error:
|
||||
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
|
||||
|
||||
@@ -58,6 +58,22 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
|
||||
if previous_end != index.end:
|
||||
raise EngravingError('layout omits the end of the score')
|
||||
from generator.repeats import analyze
|
||||
_, endings = analyze(index.staff['measures'])
|
||||
for ending in endings:
|
||||
if not ending.visible:
|
||||
continue
|
||||
identifier = index.measures[ending.start][2]['id'] + ':ending'
|
||||
if not any(p.role == 'ending-label' and p.value == ending.text for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: numbered ending was omitted')
|
||||
low, high = index.measures[ending.start][0], index.measures[ending.end - 1][1]
|
||||
for page in layout.pages:
|
||||
for system in page.systems:
|
||||
if system.start < high and system.end > low and not any(p.role == 'ending' and identifier in p.sources for p in system.primitives):
|
||||
raise EngravingError(f'{identifier}: ending continuation was omitted')
|
||||
for _, _, measure in index.measures:
|
||||
if measure.get('repeats') and not any(p.role == 'repeat-dot' for p in primitives.get(measure['id'], [])):
|
||||
raise EngravingError(f'{measure["id"]}: repeat dots were omitted')
|
||||
for event in index.events:
|
||||
note = event.value
|
||||
for source in note['pitches'] or [note]:
|
||||
@@ -85,7 +101,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]:
|
||||
if not any(p.role == 'accidental' for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: required accidental was omitted')
|
||||
if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces):
|
||||
if identifier in index.tones and (source.get('tie_to') or source.get('printed_ties')) and any(f.visible for f in pieces):
|
||||
if not any(p.role == 'tie' for p in primitives.get(identifier, [])):
|
||||
raise EngravingError(f'{identifier}: tie was omitted')
|
||||
if not note.get('hidden'):
|
||||
|
||||
@@ -10,6 +10,7 @@ from generator.engraving.attributes import barline, staff_attributes, tempo
|
||||
from generator.engraving.fonts import Fonts
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
from generator.engraving.notes import draw_event
|
||||
from generator.engraving.repeats import draw_boundary, draw_endings
|
||||
from generator.engraving.spans import draw_beams, draw_lyric_spans, draw_spans, place_text_lanes
|
||||
from generator.engraving.types import Anchor, EngravingError, Fragment, LayoutDocument, Page, Primitive, Style, System, bounds, line
|
||||
|
||||
@@ -70,6 +71,12 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
header = staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
|
||||
items.extend(header)
|
||||
cursor = max((p.box.right for p in header), default=0) + 22
|
||||
boundary_positions = {start: cursor - 9}
|
||||
if start in index.boundaries and measure.get('repeats', {}).get('left'):
|
||||
initial = draw_boundary(None, measure, cursor, -style.space * (state['staff_details']['lines'] - 1), fonts, style)
|
||||
boundary_positions[start] = cursor
|
||||
items.extend(initial)
|
||||
cursor = max(p.box.right for p in initial) + 18
|
||||
content_left = cursor - 9
|
||||
times = sorted({start} | {t for t in index.columns if start < t < end})
|
||||
placements = []
|
||||
@@ -81,15 +88,18 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
for measure_start, measure_end, m in index.measures:
|
||||
if time == measure_start and time > start:
|
||||
previous = next(previous for a, b, previous in index.measures if b == time)
|
||||
leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
|
||||
leading.extend(draw_boundary(previous, m, 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), fonts, style))
|
||||
old_state = index.state(time, before=True)
|
||||
fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k])
|
||||
leading.extend(p.moved(12, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
|
||||
offset = max((p.box.right for p in leading), default=0) + 12 if previous.get('repeats') or m.get('repeats') else 12
|
||||
leading.extend(p.moved(offset, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
|
||||
for ci, change in enumerate(m.get('attributes', [])):
|
||||
if measure_start + fraction(change['onset']) == time and time > start:
|
||||
extra = staff_attributes(index, index.state(time), time, fonts, style.space, m['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)
|
||||
if leading:
|
||||
if time in index.boundaries:
|
||||
boundary_positions[time] = cursor - bounds(leading).left
|
||||
width = bounds(leading).right - bounds(leading).left + 14
|
||||
items.extend(p.moved(cursor - bounds(leading).left, 0) for p in leading)
|
||||
cursor += width
|
||||
@@ -111,12 +121,18 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
items[first_item:last_item] = [p.moved(i * stretch, 0) for p in items[first_item:last_item]]
|
||||
anchors[first_anchor:last_anchor] = [a.moved(i * stretch, 0) for a in anchors[first_anchor:last_anchor]]
|
||||
placements = [(time, x + i * stretch) for i, (time, x) in enumerate(placements)]
|
||||
boundary_positions = {time: x + times.index(time) * stretch for time, x in boundary_positions.items()}
|
||||
width = available
|
||||
right = width - 5
|
||||
boundary_positions[end] = width - 3
|
||||
last_measure = next(m for a, b, m in index.measures if a < end <= b)
|
||||
if end in index.boundaries and last_measure.get('repeats', {}).get('right'):
|
||||
items.extend(draw_boundary(last_measure, None, width - 3, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), fonts, style))
|
||||
items = draw_beams(index, anchors, items, start, end, content_left, right, style)
|
||||
items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
|
||||
items = place_text_lanes(items, fonts, style)
|
||||
items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
|
||||
items = draw_endings(index, items, start, end, boundary_positions, fonts, style)
|
||||
# Staff lines are interrupted behind TAB numerals, not painted through them.
|
||||
changes = [(0, state['staff_details']['lines'])]
|
||||
for time, x in placements:
|
||||
@@ -135,10 +151,10 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
staff_lines.append(line('staff', (), ((cursor, y), (a, y)), style.space * 0.13))
|
||||
cursor = max(cursor, b)
|
||||
items = staff_lines + items
|
||||
last_measure = next(m for a, b, m in index.measures if a < end <= b)
|
||||
if end in index.boundaries:
|
||||
items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
|
||||
if end in index.boundaries and not last_measure.get('repeats', {}).get('right'):
|
||||
items.extend(draw_boundary(last_measure, None, width - (3 if last_measure.get('repeats') else 1), -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), fonts, style))
|
||||
if start in index.boundaries:
|
||||
if not measure.get('repeats', {}).get('left'):
|
||||
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
|
||||
else:
|
||||
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from generator.engraving.attributes import barline
|
||||
from generator.engraving.types import line
|
||||
from generator.repeats import analyze
|
||||
|
||||
def draw_boundary(previous, following, x, top, fonts, style):
|
||||
backward = previous.get('repeats', {}).get('right') if previous else None
|
||||
forward = following.get('repeats', {}).get('left') if following else None
|
||||
source = previous or following
|
||||
if not backward and not forward:
|
||||
value = previous.get('barlines', {}).get('right', 'regular') if previous else following.get('barlines', {}).get('left', 'none')
|
||||
return barline(value, x, top, source['id'])
|
||||
refs = tuple(m['id'] for m in (previous, following) if m)
|
||||
result = [line('repeat-bar', refs, ((x, top), (x, 0)), 4)]
|
||||
for marker, direction in ((backward, -1), (forward, 1)):
|
||||
if not marker:
|
||||
continue
|
||||
result.append(line('repeat-bar', refs, ((x + direction * 6, top), (x + direction * 6, 0)), 1.4))
|
||||
for offset in (-0.5, 0.5):
|
||||
result.append(fonts.glyph('augmentationDot', x + direction * 13 - 2, top / 2 + style.space * offset, style.space, 'repeat-dot', refs))
|
||||
if backward and backward.get('times', 2) != 2:
|
||||
result.append(fonts.text(f'{backward["times"]} times', x - 10, top - 12, 14, 'repeat-count', refs, True))
|
||||
return result
|
||||
|
||||
def draw_endings(index, items, start, end, positions, fonts, style):
|
||||
_, endings = analyze(index.staff['measures'])
|
||||
result = list(items)
|
||||
boundaries = [a for a, _, _ in index.measures] + [index.end]
|
||||
top = min((p.box.top for p in items), default=0) - 25
|
||||
for ending in endings:
|
||||
low, high = boundaries[ending.start], boundaries[ending.end]
|
||||
if not ending.visible or high <= start or low >= end:
|
||||
continue
|
||||
x1 = positions[max(low, start)]
|
||||
x2 = positions[min(high, end)]
|
||||
refs = (index.measures[ending.start][2]['id'] + ':ending',)
|
||||
result.append(line('ending', refs, ((x1, top), (x2, top))))
|
||||
if low >= start:
|
||||
result.append(line('ending', refs, ((x1, top), (x1, top + 10))))
|
||||
result.append(fonts.text(ending.text, x1 + 5, top - 4, 16, 'ending-label', refs))
|
||||
if high <= end and ending.closed:
|
||||
result.append(line('ending', refs, ((x2, top), (x2, top + 10))))
|
||||
return result
|
||||
@@ -101,6 +101,20 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
|
||||
target = tone.get('tie_to')
|
||||
tone_end = event.start + fraction(tone['duration'])
|
||||
anchor = by_tone.get(identifier)
|
||||
if tone.get('printed_ties'):
|
||||
for action in tone['printed_ties']:
|
||||
candidates = [(other_id, other) for other_id, (other_event, other) in index.tones.items() if other_event.voice == event.voice and other_event.start == tone_end and 'stop' in other.get('printed_ties', []) and all(other.get(k) == tone.get(k) for k in ('letter', 'alter', 'octave', 'unpitched'))]
|
||||
if action == 'start' and candidates:
|
||||
target = candidates[0][0]
|
||||
continue
|
||||
if action == 'stop' and any(other_event.voice == event.voice and other_event.start + fraction(other['duration']) == event.start and 'start' in other.get('printed_ties', []) and all(other.get(k) == tone.get(k) for k in ('letter', 'alter', 'octave', 'unpitched')) for other_event, other in index.tones.values()):
|
||||
continue
|
||||
if anchor is None:
|
||||
continue
|
||||
x1, x2 = (anchor.x + style.space * 1.4, min(right, anchor.x + 36)) if action == 'start' else (max(left, anchor.x - 24), anchor.x - 2)
|
||||
y = anchor.y - anchor.direction * 8
|
||||
if x2 > x1:
|
||||
result.append(curve('tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y - anchor.direction * 9), (x2 - (x2 - x1) / 3, y - anchor.direction * 9), (x2, y)), 1.6))
|
||||
if anchor and anchor.fragment.end < tone_end:
|
||||
target = identifier
|
||||
if target:
|
||||
|
||||
@@ -146,6 +146,9 @@ def validate_document(document: dict) -> None:
|
||||
staves = document.get('staves', [])
|
||||
if len(staves) != 1:
|
||||
raise ValueError('exactly one staff is currently supported')
|
||||
if any(m.get('repeats') or m.get('endings') for m in staves[0]['measures']):
|
||||
from generator.repeats import performed_order
|
||||
performed_order(staves[0]['measures'])
|
||||
instruments = staves[0].get('instruments', [])
|
||||
instrument_ids = {i['id'] for i in instruments}
|
||||
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
|
||||
|
||||
+58
-4
@@ -38,6 +38,7 @@ class Importer:
|
||||
self.staff_details = {'lines': 5, 'tunings': []}
|
||||
self.absolute = Fraction()
|
||||
self.tremolo_starts = {}
|
||||
self.has_repeats = False
|
||||
def fail(self, text: str):
|
||||
raise MusicXMLError(f'{self.location}: {text}')
|
||||
def children(self, node, allowed: set[str]) -> None:
|
||||
@@ -372,7 +373,12 @@ class Importer:
|
||||
if node.find('notehead') is not None:
|
||||
p['notehead'] = {'name': node.findtext('notehead'), **dict(node.find('notehead').attrib)}
|
||||
ties = {t.get('type') for t in node.findall('tie')}
|
||||
for tie in node.findall('tie'):
|
||||
self.attrs(tie, {'type', 'time-only'})
|
||||
if tie.get('time-only') and not self.has_repeats:
|
||||
self.fail('time-only tie requires a repeated section')
|
||||
notations = node.find('notations')
|
||||
visual_ties = set()
|
||||
if notations is not None:
|
||||
visual_ties = {t.get('type') for t in notations.findall('tied')}
|
||||
if 'continue' in visual_ties:
|
||||
@@ -381,12 +387,22 @@ class Importer:
|
||||
ties |= visual_ties
|
||||
if ties - {'start', 'stop'}:
|
||||
self.fail('unsupported tie notation')
|
||||
if 'stop' in ties:
|
||||
if self.has_repeats and ties:
|
||||
from generator.repeats import pass_numbers
|
||||
p['sound_ties'] = []
|
||||
p['printed_ties'] = sorted(visual_ties)
|
||||
for tie in node.findall('tie'):
|
||||
self.attrs(tie, {'type', 'time-only'})
|
||||
marker = {'type': tie.get('type')}
|
||||
if tie.get('time-only'):
|
||||
marker['passes'] = pass_numbers(tie.get('time-only'))
|
||||
p['sound_ties'].append(marker)
|
||||
if not self.has_repeats and 'stop' in ties:
|
||||
source = self.open_ties.pop(value, None)
|
||||
if source is None or source['_absolute'] + fraction(source['duration']) != p['_absolute']:
|
||||
self.fail('tie stop has no contiguous matching start')
|
||||
source['tie_to'], p['tie_from'] = p['id'], source['id']
|
||||
if 'start' in ties:
|
||||
if not self.has_repeats and 'start' in ties:
|
||||
if value in self.open_ties:
|
||||
self.fail('ambiguous duplicate tie at the same pitch')
|
||||
self.open_ties[value] = p
|
||||
@@ -496,6 +512,7 @@ class Importer:
|
||||
root = ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot()
|
||||
if root.tag != 'score-partwise' or root.get('version') not in ('3.1', '4.0'):
|
||||
self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0')
|
||||
self.has_repeats = bool(root.findall('.//barline/repeat') or root.findall('.//barline/ending'))
|
||||
self.children(root, IGNORED_TOP | {'part'})
|
||||
parts = root.findall('part')
|
||||
definitions = root.findall('part-list/score-part')
|
||||
@@ -547,6 +564,8 @@ class Importer:
|
||||
if item.tag == 'attributes':
|
||||
state = self.attributes(item, cursor)
|
||||
if cursor == 0:
|
||||
if self.has_repeats:
|
||||
measure.setdefault('initial_attributes', {}).update({k: v for k, v in state.items() if k != 'onset'})
|
||||
for k in ('key', 'meter', 'clef', 'transpose', 'staff_details'):
|
||||
measure[k] = copy.deepcopy(getattr(self, k))
|
||||
elif len(state) > 1:
|
||||
@@ -580,14 +599,46 @@ class Importer:
|
||||
extent = max(extent, cursor)
|
||||
previous = None
|
||||
elif item.tag == 'barline':
|
||||
self.children(item, {'bar-style'})
|
||||
self.attrs(item, {'location'})
|
||||
self.children(item, {'bar-style', 'repeat', 'ending'})
|
||||
side = item.get('location', 'right')
|
||||
if side not in ('left', 'right'):
|
||||
self.fail('barlines and repeats must be at measure boundaries')
|
||||
if side in measure.get('barlines', {}):
|
||||
self.fail(f'duplicate {side} barline')
|
||||
if side == 'left' and cursor != 0 or side == 'right' and cursor != extent:
|
||||
self.fail('barline location disagrees with its musical position')
|
||||
style = item.findtext('bar-style', 'regular')
|
||||
if style not in ('regular', 'light-light', 'light-heavy', 'heavy-light', 'heavy-heavy', 'heavy', 'dashed', 'dotted', 'none', 'tick', 'short'):
|
||||
self.fail('unsupported barline')
|
||||
measure.setdefault('barlines', {})[item.get('location', 'right')] = style
|
||||
measure.setdefault('barlines', {})[side] = style
|
||||
if side == 'right':
|
||||
measure['_right_barline_onset'] = cursor
|
||||
for repeat in item.findall('repeat'):
|
||||
self.attrs(repeat, {'direction', 'times', 'winged'})
|
||||
self.children(repeat, set())
|
||||
if side in measure.get('repeats', {}):
|
||||
self.fail('duplicate repeat marker')
|
||||
marker = {'direction': repeat.get('direction')}
|
||||
if repeat.get('times'):
|
||||
if marker['direction'] != 'backward':
|
||||
self.fail('repeat times requires a backward repeat')
|
||||
marker['times'] = self.number(repeat.get('times'), integer=True, positive=True)
|
||||
if repeat.get('winged', 'none') != 'none':
|
||||
self.fail('winged repeat engraving is not supported')
|
||||
measure.setdefault('repeats', {})[side] = marker
|
||||
for ending in item.findall('ending'):
|
||||
from generator.repeats import pass_numbers
|
||||
self.attrs(ending, {'number', 'type', 'print-object', 'end-length', 'text-x', 'text-y'})
|
||||
self.children(ending, set())
|
||||
if side in measure.get('endings', {}):
|
||||
self.fail('duplicate ending marker')
|
||||
measure.setdefault('endings', {})[side] = {'type': ending.get('type'), 'numbers': pass_numbers(ending.get('number', '')), 'text': ending.text or '', 'visible': ending.get('print-object', 'yes') != 'no'}
|
||||
if not voices or extent <= 0:
|
||||
self.fail('empty or zero-duration measure')
|
||||
cursor = extent
|
||||
if measure.pop('_right_barline_onset', cursor) != cursor:
|
||||
self.fail('right barline must occur at the end of its measure')
|
||||
expected = meter_length(measure['meter'])
|
||||
meter_changed = any('meter' in a for a in measure['attributes'])
|
||||
if expected and cursor != expected and not measure['implicit'] and not meter_changed:
|
||||
@@ -616,6 +667,9 @@ class Importer:
|
||||
metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])})
|
||||
result = {'schema_version': SCHEMA_VERSION, 'metadata': metadata, 'staves': [{'id': 'staff-source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]}
|
||||
result['staves'][0]['instruments'] = instruments
|
||||
if self.has_repeats:
|
||||
from generator.repeats import expand_document
|
||||
validate_document(expand_document(result))
|
||||
validate_document(result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
||||
element(item, 'octave', pitch['octave'] if pitch else 4)
|
||||
if not event.get('grace'):
|
||||
element(node, 'duration', int(fraction(pitch['duration'] if pitch else event['duration']) * divisions))
|
||||
for marker in (pitch or {}).get('sound_ties', []):
|
||||
element(node, 'tie', type=marker['type'], time_only=','.join(map(str, marker['passes'])) if marker.get('passes') else None)
|
||||
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
|
||||
if pitch and pitch.get(field):
|
||||
element(node, 'tie', type=action)
|
||||
@@ -170,6 +172,8 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
||||
for beam in event.get('beams', []):
|
||||
element(node, 'beam', beam['type'], number=beam['level'])
|
||||
notations = element(node, 'notations')
|
||||
for action in (pitch or {}).get('printed_ties', []):
|
||||
element(notations, 'tied', type=action)
|
||||
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
|
||||
if pitch and pitch.get(field):
|
||||
element(notations, 'tied', type=action)
|
||||
@@ -202,6 +206,16 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
||||
for syllable in verse['syllables']:
|
||||
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
|
||||
|
||||
def write_barline(bar, measure, location):
|
||||
if not any(location in measure.get(field, {}) for field in ('barlines', 'repeats', 'endings')):
|
||||
return
|
||||
node = element(bar, 'barline', location=location)
|
||||
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
|
||||
if ending := measure.get('endings', {}).get(location):
|
||||
element(node, 'ending', ending.get('text', ''), number=','.join(map(str, ending['numbers'])), type=ending['type'], print_object='yes' if ending.get('visible', True) else 'no')
|
||||
if repeat := measure.get('repeats', {}).get(location):
|
||||
element(node, 'repeat', direction=repeat['direction'], times=repeat.get('times'))
|
||||
|
||||
def musicxml_tree(document: dict) -> ElementTree.ElementTree:
|
||||
validate_document(document)
|
||||
root = ElementTree.Element('score-partwise', {'version': '4.0'})
|
||||
@@ -245,6 +259,7 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree:
|
||||
previous_state = {}
|
||||
for index, measure in enumerate(staff['measures']):
|
||||
bar = element(part, 'measure', number=index + 1, implicit='yes' if measure['implicit'] else 'no')
|
||||
write_barline(bar, measure, 'left')
|
||||
times = [fraction(measure['duration'])]
|
||||
times.extend(fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
|
||||
times.extend(fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches'])
|
||||
@@ -303,8 +318,7 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree:
|
||||
if cursor < fraction(measure['duration']):
|
||||
element(element(bar, 'forward'), 'duration', int((fraction(measure['duration']) - cursor) * divisions))
|
||||
cursor = fraction(measure['duration'])
|
||||
for location, style in measure.get('barlines', {}).items():
|
||||
element(element(bar, 'barline', location=location), 'bar-style', style)
|
||||
write_barline(bar, measure, 'right')
|
||||
absolute += fraction(measure['duration'])
|
||||
return ElementTree.ElementTree(root)
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
from fractions import Fraction
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import wave
|
||||
|
||||
from generator.model import TYPE_LENGTHS, fraction, pitch_number, rational
|
||||
from generator.repeats import expand_document
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
PROFILE = 'soft-struck-1'
|
||||
MAX_EVENTS = 1000000
|
||||
|
||||
class PlaybackUnavailable(ValueError):
|
||||
pass
|
||||
|
||||
def sounding_pitch(pitch: dict, state: dict, guitar: bool) -> Fraction:
|
||||
if 'unpitched' in pitch:
|
||||
raise PlaybackUnavailable('unpitched notation has no generated-tone interpretation')
|
||||
if guitar:
|
||||
import generator.practice.guitar
|
||||
value = generator.practice.guitar.sounding_pitch(pitch, state)
|
||||
else:
|
||||
transpose = state['transpose']
|
||||
value = pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
|
||||
frequency = 440 * 2 ** (float(value - 69) / 12)
|
||||
if not 20 <= frequency <= 18000:
|
||||
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated-tone range (20-18000 Hz)')
|
||||
return value
|
||||
|
||||
def compile_timeline(document: dict, *, guitar: bool = False) -> dict:
|
||||
document = expand_document(document)
|
||||
events, notes, linked = [], {}, {}
|
||||
absolute = Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
|
||||
changes = iter(sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset'])))
|
||||
change = next(changes, None)
|
||||
for note in voice['notes']:
|
||||
onset = fraction(note['onset'])
|
||||
while change is not None and fraction(change['onset']) <= onset:
|
||||
state.update({key: change[key] for key in state if key in change})
|
||||
change = next(changes, None)
|
||||
notes[note['id']] = (note, absolute + onset, dict(state), voice['id'])
|
||||
absolute += fraction(measure['duration'])
|
||||
adjustments = {}
|
||||
insertions = {}
|
||||
lanes = {}
|
||||
for item in notes.values():
|
||||
lanes.setdefault(item[3], []).append(item)
|
||||
for lane in lanes.values():
|
||||
pending = []
|
||||
previous = None
|
||||
for note, onset, state, voice in lane:
|
||||
if note.get('grace'):
|
||||
pending.append((note, onset, state, voice))
|
||||
continue
|
||||
if pending:
|
||||
cursor = onset
|
||||
specified = [n['grace'] for n, _, _, _ in pending]
|
||||
modes = {key for g in specified for key in ('make-time', 'steal-time-previous', 'steal-time-following') if key in g}
|
||||
if len(modes) > 1:
|
||||
raise PlaybackUnavailable('grace group mixes conflicting timing modes')
|
||||
mode = next(iter(modes), 'steal-time-following')
|
||||
if mode == 'make-time':
|
||||
lengths = [fraction(g.get(mode, rational(0))) for g in specified]
|
||||
total = sum(lengths, Fraction())
|
||||
insertions[onset] = max(insertions.get(onset, Fraction()), total)
|
||||
else:
|
||||
donor = previous[0] if mode == 'steal-time-previous' and previous else note if mode == 'steal-time-following' else None
|
||||
if donor is None:
|
||||
raise PlaybackUnavailable('grace notes have no preceding duration to steal')
|
||||
percentages = [fraction(g[mode]) if mode in g else Fraction(25, len(pending)) for g in specified]
|
||||
if not 0 < sum(percentages) < 100:
|
||||
raise PlaybackUnavailable('grace group must steal between zero and 100 percent')
|
||||
lengths = [fraction(donor['duration']) * amount / 100 for amount in percentages]
|
||||
total = sum(lengths, Fraction())
|
||||
if mode == 'steal-time-previous':
|
||||
cursor -= total
|
||||
adjustments[donor['id']] = (Fraction(), -total)
|
||||
else:
|
||||
adjustments[donor['id']] = (total, -total)
|
||||
for item, length in zip(pending, lengths):
|
||||
value, _, pitch_state, lane_id = item
|
||||
adjustments[value['id']] = (cursor - item[1], length)
|
||||
cursor += length
|
||||
pending = []
|
||||
previous = (note, onset)
|
||||
if pending:
|
||||
raise PlaybackUnavailable('trailing grace group has no following anchor')
|
||||
for identifier, (note, onset, state, voice) in notes.items():
|
||||
if note.get('cue'):
|
||||
continue
|
||||
delay, extension = adjustments.get(identifier, (Fraction(), Fraction()))
|
||||
performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), Fraction())
|
||||
tremolo = note.get('tremolo')
|
||||
if tremolo and tremolo['type'] == 'stop':
|
||||
continue
|
||||
for pitch in note['pitches']:
|
||||
if pitch.get('cue'):
|
||||
continue
|
||||
length = fraction(pitch['duration']) + extension
|
||||
if not note.get('grace'):
|
||||
length += sum((amount for time, amount in insertions.items() if onset < time < onset + fraction(pitch['duration'])), Fraction())
|
||||
if length <= 0:
|
||||
raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone')
|
||||
value = sounding_pitch(pitch, state, guitar)
|
||||
if pitch.get('tie_from'):
|
||||
previous = linked.get(pitch['tie_from'])
|
||||
if previous is None or fraction(previous['onset']) + fraction(previous['duration']) != performed or fraction(previous['pitch']) != value:
|
||||
raise PlaybackUnavailable(f'{identifier}: tie has inconsistent performed timing or pitch')
|
||||
previous['duration'] = rational(fraction(previous['duration']) + length)
|
||||
linked[pitch['id']] = previous
|
||||
continue
|
||||
if tremolo and tremolo['marks']:
|
||||
if pitch.get('tie_to'):
|
||||
raise PlaybackUnavailable('tied measured tremolo is not yet qualified for playback')
|
||||
written = pitch['written']
|
||||
step = min(Fraction(1), TYPE_LENGTHS[written['type']]) * fraction(written.get('ratio', rational(1))) / 2 ** tremolo['marks']
|
||||
if tremolo['type'] == 'start':
|
||||
partner = notes[tremolo['partner']]
|
||||
if partner[1] != onset + fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']):
|
||||
raise PlaybackUnavailable('paired tremolo requires contiguous equal-size chords')
|
||||
step *= 2
|
||||
length += fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration'])
|
||||
cursor, attack = Fraction(), 0
|
||||
while cursor < length:
|
||||
amount = value
|
||||
if tremolo['type'] == 'start' and attack % 2:
|
||||
other = partner[0]['pitches'][note['pitches'].index(pitch)]
|
||||
amount = sounding_pitch(other, partner[2], guitar)
|
||||
events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': rational(amount), 'onset': rational(performed + cursor), 'duration': rational(min(step, length - cursor))})
|
||||
cursor += step
|
||||
attack += 1
|
||||
if len(events) > MAX_EVENTS:
|
||||
raise PlaybackUnavailable('playback exceeds one million tone events')
|
||||
else:
|
||||
event = {'id': pitch['id'], 'pitch': rational(value), 'onset': rational(performed), 'duration': rational(length)}
|
||||
events.append(event)
|
||||
linked[pitch['id']] = event
|
||||
if len(events) > MAX_EVENTS:
|
||||
raise PlaybackUnavailable('playback exceeds one million tone events')
|
||||
events.sort(key=lambda e: (fraction(e['onset']), e['id']))
|
||||
return {'version': 1, 'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events}
|
||||
|
||||
def write_pcm(path: Path, values) -> str:
|
||||
samples = array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
|
||||
if sys.byteorder != 'little':
|
||||
samples.byteswap()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(path), 'wb') as output:
|
||||
output.setparams((1, 2, SAMPLE_RATE, 0, 'NONE', 'not compressed'))
|
||||
output.writeframes(samples.tobytes())
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def generate_tone(root: Path, pitch: Fraction) -> dict:
|
||||
frequency = 440 * 2 ** (float(pitch - 69) / 12)
|
||||
period = Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE)
|
||||
loop_length, cycles = period.denominator, period.numerator
|
||||
attack = 12000
|
||||
name = hashlib.sha256(f'{PROFILE}:{pitch}'.encode('ascii')).hexdigest()[:24]
|
||||
relative = f'audio/{name}.wav'
|
||||
harmonics = [h for h in range(1, 7) if frequency * h < SAMPLE_RATE * 0.45]
|
||||
|
||||
def sample(frame):
|
||||
phase = 2 * math.pi * cycles * frame / loop_length
|
||||
envelope = min(1, frame / 240) * (0.18 + 0.82 * (1 - min(1, frame / attack)) ** 3)
|
||||
return 0.3 * envelope * sum(math.sin(phase * h) / (h * h) for h in harmonics)
|
||||
|
||||
checksum = write_pcm(root / relative, (sample(i) for i in range(attack + loop_length)))
|
||||
return {'id': name, 'path': relative, 'sha256': checksum, 'sample_rate': SAMPLE_RATE, 'frames': attack + loop_length, 'loop_start': attack, 'loop_end': attack + loop_length}
|
||||
|
||||
def compile_tree(root: Path) -> None:
|
||||
tones = {}
|
||||
release = {'path': 'audio/release.wav', 'sample_rate': SAMPLE_RATE, 'frames': 2400}
|
||||
release['sha256'] = write_pcm(root / release['path'], ((1 - i / 2399) ** 2 for i in range(2400)))
|
||||
for path in sorted(root.rglob('*.json')):
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
try:
|
||||
guitar = path.relative_to(root).parts[:2] == ('practice', 'chord') or document['staves'][0].get('arrangement_part_type') in ('acoustic', 'electric')
|
||||
playback = compile_timeline(document, guitar=guitar)
|
||||
used = {}
|
||||
for event in playback['events']:
|
||||
event['end'] = rational(fraction(event['onset']) + fraction(event.pop('duration')))
|
||||
pitch = fraction(event.pop('pitch'))
|
||||
if pitch not in tones:
|
||||
tones[pitch] = generate_tone(root, pitch)
|
||||
tone = tones[pitch]
|
||||
event['sample'] = tone['id']
|
||||
used[tone['id']] = tone
|
||||
playback.update({'samples': list(used.values()), 'release': release, 'profile': PROFILE})
|
||||
except PlaybackUnavailable as error:
|
||||
playback = {'version': 1, 'available': False, 'reason': str(error)}
|
||||
document['playback'] = playback
|
||||
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
|
||||
def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> None:
|
||||
if playback.get('version') != 1 or type(playback.get('available')) is not bool:
|
||||
raise ValueError('invalid playback manifest version/availability')
|
||||
if not playback['available']:
|
||||
if not isinstance(playback.get('reason'), str) or not playback['reason']:
|
||||
raise ValueError('unavailable playback must explain why')
|
||||
return
|
||||
cache = {} if cache is None else cache
|
||||
|
||||
def check_sample(value):
|
||||
name = value['path']
|
||||
relative = Path(name)
|
||||
if not name.startswith('audio/') or relative.is_absolute() or relative.suffix != '.wav' or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or '\x00' in name:
|
||||
raise ValueError('invalid generated audio path')
|
||||
path = (root / relative).resolve()
|
||||
if not path.is_relative_to(root.resolve()):
|
||||
raise ValueError('generated audio escapes asset root')
|
||||
key = (str(path), value['sha256'], value['frames'], value['sample_rate'])
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
if value['sample_rate'] != SAMPLE_RATE or not 0 < value['frames'] <= 96000:
|
||||
raise ValueError('invalid generated audio rate/length')
|
||||
if path.stat().st_size != 44 + value['frames'] * 2 or hashlib.sha256(path.read_bytes()).hexdigest() != value['sha256']:
|
||||
raise ValueError('generated audio checksum mismatch')
|
||||
with wave.open(str(path)) as audio:
|
||||
pcm = audio.readframes(value['frames'])
|
||||
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate(), audio.getnframes()) != (1, 2, SAMPLE_RATE, value['frames']) or len(pcm) != value['frames'] * 2:
|
||||
raise ValueError('invalid or truncated generated PCM WAV')
|
||||
samples = array('h')
|
||||
samples.frombytes(pcm)
|
||||
if sys.byteorder != 'little':
|
||||
samples.byteswap()
|
||||
if min(samples) < -32767:
|
||||
raise ValueError('generated PCM exceeds unit range')
|
||||
cache[key] = samples
|
||||
return samples
|
||||
|
||||
duration = fraction(playback['duration'])
|
||||
if duration <= 0 or len(playback['events']) > MAX_EVENTS:
|
||||
raise ValueError('invalid playback timeline length')
|
||||
release = check_sample(playback['release'])
|
||||
if not 1 < playback['release']['frames'] <= SAMPLE_RATE // 2:
|
||||
raise ValueError('invalid release envelope length')
|
||||
if release[0] != 32767 or release[-1] != 0 or any(a < b for a, b in zip(release, release[1:])):
|
||||
raise ValueError('release envelope must decrease from one to zero')
|
||||
if len(playback['samples']) > 512 or sum(s['frames'] for s in playback['samples']) > 16 * 1024 * 1024:
|
||||
raise ValueError('generated playback exceeds sample memory budget')
|
||||
samples = {}
|
||||
for sample in playback['samples']:
|
||||
if not sample['id'] or sample['id'] in samples or not 0 <= sample['loop_start'] < sample['loop_end'] == sample['frames']:
|
||||
raise ValueError('invalid sample identity or loop')
|
||||
check_sample(sample)
|
||||
samples[sample['id']] = sample
|
||||
previous = Fraction()
|
||||
identities = set()
|
||||
for event in playback['events']:
|
||||
onset, end = fraction(event['onset']), fraction(event['end'])
|
||||
if event['id'] in identities or event['sample'] not in samples or not previous <= onset < end <= duration:
|
||||
raise ValueError('invalid playback event identity, reference or timing')
|
||||
identities.add(event['id'])
|
||||
previous = onset
|
||||
@@ -86,6 +86,8 @@ def extend_fingerings(held: dict[str, TabPosition], pending: list[tuple[str, tup
|
||||
yield from extend_fingerings({**held, identifier: position}, pending[1:])
|
||||
|
||||
def validate_guitar_practice(document: dict) -> None:
|
||||
from generator.repeats import expand_document
|
||||
document = expand_document(document)
|
||||
tones = guitar_tones(document)
|
||||
by_id = {tone.pitch['id']: tone for tone in tones}
|
||||
options = {tone.pitch['id']: tone_positions(tone) for tone in tones}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction
|
||||
import re
|
||||
|
||||
from generator.model import alteration, fraction, key_label, meter_label, rational
|
||||
|
||||
MAX_DEPTH = 16
|
||||
MAX_MEASURES = 10000
|
||||
|
||||
def pass_numbers(value: str) -> list[int]:
|
||||
if not re.fullmatch(r'[1-9][0-9]*(?:,\s*[1-9][0-9]*)*', value.strip()):
|
||||
raise ValueError('ending/time-only numbers must be comma-separated positive integers')
|
||||
result = [int(item) for item in value.split(',')]
|
||||
if len(set(result)) != len(result) or max(result) > MAX_MEASURES:
|
||||
raise ValueError('duplicate or excessive ending/time-only number')
|
||||
return sorted(result)
|
||||
|
||||
@dataclass
|
||||
class Ending:
|
||||
start: int
|
||||
end: int
|
||||
numbers: list[int]
|
||||
text: str
|
||||
closed: bool
|
||||
visible: bool
|
||||
|
||||
@dataclass
|
||||
class Region:
|
||||
start: int
|
||||
end: int
|
||||
extent: int
|
||||
count: int
|
||||
endings: list[Ending]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Occurrence:
|
||||
measure: int
|
||||
passes: tuple[int, ...]
|
||||
|
||||
def analyze(measures: list[dict]) -> tuple[list[Region], list[Ending]]:
|
||||
regions, endings, starts = [], [], []
|
||||
active = None
|
||||
for index, measure in enumerate(measures):
|
||||
for side, boundary in (('left', index), ('right', index + 1)):
|
||||
if marker := measure.get('endings', {}).get(side):
|
||||
numbers = marker['numbers']
|
||||
if numbers != pass_numbers(','.join(map(str, numbers))):
|
||||
raise ValueError(f'measure {index + 1}: invalid ending passes')
|
||||
if marker['type'] == 'start':
|
||||
if active is not None:
|
||||
raise ValueError(f'measure {index + 1}: overlapping numbered endings')
|
||||
active = (boundary, marker)
|
||||
elif marker['type'] in ('stop', 'discontinue'):
|
||||
if active is None or active[1]['numbers'] != numbers or active[0] >= boundary:
|
||||
raise ValueError(f'measure {index + 1}: ending stop has no matching nonempty start')
|
||||
first, value = active
|
||||
endings.append(Ending(first, boundary, numbers, value.get('text') or ', '.join(map(str, numbers)) + '.', marker['type'] == 'stop', value.get('visible', True)))
|
||||
active = None
|
||||
else:
|
||||
raise ValueError(f'measure {index + 1}: invalid ending type')
|
||||
if marker := measure.get('repeats', {}).get(side):
|
||||
direction = marker['direction']
|
||||
if direction == 'forward' and side == 'left':
|
||||
starts.append(boundary)
|
||||
if len(starts) > MAX_DEPTH:
|
||||
raise ValueError('repeat nesting exceeds 16 levels')
|
||||
elif direction == 'backward' and side == 'right':
|
||||
first = starts.pop() if starts else 0
|
||||
count = marker.get('times', 2)
|
||||
if type(count) is not int or not 1 <= count <= MAX_MEASURES or first >= boundary:
|
||||
raise ValueError(f'measure {index + 1}: invalid repeat count or empty repeat')
|
||||
regions.append(Region(first, boundary, boundary, count, []))
|
||||
else:
|
||||
raise ValueError(f'measure {index + 1}: repeat direction disagrees with its boundary')
|
||||
if starts or active is not None:
|
||||
raise ValueError('unterminated repeat or numbered ending')
|
||||
assigned = set()
|
||||
for region in regions:
|
||||
first = next((ending for ending in endings if region.start <= ending.start < region.end and ending.end == region.end), None)
|
||||
if first is None:
|
||||
continue
|
||||
group = [first]
|
||||
while following := next((ending for ending in endings if ending.start == group[-1].end), None):
|
||||
group.append(following)
|
||||
numbers = [number for ending in group for number in ending.numbers]
|
||||
count = max(numbers)
|
||||
if sorted(numbers) != list(range(1, count + 1)) or count not in group[-1].numbers or len(group) < 2:
|
||||
raise ValueError(f'measure {region.start + 1}: endings must cover each pass exactly once and finish with the last pass')
|
||||
marker = measures[region.end - 1]['repeats']['right']
|
||||
if 'times' in marker and marker['times'] != count:
|
||||
raise ValueError(f'measure {region.end}: repeat count contradicts ending passes')
|
||||
region.count, region.extent, region.endings = count, group[-1].end, group
|
||||
assigned.update(id(ending) for ending in group)
|
||||
if any(id(ending) not in assigned for ending in endings):
|
||||
raise ValueError('numbered ending is not attached to a repeat group')
|
||||
for a, region in enumerate(regions):
|
||||
for other in regions[a + 1:]:
|
||||
if region.start == other.start and region.extent == other.extent:
|
||||
raise ValueError('duplicate repeat region')
|
||||
left, right = sorted((region, other), key=lambda item: (item.start, -item.extent))
|
||||
if left.start < right.start < left.extent < right.extent:
|
||||
raise ValueError('crossing repeat regions are ambiguous')
|
||||
for ending in left.endings:
|
||||
if right.start < ending.start < right.extent or right.start < ending.end < right.extent:
|
||||
raise ValueError('nested repeat crosses an ending boundary')
|
||||
return regions, endings
|
||||
|
||||
def performed_order(measures: list[dict]) -> list[Occurrence]:
|
||||
regions, _ = analyze(measures)
|
||||
result = []
|
||||
|
||||
def walk(start, end, parents, passes, excluded=()):
|
||||
if len(parents) > MAX_DEPTH:
|
||||
raise ValueError('repeat nesting exceeds 16 levels')
|
||||
index = start
|
||||
while index < end:
|
||||
skipped = next((ending for ending in excluded if ending.start == index), None)
|
||||
if skipped:
|
||||
index = skipped.end
|
||||
continue
|
||||
candidates = [r for r in regions if r.start == index and r.extent <= end and id(r) not in parents]
|
||||
if candidates:
|
||||
region = max(candidates, key=lambda r: r.extent)
|
||||
for number in range(1, region.count + 1):
|
||||
walk(index, region.extent, (*parents, id(region)), (*passes, number), [e for e in region.endings if number not in e.numbers])
|
||||
index = region.extent
|
||||
else:
|
||||
result.append(Occurrence(index, passes))
|
||||
if len(result) > MAX_MEASURES:
|
||||
raise ValueError('expanded repeat traversal exceeds 10000 measures')
|
||||
index += 1
|
||||
|
||||
walk(0, len(measures), (), ())
|
||||
return result
|
||||
|
||||
def expand_document(document: dict) -> dict:
|
||||
staff = document['staves'][0]
|
||||
source = staff['measures']
|
||||
if not any(m.get('repeats') or m.get('endings') for m in source):
|
||||
return copy.deepcopy(document)
|
||||
order = performed_order(source)
|
||||
output = copy.deepcopy(document)
|
||||
target = output['staves'][0]
|
||||
target['measures'], target['tuplets'], target['octave_shifts'] = [], [], []
|
||||
starts = [Fraction()]
|
||||
harmonies_before = []
|
||||
active_harmony = None
|
||||
event_measures = {n['id']: i for i, m in enumerate(source) for v in m['voices'] for n in v['notes']}
|
||||
event_ids = {(oi, n['id']): f'occurrence-{oi + 1}-n{ni + 1}' for oi, o in enumerate(order) for ni, n in enumerate(n for v in source[o.measure]['voices'] for n in v['notes'])}
|
||||
for measure in source:
|
||||
starts.append(starts[-1] + fraction(measure['duration']))
|
||||
harmonies_before.append(active_harmony)
|
||||
for harmony in measure['harmonies']:
|
||||
active_harmony = harmony
|
||||
absolute = Fraction()
|
||||
open_ties = {}
|
||||
expected_ties, used_ties = set(), set()
|
||||
previous_state = {}
|
||||
previous_index = -1
|
||||
active_tuplets = {}
|
||||
for oi, occurrence in enumerate(order):
|
||||
si = occurrence.measure
|
||||
measure = copy.deepcopy(source[si])
|
||||
measure['id'] = f'occurrence-{oi + 1}'
|
||||
measure.pop('repeats', None)
|
||||
measure.pop('endings', None)
|
||||
if active_harmony and oi and si <= previous_index and not any(fraction(h['onset']) == 0 for h in measure['harmonies']):
|
||||
restored = copy.deepcopy(harmonies_before[si]) if harmonies_before[si] else {'kind': 'none', 'symbol': 'N.C.', 'degrees': []}
|
||||
restored['onset'] = rational(0)
|
||||
measure['harmonies'].insert(0, restored)
|
||||
for side, marker in source[si].get('repeats', {}).items():
|
||||
measure.get('barlines', {}).pop(side, None)
|
||||
if oi and si > previous_index:
|
||||
for field, value in previous_state.items():
|
||||
if field not in measure.get('initial_attributes', {}):
|
||||
measure[field] = copy.deepcopy(value)
|
||||
measure.pop('initial_attributes', None)
|
||||
if oi and measure['key'].get('cancel') is not None:
|
||||
measure['key']['cancel'] = previous_state['key'].get('fifths', 0)
|
||||
measure['key_signature'] = key_label(measure['key'])
|
||||
measure['time_signature'] = meter_label(measure['meter'])
|
||||
previous_state = {field: copy.deepcopy(measure[field]) for field in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
|
||||
for change in measure.get('attributes', []):
|
||||
previous_state.update({field: copy.deepcopy(value) for field, value in change.items() if field != 'onset'})
|
||||
event_map, tone_ids = {}, {}
|
||||
for hi, harmony in enumerate(measure['harmonies']):
|
||||
harmony['id'] = f'{measure["id"]}-h{hi + 1}'
|
||||
for ni, note in enumerate(n for v in measure['voices'] for n in v['notes']):
|
||||
old = note['id']
|
||||
note['id'] = f'{measure["id"]}-n{ni + 1}'
|
||||
event_map[old] = note['id']
|
||||
for pi, pitch in enumerate(note['pitches']):
|
||||
original = pitch['id']
|
||||
pitch['id'] = f'{note["id"]}-p{pi + 1}'
|
||||
tone_ids[pitch['id']] = original
|
||||
if 'sound_ties' not in pitch:
|
||||
pitch['sound_ties'] = [{'type': action} for field, action in (('tie_from', 'stop'), ('tie_to', 'start')) if pitch.get(field)]
|
||||
expected_ties.update((original, marker['type']) for marker in pitch['sound_ties'])
|
||||
pitch.pop('tie_from', None)
|
||||
pitch.pop('tie_to', None)
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
onset = absolute + fraction(note['onset'])
|
||||
for pitch in note['pitches']:
|
||||
markers = pitch.pop('sound_ties', [])
|
||||
pitch.pop('printed_ties', None)
|
||||
unpitched = pitch.get('unpitched', {})
|
||||
identity = (voice['id'], pitch.get('letter'), alteration(pitch.get('alter', 0)), pitch.get('octave'), unpitched.get('letter'), unpitched.get('octave'), pitch.get('instrument'))
|
||||
for marker in sorted(markers, key=lambda t: t['type'] == 'start'):
|
||||
if marker.get('passes') and (not occurrence.passes or occurrence.passes[-1] not in marker['passes']):
|
||||
continue
|
||||
used_ties.add((tone_ids[pitch['id']], marker['type']))
|
||||
if marker['type'] == 'stop':
|
||||
pending = open_ties.pop(identity, None)
|
||||
if not pending or pending[1] != onset:
|
||||
raise ValueError(f'measure {source[si]["number"]}, passes {occurrence.passes}: tie stop has no adjacent start on the performed path; use time-only for pass-specific ties')
|
||||
old, _ = pending
|
||||
old['tie_to'], pitch['tie_from'] = pitch['id'], old['id']
|
||||
else:
|
||||
if identity in open_ties:
|
||||
raise ValueError(f'measure {source[si]["number"]}: overlapping tie starts')
|
||||
open_ties[identity] = (pitch, onset + fraction(pitch['duration']))
|
||||
if partner := note.get('tremolo', {}).get('partner'):
|
||||
pi = oi + event_measures[partner] - si
|
||||
low, high = sorted((oi, pi))
|
||||
if pi < 0 or pi >= len(order) or any(order[j].measure != si + j - oi for j in range(low, high + 1)):
|
||||
raise ValueError(f'measure {source[si]["number"]}: repeat jump splits a paired tremolo')
|
||||
note['tremolo']['partner'] = event_ids[pi, partner]
|
||||
continued = {}
|
||||
for span in staff.get('tuplets', []):
|
||||
members = [n for v in measure['voices'] for n in v['notes'] if span['id'] in n.get('tuplet_ids', [])]
|
||||
if members:
|
||||
previous = active_tuplets.get(span['id']) if si == previous_index + 1 else None
|
||||
if previous and previous['continued_end'] and span['start'] not in event_map:
|
||||
rendered = previous
|
||||
rendered['end'] = members[-1]['id']
|
||||
rendered['continued_end'] = span['end'] not in event_map
|
||||
else:
|
||||
rendered = {**copy.deepcopy(span), 'id': f'{measure["id"]}-{span["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': span['start'] not in event_map, 'continued_end': span['end'] not in event_map}
|
||||
target['tuplets'].append(rendered)
|
||||
identifier = rendered['id']
|
||||
continued[span['id']] = rendered
|
||||
for note in members:
|
||||
note['tuplet_ids'] = [identifier if t == span['id'] else t for t in note['tuplet_ids']]
|
||||
active_tuplets = continued
|
||||
for shift in staff.get('octave_shifts', []):
|
||||
low, high = max(starts[si], fraction(shift['start'])), min(starts[si + 1], fraction(shift['end']))
|
||||
if low < high:
|
||||
target['octave_shifts'].append({**shift, 'id': f'{measure["id"]}-{shift["id"]}', 'start': rational(absolute + low - starts[si]), 'end': rational(absolute + high - starts[si])})
|
||||
target['measures'].append(measure)
|
||||
absolute += fraction(measure['duration'])
|
||||
previous_index = si
|
||||
if open_ties:
|
||||
raise ValueError('tie start has no matching endpoint on the performed path; use time-only for pass-specific ties')
|
||||
if expected_ties != used_ties:
|
||||
raise ValueError('time-only tie refers to a pass that is never performed')
|
||||
return output
|
||||
@@ -123,5 +123,10 @@ def count_json_files(root: Path) -> int:
|
||||
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
||||
|
||||
def validate_generated_tree(root: Path) -> None:
|
||||
audio_cache = {}
|
||||
for path in root.rglob('*.json'):
|
||||
load_json(path)
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
if 'playback' in document:
|
||||
from generator.playback import validate_assets
|
||||
validate_assets(document['playback'], root, audio_cache)
|
||||
|
||||
@@ -110,6 +110,9 @@ def transpose_document(document: dict, target_key_name: str, *, octave_shift: in
|
||||
for change in measure.get('attributes', []):
|
||||
if 'key' in change:
|
||||
change['key'] = shift_key(change['key'], steps, semitones)
|
||||
initial = measure.get('initial_attributes', {})
|
||||
if 'key' in initial:
|
||||
initial['key'] = shift_key(initial['key'], steps, semitones)
|
||||
for h in measure.get('harmonies', []):
|
||||
for field in ('root', 'bass'):
|
||||
if field in h:
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <future>
|
||||
#include <optional>
|
||||
#include <filesystem>
|
||||
|
||||
#include <boost/asio/thread_pool.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
#include <sonus.hpp>
|
||||
|
||||
#include <vamp_zero/config.hpp>
|
||||
#include <vamp_zero/types.hpp>
|
||||
#include <vamp_zero/score_loader.hpp>
|
||||
|
||||
|
||||
namespace vamp_zero {
|
||||
@@ -21,6 +27,7 @@ public:
|
||||
auto operator=(vamp_zero::audio_controller const &) -> vamp_zero::audio_controller & = delete;
|
||||
~audio_controller();
|
||||
auto sync(vamp_zero::app_state const &state) -> void;
|
||||
auto tick(vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, std::filesystem::path const &assets) -> std::optional<std::expected<void, modulus::error>>;
|
||||
private:
|
||||
auto make_metronome_config(vamp_zero::app_state const &state) const -> sonus::metronome_config;
|
||||
vamp_zero::audio_config config_{};
|
||||
@@ -28,6 +35,13 @@ private:
|
||||
mutable std::mutex mutex_{};
|
||||
std::bool_t metronome_running_{false};
|
||||
std::uint32_t last_metronome_bpm_{};
|
||||
boost::asio::thread_pool worker_{1};
|
||||
std::future<std::expected<sonus::prepared_sequence, modulus::error>> preparation_{};
|
||||
std::uint64_t preparing_session_{};
|
||||
std::optional<std::uint64_t> attempted_session_{};
|
||||
std::shared_ptr<vamp_zero::catalogue const> catalog_{};
|
||||
sonus::playback_session playback_{};
|
||||
std::bool_t reported_{true};
|
||||
};
|
||||
|
||||
auto create_audio_controller(vamp_zero::audio_config const &config) -> std::expected<std::unique_ptr<vamp_zero::audio_controller>, modulus::error>;
|
||||
|
||||
@@ -48,7 +48,7 @@ struct control_config {
|
||||
|
||||
struct audio_config {
|
||||
std::bool_t enabled{true};
|
||||
std::bool_t track_enabled{false};
|
||||
std::bool_t playback_enabled{false};
|
||||
std::bool_t metronome_enabled{false};
|
||||
std::uint32_t metronome_bpm{120};
|
||||
std::uint32_t metronome_min_bpm{40};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
|
||||
#include <modulus.hpp>
|
||||
#include <sonus.hpp>
|
||||
|
||||
#include <vamp_zero/types.hpp>
|
||||
|
||||
|
||||
namespace vamp_zero {
|
||||
|
||||
auto prepare_playback(vamp_zero::score_document const &score, std::filesystem::path const &assets, std::uint32_t bpm) -> std::expected<sonus::sample_sequence, modulus::error>;
|
||||
auto validate_playback_assets(vamp_zero::score_document const &score, std::filesystem::path const &assets) -> std::expected<void, modulus::error>;
|
||||
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
#include <vamp_zero/config.hpp>
|
||||
#include <vamp_zero/types.hpp>
|
||||
|
||||
|
||||
namespace vamp_zero {
|
||||
|
||||
class control_state {
|
||||
@@ -25,6 +24,7 @@ public:
|
||||
auto heartbeat() -> 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 finish_playback(std::uint64_t session, std::string error = {}, std::shared_ptr<vamp_zero::catalogue const> catalog = {}) -> void;
|
||||
private:
|
||||
auto pico_connected_locked() const -> std::bool_t;
|
||||
auto snapshot_locked() const -> vamp_zero::app_state;
|
||||
|
||||
@@ -128,7 +128,8 @@ enum class app_mode {
|
||||
};
|
||||
|
||||
struct audio_state {
|
||||
std::bool_t track_enabled{false};
|
||||
std::string playback_error{};
|
||||
std::bool_t playback_enabled{false};
|
||||
std::bool_t metronome_enabled{false};
|
||||
std::uint32_t metronome_bpm{120};
|
||||
std::uint32_t metronome_min_bpm{40};
|
||||
@@ -151,6 +152,7 @@ struct app_state {
|
||||
std::string active_part_id{};
|
||||
std::string active_practice_id{};
|
||||
std::size_t page_index{};
|
||||
std::uint64_t score_session{};
|
||||
std::uint64_t next_count{};
|
||||
std::uint64_t previous_count{};
|
||||
std::uint64_t select_count{};
|
||||
|
||||
@@ -38,6 +38,7 @@ public:
|
||||
auto stop() -> void;
|
||||
auto publish(vamp_zero::ui_state state) -> std::expected<void, modulus::error>;
|
||||
auto snapshot() const -> vamp_zero::ui_state;
|
||||
auto selected_score_loader() const -> std::shared_ptr<vamp_zero::score_loader>;
|
||||
private:
|
||||
std::shared_ptr<luma::application> application_;
|
||||
std::shared_ptr<vamp_zero::ui_runtime> runtime_;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"audio": {
|
||||
"enabled": true,
|
||||
"track_enabled": false,
|
||||
"playback_enabled": false,
|
||||
"metronome_enabled": false,
|
||||
"metronome_bpm": 120,
|
||||
"metronome_min_bpm": 40,
|
||||
|
||||
+52
-10
@@ -39,6 +39,7 @@
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/playback.hpp>
|
||||
#include <vamp_zero/ui.hpp>
|
||||
|
||||
|
||||
@@ -178,7 +179,7 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
|
||||
}),
|
||||
.audio =
|
||||
{
|
||||
.track_enabled = config.audio.track_enabled,
|
||||
.playback_enabled = config.audio.playback_enabled,
|
||||
.metronome_enabled = config.audio.metronome_enabled,
|
||||
.metronome_bpm = config.audio.metronome_bpm,
|
||||
.metronome_min_bpm = config.audio.metronome_min_bpm,
|
||||
@@ -192,8 +193,8 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
|
||||
return state;
|
||||
}
|
||||
|
||||
auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::database_config const &config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto score = co_await vamp_zero::async_load_score(executor, config, std::move(reference));
|
||||
auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto score = co_await vamp_zero::async_load_score(executor, config.database, std::move(reference));
|
||||
if (!score) {
|
||||
co_return std::unexpected(score.error());
|
||||
}
|
||||
@@ -203,10 +204,14 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::data
|
||||
co_return std::unexpected(valid.error());
|
||||
}
|
||||
}
|
||||
auto playback = vamp_zero::validate_playback_assets(*score, config.runtime.assets_path);
|
||||
if (!playback) {
|
||||
co_return std::unexpected(playback.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto validate_catalogue(boost::asio::any_io_executor executor, vamp_zero::database_config const &config, vamp_zero::catalogue const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto validate_catalogue(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::catalogue const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
for (auto const &part : catalog.performance.parts) {
|
||||
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part.id};
|
||||
auto valid = co_await validate_score_pages(executor, config, std::move(reference));
|
||||
@@ -265,7 +270,7 @@ auto probe_runtime_directory(vamp_zero::config const &config) -> std::expected<v
|
||||
|
||||
auto check_readiness(vamp_zero::config const &config, vamp_zero::catalogue const &catalog) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, validate_catalogue(io.get_executor(), config.database, catalog), boost::asio::use_future);
|
||||
auto future = boost::asio::co_spawn(io, validate_catalogue(io.get_executor(), config, catalog), boost::asio::use_future);
|
||||
io.run();
|
||||
auto valid = future.get();
|
||||
if (!valid) {
|
||||
@@ -290,6 +295,44 @@ auto check_readiness(vamp_zero::config const &config, vamp_zero::catalogue const
|
||||
return {};
|
||||
}
|
||||
|
||||
class playback_poll {
|
||||
public:
|
||||
playback_poll(boost::asio::io_context &io, vamp_zero::control_state &state, vamp_zero::audio_controller *audio, std::shared_ptr<vamp_zero::score_loader> loader, std::filesystem::path assets, std::bool_t selects)
|
||||
: timer_{io}, state_{state}, audio_{audio}, loader_{std::move(loader)}, assets_{std::move(assets)}, selects_{selects} {
|
||||
schedule();
|
||||
}
|
||||
~playback_poll() {
|
||||
timer_.cancel();
|
||||
}
|
||||
private:
|
||||
auto schedule() -> void {
|
||||
timer_.expires_after(std::chrono::milliseconds{20});
|
||||
timer_.async_wait([this](auto const &error) {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
auto app = state_.snapshot();
|
||||
if (selects_) {
|
||||
loader_->select(app.catalog, vamp_zero::selected_score_reference(app));
|
||||
}
|
||||
if (audio_) {
|
||||
if (auto result = audio_->tick(app, loader_->snapshot(), assets_)) {
|
||||
state_.finish_playback(app.score_session, *result ? std::string{} : result->error().describe(), app.catalog);
|
||||
}
|
||||
} else if (app.audio.playback_enabled && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) {
|
||||
state_.finish_playback(app.score_session, "Audio output is unavailable");
|
||||
}
|
||||
schedule();
|
||||
});
|
||||
}
|
||||
boost::asio::steady_timer timer_;
|
||||
vamp_zero::control_state &state_;
|
||||
vamp_zero::audio_controller *audio_;
|
||||
std::shared_ptr<vamp_zero::score_loader> loader_;
|
||||
std::filesystem::path assets_;
|
||||
std::bool_t selects_;
|
||||
};
|
||||
|
||||
auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto endpoint = make_server_endpoint(config);
|
||||
@@ -303,9 +346,10 @@ auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &sta
|
||||
return std::unexpected(started.error());
|
||||
}
|
||||
if (audio != nullptr) {
|
||||
state.set_observer([audio](vamp_zero::app_state app) { audio->sync(app); });
|
||||
audio->sync(state.snapshot());
|
||||
}
|
||||
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = io.get_executor(), database = config.database](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference)); });
|
||||
auto playback = playback_poll{io, state, audio, loader, config.runtime.assets_path, true};
|
||||
auto heartbeat = write_heartbeat(config);
|
||||
if (!heartbeat) {
|
||||
server.stop();
|
||||
@@ -330,10 +374,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, [&state](vamp_protocol::page_command command) {
|
||||
static_cast<void>(state.apply(command));
|
||||
}};
|
||||
state.set_observer([&ui, &config, audio](vamp_zero::app_state app) {
|
||||
if (audio != nullptr) {
|
||||
audio->sync(app);
|
||||
}
|
||||
state.set_observer([&ui, &config](vamp_zero::app_state app) {
|
||||
auto status = ui.publish(vamp_zero::make_ui_state(config, std::move(app)));
|
||||
if (!status) {
|
||||
modulus::log_error("failed to publish UI state: " + status.error().describe());
|
||||
@@ -346,6 +387,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
|
||||
if (!published) {
|
||||
return std::unexpected(published.error());
|
||||
}
|
||||
auto playback = playback_poll{io, state, audio, ui.selected_score_loader(), config.runtime.assets_path, false};
|
||||
auto server = ligo::http::server{
|
||||
io, *endpoint, vamp_zero::make_router(state), ligo::http::server_options_from_config(config.control.endpoint)};
|
||||
auto started = server.start();
|
||||
|
||||
+81
-1
@@ -3,12 +3,15 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <boost/asio/post.hpp>
|
||||
|
||||
#include <modulus.hpp>
|
||||
#include <sonus.hpp>
|
||||
|
||||
#include <vamp_zero/audio.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/playback.hpp>
|
||||
#include <vamp_zero/errors.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
@@ -34,6 +37,7 @@ vamp_zero::audio_controller::audio_controller(vamp_zero::audio_config config, so
|
||||
: config_{config}, engine_{std::move(engine)} {}
|
||||
|
||||
vamp_zero::audio_controller::~audio_controller() {
|
||||
worker_.join();
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
engine_.stop_metronome();
|
||||
engine_.stop_all();
|
||||
@@ -50,7 +54,10 @@ auto vamp_zero::audio_controller::make_metronome_config(vamp_zero::app_state con
|
||||
|
||||
auto vamp_zero::audio_controller::sync(vamp_zero::app_state const &state) -> void {
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
auto const should_run_metronome = state.audio.metronome_enabled && score_active(state);
|
||||
auto const should_run_metronome = state.audio.metronome_enabled && !state.audio.playback_enabled && score_active(state);
|
||||
if (!state.audio.playback_enabled || !score_active(state) || attempted_session_ != state.score_session || catalog_ != state.catalog) {
|
||||
playback_.stop();
|
||||
}
|
||||
if (!should_run_metronome) {
|
||||
if (metronome_running_) {
|
||||
engine_.stop_metronome();
|
||||
@@ -83,6 +90,79 @@ auto vamp_zero::audio_controller::sync(vamp_zero::app_state const &state) -> voi
|
||||
}
|
||||
}
|
||||
|
||||
auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, std::filesystem::path const &assets) -> std::optional<std::expected<void, modulus::error>> {
|
||||
sync(state);
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
engine_.collect_finished();
|
||||
auto wanted = state.audio.playback_enabled && score_active(state);
|
||||
if (!wanted || catalog_ != state.catalog) {
|
||||
attempted_session_.reset();
|
||||
reported_ = true;
|
||||
}
|
||||
catalog_ = state.catalog;
|
||||
if (preparation_.valid()) {
|
||||
if (preparation_.wait_for(std::chrono::seconds{0}) != std::future_status::ready) {
|
||||
return std::nullopt;
|
||||
}
|
||||
auto prepared = preparation_.get();
|
||||
if (wanted && attempted_session_ == state.score_session && preparing_session_ == state.score_session) {
|
||||
if (!prepared) {
|
||||
reported_ = true;
|
||||
return std::unexpected(prepared.error());
|
||||
}
|
||||
auto started = engine_.play_sequence(std::move(*prepared));
|
||||
if (!started) {
|
||||
reported_ = true;
|
||||
return std::unexpected(started.error());
|
||||
}
|
||||
playback_ = std::move(*started);
|
||||
reported_ = false;
|
||||
}
|
||||
}
|
||||
if (!wanted) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (attempted_session_ != state.score_session) {
|
||||
auto reference = vamp_zero::selected_score_reference(state);
|
||||
if (loaded.document && (!reference || loaded.document->id != reference->id)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (loaded.loading || (!loaded.document && !loaded.error)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
attempted_session_ = state.score_session;
|
||||
reported_ = true;
|
||||
if (loaded.error) {
|
||||
return std::unexpected(*loaded.error);
|
||||
}
|
||||
preparing_session_ = state.score_session;
|
||||
auto task = std::make_shared<std::packaged_task<std::expected<sonus::prepared_sequence, modulus::error>()>>([score = loaded.document, assets, bpm = state.audio.metronome_bpm]() -> std::expected<sonus::prepared_sequence, modulus::error> {
|
||||
try {
|
||||
auto sequence = vamp_zero::prepare_playback(*score, assets, bpm);
|
||||
if (!sequence) {
|
||||
return std::unexpected(sequence.error());
|
||||
}
|
||||
return sonus::prepared_sequence::create(std::move(*sequence), 48000U);
|
||||
} catch (...) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INTERNAL, "audio preparation failed"));
|
||||
}
|
||||
});
|
||||
preparation_ = task->get_future();
|
||||
boost::asio::post(worker_, [task] { (*task)(); });
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!reported_ && playback_.status() != sonus::playback_status::PLAYING) {
|
||||
reported_ = true;
|
||||
if (playback_.status() == sonus::playback_status::COMPLETED) {
|
||||
return std::expected<void, modulus::error>{};
|
||||
}
|
||||
if (playback_.status() == sonus::playback_status::FAILED) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "sample playback failed"));
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto vamp_zero::create_audio_controller(vamp_zero::audio_config const &config)
|
||||
-> std::expected<std::unique_ptr<vamp_zero::audio_controller>, modulus::error> {
|
||||
if (!config.enabled) {
|
||||
|
||||
@@ -238,7 +238,19 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
|
||||
return fallback;
|
||||
}
|
||||
auto enabled = optional_bool(**object, "enabled", fallback.enabled);
|
||||
auto track_enabled = optional_bool(**object, "track_enabled", fallback.track_enabled);
|
||||
auto playback_enabled = optional_bool(**object, "playback_enabled", fallback.playback_enabled);
|
||||
auto legacy_track = optional_bool(**object, "track_enabled", fallback.playback_enabled);
|
||||
if (!legacy_track) {
|
||||
return std::unexpected(legacy_track.error());
|
||||
}
|
||||
if ((*object)->if_contains("track_enabled")) {
|
||||
if ((*object)->if_contains("playback_enabled") && playback_enabled && *playback_enabled != *legacy_track) {
|
||||
return std::unexpected(invalid("audio.playback_enabled contradicts legacy track_enabled"));
|
||||
}
|
||||
if (!(*object)->if_contains("playback_enabled")) {
|
||||
playback_enabled = legacy_track;
|
||||
}
|
||||
}
|
||||
auto metronome_enabled = optional_bool(**object, "metronome_enabled", fallback.metronome_enabled);
|
||||
auto metronome_bpm = optional_uint32(**object, "metronome_bpm", fallback.metronome_bpm, 1U, 400U);
|
||||
auto metronome_min_bpm = optional_uint32(**object, "metronome_min_bpm", fallback.metronome_min_bpm, 1U, 400U);
|
||||
@@ -250,8 +262,8 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
|
||||
if (!enabled) {
|
||||
return std::unexpected(enabled.error());
|
||||
}
|
||||
if (!track_enabled) {
|
||||
return std::unexpected(track_enabled.error());
|
||||
if (!playback_enabled) {
|
||||
return std::unexpected(playback_enabled.error());
|
||||
}
|
||||
if (!metronome_enabled) {
|
||||
return std::unexpected(metronome_enabled.error());
|
||||
@@ -285,7 +297,7 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
|
||||
}
|
||||
return vamp_zero::audio_config{
|
||||
.enabled = *enabled,
|
||||
.track_enabled = *track_enabled,
|
||||
.playback_enabled = *playback_enabled,
|
||||
.metronome_enabled = *metronome_enabled,
|
||||
.metronome_bpm = *metronome_bpm,
|
||||
.metronome_min_bpm = *metronome_min_bpm,
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace {
|
||||
|
||||
constexpr auto main_menu_performance = std::size_t{0};
|
||||
constexpr auto main_menu_practice = std::size_t{1};
|
||||
constexpr auto main_menu_track = std::size_t{2};
|
||||
constexpr auto main_menu_playback = std::size_t{2};
|
||||
constexpr auto main_menu_metronome = std::size_t{3};
|
||||
constexpr auto main_menu_tempo = std::size_t{4};
|
||||
constexpr auto main_menu_count = std::size_t{5};
|
||||
@@ -152,7 +152,8 @@ auto command_json(vamp_zero::app_state const &state) -> boost::json::object {
|
||||
|
||||
auto audio_json(vamp_zero::app_state const &state) -> boost::json::object {
|
||||
auto audio = boost::json::object{};
|
||||
audio["track_enabled"] = state.audio.track_enabled;
|
||||
audio["playback_enabled"] = state.audio.playback_enabled;
|
||||
audio["playback_error"] = state.audio.playback_error;
|
||||
audio["metronome_enabled"] = state.audio.metronome_enabled;
|
||||
audio["metronome_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_bpm);
|
||||
audio["metronome_min_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_min_bpm);
|
||||
@@ -365,8 +366,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
||||
state.selected_practice_key_index = 0;
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
} else if (state.selected_main_menu_index == main_menu_track) {
|
||||
state.audio.track_enabled = !state.audio.track_enabled;
|
||||
} else if (state.selected_main_menu_index == main_menu_playback) {
|
||||
state.audio.playback_enabled = !state.audio.playback_enabled;
|
||||
} else if (state.selected_main_menu_index == main_menu_metronome) {
|
||||
state.audio.metronome_enabled = !state.audio.metronome_enabled;
|
||||
} else if (state.selected_main_menu_index == main_menu_tempo) {
|
||||
@@ -403,6 +404,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
||||
state.active_practice_id.clear();
|
||||
state.page_index = 0;
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
++state.score_session;
|
||||
state.audio.playback_error.clear();
|
||||
}
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
@@ -489,6 +492,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
||||
state.active_practice_id = exercise->id;
|
||||
state.selected_practice_key_index = 0;
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_SCORE;
|
||||
++state.score_session;
|
||||
state.audio.playback_error.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,6 +516,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
state.mode = vamp_zero::app_mode::PRACTICE_SCORE;
|
||||
++state.score_session;
|
||||
state.audio.playback_error.clear();
|
||||
}
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
#include <vamp_zero/playback.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <numeric>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
#include <boost/multiprecision/cpp_int.hpp>
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto invalid(std::string message) -> modulus::error {
|
||||
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "playback: " + std::move(message));
|
||||
}
|
||||
|
||||
auto number(boost::json::object const &object, std::string_view key) -> std::expected<std::uint64_t, modulus::error> {
|
||||
auto value = object.if_contains(key);
|
||||
if (value && value->is_uint64()) {
|
||||
return value->as_uint64();
|
||||
}
|
||||
if (value && value->is_int64() && value->as_int64() >= 0) {
|
||||
return static_cast<std::uint64_t>(value->as_int64());
|
||||
}
|
||||
return std::unexpected(invalid(std::string{key} + " must be a nonnegative integer"));
|
||||
}
|
||||
|
||||
auto text(boost::json::object const &object, std::string_view key) -> std::expected<std::string, modulus::error> {
|
||||
auto value = object.if_contains(key);
|
||||
if (value && value->is_string()) {
|
||||
return std::string{value->as_string()};
|
||||
}
|
||||
return std::unexpected(invalid(std::string{key} + " must be a string"));
|
||||
}
|
||||
|
||||
auto frame_at(boost::json::object const &object, std::string_view key, std::uint32_t bpm) -> std::expected<std::uint64_t, modulus::error> {
|
||||
auto value = object.if_contains(key);
|
||||
if (!value || !value->is_object()) {
|
||||
return std::unexpected(invalid(std::string{key} + " must be a rational beat position"));
|
||||
}
|
||||
auto numerator = number(value->as_object(), "numerator");
|
||||
auto denominator = number(value->as_object(), "denominator");
|
||||
if (!numerator || !denominator || *denominator == 0U || *numerator > INT64_MAX || *denominator > INT64_MAX || std::gcd(*numerator, *denominator) != 1U) {
|
||||
return std::unexpected(invalid("invalid reduced beat position"));
|
||||
}
|
||||
boost::multiprecision::uint128_t divisor = boost::multiprecision::uint128_t{*denominator} * bpm;
|
||||
boost::multiprecision::uint128_t frames = (boost::multiprecision::uint128_t{*numerator} * 48000U * 60U + divisor / 2U) / divisor;
|
||||
if (frames > std::uint64_t{48000U} * 86400U) {
|
||||
return std::unexpected(invalid("sequence exceeds 24 hours"));
|
||||
}
|
||||
return frames.convert_to<std::uint64_t>();
|
||||
}
|
||||
|
||||
auto sample(boost::json::object const &descriptor, std::filesystem::path const &assets) -> std::expected<sonus::sample_buffer, modulus::error> {
|
||||
auto name = text(descriptor, "path");
|
||||
auto hash = text(descriptor, "sha256");
|
||||
auto rate = number(descriptor, "sample_rate");
|
||||
auto frames = number(descriptor, "frames");
|
||||
if (!name || !hash || !rate || !frames || *rate != 48000U || *frames == 0U || *frames > 48000U * 2U || hash->size() != 64U) {
|
||||
return std::unexpected(invalid("invalid sample manifest"));
|
||||
}
|
||||
auto relative = std::filesystem::path{*name};
|
||||
if (relative.is_absolute() || name->find('\0') != std::string::npos || name->find('\\') != std::string::npos || relative.extension() != ".wav" || relative.begin() == relative.end() || *relative.begin() != "audio" || std::any_of(relative.begin(), relative.end(), [](auto const &part) { return part == ".." || part == "."; })) {
|
||||
return std::unexpected(invalid("sample must be a safe audio/*.wav asset"));
|
||||
}
|
||||
auto error = std::error_code{};
|
||||
auto root = std::filesystem::canonical(assets, error);
|
||||
if (error) {
|
||||
return std::unexpected(invalid("asset root is missing"));
|
||||
}
|
||||
auto path = std::filesystem::canonical(root / relative, error);
|
||||
if (error || path.lexically_relative(root).empty() || *path.lexically_relative(root).begin() == "..") {
|
||||
return std::unexpected(invalid("sample is missing or escapes asset root"));
|
||||
}
|
||||
auto size = std::filesystem::file_size(path, error);
|
||||
if (error || size != 44U + *frames * 2U) {
|
||||
return std::unexpected(invalid("sample PCM length does not match manifest"));
|
||||
}
|
||||
auto bytes = std::vector<std::uchar_t>(static_cast<std::size_t>(size));
|
||||
auto input = std::ifstream{path, std::ios::binary};
|
||||
if (!input.read(reinterpret_cast<std::char_t *>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
|
||||
return std::unexpected(invalid("failed to read sample"));
|
||||
}
|
||||
auto digest = std::array<std::uchar_t, SHA256_DIGEST_LENGTH>{};
|
||||
SHA256(bytes.data(), bytes.size(), digest.data());
|
||||
auto actual = std::string{};
|
||||
constexpr auto hex = std::string_view{"0123456789abcdef"};
|
||||
for (auto byte : digest) {
|
||||
actual.push_back(hex[byte >> 4U]);
|
||||
actual.push_back(hex[byte & 15U]);
|
||||
}
|
||||
if (actual != *hash) {
|
||||
return std::unexpected(invalid("sample checksum mismatch"));
|
||||
}
|
||||
auto word = [&](std::size_t offset) -> std::uint32_t {
|
||||
return static_cast<std::uint32_t>(bytes[offset]) | static_cast<std::uint32_t>(bytes[offset + 1U]) << 8U;
|
||||
};
|
||||
auto dword = [&](std::size_t offset) -> std::uint32_t { return word(offset) | word(offset + 2U) << 16U; };
|
||||
auto tag = [&](std::size_t offset, std::string_view expected) -> std::bool_t {
|
||||
return std::equal(expected.begin(), expected.end(), bytes.begin() + static_cast<std::ptrdiff_t>(offset));
|
||||
};
|
||||
if (!tag(0U, "RIFF") || dword(4U) != size - 8U || !tag(8U, "WAVEfmt ") || dword(16U) != 16U || word(20U) != 1U || word(22U) != 1U || dword(24U) != 48000U || dword(28U) != 96000U || word(32U) != 2U || word(34U) != 16U || !tag(36U, "data") || dword(40U) != *frames * 2U) {
|
||||
return std::unexpected(invalid("expected generated mono 48 kHz PCM16 WAV"));
|
||||
}
|
||||
auto result = sonus::sample_buffer{.sample_rate = 48000U};
|
||||
result.frames.reserve(*frames);
|
||||
for (auto index = std::size_t{44U}; index < bytes.size(); index += 2U) {
|
||||
auto raw = static_cast<std::int32_t>(word(index));
|
||||
auto value = raw >= 32768 ? raw - 65536 : raw;
|
||||
result.frames.push_back(static_cast<std::float32_t>(value) / 32767.0F);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::prepare_playback(vamp_zero::score_document const &score, std::filesystem::path const &assets, std::uint32_t bpm) -> std::expected<sonus::sample_sequence, modulus::error> {
|
||||
if (bpm == 0U || bpm > 400U) {
|
||||
return std::unexpected(invalid("BPM must be between 1 and 400"));
|
||||
}
|
||||
auto error = boost::system::error_code{};
|
||||
auto root = boost::json::parse(score.source_json, error);
|
||||
auto playback = !error && root.is_object() ? root.as_object().if_contains("playback") : nullptr;
|
||||
if (!playback || !playback->is_object()) {
|
||||
return std::unexpected(invalid("regenerate this score to enable playback"));
|
||||
}
|
||||
auto const &object = playback->as_object();
|
||||
auto version = number(object, "version");
|
||||
auto available = object.if_contains("available");
|
||||
if (!version || *version != 1U || !available || !available->is_bool()) {
|
||||
return std::unexpected(invalid("unsupported playback manifest"));
|
||||
}
|
||||
if (!available->as_bool()) {
|
||||
return std::unexpected(invalid(text(object, "reason").value_or("not available")));
|
||||
}
|
||||
auto duration = frame_at(object, "duration", bpm);
|
||||
auto samples = object.if_contains("samples");
|
||||
auto events = object.if_contains("events");
|
||||
auto release = object.if_contains("release");
|
||||
if (!duration || *duration == 0U || !samples || !samples->is_array() || samples->as_array().size() > 512U || !events || !events->is_array() || events->as_array().size() > 1000000U || !release || !release->is_object()) {
|
||||
return std::unexpected(invalid("invalid timeline, samples or release envelope"));
|
||||
}
|
||||
auto result = sonus::sample_sequence{.duration_frames = *duration};
|
||||
auto envelope = sample(release->as_object(), assets);
|
||||
if (!envelope || envelope->frames.size() > 24000U || envelope->frames.front() != 1.0F || envelope->frames.back() != 0.0F || !std::is_sorted(envelope->frames.rbegin(), envelope->frames.rend()) || envelope->frames.back() < 0.0F) {
|
||||
return std::unexpected(envelope ? invalid("invalid release envelope") : envelope.error());
|
||||
}
|
||||
result.release_envelope = std::move(envelope->frames);
|
||||
auto handles = std::unordered_map<std::string, std::uint32_t>{};
|
||||
auto total_frames = std::uint64_t{};
|
||||
for (auto const &entry : samples->as_array()) {
|
||||
if (!entry.is_object()) {
|
||||
return std::unexpected(invalid("sample descriptor must be an object"));
|
||||
}
|
||||
auto id = text(entry.as_object(), "id");
|
||||
auto first = number(entry.as_object(), "loop_start");
|
||||
auto last = number(entry.as_object(), "loop_end");
|
||||
if (!id || id->empty() || handles.contains(*id) || !first || !last || *first >= *last) {
|
||||
return std::unexpected(invalid("invalid sample identity or sustain loop"));
|
||||
}
|
||||
auto loaded = sample(entry.as_object(), assets);
|
||||
if (!loaded) {
|
||||
return std::unexpected(loaded.error());
|
||||
}
|
||||
total_frames += loaded->frames.size();
|
||||
if (*last != loaded->frames.size() || total_frames > 16U * 1024U * 1024U) {
|
||||
return std::unexpected(invalid("invalid loop endpoint or sample memory limit"));
|
||||
}
|
||||
loaded->loop_start = *first;
|
||||
loaded->loop_end = *last;
|
||||
handles.emplace(*id, static_cast<std::uint32_t>(result.samples.size()));
|
||||
result.samples.push_back(std::move(*loaded));
|
||||
}
|
||||
auto previous = std::uint64_t{};
|
||||
for (auto const &entry : events->as_array()) {
|
||||
if (!entry.is_object()) {
|
||||
return std::unexpected(invalid("sample event must be an object"));
|
||||
}
|
||||
auto id = text(entry.as_object(), "sample");
|
||||
auto start = frame_at(entry.as_object(), "onset", bpm);
|
||||
auto end = frame_at(entry.as_object(), "end", bpm);
|
||||
if (!id || !handles.contains(*id) || !start || !end || *start < previous || *end <= *start || *end > *duration) {
|
||||
return std::unexpected(invalid("invalid sample reference or unrenderable event timing"));
|
||||
}
|
||||
result.events.push_back({.sample = handles.at(*id), .start_frame = *start, .duration_frames = *end - *start});
|
||||
previous = *start;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
auto vamp_zero::validate_playback_assets(vamp_zero::score_document const &score, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
|
||||
auto error = boost::system::error_code{};
|
||||
auto root = boost::json::parse(score.source_json, error);
|
||||
auto playback = !error && root.is_object() ? root.as_object().if_contains("playback") : nullptr;
|
||||
if (!playback) {
|
||||
return {};
|
||||
}
|
||||
if (playback->is_object()) {
|
||||
auto available = playback->as_object().if_contains("available");
|
||||
if (available && available->is_bool() && !available->as_bool()) {
|
||||
auto version = number(playback->as_object(), "version");
|
||||
auto reason = text(playback->as_object(), "reason");
|
||||
if (version && *version == 1U && reason && !reason->empty()) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
auto prepared = prepare_playback(score, assets, 120U);
|
||||
if (!prepared) {
|
||||
return std::unexpected(prepared.error());
|
||||
}
|
||||
auto sequence = sonus::prepared_sequence::create(std::move(*prepared), 48000U);
|
||||
if (!sequence) {
|
||||
return std::unexpected(sequence.error());
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -124,6 +124,30 @@ auto vamp_zero::control_state::set_observer(std::function<void(vamp_zero::app_st
|
||||
observer_ = std::move(observer);
|
||||
}
|
||||
|
||||
auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::string error, std::shared_ptr<vamp_zero::catalogue const> catalog) -> void {
|
||||
auto observer = std::function<void(vamp_zero::app_state)>{};
|
||||
auto updated = vamp_zero::app_state{};
|
||||
{
|
||||
auto lock = std::scoped_lock{mutex_};
|
||||
if (!state_.audio.playback_enabled || state_.score_session != session || (catalog && catalog != state_.catalog) || (state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE && state_.mode != vamp_zero::app_mode::PRACTICE_SCORE)) {
|
||||
return;
|
||||
}
|
||||
if (error.empty()) {
|
||||
auto count = state_.select_count;
|
||||
state_ = vamp_zero::apply_app_command(std::move(state_), vamp_protocol::page_command::SELECT);
|
||||
state_.select_count = count;
|
||||
state_.last_command = "playback-completed";
|
||||
} else {
|
||||
state_.audio.playback_error = std::move(error);
|
||||
}
|
||||
updated = snapshot_locked();
|
||||
observer = observer_;
|
||||
}
|
||||
if (observer) {
|
||||
observer(std::move(updated));
|
||||
}
|
||||
}
|
||||
|
||||
auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value {
|
||||
auto control = boost::json::object{};
|
||||
control["ap_ssid"] = config.control.ap_ssid;
|
||||
|
||||
+11
-2
@@ -170,8 +170,8 @@ auto draw_main_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::a
|
||||
state.selected_main_menu_index == 1U);
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height},
|
||||
"Track",
|
||||
state.audio.track_enabled ? "ON" : "OFF",
|
||||
"Playback",
|
||||
state.audio.playback_enabled ? "ON" : "OFF",
|
||||
state.selected_main_menu_index == 2U);
|
||||
draw_row(context,
|
||||
{.x = bounds.x + 36.0F, .y = list_y + row_height * 3.0F, .width = row_width, .height = row_height},
|
||||
@@ -489,6 +489,11 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
|
||||
.width = bounds.width - 48.0F,
|
||||
.height = bounds.height - 36.0F,
|
||||
};
|
||||
if (!state.audio.playback_error.empty()) {
|
||||
draw_text(context, bounds.x + 24.0F, bounds.y + 12.0F, state.audio.playback_error, 1, vamp_style::danger, bounds.width - 48.0F);
|
||||
paper_bounds.y += 48.0F;
|
||||
paper_bounds.height -= 48.0F;
|
||||
}
|
||||
if (auto status = score_cache.draw(context, paper_bounds, state.page_index); !status) {
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE PAGE ERROR", 3, vamp_style::danger, bounds.width - 72.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, status.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
|
||||
@@ -596,6 +601,10 @@ auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state {
|
||||
return runtime_->state_;
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::selected_score_loader() const -> std::shared_ptr<vamp_zero::score_loader> {
|
||||
return score_loader_;
|
||||
}
|
||||
|
||||
auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state {
|
||||
return vamp_zero::ui_state{
|
||||
.app = std::move(app),
|
||||
|
||||
@@ -6,6 +6,7 @@ add_executable(product-vamp-test
|
||||
test_catalogue_runtime.cpp
|
||||
test_health.cpp
|
||||
test_pico.cpp
|
||||
test_playback.cpp
|
||||
test_score_loader.cpp
|
||||
test_touch_input.cpp
|
||||
)
|
||||
@@ -23,7 +24,18 @@ target_compile_definitions(product-vamp-test PRIVATE
|
||||
VAMP_ZERO_PORT=8080
|
||||
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
||||
VAMP_TEST_PLAYBACK_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/playback-assets"
|
||||
)
|
||||
file(GLOB_RECURSE VAMP_PLAYBACK_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/playback-assets/score.json
|
||||
COMMAND ${Python3_EXECUTABLE} -m tests.test_playback --fixture ${CMAKE_CURRENT_BINARY_DIR}/playback-assets
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
DEPENDS test_playback.py test_musicxml.py ${VAMP_PLAYBACK_TEST_INPUTS}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(vamp-playback-test-assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/playback-assets/score.json)
|
||||
add_dependencies(product-vamp-test vamp-playback-test-assets)
|
||||
add_test(NAME product-vamp COMMAND product-vamp-test)
|
||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 180)
|
||||
add_test(
|
||||
|
||||
+13
-6
@@ -37,6 +37,7 @@
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/parse.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/types.hpp>
|
||||
|
||||
@@ -172,7 +173,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
||||
auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json"));
|
||||
BOOST_REQUIRE(defaults);
|
||||
BOOST_TEST(defaults->audio.enabled);
|
||||
BOOST_TEST(!defaults->audio.track_enabled);
|
||||
BOOST_TEST(!defaults->audio.playback_enabled);
|
||||
BOOST_TEST(!defaults->audio.metronome_enabled);
|
||||
BOOST_TEST(defaults->audio.metronome_bpm == 120U);
|
||||
BOOST_TEST(defaults->audio.metronome_min_bpm == 40U);
|
||||
@@ -181,7 +182,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
||||
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
|
||||
"audio": {
|
||||
"enabled": false,
|
||||
"track_enabled": true,
|
||||
"playback_enabled": true,
|
||||
"metronome_enabled": true,
|
||||
"metronome_bpm": 90,
|
||||
"metronome_min_bpm": 50,
|
||||
@@ -194,7 +195,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
||||
})json"));
|
||||
BOOST_REQUIRE(parsed);
|
||||
BOOST_TEST(!parsed->audio.enabled);
|
||||
BOOST_TEST(parsed->audio.track_enabled);
|
||||
BOOST_TEST(parsed->audio.playback_enabled);
|
||||
BOOST_TEST(parsed->audio.metronome_enabled);
|
||||
BOOST_TEST(parsed->audio.metronome_bpm == 90U);
|
||||
BOOST_TEST(parsed->audio.metronome_min_bpm == 50U);
|
||||
@@ -222,7 +223,7 @@ BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_main_menu_index == 2U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.audio.track_enabled);
|
||||
BOOST_TEST(state.audio.playback_enabled);
|
||||
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_main_menu_index == 3U);
|
||||
@@ -251,13 +252,13 @@ BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
|
||||
BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) {
|
||||
auto state = navigation_state();
|
||||
state.audio.track_enabled = true;
|
||||
state.audio.playback_enabled = true;
|
||||
state.audio.metronome_enabled = true;
|
||||
state.audio.metronome_bpm = 135;
|
||||
state.audio.tempo_editing = true;
|
||||
auto status = vamp_zero::app_status_json(state).as_object();
|
||||
auto const &audio = status.at("audio").as_object();
|
||||
BOOST_TEST(audio.at("track_enabled").as_bool());
|
||||
BOOST_TEST(audio.at("playback_enabled").as_bool());
|
||||
BOOST_TEST(audio.at("metronome_enabled").as_bool());
|
||||
BOOST_TEST(audio.at("metronome_bpm").as_uint64() == 135U);
|
||||
BOOST_TEST(audio.at("metronome_min_bpm").as_uint64() == 40U);
|
||||
@@ -339,6 +340,12 @@ BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_d
|
||||
BOOST_TEST(remote.pages == 3U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.page_index == 1U);
|
||||
auto playing = state;
|
||||
playing.audio.playback_enabled = true;
|
||||
auto control = vamp_zero::control_state{{}, playing};
|
||||
control.finish_playback(playing.score_session);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
||||
BOOST_TEST(control.snapshot().selected_practice_key_index == 2U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
||||
BOOST_TEST(state.selected_practice_key_index == 2U);
|
||||
|
||||
+27
-1
@@ -114,7 +114,7 @@ auto config_file(std::filesystem::path const &root, vamp_zero::config const &con
|
||||
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"hardware", "real"}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
|
||||
{"database", {{"path", config.database.path}}},
|
||||
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
|
||||
{"audio", {{"enabled", true}, {"track_enabled", true}, {"metronome_enabled", true}}},
|
||||
{"audio", {{"enabled", true}, {"playback_enabled", true}, {"metronome_enabled", true}}},
|
||||
};
|
||||
auto path = root / "config.json";
|
||||
write_file(path, boost::json::serialize(json));
|
||||
@@ -350,6 +350,32 @@ BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_parts_and_practice_variants
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_root) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
auto assets = std::filesystem::path{config.runtime.assets_path};
|
||||
auto fixture = std::filesystem::path{VAMP_TEST_PLAYBACK_ASSETS_DIR};
|
||||
std::filesystem::copy(fixture / "audio", assets / "audio", std::filesystem::copy_options::recursive);
|
||||
auto path = assets / "practice/melody/practice/exercise/c.json";
|
||||
write_score(path);
|
||||
auto score = boost::json::parse(read_file(path));
|
||||
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
|
||||
write_file(path, boost::json::serialize(score));
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
|
||||
auto &sample = score.at("playback").at("samples").as_array().front().as_object();
|
||||
auto audio = assets / std::string{sample.at("path").as_string()};
|
||||
auto corrupt = read_file(audio);
|
||||
corrupt[0] = 'X';
|
||||
sample["sha256"] = checksum(corrupt);
|
||||
write_file(audio, corrupt);
|
||||
write_file(path, boost::json::serialize(score));
|
||||
ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE(!ready);
|
||||
BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
|
||||
for (std::bool_t escape : {false, true}) {
|
||||
auto root = temporary_directory{};
|
||||
|
||||
@@ -137,7 +137,7 @@ class MusicXMLTests(unittest.TestCase):
|
||||
for extra in extras:
|
||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(xml_note(extra=extra)))
|
||||
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>5</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||
for extra in ('<barline location="middle"><repeat direction="backward"/></barline>', '<backup><duration>5</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(xml_note() + extra))
|
||||
self.assertEqual(fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), Fraction(1, 2))
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
#include <boost/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <thread>
|
||||
|
||||
#include <vamp_zero/audio.hpp>
|
||||
#include <vamp_zero/config.hpp>
|
||||
#include <vamp_zero/playback.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto playback_score() -> vamp_zero::score_document {
|
||||
auto input = std::ifstream{std::string{VAMP_TEST_PLAYBACK_ASSETS_DIR} + "/score.json"};
|
||||
auto score = vamp_zero::score_document{};
|
||||
score.source_json = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
||||
return score;
|
||||
}
|
||||
|
||||
auto performance_state() -> vamp_zero::app_state {
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue>();
|
||||
catalog->performance.parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U});
|
||||
catalog->performance.groups.push_back({.title = "Song", .parts = {0U}});
|
||||
auto state = vamp_zero::app_state{};
|
||||
state.catalog = std::move(catalog);
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
state.selected_song_index = 1U;
|
||||
state.selected_part_index = 1U;
|
||||
state.active_part_id = "part";
|
||||
state.score_session = 42U;
|
||||
state.audio.playback_enabled = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(generated_audio_plays_at_menu_bpm) {
|
||||
auto score = playback_score();
|
||||
auto normal = vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 120U);
|
||||
auto slow = vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 60U);
|
||||
BOOST_REQUIRE_MESSAGE(normal, (normal ? "" : normal.error().describe()));
|
||||
BOOST_REQUIRE(slow);
|
||||
BOOST_TEST(normal->duration_frames == 96000U);
|
||||
BOOST_TEST(slow->duration_frames == normal->duration_frames * 2U);
|
||||
BOOST_TEST(normal->samples[0].frames == slow->samples[0].frames, boost::test_tools::per_element());
|
||||
auto engine = sonus::engine::create({.sample_rate = 48000U, .channels = 1U, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto session = engine->play_sequence(std::move(*normal));
|
||||
BOOST_REQUIRE(session);
|
||||
auto output = std::array<std::float32_t, 480>{};
|
||||
auto energy = std::float32_t{};
|
||||
for (auto block = std::uint32_t{}; block < 200U; ++block) {
|
||||
BOOST_REQUIRE(engine->render(output.data(), output.size()));
|
||||
for (auto value : output) {
|
||||
energy += value * value;
|
||||
}
|
||||
if (block == 150U) {
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::PLAYING);
|
||||
}
|
||||
}
|
||||
BOOST_TEST(energy > 0.1F);
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||
BOOST_REQUIRE(vamp_zero::validate_playback_assets(score, VAMP_TEST_PLAYBACK_ASSETS_DIR));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(playback_completion_is_session_guarded) {
|
||||
auto state = vamp_zero::control_state{{}, performance_state()};
|
||||
state.finish_playback(41U);
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
state.finish_playback(42U, {}, std::make_shared<vamp_zero::catalogue const>());
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
state.finish_playback(42U, "sample missing");
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
BOOST_TEST(state.snapshot().audio.playback_error == "sample missing");
|
||||
state.finish_playback(42U);
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
||||
BOOST_TEST(state.snapshot().selected_part_index == 1U);
|
||||
BOOST_TEST(state.snapshot().select_count == 0U);
|
||||
state.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.snapshot().score_session == 43U);
|
||||
state.finish_playback(42U);
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
auto disabled = performance_state();
|
||||
disabled.audio.playback_enabled = false;
|
||||
auto quiet = vamp_zero::control_state{{}, disabled};
|
||||
quiet.finish_playback(42U);
|
||||
BOOST_CHECK(quiet.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(playback_worker_discards_cancelled_preparation_and_reports_errors_once) {
|
||||
auto engine = sonus::engine::create({.no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto audio = vamp_zero::audio_controller{{}, std::move(*engine)};
|
||||
auto state = performance_state();
|
||||
auto invalid = std::make_shared<vamp_zero::score_document>();
|
||||
invalid->id = "part";
|
||||
invalid->source_json = "{}";
|
||||
auto loaded = vamp_zero::score_load_state{.document = invalid};
|
||||
BOOST_CHECK(!audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR));
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
for (auto attempt = std::uint32_t{}; attempt < 100U; ++attempt) {
|
||||
BOOST_CHECK(!audio.tick(state, {}, VAMP_TEST_PLAYBACK_ASSETS_DIR));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds{1});
|
||||
}
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
++state.score_session;
|
||||
auto reported = std::bool_t{};
|
||||
for (auto attempt = std::uint32_t{}; attempt < 1000U && !reported; ++attempt) {
|
||||
if (auto result = audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR)) {
|
||||
BOOST_CHECK(!*result);
|
||||
reported = true;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds{1});
|
||||
}
|
||||
BOOST_TEST(reported);
|
||||
BOOST_CHECK(!audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(playback_legacy_config_and_invalid_assets) {
|
||||
auto legacy = vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}}}});
|
||||
BOOST_REQUIRE(legacy);
|
||||
BOOST_TEST(legacy->audio.playback_enabled);
|
||||
BOOST_CHECK(!vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}, {"playback_enabled", false}}}}));
|
||||
auto score = playback_score();
|
||||
BOOST_CHECK(!vamp_zero::prepare_playback(score, "/missing-vamp-assets", 120U));
|
||||
BOOST_CHECK(!vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 0U));
|
||||
score.source_json = "{}";
|
||||
BOOST_CHECK(!vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 120U));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
from fractions import Fraction
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import wave
|
||||
|
||||
from generator.model import fraction
|
||||
from generator.playback import PlaybackUnavailable, compile_timeline, generate_tone, validate_assets, write_pcm
|
||||
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||
|
||||
class PlaybackTests(unittest.TestCase):
|
||||
def test_chords_and_trailing_rest(self):
|
||||
document = parse(xml_score(xml_note(duration=2, kind='half') + xml_note('E', 1, 'quarter', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
|
||||
result = compile_timeline(document)
|
||||
self.assertEqual(fraction(result['duration']), 4)
|
||||
self.assertEqual([fraction(e['duration']) for e in result['events']], [2, 1])
|
||||
def test_tie_is_one_attack(self):
|
||||
result = compile_timeline(parse(xml_score(xml_note(duration=2, kind='half', extra='<tie type="start"/>') + xml_note(duration=2, kind='half', extra='<tie type="stop"/>'))))
|
||||
self.assertEqual(len(result['events']), 1)
|
||||
self.assertEqual(fraction(result['events'][0]['duration']), 4)
|
||||
def test_octave_and_microtones(self):
|
||||
result = compile_timeline(parse(xml_score(xml_note(alter='0.5'), '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>')))
|
||||
self.assertEqual(fraction(result['events'][0]['pitch']), Fraction(97, 2))
|
||||
def test_grace_default_and_cues(self):
|
||||
grace = '<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
|
||||
result = compile_timeline(parse(xml_score(grace + xml_note())))
|
||||
self.assertEqual([fraction(e['duration']) for e in result['events']], [1, 3])
|
||||
self.assertEqual([fraction(e['onset']) for e in result['events']], [0, 1])
|
||||
self.assertEqual(compile_timeline(parse(xml_score(xml_note(prefix='<cue/>'))))['events'], [])
|
||||
def test_tremolo(self):
|
||||
result = compile_timeline(parse(xml_score(xml_note(extra='<notations><ornaments><tremolo>3</tremolo></ornaments></notations>'))))
|
||||
self.assertEqual(len(result['events']), 32)
|
||||
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><ornaments><tremolo>1</tremolo></ornaments></notations>'
|
||||
result = compile_timeline(parse(xml_score(xml_note(duration=1, kind='eighth', extra=extra), divisions=3, implicit=True)))
|
||||
self.assertEqual([fraction(e['duration']) for e in result['events']], [Fraction(1, 6)] * 2)
|
||||
def test_hidden_pitches_sound_and_unpitched_is_unavailable(self):
|
||||
hidden = xml_note().replace('<note>', '<note print-object="no">')
|
||||
self.assertEqual(len(compile_timeline(parse(xml_score(hidden)))['events']), 1)
|
||||
unpitched = '<note><unpitched><display-step>C</display-step><display-octave>4</display-octave></unpitched><duration>4</duration><type>whole</type></note>'
|
||||
with self.assertRaises(PlaybackUnavailable):
|
||||
compile_timeline(parse(xml_score(unpitched)))
|
||||
def test_guitar_octave_depends_on_category_not_lesson_name(self):
|
||||
from generator.playback import compile_tree
|
||||
from generator.score import write_json
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = parse(xml_score(xml_note()))
|
||||
for relative in ('practice/melody/chord/score.json', 'practice/chord/lesson/score.json'):
|
||||
write_json(root / relative, source)
|
||||
compile_tree(root)
|
||||
for relative, pitch in [('practice/melody/chord/score.json', 60), ('practice/chord/lesson/score.json', 48)]:
|
||||
playback = json.loads((root / relative).read_text())['playback']
|
||||
self.assertEqual(playback['samples'][0]['id'], generate_tone(root, Fraction(pitch))['id'])
|
||||
def test_audio_assets_validate_and_reject_corruption(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
generate_fixture(root)
|
||||
manifest = json.loads((root / 'score.json').read_text())['playback']
|
||||
validate_assets(manifest, root)
|
||||
sample = manifest['samples'][0]
|
||||
path = root / sample['path']
|
||||
path.write_bytes(path.read_bytes()[:-1])
|
||||
with self.assertRaises(ValueError):
|
||||
validate_assets(manifest, root)
|
||||
generate_fixture(root)
|
||||
manifest['release']['sha256'] = write_pcm(root / 'audio/release.wav', [0.5] * 2400)
|
||||
with self.assertRaisesRegex(ValueError, 'envelope'):
|
||||
validate_assets(manifest, root)
|
||||
sample['path'] = '../outside.wav'
|
||||
with self.assertRaises(ValueError):
|
||||
validate_assets(manifest, root)
|
||||
def test_generated_tone_is_deterministic_and_loopable(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
a = generate_tone(root, Fraction(60))
|
||||
b = generate_tone(root, Fraction(60))
|
||||
self.assertEqual(a, b)
|
||||
with wave.open(str(root / a['path'])) as audio:
|
||||
self.assertEqual(audio.getframerate(), 48000)
|
||||
self.assertEqual(audio.getnframes(), a['loop_end'])
|
||||
self.assertEqual(audio.getnchannels(), 1)
|
||||
self.assertGreater(a['loop_end'], a['loop_start'])
|
||||
|
||||
def generate_fixture(directory):
|
||||
from generator.score import write_json
|
||||
from generator.playback import compile_tree
|
||||
root = Path(directory)
|
||||
write_json(root / 'score.json', parse(xml_score(xml_note(duration=2, kind='half') + xml_note('E', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>')))
|
||||
compile_tree(root)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--fixture', required=True)
|
||||
generate_fixture(parser.parse_args().fixture)
|
||||
@@ -0,0 +1,141 @@
|
||||
import argparse
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator.model import fraction, validate_document
|
||||
from generator.musicxml import MusicXMLError
|
||||
from generator.playback import compile_timeline
|
||||
from generator.repeats import expand_document, performed_order
|
||||
from tests.test_musicxml import parse, xml_note, xml_score
|
||||
|
||||
def score_bars(bars):
|
||||
return xml_score(bars[0]).replace('</measure></part>', '</measure>' + ''.join(f'<measure number="{i + 2}">{bar}</measure>' for i, bar in enumerate(bars[1:])) + '</part>')
|
||||
|
||||
def boundary(direction, times=None):
|
||||
side = 'left' if direction == 'forward' else 'right'
|
||||
count = f' times="{times}"' if times else ''
|
||||
return f'<barline location="{side}"><repeat direction="{direction}"{count}/></barline>'
|
||||
|
||||
def ending(numbers, action, repeat=False):
|
||||
side = 'left' if action == 'start' else 'right'
|
||||
return f'<barline location="{side}"><ending number="{numbers}" type="{action}"/>' + ('<repeat direction="backward"/>' if repeat else '') + '</barline>'
|
||||
|
||||
class RepeatTests(unittest.TestCase):
|
||||
def test_repeat_count_and_implicit_start(self):
|
||||
source = parse(score_bars([xml_note(), xml_note('D') + boundary('backward', 3)]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1] * 3)
|
||||
expanded = expand_document(source)
|
||||
validate_document(expanded)
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 6)
|
||||
def test_combined_endings(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1,2', 'start') + xml_note('D') + ending('1,2', 'stop', True), ending('3', 'start') + xml_note('E') + ending('3', 'discontinue')]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 0, 1, 0, 2])
|
||||
self.assertIn('endings', source['staves'][0]['measures'][1])
|
||||
self.assertNotIn('endings', expand_document(source)['staves'][0]['measures'][1])
|
||||
def test_nested_repeat(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), boundary('forward') + xml_note('D') + boundary('backward'), xml_note('E') + boundary('backward')]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 1, 2] * 2)
|
||||
def test_sequential_and_expansion_limit(self):
|
||||
source = parse(score_bars([xml_note() + boundary('backward'), boundary('forward') + xml_note('D') + boundary('backward', 3)]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 0, 1, 1, 1])
|
||||
with self.assertRaisesRegex(MusicXMLError, '10000'):
|
||||
parse(score_bars([boundary('forward') + xml_note(), xml_note() + boundary('backward', 10000)]))
|
||||
def test_bad_endings_and_open_repeat(self):
|
||||
for content in (boundary('forward') + xml_note(), ending('1', 'start') + xml_note(), ending('1', 'start') + xml_note() + ending('1', 'stop')):
|
||||
with self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(content))
|
||||
def test_ties_reidentify_each_pass(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), xml_note(extra='<tie type="stop"/>') + boundary('backward')]))
|
||||
validate_document(expand_document(source))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 2)
|
||||
def test_ties_across_repeat_jump_and_unreachable_pass(self):
|
||||
first = xml_note(extra='<tie type="stop" time-only="2"/>')
|
||||
last = xml_note(extra='<tie type="start" time-only="1"/>')
|
||||
source = parse(score_bars([boundary('forward') + first, last + boundary('backward')]))
|
||||
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [4, 8, 4])
|
||||
for bad in (first.replace('time-only="2"', ''), first.replace('time-only="2"', 'time-only="3"')):
|
||||
with self.assertRaisesRegex(MusicXMLError, 'tie'):
|
||||
parse(score_bars([boundary('forward') + bad, last + boundary('backward')]))
|
||||
def test_printed_tie_does_not_create_sound_tie(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 4)
|
||||
def test_ties_into_both_endings(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), ending('1', 'start') + xml_note(extra='<tie type="stop"/>') + ending('1', 'stop', True), ending('2', 'start') + xml_note(extra='<tie type="stop"/>') + ending('2', 'discontinue')]))
|
||||
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8, 8])
|
||||
def test_cross_measure_tuplets_and_tremolos_remap(self):
|
||||
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||
bars = [boundary('forward'), '']
|
||||
for index in range(3):
|
||||
bars[0] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="start"/></notations>' if index == 0 else ''))
|
||||
bars[1] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="stop"/></notations>' if index == 2 else ''))
|
||||
bars[1] += boundary('backward')
|
||||
source = parse(score_bars(bars).replace('<divisions>1</divisions>', '<divisions>3</divisions>'))
|
||||
expanded = expand_document(source)
|
||||
validate_document(expanded)
|
||||
self.assertEqual(len(expanded['staves'][0]['tuplets']), 2)
|
||||
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
||||
bars = [xml_note(step, kind='breve', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')]]
|
||||
source = parse(score_bars([boundary('forward') + bars[0], bars[1] + boundary('backward')]))
|
||||
validate_document(expand_document(source))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 128)
|
||||
def test_no_repeat_is_unchanged(self):
|
||||
source = parse(xml_score(xml_note()))
|
||||
self.assertEqual(source, expand_document(source))
|
||||
def test_roundtrip_and_engraving(self):
|
||||
from generator.musicxml_writer import write_musicxml
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.engraving import Fonts, Style, layout_score
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'repeat.xml'
|
||||
write_musicxml(path, source)
|
||||
restored = load_musicxml(path)
|
||||
self.assertEqual(performed_order(source['staves'][0]['measures']), performed_order(restored['staves'][0]['measures']))
|
||||
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
|
||||
items = [p for page in layout.pages for p in page.primitives]
|
||||
self.assertEqual(sum(p.role == 'repeat-dot' for p in items), 4)
|
||||
self.assertEqual([p.value for p in items if p.role == 'ending-label'], ['1.', '2.'])
|
||||
def test_printed_tie_continues_across_system_boundary(self):
|
||||
from generator.engraving import Fonts, Style
|
||||
from generator.engraving.layout import make_system
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
|
||||
index = ScoreIndex(source)
|
||||
fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||
for start, end in [(0, 4), (4, 8)]:
|
||||
system = make_system(index, start, end, fonts, Style())
|
||||
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
|
||||
def test_build_pipeline_keeps_compact_practice_and_compiles_performed_order(self):
|
||||
from generator.__main__ import generate_assets
|
||||
from generator.arrange.generate import arrange_document
|
||||
text = score_bars([boundary('forward') + xml_note(), xml_note('D') + boundary('backward')])
|
||||
source = parse(text)
|
||||
self.assertEqual(arrange_document(source, 'vocal'), arrange_document(expand_document(source), 'vocal'))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
data = root / 'data'
|
||||
lesson = data / 'practice/melody/lesson'
|
||||
lesson.mkdir(parents=True)
|
||||
(data / 'songs').mkdir()
|
||||
(lesson / 'repeat.xml').write_text(text)
|
||||
output = root / 'assets'
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
generate_assets(args)
|
||||
bundle = json.loads((output / 'practice/melody/lesson/repeat/c.json').read_text())
|
||||
self.assertEqual(len(bundle['music']['staves'][0]['measures']), 2)
|
||||
self.assertEqual(len(bundle['playback']['events']), 4)
|
||||
self.assertEqual(fraction(bundle['playback']['duration']), 16)
|
||||
before = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||
generate_assets(args)
|
||||
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
|
||||
with self.assertRaisesRegex(ValueError, 'audio'):
|
||||
generate_assets(args)
|
||||
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
def test_alternate_ending_does_not_inherit_skipped_key_change(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + '<attributes><key><fifths>2</fifths></key></attributes>' + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
|
||||
expanded = expand_document(source)
|
||||
self.assertEqual([m['key']['fifths'] for m in expanded['staves'][0]['measures']], [0, 2, 0, 0])
|
||||
Reference in New Issue
Block a user