Soloing
ci / checks (push) Successful in 50m39s

This commit is contained in:
khodowany
2026-10-01 06:31:14 -05:00
parent ce695a626a
commit eae432aa83
29 changed files with 1402 additions and 158 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
+4 -1
View File
@@ -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')
+2 -2
View File
@@ -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)))
+15 -7
View File
@@ -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
+4 -1
View File
@@ -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 = []
+2 -2
View File
@@ -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']}
+1
View File
@@ -0,0 +1 @@
"""Build-time phrase composition; runtime consumes ordinary score events."""
+74
View File
@@ -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)
+123
View File
@@ -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)
+58
View File
@@ -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')
+84
View File
@@ -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
+38
View File
@@ -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))
+90
View File
@@ -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)
+253
View File
@@ -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)
+31
View File
@@ -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'
+66
View File
@@ -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))
+111
View File
@@ -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()
+8
View File
@@ -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))))
+3 -1
View File
@@ -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']
+3 -1
View File
@@ -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'
+4 -2
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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)
+9 -3
View File
@@ -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')
+262
View File
@@ -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())