Soloing
This commit is contained in:
@@ -97,6 +97,8 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live
|
|||||||
|
|
||||||
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. Build-time Python uses each song's actual melody, its mapped harmony, `data/sequences`, and `data/rhythms` to compose recognizable acoustic and electric variations. The standalone `data/lead_phrases` material is an audition control, not a production seed. No solo assignments are stored in song XML, and the runtime format is unchanged.
|
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. Build-time Python uses each song's actual melody, its mapped harmony, `data/sequences`, and `data/rhythms` to compose recognizable acoustic and electric variations. The standalone `data/lead_phrases` material is an audition control, not a production seed. No solo assignments are stored in song XML, and the runtime format is unchanged.
|
||||||
|
|
||||||
|
The arrangement reuses six musical parts: acoustic accompaniment and solo; electric accompaniment, melody and solo; and vocal melody. Each guitar learns one accompaniment over the full form. Corresponding bars reuse identical rhythms, durations, pitches and TAB fingerings, including behind solos. Only authored harmony changes and protected pickup/ending boundaries create local exceptions.
|
||||||
|
|
||||||
Source analysis retains complete sounding notes, rests, pickups and cross-bar holds. Production uses **thematic recomposition**: a short rhythm, contour, opening, or characteristic interval from the head initializes a new solo theme. Later phrases develop that realization through harmonic adaptation, extension, answers, or optional brief callbacks. Each instrument establishes a distinct related theme instead of restarting from the corresponding head phrase at every source boundary. Close paraphrase remains available through `composer.compose(..., mode='paraphrase')` for comparison.
|
Source analysis retains complete sounding notes, rests, pickups and cross-bar holds. Production uses **thematic recomposition**: a short rhythm, contour, opening, or characteristic interval from the head initializes a new solo theme. Later phrases develop that realization through harmonic adaptation, extension, answers, or optional brief callbacks. Each instrument establishes a distinct related theme instead of restarting from the corresponding head phrase at every source boundary. Close paraphrase remains available through `composer.compose(..., mode='paraphrase')` for comparison.
|
||||||
|
|
||||||
Treatment-specific contracts preserve the selected features only. Whole-source deviation applies to close paraphrase, including sounding durations; it does not penalize recomposition for changing unrelated notes. Before optional runs, realized pitch/time overlap and interval/rhythm fragments reject substantial near-copies (initial settings: 80% agreement across a two-bar window with at least four source attacks). Octave shifts, reattacks and extra attacks cannot dilute the duration comparison. Additional distance earns no bonus. The original source reference and the solo's realized theme remain separate in state and reports.
|
Treatment-specific contracts preserve the selected features only. Whole-source deviation applies to close paraphrase, including sounding durations; it does not penalize recomposition for changing unrelated notes. Before optional runs, realized pitch/time overlap and interval/rhythm fragments reject substantial near-copies (initial settings: 80% agreement across a two-bar window with at least four source attacks). Octave shifts, reattacks and extra attacks cannot dilute the duration comparison. Additional distance earns no bonus. The original source reference and the solo's realized theme remain separate in state and reports.
|
||||||
@@ -109,19 +111,21 @@ Destination retrieval retains representatives of the actual chord degrees before
|
|||||||
|
|
||||||
After selecting the strict acoustic/electric pair and its mapped runs, `solo/enclosures.py` compares sparse local ornaments with direct arrivals. The initial vocabulary is a mapped upper neighbor followed by a lower semitone neighbor, each an eighth note, then the exact preselected chord-tone target. The upper is at most three semitones above the target. The lower can already be mapped; chromatic status earns no extra reward. Each candidate replaces a continuously sounding beat before an existing intermediate stop or terminal hold, optionally revising a complete mapped route up to two beats earlier. Identifying features, protected quotations, rests, target timing and the whole required hold remain intact. No target is reattacked merely to make an ornament possible.
|
After selecting the strict acoustic/electric pair and its mapped runs, `solo/enclosures.py` compares sparse local ornaments with direct arrivals. The initial vocabulary is a mapped upper neighbor followed by a lower semitone neighbor, each an eighth note, then the exact preselected chord-tone target. The upper is at most three semitones above the target. The lower can already be mapped; chromatic status earns no extra reward. Each candidate replaces a continuously sounding beat before an existing intermediate stop or terminal hold, optionally revising a complete mapped route up to two beats earlier. Identifying features, protected quotations, rests, target timing and the whole required hold remain intact. No target is reattacked merely to make an ornament possible.
|
||||||
|
|
||||||
Validation checks the complete sounded gesture, all crossed harmony/scale spans, exact pitch and octave, arrival and release, and actual preparation/resolution timing. Only the contracted lower event can receive an exception. Missing, changed, clipped or unresolved targets invalidate the entire gesture. Tied splits do not change classification. A small chorus frontier retains direct options, with at most one enclosure per phrase and four per 32 bars; sparse use is a soft preference, not a quota. The home theme and development history stay strict. Placement is resolved across the final solo, and backing responds to the decorated foreground. Build-time `enclosures_enabled=False` on compose/compose_pair/arrangement skips only this final pass; no runtime fields or performance expression are added.
|
Validation checks the complete sounded gesture, all crossed harmony/scale spans, exact pitch and octave, arrival and release, and actual preparation/resolution timing. Only the contracted lower event can receive an exception. Missing, changed, clipped or unresolved targets invalidate the entire gesture. Tied splits do not change classification. A small chorus frontier retains direct options, with at most one enclosure per phrase and four per 32 bars; sparse use is a soft preference, not a quota. The home theme and development history stay strict. A previously sounding mapped upper neighbor may supply the final eighth-note approach segment without a new attack, but its complete duration still obeys ordinary sustained-note rules. Incidental repeated upper-neighbor attacks carry a local cost; deliberate repeated attacks remain available. Placement is resolved across the final solo; ornamentation never changes accompaniment. Build-time `enclosures_enabled=False` on compose/compose_pair/arrangement skips only this final pass; no runtime fields or performance expression are added.
|
||||||
|
|
||||||
The plain recomposed theme is exported independently of optional runs. Activity retains a soft 4–6 attacks-per-four-beats preference; occupancy remains diagnostic. No accents, dynamics, swing or other performance-expression changes are applied.
|
The plain recomposed theme is exported independently of optional runs. Activity retains a soft 4–6 attacks-per-four-beats preference; occupancy remains diagnostic. No accents, dynamics, swing or other performance-expression changes are applied.
|
||||||
|
|
||||||
Search retains eight phrase alternatives and an eight-state chorus frontier, with two intermediate continuations per established theme. Up to 64 acoustic/electric pairs compete; equivalent themes after transposition are excluded while individual musical costs and pair similarity remain separate. Concrete caches include incoming state, harmony, timing and vocabulary. One bounded replan broadens eligible start and destination pitches. Exhaustion identifies the failed window without invoking a generic composer. Stable ordering and final tie-breaking preserve determinism.
|
Search retains eight phrase alternatives and an eight-state chorus frontier, with two intermediate continuations per established theme. Up to 64 acoustic/electric pairs compete; equivalent themes after transposition are excluded while individual musical costs and pair similarity remain separate. Concrete caches include incoming state, harmony, timing and vocabulary. One bounded replan broadens eligible start and destination pitches. Exhaustion identifies the failed window without invoking a generic composer. Stable ordering and final tie-breaking preserve determinism.
|
||||||
|
|
||||||
Phrase register alternatives cover the available F2–E5 sounding range. Placement uses all six strings, frets 1–12 without open notes, with a four-fret hand-position model and alternative endpoint fingerings. Movement costs use available time; they do not claim a maximum playable speed. Equal-cost tie-breaking has no first-string preference. Chords elsewhere retain their existing 1–12 anchor policy. The other guitarist is recomposed around the selected foreground, and the returning vocal pickup remains protected.
|
Electric solos use strings 1–4 and favor roughly the electric melody's sounding register. Acoustic solos use strings 2–6 and favor about one octave below that melody. Both require frets 1–12 with no open notes. The register reference uses the original melody voice, excluding added chord voices, with a four-semitone median deadband and bounded preference; it is not a hard pitch corridor. Instrument profiles constrain pitch/anchor retrieval, complete routes, placement and validation, with cache keys separated by profile. Whole phrases can explore and return without a string-use quota. The four-fret hand-position model retains alternative endpoints and uses available time without claiming a maximum playable speed. Chords retain their existing 1–12 anchor policy, and the returning vocal pickup remains protected.
|
||||||
|
|
||||||
|
A clear return refers to the introduced theme's stored opening signature (normally three to five attacks with rhythm/rests), rather than selecting a different later three-note turn. Freer development retains a smaller core. Bounded gesture revisions can reconnect editable isolated notes without changing pitch order, attack count, or required arrivals. Existing anchors distinguish local settling, cadential resolution and handoff; a local settle carries the next harmonic destination into subsequent selection. These distinctions affect the solo, never its accompaniment.
|
||||||
|
|
||||||
Build reports in `generated_assets/analysis/` include selected source features, recurring solo themes, development lineage, anchors, complete routes, plain-versus-head and elaborated-versus-head comparisons, candidate diagnostics, destination degrees and complete enclosure contracts. Out-of-map approach notes are reported separately. Audition reports additionally show string use weighted by sounding duration and distinguish forced first-string notes from optional placements. These measurements establish technical behavior, not musical quality.
|
Build reports in `generated_assets/analysis/` include selected source features, recurring solo themes, development lineage, anchors, complete routes, plain-versus-head and elaborated-versus-head comparisons, candidate diagnostics, destination degrees and complete enclosure contracts. Out-of-map approach notes are reported separately. Audition reports additionally show string use weighted by sounding duration and distinguish forced first-string notes from optional placements. These measurements establish technical behavior, not musical quality.
|
||||||
|
|
||||||
For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Full final mixes also use responsive accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`.
|
For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Historical mixes retain their original backing; new production uses canonical accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`.
|
||||||
|
|
||||||
The current enclosure comparison is under `build/solo_enclosure_auditions/index.html`: previous production, the new selected strict solo, and that same solo with enclosures, with fixed backing and the final responsive arrangement. `before_enclosure` retains the complete pre-ornament realization, distinct from the plain theme before runs. To capture a new comparison, preserve the prior snapshot as `before/snapshot.json`, then run `./modulus.py python -m generator.solo.audition --output build/solo_enclosure_auditions --capture after` and `--render`. `--no-enclosures` provides a strict capture. These build artifacts are ignored by Git.
|
The current comparison is under `build/solo_foreground_auditions/index.html`: preserved production, a whole-phrase register/string-only control, the new plain theme, the selected strict theme before rhythmic revision and enclosures, and the final solo. Fixed comparisons use the same canonical backing and neutral playback. Full, opening-eight-bar and closing-four-bar comparisons include notation and passage reports; full solos also have unaccompanied versions. `before_enclosure` retains the exact selected pre-ornament realization, and `before_rhythm` isolates the timing revision. Preserve a previous snapshot as `before/snapshot.json`, then run `./modulus.py python -m generator.solo.audition --output build/solo_foreground_auditions --capture after` and `--render`. `--no-enclosures` provides a strict capture. Artifacts remain ignored by Git. Older enclosure comparisons remain under `build/solo_enclosure_auditions/`.
|
||||||
|
|
||||||
To inspect generated assets without building the full package, run:
|
To inspect generated assets without building the full package, run:
|
||||||
|
|
||||||
@@ -138,7 +142,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. Measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure, and exported excerpts retain their arrangement measure labels.
|
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. Measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure, and exported excerpts retain their arrangement measure labels.
|
||||||
|
|
||||||
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.
|
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 separately planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable voicing until the harmony changes in the canonical form. That complete part repeats unchanged across arrangement iterations. 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. The original song informs its one-time rhythm choice; generated solos do not alter it. 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.
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ Guitar parts share standard tuning and chord-tone helpers but use separate acous
|
|||||||
|
|
||||||
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
|
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
|
||||||
|
|
||||||
Electric melody prioritizes four-string chords, then melody on top. Every generated guitar voicing requires the root, the actual fifth, or both somewhere in the voicing, not necessarily in the bass. Defining chord tones remain mandatory. Acoustic chooses four connected string-voice journeys independently for each arrangement iteration, minimizing total fret travel across harmonic spans. Upper strings stay fixed; bass movement counts both fret distance and one step for switching between strings 5 and 6. All bass inversions are eligible. There is no acoustic register, physical-reach, or shape-effort penalty beyond its hard fret constraints. Complete triads are preferred when feasible; doubling is allowed, authored omissions are never restored, and missing optional tones break equal-movement ties. Electric chord effort balances span and distinct frets against position-center movement, without an absolute fret-height penalty. Unharmonized melody placement retains its existing register and movement costs. This is a fretboard-feasibility approximation, not a finger-assignment model. Exact melody pitches and rhythms remain unchanged.
|
Electric melody prioritizes four-string chords, then melody on top. Every generated guitar voicing requires the root, the actual fifth, or both somewhere in the voicing, not necessarily in the bass. Defining chord tones remain mandatory. Acoustic chooses four connected string-voice journeys once across the canonical form, minimizing total fret travel across harmonic spans, then reuses them in every accompaniment iteration. Upper strings stay fixed; bass movement counts both fret distance and one step for switching between strings 5 and 6. All bass inversions are eligible. There is no acoustic register, physical-reach, or shape-effort penalty beyond its hard fret constraints. Complete triads are preferred when feasible; doubling is allowed, authored omissions are never restored, and missing optional tones break equal-movement ties. Electric chord effort balances span and distinct frets against position-center movement, without an absolute fret-height penalty. Unharmonized melody placement retains its existing register and movement costs. This is a fretboard-feasibility approximation, not a finger-assignment model. Exact melody pitches and rhythms remain unchanged.
|
||||||
|
|
||||||
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending.
|
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending.
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -154,7 +154,7 @@ Acoustic candidates are generated from chord-tone positions on the four voices,
|
|||||||
|
|
||||||
`electric.py` links song accompaniment attacks to harmonized melody events using authored source positions, not displayed measure numbers or a song-wide chord dictionary. References stay inside the corresponding source harmony span and must match its structured chord. Within a span, an attack uses the latest available melody grip at or before its source position, or the first later grip if it precedes all melody chords. This allows different shapes for the same chord in different passages. After guitar placement and melody tie resolution, linked stabs copy the actual melody pitches and TAB positions while retaining their own IDs, timing, and written durations; melody ties and markings are not copied. A melody that remains a single note provides no grip. Passages without a matching playable three- or four-string melody chord, including altered endings without a corresponding reference, retain independently generated electric shapes. No reference metadata reaches runtime JSON.
|
`electric.py` links song accompaniment attacks to harmonized melody events using authored source positions, not displayed measure numbers or a song-wide chord dictionary. References stay inside the corresponding source harmony span and must match its structured chord. Within a span, an attack uses the latest available melody grip at or before its source position, or the first later grip if it precedes all melody chords. This allows different shapes for the same chord in different passages. After guitar placement and melody tie resolution, linked stabs copy the actual melody pitches and TAB positions while retaining their own IDs, timing, and written durations; melody ties and markings are not copied. A melody that remains a single note provides no grip. Passages without a matching playable three- or four-string melody chord, including altered endings without a corresponding reference, retain independently generated electric shapes. No reference metadata reaches runtime JSON.
|
||||||
|
|
||||||
Acoustic transition cost is the sum of absolute fret changes for all four named voices, plus one when the bass switches between strings 5 and 6. For example, bass string 5 fret 7 to string 6 fret 9 costs three steps, not just a string-change charge. An unchanged assignment costs zero. Exact dynamic programming minimizes total travel per iteration; equal travel costs prefer fewer missing optional chord tones, followed by stable position ordering. The starting assignment has no position cost. Harmony spans, not rhythmic attacks, determine the transitions. There are no acoustic register, distinct-fret, span, or physical-reach scoring terms. The three-fret limit and fret-12 anchor are eligibility checks, not preferences.
|
Acoustic transition cost is the sum of absolute fret changes for all four named voices, plus one when the bass switches between strings 5 and 6. For example, bass string 5 fret 7 to string 6 fret 9 costs three steps, not just a string-change charge. An unchanged assignment costs zero. Exact dynamic programming minimizes total travel across the canonical form; accompaniment iterations copy the selected fingerings. Within that selection, equal travel costs prefer fewer missing optional chord tones, followed by stable position ordering. The starting assignment has no position cost. Harmony spans, not rhythmic attacks, determine the transitions. There are no acoustic register, distinct-fret, span, or physical-reach scoring terms. The three-fret limit and fret-12 anchor are eligibility checks, not preferences.
|
||||||
|
|
||||||
Electric chord effort is `2 * max(0, distinct_frets - 1) + 3 * fret_span`, plus Manhattan movement between doubled fret/string position centers. Here `fret_span` remains `max(frets) - min(frets)` for scoring; eligibility counts fret positions inclusively. Neither the highest fret nor crossing fret 12 adds a chord cost. The small-grid distance transform and exact melody constraints are unchanged. Unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`, and pure tie continuations add no fresh shape or movement cost. The acoustic Autumn Leaves lead-in can use Em7b5/G at `6:3, 4:2, 3:3, 2:3` followed by A7b9/G at `6:3, 4:2, 3:3, 2:2`, moving only the second string by one fret. This is a regression example, not a prescribed chord shape; generated bass inversions do not rewrite source chord labels.
|
Electric chord effort is `2 * max(0, distinct_frets - 1) + 3 * fret_span`, plus Manhattan movement between doubled fret/string position centers. Here `fret_span` remains `max(frets) - min(frets)` for scoring; eligibility counts fret positions inclusively. Neither the highest fret nor crossing fret 12 adds a chord cost. The small-grid distance transform and exact melody constraints are unchanged. Unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`, and pure tie continuations add no fresh shape or movement cost. The acoustic Autumn Leaves lead-in can use Em7b5/G at `6:3, 4:2, 3:3, 2:3` followed by A7b9/G at `6:3, 4:2, 3:3, 2:2`, moving only the second string by one fret. This is a regression example, not a prescribed chord shape; generated bass inversions do not rewrite source chord labels.
|
||||||
|
|
||||||
@@ -190,11 +190,17 @@ Scale labels appear above chord symbols at collection changes and system starts.
|
|||||||
|
|
||||||
### Phrase Composition
|
### Phrase Composition
|
||||||
|
|
||||||
|
The musical arrangement has six reusable parts: acoustic accompaniment/solo, electric accompaniment/melody/solo, and vocal melody. `arrange.accompaniment.install` builds each accompaniment once and copies its events at matching authored form positions, retaining cycle phase in lead-in/ending excerpts. It preserves the actual guitar pickup's event identities, ties and tuplets. Changed effective harmony is revoiced locally; a structural audit rejects unexplained differences among repeated bars. `solo.arrangement.apply` installs only the soloist's notes and validates the unchanged supporting part against the solo scale map. No solo-dependent accompaniment planning, voicing, or dynamics remains in production.
|
||||||
|
|
||||||
|
Solo `profiles.Instrument` carries hard string limits, the sounding register, and a melody-derived preferred center. The electric reference is the prepared concert melody plus twelve semitones; acoustic is twelve semitones below electric. This reproduces the existing electric-melody sounding transformation without using the highest harmonization tone. The soft center has a four-semitone deadband, and excursions remain possible. Concrete search and placement caches include the profile.
|
||||||
|
|
||||||
|
`gesture_rhythm.revise` compares up to eight alternatives in each of two editable windows, retaining the same pitch sequence and attack count. Feature contracts, complete-duration harmony checks, anchor checks and required tails all remain binding. `Phrase.before_rhythm` preserves the exact pre-revision candidate for comparison. Anchor `effect` distinguishes local settling, cadential arrival and handoff; local settling carries a small next-destination preference into the following phrase. An enclosure may reuse a contiguous mapped upper note through an explicit approach segment; extending that note does not waive its ordinary duration-sensitive harmonic policy.
|
||||||
|
|
||||||
`generator/solo/` is build-only. The active path is `arrangement.apply` → `composer.compose_pair` → `leads.thematic_options` → `themes.phrase_candidates` → `realization.checked_phrase`. The song's actual melody supplies a selected rhythmic, contour, opening, or interval seed. Each instrument introduces its own realized solo theme; later development refers to that heard identity, not each successive phrase of the head. Close paraphrase remains available through `mode='paraphrase'` for comparisons. The older cell-level planner/refiner is not the production theme constructor.
|
`generator/solo/` is build-only. The active path is `arrangement.apply` → `composer.compose_pair` → `leads.thematic_options` → `themes.phrase_candidates` → `realization.checked_phrase`. The song's actual melody supplies a selected rhythmic, contour, opening, or interval seed. Each instrument introduces its own realized solo theme; later development refers to that heard identity, not each successive phrase of the head. Close paraphrase remains available through `mode='paraphrase'` for comparisons. The older cell-level planner/refiner is not the production theme constructor.
|
||||||
|
|
||||||
`phrasing.py` proposes four-, five-, six-, and eight-bar endpoints from actual measure boundaries and harmony, with explicit missing-harmony interruptions and a trimmed final handoff. Two promising endpoint alternatives compete through realization from each incoming history. A soft preference aims for approximately six substantial closes per 32 bars (five to seven is the initial range). Ending alternatives choose a chord-valid pitch, arrival, two-to-four-beat sounding interval, and optional release together before constructing the approach. Intermediate search units do not close the thought. A held target must remain one sounding event for the promised interval; repeated attacks or touching and abandoning it do not fulfill the contract. Cross-bar holds must remain valid across every chord and mapped collection they overlap.
|
`phrasing.py` proposes four-, five-, six-, and eight-bar endpoints from actual measure boundaries and harmony, with explicit missing-harmony interruptions and a trimmed final handoff. Two promising endpoint alternatives compete through realization from each incoming history. A soft preference aims for approximately six substantial closes per 32 bars (five to seven is the initial range). Ending alternatives choose a chord-valid pitch, arrival, two-to-four-beat sounding interval, and optional release together before constructing the approach. Intermediate search units do not close the thought. A held target must remain one sounding event for the promised interval; repeated attacks or touching and abandoning it do not fulfill the contract. Cross-bar holds must remain valid across every chord and mapped collection they overlap.
|
||||||
|
|
||||||
Theme appearances distinguish clear returns, developed appearances, and freer continuation. A clear return preserves an identifying melodic relationship with its timing; development can preserve melodic intervals while altering rhythm, or a characteristic rhythm while recomposing pitches. Feature selection looks for a heard turn or leap rather than automatically protecting the first events. A uniform quarter-note rhythm alone is not sufficient identity for a rhythmic development. Approximately two clear returns are preferred, independently of phrase closures. Once a feature contract is fulfilled, additional copying is not rewarded.
|
Theme appearances distinguish clear returns, developed appearances, and freer continuation. A clear return preserves an identifying melodic relationship with its timing; development can preserve melodic intervals while altering rhythm, or a characteristic rhythm while recomposing pitches. A stored opening signature from the realized introduction supplies clear returns, including its rhythm and rests; development may use its smaller three-note core. Ordinary generated events outside that declared signature remain editable. A uniform quarter-note rhythm alone is not sufficient identity for a rhythmic development. Approximately two clear returns are preferred, independently of phrase closures. Once a feature contract is fulfilled, additional copying is not rewarded.
|
||||||
|
|
||||||
`sequences.py` validates relative paths in `data/sequences` and chains them into complete routes using octave-aware scale indices. `themes.py` combines these with exact rhythms from `data/rhythms`, including rhythmic alteration, diminution, displacement already present in catalog windows, and sustained endings. Concrete routes retain their complete entry, pitch path, timing, and destination. Up to eight rhythm alternatives and six destinations are retrieved; sixteen joint rhythm/start/destination jobs preserve coverage before expensive realization, expanding to thirty-two during a bounded replan. Eight intermediate, phrase, and chorus alternatives are retained. Cache keys include the actual musical state used in construction; history-dependent ranking occurs after shared concrete retrieval.
|
`sequences.py` validates relative paths in `data/sequences` and chains them into complete routes using octave-aware scale indices. `themes.py` combines these with exact rhythms from `data/rhythms`, including rhythmic alteration, diminution, displacement already present in catalog windows, and sustained endings. Concrete routes retain their complete entry, pitch path, timing, and destination. Up to eight rhythm alternatives and six destinations are retrieved; sixteen joint rhythm/start/destination jobs preserve coverage before expensive realization, expanding to thirty-two during a bounded replan. Eight intermediate, phrase, and chorus alternatives are retained. Cache keys include the actual musical state used in construction; history-dependent ranking occurs after shared concrete retrieval.
|
||||||
|
|
||||||
@@ -202,13 +208,13 @@ A rolling sixteen-bar history measures exact and transposed pitch/rhythm recurre
|
|||||||
|
|
||||||
`leads.elaborate` may replace a complete internal span of shorter notes, as well as an editable portion of a hold. Feature contracts and protected sounding tails are rechecked against the complete result. Exact source quotations remain immutable. The plain theme is a complete playable line, and elaboration is optional. Harmonic validity, exact timing, scale membership, monophony, required holds, and guitar placement remain hard constraints. A bounded replan may reconsider eligible ending alternatives; exhausted search reports its failed passage without claiming mathematical incompatibility or falling back to generic material.
|
`leads.elaborate` may replace a complete internal span of shorter notes, as well as an editable portion of a hold. Feature contracts and protected sounding tails are rechecked against the complete result. Exact source quotations remain immutable. The plain theme is a complete playable line, and elaboration is optional. Harmonic validity, exact timing, scale membership, monophony, required holds, and guitar placement remain hard constraints. A bounded replan may reconsider eligible ending alternatives; exhausted search reports its failed passage without claiming mathematical incompatibility or falling back to generic material.
|
||||||
|
|
||||||
Acoustic/electric selection compares complete alternatives, preserving different related themes and discouraging unintended long copies. Placement uses sounding pitches on all six strings, frets 1–12 without open strings; written guitar pitch is one octave above sound exactly once. Ties retain string and fret. The first declared source tempo, or 120 quarter notes/minute, informs movement costs; runtime tempo remains adjustable. No out-of-collection chromatic substitutions, accents, dynamics, velocity shaping, or swing changes are part of this composer.
|
Acoustic/electric selection compares complete alternatives, preserving different related themes and discouraging unintended long copies. Placement uses sounding pitches on strings 2–6 for acoustic solos and 1–4 for electric solos, frets 1–12 without open strings; written guitar pitch is one octave above sound exactly once. Ties retain string and fret. The first declared source tempo, or 120 quarter notes/minute, informs movement costs; runtime tempo remains adjustable. No out-of-collection chromatic substitutions, accents, dynamics, velocity shaping, or swing changes are part of this composer.
|
||||||
|
|
||||||
`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.
|
`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. Supporting parts retain the canonical accompaniment chosen from the original song, including its exact rhythm, pitches and fingerings. The electric canonical part may reuse a corresponding melody grip only when it satisfies the written chord requirements and contains chord tones. Solo installation validates the existing backing against the mapped collections and audits repeated form positions; it does not regenerate backing from foreground activity. The complete vocal part remains unchanged, and its 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. Engraving, playback and C++ consumption retain their existing formats. `report.py` appends motif/source IDs, transformation contracts, activity and landmark plans, target windows, sounding pitches, TAB decisions and search diagnostics. Generation still stages and validates the complete output before publication. Listening and playing remain necessary to accept the musical result.
|
`notation.py` converts placed attack spans into ordinary score events, rests, and tied continuations across notation/bar boundaries. Engraving, playback and C++ consumption retain their existing formats. `report.py` appends motif/source IDs, transformation contracts, activity and landmark plans, target windows, sounding pitches, TAB decisions and search diagnostics. Generation still stages and validates the complete output before publication. Listening and playing remain necessary to accept the musical result.
|
||||||
|
|
||||||
`python -m generator.solo.audition --output build/solo_phrasing_auditions --capture before` preserves the current implementation, and `--capture after` captures its successor. Existing snapshots are never overwritten. `--render` exports full solos, opening eight bars, and closing four bars as MusicXML and WAV, with baseline backing held constant and a separate responsive arrangement. It also exports the new plain theme and unaccompanied plain/elaborated full solos. Source hashes, implementation/policy fingerprints, tempo, and shared mix gain are recorded. Reports distinguish completed sustained endings from ordinary long notes, show retained theme features, recurrence counts, movement/arrival activity, local register distribution, and forced versus optional first-string use. These diagnostics establish behavior; listening is still needed to judge musical quality.
|
`python -m generator.solo.audition --output build/solo_foreground_auditions --capture before` preserves the current implementation, and `--capture after` captures its successor. Existing snapshots are never overwritten. `--render` exports full solos, opening eight bars, and closing four bars as MusicXML and WAV, with the final canonical backing held constant. The published arrangement reuses that same backing instead of recomposing around the foreground. It also exports whole-phrase register transfers of the baseline, the selected theme before rhythmic revision, the new plain theme and unaccompanied plain/elaborated full solos. Source hashes, implementation/policy fingerprints, tempo, and shared mix gain are recorded. Reports distinguish completed sustained endings from ordinary long notes, show retained theme features, recurrence counts, movement/arrival activity, local register distribution, and forced versus optional first-string use. These diagnostics establish behavior; listening is still needed to judge musical quality.
|
||||||
|
|
||||||
### Engraving Boundary
|
### Engraving Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""One learned accompaniment per instrument, reused at authored form positions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from fractions import Fraction as F
|
||||||
|
|
||||||
|
import generator.accompaniment
|
||||||
|
import generator.acoustic
|
||||||
|
import generator.acoustic_voicing
|
||||||
|
import generator.electric
|
||||||
|
import generator.guitar
|
||||||
|
import generator.fretboard
|
||||||
|
import generator.model
|
||||||
|
import generator.parts
|
||||||
|
import generator.score
|
||||||
|
from generator.arrange import form
|
||||||
|
import generator.arrange.pickup
|
||||||
|
|
||||||
|
|
||||||
|
def harmonic_key(measure, previous):
|
||||||
|
def key(chord):
|
||||||
|
return repr({k: chord.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')}) if chord else ''
|
||||||
|
active = generator.accompaniment.active_chord(measure, F(), previous)
|
||||||
|
return (key(active), tuple((generator.model.fraction(h['onset']), key(h)) for h in measure['harmonies']
|
||||||
|
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
|
||||||
|
|
||||||
|
|
||||||
|
def voice(source, document, measures, part, initial=None, source_offset=0):
|
||||||
|
staff = document['staves'][0]
|
||||||
|
result = generator.score.make_arranged_document(source, staff, staff_id=staff['id'],
|
||||||
|
staff_name=staff['name'], role='guitar', arrangement_part_type=part, measures=measures)
|
||||||
|
if part == 'acoustic':
|
||||||
|
generator.acoustic.prepare(result)
|
||||||
|
generator.acoustic_voicing.place(result)
|
||||||
|
generator.acoustic.combine(result)
|
||||||
|
else:
|
||||||
|
generator.guitar.place_guitar(result)
|
||||||
|
# Melody grips remain optional candidates from the same source position.
|
||||||
|
# Only genuine chord tones are copied: exposed melody tensions must not
|
||||||
|
# become a repeated accompaniment tension behind every solo.
|
||||||
|
sections = form.full_arrangement_sections(generator.score.source_staff(source)['measures'], part)
|
||||||
|
first = sum(s.measure_count for s in sections if s.section_index < 2)
|
||||||
|
head = document['staves'][0]['measures'][first + source_offset:first + source_offset + len(measures)]
|
||||||
|
previous = generator.accompaniment.carried_chords(measures, initial)
|
||||||
|
links = []
|
||||||
|
for i, measure in enumerate(measures):
|
||||||
|
if i >= len(head): break
|
||||||
|
for note in generator.score.measure_notes(measure):
|
||||||
|
if not note['pitches']: continue
|
||||||
|
chord = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous[i])
|
||||||
|
requirements = generator.fretboard.chord_requirements(chord)
|
||||||
|
allowed = set(requirements.allowed)
|
||||||
|
matches = [n for n in generator.score.measure_notes(head[i]) if len(n['pitches']) >= 3
|
||||||
|
and {int(generator.model.pitch_number(p)) % 12 for p in n['pitches']} <= allowed
|
||||||
|
and requirements.covers({int(generator.model.pitch_number(p)) % 12 for p in n['pitches']})]
|
||||||
|
links.append(generator.electric.VoicingLink(note, tuple(matches)))
|
||||||
|
generator.electric.reuse_voicings(links)
|
||||||
|
generator.model.validate_document(result)
|
||||||
|
generator.fretboard.validate_document(result)
|
||||||
|
return result['staves'][0]['measures']
|
||||||
|
|
||||||
|
|
||||||
|
def install(source, document, plans):
|
||||||
|
"""Freeze complete canonical grips; copies are never placed a second time."""
|
||||||
|
from generator.arrange.generate import section_source
|
||||||
|
part = document['staves'][0]['arrangement_part_type']
|
||||||
|
if part == 'vocal': return
|
||||||
|
authored = generator.score.source_staff(source)['measures']
|
||||||
|
sections = form.full_arrangement_sections(authored, part)
|
||||||
|
if 1 not in plans or not any(s.section_index == 1 for s in sections):
|
||||||
|
return [] # A standalone section already is its own canonical part.
|
||||||
|
body = generator.score.body_start_measure_index(authored)
|
||||||
|
body_measures = copy.deepcopy(authored[body:])
|
||||||
|
for m in body_measures:
|
||||||
|
for h in m['harmonies']: h.pop('chord_optional', None)
|
||||||
|
previous = generator.accompaniment.carried_chords(authored)
|
||||||
|
plan = plans[1]
|
||||||
|
|
||||||
|
def build(measures, prior, offset=0):
|
||||||
|
carried = generator.accompaniment.carried_chords(measures, prior)
|
||||||
|
result = []
|
||||||
|
for i, m in enumerate(measures):
|
||||||
|
j = offset + i
|
||||||
|
rhythm = (plan.acoustic_bass[j], plan.acoustic_upper[j]) if part == 'acoustic' else plan.electric[j]
|
||||||
|
result.append(generator.parts.build_measure(m, f'canonical_{part}_{j}', 'accompaniment', part, carried[i], rhythm=rhythm))
|
||||||
|
return result
|
||||||
|
|
||||||
|
canonical = voice(source, document, build(body_measures, previous[body]), part, previous[body])
|
||||||
|
canonical_previous = generator.accompaniment.carried_chords(body_measures, previous[body])
|
||||||
|
keys = [harmonic_key(m, p) for m, p in zip(body_measures, canonical_previous)]
|
||||||
|
cache, exceptions = {}, []
|
||||||
|
destination = 0
|
||||||
|
for si, section in enumerate(sections):
|
||||||
|
if section.role != 'accompaniment':
|
||||||
|
destination += section.measure_count
|
||||||
|
continue
|
||||||
|
effective = section_source(authored, section, sections[si + 1] if si + 1 < len(sections) else None)
|
||||||
|
carried = generator.accompaniment.carried_chords(effective)
|
||||||
|
for j in range(section.measure_count):
|
||||||
|
source_index = section.source_measure_index + j
|
||||||
|
index = source_index - body
|
||||||
|
old = document['staves'][0]['measures'][destination + j]
|
||||||
|
key = harmonic_key(effective[source_index], carried[source_index])
|
||||||
|
if key == keys[index]:
|
||||||
|
template = canonical[index]
|
||||||
|
else:
|
||||||
|
# Changed pickups/ending harmony reuse the rhythm and only revoice the
|
||||||
|
# affected measure. These are chart differences, never solo responses.
|
||||||
|
identity = (index, repr(key))
|
||||||
|
if identity not in cache:
|
||||||
|
cache[identity] = voice(source, document,
|
||||||
|
build([effective[source_index]], carried[source_index], index), part, carried[source_index], index)[0]
|
||||||
|
template = cache[identity]
|
||||||
|
exceptions.append({'measure': old['number'], 'source_measure': authored[source_index]['number'], 'reason': 'changed harmony or arrangement boundary'})
|
||||||
|
replacement = copy.deepcopy(old)
|
||||||
|
replacement['voices'] = copy.deepcopy(template['voices'])
|
||||||
|
replacement['id'] = 'canonical_' + old['id']
|
||||||
|
generator.score.reidentify([replacement], [])
|
||||||
|
replacement['id'] = old['id']
|
||||||
|
following = sections[si + 1] if si + 1 < len(sections) else None
|
||||||
|
if j == section.measure_count-1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||||
|
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(authored[0]) if n['pitches']]
|
||||||
|
if sounding:
|
||||||
|
cutoff = generator.arrange.pickup.offset(old, authored[0]) + min(sounding)
|
||||||
|
prefix = [n for n in generator.score.measure_notes(replacement) if generator.model.fraction(n['onset']) < cutoff]
|
||||||
|
for n in prefix: generator.arrange.pickup.truncate(n, cutoff)
|
||||||
|
# Preserve original event IDs: incoming melody ties and tuplets refer
|
||||||
|
# to these exact pickup events, not to a freshly voiced copy.
|
||||||
|
tail = copy.deepcopy([n for n in generator.score.measure_notes(old) if generator.model.fraction(n['onset']) >= cutoff])
|
||||||
|
identifier = old['voices'][0]['id']
|
||||||
|
for n in prefix: n['voice'] = identifier
|
||||||
|
replacement['voices'] = [{'id': identifier, 'notes': prefix + tail}]
|
||||||
|
exceptions.append({'measure': old['number'], 'source_measure': authored[source_index]['number'], 'reason': 'protected melody pickup'})
|
||||||
|
document['staves'][0]['measures'][destination + j] = replacement
|
||||||
|
destination += section.measure_count
|
||||||
|
# Build-only diagnostics live outside the published musical schema.
|
||||||
|
return exceptions
|
||||||
|
|
||||||
|
|
||||||
|
def audit(source, documents):
|
||||||
|
"""Compare music, not generated IDs, at every reused form position."""
|
||||||
|
authored = generator.score.source_staff(source)['measures']
|
||||||
|
result = {}
|
||||||
|
for part, document in documents.items():
|
||||||
|
if part == 'vocal': continue
|
||||||
|
sections = form.full_arrangement_sections(authored, part)
|
||||||
|
measures = document['staves'][0]['measures']
|
||||||
|
carried = generator.accompaniment.carried_chords(measures)
|
||||||
|
seen, exceptions, offset, reused = {}, [], 0, 0
|
||||||
|
for si, section in enumerate(sections):
|
||||||
|
following = sections[si + 1] if si + 1 < len(sections) else None
|
||||||
|
for j in range(section.measure_count):
|
||||||
|
if section.role != 'accompaniment': continue
|
||||||
|
m = measures[offset+j]
|
||||||
|
if j == section.measure_count-1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||||
|
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
|
||||||
|
continue
|
||||||
|
key = (section.source_measure_index+j, repr(harmonic_key(m, carried[offset+j])))
|
||||||
|
signature = tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||||
|
tuple((generator.model.pitch_number(p),p['tablature']['string'],p['tablature']['fret']) for p in n['pitches']))
|
||||||
|
for n in generator.score.measure_notes(m))
|
||||||
|
if key in seen:
|
||||||
|
if signature != seen[key]:
|
||||||
|
raise ValueError(f'{part}, arrangement measure {m["number"]}: canonical accompaniment changed')
|
||||||
|
reused += 1
|
||||||
|
else:
|
||||||
|
if any(k[0] == key[0] for k in seen):
|
||||||
|
exceptions.append({'measure': m['number'], 'reason': 'changed effective harmony or duration'})
|
||||||
|
seen[key] = signature
|
||||||
|
offset += section.measure_count
|
||||||
|
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
|
||||||
|
return result
|
||||||
@@ -28,7 +28,7 @@ def section_role(part_type: str, section_index: int) -> typing.Literal["accompan
|
|||||||
return "slash"
|
return "slash"
|
||||||
if section_index == 2:
|
if section_index == 2:
|
||||||
return "melody"
|
return "melody"
|
||||||
if section_index in {1, 5, 6}:
|
if section_index in {1, 3, 5, 6}:
|
||||||
return "accompaniment"
|
return "accompaniment"
|
||||||
return "rest"
|
return "rest"
|
||||||
return "vocal" if section_index in {1, 5, 6} else "rest"
|
return "vocal" if section_index in {1, 5, 6} else "rest"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import fractions
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
import generator.accompaniment
|
import generator.accompaniment
|
||||||
|
import generator.arrange.accompaniment
|
||||||
import generator.acoustic
|
import generator.acoustic
|
||||||
import generator.acoustic_voicing
|
import generator.acoustic_voicing
|
||||||
import generator.arrange.form
|
import generator.arrange.form
|
||||||
@@ -45,15 +46,15 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
|
|||||||
|
|
||||||
def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]:
|
def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]:
|
||||||
source = generator.score.source_staff(document)['measures']
|
source = generator.score.source_staff(document)['measures']
|
||||||
|
body = generator.score.body_start_measure_index(source)
|
||||||
|
previous = generator.accompaniment.carried_chords(source)
|
||||||
|
canonical = generator.accompaniment.plan_section(source[body:], rhythms, initial=previous[body], electric=True)
|
||||||
result = {}
|
result = {}
|
||||||
sections = generator.arrange.form.full_arrangement_sections(source, 'acoustic')
|
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'):
|
||||||
for index, section in enumerate(sections):
|
start = section.source_measure_index - body
|
||||||
if section.role == 'slash':
|
end = start + section.measure_count
|
||||||
continue
|
result[section.section_index] = generator.accompaniment.Plan(canonical.acoustic_bass[start:end],
|
||||||
measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
|
canonical.acoustic_upper[start:end], canonical.electric[start:end])
|
||||||
previous = generator.accompaniment.carried_chords(measures)
|
|
||||||
start = section.source_measure_index
|
|
||||||
result[section.section_index] = generator.accompaniment.plan_section(measures[start:start + section.measure_count], rhythms, initial=previous[start], electric=section.section_index in (1, 5, 6))
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan], *, scales: generator.scales.library.Library | None = None, reports: list[str] | None = None, source_name: str = '') -> dict:
|
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan], *, scales: generator.scales.library.Library | None = None, reports: list[str] | None = None, source_name: str = '') -> dict:
|
||||||
@@ -129,6 +130,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
|||||||
# the complete performance, including its lead-in and repeated choruses.
|
# the complete performance, including its lead-in and repeated choruses.
|
||||||
for number, measure in enumerate(output, 1):
|
for number, measure in enumerate(output, 1):
|
||||||
measure['number'] = str(number)
|
measure['number'] = str(number)
|
||||||
|
if part_type != 'vocal':
|
||||||
|
generator.arrange.accompaniment.install(document, result, plans)
|
||||||
generator.fretboard.validate_document(result)
|
generator.fretboard.validate_document(result)
|
||||||
if scales and solos:
|
if scales and solos:
|
||||||
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
|
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ import copy
|
|||||||
import fractions
|
import fractions
|
||||||
|
|
||||||
import generator.accompaniment
|
import generator.accompaniment
|
||||||
import generator.acoustic
|
import generator.arrange.accompaniment
|
||||||
import generator.acoustic_voicing
|
|
||||||
import generator.arrange.form
|
import generator.arrange.form
|
||||||
import generator.electric
|
import generator.electric
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.guitar
|
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.parts
|
|
||||||
import generator.score
|
import generator.score
|
||||||
import generator.solo.analysis
|
import generator.solo.analysis
|
||||||
import generator.solo.composer
|
import generator.solo.composer
|
||||||
@@ -41,57 +38,11 @@ def annotate(document, solo, offset):
|
|||||||
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})
|
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
|
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, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True):
|
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True):
|
||||||
"""Compose once per song, replacing only already-placed solo section windows."""
|
"""Compose once per song, replacing only already-placed solo section windows."""
|
||||||
analysis = generator.solo.analysis.analyze(source)
|
analysis = generator.solo.analysis.analyze(source)
|
||||||
sections = regions(source)
|
sections = regions(source)
|
||||||
source_measures = generator.score.source_staff(source)['measures']
|
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 = []
|
reports = []
|
||||||
requests = {}
|
requests = {}
|
||||||
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
|
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
|
||||||
@@ -115,17 +66,21 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
|
|||||||
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||||
generator.solo.notation.replace_region(parts[part], first, foreground)
|
generator.solo.notation.replace_region(parts[part], first, foreground)
|
||||||
annotate(parts[part], solo, offset)
|
annotate(parts[part], solo, offset)
|
||||||
# The other guitarist responds to the actual foreground, including vocal return.
|
# The other instrument already contains its canonical accompaniment.
|
||||||
if cutoff < length:
|
# Installing a solo must never rewrite the learned supporting part.
|
||||||
vocal = parts['vocal']['staves'][0]['measures'][first:first + count]
|
# Canonical supporting notes must remain compatible throughout every
|
||||||
time = fractions.Fraction()
|
# overlapped mapped span, independent of the foreground realization.
|
||||||
for measure, voice_measure in zip(foreground, vocal):
|
elapsed = fractions.Fraction()
|
||||||
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]
|
for measure in parts[other]['staves'][0]['measures'][first:first+count]:
|
||||||
if returning:
|
for event in generator.score.measure_notes(measure):
|
||||||
measure['voices'].append({'id': 'vocal_return', 'notes': returning})
|
start = elapsed + generator.model.fraction(event['onset'])
|
||||||
time += generator.model.fraction(measure['duration'])
|
end = min(cutoff, start + generator.model.fraction(event['duration']))
|
||||||
accompaniment = backing(parts[other], foreground, other, rhythms, head, solo.choices, initial)
|
if start >= end: continue
|
||||||
generator.solo.notation.replace_region(parts[other], first, accompaniment)
|
allowed = generator.solo.types.permitted(solo.choices,start,end)
|
||||||
|
if any(int(generator.model.pitch_number(p)) % 12 not in allowed for p in event['pitches']):
|
||||||
|
raise ValueError(f'{other} accompaniment, measure {measure["number"]}: canonical voicing outside solo map')
|
||||||
|
elapsed += generator.model.fraction(measure['duration'])
|
||||||
|
solo.diagnostics['canonical_accompaniment'] = generator.arrange.accompaniment.audit(source, parts)
|
||||||
reports.append(generator.solo.report.describe(solo, scales, first, offset))
|
reports.append(generator.solo.report.describe(solo, scales, first, offset))
|
||||||
for document in parts.values():
|
for document in parts.values():
|
||||||
generator.model.validate_document(document)
|
generator.model.validate_document(document)
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
|
|||||||
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
|
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
|
||||||
view = excerpt(parts[solo.part], first + begin, stop - begin)
|
view = excerpt(parts[solo.part], first + begin, stop - begin)
|
||||||
local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
|
local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
|
||||||
placed = generator.solo.placement.place(local, solo.tempo)
|
placed = generator.solo.placement.place(local, solo.tempo, profile=solo.profile)
|
||||||
if placed is None:
|
if placed is None:
|
||||||
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
|
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
|
||||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
|
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
|
||||||
@@ -135,11 +135,11 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
|
|||||||
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
|
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
|
||||||
song['solos'][solo.part]['draft_documents'] = drafts
|
song['solos'][solo.part]['draft_documents'] = drafts
|
||||||
stages = {}
|
stages = {}
|
||||||
for stage_name in ('initial', 'refined', 'before_enclosure'):
|
for stage_name in ('initial', 'refined', 'before_rhythm', 'before_enclosure'):
|
||||||
if not all(getattr(p, stage_name) for p in solo.phrases):
|
if not all(getattr(p, stage_name) for p in solo.phrases):
|
||||||
continue
|
continue
|
||||||
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
|
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
|
||||||
placed = generator.solo.placement.place(notes, solo.tempo)
|
placed = generator.solo.placement.place(notes, solo.tempo, profile=solo.profile if stage_name != 'initial' else None)
|
||||||
if placed is None:
|
if placed is None:
|
||||||
raise ValueError(stage_name + ': selected theme cannot be placed')
|
raise ValueError(stage_name + ': selected theme cannot be placed')
|
||||||
sounded = generator.solo.types.sounding_notes(placed[1])
|
sounded = generator.solo.types.sounding_notes(placed[1])
|
||||||
@@ -212,6 +212,38 @@ def mix(documents, tempo, root, cache):
|
|||||||
output[frame] += gain * release * values[position] / 32767
|
output[frame] += gain * release * values[position] / 32767
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def register_control(baseline,song,part):
|
||||||
|
"""Whole-phrase octave transfers of the preserved baseline, never new tunes."""
|
||||||
|
from generator.solo import profiles,types,placement,notation
|
||||||
|
detail=song['solos'][part]
|
||||||
|
raw=detail['structure'].get('instrument_profile')
|
||||||
|
if not raw:return None
|
||||||
|
profile=profiles.Instrument(raw['part'],tuple(raw['strings']),raw['reference'],tuple(raw['melody_range']))
|
||||||
|
result=[]
|
||||||
|
for phrase in baseline['solos'][part]['structure']['phrases']:
|
||||||
|
notes=[]
|
||||||
|
for n in phrase['draft']['notes']:
|
||||||
|
p=n['pitch']
|
||||||
|
notes.append(types.Note(fractions.Fraction(n['start']),fractions.Fraction(n['end']),
|
||||||
|
types.Pitch(p['letter'],fractions.Fraction(p['alter']),p['octave']),tuple(n['sources']),n['role'],
|
||||||
|
continuation=n['continuation'],event_id=n['event_id']))
|
||||||
|
options=[]
|
||||||
|
for octave in range(-2,3):
|
||||||
|
moved=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+octave)) for n in notes)
|
||||||
|
if not all(profile.permits(n.pitch.number()) for n in moved):continue
|
||||||
|
placed=placement.place(moved,fractions.Fraction(detail['tempo']),result[-1] if result else None,profile)
|
||||||
|
if placed:
|
||||||
|
rank=profile.cost(profiles.weighted_center(moved))+.02*abs(octave)+.001*placed[0]/max(1,len(moved))
|
||||||
|
options.append((rank,octave,placed[1]))
|
||||||
|
if not options:raise ValueError(part+': no whole-phrase register control within the instrument limits')
|
||||||
|
result.extend(min(options,key=lambda x:x[:2])[2])
|
||||||
|
view=excerpt(song['parts'][part],detail['first'],detail['count'])
|
||||||
|
notation.replace_region(view,0,notation.measures(view['staves'][0]['measures'],types.sounding_notes(result),'register_control_'+part))
|
||||||
|
generator.model.validate_document(view)
|
||||||
|
generator.fretboard.validate_document(view)
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
def render(root):
|
def render(root):
|
||||||
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
|
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
|
||||||
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
|
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
|
||||||
@@ -220,6 +252,7 @@ def render(root):
|
|||||||
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
|
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
|
||||||
for song_name, song in snapshot['songs'].items():
|
for song_name, song in snapshot['songs'].items():
|
||||||
baseline = snapshots[stages[0]]['songs'][song_name]
|
baseline = snapshots[stages[0]]['songs'][song_name]
|
||||||
|
comparison = snapshots[stages[-1]]['songs'][song_name]
|
||||||
if song['source_sha256'] != baseline['source_sha256']:
|
if song['source_sha256'] != baseline['source_sha256']:
|
||||||
raise ValueError(f'{song_name}: source changed between audition stages')
|
raise ValueError(f'{song_name}: source changed between audition stages')
|
||||||
for part, solo in song['solos'].items():
|
for part, solo in song['solos'].items():
|
||||||
@@ -234,7 +267,7 @@ def render(root):
|
|||||||
if 'structure' in solo:
|
if 'structure' in solo:
|
||||||
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
||||||
for responsive in [False, True] if stage == stages[-1] else [False]:
|
for responsive in [False, True] if stage == stages[-1] else [False]:
|
||||||
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
|
backing = excerpt((song if responsive else comparison)['parts'][other], first, count)
|
||||||
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
||||||
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
|
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
|
||||||
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
||||||
@@ -244,7 +277,7 @@ def render(root):
|
|||||||
strict = excerpt(solo['lead_stages']['before_enclosure']['document'], offset, count)
|
strict = excerpt(solo['lead_stages']['before_enclosure']['document'], offset, count)
|
||||||
strict_prefix=prefix.with_name(prefix.name+'_strict')
|
strict_prefix=prefix.with_name(prefix.name+'_strict')
|
||||||
generator.musicxml_writer.write_musicxml(strict_prefix.with_suffix('.musicxml'),strict)
|
generator.musicxml_writer.write_musicxml(strict_prefix.with_suffix('.musicxml'),strict)
|
||||||
backing=excerpt(baseline['parts'][other],first,count)
|
backing=excerpt(comparison['parts'][other],first,count)
|
||||||
vocal=excerpt(baseline['parts']['vocal'],first,count)
|
vocal=excerpt(baseline['parts']['vocal'],first,count)
|
||||||
path=strict_prefix.with_name(strict_prefix.name+'_fixed').with_suffix('.wav')
|
path=strict_prefix.with_name(strict_prefix.name+'_fixed').with_suffix('.wav')
|
||||||
clips.append((path,mix((strict,backing,vocal),fractions.Fraction(solo['tempo']),root,cache)))
|
clips.append((path,mix((strict,backing,vocal),fractions.Fraction(solo['tempo']),root,cache)))
|
||||||
@@ -257,7 +290,7 @@ def render(root):
|
|||||||
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
|
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
|
||||||
plain_prefix = prefix.with_name(prefix.name + '_plain')
|
plain_prefix = prefix.with_name(prefix.name + '_plain')
|
||||||
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
|
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
|
||||||
backing = excerpt(baseline['parts'][other], first, count)
|
backing = excerpt(comparison['parts'][other], first, count)
|
||||||
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
||||||
path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav')
|
path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav')
|
||||||
clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)))
|
clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)))
|
||||||
@@ -267,11 +300,21 @@ def render(root):
|
|||||||
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
|
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
|
||||||
clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache)))
|
clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache)))
|
||||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label})
|
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label})
|
||||||
|
if stage == 'after':
|
||||||
|
for control, document in (('register_control', register_control(baseline,song,part)),
|
||||||
|
('before_rhythm',solo.get('lead_stages',{}).get('before_rhythm',{}).get('document'))):
|
||||||
|
if document is None: continue
|
||||||
|
prefix=root/stage/f'{song_name}_{part}_{control}'
|
||||||
|
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'),document)
|
||||||
|
backing=excerpt(comparison['parts'][other],solo['first'],solo['count'])
|
||||||
|
path=prefix.with_suffix('.wav')
|
||||||
|
clips.append((path,mix((document,backing),fractions.Fraction(solo['tempo']),root,cache)))
|
||||||
|
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':control,'window':'full'})
|
||||||
for draft in solo.get('draft_documents', ()):
|
for draft in solo.get('draft_documents', ()):
|
||||||
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
||||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
||||||
if draft['first'] < 8:
|
if draft['first'] < 8:
|
||||||
backing = excerpt(baseline['parts'][other], solo['first'] + draft['first'], draft['count'])
|
backing = excerpt(comparison['parts'][other], solo['first'] + draft['first'], draft['count'])
|
||||||
path = prefix.with_suffix('.wav')
|
path = prefix.with_suffix('.wav')
|
||||||
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
|
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
|
||||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
|
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
|
||||||
@@ -334,16 +377,20 @@ def write_index(root, snapshots):
|
|||||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||||
|
|
||||||
def write_passage_index(root, snapshots):
|
def write_passage_index(root, snapshots):
|
||||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. Fixed comparisons retain the original backing. Responsive comparisons include accompaniment recomposed around the new foreground. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
|
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. All fixed comparisons use the final canonical backing. The published arrangement reuses that same learned accompaniment throughout the form. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
|
||||||
for name, song in snapshots['after']['songs'].items():
|
for name, song in snapshots['after']['songs'].items():
|
||||||
for part in song['solos']:
|
for part in song['solos']:
|
||||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
|
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
|
||||||
for stage in ('before', 'after'):
|
for stage in ('before', 'after'):
|
||||||
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
|
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
|
||||||
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
|
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
|
||||||
|
for control,label in (('register_control','Original phrases with register/string changes only'),('before_rhythm','Selected strict theme before rhythmic revision')):
|
||||||
|
if control=='before_rhythm' and control not in song['solos'][part].get('lead_stages',{}):continue
|
||||||
|
prefix=f'after/{name}_{part}_{control}'
|
||||||
|
body.append(f'<p>{label}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">Notation</a></p>')
|
||||||
strict_available='before_enclosure' in song['solos'][part].get('lead_stages',{})
|
strict_available='before_enclosure' in song['solos'][part].get('lead_stages',{})
|
||||||
plain_available='refined' in song['solos'][part].get('lead_stages',{})
|
plain_available='refined' in song['solos'][part].get('lead_stages',{})
|
||||||
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
|
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>Final canonical arrangement</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
|
||||||
for window in ('full', 'opening8', 'closing4'):
|
for window in ('full', 'opening8', 'closing4'):
|
||||||
body.append(f'<tr><th>{window}</th>')
|
body.append(f'<tr><th>{window}</th>')
|
||||||
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
|
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import fractions
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import generator.solo.enclosures
|
import generator.solo.enclosures
|
||||||
|
import generator.solo.profiles
|
||||||
|
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
@@ -129,10 +130,11 @@ def choose_phrases(plans, realize, revise_pair=None):
|
|||||||
|
|
||||||
def validate(solo):
|
def validate(solo):
|
||||||
previous = None
|
previous = None
|
||||||
|
profile = solo.profile or generator.solo.profiles.for_part(solo.part)
|
||||||
for note in solo.notes():
|
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:
|
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}')
|
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
|
||||||
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
|
if note.position not in generator.solo.placement.positions(note.pitch.number(), profile):
|
||||||
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
||||||
previous = note
|
previous = note
|
||||||
for phrase in solo.phrases:
|
for phrase in solo.phrases:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def approach(target, harmony):
|
|||||||
letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0)
|
letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0)
|
||||||
alter=number-1-generator.model.NATURAL[letter]-12*(octave+1)
|
alter=number-1-generator.model.NATURAL[letter]-12*(octave+1)
|
||||||
lower=types.Pitch(letter,F(alter),octave)
|
lower=types.Pitch(letter,F(alter),octave)
|
||||||
if not all(placement.positions(p.number()) for p in (upper,lower)):return None
|
if not all(placement.positions(p.number(),harmony.profile) for p in (upper,lower)):return None
|
||||||
identity=target.event_id+':enclosure'
|
identity=target.event_id+':enclosure'
|
||||||
return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'),
|
return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'),
|
||||||
types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower'))
|
types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower'))
|
||||||
@@ -113,22 +113,34 @@ def candidates(phrase,harmony,library,tempo,rejections):
|
|||||||
for begin,replacement,route in proposals[:1+policy.DEFAULT.enclosure_routes]:
|
for begin,replacement,route in proposals[:1+policy.DEFAULT.enclosure_routes]:
|
||||||
actual=replace_window(notes,begin,target.start,replacement)
|
actual=replace_window(notes,begin,target.start,replacement)
|
||||||
if actual is None:continue
|
if actual is None:continue
|
||||||
|
variants=[(actual,contract)]
|
||||||
|
upper_index=next((i for i,n in enumerate(actual) if n.event_id==upper.event_id),0)
|
||||||
|
if upper_index:
|
||||||
|
prior=actual[upper_index-1]
|
||||||
|
if prior.end==upper.start and prior.pitch==upper.pitch and editable(draft,prior.start,upper.start):
|
||||||
|
shared=dataclasses.replace(prior,end=upper.end)
|
||||||
|
combined=(*actual[:upper_index-1],shared,*actual[upper_index+1:])
|
||||||
|
variants.append((combined,dataclasses.replace(contract,upper=shared,approach_start=upper.start)))
|
||||||
|
for actual,actual_contract in variants:
|
||||||
valid,error=drafts.contract_valid(draft,actual)
|
valid,error=drafts.contract_valid(draft,actual)
|
||||||
if not valid:continue
|
if not valid:continue
|
||||||
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(contract,))
|
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(actual_contract,))
|
||||||
if not checked.valid:continue
|
if not checked.valid:continue
|
||||||
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
|
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
|
||||||
if not valid:continue
|
if not valid:continue
|
||||||
placed=placement.place(actual,tempo)
|
placed=placement.place(actual,tempo,profile=harmony.profile)
|
||||||
if placed is None:continue
|
if placed is None:continue
|
||||||
old_travel=placement.place(notes,tempo)[0]
|
old_travel=placement.place(notes,tempo,profile=harmony.profile)[0]
|
||||||
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
|
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
|
||||||
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
|
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
|
||||||
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
|
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
|
||||||
|
repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(actual,actual[1:]))
|
||||||
|
old_repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(notes,notes[1:]))
|
||||||
|
delta+=.15*max(0,repeated-old_repeated)
|
||||||
delta+=.1*(max(0,density-6)-max(0,before_density-6))
|
delta+=.1*(max(0,density-6)-max(0,before_density-6))
|
||||||
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
|
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
|
||||||
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(contract,))
|
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(actual_contract,))
|
||||||
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
|
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
|
||||||
if tuple(n for d in decisions for n in d.notes)!=actual:continue
|
if tuple(n for d in decisions for n in d.notes)!=actual:continue
|
||||||
retained=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
|
retained=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
|
||||||
@@ -152,7 +164,7 @@ def strict(solo):
|
|||||||
|
|
||||||
|
|
||||||
def refine(solo,library,enabled=True):
|
def refine(solo,library,enabled=True):
|
||||||
started=time.monotonic();harmony=harmonic.Harmony(solo.choices);rejections=[]
|
started=time.monotonic();harmony=harmonic.Harmony(solo.choices,solo.profile);rejections=[]
|
||||||
baseline=[]
|
baseline=[]
|
||||||
for phrase in solo.phrases:
|
for phrase in solo.phrases:
|
||||||
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes)) if phrase.draft else None
|
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes)) if phrase.draft else None
|
||||||
@@ -179,7 +191,7 @@ def refine(solo,library,enabled=True):
|
|||||||
_,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))
|
_,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))
|
||||||
# Resolve the complete guitar path once, including both sides of phrase joins.
|
# Resolve the complete guitar path once, including both sides of phrase joins.
|
||||||
raw=tuple(n for p in phrases for d in p.decisions for n in d.notes)
|
raw=tuple(n for p in phrases for d in p.decisions for n in d.notes)
|
||||||
placed=placement.place(raw,solo.tempo)
|
placed=placement.place(raw,solo.tempo,profile=harmony.profile)
|
||||||
if placed is None:raise ValueError('selected enclosure solo has no complete guitar path')
|
if placed is None:raise ValueError('selected enclosure solo has no complete guitar path')
|
||||||
by_id={n.event_id:n for n in placed[1]};final=[]
|
by_id={n.event_id:n for n in placed[1]};final=[]
|
||||||
for phrase in phrases:
|
for phrase in phrases:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Bounded timing revisions of complete realized gestures, with fixed pitches."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from fractions import Fraction as F
|
||||||
|
|
||||||
|
from generator.solo import drafts, harmonic, types
|
||||||
|
|
||||||
|
|
||||||
|
def fragmentation(notes):
|
||||||
|
isolated = sum(n.end-n.start <= F(1,2) and a.end < n.start and n.end < b.start
|
||||||
|
for a,n,b in zip(notes, notes[1:], notes[2:]))
|
||||||
|
gaps = sum(0 < b.start-a.end <= 1 for a,b in zip(notes, notes[1:]))
|
||||||
|
return isolated + .1*gaps
|
||||||
|
|
||||||
|
|
||||||
|
def revise(draft, harmony, treatments=()):
|
||||||
|
notes=draft.notes; edits=[]
|
||||||
|
if len(notes)<3: return draft, ()
|
||||||
|
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
|
||||||
|
# An identifying rhythm also protects its internal rests.
|
||||||
|
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
|
||||||
|
for _ in range(2):
|
||||||
|
proposals=[]
|
||||||
|
for i,(a,n,b) in enumerate(zip(notes,notes[1:],notes[2:]),1):
|
||||||
|
if n.event_id in protected or n.end-n.start>1: continue
|
||||||
|
if not (a.end<n.start or n.end<b.start): continue
|
||||||
|
if a.event_id not in protected and 0<n.start-a.end<=1:
|
||||||
|
proposals.append((*notes[:i-1],dataclasses.replace(a,end=n.start),*notes[i:]))
|
||||||
|
if 0<b.start-n.end<=1:
|
||||||
|
proposals.append((*notes[:i],dataclasses.replace(n,end=b.start),*notes[i+1:]))
|
||||||
|
for delta in (F(-1,2),F(1,2)):
|
||||||
|
if a.end<=n.start+delta and n.end+delta<=b.start:
|
||||||
|
proposals.append((*notes[:i],dataclasses.replace(n,start=n.start+delta,end=n.end+delta),*notes[i+1:]))
|
||||||
|
if len(proposals)>=8: break
|
||||||
|
valid=[]
|
||||||
|
for actual in proposals[:8]:
|
||||||
|
if fragmentation(actual)>=fragmentation(notes): continue
|
||||||
|
if not drafts.contract_valid(draft,actual)[0]: continue
|
||||||
|
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid: continue
|
||||||
|
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]: continue
|
||||||
|
valid.append(actual)
|
||||||
|
if not valid: break
|
||||||
|
actual=min(valid,key=lambda ns:(fragmentation(ns),tuple((n.start,n.end,n.pitch.number()) for n in ns)))
|
||||||
|
changed=[(a,b) for a,b in zip(notes,actual) if (a.start,a.end)!=(b.start,b.end)]
|
||||||
|
edits.append(types.DraftEdit('connected_rhythm',min(min(a.start,b.start) for a,b in changed),
|
||||||
|
max(max(a.end,b.end) for a,b in changed),tuple(a.event_id for a,b in changed),
|
||||||
|
tuple(b.event_id for a,b in changed),'same pitch sequence and attack count; connect an editable gesture'))
|
||||||
|
notes=actual
|
||||||
|
transforms=tuple(dataclasses.replace(t,rhythm=tuple((n.start-t.start,n.end-t.start) for n in notes if n.event_id in t.events))
|
||||||
|
if not t.preserves else t for t in draft.transformations)
|
||||||
|
return dataclasses.replace(draft,notes=notes,transformations=transforms),tuple(edits)
|
||||||
@@ -49,7 +49,9 @@ class Result:
|
|||||||
error: str = ''
|
error: str = ''
|
||||||
|
|
||||||
class Harmony:
|
class Harmony:
|
||||||
def __init__(self, choices):
|
def __init__(self, choices, profile=None):
|
||||||
|
from generator.solo.profiles import UNRESTRICTED
|
||||||
|
self.profile = profile or UNRESTRICTED
|
||||||
self.choices = choices
|
self.choices = choices
|
||||||
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
|
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
|
||||||
self._at = {}
|
self._at = {}
|
||||||
@@ -110,7 +112,8 @@ class Harmony:
|
|||||||
actual = events.get(expected.event_id)
|
actual = events.get(expected.event_id)
|
||||||
if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch):
|
if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch):
|
||||||
return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id
|
return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id
|
||||||
if upper.end-upper.start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
|
approach_start = contract.approach_start if contract.approach_start is not None else upper.start
|
||||||
|
if not upper.start <= approach_start or upper.end-approach_start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
|
||||||
return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target'
|
return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target'
|
||||||
if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1:
|
if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1:
|
||||||
return {}, contract.id + ': enclosure does not surround the exact target register'
|
return {}, contract.id + ': enclosure does not surround the exact target register'
|
||||||
@@ -192,7 +195,8 @@ class Harmony:
|
|||||||
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
|
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
|
||||||
covered = end
|
covered = end
|
||||||
function = self.function(number, span)
|
function = self.function(number, span)
|
||||||
if function == 'basic' or note.role == 'quote' or enclosure:
|
brief_enclosure = enclosure and (exception or enclosure.approach_start is None or enclosure.approach_start == note.start)
|
||||||
|
if function == 'basic' or note.role == 'quote' or brief_enclosure:
|
||||||
continue
|
continue
|
||||||
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
|
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
|
||||||
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
|
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import fractions
|
|||||||
|
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references
|
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references, profiles
|
||||||
|
|
||||||
F = fractions.Fraction
|
F = fractions.Fraction
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ def available(harmony, start, end, *, members=False):
|
|||||||
allowed &= harmony.members(start, end)
|
allowed &= harmony.members(start, end)
|
||||||
if not span or not span.scale:
|
if not span or not span.scale:
|
||||||
return ()
|
return ()
|
||||||
return tuple(p for i in range(-35, 28) if 41 <= (p := types.scale_pitch(span.scale, i)).number() <= 76 and p.number()%12 in allowed)
|
return tuple(p for i in range(-35, 28) if harmony.profile.register[0] <= (p := types.scale_pitch(span.scale, i)).number() <= harmony.profile.register[1] and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
|
||||||
|
|
||||||
|
|
||||||
def adapt(notes, harmony):
|
def adapt(notes, harmony):
|
||||||
@@ -324,7 +324,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
|
|||||||
from generator.solo import realization,themes,phrasing
|
from generator.solo import realization,themes,phrasing
|
||||||
context.resources[id(library)]=library
|
context.resources[id(library)]=library
|
||||||
context.resources[id(rhythms)]=rhythms
|
context.resources[id(rhythms)]=rhythms
|
||||||
harmony=harmonic.Harmony(choices)
|
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
|
||||||
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
|
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||||
states=[(0.,())]
|
states=[(0.,())]
|
||||||
failures=[]
|
failures=[]
|
||||||
@@ -344,7 +344,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
|
|||||||
meter=measure['time_signature'];break
|
meter=measure['time_signature'];break
|
||||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||||
for source in sources:
|
for source in sources:
|
||||||
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT)
|
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT,harmony.profile)
|
||||||
if key in context.results:
|
if key in context.results:
|
||||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||||
else:
|
else:
|
||||||
@@ -383,7 +383,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
|
|||||||
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
|
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
|
||||||
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
|
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
|
||||||
{**context.diagnostics,'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
|
{**context.diagnostics,'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
|
||||||
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in finished)
|
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in finished)
|
||||||
|
|
||||||
|
|
||||||
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
||||||
@@ -393,7 +393,7 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
|
|||||||
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
|
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
|
||||||
context.resources[id(library)]=library
|
context.resources[id(library)]=library
|
||||||
context.resources[id(rhythms)]=rhythms
|
context.resources[id(rhythms)]=rhythms
|
||||||
harmony=harmonic.Harmony(choices)
|
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
|
||||||
sources=clipped_sources(analysis,end)
|
sources=clipped_sources(analysis,end)
|
||||||
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
|
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||||
states=[(0.,())]
|
states=[(0.,())]
|
||||||
@@ -424,7 +424,7 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
|
|||||||
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
|
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
|
||||||
lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
|
lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
|
||||||
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','policy':policy.describe()['fingerprint']},
|
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','policy':policy.describe()['fingerprint']},
|
||||||
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states)
|
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in states)
|
||||||
|
|
||||||
|
|
||||||
def similarity(first,second):
|
def similarity(first,second):
|
||||||
|
|||||||
+38
-10
@@ -7,7 +7,7 @@ from fractions import Fraction as F
|
|||||||
|
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
from generator.solo import types, policy, sequences
|
from generator.solo import types, policy, sequences, profiles
|
||||||
|
|
||||||
|
|
||||||
def heard(notes, operation, anchor=None):
|
def heard(notes, operation, anchor=None):
|
||||||
@@ -52,7 +52,11 @@ def repetition(figure, history, bar):
|
|||||||
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
|
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
|
||||||
|
|
||||||
|
|
||||||
def register_goal(incoming, part, fallback):
|
def register_goal(incoming, part, fallback, profile=None):
|
||||||
|
if profile and profile.reference is not None:
|
||||||
|
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||||
|
center = round(sum(recent)/len(recent)) if recent else profile.reference
|
||||||
|
return max(profile.reference-4,min(profile.reference+4,center))
|
||||||
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||||
if not recent:
|
if not recent:
|
||||||
return fallback
|
return fallback
|
||||||
@@ -76,7 +80,7 @@ def endpoint_cost(finish, start, end, count, harmony, bar):
|
|||||||
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
|
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
|
||||||
dominant=degrees.get(3)==0 and degrees.get(7)==-1
|
dominant=degrees.get(3)==0 and degrees.get(7)==-1
|
||||||
short_remainder=.2*max(0,4-remaining) if remaining else 0.
|
short_remainder=.2*max(0,4-remaining) if remaining else 0.
|
||||||
return pacing+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
|
return pacing+(.08 if ending_effect(finish-F(1,100),harmony,False)=='local_settle' else 0)+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
|
||||||
|
|
||||||
|
|
||||||
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
|
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
|
||||||
@@ -105,11 +109,11 @@ def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2
|
|||||||
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
|
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
|
||||||
|
|
||||||
|
|
||||||
def endings(source, incoming, part, harmony, bar, *, limit=8):
|
def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
|
||||||
"""Choose pitch AND sounding interval before generating the approach."""
|
"""Choose pitch AND sounding interval before generating the approach."""
|
||||||
from generator.solo.leads import available
|
from generator.solo.leads import available
|
||||||
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
|
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
|
||||||
goal=register_goal(incoming,part,base)
|
goal=register_goal(incoming,part,base,harmony.profile)
|
||||||
values=[]
|
values=[]
|
||||||
duration=source.end-source.start
|
duration=source.end-source.start
|
||||||
holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),)
|
holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),)
|
||||||
@@ -120,10 +124,10 @@ def endings(source, incoming, part, harmony, bar, *, limit=8):
|
|||||||
for pitch in available(harmony,arrival,arrival+hold,members=True):
|
for pitch in available(harmony,arrival,arrival+hold,members=True):
|
||||||
if abs(pitch.number()-goal)>12:continue
|
if abs(pitch.number()-goal)>12:continue
|
||||||
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
|
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
|
||||||
harmony.members(arrival,arrival+hold),(41,76),hold,'terminal',harmony.at(arrival).chord,
|
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord,
|
||||||
hold_until=arrival+hold)
|
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
|
||||||
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
|
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
|
||||||
rank=destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
|
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
|
||||||
values.append((rank,anchor))
|
values.append((rank,anchor))
|
||||||
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
|
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
|
||||||
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
|
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
|
||||||
@@ -158,7 +162,7 @@ def evaluate(notes, incoming, harmony, start, end, bar):
|
|||||||
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
|
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
|
||||||
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
|
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
|
||||||
history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
|
history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
|
||||||
return 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
|
return harmony.profile.cost(profiles.weighted_center(notes))+2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=512)
|
@functools.lru_cache(maxsize=512)
|
||||||
@@ -197,7 +201,8 @@ def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, termi
|
|||||||
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
|
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
|
||||||
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
|
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
|
||||||
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
|
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
|
||||||
return function+register+entry+recurrence+lookahead+direction
|
pending=.08 if incoming and incoming.next_destination and pc not in incoming.next_destination else 0.
|
||||||
|
return function+register+entry+recurrence+lookahead+direction+pending
|
||||||
|
|
||||||
|
|
||||||
def destination_family(pitch, time, harmony):
|
def destination_family(pitch, time, harmony):
|
||||||
@@ -217,3 +222,26 @@ def destination_options(values, rank, pitch, time, harmony, limit):
|
|||||||
if value not in selected:selected.append(value)
|
if value not in selected:selected.append(value)
|
||||||
if len(selected)==limit:break
|
if len(selected)==limit:break
|
||||||
return tuple(selected)
|
return tuple(selected)
|
||||||
|
|
||||||
|
|
||||||
|
def ending_effect(time, harmony, final=False):
|
||||||
|
if final: return 'handoff'
|
||||||
|
current=harmony.at(time)
|
||||||
|
previous=next((s for s in reversed(harmony.spans) if current and s.end<=current.start and s.chord!=current.chord and s.scale),None)
|
||||||
|
if not current or not current.scale or not previous: return 'local_settle'
|
||||||
|
old=generator.harmony.parse_symbol(previous.chord)
|
||||||
|
new=generator.harmony.parse_symbol(current.chord)
|
||||||
|
degrees=generator.harmony.degrees(old)
|
||||||
|
def root(chord):
|
||||||
|
p=chord['root'];return int(generator.model.NATURAL[p['letter']]+generator.model.alteration(p['alter']))%12
|
||||||
|
if degrees.get(3)==0 and degrees.get(7)==-1 and (root(old)-root(new))%12==7:
|
||||||
|
return 'cadential_arrival'
|
||||||
|
return 'local_settle'
|
||||||
|
|
||||||
|
|
||||||
|
def next_destination(anchor,harmony):
|
||||||
|
if anchor.effect!='local_settle': return ()
|
||||||
|
following=[s for s in harmony.spans if s.start>=anchor.hold_until and s.scale]
|
||||||
|
resolution=next((s for s in following[:2] if ending_effect(s.start,harmony)=='cadential_arrival'),None)
|
||||||
|
selected=resolution or next(iter(following),None)
|
||||||
|
return tuple(sorted(selected.roles.basic & selected.scale.pitches)) if selected else ()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import functools
|
|||||||
|
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.solo.types
|
import generator.solo.types
|
||||||
|
from generator.solo.profiles import UNRESTRICTED
|
||||||
|
|
||||||
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
|
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
|
||||||
if previous is None:
|
if previous is None:
|
||||||
@@ -33,12 +34,12 @@ def stable_path(path):
|
|||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=4096)
|
@functools.lru_cache(maxsize=4096)
|
||||||
def alternatives(notes, tempo, previous=None, limit=2):
|
def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED):
|
||||||
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
|
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
|
||||||
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
|
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
|
||||||
for note in notes:
|
for note in notes:
|
||||||
following = {}
|
following = {}
|
||||||
for position in positions(note.pitch.number()):
|
for position in positions(note.pitch.number(), profile):
|
||||||
current = dataclasses.replace(note, position=position)
|
current = dataclasses.replace(note, position=position)
|
||||||
for hand in hand_positions(position):
|
for hand in hand_positions(position):
|
||||||
best = None
|
best = None
|
||||||
@@ -73,10 +74,11 @@ def alternatives(notes, tempo, previous=None, limit=2):
|
|||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=4096)
|
@functools.lru_cache(maxsize=4096)
|
||||||
def place(notes, tempo, previous=None):
|
def place(notes, tempo, previous=None, profile=UNRESTRICTED):
|
||||||
result = alternatives(notes,tempo,previous,1)
|
result = alternatives(notes,tempo,previous,1,profile)
|
||||||
return result[0] if result else None
|
return result[0] if result else None
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=128)
|
@functools.lru_cache(maxsize=2048)
|
||||||
def positions(number: int):
|
def positions(number: int, profile=UNRESTRICTED):
|
||||||
return generator.fretboard.single_positions_for_pitch(fractions.Fraction(number))
|
profile = profile or UNRESTRICTED
|
||||||
|
return tuple(p for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)) if p.string_number in profile.strings)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class Policy:
|
|||||||
enclosure_routes: int = 4
|
enclosure_routes: int = 4
|
||||||
enclosure_frontier: int = 8
|
enclosure_frontier: int = 8
|
||||||
enclosure_max_per_32_bars: int = 4
|
enclosure_max_per_32_bars: int = 4
|
||||||
composition_version: str = 'chord-destinations-enclosures-1'
|
composition_version: str = 'six-parts-register-signatures-1'
|
||||||
|
|
||||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Sounding-register references and hard solo instrument domains."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from fractions import Fraction as F
|
||||||
|
|
||||||
|
import generator.fretboard
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Instrument:
|
||||||
|
part: str = ''
|
||||||
|
strings: tuple[int, ...] = (1, 2, 3, 4, 5, 6)
|
||||||
|
reference: int | None = None
|
||||||
|
melody_range: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def register(self):
|
||||||
|
tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings]
|
||||||
|
return min(tuning) + 1, max(tuning) + 12
|
||||||
|
|
||||||
|
def permits(self, number):
|
||||||
|
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
|
||||||
|
|
||||||
|
def cost(self, number):
|
||||||
|
return min(1., max(0, abs(number - self.reference) - 4) / 12) if self.reference is not None else 0.
|
||||||
|
|
||||||
|
|
||||||
|
UNRESTRICTED = Instrument()
|
||||||
|
|
||||||
|
|
||||||
|
def weighted_center(notes):
|
||||||
|
values = sorted((n.pitch.number(), n.end - n.start) for n in notes)
|
||||||
|
total = sum((d for _, d in values), F())
|
||||||
|
elapsed = F()
|
||||||
|
for number, duration in values:
|
||||||
|
elapsed += duration
|
||||||
|
if elapsed >= total / 2:
|
||||||
|
return number
|
||||||
|
return 60
|
||||||
|
|
||||||
|
|
||||||
|
def for_part(part, analysis=None):
|
||||||
|
if part not in ('acoustic', 'electric'):
|
||||||
|
raise ValueError('unknown solo instrument: ' + part)
|
||||||
|
strings = (2, 3, 4, 5, 6) if part == 'acoustic' else (1, 2, 3, 4)
|
||||||
|
if analysis is None or not analysis.melody:
|
||||||
|
return Instrument(part, strings)
|
||||||
|
# Analysis contains the prepared concert source. Electric melody is written
|
||||||
|
# two octaves up and sounds one down (parts.build_measure), hence +12.
|
||||||
|
# Use the source voice, never the highest added harmonization voice.
|
||||||
|
shift = 12 if part == 'electric' else 0
|
||||||
|
notes = tuple(n for n in analysis.melody if n.end > analysis.body_offset)
|
||||||
|
return Instrument(part, strings, weighted_center(notes) + shift,
|
||||||
|
(min(n.pitch.number() for n in notes) + shift, max(n.pitch.number() for n in notes) + shift))
|
||||||
@@ -501,7 +501,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
|
|||||||
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
|
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
|
||||||
if not valid:
|
if not valid:
|
||||||
return None
|
return None
|
||||||
placed = generator.solo.placement.place(notes, tempo, incoming.last)
|
placed = generator.solo.placement.place(notes, tempo, incoming.last, harmony.profile)
|
||||||
if placed is None:
|
if placed is None:
|
||||||
return None
|
return None
|
||||||
travel, notes = placed
|
travel, notes = placed
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import generator.solo.harmonic
|
|||||||
import generator.solo.drafts
|
import generator.solo.drafts
|
||||||
import generator.solo.similarity
|
import generator.solo.similarity
|
||||||
import generator.solo.references
|
import generator.solo.references
|
||||||
|
import generator.solo.placement
|
||||||
|
|
||||||
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
|
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')
|
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
|
||||||
@@ -58,7 +59,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
|||||||
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
|
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
|
||||||
for anchor in phrase.anchors:
|
for anchor in phrase.anchors:
|
||||||
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
|
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
|
||||||
lines.append(f' Anchor {anchor.id}: {anchor.role}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
|
lines.append(f' Anchor {anchor.id}: {anchor.role}/{anchor.effect}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
|
||||||
if phrase.state:
|
if phrase.state:
|
||||||
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
|
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
|
||||||
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
|
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
|
||||||
@@ -124,7 +125,7 @@ def structure(solo):
|
|||||||
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
|
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
|
||||||
entry['score'] = phrase.score
|
entry['score'] = phrase.score
|
||||||
phrases.append(entry)
|
phrases.append(entry)
|
||||||
return {'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
return {'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
||||||
|
|
||||||
|
|
||||||
def phrasing_report(solo):
|
def phrasing_report(solo):
|
||||||
@@ -146,7 +147,7 @@ def phrasing_report(solo):
|
|||||||
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
|
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
|
||||||
note=generator.solo.harmonic.anchor_note(anchor,phrase.state.notes)
|
note=generator.solo.harmonic.anchor_note(anchor,phrase.state.notes)
|
||||||
endings.append({'phrase':phrase.plan.id,'arrival':str(note.start) if note else None,'pitch':anchor.pitch.label(),
|
endings.append({'phrase':phrase.plan.id,'arrival':str(note.start) if note else None,'pitch':anchor.pitch.label(),
|
||||||
'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
|
'effect':anchor.effect,'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
|
||||||
'release':str(phrase.plan.release),'completed':note is not None})
|
'release':str(phrase.plan.release),'completed':note is not None})
|
||||||
if local:
|
if local:
|
||||||
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
|
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
|
||||||
@@ -180,7 +181,7 @@ def fretboard_report(solo):
|
|||||||
string=note.position.string_number
|
string=note.position.string_number
|
||||||
attacks[string]+=1;durations[string]+=float(note.end-note.start)
|
attacks[string]+=1;durations[string]+=float(note.end-note.start)
|
||||||
if string==1:
|
if string==1:
|
||||||
if len(generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())))==1:forced+=1
|
if len(generator.solo.placement.positions(note.pitch.number(),solo.profile))==1:forced+=1
|
||||||
else:optional+=1
|
else:optional+=1
|
||||||
total=sum(durations.values())
|
total=sum(durations.values())
|
||||||
return {'attacks_by_string':dict(sorted(attacks.items())),
|
return {'attacks_by_string':dict(sorted(attacks.items())),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
|
|||||||
harmony.route_libraries={}
|
harmony.route_libraries={}
|
||||||
harmony.concrete_routes={}
|
harmony.concrete_routes={}
|
||||||
harmony.route_libraries[id(library)]=library
|
harmony.route_libraries[id(library)]=library
|
||||||
concrete_key=(notes,frozenset(fixed),id(library),register)
|
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile)
|
||||||
if concrete_key in harmony.concrete_routes:
|
if concrete_key in harmony.concrete_routes:
|
||||||
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
|
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
|
||||||
# Event/source IDs and non-fixed placeholder pitches do not affect route
|
# Event/source IDs and non-fixed placeholder pitches do not affect route
|
||||||
@@ -42,7 +42,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
|
|||||||
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
|
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
|
||||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
|
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
|
||||||
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
|
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
|
||||||
key=(canonical,fixed_indices,id(library),register)
|
key=(canonical,fixed_indices,id(library),register,harmony.profile)
|
||||||
if key not in harmony.route_cache:
|
if key not in harmony.route_cache:
|
||||||
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
|
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
|
||||||
result=[]
|
result=[]
|
||||||
@@ -76,9 +76,9 @@ def _candidates(notes, fixed, harmony, library, register):
|
|||||||
types.permitted(harmony.choices,n.start,n.end),
|
types.permitted(harmony.choices,n.start,n.end),
|
||||||
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
|
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
|
||||||
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
|
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
|
||||||
key=(first.pitch,contexts,id(library),register)
|
key=(first.pitch,contexts,id(library),register,harmony.profile)
|
||||||
if key not in harmony.pitch_routes:
|
if key not in harmony.pitch_routes:
|
||||||
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register)
|
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register,harmony.profile)
|
||||||
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
|
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
|
||||||
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
|
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
|
||||||
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
|
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
|
||||||
@@ -91,12 +91,15 @@ def _candidates(notes, fixed, harmony, library, register):
|
|||||||
return tuple(result)
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
def pitch_paths(first, contexts, library, register):
|
def pitch_paths(first, contexts, library, register, profile=None):
|
||||||
"""Time-independent pitch work; context includes every overlapped collection.
|
"""Time-independent pitch work; context includes every overlapped collection.
|
||||||
|
|
||||||
Sounding durations and chord roles are still validated on each concrete
|
Sounding durations and chord roles are still validated on each concrete
|
||||||
realization. Equal collection contexts can reuse paths across rhythmic edits.
|
realization. Equal collection contexts can reuse paths across rhythmic edits.
|
||||||
"""
|
"""
|
||||||
|
from generator.solo.profiles import UNRESTRICTED
|
||||||
|
profile = profile or UNRESTRICTED
|
||||||
|
if not profile.permits(first.number()): return ()
|
||||||
destination=contexts[-1][2]
|
destination=contexts[-1][2]
|
||||||
try:
|
try:
|
||||||
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
|
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
|
||||||
@@ -106,6 +109,7 @@ def pitch_paths(first, contexts, library, register):
|
|||||||
direction=(destination>first.number())-(destination<first.number())
|
direction=(destination>first.number())-(destination<first.number())
|
||||||
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
|
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
|
||||||
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
|
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
|
||||||
|
low, high = max(low, profile.register[0]), min(high, profile.register[1])
|
||||||
result=[]
|
result=[]
|
||||||
for path in paths:
|
for path in paths:
|
||||||
realized=[first]
|
realized=[first]
|
||||||
@@ -116,7 +120,7 @@ def pitch_paths(first, contexts, library, register):
|
|||||||
pitch=options[0];number=pitch.number()
|
pitch=options[0];number=pitch.number()
|
||||||
if not low<=number<=high or number%12 not in allowed:break
|
if not low<=number<=high or number%12 not in allowed:break
|
||||||
if fixed is not None and number!=fixed:break
|
if fixed is not None and number!=fixed:break
|
||||||
if not generator.solo.placement.positions(number):break
|
if not generator.solo.placement.positions(number,profile):break
|
||||||
realized.append(pitch)
|
realized.append(pitch)
|
||||||
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
|
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
|
||||||
return tuple(result)
|
return tuple(result)
|
||||||
|
|||||||
+40
-29
@@ -10,7 +10,7 @@ from fractions import Fraction as F
|
|||||||
|
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing
|
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing, profiles
|
||||||
|
|
||||||
|
|
||||||
def source_notes(analysis):
|
def source_notes(analysis):
|
||||||
@@ -22,7 +22,7 @@ def source_notes(analysis):
|
|||||||
def silence(source,incoming,part,harmony,tempo,seed):
|
def silence(source,incoming,part,harmony,tempo,seed):
|
||||||
from generator.solo import realization
|
from generator.solo import realization
|
||||||
motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony')
|
motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony')
|
||||||
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,(41,76),F(),F(),
|
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,harmony.profile.register,F(),F(),
|
||||||
(types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end)
|
(types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end)
|
||||||
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
|
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
|
||||||
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
|
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
|
||||||
@@ -43,10 +43,10 @@ def short_fragment(source,incoming,part,harmony,tempo,seed,final):
|
|||||||
for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True):
|
for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True):
|
||||||
note=dataclasses.replace(template,pitch=pitch)
|
note=dataclasses.replace(template,pitch=pitch)
|
||||||
motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity')
|
motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity')
|
||||||
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),(41,76),note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
|
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),harmony.profile.register,note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
|
||||||
transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=())
|
transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=())
|
||||||
draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference)
|
draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference)
|
||||||
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,(41,76),F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
|
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,harmony.profile.register,F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
|
||||||
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
|
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
|
||||||
if phrase:
|
if phrase:
|
||||||
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
|
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
|
||||||
@@ -194,7 +194,7 @@ def instantiate(templates,first,steps,harmony):
|
|||||||
options=routes.advance(span.scale,result[-1].pitch.number(),step)
|
options=routes.advance(span.scale,result[-1].pitch.number(),step)
|
||||||
if not options:return ()
|
if not options:return ()
|
||||||
p=options[0]
|
p=options[0]
|
||||||
if not 41<=p.number()<=76 or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
|
if not harmony.profile.permits(p.number()) or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
|
||||||
result.append(dataclasses.replace(note,pitch=p))
|
result.append(dataclasses.replace(note,pitch=p))
|
||||||
return tuple(result) if len(result)==len(templates) else ()
|
return tuple(result) if len(result)==len(templates) else ()
|
||||||
|
|
||||||
@@ -222,11 +222,20 @@ def _prefix_paths(templates,ref,first,harmony,library):
|
|||||||
result.append(dataclasses.replace(template,pitch=p))
|
result.append(dataclasses.replace(template,pitch=p))
|
||||||
return (tuple(result),)
|
return (tuple(result),)
|
||||||
paths=[]
|
paths=[]
|
||||||
|
if ref.treatment == 'return':
|
||||||
|
from generator.solo.leads import available
|
||||||
|
delta=first.number()-original[0].pitch.number()
|
||||||
|
direct=[]
|
||||||
|
for template,n in zip(templates,original):
|
||||||
|
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==n.pitch.number()+delta),None)
|
||||||
|
if p is None: break
|
||||||
|
direct.append(dataclasses.replace(template,pitch=p))
|
||||||
|
if len(direct)==count: paths.append(tuple(direct))
|
||||||
end_template=templates[count-1]
|
end_template=templates[count-1]
|
||||||
source_motion=original[-1].pitch.number()-original[0].pitch.number()
|
source_motion=original[-1].pitch.number()-original[0].pitch.number()
|
||||||
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
|
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
|
||||||
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
|
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
|
||||||
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,(41,76),'seed '+ref.id))
|
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'seed '+ref.id))
|
||||||
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
|
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
|
||||||
return sequences.diverse(valid,lambda ns:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)),
|
return sequences.diverse(valid,lambda ns:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)),
|
||||||
lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4)
|
lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4)
|
||||||
@@ -247,7 +256,7 @@ class Unit:
|
|||||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
|
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
|
||||||
# History is evaluated by the caller after retrieving these concrete options.
|
# History is evaluated by the caller after retrieving these concrete options.
|
||||||
# Every state component used to construct this pool is in its cache key.
|
# Every state component used to construct this pool is in its cache key.
|
||||||
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices)
|
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',(harmony.choices,harmony.profile))
|
||||||
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
||||||
if key in context.results:
|
if key in context.results:
|
||||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||||
@@ -257,14 +266,14 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
|||||||
if len(pattern)<count or len(pattern)>16:continue
|
if len(pattern)<count or len(pattern)>16:continue
|
||||||
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources),
|
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources),
|
||||||
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
|
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
|
||||||
base=incoming.last.pitch.number() if incoming.last else ref.notes[0].pitch.number()
|
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference if harmony.profile.reference is not None else ref.notes[0].pitch.number()
|
||||||
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
|
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
|
||||||
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
|
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
|
||||||
for first in firsts:
|
for first in firsts:
|
||||||
for target in targets:
|
for target in targets:
|
||||||
# Rank joint jobs, retaining rhythm and destination coverage before
|
# Rank joint jobs, retaining rhythm and destination coverage before
|
||||||
# expensive path instantiation. The budget is spent on complete routes.
|
# expensive path instantiation. The budget is spent on complete routes.
|
||||||
rank=max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
|
rank=harmony.profile.cost(first.number())+max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
|
||||||
jobs.append((rank,templates,label,first,target))
|
jobs.append((rank,templates,label,first,target))
|
||||||
jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))))
|
jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))))
|
||||||
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set()
|
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set()
|
||||||
@@ -292,7 +301,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
|||||||
continue
|
continue
|
||||||
tail=(prefix[-1],*templates[count:])
|
tail=(prefix[-1],*templates[count:])
|
||||||
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
|
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
|
||||||
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,(41,76),operation+' '+ref.id)[:3+batch]:
|
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,harmony.profile.register,operation+' '+ref.id)[:3+batch]:
|
||||||
proposed.append(((*prefix[:-1],*route.notes),(route,)))
|
proposed.append(((*prefix[:-1],*route.notes),(route,)))
|
||||||
seen=set()
|
seen=set()
|
||||||
for actual,connections in proposed:
|
for actual,connections in proposed:
|
||||||
@@ -309,8 +318,8 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
|||||||
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1)
|
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1)
|
||||||
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
|
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
|
||||||
# Activity belongs to the complete phrase; contracts are pass/fail.
|
# Activity belongs to the complete phrase; contracts are pass/fail.
|
||||||
score=2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
|
score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
|
||||||
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
|
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register,
|
||||||
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
|
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
|
||||||
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
|
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
|
||||||
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
||||||
@@ -322,17 +331,14 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
|||||||
|
|
||||||
|
|
||||||
def home_reference(home,operation):
|
def home_reference(home,operation):
|
||||||
# Prefer a distinguishing turn/leap wherever it actually occurred. The heard
|
# A return refers to the opening the listener heard, not whichever later
|
||||||
# home remains immutable; a development never becomes the new source theme.
|
# three-note turn happens to rank best. Development may use its smaller core.
|
||||||
groups=[home.notes[i:i+3] for i in range(max(1,len(home.notes)-2))]
|
notes = home.signature or home.notes[:min(4, len(home.notes))]
|
||||||
def rank(group):
|
if operation not in ('return', 'repeat_with_harmonic_adaptation'):
|
||||||
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])]
|
notes = notes[:3]
|
||||||
turn=len(intervals)>1 and intervals[0]*intervals[1]<0
|
|
||||||
return (-int(turn),-max((abs(x) for x in intervals),default=0),group[0].start)
|
|
||||||
notes=min(groups,key=rank)
|
|
||||||
origin=notes[0].start
|
origin=notes[0].start
|
||||||
notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes)
|
notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes)
|
||||||
treatment='rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','return','develop_rhythm') else 'contour_seed'
|
treatment='return' if operation=='return' else 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','develop_rhythm') else 'contour_seed'
|
||||||
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
|
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
|
||||||
|
|
||||||
|
|
||||||
@@ -341,7 +347,7 @@ def make_home(unit,harmony,start=None,end=None):
|
|||||||
start=unit.notes[0].start if start is None else start
|
start=unit.notes[0].start if start is None else start
|
||||||
identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16]
|
identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16]
|
||||||
home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony)
|
home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony)
|
||||||
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony))
|
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)])
|
||||||
|
|
||||||
|
|
||||||
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
|
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
|
||||||
@@ -355,7 +361,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
|||||||
seed_key=('source-seeds',analysis,bar,seed)
|
seed_key=('source-seeds',analysis,bar,seed)
|
||||||
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
|
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
|
||||||
roots=context.pools[seed_key]
|
roots=context.pools[seed_key]
|
||||||
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1))
|
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1),final=final)
|
||||||
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
|
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
|
||||||
cursor=source.start
|
cursor=source.start
|
||||||
while cursor<source.end and states:
|
while cursor<source.end and states:
|
||||||
@@ -447,13 +453,18 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
|||||||
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
|
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
|
||||||
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
||||||
landmark=types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sustained destination')
|
landmark=types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sustained destination')
|
||||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,(41,76),F(5,4),
|
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
|
||||||
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
|
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
|
||||||
source.id,source.start,source.end,(anchors,))
|
source.id,source.start,source.end,(anchors,))
|
||||||
|
before_rhythm=draft
|
||||||
|
from generator.solo import gesture_rhythm
|
||||||
|
if stage=='elaborated':
|
||||||
|
draft,rhythm_edits=gesture_rhythm.revise(draft,harmony,incoming.treatments)
|
||||||
|
notes=draft.notes;edits=(*edits,*rhythm_edits);transforms=draft.transformations
|
||||||
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
||||||
if phrase is None:continue
|
if phrase is None:continue
|
||||||
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)
|
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)+.1*gesture_rhythm.fragmentation(notes)
|
||||||
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number()),u.anchor.role=='terminal') for u in units)/len(units)
|
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number(),harmony.profile),u.anchor.role=='terminal') for u in units)/len(units)
|
||||||
appearance_state=incoming
|
appearance_state=incoming
|
||||||
for unit in units:
|
for unit in units:
|
||||||
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
|
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
|
||||||
@@ -464,16 +475,16 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
|||||||
for transform in transforms:
|
for transform in transforms:
|
||||||
group=tuple(n for n in notes if n.event_id in transform.events)
|
group=tuple(n for n in notes if n.event_id in transform.events)
|
||||||
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None),bar)
|
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None),bar)
|
||||||
current=dataclasses.replace(phrase.state,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
|
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony),home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
|
||||||
history=history,returns=state.returns,closures=incoming.closures+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
|
history=history,returns=state.returns,closures=incoming.closures+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
|
||||||
if not incoming.home:
|
if not incoming.home:
|
||||||
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
|
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
|
||||||
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
|
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
|
||||||
degree_steps=relative_steps(introduced,harmony))
|
degree_steps=relative_steps(introduced,harmony),signature=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced[:len(units[0].reference.notes)]))
|
||||||
current=dataclasses.replace(current,home=heard,themes=(heard,))
|
current=dataclasses.replace(current,home=heard,themes=(heard,))
|
||||||
source_control=leads.shift(source.notes,next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
|
source_control=leads.shift(source.notes,next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
|
||||||
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
|
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
|
||||||
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
|
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
|
||||||
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
|
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
|
||||||
return sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
return sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
||||||
lambda p:(p.state.home.id,p.state.last.pitch.number(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
|
lambda p:(p.state.home.id,p.state.last.pitch.number(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import generator.fretboard
|
|||||||
import generator.model
|
import generator.model
|
||||||
import generator.scales.library
|
import generator.scales.library
|
||||||
import generator.scales.selection
|
import generator.scales.selection
|
||||||
|
import generator.solo.profiles
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True, order=True)
|
@dataclasses.dataclass(frozen=True, order=True)
|
||||||
class Pitch:
|
class Pitch:
|
||||||
@@ -116,6 +117,7 @@ class Anchor:
|
|||||||
chord: str
|
chord: str
|
||||||
onset_required: bool = False
|
onset_required: bool = False
|
||||||
hold_until: fractions.Fraction | None = None
|
hold_until: fractions.Fraction | None = None
|
||||||
|
effect: str = 'local_settle'
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class Gesture:
|
class Gesture:
|
||||||
@@ -136,6 +138,7 @@ class RealizedMotif:
|
|||||||
chord_degrees: tuple[tuple[int, ...], ...] = ()
|
chord_degrees: tuple[tuple[int, ...], ...] = ()
|
||||||
source_start: fractions.Fraction = fractions.Fraction()
|
source_start: fractions.Fraction = fractions.Fraction()
|
||||||
degree_steps: tuple[int, ...] = ()
|
degree_steps: tuple[int, ...] = ()
|
||||||
|
signature: tuple[Note, ...] = ()
|
||||||
def contour(self):
|
def contour(self):
|
||||||
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
|
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
|
||||||
|
|
||||||
@@ -163,6 +166,7 @@ class ContinuationState:
|
|||||||
returns: tuple[fractions.Fraction, ...] = ()
|
returns: tuple[fractions.Fraction, ...] = ()
|
||||||
closures: int = 0
|
closures: int = 0
|
||||||
register_peak: int = 0
|
register_peak: int = 0
|
||||||
|
next_destination: tuple[int, ...] = ()
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class HeardFigure:
|
class HeardFigure:
|
||||||
@@ -243,6 +247,7 @@ class EnclosureContract:
|
|||||||
chord: str
|
chord: str
|
||||||
target_role: str
|
target_role: str
|
||||||
protected_tail: SustainedTail
|
protected_tail: SustainedTail
|
||||||
|
approach_start: fractions.Fraction | None = None
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class MelodicDraft:
|
class MelodicDraft:
|
||||||
@@ -343,6 +348,7 @@ class Phrase:
|
|||||||
refined: MelodicDraft | None = None
|
refined: MelodicDraft | None = None
|
||||||
routes: tuple[RouteCandidate, ...] = ()
|
routes: tuple[RouteCandidate, ...] = ()
|
||||||
before_enclosure: MelodicDraft | None = None
|
before_enclosure: MelodicDraft | None = None
|
||||||
|
before_rhythm: MelodicDraft | None = None
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class Solo:
|
class Solo:
|
||||||
@@ -353,6 +359,7 @@ class Solo:
|
|||||||
end: fractions.Fraction
|
end: fractions.Fraction
|
||||||
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
|
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
|
||||||
passages: tuple[MelodicPassagePlan, ...] = ()
|
passages: tuple[MelodicPassagePlan, ...] = ()
|
||||||
|
profile: generator.solo.profiles.Instrument | None = None
|
||||||
def notes(self) -> tuple[Note, ...]:
|
def notes(self) -> tuple[Note, ...]:
|
||||||
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
|
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ class ScaleOutputTests(unittest.TestCase):
|
|||||||
def test_only_soloing_part_is_annotated_without_revoicing(self):
|
def test_only_soloing_part_is_annotated_without_revoicing(self):
|
||||||
content = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()
|
content = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()
|
||||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
||||||
roles = {'acoustic': ('slash', 'accompaniment'), 'electric': ('rest', 'slash'), 'vocal': ('rest', 'rest')}
|
roles = {'acoustic': ('slash', 'accompaniment'), 'electric': ('accompaniment', 'slash'), 'vocal': ('rest', 'rest')}
|
||||||
for part in ('acoustic', 'electric', 'vocal'):
|
for part in ('acoustic', 'electric', 'vocal'):
|
||||||
before = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
|
before = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
|
||||||
result = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library())
|
result = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library())
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import copy
|
||||||
|
import dataclasses
|
||||||
|
from fractions import Fraction as F
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import generator.arrange.generate
|
||||||
|
import generator.arrange.form
|
||||||
|
import generator.model
|
||||||
|
import generator.parts
|
||||||
|
import generator.solo.arrangement
|
||||||
|
from generator.solo import analysis, profiles, placement, themes, types, harmonic, gesture_rhythm, phrasing
|
||||||
|
from tests.test_solo import song
|
||||||
|
from tests.test_solo_development import note
|
||||||
|
from tests.test_solo_passages import choices
|
||||||
|
import tests.rhythm_fixtures
|
||||||
|
|
||||||
|
|
||||||
|
def events(measure):
|
||||||
|
return tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||||
|
tuple((p['letter'],p['alter']['numerator'],p['octave'],p['tablature']['string'],p['tablature']['fret']) for p in n['pitches']))
|
||||||
|
for v in measure['voices'] for n in v['notes'])
|
||||||
|
|
||||||
|
|
||||||
|
class ReusableAccompaniment(unittest.TestCase):
|
||||||
|
def test_corresponding_bars_repeat_rhythm_pitch_and_fingering(self):
|
||||||
|
source=song(count=12,chords=('CM7','Dm7','G7'))
|
||||||
|
library=tests.rhythm_fixtures.library()
|
||||||
|
prepared=generator.parts.prepare_document(source)
|
||||||
|
plans=generator.arrange.generate.plan_arrangement(prepared,library)
|
||||||
|
for part in ('acoustic','electric'):
|
||||||
|
result=generator.arrange.generate._arrange_document(prepared,part,plans)
|
||||||
|
seen={};offset=0;checked=0
|
||||||
|
sections=generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'],part)
|
||||||
|
for section in sections:
|
||||||
|
for j,m in enumerate(result['staves'][0]['measures'][offset:offset+section.measure_count]):
|
||||||
|
if section.role!='accompaniment':continue
|
||||||
|
key=section.source_measure_index+j
|
||||||
|
if key in seen:
|
||||||
|
self.assertEqual(events(m),seen[key]);checked+=1
|
||||||
|
seen[key]=events(m)
|
||||||
|
self.assertFalse(any('tie_from' in p or 'tie_to' in p for v in m['voices'] for n in v['notes'] for p in n['pitches']))
|
||||||
|
offset+=section.measure_count
|
||||||
|
self.assertGreater(checked,12)
|
||||||
|
generator.model.validate_document(result)
|
||||||
|
|
||||||
|
def test_excerpt_rhythms_retain_form_phase(self):
|
||||||
|
source=generator.parts.prepare_document(song(count=13,chords=('CM7',)))
|
||||||
|
plans=generator.arrange.generate.plan_arrangement(source,tests.rhythm_fixtures.library())
|
||||||
|
for lane in ('acoustic_bass','acoustic_upper','electric'):
|
||||||
|
full=getattr(plans[1],lane)
|
||||||
|
self.assertEqual(getattr(plans[0],lane),full[-8:])
|
||||||
|
self.assertEqual(getattr(plans[6],lane),full[-8:])
|
||||||
|
self.assertEqual(getattr(plans[3],lane),full)
|
||||||
|
self.assertEqual(getattr(plans[4],lane),full)
|
||||||
|
|
||||||
|
|
||||||
|
class SoloInstrumentAndIdentity(unittest.TestCase):
|
||||||
|
def test_role_ranges_and_no_open_strings(self):
|
||||||
|
acoustic=profiles.for_part('acoustic');electric=profiles.for_part('electric')
|
||||||
|
self.assertEqual(acoustic.register,(41,71));self.assertEqual(electric.register,(51,76))
|
||||||
|
self.assertTrue(acoustic.permits(41));self.assertFalse(electric.permits(50))
|
||||||
|
self.assertFalse(acoustic.permits(72))
|
||||||
|
for profile in (acoustic,electric):
|
||||||
|
for midi in range(40,78):
|
||||||
|
for p in placement.positions(midi,profile):
|
||||||
|
self.assertIn(p.string_number,profile.strings)
|
||||||
|
self.assertTrue(1<=p.fret<=12)
|
||||||
|
self.assertTrue(any(p.string_number==1 for p in placement.positions(65,electric)))
|
||||||
|
self.assertFalse(any(p.string_number==1 for p in placement.positions(65,acoustic)))
|
||||||
|
|
||||||
|
def test_sounding_melody_reference_and_octave_relation(self):
|
||||||
|
source=analysis.analyze(generator.parts.prepare_document(song(count=4,chords=('CM7',))))
|
||||||
|
acoustic=profiles.for_part('acoustic',source);electric=profiles.for_part('electric',source)
|
||||||
|
self.assertEqual(electric.reference-acoustic.reference,12)
|
||||||
|
self.assertEqual(electric.reference,profiles.weighted_center(source.melody)+12)
|
||||||
|
self.assertEqual(acoustic.cost(acoustic.reference+4),0)
|
||||||
|
self.assertGreater(acoustic.cost(acoustic.reference+9),0)
|
||||||
|
|
||||||
|
def test_return_keeps_opening_instead_of_later_largest_turn(self):
|
||||||
|
notes=tuple(note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('d','f','g','f','c','b')))
|
||||||
|
home=types.RealizedMotif('home','seed',notes,F(6),'introduce','contour',signature=notes[:4])
|
||||||
|
reference=themes.home_reference(home,'return')
|
||||||
|
self.assertEqual(reference.notes,notes[:4])
|
||||||
|
self.assertEqual(reference.treatment,'return')
|
||||||
|
self.assertEqual(len(themes.home_reference(home,'develop_contour').notes),3)
|
||||||
|
|
||||||
|
def test_endings_distinguish_local_cadence_and_handoff(self):
|
||||||
|
harmony=harmonic.Harmony(choices(('Em7b5','A7','Dm','Dm')))
|
||||||
|
self.assertEqual(phrasing.ending_effect(F(2),harmony),'local_settle')
|
||||||
|
self.assertEqual(phrasing.ending_effect(F(9),harmony),'cadential_arrival')
|
||||||
|
self.assertEqual(phrasing.ending_effect(F(15),harmony,True),'handoff')
|
||||||
|
anchor=types.Anchor('comma',F(1),F(1),note(0,1,'g').pitch,frozenset({7}),(41,76),F(2),'terminal','Em7b5',hold_until=F(3))
|
||||||
|
self.assertEqual(set(phrasing.next_destination(anchor,harmony)),{2,5,9})
|
||||||
|
|
||||||
|
def test_rhythm_revision_connects_without_changing_attacks_or_arrival(self):
|
||||||
|
notes=(note(0,1,'g',event_id='a'),note(F(3,2),2,'e',event_id='b'),note(F(5,2),3,'g',event_id='c'),note(3,6,'e',event_id='end'))
|
||||||
|
anchor=types.Anchor('end',F(3),F(3),notes[-1].pitch,frozenset({4}),(41,76),F(3),'terminal','C7',hold_until=F(6))
|
||||||
|
draft=types.MelodicDraft('gesture',F(),F(6),notes,(),(),(),(anchor,),None,'develop',sustained_tails=(types.SustainedTail(F(3),F(6),64),))
|
||||||
|
revised,edits=gesture_rhythm.revise(draft,harmonic.Harmony(choices(('C7','C7'))))
|
||||||
|
self.assertTrue(edits)
|
||||||
|
self.assertEqual(tuple(n.pitch for n in revised.notes),tuple(n.pitch for n in notes))
|
||||||
|
self.assertEqual(revised.notes[-1],notes[-1])
|
||||||
|
self.assertLess(gesture_rhythm.fragmentation(revised.notes),gesture_rhythm.fragmentation(notes))
|
||||||
|
protected=dataclasses.replace(draft,protected=tuple(n.event_id for n in notes))
|
||||||
|
self.assertEqual(gesture_rhythm.revise(protected,harmonic.Harmony(choices(('C7','C7'))))[0].notes,notes)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -210,6 +210,9 @@ class SoloTests(unittest.TestCase):
|
|||||||
old, new = before[part]['staves'][0]['measures'], parts[part]['staves'][0]['measures']
|
old, new = before[part]['staves'][0]['measures'], parts[part]['staves'][0]['measures']
|
||||||
self.assertEqual(old[:low], new[:low])
|
self.assertEqual(old[:low], new[:low])
|
||||||
self.assertEqual(old[high:], new[high:])
|
self.assertEqual(old[high:], new[high:])
|
||||||
|
support = 4 if part == 'acoustic' else 3
|
||||||
|
first, count, _, _ = regions[support]
|
||||||
|
self.assertEqual(old[first:first+count],new[first:first+count])
|
||||||
for section in (3, 4):
|
for section in (3, 4):
|
||||||
first, count, _, _ = regions[section]
|
first, count, _, _ = regions[section]
|
||||||
self.assertTrue(any(n['pitches'] for m in new[first:first + count] for n in generator.score.measure_notes(m)))
|
self.assertTrue(any(n['pitches'] for m in new[first:first + count] for n in generator.score.measure_notes(m)))
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ def fixture(symbol='C7',target=None,scale=None):
|
|||||||
|
|
||||||
|
|
||||||
class EnclosureContracts(unittest.TestCase):
|
class EnclosureContracts(unittest.TestCase):
|
||||||
|
def test_existing_upper_can_continue_without_a_new_attack(self):
|
||||||
|
h,notes,c=fixture('Dm',note(4,8,'d'))
|
||||||
|
upper=dataclasses.replace(notes[0],start=F(5,2))
|
||||||
|
contract=dataclasses.replace(c,upper=upper,approach_start=F(3))
|
||||||
|
self.assertTrue(h.evaluate((upper,*notes[1:]),enclosures=(contract,)).valid)
|
||||||
|
# Joining a tie must not hide an exposed long non-chord upper neighbor.
|
||||||
|
long_upper=dataclasses.replace(upper,start=F(1))
|
||||||
|
invalid=dataclasses.replace(contract,upper=long_upper)
|
||||||
|
self.assertFalse(h.evaluate((long_upper,*notes[1:]),enclosures=(invalid,)).valid)
|
||||||
|
split=(dataclasses.replace(upper,end=F(3)),dataclasses.replace(upper,start=F(3),continuation=True,event_id='tie'),*notes[1:])
|
||||||
|
self.assertEqual(h.evaluate(split,enclosures=(contract,)),h.evaluate((upper,*notes[1:]),enclosures=(contract,)))
|
||||||
|
|
||||||
def test_worked_examples_and_actual_collection_membership(self):
|
def test_worked_examples_and_actual_collection_membership(self):
|
||||||
hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
|
hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
|
||||||
for symbol,target,scale,expected in (
|
for symbol,target,scale,expected in (
|
||||||
|
|||||||
Reference in New Issue
Block a user