Tone Generation
ci / checks (push) Has been cancelled

This commit is contained in:
khodowany
2026-09-18 01:17:36 -05:00
parent 64eac2f11e
commit 80873a0896
39 changed files with 1624 additions and 52 deletions
+3 -1
View File
@@ -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
View File
@@ -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`.
+1
View File
@@ -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
+2
View File
@@ -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'
+2 -1
View File
@@ -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)
+3 -1
View File
@@ -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
+17 -1
View File
@@ -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'):
+21 -5
View File
@@ -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'],)))
+42
View File
@@ -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
+14
View File
@@ -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:
+3
View File
@@ -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
View File
@@ -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
+16 -2
View File
@@ -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)
+263
View File
@@ -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
+2
View File
@@ -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}
+260
View File
@@ -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
+5
View File
@@ -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)
+3
View File
@@ -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:
+14
View File
@@ -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>;
+1 -1
View File
@@ -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};
+18
View File
@@ -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>;
}
+1 -1
View File
@@ -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;
+3 -1
View File
@@ -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{};
+1
View File
@@ -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_;
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+16 -4
View File
@@ -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,
+11 -4
View File
@@ -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));
+224
View File
@@ -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 {};
}
+24
View File
@@ -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
View File
@@ -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),
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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{};
+1 -1
View File
@@ -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))
+133
View File
@@ -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));
}
+96
View File
@@ -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)
+141
View File
@@ -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])