Compare commits

..

3 Commits

Author SHA1 Message Date
khodowany 8a23fa0fc2 Generation
ci / checks (push) Successful in 47m20s
2026-09-30 01:54:59 -05:00
khodowany deaa0468a7 Generation 2026-09-30 01:38:16 -05:00
khodowany b1a7b44d7a Harmonic Minor 2026-09-29 23:55:08 -05:00
26 changed files with 1196 additions and 199 deletions
+3 -3
View File
@@ -111,11 +111,11 @@ Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats
There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order. There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order.
Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span including open strings. Open strings can accompany frets 1-4, but not distant positions such as 11 or 12. Acoustic uses a bass on string 5 or 6 and two or three consecutive upper strings within 2-4, with zero or one unused string between them. Electric chords use strings 1-3 or 1-4 and may omit the root. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Electric melodies are raised one octave; vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment fails generation. Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 1-20, and a maximum four-fret span. Open strings are excluded from chords and single-note melodies. Acoustic uses a bass on string 5 or 6 and two or three consecutive upper strings within 2-4, with zero or one unused string between them. Electric chords use strings 1-3 or 1-4 and may omit the root. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Electric melodies are raised one octave; vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment or exact melody pitches fail generation.
All parts use two music systems per page, with a single system only when the score ends on an odd system. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product. All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
Electric melody prioritizes four-string chords, then melody on top, then root inclusion. Compact shapes, nearby positions, and fret comfort are preferred before optional chord tones; rootless voicings remain fallbacks, not equally desirable defaults. Electric accompaniment shares the fullness/root priorities. Acoustic instead favors simple grips, allowing bass plus two upper notes when that is easier than a four-note shape. Defining chord tones remain mandatory. All guitar placement uses a fret comfort threshold: frets 0-12 inclusive have no height penalty, and each fret above 12 adds two effort points based on the grip's highest fret. Frets 13-20 remain available; the soft penalty does not override musical priorities or force a less convenient move to a lower position. Exact melody pitches and rhythms remain unchanged. Electric melody prioritizes four-string chords, then melody on top. Every generated guitar chord requires the root, the fifth, or both; neither is preferred over the other. Acoustic favors simple grips, allowing bass plus two upper notes when that is easier than a four-note shape. Defining chord tones remain mandatory. Acoustic and electric chords share a combined effort score for shape difficulty, fret height, and movement, so a compact mid-neck grip can beat a stretched lower one. Shape difficulty adds three points per fret of span and two per additional distinct fret. Height adds one point per fret, plus two more per fret above 12. Acoustic transitions weight per-string movement by two; electric retains its doubled-center movement metric. Frets 13-20 remain available when the overall tradeoff justifies them. Exact melody pitches and rhythms remain unchanged, and unharmonized melody placement retains its existing proximity scoring.
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending. Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending.
+11 -5
View File
@@ -128,23 +128,27 @@ Acoustic accompaniment uses the planned foundation. Electric accompaniment secti
Each source is parsed, validated, and normalized once for all three parts. The batch generator computes shared accompaniment plans before building those parts. Public `arrange_document` and `generate_performance_scores` calls require a keyword `rhythms` argument holding a `generator.rhythm.Library`; internal part builders receive precomputed plans. Builders treat the prepared source and plans as read-only and own their generated notes and spans. Source-path checks reject colliding song slugs before writing parts: for example, `one-two.xml` and `one_two.xml` would both produce `one-two-acoustic.json` and cannot coexist. Each source is parsed, validated, and normalized once for all three parts. The batch generator computes shared accompaniment plans before building those parts. Public `arrange_document` and `generate_performance_scores` calls require a keyword `rhythms` argument holding a `generator.rhythm.Library`; internal part builders receive precomputed plans. Builders treat the prepared source and plans as read-only and own their generated notes and spans. Source-path checks reject colliding song slugs before writing parts: for example, `one-two.xml` and `one_two.xml` would both produce `one-two-acoustic.json` and cannot coexist.
All three parts use octave-transposing notation: written notes sound one octave lower. This includes the vocal part for the singer's range. Unmarked source notation uses that convention by default. Vocal melodies retain their written pitches and octave numbers. Song electric melody sections add twelve semitones to both written and sounding pitch; the guitar's octave-transposing convention still applies. Written C4 sounds C3 (A-string fret 3), and written C#6 sounds C#5, within the 0-20 fret range. Generated accompaniment is notated using the same convention. All three parts use octave-transposing notation: written notes sound one octave lower. This includes the vocal part for the singer's range. Unmarked source notation uses that convention by default. Vocal melodies retain their written pitches and octave numbers. Song electric melody sections add twelve semitones to both written and sounding pitch; the guitar's octave-transposing convention still applies. Written C4 sounds C3 (A-string fret 3), and written C#6 sounds C#5, within the 1-20 fret range. Generated accompaniment is notated using the same convention.
`pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation except for the intentional song electric melody octave increase. There is no automatic octave fitting, vocal-specific range shift, or key transposition. `pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation except for the intentional song electric melody octave increase. There is no automatic octave fitting, vocal-specific range shift, or key transposition.
`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between the lowest and highest positions, including fret 0. Open strings participate in span validation and proximity scoring: 0/2/3 and 0/4 are valid, while 0/5 and 0/11/12 are not. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Each continuous acoustic harmony span keeps the same bass pitch, upper voicing, and string/fret positions. Root-bass and fifth-bass candidates compete using the same complete-grip cost; fifth choices are not merely fallbacks. The fifth follows the chord's actual degree, including diminished or augmented fifths, and is unavailable if omitted from the chord. Explicit slash basses and inversions override this choice. All required chord tones remain mandatory, including the root when required in an upper voice; there is no alternating-bass substitution. `guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 1-20 inclusive, with at most four frets between the lowest and highest positions. Open strings are excluded from chords and single-note melodies alike. A pitch that only an open string could produce fails generation rather than being octave-shifted or omitted. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Each continuous acoustic harmony span keeps the same bass pitch, upper voicing, and string/fret positions. Root-bass and fifth-bass candidates compete equally; fifth choices are not merely fallbacks. Explicit acoustic slash basses and inversions override the default bass choices. There is no alternating-bass substitution.
Shared `ChordRequirements` separate mandatory pitch classes, allowed pitch classes, root-or-fifth alternatives, and bass constraints. Every complete generated guitar grip must contain the root, the fifth, or both, with no preference for the root itself. This rule applies equally to acoustic accompaniment, electric accompaniment, and electric melody chords, not isolated bass/upper attacks or unharmonized melody notes. The fifth follows the chord's actual degree, including diminished or augmented fifths. Explicitly removed degrees are never restored, and a harmony removing both root and fifth cannot generate a guitar chord. Defining thirds/suspensions, sixths/sevenths, alterations, and required extensions remain mandatory; an altered fifth cannot be omitted merely because the root is present.
Acoustic spans follow the harmony timeline, including changes during silence. Barline crossings, rests, repeated identical harmonies, and section labels do not reset the grip while accompaniment continues. Changed chord kinds, roots, degrees, basses, or inversions start a new span. N.C. and non-accompaniment passages end a span. Returning to a chord after a different harmony starts a new decision, not a song-wide fixed fingering. A span with attacks is placed once as a complete grip, then each bass or upper attack receives its corresponding subset; simultaneous attacks receive both. If no complete grip satisfies the existing harmonic and fretboard constraints, generation fails with passage context without replacing published assets. Span state is build-only and does not add runtime fields. Acoustic spans follow the harmony timeline, including changes during silence. Barline crossings, rests, repeated identical harmonies, and section labels do not reset the grip while accompaniment continues. Changed chord kinds, roots, degrees, basses, or inversions start a new span. N.C. and non-accompaniment passages end a span. Returning to a chord after a different harmony starts a new decision, not a song-wide fixed fingering. A span with attacks is placed once as a complete grip, then each bass or upper attack receives its corresponding subset; simultaneous attacks receive both. If no complete grip satisfies the existing harmonic and fretboard constraints, generation fails with passage context without replacing published assets. Span state is build-only and does not add runtime fields.
Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voicings and octave doublings are allowed as fallbacks; the third or suspension, sixth/seventh, alterations, and named extensions remain required. An unaltered fifth in extended chords and unaltered intermediate ninth/eleventh extensions may be omitted. Exact melody pitches, including non-chord tones, remain unchanged. Root preference never restores a degree explicitly subtracted from the harmony. Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voicings containing the fifth and octave doublings are ordinary candidates. An unaltered fifth may be omitted when the root is present, and unaltered intermediate ninth/eleventh extensions may be omitted. Exact melody pitches, including non-chord tones, remain unchanged.
`melody.py` treats the full tied duration as one note when testing whether it exceeds one quarter-note beat. Qualifying notes receive a chord at their initial attack and at each subsequent changed harmony, even if a resulting fragment is less than one beat. Changed harmonies break ties and reattack the melody; repeated identical harmonies do not. N.C. releases the added voices while preserving the melody's sustain. Added tones through unchanged harmony retain their own ties. The quarter-note cap on electric stabs does not apply to these melody chords. If no valid chord fits, the exact melody remains with a diagnostic; it is not octave-wrapped or omitted. `melody.py` treats the full tied duration as one note when testing whether it exceeds one quarter-note beat. Qualifying notes receive a chord at their initial attack and at each subsequent changed harmony, even if a resulting fragment is less than one beat. Changed harmonies break ties and reattack the melody; repeated identical harmonies do not. N.C. releases the added voices while preserving the melody's sustain. Added tones through unchanged harmony retain their own ties. The quarter-note cap on electric stabs does not apply to these melody chords. If no valid chord fits, the exact melody remains with a diagnostic; it is not octave-wrapped or omitted.
Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, missing root, physical effort, and missing optional pitch classes. These costs accumulate over the whole passage, so movement cannot outweigh the earlier musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. Acoustic does not receive electric fullness/root penalties: a simpler three-note grip can beat a more awkward four-note grip while satisfying its hard harmonic requirements. Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, combined physical effort, and missing optional pitch classes. These costs accumulate over the whole passage, so physical effort cannot outweigh the earlier electric musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. Acoustic does not receive electric fullness penalties: a simpler three-note grip can beat a more awkward four-note grip. Shape difficulty competes directly with fret height and movement, not as a later tie-breaker.
`electric.py` links song accompaniment attacks to harmonized melody events using authored source positions, not displayed measure numbers or a song-wide chord dictionary. References stay inside the corresponding source harmony span and must match its structured chord. Within a span, an attack uses the latest available melody grip at or before its source position, or the first later grip if it precedes all melody chords. This allows different shapes for the same chord in different passages. After guitar placement and melody tie resolution, linked stabs copy the actual melody pitches and TAB positions while retaining their own IDs, timing, and written durations; melody ties and markings are not copied. A melody that remains a single note provides no grip. Passages without a matching playable three- or four-string melody chord, including altered endings without a corresponding reference, retain independently generated electric shapes. No reference metadata reaches runtime JSON. `electric.py` links song accompaniment attacks to harmonized melody events using authored source positions, not displayed measure numbers or a song-wide chord dictionary. References stay inside the corresponding source harmony span and must match its structured chord. Within a span, an attack uses the latest available melody grip at or before its source position, or the first later grip if it precedes all melody chords. This allows different shapes for the same chord in different passages. After guitar placement and melody tie resolution, linked stabs copy the actual melody pitches and TAB positions while retaining their own IDs, timing, and written durations; melody ties and markings are not copied. A melody that remains a single note provides no grip. Passages without a matching playable three- or four-string melody chord, including altered endings without a corresponding reference, retain independently generated electric shapes. No reference metadata reaches runtime JSON.
Physical effort is `movement + 2 * max(0, highest_fret - 12) + fret_span` for electric and exact melody placement, and `movement + 2 * max(0, highest_fret - 12) + 3 * fret_span + 2 * (distinct_frets - 1)` for acoustic. The shared `PREFERRED_MAX_FRET` is 12: height alone adds no cost within frets 0-12 inclusive, then adds two points per excess fret through the hard maximum of 20. This is a soft contribution to effort, not a range priority overriding musical correctness, grip simplicity, or movement. Movement is the Manhattan distance between doubled fret/string position centers. Open strings count in every fret measurement. Acoustic shape costs are charged once per harmony span, independent of rhythm density, and transitions compare complete grips rather than whichever lane last sounded. Pure melody tie continuations add no fresh shape or movement cost. There is no automatic octave fitting. Candidate pruning and the exact small-grid distance transform share this ranking; held notes keep their occupied strings and all compatible candidate states. Stable position ordering resolves equal costs. Both generated chord profiles use the same shape-and-height cost: `highest_fret + 2 * max(0, highest_fret - 12) + 3 * fret_span + 2 * max(0, distinct_frets - 1)`. Electric adds its existing movement metric, the Manhattan distance between doubled fret/string position centers, with the exact small-grid distance transform used for independent attacks. Acoustic adds twice its per-string movement: fret changes on retained strings plus one unit per added or released string. The doubled weight balances transitions against shape difficulty so a lower isolated grip does not justify a large jump. An unchanged grip costs zero movement. Exact candidate-to-candidate dynamic programming retains all acoustic grips, including different grips with equal centers. Exact, unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`.
Generated chord height contributes one point per fret throughout the allowed range. The shared `PREFERRED_MAX_FRET` remains 12, above which each excess fret adds two more points. Compact mid-neck grips can beat stretched lower ones, while lower positions still win when shape difficulty and movement are comparable. Autumn Leaves begins with Em7b5 at `5:7, 4:8, 3:7, 2:8` and A7b9 at `5:7, 4:8, 3:6, 2:8`; the nearby C7 at `5:10, 3:9, 2:11` remains preferred after Gm7 at `5:10, 3:10, 2:11`. These are regression examples, not a song-specific shape dictionary. Acoustic shape costs are charged once per harmony span, independent of rhythm density, and transitions compare complete grips rather than whichever lane last sounded. Pure melody tie continuations add no fresh shape or movement cost. There is no automatic octave fitting or finger-assignment model. Stable position ordering resolves equal costs.
There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure. There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure.
@@ -180,6 +184,8 @@ Generated guitar tones carry `tablature: {string, fret}`; their staff is marked
Every part uses two music systems per page; only an odd final system occupies a row alone. Pagination reserves two rows below the header, keeping a fixed gap and the footer clear. Both systems on a page share a uniform scale when their measured height exceeds a row; notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. The layout audit accounts for each system's scale when verifying pitch and TAB alignment. Existing `.pages/*.png` preview files are the exact packaged page textures. Every part uses two music systems per page; only an odd final system occupies a row alone. Pagination reserves two rows below the header, keeping a fixed gap and the footer clear. Both systems on a page share a uniform scale when their measured height exceeds a row; notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. The layout audit accounts for each system's scale when verifying pitch and TAB alignment. Existing `.pages/*.png` preview files are the exact packaged page textures.
Line breaks occur only at measure boundaries. Each line fits as many complete measures as readable spacing allows; it never fills remaining space with a partial measure. A complete measure exceeding the available width or height fails with source and measure context instead of being split or squeezed to fit. Authored ties, beams, tuplets, and lyric spans can continue across whole-measure line breaks. Column measurement and fit trials reuse a score index and bounded column cache. Page placement then re-spaces columns for the final height-fitting scale, keeping every notation/TAB staff at the same physical left and right margins without stretching glyphs. Non-final lines are justified; a short final line retains natural note spacing while staff lines extend to the same right margin. Layout audits enforce these margins as well as musical coverage.
`audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite. `audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite.
Bundles preserve the complete `music` object. Presentation identifies the `Vamp Python` engraver; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Scores, playback manifests, and the catalog have no numbered schema labels. Required fields, types, relationships, dimensions, and checksums determine whether generated data is usable. Incompatible data is rejected, never migrated or guessed. Bundles preserve the complete `music` object. Presentation identifies the `Vamp Python` engraver; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Scores, playback manifests, and the catalog have no numbered schema labels. Required fields, types, relationships, dimensions, and checksums determine whether generated data is usable. Incompatible data is rejected, never migrated or guessed.
@@ -0,0 +1,107 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<identification>
<encoding>
<supports element="accidental" type="yes"/>
<supports element="transpose" type="yes"/>
<software>Soundslice MusicXML exporter</software>
<encoding-date>2026-09-29</encoding-date>
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Vocal</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key>
<fifths>0</fifths>
<mode>major</mode>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<sound tempo="120"/>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>E</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>F</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
<measure number="2">
<note>
<pitch>
<step>G</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>A</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>B</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>C</step>
<octave>5</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>
@@ -0,0 +1,111 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<identification>
<encoding>
<supports element="accidental" type="yes"/>
<supports element="transpose" type="yes"/>
<software>Soundslice MusicXML exporter</software>
<encoding-date>2026-09-29</encoding-date>
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Vocal</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key>
<fifths>0</fifths>
<mode>major</mode>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<sound tempo="120"/>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>E</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>F</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
<measure number="2">
<note>
<pitch>
<step>G</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>A</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>B</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>C</step>
<octave>5</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>
@@ -0,0 +1,105 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<identification>
<encoding>
<supports element="accidental" type="yes"/>
<supports element="transpose" type="yes"/>
<software>Soundslice MusicXML exporter</software>
<encoding-date>2026-09-29</encoding-date>
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Vocal</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key>
<fifths>0</fifths>
<mode>major</mode>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<sound tempo="120"/>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>E</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>F</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
<measure number="2">
<note>
<pitch>
<step>G</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>G</step>
<alter>1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>sharp</accidental>
</note>
<note>
<pitch>
<step>B</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>C</step>
<octave>5</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>
@@ -0,0 +1,109 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<identification>
<encoding>
<supports element="accidental" type="yes"/>
<supports element="transpose" type="yes"/>
<software>Soundslice MusicXML exporter</software>
<encoding-date>2026-09-29</encoding-date>
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Vocal</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key>
<fifths>0</fifths>
<mode>major</mode>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<sound tempo="120"/>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>E</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>F</step>
<alter>1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>sharp</accidental>
</note>
</measure>
<measure number="2">
<note>
<pitch>
<step>G</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>A</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>B</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>C</step>
<octave>5</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>
@@ -0,0 +1,109 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<identification>
<encoding>
<supports element="accidental" type="yes"/>
<supports element="transpose" type="yes"/>
<software>Soundslice MusicXML exporter</software>
<encoding-date>2026-09-29</encoding-date>
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Vocal</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key>
<fifths>0</fifths>
<mode>major</mode>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<sound tempo="120"/>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>E</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>F</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
<measure number="2">
<note>
<pitch>
<step>G</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>A</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>B</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>C</step>
<octave>5</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>
@@ -0,0 +1,107 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<identification>
<encoding>
<supports element="accidental" type="yes"/>
<supports element="transpose" type="yes"/>
<software>Soundslice MusicXML exporter</software>
<encoding-date>2026-09-29</encoding-date>
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Vocal</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key>
<fifths>0</fifths>
<mode>major</mode>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<sound tempo="120"/>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<alter>1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>sharp</accidental>
</note>
<note>
<pitch>
<step>E</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>F</step>
<alter>1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>sharp</accidental>
</note>
</measure>
<measure number="2">
<note>
<pitch>
<step>G</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>A</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>B</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>C</step>
<octave>5</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>
@@ -0,0 +1,115 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<identification>
<encoding>
<supports element="accidental" type="yes"/>
<supports element="transpose" type="yes"/>
<software>Soundslice MusicXML exporter</software>
<encoding-date>2026-09-29</encoding-date>
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Vocal</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>4</divisions>
<key>
<fifths>0</fifths>
<mode>major</mode>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<sound tempo="120"/>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
<note>
<pitch>
<step>D</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>E</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>F</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
</measure>
<measure number="2">
<note>
<pitch>
<step>G</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>A</step>
<alter>-1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat</accidental>
</note>
<note>
<pitch>
<step>B</step>
<alter>-2</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
<accidental>flat-flat</accidental>
</note>
<note>
<pitch>
<step>C</step>
<octave>5</octave>
</pitch>
<duration>4</duration>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>
+7
View File
@@ -33,6 +33,13 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
for source in item.sources: for source in item.sources:
primitives.setdefault(source, []).append(item) primitives.setdefault(source, []).append(item)
for system in page.systems: for system in page.systems:
for role in ('staff', 'tab-staff'):
lines = [p for p in system.primitives if p.role == role]
if lines:
left = min(x for p in lines for x, _ in p.points)
right = max(x for p in lines for x, _ in p.points)
if abs(left - layout.style.margin_x - 1) > 0.01 or abs(right - (page.width - layout.style.margin_x - 1)) > 0.01:
raise generator.engraving.types.EngravingError('music systems must have consistent rendered staff widths')
if system.start != previous_end or system.end <= system.start: if system.start != previous_end or system.end <= system.start:
raise generator.engraving.types.EngravingError('layout systems do not partition the score timeline') raise generator.engraving.types.EngravingError('layout systems do not partition the score timeline')
previous_end = system.end previous_end = system.end
+35 -24
View File
@@ -78,7 +78,7 @@ def column_metrics(index, time, start, end, fonts, style, columns):
columns.popitem(last=False) columns.popitem(last=False)
return result return result
def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None) -> generator.engraving.types.System: def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None, staff_width: float | None = None) -> generator.engraving.types.System:
items, anchors, fragments = [], [], [] items, anchors, fragments = [], [], []
state = index.state(start) state = index.state(start)
measure = index.measure_at(start)[2] measure = index.measure_at(start)[2]
@@ -118,7 +118,7 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
cursor = x + max(style.space * 1.4, box.right) + spring cursor = x + max(style.space * 1.4, box.right) + spring
groups.append((item_start, len(items), anchor_start, len(anchors))) groups.append((item_start, len(items), anchor_start, len(anchors)))
width = cursor + 6 width = cursor + 6
available = style.width - style.margin_x * 2 available = staff_width + 1 if staff_width is not None else style.width - style.margin_x * 2
if justify and width < available: if justify and width < available:
stretch = (available - width) / max(1, len(times)) stretch = (available - width) / max(1, len(times))
for i, (first_item, last_item, first_anchor, last_anchor) in enumerate(groups): for i, (first_item, last_item, first_anchor, last_anchor) in enumerate(groups):
@@ -126,6 +126,10 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
anchors[first_anchor:last_anchor] = [a.moved(i * stretch, 0) for a in anchors[first_anchor:last_anchor]] 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)] placements = [(time, x + i * stretch) for i, (time, x) in enumerate(placements)]
width = available width = available
if staff_width is not None:
if width > available + 0.01:
raise generator.engraving.types.EngravingError(f'at musical offset {start}: content exceeds the final staff width')
width = available
right = width - 5 right = width - 5
last_measure = index.measure_at(end, before=True)[2] last_measure = index.measure_at(end, before=True)[2]
tab_items = [p for p in items if p.role.startswith('tab')] tab_items = [p for p in items if p.role.startswith('tab')]
@@ -155,8 +159,22 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style) items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items)) return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items))
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.Page, ...]: def fit_page_systems(systems, index, row_height, fonts, style):
scale = min(1, row_height / max(s.box.bottom - s.box.top for s in systems))
columns = collections.OrderedDict()
# Re-space columns at the final physical width; scale glyphs uniformly, not horizontally.
for _ in range(8):
width = (style.width - 2 * style.margin_x - 2) / scale
fitted = [make_system(index, s.start, s.end, fonts, style, justify=s.end < index.end, columns=columns, staff_width=width) for s in systems]
height = max(s.box.bottom - s.box.top for s in fitted)
if height * scale <= row_height + 0.001:
return [s.scaled(scale) for s in fitted]
scale = row_height / height
raise generator.engraving.types.EngravingError(f'at musical offset {systems[0].start}: page spacing did not converge')
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[generator.engraving.types.Page, ...]:
pages = [] pages = []
index = index or generator.engraving.notation.ScoreIndex(document)
guitar = document['staves'][0].get('tablature', False) guitar = document['staves'][0].get('tablature', False)
header_gap = 14 if guitar else 24 header_gap = 14 if guitar else 24
@@ -183,31 +201,29 @@ def paginate(systems: list[generator.engraving.types.System], document: dict, fo
row_height = (style.height - style.margin_y - 24 - top - style.system_gap) / 2 row_height = (style.height - style.margin_y - 24 - top - style.system_gap) / 2
if row_height <= 0: if row_height <= 0:
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems') raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
pair = systems[first:first + 2] pair = fit_page_systems(systems[first:first + 2], index, row_height, fonts, style)
scale = min(1, row_height / max(s.box.bottom - s.box.top for s in pair))
placed = [] placed = []
for row, system in enumerate(pair): for row, system in enumerate(pair):
system = system.scaled(scale)
dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - system.box.top dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - 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))) 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)))
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
return tuple(pages) return tuple(pages)
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> tuple[generator.engraving.types.System, ...]: def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
generator.model.validate_document(document) if index is None:
index = generator.engraving.notation.ScoreIndex(document) generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
if not index.measures: if not index.measures:
raise generator.engraving.types.EngravingError('score has no measures') raise generator.engraving.types.EngravingError('score has no measures')
systems = [] systems = []
start = fractions.Fraction() start = fractions.Fraction()
available = style.width - style.margin_x * 2 available = style.width - style.margin_x * 2 - 2
while start < index.end: while start < index.end:
columns = collections.OrderedDict() columns = collections.OrderedDict()
candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, start):] candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, start):]
selected = None selected = None
# Reserve the same title/credit and footer bands when considering a split, # Reserve title/credit and footer bands before fitting complete measures.
# so a wide pitch range can break vertically as well as horizontally.
height_limit = style.height - style.margin_y * 2 - 110 height_limit = style.height - style.margin_y * 2 - 110
for end in candidates: for end in candidates:
trial = make_system(index, start, end, fonts, style, columns=columns) trial = make_system(index, start, end, fonts, style, columns=columns)
@@ -215,22 +231,17 @@ def layout_systems(document: dict, style: generator.engraving.types.Style, fonts
break break
selected = trial selected = trial
if selected is None: if selected is None:
measure_end = candidates[0] measure = index.measure_at(start)[2]
for end in index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, measure_end)]: raise generator.engraving.types.EngravingError(f'measure {measure["number"]} ({measure["id"]}): complete measure exceeds the page width or height at readable spacing')
trial = make_system(index, start, end, fonts, style, columns=columns) systems.append(selected)
if trial.box.right - trial.box.left <= available and trial.box.bottom - trial.box.top <= height_limit:
selected = trial
else:
break
if selected is None:
raise generator.engraving.types.EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height')
systems.append(make_system(index, start, selected.end, fonts, style, justify=True, columns=columns) if selected.end < index.end else selected)
start = selected.end start = selected.end
return tuple(systems) return tuple(systems)
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument: def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
generator.model.validate_document(document)
style = generator.engraving.types.score_style(document, style) style = generator.engraving.types.score_style(document, style)
systems = layout_systems(document, style, fonts) index = generator.engraving.notation.ScoreIndex(document)
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style)) systems = layout_systems(document, style, fonts, index=index)
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style, index=index), style, fonts.fingerprint(style))
generator.engraving.audit.audit_layout(document, layout, fonts) generator.engraving.audit.audit_layout(document, layout, fonts)
return layout return layout
+1
View File
@@ -60,6 +60,7 @@ class ScoreIndex:
time = end time = end
self.end = time self.end = time
self.measure_starts = [item[0] for item in self.measures] self.measure_starts = [item[0] for item in self.measures]
self.measure_starts_by_id = {measure['id']: start for start, _, measure in self.measures}
self.events.sort(key=lambda e: (e.start, not bool(e.value.get('grace')), e.lane)) self.events.sort(key=lambda e: (e.start, not bool(e.value.get('grace')), e.lane))
self.event_starts = [] self.event_starts = []
self.event_max_ends = [] self.event_max_ends = []
+6 -2
View File
@@ -17,6 +17,7 @@ import generator.engraving.audit
import generator.engraving.cache import generator.engraving.cache
import generator.engraving.fonts import generator.engraving.fonts
import generator.engraving.layout import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster import generator.engraving.raster
import generator.engraving.types import generator.engraving.types
import generator.engraving.validation import generator.engraving.validation
@@ -111,6 +112,7 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
family = generator.engraving.fonts.Fonts(font_path) family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style() style = generator.engraving.types.Style()
systems = None systems = None
index = None
scores = {} scores = {}
audited = set() audited = set()
result = GroupResult() result = GroupResult()
@@ -132,10 +134,12 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
else: else:
if systems is None: if systems is None:
started = time.monotonic() started = time.monotonic()
systems = generator.engraving.layout.layout_systems(document, style, family) generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
result.timings['layout'] += time.monotonic() - started result.timings['layout'] += time.monotonic() - started
started = time.monotonic() started = time.monotonic()
pages_layout = generator.engraving.layout.paginate(systems, document, family, style) pages_layout = generator.engraving.layout.paginate(systems, document, family, style, index=index)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout) placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited: if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint) layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
+1 -1
View File
@@ -17,7 +17,7 @@ def beam_groups(index: generator.engraving.notation.ScoreIndex) -> tuple[tuple[s
for event in (e for e in index.events if e.voice == voice): for event in (e for e in index.events if e.voice == voice):
note = event.value note = event.value
primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None) primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None)
measure_start = next(start for start, _, measure in index.measures if measure['id'] == event.measure['id']) measure_start = index.measure_starts_by_id[event.measure['id']]
beats = generator.model.beat_lengths(index.state(event.start)['meter']) beats = generator.model.beat_lengths(index.state(event.start)['meter'])
offset = event.start - measure_start offset = event.start - measure_start
beat = 0 beat = 0
+17 -13
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import generator.engraving.notes
import generator.engraving.types import generator.engraving.types
import generator.model import generator.model
@@ -40,19 +39,24 @@ def staff(index, items, start, end, left, right, fonts, style):
for bar in (p for p in notation if p.role == 'barline'): for bar in (p for p in notation if p.role == 'barline'):
drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 * style.tab_space / (4 * style.space)) for x, y in bar.points), bar.thickness)) drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 * style.tab_space / (4 * style.space)) for x, y in bar.points), bar.thickness))
by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]} by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]}
for identifier, (event, tone) in index.tones.items():
target = tone.get('tie_to') def tie(identifier, tone, x1, x2):
finish = event.start + generator.model.fraction(tone['duration'])
if not target and not (event.start < start < finish or event.start < end < finish):
continue
if target:
finish = index.tones[target][0].start
if finish < start or event.start >= end:
continue
first, last = by_source.get(identifier), by_source.get(target)
x1 = first.box.right + 2 if first else left
x2 = last.box.left - 2 if last else right
y = offset - (6 - tone['tablature']['string']) * style.tab_space + 6 y = offset - (6 - tone['tablature']['string']) * style.tab_space + 6
if x2 > x1: if x2 > x1:
drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y)))) drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
for identifier, (event, tone) in index.tones.items():
first = by_source.get(identifier)
finish = event.start + generator.model.fraction(tone['duration'])
if first and event.start < start < finish:
tie(identifier, tone, left, first.box.left - 2)
if first and finish > end:
tie(identifier, tone, first.box.right + 2, right)
elif target := tone.get('tie_to'):
destination = index.tones[target][0].start
if event.start >= end or destination < start:
continue
last = by_source.get(target)
if first or last:
tie(identifier, tone, first.box.right + 2 if first else left, last.box.left - 2 if last else right)
return notation + drawing return notation + drawing
+57 -43
View File
@@ -13,7 +13,7 @@ import generator.model
import generator.pitch import generator.pitch
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 0 MIN_FRET = 1
MAX_FRET = 20 MAX_FRET = 20
PREFERRED_MAX_FRET = 12 PREFERRED_MAX_FRET = 12
MAX_FRET_SPAN = 4 MAX_FRET_SPAN = 4
@@ -30,7 +30,6 @@ class VoicingCost(typing.NamedTuple):
string_shortfall: int = 0 string_shortfall: int = 0
covered_melody: int = 0 covered_melody: int = 0
covering_tones: int = 0 covering_tones: int = 0
missing_root: int = 0
effort: int = 0 effort: int = 0
missing_tones: int = 0 missing_tones: int = 0
def plus(self, other: VoicingCost) -> VoicingCost: def plus(self, other: VoicingCost) -> VoicingCost:
@@ -40,17 +39,17 @@ class VoicingCost(typing.NamedTuple):
return self._replace(effort=self.effort + distance) return self._replace(effort=self.effort + distance)
@functools.lru_cache(maxsize=8192) @functools.lru_cache(maxsize=8192)
def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), root: int | None = None, melody: tuple[int, ...] = ()) -> VoicingCost: def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost:
frets = {p.fret for p in positions} frets = {p.fret for p in positions}
classes = {p.midi % 12 for p in positions} classes = {p.midi % 12 for p in positions}
above = sum(p.midi > max(melody) for p in positions) if melody else 0 above = sum(p.midi > max(melody) for p in positions) if melody else 0
span = compactness(positions) span = compactness(positions)
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET)
if profile == 'acoustic': if profile in ('acoustic', 'electric'):
effort += 2 * span + 2 * max(0, len(frets) - 1) effort += max(frets, default=0) + 3 * span + 2 * max(0, len(frets) - 1)
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, else:
int(bool(above)), above, int(profile == 'electric' and root is not None and root not in classes), effort += span
effort, len(set(allowed) - classes)) return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, len(set(allowed) - classes))
def within_fret_span(positions) -> bool: def within_fret_span(positions) -> bool:
frets = [position.fret for position in positions] frets = [position.fret for position in positions]
@@ -70,15 +69,27 @@ def validate_tuning(state: dict, location: str) -> None:
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected): if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
raise ValueError(f'{location}: guitar generation requires standard six-string tuning') raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int, ...], tuple[int, ...], int | None]: @dataclasses.dataclass(frozen=True)
class ChordRequirements:
required: tuple[int, ...] = ()
allowed: tuple[int, ...] = ()
foundation: tuple[int, ...] = ()
basses: tuple[int, ...] = ()
def covers(self, classes: set[int]) -> bool:
return set(self.required) <= classes and bool(set(self.foundation) & classes)
def chord_requirements(definition: dict, profile: str = '') -> ChordRequirements:
degrees = generator.harmony.degrees(definition) degrees = generator.harmony.degrees(definition)
root = definition['root'] root = definition['root']
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'} explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'}
optional = {d for d, a in degrees.items() if not a and d not in explicit and (d == 5 and len(degrees) > 3 or d in (9, 11) and d < max(degrees))} optional = {d for d, a in degrees.items() if not a and d not in explicit and d in (9, 11) and d < max(degrees)}
classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()} classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {value for d, value in classes.items() if d not in optional and not (profile == 'electric' and d == 1)} required = {value for d, value in classes.items() if d not in optional and (d not in (1, 5) or degrees[d])}
allowed = set(classes.values()) allowed = set(classes.values())
foundation = {value for d, value in classes.items() if d in (1, 5)}
if not foundation:
raise ValueError('generated guitar chords require a root or fifth in the harmony')
bass = definition.get('bass') bass = definition.get('bass')
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
if bass_pc is None and 'inversion' in definition: if bass_pc is None and 'inversion' in definition:
@@ -92,20 +103,19 @@ def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int,
if bass_pc is not None: if bass_pc is not None:
allowed.add(bass_pc) allowed.add(bass_pc)
required.add(bass_pc) required.add(bass_pc)
if profile == 'acoustic' and bass_pc is None: basses = {bass_pc} if bass_pc is not None else foundation if profile == 'acoustic' else set()
bass_pc = root_pc % 12 return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
@functools.lru_cache(maxsize=256) @functools.lru_cache(maxsize=256)
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), root: int | None = None) -> tuple[tuple[TabPosition, ...], ...]: def chord_positions(requirements: ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[TabPosition, ...], ...]:
result = [] result = []
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING] options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING]
def visit(index, selected, covered): def visit(index, selected, covered):
if len(set(required) - covered) > len(options) - index: if len(set(requirements.required) - covered) > len(options) - index:
return return
if index == len(options): if index == len(options):
if selected and set(required) <= covered and (bass is None or min(selected, key=lambda p: p.midi).midi % 12 == bass): if selected and requirements.covers(covered) and (not requirements.basses or min(selected, key=lambda p: p.midi).midi % 12 in requirements.basses):
result.append(tuple(selected)) result.append(tuple(selected))
return return
visit(index + 1, selected, covered) visit(index + 1, selected, covered)
@@ -117,21 +127,21 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i
by_string = {string: options[i] for i, (string, _) in enumerate(STANDARD_TUNING)} by_string = {string: options[i] for i, (string, _) in enumerate(STANDARD_TUNING)}
for strings in ACOUSTIC_STRINGS if profile == 'acoustic' else ELECTRIC_STRINGS: for strings in ACOUSTIC_STRINGS if profile == 'acoustic' else ELECTRIC_STRINGS:
for selected in itertools.product(*(by_string[s] for s in strings)): for selected in itertools.product(*(by_string[s] for s in strings)):
if not within_fret_span(selected) or not set(required) <= {p.midi % 12 for p in selected} or not set(melody) <= {p.midi for p in selected}: if not within_fret_span(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
continue continue
if bass is not None and (selected[0].midi % 12 != bass or selected[0].midi != min(p.midi for p in selected)): if requirements.basses and (selected[0].midi % 12 not in requirements.basses or selected[0].midi != min(p.midi for p in selected)):
continue continue
result.append(selected) result.append(selected)
else: else:
visit(0, (), set()) visit(0, (), set())
if not prune: if not prune or profile == 'acoustic':
return tuple(sorted(result)) return tuple(sorted(result))
# Independent attacks with equal centers have identical movement costs. # Independent attacks with equal centers have identical movement costs.
# Held strings require the full candidate set instead of this reduction. # Held strings require the full candidate set instead of this reduction.
best = {} best = {}
for positions in result: for positions in result:
center = position_center(positions) center = position_center(positions)
rank = (voicing_cost(positions, profile, allowed, root, melody), positions) rank = (voicing_cost(positions, profile, requirements.allowed, melody), positions)
if center not in best or rank < best[center][0]: if center not in best or rank < best[center][0]:
best[center] = (rank, positions) best[center] = (rank, positions)
return tuple(sorted(item[1] for item in best.values())) return tuple(sorted(item[1] for item in best.values()))
@@ -157,6 +167,14 @@ def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]
return 0 return 0
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current))) return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
@functools.lru_cache(maxsize=16384)
def acoustic_movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
if not previous or not current:
return 0
before = {p.string_number: p.fret for p in previous}
after = {p.string_number: p.fret for p in current}
return sum(abs(before[s] - after[s]) for s in before.keys() & after.keys()) + len(before.keys() ^ after.keys())
@functools.lru_cache(maxsize=4096) @functools.lru_cache(maxsize=4096)
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]: def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
frets = [p.fret for p in positions] or [0] frets = [p.fret for p in positions] or [0]
@@ -236,16 +254,11 @@ class EventPlacement:
added: tuple[TabPosition, ...] = () added: tuple[TabPosition, ...] = ()
def acoustic_candidates(span: generator.acoustic.HarmonySpan): def acoustic_candidates(span: generator.acoustic.HarmonySpan):
required, allowed, bass = chord_requirements(span.harmony, 'acoustic') requirements = chord_requirements(span.harmony, 'acoustic')
basses = {bass} for shape in chord_positions(requirements, profile='acoustic'):
degrees = generator.harmony.degrees(span.harmony)
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
basses.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
shapes = sorted({shape for value in sorted(basses) for shape in chord_positions(required, allowed, value, profile='acoustic')})
for shape in shapes:
active = tuple((str(p.string_number), p) for p in shape) active = tuple((str(p.string_number), p) for p in shape)
choices = tuple(EventPlacement(note, (), tuple(p for p in shape if (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))) for note in span.notes) choices = tuple(EventPlacement(note, (), tuple(p for p in shape if (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))) for note in span.notes)
yield AttackCandidate(active, choices, shape, voicing_cost(shape, 'acoustic', allowed)) yield AttackCandidate(active, choices, shape, voicing_cost(shape, 'acoustic', requirements.allowed))
def continued_sources(notes): def continued_sources(notes):
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p} result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
@@ -270,16 +283,10 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
pending.append((tone['id'], options)) pending.append((tone['id'], options))
if fallback and chord and chord.get('_harmonize'): if fallback and chord and chord.get('_harmonize'):
chords, chord, profile = [], None, '' chords, chord, profile = [], None, ''
requirements = chord_requirements(chord['_harmony'], profile) if chord else ((), (), None) requirements = chord_requirements(chord['_harmony'], profile) if chord else ChordRequirements()
root = None
if chord and 1 in generator.harmony.degrees(chord['_harmony']):
pitch = chord['_harmony']['root']
value = int((generator.model.NATURAL[pitch['letter']] + generator.model.alteration(pitch['alter'])) % 12)
if value in requirements[1]:
root = value
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else () melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
fresh = has_attack(notes) fresh = has_attack(notes)
candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, root=root) if chord else [()] candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) if chord else [()]
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))): for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
exact_positions = dict(exact) exact_positions = dict(exact)
for positions in candidates: for positions in candidates:
@@ -291,7 +298,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
compatible = True compatible = True
for note in notes: for note in notes:
added = tuple(p for p in positions if p not in exact_positions.values() and note in chords) added = tuple(p for p in positions if p not in exact_positions.values() and note in chords)
if any(p.midi % 12 not in requirements[1] for p in added): if any(p.midi % 12 not in requirements.allowed for p in added):
compatible = False compatible = False
break break
if '_continue_from' in note: if '_continue_from' in note:
@@ -303,7 +310,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added)) choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
if not compatible: if not compatible:
continue continue
cost = voicing_cost(positions, profile, requirements[1], root, melody) if fresh else VoicingCost() cost = voicing_cost(positions, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost) yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
else: else:
shape = tuple(p for _, p in exact) + positions shape = tuple(p for _, p in exact) + positions
@@ -311,7 +318,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
continue continue
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes) choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost() cost = voicing_cost(shape, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost) yield AttackCandidate(assignment, choices, shape, cost)
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]: def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
@@ -373,6 +380,13 @@ def advance_attacks(candidates, states: dict) -> dict:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices) result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
return result return result
def advance_acoustic(candidates, states: dict) -> dict:
result = {}
for candidate in candidates:
cost, _, previous = min((p.cost.moved(2 * acoustic_movement(tuple(position for _, position in p.active), candidate.shape)), i, p) for i, p in enumerate(states.values()))
result[candidate.active] = Placement(cost.plus(candidate.cost), candidate.active, previous, candidate.choices)
return result
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict: def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict:
continued = continued_sources(notes) continued = continued_sources(notes)
fresh = has_attack(notes) fresh = has_attack(notes)
@@ -405,7 +419,7 @@ def place_guitar(document: dict) -> None:
states = {(): Placement(VoicingCost(), (), None, ())} states = {(): Placement(VoicingCost(), (), None, ())}
if document['staves'][0].get('arrangement_part_type') == 'acoustic': if document['staves'][0].get('arrangement_part_type') == 'acoustic':
for span in generator.acoustic.harmony_spans(document): for span in generator.acoustic.harmony_spans(document):
states = advance_attacks(acoustic_candidates(span), states) states = advance_acoustic(acoustic_candidates(span), states)
if not states: if not states:
raise ValueError(f'{span.location}: no acoustic grip within frets {MIN_FRET}-{MAX_FRET} and four-fret span for {span.harmony.get("symbol", span.harmony["kind"])}') raise ValueError(f'{span.location}: no acoustic grip within frets {MIN_FRET}-{MAX_FRET} and four-fret span for {span.harmony.get("symbol", span.harmony["kind"])}')
else: else:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

+1 -1
View File
@@ -27,7 +27,7 @@ def acoustic_document(bars):
for measure, (bass, upper) in zip(staff['measures'], bars): for measure, (bass, upper) in zip(staff['measures'], bars):
measure['transpose']['octave_change'] = -1 measure['transpose']['octave_change'] = -1
measure['voices'] = [] measure['voices'] = []
for lane, attacks, positions in (('bass', bass, (('c', 5, 3),)), ('upper', upper, (('e', 4, 2), ('g', 3, 0)))): for lane, attacks, positions in (('bass', bass, (('d', 5, 5),)), ('upper', upper, (('f', 4, 3), ('a', 3, 2)))):
notes, cursor = [], fractions.Fraction() notes, cursor = [], fractions.Fraction()
for onset, duration in attacks: for onset, duration in attacks:
onset, duration = fractions.Fraction(onset), fractions.Fraction(duration) onset, duration = fractions.Fraction(onset), fractions.Fraction(duration)
+36 -17
View File
@@ -52,18 +52,15 @@ class AcousticVoicingTests(unittest.TestCase):
self.assertLessEqual(len(basses), 1) self.assertLessEqual(len(basses), 1)
self.assertLessEqual(len(uppers), 1) self.assertLessEqual(len(uppers), 1)
grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True)) grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True))
required, allowed, bass = generator.guitar.chord_requirements(span.harmony, 'acoustic') requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
bass_classes = {bass}
degrees = generator.harmony.degrees(span.harmony)
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
bass_classes.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
if basses and uppers: if basses and uppers:
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS) self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
self.assertTrue(set(required) <= {p.midi % 12 for p in grip} <= set(allowed)) self.assertTrue(requirements.covers({p.midi % 12 for p in grip}))
self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed))
if basses: if basses:
self.assertIn(min(p.midi for p in grip) % 12, bass_classes) self.assertIn(min(p.midi for p in grip) % 12, requirements.basses)
self.assertTrue(generator.guitar.within_fret_span(grip)) self.assertTrue(generator.guitar.within_fret_span(grip))
self.assertTrue(all(0 <= p.fret <= 20 for p in grip)) self.assertTrue(all(1 <= p.fret <= 20 for p in grip))
for note in span.notes: for note in span.notes:
self.assertFalse(any(k.startswith('_') for k in note)) self.assertFalse(any(k.startswith('_') for k in note))
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches'])) self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
@@ -78,16 +75,16 @@ class AcousticVoicingTests(unittest.TestCase):
generator.acoustic.combine(document) generator.acoustic.combine(document)
self.assertEqual(tests.test_acoustic.sounding(document), before) self.assertEqual(tests.test_acoustic.sounding(document), before)
generator.model.validate_document(document) generator.model.validate_document(document)
def test_simpler_fifth_bass_can_win_without_alternating(self): def test_fifth_bass_can_win_without_alternating(self):
document = planned_document([[(0, 'Csus')], []]) document = planned_document([[(0, 'Csus')], []])
spans = self.place(document) spans = self.place(document)
self.assertEqual(len(spans), 1) self.assertEqual(len(spans), 1)
bass = [p for n in spans[0].notes for p in shape(n) if p.string_number >= 5] bass = [p for n in spans[0].notes for p in shape(n) if p.string_number >= 5]
self.assertTrue(bass) self.assertTrue(bass)
self.assertEqual({p.midi % 12 for p in bass}, {7}) self.assertEqual({p.midi % 12 for p in bass}, {7})
root_shapes = generator.guitar.chord_positions(*generator.guitar.chord_requirements(spans[0].harmony, 'acoustic'), profile='acoustic') root_shapes = generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus/C'), 'acoustic'), profile='acoustic')
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True)) selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
self.assertLess(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes)) self.assertLessEqual(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
def test_bass_options_follow_defined_fifth_and_explicit_basses(self): def test_bass_options_follow_defined_fifth_and_explicit_basses(self):
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0} inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
for chord, expected in (('C7', {0, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('C7/E', {4}), (inversion, {0})): for chord, expected in (('C7', {0, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('C7/E', {4}), (inversion, {0})):
@@ -95,8 +92,8 @@ class AcousticVoicingTests(unittest.TestCase):
span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0] span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span)) candidates = list(generator.guitar.acoustic_candidates(span))
self.assertEqual({min(p.midi for p in c.shape) % 12 for c in candidates}, expected) self.assertEqual({min(p.midi for p in c.shape) % 12 for c in candidates}, expected)
required, allowed, _ = generator.guitar.chord_requirements(span.harmony, 'acoustic') requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
self.assertTrue(all(set(required) <= {p.midi % 12 for p in c.shape} <= set(allowed) for c in candidates)) self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self): def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self):
attacks = [([(0, 1), (2, 1)], [(0, 1), (3, 1)]), ([], []), ([(2, 1)], [(1, 1), (3, 1)])] attacks = [([(0, 1), (2, 1)], [(0, 1), (3, 1)]), ([], []), ([(2, 1)], [(1, 1), (3, 1)])]
document = planned_document([[(0, 'C7')], [], [(0, 'C7'), (2, 'C7')]], attacks) document = planned_document([[(0, 'C7')], [], [(0, 'C7'), (2, 'C7')]], attacks)
@@ -118,7 +115,9 @@ class AcousticVoicingTests(unittest.TestCase):
document = planned_document([changes], [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])]) document = planned_document([changes], [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])])
spans = self.place(document) spans = self.place(document)
self.assertEqual(len(spans), 4) self.assertEqual(len(spans), 4)
self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 6]) basses = [min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans]
self.assertIn(basses[0], (0, 7))
self.assertEqual(basses[1:], [4, 7, 6])
def test_non_accompaniment_passage_ends_the_span(self): def test_non_accompaniment_passage_ends_the_span(self):
document = planned_document([[(0, 'C7')], [], []]) document = planned_document([[(0, 'C7')], [], []])
measure = document['staves'][0]['measures'][1] measure = document['staves'][0]['measures'][1]
@@ -159,14 +158,34 @@ class AcousticVoicingTests(unittest.TestCase):
candidates = list(generator.guitar.acoustic_candidates(span)) candidates = list(generator.guitar.acoustic_candidates(span))
expected = {} expected = {}
for candidate in candidates: for candidate in candidates:
expected[candidate.active] = min((previous.cost.moved(generator.guitar.movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values())) expected[candidate.active] = min((previous.cost.moved(2 * generator.guitar.acoustic_movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values()))
actual = generator.guitar.advance_attacks(candidates, states) actual = generator.guitar.advance_acoustic(candidates, states)
predecessors = {id(p): i for i, p in enumerate(states.values())} predecessors = {id(p): i for i, p in enumerate(states.values())}
self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected) self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected)
states = actual states = actual
def test_impossible_grip_fails_contextually_without_partial_materialization(self): def test_impossible_grip_fails_contextually_without_partial_materialization(self):
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]]) document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
before = copy.deepcopy(document) before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*0-20'): with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*1-20'):
generator.guitar.place_guitar(document) generator.guitar.place_guitar(document)
self.assertEqual(document, before) self.assertEqual(document, before)
def test_g_minor_to_c_seventh_preserves_positions_with_fifth_bass(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
previous = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 10), (3, 10), (2, 11)))
active = tuple((str(p.string_number), p) for p in previous)
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
span = generator.acoustic.harmony_spans(planned_document([[(0, 'C7')]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
result = generator.guitar.advance_acoustic(candidates, states)
best = min(result.values(), key=lambda p: (p.cost, p.active))
chosen = tuple(p for _, p in best.active)
self.assertEqual([(p.string_number, p.fret) for p in chosen], [(5, 10), (3, 9), (2, 11)])
self.assertEqual(generator.guitar.acoustic_movement(previous, chosen), 1)
self.assertEqual(generator.guitar.acoustic_movement(chosen, previous), 1)
self.assertEqual(generator.guitar.acoustic_movement(previous, previous), 0)
self.assertEqual(generator.guitar.acoustic_movement((), chosen), 0)
def test_acoustic_candidates_are_not_pruned_by_grip_center(self):
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic')
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
self.assertEqual(candidates, generator.guitar.chord_positions(requirements, profile='acoustic', prune=False))
self.assertGreater(len(candidates), len({generator.guitar.position_center(p) for p in candidates}))
+93 -22
View File
@@ -2,6 +2,7 @@ import collections
import copy import copy
import dataclasses import dataclasses
import fractions import fractions
import math
import pathlib import pathlib
import unittest import unittest
import unittest.mock import unittest.mock
@@ -50,6 +51,12 @@ class EngravingTests(unittest.TestCase):
self.assertIn(len(layout.pages[-1].systems), (1, 2)) self.assertIn(len(layout.pages[-1].systems), (1, 2))
for page in layout.pages: for page in layout.pages:
self.assertEqual(len({system.scale for system in page.systems}), 1) self.assertEqual(len({system.scale for system in page.systems}), 1)
for system in page.systems:
for role in ('staff', 'tab-staff'):
lines = [p for p in system.primitives if p.role == role]
if lines:
self.assertAlmostEqual(min(x for p in lines for x, _ in p.points), layout.style.margin_x + 1)
self.assertAlmostEqual(max(x for p in lines for x, _ in p.points), page.width - layout.style.margin_x - 1)
for first, second in zip(page.systems, page.systems[1:]): for first, second in zip(page.systems, page.systems[1:]):
self.assertGreaterEqual(second.box.top - first.box.bottom, layout.style.system_gap - 0.001) self.assertGreaterEqual(second.box.top - first.box.bottom, layout.style.system_gap - 0.001)
def test_guitar_pages_always_pair_systems_including_tall_passages(self): def test_guitar_pages_always_pair_systems_including_tall_passages(self):
@@ -60,7 +67,7 @@ class EngravingTests(unittest.TestCase):
self.assert_paired_pages(layout) self.assert_paired_pages(layout)
self.assertEqual(layout.style.space, 10) self.assertEqual(layout.style.space, 10)
self.assertEqual(layout.style.tab_font_size, 12) self.assertEqual(layout.style.tab_font_size, 12)
wide = tests.test_musicxml.xml_note('E', 2, 'half', octave=3) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7) wide = tests.test_musicxml.xml_note('F', 2, 'half', octave=3) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7)
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([wide] * 32)) document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([wide] * 32))
for measure in document['staves'][0]['measures']: for measure in document['staves'][0]['measures']:
measure['transpose']['octave_change'] = -1 measure['transpose']['octave_change'] = -1
@@ -104,8 +111,8 @@ class EngravingTests(unittest.TestCase):
self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox()) self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox())
def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self): def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self):
tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>')
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64)) content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(dense, divisions=16)) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2))
documents = [generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')), documents = [generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')),
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tied)), tests.test_musicxml.parse(tests.test_musicxml.xml_score(tied)),
tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())] tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())]
@@ -238,20 +245,85 @@ class EngravingTests(unittest.TestCase):
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural']) self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural'])
conflict = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter=1, extra='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note(extra='<voice>b</voice>'))) conflict = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter=1, extra='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note(extra='<voice>b</voice>')))
self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2) self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2)
def test_dense_measure_splits_exactly_without_modifying_music(self): def test_line_breaks_only_use_complete_measures_even_when_part_would_fit(self):
content = ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4))
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 3))
index = generator.engraving.notation.ScoreIndex(source)
style = generator.engraving.types.Style()
trial = generator.engraving.layout.make_system(index, fractions.Fraction(), fractions.Fraction(5), self.fonts, style)
style = dataclasses.replace(style, width=math.ceil(trial.box.right - trial.box.left + 2 * style.margin_x + 3))
layout = self.layout(source, style)
self.assertEqual(layout.pages[0].systems[0].end, 4)
self.assertEqual(layout.pages[0].systems[1].start, 4)
self.assertTrue(all(s.start in index.boundaries and s.end in index.boundaries for p in layout.pages for s in p.systems))
self.assertFalse(self.primitives(layout, 'continuation'))
self.assertEqual(len(self.primitives(layout, 'barline')), 3)
self.assert_paired_pages(layout)
def test_final_short_line_extends_staff_without_stretching_notes(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') * 2))
style = generator.engraving.types.Style()
natural = generator.engraving.layout.layout_systems(source, style, self.fonts)[0]
layout = self.layout(source, style)
final = layout.pages[0].systems[0]
for before, after in zip(natural.anchors, final.anchors):
self.assertAlmostEqual(after.x, style.margin_x + 1 + before.x * final.scale)
self.assert_paired_pages(layout)
def test_beamed_tuplet_can_continue_across_an_ordinary_line_break(self):
modification = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
notes = [tests.test_musicxml.xml_note('G', 1, 'eighth', extra=modification + f'<beam number="1">{action}</beam>') for action in ('begin', *(['continue'] * 22), 'end')]
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([''.join(notes[:12]), ''.join(notes[12:])], divisions=3))
events = tests.test_musicxml.notes(source) + tests.test_musicxml.notes(source, 1)
source['staves'][0]['tuplets'] = [{'id': 'across-line', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes'}]
for event in events:
event['tuplet_ids'] = ['across-line']
layout = self.layout(source, generator.engraving.types.Style(width=800))
systems = [s for p in layout.pages for s in p.systems]
self.assertGreater(len(systems), 1)
for system in systems:
self.assertTrue(any(p.role == 'beam' for p in system.primitives))
self.assertTrue(any(p.role == 'tuplet-number' and 'across-line' in p.sources for p in system.primitives))
self.assertEqual(len(self.primitives(layout, 'barline')), 2)
def test_accidental_memory_and_grace_groups_follow_the_measure_not_the_line(self):
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
content = ''.join(grace + tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=1) for _ in range(4))
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 2))
layout = self.layout(source, generator.engraving.types.Style(width=700))
systems = [s for p in layout.pages for s in p.systems]
self.assertGreater(len(systems), 1)
self.assertEqual([p.value for p in self.primitives(layout, 'accidental')], ['accidentalSharp', 'accidentalSharp'])
events = tests.test_musicxml.notes(source) + tests.test_musicxml.notes(source, 1)
for grace, principal in zip(events[::2], events[1::2]):
for system in systems:
identifiers = {a.event for a in system.anchors}
self.assertEqual(grace['id'] in identifiers, principal['id'] in identifiers)
def test_tab_ties_enter_and_leave_whole_measure_systems(self):
bars = []
for ties in ('<tie type="start"/>', '<tie type="stop"/><tie type="start"/>', '<tie type="stop"/>'):
content = tests.test_musicxml.xml_note('E', octave=5, extra='<voice>held</voice>' + ties) + '<backup><duration>4</duration></backup>'
content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', octave=5, extra='<voice>moving</voice>') for _ in range(4))
bars.append(content)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars(bars))
for measure in source['staves'][0]['measures']:
measure['transpose']['octave_change'] = -1
generator.guitar.place_guitar(source)
style = generator.engraving.types.score_style(source, generator.engraving.types.Style())
index = generator.engraving.notation.ScoreIndex(source)
for i, (start, end) in enumerate(((0, 4), (4, 8), (8, 12))):
tone = source['staves'][0]['measures'][i]['voices'][0]['notes'][0]['pitches'][0]
held = tone['id']
system = generator.engraving.layout.make_system(index, fractions.Fraction(start), fractions.Fraction(end), self.fonts, style)
number = next(p for p in system.primitives if p.role == 'tab' and held in p.sources)
ties = [p for p in system.primitives if p.role == 'tab-tie' and {held, tone.get('tie_from')} & set(p.sources)]
self.assertEqual(sum(p.points[-1][0] < number.box.left for p in ties), int(start > 0))
self.assertEqual(sum(p.points[0][0] > number.box.right for p in ties), int(end < 12))
def test_dense_measure_fails_contextually_without_modifying_music(self):
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra='<voice>fast</voice>') for _ in range(64)) content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra='<voice>fast</voice>') for _ in range(64))
content += '<backup><duration>64</duration></backup>' + tests.test_musicxml.xml_note('C', 64, 'whole', extra='<voice>held</voice>') content += '<backup><duration>64</duration></backup>' + tests.test_musicxml.xml_note('C', 64, 'whole', extra='<voice>held</voice>')
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16))
layout = self.layout(source) before = copy.deepcopy(source)
systems = [s for p in layout.pages for s in p.systems] with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'measure 1 .*complete measure exceeds.*readable spacing'):
self.assertGreater(len(systems), 1) self.layout(source)
held = source['staves'][0]['measures'][0]['voices'][1]['notes'][0]['pitches'][0]['id'] self.assertEqual(source, before)
pieces = [f for s in systems for f in s.fragments if f.source == held]
self.assertEqual(sum(f.length() for f in pieces), 4)
self.assertGreater(len(pieces), 1)
self.assertTrue(self.primitives(layout, 'tie'))
for page in layout.pages:
self.assertEqual(generator.engraving.raster.rasterize_page(page, self.fonts).size, (976, 484))
def test_hidden_pitch_does_not_suppress_a_visible_accidental(self): def test_hidden_pitch_does_not_suppress_a_visible_accidental(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', alter=1) + tests.test_musicxml.xml_note(duration=2, kind='half', alter=1))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', alter=1) + tests.test_musicxml.xml_note(duration=2, kind='half', alter=1)))
tests.test_musicxml.notes(source)[0]['hidden'] = True tests.test_musicxml.notes(source)[0]['hidden'] = True
@@ -263,11 +335,10 @@ class EngravingTests(unittest.TestCase):
index = generator.engraving.notation.ScoreIndex(source) index = generator.engraving.notation.ScoreIndex(source)
self.assertEqual(index.pitch_position(tests.test_musicxml.notes(source)[0]['pitches'][0], fractions.Fraction()), original - 7) self.assertEqual(index.pitch_position(tests.test_musicxml.notes(source)[0]['pitches'][0], fractions.Fraction()), original - 7)
self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift'))) self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift')))
def test_wide_pitch_range_can_break_inside_a_measure_vertically(self): def test_excessive_pitch_range_fails_instead_of_splitting_the_measure(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', octave=10) + tests.test_musicxml.xml_note(duration=2, kind='half', octave=-1))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', octave=10) + tests.test_musicxml.xml_note(duration=2, kind='half', octave=-1)))
layout = self.layout(source) with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'measure 1 .*complete measure exceeds.*height'):
self.assertEqual([len(page.systems) for page in layout.pages], [2]) self.layout(source)
self.assertEqual(len(self.primitives(layout, 'head')), 2)
def test_cue_grace_and_tremolo_are_drawn(self): def test_cue_grace_and_tremolo_are_drawn(self):
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>' grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note(duration=4, kind='whole', prefix='<cue/>'))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note(duration=4, kind='whole', prefix='<cue/>')))
@@ -283,8 +354,8 @@ class EngravingTests(unittest.TestCase):
tab = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6))), 'electric', rhythms=tests.rhythm_fixtures.library()) tab = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6))), 'electric', rhythms=tests.rhythm_fixtures.library())
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '20') self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '20')
def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self): def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self):
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64)) content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)), 'electric', rhythms=tests.rhythm_fixtures.library()) source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2)), 'electric', rhythms=tests.rhythm_fixtures.library())
layout = self.layout(source) layout = self.layout(source)
systems = [s for page in layout.pages for s in page.systems] systems = [s for page in layout.pages for s in page.systems]
self.assertGreater(len(systems), 1) self.assertGreater(len(systems), 1)
@@ -364,8 +435,8 @@ class EngravingTests(unittest.TestCase):
self.assertEqual([p.value for p in self.primitives(layout, 'clef')], ['gClef', 'cClef']) self.assertEqual([p.value for p in self.primitives(layout, 'clef')], ['gClef', 'cClef'])
def test_lyric_extender_continues_on_systems_without_syllables(self): def test_lyric_extender_continues_on_systems_without_syllables(self):
lyrics = '<lyric number="1"><text>Ah</text><extend type="start"/></lyric>' lyrics = '<lyric number="1"><text>Ah</text><extend type="start"/></lyric>'
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra=lyrics if i == 0 else '') for i in range(64)) bars = [''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra=lyrics if bar == i == 0 else '') for i in range(4)) for bar in range(8)]
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars(bars))
layout = self.layout(source) layout = self.layout(source)
systems = [s for p in layout.pages for s in p.systems] systems = [s for p in layout.pages for s in p.systems]
self.assertGreater(len(systems), 1) self.assertGreater(len(systems), 1)
+77 -23
View File
@@ -21,46 +21,100 @@ import tests.test_musicxml
class GuitarTests(unittest.TestCase): class GuitarTests(unittest.TestCase):
def test_fret_boundaries_and_span(self): def test_fret_boundaries_and_span(self):
self.assertIn(generator.guitar.TabPosition(6, 0, 40), generator.guitar.positions_for_pitch(fractions.Fraction(40))) self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(40)))
self.assertIn(generator.guitar.TabPosition(6, 1, 41), generator.guitar.positions_for_pitch(fractions.Fraction(41)))
self.assertIn(generator.guitar.TabPosition(1, 20, 84), generator.guitar.positions_for_pitch(fractions.Fraction(84))) self.assertIn(generator.guitar.TabPosition(1, 20, 84), generator.guitar.positions_for_pitch(fractions.Fraction(84)))
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(85))) self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(85)))
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(129, 2))) self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(129, 2)))
self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 11, 61), generator.guitar.TabPosition(3, 12, 67), generator.guitar.TabPosition(2, 14, 73)))) self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 11, 61), generator.guitar.TabPosition(3, 12, 67), generator.guitar.TabPosition(2, 14, 73))))
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84)))) self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84))))
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 10, 60), generator.guitar.TabPosition(1, 20, 84)))) self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 10, 60), generator.guitar.TabPosition(1, 20, 84))))
def test_open_strings_count_in_span_and_proximity(self): def test_fretted_positions_keep_span_and_proximity_scoring(self):
for frets, valid in (((0, 2, 3), True), ((0, 4), True), ((0, 5), False), ((12, 0, 11), False), ((0, 0), True)): for frets, valid in (((1, 2, 3), True), ((1, 5), True), ((1, 6), False), ((12, 1, 11), False), ((3, 3), True)):
with self.subTest(frets=frets): with self.subTest(frets=frets):
positions = tuple(generator.guitar.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets)) positions = tuple(generator.guitar.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
self.assertEqual(generator.guitar.within_fret_span(positions), valid) self.assertEqual(generator.guitar.within_fret_span(positions), valid)
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets)) self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets)) self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
self.assertTrue(generator.guitar.within_fret_span(())) self.assertTrue(generator.guitar.within_fret_span(()))
def test_held_open_string_cannot_mix_with_high_frets(self): def test_low_open_e_fails_without_octave_fitting_or_mutation(self):
held = tests.test_musicxml.xml_note('E', octave=2) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=2)))
high = tests.test_musicxml.xml_note('C', 2, 'half', octave=6, extra='<voice>2</voice>') before = copy.deepcopy(document)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(held + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + high)) with self.assertRaisesRegex(ValueError, 'measure-1, event .*pitch 40.*1-20'):
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
generator.guitar.place_guitar(document) generator.guitar.place_guitar(document)
self.assertEqual(document, before)
def test_open_string_pitch_uses_a_fretted_alternative(self):
for _, midi in generator.guitar.STANDARD_TUNING[1:]:
positions = generator.guitar.positions_for_pitch(fractions.Fraction(midi))
self.assertTrue(positions)
self.assertTrue(all(p.fret >= 1 and p.midi == midi for p in positions))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
generator.guitar.place_guitar(document)
tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
self.assertGreater(tone['tablature']['fret'], 0)
self.assertEqual(generator.model.pitch_number(tone), 64)
def test_explicit_open_tab_is_rejected_without_substitution(self):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
tests.test_musicxml.notes(document)[0]['pitches'][0]['tablature'] = {'string': 1, 'fret': 0}
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, 'event .*1-20'):
generator.guitar.place_guitar(document)
self.assertEqual(document, before)
document['staves'][0]['tablature'] = True
with self.assertRaisesRegex(ValueError, 'invalid generated tablature'):
generator.model.validate_document(document)
def test_theory_controls_omissions_and_bass(self): def test_theory_controls_omissions_and_bass(self):
for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'): for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'):
with self.subTest(symbol=symbol): with self.subTest(symbol=symbol):
required, allowed, bass = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol)) requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol))
candidates = generator.guitar.chord_positions(required, allowed, bass) candidates = generator.guitar.chord_positions(requirements)
self.assertTrue(candidates) self.assertTrue(candidates)
for candidate in candidates: for candidate in candidates:
covered = {p.midi % 12 for p in candidate} covered = {p.midi % 12 for p in candidate}
self.assertTrue(set(required) <= covered <= set(allowed)) self.assertTrue(requirements.covers(covered))
self.assertTrue(covered <= set(requirements.allowed))
self.assertEqual(len(candidate), len({p.string_number for p in candidate})) self.assertEqual(len(candidate), len({p.string_number for p in candidate}))
self.assertTrue(generator.guitar.within_fret_span(candidate)) self.assertTrue(generator.guitar.within_fret_span(candidate))
if bass is not None: self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
self.assertEqual(min(p.midi for p in candidate) % 12, bass) if requirements.basses:
self.assertIn(2, generator.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9'))[0]) self.assertIn(min(p.midi for p in candidate) % 12, requirements.basses)
self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'))[0]) self.assertIn(2, generator.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9')).required)
self.assertIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))[0]) self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7')).required)
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0})[2], 0) self.assertEqual(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM')).foundation, (0, 7))
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1})[2], 4) self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0}).basses, (0,))
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(*generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c)) self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1}).basses, (4,))
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c))
def test_root_or_fifth_is_required_for_every_generated_profile(self):
for profile in ('', 'acoustic', 'electric'):
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), profile)
for classes in ({0, 4, 10}, {4, 7, 10}, {0, 4, 7, 10}):
self.assertTrue(requirements.covers(classes))
self.assertFalse(requirements.covers({4, 10}))
candidates = generator.guitar.chord_positions(requirements, prune=False, profile=profile)
self.assertTrue(candidates)
self.assertTrue(all({p.midi % 12 for p in shape} & {0, 7} for shape in candidates))
self.assertEqual({frozenset({p.midi % 12 for p in shape} & {0, 7}) for shape in candidates}, {frozenset({0}), frozenset({7}), frozenset({0, 7})})
def test_altered_and_removed_fifths_keep_their_semantics(self):
for symbol, fifth in (('Cdim7', 6), ('Caug7', 8)):
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'electric')
self.assertEqual(requirements.foundation, (0, fifth))
self.assertIn(fifth, requirements.required)
self.assertNotIn(7, requirements.allowed)
for omitted, remaining in ((1, 7), (5, 0)):
chord = generator.harmony.parse_symbol('C7')
chord['degrees'].append({'value': omitted, 'alter': 0, 'type': 'subtract'})
for profile in ('acoustic', 'electric'):
requirements = generator.guitar.chord_requirements(chord, profile)
self.assertEqual(requirements.foundation, (remaining,))
self.assertTrue(all(remaining in {p.midi % 12 for p in c} for c in generator.guitar.chord_positions(requirements, profile=profile)))
chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'})
with self.assertRaisesRegex(ValueError, 'root or fifth'):
generator.guitar.chord_requirements(chord)
def test_explicit_degree_chord_uses_the_same_root_or_fifth_rule(self):
standard = generator.harmony.parse_symbol('C7')
explicit = {**standard, 'kind': 'other', 'degrees': [{'value': d, 'alter': a, 'type': 'add'} for d, a in generator.harmony.degrees(standard).items()]}
for profile in ('', 'acoustic', 'electric'):
self.assertEqual(generator.guitar.chord_requirements(standard, profile), generator.guitar.chord_requirements(explicit, profile))
def test_melody_placement_preserves_notes_timing_and_sound(self): def test_melody_placement_preserves_notes_timing_and_sound(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4)))
before = copy.deepcopy(source) before = copy.deepcopy(source)
@@ -131,7 +185,7 @@ class GuitarTests(unittest.TestCase):
states = {} states = {}
for index in range(40): for index in range(40):
positions = tuple(generator.guitar.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3)) positions = tuple(generator.guitar.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in range(6))), tuple((str(i), p) for i, p in enumerate(positions)), None, ()) states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in generator.guitar.VoicingCost._fields)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
grid = generator.guitar.nearest_positions(states) grid = generator.guitar.nearest_positions(states)
for x, row in enumerate(grid): for x, row in enumerate(grid):
for y, actual in enumerate(row): for y, actual in enumerate(row):
@@ -161,7 +215,7 @@ class GuitarTests(unittest.TestCase):
generator.guitar.place_guitar(document) generator.guitar.place_guitar(document)
self.assertEqual(before, document) self.assertEqual(before, document)
def test_source_contract_and_unplayable_music_fail(self): def test_source_contract_and_unplayable_music_fail(self):
cases = [(tests.test_musicxml.xml_note(octave=7, alter=1), '', '0-20'), (tests.test_musicxml.xml_note(alter='0.5'), '', '0-20'), cases = [(tests.test_musicxml.xml_note(octave=7, alter=1), '', '1-20'), (tests.test_musicxml.xml_note(alter='0.5'), '', '1-20'),
(tests.test_musicxml.xml_note(), '<staff-details><capo>1</capo></staff-details>', 'capo')] (tests.test_musicxml.xml_note(), '<staff-details><capo>1</capo></staff-details>', 'capo')]
for content, attributes, error in cases: for content, attributes, error in cases:
with self.subTest(error=error), self.assertRaisesRegex(ValueError, error): with self.subTest(error=error), self.assertRaisesRegex(ValueError, error):
@@ -187,13 +241,13 @@ class GuitarTests(unittest.TestCase):
output.mkdir() output.mkdir()
(output / 'last-good').write_text('preserved') (output / 'last-good').write_text('preserved')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part electric:.*0-20'): with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part electric:.*1-20'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good']) self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved') self.assertEqual((output / 'last-good').read_text(), 'preserved')
harmony = '<harmony><root><root-step>C</root-step></root><kind>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>' harmony = '<harmony><root><root-step>C</root-step></root><kind>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>'
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note())) (root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*0-20'): with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*1-20'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good']) self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved') self.assertEqual((output / 'last-good').read_text(), 'preserved')
+9 -6
View File
@@ -27,17 +27,19 @@ class GuitarPartTests(unittest.TestCase):
for profile in ('acoustic', 'electric'): for profile in ('acoustic', 'electric'):
with self.subTest(symbol=symbol, profile=profile): with self.subTest(symbol=symbol, profile=profile):
definition = generator.harmony.parse_symbol(symbol) definition = generator.harmony.parse_symbol(symbol)
required, allowed, bass = generator.guitar.chord_requirements(definition, profile) requirements = generator.guitar.chord_requirements(definition, profile)
candidates = generator.guitar.chord_positions(required, allowed, bass, profile=profile) candidates = generator.guitar.chord_positions(requirements, profile=profile)
self.assertTrue(candidates) self.assertTrue(candidates)
for candidate in candidates: for candidate in candidates:
strings = tuple(p.string_number for p in candidate) strings = tuple(p.string_number for p in candidate)
self.assertIn(strings, generator.guitar.ACOUSTIC_STRINGS if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS) self.assertIn(strings, generator.guitar.ACOUSTIC_STRINGS if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS)
self.assertTrue(set(required) <= {p.midi % 12 for p in candidate} <= set(allowed)) self.assertTrue(requirements.covers({p.midi % 12 for p in candidate}))
self.assertTrue({p.midi % 12 for p in candidate} <= set(requirements.allowed))
self.assertTrue(generator.guitar.within_fret_span(candidate)) self.assertTrue(generator.guitar.within_fret_span(candidate))
self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
chord = generator.harmony.parse_symbol('C7') chord = generator.harmony.parse_symbol('C7')
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0]) self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric').required)
candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric') candidates = generator.guitar.chord_positions(generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates)) self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self): def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic') result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
@@ -47,7 +49,8 @@ class GuitarPartTests(unittest.TestCase):
bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p] bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p]
upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p} upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p}
self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper) self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper)
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [7, 7]) self.assertEqual(len(bass), 2)
self.assertIn([generator.model.pitch_number(p) % 12 for _, p in bass], ([0, 0], [7, 7]))
self.assertEqual(bass[0][1]['tablature'], bass[1][1]['tablature']) self.assertEqual(bass[0][1]['tablature'], bass[1][1]['tablature'])
upper_shapes = {tuple((p['tablature']['string'], p['tablature']['fret']) for p in n['pitches'] if p['tablature']['string'] <= 4) for n in notes} upper_shapes = {tuple((p['tablature']['string'], p['tablature']['fret']) for p in n['pitches'] if p['tablature']['string'] <= 4) for n in notes}
self.assertEqual(len(upper_shapes - {()}), 1) self.assertEqual(len(upper_shapes - {()}), 1)
+1 -1
View File
@@ -38,7 +38,7 @@ class MusicModelTests(unittest.TestCase):
self.assertEqual(set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(label))), expected, label) self.assertEqual(set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(label))), expected, label)
for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]: for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]:
definition = generator.harmony.parse_symbol(label) definition = generator.harmony.parse_symbol(label)
pitches = generator.guitar.chord_pitches(definition, generator.guitar.chord_positions(*generator.guitar.chord_requirements(definition))[0]) pitches = generator.guitar.chord_pitches(definition, generator.guitar.chord_positions(generator.guitar.chord_requirements(definition))[0])
self.assertTrue(essentials.issubset({generator.model.pitch_number(p) % 12 for p in pitches}), label) self.assertTrue(essentials.issubset({generator.model.pitch_number(p) % 12 for p in pitches}), label)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
generator.harmony.parse_symbol('Cunrecognized') generator.harmony.parse_symbol('Cunrecognized')
+5
View File
@@ -64,6 +64,11 @@ class ScoreBundleTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'write the music out'): with self.assertRaisesRegex(ValueError, 'write the music out'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}) self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
source.write_text(tests.test_musicxml.xml_score(dense, divisions=16))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, r'songs/fixture-electric.json: measure 1 .*complete measure exceeds'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>'))) source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'): with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
+73 -38
View File
@@ -7,6 +7,7 @@ import generator.guitar
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.musicxml import generator.musicxml
import generator.rhythm
import tests.rhythm_fixtures import tests.rhythm_fixtures
import tests.test_musicxml import tests.test_musicxml
import tests.test_parts import tests.test_parts
@@ -17,10 +18,9 @@ def grip(frets, strings=(4, 3, 2, 1)):
def ranked(symbol, melody=(), profile='electric', prune=False): def ranked(symbol, melody=(), profile='electric', prune=False):
definition = generator.harmony.parse_symbol(symbol) definition = generator.harmony.parse_symbol(symbol)
required, allowed, bass = generator.guitar.chord_requirements(definition, profile) requirements = generator.guitar.chord_requirements(definition, profile)
root = int((generator.model.NATURAL[definition['root']['letter']] + generator.model.alteration(definition['root']['alter'])) % 12) candidates = generator.guitar.chord_positions(requirements, prune=prune, profile=profile, melody=melody)
candidates = generator.guitar.chord_positions(required, allowed, bass, prune=prune, profile=profile, melody=melody, root=root) return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape))
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, allowed, root, melody), shape))
class VoicingTests(unittest.TestCase): class VoicingTests(unittest.TestCase):
def test_requested_shapes_win_without_a_shape_dictionary(self): def test_requested_shapes_win_without_a_shape_dictionary(self):
@@ -28,34 +28,37 @@ class VoicingTests(unittest.TestCase):
with self.subTest(symbol=symbol): with self.subTest(symbol=symbol):
self.assertEqual(ranked(symbol, (melody,))[0], grip(expected)) self.assertEqual(ranked(symbol, (melody,))[0], grip(expected))
def test_four_strings_precede_melody_height_and_root_presence(self): def test_four_strings_precede_melody_height_and_root_presence(self):
candidates = ranked('CM7', (64,)) candidates = ranked('C#M7', (65,))
four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 64) four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 65)
three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 64) three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 65)
self.assertLess(candidates.index(four), candidates.index(three)) self.assertLess(candidates.index(four), candidates.index(three))
candidates = ranked('FM7', (69,)) candidates = ranked('FM7', (69,))
rootless = next(c for c in candidates if len(c) == 4 and 5 not in {p.midi % 12 for p in c}) rootless = next(c for c in candidates if len(c) == 4 and 5 not in {p.midi % 12 for p in c})
rooted = next(c for c in candidates if len(c) == 3 and 5 in {p.midi % 12 for p in c}) rooted = next(c for c in candidates if len(c) == 3 and 5 in {p.midi % 12 for p in c})
self.assertLess(candidates.index(rootless), candidates.index(rooted)) self.assertLess(candidates.index(rootless), candidates.index(rooted))
def test_melody_on_top_precedes_root_among_four_string_shapes(self): def test_melody_on_top_precedes_root_among_four_string_shapes(self):
candidates = ranked('C7b9', (64,)) candidates = ranked('C#M7', (65,))
best = candidates[0] best = candidates[0]
self.assertEqual(len(best), 4) self.assertEqual(len(best), 4)
self.assertEqual(max(p.midi for p in best), 64) self.assertEqual(max(p.midi for p in best), 65)
self.assertNotIn(0, {p.midi % 12 for p in best}) self.assertNotIn(1, {p.midi % 12 for p in best})
self.assertTrue(any(0 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 64 for c in candidates)) self.assertTrue(any(1 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 65 for c in candidates))
def test_root_precedes_physical_cost_without_covering_the_melody(self): def test_compact_chord_can_beat_lower_frets_without_covering_melody(self):
self.assertEqual(ranked('CM7', (67,))[0], grip((2, 4, 1, 3))) candidates = ranked('CM7', (67,))
rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), 0, (67,)) self.assertEqual(candidates[0], grip((5, 4, 5, 3)))
rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 3)), 'electric', (0, 4, 7, 11), 0, (67,)) self.assertIn(grip((2, 4, 1, 3)), candidates)
self.assertGreater(rooted.effort, rootless.effort) rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), (67,))
self.assertLess(rooted, rootless) rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 3)), 'electric', (0, 4, 7, 11), (67,))
self.assertEqual((rooted.effort, rootless.effort), (19, 15))
self.assertLess(rootless, rooted)
def test_essential_extensions_are_never_relaxed_to_add_the_root(self): def test_essential_extensions_are_never_relaxed_to_add_the_root(self):
for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'): for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'):
definition = generator.harmony.parse_symbol(symbol) definition = generator.harmony.parse_symbol(symbol)
required, allowed, _ = generator.guitar.chord_requirements(definition, 'electric') requirements = generator.guitar.chord_requirements(definition, 'electric')
for candidate in ranked(symbol): for candidate in ranked(symbol):
classes = {p.midi % 12 for p in candidate} classes = {p.midi % 12 for p in candidate}
self.assertTrue(set(required) <= classes <= set(allowed)) self.assertTrue(requirements.covers(classes))
self.assertTrue(classes <= set(requirements.allowed))
def test_explicitly_removed_root_is_not_restored_or_penalized(self): def test_explicitly_removed_root_is_not_restored_or_penalized(self):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony(kind='major-seventh') + tests.test_musicxml.xml_note('E', octave=5))) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony(kind='major-seventh') + tests.test_musicxml.xml_note('E', octave=5)))
measure = document['staves'][0]['measures'][0] measure = document['staves'][0]['measures'][0]
@@ -67,34 +70,66 @@ class VoicingTests(unittest.TestCase):
_, tones = generator.guitar.guitar_events(document) _, tones = generator.guitar.guitar_events(document)
candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False)) candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False))
self.assertTrue(candidates) self.assertTrue(candidates)
self.assertTrue(all(c.cost.missing_root == 0 and all(p.midi % 12 != 0 for p in c.shape) for c in candidates)) self.assertTrue(all(all(p.midi % 12 != 0 for p in c.shape) and any(p.midi % 12 == 7 for p in c.shape) for c in candidates))
def test_frets_zero_through_twelve_have_no_height_cost(self): def test_chord_height_cost_increases_throughout_the_fretboard(self):
for profile in ('', 'electric', 'acoustic'): for profile in ('electric', 'acoustic'):
for fret in range(13): for fret in range(1, 21):
with self.subTest(profile=profile, fret=fret): with self.subTest(profile=profile, fret=fret):
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, 0) self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, fret + 2 * max(0, fret - 12))
self.assertEqual(generator.guitar.voicing_cost((), profile).effort, 0) self.assertEqual(generator.guitar.voicing_cost((), profile).effort, 0)
def test_height_cost_only_counts_frets_above_twelve(self): def test_exact_melody_height_scoring_remains_unchanged(self):
for profile in ('', 'electric', 'acoustic'): for fret in range(1, 21):
for fret in range(13, 21): self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
with self.subTest(profile=profile, fret=fret):
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, 2 * (fret - 12))
crossing = grip((11, 12, 13, 14)) crossing = grip((11, 12, 13, 14))
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7) self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 19) for profile in ('electric', 'acoustic'):
def test_span_and_acoustic_simplicity_costs_remain_unchanged(self): self.assertEqual(generator.guitar.voicing_cost(crossing, profile).effort, 33)
shape = grip((0, 2, 3, 4)) def test_chord_shape_and_height_share_one_effort_cost(self):
shape = grip((1, 3, 4, 5))
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4) self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 18) for profile in ('electric', 'acoustic'):
self.assertEqual(generator.guitar.voicing_cost(shape, profile).effort, 23)
for profile in ('', 'electric', 'acoustic'): for profile in ('', 'electric', 'acoustic'):
self.assertLess(generator.guitar.voicing_cost(grip((8, 8, 8, 8)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile)) self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
for profile in ('electric', 'acoustic'):
self.assertEqual(generator.guitar.voicing_cost(simple, profile).effort, 13)
self.assertEqual(generator.guitar.voicing_cost(varied, profile).effort, 15)
self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort)
def test_compact_mid_neck_acoustic_pair_beats_high_and_stretched_low_pairs(self):
high = [grip((12, 12, 12, 11), (6, 4, 3, 2)), grip((12, 11, 12, 11), (6, 4, 3, 2))]
requested = [grip((7, 8, 7, 8), (5, 4, 3, 2)), grip((7, 8, 6, 8), (5, 4, 3, 2))]
low = [grip((6, 5, 3, 3), (6, 4, 3, 2)), grip((5, 5, 3, 2), (6, 4, 3, 2))]
for symbol, upper, middle, lower in zip(('Em7b5', 'A7b9'), high, requested, low):
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
for shape in (upper, middle, lower):
self.assertIn(shape, candidates)
self.assertEqual({p.midi % 12 for p in upper}, {p.midi % 12 for p in middle})
self.assertEqual(min(p.midi for p in upper), min(p.midi for p in middle))
costs = [sum(generator.guitar.voicing_cost(s, 'acoustic').effort for s in pair) + 2 * generator.guitar.acoustic_movement(*pair) for pair in (requested, high, low)]
self.assertEqual(costs, [33, 36, 41])
def test_real_autumn_leaves_lead_in_uses_requested_compact_grips(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
before = copy.deepcopy(source)
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms')))
generator.model.validate_document(result)
measures = result['staves'][0]['measures'][:2]
self.assertEqual(measures[0]['section_label'], 'Rhythm Lead-in')
for measure, symbol, expected in zip(measures, ('Em7b5', 'A7b9'), (((5, 7), (4, 8), (3, 7), (2, 8)), ((5, 7), (4, 8), (3, 6), (2, 8)))):
with self.subTest(chord=symbol):
self.assertEqual(generator.harmony.symbol(measure['harmonies'][0]), symbol)
positions = {(p['tablature']['string'], p['tablature']['fret']) for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
self.assertEqual(tuple(sorted(positions, reverse=True)), expected)
self.assertEqual(source, before)
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self): def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
candidates = ranked('C6', profile='acoustic') candidates = ranked('Csus', profile='acoustic')
best = candidates[0] best = candidates[0]
self.assertEqual(len(best), 3) self.assertEqual(len(best), 3)
self.assertTrue(any(len(c) == 4 for c in candidates)) self.assertTrue(any(len(c) == 4 for c in candidates))
required, _, _ = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C6'), 'acoustic') requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus'), 'acoustic')
self.assertTrue(set(required) <= {p.midi % 12 for p in best}) self.assertTrue(requirements.covers({p.midi % 12 for p in best}))
def test_nearby_position_beats_lower_frets_within_comfort_range(self): def test_nearby_position_beats_lower_frets_within_comfort_range(self):
low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9)) low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9))
for profile in ('', 'electric', 'acoustic'): for profile in ('', 'electric', 'acoustic'):
@@ -104,7 +139,7 @@ class VoicingTests(unittest.TestCase):
for profile in ('', 'electric', 'acoustic'): for profile in ('', 'electric', 'acoustic'):
self.assertLess(generator.guitar.voicing_cost(high, profile), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(high, low))) self.assertLess(generator.guitar.voicing_cost(high, profile), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(high, low)))
def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self): def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self):
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('C7', 'acoustic', ()), ('Gm7', 'electric', (67,))): for symbol, profile, melody in (('FM7', 'electric', (69,)), ('Gm7', 'electric', (67,))):
best = {} best = {}
for shape in ranked(symbol, melody, profile): for shape in ranked(symbol, melody, profile):
best.setdefault(generator.guitar.position_center(shape), shape) best.setdefault(generator.guitar.position_center(shape), shape)