Compare commits
2 Commits
3a01b4b6bd
...
300097a37e
| Author | SHA1 | Date | |
|---|---|---|---|
| 300097a37e | |||
| 841a3f974e |
@@ -102,13 +102,13 @@ Six roles generate independently: acoustic accompaniment/solo; electric accompan
|
||||
|
||||
Electric accompaniment and electric melody share compact chord families on strings 1–4. Melody harmonization adapts these to keep the original melody on the lowest-numbered feasible string, preferring it above the added chord tones. The separate Electric Solo stays monophonic. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Transposable degree families cover triads, sevenths/sixths, suspensions, diminished/augmented and extended/altered chords, with root/fifth bass alternatives on both bass strings. Plain major/minor chords also allow third basses; explicit slash basses and inversions take priority. Unplayable variants are discarded. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement.
|
||||
|
||||
Accompaniment uses independently authored two-bar jazz pattern families, chosen against each song's original melody and harmonic rhythm. Acoustic repeats two patterns (A–B); electric repeats four (A–B–C–D). Occasional source-phrase-ending fills recur at the same positions in every iteration; both instruments allow at most one per eight full measures, including the repeat join. Acoustic normally has 1–3 attack positions per full bar with quarter/eighth bass and upper-chord notes that may overlap or sound together. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustains and space. Cross-bar holds use ties; repeated attacks remain attacks. Pattern phase follows source-form position, including partial entries and odd-length forms. Meter changes use compatible cycles, with explicit diagnostics if fewer distinct patterns are available. Tempo changes do not reshuffle the accompaniment.
|
||||
Accompaniment uses independently authored two-bar jazz pattern families, chosen against each song's original melody and harmonic rhythm. Acoustic repeats two patterns (A–B); electric repeats four (A–B–C–D). Occasional source-phrase-ending fills recur at the same positions in every iteration; both instruments allow at most one per eight full measures, including the repeat join. Acoustic normally has 1–3 attack positions per full bar with quarter/eighth bass and upper-chord notes played together or in sequential pairs. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustains and space. Cross-bar holds use ties; repeated attacks remain attacks. Pattern phase follows source-form position, including partial entries and odd-length forms. Meter changes use compatible cycles, with explicit diagnostics if fewer distinct patterns are available. Tempo changes do not reshuffle the accompaniment.
|
||||
|
||||
Each instrument has 24 distinct two-bar patterns in 4/4, 24 in 3/4, and 24 for each of the 3+2 and 2+3 groupings in 5/4. Written additive grouping takes precedence. Otherwise, the original chord changes and melody choose one consistent 5/4 grouping per continuous meter section, shared as source analysis by the independently generated parts. Explicit meter matching prevents treating 6/8 as 3/4. The learned solo model and playback count-in are unchanged by this accompaniment expansion.
|
||||
|
||||
Both accompaniment parts balance movement with sounding register and a soft cost for mean fret positions above nine; comfortable shifts can win over staying high. Acoustic uses a register center of MIDI 53 and electric uses 60. Compact spans and preferred chord families remain higher priorities, including for the final sustained chord. Electric melody's supporting chords use the same balance after the existing melody-string and foreground priorities; the original melody pitches and rhythm remain fixed. Voicing preferences do not reseed accompaniment rhythms.
|
||||
|
||||
Acoustic attacks normally play bass and upper chord together. Consecutive attacks exactly an eighth note apart split into non-overlapping bass–upper pairs within the same harmony; an odd leftover attack plays both together. Pairing continues across barlines and two-bar pattern boundaries, but stops at chord changes, chorus boundaries and source-defined entries/handoffs. The first attack for each new chord therefore includes bass, including a qualified anticipated entrance. Delayed entrances remain delayed. Bass may ring beneath the upper attack; only conflicting same-string releases are shortened. Bass and upper notes share one engraved part, with ties preserving overlaps and independent releases.
|
||||
Acoustic attacks normally play bass and upper chord together. Consecutive attacks exactly an eighth note apart split into bass–upper pairs within the same harmony; an odd leftover attack plays both together. Pairing continues across barlines and two-bar pattern boundaries, but stops at chord changes, chorus boundaries and source-defined entries/handoffs. The first attack for each new chord therefore includes bass, including a qualified anticipated entrance. Delayed entrances remain delayed. Every new acoustic attack releases the previous sound: a bass eighth followed by an upper-chord quarter has no bass underneath the quarter. Complete-cycle selection, fills and final realization use this same release rule. Bass and upper notes share one engraved part; uninterrupted holds may still tie across barlines.
|
||||
|
||||
Each song has six canonical roles: electric melody, electric accompaniment, electric solo, acoustic accompaniment, acoustic solo, and vocal melody. Matching source-form positions reuse the same musical events and TAB whenever a role returns. Final-turnaround omission changes only affected harmony; source-defined pickups/handoffs and the three-bar instrumental closing sustain remain explicit exceptions.
|
||||
|
||||
@@ -170,6 +170,8 @@ See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicX
|
||||
|
||||
Build-time engraving is implemented in Python in `generator/engraving`, using the bundled Petaluma OTF fonts and SMuFL metadata. MuseScore is an optional authoring tool, not a build dependency or rendering backend. Configure installs the optional Modulus asset dependencies. Exact musical timing is retained through layout, with line breaks between complete measures. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets.
|
||||
|
||||
Every normal build also writes printable PDFs to `build/<target>/generated_assets/print/`: one complete acoustic, electric and vocal part per song, including every arranged iteration and the ending. Print at actual size on portrait US Letter paper. Pages have white backgrounds, black notation, at least half-inch printer margins, and multiple systems per sheet; guitar parts include TAB. These are fresh 300 DPI renders, without the duplicated lines used by playback transition pages. PDFs use the existing Modulus-managed Pillow dependency, are cached separately, and remain build-only artifacts rather than being installed on the device.
|
||||
|
||||
`generator/palette.json` preserves the sheet music’s existing dark palette across Zero, Pico, score pages, and previews. Menus use the same Petaluma Script lettering, quiet rules, tune/composer hierarchy and restrained accent colors for a jazz Real Book feel. Performance status shows Count-in, Playing or Sustain, BPM, page progress and a hold-to-exit hint. CMake generates the shared C++ colors from the same palette used by Python. Palette changes invalidate the engraving cache without changing music, spacing, or pagination.
|
||||
|
||||
Petaluma is from Steinberg under the SIL Open Font License. Petaluma supplies music glyphs, and Petaluma Script supplies all visible text on score pages and both device interfaces. The Zero uses packaged `PetalumaScript.otf` for dynamic UI text; the Pico displays prerendered Petaluma Script artwork. Runtime packages retain `OFL.txt`; the music font and engraver stay build-only.
|
||||
|
||||
+15
-3
@@ -145,17 +145,17 @@ Fills replace only a two-bar figure's ending bar, using a same-family variation
|
||||
|
||||
Normal acoustic patterns use 1–3 attack positions per full bar. Their event layer is `acoustic`; the instrument's versioned `attack_assignment` policy is `combined_except_eighth_pairs`. Authored bass/upper decisions are replaced by chronological assignment across the complete canonical source form: ordinary attacks are combined, while adjacent half-beat-separated attacks in the same effective harmony become bass then upper. Pairs do not overlap; an odd leftover attack is combined. Bar and two-bar pattern boundaries do not reset pairing. Source-defined entries, handoffs and chorus boundaries do; the same boundary set applies at repeated form positions. Chord changes break pairs, but repeated identical chord labels do not. An anticipation uses its upcoming harmonic region for this decision. First attacks of new harmonic spans still include bass (`first_attack: bass_per_harmony`).
|
||||
|
||||
Quarter/eighth articulations and explicitly authored sustains retain their duration unless a chord boundary, handoff or fresh attack on the same strings requires release. Bass may ring beneath a later upper attack. Shortening that would strand an anticipation first rebinds it to current harmony and recomputes assignment. Complete-form assignment happens before grip placement and notation splitting, and is re-evaluated for source-harmony variants. Concrete block caches include their assigned event signature: a neighboring chord change can break a pair even when this block's own harmony is unchanged. The renderer verifies actual sounding lane events against the selected assignment. The completed validator follows sounding tie chains; placement validates all simultaneously sounding strings, fret spans and anchors. The two lanes are merged into one notated part with tied fragments preserving their true durations. The three-bar closing sustain remains combined.
|
||||
Quarter/eighth articulations and explicitly authored sustains release at the earliest of their intended end, the next acoustic attack in either lane, or a required harmonic/handoff boundary. Bass cannot ring beneath a later upper attack, nor can an upper chord ring beneath a later bass attack. Combined attacks have one shared release. Shortening that would strand an anticipation first rebinds it to current harmony and recomputes assignment. `acoustic_context` and `realize_acoustic_form` implement the shared source-form contract used by cycle comparison, fill selection and final source-harmony variants, before grip placement or notation splitting. Raw candidates retain authored releases so a changed neighboring attack can be evaluated afresh. Fills must improve the resolved whole-form cost without changing events outside their editable second bar. Concrete block caches include their assigned event signature: a neighboring chord change can break a pair even when this block's own harmony is unchanged. The renderer verifies actual sounding lane events against the selected assignment. The completed validator follows sounding tie chains and rejects any overlap between distinct acoustic attacks, even across different lanes. Placement validates all simultaneously sounding strings, fret spans and anchors. Bass and upper events merge into one notated part. Uninterrupted cross-bar holds and the combined three-bar closing sustain remain intact.
|
||||
|
||||
Electric normally uses 0–2 attack positions per bar, with stabs, sustains and space. Direct events release at changed harmony. An eligible acoustic/electric template can anticipate a chord by half a beat when it continues into that chord; the grip comes from the upcoming harmony and releases by its following change. Acoustic anticipations also include bass on the new chord's first attack. Missing harmony remains silent. Protected participation limits retain the chosen figure, shortening only its affected releases and recording exceptions. Cross-bar sounding notes become ties, never automatic merging of repeated attacks.
|
||||
|
||||
All six production roles receive reuse audits. Musical comparisons include rests, pitch durations, ties and TAB while ignoring regenerated event IDs and global measure numbers. Source-defined pickup/participation changes and effective final-turnaround harmony are reported separately. The final three-bar hold remains instrumental.
|
||||
|
||||
Named activity, melody-support, harmonic-support and articulation contributions evaluate complete figures. Cycle selection aggregates them over all occurrences, retaining family diversity before the final bounded comparison. For electric, `groove_cycles.cost` is the selected base cycle's aggregate cost before optional fills. Acoustic reports preserve this as `retrieval_cost` and report `cost` for the final assigned source-form events, including selected fills and cross-block pairing. Reports identify the base slot, realized pattern, projected events, continuations, anticipation target times and source-context exceptions. Tempo affects metadata/cache identity but never musical choice. The acoustic assignment policy and data migrate independently; the electric choice domain and other roles' musical seeds are unchanged.
|
||||
Named activity, melody-support, harmonic-support and articulation contributions evaluate complete figures. Cycle selection aggregates them over all occurrences, retaining family diversity before the final bounded comparison. `groove_cycles.retrieval_cost` is the isolated-figure estimate used for shortlisting. `cost` is the actual selected base cycle's cost before optional fills; acoustic cycles are assigned and released together before this comparison. Acoustic `final_cost` reports the complete source-form result after fills and any meter-section joins. The selected score is never overwritten by a post-selection report. Reports identify the base slot, realized pattern, projected events, continuations, anticipation target times and source-context exceptions. Tempo affects metadata/cache identity but never musical choice. This release correction leaves musical data and role choice seeds unchanged; code fingerprints invalidate stale concrete caches.
|
||||
|
||||
Acoustic events use temporary bass/upper notation lanes for projection; then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. The product build audits canonical accompaniment reuse across arrangement iterations, allowing only source-derived changes in harmony, pickups and role handoffs. The `.roles` sidecars report pattern IDs, exact projected layer events, continuations, anticipation targets and exceptions. Their `selection_cost` records the complete figure’s local contributions, repeated on each projected bar for context; the cycle selector aggregates these by the number of measures in each occurrence, without an anti-repetition transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances.
|
||||
|
||||
Acoustic pair-assignment verification on October 7, 2026: the full containerized `unittest discover -s tests -p 'test_*.py'` passed 447 tests. After adding the explicit cross-bar meter case and retaining the existing long-sustain boundary semantics, the final targeted run (`tests.test_acoustic_pairs tests.test_comping_rhythm tests.test_comping_cycles tests.test_comping_meters tests.test_arrangement_ending tests.test_chord_families.CombinedAcoustic`) passed 63 tests. The first full run exposed a broken-tie check that had depended on rejecting a long separate event; explicit reciprocal-link, timing and pitch/TAB checks now reject it directly. A host-only targeted command could not import Modulus's build dependencies; the configured-container rerun passed. Native and ARM product builds passed asset checks, with 394 identical JSON/PNG artifacts. Both acoustic score/TAB pages were visually inspected. All ten other-role documents exactly match the saved pre-change baseline. The acoustic source-form reports show 64 combined attacks for All of Me, and 48 combined plus 16 bass/upper pairs for Autumn Leaves; these are observations, not quotas. No audio audition was performed. Comparison copies remain outside the repository under `/tmp/`.
|
||||
Acoustic sequential-release verification on October 7, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 451 tests in 162.318 seconds. Coverage includes release in both lane directions, cross-bar pairing in 3/4, 4/4 and both 5/4 groupings, expired and surviving anticipations, complete-cycle ranking versus isolated retrieval, fills protecting neighboring assignments, malformed overlap before/after notation merging, canonical reuse, cold/warm caches and parallel roles. `python3 modulus.py format --check` passed. `python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` passed both generator/asset checks; 394 score JSON/PNG artifacts match across targets. Acoustic composition took 2.1 seconds for All of Me and 2.2 seconds for Autumn Leaves; native assets finished in 31 seconds and the cached ARM build in 11 seconds. Compared with the saved pre-change output, all attack pitches, onsets and TAB positions are identical: only 67 Autumn Leaves bass releases are shorter. Its arrangement measure 3 now contains bass [0, 1/2), upper chord [1/2, 3/2), and combined chord [2, 3), without ties. All of Me and the ten other-role documents are unchanged. Canonical reuse audits pass, and both three-bar instrumental endings retain one twelve-beat attack. Both acoustic opening pages were visually inspected; no audio audition was performed. The comparison baseline remains outside the repository under `/tmp/vamp-sequential-acoustic-yt_irkfd`.
|
||||
|
||||
Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
|
||||
|
||||
@@ -291,6 +291,18 @@ Bundles preserve the complete `music` object. Presentation identifies the `Vamp
|
||||
|
||||
Pages are 976x484 pixels with nominal 10-pixel guitar or 12-pixel vocal staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. The two acoustic rest lanes sit closer to the staff, with collision offsets preserving their separation. Systems and pages contain complete measures; tied notes and notation spans may continue across their boundaries. Presentation fragments preserve exact timing and never change the semantic JSON. A measure that cannot fit at readable spacing fails with source and measure context; the uniform part-wide scaling described above fits pairs of complete systems vertically. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics.
|
||||
|
||||
### Printable Parts
|
||||
|
||||
`generator.printing.generate_tree` runs after screen engraving inside the normal asset staging transaction. It reads each completed song/part's musical document, not its screen PNGs or overlapping playback views, and writes `print/<score-stem>.pdf`. Every arranged measure and the instrumental ending appear once in order. Acoustic/electric parts retain standard notation and TAB; vocal parts retain lyrics and rests. No music, runtime bundle fields or catalog rows are changed. Both CMake installation and runtime image copying exclude the `print` directory.
|
||||
|
||||
Print layout uses portrait US Letter (612 × 792 PDF points), a minimum half-inch clear margin, and fixed five-point guitar/six-point vocal staff spacing. It reserves the actual overhang of centered measure numbers inside the margin. Complete systems fill the available page height with consistent notation size; the final page keeps natural vertical spacing. Headers identify the tune, composer and instrument; printed page numbers are independent of device pagination. The existing musical/fragment/TAB audits remain active, with variable systems per page allowed only by the print caller. Screen audits still require two systems except the odd final system.
|
||||
|
||||
The shared primitive renderer accepts explicit colors and output scale while retaining unchanged screen defaults. Print draws black on white directly at 2550 × 3300 pixels (300 DPI), then writes grayscale image pages through Pillow's PDF writer at quality 95. This is a raster PDF, not searchable/vector notation. One page is rendered and appended at a time to bound image memory. The pinned Pillow parser checks page count, Letter media boxes and image resolution/color; tests decode the embedded JPEGs and verify clear margins. No new Python package is required: Modulus's existing `assets` group owns Pillow, fonttools and aggdraw. Timestamps and output-path metadata are omitted for byte-reproducible PDFs.
|
||||
|
||||
With `--engraving-cache`, print results use a separate `print/<print-renderer>/<score-key>/` namespace. Keys cover score content, print settings, fonts, renderer source and dependency versions. Cached PDFs have checked hashes and page metadata; missing or corrupt entries regenerate. Native and ARM builds share the cache locally. This print namespace is separate from the screen-only CI cache snapshot. Output PDFs are validated before the build publishes; failure preserves the previous complete asset directory. Stale PDFs disappear when source parts disappear. Temporary print files live only in build/cache staging.
|
||||
|
||||
Print rollout verification: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 458 tests; the seven `tests.test_printing` tests were rerun after the final footer-margin adjustment. `python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` passed with byte-identical PDFs on both targets. All of Me has 10/8/5 acoustic/electric/vocal pages and Autumn Leaves has 9/7/4, totaling 43. All pages were checked for Letter size, 300 DPI grayscale images and pure-white half-inch margins; representative opening, solo, lyric and ending pages were visually inspected. All 394 preexisting score JSON/screen PNG files stayed byte-identical. PDF generation took 9.7 seconds uncached and 0.1 seconds cached on this development machine; this is the print stage only, not a clean whole-product build benchmark. Physical printing was not tested.
|
||||
|
||||
### Validation And Publication
|
||||
|
||||
Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete; deleted sources cannot leave stale JSON, pages, audio, or Pico title assets. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalog. Runtime packages contain `catalog.db` with embedded semantic JSON and title-image identifiers, PNG pages, supplied WAV recordings, Pico title images, 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 catalog score.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
if(NOT EXISTS "${SOURCE}/catalog.db")
|
||||
message(FATAL_ERROR "Missing generated catalog: ${SOURCE}/catalog.db")
|
||||
endif()
|
||||
file(COPY "${SOURCE}/" DESTINATION "${DESTINATION}" PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE)
|
||||
file(COPY "${SOURCE}/" DESTINATION "${DESTINATION}" PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE PATTERN "print" EXCLUDE)
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ install(TARGETS vamp_zero
|
||||
)
|
||||
install(FILES package/config/vamp.example.json DESTINATION zero/config)
|
||||
install(DIRECTORY ${VAMP_GENERATED_ASSETS_DIR}/ DESTINATION zero/share/product_vamp
|
||||
PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE)
|
||||
PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE PATTERN "print" EXCLUDE)
|
||||
install(PROGRAMS
|
||||
package/zero/install_zero.sh
|
||||
package/zero/start_ap.sh
|
||||
|
||||
@@ -18,6 +18,7 @@ import generator.engraving.types
|
||||
import generator.parts
|
||||
import generator.pico_artwork
|
||||
import generator.performance
|
||||
import generator.printing
|
||||
import generator.score
|
||||
import generator.summary
|
||||
import platform
|
||||
@@ -103,6 +104,8 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
generator.performance.validate_recording_lengths(staged, audio_assets)
|
||||
report('engraving pages')
|
||||
validated_pages = generator.engraving.pipeline.engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress)
|
||||
report('generating printable parts')
|
||||
generator.printing.generate_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, progress=progress)
|
||||
report('rendering Pico titles')
|
||||
generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma')
|
||||
report('validating score, page, and audio assets')
|
||||
|
||||
@@ -162,12 +162,12 @@ def validate_comping(document: dict) -> None:
|
||||
raise ValueError('acoustic comping requires quarter/eighth notes or authored sustains')
|
||||
events.append((start, end))
|
||||
events.sort()
|
||||
# Distinct lanes can overlap. The physical validator still checks sounding
|
||||
# strings and grip span, including staggered releases after voice merging.
|
||||
# A combined attack is one interval with both lanes. Separate attacks must
|
||||
# be sequential, including bass/upper alternation and cross-bar ties.
|
||||
for i, a in enumerate(events):
|
||||
for b in events[i + 1:]:
|
||||
if a[1] > b[0] and groups[a] & groups[b]:
|
||||
raise ValueError('acoustic attacks overlap within the same lane')
|
||||
if a[1] > b[0]:
|
||||
raise ValueError('acoustic sound must release before the next attack')
|
||||
spans = []
|
||||
for span in generator.harmonic_time.read(document['staves'][0]):
|
||||
if spans and generator.accompaniment.same_chord(spans[-1].chord, span.chord):
|
||||
|
||||
+13
-32
@@ -208,10 +208,6 @@ class Canonical:
|
||||
size = 2
|
||||
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
|
||||
index += size
|
||||
choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed)
|
||||
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
|
||||
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
|
||||
selected = [r for _, _, r in choices]
|
||||
self.acoustic_cache = {}
|
||||
self.pair_breaks = {self.first, len(self.measures)}
|
||||
self.pair_stops = []
|
||||
@@ -229,13 +225,11 @@ class Canonical:
|
||||
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(self.measures[0]) if n['pitches']]
|
||||
if attacks:
|
||||
self.pair_stops.append((last, pad + min(attacks)))
|
||||
if part == 'acoustic':
|
||||
assigned = self.acoustic_realizations(self.measures)
|
||||
selected = [assigned[g[0][2]] for g in self.groups]
|
||||
for report in self.groove_report:
|
||||
report['retrieval_cost'] = report['cost']
|
||||
report['cost'] = sum(r.cost * len(g) for g, r in zip(self.groups, selected) if report['source_start'] <= g[0][2] <= report['source_end'])
|
||||
report['attack_assignment'] = 'combined_except_eighth_pairs'
|
||||
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks] + self.pair_stops
|
||||
choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed, breaks=breaks)
|
||||
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
|
||||
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
|
||||
selected = [r for _, _, r in choices]
|
||||
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
|
||||
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
|
||||
active = tuple(span for spans in prepared for span in spans if span.events)
|
||||
@@ -287,11 +281,10 @@ class Canonical:
|
||||
starts = [fractions.Fraction()]
|
||||
for m in sources:
|
||||
starts.append(starts[-1] + generator.model.fraction(m['duration']))
|
||||
breaks = {starts[i] for i in self.pair_breaks | set(bounds or ())}
|
||||
breaks.update(starts[i] + t for i, t in self.pair_stops)
|
||||
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks | set(bounds or ())] + self.pair_stops
|
||||
if stop:
|
||||
breaks.add(starts[stop[0]] + stop[1])
|
||||
events, raw = [], {}
|
||||
breaks.append(stop)
|
||||
groups, raw = [], []
|
||||
for original in self.groups:
|
||||
group = tuple(full[i] for _, _, i in original)
|
||||
index = group[0][2]
|
||||
@@ -300,23 +293,11 @@ class Canonical:
|
||||
rhythm = generator.comping_rhythm.realize(pattern, group, 'acoustic', self.melody, anticipate=anticipate, limit=limit, boundary=True, assign=False)
|
||||
if rhythm is None:
|
||||
raise ValueError(f'source measure {sources[index]["number"]}: selected acoustic rhythm cannot fit written harmony')
|
||||
raw[index] = rhythm
|
||||
events.extend(dataclasses.replace(e, start=e.start + starts[index], end=e.end + starts[index],
|
||||
harmonic_time=e.harmonic_time + starts[index] if e.harmonic_time is not None else None) for e in rhythm.events)
|
||||
harmony = generator.comping_rhythm.spans(generator.comping_rhythm.flatten(full), None)
|
||||
assigned = generator.comping_rhythm.assign_acoustic(events, harmony, breaks)
|
||||
result = {}
|
||||
for original in self.groups:
|
||||
group = tuple(full[i] for _, _, i in original)
|
||||
index, end = group[0][2], group[-1][2] + 1
|
||||
local = tuple(dataclasses.replace(e, start=e.start - starts[index], end=e.end - starts[index],
|
||||
harmonic_time=e.harmonic_time - starts[index] if e.harmonic_time is not None else None)
|
||||
for e in assigned if starts[index] <= e.start < starts[end])
|
||||
melody = tuple((a + starts[i] - starts[index], b + starts[i] - starts[index]) for _, _, i in group for a, b in self.melody[i])
|
||||
flat = generator.comping_rhythm.flatten(group)
|
||||
contributions = generator.comping_rhythm.evaluate(local, 'acoustic', melody, generator.comping_rhythm.spans(flat, None), tuple(starts[i + 1] - starts[i] for _, _, i in group))
|
||||
result[index] = dataclasses.replace(raw[index], events=local, contributions=contributions,
|
||||
exceptions=(*raw[index].exceptions, ('layer_assignment', 'source-form combined attacks with non-overlapping same-harmony eighth pairs')))
|
||||
raw.append(rhythm)
|
||||
groups.append(group)
|
||||
context = generator.comping_rhythm.acoustic_context(groups, self.melody, breaks)
|
||||
realized = generator.comping_rhythm.realize_acoustic_form(raw, context)
|
||||
result = {g[0][2]: r for g, r in zip(groups, realized)}
|
||||
self.acoustic_cache[key] = result
|
||||
return result
|
||||
def prepare(self, inputs, rhythm):
|
||||
|
||||
+111
-17
@@ -137,8 +137,8 @@ def validate_events(events, part, lengths=None):
|
||||
raise ValueError('electric comping requires chord events')
|
||||
for i, a in enumerate(ordered):
|
||||
for b in ordered[i + 1:]:
|
||||
if a.end > b.start and (part != 'acoustic' or a.lane == b.lane or 'combined' in (a.lane, b.lane)):
|
||||
raise ValueError('comping attacks cannot overlap on the same strings')
|
||||
if a.end > b.start:
|
||||
raise ValueError('comping sound must release before the next attack')
|
||||
|
||||
def assign_acoustic(events, harmony, breaks=()):
|
||||
"""Assign attacks, not notation fragments, across a complete source span.
|
||||
@@ -164,10 +164,10 @@ def assign_acoustic(events, harmony, breaks=()):
|
||||
else:
|
||||
i += 1
|
||||
result = [dataclasses.replace(e, lane=lane) for e, lane in zip(ordered, lanes)]
|
||||
# Different strings may ring together, but never overlap a fresh attack on
|
||||
# the same strings. Preserve the authored release whenever it is playable.
|
||||
# One rhythm-guitar stream: every new attack releases the previous sound,
|
||||
# including a change of lane. A combined attack has one shared release.
|
||||
for i, a in enumerate(result):
|
||||
following = next((b.start for b in result[i + 1:] if a.lane == b.lane or 'combined' in (a.lane, b.lane)), a.end)
|
||||
following = result[i + 1].start if i + 1 < len(result) else a.end
|
||||
result[i] = dataclasses.replace(a, end=min(a.end, following))
|
||||
# Shortening must not strand an anticipation before its promised arrival.
|
||||
# Rebind that event to the current chord, then reconsider its pair once.
|
||||
@@ -325,7 +325,53 @@ def identity(pattern):
|
||||
"""Layer swaps and anticipation permissions alone are not different rhythms."""
|
||||
return tuple((fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events'])
|
||||
|
||||
def choose_cycles(groups, part, melody, seed):
|
||||
def acoustic_context(groups, melody, breaks=()):
|
||||
"""Immutable source timing for assigning and evaluating a complete stream.
|
||||
|
||||
Breaks are (source measure index, offset) pairs. Ordinary storage boundaries
|
||||
are absent: adjacent figures must see each other's attacks before scoring.
|
||||
"""
|
||||
blocks, starts, cursor = [], {}, fractions.Fraction()
|
||||
for group in groups:
|
||||
start, context, lengths = cursor, [], []
|
||||
for m, _, i in group:
|
||||
starts[i] = cursor
|
||||
context.extend((a + cursor - start, b + cursor - start) for a, b in melody[i])
|
||||
lengths.append(generator.model.fraction(m['duration']))
|
||||
cursor += lengths[-1]
|
||||
starts[i + 1] = cursor
|
||||
blocks.append((start, cursor, tuple(context), spans(flatten(group), None), tuple(lengths)))
|
||||
full = tuple(row for group in groups for row in group)
|
||||
return {'blocks': tuple(blocks), 'harmony': spans(flatten(full), None),
|
||||
'breaks': tuple(sorted(starts[i] + t for i, t in breaks if i in starts))}
|
||||
|
||||
def realize_acoustic_form(rhythms, context):
|
||||
"""The shared release/lane contract for selection, fills and final output.
|
||||
|
||||
Inputs retain authored releases. Re-evaluate those releases whenever an
|
||||
edited neighboring figure changes the next attack; never restore duration
|
||||
from previously shortened notation.
|
||||
"""
|
||||
if len(rhythms) != len(context['blocks']):
|
||||
raise ValueError('acoustic realization requires one rhythm per source block')
|
||||
events = []
|
||||
for rhythm, (start, _, _, _, _) in zip(rhythms, context['blocks']):
|
||||
events.extend(dataclasses.replace(e, start=e.start + start, end=e.end + start, harmonic_time=e.harmonic_time + start if e.harmonic_time is not None else None) for e in rhythm.events)
|
||||
assigned = assign_acoustic(events, context['harmony'], context['breaks'])
|
||||
result = []
|
||||
for rhythm, (start, end, melody, harmony, lengths) in zip(rhythms, context['blocks']):
|
||||
local = tuple(dataclasses.replace(e, start=e.start - start, end=e.end - start, harmonic_time=e.harmonic_time - start if e.harmonic_time is not None else None)
|
||||
for e in assigned if start <= e.start < end)
|
||||
exceptions = tuple(e for e in rhythm.exceptions if e[0] != 'layer_assignment')
|
||||
result.append(dataclasses.replace(rhythm, events=local, contributions=evaluate(local, 'acoustic', melody, harmony, lengths),
|
||||
pattern_id=rhythm.pattern_id.removesuffix('.anticipated') + ('.anticipated' if any(e.harmonic_time is not None for e in local) else ''),
|
||||
exceptions=(*exceptions, ('layer_assignment', 'combined attacks or bass/upper eighth pairs; release at every next attack'))))
|
||||
return tuple(result)
|
||||
|
||||
def form_cost(rhythms, groups):
|
||||
return sum(r.cost * len(g) for r, g in zip(rhythms, groups))
|
||||
|
||||
def choose_cycles(groups, part, melody, seed, *, breaks=()):
|
||||
"""Select a small recurring vocabulary against every occurrence in the form.
|
||||
|
||||
No local shortlist precedes whole-form evaluation. A pattern/mode is retained
|
||||
@@ -349,12 +395,19 @@ def choose_cycles(groups, part, melody, seed):
|
||||
requested = settings['cycle_patterns']
|
||||
identities = {p['id']: identity(p) for p in patterns}
|
||||
available = len(set(identities.values()))
|
||||
cache = {}
|
||||
cache, estimates = {}, {}
|
||||
context = acoustic_context(run, melody, breaks) if part == 'acoustic' else None
|
||||
for i, group in enumerate(run):
|
||||
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in group)
|
||||
local_context = acoustic_context((group,), melody, breaks) if context else None
|
||||
for p in patterns:
|
||||
for anticipate in (False, True):
|
||||
cache[i, p['id'], anticipate] = realize(project(p, fractions.Fraction(), lengths), group, part, melody, anticipate=anticipate)
|
||||
value = realize(project(p, fractions.Fraction(), lengths), group, part, melody, anticipate=anticipate, assign=part != 'acoustic')
|
||||
cache[i, p['id'], anticipate] = value
|
||||
try:
|
||||
estimates[i, p['id'], anticipate] = realize_acoustic_form((value,), local_context)[0] if context and value is not None else value
|
||||
except ValueError:
|
||||
estimates[i, p['id'], anticipate] = None
|
||||
winner, examined = None, 0
|
||||
for width in range(min(requested, len(run), available), 0, -1):
|
||||
layers = []
|
||||
@@ -362,7 +415,7 @@ def choose_cycles(groups, part, melody, seed):
|
||||
unique = {}
|
||||
for p in patterns:
|
||||
for anticipate in (False, True):
|
||||
values = [cache[i, p['id'], anticipate] for i in range(slot, len(run), width)]
|
||||
values = [estimates[i, p['id'], anticipate] for i in range(slot, len(run), width)]
|
||||
if any(v is None for v in values):
|
||||
continue
|
||||
cost = sum(v.cost * len(run[i]) for i, v in zip(range(slot, len(run), width), values))
|
||||
@@ -383,7 +436,14 @@ def choose_cycles(groups, part, melody, seed):
|
||||
if len({identities[o[1]['id']] for o in cycle}) != width:
|
||||
continue
|
||||
examined += 1
|
||||
rank = (sum(o[0][0] for o in cycle), tuple(o[0][1] for o in cycle))
|
||||
cost = sum(o[0][0] for o in cycle)
|
||||
if context:
|
||||
values = [cache[i, cycle[i % width][1]['id'], cycle[i % width][2]] for i in range(len(run))]
|
||||
try:
|
||||
cost = form_cost(realize_acoustic_form(values, context), run)
|
||||
except ValueError:
|
||||
continue
|
||||
rank = (cost, tuple(o[0][1] for o in cycle))
|
||||
if winner is None or rank < winner[0]:
|
||||
winner = rank, cycle
|
||||
if winner is not None:
|
||||
@@ -395,16 +455,24 @@ def choose_cycles(groups, part, melody, seed):
|
||||
'grouping': list(grouping), 'grouping_evidence': grouping_evidence, 'eligible_patterns': len(patterns),
|
||||
'requested_patterns': requested, 'cycle_patterns': len(cycle), 'patterns': [o[1]['id'] for o in cycle],
|
||||
'anticipation': [o[2] for o in cycle], 'cost': winner[0][0], 'complete_cycles_compared': examined,
|
||||
'retrieval_cost': sum(o[0][0] for o in cycle),
|
||||
'reduction': 'available distinct, legal patterns or source length' if len(cycle) < requested else None}
|
||||
reports.append(report)
|
||||
for i, group in enumerate(run):
|
||||
_, p, anticipate = cycle[i % len(cycle)]
|
||||
selected.append((p, anticipate, cache[i, p['id'], anticipate]))
|
||||
locations.append({'cycle_start': run[0][0][2], 'cycle_slot': i % len(cycle), 'base_pattern': p['id'], 'fill': None})
|
||||
add_fills(groups, selected, locations, part, melody, seed)
|
||||
context = acoustic_context(groups, melody, breaks) if part == 'acoustic' else None
|
||||
add_fills(groups, selected, locations, part, melody, seed, context=context)
|
||||
if context:
|
||||
realized = realize_acoustic_form([r for _, _, r in selected], context)
|
||||
selected = [(p, a, r) for (p, a, _), r in zip(selected, realized)]
|
||||
for report in reports:
|
||||
report['final_cost'] = sum(r.cost * len(g) for g, r in zip(groups, realized) if report['source_start'] <= g[0][2] <= report['source_end'])
|
||||
report['attack_assignment'] = 'sequential_combined_except_eighth_pairs'
|
||||
return selected, locations, reports
|
||||
|
||||
def add_fills(groups, selected, locations, part, melody, seed):
|
||||
def add_fills(groups, selected, locations, part, melody, seed, *, context=None):
|
||||
"""Fixed source-position answers; no iteration seed or generated lead input."""
|
||||
policy = generator.vocabulary.accompaniment(part)
|
||||
spacing = policy['groove']['fill_spacing_measures']
|
||||
@@ -415,7 +483,29 @@ def add_fills(groups, selected, locations, part, melody, seed):
|
||||
options = []
|
||||
if body_length < spacing:
|
||||
return
|
||||
for i, (group, (base, anticipate, current)) in enumerate(zip(groups, selected)):
|
||||
original = realize_acoustic_form([r for _, _, r in selected], context) if context else tuple(r for _, _, r in selected)
|
||||
|
||||
def proposal(i, candidate, baseline):
|
||||
if not context:
|
||||
return candidate.cost < baseline[i].cost - 1e-9, baseline[i].cost - candidate.cost
|
||||
raw = [r for _, _, r in selected]
|
||||
raw[i] = candidate
|
||||
try:
|
||||
revised = realize_acoustic_form(raw, context)
|
||||
except ValueError:
|
||||
return False, 0
|
||||
middle = generator.model.fraction(groups[i][0][0]['duration'])
|
||||
# A second-bar fill must not alter the preceding/following block's lane
|
||||
# assignment or release, nor any part of its own first bar.
|
||||
if any(a.events != b.events for j, (a, b) in enumerate(zip(baseline, revised)) if j != i):
|
||||
return False, 0
|
||||
if tuple(e for e in baseline[i].events if e.start < middle) != tuple(e for e in revised[i].events if e.start < middle):
|
||||
return False, 0
|
||||
gain = form_cost(baseline, groups) - form_cost(revised, groups)
|
||||
return gain > 1e-9, gain
|
||||
|
||||
for i, (group, (base, anticipate, _)) in enumerate(zip(groups, selected)):
|
||||
current = original[i]
|
||||
if len(group) != 2:
|
||||
continue
|
||||
last, _, index = group[-1]
|
||||
@@ -434,17 +524,21 @@ def add_fills(groups, selected, locations, part, melody, seed):
|
||||
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
|
||||
[r for r in base['events'] if fractions.Fraction(r['onset']) < middle] +
|
||||
[r for r in variation['events'] if fractions.Fraction(r['onset']) >= middle]}
|
||||
candidate = realize(hybrid, group, part, melody, anticipate=anticipate)
|
||||
if candidate is None or candidate.cost >= current.cost - 1e-9:
|
||||
candidate = realize(hybrid, group, part, melody, anticipate=anticipate, assign=part != 'acoustic')
|
||||
if candidate is None:
|
||||
continue
|
||||
if tuple(e for e in candidate.events if e.start < middle) != tuple(e for e in current.events if e.start < middle):
|
||||
if not context and tuple(e for e in candidate.events if e.start < middle) != tuple(e for e in current.events if e.start < middle):
|
||||
continue
|
||||
valid, gain = proposal(i, candidate, original)
|
||||
if not valid:
|
||||
continue
|
||||
gain = current.cost - candidate.cost
|
||||
options.append((-gain, generator.solo.policy.stable_rank(seed, (index, hybrid['id'])), i, index, hybrid, anticipate, candidate, reason))
|
||||
chosen = []
|
||||
for _, _, i, index, pattern, anticipate, candidate, reason in sorted(options):
|
||||
if any(min(abs(index - other), body_length - abs(index - other)) < spacing for other in chosen):
|
||||
continue
|
||||
if context and not proposal(i, candidate, realize_acoustic_form([r for _, _, r in selected], context))[0]:
|
||||
continue
|
||||
selected[i] = pattern, anticipate, candidate
|
||||
locations[i]['fill'] = {'pattern': pattern['id'], 'source_index': index, 'reason': reason}
|
||||
chosen.append(index)
|
||||
|
||||
@@ -19,15 +19,16 @@ def audit_primitives(primitives, width, height, fonts):
|
||||
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
|
||||
raise generator.engraving.types.EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
|
||||
|
||||
def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts) -> None:
|
||||
def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts, *, systems_per_page: int | None = 2) -> None:
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
fragments, primitives = {}, {}
|
||||
previous_end = fractions.Fraction()
|
||||
scale = layout.pages[0].systems[0].scale
|
||||
for page in layout.pages:
|
||||
if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1):
|
||||
if not page.systems or (systems_per_page is not None and len(page.systems) != systems_per_page and (page is not layout.pages[-1] or not 1 <= len(page.systems) < systems_per_page)):
|
||||
raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system')
|
||||
if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01:
|
||||
for first, second in zip(page.systems, page.systems[1:]):
|
||||
if first.box.bottom + layout.style.system_gap > second.box.top + 0.01:
|
||||
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
|
||||
audit_primitives(page.primitives, page.width, page.height, fonts)
|
||||
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
|
||||
|
||||
@@ -30,12 +30,14 @@ class OutlinePen(fontTools.pens.basePen.BasePen):
|
||||
def _endPath(self):
|
||||
pass
|
||||
|
||||
def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
|
||||
return rasterize_band(page, page.primitives, 0, page.height, fonts)
|
||||
def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, *, ink=None, background=None, output_scale=1) -> PIL.Image.Image:
|
||||
return rasterize_band(page, page.primitives, 0, page.height, fonts, ink=ink, background=background, output_scale=output_scale)
|
||||
|
||||
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
|
||||
scale = page.supersampling
|
||||
image = PIL.Image.new('RGB', (page.width * scale, (bottom - top) * scale), generator.theme.rgb('background'))
|
||||
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts, *, ink=None, background=None, output_scale=1) -> PIL.Image.Image:
|
||||
ink = generator.theme.rgb('ink') if ink is None else ink
|
||||
background = generator.theme.rgb('background') if background is None else background
|
||||
scale = page.supersampling * output_scale
|
||||
image = PIL.Image.new('RGB', (round(page.width * scale), round((bottom - top) * scale)), background)
|
||||
context = aggdraw.Draw(image)
|
||||
for item in primitives:
|
||||
points = [(x * scale, y * scale - top * scale) for x, y in item.points]
|
||||
@@ -56,16 +58,18 @@ def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[gener
|
||||
if item.kind == 'polygon':
|
||||
path.close()
|
||||
if item.kind in ('glyph', 'polygon'):
|
||||
context.path(path, None, aggdraw.Brush(generator.theme.rgb('ink')))
|
||||
context.path(path, None, aggdraw.Brush(ink))
|
||||
else:
|
||||
context.path(path, aggdraw.Pen(generator.theme.rgb('ink'), item.thickness * scale))
|
||||
context.path(path, aggdraw.Pen(ink, item.thickness * scale))
|
||||
context.flush()
|
||||
drawing = PIL.ImageDraw.Draw(image)
|
||||
for item in primitives:
|
||||
if item.kind == 'text':
|
||||
x, y = item.points[0]
|
||||
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill=generator.theme.rgb('ink'))
|
||||
return image.resize((page.width, bottom - top), PIL.Image.Resampling.LANCZOS)
|
||||
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill=ink)
|
||||
result = image.resize((round(page.width * output_scale), round((bottom - top) * output_scale)), PIL.Image.Resampling.LANCZOS)
|
||||
image.close()
|
||||
return result
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Band:
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Build-only Letter PDFs from the complete arranged parts, never screen PNGs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import PIL.PdfParser
|
||||
|
||||
import generator.engraving.audit
|
||||
import generator.engraving.cache
|
||||
import generator.engraving.fonts
|
||||
import generator.engraving.layout
|
||||
import generator.engraving.notation
|
||||
import generator.engraving.raster
|
||||
import generator.engraving.types
|
||||
import generator.model
|
||||
|
||||
# Two layout units per PDF point: guitar staff spacing is five points.
|
||||
# Raster resolution is independent of layout and the device's display theme.
|
||||
STYLE = generator.engraving.types.Style(width=1224, height=1584, margin_x=72, margin_y=72, page_clearance=28, system_gap=32, supersampling=1)
|
||||
DPI = 300
|
||||
PIXELS = (2550, 3300)
|
||||
MEDIA_BOX = [0, 0, 612, 792]
|
||||
VERSION = 'letter-parts-v1'
|
||||
|
||||
def layout_part(document, fonts):
|
||||
generator.model.validate_document(document)
|
||||
style = dataclasses.replace(generator.engraving.types.score_style(document, STYLE), system_gap=STYLE.system_gap)
|
||||
# Measure numbers are centered over barlines and can extend left of the
|
||||
# staff. Reserve their actual overhang inside the half-inch paper margin.
|
||||
overhang = max(-fonts.text(str(m['number']), 0, 0, 12, 'measure_number', (), True).box.left for m in document['staves'][0]['measures'])
|
||||
style = dataclasses.replace(style, margin_x=style.margin_x + math.ceil(max(0, overhang)) + 4)
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
systems = generator.engraving.layout.layout_systems(document, style, fonts, index=index)
|
||||
# The shared header wraps long credits; print it at twice its display size.
|
||||
header_style = dataclasses.replace(style, width=style.width // 2, margin_x=style.margin_x / 2, margin_y=style.margin_y / 2)
|
||||
header = tuple(p.scaled(2) for p in generator.engraving.layout.part_header(document, fonts, header_style))
|
||||
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
||||
bottom = style.height - style.margin_y - 20 - style.page_clearance
|
||||
pages, placed, cursor = [], [], top
|
||||
|
||||
def finish():
|
||||
number = len(pages) + 1
|
||||
# Include clearance for numeric descenders and JPEG block-edge ringing.
|
||||
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True)
|
||||
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed),
|
||||
(*header, *(p for system in placed for p in system.primitives), footer)))
|
||||
|
||||
for system in systems:
|
||||
system = generator.engraving.layout.make_system(index, system.start, system.end, fonts, style, justify=system.end < index.end, staff_width=style.width - 2 * style.margin_x - 2)
|
||||
height = system.box.bottom - system.box.top
|
||||
if height > bottom - top:
|
||||
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: system cannot fit a Letter page at readable size')
|
||||
if placed and cursor + height > bottom:
|
||||
finish()
|
||||
placed, cursor = [], top
|
||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives),
|
||||
anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
|
||||
cursor += height + style.system_gap
|
||||
finish()
|
||||
result = generator.engraving.types.LayoutDocument(tuple(pages), style, fonts.fingerprint(style))
|
||||
generator.engraving.audit.audit_layout(document, result, fonts, systems_per_page=None)
|
||||
if any(p.box.left < STYLE.margin_x or p.box.right > style.width - STYLE.margin_x or
|
||||
p.box.top < STYLE.margin_y or p.box.bottom > style.height - STYLE.margin_y for page in pages for p in page.primitives):
|
||||
raise generator.engraving.types.EngravingError('print notation exceeds the half-inch printer margins')
|
||||
return result
|
||||
|
||||
def rasterize(page, fonts):
|
||||
return generator.engraving.raster.rasterize_page(page, fonts, ink=(0, 0, 0), background=(255, 255, 255), output_scale=DPI / 144)
|
||||
|
||||
def validate_pdf(path, expected_pages=None):
|
||||
"""Check the narrow PDF format written by the pinned Pillow asset dependency.
|
||||
|
||||
This adapter deliberately uses Pillow's parser, not a new general PDF stack.
|
||||
Tests also decode the embedded page images to check print color/resolution.
|
||||
"""
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError('expected a regular print PDF')
|
||||
pdf = PIL.PdfParser.PdfParser(filename=str(path))
|
||||
try:
|
||||
count = len(pdf.pages)
|
||||
if not 0 < count <= 10000 or (expected_pages is not None and count != expected_pages):
|
||||
raise ValueError('invalid print PDF page count')
|
||||
for reference in pdf.pages:
|
||||
page = pdf.read_indirect(reference)
|
||||
if page[b'MediaBox'] != MEDIA_BOX:
|
||||
raise ValueError('print PDF must use portrait US Letter')
|
||||
image = pdf.read_indirect(page[b'Resources'][b'XObject'][b'image'])
|
||||
if (image.dictionary[b'Width'], image.dictionary[b'Height']) != PIXELS or image.dictionary[b'ColorSpace'] != b'DeviceGray':
|
||||
raise ValueError('print PDF must contain 300 DPI grayscale pages')
|
||||
return count
|
||||
finally:
|
||||
pdf.close()
|
||||
except (OSError, KeyError, TypeError, IndexError, PIL.PdfParser.PdfFormatError) as error:
|
||||
raise ValueError(f'{path.name}: invalid print PDF: {error}') from error
|
||||
|
||||
def write_pdf(path, document, fonts):
|
||||
layout = layout_part(document, fonts)
|
||||
# Append one page at a time so a long arrangement never keeps a chorus of
|
||||
# full-resolution raster images in memory. The enclosing build is staged.
|
||||
for page in layout.pages:
|
||||
with rasterize(page, fonts) as rgb, rgb.convert('L') as image:
|
||||
if image.size != PIXELS or image.getextrema()[0] == 255:
|
||||
raise ValueError('print renderer produced an empty or incorrectly sized page')
|
||||
image.save(path, format='PDF', append=page.number > 1, resolution=DPI, quality=95,
|
||||
title=document['metadata']['title'] + ' - ' + document['staves'][0].get('name', 'Part'),
|
||||
creator='Product Vamp', creationDate=None, modDate=None)
|
||||
validate_pdf(path, len(layout.pages))
|
||||
return len(layout.pages)
|
||||
|
||||
def renderer_key(fonts):
|
||||
identity = generator.engraving.cache.renderer_identity(fonts, STYLE)
|
||||
identity.update(print_version=VERSION, print_code=hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(), dpi=DPI, ink='black', background='white', quality=95)
|
||||
return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
def restore(entry, key, target):
|
||||
try:
|
||||
if generator.engraving.cache.has_symlink(entry):
|
||||
return None
|
||||
pdf, manifest = entry / 'part.pdf', entry / 'print.json'
|
||||
if pdf.is_symlink() or manifest.is_symlink():
|
||||
return None
|
||||
info = json.loads(manifest.read_text())
|
||||
if info['key'] != key or info['sha256'] != hashlib.sha256(pdf.read_bytes()).hexdigest():
|
||||
return None
|
||||
pages = validate_pdf(pdf, info['pages'])
|
||||
shutil.copyfile(pdf, target)
|
||||
return pages
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
def store(entry, key, target, pages):
|
||||
if generator.engraving.cache.has_symlink(entry.parent):
|
||||
raise ValueError('print cache must not contain symlinks')
|
||||
entry.parent.mkdir(parents=True, exist_ok=True)
|
||||
if entry.is_symlink() or entry.is_file():
|
||||
entry.unlink()
|
||||
entry.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.print_', dir=entry.parent) as temporary:
|
||||
staged = pathlib.Path(temporary)
|
||||
shutil.copyfile(target, staged / 'part.pdf')
|
||||
(staged / 'print.json').write_text(json.dumps({'key': key, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), 'pages': pages}))
|
||||
for name in ('part.pdf', 'print.json'):
|
||||
destination = entry / name
|
||||
if destination.is_dir() and not destination.is_symlink():
|
||||
shutil.rmtree(destination)
|
||||
(staged / name).replace(destination)
|
||||
|
||||
def generate_tree(root, font_path, *, cache_dir=None, progress=False):
|
||||
"""Called inside the normal build's atomic staging directory."""
|
||||
if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())):
|
||||
raise ValueError('print cache and generated assets must not overlap')
|
||||
destination = root / 'print'
|
||||
destination.mkdir(exist_ok=True)
|
||||
paths = sorted((root / 'songs').glob('*.json'))
|
||||
fonts = generator.engraving.fonts.Fonts(font_path)
|
||||
renderer = renderer_key(fonts)
|
||||
expected, pages_total, reused = set(), 0, 0
|
||||
started = time.monotonic()
|
||||
for path in paths:
|
||||
bundle = json.loads(path.read_text())
|
||||
document = bundle.get('music', bundle)
|
||||
target = destination / path.with_suffix('.pdf').name
|
||||
expected.add(target)
|
||||
key = generator.engraving.cache.score_fingerprint(document, renderer)
|
||||
entry = cache_dir / 'print' / renderer / key if cache_dir else None
|
||||
pages = restore(entry, key, target) if entry else None
|
||||
if pages is None:
|
||||
pages = write_pdf(target, document, fonts)
|
||||
if entry:
|
||||
store(entry, key, target, pages)
|
||||
else:
|
||||
reused += 1
|
||||
pages_total += pages
|
||||
if progress:
|
||||
print(f'Print: {target.name}: {pages} pages', flush=True)
|
||||
for stale in destination.glob('*.pdf'):
|
||||
if stale not in expected:
|
||||
stale.unlink()
|
||||
if progress:
|
||||
print(f'Print complete: {len(paths)} PDFs, {pages_total} pages, {reused} cached; {time.monotonic() - started:.1f}s', flush=True)
|
||||
return len(paths)
|
||||
@@ -35,12 +35,13 @@ class AcousticPairs(unittest.TestCase):
|
||||
self.assertEqual(tuple(e.start for e in events), tuple(map(F, onsets)))
|
||||
def test_pairing_uses_attack_spacing_not_written_duration(self):
|
||||
events = self.assign(('0', '1/2'), ('1', '1'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1)), ('upper', F(3, 2))])
|
||||
# Bass keeps ringing, without another bass attack under the upper chord.
|
||||
self.assertGreater(events[0].end, events[1].start)
|
||||
def test_same_strings_release_but_different_strings_can_ring(self):
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(3, 2))])
|
||||
self.assertEqual(events[0].end, events[1].start)
|
||||
def test_each_attack_releases_previous_sound_in_both_directions(self):
|
||||
events = self.assign(('0', '1/2', '1'), ('2', '2', '1'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1)), ('upper', F(1)), ('combined', F(2))])
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('combined', F(2))])
|
||||
events = self.assign(('0', '1/2', '1', '3/2'), ('2', '2', '2', '2'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('bass', F(3, 2)), ('upper', F(7, 2))])
|
||||
events = self.assign(('0', '1/2'), ('1', '3'))
|
||||
self.assertEqual(events[-1].end, F(7, 2))
|
||||
def test_chord_change_breaks_pair_but_repeated_label_does_not(self):
|
||||
@@ -66,17 +67,17 @@ class AcousticPairs(unittest.TestCase):
|
||||
{'onset': str(t), 'duration': '1', 'layer': 'acoustic'} for t in (F(beats) - F(1, 2), F(beats))]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
|
||||
self.assertEqual(events[0].end, F(beats) + F(1, 2))
|
||||
def test_anticipated_pair_plays_same_future_harmony(self):
|
||||
self.assertEqual(events[0].end, F(beats))
|
||||
def test_release_rebinds_expired_anticipation_to_direct_harmony(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
|
||||
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
|
||||
pattern = {'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '7/2', 'duration': '1', 'layer': 'acoustic', 'anticipate': True},
|
||||
{'onset': '4', 'duration': '1', 'layer': 'acoustic'}]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True)
|
||||
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
|
||||
self.assertEqual(events[0].harmonic_time, 4)
|
||||
self.assertGreater(events[0].end, 4)
|
||||
self.assertEqual([e.lane for e in events], ['combined', 'combined'])
|
||||
self.assertIsNone(events[0].harmonic_time)
|
||||
self.assertEqual(events[0].end, 4)
|
||||
# A participation break prevents pairing; an expired anticipation must
|
||||
# revert to the current harmony rather than keep an unresolved label.
|
||||
raw, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True, assign=False)
|
||||
@@ -84,6 +85,11 @@ class AcousticPairs(unittest.TestCase):
|
||||
self.assertEqual([e.lane for e in broken], ['combined', 'combined'])
|
||||
self.assertIsNone(broken[0].harmonic_time)
|
||||
self.assertEqual(broken[0].end, 4)
|
||||
# With no intervening attack the bounded anticipated hold stays intact.
|
||||
pattern['events'].pop()
|
||||
held, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True)
|
||||
self.assertEqual(held[0].harmonic_time, 4)
|
||||
self.assertEqual(held[0].end, F(9, 2))
|
||||
def resources(self):
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
|
||||
@@ -111,6 +111,8 @@ class CatalogTests(unittest.TestCase):
|
||||
write_score(self.root, 'songs/tune.json')
|
||||
generator.catalog.build_catalog(self.root)
|
||||
(self.root / 'songs/tune.png').write_bytes(b'page')
|
||||
(self.root / 'print').mkdir()
|
||||
(self.root / 'print/tune.pdf').write_bytes(b'build-only PDF')
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
destination = pathlib.Path(temporary)
|
||||
subprocess.run(['cmake', f'-DSOURCE={self.root}', f'-DDESTINATION={destination}', '-P', 'cmake/runtime_assets.cmake'], check=True, capture_output=True)
|
||||
@@ -120,6 +122,8 @@ class CatalogTests(unittest.TestCase):
|
||||
self.assertEqual(pathlib.Path(config['database']['path']).name, 'catalog.db')
|
||||
self.assertTrue((destination / 'songs/tune.png').exists())
|
||||
self.assertFalse(list(destination.rglob('*.json')))
|
||||
self.assertFalse((destination / 'print').exists())
|
||||
self.assertIn('PATTERN "print" EXCLUDE', pathlib.Path('cmake/zero.cmake').read_text())
|
||||
def test_menu_query_uses_index_and_preserves_binary_identity_order(self):
|
||||
for name, title, composer, part in (('z', 'Same', 'Bea', 'Acoustic'), ('b', 'Same', 'Ada', 'Vocal'), ('a', 'Same', 'Ada', 'Acoustic'), ('c', 'same', 'Ada', 'Electric'), ('d', 'Same', 'Ada', 'Acoustic')):
|
||||
document = bundle(title=title, part=part)
|
||||
|
||||
@@ -65,6 +65,58 @@ class RecurringGrooves(unittest.TestCase):
|
||||
selected, _, report = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'coverage')
|
||||
self.assertTrue(all(p['id'] == 'electric.quarters.statement' for p, _, _ in selected))
|
||||
self.assertEqual(report[0]['cost'], 0)
|
||||
def test_acoustic_cycle_is_ranked_after_cross_block_assignment(self):
|
||||
source = tests.solo_fixtures.song(count=4, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [{'id': name, 'family': 'quarters', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': t, 'duration': '1', 'layer': 'acoustic'} for t in times]}
|
||||
for name, times in (('crossing', ('0', '1/2', '15/2')), ('local', ('0', '2', '6')))]
|
||||
def evaluated(events, *args):
|
||||
# Isolated retrieval prefers 'local' (0) over 'crossing' (1).
|
||||
# The complete stream permits a prepared upper entry in block two.
|
||||
cost = -3 if events[0].lane == 'upper' else 1 if events[1].start == fractions.Fraction(1, 2) else 0
|
||||
return [('test_entry', cost)]
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment'), unittest.mock.patch('generator.comping_rhythm.evaluate', side_effect=evaluated):
|
||||
selected, _, reports = generator.comping_rhythm.choose_cycles(groups(source), 'acoustic', generator.comping_rhythm.melody_contexts(source), 'complete')
|
||||
self.assertEqual([p['id'] for p, _, _ in selected], ['crossing', 'crossing'])
|
||||
self.assertEqual([r.events[0].lane for _, _, r in selected], ['bass', 'upper'])
|
||||
self.assertEqual(reports[0]['retrieval_cost'], 4)
|
||||
self.assertEqual(reports[0]['cost'], -4)
|
||||
self.assertEqual(reports[0]['cost'], sum(r.cost * 2 for _, _, r in selected))
|
||||
self.assertEqual(reports[0]['cost'], reports[0]['final_cost'])
|
||||
for _, _, rhythm in selected:
|
||||
generator.comping_rhythm.validate_events(rhythm.events, 'acoustic')
|
||||
def test_acoustic_fill_cannot_reassign_neighboring_attacks(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
source['staves'][0]['measures'][2]['section_label'] = 'Source boundary'
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['patterns'] = [{'id': name, 'family': 'quarters', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': t, 'duration': '1/2', 'layer': 'acoustic'} for t in ('0', '2', last)]}
|
||||
for name, last in (('base', '5'), ('unsafe', '15/2'), ('safe', '7'))]
|
||||
source_groups = groups(source)
|
||||
melody = generator.comping_rhythm.melody_contexts(source)
|
||||
context = generator.comping_rhythm.acoustic_context(source_groups, melody)
|
||||
base = policy['patterns'][0]
|
||||
selected = [(base, False, generator.comping_rhythm.realize(base, g, 'acoustic', melody, assign=False)) for g in source_groups]
|
||||
locations = [{'fill': None} for _ in selected]
|
||||
original = generator.comping_rhythm.realize_acoustic_form([r for _, _, r in selected], context)
|
||||
def evaluated(events, part, melody, *args):
|
||||
# Only the first block has this source entrance. The preferred local
|
||||
# fill pairs with the next block's first attack, so it is ineligible.
|
||||
eligible = melody[0][0] == 0
|
||||
cost = (-2 if events[-1].start == fractions.Fraction(15, 2) else -1 if events[-1].start == 7 else 0) if eligible else 0
|
||||
return [('test_fill', cost)]
|
||||
# Distinguish source contexts without relying on the pattern or part ID.
|
||||
contexts = tuple(m if i < 2 else tuple((a + fractions.Fraction(1, 4), b) for a, b in m) for i, m in enumerate(melody))
|
||||
context = generator.comping_rhythm.acoustic_context(source_groups, contexts)
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment'), unittest.mock.patch('generator.comping_rhythm.evaluate', side_effect=evaluated):
|
||||
generator.comping_rhythm.add_fills(source_groups, selected, locations, 'acoustic', contexts, 'fill', context=context)
|
||||
self.assertEqual(locations[0]['fill']['pattern'], 'base.fill.safe')
|
||||
final = generator.comping_rhythm.realize_acoustic_form([r for _, _, r in selected], context)
|
||||
self.assertEqual([r.events for r in original[1:]], [r.events for r in final[1:]])
|
||||
def test_melody_and_progression_influence_selection_with_fixed_seed(self):
|
||||
source = tests.solo_fixtures.song(count=16, chords=('C7', 'FM7'))
|
||||
sparse = copy.deepcopy(source)
|
||||
@@ -149,8 +201,9 @@ class RecurringGrooves(unittest.TestCase):
|
||||
after = canonical(changed[6], changed[5]['harmonies'][0], 6, sources=changed)
|
||||
self.assertEqual(signature(before), signature(after))
|
||||
self.assertEqual(canonical.rhythm_report[-1]['base_pattern'], canonical.locations[6]['base_pattern'])
|
||||
def test_overlap_survives_single_voice_merge_and_tablature(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
def test_bass_eighth_then_upper_quarter_survives_single_voice_merge(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('Dm7',))
|
||||
source['staves'][0]['measures'][0]['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'middle', 'onset': generator.model.rational(2)})
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['patterns'] = [p for p in policy['patterns'] if p['id'] == 'acoustic.quarters.overlapping']
|
||||
@@ -165,9 +218,14 @@ class RecurringGrooves(unittest.TestCase):
|
||||
generator.acoustic.validate_comping(source)
|
||||
first = source['staves'][0]['measures'][0]
|
||||
self.assertEqual(len(first['voices']), 1)
|
||||
overlap = next(n for n in generator.score.measure_notes(first) if generator.model.fraction(n['onset']) == fractions.Fraction(1, 2))
|
||||
self.assertEqual(len(overlap['pitches']), 4)
|
||||
self.assertTrue(any(p.get('tie_from') and p['tablature']['string'] >= 5 for p in overlap['pitches']))
|
||||
notes = [n for n in generator.score.measure_notes(first) if n['pitches']]
|
||||
self.assertEqual([(generator.model.fraction(n['onset']), generator.model.fraction(n['duration'])) for n in notes],
|
||||
[(0, fractions.Fraction(1, 2)), (fractions.Fraction(1, 2), 1), (2, 1)])
|
||||
strings = [{p['tablature']['string'] for p in n['pitches']} for n in notes]
|
||||
self.assertIn(strings[0], ({5}, {6}))
|
||||
self.assertEqual(strings[1], {2, 3, 4})
|
||||
self.assertIn(strings[2], ({2, 3, 4, 5}, {2, 3, 4, 6}))
|
||||
self.assertFalse(any(p.get('tie_from') or p.get('tie_to') for n in notes for p in n['pitches']))
|
||||
def test_mid_measure_chord_change_bass_without_forced_barline_attack(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
m = source['staves'][0]['measures'][0]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Independent rhythm contracts, including real overlapping guitar voices."""
|
||||
"""Independent rhythm contracts and their complete sounding durations."""
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
@@ -53,14 +53,13 @@ class LayeredComping(unittest.TestCase):
|
||||
self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
|
||||
self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events))
|
||||
self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
|
||||
def test_separate_lanes_can_overlap_and_short_combined_attacks_are_valid(self):
|
||||
def test_separate_attacks_cannot_overlap_and_combined_attacks_are_valid(self):
|
||||
F = fractions.Fraction
|
||||
attack = generator.comping_rhythm.Attack
|
||||
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic')
|
||||
generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
|
||||
generator.comping_rhythm.validate_events((attack('combined', F(0), F(1)),), 'acoustic')
|
||||
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), 'acoustic')
|
||||
for events in ((attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(1, 3)),)):
|
||||
for events in ((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), (attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(1, 3)),)):
|
||||
with self.assertRaises(ValueError):
|
||||
generator.comping_rhythm.validate_events(events, 'acoustic')
|
||||
def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self):
|
||||
@@ -72,7 +71,10 @@ class LayeredComping(unittest.TestCase):
|
||||
if pattern['lengths'] != ['4', '4']:
|
||||
continue
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([(e.start, e.end - e.start) for e in events], [(fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']])
|
||||
self.assertEqual([e.start for e in events], [fractions.Fraction(r['onset']) for r in pattern['events']])
|
||||
for i, (e, r) in enumerate(zip(events, pattern['events'])):
|
||||
intended = e.start + fractions.Fraction(r['duration'])
|
||||
self.assertEqual(e.end, min(intended, events[i + 1].start) if i + 1 < len(events) else intended)
|
||||
if events:
|
||||
self.assertIn(events[0].lane, ('bass', 'combined'))
|
||||
pattern = {'lengths': ['4', '4'], 'events': [{'onset': '0', 'duration': '1', 'layer': 'bass'}, {'onset': '4', 'duration': '1', 'layer': 'upper'}]}
|
||||
@@ -104,7 +106,7 @@ class LayeredComping(unittest.TestCase):
|
||||
pattern = {'id': 'test.delayed', 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '1', 'duration': '1/2', 'layer': 'upper'}, {'onset': '2', 'duration': '1', 'layer': 'bass'},
|
||||
{'onset': '5', 'duration': '1', 'layer': 'combined'}]}
|
||||
# A long combined event remains available alongside short overlaps.
|
||||
# A long combined event remains available alongside short attacks.
|
||||
pattern['events'][-1]['duration'] = '2'
|
||||
resources = copy.deepcopy(self.resources)
|
||||
resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern]
|
||||
@@ -117,6 +119,21 @@ class LayeredComping(unittest.TestCase):
|
||||
first['voices'] = [v for v in first['voices'] if v['id'] != 'bass']
|
||||
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
def test_completed_score_rejects_bass_under_upper_before_and_after_merge(self):
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C7',))
|
||||
source['staves'][0]['arrangement_part_type'] = 'acoustic'
|
||||
source['staves'][0]['measures'][0]['voices'] = [
|
||||
{'id': 'bass', 'notes': [generator.model.make_event('bass', fractions.Fraction(), fractions.Fraction(1), 'note', [
|
||||
{'letter': 'c', 'alter': 0, 'octave': 3, 'tablature': {'string': 5, 'fret': 3}}])]},
|
||||
{'id': 'upper', 'notes': [generator.model.make_event('upper', fractions.Fraction(1, 2), fractions.Fraction(1), 'note', [
|
||||
{'letter': 'e', 'alter': 0, 'octave': 3, 'tablature': {'string': 4, 'fret': 2}}])]}]
|
||||
with self.assertRaisesRegex(ValueError, 'release before the next attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
generator.acoustic.combine(source)
|
||||
# The generic merger preserves imported overlaps faithfully. Generated
|
||||
# accompaniment must fail even when the overlap is encoded using ties.
|
||||
with self.assertRaisesRegex(ValueError, 'release before the next attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self):
|
||||
source = copy.deepcopy(self.source)
|
||||
for m in source['staves'][0]['measures'][:2]:
|
||||
@@ -178,7 +195,7 @@ class LayeredComping(unittest.TestCase):
|
||||
self.assertEqual(sounding(first['document']), sounding(second['document']))
|
||||
self.assertEqual(first['details'], second['details'])
|
||||
self.assertNotEqual(first['trace']['cache_key'], second['trace']['cache_key'])
|
||||
def test_assembly_keeps_overlaps_and_canonical_reuse(self):
|
||||
def test_assembly_keeps_releases_and_canonical_reuse(self):
|
||||
source = generator.production.SourceAnalysis.read(self.source)
|
||||
results = {role: generator.production.generate_role(source, role, pathlib.Path('data')) for role in generator.production.ROLES if not role.endswith('_solo')}
|
||||
# Silence in source-defined solo slots is sufficient to test union;
|
||||
|
||||
@@ -360,10 +360,16 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
(output / 'solo_xml').mkdir()
|
||||
(output / 'solo_xml/stale.xml').write_text('obsolete solo export')
|
||||
(output / 'solo_xml/manifest.solo').write_text('{}')
|
||||
(output / 'print/stale.pdf').write_bytes(b'obsolete print part')
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, self.files(output))
|
||||
self.assertFalse((output / 'solo_xml').exists())
|
||||
self.assertFalse(list(output.rglob('*.xml')))
|
||||
self.assertEqual(len(list(output.glob('print/*.pdf'))), 3)
|
||||
with unittest.mock.patch('generator.printing.generate_tree', side_effect=ValueError('print rendering failed')):
|
||||
with self.assertRaisesRegex(ValueError, 'print rendering failed'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, self.files(output))
|
||||
source.write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
|
||||
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
|
||||
generator.__main__.generate_assets(args)
|
||||
|
||||
@@ -144,6 +144,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertFalse(list(output.rglob('*.json')))
|
||||
self.assertFalse(list(output.rglob('*.png')))
|
||||
self.assertFalse(list(output.rglob('*.pdf')))
|
||||
def test_pickup_tie_survives_vocal_restoration(self):
|
||||
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
|
||||
source = source.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Build-only print layout, PDF contents and reproducible cache behavior."""
|
||||
import copy
|
||||
import dataclasses
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import PIL.Image
|
||||
import PIL.PdfParser
|
||||
|
||||
import generator.arrange.generate
|
||||
import generator.engraving.audit
|
||||
import generator.engraving.fonts
|
||||
import generator.engraving.types
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.printing
|
||||
import generator.score
|
||||
import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
|
||||
class PrintableParts(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.font_path = pathlib.Path('package/assets/fonts/petaluma')
|
||||
cls.fonts = generator.engraving.fonts.Fonts(cls.font_path)
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = pathlib.Path(temporary.name)
|
||||
self.source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
def tree(self, name, document=None):
|
||||
root = self.root / name
|
||||
(root / 'songs').mkdir(parents=True)
|
||||
generator.score.write_json(root / 'songs/part.json', document or self.source)
|
||||
return root
|
||||
def test_letter_pagination_preserves_complete_order_without_transition_duplicates(self):
|
||||
source = tests.solo_fixtures.song(count=32, chords=('C7',))
|
||||
before = copy.deepcopy(source)
|
||||
layout = generator.printing.layout_part(source, self.fonts)
|
||||
self.assertGreater(len(layout.pages), 1)
|
||||
self.assertGreater(len(layout.pages[0].systems), 2)
|
||||
self.assertEqual(source, before)
|
||||
systems = [s for p in layout.pages for s in p.systems]
|
||||
self.assertEqual(systems[0].start, 0)
|
||||
self.assertEqual(systems[-1].end, 128)
|
||||
self.assertTrue(all(a.end == b.start for a, b in zip(systems, systems[1:])))
|
||||
for page in layout.pages:
|
||||
self.assertEqual((page.width, page.height), (1224, 1584))
|
||||
self.assertTrue(all(p.box.left >= 72 and p.box.right <= 1152 for p in page.primitives))
|
||||
self.assertTrue(all(p.box.top >= 72 and p.box.bottom <= 1512 for p in page.primitives))
|
||||
# Sharing content audits must not relax the screen's two-system contract.
|
||||
with self.assertRaisesRegex(ValueError, 'two music systems'):
|
||||
generator.engraving.audit.audit_layout(source, layout, self.fonts)
|
||||
def test_content_audits_remain_active_for_print(self):
|
||||
source = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
|
||||
layout = generator.printing.layout_part(source, self.fonts)
|
||||
roles = {p.role for page in layout.pages for p in page.primitives}
|
||||
self.assertTrue({'tie', 'lyric', 'harmony', 'accidental'} <= roles)
|
||||
page = layout.pages[0]
|
||||
broken = dataclasses.replace(page, primitives=tuple(p for p in page.primitives if p.role != 'tie'))
|
||||
with self.assertRaisesRegex(ValueError, 'tie was omitted'):
|
||||
generator.engraving.audit.audit_layout(source, dataclasses.replace(layout, pages=(broken,)), self.fonts, systems_per_page=None)
|
||||
def test_guitar_tab_pickups_and_final_three_bar_hold_are_included(self):
|
||||
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter'), implicit=True)
|
||||
source = source.replace('</measure></part>', '</measure><measure number="2"><harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note() + '</measure></part>')
|
||||
source = tests.test_musicxml.parse(source)
|
||||
for part in ('acoustic', 'electric', 'vocal'):
|
||||
document = generator.arrange.generate.arrange_document(source, part)
|
||||
if part != 'vocal':
|
||||
document['staves'][0]['tablature'] = True
|
||||
before = copy.deepcopy(document)
|
||||
layout = generator.printing.layout_part(document, self.fonts)
|
||||
self.assertEqual(document, before)
|
||||
self.assertEqual(layout.pages[-1].systems[-1].end, sum(generator.model.fraction(m['duration']) for m in document['staves'][0]['measures']))
|
||||
self.assertTrue(all(m['ending_sustain'] for m in document['staves'][0]['measures'][-3:]))
|
||||
roles = {p.role for page in layout.pages for p in page.primitives}
|
||||
self.assertIn('rest', roles)
|
||||
if part != 'vocal':
|
||||
self.assertTrue({'tab', 'tab_staff', 'tie'} <= roles)
|
||||
def test_pdf_pages_are_letter_300dpi_white_grayscale_and_reproducible(self):
|
||||
source = tests.solo_fixtures.song(count=32, chords=('C7',))
|
||||
first, second = self.root / 'first.pdf', self.root / 'second.pdf'
|
||||
count = generator.printing.write_pdf(first, source, self.fonts)
|
||||
self.assertGreater(count, 1)
|
||||
generator.printing.write_pdf(second, source, self.fonts)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
pdf = PIL.PdfParser.PdfParser(filename=str(first))
|
||||
try:
|
||||
self.assertEqual(len(pdf.pages), count)
|
||||
self.assertNotIn(b'CreationDate', pdf.info)
|
||||
self.assertNotIn(b'ModDate', pdf.info)
|
||||
for reference in pdf.pages:
|
||||
page = pdf.read_indirect(reference)
|
||||
self.assertEqual(page[b'MediaBox'], [0, 0, 612, 792])
|
||||
stream = pdf.read_indirect(page[b'Resources'][b'XObject'][b'image'])
|
||||
with PIL.Image.open(io.BytesIO(stream.buf)) as image:
|
||||
self.assertEqual(image.size, (2550, 3300))
|
||||
self.assertEqual(image.mode, 'L')
|
||||
self.assertEqual(image.getextrema(), (0, 255))
|
||||
for box in ((0, 0, 150, 3300), (2400, 0, 2550, 3300), (0, 0, 2550, 150), (0, 3150, 2550, 3300)):
|
||||
with image.crop(box) as margin:
|
||||
self.assertEqual(margin.getextrema(), (255, 255))
|
||||
finally:
|
||||
pdf.close()
|
||||
with self.assertRaisesRegex(ValueError, 'page count'):
|
||||
generator.printing.validate_pdf(first, count + 1)
|
||||
def test_single_page_cache_hit_corruption_and_stale_removal(self):
|
||||
root, cache = self.tree('assets'), self.root / 'cache'
|
||||
self.assertEqual(generator.printing.generate_tree(root, self.font_path, cache_dir=cache), 1)
|
||||
target = root / 'print/part.pdf'
|
||||
original = target.read_bytes()
|
||||
self.assertEqual(generator.printing.validate_pdf(target), 1)
|
||||
warm = self.tree('warm')
|
||||
with unittest.mock.patch('generator.printing.write_pdf', side_effect=AssertionError('cache miss')):
|
||||
generator.printing.generate_tree(warm, self.font_path, cache_dir=cache)
|
||||
self.assertEqual(original, (warm / 'print/part.pdf').read_bytes())
|
||||
asset = next(cache.rglob('part.pdf'))
|
||||
asset.write_bytes(original + b'corrupt')
|
||||
with unittest.mock.patch('generator.printing.write_pdf', wraps=generator.printing.write_pdf) as render:
|
||||
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
|
||||
render.assert_called_once()
|
||||
self.assertEqual(asset.read_bytes(), original)
|
||||
info = next(cache.rglob('print.json'))
|
||||
metadata = json.loads(info.read_text())
|
||||
info.write_text(json.dumps({**metadata, 'pages': metadata['pages'] + 1}))
|
||||
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
|
||||
self.assertEqual(json.loads(info.read_text()), metadata)
|
||||
(root / 'songs/part.json').unlink()
|
||||
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
|
||||
self.assertFalse(list((root / 'print').iterdir()))
|
||||
def test_print_cache_tracks_music_style_and_fonts(self):
|
||||
root, cache = self.tree('assets'), self.root / 'cache'
|
||||
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
|
||||
changed = copy.deepcopy(self.source)
|
||||
changed['metadata']['title'] = 'Another title'
|
||||
generator.score.write_json(root / 'songs/part.json', changed)
|
||||
with unittest.mock.patch('generator.printing.write_pdf', wraps=generator.printing.write_pdf) as render:
|
||||
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
|
||||
render.assert_called_once()
|
||||
key = generator.printing.renderer_key(self.fonts)
|
||||
with unittest.mock.patch('generator.printing.STYLE', dataclasses.replace(generator.printing.STYLE, system_gap=40)):
|
||||
self.assertNotEqual(key, generator.printing.renderer_key(self.fonts))
|
||||
with unittest.mock.patch.object(self.fonts, 'fingerprint', return_value='changed-fonts'):
|
||||
self.assertNotEqual(key, generator.printing.renderer_key(self.fonts))
|
||||
def test_print_cache_cannot_overlap_published_assets(self):
|
||||
root = self.tree('assets')
|
||||
for cache in (root, root / 'cache', root.parent):
|
||||
with self.assertRaisesRegex(ValueError, 'must not overlap'):
|
||||
generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user