@@ -112,7 +112,7 @@ jobs:
|
|||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}
|
path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}
|
||||||
key: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_${{ hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/rhythms/**/*.xml', 'data/scales/**/*.xml') }}
|
key: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_${{ hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/rhythms/**/*.xml', 'data/scales/**/*.xml', 'data/sequences/**/*.xml') }}
|
||||||
restore-keys: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_
|
restore-keys: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_
|
||||||
- name: Build native release
|
- name: Build native release
|
||||||
run: python3 modulus.py build --target x86_64_gcc12_release
|
run: python3 modulus.py build --target x86_64_gcc12_release
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live
|
|||||||
|
|
||||||
`data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts.
|
`data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts.
|
||||||
|
|
||||||
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build transposes these patterns internally to label the acoustic and electric solo passages; it never transposes the songs or stores scale assignments in song XML. Scale changes appear above the existing solo chord symbols, with detailed build-only reports in `generated_assets/analysis/`. Editing a scale regenerates assets, and invalid patterns fail before replacing the previous output.
|
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build uses these collections, the song melody, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with recurring motifs, harmonic arrivals, and space. The other guitar provides solo-aware accompaniment. Scale changes appear above the solo notation and TAB; detailed build-only phrase reports live in `generated_assets/analysis/`. No solo assignments are stored in song XML. Invalid patterns or a harmony without an exact permitted scale fail before replacing the previous output.
|
||||||
|
|
||||||
To inspect generated assets without building the full package, run:
|
To inspect generated assets without building the full package, run:
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Identical notation bodies share layout and body rasterization even when titles o
|
|||||||
|
|
||||||
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated_tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu.
|
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated_tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu.
|
||||||
|
|
||||||
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable voicing until the harmony changes within an arrangement iteration. Bass, upper voices, and TAB positions stay constant across rests, barlines, and repeated chord symbols. Any defined chord tone can serve as the bass; explicit slash basses and inversions remain authoritative. The rhythms combine into one notated voice with no ties: each attack releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Electric accompaniment targets about two attacks per 4/4 bar, scaled for other meters, favoring gaps around the melody and acoustic rhythms. Its notes retain the selected rhythm's duration without a one-beat cap, stopping at chord changes or barlines and reusing the corresponding melody passage's chord shape when available. Both parts remain tie-free; clipped binary durations that need ties shorten to one conventional note followed by silence. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
|
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable voicing until the harmony changes within an arrangement iteration. Bass, upper voices, and TAB positions stay constant across rests, barlines, and repeated chord symbols. Any defined chord tone can serve as the bass; explicit slash basses and inversions remain authoritative. The rhythms combine into one notated voice with no ties: each attack releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Electric accompaniment targets about two attacks per 4/4 bar, scaled for other meters, favoring gaps around the foreground. Its notes retain the selected rhythm's duration without a one-beat cap, stopping at chord changes or barlines and reusing the corresponding melody passage's chord shape when available and scale-compatible. Both parts remain tie-free; clipped binary durations that need ties shorten to one conventional note followed by silence. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. The seven-section form is unchanged; soloing guitars play single-note phrases without self-accompaniment. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
+20
-6
@@ -126,13 +126,13 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
|
|||||||
|
|
||||||
### Shared Part Generation
|
### Shared Part Generation
|
||||||
|
|
||||||
`parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles, realizes independent bass/upper voices or electric chord attacks, and owns shared tie-free duration spelling. Before guitar placement, `acoustic.py` shortens generated bass/upper attacks so they cannot overlap and groups them into continuous harmony spans; afterward it combines them into one notated voice. `guitar.py` selects one complete grip per acoustic span using its shared candidate generation and placement search. `melody.py` identifies long tied melody runs and splits them at harmony changes before guitar placement. `arrange/generate.py` supplies the existing seven-section song form. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer.
|
`parts.py` owns shared source validation, melody-copying, measure construction, rests, and temporary solo placeholders. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles, realizes independent bass/upper voices or electric chord attacks, and owns shared tie-free duration spelling. Before guitar placement, `acoustic.py` shortens generated bass/upper attacks so they cannot overlap and groups them into continuous harmony spans; afterward it combines them into one notated voice. `acoustic_voicing.py` selects one complete grip per acoustic span. `melody.py` identifies long tied melody runs and splits them at harmony changes before electric guitar placement. `arrange/generate.py` supplies the existing seven-section song form, then `solo/arrangement.py` replaces solo placeholders and backing after regular placement. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer.
|
||||||
|
|
||||||
Generated accompaniment has no ties. Acoustic attacks release at the earliest of their planned end, the next combined bass/upper attack, or the barline. Simultaneous bass/upper attacks use the shorter planned duration for the entire grip. If a binary duration would need ties, it shortens to the longest conventional single note that fits, followed by silence; no extra attack or artificial tuplet is introduced. Rhythm-snippet continuations are silent, and the planner does not count them as acoustic coverage. After placement, consolidation preserves the prepared timing and TAB assignments, combines simultaneous tones under one stem, and writes rests only for complete silence. A silent full bar has one measure rest. Old lane stem directions and beam groups are removed in favor of single-voice engraving. Authored melody ties, solo slashes, and electric melody harmonization remain separate from this build-time accompaniment policy.
|
Generated accompaniment has no ties. Acoustic attacks release at the earliest of their planned end, the next combined bass/upper attack, or the barline. Simultaneous bass/upper attacks use the shorter planned duration for the entire grip. If a binary duration would need ties, it shortens to the longest conventional single note that fits, followed by silence; no extra attack or artificial tuplet is introduced. Rhythm-snippet continuations are silent, and the planner does not count them as acoustic coverage. After placement, consolidation preserves the prepared timing and TAB assignments, combines simultaneous tones under one stem, and writes rests only for complete silence. A silent full bar has one measure rest. Old lane stem directions and beam groups are removed in favor of single-voice engraving. Authored melody ties, composed solo sustains, and electric melody harmonization remain separate from this build-time accompaniment policy.
|
||||||
|
|
||||||
Acoustic accompaniment uses the planned foundation. Electric accompaniment sections in songs use the response plan; song electric melody sections are raised one octave before harmonization. Song sections remain Rhythm Lead-in, Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, Vocal Melody, and Ending, with each part retaining its melody, accompaniment, rest, or solo-slash role. Optional turnarounds are removed before planning the final ending. The generated phrase cycles contain explicit events in reading order, not runtime loops or repeat signs.
|
Acoustic accompaniment uses the planned foundation. Electric accompaniment sections in songs use the response plan; song electric melody sections are raised one octave before harmonization. Song sections remain Rhythm Lead-in, Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, Vocal Melody, and Ending. Solo sections now contain composed single-note guitar lines supported by the other guitar. Optional turnarounds are removed before planning the final ending. The generated phrase cycles contain explicit events in reading order, not runtime loops or repeat signs.
|
||||||
|
|
||||||
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. `arrange_document` constructs the regular form with a keyword `rhythms` library. The complete `generate_performance_scores` pipeline additionally requires `scales` and `sequences`; it always composes both solos before writing the song's three parts. 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 1-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.
|
||||||
|
|
||||||
@@ -184,10 +184,24 @@ The analyzer requires a declared tonic and mode in the song's MusicXML key. A on
|
|||||||
|
|
||||||
Selection first preserves every required pitch. Recognized ii-V pairs, resolving dominants, and leading-tone diminished resolutions establish local destinations; the declared key provides the default context elsewhere. Minor cadences prefer harmonic minor and major cadences prefer the destination's major collection when compatible. The arrival chord is evaluated afresh, so a plain D-minor tonic need not retain a preceding cadence's raised seventh. Among equally appropriate choices, a deterministic dynamic program minimizes collection changes, then contextual pitch differences. Equivalent modes share a pitch collection rather than forcing new boundaries. This is a conservative harmonic heuristic, not a claim that ambiguous harmony has only one interpretation.
|
Selection first preserves every required pitch. Recognized ii-V pairs, resolving dominants, and leading-tone diminished resolutions establish local destinations; the declared key provides the default context elsewhere. Minor cadences prefer harmonic minor and major cadences prefer the destination's major collection when compatible. The arrival chord is evaluated afresh, so a plain D-minor tonic need not retain a preceding cadence's raised seventh. Among equally appropriate choices, a deterministic dynamic program minimizes collection changes, then contextual pitch differences. Equivalent modes share a pitch collection rather than forcing new boundaries. This is a conservative harmonic heuristic, not a claim that ambiguous harmony has only one interpretation.
|
||||||
|
|
||||||
Only the soloing part receives compact `scale_spans`, with absolute rational start/end offsets, labels, and matched status. `N.C.` and solo boundaries stop annotations. Accompaniment, melody, pitches, TAB, and playback are unchanged; no solo notes are synthesized. Original melody notes are not constraints on these slash-only solo passages. Unmatched chords print `No exact scale match` and emit a source/measure/beat warning without changing the harmony. Invalid scale data or missing tonal metadata instead fail generation before publication.
|
Only the soloing part receives compact `scale_spans`, with absolute rational start/end offsets, labels, and matched status. `N.C.` and solo boundaries stop annotations. The composer consumes the structured scale choices directly and checks every sounding note across its entire duration. It may select another permitted collection to preserve an exact melody quotation, but never silently changes that quotation's notes. A harmony with no exact compatible collection, invalid scale data, or missing tonal metadata fails generation before publication. The standalone scale-analysis utility can still report unmatched chords without composing them.
|
||||||
|
|
||||||
Scale labels appear above chord symbols at collection changes and system starts. Detailed parent collections, source and arranged positions, chord-root modes, degrees, chord-tone targets, and selection reasons are written to `generated_assets/analysis/<song>.txt`. These reports are published transactionally with the build output but excluded from both installation and Docker runtime assets. The C++ runtime only displays the generated pages; it does not load the scale library or run harmonic analysis.
|
Scale labels appear above chord symbols at collection changes and system starts. Detailed parent collections, source and arranged positions, chord-root modes, degrees, chord-tone targets, and selection reasons are written to `generated_assets/analysis/<song>.txt`. These reports are published transactionally with the build output but excluded from both installation and Docker runtime assets. The C++ runtime only displays the generated pages; it does not load the scale library or run harmonic analysis.
|
||||||
|
|
||||||
|
### Phrase Composition
|
||||||
|
|
||||||
|
`generator/solo/` is build-only. `analysis.py` extracts one- and two-bar monophonic motifs from the normalized melody, collapsing ties while retaining repeated attacks, leading rests, source event IDs, and harmonic context. `planning.py` selects at most three motif identities and plans statements, returns, variations, answers, a register peak, and a final callback around two- to four-bar phrase boundaries. Melody breathing points and harmonic destinations influence those boundaries. `types.py` owns exact-time motif, phrase, target, and sounding-pitch models. No annotations are added to source XML.
|
||||||
|
|
||||||
|
`sequences.py` validates the C-major interval paths in `data/sequences/2_4`, `3_4`, and `4_4`, then indexes them by attack count and ranks their range, largest leap, and contour turns. Steps accumulate through octaves, including negative scale indices. Sequence meter describes the source container, not the realized duration. `realization.py` matches those paths to rhythm attacks, not rests or tied continuations. It also supports single targets, explicit repeated notes, holds, and silence. Rhythm snippets retain their meter and exact durations; shorter fitting cells and melody rhythms are not stretched. Catalog entries are ranked by phrase function rather than sampled uniformly or cycled to fill time.
|
||||||
|
|
||||||
|
Phrase search retains at most 64 distinct endings and considers at most 32 ranked extensions per state. Ordered preferences protect motif identity, register/activity, contour, connection, and playability. Arrival windows require actual defining chord tones, respecting suspensions and explicit alterations. Quotes retain all pitches and attack/release timing, with a whole-fragment octave relocation reported explicitly. Other treatments include harmonic adaptation, changed endings, fragmentation, and rhythmic displacement. A scale change need not end the phrase; held pitches must remain permitted throughout. Missing harmony means silence. Chromatic ornaments, swing, dynamics, and automatic solo chords are not added.
|
||||||
|
|
||||||
|
Acoustic starts with melody-related material and a softer activity/register preference; electric introduces a contrasting sequence-derived motif and returns to the melody before the vocal pickup. These are compositional preferences, not fixed notes-per-bar quotas. Each phrase reserves release space. Sounding registers start around G3-D5 and B3-G5 respectively; written guitar notation is one octave above sound, exactly once. `placement.py` chooses frets 1-20 with no open strings, retaining tied positions. Movement cost accounts for the time between attacks at the first declared source tempo, or 120 quarter notes/minute when absent. Runtime tempo remains adjustable; this check is not a guarantee of ease at every tempo.
|
||||||
|
|
||||||
|
`arrangement.py` analyzes each song once and replaces only the two solo-section windows after normal head/accompaniment placement. Electric backs Acoustic Solo; acoustic backs Electric Solo. Neither guitarist accompanies itself. Backing rhythms use the generated solo's foreground and the reserved vocal return, not the absent original melody. Electric may reuse the corresponding placed head grip only when its pitches fit the active solo collection; otherwise ordinary chord-tone placement applies. Existing chord constraints remain unchanged. All non-solo measures and the complete vocal part are regression boundaries. The vocal pickup cutoff is reserved before composing the electric ending.
|
||||||
|
|
||||||
|
`notation.py` converts placed attack spans into ordinary score events, rests, and tied continuations across notation/bar boundaries. Existing engraving, playback compilation, and C++ consumption require no new runtime representation. `report.py` appends phrase/motif/source IDs, transformations, target windows, sounding pitches, and TAB decisions to the build analysis report. A bounded-search failure identifies the passage and search limits; it does not claim mathematical impossibility. Generation still stages and validates the complete output before publication. Listening and playing remain necessary to accept the musical result.
|
||||||
|
|
||||||
### Engraving Boundary
|
### Engraving Boundary
|
||||||
|
|
||||||
`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `generator.engraving.layout.layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `generator.engraving.raster.rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `generator.engraving.pipeline.engrave_tree(root, fonts)` is the asset-pipeline entry point. `fonts.Fonts`, `types.Style`, and `types.EngravingError` live in their respective engraving modules; page constants and `check_page` live in `validation`. Package initializers do not re-export these APIs. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving.
|
`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `generator.engraving.layout.layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `generator.engraving.raster.rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `generator.engraving.pipeline.engrave_tree(root, fonts)` is the asset-pipeline entry point. `fonts.Fonts`, `types.Style`, and `types.EngravingError` live in their respective engraving modules; page constants and `check_page` live in `validation`. Package initializers do not re-export these APIs. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving.
|
||||||
@@ -226,7 +240,7 @@ The six compact source files were replaced, not retained as a second authoring f
|
|||||||
|
|
||||||
References: [MusicXML durations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/duration/), [tuplets](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tuplet/), [degree alterations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/degree-alter/), and [SMuFL accidentals](https://smufl.formats.music/latest/tables/standard-accidentals-12-edo.html).
|
References: [MusicXML durations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/duration/), [tuplets](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tuplet/), [degree alterations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/degree-alter/), and [SMuFL accidentals](https://smufl.formats.music/latest/tables/standard-accidentals-12-edo.html).
|
||||||
|
|
||||||
Generator checks compare the generated performance summary against `data/expected/performance_summary.json`. The summary stores compatibility invariants such as section layout, chord-label placement, slash-section placement, and vocal timing without committing the generated score files themselves. Refresh that file only when an intentional arrangement-logic change should become the new baseline.
|
Generator checks compare the generated performance summary against `data/expected/performance_summary.json`. The summary stores compatibility invariants such as section layout, chord-label placement, absence of uncomposed solo slashes, and vocal timing without committing the generated score files themselves. Refresh that file only when an intentional arrangement-logic change should become the new baseline.
|
||||||
|
|
||||||
## Pico Runtime
|
## Pico Runtime
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class product_vamp_conan(conan.ConanFile):
|
|||||||
"data/songs/*.xml",
|
"data/songs/*.xml",
|
||||||
"data/rhythms/**/*.xml",
|
"data/rhythms/**/*.xml",
|
||||||
"data/scales/**/*.xml",
|
"data/scales/**/*.xml",
|
||||||
|
"data/sequences/**/*.xml",
|
||||||
"tests/fixtures/*",
|
"tests/fixtures/*",
|
||||||
"tests/fixtures/**/*",
|
"tests/fixtures/**/*",
|
||||||
"generator/*.py",
|
"generator/*.py",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import generator.rhythm
|
|||||||
import generator.scales.library
|
import generator.scales.library
|
||||||
import generator.score
|
import generator.score
|
||||||
import generator.summary
|
import generator.summary
|
||||||
|
import generator.solo.sequences
|
||||||
|
|
||||||
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
||||||
|
|
||||||
@@ -72,13 +73,15 @@ def generate_assets(args: argparse.Namespace) -> None:
|
|||||||
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
|
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
|
||||||
report('loading solo scale patterns')
|
report('loading solo scale patterns')
|
||||||
scales = generator.scales.library.load_library(data_dir / 'scales')
|
scales = generator.scales.library.load_library(data_dir / 'scales')
|
||||||
|
report('loading solo interval sequences')
|
||||||
|
sequences = generator.solo.sequences.load_library(data_dir / 'sequences')
|
||||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
|
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
|
||||||
staged = pathlib.Path(temporary) / 'assets'
|
staged = pathlib.Path(temporary) / 'assets'
|
||||||
staged.mkdir(parents=True, exist_ok=True)
|
staged.mkdir(parents=True, exist_ok=True)
|
||||||
copy_runtime_fonts(static_assets_dir, staged)
|
copy_runtime_fonts(static_assets_dir, staged)
|
||||||
report('generating performance parts')
|
report('generating performance parts')
|
||||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales)
|
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences)
|
||||||
generator.score.validate_generated_tree(staged)
|
generator.score.validate_generated_tree(staged)
|
||||||
if args.check:
|
if args.check:
|
||||||
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context
|
|||||||
raise ValueError(f'measure {contexts[0].measure["number"]}: rhythm library cannot provide an audible electric response')
|
raise ValueError(f'measure {contexts[0].measure["number"]}: rhythm library cannot provide an audible electric response')
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True) -> Plan:
|
def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True, acoustic_companions: bool = True) -> Plan:
|
||||||
previous = carried_chords(measures, initial)
|
previous = carried_chords(measures, initial)
|
||||||
basses, uppers, responses = [], [], []
|
basses, uppers, responses = [], [], []
|
||||||
start = 0
|
start = 0
|
||||||
@@ -139,7 +139,7 @@ def plan_section(measures: list[dict], library: generator.rhythm.Library, *, ini
|
|||||||
bass = cycle(snippets, contexts, 'bass')
|
bass = cycle(snippets, contexts, 'bass')
|
||||||
upper = cycle(snippets, contexts, 'upper')
|
upper = cycle(snippets, contexts, 'upper')
|
||||||
companions = [tuple(sorted(set(sounding_attacks(bass[i % 4], item)) | set(sounding_attacks(upper[i % 4], item)))) for i, item in enumerate(contexts)]
|
companions = [tuple(sorted(set(sounding_attacks(bass[i % 4], item)) | set(sounding_attacks(upper[i % 4], item)))) for i, item in enumerate(contexts)]
|
||||||
response = cycle(snippets, contexts, 'electric', companions) if electric else upper
|
response = cycle(snippets, contexts, 'electric', companions if acoustic_companions else None) if electric else upper
|
||||||
basses.extend(bass[i % 4] for i in range(len(segment)))
|
basses.extend(bass[i % 4] for i in range(len(segment)))
|
||||||
uppers.extend(upper[i % 4] for i in range(len(segment)))
|
uppers.extend(upper[i % 4] for i in range(len(segment)))
|
||||||
responses.extend(response[i % 4] for i in range(len(segment)))
|
responses.extend(response[i % 4] for i in range(len(segment)))
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import generator.scales.library
|
|||||||
import generator.scales.mapping
|
import generator.scales.mapping
|
||||||
import generator.scales.timeline
|
import generator.scales.timeline
|
||||||
import generator.score
|
import generator.score
|
||||||
|
import generator.solo.arrangement
|
||||||
|
import generator.solo.sequences
|
||||||
|
|
||||||
FINAL_SECTION_INDEX = 6
|
FINAL_SECTION_INDEX = 6
|
||||||
|
|
||||||
def arrange_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library | None = None) -> dict:
|
def arrange_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library | None = None) -> dict:
|
||||||
|
"""Place the regular form; the song pipeline inserts solos after all heads exist."""
|
||||||
document = generator.parts.prepare_document(document)
|
document = generator.parts.prepare_document(document)
|
||||||
plans = plan_arrangement(document, rhythms) if part_type != 'vocal' else {}
|
plans = plan_arrangement(document, rhythms) if part_type != 'vocal' else {}
|
||||||
return _arrange_document(document, part_type, plans, scales=scales)
|
return _arrange_document(document, part_type, plans, scales=scales)
|
||||||
@@ -127,7 +130,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
|||||||
reports.append(report)
|
reports.append(report)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library | None = None) -> int:
|
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library, sequences: generator.solo.sequences.Library) -> int:
|
||||||
count = 0
|
count = 0
|
||||||
for path in generator.parts.source_paths(source_dir):
|
for path in generator.parts.source_paths(source_dir):
|
||||||
try:
|
try:
|
||||||
@@ -135,15 +138,20 @@ def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Pa
|
|||||||
plans = plan_arrangement(document, rhythms)
|
plans = plan_arrangement(document, rhythms)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise ValueError(f'{path}: {error}') from error
|
raise ValueError(f'{path}: {error}') from error
|
||||||
reports = []
|
parts = {}
|
||||||
for part in generator.parts.PART_TYPES:
|
for part in generator.parts.PART_TYPES:
|
||||||
try:
|
try:
|
||||||
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}_{part}.json', _arrange_document(document, part, plans, scales=scales, reports=reports, source_name=path.name))
|
parts[part] = _arrange_document(document, part, plans)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise ValueError(f'{path}, part {part}: {error}') from error
|
raise ValueError(f'{path}, part {part}: {error}') from error
|
||||||
|
try:
|
||||||
|
report = generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(f'{path}: {error}') from error
|
||||||
|
for part, result in parts.items():
|
||||||
|
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}_{part}.json', result)
|
||||||
count += 1
|
count += 1
|
||||||
if reports:
|
report_path = output_dir.parent / 'analysis' / f'{generator.score.slugify(path.stem)}.txt'
|
||||||
report_path = output_dir.parent / 'analysis' / f'{generator.score.slugify(path.stem)}.txt'
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
report_path.write_text(document['metadata']['title'] + '\nSource: ' + path.name + '\n\n' + report, encoding='utf-8')
|
||||||
report_path.write_text(document['metadata']['title'] + '\nSource: ' + path.name + '\n\n' + '\n'.join(reports), encoding='utf-8')
|
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def following_indices(timeline: tuple[generator.scales.timeline.ChordSpan, ...])
|
|||||||
result[index] = result[index + 1] if same else index + 1
|
result[index] = result[index + 1] if same else index + 1
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def select(timeline: tuple[generator.scales.timeline.ChordSpan, ...], solo: generator.scales.timeline.Solo, library: generator.scales.library.Library) -> tuple[Choice, ...]:
|
def select(timeline: tuple[generator.scales.timeline.ChordSpan, ...], solo: generator.scales.timeline.Solo, library: generator.scales.library.Library, *, retained: tuple = ()) -> tuple[Choice, ...]:
|
||||||
collections = library.collections()
|
collections = library.collections()
|
||||||
following = following_indices(timeline)
|
following = following_indices(timeline)
|
||||||
rows, layers = [], []
|
rows, layers = [], []
|
||||||
@@ -77,6 +77,9 @@ def select(timeline: tuple[generator.scales.timeline.ChordSpan, ...], solo: gene
|
|||||||
if target.pitches == home.pitches:
|
if target.pitches == home.pitches:
|
||||||
target = home
|
target = home
|
||||||
required = set(generator.harmony.pitch_classes(span.chord)) if span.chord else set()
|
required = set(generator.harmony.pitch_classes(span.chord)) if span.chord else set()
|
||||||
|
for start, end, pitches in retained:
|
||||||
|
if start < span.end and end > span.start:
|
||||||
|
required.update(pitches)
|
||||||
options = [target if scale.pitches == target.pitches else home if scale.pitches == home.pitches else scale for scale in collections if required <= scale.pitches] if span.chord else []
|
options = [target if scale.pitches == target.pitches else home if scale.pitches == home.pitches else scale for scale in collections if required <= scale.pitches] if span.chord else []
|
||||||
options = options or [None]
|
options = options or [None]
|
||||||
layer = []
|
layer = []
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ class Solo:
|
|||||||
end: fractions.Fraction
|
end: fractions.Fraction
|
||||||
label: str
|
label: str
|
||||||
|
|
||||||
def read(staff: dict) -> tuple[ChordSpan, ...]:
|
def read(staff: dict, *, initial: dict | None = None) -> tuple[ChordSpan, ...]:
|
||||||
result = []
|
result = []
|
||||||
absolute = fractions.Fraction()
|
absolute = fractions.Fraction()
|
||||||
active = None
|
active = initial
|
||||||
for number, measure in enumerate(staff['measures'], 1):
|
for number, measure in enumerate(staff['measures'], 1):
|
||||||
length = generator.model.fraction(measure['duration'])
|
length = generator.model.fraction(measure['duration'])
|
||||||
harmonies = {generator.model.fraction(h['onset']): h for h in measure['harmonies']}
|
harmonies = {generator.model.fraction(h['onset']): h for h in measure['harmonies']}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Build-time phrase composition; runtime consumes ordinary score events."""
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import fractions
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import generator.harmony
|
||||||
|
import generator.model
|
||||||
|
import generator.score
|
||||||
|
import generator.solo.types
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Analysis:
|
||||||
|
motifs: tuple[generator.solo.types.Motif, ...]
|
||||||
|
boundaries: tuple[fractions.Fraction, ...]
|
||||||
|
tempo: fractions.Fraction
|
||||||
|
melody: tuple[generator.solo.types.Note, ...]
|
||||||
|
|
||||||
|
def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
|
||||||
|
"""Collapse tied tones before extracting monophonic melody fragments."""
|
||||||
|
result, pending = [], {}
|
||||||
|
absolute = fractions.Fraction()
|
||||||
|
for measure in generator.score.source_staff(document)['measures']:
|
||||||
|
for event in generator.score.measure_notes(measure):
|
||||||
|
if event.get('grace') or event.get('cue'):
|
||||||
|
continue
|
||||||
|
for tone in event['pitches']:
|
||||||
|
number = generator.model.pitch_number(tone)
|
||||||
|
if number.denominator != 1:
|
||||||
|
continue
|
||||||
|
start = absolute + generator.model.fraction(event['onset'])
|
||||||
|
end = start + generator.model.fraction(tone['duration'])
|
||||||
|
pitch = generator.solo.types.Pitch(tone['letter'], generator.model.alteration(tone['alter']), tone['octave'])
|
||||||
|
source = pending.pop(tone.get('tie_from'), None)
|
||||||
|
if source is not None:
|
||||||
|
result[source] = dataclasses.replace(result[source], end=end, sources=(*result[source].sources, event['id']))
|
||||||
|
else:
|
||||||
|
source = len(result)
|
||||||
|
result.append(generator.solo.types.Note(start, end, pitch, (event['id'],)))
|
||||||
|
if 'tie_to' in tone:
|
||||||
|
pending[tone['id']] = source
|
||||||
|
absolute += generator.model.fraction(measure['duration'])
|
||||||
|
return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources)))
|
||||||
|
|
||||||
|
def analyze(document: dict) -> Analysis:
|
||||||
|
measures = generator.score.source_staff(document)['measures']
|
||||||
|
melody = attacks(document)
|
||||||
|
boundaries = [fractions.Fraction()]
|
||||||
|
for measure in measures:
|
||||||
|
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||||
|
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', [])), fractions.Fraction(120))
|
||||||
|
candidates = []
|
||||||
|
for index, measure in enumerate(measures):
|
||||||
|
start = boundaries[index]
|
||||||
|
# One- and two-bar ideas retain leading silence and the complete held ending.
|
||||||
|
for count in (1, 2):
|
||||||
|
if index + count >= len(boundaries):
|
||||||
|
continue
|
||||||
|
end = boundaries[index + count]
|
||||||
|
notes = tuple(n for n in melody if start <= n.start < end)
|
||||||
|
if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])):
|
||||||
|
continue
|
||||||
|
if len(notes) > 8:
|
||||||
|
continue
|
||||||
|
normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes)
|
||||||
|
identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii')
|
||||||
|
identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12]
|
||||||
|
context = tuple(generator.harmony.symbol(h) for m in measures[index:index + count] for h in m['harmonies'])
|
||||||
|
candidates.append(generator.solo.types.Motif(identifier, normalized, end - start, context))
|
||||||
|
unique = {m.id: m for m in reversed(candidates)}
|
||||||
|
# Preserve the opening idea, then favor a contrasting rhythm/contour with space.
|
||||||
|
ordered = list(unique.values())
|
||||||
|
ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration))
|
||||||
|
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import fractions
|
||||||
|
|
||||||
|
import generator.accompaniment
|
||||||
|
import generator.acoustic
|
||||||
|
import generator.acoustic_voicing
|
||||||
|
import generator.arrange.form
|
||||||
|
import generator.electric
|
||||||
|
import generator.guitar
|
||||||
|
import generator.model
|
||||||
|
import generator.parts
|
||||||
|
import generator.score
|
||||||
|
import generator.solo.analysis
|
||||||
|
import generator.solo.composer
|
||||||
|
import generator.solo.notation
|
||||||
|
import generator.solo.report
|
||||||
|
import generator.solo.types
|
||||||
|
|
||||||
|
def regions(document):
|
||||||
|
result, first, start = {}, 0, fractions.Fraction()
|
||||||
|
source = generator.score.source_staff(document)['measures']
|
||||||
|
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'):
|
||||||
|
length = sum((generator.model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index + section.measure_count]), fractions.Fraction())
|
||||||
|
result[section.section_index] = (first, section.measure_count, start, length)
|
||||||
|
first += section.measure_count
|
||||||
|
start += length
|
||||||
|
return result
|
||||||
|
|
||||||
|
def annotate(document, solo, offset):
|
||||||
|
spans = []
|
||||||
|
for choice in solo.choices:
|
||||||
|
if choice.scale is None:
|
||||||
|
continue
|
||||||
|
label = choice.scale.label()
|
||||||
|
if spans and spans[-1]['label'] == label and generator.model.fraction(spans[-1]['end']) == offset + choice.span.start:
|
||||||
|
spans[-1]['end'] = generator.model.rational(offset + choice.span.end)
|
||||||
|
else:
|
||||||
|
spans.append({'id': f'{solo.part}_scale_{len(spans) + 1}', 'start': generator.model.rational(offset + choice.span.start), 'end': generator.model.rational(offset + choice.span.end), 'label': label, 'matched': True})
|
||||||
|
document['staves'][0]['scale_spans'] = spans
|
||||||
|
|
||||||
|
def reuse_head_grips(document, references, choices):
|
||||||
|
previous = generator.accompaniment.carried_chords(references)
|
||||||
|
target_previous = generator.accompaniment.carried_chords(document['staves'][0]['measures'])
|
||||||
|
links, absolute = [], fractions.Fraction()
|
||||||
|
for index, measure in enumerate(document['staves'][0]['measures']):
|
||||||
|
for note in generator.score.measure_notes(measure):
|
||||||
|
onset = generator.model.fraction(note['onset'])
|
||||||
|
chord = generator.accompaniment.active_chord(measure, onset, target_previous[index])
|
||||||
|
if not note['pitches'] or chord is None:
|
||||||
|
continue
|
||||||
|
allowed = generator.solo.types.permitted(choices, absolute + onset, absolute + onset + generator.model.fraction(note['duration']))
|
||||||
|
matches = []
|
||||||
|
for candidate in generator.score.measure_notes(references[index]):
|
||||||
|
if len(candidate['pitches']) < 3:
|
||||||
|
continue
|
||||||
|
time = generator.model.fraction(candidate['onset'])
|
||||||
|
head_chord = generator.accompaniment.active_chord(references[index], time, previous[index])
|
||||||
|
if generator.accompaniment.same_chord(chord, head_chord) and {int(generator.model.pitch_number(p)) % 12 for p in candidate['pitches']} <= allowed:
|
||||||
|
matches.append((abs(time - onset), candidate['id'], candidate))
|
||||||
|
links.append(generator.electric.VoicingLink(note, tuple(item[2] for item in sorted(matches))))
|
||||||
|
absolute += generator.model.fraction(measure['duration'])
|
||||||
|
generator.electric.reuse_voicings(links)
|
||||||
|
|
||||||
|
def backing(baseline, foreground, part, rhythms, references, choices, initial=None):
|
||||||
|
plan = generator.accompaniment.plan_section(foreground, rhythms, initial=initial, electric=part == 'electric', acoustic_companions=False)
|
||||||
|
previous = generator.accompaniment.carried_chords(foreground, initial)
|
||||||
|
measures = []
|
||||||
|
for index, measure in enumerate(foreground):
|
||||||
|
rhythm = (plan.acoustic_bass[index], plan.acoustic_upper[index]) if part == 'acoustic' else plan.electric[index]
|
||||||
|
result = generator.parts.build_measure(measure, measure['id'], 'accompaniment', part, previous[index], measure.get('section_label', ''), rhythm)
|
||||||
|
measures.append(result)
|
||||||
|
generator.score.reidentify(measures, [])
|
||||||
|
staff = baseline['staves'][0]
|
||||||
|
document = generator.score.make_arranged_document(baseline, staff, staff_id=staff['id'], staff_name=staff['name'], role='guitar', arrangement_part_type=part, measures=measures)
|
||||||
|
if part == 'acoustic':
|
||||||
|
generator.acoustic.prepare(document)
|
||||||
|
generator.acoustic_voicing.place(document)
|
||||||
|
generator.acoustic.combine(document)
|
||||||
|
else:
|
||||||
|
generator.guitar.place_guitar(document)
|
||||||
|
reuse_head_grips(document, references, choices)
|
||||||
|
generator.model.validate_document(document)
|
||||||
|
return document['staves'][0]['measures']
|
||||||
|
|
||||||
|
def apply(source, parts, *, rhythms, scales, sequences):
|
||||||
|
"""Compose once per song, replacing only already-placed solo section windows."""
|
||||||
|
analysis = generator.solo.analysis.analyze(source)
|
||||||
|
sections = regions(source)
|
||||||
|
source_measures = generator.score.source_staff(source)['measures']
|
||||||
|
head_first, head_count, _, _ = sections[2]
|
||||||
|
head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count]
|
||||||
|
reports = []
|
||||||
|
for section_index, part, other in ((3, 'acoustic', 'electric'), (4, 'electric', 'acoustic')):
|
||||||
|
first, count, offset, length = sections[section_index]
|
||||||
|
initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first]
|
||||||
|
template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count])
|
||||||
|
cutoff = length
|
||||||
|
if part == 'electric' and generator.score.body_start_measure_index(source_measures):
|
||||||
|
pickup = source_measures[0]
|
||||||
|
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
|
||||||
|
if sounding:
|
||||||
|
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
|
||||||
|
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial)
|
||||||
|
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||||
|
generator.solo.notation.replace_region(parts[part], first, foreground)
|
||||||
|
annotate(parts[part], solo, offset)
|
||||||
|
# The other guitarist responds to the actual foreground, including vocal return.
|
||||||
|
if cutoff < length:
|
||||||
|
vocal = parts['vocal']['staves'][0]['measures'][first:first + count]
|
||||||
|
time = fractions.Fraction()
|
||||||
|
for measure, voice_measure in zip(foreground, vocal):
|
||||||
|
returning = [copy.deepcopy(event) | {'voice': 'vocal_return'} for event in generator.score.measure_notes(voice_measure) if event['pitches'] and time + generator.model.fraction(event['onset']) >= cutoff]
|
||||||
|
if returning:
|
||||||
|
measure['voices'].append({'id': 'vocal_return', 'notes': returning})
|
||||||
|
time += generator.model.fraction(measure['duration'])
|
||||||
|
accompaniment = backing(parts[other], foreground, other, rhythms, head, solo.choices, initial)
|
||||||
|
generator.solo.notation.replace_region(parts[other], first, accompaniment)
|
||||||
|
reports.append(generator.solo.report.describe(solo, scales, first, offset))
|
||||||
|
for document in parts.values():
|
||||||
|
generator.model.validate_document(document)
|
||||||
|
return '\n'.join(reports)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import fractions
|
||||||
|
|
||||||
|
import generator.fretboard
|
||||||
|
import generator.harmony
|
||||||
|
import generator.model
|
||||||
|
import generator.scales.selection
|
||||||
|
import generator.scales.timeline
|
||||||
|
import generator.solo.analysis
|
||||||
|
import generator.solo.planning
|
||||||
|
import generator.solo.realization
|
||||||
|
import generator.solo.types
|
||||||
|
|
||||||
|
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None):
|
||||||
|
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
|
||||||
|
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
|
||||||
|
choices = generator.scales.selection.select(timeline, region, scales)
|
||||||
|
for choice in choices:
|
||||||
|
if choice.span.chord and choice.scale is None:
|
||||||
|
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
|
||||||
|
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences)
|
||||||
|
# A protected quote may change the local collection, never its individual pitches.
|
||||||
|
for plan in plans:
|
||||||
|
if plan.treatment != 'quote' or plan.motif.duration > plan.end - plan.start:
|
||||||
|
continue
|
||||||
|
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for n in plan.motif.notes)
|
||||||
|
if all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||||
|
continue
|
||||||
|
alternative = generator.scales.selection.select(timeline, region, scales, retained=retained)
|
||||||
|
if all(pc <= generator.solo.types.permitted(alternative, start, finish) for start, finish, pc in retained) and all(c.scale or c.span.chord is None for c in alternative):
|
||||||
|
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in alternative)
|
||||||
|
phrases, previous = [], None
|
||||||
|
for plan in plans:
|
||||||
|
phrase = generator.solo.realization.compose(plan, measures, choices, sequences, rhythms, analysis.tempo, previous)
|
||||||
|
phrases.append(phrase)
|
||||||
|
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||||
|
if notes:
|
||||||
|
previous = notes[-1]
|
||||||
|
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end)
|
||||||
|
validate(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def validate(solo):
|
||||||
|
previous = None
|
||||||
|
for note in solo.notes():
|
||||||
|
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
|
||||||
|
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
|
||||||
|
if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
|
||||||
|
raise ValueError(f'{solo.part} solo: held pitch {note.pitch.label()} violates a scale span at {note.start}-{note.end}')
|
||||||
|
if note.position not in generator.fretboard.positions_for_pitch(fractions.Fraction(note.pitch.number())):
|
||||||
|
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
||||||
|
previous = note
|
||||||
|
for phrase in solo.phrases:
|
||||||
|
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||||
|
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
|
||||||
|
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import fractions
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
import generator.accompaniment
|
||||||
|
import generator.model
|
||||||
|
import generator.score
|
||||||
|
import generator.solo.types
|
||||||
|
|
||||||
|
def durations(length):
|
||||||
|
if length.denominator & (length.denominator - 1) == 0:
|
||||||
|
yield from generator.accompaniment.written_durations(length)
|
||||||
|
else:
|
||||||
|
kind, base = next((kind, value) for kind, value in reversed(generator.model.TYPE_LENGTHS.items()) if value >= length)
|
||||||
|
yield length, {'type': kind, 'dots': 0, 'ratio': generator.model.rational(length / base)}
|
||||||
|
|
||||||
|
def measures(sources: list[dict], notes: tuple[generator.solo.types.Note, ...], prefix: str) -> list[dict]:
|
||||||
|
"""Write attack spans as conventional durations; only continuations get ties."""
|
||||||
|
result, previous = [], {}
|
||||||
|
absolute = fractions.Fraction()
|
||||||
|
for index, source in enumerate(sources):
|
||||||
|
length = generator.model.fraction(source['duration'])
|
||||||
|
events, cursor = [], absolute
|
||||||
|
|
||||||
|
def emit(start, end, tone=None, identity=None):
|
||||||
|
for duration, written in durations(end - start):
|
||||||
|
identifier = f'{prefix}_{index + 1}_{len(events) + 1}'
|
||||||
|
pitches = [tone.pitch.written() | {'tablature': {'string': tone.position.string_number, 'fret': tone.position.fret}}] if tone else []
|
||||||
|
event = generator.model.make_event(identifier, start - absolute, duration, 'note' if tone else 'rest', pitches, written)
|
||||||
|
if tone:
|
||||||
|
pitch = event['pitches'][0]
|
||||||
|
if identity in previous:
|
||||||
|
prior = previous[identity]
|
||||||
|
prior['tie_to'] = pitch['id']
|
||||||
|
pitch['tie_from'] = prior['id']
|
||||||
|
previous[identity] = pitch
|
||||||
|
events.append(event)
|
||||||
|
start += duration
|
||||||
|
|
||||||
|
for identity, note in enumerate(notes):
|
||||||
|
start, end = max(absolute, note.start), min(absolute + length, note.end)
|
||||||
|
if start >= end:
|
||||||
|
continue
|
||||||
|
if start > cursor:
|
||||||
|
emit(cursor, start)
|
||||||
|
emit(start, end, note, identity)
|
||||||
|
cursor = end
|
||||||
|
if cursor < absolute + length:
|
||||||
|
emit(cursor, absolute + length)
|
||||||
|
measure = generator.score.make_measure(source['id'], source, events, section_label=source.get('section_label', ''))
|
||||||
|
measure['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1, 'explicit': True}
|
||||||
|
measure['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
|
||||||
|
for change in measure.get('attributes', []):
|
||||||
|
change.pop('transpose', None)
|
||||||
|
change.pop('clef', None)
|
||||||
|
result.append(measure)
|
||||||
|
absolute += length
|
||||||
|
return result
|
||||||
|
|
||||||
|
def tuplets(measures):
|
||||||
|
spans = []
|
||||||
|
for measure in measures:
|
||||||
|
for ratio, values in itertools.groupby(generator.score.measure_notes(measure), key=lambda n: generator.model.fraction(n['written'].get('ratio', generator.model.rational(1)))):
|
||||||
|
notes = list(values)
|
||||||
|
if ratio == 1:
|
||||||
|
continue
|
||||||
|
for first in range(0, len(notes), ratio.denominator):
|
||||||
|
members = notes[first:first + ratio.denominator]
|
||||||
|
identifier = members[0]['id'] + '_tuplet'
|
||||||
|
spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'})
|
||||||
|
for note in members:
|
||||||
|
note['tuplet_ids'] = [identifier]
|
||||||
|
return spans
|
||||||
|
|
||||||
|
def replace_region(document: dict, first: int, replacement: list[dict]) -> None:
|
||||||
|
staff = document['staves'][0]
|
||||||
|
replacement = copy.deepcopy(replacement)
|
||||||
|
spans = tuplets(replacement)
|
||||||
|
staff['measures'][first:first + len(replacement)] = replacement
|
||||||
|
staff['tablature'] = True
|
||||||
|
existing = {n['id'] for m in staff['measures'] for n in generator.score.measure_notes(m)}
|
||||||
|
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in existing and t['end'] in existing] + spans
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import fractions
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import generator.fretboard
|
||||||
|
import generator.solo.types
|
||||||
|
|
||||||
|
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
|
||||||
|
if previous is None:
|
||||||
|
return 0
|
||||||
|
return transition(previous.position, current.position, current.start - previous.start, tempo)
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=16384)
|
||||||
|
def transition(previous, current, duration, tempo):
|
||||||
|
distance = abs(previous.fret - current.fret) + abs(previous.string_number - current.string_number)
|
||||||
|
seconds = float(duration * 60 / tempo)
|
||||||
|
# Movement is a preference, not an unsupported claim about a player's limits.
|
||||||
|
return round(100 * distance / max(seconds, 0.05))
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=4096)
|
||||||
|
def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fraction, previous: generator.solo.types.Note | None = None) -> tuple[int, tuple[generator.solo.types.Note, ...]] | None:
|
||||||
|
states = [(0, ())]
|
||||||
|
for note in notes:
|
||||||
|
following = []
|
||||||
|
for position in positions(note.pitch.number()):
|
||||||
|
current = dataclasses.replace(note, position=position)
|
||||||
|
candidates = [(cost + travel(path[-1] if path else previous, current, tempo), (*path, current)) for cost, path in states]
|
||||||
|
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1]))))
|
||||||
|
if not following:
|
||||||
|
return None
|
||||||
|
states = following
|
||||||
|
return min(states, key=lambda item: (item[0], tuple(n.position for n in item[1])))
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=128)
|
||||||
|
def positions(number: int):
|
||||||
|
return generator.fretboard.positions_for_pitch(fractions.Fraction(number))
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fractions
|
||||||
|
|
||||||
|
import generator.harmony
|
||||||
|
import generator.model
|
||||||
|
import generator.scales.selection
|
||||||
|
import generator.solo.analysis
|
||||||
|
import generator.solo.sequences
|
||||||
|
import generator.solo.types
|
||||||
|
|
||||||
|
def target_pitches(chord: dict, final: bool) -> frozenset[int]:
|
||||||
|
degrees = generator.harmony.degrees(chord)
|
||||||
|
explicit = {d['value'] for d in chord.get('degrees', []) if d['type'] != 'subtract'}
|
||||||
|
preferred = {1, 3} if final else {3, 7}
|
||||||
|
preferred |= explicit | {d for d, a in degrees.items() if d == 5 and a}
|
||||||
|
if 3 not in degrees:
|
||||||
|
preferred |= {2, 4}
|
||||||
|
root = generator.scales.selection.root_number(chord)
|
||||||
|
return frozenset(int(root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items() if d in preferred) or frozenset(generator.harmony.pitch_classes(chord))
|
||||||
|
|
||||||
|
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2)):
|
||||||
|
last = next((c for c in reversed(choices) if c.span.start < end and c.span.end > start), None)
|
||||||
|
if not last or not last.span.chord:
|
||||||
|
return None
|
||||||
|
latest = end - release - fractions.Fraction(1, 4)
|
||||||
|
if last.span.start > latest:
|
||||||
|
return None
|
||||||
|
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 1 - release), latest, target_pitches(last.span.chord, final), generator.harmony.symbol(last.span.chord), 'phrase resolution' if final else 'defining chord-tone arrival')
|
||||||
|
|
||||||
|
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction) -> generator.solo.types.Motif:
|
||||||
|
paths = sequences.connecting(3) or sequences.connecting(2) or sequences.connecting(4)
|
||||||
|
if not paths:
|
||||||
|
raise ValueError('solo composition requires a two-, three-, or four-note sequence')
|
||||||
|
path = min(paths, key=lambda s: (sum(a * b >= 0 for a, b in zip(s.steps, s.steps[1:])), s.rank()))
|
||||||
|
count = len(path.steps) + 1
|
||||||
|
unit = min(fractions.Fraction(1, 2), length / (count + 1))
|
||||||
|
notes = []
|
||||||
|
for i, offset in enumerate(path.offsets()):
|
||||||
|
octave, degree = divmod(28 + offset, 7)
|
||||||
|
pitch = generator.solo.types.Pitch(generator.model.LETTERS[degree], fractions.Fraction(), octave)
|
||||||
|
notes.append(generator.solo.types.Note(unit * i, unit * (i + 1), pitch))
|
||||||
|
return generator.solo.types.Motif('new_' + path.id.replace('/', '_').removesuffix('.xml'), tuple(notes), length, (), 'sequence ' + path.id)
|
||||||
|
|
||||||
|
def choose_motifs(analysis, sequences, length):
|
||||||
|
eligible = [m for m in analysis.motifs if m.duration <= 2 * length]
|
||||||
|
head = next((m for m in eligible if len(m.notes) >= 2), eligible[0] if eligible else new_motif(sequences, length))
|
||||||
|
others = [m for m in eligible if m.id != head.id]
|
||||||
|
contrast = min(others, key=lambda m: (m.contour() == head.contour(), abs(len(m.notes) - 3), m.duration, m.id)) if others else head
|
||||||
|
return head, contrast, new_motif(sequences, length)
|
||||||
|
|
||||||
|
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]:
|
||||||
|
boundaries = [fractions.Fraction()]
|
||||||
|
for measure in measures:
|
||||||
|
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
|
||||||
|
head, contrast, invented = choose_motifs(analysis, sequences, boundaries[1])
|
||||||
|
result, index = [], 0
|
||||||
|
while index < len(measures) and boundaries[index] < end:
|
||||||
|
start = boundaries[index]
|
||||||
|
possible = list(range(min(index + 2, len(measures)), min(index + 4, len(measures)) + 1))
|
||||||
|
|
||||||
|
def boundary_rank(stop):
|
||||||
|
time = boundaries[stop]
|
||||||
|
before = next((c for c in reversed(choices) if c.span.start < time), None)
|
||||||
|
after = next((c for c in choices if c.span.start >= time and c.span.chord), None)
|
||||||
|
resolution = before and after and before.span.chord and generator.scales.selection.dominant(before.span.chord) and (generator.scales.selection.root_number(before.span.chord) - generator.scales.selection.root_number(after.span.chord)) % 12 == 7
|
||||||
|
source_index = min(stop + int(len(analysis.boundaries) > len(measures) + 1), len(analysis.boundaries) - 1)
|
||||||
|
source_time = analysis.boundaries[source_index]
|
||||||
|
preceding = [n for n in analysis.melody if n.start < source_time]
|
||||||
|
breathing = bool(preceding) and preceding[-1].end <= source_time and (preceding[-1].end < source_time or preceding[-1].end - preceding[-1].start >= 2)
|
||||||
|
return not resolution, not breathing, abs(stop - index - 3), stop
|
||||||
|
|
||||||
|
stop = min(possible, key=boundary_rank)
|
||||||
|
finish = boundaries[stop]
|
||||||
|
final = finish == end
|
||||||
|
phase = (start + finish) / (2 * end)
|
||||||
|
motif = head if part == 'acoustic' or final else invented
|
||||||
|
if len(result) > 1 and not final and len(result) % 3 == 2:
|
||||||
|
motif = contrast if part == 'acoustic' else invented
|
||||||
|
relationship = 'statement' if not result else 'callback' if final else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
|
||||||
|
treatment = 'quote' if (part == 'acoustic' and not result) or (part == 'electric' and final and motif.origin == 'melody') else 'thematic'
|
||||||
|
low, high = (55, 74) if part == 'acoustic' else (59, 79)
|
||||||
|
# Reserve the upper register for a prepared peak, not every active passage.
|
||||||
|
register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4)
|
||||||
|
activity = fractions.Fraction(3, 4) if part == 'acoustic' else fractions.Fraction(1)
|
||||||
|
if final:
|
||||||
|
activity *= fractions.Fraction(2, 3)
|
||||||
|
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final), register, activity))
|
||||||
|
index = stop
|
||||||
|
return tuple(result)
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import fractions
|
||||||
|
import functools
|
||||||
|
import heapq
|
||||||
|
|
||||||
|
import generator.fretboard
|
||||||
|
import generator.model
|
||||||
|
import generator.rhythm
|
||||||
|
import generator.scales.selection
|
||||||
|
import generator.solo.placement
|
||||||
|
import generator.solo.planning
|
||||||
|
import generator.solo.types
|
||||||
|
|
||||||
|
BEAM_WIDTH = 64
|
||||||
|
CELL_LIMIT = 32
|
||||||
|
|
||||||
|
class SearchExhausted(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Cell:
|
||||||
|
start: fractions.Fraction
|
||||||
|
end: fractions.Fraction
|
||||||
|
patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...]
|
||||||
|
motif: generator.solo.types.Motif | None = None
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Candidate:
|
||||||
|
notes: tuple[generator.solo.types.Note, ...]
|
||||||
|
source: str
|
||||||
|
treatment: str
|
||||||
|
rank: tuple
|
||||||
|
|
||||||
|
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction):
|
||||||
|
result = {}
|
||||||
|
for snippet in library.for_meter(meter):
|
||||||
|
for index, bar in enumerate(snippet.bars):
|
||||||
|
spans = []
|
||||||
|
for event in bar.events:
|
||||||
|
if not event.sounding:
|
||||||
|
continue
|
||||||
|
if event.continuation:
|
||||||
|
if spans and spans[-1][1] == event.onset:
|
||||||
|
spans[-1] = (spans[-1][0], event.onset + event.duration)
|
||||||
|
else:
|
||||||
|
# A continuation without its attack is not an independent cell.
|
||||||
|
spans = []
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
spans.append((event.onset, event.onset + event.duration))
|
||||||
|
pattern = tuple(spans)
|
||||||
|
if all(end <= length for _, end in pattern) and len(pattern) <= 4:
|
||||||
|
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}')
|
||||||
|
ranked = sorted(result.items(), key=lambda item: (abs(len(item[0]) - length * activity), -sum(end - start for start, end in item[0]), item[1]))
|
||||||
|
return tuple((identifier, pattern) for pattern, identifier in ranked[:8])
|
||||||
|
|
||||||
|
def cells(plan, measures, rhythms):
|
||||||
|
result, start, index = [], fractions.Fraction(), 0
|
||||||
|
boundaries = [fractions.Fraction()]
|
||||||
|
for measure in measures:
|
||||||
|
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||||
|
start = plan.start
|
||||||
|
music_end = plan.end - plan.release
|
||||||
|
arrival_fits = not plan.target or plan.motif.duration < music_end - start or any(plan.target.earliest <= start + n.start <= plan.target.latest for n in plan.motif.notes)
|
||||||
|
if plan.motif.duration <= music_end - start and arrival_fits:
|
||||||
|
motif = plan.motif
|
||||||
|
spans = tuple((n.start, n.end) for n in motif.notes)
|
||||||
|
patterns = [(motif.id, spans)]
|
||||||
|
if plan.relationship == 'variation' and spans[-1][1] + fractions.Fraction(1, 2) <= motif.duration:
|
||||||
|
patterns.insert(0, (motif.id + ':displaced', tuple((a + fractions.Fraction(1, 2), b + fractions.Fraction(1, 2)) for a, b in spans)))
|
||||||
|
result.append(Cell(start, start + motif.duration, tuple(patterns), motif))
|
||||||
|
start += motif.duration
|
||||||
|
elif plan.motif.notes and plan.motif.duration > music_end - start:
|
||||||
|
# Fragmentation is an explicit transformation, never a truncated quotation.
|
||||||
|
available = (plan.target.earliest if plan.target else music_end) - start
|
||||||
|
notes = tuple(n for n in plan.motif.notes if n.end <= available)
|
||||||
|
if notes:
|
||||||
|
motif = dataclasses.replace(plan.motif, notes=notes, duration=notes[-1].end)
|
||||||
|
result.append(Cell(start, start + motif.duration, ((motif.id + ':fragment', tuple((n.start, n.end) for n in notes)),), motif))
|
||||||
|
start += motif.duration
|
||||||
|
while start < music_end:
|
||||||
|
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
|
||||||
|
end = min(music_end, boundaries[index + 1])
|
||||||
|
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, plan.activity)
|
||||||
|
# These primitives are deliberate alternatives, independent of catalog counts.
|
||||||
|
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
|
||||||
|
if plan.target and start <= plan.target.latest < end:
|
||||||
|
onset = max(start, plan.target.earliest) - start
|
||||||
|
patterns = (('arrival_and_release', ((onset, min(end - start, onset + 1)),)), *patterns)
|
||||||
|
result.append(Cell(start, end, patterns))
|
||||||
|
start = end
|
||||||
|
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=2048)
|
||||||
|
def scale_options(scale, allowed, low, high):
|
||||||
|
return tuple(generator.solo.types.scale_pitch(scale, index) for index in range(-21, 28) if low <= generator.solo.types.scale_pitch(scale, index).number() <= high and generator.solo.types.scale_pitch(scale, index).number() % 12 in allowed and generator.fretboard.positions_for_pitch(fractions.Fraction(generator.solo.types.scale_pitch(scale, index).number())))
|
||||||
|
|
||||||
|
def valid_notes(notes, choices):
|
||||||
|
return all(n.pitch.number() % 12 in generator.solo.types.permitted(choices, n.start, n.end) for n in notes)
|
||||||
|
|
||||||
|
def options(choices, start, end, register):
|
||||||
|
scale = next((c.scale for c in choices if c.span.start <= start < c.span.end), None)
|
||||||
|
allowed = generator.solo.types.permitted(choices, start, end)
|
||||||
|
return scale_options(scale, allowed, max(41, register[0] - 7), min(84, register[1] + 5)) if scale and allowed else ()
|
||||||
|
|
||||||
|
def target_ok(notes, target):
|
||||||
|
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
|
||||||
|
|
||||||
|
def thematic_paths(cell, spans, choices, plan):
|
||||||
|
states = [(0, ())]
|
||||||
|
for index, (start, end) in enumerate(spans):
|
||||||
|
following = []
|
||||||
|
for cost, path in states:
|
||||||
|
available = options(choices, start, end, plan.register)
|
||||||
|
changing_ending = plan.relationship == 'answer' and index == len(spans) - 1
|
||||||
|
if changing_ending:
|
||||||
|
chord = next((c.span.chord for c in choices if c.span.start <= start < c.span.end), None)
|
||||||
|
if chord:
|
||||||
|
available = tuple(p for p in available if p.number() % 12 in generator.solo.planning.target_pitches(chord, False))
|
||||||
|
for pitch in available:
|
||||||
|
reference = cell.motif.notes[index]
|
||||||
|
expected = cell.motif.contour()[index - 1] if index else 0
|
||||||
|
motion = pitch.number() - path[-1].pitch.number() if path else 0
|
||||||
|
# Repeated notes are identity, not missing sequence transitions.
|
||||||
|
mismatch = int(bool(index) and ((expected == 0) != (motion == 0) or expected * motion < 0))
|
||||||
|
note = generator.solo.types.Note(start, end, pitch, reference.sources, 'motif')
|
||||||
|
following.append((cost + (0 if changing_ending else 8 * mismatch + abs(motion - expected)), (*path, note)))
|
||||||
|
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:BEAM_WIDTH]
|
||||||
|
if not states:
|
||||||
|
break
|
||||||
|
return states
|
||||||
|
|
||||||
|
def adapt_path(pitches, spans, choices, register):
|
||||||
|
"""Connect actual pitches across collections without carrying a stale degree index."""
|
||||||
|
states = [(0, ())]
|
||||||
|
for index, ((start, end), intended) in enumerate(zip(spans, pitches)):
|
||||||
|
available = options(choices, start, end, register)
|
||||||
|
following = []
|
||||||
|
for cost, path in states:
|
||||||
|
desired = path[-1].pitch.number() + intended.number() - pitches[index - 1].number() if path else intended.number()
|
||||||
|
for pitch in sorted(available, key=lambda p: (abs(p.number() - desired), p))[:2]:
|
||||||
|
following.append((cost + abs(pitch.number() - desired), (*path, generator.solo.types.Note(start, end, pitch, role='adapted_path'))))
|
||||||
|
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:8]
|
||||||
|
return [notes for _, notes in states]
|
||||||
|
|
||||||
|
def sequence_paths(spans, choices, plan, sequences):
|
||||||
|
if not spans:
|
||||||
|
return [((), 'space')]
|
||||||
|
first_scale = next((c.scale for c in choices if c.span.start <= spans[0][0] < c.span.end), None)
|
||||||
|
if first_scale is None:
|
||||||
|
return []
|
||||||
|
paths = []
|
||||||
|
allowed = tuple(generator.solo.types.permitted(choices, start, end) for start, end in spans)
|
||||||
|
changing = any(classes != allowed[0] for classes in allowed)
|
||||||
|
sequence_options = sequences.connecting(len(spans))[:16]
|
||||||
|
offsets = [(s.offsets(), 'sequence ' + s.id) for s in sequence_options]
|
||||||
|
# Single notes and explicit repetitions do not pretend to be catalog paths.
|
||||||
|
offsets.append(((0,) * len(spans), 'single' if len(spans) == 1 else 'repeated_note'))
|
||||||
|
for steps, identifier in offsets:
|
||||||
|
for target_index in range(-14, 21):
|
||||||
|
start_index = target_index - steps[-1]
|
||||||
|
pitches = tuple(generator.solo.types.scale_pitch(first_scale, start_index + step) for step in steps)
|
||||||
|
if not all(max(41, plan.register[0] - 7) <= p.number() <= min(84, plan.register[1] + 5) for p in pitches):
|
||||||
|
continue
|
||||||
|
if all(pitch.number() % 12 in classes for pitch, classes in zip(pitches, allowed)):
|
||||||
|
notes = tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches))
|
||||||
|
paths.append((notes, identifier))
|
||||||
|
elif changing and not paths and steps == offsets[0][0]:
|
||||||
|
paths.extend((notes, 'harmonic_adaptation of ' + identifier) for notes in adapt_path(pitches, spans, choices, plan.register))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def candidates(cell, plan, choices, sequences):
|
||||||
|
result = []
|
||||||
|
target = plan.target if plan.target and cell.start <= plan.target.latest < cell.end else None
|
||||||
|
for identifier, pattern in cell.patterns:
|
||||||
|
spans = tuple((cell.start + start, cell.start + end) for start, end in pattern)
|
||||||
|
active = tuple(i for i, (start, end) in enumerate(spans) if not any(c.scale is None and c.span.start < end and c.span.end > start for c in choices))
|
||||||
|
local_cell = cell
|
||||||
|
if len(active) != len(spans):
|
||||||
|
spans = tuple(spans[i] for i in active)
|
||||||
|
identifier += ':harmonic_fragment'
|
||||||
|
if cell.motif:
|
||||||
|
local_cell = dataclasses.replace(cell, motif=dataclasses.replace(cell.motif, notes=tuple(cell.motif.notes[i] for i in active)))
|
||||||
|
paths = []
|
||||||
|
if local_cell.motif and spans:
|
||||||
|
if plan.treatment == 'quote' and ':fragment' not in identifier and ':harmonic_fragment' not in identifier and ':displaced' not in identifier:
|
||||||
|
for octaves in (0, 1, -1, 2, -2):
|
||||||
|
notes = tuple(generator.solo.types.Note(start, end, dataclasses.replace(source.pitch, octave=source.pitch.octave + octaves), source.sources, 'quote') for (start, end), source in zip(spans, local_cell.motif.notes))
|
||||||
|
if valid_notes(notes, choices):
|
||||||
|
paths.append((notes, f'quote octave {octaves:+}', 0))
|
||||||
|
for mismatch, notes in thematic_paths(local_cell, spans, choices, plan):
|
||||||
|
treatment = 'fragment' if 'fragment' in identifier else 'rhythmic_displacement' if ':displaced' in identifier else 'changed_ending' if plan.relationship == 'answer' else 'harmonic_adaptation'
|
||||||
|
paths.append((notes, treatment, 1 + mismatch))
|
||||||
|
else:
|
||||||
|
paths = [(notes, name, 0) for notes, name in sequence_paths(spans, choices, plan, sequences)]
|
||||||
|
for notes, treatment, identity in paths:
|
||||||
|
if not target_ok(notes, target):
|
||||||
|
continue
|
||||||
|
notes = tuple(dataclasses.replace(n, role='target' if target and target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches else n.role) for n in notes)
|
||||||
|
register = 16 * sum(max(0, plan.register[0] - n.pitch.number(), n.pitch.number() - plan.register[1]) for n in notes)
|
||||||
|
if notes:
|
||||||
|
register += abs(max(n.pitch.number() for n in notes) - sum(plan.register) // 2)
|
||||||
|
density = abs(len(notes) - (cell.end - cell.start) * plan.activity)
|
||||||
|
contour = sum(max(0, abs(b.pitch.number() - a.pitch.number()) - 5) for a, b in zip(notes, notes[1:]))
|
||||||
|
if not cell.motif and len({n.pitch.number() for n in notes}) == 1:
|
||||||
|
contour += max(0, len(notes) - 2)
|
||||||
|
rank = (identity, register, density if not cell.motif else 0, contour)
|
||||||
|
result.append(Candidate(notes, identifier, treatment, rank))
|
||||||
|
# A no-harmony passage is intentional silence, not a failed pitch search.
|
||||||
|
if not any(c.span.chord and c.span.start < cell.end and c.span.end > cell.start for c in choices):
|
||||||
|
result = [Candidate((), 'no_harmony', 'space', (0, 0, 0, 0))]
|
||||||
|
return sorted(result, key=lambda c: (c.rank, c.source, c.treatment, tuple(n.pitch for n in c.notes)))
|
||||||
|
|
||||||
|
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
|
||||||
|
states = [((0, 0, 0, 0, 0, 0, 0), (), previous)]
|
||||||
|
for cell in cells(plan, measures, rhythms):
|
||||||
|
pool = candidates(cell, plan, choices, sequences)
|
||||||
|
following, extensions = [], {}
|
||||||
|
for cost, decisions, last in states:
|
||||||
|
|
||||||
|
def extension_rank(candidate):
|
||||||
|
jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) if candidate.notes and last else 0
|
||||||
|
return candidate.rank, jump, candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes)
|
||||||
|
|
||||||
|
key = last.pitch.number() if last else None
|
||||||
|
if key not in extensions:
|
||||||
|
extensions[key] = heapq.nsmallest(CELL_LIMIT, pool, key=extension_rank)
|
||||||
|
for candidate in extensions[key]:
|
||||||
|
placed = generator.solo.placement.place(candidate.notes, tempo, last)
|
||||||
|
if placed is None:
|
||||||
|
continue
|
||||||
|
comfort, notes = placed
|
||||||
|
jump = abs(notes[0].pitch.number() - last.pitch.number()) if notes and last else 0
|
||||||
|
monotony = int(bool(notes and decisions and decisions[-1].notes) and tuple(n.pitch for n in notes) == tuple(n.pitch for n in decisions[-1].notes) and cell.motif is None)
|
||||||
|
local = (*candidate.rank, jump, comfort, monotony)
|
||||||
|
total = tuple(a + b for a, b in zip(cost, local))
|
||||||
|
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes)
|
||||||
|
following.append((total, (*decisions, decision), notes[-1] if notes else last))
|
||||||
|
if not following:
|
||||||
|
raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; no feasible phrase extension among {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
|
||||||
|
following.sort(key=lambda item: (item[0], tuple((d.source, d.treatment, tuple((n.pitch, n.position) for n in d.notes)) for d in item[1])))
|
||||||
|
# Retain distinct endings for lookahead instead of 64 copies of one path.
|
||||||
|
unique = {}
|
||||||
|
for item in following:
|
||||||
|
last = item[2]
|
||||||
|
key = (last.pitch, last.position, last.end) if last else None
|
||||||
|
unique.setdefault(key, item)
|
||||||
|
states = list(unique.values())[:BEAM_WIDTH]
|
||||||
|
_, decisions, _ = states[0]
|
||||||
|
return generator.solo.types.Phrase(plan, decisions)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
import generator.harmony
|
||||||
|
import generator.model
|
||||||
|
import generator.scales.report
|
||||||
|
import generator.scales.selection
|
||||||
|
import generator.scales.timeline
|
||||||
|
import generator.solo.types
|
||||||
|
|
||||||
|
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
|
||||||
|
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
|
||||||
|
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
|
||||||
|
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
|
||||||
|
for phrase in solo.phrases:
|
||||||
|
plan = phrase.plan
|
||||||
|
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
|
||||||
|
if plan.target:
|
||||||
|
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
|
||||||
|
for decision in phrase.decisions:
|
||||||
|
lines.append(f' {decision.start}-{decision.end}: {decision.source}; {decision.treatment}')
|
||||||
|
if not decision.notes:
|
||||||
|
lines.append(' Silence')
|
||||||
|
for note in decision.notes:
|
||||||
|
choice = next(c for c in solo.choices if c.span.start <= note.start < c.span.end)
|
||||||
|
root = generator.scales.selection.root_number(choice.span.chord)
|
||||||
|
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
|
||||||
|
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
|
||||||
|
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
|
||||||
|
return '\n'.join(lines) + '\n'
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import pathlib
|
||||||
|
import types
|
||||||
|
|
||||||
|
import generator.model
|
||||||
|
import generator.musicxml
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Sequence:
|
||||||
|
id: str
|
||||||
|
steps: tuple[int, ...]
|
||||||
|
def offsets(self) -> tuple[int, ...]:
|
||||||
|
result = [0]
|
||||||
|
for step in self.steps:
|
||||||
|
result.append(result[-1] + step)
|
||||||
|
return tuple(result)
|
||||||
|
def rank(self) -> tuple:
|
||||||
|
offsets = self.offsets()
|
||||||
|
return max(map(abs, self.steps)), max(offsets) - min(offsets), sum(a * b < 0 for a, b in zip(self.steps, self.steps[1:])), self.id
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, init=False)
|
||||||
|
class Library:
|
||||||
|
paths: types.MappingProxyType
|
||||||
|
def __init__(self, sequences: tuple[Sequence, ...]):
|
||||||
|
paths, identities = {}, set()
|
||||||
|
for sequence in sequences:
|
||||||
|
if sequence.id in identities or not 1 <= len(sequence.steps) <= 3 or any(not 1 <= abs(step) <= 7 for step in sequence.steps):
|
||||||
|
raise ValueError(f'{sequence.id}: invalid or duplicate sequence')
|
||||||
|
identities.add(sequence.id)
|
||||||
|
paths.setdefault(len(sequence.steps) + 1, []).append(sequence)
|
||||||
|
object.__setattr__(self, 'paths', types.MappingProxyType({count: tuple(sorted(items, key=lambda s: s.rank())) for count, items in paths.items()}))
|
||||||
|
def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]:
|
||||||
|
return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement)
|
||||||
|
|
||||||
|
def read(path: pathlib.Path, identifier: str, count: int) -> Sequence:
|
||||||
|
document = generator.musicxml.load_musicxml(path)
|
||||||
|
measures = document['staves'][0]['measures']
|
||||||
|
if len(measures) != 1 or len(measures[0]['voices']) != 1 or measures[0]['time_signature'] != f'{count}/4':
|
||||||
|
raise ValueError(f'{path}: expected one {count}/4 sequence measure and one voice')
|
||||||
|
notes = measures[0]['voices'][0]['notes']
|
||||||
|
if len(notes) != count:
|
||||||
|
raise ValueError(f'{path}: expected {count} sequence notes')
|
||||||
|
degrees = []
|
||||||
|
for index, event in enumerate(notes):
|
||||||
|
if event.get('grace') or len(event['pitches']) != 1 or generator.model.fraction(event['onset']) != index or generator.model.fraction(event['duration']) != 1:
|
||||||
|
raise ValueError(f'{path}: sequence requires consecutive untied quarter notes')
|
||||||
|
pitch = event['pitches'][0]
|
||||||
|
if generator.model.alteration(pitch['alter']) or 'tie_from' in pitch or 'tie_to' in pitch:
|
||||||
|
raise ValueError(f'{path}: sequences must use untied C-major scale degrees')
|
||||||
|
degrees.append(7 * pitch['octave'] + generator.model.LETTERS.index(pitch['letter']))
|
||||||
|
if degrees[0] != 28:
|
||||||
|
raise ValueError(f'{path}: sequence must begin on C4')
|
||||||
|
return Sequence(identifier, tuple(b - a for a, b in zip(degrees, degrees[1:])))
|
||||||
|
|
||||||
|
def load_library(root: pathlib.Path) -> Library:
|
||||||
|
sequences = []
|
||||||
|
for path in sorted(root.rglob('*.xml')):
|
||||||
|
relative = path.relative_to(root)
|
||||||
|
if len(relative.parts) != 2 or relative.parts[0] not in ('2_4', '3_4', '4_4'):
|
||||||
|
raise ValueError(f'{path}: sequence sources belong under 2_4, 3_4, or 4_4')
|
||||||
|
sequences.append(read(path, relative.as_posix(), int(relative.parts[0][0])))
|
||||||
|
if not sequences:
|
||||||
|
raise ValueError(f'{root}: sequence library is empty or missing')
|
||||||
|
return Library(tuple(sequences))
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import fractions
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import generator.fretboard
|
||||||
|
import generator.model
|
||||||
|
import generator.scales.library
|
||||||
|
import generator.scales.selection
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, order=True)
|
||||||
|
class Pitch:
|
||||||
|
letter: str
|
||||||
|
alter: fractions.Fraction
|
||||||
|
octave: int
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.letter not in generator.model.NATURAL or self.alter.denominator != 1:
|
||||||
|
raise ValueError('solo pitches require spelled semitones from the permitted scales')
|
||||||
|
def number(self) -> int:
|
||||||
|
return generator.model.NATURAL[self.letter] + int(self.alter) + 12 * (self.octave + 1)
|
||||||
|
def written(self) -> dict:
|
||||||
|
return {'letter': self.letter, 'alter': generator.model.rational(self.alter), 'octave': self.octave + 1}
|
||||||
|
def label(self) -> str:
|
||||||
|
return generator.model.pitch_name(self.written()) + str(self.octave)
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Note:
|
||||||
|
start: fractions.Fraction
|
||||||
|
end: fractions.Fraction
|
||||||
|
pitch: Pitch
|
||||||
|
sources: tuple[str, ...] = ()
|
||||||
|
role: str = 'continuation'
|
||||||
|
position: generator.fretboard.TabPosition | None = None
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Motif:
|
||||||
|
id: str
|
||||||
|
notes: tuple[Note, ...]
|
||||||
|
duration: fractions.Fraction
|
||||||
|
context: tuple[str, ...]
|
||||||
|
origin: str = 'melody'
|
||||||
|
def contour(self) -> tuple[int, ...]:
|
||||||
|
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class TargetEvent:
|
||||||
|
earliest: fractions.Fraction
|
||||||
|
latest: fractions.Fraction
|
||||||
|
pitches: frozenset[int]
|
||||||
|
chord: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class PhrasePlan:
|
||||||
|
id: str
|
||||||
|
start: fractions.Fraction
|
||||||
|
end: fractions.Fraction
|
||||||
|
motif: Motif
|
||||||
|
relationship: str
|
||||||
|
treatment: str
|
||||||
|
target: TargetEvent | None
|
||||||
|
register: tuple[int, int]
|
||||||
|
activity: fractions.Fraction
|
||||||
|
release: fractions.Fraction = fractions.Fraction(1, 2)
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Decision:
|
||||||
|
start: fractions.Fraction
|
||||||
|
end: fractions.Fraction
|
||||||
|
source: str
|
||||||
|
treatment: str
|
||||||
|
notes: tuple[Note, ...]
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Phrase:
|
||||||
|
plan: PhrasePlan
|
||||||
|
decisions: tuple[Decision, ...]
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Solo:
|
||||||
|
part: str
|
||||||
|
choices: tuple[generator.scales.selection.Choice, ...]
|
||||||
|
phrases: tuple[Phrase, ...]
|
||||||
|
tempo: fractions.Fraction
|
||||||
|
end: fractions.Fraction
|
||||||
|
def notes(self) -> tuple[Note, ...]:
|
||||||
|
return tuple(n for p in self.phrases for d in p.decisions for n in d.notes)
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=4096)
|
||||||
|
def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = 4) -> Pitch:
|
||||||
|
"""Index an extended scale, including negative degrees and octave crossings."""
|
||||||
|
root = generator.model.parse_pitch_name(scale.root)
|
||||||
|
root_number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) + 12 * (octave + 1)
|
||||||
|
turns, degree = divmod(index, 7)
|
||||||
|
name = generator.model.parse_pitch_name(scale.notes[degree])
|
||||||
|
number = root_number + 12 * turns + scale.pattern.intervals[degree]
|
||||||
|
alter = generator.model.alteration(name['alter'])
|
||||||
|
return Pitch(name['letter'], alter, int((number - generator.model.NATURAL[name['letter']] - alter) // 12 - 1))
|
||||||
|
|
||||||
|
def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fractions.Fraction, end: fractions.Fraction) -> frozenset[int]:
|
||||||
|
covered = start
|
||||||
|
pitches = frozenset(range(12))
|
||||||
|
for choice in choices:
|
||||||
|
if choice.span.end <= start or choice.span.start >= end:
|
||||||
|
continue
|
||||||
|
if choice.span.start > covered or choice.scale is None:
|
||||||
|
return frozenset()
|
||||||
|
pitches &= choice.scale.pitches
|
||||||
|
covered = max(covered, choice.span.end)
|
||||||
|
return pitches if covered >= end else frozenset()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import functools
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
import generator.solo.sequences
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def library():
|
||||||
|
return generator.solo.sequences.Library(tuple(generator.solo.sequences.Sequence(f'{count}_4/{index:04}.xml', steps) for count in (2, 3, 4) for index, steps in enumerate(itertools.product((1, -1, 2, -2), repeat=count - 1))))
|
||||||
@@ -5,6 +5,8 @@ import unittest
|
|||||||
|
|
||||||
import generator.arrange.generate
|
import generator.arrange.generate
|
||||||
import tests.rhythm_fixtures
|
import tests.rhythm_fixtures
|
||||||
|
import tests.scale_fixtures
|
||||||
|
import tests.solo_fixtures
|
||||||
import tests.test_musicxml
|
import tests.test_musicxml
|
||||||
|
|
||||||
class CatalogTests(unittest.TestCase):
|
class CatalogTests(unittest.TestCase):
|
||||||
@@ -13,7 +15,7 @@ class CatalogTests(unittest.TestCase):
|
|||||||
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
|
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
|
||||||
source.mkdir()
|
source.mkdir()
|
||||||
(source / 'song.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
(source / 'song.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||||
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 3)
|
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()), 3)
|
||||||
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
|
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
|
||||||
for path in output.iterdir():
|
for path in output.iterdir():
|
||||||
metadata = json.loads(path.read_text())['metadata']
|
metadata = json.loads(path.read_text())['metadata']
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ import generator.pico_artwork
|
|||||||
import generator.score
|
import generator.score
|
||||||
import tests.rhythm_fixtures
|
import tests.rhythm_fixtures
|
||||||
import tests.scale_fixtures
|
import tests.scale_fixtures
|
||||||
|
import tests.solo_fixtures
|
||||||
import tests.test_musicxml
|
import tests.test_musicxml
|
||||||
|
|
||||||
class EngravingCacheTests(unittest.TestCase):
|
class EngravingCacheTests(unittest.TestCase):
|
||||||
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
|
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
|
||||||
workflow = pathlib.Path('.gitea/workflows/ci.yml').read_text()
|
workflow = pathlib.Path('.gitea/workflows/ci.yml').read_text()
|
||||||
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/rhythms/**/*.xml', 'data/scales/**/*.xml')", workflow)
|
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/rhythms/**/*.xml', 'data/scales/**/*.xml', 'data/sequences/**/*.xml')", workflow)
|
||||||
self.assertIn('python3 modulus.py python -m generator --renderer-fingerprint', workflow)
|
self.assertIn('python3 modulus.py python -m generator --renderer-fingerprint', workflow)
|
||||||
self.assertIn('restore-keys: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_', workflow)
|
self.assertIn('restore-keys: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_', workflow)
|
||||||
self.assertIn('path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}', workflow)
|
self.assertIn('path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}', workflow)
|
||||||
@@ -347,6 +348,7 @@ class EngravingCacheTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, 'must not overlap'):
|
with self.assertRaisesRegex(ValueError, 'must not overlap'):
|
||||||
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=cache)
|
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=cache)
|
||||||
def test_parallel_generation_preserves_assets_on_worker_failure(self):
|
def test_parallel_generation_preserves_assets_on_worker_failure(self):
|
||||||
|
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
|
||||||
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
||||||
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
|
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
|
||||||
data, output = self.root / 'data', self.root / 'output'
|
data, output = self.root / 'data', self.root / 'output'
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import generator.playback
|
|||||||
import tests.arrangement_fixtures
|
import tests.arrangement_fixtures
|
||||||
import tests.rhythm_fixtures
|
import tests.rhythm_fixtures
|
||||||
import tests.scale_fixtures
|
import tests.scale_fixtures
|
||||||
|
import tests.solo_fixtures
|
||||||
import tests.test_musicxml
|
import tests.test_musicxml
|
||||||
|
|
||||||
class GuitarTests(unittest.TestCase):
|
class GuitarTests(unittest.TestCase):
|
||||||
@@ -248,11 +249,12 @@ class GuitarTests(unittest.TestCase):
|
|||||||
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
|
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
|
||||||
source.mkdir()
|
source.mkdir()
|
||||||
(source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
(source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||||
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 3)
|
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()), 3)
|
||||||
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
|
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
|
||||||
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library())
|
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
|
||||||
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
|
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
|
||||||
def test_unplayable_generation_preserves_previous_assets(self):
|
def test_unplayable_generation_preserves_previous_assets(self):
|
||||||
|
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
|
||||||
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
root = pathlib.Path(temporary)
|
root = pathlib.Path(temporary)
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import generator.parts
|
|||||||
import generator.pitch
|
import generator.pitch
|
||||||
import tests.rhythm_fixtures
|
import tests.rhythm_fixtures
|
||||||
import tests.scale_fixtures
|
import tests.scale_fixtures
|
||||||
|
import tests.solo_fixtures
|
||||||
import tests.test_musicxml
|
import tests.test_musicxml
|
||||||
|
|
||||||
class MusicModelTests(unittest.TestCase):
|
class MusicModelTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
|
||||||
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
||||||
engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree')
|
engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree')
|
||||||
engraving.start()
|
engraving.start()
|
||||||
|
|||||||
+3
-1
@@ -19,6 +19,7 @@ import generator.playback
|
|||||||
import tests.arrangement_fixtures
|
import tests.arrangement_fixtures
|
||||||
import tests.rhythm_fixtures
|
import tests.rhythm_fixtures
|
||||||
import tests.scale_fixtures
|
import tests.scale_fixtures
|
||||||
|
import tests.solo_fixtures
|
||||||
import tests.test_musicxml
|
import tests.test_musicxml
|
||||||
|
|
||||||
def xml_harmony(root='C', kind='major', offset=0):
|
def xml_harmony(root='C', kind='major', offset=0):
|
||||||
@@ -166,11 +167,12 @@ class PartGenerationTests(unittest.TestCase):
|
|||||||
for name in names:
|
for name in names:
|
||||||
(source / (name + '.xml')).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
(source / (name + '.xml')).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||||
with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised:
|
with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised:
|
||||||
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library())
|
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
|
||||||
for name in names:
|
for name in names:
|
||||||
self.assertIn(str(source / (name + '.xml')), str(raised.exception))
|
self.assertIn(str(source / (name + '.xml')), str(raised.exception))
|
||||||
self.assertEqual([p.name for p in output.iterdir()], ['sentinel'])
|
self.assertEqual([p.name for p in output.iterdir()], ['sentinel'])
|
||||||
def test_pipeline_parses_and_normalizes_each_source_once(self):
|
def test_pipeline_parses_and_normalizes_each_source_once(self):
|
||||||
|
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
|
||||||
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = pathlib.Path(directory)
|
root = pathlib.Path(directory)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
data, output = root / 'data', root / 'output'
|
data, output = root / 'data', root / 'output'
|
||||||
(data / 'songs').mkdir(parents=True)
|
(data / 'songs').mkdir(parents=True)
|
||||||
shutil.copytree(pathlib.Path('data/scales'), data / 'scales')
|
shutil.copytree(pathlib.Path('data/scales'), data / 'scales')
|
||||||
|
shutil.copytree(pathlib.Path('data/sequences/2_4'), data / 'sequences/2_4')
|
||||||
source = data / 'songs/fixture.xml'
|
source = data / 'songs/fixture.xml'
|
||||||
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
|
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
|
||||||
@@ -49,12 +50,17 @@ class ScoreBundleTests(unittest.TestCase):
|
|||||||
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||||
self.assertTrue(list(output.rglob('*.png')))
|
self.assertTrue(list(output.rglob('*.png')))
|
||||||
(data / 'rhythms' / 'drafts').mkdir(parents=True)
|
(data / 'rhythms' / 'drafts').mkdir(parents=True)
|
||||||
(data / 'sequences' / '3_4').mkdir(parents=True)
|
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml'):
|
||||||
(data / 'sequences/3_4/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
||||||
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml', 'sequences/0001.xml', 'sequences/3_4/0002.xml'):
|
|
||||||
(data / path).write_text('<unfinished')
|
(data / path).write_text('<unfinished')
|
||||||
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()})
|
||||||
|
sequence = data / 'sequences/2_4/0001.xml'
|
||||||
|
saved = sequence.read_bytes()
|
||||||
|
sequence.write_text('<unfinished')
|
||||||
|
with self.assertRaisesRegex(ValueError, '0001.xml'):
|
||||||
|
generator.__main__.generate_assets(args)
|
||||||
|
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||||
|
sequence.write_bytes(saved)
|
||||||
scale = data / 'scales/major_scale_modes/0002.xml'
|
scale = data / 'scales/major_scale_modes/0002.xml'
|
||||||
previous = scale.read_bytes()
|
previous = scale.read_bytes()
|
||||||
scale.write_text('<unfinished')
|
scale.write_text('<unfinished')
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import dataclasses
|
||||||
|
import fractions
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
|
import generator.__main__
|
||||||
|
import generator.arrange.generate
|
||||||
|
import generator.fretboard
|
||||||
|
import generator.harmony
|
||||||
|
import generator.model
|
||||||
|
import generator.parts
|
||||||
|
import generator.playback
|
||||||
|
import generator.scales.library
|
||||||
|
import generator.scales.selection
|
||||||
|
import generator.scales.timeline
|
||||||
|
import generator.score
|
||||||
|
import generator.solo.analysis
|
||||||
|
import generator.solo.arrangement
|
||||||
|
import generator.solo.composer
|
||||||
|
import generator.solo.notation
|
||||||
|
import generator.solo.placement
|
||||||
|
import generator.solo.planning
|
||||||
|
import generator.solo.realization
|
||||||
|
import generator.solo.report
|
||||||
|
import generator.solo.sequences
|
||||||
|
import generator.solo.types
|
||||||
|
import tests.rhythm_fixtures
|
||||||
|
import tests.scale_fixtures
|
||||||
|
import tests.solo_fixtures
|
||||||
|
import tests.test_musicxml
|
||||||
|
|
||||||
|
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
|
||||||
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
|
||||||
|
first = document['staves'][0]['measures'][0]
|
||||||
|
measures = []
|
||||||
|
for index in range(count):
|
||||||
|
measure = copy.deepcopy(first)
|
||||||
|
measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4')
|
||||||
|
measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)]
|
||||||
|
measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else []
|
||||||
|
for harmony in measure['harmonies']:
|
||||||
|
harmony['symbol'] = generator.harmony.symbol(harmony)
|
||||||
|
measures.append(measure)
|
||||||
|
document['staves'][0]['measures'] = measures
|
||||||
|
return generator.parts.prepare_document(document)
|
||||||
|
|
||||||
|
def compose(document, part='acoustic', cutoff=None):
|
||||||
|
analysis = generator.solo.analysis.analyze(document)
|
||||||
|
measures = document['staves'][0]['measures']
|
||||||
|
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
|
||||||
|
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
|
||||||
|
|
||||||
|
class SoloTests(unittest.TestCase):
|
||||||
|
def test_catalog_intervals_and_negative_scale_indices(self):
|
||||||
|
library = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
|
||||||
|
self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744})
|
||||||
|
self.assertEqual(len({s.steps for s in library.paths[4]}), 2744)
|
||||||
|
self.assertTrue(all(s.offsets()[-1] == 0 for s in library.connecting(3, 0)))
|
||||||
|
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
|
||||||
|
pitches = [generator.solo.types.scale_pitch(scale, i) for i in (2, 3, 5, 4)]
|
||||||
|
self.assertEqual([p.label() for p in pitches], ['F4', 'G4', 'Bb4', 'A4'])
|
||||||
|
self.assertEqual(generator.solo.types.scale_pitch(scale, -1).label(), 'C4')
|
||||||
|
self.assertEqual(generator.solo.types.scale_pitch(scale, -8).label(), 'C3')
|
||||||
|
self.assertEqual(generator.solo.types.scale_pitch(scale, 7).label(), 'D5')
|
||||||
|
diminished = tests.scale_fixtures.library().scale('harmonic_minor', 7, 'C')
|
||||||
|
self.assertEqual(generator.solo.types.scale_pitch(diminished, 6).label(), 'Bbb4')
|
||||||
|
def test_invalid_sequences_fail_with_source(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = pathlib.Path(directory)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'empty or missing'):
|
||||||
|
generator.solo.sequences.load_library(root)
|
||||||
|
path = root / '2_4/0001.xml'
|
||||||
|
path.parent.mkdir()
|
||||||
|
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||||
|
with self.assertRaisesRegex(ValueError, '0001.xml.*2/4'):
|
||||||
|
generator.solo.sequences.load_library(root)
|
||||||
|
for steps in ((0,), (8,), (), (1, 1, 1, 1)):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'invalid'):
|
||||||
|
generator.solo.sequences.Library((generator.solo.sequences.Sequence('bad', steps),))
|
||||||
|
def test_ties_are_one_attack_but_repeated_notes_are_two(self):
|
||||||
|
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>')
|
||||||
|
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
|
||||||
|
content += tests.test_musicxml.xml_note(duration=2, kind='half')
|
||||||
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
||||||
|
attacks = generator.solo.analysis.attacks(document)
|
||||||
|
self.assertEqual([(n.start, n.end) for n in attacks], [(0, 2), (2, 4)])
|
||||||
|
self.assertEqual(len(attacks[0].sources), 2)
|
||||||
|
self.assertEqual(attacks[0].pitch, attacks[1].pitch)
|
||||||
|
def test_held_pitch_checked_across_every_collection(self):
|
||||||
|
document = song(count=2, chords=('CM7', 'DmM7'))
|
||||||
|
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
|
||||||
|
self.assertIn(0, generator.solo.types.permitted(choices, 0, 4))
|
||||||
|
self.assertNotIn(0, generator.solo.types.permitted(choices, 3, 5))
|
||||||
|
self.assertIn(2, generator.solo.types.permitted(choices, 3, 5))
|
||||||
|
self.assertFalse(generator.solo.types.permitted(choices, 7, 9))
|
||||||
|
def test_targets_preserve_suspensions_alterations_and_extensions(self):
|
||||||
|
for symbol, present, absent in (('Csus', 5, 4), ('Cm7b5', 6, 7), ('CmM7', 11, 10), ('C7b9', 1, 2)):
|
||||||
|
pitches = generator.solo.planning.target_pitches(generator.harmony.parse_symbol(symbol), False)
|
||||||
|
self.assertIn(present, pitches)
|
||||||
|
self.assertNotIn(absent, pitches)
|
||||||
|
def test_compose_all_meters_without_stretching(self):
|
||||||
|
for meter in (2, 3, 4, 5):
|
||||||
|
with self.subTest(meter=meter):
|
||||||
|
source = song(meter, 3)
|
||||||
|
result = compose(source)
|
||||||
|
generator.solo.composer.validate(result)
|
||||||
|
self.assertEqual(result.end, meter * 3)
|
||||||
|
self.assertTrue(result.notes())
|
||||||
|
self.assertTrue(all(1 <= n.position.fret <= 20 for n in result.notes()))
|
||||||
|
self.assertEqual(result, compose(source))
|
||||||
|
def test_motif_recurrence_and_whole_fragment_octave_quote(self):
|
||||||
|
source = song(count=6, chords=('CM7',))
|
||||||
|
result = compose(source)
|
||||||
|
self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3)
|
||||||
|
self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id)
|
||||||
|
first = result.phrases[0]
|
||||||
|
quote = next(d for d in first.decisions if d.treatment.startswith('quote'))
|
||||||
|
self.assertEqual(len(quote.notes), len(first.plan.motif.notes))
|
||||||
|
shifts = {a.pitch.number() - b.pitch.number() for a, b in zip(quote.notes, first.plan.motif.notes)}
|
||||||
|
self.assertEqual(len(shifts), 1)
|
||||||
|
self.assertEqual(next(iter(shifts)) % 12, 0)
|
||||||
|
self.assertEqual([(n.start, n.end) for n in quote.notes], [(first.plan.start + n.start, first.plan.start + n.end) for n in first.plan.motif.notes])
|
||||||
|
report = generator.solo.report.describe(result, tests.scale_fixtures.library())
|
||||||
|
self.assertIn('quote octave', report)
|
||||||
|
self.assertIn('source events:', report)
|
||||||
|
def test_no_harmony_is_silence_and_conflict_is_an_error(self):
|
||||||
|
result = compose(song(chords=()))
|
||||||
|
self.assertFalse(result.notes())
|
||||||
|
with self.assertRaisesRegex(ValueError, 'source measure 1.*No exact match'):
|
||||||
|
compose(song(chords=('A13b9',)))
|
||||||
|
def test_no_harmony_inside_a_motif_is_an_explicit_fragment(self):
|
||||||
|
source = song(count=3, chords=('CM7',))
|
||||||
|
measure = source['staves'][0]['measures'][0]
|
||||||
|
measure['harmonies'].append({'id': 'nc', 'onset': generator.model.rational(1), 'kind': 'none', 'degrees': [], 'symbol': 'N.C.'})
|
||||||
|
measure['harmonies'].append({**copy.deepcopy(measure['harmonies'][0]), 'id': 'return', 'onset': generator.model.rational(2)})
|
||||||
|
result = compose(source)
|
||||||
|
self.assertTrue(result.notes())
|
||||||
|
self.assertFalse(any(n.start < 2 and n.end > 1 for n in result.notes()))
|
||||||
|
self.assertTrue(any(d.treatment == 'fragment' for p in result.phrases for d in p.decisions))
|
||||||
|
def test_microtones_are_not_rounded_into_the_catalog(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'semitones'):
|
||||||
|
generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4)
|
||||||
|
def test_pickup_cutoff_is_reserved_before_composition(self):
|
||||||
|
result = compose(song(count=6), 'electric', fractions.Fraction(43, 2))
|
||||||
|
self.assertTrue(all(n.end <= fractions.Fraction(43, 2) for n in result.notes()))
|
||||||
|
self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2))
|
||||||
|
def test_search_exhaustion_does_not_claim_impossibility(self):
|
||||||
|
source = song()
|
||||||
|
with unittest.mock.patch('generator.solo.realization.candidates', return_value=[]), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'):
|
||||||
|
compose(source)
|
||||||
|
def test_time_aware_placement_does_not_penalize_absolute_frets(self):
|
||||||
|
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
|
||||||
|
before = generator.solo.types.Note(0, 1, p, position=generator.fretboard.TabPosition(2, 1, 60))
|
||||||
|
after = generator.solo.types.Note(1, 2, p, position=generator.fretboard.TabPosition(2, 6, 65))
|
||||||
|
self.assertGreater(generator.solo.placement.travel(before, after, fractions.Fraction(180)), generator.solo.placement.travel(before, after, fractions.Fraction(90)))
|
||||||
|
shifted = dataclasses.replace(before, position=generator.fretboard.TabPosition(2, 8, 67))
|
||||||
|
current = dataclasses.replace(after, position=generator.fretboard.TabPosition(2, 13, 72))
|
||||||
|
self.assertEqual(generator.solo.placement.travel(before, after, fractions.Fraction(120)), generator.solo.placement.travel(shifted, current, fractions.Fraction(120)))
|
||||||
|
def test_notation_ties_cross_bars_and_keep_string_without_new_attack(self):
|
||||||
|
document = song(count=2, chords=('CM7',))
|
||||||
|
note = generator.solo.types.Note(fractions.Fraction(3), fractions.Fraction(6), generator.solo.types.Pitch('e', fractions.Fraction(), 4), position=generator.fretboard.TabPosition(2, 5, 64))
|
||||||
|
document['staves'][0]['measures'] = generator.solo.notation.measures(document['staves'][0]['measures'], (note,), 'test')
|
||||||
|
document['staves'][0]['tablature'] = True
|
||||||
|
generator.model.validate_document(document)
|
||||||
|
events = generator.playback.compile_timeline(document)['events']
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(generator.model.fraction(events[0]['duration']), 3)
|
||||||
|
self.assertEqual(generator.model.fraction(events[0]['pitch']), 64)
|
||||||
|
def test_rational_motif_rhythm_has_visible_tuplets_and_exact_playback(self):
|
||||||
|
document = song(count=1, chords=('CM7',))
|
||||||
|
pitch = generator.solo.types.Pitch('e', fractions.Fraction(), 4)
|
||||||
|
notes = tuple(generator.solo.types.Note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), pitch, position=generator.fretboard.TabPosition(2, 5, 64)) for i in range(3))
|
||||||
|
replacement = generator.solo.notation.measures(document['staves'][0]['measures'], notes, 'triplet')
|
||||||
|
generator.solo.notation.replace_region(document, 0, replacement)
|
||||||
|
generator.model.validate_document(document)
|
||||||
|
self.assertEqual(document['staves'][0]['tuplets'][0]['note_count'], 3)
|
||||||
|
self.assertEqual(document['staves'][0]['tuplets'][0]['space_count'], 2)
|
||||||
|
timeline = generator.playback.compile_timeline(document)
|
||||||
|
self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']], [fractions.Fraction(1, 3)] * 3)
|
||||||
|
def test_source_tempo_and_fallback_are_quarter_note_rates(self):
|
||||||
|
source = song()
|
||||||
|
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 120)
|
||||||
|
source['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'written': {'type': 'half', 'dots': 0}, 'bpm': generator.model.rational(75)}]
|
||||||
|
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 150)
|
||||||
|
def test_harmony_carries_into_the_solo_until_an_explicit_no_chord(self):
|
||||||
|
source = song(count=2, chords=())
|
||||||
|
source['staves'][0]['measures'][1]['harmonies'] = [{'id': 'nc', 'onset': generator.model.rational(0), 'kind': 'none', 'symbol': 'N.C.'}]
|
||||||
|
result = generator.solo.composer.compose(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'acoustic', 8, initial=generator.harmony.parse_symbol('CM7'), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
|
||||||
|
self.assertTrue(result.notes())
|
||||||
|
self.assertTrue(all(n.end <= 4 for n in result.notes()))
|
||||||
|
def test_arrangement_preserves_head_and_vocal_and_backs_both_solos(self):
|
||||||
|
source = song(count=3, chords=('CM7',))
|
||||||
|
rhythms = tests.rhythm_fixtures.library()
|
||||||
|
plans = generator.arrange.generate.plan_arrangement(source, rhythms)
|
||||||
|
parts = {p: generator.arrange.generate._arrange_document(source, p, plans) for p in generator.parts.PART_TYPES}
|
||||||
|
before = copy.deepcopy(parts)
|
||||||
|
generator.solo.arrangement.apply(source, parts, rhythms=rhythms, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
|
||||||
|
self.assertEqual(before['vocal'], parts['vocal'])
|
||||||
|
regions = generator.solo.arrangement.regions(source)
|
||||||
|
low, high = regions[3][0], regions[4][0] + regions[4][1]
|
||||||
|
for part in ('acoustic', 'electric'):
|
||||||
|
old, new = before[part]['staves'][0]['measures'], parts[part]['staves'][0]['measures']
|
||||||
|
self.assertEqual(old[:low], new[:low])
|
||||||
|
self.assertEqual(old[high:], new[high:])
|
||||||
|
for section in (3, 4):
|
||||||
|
first, count, _, _ = regions[section]
|
||||||
|
self.assertTrue(any(n['pitches'] for m in new[first:first + count] for n in generator.score.measure_notes(m)))
|
||||||
|
generator.model.validate_document(parts[part])
|
||||||
|
def test_conflict_preserves_published_assets(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = pathlib.Path(directory)
|
||||||
|
(root / 'data/songs').mkdir(parents=True)
|
||||||
|
path = root / 'data/songs/conflict.xml'
|
||||||
|
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||||
|
output = root / 'assets'
|
||||||
|
output.mkdir()
|
||||||
|
(output / 'accepted').write_text('previous build')
|
||||||
|
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()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.arrangement.apply', side_effect=ValueError('No exact match within the permitted catalog')):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'):
|
||||||
|
generator.__main__.generate_assets(args)
|
||||||
|
self.assertEqual([p.name for p in output.iterdir()], ['accepted'])
|
||||||
|
self.assertEqual((output / 'accepted').read_text(), 'previous build')
|
||||||
|
def test_unrelated_song_does_not_change_an_accepted_solo(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = pathlib.Path(directory)
|
||||||
|
source, output = root / 'songs', root / 'assets/songs'
|
||||||
|
source.mkdir()
|
||||||
|
xml = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + ''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G')))
|
||||||
|
(source / 'accepted.xml').write_text(xml)
|
||||||
|
resources = {'rhythms': tests.rhythm_fixtures.library(), 'scales': tests.scale_fixtures.library(), 'sequences': tests.solo_fixtures.library()}
|
||||||
|
generator.arrange.generate.generate_performance_scores(source, output, **resources)
|
||||||
|
before = {p: p.read_bytes() for p in root.joinpath('assets').rglob('*') if p.is_file()}
|
||||||
|
(source / 'another.xml').write_text(xml.replace('Fixture', 'Another'))
|
||||||
|
generator.arrange.generate.generate_performance_scores(source, output, **resources)
|
||||||
|
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||||
|
def test_quote_can_reselect_a_collection_without_changing_harmony(self):
|
||||||
|
document = song(count=2, chords=('DmM7',))
|
||||||
|
timeline = generator.scales.timeline.read(document['staves'][0])
|
||||||
|
region = generator.scales.timeline.Solo(0, 8, 'solo')
|
||||||
|
retained = ((0, 2, {11}),)
|
||||||
|
result = generator.scales.selection.select(timeline, region, tests.scale_fixtures.library(), retained=retained)
|
||||||
|
self.assertTrue(all(11 in c.scale.pitches for c in result))
|
||||||
|
self.assertTrue(all(set(generator.harmony.pitch_classes(c.span.chord)) <= c.scale.pitches for c in result))
|
||||||
|
self.assertEqual([c.span.chord for c in result], [s.chord for s in timeline])
|
||||||
|
def test_pitch_adaptation_crosses_collection_without_root_restart(self):
|
||||||
|
document = song(count=2, chords=('CM7', 'DmM7'))
|
||||||
|
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
|
||||||
|
pitches = (generator.solo.types.Pitch('b', fractions.Fraction(), 3), generator.solo.types.Pitch('c', fractions.Fraction(), 4))
|
||||||
|
paths = generator.solo.realization.adapt_path(pitches, ((3, 4), (4, 5)), choices, (55, 74))
|
||||||
|
self.assertTrue(paths)
|
||||||
|
for path in paths:
|
||||||
|
self.assertTrue(generator.solo.realization.valid_notes(path, choices))
|
||||||
|
self.assertLessEqual(abs(path[1].pitch.number() - path[0].pitch.number()), 3)
|
||||||
|
def test_sequence_sources_are_packaged_and_tracked(self):
|
||||||
|
self.assertIn('"data/sequences/**/*.xml"', pathlib.Path('conanfile.py').read_text())
|
||||||
|
self.assertIn("'data/sequences/**/*.xml'", pathlib.Path('.gitea/workflows/ci.yml').read_text())
|
||||||
|
self.assertIn('data/*.xml', pathlib.Path('cmake/zero.cmake').read_text())
|
||||||
Reference in New Issue
Block a user