Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fff5a165e | |||
| a0bd19b3ba | |||
| f2f371b951 | |||
| f5861fcdf6 |
@@ -95,17 +95,25 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live
|
||||
|
||||
`data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts.
|
||||
|
||||
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build uses these collections, the song melody, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with transformed motifs, connected melodic lines, varying activity, sustained arrivals, and planned space. A passage plan groups roughly four to eight bars, connects opening, intermediate and terminal chord-tone anchors, and carries realized motif identity across smaller search cells and phrase boundaries. A bounded search considers the next phrase before committing. Exact quotation is available but is no longer the default. The other guitar provides solo-aware accompaniment. Scale changes appear above the solo notation and TAB; detailed build-only phrase reports live in `generated_assets/analysis/`. No solo assignments are stored in song XML. Invalid patterns or a harmony without an exact permitted scale fail before replacing the previous output.
|
||||
`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.
|
||||
|
||||
Solo harmonic function is distinct from scale permission. Basic members respect the chord's suspensions, alterations and omissions; symbol-specified extensions require a planned color when sustained, and a non-chord slash bass is not automatically a melodic destination. Ordinary notes lasting two quarter-note beats or longer must remain chord members across every overlapping chord, even when the scale stays unchanged. Short scale motion remains available. A prepared suspension must resolve by the planned deadline to an adjacent chord member. These checks operate on complete sounding events, so notation ties do not change harmonic treatment or attack counts. Protected quotations keep their musical content and report exposed tensions.
|
||||
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.
|
||||
|
||||
The composer builds complete timed melodic drafts before selecting elaboration. An identifying rhythm is repeated, extended, displaced, fragmented or answered across the phrase, including across barlines. Extensions can reopen provisional endings; committed anchors and protected quotations remain binding. The original realized home motif and its latest development are retained separately, including into later passages, where continuation, recall and contrast are explicit choices. Several anchor contours and terminal durations are considered with their rhythmic realization. Bounded revision can edit both sides of an internal join, followed by complete harmonic, motif, timing and guitar validation.
|
||||
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.
|
||||
|
||||
Concrete continuations start from the actual incoming pitch and guitar position. Relative routes from `data/sequences` use exact octave-aware scale indices; a collection change adapts actual pitches without transferring stale degree numbers. Intermediate arrivals permit continuation; terminal arrivals must meet their duration and ending contract. Activity and occupancy targets remain unchanged. Search retains up to 64 pitch states within a musical group, 16 at completed groups, and eight complete phrase alternatives. Join revision considers up to eight proposals in two-beat windows on either side, with at most two sweeps. One-phrase lookahead also permits revisions across an uncommitted connected phrase seam. Up to two further construction batches may be tried without weakening musical contracts. Exhaustion reports the failed continuation or contract, not a claim of musical impossibility.
|
||||
Scale permission and chord function remain distinct. Every generated sound must belong to all mapped collections it overlaps. Added chromatic substitutions and their validation exception have been removed; mapped accidentals such as C-sharp in D harmonic minor remain valid. Basic chord members respect suspensions, alterations and omissions; slash-bass requirements do not automatically become melodic targets. Ordinary sounds lasting at least two quarter-note beats must remain chord members across every overlapping chord, unless an explicit valid color or prepared suspension applies. Tied notation is merged before evaluation. Terminal anchors must actually complete, and protected quotations retain their contracts.
|
||||
|
||||
For reproducible listening comparisons, `python -m generator.solo.audition --output build/solo_passage_auditions --capture before` captures the previous implementation; run the corresponding `--capture after` after changing the engine, then `--render`. Use the repository's pinned Python runner. The output includes full solos, opening eight-bar and closing four-bar excerpts, MusicXML, passage reports and structured diagnostics. Fixed-backing comparisons keep the original accompaniment; responsive comparisons use the newly arranged accompaniment. These build-only tools do not add runtime data or playback expression.
|
||||
Rhythm snippets and complete interval routes realize one- or two-bar edits jointly with chord-tone destinations. Routes retain their entry, complete timing, pitch path and arrival across barlines and collection changes. Later candidates receive the incoming note, position and realized theme before pitch generation. Run entries may extend an existing note without reattack. Intermediate arrivals continue the thought; the final destination has a closed-ending contract. Missing harmony creates explicit silence while preserving thematic memory. Short fragments around missing harmony are explicitly reduced and do not claim full motif fidelity.
|
||||
|
||||
For phrase-draft experiments, preserve a `--capture baseline` snapshot before changing the implementation, then run `--compare-phrases` and `--render` in that output directory. This compares complete phrase construction with and without join revision, sharing concrete draft pools when incoming musical states match. It also exports simple drafts, neutral-pitch rhythm diagnostics, motif lineage, selected edits and ending completion. Earlier revisions can change later incoming states and therefore require different draft pools. Capture timings include cache effects; normal production builds initialize their own search. Listening and playing, rather than the diagnostics alone, determine whether the result is better.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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, and candidate diagnostics. 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`.
|
||||
|
||||
To inspect generated assets without building the full package, run:
|
||||
|
||||
|
||||
+9
-7
@@ -190,23 +190,25 @@ Scale labels appear above chord symbols at collection changes and system starts.
|
||||
|
||||
### Phrase Composition
|
||||
|
||||
`generator/solo/` is build-only. `analysis.py` extracts one- and two-bar monophonic motifs from the normalized melody, collapsing ties while retaining repeated attacks, leading rests, source event IDs, and harmonic context. `planning.py` selects up to three recurring motif identities and plans statements, returns, variations and answers around two- to four-bar boundaries. Each phrase has active passages, a sustained arrival and optional intentional space, plus entrance, intermediate and arrival register windows. Melody breathing points and harmonic destinations influence phrase boundaries. `types.py` owns the exact rational timing models. No annotations are added to source XML.
|
||||
`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.
|
||||
|
||||
`sequences.py` validates all 2-, 3- and 4-note paths in `data/sequences`, then retrieves representatives by displacement, direction, shape and destination. Longer paths chain catalog entries through a shared endpoint and report every source. Steps accumulate through octaves, including negative scale indices. Sequence meter describes the source container, not the realized duration. `realization.py` fits paths to attacks from `data/rhythms`, retaining connected eighth-note patterns and slicing at the actual bar offset without stretching time. It also supports single targets, repeated notes, holds, and silence. Thematic contour and harmonic adaptation continue across cells and collection changes; every held pitch must remain permitted for its entire duration.
|
||||
`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.
|
||||
|
||||
`scoring.py` evaluates complete sounding prefixes against the activity planned so far. Attack density and sounding occupancy are separate, and explicit ties count as one attack. Active or sustained passages cannot disappear through a missing register value; intentional space and no-harmony regions have different obligations. A register corridor charges only pitches outside its bounds. Intermediate landmarks are evaluated once their windows close. Duration-normalized, bounded penalties use the curated weights in `policy.py`: activity 4, occupancy 4, motif fidelity 3, landmarks 3, register/contour/connection/repetition 1 each, guitar movement 0.5. Fixed evaluation windows and bounded caches avoid repeating harmonic analysis for every extension. Soft ranking uses floating-point penalties; timing, scale validity, protected quotes, required arrivals and guitar placement retain exact constraints.
|
||||
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.
|
||||
|
||||
Motif fidelity follows the planned contract: `exact_quote` preserves pitches and attack/release timing, permitting only a whole-fragment octave relocation; `rhythmic_reference` preserves timing while changing pitches; `contour_reference` retains direction changes; `opening_reference` protects the beginning; `answer` permits a changed ending; and `invented` develops its own sequence-derived identity. Exact quotation remains explicitly available but is not scheduled automatically. Invented motifs depend on harmony, starting register, shape and destination. Stable musical-content identities ignore file paths and titles; reports record both identity and the policy fingerprint.
|
||||
`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.
|
||||
|
||||
Phrase search retains at most 64 states and considers 32 diverse extensions per state. Its identity includes activity, rhythm, landmark and motif progress as well as the placed ending. Up to four completed alternatives are retained, and `composer.py` evaluates the next phrase from those endings before committing. Closed arrivals prefer defining chord tones; open statements may target a compatible fifth or extension, respecting explicit alterations and omissions. A bounded-search failure identifies the passage and limits, not mathematical impossibility.
|
||||
A rolling sixteen-bar history measures exact and transposed pitch/rhythm recurrence, repeated oscillations, and cadence formulas. Recent reuse receives more pressure than distant reuse, and deliberate returns receive a discount rather than immunity. Ties collapse to one attack. Register goals offer wider acoustic development and lower electric answers after high passages. These are musical preferences, not range or string quotas. Phrase activity retains a broad preference of four to six attacks per four quarter-note beats, including its held ending; individual closing cells have no attack quota. Occupancy is diagnostic. Explicit active, sustained-arrival, and space spans prevent a silent candidate from satisfying a moving passage.
|
||||
|
||||
Acoustic begins with a transformed melody reference; electric begins with a contextual invention. Soft activity goals average 4.5 and 5.5 attacks per four beats respectively, distributed unevenly across movement and arrival. These are not per-bar quotas. Boundaries may connect, sustain or breathe; only the returning vocal pickup has mandatory protection. Sounding registers start around G3–D5 and B3–G5 respectively; written guitar notation is one octave above sound, exactly once. `placement.py` uses frets 1–20 with no open strings and preserves positions through explicit sustain continuations. Movement cost uses the first declared source tempo, or 120 quarter notes/minute. Runtime tempo remains adjustable, so this is not a guarantee of ease at every tempo. Chromatic ornaments, expressive articulation, dynamics and swing playback remain deferred.
|
||||
`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.
|
||||
|
||||
`arrangement.py` analyzes each song once and replaces only the two solo-section windows after normal head/accompaniment placement. Electric backs Acoustic Solo; acoustic backs Electric Solo. Neither guitarist accompanies itself. Backing rhythms use the generated solo's foreground and the reserved vocal return, not the absent original melody. Electric may reuse the corresponding placed head grip only when its pitches fit the active solo collection; otherwise ordinary chord-tone placement applies. Existing chord constraints remain unchanged. All non-solo measures and the complete vocal part are regression boundaries. The vocal pickup cutoff is reserved before composing the electric ending.
|
||||
|
||||
`notation.py` converts placed attack spans into ordinary score events, rests, and tied continuations across notation/bar boundaries. 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 --capture D` captures arrangements, reports, full-solo metrics, source hashes, policy and implementation fingerprints under `build/solo_auditions/D`. The build-only utility refuses to overwrite a snapshot. Capture `baseline`, `A`, `B` and `C` from their corresponding implementation checkpoints before capture D; these labels do not select runtime presets. `--render` creates eight-bar MusicXML and WAV comparisons for each song and guitar using the baseline backing, plus final responsive-backing clips. All clips use existing tone generation, source tempo and a shared gain. `index.html` provides playback and links to reports; `manifest.json` records reproducibility metadata. Metrics cover activity, occupancy, unplanned gaps, connected eighths, quotation and surviving candidate families. They diagnose behavior rather than establish musical quality.
|
||||
`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.
|
||||
|
||||
### Engraving Boundary
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY)
|
||||
file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/data/*.xml
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/data/expected/*.json
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/data/lead_phrases/*.json
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
|
||||
${VAMP_SHARED_PYTHON_INPUTS}
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
{
|
||||
"description": "Original hand-composed reference, not output from the user engine or a quotation of Autumn Leaves.",
|
||||
"tempo_bpm": 120,
|
||||
"meter": "4/4",
|
||||
"pitch_convention": "sounding",
|
||||
"time_units": "exact rational quarter-note beats",
|
||||
"velocity_policy": "lead 80, backing 48; constant within each track; no accents or dynamics",
|
||||
"scale_regions": [
|
||||
{
|
||||
"start": "0",
|
||||
"end": "16",
|
||||
"scale": "D Aeolian"
|
||||
},
|
||||
{
|
||||
"start": "16",
|
||||
"end": "24",
|
||||
"scale": "D Harmonic Minor"
|
||||
},
|
||||
{
|
||||
"start": "24",
|
||||
"end": "32",
|
||||
"scale": "D Aeolian"
|
||||
}
|
||||
],
|
||||
"harmony": [
|
||||
{
|
||||
"bar": 1,
|
||||
"chord": "Gm7",
|
||||
"required_basic_pitch_classes": [
|
||||
7,
|
||||
10,
|
||||
2,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"chord": "C7",
|
||||
"required_basic_pitch_classes": [
|
||||
0,
|
||||
4,
|
||||
7,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"chord": "Fmaj7",
|
||||
"required_basic_pitch_classes": [
|
||||
5,
|
||||
9,
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"chord": "Bbmaj7",
|
||||
"required_basic_pitch_classes": [
|
||||
10,
|
||||
2,
|
||||
5,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"chord": "Em7b5",
|
||||
"required_basic_pitch_classes": [
|
||||
4,
|
||||
7,
|
||||
10,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"chord": "A7",
|
||||
"required_basic_pitch_classes": [
|
||||
9,
|
||||
1,
|
||||
4,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"chord": "Dm7",
|
||||
"required_basic_pitch_classes": [
|
||||
2,
|
||||
5,
|
||||
9,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"chord": "Dm7",
|
||||
"required_basic_pitch_classes": [
|
||||
2,
|
||||
5,
|
||||
9,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"variants": {
|
||||
"lead_theme": {
|
||||
"attacks": 31,
|
||||
"events": [
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "0",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "1",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "5/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "3",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "4",
|
||||
"duration_beats": "2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "6",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "13/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "7",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "8",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "9",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "21/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "11",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "12",
|
||||
"duration_beats": "2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "14",
|
||||
"duration_beats": "1",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "15",
|
||||
"duration_beats": "1",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "16",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "Bb4",
|
||||
"midi_pitch": 70
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "35/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "18",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "19",
|
||||
"duration_beats": "1",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "20",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "21",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "43/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "22",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C#4",
|
||||
"midi_pitch": 61
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "23",
|
||||
"duration_beats": "1",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "24",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "25",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "53/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "27",
|
||||
"duration_beats": "1",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "28",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "57/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "29",
|
||||
"duration_beats": "2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "31",
|
||||
"duration_beats": "1",
|
||||
"pitch": null,
|
||||
"midi_pitch": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"lead_with_runs": {
|
||||
"attacks": 36,
|
||||
"events": [
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "0",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "1",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "5/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "3",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "4",
|
||||
"duration_beats": "1",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "5",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "11/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "6",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "13/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "Bb4",
|
||||
"midi_pitch": 70
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "7",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "15/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "8",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "9",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "21/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "11",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "12",
|
||||
"duration_beats": "2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "14",
|
||||
"duration_beats": "1",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "15",
|
||||
"duration_beats": "1",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "16",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "Bb4",
|
||||
"midi_pitch": 70
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "35/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "18",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "19",
|
||||
"duration_beats": "1",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "20",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "21",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "43/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "22",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "45/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "C#4",
|
||||
"midi_pitch": 61
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "23",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "47/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "C#4",
|
||||
"midi_pitch": 61
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "24",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "25",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "53/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "27",
|
||||
"duration_beats": "1",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "28",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "57/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "29",
|
||||
"duration_beats": "2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "31",
|
||||
"duration_beats": "1",
|
||||
"pitch": null,
|
||||
"midi_pitch": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "original_lead_theme",
|
||||
"acceptance": "User accepted plain theme and runs as references",
|
||||
"tonic": "D",
|
||||
"mode": "minor",
|
||||
"run_windows": [
|
||||
{
|
||||
"start": "4",
|
||||
"end": "8"
|
||||
},
|
||||
{
|
||||
"start": "20",
|
||||
"end": "24"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"id": "leap_return",
|
||||
"start": "0",
|
||||
"end": "4",
|
||||
"relationship": "timed leap and return"
|
||||
},
|
||||
{
|
||||
"id": "restatement",
|
||||
"start": "8",
|
||||
"end": "12",
|
||||
"relationship": "related rhythm and leap with harmonic adaptation"
|
||||
}
|
||||
],
|
||||
"development": [
|
||||
{
|
||||
"role": "statement",
|
||||
"start": "0",
|
||||
"end": "4",
|
||||
"degrees": [
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "answer",
|
||||
"start": "4",
|
||||
"end": "8",
|
||||
"degrees": [
|
||||
0,
|
||||
2,
|
||||
3,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "return",
|
||||
"start": "8",
|
||||
"end": "12",
|
||||
"degrees": [
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "extension",
|
||||
"start": "12",
|
||||
"end": "16",
|
||||
"degrees": [
|
||||
0,
|
||||
2,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "departure",
|
||||
"start": "16",
|
||||
"end": "20",
|
||||
"degrees": [
|
||||
0,
|
||||
-1,
|
||||
-2,
|
||||
-4
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "continuation",
|
||||
"start": "20",
|
||||
"end": "24",
|
||||
"degrees": [
|
||||
0,
|
||||
-1,
|
||||
-2,
|
||||
-4,
|
||||
-2
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "recall",
|
||||
"start": "24",
|
||||
"end": "28",
|
||||
"degrees": [
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "ending",
|
||||
"start": "28",
|
||||
"end": "32",
|
||||
"degrees": [
|
||||
0,
|
||||
-2,
|
||||
-1
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -136,7 +136,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
reports.append(report)
|
||||
return result
|
||||
|
||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library, sequences: generator.solo.sequences.Library) -> int:
|
||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library, sequences: generator.solo.sequences.Library, references=()) -> int:
|
||||
count = 0
|
||||
for path in generator.parts.source_paths(source_dir):
|
||||
try:
|
||||
@@ -151,7 +151,7 @@ def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Pa
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}, part {part}: {error}') from error
|
||||
try:
|
||||
report = generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences)
|
||||
report = generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, references=references)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}: {error}') from error
|
||||
for part, result in parts.items():
|
||||
|
||||
@@ -16,6 +16,8 @@ class Analysis:
|
||||
boundaries: tuple[fractions.Fraction, ...]
|
||||
tempo: fractions.Fraction
|
||||
melody: tuple[generator.solo.types.Note, ...]
|
||||
body_offset: fractions.Fraction = fractions.Fraction()
|
||||
source_phrases: tuple[generator.solo.types.SourcePhrase, ...] = ()
|
||||
|
||||
def identity(analysis, measures, part):
|
||||
anchor = analysis.melody[0].pitch.number() if analysis.melody else 0
|
||||
@@ -63,16 +65,14 @@ def analyze(document: dict) -> Analysis:
|
||||
candidates = []
|
||||
for index, measure in enumerate(measures):
|
||||
start = boundaries[index]
|
||||
# One- and two-bar ideas retain leading silence and the complete held ending.
|
||||
for count in (1, 2):
|
||||
# Legacy motif consumers also receive complete longer source material.
|
||||
for count in (1, 2, 4, 6, 8):
|
||||
if index + count >= len(boundaries):
|
||||
continue
|
||||
end = boundaries[index + count]
|
||||
notes = tuple(n for n in melody if start <= n.start < end)
|
||||
if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])):
|
||||
continue
|
||||
if len(notes) > 8:
|
||||
continue
|
||||
normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes)
|
||||
identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii')
|
||||
identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12]
|
||||
@@ -82,4 +82,56 @@ def analyze(document: dict) -> Analysis:
|
||||
# Preserve the opening idea, then favor a contrasting rhythm/contour with space.
|
||||
ordered = list(unique.values())
|
||||
ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration))
|
||||
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody)
|
||||
offset = boundaries[generator.score.body_start_measure_index(measures)]
|
||||
phrases = source_phrases(melody, offset, boundaries[-1] - offset,
|
||||
generator.model.fraction(measures[-1]['duration']))
|
||||
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody, offset, phrases)
|
||||
|
||||
def source_phrases(melody, offset, end, bar):
|
||||
"""Partition at musical releases, without slicing a sounding event at bars."""
|
||||
notes = tuple(dataclasses.replace(n, start=n.start-offset, end=n.end-offset,
|
||||
event_id=n.sources[0]) for n in melody if n.end > offset)
|
||||
if any(a.end > b.start for a, b in zip(notes, notes[1:])):
|
||||
raise ValueError('solo source requires a monophonic melody')
|
||||
candidates = sorted({fractions.Fraction(), end, *(n.end for n in notes if 0 < n.end <= end),
|
||||
*(n.start for n in notes if 0 < n.start < end)})
|
||||
candidates = [t for t in candidates if not any(n.start < t < n.end for n in notes)]
|
||||
if not candidates or candidates[0] != 0:
|
||||
candidates.insert(0, fractions.Fraction())
|
||||
best = {end: (0., ())}
|
||||
for start in reversed(candidates[:-1]):
|
||||
options = []
|
||||
for finish in candidates:
|
||||
duration = finish-start
|
||||
if finish not in best or duration <= 0 or duration > 8*bar or duration < 2*bar and finish != end:
|
||||
continue
|
||||
prior = next((n for n in reversed(notes) if n.end <= finish), None)
|
||||
after = next((n for n in notes if n.start >= finish), None)
|
||||
gap = float(max(0, after.start-prior.end)) if prior and after else 0.
|
||||
held = float(prior.end-prior.start) if prior else 0.
|
||||
cost = abs(float(duration/bar)-4)/4 + .7*(held < 2) - .3*min(2,gap)
|
||||
options.append((cost+best[finish][0], (finish,*best[finish][1])))
|
||||
if options:
|
||||
best[start] = min(options)
|
||||
if 0 not in best:
|
||||
# A single extended held event is one musical unit, regardless of length.
|
||||
stops = (end,)
|
||||
else:
|
||||
stops = best[0][1]
|
||||
result, start = [], fractions.Fraction()
|
||||
for finish in stops:
|
||||
actual = tuple(n for n in notes if n.start < finish and n.end > start)
|
||||
cursor, rests = start, []
|
||||
for n in actual:
|
||||
if cursor < n.start:
|
||||
rests.append((cursor,n.start))
|
||||
cursor = n.end
|
||||
if cursor < finish:
|
||||
rests.append((cursor,finish))
|
||||
origin = actual[0].pitch.number() if actual else 0
|
||||
shape = tuple((n.start-start,n.end-start,n.pitch.number()-origin) for n in actual)
|
||||
family = hashlib.sha256(repr(shape).encode()).hexdigest()[:12]
|
||||
pickup = tuple(dataclasses.replace(n,start=n.start-offset,end=n.end-offset,event_id=n.sources[0]) for n in melody if n.end<=offset) if start==0 else ()
|
||||
result.append(generator.solo.types.SourcePhrase(f'source_{start}_{finish}',start,finish,actual,tuple(rests),family,pickup))
|
||||
start = finish
|
||||
return tuple(result)
|
||||
|
||||
@@ -85,7 +85,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
|
||||
generator.model.validate_document(document)
|
||||
return document['staves'][0]['measures']
|
||||
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None):
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated'):
|
||||
"""Compose once per song, replacing only already-placed solo section windows."""
|
||||
analysis = generator.solo.analysis.analyze(source)
|
||||
sections = regions(source)
|
||||
@@ -93,7 +93,8 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
|
||||
head_first, head_count, _, _ = sections[2]
|
||||
head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count]
|
||||
reports = []
|
||||
for section_index, part, other in ((3, 'acoustic', 'electric'), (4, 'electric', 'acoustic')):
|
||||
requests = {}
|
||||
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
|
||||
first, count, offset, length = sections[section_index]
|
||||
initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first]
|
||||
template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count])
|
||||
@@ -103,7 +104,12 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
|
||||
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
|
||||
if sounding:
|
||||
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
|
||||
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, context=(contexts or {}).get(part))
|
||||
requests[part] = (template, cutoff, initial)
|
||||
selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage)
|
||||
for solo, section_index, other in zip(selected, (3, 4), ('electric', 'acoustic')):
|
||||
part = solo.part
|
||||
first, count, offset, length = sections[section_index]
|
||||
template, cutoff, initial = requests[part]
|
||||
if solos is not None:
|
||||
solos.append(solo)
|
||||
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||
|
||||
@@ -28,6 +28,8 @@ import generator.solo.arrangement
|
||||
import generator.solo.report
|
||||
import generator.solo.sequences
|
||||
import generator.solo.realization
|
||||
import generator.solo.references
|
||||
import generator.solo.harmonic
|
||||
|
||||
STAGES = ('baseline', 'A', 'B', 'C', 'D')
|
||||
|
||||
@@ -81,12 +83,21 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
if destination.exists():
|
||||
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
|
||||
rhythms, scales, sequences = libraries or resources()
|
||||
result = {'stage': stage, 'implementation': fingerprint(), 'songs': {}}
|
||||
reference_library = generator.solo.references.load_library(pathlib.Path('data/lead_phrases'))
|
||||
result = {'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}}
|
||||
try:
|
||||
result['policy'] = importlib.import_module('generator.solo.policy').describe()
|
||||
except ModuleNotFoundError:
|
||||
result['policy'] = {'baseline': True}
|
||||
for path in generator.parts.source_paths(pathlib.Path('data/songs')):
|
||||
source_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json'
|
||||
if checkpoint.exists():
|
||||
saved = json.loads(checkpoint.read_text())
|
||||
if saved['implementation'] == result['implementation'] and saved.get('reference_versions') == result['reference_versions'] and saved['song']['source_sha256'] == source_hash:
|
||||
result['songs'][path.stem] = saved['song']
|
||||
result.setdefault('resumed_songs', []).append(path.stem)
|
||||
continue
|
||||
started = time.monotonic()
|
||||
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||||
plans = generator.arrange.generate.plan_arrangement(document, rhythms)
|
||||
@@ -96,7 +107,7 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
if shared:
|
||||
for context in shared.values():
|
||||
context.diagnostics = generator.solo.realization.SearchContext().diagnostics
|
||||
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared)
|
||||
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library)
|
||||
song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
|
||||
regions = generator.solo.arrangement.regions(document)
|
||||
for solo, index in zip(solos, (3, 4)):
|
||||
@@ -110,10 +121,10 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
for phrase in solo.phrases:
|
||||
if not phrase.draft:
|
||||
continue
|
||||
begin = boundaries.index(phrase.plan.start)
|
||||
begin = max(i for i,boundary in enumerate(boundaries) if boundary <= phrase.plan.start)
|
||||
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
|
||||
view = excerpt(parts[solo.part], first + begin, stop - begin)
|
||||
local = tuple(dataclasses.replace(n, start=n.start - phrase.plan.start, end=n.end - phrase.plan.start, 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)
|
||||
if placed is None:
|
||||
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
|
||||
@@ -123,8 +134,30 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
generator.fretboard.validate_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
|
||||
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
|
||||
stages = {}
|
||||
for stage_name in ('initial', 'refined'):
|
||||
if not all(getattr(p, stage_name) for p in solo.phrases):
|
||||
continue
|
||||
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
|
||||
placed = generator.solo.placement.place(notes, solo.tempo)
|
||||
if placed is None:
|
||||
raise ValueError(stage_name + ': selected theme cannot be placed')
|
||||
sounded = generator.solo.types.sounding_notes(placed[1])
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
|
||||
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
|
||||
if not checked.valid and stage_name != 'initial':
|
||||
raise ValueError(stage_name + ': ' + checked.error)
|
||||
view = excerpt(parts[solo.part], first, count)
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error}
|
||||
song['solos'][solo.part]['lead_stages'] = stages
|
||||
print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True)
|
||||
result['songs'][path.stem] = song
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
checkpoint.write_text(json.dumps({'implementation': result['implementation'], 'reference_versions': result['reference_versions'], 'song': song}) + '\n')
|
||||
destination.mkdir(parents=True)
|
||||
(destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
|
||||
@@ -207,6 +240,20 @@ def render(root):
|
||||
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
||||
clips.append((path, samples))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
||||
if stage == 'after' and 'refined' in solo.get('lead_stages', {}):
|
||||
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
|
||||
plain_prefix = prefix.with_name(prefix.name + '_plain')
|
||||
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
|
||||
backing = excerpt(baseline['parts'][other], first, count)
|
||||
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
||||
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)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'plain_theme', 'window': label})
|
||||
if label == 'full':
|
||||
for kind, document in (('plain', plain), ('elaborated', foreground)):
|
||||
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
|
||||
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})
|
||||
for draft in solo.get('draft_documents', ()):
|
||||
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
||||
@@ -281,14 +328,21 @@ def write_passage_index(root, snapshots):
|
||||
for stage in ('before', 'after'):
|
||||
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('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th></tr>')
|
||||
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>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
|
||||
for window in ('full', 'opening8', 'closing4'):
|
||||
body.append(f'<tr><th>{window}</th>')
|
||||
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
|
||||
prefix = stage + '/' + name + '_' + part + '_' + window
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
|
||||
if plain_available:
|
||||
prefix=f'after/{name}_{part}_{window}_plain'
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Plain theme notation</a></small></td>')
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
if plain_available:
|
||||
prefix=f'after/{name}_{part}_full'
|
||||
body.append(f'<p>Unaccompanied plain theme: <audio controls preload="none" src="{prefix}_plain_solo.wav"></audio> Elaborated: <audio controls preload="none" src="{prefix}_elaborated_solo.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def main():
|
||||
|
||||
+34
-28
@@ -15,43 +15,49 @@ import generator.solo.policy
|
||||
import generator.solo.realization
|
||||
import generator.solo.types
|
||||
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None):
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic'):
|
||||
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
|
||||
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
|
||||
choices = generator.scales.selection.select(timeline, region, scales)
|
||||
for choice in choices:
|
||||
if choice.span.chord and choice.scale is None:
|
||||
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
|
||||
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences)
|
||||
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes)
|
||||
if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
choices = generator.scales.selection.select(timeline, region, scales, retained=retained)
|
||||
if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection')
|
||||
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices)
|
||||
from generator.solo import leads
|
||||
context = context if context is not None else generator.solo.realization.SearchContext()
|
||||
context.revise = revise
|
||||
|
||||
def realize(plan, previous):
|
||||
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
|
||||
context.batch = attempt
|
||||
try:
|
||||
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
|
||||
return options
|
||||
except generator.solo.realization.SearchExhausted as error:
|
||||
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote'):
|
||||
raise
|
||||
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'})
|
||||
|
||||
phrases = choose_phrases(plans, realize, lambda first, second, incoming: generator.solo.realization.revise_pair(first, second, incoming, choices, sequences, analysis.tempo, context))
|
||||
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
|
||||
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
|
||||
passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id))
|
||||
passages = tuple(generator.solo.types.MelodicPassagePlan(identifier, min(p.start for p in plans if p.passage_id == identifier), max(p.end for p in plans if p.passage_id == identifier), tuple(p.id for p in plans if p.passage_id == identifier), next(p.seed for p in plans if p.passage_id == identifier)) for identifier in passage_ids)
|
||||
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics, passages)
|
||||
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
|
||||
result = options[0][1]
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
|
||||
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic'):
|
||||
"""Select two recognizable interpretations together, before arranging backing."""
|
||||
from generator.solo import leads
|
||||
pools = {}
|
||||
for part, (measures,end,initial) in requests.items():
|
||||
timeline = generator.scales.timeline.read({'measures': measures},initial=initial)
|
||||
choices = generator.scales.selection.select(timeline,generator.scales.timeline.Solo(0,end,part),scales)
|
||||
for c in choices:
|
||||
if c.span.chord and c.scale is None:
|
||||
raise ValueError(f'{part} solo, source measure {c.span.source_measure}: No exact match within the permitted catalog')
|
||||
context = (contexts or {}).get(part) or generator.solo.realization.SearchContext()
|
||||
pools[part] = leads.solo_options(analysis,measures,part,end,choices,sequences,rhythms,context,stage,mode)
|
||||
def theme_key(solo):
|
||||
home=next((p.state.home for p in solo.phrases if p.state and p.state.home),None)
|
||||
if not home or not home.notes:return ()
|
||||
return tuple((n.start-home.notes[0].start,n.end-n.start,n.pitch.number()-home.notes[0].pitch.number()) for n in home.notes)
|
||||
pairs = [(a_cost+e_cost+2*leads.similarity(a,e),a,e) for a_cost,a in pools['acoustic'] for e_cost,e in pools['electric']
|
||||
if mode!='thematic' or not theme_key(a) or theme_key(a)!=theme_key(e)]
|
||||
if not pairs:
|
||||
raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search')
|
||||
_,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes()))))
|
||||
overlap = leads.similarity(acoustic,electric)
|
||||
for solo in (acoustic,electric):
|
||||
solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part]))
|
||||
validate(solo)
|
||||
return acoustic,electric
|
||||
|
||||
|
||||
def phrase_notes(phrase):
|
||||
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||
if notes and notes[0].continuation:
|
||||
@@ -115,7 +121,7 @@ def validate(solo):
|
||||
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
|
||||
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
|
||||
if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
|
||||
raise ValueError(f'{solo.part} solo: held pitch {note.pitch.label()} violates a scale span at {note.start}-{note.end}')
|
||||
raise ValueError(f'{solo.part} solo: note outside mapped collection at {note.start}')
|
||||
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
|
||||
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
||||
previous = note
|
||||
|
||||
+73
-23
@@ -14,6 +14,7 @@ import generator.solo.policy
|
||||
import generator.solo.scoring
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types
|
||||
import generator.solo.references
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@@ -77,7 +78,7 @@ def templates(plan, reference, library, meter):
|
||||
result.append((reference.id, source, length))
|
||||
choices = catalog(library, meter)
|
||||
ranked = generator.solo.sequences.diverse(choices,
|
||||
lambda item: (abs(len(item[1]) / float(item[2]) - float(plan.activity)), abs(sum(float(b - a) for a, b in item[1]) / float(item[2]) - 0.75), item[0]),
|
||||
lambda item: (abs(len(item[1]) / float(item[2]) - float(plan.activity)) > 0.75, generator.solo.policy.stable_rank(plan.seed, item)),
|
||||
lambda item: (len(item[1]), bool(item[1][0][0]), item[2]), 4)
|
||||
result.extend(ranked)
|
||||
return tuple(result)
|
||||
@@ -127,17 +128,51 @@ def ending_options(anchors, plan, choices):
|
||||
terminal = next((a for a in anchors if a.role == 'terminal'), None)
|
||||
if terminal is None or terminal.hold_until is None:
|
||||
return tuple([anchors])
|
||||
finish = terminal.hold_until
|
||||
last = next((c for c in choices if c.span.start < finish <= c.span.end), None)
|
||||
passage_end = plan.passage_end or plan.end
|
||||
active = [c for c in choices if c.scale and c.span.start < passage_end and c.span.end > plan.start]
|
||||
if not active:
|
||||
return tuple([anchors])
|
||||
# Missing harmony is mandatory space. Retiming an ordinary ending must not
|
||||
# move its destination into a trailing N.C. span.
|
||||
harmonic_end = min(passage_end, active[-1].span.end)
|
||||
result = []
|
||||
for duration in (F(1, 2), F(1), F(2), F(3)):
|
||||
start = finish - duration
|
||||
if last is None or start < max(last.span.start, plan.passage_start or plan.start) or any(a.role != 'terminal' and a.latest + a.duration > start for a in anchors):
|
||||
continue
|
||||
changed = dataclasses.replace(terminal, earliest=start, latest=start, duration=duration)
|
||||
result.append(tuple(changed if a.id == terminal.id else a for a in anchors))
|
||||
for release in (F(), F(1, 2), F(1)):
|
||||
finish = harmonic_end - release
|
||||
last = next((c for c in choices if c.span.start < finish <= c.span.end), None)
|
||||
for duration in (F(1, 2), F(1), F(2), F(3)):
|
||||
start = finish - duration
|
||||
if last is None or start < max(last.span.start, plan.passage_start or plan.start) or any(a.role != 'terminal' and a.latest + a.duration > start for a in anchors):
|
||||
continue
|
||||
changed = dataclasses.replace(terminal, earliest=start, latest=start, duration=duration, hold_until=finish)
|
||||
result.append(tuple(changed if a.id == terminal.id else a for a in anchors))
|
||||
return tuple(result) or tuple([anchors])
|
||||
|
||||
def ending_plan(plan, anchors):
|
||||
if plan.target or plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return plan
|
||||
terminal = next((a for a in anchors if a.role == 'terminal'), None)
|
||||
if terminal is None or terminal.hold_until is None:
|
||||
return plan
|
||||
finish = plan.passage_end or plan.end
|
||||
release = finish - terminal.hold_until
|
||||
# The candidate chooses where arrival begins. Earlier portions remain active.
|
||||
spans = []
|
||||
for span in plan.passages:
|
||||
low, high = span.start, min(span.end, terminal.earliest)
|
||||
if low < high:
|
||||
spans.append(dataclasses.replace(span, end=high, intent='active', occupancy=F(str(generator.solo.policy.DEFAULT.active_occupancy))))
|
||||
if not spans and plan.start < terminal.earliest:
|
||||
spans.append(generator.solo.types.ActivitySpan(plan.passage_start if plan.passage_start is not None else plan.start, terminal.earliest, 'active', plan.activity, F(str(generator.solo.policy.DEFAULT.active_occupancy))))
|
||||
arrival_length = terminal.hold_until - terminal.earliest
|
||||
desired = max(F(), plan.activity * (finish - (plan.passage_start if plan.passage_start is not None else plan.start)) - 1)
|
||||
expected = sum(s.activity * (s.end - s.start) for s in spans)
|
||||
spans = [dataclasses.replace(s, activity=min(F(2), s.activity * desired / expected) if expected else plan.activity) for s in spans]
|
||||
spans.append(generator.solo.types.ActivitySpan(terminal.earliest, terminal.hold_until, 'sustained_arrival', 1 / arrival_length, F(str(generator.solo.policy.DEFAULT.arrival_occupancy))))
|
||||
if release:
|
||||
spans.append(generator.solo.types.ActivitySpan(terminal.hold_until, finish, 'space', F(), F()))
|
||||
complete = plan.end == finish
|
||||
return dataclasses.replace(plan, release=release if complete else F(), passages=tuple(spans), ending=('breath' if release else 'sustain') if complete else 'connected')
|
||||
|
||||
def align_anchors(slots, anchors, plan):
|
||||
"""Choose arrival timing with the rhythm, before assigning any free pitches."""
|
||||
slots = list(slots)
|
||||
@@ -172,13 +207,15 @@ def construct(plan, reference, relationship, template, anchors, variant, choices
|
||||
finish = plan.end - plan.release
|
||||
slots, groups, cursor = (), [], plan.start
|
||||
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
schedule = (('repeat', 'extend', 'answer'), ('extend', 'displace', 'recall'))[variant % 2]
|
||||
while cursor < finish:
|
||||
index = len(groups)
|
||||
operation = relationship if index == 0 else schedule[(index - 1) % len(schedule)]
|
||||
# Establish the idea before departing. Choose from the remaining musical
|
||||
# span instead of cycling a fixed list of transformations indefinitely.
|
||||
remaining = finish - cursor
|
||||
operation = relationship if index == 0 else 'repeat' if index == 1 and variant == 0 else 'answer' if remaining <= period else 'extend' if variant == 1 and index == 1 else 'recall'
|
||||
activity = next((p for p in plan.passages if p.start <= cursor < p.end), None)
|
||||
rate = F(len(pattern), 1) / period
|
||||
unit = F(2) if activity and activity.activity < F(3, 4) else F(1, 2) if activity and activity.activity > rate + F(1, 4) and index else F(1)
|
||||
unit = F(1) # Rhythmic development is musical intent, never a density repair.
|
||||
local = pattern
|
||||
length = period
|
||||
if index and operation == 'extend':
|
||||
@@ -250,7 +287,7 @@ def construct(plan, reference, relationship, template, anchors, variant, choices
|
||||
identity = generator.solo.policy.stable_rank(plan.seed, (identifier, variant, anchors, relationship, octave))[:12]
|
||||
return RhythmDraft(identity, tuple(filtered), tuple(groups), anchors, reference, relationship, octave)
|
||||
|
||||
def rhythms(plan, incoming, measures, choices, library, batch=0):
|
||||
def rhythms(plan, incoming, measures, choices, library, batch=0, reference_library=()):
|
||||
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
anchors = (incoming.selected_anchors,) if continuing and incoming.selected_anchors else plan.anchor_options or tuple([()])
|
||||
references = [(plan.motif, 'statement')]
|
||||
@@ -262,6 +299,8 @@ def rhythms(plan, incoming, measures, choices, library, batch=0):
|
||||
references.append((plan.motif, 'contrast'))
|
||||
if incoming.themes and incoming.themes[0].id != incoming.home.id:
|
||||
references.append((incoming.themes[0], 'recall'))
|
||||
if plan.treatment not in ('quote', 'exact_quote', 'rhythmic_reference') and not continuing:
|
||||
references.extend((motif, 'contrast' if incoming.home else 'statement') for example in reference_library for motif in example.windows())
|
||||
elapsed = F()
|
||||
meter = measures[0]['time_signature']
|
||||
for measure in measures:
|
||||
@@ -273,28 +312,29 @@ def rhythms(plan, incoming, measures, choices, library, batch=0):
|
||||
result = []
|
||||
for reference, relationship in references:
|
||||
available_templates = templates(plan, reference, library, meter)
|
||||
if isinstance(reference, generator.solo.types.RealizedMotif):
|
||||
available_templates = available_templates[:1]
|
||||
if isinstance(reference, generator.solo.types.RealizedMotif) or reference.origin == 'accepted reference profile':
|
||||
available_templates = tuple([(reference.id, tuple((n.start, n.end) for n in reference.notes), reference.duration)])
|
||||
for template in available_templates:
|
||||
for backbone in anchors:
|
||||
endings = (backbone,) if continuing else ending_options(backbone, plan, choices)
|
||||
for ending in endings:
|
||||
for variant in range(2):
|
||||
for octave in ((0, 1, -1, 2, -2) if plan.treatment in ('quote', 'exact_quote') else tuple([0])):
|
||||
rhythm = construct(plan, reference, relationship, template, ending, variant, choices, octave)
|
||||
rhythm = construct(ending_plan(plan, ending), reference, relationship, template, ending, variant, choices, octave)
|
||||
if rhythm:
|
||||
result.append(rhythm)
|
||||
evaluation = generator.solo.scoring.Evaluation(dataclasses.replace(plan, passages=tuple(p for p in plan.passages if p.end > plan.start)), choices)
|
||||
|
||||
def rank(rhythm):
|
||||
dummy = tuple(generator.solo.types.Note(s.start, s.end, s.forced or rhythm.reference.notes[0].pitch) for s in rhythm.slots)
|
||||
features = evaluation.features(dummy)
|
||||
return features['activity'] * 4 + features['occupancy'] * 4, generator.solo.policy.stable_rank(plan.seed, rhythm.id)
|
||||
expected = float(plan.activity * (plan.end - plan.start))
|
||||
# Broad rhythm feasibility only. Pitch/rhythm relationships are evaluated
|
||||
# after realization; one attack cannot eliminate an otherwise useful tune.
|
||||
broad = abs(len(rhythm.slots) - expected) > max(4, expected / 2)
|
||||
return broad, generator.solo.policy.stable_rank(plan.seed, rhythm.id)
|
||||
|
||||
def family(rhythm):
|
||||
pitches = tuple(a.pitch.number() for a in rhythm.anchors)
|
||||
shape = generator.solo.sequences.family(tuple(b - a for a, b in zip(pitches, pitches[1:])))
|
||||
return rhythm.relationship, shape, rhythm.groups[0].source, rhythm.anchors[-1].duration if rhythm.anchors else None
|
||||
return rhythm.relationship, rhythm.groups[0].source, shape
|
||||
|
||||
limit = generator.solo.policy.DEFAULT.draft_frontier
|
||||
return generator.solo.sequences.diverse(result, rank, family, limit * (batch + 1))[limit * batch:]
|
||||
@@ -310,6 +350,9 @@ def local_sounding(notes):
|
||||
|
||||
def contract_valid(draft, notes):
|
||||
by_id = {n.event_id: n for n in local_sounding(notes)}
|
||||
for tail in draft.sustained_tails:
|
||||
if not any(n.pitch.number() == tail.pitch and n.start <= tail.start and n.end >= tail.end for n in by_id.values()):
|
||||
return False, f'{tail.start}-{tail.end}: required sounding arrival tail changed'
|
||||
original = {n.event_id: n for n in draft.notes}
|
||||
for identity in (*draft.structural, *draft.protected):
|
||||
before, after = original[identity], by_id.get(identity)
|
||||
@@ -326,6 +369,12 @@ def contract_valid(draft, notes):
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(core, core[1:]))
|
||||
if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]:
|
||||
return False, f'{transform.id}: identifying contour changed'
|
||||
for feature in draft.features:
|
||||
if generator.solo.references.feature_error(feature, local_sounding(notes)):
|
||||
return False, f'{feature.id}: selected identifying relationship changed'
|
||||
for first, repeated in draft.correspondence:
|
||||
if first not in by_id or repeated not in by_id:
|
||||
return False, 'lead statement/return correspondence was lost'
|
||||
return True, ''
|
||||
|
||||
def relationship_features(draft, notes):
|
||||
@@ -338,8 +387,9 @@ def relationship_features(draft, notes):
|
||||
group = [by_id[i] for i in transform.events if i in by_id]
|
||||
actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:]))
|
||||
direction.extend(a != b for a, b in zip(actual, transform.directions))
|
||||
if draft.home and transform.operation not in ('statement', 'contrast', 'close', 'fragment', 'broaden_ending'):
|
||||
development.append(generator.solo.scoring.motif_fidelity(group[:3], draft.home, 'opening_reference'))
|
||||
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
|
||||
if reference and not draft.lead_identity and not draft.source_phrase and transform.operation not in ('close', 'fragment', 'broaden_ending'):
|
||||
development.append(generator.solo.references.relationship(group, reference))
|
||||
joins = []
|
||||
for time in draft.joins:
|
||||
left = next((n for n in reversed(notes) if n.start < time), None)
|
||||
|
||||
@@ -52,13 +52,25 @@ class Harmony:
|
||||
def __init__(self, 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._at = {}
|
||||
self._segments = {}
|
||||
self._members = {}
|
||||
self.evaluations = {}
|
||||
def at(self, time):
|
||||
return next((s for s in self.spans if s.start <= time < s.end), None)
|
||||
if time not in self._at:
|
||||
self._at[time] = next((s for s in self.spans if s.start <= time < s.end), None)
|
||||
return self._at[time]
|
||||
def segments(self, note):
|
||||
return tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start)
|
||||
key = (note.start, note.end)
|
||||
if key not in self._segments:
|
||||
self._segments[key] = tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start)
|
||||
return self._segments[key]
|
||||
def members(self, start, end):
|
||||
spans = [s for s in self.spans if s.start < end and s.end > start]
|
||||
return frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
|
||||
key = (start, end)
|
||||
if key not in self._members:
|
||||
spans = [s for s in self.spans if s.start < end and s.end > start]
|
||||
self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
|
||||
return self._members[key]
|
||||
def function(self, number, span):
|
||||
pitch = number % 12
|
||||
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
|
||||
@@ -117,13 +129,13 @@ class Harmony:
|
||||
sounding += float(duration)
|
||||
covered = note.start
|
||||
for span, start, end in self.segments(note):
|
||||
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
|
||||
if start > covered or not span.scale or number % 12 not in span.scale.pitches:
|
||||
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
|
||||
covered = end
|
||||
function = self.function(number, span)
|
||||
if function == 'basic' or note.role == 'quote':
|
||||
continue
|
||||
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), 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')
|
||||
connected = duration < generator.solo.policy.DEFAULT.stopping_note and self.connected_motion(notes, index)
|
||||
@@ -137,7 +149,8 @@ class Harmony:
|
||||
result = []
|
||||
for index, note in enumerate(notes):
|
||||
for span, start, end in self.segments(note):
|
||||
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]})
|
||||
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
|
||||
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
|
||||
return result
|
||||
|
||||
def anchor_note(anchor, notes, *, until=None):
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Audition selected lead stages and accepted controls through the normal output path."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import html
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.musicxml_writer
|
||||
import generator.parts
|
||||
import generator.playback
|
||||
import generator.scales.timeline
|
||||
import generator.scales.selection
|
||||
import generator.solo.audition as audition
|
||||
import generator.solo.drafts as drafts
|
||||
import generator.solo.harmonic as harmonic
|
||||
import generator.solo.notation as notation
|
||||
import generator.solo.placement as placement
|
||||
import generator.solo.realization as realization
|
||||
import generator.solo.references as references
|
||||
import generator.solo.types as types
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
def document_for(template, notes, tempo, identifier):
|
||||
placed = placement.place(notes, tempo)
|
||||
if placed is None:
|
||||
raise ValueError(identifier + ': no valid guitar placement')
|
||||
document = copy.deepcopy(template)
|
||||
notation.replace_region(document, 0, notation.measures(document['staves'][0]['measures'], placed[1], identifier))
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
timeline = generator.playback.compile_timeline(document)
|
||||
expected = [(n.start, n.end - n.start, n.pitch.number()) for n in types.sounding_notes(placed[1])]
|
||||
actual = [(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in timeline['events']]
|
||||
if actual != expected:
|
||||
raise ValueError(identifier + ': notation/playback changed the sounding melody')
|
||||
return document
|
||||
|
||||
def controls(song, libraries):
|
||||
"""Literal reference injection is restricted to this diagnostic, never production."""
|
||||
_, scales, sequences = libraries
|
||||
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
|
||||
solo = song['solos']['acoustic']
|
||||
measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']]
|
||||
choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales)
|
||||
harmony = harmonic.Harmony(choices)
|
||||
template = audition.excerpt(song['parts']['acoustic'], solo['first'], 8)
|
||||
result = []
|
||||
for label, source in (('plain', reference.notes), ('runs', reference.decorated)):
|
||||
checked = harmony.evaluate(source, until=reference.duration, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError('accepted control: ' + checked.error)
|
||||
theme = types.Motif(reference.id, source, reference.duration, tuple(c for _, c in reference.harmony), 'accepted control')
|
||||
groups = tuple(types.Transformation(f'control_{i}', reference.id, 'control_0', 'statement' if i == 0 else 'answer', F(i * 16), F((i + 1) * 16), tuple(n.event_id for n in source if i * 16 <= n.start < (i + 1) * 16), tuple((n.start - i * 16, n.end - i * 16) for n in source if i * 16 <= n.start < (i + 1) * 16), preserves=(), source=reference.id) for i in range(2))
|
||||
final = source[-1]
|
||||
terminal = types.Anchor('accepted_terminal', final.start, final.start, final.pitch, frozenset({final.pitch.number() % 12}), (55, 79), final.end - final.start, 'terminal', 'Dm', hold_until=final.end)
|
||||
home = realization.make_identity('control_0', reference.id, source[:len(groups[0].events)], F(), F(16), 'statement', harmony)
|
||||
draft = types.MelodicDraft('accepted_' + label, F(), reference.duration, source, groups, (final.event_id,), (), (terminal,), home, 'statement', (F(16),), references.identifying_features(source, reference.id), reference.id, theme)
|
||||
plan = types.PhrasePlan('accepted_' + label, F(), reference.duration, theme, 'statement', 'opening_reference', None, (55, 79), F(9, 8), F(1), passage_id='control', passage_start=F(), passage_end=reference.duration)
|
||||
incoming = types.ContinuationState('')
|
||||
phrase = realization.checked_phrase(plan, draft, source, (), incoming, harmony, F(solo['tempo']))
|
||||
if phrase is None:
|
||||
raise ValueError('accepted control rejected by phrase validation')
|
||||
context = realization.SearchContext()
|
||||
refined = realization.refine(phrase, (phrase,), incoming, harmony, sequences, F(solo['tempo']), context)
|
||||
elaborated = realization.elaborate(refined, incoming, harmony, sequences, F(solo['tempo']), context)
|
||||
for stage, notes in (('injected', source), ('processed', realization.emitted_notes(elaborated))):
|
||||
identifier = 'accepted_' + label + '_' + stage
|
||||
checked = harmony.evaluate(notes, until=reference.duration, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(identifier + ': ' + checked.error)
|
||||
result.append({'id': identifier, 'document': document_for(template, notes, F(solo['tempo']), identifier), 'attacks': len(notes), 'harmonic_exposure': checked.exposure, 'changed_events': sum((a.start, a.end, a.pitch) != (b.start, b.end, b.pitch) for a, b in zip(source, notes)) + abs(len(source) - len(notes)), 'edits': [e.kind for e in elaborated.edits] if stage == 'processed' else [], 'acceptance': reference.acceptance, 'reference_version': reference.version})
|
||||
return result
|
||||
|
||||
def render(root):
|
||||
baseline = json.loads((root / 'paraphrase_baseline/snapshot.json').read_text())
|
||||
current = json.loads((root / 'elaborated/snapshot.json').read_text())
|
||||
cache, clips = {}, []
|
||||
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
|
||||
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Source is the original head, close paraphrase is the preserved previous plain theme, refined is the newly recomposed theme, and elaborated includes optional in-scale runs. The selected production branch supplies both new stages.'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the original head, the previous close paraphrase, the new solo theme, and its optional in-scale runs. Source controls may contain original harmonic tensions that production explicitly adapts. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
|
||||
|
||||
def clip(prefix, foreground, tempo, backing=()):
|
||||
path = root / (prefix + '.wav')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
samples = audition.mix((foreground, *backing), tempo, root, cache)
|
||||
peak = max(map(abs, samples), default=0) * gain
|
||||
if peak >= 1:
|
||||
raise ValueError(prefix + ': shared audition gain would clip')
|
||||
generator.playback.write_pcm(path, (value * gain for value in samples))
|
||||
clips.append({'path': path.relative_to(root).as_posix(), 'tempo': str(tempo), 'peak': peak, 'attacks': len(generator.playback.compile_timeline(foreground)['events'])})
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
for name, song in current['songs'].items():
|
||||
old = baseline['songs'][name]
|
||||
if old['source_sha256'] != song['source_sha256']:
|
||||
raise ValueError(name + ': source changed during comparison')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Song source</th><th>Previous close paraphrase</th><th>New theme</th><th>Theme with runs</th></tr>')
|
||||
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
|
||||
first = solo['first'] + offset
|
||||
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
|
||||
body.append('<tr><th>' + window + '</th>')
|
||||
for stage in ('initial', 'close_paraphrase', 'refined', 'elaborated'):
|
||||
foreground = audition.excerpt(song['parts'][part], first, count) if stage=='elaborated' else audition.excerpt(old['solos'][part]['lead_stages']['refined']['document'] if stage=='close_paraphrase' else solo['lead_stages'][stage]['document'], offset, count)
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing)
|
||||
solo_path = clip(prefix + '_solo', foreground, F(solo['tempo']))
|
||||
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), foreground)
|
||||
body.append(f'<td><audio controls preload="none" src="{path}"></audio><small><a href="{solo_path}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></small></td>')
|
||||
if stage == 'elaborated':
|
||||
clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal')))
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
body.append(f'<p><a href="paraphrase_baseline/{name}_{part}_full_solo.wav">Previous complete production solo</a></p>')
|
||||
prefix = f'elaborated/{name}_{part}'
|
||||
(root / (prefix + '.txt')).write_text(solo['report'])
|
||||
(root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
||||
body.append(f'<p><a href="{prefix}.txt">Passage report</a> · <a href="{prefix}.json">Anchors, motif lineage, stages and routes</a> · <a href="{prefix}_full_responsive.wav">Full solo with responsive backing</a></p>')
|
||||
print('Rendered', name, part, flush=True)
|
||||
song = current['songs']['autumn_leaves']
|
||||
solo = song['solos']['acoustic']
|
||||
backing = tuple(audition.excerpt(baseline['songs']['autumn_leaves']['parts'][p], solo['first'], 8) for p in ('electric', 'vocal'))
|
||||
body.append('<h2>Accepted reference controls</h2><p>The exact accepted notes enter the ordinary validation, guitar placement, notation and playback path. Processed controls additionally run refinement and elaboration. Literal injection is confined to these controls.</p>')
|
||||
results = controls(song, audition.resources())
|
||||
manifest['controls'] = [{k: v for k, v in r.items() if k != 'document'} for r in results]
|
||||
for control in results:
|
||||
prefix = 'controls/' + control['id']
|
||||
path = clip(prefix + '_fixed', control['document'], F(solo['tempo']), backing)
|
||||
single = clip(prefix + '_solo', control['document'], F(solo['tempo']))
|
||||
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), control['document'])
|
||||
body.append(f'<p>{control["id"]}: <audio controls preload="none" src="{path}"></audio> <a href="{single}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></p>')
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_melody_auditions'))
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument('--capture', action='store_true')
|
||||
action.add_argument('--render', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.capture:
|
||||
audition.capture(args.output, 'elaborated')
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Source-phrase variations: edit actual music, then realize complete routes.
|
||||
|
||||
The external lead references are audition controls, never production seeds.
|
||||
All notes use sounding coordinates and all time uses quarter-note Fractions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
import generator.fretboard
|
||||
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
|
||||
def signature(notes):
|
||||
return tuple((n.start, n.end, n.pitch.number()) for n in notes)
|
||||
|
||||
|
||||
def shift(notes, octave):
|
||||
return tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave+octave),
|
||||
position=None, role='source', event_id=n.event_id or n.sources[0]) for n in notes)
|
||||
|
||||
|
||||
def available(harmony, start, end, *, members=False):
|
||||
span = harmony.at(start)
|
||||
allowed = types.permitted(harmony.choices, start, end)
|
||||
if members:
|
||||
allowed &= harmony.members(start, end)
|
||||
if not span or not span.scale:
|
||||
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)
|
||||
|
||||
|
||||
def adapt(notes, harmony):
|
||||
"""Document required harmonic rewrites; never repair a sequence one step at a time."""
|
||||
result, edits = [], []
|
||||
for index, note in enumerate(notes):
|
||||
terminal = index == len(notes)-1
|
||||
choices = available(harmony,note.start,note.end,members=terminal or note.end-note.start >= 2)
|
||||
pieces = [(note.start,note.end,choices)]
|
||||
if not choices:
|
||||
pieces = [(a,b,available(harmony,a,b,members=terminal or b-a >= 2)) for _,a,b in harmony.segments(note)]
|
||||
for j,(start,end,domain) in enumerate(pieces):
|
||||
if not domain:
|
||||
if harmony.at(start) and harmony.at(start).scale:
|
||||
return (), ()
|
||||
edits.append(types.DraftEdit('missing_harmony',start,end,(note.event_id,),(), 'explicit no-chord space'))
|
||||
continue
|
||||
pitch = min(domain,key=lambda p:(abs(p.number()-note.pitch.number()),p.number()))
|
||||
identifier = note.event_id if j == 0 else note.event_id+f':harmonic:{j}'
|
||||
actual = dataclasses.replace(note,start=start,end=end,pitch=pitch,event_id=identifier)
|
||||
result.append(actual)
|
||||
if (start,end,pitch) != (note.start,note.end,note.pitch):
|
||||
edits.append(types.DraftEdit('harmonic_adaptation',start,end,(note.event_id,),(identifier,),
|
||||
f'{note.pitch.label()} to {pitch.label()}: sounding duration / {harmony.at(start).chord}'))
|
||||
if result:
|
||||
last=result[-1]
|
||||
domain=available(harmony,last.start,last.end,members=True)
|
||||
if not domain:
|
||||
return (),()
|
||||
if last.pitch.number()%12 not in harmony.members(last.start,last.end):
|
||||
pitch=min(domain,key=lambda p:(abs(p.number()-last.pitch.number()),p.number()))
|
||||
result[-1]=dataclasses.replace(last,pitch=pitch)
|
||||
edits.append(types.DraftEdit('harmonic_destination',last.start,last.end,(last.event_id,),(last.event_id,),
|
||||
f'{last.pitch.label()} to {pitch.label()}: final sounding arrival before release'))
|
||||
return tuple(result),tuple(edits)
|
||||
|
||||
|
||||
def identifying(notes, source):
|
||||
"""A complete heard figure, chosen by its musical content rather than its index."""
|
||||
if not notes:
|
||||
return ()
|
||||
count = min(4,max(1,len(notes)//2))
|
||||
windows = [notes[i:i+count] for i in range(len(notes)-count+1)]
|
||||
def rank(group):
|
||||
intervals = tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:]))
|
||||
rhythm = {(n.end-n.start) for n in group}
|
||||
# Favor a rhythm/turn with information; leave long holds available for development.
|
||||
return (-len(rhythm),-len(set(intervals)),sum(max(F(),n.end-n.start-1) for n in group),group[0].start)
|
||||
group = min(windows,key=rank)
|
||||
return (types.IdentifyingFeature(source+':figure',tuple(n.event_id for n in group),
|
||||
tuple((n.start-group[0].start,n.end-n.start) for n in group),
|
||||
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])),source),)
|
||||
|
||||
|
||||
def variations(notes, source, harmony):
|
||||
"""One purposeful timing or pitch edit; no generic replacement openings."""
|
||||
result=[]
|
||||
if source.pickup and notes and source.start==0:
|
||||
length=-source.pickup[0].start
|
||||
if notes[0].end-length>=F(1,2):
|
||||
pickup=tuple(dataclasses.replace(n,start=n.start+length,end=n.end+length) for n in source.pickup)
|
||||
changed=(*pickup,dataclasses.replace(notes[0],start=length),*notes[1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
result.append((changed,(types.DraftEdit('pickup_entrance',F(),notes[0].end,(notes[0].event_id,),
|
||||
tuple(n.event_id for n in pickup)+(notes[0].event_id,),source.id+': bring source pickup inside the solo and shorten its first hold'),)))
|
||||
for i,(before,note) in enumerate(zip(notes,notes[1:]),1):
|
||||
if before.end == note.start and before.end-before.start >= 1 and note.end-note.start >= F(1,2):
|
||||
for displacement in (F(-1,2),F(1,2)):
|
||||
time = note.start+displacement
|
||||
if time-before.start < F(1,2) or note.end-time < F(1,2):
|
||||
continue
|
||||
changed=(*notes[:i-1],dataclasses.replace(before,end=time),dataclasses.replace(note,start=time),*notes[i+1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
edit=types.DraftEdit('rhythmic_paraphrase',before.start,note.end,(before.event_id,note.event_id),(before.event_id,note.event_id),
|
||||
f'{source.id}: retain both pitches and their order; move shared boundary {displacement} beat')
|
||||
result.append((changed,(edit,)))
|
||||
# Structural color is chosen in relation to the original turn, never a new random contour.
|
||||
if 0 < i < len(notes)-1:
|
||||
after=notes[i+1]
|
||||
for p in available(harmony,note.start,note.end,members=note.end-note.start>=1):
|
||||
if p == note.pitch or abs(p.number()-note.pitch.number())>2:
|
||||
continue
|
||||
direction=lambda a,b:(b>a)-(b<a)
|
||||
if (direction(before.pitch.number(),p.number()),direction(p.number(),after.pitch.number())) != (direction(before.pitch.number(),note.pitch.number()),direction(note.pitch.number(),after.pitch.number())):
|
||||
continue
|
||||
changed=(*notes[:i],dataclasses.replace(note,pitch=p),*notes[i+1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
edit=types.DraftEdit('melodic_paraphrase',note.start,note.end,(note.event_id,),(note.event_id,),f'{source.id}: {note.pitch.label()} to {p.label()}, retain rhythm and turn')
|
||||
result.append((changed,(edit,)))
|
||||
# Broaden or release an ending as an explicit whole-phrase alternative.
|
||||
if notes and notes[-1].end-notes[-1].start >= F(3,2):
|
||||
tail=dataclasses.replace(notes[-1],end=notes[-1].end-F(1,2))
|
||||
result.append(((*notes[:-1],tail),(types.DraftEdit('ending_release',tail.end,notes[-1].end,(tail.event_id,),(tail.event_id,),source.id+': half-beat breath after the arrival'),)))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def rhythm_catalog(library, meter):
|
||||
# Keep complete snippet objects. Slices below retain real rests and durations.
|
||||
return drafts.catalog(library,meter)
|
||||
|
||||
|
||||
def connection_patterns(start,end,entry,destination,rhythms,meter):
|
||||
length=end-start
|
||||
spans={( (F(),length), ):'held'}
|
||||
for identifier,pattern,total in rhythm_catalog(rhythms,meter):
|
||||
if total < length:
|
||||
continue
|
||||
# Retrieve every exact-duration window, including offbeat and cross-bar windows.
|
||||
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
||||
if offset+length>total:
|
||||
continue
|
||||
if any(a<offset<b or a<offset+length<b for a,b in pattern):
|
||||
continue
|
||||
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
|
||||
if 2<=len(selected)<=7 and selected[0][0]==0:
|
||||
spans.setdefault(selected,identifier)
|
||||
ranked=sequences.diverse(tuple(spans.items()),
|
||||
lambda item:(abs(len(item[0])-float(length)*1.5),item[1]),
|
||||
lambda item:(len(item[0]),any(a[1]!=b[0] for a,b in zip(item[0],item[0][1:]))),8)
|
||||
result=[]
|
||||
for pattern,identifier in ranked:
|
||||
events=tuple(dataclasses.replace(entry,start=start+a,end=start+b,role='connection',
|
||||
event_id=f'{entry.event_id}:route:{a}',position=None) for a,b in pattern)
|
||||
if len(events)>=2:
|
||||
result.append(((*events,destination),identifier))
|
||||
return result
|
||||
|
||||
|
||||
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False, tails=None):
|
||||
"""Replace complete internal spans while retaining identity and sounding tails."""
|
||||
legacy=tails is None
|
||||
tails=tails or ()
|
||||
fixed={e for f in features for e in f.events} if legacy else {n.event_id for n in notes if n.role=='quote'}
|
||||
windows=[]
|
||||
for i,(note,target) in enumerate(zip(notes,notes[1:])):
|
||||
if note.event_id in fixed or note.end!=target.start:continue
|
||||
if note.end-note.start>=F(3,2):
|
||||
start=max(note.start+F(1,2),target.start-3)
|
||||
windows.append((i,i+1,start,target))
|
||||
if not legacy:
|
||||
for j in range(i+2,min(i+5,len(notes))):
|
||||
target=notes[j]
|
||||
if not 1<=target.start-note.start<=3:continue
|
||||
if any(n.event_id in fixed for n in notes[i:j]):continue
|
||||
if any(t.start<target.start and t.end>note.start for t in tails):continue
|
||||
windows.append((i,j,note.start,target))
|
||||
if legacy and notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
|
||||
tail=notes[-1]
|
||||
for duration in (F(1,2),F(1),F(3,2),F(2)):
|
||||
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
|
||||
start=max(tail.start+F(1,2),target.start-3)
|
||||
if target.start-start>=1:windows.append((len(notes)-1,len(notes)-1,start,target))
|
||||
# Consequential edits only; do not exhaustively branch at each passing note.
|
||||
windows=sequences.diverse(windows,lambda w:(policy.stable_rank(source.id,w),),lambda w:w[0],8)
|
||||
result=[]
|
||||
for i,j,start,target in windows:
|
||||
entry=notes[i]
|
||||
prefix=dataclasses.replace(entry,end=start)
|
||||
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
|
||||
for pattern,rhythm_id in connection_patterns(start,target.start,seed,target,rhythms,meter):
|
||||
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
|
||||
if sustain_entry or start==entry.start:
|
||||
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
|
||||
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
|
||||
actual=(*notes[:i],*route.notes,*notes[j+1:])
|
||||
else:
|
||||
actual=(*notes[:i],prefix,*route.notes,*notes[j+1:])
|
||||
if any(references.feature_error(f,actual) for f in features):continue
|
||||
if any(not any(n.pitch.number()==t.pitch and n.start<=t.start and n.end>=t.end for n in actual) for t in tails):continue
|
||||
if not harmony.evaluate(actual,complete=True).valid:continue
|
||||
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):continue
|
||||
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
|
||||
edit=types.DraftEdit('complete_route',start,target.start,tuple(n.event_id for n in notes[i:j+1]),tuple(n.event_id for n in route.notes),
|
||||
source.id+'; '+rhythm_id+'; lead into '+target.pitch.label())
|
||||
result.append((actual,edit,route))
|
||||
return sequences.diverse(result,lambda v:(max(0,4-len(v[0])*4/float(source.end-source.start),len(v[0])*4/float(source.end-source.start)-6),v[2].id),
|
||||
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
|
||||
|
||||
|
||||
def make_phrase(source, original, refined, notes, edits, route_list, octave, part, harmony, tempo, seed):
|
||||
from generator.solo import realization
|
||||
features=identifying(refined,source.id)
|
||||
# Only fully retained source turns become committed anchor events.
|
||||
anchors=[]
|
||||
for i,n in enumerate(notes):
|
||||
if n.pitch.number()%12 not in harmony.members(n.start,n.end):
|
||||
continue
|
||||
role='terminal' if i==len(notes)-1 else 'opening' if i==0 else 'intermediate'
|
||||
if role=='intermediate' and n.end-n.start<2:
|
||||
continue
|
||||
anchors.append(types.Anchor(n.event_id+':anchor',n.start,n.start,n.pitch,harmony.members(n.start,n.end),
|
||||
(41,76),n.end-n.start,role,harmony.at(n.start).chord,hold_until=n.end))
|
||||
if notes and (not anchors or anchors[-1].role!='terminal'):
|
||||
return None
|
||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,signature(notes))[:12]
|
||||
motif=types.Motif(source.id,tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start) for n in refined),
|
||||
source.end-source.start,tuple(s.chord for s in harmony.spans if s.start<source.end and s.end>source.start),'song melody '+source.family)
|
||||
home=realization.make_identity(identifier,source.id,refined,source.start,source.end,'source_variation',harmony)
|
||||
transform=types.Transformation(identifier,source.id,source.family,'source_variation',source.start,source.end,
|
||||
tuple(n.event_id for n in notes),tuple((n.start-source.start,n.end-source.start) for n in notes),preserves=(),source=source.id)
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,(transform,),
|
||||
tuple(a.id.removesuffix(':anchor') for a in anchors),(),tuple(anchors),home,'source_variation',features=features,
|
||||
reference=source.id,reference_motif=motif,source_phrase=source,octave_transfer=octave,
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes))
|
||||
spaces=[];cursor=source.start
|
||||
for n in notes:
|
||||
if n.start>cursor:spaces.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
|
||||
spaces.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
|
||||
cursor=n.end
|
||||
if cursor<source.end:spaces.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
|
||||
if not notes:spaces=[types.ActivitySpan(source.start,source.end,'space',F(),F())]
|
||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,'source_variation','source_variation',None,(41,76),F(5,4),
|
||||
source.end-cursor,tuple(spaces),(), 'breath' if cursor<source.end else 'sustain','closed',seed,source.id,source.start,source.end, (tuple(anchors),))
|
||||
phrase=realization.checked_phrase(plan,draft,notes,(),types.ContinuationState(''),harmony,tempo,edits)
|
||||
if phrase is None:
|
||||
return None
|
||||
# Source correspondence is evaluated once. Do not score the imported tune's
|
||||
# characteristic leaps as generic contour defects or reward filling its rests.
|
||||
source_by_id={n.event_id:n for n in original}
|
||||
retained={n.event_id:n for n in notes if n.event_id in source_by_id}
|
||||
deviation=sum(min(1,abs(n.pitch.number()-retained[k].pitch.number())/5)+
|
||||
min(1,float(abs(n.start-retained[k].start))/2)+min(1,float(abs((n.end-n.start)-(retained[k].end-retained[k].start)))/2) for k,n in source_by_id.items() if k in retained)/max(1,len(source_by_id))
|
||||
exposure=harmony.evaluate(notes,complete=True).exposure
|
||||
density=len(notes)*4/max(.001,float(source.end-source.start))
|
||||
score=3*deviation+2*exposure+.35*max(0,4-density,density-6)
|
||||
source_draft=dataclasses.replace(draft,id=identifier+':source',notes=original,features=(),anchors=(),structural=())
|
||||
refined_draft=dataclasses.replace(draft,id=identifier+':variation',notes=refined,features=features,anchors=(),structural=())
|
||||
return dataclasses.replace(phrase,score=score,initial=source_draft,refined=refined_draft,routes=route_list)
|
||||
|
||||
|
||||
def candidates(source, harmony, sequences_library, rhythms, meter, part, tempo, seed, *, stage='elaborated'):
|
||||
result=[]
|
||||
for octave in range(-3,4):
|
||||
original=shift(source.notes,octave)
|
||||
if any(not generator.fretboard.single_positions_for_pitch(F(n.pitch.number())) for n in original):
|
||||
continue
|
||||
adapted,required=adapt(original,harmony)
|
||||
if original and not adapted and any(harmony.at(n.start) and harmony.at(n.start).scale for n in original):
|
||||
continue
|
||||
variants=list(variations(adapted,dataclasses.replace(source,pickup=shift(source.pickup,octave)),harmony))
|
||||
if required or not adapted:
|
||||
variants.append((adapted,()))
|
||||
# A whole preserved phrase is the baseline, not an accidental production fallback.
|
||||
if not variants:
|
||||
continue
|
||||
variants=sequences.diverse(variants,lambda v:(len(v[1]),policy.stable_rank(seed,signature(v[0]))),
|
||||
lambda v:(v[1][0].kind if v[1] else 'harmonic_adaptation',v[1][0].start if v[1] else source.start),8)
|
||||
for refined,changes in variants:
|
||||
options=[(refined,(),())]
|
||||
if stage=='elaborated':
|
||||
connections=elaborate(refined,identifying(refined,source.id),harmony,sequences_library,rhythms,meter,source)
|
||||
options.extend((notes,(edit,),(route,)) for notes,edit,route in connections)
|
||||
# A second, distinct source-held span may develop the same phrase.
|
||||
# Reuse complete routes; never elaborate an already elaborated event.
|
||||
for first_notes,first_edit,first_route in connections[:2]:
|
||||
fixed=(*identifying(refined,source.id),types.IdentifyingFeature('route',
|
||||
tuple(n.event_id for n in first_route.notes)+first_edit.removed,(),(),source.id))
|
||||
second=elaborate(first_notes,fixed,harmony,sequences_library,rhythms,meter,source)
|
||||
options.extend((actual,(first_edit,edit),(first_route,route)) for actual,edit,route in second[:2])
|
||||
for notes,added,route_list in options:
|
||||
phrase=make_phrase(source,original,refined,notes,(*required,*changes,*added),route_list,octave,part,harmony,tempo,seed)
|
||||
if phrase:
|
||||
result.append(phrase)
|
||||
# Preserve register AND edit-location diversity, rather than eight near-copies.
|
||||
unique={signature(p.draft.notes):p for p in result}
|
||||
return sequences.diverse(tuple(unique.values()),lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
||||
lambda p:p.draft.octave_transfer,policy.DEFAULT.source_variations)
|
||||
|
||||
|
||||
def clipped_sources(analysis,end):
|
||||
result=[]
|
||||
for source in analysis.source_phrases:
|
||||
if source.start>=end:break
|
||||
finish=min(source.end,end)
|
||||
notes=tuple(dataclasses.replace(n,start=max(F(),n.start),end=min(n.end,finish)) for n in source.notes if n.start<finish and n.end>0)
|
||||
result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if a<finish)))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def transition(first,second):
|
||||
if not first or not first.decisions or not second.decisions:return 0.
|
||||
a=first.state.last;b=second.decisions[0].notes[0]
|
||||
leap=max(0,abs(a.pitch.number()-b.pitch.number())-7)/12
|
||||
same_family=first.draft.source_phrase.family==second.draft.source_phrase.family
|
||||
repeated_end=not same_family and first.anchors and second.anchors and first.anchors[-1].pitch==second.anchors[-1].pitch and first.anchors[-1].duration==second.anchors[-1].duration
|
||||
return .2*leap+.15*bool(repeated_end)
|
||||
|
||||
|
||||
def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage):
|
||||
from generator.solo import realization,themes,phrasing
|
||||
context.resources[id(library)]=library
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
harmony=harmonic.Harmony(choices)
|
||||
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||
states=[(0.,())]
|
||||
failures=[]
|
||||
for _ in range(2*len(measures)+1):
|
||||
if all(history and history[-1].plan.end==end for _,history in states):break
|
||||
following=[]
|
||||
for cost,history in states:
|
||||
start=history[-1].plan.end if history else F()
|
||||
if start==end:
|
||||
following.append((cost,history));continue
|
||||
incoming=history[-1].state if history else types.ContinuationState('')
|
||||
sources=phrasing.window_options(analysis,measures,start,end,harmony,incoming)
|
||||
elapsed=F();meter=measures[0]['time_signature']
|
||||
for measure in measures:
|
||||
elapsed+=generator.model.fraction(measure['duration'])
|
||||
if elapsed>start:
|
||||
meter=measure['time_signature'];break
|
||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
for source in sources:
|
||||
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||
else:
|
||||
pool=()
|
||||
if not harmony.at(start) or not harmony.at(start).scale:
|
||||
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
|
||||
else:
|
||||
for batch in range(policy.DEFAULT.replan_attempts):
|
||||
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,
|
||||
stage=stage,batch=batch,final=source.end==end,solo_end=end)
|
||||
if pool:break
|
||||
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
|
||||
context.results[key]=pool
|
||||
context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend(
|
||||
{'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None,
|
||||
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns)} for p in pool)
|
||||
for candidate in pool:
|
||||
span=float((source.end-source.start)/end)
|
||||
total=cost+span*(candidate.score+phrasing.endpoint_cost(source.end,start,end,incoming.closures,harmony,bar))
|
||||
total+=transition(history[-1] if history else None,candidate)
|
||||
following.append((total,(*history,candidate)))
|
||||
if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation')
|
||||
if not following:
|
||||
raise realization.SearchExhausted(f'{part}: {"; ".join(failures[-4:])} failed within the bounded search; not proof of incompatibility')
|
||||
def rank(value):
|
||||
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end
|
||||
musical=cost/max(.001,float(elapsed/end))
|
||||
if elapsed==end:
|
||||
target=policy.DEFAULT.settling_target*float(end)/(32*4)
|
||||
musical+=.1*max(0,abs(state.closures-target)-1)
|
||||
musical+=.15*abs(len(state.returns)-policy.DEFAULT.return_target*float(end)/(32*4))
|
||||
return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history))
|
||||
states=sequences.diverse(following,rank,
|
||||
lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier)
|
||||
finished=[(cost,history) for cost,history in states if history[-1].plan.end==end]
|
||||
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,
|
||||
{**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)
|
||||
|
||||
|
||||
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
||||
from generator.solo import realization
|
||||
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
|
||||
if mode=='thematic':
|
||||
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
|
||||
context.resources[id(library)]=library
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
harmony=harmonic.Harmony(choices)
|
||||
sources=clipped_sources(analysis,end)
|
||||
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||
states=[(0.,())]
|
||||
for source in sources:
|
||||
meter=measures[min(len(measures)-1,int(source.start//generator.model.fraction(measures[0]['duration'])))]['time_signature']
|
||||
following=[]
|
||||
for cost,history in states:
|
||||
incoming=history[-1].state if history else types.ContinuationState('')
|
||||
key=(mode,source,None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||
else:
|
||||
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
|
||||
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
|
||||
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
|
||||
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
|
||||
for candidate in pool:
|
||||
last=incoming.last
|
||||
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
|
||||
for travel,notes in placements:
|
||||
decision=dataclasses.replace(candidate.decisions[0],notes=notes) if candidate.decisions else None
|
||||
state=dataclasses.replace(candidate.state,notes=notes,last=notes[-1] if notes else last,travel=travel)
|
||||
placed=dataclasses.replace(candidate,decisions=(decision,) if decision else (),state=state)
|
||||
total=cost+candidate.score*float(source.end-source.start)/float(end)+.05*travel/max(1,len(notes))/2000+transition(history[-1] if history else None,placed)
|
||||
following.append((total,(*history,placed)))
|
||||
if not following:
|
||||
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
def similarity(first,second):
|
||||
"""Compare composition independently of octave and fingering; callbacks are explicit."""
|
||||
def events(solo):
|
||||
callbacks={e for p in solo.phrases if p.draft for f in p.draft.features if f.id.endswith(':callback') for e in f.events}
|
||||
return tuple(n for p in solo.phrases if p.plan.relationship!='callback' for d in p.decisions for n in d.notes if n.event_id not in callbacks)
|
||||
a,b=events(first),events(second)
|
||||
def tokens(notes):
|
||||
return {(n.start,n.end,n.pitch.number()%12) for n in notes}
|
||||
left,right=tokens(a),tokens(b)
|
||||
literal=len(left&right)/max(1,min(len(left),len(right)))
|
||||
def runs(notes):
|
||||
return {tuple((n.start-notes[i].start,n.end-n.start,n.pitch.number()-notes[i].pitch.number()) for n in notes[i:i+4]) for i in range(len(notes)-3)}
|
||||
left,right=runs(a),runs(b)
|
||||
interval=len(left&right)/max(1,min(len(left),len(right)))
|
||||
return .65*literal+.35*interval
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Sustained destinations and bounded memory for the existing theme composer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
import generator.harmony
|
||||
from generator.solo import types, policy, sequences
|
||||
|
||||
|
||||
def heard(notes, operation, anchor=None):
|
||||
notes = types.sounding_notes(notes)
|
||||
first = notes[0].start
|
||||
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
|
||||
ending = (anchor.pitch.number()%12, anchor.earliest%4, anchor.duration) if anchor and anchor.role == 'terminal' else ()
|
||||
return types.HeardFigure(first, notes[-1].end, events, operation, ending)
|
||||
|
||||
|
||||
def remember(history, figure, bar):
|
||||
return tuple(h for h in (*history, figure) if h.end > figure.end-policy.DEFAULT.history_bars*bar)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def fingerprints(events, relative=False):
|
||||
result = set()
|
||||
for size in (3, 4, 6, 8):
|
||||
for i in range(len(events)-size+1):
|
||||
group = events[i:i+size]
|
||||
start, _, pitch = group[0]
|
||||
result.add(tuple((a-start, d, p-pitch if relative else p) for a,d,p in group))
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def repetition(figure, history, bar):
|
||||
"""Bounded overuse, not a reward for arbitrary difference or extra attacks."""
|
||||
exact, relative = fingerprints(figure.events), fingerprints(figure.events, True)
|
||||
overuse = cadence = 0.
|
||||
for old in history:
|
||||
age = max(0., float((figure.start-old.end)/bar))
|
||||
recent = max(0., 1-age/policy.DEFAULT.history_bars)
|
||||
a,b = fingerprints(old.events), fingerprints(old.events, True)
|
||||
exact_match = len(exact & a)/max(1,min(len(exact),len(a)))
|
||||
shifted_match = len(relative & b)/max(1,min(len(relative),len(b)))
|
||||
overuse += recent*(.65*exact_match+.35*shifted_match)
|
||||
if figure.ending and figure.ending == old.ending:
|
||||
cadence += recent
|
||||
pitches = tuple(p for _,_,p in figure.events)
|
||||
oscillation = sum(a==c and b==d for a,b,c,d in zip(pitches,pitches[1:],pitches[2:],pitches[3:]))/max(1,len(pitches)-3)
|
||||
discount = .45 if figure.operation == 'return' else 1.
|
||||
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
|
||||
|
||||
|
||||
def register_goal(incoming, part, fallback):
|
||||
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||
if not recent:
|
||||
return fallback
|
||||
center = sum(recent)/len(recent)
|
||||
if part == 'electric' and (center >= 67 or incoming.register_peak >= 72 and center >= 64):
|
||||
return max(48, round(center)-7)
|
||||
if part == 'acoustic' and max(recent)-min(recent) < 8:
|
||||
home = sum(n.pitch.number() for n in incoming.home.notes)/len(incoming.home.notes) if incoming.home else center
|
||||
return min(72, round(center)+5) if center <= home+7 else max(45, round(center)-5)
|
||||
return round(center)
|
||||
|
||||
|
||||
def endpoint_cost(finish, start, end, count, harmony, bar):
|
||||
length = float((finish-start)/bar)
|
||||
remaining = float((end-finish)/bar)
|
||||
projected = count+1+remaining/(32/policy.DEFAULT.settling_target)
|
||||
low, high = policy.DEFAULT.settling_range
|
||||
pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target)
|
||||
span = harmony.at(finish-F(1,100))
|
||||
# A dominant may close an open question, but a nearby resolution is preferred.
|
||||
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
|
||||
short_remainder=.2*max(0,4-remaining) if remaining else 0.
|
||||
return pacing+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
|
||||
|
||||
|
||||
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
|
||||
from generator.solo.themes import source_notes
|
||||
bounds=[F()]
|
||||
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
|
||||
index=next((i for i,b in enumerate(bounds[:-1]) if b<=start<bounds[i+1]),len(measures)-1)
|
||||
bar=bounds[index+1]-bounds[index]
|
||||
current=harmony.at(start)
|
||||
if not current or not current.scale:
|
||||
ends=(min(end,current.end if current else bounds[index+1]),)
|
||||
else:
|
||||
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
|
||||
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (4,5,6,8)}))
|
||||
if barrier>=end:
|
||||
# The returning pickup may shorten the last measure. Do not strand a
|
||||
# one-beat "new phrase" after an otherwise completed sustained close.
|
||||
ends=tuple(sorted({end if 0<end-finish<bar else finish for finish in ends}))
|
||||
melody=source_notes(analysis)
|
||||
values=[]
|
||||
for finish in ends:
|
||||
if finish<=start:continue
|
||||
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
|
||||
for n in melody if n.start<finish and n.end>start)
|
||||
values.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
|
||||
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):
|
||||
"""Choose pitch AND sounding interval before generating the approach."""
|
||||
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
|
||||
goal=register_goal(incoming,part,base)
|
||||
values=[]
|
||||
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),)
|
||||
for hold in holds:
|
||||
for release in (F(),F(1,2),F(1),F(2)):
|
||||
arrival=source.end-release-hold
|
||||
if arrival-source.start<F(3,2):continue
|
||||
for pitch in available(harmony,arrival,arrival+hold,members=True):
|
||||
if abs(pitch.number()-goal)>12:continue
|
||||
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,
|
||||
hold_until=arrival+hold)
|
||||
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
|
||||
rank=abs(pitch.number()-goal)/24+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
|
||||
values.append((rank,anchor))
|
||||
selected=sequences.diverse(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
|
||||
lambda x:(x[1].duration,x[1].earliest%bar,x[1].pitch.number()>goal),limit)
|
||||
return tuple(a for _,a in selected)
|
||||
|
||||
|
||||
def appearance_cost(operation, incoming, start, end):
|
||||
expected=policy.DEFAULT.return_target*float(start/max(end,F(1)))
|
||||
actual=len(incoming.returns)
|
||||
if operation=='return':
|
||||
gap=start-(incoming.returns[-1] if incoming.returns else F())
|
||||
return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24)
|
||||
return .45*max(0,expected-actual)
|
||||
|
||||
|
||||
def activity(source, ending):
|
||||
spans=[]
|
||||
if ending.earliest>source.start:
|
||||
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
|
||||
spans.append(types.ActivitySpan(ending.earliest,ending.hold_until,'sustained_arrival',F(),F(1)))
|
||||
if ending.hold_until<source.end:
|
||||
spans.append(types.ActivitySpan(ending.hold_until,source.end,'space',F(),F()))
|
||||
return tuple(spans)
|
||||
|
||||
|
||||
def evaluate(notes, incoming, harmony, start, end, bar):
|
||||
"""Final musical cost uses a complete sounding phrase, never cell totals."""
|
||||
notes=types.sounding_notes(notes)
|
||||
checked=harmony.evaluate(notes,until=end,complete=True)
|
||||
if not checked.valid or not notes:return float('inf')
|
||||
density=4*len(notes)/float(end-start)
|
||||
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)
|
||||
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
|
||||
+52
-14
@@ -19,25 +19,63 @@ def transition(previous, current, duration, tempo):
|
||||
# Movement is a preference, not an unsupported claim about a player's limits.
|
||||
return round(100 * distance / max(seconds, 0.05))
|
||||
|
||||
def hand_positions(position):
|
||||
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def position_rank(position):
|
||||
from generator.solo.policy import stable_rank
|
||||
return stable_rank('guitar-position', position)
|
||||
|
||||
def stable_path(path):
|
||||
return tuple(position_rank(n.position) for n in path)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fraction, previous: generator.solo.types.Note | None = None) -> tuple[int, tuple[generator.solo.types.Note, ...]] | None:
|
||||
states = [(0, ())]
|
||||
def alternatives(notes, tempo, previous=None, limit=2):
|
||||
"""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)]
|
||||
for note in notes:
|
||||
following = []
|
||||
following = {}
|
||||
for position in positions(note.pitch.number()):
|
||||
current = dataclasses.replace(note, position=position)
|
||||
candidates = []
|
||||
for cost, path in states:
|
||||
prior = path[-1] if path else previous
|
||||
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
|
||||
continue
|
||||
candidates.append((cost + (0 if note.continuation else travel(prior, current, tempo)), (*path, current)))
|
||||
if candidates:
|
||||
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1]))))
|
||||
for hand in hand_positions(position):
|
||||
best = None
|
||||
for cost, path, old_hand in states:
|
||||
prior = path[-1] if path else previous
|
||||
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
|
||||
continue
|
||||
movement = 0
|
||||
if prior and not note.continuation:
|
||||
seconds = max(.05, float((note.start-prior.start)*60/tempo))
|
||||
distance = abs(hand-old_hand) if old_hand is not None else 0
|
||||
distance += .25*abs(position.fret-prior.position.fret) + .5*abs(position.string_number-prior.position.string_number)
|
||||
movement = round(100*distance/seconds)
|
||||
candidate = (cost+movement, (*path,current), hand)
|
||||
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
|
||||
best = candidate
|
||||
if best is not None:
|
||||
following[position,hand] = best
|
||||
if not following:
|
||||
return None
|
||||
states = following
|
||||
return min(states, key=lambda item: (item[0], tuple(n.position for n in item[1])))
|
||||
return ()
|
||||
states = list(following.values())
|
||||
# Endpoints survive so the following phrase can choose a better transition.
|
||||
selected, seen = [], set()
|
||||
for cost,path,hand in sorted(states,key=lambda v:(v[0],stable_path(v[1]))):
|
||||
endpoint = path[-1].position if path else None
|
||||
if endpoint in seen:
|
||||
continue
|
||||
seen.add(endpoint); selected.append((cost,path))
|
||||
if len(selected) == limit:
|
||||
break
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def place(notes, tempo, previous=None):
|
||||
result = alternatives(notes,tempo,previous,1)
|
||||
return result[0] if result else None
|
||||
|
||||
@functools.lru_cache(maxsize=128)
|
||||
def positions(number: int):
|
||||
|
||||
+11
-11
@@ -86,15 +86,13 @@ def activity_passages(start, end, release, activity, shape=0):
|
||||
boundaries = sorted({start, music_end} | {start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) for i in (1, 2, 3) if start < start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) < music_end})
|
||||
weights = ((1, 1.6, 1.1), (0.8, 1.7, 1.2), (1.5, 1.4, 0.8))[shape % 3]
|
||||
spans = list(zip(boundaries, boundaries[1:]))
|
||||
arrival_rate = fractions.Fraction(3, 8)
|
||||
desired = max(0, activity * (end - start) - arrival_rate * (spans[-1][1] - spans[-1][0]))
|
||||
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans[:-1]))
|
||||
desired = activity * (end - start)
|
||||
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans))
|
||||
result = []
|
||||
for i, (a, b) in enumerate(spans):
|
||||
arrival = i == len(spans) - 1
|
||||
rate = arrival_rate if arrival else fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
|
||||
occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy
|
||||
result.append(generator.solo.types.ActivitySpan(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
|
||||
rate = fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
|
||||
occupancy = generator.solo.policy.DEFAULT.active_occupancy
|
||||
result.append(generator.solo.types.ActivitySpan(a, b, 'active', rate, fractions.Fraction(str(occupancy))))
|
||||
if release:
|
||||
result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
|
||||
return tuple(result)
|
||||
@@ -154,7 +152,7 @@ def anchor_backbones(identifier, start, end, release, register, choices, bar, se
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[2], item[1][0].pitch.number()), generator.solo.policy.DEFAULT.anchor_lines))
|
||||
return tuple(dict.fromkeys(anchors for _, anchors, _ in states))
|
||||
|
||||
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]:
|
||||
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library, *, lead=False) -> tuple[generator.solo.types.PhrasePlan, ...]:
|
||||
boundaries = [fractions.Fraction()]
|
||||
for measure in measures:
|
||||
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
|
||||
@@ -189,13 +187,15 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
|
||||
release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction()
|
||||
release = min(release, (finish - start) / 8)
|
||||
envelope = activity_passages(start, finish, release, activity, decision(passage_seed, 'activity') % 3)
|
||||
anchors = anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed)
|
||||
anchors = () if lead else anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed)
|
||||
# Prefer four-bar phrasing; extend when a sustained harmony crosses that seam.
|
||||
middle = min(index + 4, stop)
|
||||
before = next((c for c in reversed(choices) if c.span.start < boundaries[middle]), None)
|
||||
after = next((c for c in choices if c.span.start <= boundaries[middle] < c.span.end), None)
|
||||
extended = stop - index > 4 and same_harmony(before, after)
|
||||
phrase_stops = [stop] if extended or middle == stop else [middle, stop]
|
||||
# A bounded complete lead draft can express an extended phrase directly.
|
||||
# Computational note groups below do not require additional phrase ends.
|
||||
phrase_stops = [stop] if lead or extended or middle == stop else [middle, stop]
|
||||
local = tuple(c for c in choices if c.span.start < boundaries[phrase_stops[0]] and c.span.end > start)
|
||||
slot = decision(passage_seed, 'motif') % 3
|
||||
if part == 'acoustic':
|
||||
@@ -212,7 +212,7 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
|
||||
for phrase_stop in phrase_stops:
|
||||
phrase_end = boundaries[phrase_stop]
|
||||
terminal = phrase_end == finish
|
||||
treatment = 'opening_reference' if motif.origin == 'melody' else 'invented'
|
||||
treatment = 'lead' if lead else 'opening_reference' if motif.origin == 'melody' else 'invented'
|
||||
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, min(v[i].pitch.number() for v in anchors), max(v[i].pitch.number() for v in anchors), 0, a.role) for i, a in enumerate(anchors[0])) if anchors and anchors[0] else ()
|
||||
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors))
|
||||
phrase_start = phrase_end
|
||||
|
||||
@@ -8,10 +8,10 @@ import json
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Policy:
|
||||
acoustic_activity: float = 1.125
|
||||
electric_activity: float = 1.375
|
||||
active_occupancy: float = 0.75
|
||||
electric_activity: float = 1.125
|
||||
active_occupancy: float = 0.925
|
||||
arrival_occupancy: float = 0.9
|
||||
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2), ('draft_preservation', 3), ('development', 2), ('direction', 1), ('join_coherence', 2), ('ending_integration', 2))
|
||||
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2), ('draft_preservation', 3), ('development', 2), ('direction', 1), ('join_coherence', 2), ('ending_integration', 2))
|
||||
beam_width: int = 64
|
||||
extensions: int = 32
|
||||
retrieval: int = 32
|
||||
@@ -26,7 +26,29 @@ class Policy:
|
||||
phrase_alternatives: int = 8
|
||||
join_alternatives: int = 8
|
||||
revision_sweeps: int = 2
|
||||
revision_joins: int = 2
|
||||
refinement_alternatives: int = 4
|
||||
join_window: float = 2.0
|
||||
activity_deadband: float = 1.0
|
||||
occupancy_deadband: float = 0.1
|
||||
lead_activity_range: tuple[float, float] = (1.0, 1.5)
|
||||
lead_occupancy_range: tuple[float, float] = (0.85, 1.0)
|
||||
source_variations: int = 8
|
||||
chorus_frontier: int = 8
|
||||
theme_copy_threshold: float = 0.8
|
||||
theme_copy_beats: float = 8.0
|
||||
theme_copy_attacks: int = 4
|
||||
theme_seed_limit: int = 8
|
||||
theme_unit_alternatives: int = 8
|
||||
theme_rhythm_alternatives: int = 8
|
||||
theme_destinations: int = 6
|
||||
theme_continuations: int = 8
|
||||
theme_route_jobs: int = 16
|
||||
settling_range: tuple[int, int] = (5, 7)
|
||||
settling_target: int = 6
|
||||
return_target: int = 2
|
||||
history_bars: int = 16
|
||||
composition_version: str = 'thematic-phrasing-2'
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
+221
-72
@@ -6,6 +6,8 @@ import functools
|
||||
import time
|
||||
|
||||
import generator.solo.drafts
|
||||
import generator.solo.references
|
||||
import generator.solo.routes
|
||||
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
@@ -354,6 +356,8 @@ class SearchContext:
|
||||
resources: dict = dataclasses.field(default_factory=dict)
|
||||
revise: bool = True
|
||||
batch: int = 0
|
||||
references: tuple = ()
|
||||
stage: str = 'elaborated'
|
||||
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
|
||||
|
||||
def note_family(notes):
|
||||
@@ -421,19 +425,9 @@ def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, conte
|
||||
expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
|
||||
anchor = next_anchor[index]
|
||||
target = anchor.pitch.number() if anchor else expected
|
||||
route = expected
|
||||
if prior and anchor and span and span.scale:
|
||||
try:
|
||||
start_degree = generator.solo.types.scale_index(span.scale, prior.pitch.number())
|
||||
target_degree = generator.solo.types.scale_index(span.scale, target)
|
||||
displacement = target_degree - start_degree
|
||||
count = min(16, max(2, remaining + 1))
|
||||
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
|
||||
if paths:
|
||||
route = generator.solo.types.scale_pitch(span.scale, start_degree + paths[0].steps[0]).number()
|
||||
except ValueError:
|
||||
# Collection changes preserve the incoming sound, never its old index.
|
||||
route = prior.pitch.number() + ((target > prior.pitch.number()) - (target < prior.pitch.number())) * 2
|
||||
# Theme invention follows a complete paired rhythmic/interval idea.
|
||||
# Authored sequence routes are instantiated as whole alternatives later.
|
||||
route = expected if not anchor else target
|
||||
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
|
||||
if available:
|
||||
bridge = min(available, key=lambda p: (abs(p.number() - route), p))
|
||||
@@ -443,14 +437,13 @@ def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, conte
|
||||
direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
|
||||
intended = (step > 0) - (step < 0)
|
||||
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced)
|
||||
if identifying and reference and direction != intended:
|
||||
continue
|
||||
jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
|
||||
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note
|
||||
motion_cost = abs(pitch.number() - expected) / 12 * (1.5 if identifying else 0.35)
|
||||
direction_cost = max(0, abs(pitch.number() - target) - 2 * max(0, remaining - 1)) / 12 if anchor else 0
|
||||
exposure = 0.6 if stopping and span and pitch.number() % 12 not in span.roles.basic and not quoted else 0
|
||||
score = cost + motion_cost + direction_cost + max(0, jump - 5) / 12 + exposure
|
||||
supported_leap = reference and abs(step) >= 3 and abs(pitch.number() - expected) <= 2
|
||||
score = cost + motion_cost + direction_cost + (0 if supported_leap else max(0, jump - 5) / 12) + exposure
|
||||
sources = reference[min(slot.reference, len(reference) - 1)].sources if reference else ()
|
||||
note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
|
||||
path = (*notes, note)
|
||||
@@ -489,10 +482,9 @@ def make_draft(rhythm, notes, plan, harmony, incoming):
|
||||
identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
|
||||
if home is None:
|
||||
home = identity
|
||||
structural.extend(n.event_id for n in actual[:3])
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
|
||||
# Closing simplification explicitly releases the characteristic core.
|
||||
preserves = () if group.operation == 'close' else ('rhythm', 'opening_contour')
|
||||
preserves = ()
|
||||
if plan.treatment == 'rhythmic_reference':
|
||||
preserves = ('rhythm',)
|
||||
transforms.append(generator.solo.types.Transformation(group.id, parent, home.id, group.operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, preserves, group.source))
|
||||
@@ -503,7 +495,7 @@ def make_draft(rhythm, notes, plan, harmony, incoming):
|
||||
if note:
|
||||
structural.append(note.event_id)
|
||||
joins = tuple(t.start for t in transforms[1:])
|
||||
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins)
|
||||
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins, reference=rhythm.reference.id, reference_motif=rhythm.reference)
|
||||
|
||||
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
|
||||
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
|
||||
@@ -524,14 +516,21 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
|
||||
return None
|
||||
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
||||
evaluation = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
||||
evaluation_key = (plan, draft.anchors)
|
||||
if evaluation_key not in harmony.evaluations:
|
||||
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
||||
evaluation = harmony.evaluations[evaluation_key]
|
||||
if not evaluation.feasible(score_notes):
|
||||
return None
|
||||
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
|
||||
features.update(generator.solo.drafts.relationship_features(draft, notes))
|
||||
if draft.lead_identity or draft.source_phrase:
|
||||
supported = {e for feature in draft.features for e in feature.events}
|
||||
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
|
||||
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
|
||||
# Fidelity is checked against explicit transformed identities, not against
|
||||
# the source head as one undifferentiated phrase-long quotation.
|
||||
features['fidelity'] = features['development']
|
||||
features.pop('fidelity', None)
|
||||
features['harmonic_exposure'] = checked.exposure
|
||||
intended_repeats = 0
|
||||
if draft.home:
|
||||
@@ -589,32 +588,17 @@ def emitted_notes(phrase):
|
||||
def window_variants(phrase, low, high, harmony, sequences, kind):
|
||||
"""Two fixed outside endpoints bound edits to the existing melody."""
|
||||
notes = emitted_notes(phrase)
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected))
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events), *(n.event_id for r in phrase.routes for n in r.notes)))
|
||||
inside = [i for i, n in enumerate(notes) if low <= n.start and n.end <= high]
|
||||
editable = [i for i in inside if notes[i].event_id not in protected]
|
||||
if not editable:
|
||||
return ()
|
||||
candidates = []
|
||||
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1)
|
||||
# Route both sides of a join as one path, with the original outside pitches.
|
||||
if 1 < right - left < 16:
|
||||
scale = harmony.at(notes[left].start).scale
|
||||
try:
|
||||
first = generator.solo.types.scale_index(scale, notes[left].pitch.number())
|
||||
displacement = generator.solo.types.scale_index(scale, notes[right].pitch.number()) - first
|
||||
paths = sequences.retrieve(right - left + 1, displacement, (displacement > 0) - (displacement < 0))
|
||||
except ValueError:
|
||||
paths = ()
|
||||
for path in paths[:generator.solo.policy.DEFAULT.join_alternatives]:
|
||||
updated = list(notes)
|
||||
for i in editable:
|
||||
offset = path.offsets()[i - left]
|
||||
intended = generator.solo.types.scale_pitch(scale, first + offset).number()
|
||||
available = options(harmony.choices, notes[i].start, notes[i].end, phrase.plan.register)
|
||||
if available:
|
||||
pitch = min(available, key=lambda p: (abs(p.number() - intended), p))
|
||||
updated[i] = dataclasses.replace(notes[i], pitch=pitch, position=None)
|
||||
candidates.append((tuple(updated), 'sequence ' + ' + '.join(path.sources)))
|
||||
# Compare complete routes through both fixed endpoints. Reject an invalid
|
||||
# path as a unit; never substitute nearest pitches for individual steps.
|
||||
for route in generator.solo.routes.candidates(notes[left:right + 1], protected, harmony, sequences, phrase.plan.register):
|
||||
candidates.append(((*notes[:left], *route.notes, *notes[right + 1:]), 'complete route ' + ' + '.join(route.sources)))
|
||||
for index in editable:
|
||||
note = notes[index]
|
||||
if index + 1 < len(notes) and notes[index + 1].start <= high:
|
||||
@@ -625,20 +609,6 @@ def window_variants(phrase, low, high, harmony, sequences, kind):
|
||||
# The tail and pickup move together, preserving total sounding time.
|
||||
time = after.start - fractions.Fraction(1, 2)
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=time), dataclasses.replace(after, start=time), *notes[index + 2:]), 'reopen tail into pickup'))
|
||||
if kind == 'elaboration' and index + 1 < len(notes) and fractions.Fraction(1) <= note.end - note.start < 2:
|
||||
# Add motion only as an alternative evaluated against the unchanged
|
||||
# activity envelope; keeping the original draft is always available.
|
||||
split = note.end - fractions.Fraction(1, 2)
|
||||
scale = harmony.at(split).scale
|
||||
target = notes[index + 1].pitch
|
||||
try:
|
||||
degree = generator.solo.types.scale_index(scale, note.pitch.number())
|
||||
finish = generator.solo.types.scale_index(scale, target.number())
|
||||
pitch = generator.solo.types.scale_pitch(scale, degree + ((finish > degree) - (finish < degree)))
|
||||
addition = generator.solo.types.Note(split, note.end, pitch, note.sources, 'elaboration', event_id=note.event_id + '_passing')
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=split), addition, *notes[index + 1:]), 'interpolate toward next structural event'))
|
||||
except ValueError:
|
||||
pass
|
||||
return tuple(candidates)
|
||||
|
||||
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
|
||||
@@ -648,7 +618,7 @@ def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind,
|
||||
# Keep distinct rhythmic and pitch revisions; no generic unlimited search.
|
||||
proposals = generator.solo.sequences.diverse(proposals, lambda p: (abs(len(p[0]) - len(original)), p[1], tuple(n.pitch for n in p[0])), lambda p: (p[1].split()[0], len(p[0])), generator.solo.policy.DEFAULT.join_alternatives)
|
||||
for notes, source in proposals:
|
||||
if notes == original:
|
||||
if tuple((n.start, n.end, n.pitch, n.continuation) for n in notes) == tuple((n.start, n.end, n.pitch, n.continuation) for n in original):
|
||||
continue
|
||||
changed_before = tuple(n.event_id for n in original if n not in notes)
|
||||
changed_after = tuple(n.event_id for n in notes if n not in original)
|
||||
@@ -656,16 +626,180 @@ def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind,
|
||||
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
|
||||
if revised:
|
||||
viable.append(revised)
|
||||
viable.append(dataclasses.replace(revised, initial=phrase.initial, refined=phrase.refined, routes=phrase.routes))
|
||||
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple((n.pitch, n.start, n.end) for n in emitted_notes(p))))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
|
||||
return chosen
|
||||
|
||||
def rebase_draft(draft, notes, harmony):
|
||||
"""Reassign edited events by absolute group time, retaining source contracts."""
|
||||
notes = tuple(dataclasses.replace(n, position=None) for n in notes)
|
||||
groups = []
|
||||
original_groups = {g.id: g for g in draft.transformations}
|
||||
for i, group in enumerate(draft.transformations):
|
||||
low = draft.start if i == 0 else group.start
|
||||
high = draft.transformations[i + 1].start if i + 1 < len(draft.transformations) else draft.end
|
||||
actual = tuple(n for n in notes if low <= n.start < high)
|
||||
if actual:
|
||||
parent = group.parent
|
||||
while parent in original_groups and parent not in {g.id for g in groups}:
|
||||
parent = original_groups[parent].parent
|
||||
groups.append(dataclasses.replace(group, parent=parent, events=tuple(n.event_id for n in actual), rhythm=group.rhythm if group.preserves else tuple((n.start - group.start, n.end - group.start) for n in actual)))
|
||||
home = draft.home
|
||||
if home and groups and home.id == groups[0].id:
|
||||
actual = tuple(n for n in notes if n.event_id in groups[0].events)
|
||||
home = make_identity(home.id, home.parent, actual, groups[0].start, groups[0].end, home.relationship, harmony)
|
||||
return dataclasses.replace(draft, notes=notes, transformations=tuple(groups), home=home)
|
||||
|
||||
def replace_idea(phrase, donor, low, high, incoming, harmony, tempo):
|
||||
"""A whole two-bar idea is editable until its identifying features are selected."""
|
||||
original = emitted_notes(phrase)
|
||||
if any(n.start < low < n.end or n.start < high < n.end for n in original):
|
||||
return None
|
||||
replacement = tuple(n for n in emitted_notes(donor) if low <= n.start and n.end <= high)
|
||||
if not replacement or tuple((n.start, n.end, n.pitch) for n in replacement) == tuple((n.start, n.end, n.pitch) for n in original if low <= n.start < high):
|
||||
return None
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected))
|
||||
identifiers = {}
|
||||
for before in original:
|
||||
if before.event_id in protected and low <= before.start < high:
|
||||
after = next((n for n in replacement if (n.start, n.end, n.pitch) == (before.start, before.end, before.pitch)), None)
|
||||
if not after:
|
||||
return None
|
||||
identifiers[after.event_id] = before.event_id
|
||||
replacement = tuple(dataclasses.replace(n, event_id=identifiers.get(n.event_id, n.event_id + '_replacement'), position=None) for n in replacement)
|
||||
notes = tuple(n for n in original if n.end <= low) + replacement + tuple(n for n in original if n.start >= high)
|
||||
valid, _ = generator.solo.drafts.contract_valid(phrase.draft, notes)
|
||||
if not valid:
|
||||
return None
|
||||
draft = rebase_draft(phrase.draft, notes, harmony)
|
||||
edit = generator.solo.types.DraftEdit('replace_idea', low, high, tuple(n.event_id for n in original if low <= n.start < high), tuple(n.event_id for n in replacement), donor.draft.reference + '; ' + donor.draft.id)
|
||||
return checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
|
||||
def refine(phrase, pool, incoming, harmony, sequences, tempo, context, search=True):
|
||||
initial = phrase.draft
|
||||
for low in (phrase.plan.start, phrase.plan.start + 8) if search else ():
|
||||
high = min(phrase.plan.end, low + 8)
|
||||
if high - low < 8 or phrase.draft.protected:
|
||||
continue
|
||||
viable = [phrase]
|
||||
for donor in pool:
|
||||
if donor.draft.id == phrase.draft.id:
|
||||
continue
|
||||
context.diagnostics['idea_replacements_tried'] = context.diagnostics.get('idea_replacements_tried', 0) + 1
|
||||
revised = replace_idea(phrase, donor, low, high, incoming, harmony, tempo)
|
||||
if revised:
|
||||
viable.append(revised)
|
||||
chosen = min(viable, key=lambda p: (p.score, len(p.edits), p.draft.id))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics['idea_replacements_selected'] = context.diagnostics.get('idea_replacements_selected', 0) + 1
|
||||
phrase = chosen
|
||||
if search and context.revise:
|
||||
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
|
||||
notes = emitted_notes(phrase)
|
||||
draft = rebase_draft(phrase.draft, notes, harmony)
|
||||
# Only now commit a selected, inspectable relationship for elaboration.
|
||||
features = generator.solo.references.identifying_features(notes, draft.reference)
|
||||
draft = dataclasses.replace(draft, features=features)
|
||||
# Rebuild lineage after group removal or replacement, including when no run
|
||||
# is selected afterward. The introduced home is the actual refined tune.
|
||||
rebuilt = checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, phrase.edits)
|
||||
if rebuilt is None:
|
||||
raise ValueError(phrase.plan.id + ': refined theme lost a committed contract')
|
||||
return dataclasses.replace(rebuilt, initial=initial, refined=draft)
|
||||
|
||||
def elaborate(phrase, incoming, harmony, sequences, tempo, context):
|
||||
started = time.monotonic()
|
||||
for transform in phrase.draft.transformations:
|
||||
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, transform.start, transform.end, 'elaboration', context)
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events)))
|
||||
if phrase.draft.protected:
|
||||
return phrase
|
||||
# Elaborate selected approaches in the existing tune, at most two per phrase.
|
||||
notes = emitted_notes(phrase)
|
||||
windows = []
|
||||
for right in range(2, len(notes)):
|
||||
if notes[right].event_id not in protected and right != len(notes) - 1:
|
||||
continue
|
||||
left = next((i for i in range(right - 1, -1, -1) if notes[right].start - notes[i].start >= 4), None)
|
||||
if left is not None and notes[right].start - notes[left].start <= 4:
|
||||
windows.append((notes[left].start, notes[right].start))
|
||||
limit = max(1, min(2, int((phrase.plan.end - phrase.plan.start) / 16))) if phrase.draft.lead_identity else 2
|
||||
if phrase.draft.lead_identity:
|
||||
intended = {phrase.plan.start + a for reference in context.references for a, _ in reference.runs}
|
||||
windows.sort(key=lambda window: (sum(n.event_id in protected for n in notes if window[0] <= n.start < window[1]), window[0] not in intended, window))
|
||||
selected_windows = windows[:limit]
|
||||
else:
|
||||
selected_windows = windows[-limit:]
|
||||
for low, high in selected_windows:
|
||||
if any(low < r.destination.end and high > r.notes[0].start for r in phrase.routes):
|
||||
continue
|
||||
notes = emitted_notes(phrase)
|
||||
left = next(i for i, n in enumerate(notes) if n.start == low)
|
||||
right = next(i for i, n in enumerate(notes) if n.start == high)
|
||||
patterns = [notes[left:right + 1]]
|
||||
# A full approach rhythm, preserving every committed event. Existing
|
||||
# silence remains a candidate; short-note motion is never compulsory.
|
||||
moving = []
|
||||
timepoint = low
|
||||
while timepoint < high:
|
||||
original = next((n for n in notes[left:right] if n.start == timepoint), None)
|
||||
end = min(high, timepoint + fractions.Fraction(1, 2))
|
||||
moving.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(timepoint, end, notes[left].pitch, role='run', event_id=f'route_{phrase.draft.id}_{timepoint}'))
|
||||
timepoint = end
|
||||
patterns.append((*moving, notes[right]))
|
||||
for reference in context.references:
|
||||
for a, b in reference.runs:
|
||||
if high - low != b - a:
|
||||
continue
|
||||
profile = tuple(n for n in reference.decorated if a <= n.start and n.end <= b)
|
||||
timed = []
|
||||
for index, event in enumerate(profile):
|
||||
start, end = low + event.start - a, low + event.end - a
|
||||
original = next((n for n in notes[left:right] if n.start == start), None)
|
||||
timed.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(start, end, notes[left].pitch, (reference.id,), 'run', event_id=f'profile_{phrase.draft.id}_{start}'))
|
||||
if timed:
|
||||
patterns.append((*timed, notes[right]))
|
||||
viable = [phrase]
|
||||
for pattern in dict.fromkeys(patterns):
|
||||
# Fixed events retain their duration and attack identity as well as pitch.
|
||||
if any(not any((n.event_id, n.start, n.end) == (p.event_id, p.start, p.end) for p in pattern) for n in notes[left:right + 1] if n.event_id in protected):
|
||||
continue
|
||||
for route in generator.solo.routes.candidates(pattern, protected, harmony, sequences, phrase.plan.register, 'approach the theme destination'):
|
||||
if phrase.draft.lead_identity:
|
||||
# A run must actually contain connected eighth-note movement;
|
||||
# relabeling the unchanged quarter-note answer is not elaboration.
|
||||
run = peak = 0
|
||||
previous_end = None
|
||||
for note in route.notes:
|
||||
eighth = note.end - note.start == fractions.Fraction(1, 2)
|
||||
run = run + 1 if eighth and previous_end == note.start else int(eighth)
|
||||
peak = max(peak, run)
|
||||
previous_end = note.end
|
||||
if peak < 4:
|
||||
continue
|
||||
actual = (*notes[:left], *route.notes, *notes[right + 1:])
|
||||
if phrase.draft.lead_identity and len(actual) > generator.solo.policy.DEFAULT.lead_activity_range[1] * float(phrase.plan.end - phrase.plan.start):
|
||||
continue
|
||||
context.diagnostics['complete_routes_tried'] = context.diagnostics.get('complete_routes_tried', 0) + 1
|
||||
if not generator.solo.drafts.contract_valid(phrase.draft, actual)[0]:
|
||||
continue
|
||||
draft = rebase_draft(phrase.draft, actual, harmony)
|
||||
edit = generator.solo.types.DraftEdit('complete_route', low, notes[right].end, tuple(n.event_id for n in notes[left:right + 1]), tuple(n.event_id for n in route.notes), ' + '.join(route.sources))
|
||||
revised = checked_phrase(phrase.plan, draft, actual, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
if revised:
|
||||
# Retain the refined theme, not the ornamental version, as home.
|
||||
state = dataclasses.replace(revised.state, home=phrase.state.home, themes=phrase.state.themes)
|
||||
viable.append(dataclasses.replace(revised, state=state, initial=phrase.initial, refined=phrase.refined, routes=(*phrase.routes, route)))
|
||||
else:
|
||||
context.diagnostics['complete_routes_rejected'] = context.diagnostics.get('complete_routes_rejected', 0) + 1
|
||||
# A selected approach is an explicit musical operation. Its complete valid
|
||||
# routes compete with one another; a zero-edit movement advantage must not
|
||||
# silently cancel every requested run. Failure retains the plain tune.
|
||||
eligible = viable[1:] if phrase.draft.lead_identity and len(viable) > 1 else viable
|
||||
chosen = min(eligible, key=lambda p: (p.score, len(p.edits), tuple(n.pitch for n in emitted_notes(p))))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics['complete_routes_selected'] = context.diagnostics.get('complete_routes_selected', 0) + 1
|
||||
phrase = chosen
|
||||
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
|
||||
return phrase
|
||||
|
||||
@@ -674,7 +808,15 @@ def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
|
||||
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
||||
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
|
||||
before = phrase
|
||||
for join in phrase.draft.joins:
|
||||
notes = emitted_notes(phrase)
|
||||
|
||||
def weakness(join):
|
||||
before = next((n for n in reversed(notes) if n.start < join), None)
|
||||
after = next((n for n in notes if n.start >= join), None)
|
||||
return (max(0, abs(after.pitch.number() - before.pitch.number()) - 4) + float(max(0, after.start - before.end))) if before and after else 0
|
||||
|
||||
joins = sorted(phrase.draft.joins, key=lambda j: (-weakness(j), j))[:generator.solo.policy.DEFAULT.revision_joins]
|
||||
for join in joins:
|
||||
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, max(phrase.plan.start, join - width), min(phrase.plan.end, join + width), 'join', context)
|
||||
if phrase == before:
|
||||
break
|
||||
@@ -683,7 +825,7 @@ def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
|
||||
|
||||
def revise_pair(first, second, incoming, choices, sequences, tempo, context):
|
||||
"""Revise an uncommitted connected phrase seam, then rebuild both states."""
|
||||
if not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
|
||||
if first.routes or second.routes or first.refined or second.refined or not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
|
||||
return first, second
|
||||
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
@@ -718,12 +860,14 @@ def revise_pair(first, second, incoming, choices, sequences, tempo, context):
|
||||
|
||||
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
|
||||
context = context if context is not None else SearchContext()
|
||||
if plan.treatment == 'lead':
|
||||
raise ValueError('generic lead identities are retired; use composer.compose for source variations')
|
||||
# Keep resources alive while identity-keyed cache entries exist. A later
|
||||
# library cannot accidentally reuse the identity of a collected object.
|
||||
context.resources[id(sequences)] = sequences
|
||||
context.resources[id(rhythms)] = rhythms
|
||||
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms))
|
||||
key = (*pool_key, context.revise)
|
||||
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms), tuple(r.version for r in context.references))
|
||||
key = (*pool_key, context.revise, context.stage)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits'] += 1
|
||||
return context.results[key]
|
||||
@@ -736,10 +880,11 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
|
||||
selected = context.pools[pool_key]
|
||||
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
|
||||
else:
|
||||
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch)
|
||||
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch, context.references)
|
||||
complete, failure = [], ''
|
||||
for rhythm in constructions:
|
||||
paths, error = draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context)
|
||||
candidate_plan = generator.solo.drafts.ending_plan(plan, rhythm.anchors)
|
||||
paths, error = draft_pitch_paths(rhythm, candidate_plan, choices, harmony, sequences, incoming, context)
|
||||
failure = error or failure
|
||||
context.diagnostics['generated_candidates'] += len(paths)
|
||||
for notes, cost, contracts in paths:
|
||||
@@ -750,29 +895,33 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
|
||||
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors):
|
||||
realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
|
||||
for actual in realizations:
|
||||
draft = make_draft(rhythm, actual, plan, harmony, incoming)
|
||||
phrase = checked_phrase(plan, draft, actual, contracts, incoming, harmony, tempo)
|
||||
draft = make_draft(rhythm, actual, candidate_plan, harmony, incoming)
|
||||
phrase = checked_phrase(candidate_plan, draft, actual, contracts, incoming, harmony, tempo)
|
||||
if phrase:
|
||||
complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost))
|
||||
complete.append(phrase)
|
||||
if not complete:
|
||||
raise SearchExhausted(f'{plan.id}: bounded phrase draft search exhausted; {failure or "no complete draft satisfies timing, anchors, treatments and motif contracts"}; {len(constructions)} constructions / {BEAM_WIDTH} pitch states (not proof that the phrase is unplayable)')
|
||||
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
|
||||
selected = generator.solo.sequences.diverse(complete, lambda p: (p.score, p.draft.id, tuple(n.pitch for n in p.draft.notes)), lambda p: (p.draft.relationship, note_family(p.draft.notes), p.anchors[-1].duration if p.anchors else None), generator.solo.policy.DEFAULT.phrase_alternatives)
|
||||
selected = tuple(elaborate(p, incoming, harmony, sequences, tempo, context) for p in selected)
|
||||
context.pools[pool_key] = selected
|
||||
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
|
||||
pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
|
||||
context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
|
||||
result = []
|
||||
for phrase in selected:
|
||||
if context.revise:
|
||||
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
|
||||
for index, phrase in enumerate(selected):
|
||||
if context.stage == 'initial':
|
||||
phrase = dataclasses.replace(phrase, initial=phrase.draft)
|
||||
else:
|
||||
phrase = refine(phrase, selected, incoming, harmony, sequences, tempo, context, search=index < generator.solo.policy.DEFAULT.refinement_alternatives)
|
||||
if context.stage == 'elaborated':
|
||||
phrase = elaborate(phrase, incoming, harmony, sequences, tempo, context)
|
||||
result.append(phrase)
|
||||
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
|
||||
for phrase in result:
|
||||
family = note_family(emitted_notes(phrase))
|
||||
counts = context.diagnostics['surviving_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
context.diagnostics.setdefault('complete_ideas', {})[input_id] = [{'id': p.draft.id, 'reference': p.draft.reference, 'score': round(p.score, 6), 'attacks': len(emitted_notes(p)), 'ending': p.plan.ending, 'hold': str(p.anchors[-1].duration) if p.anchors else '', 'edits': [e.kind for e in p.edits]} for p in result]
|
||||
context.results[key] = result
|
||||
return result
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Accepted, paired pitch/rhythm examples; production uses contextual profiles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.types as types
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ReferencePhrase:
|
||||
id: str
|
||||
version: str
|
||||
notes: tuple[types.Note, ...]
|
||||
decorated: tuple[types.Note, ...]
|
||||
duration: F
|
||||
tonic: int
|
||||
harmony: tuple[tuple[F, str], ...]
|
||||
features: tuple[tuple[str, F, F], ...]
|
||||
runs: tuple[tuple[F, F], ...]
|
||||
acceptance: str
|
||||
development: tuple[tuple[str, F, F, tuple[int, ...]], ...] = ()
|
||||
def windows(self):
|
||||
# Windows retain their parent and context; they are not separate approvals.
|
||||
result = []
|
||||
for length in (F(8), F(16)):
|
||||
for start in range(0, int(self.duration), int(length)):
|
||||
end = min(self.duration, start + length)
|
||||
selected = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in self.notes if start <= n.start and n.end <= end)
|
||||
if len(selected) >= 2:
|
||||
chords = tuple(chord for time, chord in self.harmony if start <= time < end)
|
||||
result.append(types.Motif(f'{self.id}:{start}:{end}', selected, end - start, chords, 'accepted reference profile'))
|
||||
return tuple(result)
|
||||
|
||||
def read(path):
|
||||
raw = path.read_bytes()
|
||||
data = json.loads(raw)
|
||||
if data['pitch_convention'] != 'sounding' or not data.get('acceptance'):
|
||||
raise ValueError(f'{path}: reference requires sounding pitches and explicit acceptance provenance')
|
||||
|
||||
def notes(variant):
|
||||
result, cursor = [], F()
|
||||
for i, event in enumerate(data['variants'][variant]['events']):
|
||||
start, duration = F(event['onset_beats']), F(event['duration_beats'])
|
||||
if start != cursor or duration <= 0:
|
||||
raise ValueError(f'{path}: reference events must fill exact sequential time')
|
||||
cursor = start + duration
|
||||
if event['pitch'] is None:
|
||||
continue
|
||||
name = event['pitch']
|
||||
pitch = types.Pitch(name[0].lower(), F(1 if '#' in name else -1 if 'b' in name else 0), int(name[-1]))
|
||||
if pitch.number() != event['midi_pitch']:
|
||||
raise ValueError(f'{path}: inconsistent sounding pitch {name}')
|
||||
result.append(types.Note(start, cursor, pitch, (data['id'],), event_id=f'{variant}_{i}'))
|
||||
return tuple(result), cursor
|
||||
|
||||
plain, duration = notes('lead_theme')
|
||||
decorated, other_duration = notes('lead_with_runs')
|
||||
if duration != other_duration or len(plain) != data['variants']['lead_theme']['attacks'] or len(decorated) != data['variants']['lead_with_runs']['attacks']:
|
||||
raise ValueError(f'{path}: inconsistent reference variants')
|
||||
tonic = generator.model.parse_pitch_name(data['tonic'])
|
||||
number = int(generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter']))
|
||||
harmony = tuple((F(4 * (h['bar'] - 1)), h['chord']) for h in data['harmony'])
|
||||
for _, chord in harmony:
|
||||
generator.harmony.parse_symbol(chord)
|
||||
return ReferencePhrase(data['id'], hashlib.sha256(raw).hexdigest(), plain, decorated, duration, number, harmony,
|
||||
tuple((f['id'], F(f['start']), F(f['end'])) for f in data['features']), tuple((F(w['start']), F(w['end'])) for w in data['run_windows']), data['acceptance'],
|
||||
tuple((d['role'], F(d['start']), F(d['end']), tuple(d.get('degrees', ()))) for d in data.get('development', ())))
|
||||
|
||||
def load_library(root: pathlib.Path):
|
||||
return tuple(read(path) for path in sorted(root.glob('*.json')))
|
||||
|
||||
def identifying_features(notes, source):
|
||||
"""Select a characteristic leap followed by return, wherever it occurs."""
|
||||
notes = tuple(notes)
|
||||
candidates = []
|
||||
for i in range(len(notes) - 2):
|
||||
a, b, c = notes[i:i + 3]
|
||||
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
|
||||
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
|
||||
group = notes[i:min(len(notes), i + 4)]
|
||||
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
|
||||
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
|
||||
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
|
||||
return tuple(candidates[:1])
|
||||
|
||||
def feature_error(feature, notes):
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
actual = [by_id.get(identity) for identity in feature.events]
|
||||
if not actual or not all(actual):
|
||||
return 1.0
|
||||
first = actual[0].start
|
||||
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
|
||||
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
|
||||
errors = []
|
||||
if 'rhythm' in feature.preserves:
|
||||
errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm)))
|
||||
if 'intervals' in feature.preserves or 'contour' in feature.preserves:
|
||||
if len(movement) != len(feature.intervals) or not movement and feature.intervals:
|
||||
errors.append(1.0)
|
||||
else:
|
||||
errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement)))
|
||||
if 'exact_intervals' in feature.preserves:
|
||||
errors.append(float(movement != feature.intervals))
|
||||
if 'pitches' in feature.preserves:
|
||||
errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches))
|
||||
return max(errors,default=0.0)
|
||||
|
||||
def relationship(notes, reference):
|
||||
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
|
||||
notes = tuple(notes)
|
||||
if len(notes) < 2 or len(reference.notes) < 2:
|
||||
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
|
||||
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
|
||||
expected = reference.contour()
|
||||
count = min(len(actual), len(expected))
|
||||
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
|
||||
start, ref_start = notes[0].start, reference.notes[0].start
|
||||
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
|
||||
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
|
||||
return max(evidence, (contour + rhythm) / 2)
|
||||
@@ -12,6 +12,8 @@ import generator.scales.timeline
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.drafts
|
||||
import generator.solo.similarity
|
||||
import generator.solo.references
|
||||
|
||||
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')
|
||||
@@ -20,6 +22,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
if solo.diagnostics:
|
||||
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True))
|
||||
detail = structure(solo)
|
||||
lines.append("Sustained phrasing: " + json.dumps(detail["phrasing"], sort_keys=True))
|
||||
for passage in detail['passages']:
|
||||
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
|
||||
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
|
||||
@@ -27,9 +30,20 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
plan = phrase.plan
|
||||
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
|
||||
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
|
||||
if plan.treatment in ('source_variation', 'thematic_recomposition'):
|
||||
lines.append(' Activity preference: 4–6 attacks per four beats; source phrasing takes precedence. Occupancy is diagnostic, not a target.')
|
||||
if phrase.draft:
|
||||
draft = phrase.draft
|
||||
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
|
||||
lines.append(f' Lead reference: {draft.reference}; selected features: ' + ', '.join(f.id for f in draft.features))
|
||||
if draft.source_phrase:
|
||||
lines.append(f' Source phrase {draft.source_phrase.id}; octave transfer {draft.octave_transfer:+}; source rests {draft.source_phrase.rests}; source-event links {draft.source_links}')
|
||||
if draft.source_reference:
|
||||
ref=draft.source_reference
|
||||
lines.append(f' Source influence {ref.id}: {ref.treatment}; source {ref.source_id}, beats {ref.start}-{ref.end}. Later developments refer to solo theme {phrase.state.home.id}.')
|
||||
lines.append(' Retained contracts: '+', '.join(f'{f.id}: {f.preserves}' for f in draft.features))
|
||||
for route in phrase.routes:
|
||||
lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}')
|
||||
for transform in draft.transformations:
|
||||
lines.append(f' Development {transform.id}: {transform.operation}; {transform.start}-{transform.end}; parent {transform.parent}; home {transform.home}; preserves {transform.preserves}')
|
||||
for edit in phrase.edits:
|
||||
@@ -92,5 +106,80 @@ def structure(solo):
|
||||
entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])}
|
||||
if phrase.draft:
|
||||
entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
|
||||
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft}
|
||||
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
|
||||
if phrase.initial and phrase.refined:
|
||||
# A source hold may already be sounding at this generated phrase boundary.
|
||||
# Detach its clipped entrance for the local comparison only; the full
|
||||
# source comparison below still merges its original tied continuations.
|
||||
local_source=generator.solo.drafts.local_sounding(phrase.initial.notes)
|
||||
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source)
|
||||
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
|
||||
if phrase.state and phrase.state.home:
|
||||
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
|
||||
entry['score'] = phrase.score
|
||||
phrases.append(entry)
|
||||
return {'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, 'harmonic_roles': harmony.describe(solo.notes(), contracts), '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 {'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, 'harmonic_roles': harmony.describe(solo.notes(), contracts), '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):
|
||||
import collections
|
||||
from generator.solo import phrasing
|
||||
notes=solo.notes()
|
||||
figures=[phrasing.heard(notes,'chorus')] if notes else []
|
||||
exact=collections.Counter();relative=collections.Counter()
|
||||
for figure in figures:
|
||||
# Count occurrences, not just unique vocabulary within each phrase.
|
||||
for i in range(len(figure.events)-3):
|
||||
group=figure.events[i:i+4];start,_,pitch=group[0]
|
||||
exact[tuple((a-start,d,p) for a,d,p in group)]+=1
|
||||
relative[tuple((a-start,d,p-pitch) for a,d,p in group)]+=1
|
||||
endings=[];regions=[];appearances=[]
|
||||
for phrase in solo.phrases:
|
||||
local=tuple(n for d in phrase.decisions for n in d.notes)
|
||||
for anchor in phrase.anchors:
|
||||
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
|
||||
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(),
|
||||
'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})
|
||||
if local:
|
||||
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
|
||||
'low':min(n.pitch.number() for n in local),'high':max(n.pitch.number() for n in local),
|
||||
'duration_weighted_mean':sum(n.pitch.number()*float(n.end-n.start) for n in local)/sum(float(n.end-n.start) for n in local)})
|
||||
if phrase.draft:
|
||||
for transform,feature in zip(phrase.draft.transformations,phrase.draft.features):
|
||||
appearances.append({'start':str(transform.start),'operation':transform.operation,'home':transform.home,
|
||||
'preserves':list(feature.preserves),'feature_source':feature.source})
|
||||
activity={}
|
||||
for intent in ('active','sustained_arrival','space'):
|
||||
spans=[s for p in solo.phrases for s in p.plan.passages if s.intent==intent]
|
||||
duration=sum(float(s.end-s.start) for s in spans)
|
||||
attacks=sum(any(s.start<=n.start<s.end for s in spans) for n in notes)
|
||||
occupancy=sum(float(max(0,min(n.end,s.end)-max(n.start,s.start))) for n in notes for s in spans)
|
||||
activity[intent]={'beats':duration,'attacks':attacks,'attacks_per_four_beats':4*attacks/duration if duration else 0,
|
||||
'occupancy':occupancy/duration if duration else 0}
|
||||
return {'completed_sustained_endings':sum(e['completed'] and fractions.Fraction(e['sounding_beats'])>=2 for e in endings),
|
||||
'endings':endings,'appearances':appearances,'clear_returns':sum(a['operation']=='return' for a in appearances),
|
||||
'exact_four_note_recurrences':sum(max(0,n-1) for n in exact.values()),
|
||||
'interval_rhythm_four_note_recurrences':sum(max(0,n-1) for n in relative.values()),
|
||||
'activity':activity,'register_regions':regions}
|
||||
|
||||
|
||||
def fretboard_report(solo):
|
||||
import collections
|
||||
import generator.fretboard
|
||||
notes=solo.notes()
|
||||
attacks=collections.Counter(); durations=collections.Counter(); forced=optional=0
|
||||
for note in notes:
|
||||
string=note.position.string_number
|
||||
attacks[string]+=1;durations[string]+=float(note.end-note.start)
|
||||
if string==1:
|
||||
if len(generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())))==1:forced+=1
|
||||
else:optional+=1
|
||||
total=sum(durations.values())
|
||||
return {'attacks_by_string':dict(sorted(attacks.items())),
|
||||
'duration_fraction_by_string':{s:d/max(.001,total) for s,d in sorted(durations.items())},
|
||||
'first_string_forced':forced,'first_string_optional':optional,
|
||||
'range_midi': [min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.span.start for c in solo.choices if c.span.beat == 1}) for n in notes)}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Instantiate complete authored routes; a failed path is never repaired note by note."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.fretboard
|
||||
import generator.solo.placement
|
||||
import generator.solo.policy
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types as types
|
||||
|
||||
def advance(scale, previous, step):
|
||||
try:
|
||||
degree = types.scale_index(scale, previous)
|
||||
return tuple([types.scale_pitch(scale, degree + step)])
|
||||
except ValueError:
|
||||
# At a changed collection count actual intervening scale tones from the
|
||||
# incoming sound. No old degree number is transferred to the new scale.
|
||||
if not step:
|
||||
return ()
|
||||
nearby = sorted((types.scale_pitch(scale, i) for i in range(-28, 35)), key=lambda p: p.number())
|
||||
outgoing = [p for p in nearby if (p.number() - previous) * step > 0]
|
||||
if step < 0:
|
||||
outgoing.reverse()
|
||||
return tuple(outgoing[abs(step) - 1:abs(step)])
|
||||
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme'):
|
||||
# Cache concrete routes only with their endpoints, timing, scale context and
|
||||
# vocabulary. The per-Harmony cache cannot leak across collection changes.
|
||||
if not hasattr(harmony,'route_cache'):
|
||||
harmony.route_cache={}
|
||||
harmony.route_libraries={}
|
||||
harmony.concrete_routes={}
|
||||
harmony.route_libraries[id(library)]=library
|
||||
concrete_key=(notes,frozenset(fixed),id(library),register)
|
||||
if concrete_key in harmony.concrete_routes:
|
||||
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
|
||||
# feasibility. Cache the pitch work across different thematic histories,
|
||||
# then restore this caller's provenance before ranking complete routes.
|
||||
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,
|
||||
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)
|
||||
if key not in harmony.route_cache:
|
||||
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
|
||||
result=[]
|
||||
for route in harmony.route_cache[key]:
|
||||
actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None)
|
||||
for i,(n,r) in enumerate(zip(notes,route.notes)))
|
||||
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16]
|
||||
result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
|
||||
selected=generator.solo.sequences.diverse(result,
|
||||
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
|
||||
lambda r:generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),
|
||||
generator.solo.policy.DEFAULT.join_alternatives)
|
||||
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes)))
|
||||
if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
|
||||
harmony.concrete_routes[concrete_key]=selected
|
||||
return selected
|
||||
|
||||
def _candidates(notes, fixed, harmony, library, register):
|
||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||
|
||||
Callers validate the whole surrounding sounding context and guitar path.
|
||||
This function applies exact scale paths, fixed events and range constraints.
|
||||
"""
|
||||
if not 2 <= len(notes) <= 16:
|
||||
return ()
|
||||
first, destination = notes[0], notes[-1]
|
||||
span = harmony.at(first.start)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None,
|
||||
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))
|
||||
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
|
||||
key=(first.pitch,contexts,id(library),register)
|
||||
if key not in harmony.pitch_routes:
|
||||
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register)
|
||||
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())
|
||||
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
|
||||
result=[]
|
||||
for pitches,sources in harmony.pitch_routes[key]:
|
||||
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None)
|
||||
for n,p in zip(notes[1:],pitches[1:])))
|
||||
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
|
||||
result.append(types.RouteCandidate(identity,realized,destination,route_spans,'connect theme',sources))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def pitch_paths(first, contexts, library, register):
|
||||
"""Time-independent pitch work; context includes every overlapped collection.
|
||||
|
||||
Sounding durations and chord roles are still validated on each concrete
|
||||
realization. Equal collection contexts can reuse paths across rhythmic edits.
|
||||
"""
|
||||
destination=contexts[-1][2]
|
||||
try:
|
||||
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
|
||||
displacements=(displacement,)
|
||||
except ValueError:
|
||||
displacements=tuple(range(-7,8))
|
||||
direction=(destination>first.number())-(destination<first.number())
|
||||
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))
|
||||
result=[]
|
||||
for path in paths:
|
||||
realized=[first]
|
||||
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
|
||||
if not scale:break
|
||||
options=advance(scale,realized[-1].number(),step)
|
||||
if not options:break
|
||||
pitch=options[0];number=pitch.number()
|
||||
if not low<=number<=high or number%12 not in allowed:break
|
||||
if fixed is not None and number!=fixed:break
|
||||
if not generator.solo.placement.positions(number):break
|
||||
realized.append(pitch)
|
||||
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
|
||||
return tuple(result)
|
||||
@@ -78,9 +78,22 @@ class Evaluation:
|
||||
for start, end, active, length, expected, occupied_goal, tolerance in self.windows:
|
||||
attacks = sum(start <= a < end and any(x <= a < y for x, y in active) for a, _, _ in sounded)
|
||||
occupied = sum(max(0, min(b, y) - max(a, x)) for a, b, _ in sounded for x, y in active)
|
||||
deviation = max(0, abs(attacks - expected) - tolerance)
|
||||
deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration))
|
||||
activity += length * min(1, deviation / max(1, expected))
|
||||
occupancy += length * abs(occupied / length - occupied_goal)
|
||||
occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
|
||||
if self.plan.treatment in ('lead', 'source_variation', 'thematic_recomposition'):
|
||||
# One broad range over the complete thought; quiet bars are not failed
|
||||
# local quotas. Missing harmony and explicit release are excluded here.
|
||||
active = tuple(window for window in self.windows if window[4] > 0)
|
||||
length = sum(w[3] for w in active)
|
||||
attacks = sum(any(a <= onset < b for w in active for a, b in w[2]) for onset, _, _ in sounded)
|
||||
occupied = sum(max(0, min(end, b) - max(start, a)) for start, end, _ in sounded for w in active for a, b in w[2])
|
||||
low, high = generator.solo.policy.DEFAULT.lead_activity_range
|
||||
activity = length * corridor(attacks / max(length, .001), low, high) / high
|
||||
low, high = generator.solo.policy.DEFAULT.lead_occupancy_range
|
||||
occupancy = length * corridor(occupied / max(length, .001), low, high)
|
||||
if self.plan.treatment in ('source_variation', 'thematic_recomposition'):
|
||||
occupancy = 0.0 # Space belongs to the source and its explicit edits.
|
||||
start, end = float(self.plan.start), float(self.until)
|
||||
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
|
||||
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Duration-aware comparisons of realized music, independent of event labels."""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
|
||||
from generator.solo import types, policy
|
||||
|
||||
|
||||
def events(notes):
|
||||
return tuple((float(n.start),float(n.end),n.pitch.number()) for n in types.sounding_notes(notes))
|
||||
|
||||
|
||||
def occupancy(left, right, modulo=False):
|
||||
"""Intersection of pitch functions; repeated attacks cannot dilute a hold."""
|
||||
i=j=0; total=0.
|
||||
while i<len(left) and j<len(right):
|
||||
a,b=left[i],right[j]
|
||||
same = a[2]%12==b[2]%12 if modulo else a[2]==b[2]
|
||||
if same:
|
||||
total+=max(0.,min(a[1],b[1])-max(a[0],b[0]))
|
||||
if a[1]<=b[1]:i+=1
|
||||
else:j+=1
|
||||
return total
|
||||
|
||||
|
||||
def interval_match(left, right):
|
||||
"""Longest exact interval/rhythm fragment, allowing relocation/transposition."""
|
||||
def tokens(notes):
|
||||
return tuple((b[0]-a[0],a[1]-a[0],b[2]-a[2]) for a,b in zip(notes,notes[1:]))
|
||||
a,b=tokens(left),tokens(right); previous={}; best=(0.,0)
|
||||
for i,x in enumerate(a):
|
||||
current={}
|
||||
for j,y in enumerate(b):
|
||||
if x!=y:continue
|
||||
count=previous.get(j-1,0)+1
|
||||
current[j]=count
|
||||
# The last note's duration also belongs to the matching fragment.
|
||||
tail=min(left[i+1][1]-left[i+1][0],right[j+1][1]-right[j+1][0])
|
||||
duration=left[i+1][0]+tail-left[i+1-count][0]
|
||||
best=max(best,(duration,count+1))
|
||||
previous=current
|
||||
return best
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def compare_events(candidate, source):
|
||||
denominator=sum(b-a for a,b,_ in source)
|
||||
if not denominator:
|
||||
return dict(pitch_time=0.,octave_insensitive=0.,attack_agreement=0.,arrival_agreement=0.,
|
||||
interval_rhythm_coverage=0.,longest_fragment_beats=0.,longest_fragment_attacks=0)
|
||||
length,count=interval_match(candidate,source)
|
||||
attacks={(a,b,p%12) for a,b,p in candidate}
|
||||
arrivals=[n for n in source if n[1]-n[0]>=2]
|
||||
return dict(pitch_time=occupancy(candidate,source)/denominator,
|
||||
octave_insensitive=occupancy(candidate,source,True)/denominator,
|
||||
attack_agreement=sum((a,b,p%12) in attacks for a,b,p in source)/max(1,len(source)),
|
||||
arrival_agreement=sum((a,b,p%12) in attacks for a,b,p in arrivals)/max(1,len(arrivals)),
|
||||
interval_rhythm_coverage=min(1.,length/denominator),longest_fragment_beats=length,longest_fragment_attacks=count)
|
||||
|
||||
|
||||
def compare(candidate, source):
|
||||
return compare_events(events(candidate),events(source))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def copy_evidence(candidate, source, start, end):
|
||||
"""Scan source-aligned windows, without an added-event denominator advantage."""
|
||||
minimum=policy.DEFAULT.theme_copy_beats
|
||||
threshold=policy.DEFAULT.theme_copy_threshold
|
||||
fragment,count=interval_match(candidate,source)
|
||||
copied=fragment>=minimum and count>=policy.DEFAULT.theme_copy_attacks
|
||||
best=0.; best_offset=0.
|
||||
if end-start>=minimum and candidate:
|
||||
windows=sorted({start,*(a for a,_,_ in candidate if start<=a<=end-minimum)})
|
||||
for left in windows:
|
||||
right=min(end,left+minimum)
|
||||
offsets={a[0]-b[0] for a in candidate if left<=a[0]<left+2 for b in source}
|
||||
for offset in sorted(offsets):
|
||||
window=tuple((max(left,a+offset),min(right,b+offset),p) for a,b,p in source if a+offset<right and b+offset>left)
|
||||
count_in=sum(left<=a+offset<right for a,_,_ in source)
|
||||
duration=sum(b-a for a,b,_ in window)
|
||||
if count_in<policy.DEFAULT.theme_copy_attacks or duration<minimum/2:
|
||||
continue
|
||||
score=occupancy(candidate,window,True)/duration
|
||||
if score>best:best,best_offset=score,offset
|
||||
if score>=threshold:copied=True
|
||||
return {'near_copy':copied,'aligned_occupancy':best,'alignment_offset':best_offset,
|
||||
'longest_fragment_beats':fragment,'longest_fragment_attacks':count}
|
||||
|
||||
|
||||
def near_copy(notes, source, start, end):
|
||||
return copy_evidence(events(notes),events(source),float(start),float(end))
|
||||
@@ -0,0 +1,470 @@
|
||||
"""Thematic transformations used by the existing complete-draft composer.
|
||||
|
||||
Source features initialize a heard theme. Subsequent complete routes develop
|
||||
that realization, conditioned on the actual incoming musical state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
import generator.harmony
|
||||
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing
|
||||
|
||||
|
||||
def source_notes(analysis):
|
||||
return tuple(dataclasses.replace(n,start=max(F(),n.start-analysis.body_offset),end=n.end-analysis.body_offset,
|
||||
event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset)
|
||||
|
||||
|
||||
|
||||
def silence(source,incoming,part,harmony,tempo,seed):
|
||||
from generator.solo import realization
|
||||
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(),
|
||||
(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)
|
||||
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
|
||||
if phrase is None:return ()
|
||||
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
|
||||
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
|
||||
original=dataclasses.replace(draft,notes=source.notes)
|
||||
return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),)
|
||||
|
||||
|
||||
def short_fragment(source,incoming,part,harmony,tempo,seed,final):
|
||||
"""An explicitly reduced pickup around missing harmony; not a new theme."""
|
||||
from generator.solo import leads,realization
|
||||
reference=incoming.home.notes if incoming.home else source.notes
|
||||
if not reference:return ()
|
||||
template=dataclasses.replace(reference[0],start=source.start,end=source.end,position=None,continuation=False,event_id=source.id+':reduction')
|
||||
result=[]
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
|
||||
if phrase:
|
||||
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
|
||||
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
|
||||
result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def seeds(analysis, bar, seed):
|
||||
melody=tuple(dataclasses.replace(n,start=n.start-analysis.body_offset,end=n.end-analysis.body_offset,
|
||||
event_id=n.sources[0]) for n in analysis.melody)
|
||||
groups=[]
|
||||
for i in range(len(melody)):
|
||||
for count in ((len(melody),) if len(melody)<3 else (3,4,5)):
|
||||
group=melody[i:i+count]
|
||||
if len(group)!=count or group[-1].end-group[0].start>max(bar,F(3)) or group[0].start>=8*bar:
|
||||
continue
|
||||
local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group)
|
||||
parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)<s.end),'pickup')
|
||||
for treatment in ('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval'):
|
||||
identity=policy.stable_rank(seed,(group,treatment))[:12]
|
||||
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
|
||||
def rank(ref):
|
||||
rhythm={(n.start,n.end-n.start) for n in ref.notes}
|
||||
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
|
||||
return (-len(intervals),-len(rhythm),ref.start,ref.id)
|
||||
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
|
||||
|
||||
|
||||
def feature(reference, actual, *, treatment=None):
|
||||
treatment=treatment or reference.treatment
|
||||
required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',),
|
||||
'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',),
|
||||
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
|
||||
'extend':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'),
|
||||
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
|
||||
'develop_contour':('intervals',), 'continue':('contour',)}[treatment]
|
||||
original=reference.notes
|
||||
origin=original[0].start
|
||||
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
|
||||
return types.IdentifyingFeature(reference.id+':'+treatment,tuple(n.event_id for n in actual),
|
||||
tuple((n.start-origin,n.end-n.start) for n in original),
|
||||
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(original,original[1:])),reference.id,required,
|
||||
tuple(n.pitch.number()+octave for n in original))
|
||||
|
||||
|
||||
def rhythm_windows(library,meter,length,context):
|
||||
from generator.solo import drafts
|
||||
key=('theme-rhythm',id(library),meter,length)
|
||||
if key not in context.pools:
|
||||
patterns={}
|
||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
if length>bar:
|
||||
# The two-bar catalog contains quarter-note masks. Include combinations
|
||||
# of the richer one-bar material as complete timing alternatives, rather
|
||||
# than accidentally excluding eighths/dotted values by snippet length.
|
||||
# Pitch paths and feature contracts span the assembled rhythm's seams.
|
||||
split=bar
|
||||
for left,left_id in rhythm_windows(library,meter,split,context):
|
||||
for right,right_id in rhythm_windows(library,meter,length-split,context):
|
||||
combined=(*left,*((a+split,b+split) for a,b in right))
|
||||
if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id)
|
||||
for identifier,pattern,total in drafts.catalog(library,meter):
|
||||
if total<length:continue
|
||||
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
||||
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
|
||||
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
|
||||
if selected and len(selected)<=16:patterns.setdefault(selected,identifier)
|
||||
context.pools[key]=sequences.diverse(tuple(patterns.items()),
|
||||
lambda x:(not .7*float(length)<=len(x[0])<=1.8*float(length),policy.stable_rank('rhythm-window',x[0])),
|
||||
lambda x:(len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
|
||||
return context.pools[key]
|
||||
|
||||
|
||||
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None):
|
||||
length=finish-start; values={}
|
||||
if home and operation in ('repeat_with_harmonic_adaptation','return'):
|
||||
pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length)
|
||||
if pattern:values[pattern]='heard-theme'
|
||||
prefix=tuple((n.start,n.end) for n in ref.notes)
|
||||
fixed_rhythm=ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('fragment','repeat_with_harmonic_adaptation','return','develop_rhythm')
|
||||
if fixed_rhythm:
|
||||
duration=prefix[-1][1]
|
||||
if duration==length:values[prefix]=ref.id
|
||||
if duration<length:
|
||||
for tail,label in rhythm_windows(rhythms,meter,length-duration,context):
|
||||
values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label
|
||||
else:
|
||||
values.update(rhythm_windows(rhythms,meter,length,context))
|
||||
if home and operation=='answer':
|
||||
for p,label in rhythm_windows(rhythms,meter,length,context):values.setdefault(p,label)
|
||||
if not fixed_rhythm:
|
||||
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted
|
||||
# alternatives are actual contracts, not failed copies of a rhythmic seed.
|
||||
for original,label in tuple(values.items()):
|
||||
for i,(a,b) in enumerate(original):
|
||||
if b-a==1:
|
||||
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
|
||||
for i,((a,b),(c,d)) in enumerate(zip(original,original[1:])):
|
||||
if b!=c:continue
|
||||
for split in (a+F(1,2),a+F(3,2),d-F(1,2)):
|
||||
if a<split<d:
|
||||
values[(*original[:i],(a,split),(split,d),*original[i+2:])]=label+'; rhythmic alteration'
|
||||
if ending:
|
||||
arrivals={}
|
||||
arrival=ending.earliest-start
|
||||
for original,label in values.items():
|
||||
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
|
||||
if len(head)>=len(ref.notes):
|
||||
arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close'
|
||||
values=arrivals
|
||||
return sequences.diverse(tuple(values.items()),lambda x:(len(x[0])>16,policy.stable_rank(ref.id,x[0])),
|
||||
lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives)
|
||||
|
||||
|
||||
def pitches(harmony,note,base,*,members=False,limit=6):
|
||||
from generator.solo.leads import available
|
||||
domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2)
|
||||
return sequences.diverse(domain,lambda p:(abs(p.number()-base),p.number()),
|
||||
lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit)
|
||||
|
||||
|
||||
def relative_steps(notes,harmony):
|
||||
result=[]
|
||||
for a,b in zip(notes,notes[1:]):
|
||||
scale=harmony.at(b.start).scale
|
||||
try:step=types.scale_index(scale,b.pitch.number())-types.scale_index(scale,a.pitch.number())
|
||||
except ValueError:
|
||||
lo,hi=sorted((a.pitch.number(),b.pitch.number()))
|
||||
intervening=range(lo+1,hi+1) if b.pitch.number()>a.pitch.number() else range(lo,hi)
|
||||
count=sum(p%12 in scale.pitches for p in intervening)
|
||||
step=count if b.pitch.number()>a.pitch.number() else -count
|
||||
result.append(step)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def instantiate(templates,first,steps,harmony):
|
||||
result=[dataclasses.replace(templates[0],pitch=first)]
|
||||
for note,step in zip(templates[1:],steps):
|
||||
span=harmony.at(note.start)
|
||||
if not span or not span.scale:return ()
|
||||
options=routes.advance(span.scale,result[-1].pitch.number(),step)
|
||||
if not options:return ()
|
||||
p=options[0]
|
||||
if not 41<=p.number()<=76 or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
|
||||
result.append(dataclasses.replace(note,pitch=p))
|
||||
return tuple(result) if len(result)==len(templates) else ()
|
||||
|
||||
|
||||
def prefix_paths(templates,ref,first,harmony,library):
|
||||
if not hasattr(harmony,'theme_prefixes'):harmony.theme_prefixes={}
|
||||
key=(templates[:len(ref.notes)],ref,first,id(library))
|
||||
if key not in harmony.theme_prefixes:
|
||||
harmony.theme_prefixes[key]=_prefix_paths(templates,ref,first,harmony,library)
|
||||
return harmony.theme_prefixes[key]
|
||||
|
||||
|
||||
def _prefix_paths(templates,ref,first,harmony,library):
|
||||
count=len(ref.notes); original=ref.notes
|
||||
if count==1:return ((dataclasses.replace(templates[0],pitch=first),),)
|
||||
if ref.treatment in ('opening_fragment','characteristic_interval','callback'):
|
||||
delta=first.number()-original[0].pitch.number()
|
||||
if ref.treatment in ('opening_fragment','callback') and delta%12:return ()
|
||||
result=[]
|
||||
from generator.solo.leads import available
|
||||
for template,n in zip(templates,original):
|
||||
target=n.pitch.number()+delta
|
||||
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==target),None)
|
||||
if p is None:return ()
|
||||
result.append(dataclasses.replace(template,pitch=p))
|
||||
return (tuple(result),)
|
||||
paths=[]
|
||||
end_template=templates[count-1]
|
||||
source_motion=original[-1].pitch.number()-original[0].pitch.number()
|
||||
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]))
|
||||
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,(41,76),'seed '+ref.id))
|
||||
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)),
|
||||
lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Unit:
|
||||
notes: tuple[types.Note,...]
|
||||
feature: types.IdentifyingFeature
|
||||
reference: types.SourceReference
|
||||
operation: str
|
||||
anchor: types.Anchor
|
||||
score: float
|
||||
source: str
|
||||
routes: tuple[types.RouteCandidate,...] = ()
|
||||
|
||||
|
||||
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.
|
||||
# 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)
|
||||
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||
result=[]; count=len(ref.notes)
|
||||
jobs=[]
|
||||
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal,ending=ending):
|
||||
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),
|
||||
'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()
|
||||
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)
|
||||
for first in firsts:
|
||||
for target in targets:
|
||||
# Rank joint jobs, retaining rhythm and destination coverage before
|
||||
# expensive path instantiation. The budget is spent on complete routes.
|
||||
rank=max(0,abs(first.number()-base)-5)/12+abs(target.number()-(goal if goal is not None else base))/48
|
||||
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]))))
|
||||
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set()
|
||||
for job in jobs:
|
||||
rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number()
|
||||
if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen:
|
||||
selected.append(job);rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry)
|
||||
job_limit=policy.DEFAULT.theme_route_jobs*(batch+1)
|
||||
for job in jobs:
|
||||
if len(selected)>=job_limit:break
|
||||
if job not in selected:selected.append(job)
|
||||
for _,templates,label,first,target in selected[:job_limit]:
|
||||
proposed=[]
|
||||
if home and operation in ('repeat_with_harmonic_adaptation','return') and len(templates)==len(home.notes):
|
||||
heard=instantiate(templates,first,home.degree_steps,harmony)
|
||||
if heard and heard[-1].pitch==target:proposed.append((heard,()))
|
||||
for prefix in prefix_paths(templates,ref,first,harmony,library)[:2+batch]:
|
||||
if len(prefix)==len(templates):
|
||||
if prefix[-1].pitch==target:proposed.append((prefix,()))
|
||||
continue
|
||||
tail=(prefix[-1],*templates[count:])
|
||||
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]:
|
||||
proposed.append(((*prefix[:-1],*route.notes),(route,)))
|
||||
seen=set()
|
||||
for actual,connections in proposed:
|
||||
identity=tuple(n.pitch.number() for n in actual)
|
||||
if identity in seen:continue
|
||||
seen.add(identity)
|
||||
core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None)
|
||||
if references.feature_error(contract,actual):continue
|
||||
last=actual[-1]
|
||||
if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True)
|
||||
if not checked.valid:continue
|
||||
entry=max(0,abs(first.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(actual,actual[1:]))/max(1,len(actual)-1)
|
||||
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.
|
||||
score=2*checked.exposure+entry+leaps+.35*direction
|
||||
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
|
||||
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))
|
||||
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
||||
selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
||||
lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number(),tuple(n.end-n.start for n in u.notes[:count])),policy.DEFAULT.theme_unit_alternatives)
|
||||
context.diagnostics['generated_candidates']+=len(selected)
|
||||
context.results[key]=selected
|
||||
return selected
|
||||
|
||||
|
||||
def home_reference(home,operation):
|
||||
# Prefer a distinguishing turn/leap wherever it actually occurred. The heard
|
||||
# home remains immutable; a development never becomes the new source theme.
|
||||
groups=[home.notes[i:i+3] for i in range(max(1,len(home.notes)-2))]
|
||||
def rank(group):
|
||||
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])]
|
||||
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
|
||||
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'
|
||||
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
|
||||
|
||||
|
||||
def make_home(unit,harmony,start=None,end=None):
|
||||
from generator.solo.realization import make_identity
|
||||
start=unit.notes[0].start if start is None else start
|
||||
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)
|
||||
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony))
|
||||
|
||||
|
||||
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
|
||||
from generator.solo import leads,realization
|
||||
if source.end-source.start<2:
|
||||
return short_fragment(source,incoming,part,harmony,tempo,seed,final)
|
||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
unit_length=2*bar
|
||||
solo_end=solo_end or max(c.span.end for c in harmony.choices)
|
||||
head=source_notes(analysis)
|
||||
seed_key=('source-seeds',analysis,bar,seed)
|
||||
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
|
||||
roots=context.pools[seed_key]
|
||||
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1))
|
||||
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
|
||||
cursor=source.start
|
||||
while cursor<source.end and states:
|
||||
finish=min(cursor+unit_length,source.end)
|
||||
if source.end-finish<6 or any(ending.earliest<finish for *_,ending in states):finish=source.end
|
||||
next_states=[]
|
||||
for cost,units,home,state,ending in states:
|
||||
introducing=home is None
|
||||
if introducing:
|
||||
requests=[(r,'introduce') for r in roots]
|
||||
else:
|
||||
operations=['continue'] if finish==source.end and cursor>source.start else ['develop_contour','develop_rhythm']
|
||||
if cursor==source.start:operations.append('return')
|
||||
requests=[(home_reference(home,op),op) for op in operations]
|
||||
# A uniform duration alone is not identifying musical evidence.
|
||||
requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))]
|
||||
if cursor>=8*bar and cursor==source.start and incoming.callbacks<2:
|
||||
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:1] if r.end-r.start<=bar)
|
||||
for ref,operation in requests:
|
||||
if introducing and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
|
||||
# A missing-harmony boundary can leave less than one full statement.
|
||||
# Declare a smaller contour seed; never pretend it proves the full motif.
|
||||
count=min(3,int(2*(ending.earliest-cursor)),len(ref.notes))
|
||||
if count<2:
|
||||
if len(ref.notes)!=1:continue
|
||||
n=dataclasses.replace(ref.notes[0],end=min(ref.notes[0].end,F(1),ending.earliest-cursor))
|
||||
ref=dataclasses.replace(ref,id=ref.id+':constrained_pitch',notes=(n,),treatment='opening_fragment')
|
||||
else:
|
||||
ref=dataclasses.replace(ref,id=ref.id+':constrained_contour',notes=ref.notes[:count],treatment='contour_seed')
|
||||
if operation=='continue' and state.notes:
|
||||
recent=state.notes[-min(3,len(state.notes)):]
|
||||
origin=recent[0].start
|
||||
ref=dataclasses.replace(ref,notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in recent),treatment='contour_seed')
|
||||
if ref.notes[-1].end>finish-cursor:
|
||||
reduced=tuple(n for n in ref.notes if n.end<=finish-cursor)
|
||||
if len(reduced)<2:continue
|
||||
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
|
||||
operation='continue'
|
||||
terminal=finish==source.end
|
||||
base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number()
|
||||
goal=ending.pitch.number() if terminal else round(base+(ending.pitch.number()-base)*float((finish-cursor)/(source.end-cursor)))
|
||||
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
||||
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal)
|
||||
for unit in options:
|
||||
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
|
||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||
actual_home=home or make_home(unit,harmony,cursor,finish)
|
||||
figure=phrasing.heard(unit.notes,operation,unit.anchor)
|
||||
repetition=phrasing.repetition(figure,state.history,bar)
|
||||
appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0.
|
||||
history=phrasing.remember(state.history,figure,bar)
|
||||
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref,
|
||||
history=history,returns=(*state.returns,*((cursor,) if operation=='return' else ())),
|
||||
register_peak=max(state.register_peak,*(n.pitch.number() for n in unit.notes)))
|
||||
next_states.append((cost+unit.score+.8*repetition+appearance,(*units,unit),actual_home,following,ending))
|
||||
def rank(value):
|
||||
cost,units,home,state,ending=value
|
||||
attacks=sum(len(u.notes) for u in units)
|
||||
remaining=max(F(),ending.earliest-finish)
|
||||
possible=attacks+2*float(remaining)+(0 if finish==source.end else 1)
|
||||
expected=float(source.end-source.start)
|
||||
activity_bound=.35*max(0,expected-possible,attacks-1.5*expected)/max(1,float(source.end-source.start)/4)
|
||||
return cost/max(1,len(units))+activity_bound,policy.stable_rank(seed,tuple(u.notes for u in units))
|
||||
states=sequences.diverse(next_states,rank,
|
||||
lambda v:(v[2].id,v[4].pitch.number(),v[4].duration,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
|
||||
if not states:return ()
|
||||
cursor=finish
|
||||
result=[]
|
||||
for cost,units,home,state,ending in states:
|
||||
plain=tuple(n for u in units for n in u.notes)
|
||||
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
|
||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||
features=tuple(u.feature for u in units)
|
||||
# Cell tails are not compulsory melodic arrivals. Only the selected close
|
||||
# has a sounding contract; intermediate turns remain editable.
|
||||
anchors=(ending,)
|
||||
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),)
|
||||
options=[(plain,(),())]
|
||||
if stage=='elaborated':
|
||||
for actual,edit,route in leads.elaborate(plain,features,harmony,library,rhythms,meter,source,sustain_entry=True,tails=tails):
|
||||
options.append((actual,(edit,),(route,)))
|
||||
for notes,edits,selected_routes in options:
|
||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
|
||||
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
|
||||
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
|
||||
source_ref=state.source_reference
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,(),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
||||
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
|
||||
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)
|
||||
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),
|
||||
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
|
||||
source.id,source.start,source.end,(anchors,))
|
||||
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
||||
if phrase is None:continue
|
||||
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)
|
||||
appearance_state=incoming
|
||||
for unit in units:
|
||||
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
|
||||
if unit.operation=='return':
|
||||
appearance_state=dataclasses.replace(appearance_state,returns=(*appearance_state.returns,unit.notes[0].start))
|
||||
score+=.05*phrase.state.travel/max(1,len(notes))/2000
|
||||
history=incoming.history
|
||||
for transform in transforms:
|
||||
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)
|
||||
current=dataclasses.replace(phrase.state,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)))
|
||||
if not incoming.home:
|
||||
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),
|
||||
degree_steps=relative_steps(introduced,harmony))
|
||||
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))
|
||||
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
|
||||
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)))
|
||||
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)
|
||||
@@ -57,6 +57,16 @@ class Motif:
|
||||
def contour(self) -> tuple[int, ...]:
|
||||
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SourcePhrase:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
notes: tuple[Note, ...]
|
||||
rests: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
family: str
|
||||
pickup: tuple[Note, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class TargetEvent:
|
||||
earliest: fractions.Fraction
|
||||
@@ -83,6 +93,15 @@ class HarmonicTreatment:
|
||||
resolution: int | None = None
|
||||
deadline: fractions.Fraction | None = None
|
||||
preparation: fractions.Fraction = fractions.Fraction(1, 2)
|
||||
event: str = ''
|
||||
resolution_event: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class LeadIdentity:
|
||||
id: str
|
||||
durations: tuple[fractions.Fraction, ...]
|
||||
degrees: tuple[int, ...]
|
||||
source: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Anchor:
|
||||
@@ -116,6 +135,7 @@ class RealizedMotif:
|
||||
treatment: str
|
||||
chord_degrees: tuple[tuple[int, ...], ...] = ()
|
||||
source_start: fractions.Fraction = fractions.Fraction()
|
||||
degree_steps: tuple[int, ...] = ()
|
||||
def contour(self):
|
||||
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
|
||||
|
||||
@@ -136,6 +156,27 @@ class ContinuationState:
|
||||
themes: tuple[RealizedMotif, ...] = ()
|
||||
selected_anchors: tuple[Anchor, ...] = ()
|
||||
ending: str = ''
|
||||
lead_identity: LeadIdentity | None = None
|
||||
source_reference: SourceReference | None = None
|
||||
callbacks: int = 0
|
||||
history: tuple[HeardFigure, ...] = ()
|
||||
returns: tuple[fractions.Fraction, ...] = ()
|
||||
closures: int = 0
|
||||
register_peak: int = 0
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HeardFigure:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
events: tuple[tuple[fractions.Fraction, fractions.Fraction, int], ...]
|
||||
operation: str
|
||||
ending: tuple = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SustainedTail:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
pitch: int
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Transformation:
|
||||
@@ -160,6 +201,34 @@ class DraftEdit:
|
||||
added: tuple[str, ...]
|
||||
source: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class IdentifyingFeature:
|
||||
id: str
|
||||
events: tuple[str, ...]
|
||||
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
intervals: tuple[int, ...]
|
||||
source: str
|
||||
preserves: tuple[str, ...] = ('rhythm', 'intervals')
|
||||
pitches: tuple[int, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SourceReference:
|
||||
id: str
|
||||
source_id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
notes: tuple[Note, ...]
|
||||
treatment: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RouteCandidate:
|
||||
id: str
|
||||
notes: tuple[Note, ...]
|
||||
destination: Note
|
||||
spans: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
|
||||
relationship: str
|
||||
sources: tuple[str, ...]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MelodicDraft:
|
||||
id: str
|
||||
@@ -173,6 +242,16 @@ class MelodicDraft:
|
||||
home: RealizedMotif | None
|
||||
relationship: str
|
||||
joins: tuple[fractions.Fraction, ...] = ()
|
||||
features: tuple[IdentifyingFeature, ...] = ()
|
||||
reference: str = ''
|
||||
reference_motif: Motif | RealizedMotif | None = None
|
||||
lead_identity: LeadIdentity | None = None
|
||||
correspondence: tuple[tuple[str, str], ...] = ()
|
||||
source_phrase: SourcePhrase | None = None
|
||||
octave_transfer: int = 0
|
||||
source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
|
||||
source_reference: SourceReference | None = None
|
||||
sustained_tails: tuple[SustainedTail, ...] = ()
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
@@ -244,6 +323,9 @@ class Phrase:
|
||||
anchors: tuple[Anchor, ...] = ()
|
||||
draft: MelodicDraft | None = None
|
||||
edits: tuple[DraftEdit, ...] = ()
|
||||
initial: MelodicDraft | None = None
|
||||
refined: MelodicDraft | None = None
|
||||
routes: tuple[RouteCandidate, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
|
||||
@@ -58,7 +58,9 @@ add_test(
|
||||
COMMAND ${VAMP_PYTHON_COMMAND} -m unittest discover -s tests -p test_*.py
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
)
|
||||
set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 180)
|
||||
# Complete deterministic phrase searches make this suite CPU-sensitive;
|
||||
# leave headroom for concurrent asset generation on development machines.
|
||||
set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 300)
|
||||
add_custom_target(check
|
||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||
--test-dir ${CMAKE_BINARY_DIR}
|
||||
|
||||
+2
-2
@@ -116,7 +116,7 @@ class SoloTests(unittest.TestCase):
|
||||
source = song(count=6, chords=('CM7',))
|
||||
result = compose(source)
|
||||
self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3)
|
||||
self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id)
|
||||
self.assertTrue(all(p.draft.source_phrase for p in result.phrases))
|
||||
self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions))
|
||||
original = result.phrases[0].plan
|
||||
explicit = dataclasses.replace(original, treatment='exact_quote', target=None, anchor_options=(), gestures=(), passage_id='', passage_start=None, passage_end=None)
|
||||
@@ -154,7 +154,7 @@ class SoloTests(unittest.TestCase):
|
||||
self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2))
|
||||
def test_search_exhaustion_does_not_claim_impossibility(self):
|
||||
source = song()
|
||||
with unittest.mock.patch('generator.solo.realization.draft_pitch_paths', return_value=((), 'no route within the draft budget')), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'):
|
||||
with unittest.mock.patch('generator.solo.themes.phrase_candidates', return_value=()), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'):
|
||||
compose(source)
|
||||
def test_time_aware_placement_does_not_penalize_absolute_frets(self):
|
||||
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
|
||||
|
||||
@@ -80,7 +80,9 @@ class DevelopmentTests(unittest.TestCase):
|
||||
phrase = generator.solo.realization.compose(passage, source['staves'][0]['measures'], choices(end=16), tests.solo_fixtures.library(), generator.rhythm.load_library(pathlib.Path('data/rhythms')), fractions.Fraction(120))
|
||||
notes = generator.solo.composer.phrase_notes(phrase)
|
||||
self.assertGreaterEqual(sum(n.start < 8 for n in notes), 12)
|
||||
self.assertTrue(any(n.start < 4 < n.end for n in notes) or any(a.end == b.start == 4 and a.end - a.start == b.end - b.start == fractions.Fraction(1, 2) for a, b in zip(notes, notes[1:])))
|
||||
# Continuity also includes a quarter-note arrival; the new policy does
|
||||
# not force every successful crossing to be an eighth-note pair.
|
||||
self.assertTrue(any(n.start < 4 < n.end for n in notes) or any(a.end == b.start == 4 for a, b in zip(notes, notes[1:])))
|
||||
self.assertTrue(any(n.start == 8 and n.pitch.number() % 12 == 4 and n.end - n.start >= 4 for n in notes))
|
||||
self.assertEqual(notes[-1].end, 16)
|
||||
def test_exact_quote_cannot_silently_fragment_at_no_harmony(self):
|
||||
|
||||
@@ -89,7 +89,8 @@ class DraftRepresentationTests(unittest.TestCase):
|
||||
plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16))
|
||||
endings = generator.solo.drafts.ending_options(draft.anchors, plan, choices)
|
||||
self.assertEqual({a[-1].duration for a in endings}, {F(1, 2), F(1), F(2), F(3)})
|
||||
self.assertTrue(all(a[-1].pitch == draft.anchors[-1].pitch and a[-1].earliest + a[-1].duration == 16 for a in endings))
|
||||
self.assertTrue(all(a[-1].pitch == draft.anchors[-1].pitch and a[-1].earliest + a[-1].duration == a[-1].hold_until for a in endings))
|
||||
self.assertEqual({16 - a[-1].hold_until for a in endings}, {F(), F(1, 2), F(1)})
|
||||
def test_revision_cannot_change_terminal_hold_or_identifying_rhythm(self):
|
||||
draft, _ = illustration()
|
||||
for index in (2, len(draft.notes) - 1):
|
||||
@@ -186,15 +187,22 @@ class DraftCompositionTests(unittest.TestCase):
|
||||
self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous))
|
||||
self.assertTrue(draft.transformations)
|
||||
previous = phrase.state.last
|
||||
def test_home_survives_several_developments_and_phrase_boundary(self):
|
||||
def test_source_family_survives_phrase_variations(self):
|
||||
first, second = self.solo.phrases
|
||||
self.assertEqual(first.state.home.id, second.state.home.id)
|
||||
self.assertTrue(all(t.home == first.state.home.id for p in self.solo.phrases for t in p.draft.transformations))
|
||||
self.assertNotEqual(second.state.development.id, second.state.home.id)
|
||||
self.assertEqual(second.state.selected_anchors, first.state.selected_anchors)
|
||||
for phrase in self.solo.phrases:
|
||||
self.assertTrue(all(t.home == phrase.state.home.id for t in phrase.draft.transformations))
|
||||
self.assertTrue(phrase.state.home.chord_degrees)
|
||||
self.assertTrue(phrase.draft.source_links)
|
||||
def test_groups_cross_barlines_without_reference_timing_reset(self):
|
||||
groups = [t for p in self.solo.phrases for t in p.draft.transformations]
|
||||
self.assertTrue(any(t.start < beat < t.end for t in groups for beat in range(4, 32, 4)))
|
||||
# Exercise an explicitly spanning construction; do not require a changed
|
||||
# search objective to pick the same old winner merely to test timing.
|
||||
plan = self.solo.phrases[0].plan
|
||||
pattern = ((F(), F(1)), (F(1), F(3, 2)), (F(3, 2), F(2)), (F(2), F(3)))
|
||||
constructed = generator.solo.drafts.construct(plan, plan.motif, 'statement', ('spanning', pattern, F(3)), (), 0, self.solo.choices)
|
||||
self.assertIsNotNone(constructed)
|
||||
self.assertTrue(any(g.start < 4 < g.end for g in constructed.groups))
|
||||
self.assertTrue(any(s.start == F(4) and s.end == F(9, 2) for s in constructed.slots))
|
||||
for phrase in self.solo.phrases:
|
||||
by_id = {n.event_id: n for n in phrase.draft.notes}
|
||||
for transform in phrase.draft.transformations:
|
||||
@@ -219,7 +227,7 @@ class DraftCompositionTests(unittest.TestCase):
|
||||
self.assertTrue(generator.solo.drafts.contract_valid(phrase.draft, notes)[0])
|
||||
self.assertTrue(all(1 <= n.position.fret <= 12 for n in notes))
|
||||
for edit in phrase.edits:
|
||||
self.assertLessEqual(edit.end - edit.start, 4 if edit.kind != 'elaboration' else phrase.plan.end - phrase.plan.start)
|
||||
self.assertLessEqual(edit.end - edit.start, 8 if edit.kind in ('replace_idea', 'complete_route') else 4)
|
||||
def test_join_comparison_reuses_identical_upstream_draft_pool(self):
|
||||
plan = self.solo.phrases[0].plan
|
||||
measures = tests.test_solo.song(count=8)['staves'][0]['measures']
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Production source variations, strict scale permission and paired interpretation."""
|
||||
import copy
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
from generator.solo import analysis, composer, drafts, harmonic, leads, placement, realization, scoring, types
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import tests.test_solo as fixture
|
||||
import tests.test_solo_development as notes_fixture
|
||||
import tests.test_solo_passages as passage_fixture
|
||||
import tests.scale_fixtures
|
||||
import tests.rhythm_fixtures
|
||||
import tests.solo_fixtures
|
||||
|
||||
class SourceVariationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.document=fixture.song(count=8,chords=('CM7',))
|
||||
cls.context=realization.SearchContext()
|
||||
cls.solo=cls.compose(cls.document,cls.context)
|
||||
@classmethod
|
||||
def compose(cls,document,context=None,end=32):
|
||||
return composer.compose(analysis.analyze(document),document['staves'][0]['measures'],'acoustic',F(end),
|
||||
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=context,mode='paraphrase')
|
||||
def test_production_is_traceable_to_complete_source_phrases(self):
|
||||
self.assertTrue(self.solo.phrases)
|
||||
for p in self.solo.phrases:
|
||||
self.assertIsNotNone(p.draft.source_phrase)
|
||||
self.assertTrue(p.draft.source_links)
|
||||
self.assertTrue(p.edits)
|
||||
self.assertGreater(len(p.draft.source_phrase.notes),8)
|
||||
self.assertTrue(drafts.contract_valid(p.draft,composer.phrase_notes(p))[0])
|
||||
self.assertTrue(all(n.sources for n in p.draft.notes))
|
||||
def test_same_input_and_cache_preserve_complete_music(self):
|
||||
other=self.compose(self.document,self.context)
|
||||
self.assertEqual(self.solo.notes(),other.notes())
|
||||
self.assertGreater(self.context.diagnostics['cache_hits'],0)
|
||||
def test_source_change_drives_music_with_fixed_harmony(self):
|
||||
changed=copy.deepcopy(self.document)
|
||||
for m in changed['staves'][0]['measures']:
|
||||
for n in m['voices'][0]['notes']:
|
||||
n['pitches'][0]['letter']='g' if n['pitches'][0]['letter']=='c' else n['pitches'][0]['letter']
|
||||
other=self.compose(changed)
|
||||
self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes()))
|
||||
self.assertNotEqual(self.solo.phrases[0].initial.notes[0].pitch.number()%12,other.phrases[0].initial.notes[0].pitch.number()%12)
|
||||
def test_source_ties_and_rests_are_retained_before_variation(self):
|
||||
melody=(notes_fixture.note(0,5,'c'),notes_fixture.note(6,9,'e'),notes_fixture.note(9,12,'g'),notes_fixture.note(12,16,'c'))
|
||||
melody=tuple(dataclasses.replace(n,sources=(f's{i}',)) for i,n in enumerate(melody))
|
||||
source=analysis.source_phrases(melody,F(),F(16),F(4))
|
||||
self.assertEqual(tuple(n for p in source for n in p.notes)[0].end,5)
|
||||
self.assertTrue(any((F(5),F(6)) in p.rests for p in source))
|
||||
def test_strict_map_rejects_chromatic_labels(self):
|
||||
harmony=harmonic.Harmony(passage_fixture.choices(('C7',)))
|
||||
approach=dataclasses.replace(notes_fixture.note(F(5,2),3,'f',1),event_id='approach')
|
||||
target=dataclasses.replace(notes_fixture.note(3,4,'g'),event_id='target')
|
||||
contract=types.HarmonicTreatment('removed','chromatic_approach',66,F(5,2),F(3),67,F(3),F(),'approach','target')
|
||||
self.assertFalse(harmony.evaluate((approach,target),complete=True).valid)
|
||||
self.assertFalse(harmony.evaluate((approach,target),(contract,),complete=True).valid)
|
||||
def test_mapped_accidentals_and_half_steps_remain_valid(self):
|
||||
scale=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
|
||||
choices=passage_fixture.choices(('A7','Dm7'),duration=2,scale=scale)
|
||||
harmony=harmonic.Harmony(choices)
|
||||
notes=(notes_fixture.note(0,F(3,2),'e'),notes_fixture.note(F(3,2),2,'c',1),notes_fixture.note(2,4,'d'))
|
||||
self.assertTrue(harmony.evaluate(notes,complete=True).valid)
|
||||
def test_partition_does_not_change_sounding_evaluation(self):
|
||||
phrase=self.solo.phrases[0]
|
||||
whole=phrase.draft.notes;split=[]
|
||||
for n in whole:
|
||||
middle=(n.start+n.end)/2
|
||||
split.extend((dataclasses.replace(n,end=middle),dataclasses.replace(n,start=middle,continuation=True)))
|
||||
harmony=harmonic.Harmony(self.solo.choices)
|
||||
self.assertEqual(harmony.evaluate(whole,complete=True),harmony.evaluate(split,complete=True))
|
||||
self.assertEqual(scoring.features(phrase.plan,whole,self.solo.choices),scoring.features(phrase.plan,split,self.solo.choices))
|
||||
self.assertTrue(drafts.contract_valid(phrase.draft,split)[0])
|
||||
def test_low_register_and_tied_placement(self):
|
||||
low=dataclasses.replace(notes_fixture.note(0,2,'a'),pitch=types.Pitch('a',F(),2))
|
||||
following=dataclasses.replace(low,start=2,end=4,continuation=True)
|
||||
placed=placement.place((low,following),F(120))
|
||||
self.assertEqual(placed[1][0].position.string_number,6)
|
||||
self.assertEqual(placed[1][0].position,placed[1][1].position)
|
||||
self.assertEqual({p.string_number for number in range(41,77) for p in placement.positions(number)},set(range(1,7)))
|
||||
def test_pair_metric_detects_octave_copy(self):
|
||||
shifted=dataclasses.replace(self.solo,phrases=tuple(dataclasses.replace(p,decisions=tuple(dataclasses.replace(d,notes=leads.shift(d.notes,1)) for d in p.decisions)) for p in self.solo.phrases))
|
||||
self.assertEqual(leads.similarity(self.solo,shifted),1)
|
||||
callback=dataclasses.replace(shifted,phrases=tuple(dataclasses.replace(p,plan=dataclasses.replace(p.plan,relationship='callback')) for p in shifted.phrases))
|
||||
self.assertEqual(leads.similarity(self.solo,callback),0)
|
||||
def test_later_arrivals_compare_complete_routes_with_different_holds(self):
|
||||
notes=tuple(dataclasses.replace(notes_fixture.note(a,b,k),event_id=str(a),sources=(str(a),))
|
||||
for a,b,k in ((0,1,'g'),(1,2,'e'),(2,8,'c')))
|
||||
source=types.SourcePhrase('ending',F(),F(8),notes,(),'family')
|
||||
harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7')))
|
||||
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source)
|
||||
self.assertEqual({r.destination.end-r.destination.start for _,_,r in options},{F(1,2),F(1),F(3,2),F(2)})
|
||||
for actual,_,route in options:
|
||||
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
|
||||
self.assertEqual(actual[-1],route.destination)
|
||||
self.assertEqual((actual[0].start,actual[-1].end),(0,8))
|
||||
def test_repeated_source_cadence_is_not_accidental_monotony(self):
|
||||
first=self.solo.phrases[0]
|
||||
changed=dataclasses.replace(first,draft=dataclasses.replace(first.draft,
|
||||
source_phrase=dataclasses.replace(first.draft.source_phrase,family='unrelated')))
|
||||
self.assertAlmostEqual(leads.transition(first,changed)-leads.transition(first,first),.15)
|
||||
def test_space_and_vocal_return_are_preserved(self):
|
||||
silent=self.compose(fixture.song(count=8,chords=()))
|
||||
self.assertFalse(silent.notes())
|
||||
shortened=self.compose(self.document,end=F(29))
|
||||
self.assertTrue(all(n.end<=29 for n in shortened.notes()))
|
||||
composer.validate(shortened)
|
||||
def test_pair_selection_produces_valid_distinct_source_variations(self):
|
||||
a=analysis.analyze(self.document)
|
||||
pair=composer.compose_pair(a,{part:(self.document['staves'][0]['measures'],F(32),None) for part in ('acoustic','electric')},
|
||||
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),mode='paraphrase')
|
||||
self.assertLess(leads.similarity(*pair),1)
|
||||
self.assertEqual(pair[0].diagnostics['pairs_compared'],64)
|
||||
for solo in pair:
|
||||
composer.validate(solo)
|
||||
for phrase in solo.phrases:
|
||||
heard={n.event_id:n for n in composer.phrase_notes(phrase)}
|
||||
for route in phrase.routes:
|
||||
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
|
||||
for note in route.notes:
|
||||
self.assertEqual((heard[note.event_id].pitch,heard[note.event_id].start,heard[note.event_id].end),(note.pitch,note.start,note.end))
|
||||
def test_pickup_is_brought_inside_window_without_touching_previous_section(self):
|
||||
melody=(notes_fixture.note(0,5,'g'),notes_fixture.note(5,6,'a'),notes_fixture.note(6,8,'g'))
|
||||
melody=tuple(dataclasses.replace(n,sources=(f's{i}',),event_id=f's{i}') for i,n in enumerate(melody))
|
||||
pickup=tuple(dataclasses.replace(notes_fixture.note(i-3,i-2,name),sources=(f'p{i}',),event_id=f'p{i}') for i,name in enumerate(('c','d','e')))
|
||||
source=types.SourcePhrase('pickup',F(),F(8),melody,(),'family',pickup)
|
||||
harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7')))
|
||||
options=[notes for notes,edits in leads.variations(melody,source,harmony) if edits[0].kind=='pickup_entrance']
|
||||
self.assertTrue(options)
|
||||
self.assertEqual(options[0][0].start,0)
|
||||
self.assertEqual(options[0][3].start,3)
|
||||
self.assertEqual(options[0][3].end,5)
|
||||
self.assertTrue(harmony.evaluate(options[0],complete=True).valid)
|
||||
def test_occupancy_is_not_an_objective_and_active_silence_is_invalid(self):
|
||||
plan=dataclasses.replace(notes_fixture.plan(),end=F(4),release=F(),passages=(),treatment='source_variation')
|
||||
choices=passage_fixture.choices(('C7',))
|
||||
for end in (F(1),F(4)):
|
||||
self.assertEqual(scoring.features(plan,(notes_fixture.note(0,end,'e'),),choices)['occupancy'],0)
|
||||
self.assertFalse(scoring.feasible(plan,(),choices,F(4)))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Lead invention contracts and accepted musical examples, not taste metrics."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import generator.solo.placement
|
||||
import generator.solo.references as references
|
||||
import generator.solo.routes as routes
|
||||
import generator.solo.realization as realization
|
||||
import generator.solo.types as types
|
||||
import generator.solo.drafts as drafts
|
||||
import generator.solo.harmonic as harmonic
|
||||
import generator.solo.scoring as scoring
|
||||
import generator.solo.policy as policy
|
||||
import generator.solo.sequences as sequences
|
||||
import tests.scale_fixtures
|
||||
import tests.test_solo_development as fixtures
|
||||
import tests.test_solo_passages as passages
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
def notes(names):
|
||||
return tuple(dataclasses.replace(fixtures.note(F(i, 2), F(i + 1, 2), name), event_id=f'n{i}') for i, name in enumerate(names))
|
||||
|
||||
class LeadReferenceTests(unittest.TestCase):
|
||||
def test_accepted_pair_preserves_sounding_pitch_exact_time_and_harmony(self):
|
||||
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
|
||||
self.assertEqual((len(reference.notes), len(reference.decorated), reference.duration), (31, 36, 32))
|
||||
self.assertEqual(reference.notes[0].pitch.number(), 62)
|
||||
self.assertEqual(reference.notes[1].end - reference.notes[1].start, F(3, 2))
|
||||
choices = list(passages.choices(tuple(chord for _, chord in reference.harmony)))
|
||||
minor = tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')
|
||||
choices[5] = dataclasses.replace(choices[5], scale=minor)
|
||||
harmony = harmonic.Harmony(tuple(choices))
|
||||
for variant in (reference.notes, reference.decorated):
|
||||
self.assertTrue(harmony.evaluate(variant, complete=True).valid)
|
||||
placed = generator.solo.placement.place(variant, F(120))
|
||||
self.assertTrue(placed)
|
||||
self.assertTrue(all(1 <= n.position.fret <= 12 for n in placed[1]))
|
||||
self.assertTrue(all(m.context and m.duration in (8, 16) for m in reference.windows()))
|
||||
def test_feature_selection_finds_the_relationship_not_the_first_three(self):
|
||||
original = notes(('c', 'd', 'e', 'c', 'g', 'f', 'd'))
|
||||
feature, = references.identifying_features(original, 'test')
|
||||
self.assertNotEqual(feature.events[:3], tuple(n.event_id for n in original[:3]))
|
||||
self.assertEqual(references.feature_error(feature, original), 0)
|
||||
self.assertGreater(references.feature_error(feature, original[:1]), 0)
|
||||
def test_single_note_does_not_match_a_complete_tune(self):
|
||||
phrase = types.Motif('lead', notes(('d', 'g', 'f', 'd')), F(2), ('Gm7',))
|
||||
self.assertEqual(references.relationship(notes(('c',)), phrase), 1)
|
||||
self.assertEqual(references.relationship(phrase.notes, phrase), 0)
|
||||
def test_similarity_is_counted_once(self):
|
||||
self.assertNotIn('fidelity', dict(policy.DEFAULT.weights))
|
||||
self.assertEqual(scoring.value({'fidelity': 1, 'development': 0}), 0)
|
||||
self.assertEqual(scoring.value({'development': 1}), 2)
|
||||
def test_one_attack_deadband_is_not_a_new_density_target(self):
|
||||
plan = dataclasses.replace(fixtures.plan(), end=F(4), release=F(), activity=F(1), passages=())
|
||||
choices = passages.choices(('C7',))
|
||||
four = notes(('e', 'g', 'e', 'g'))
|
||||
five = notes(('e', 'g', 'e', 'g', 'e'))
|
||||
self.assertEqual(scoring.features(plan, four, choices)['activity'], 0)
|
||||
self.assertEqual(scoring.features(plan, five, choices)['activity'], 0)
|
||||
self.assertEqual(policy.DEFAULT.lead_activity_range, (1.0, 1.5))
|
||||
def test_generated_opening_is_editable_until_a_feature_is_committed(self):
|
||||
original = notes(('c', 'g', 'f', 'e'))
|
||||
draft = types.MelodicDraft('invented', F(), F(2), original, (), (), (), (), None, 'statement')
|
||||
changed = (dataclasses.replace(original[0], pitch=fixtures.note(0, 1, 'e').pitch), *original[1:])
|
||||
self.assertTrue(drafts.contract_valid(draft, changed)[0])
|
||||
selected = dataclasses.replace(draft, features=references.identifying_features(original, 'selected'))
|
||||
self.assertFalse(drafts.contract_valid(selected, changed)[0])
|
||||
def test_ending_activity_only_broadens_the_selected_hold(self):
|
||||
plan = dataclasses.replace(fixtures.plan(), end=F(16), release=F())
|
||||
anchor = dataclasses.replace(passages.anchor(), earliest=F(15), latest=F(15), duration=F(1), hold_until=F(16))
|
||||
chosen = drafts.ending_plan(plan, (anchor,))
|
||||
arrivals = [p for p in chosen.passages if p.intent == 'sustained_arrival']
|
||||
self.assertEqual([(p.start, p.end) for p in arrivals], [(15, 16)])
|
||||
self.assertTrue(all(p.intent == 'active' for p in chosen.passages if p.end <= 15))
|
||||
def test_zero_passage_origin_retains_the_full_activity_budget(self):
|
||||
plan = dataclasses.replace(fixtures.plan(), start=F(16), end=F(32), passage_start=F(), passage_end=F(32), activity=F(9, 8), passages=(types.ActivitySpan(F(), F(32), 'active', F(9, 8), F(3, 4)),))
|
||||
anchor = dataclasses.replace(passages.anchor(), earliest=F(30), latest=F(30), duration=F(2), hold_until=F(32))
|
||||
chosen = drafts.ending_plan(plan, (anchor,))
|
||||
self.assertEqual(sum(p.activity * (p.end - p.start) for p in chosen.passages), F(36))
|
||||
|
||||
class CompleteRouteTests(unittest.TestCase):
|
||||
def test_complete_route_reaches_endpoint_without_nearest_note_repairs(self):
|
||||
harmony = harmonic.Harmony(passages.choices(('C7',)))
|
||||
library = sequences.Library((sequences.Sequence('descending', (-1, -1)), sequences.Sequence('wrong', (1, 1))))
|
||||
template = notes(('g', 'c', 'e'))
|
||||
result = routes.candidates(template, {'n0', 'n2'}, harmony, library, (55, 79))
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual([n.pitch.number() for n in result[0].notes], [67, 65, 64])
|
||||
self.assertEqual(result[0].sources, ('descending',))
|
||||
self.assertEqual(result[0].destination, template[-1])
|
||||
self.assertTrue(harmony.evaluate(result[0].notes).valid)
|
||||
def test_route_cannot_repair_a_forbidden_fixed_pitch(self):
|
||||
harmony = harmonic.Harmony(passages.choices(('C7',)))
|
||||
library = sequences.Library((sequences.Sequence('descending', (-1, -1)),))
|
||||
self.assertEqual(routes.candidates(notes(('g', 'c', 'e')), {'n1'}, harmony, library, (55, 79)), ())
|
||||
def test_collection_change_preserves_sounding_coordinate(self):
|
||||
minor = tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')
|
||||
aeolian = tests.scale_fixtures.library().scale('major', 6, 'D')
|
||||
# C-sharp is absent in Aeolian: the first rising degree is actual D,
|
||||
# rather than reinterpreting C-sharp's old harmonic-minor index.
|
||||
self.assertEqual(routes.advance(aeolian, 61, 1)[0].number(), 62)
|
||||
choices = list(passages.choices(('A7', 'Dm7'), duration=1, scale=minor))
|
||||
choices[1] = dataclasses.replace(choices[1], scale=aeolian)
|
||||
harmony = harmonic.Harmony(tuple(choices))
|
||||
template = (dataclasses.replace(fixtures.note(0, F(1, 2), 'e'), event_id='a'), dataclasses.replace(fixtures.note(F(1, 2), 1, 'c', 1), event_id='b'), dataclasses.replace(fixtures.note(1, F(3, 2), 'd'), event_id='c'))
|
||||
library = sequences.Library((sequences.Sequence('resolution', (-2, 1)),))
|
||||
result = routes.candidates(template, {'a', 'c'}, harmony, library, (55, 79))
|
||||
self.assertTrue(result)
|
||||
self.assertEqual([n.pitch.number() for n in result[0].notes], [64, 61, 62])
|
||||
self.assertEqual(len(result[0].spans), 2)
|
||||
def test_whole_idea_replacement_can_change_an_ordinary_opening(self):
|
||||
harmony = harmonic.Harmony(passages.choices(('C7', 'C7')))
|
||||
plan = dataclasses.replace(fixtures.plan(), end=F(8), release=F(), passages=())
|
||||
def phrase(names, identity):
|
||||
actual = tuple(dataclasses.replace(n, start=2 * n.start, end=2 * n.end, event_id=identity + n.event_id) for n in notes(names))
|
||||
group = types.Transformation(identity, '', '', 'statement', F(), F(8), tuple(n.event_id for n in actual), tuple((n.start, n.end) for n in actual), preserves=())
|
||||
draft = types.MelodicDraft(identity, F(), F(8), actual, (group,), (), (), (), None, 'statement')
|
||||
return realization.checked_phrase(plan, draft, actual, (), types.ContinuationState(''), harmony, F(120))
|
||||
first = phrase(('c', 'c', 'c', 'e', 'g', 'g', 'g', 'e'), 'first')
|
||||
donor = phrase(('e', 'g', 'e', 'c', 'e', 'g', 'f', 'e'), 'donor')
|
||||
self.assertIsNotNone(first)
|
||||
self.assertIsNotNone(donor)
|
||||
revised = realization.replace_idea(first, donor, F(), F(8), types.ContinuationState(''), harmony, F(120))
|
||||
self.assertIsNotNone(revised)
|
||||
self.assertEqual([n.pitch for n in realization.emitted_notes(revised)], [n.pitch for n in realization.emitted_notes(donor)])
|
||||
self.assertEqual(revised.edits[-1].kind, 'replace_idea')
|
||||
@@ -115,24 +115,15 @@ class HarmonicPassageTests(unittest.TestCase):
|
||||
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
|
||||
|
||||
class PassageStateTests(unittest.TestCase):
|
||||
def test_eight_bar_cadence_carries_realized_identity_and_anchor_line(self):
|
||||
def test_source_phrases_carry_identity_and_completed_anchors(self):
|
||||
import tests.test_solo
|
||||
solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
|
||||
self.assertEqual(len(solo.passages), 1)
|
||||
self.assertEqual((solo.passages[0].start, solo.passages[0].end), (0, 32))
|
||||
self.assertEqual([(p.plan.start, p.plan.end) for p in solo.phrases], [(0, 16), (16, 32)])
|
||||
first, second = solo.phrases
|
||||
self.assertEqual(first.plan.ending, 'connected')
|
||||
self.assertEqual(first.state.motifs, second.state.motifs[:len(first.state.motifs)])
|
||||
self.assertEqual(first.anchors, second.anchors)
|
||||
self.assertTrue(any(a.role == 'intermediate' for a in second.anchors))
|
||||
self.assertTrue(all(generator.solo.harmonic.anchor_note(a, second.state.notes) for a in second.anchors))
|
||||
heard = {m.id: m for m in second.state.motifs}
|
||||
self.assertGreaterEqual(len(heard), 5)
|
||||
for motif in list(heard.values())[1:]:
|
||||
self.assertIn(motif.parent, heard)
|
||||
self.assertTrue(motif.chord_degrees)
|
||||
self.assertTrue(generator.solo.harmonic.anchors_valid(second.anchors, solo.notes(), solo.end, complete=True, closure='closed')[0])
|
||||
self.assertEqual((solo.passages[0].start, solo.passages[-1].end), (0, 32))
|
||||
for phrase in solo.phrases:
|
||||
self.assertTrue(phrase.draft.source_phrase)
|
||||
self.assertTrue(phrase.state.home.chord_degrees)
|
||||
self.assertTrue(all(generator.solo.harmonic.anchor_note(a, phrase.state.notes) for a in phrase.anchors))
|
||||
self.assertTrue(generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=True, closure='closed')[0])
|
||||
def test_development_references_realization_instead_of_source_template(self):
|
||||
plan = tests.test_solo_development.plan()
|
||||
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import unittest
|
||||
|
||||
from generator.solo import drafts, harmonic, phrasing, themes, types, leads, realization
|
||||
from tests.test_solo_development import note
|
||||
from tests.test_solo_passages import choices
|
||||
from tests.test_solo_themes import melody
|
||||
import tests.rhythm_fixtures
|
||||
import tests.solo_fixtures
|
||||
|
||||
|
||||
class SustainedPhrasing(unittest.TestCase):
|
||||
def test_vocal_cutoff_does_not_strand_a_one_beat_final_phrase(self):
|
||||
from generator.solo import analysis
|
||||
from tests.test_solo import song
|
||||
document=song(count=32,chords=('CM7',))
|
||||
source=analysis.analyze(document)
|
||||
harmony=harmonic.Harmony(choices(('CM7',)*32))
|
||||
values=phrasing.window_options(source,document['staves'][0]['measures'],F(100),F(125),harmony,types.ContinuationState('',closures=5),limit=4)
|
||||
self.assertEqual(values[0].end,125)
|
||||
self.assertFalse(any(0<125-v.end<4 for v in values))
|
||||
|
||||
def test_production_cost_uses_complete_sounding_events_across_cells(self):
|
||||
harmony=harmonic.Harmony(choices(('C7','C7')))
|
||||
whole=(note(0,1,'g'),note(1,F(3,2),'f'),note(F(3,2),4,'e'))
|
||||
split=(whole[0],whole[1],dataclasses.replace(whole[2],end=2),note(2,4,'e',continuation=True))
|
||||
incoming=types.ContinuationState('')
|
||||
self.assertEqual(phrasing.evaluate(whole,incoming,harmony,F(),F(4),F(4)),
|
||||
phrasing.evaluate(split,incoming,harmony,F(),F(4),F(4)))
|
||||
|
||||
def test_route_cache_preserves_concrete_provenance_and_uncached_selection(self):
|
||||
from generator.solo import routes,sequences,policy
|
||||
harmony=harmonic.Harmony(choices(('C7',)))
|
||||
notes=melody(((0,1,'g'),(1,2,'f'),(2,3,'e')))
|
||||
library=tests.solo_fixtures.library()
|
||||
raw=routes._candidates(notes,{'2'},harmony,library,(41,76))
|
||||
expected=sequences.diverse(raw,
|
||||
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
|
||||
lambda r:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),policy.DEFAULT.join_alternatives)
|
||||
cached=routes.candidates(notes,{'2'},harmony,library,(41,76))
|
||||
self.assertEqual(tuple((r.id,r.notes) for r in expected),tuple((r.id,r.notes) for r in cached))
|
||||
renamed=tuple(dataclasses.replace(n,event_id='other'+n.event_id,sources=('other-source',)) for n in notes)
|
||||
changed=routes.candidates(renamed,{'other2'},harmony,library,(41,76))
|
||||
self.assertEqual(len(harmony.route_cache),1)
|
||||
self.assertTrue(all(n.sources==('other-source',) for r in changed for n in r.notes))
|
||||
self.assertTrue(all(n.event_id.startswith('other') for r in changed for n in r.notes))
|
||||
|
||||
def test_extended_closing_unit_has_complete_timing_beyond_two_bars(self):
|
||||
values=themes.rhythm_windows(tests.rhythm_fixtures.library(),'4/4',F(12),realization.SearchContext())
|
||||
self.assertTrue(values)
|
||||
self.assertTrue(any(any(a>=8 for a,b in pattern) for pattern,_ in values))
|
||||
for pattern,_ in values:
|
||||
self.assertTrue(all(0<=a<b<=12 for a,b in pattern))
|
||||
self.assertTrue(all(b<=c for (a,b),(c,d) in zip(pattern,pattern[1:])))
|
||||
|
||||
def test_two_bar_retrieval_uses_the_richer_one_bar_vocabulary(self):
|
||||
values=themes.rhythm_windows(tests.rhythm_fixtures.library(),'4/4',F(8),realization.SearchContext())
|
||||
self.assertTrue(any(b-a==F(1,2) for pattern,_ in values for a,b in pattern))
|
||||
|
||||
def test_endpoint_ranking_distinguishes_major_seventh_from_dominant(self):
|
||||
major=harmonic.Harmony(choices(('CM7',)*4))
|
||||
dominant=harmonic.Harmony(choices(('C7',)*4))
|
||||
self.assertLess(phrasing.endpoint_cost(F(16),F(),F(128),0,major,F(4)),
|
||||
phrasing.endpoint_cost(F(16),F(),F(128),0,dominant,F(4)))
|
||||
|
||||
def test_protected_tail_is_sounding_time_not_an_immutable_whole_event(self):
|
||||
original=(note(0,4,'e',event_id='arrival'),)
|
||||
tail=types.SustainedTail(F(2),F(4),64)
|
||||
draft=types.MelodicDraft('held',F(),F(4),original,(),(),(),(),None,'close',sustained_tails=(tail,))
|
||||
revised=(note(0,1,'g',event_id='a'),note(1,2,'f',event_id='b'),note(2,4,'e',event_id='c'))
|
||||
self.assertTrue(drafts.contract_valid(draft,revised)[0])
|
||||
tied=(*revised[:-1],note(2,3,'e',event_id='c'),note(3,4,'e',event_id='c',continuation=True))
|
||||
self.assertTrue(drafts.contract_valid(draft,tied)[0])
|
||||
repeated=(*revised[:-1],note(2,3,'e',event_id='c'),note(3,4,'e',event_id='d'))
|
||||
self.assertFalse(drafts.contract_valid(draft,repeated)[0])
|
||||
|
||||
def test_planned_endings_have_varied_timing_and_full_chord_permission(self):
|
||||
harmony=harmonic.Harmony(choices(('Gm7','C7','FM7','BbM7')))
|
||||
source=types.SourcePhrase('phrase',F(),F(16),(note(0,1,'d'),),(),'')
|
||||
options=phrasing.endings(source,types.ContinuationState(''), 'acoustic',harmony,F(4))
|
||||
self.assertTrue(options)
|
||||
self.assertGreater(len({(a.earliest,a.duration) for a in options}),1)
|
||||
for anchor in options:
|
||||
self.assertGreaterEqual(anchor.duration,2)
|
||||
self.assertLessEqual(anchor.duration,4)
|
||||
self.assertIn(anchor.pitch.number()%12,harmony.members(anchor.earliest,anchor.hold_until))
|
||||
self.assertTrue(harmony.evaluate((types.Note(anchor.earliest,anchor.hold_until,anchor.pitch),),complete=True).valid)
|
||||
|
||||
def test_rhythmic_development_keeps_a_heard_turn_without_copying_its_timing(self):
|
||||
original=melody(((0,1,'d'),(1,2,'f'),(2,3,'e')))
|
||||
home=types.RealizedMotif('home','seed',original,F(3),'introduce','contour')
|
||||
ref=themes.home_reference(home,'develop_contour')
|
||||
context=realization.SearchContext()
|
||||
patterns=themes.patterns(ref,F(),F(8),tests.rhythm_fixtures.library(),'4/4',context,home=home,operation='develop_contour')
|
||||
self.assertGreater(len({p[:3] for p,_ in patterns}),1)
|
||||
actual=(note(0,F(1,2),'d',event_id='0'),note(F(1,2),2,'f',event_id='1'),note(2,3,'e',event_id='2'))
|
||||
feature=themes.feature(ref,actual,treatment='develop_contour')
|
||||
from generator.solo.references import feature_error
|
||||
self.assertEqual(feature_error(feature,actual),0)
|
||||
|
||||
def test_repetition_remembers_recent_figures_but_not_tie_splits(self):
|
||||
original=melody(((0,1,'d'),(1,2,'f'),(2,3,'e'),(3,4,'f')))
|
||||
old=phrasing.heard(original,'introduce')
|
||||
repeated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in original)
|
||||
figure=phrasing.heard(repeated,'develop_contour')
|
||||
self.assertGreater(phrasing.repetition(figure,(old,),F(4)),phrasing.repetition(figure,(),F(4)))
|
||||
returned=dataclasses.replace(figure,operation='return')
|
||||
self.assertGreater(phrasing.repetition(returned,(old,old,old),F(4)),phrasing.repetition(returned,(old,),F(4)))
|
||||
hold=(note(0,4,'e'),)
|
||||
split=(note(0,2,'e'),note(2,4,'e',continuation=True))
|
||||
self.assertEqual(phrasing.heard(hold,'close'),phrasing.heard(split,'close'))
|
||||
self.assertEqual(phrasing.repetition(phrasing.heard(hold,'close'),(),F(4)),0)
|
||||
|
||||
def test_high_electric_state_proposes_a_lower_goal(self):
|
||||
high=melody(((0,1,'c'),(1,2,'e'),(2,3,'d')))
|
||||
high=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=5)) for n in high)
|
||||
state=types.ContinuationState('',last=high[-1],history=(phrasing.heard(high,'develop_contour'),),register_peak=76)
|
||||
self.assertLess(phrasing.register_goal(state,'electric',74),min(n.pitch.number() for n in high))
|
||||
|
||||
def test_internal_short_notes_can_be_elaborated_without_consuming_arrival(self):
|
||||
notes=melody(((0,1,'g'),(1,2,'f'),(2,3,'g'),(3,5,'e')))
|
||||
source=types.SourcePhrase('internal',F(),F(5),notes,(),'')
|
||||
harmony=harmonic.Harmony(choices(('C7','C7')))
|
||||
tails=(types.SustainedTail(F(3),F(5),64),)
|
||||
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True,tails=tails)
|
||||
self.assertTrue(options)
|
||||
for actual,_,_ in options:
|
||||
self.assertTrue(any(n.pitch.number()==64 and n.start<=3 and n.end>=5 for n in actual))
|
||||
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Source influence and realized-theme continuity are different contracts."""
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import unittest
|
||||
|
||||
from generator.solo import analysis, composer, drafts, harmonic, leads, references, realization, similarity, themes, types
|
||||
import tests.test_solo as fixture
|
||||
import tests.test_solo_development as notes_fixture
|
||||
import tests.test_solo_passages as harmony_fixture
|
||||
import tests.scale_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.rhythm_fixtures
|
||||
|
||||
|
||||
def melody(values):
|
||||
return tuple(dataclasses.replace(notes_fixture.note(a,b,p),event_id=str(i),sources=(str(i),)) for i,(a,b,p) in enumerate(values))
|
||||
|
||||
class ThemeContracts(unittest.TestCase):
|
||||
def test_selected_rhythm_does_not_also_require_source_pitches(self):
|
||||
source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e')))
|
||||
ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed')
|
||||
actual=melody(((8,9,'g'),(9,10,'e'),(10,11,'c')))
|
||||
self.assertEqual(references.feature_error(themes.feature(ref,actual),actual),0)
|
||||
contour=dataclasses.replace(ref,treatment='contour_seed')
|
||||
self.assertGreater(references.feature_error(themes.feature(contour,actual),actual),0)
|
||||
|
||||
def test_a_single_event_is_not_evidence_of_a_multinote_feature(self):
|
||||
source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e')))
|
||||
ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed')
|
||||
contract=themes.feature(ref,source)
|
||||
self.assertEqual(references.feature_error(contract,source[:1]),1)
|
||||
self.assertEqual(references.feature_error(themes.feature(ref,source[:1]),source[:1]),1)
|
||||
|
||||
def test_copy_comparison_cannot_be_diluted_by_octaves_runs_or_reattacks(self):
|
||||
source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c')))
|
||||
shifted=leads.shift(source,1)
|
||||
self.assertTrue(similarity.near_copy(shifted,source,F(),F(8))['near_copy'])
|
||||
repeated=tuple(part for n in shifted for part in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,event_id=n.event_id+'r')))
|
||||
self.assertTrue(similarity.near_copy(repeated,source,F(),F(8))['near_copy'])
|
||||
self.assertEqual(similarity.compare(repeated,source)['octave_insensitive'],1)
|
||||
shortened=tuple(dataclasses.replace(n,end=n.end-F(1,2)) for n in shifted)
|
||||
self.assertEqual(similarity.compare(shortened,source)['octave_insensitive'],.75)
|
||||
relocated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in shifted)
|
||||
self.assertTrue(similarity.near_copy(relocated,source,F(8),F(16))['near_copy'])
|
||||
|
||||
def test_tied_partition_does_not_change_similarity(self):
|
||||
source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c')))
|
||||
split=tuple(piece for n in source for piece in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,continuation=True)))
|
||||
self.assertEqual(similarity.compare(source,source),similarity.compare(split,source))
|
||||
|
||||
def test_downward_cross_collection_steps_preserve_the_actual_target(self):
|
||||
from generator.solo import routes
|
||||
scale=tests.scale_fixtures.library().scale('major',6,'D')
|
||||
harmony=harmonic.Harmony(harmony_fixture.choices(('Dm7',),scale=scale))
|
||||
first=dataclasses.replace(notes_fixture.note(0,1,'c'),pitch=types.Pitch('c',F(1),4))
|
||||
last=dataclasses.replace(notes_fixture.note(1,2,'b'),pitch=types.Pitch('b',F(-1),3))
|
||||
step,=themes.relative_steps((first,last),harmony)
|
||||
self.assertEqual(step,-2)
|
||||
self.assertEqual(routes.advance(scale,first.pitch.number(),step)[0].number(),last.pitch.number())
|
||||
|
||||
def test_two_note_route_uses_exact_scale_displacement(self):
|
||||
from generator.solo import routes
|
||||
harmony=harmonic.Harmony(harmony_fixture.choices(('C7',)))
|
||||
notes=melody(((0,1,'g'),(1,2,'e')))
|
||||
options=routes.candidates(notes,{'1'},harmony,tests.solo_fixtures.library(),(41,76))
|
||||
self.assertTrue(options)
|
||||
self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in options))
|
||||
|
||||
def test_elaboration_may_continue_its_entry_instead_of_reattacking(self):
|
||||
notes=melody(((0,3,'g'),(3,4,'e')))
|
||||
source=types.SourcePhrase('held_entry',F(),F(4),notes,(),'')
|
||||
harmony=harmonic.Harmony(harmony_fixture.choices(('C7',)))
|
||||
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True)
|
||||
self.assertTrue(options)
|
||||
for actual,edit,route in options:
|
||||
self.assertEqual(actual[0].start,0)
|
||||
self.assertGreater(actual[0].end,edit.start)
|
||||
self.assertEqual(actual[:len(route.notes)],route.notes)
|
||||
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
|
||||
|
||||
class ThemeProduction(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.document=fixture.song(count=8,chords=('CM7',))
|
||||
cls.analysis=analysis.analyze(cls.document)
|
||||
cls.context=realization.SearchContext()
|
||||
cls.kw=dict(scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=cls.context)
|
||||
cls.solo=composer.compose(cls.analysis,cls.document['staves'][0]['measures'],'acoustic',F(32),**cls.kw)
|
||||
|
||||
def test_one_heard_theme_develops_across_source_boundaries(self):
|
||||
self.assertEqual(self.solo.diagnostics['construction'],'thematic_recomposition')
|
||||
self.assertEqual(len({p.state.home.id for p in self.solo.phrases}),1)
|
||||
for phrase in self.solo.phrases:
|
||||
self.assertTrue(drafts.contract_valid(phrase.draft,composer.phrase_notes(phrase))[0])
|
||||
self.assertFalse(similarity.near_copy(phrase.refined.notes,themes.source_notes(self.analysis),phrase.plan.start,phrase.plan.end)['near_copy'])
|
||||
self.assertEqual(self.solo.phrases[0].plan.closure,'closed')
|
||||
self.assertTrue(all(a.duration>=2 for a in self.solo.phrases[0].anchors))
|
||||
self.assertEqual(self.solo.phrases[-1].anchors[-1].role,'terminal')
|
||||
self.assertTrue(any(t.operation in ('develop_contour','develop_rhythm','continue','return') for p in self.solo.phrases for t in p.draft.transformations))
|
||||
|
||||
def test_library_routes_match_the_actual_complete_theme(self):
|
||||
self.assertTrue(any(p.routes for p in self.solo.phrases))
|
||||
for phrase in self.solo.phrases:
|
||||
heard={n.event_id:n for n in composer.phrase_notes(phrase)}
|
||||
for route in phrase.routes:
|
||||
for note in route.notes:
|
||||
actual=heard[note.event_id]
|
||||
self.assertEqual((note.start,note.end,note.pitch),(actual.start,actual.end,actual.pitch))
|
||||
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
|
||||
|
||||
def test_report_can_compare_a_source_hold_cut_by_new_phrase_boundaries(self):
|
||||
from generator.solo import report
|
||||
phrases=[]
|
||||
for i,p in enumerate(self.solo.phrases):
|
||||
held=notes_fixture.note(p.plan.start,p.plan.end,'c',event_id='source_hold',continuation=i>0)
|
||||
phrases.append(dataclasses.replace(p,initial=dataclasses.replace(p.initial,notes=(held,))))
|
||||
solo=dataclasses.replace(self.solo,phrases=tuple(phrases))
|
||||
detail=report.structure(solo)
|
||||
self.assertEqual(len(detail['phrases']),len(phrases))
|
||||
self.assertTrue(phrases[-1].initial.notes[0].continuation)
|
||||
|
||||
def test_incoming_pitch_changes_concrete_continuation_pool(self):
|
||||
harmony=harmonic.Harmony(self.solo.choices)
|
||||
home=self.solo.phrases[0].state.home
|
||||
ref=themes.home_reference(home,'answer')
|
||||
context=realization.SearchContext()
|
||||
outputs=[]
|
||||
for octave in (3,4):
|
||||
prior=dataclasses.replace(notes_fixture.note(15,16,'e'),pitch=types.Pitch('e',F(),octave))
|
||||
state=types.ContinuationState('prior',last=prior,home=home)
|
||||
options=themes.unit_candidates(ref,F(16),F(24),state,harmony,self.kw['sequences'],self.kw['rhythms'],'4/4',context,home=home,operation='answer')
|
||||
self.assertTrue(options)
|
||||
outputs.append({u.notes[0].pitch.number() for u in options})
|
||||
self.assertNotEqual(*outputs)
|
||||
|
||||
def test_deterministic_cache_and_actual_source_input(self):
|
||||
repeated=composer.compose(self.analysis,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw)
|
||||
self.assertEqual(self.solo.notes(),repeated.notes())
|
||||
changed=dataclasses.replace(self.analysis,melody=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,letter='g')) for n in self.analysis.melody))
|
||||
other=composer.compose(changed,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw)
|
||||
self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes()))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in New Issue
Block a user