Comp
This commit is contained in:
@@ -95,18 +95,20 @@ 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. The Python build uses these collections, the song melody, the accepted paired pitch/rhythm references in `data/lead_phrases`, `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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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. Ordinary invented openings are editable: bounded refinement can replace an entire two-bar idea with another jointly realized pitch/rhythm candidate, then edit both sides of internal joins. Only committed anchors, exact quotations and explicitly selected identifying relationships are protected during elaboration. Full harmonic, timing and guitar validation follows every edit. Development similarity has one weight; it is not also charged as fidelity.
|
||||
|
||||
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.
|
||||
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. Acoustic/electric activity targets remain 1.125/1.375 attacks per beat, with occupancy goals of 0.75 for active motion and 0.9 for arrivals. Soft comparison allows one attack per evaluated phrase and 0.1 occupancy deviation; these tolerances do not permit accidental silence. Ending candidates combine half-, one-, two- or three-beat holds with zero, half- or one-beat release. Only the chosen hold becomes an arrival region; the final quarter of every passage is no longer forced to slow down. Search retains up to 64 pitch states within a musical group, 16 at completed groups, and eight complete phrase alternatives. The four most promising complete alternatives receive two-bar replacement search. Join revision considers up to eight proposals at the two weakest joins, in two-beat windows on either side, with at most two sweeps. The other complete alternatives remain eligible. Complete runs retain their entire attack rhythm, pitch route and destination; an invalid route is rejected as a unit. Up to two approaches per phrase may be elaborated, using catalog routes and the accepted decorated reference’s approach rhythms. Later edits cannot dismantle selected routes. One-phrase lookahead compares the resulting complete alternatives. 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
For lead-melody comparisons, preserve the old generator with `python -m generator.solo.audition --output build/solo_lead_auditions --capture baseline`, then use `python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_lead_auditions/index.html` to compare baseline, initial tune, refined tune and elaboration with fixed backing or alone; the final version also has responsive accompaniment. Intermediate exports belong to the selected production branches and retain their incoming contexts. They are not independent composition runs. The accepted original theme and decorated version are injected literally only in the diagnostic controls; normal generation adapts their timed movement to the current harmony. The controls verify exact sounding pitches and durations through placement, notation and playback, and also expose what refinement/elaboration changes. The temporary reference ZIP is not a build input and its script is never executed.
|
||||
|
||||
To inspect generated assets without building the full package, run:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -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,630 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import generator.scales.library
|
||||
import generator.score
|
||||
import generator.summary
|
||||
import generator.solo.sequences
|
||||
import generator.solo.references
|
||||
|
||||
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
||||
|
||||
@@ -75,13 +76,14 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
scales = generator.scales.library.load_library(data_dir / 'scales')
|
||||
report('loading solo interval sequences')
|
||||
sequences = generator.solo.sequences.load_library(data_dir / 'sequences')
|
||||
references = generator.solo.references.load_library(data_dir / 'lead_phrases')
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
|
||||
staged = pathlib.Path(temporary) / 'assets'
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
report('generating performance parts')
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences)
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences, references=references)
|
||||
generator.score.validate_generated_tree(staged)
|
||||
if args.check:
|
||||
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
@@ -103,7 +103,7 @@ 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))
|
||||
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, references=references, stage=stage, context=(contexts or {}).get(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)):
|
||||
@@ -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:
|
||||
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}
|
||||
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')
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ 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'):
|
||||
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)
|
||||
@@ -31,6 +31,8 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
|
||||
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)
|
||||
context = context if context is not None else generator.solo.realization.SearchContext()
|
||||
context.revise = revise
|
||||
context.references = references
|
||||
context.stage = stage
|
||||
|
||||
def realize(plan, previous):
|
||||
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
|
||||
|
||||
+61
-17
@@ -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 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)
|
||||
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,10 +207,12 @@ 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)
|
||||
@@ -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:]
|
||||
@@ -326,6 +366,9 @@ 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'
|
||||
return True, ''
|
||||
|
||||
def relationship_features(draft, notes):
|
||||
@@ -338,8 +381,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 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):
|
||||
key = (start, end)
|
||||
if key not in self._members:
|
||||
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()
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""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 / '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': 'Initial and refined are intermediate versions of the selected production branches, with the same incoming context. They are not independently rerun compositions.'}
|
||||
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 tune before and after refinement, then hear whether the runs preserve it. 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>Baseline</th><th>Initial tune</th><th>Refined tune</th><th>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 ('baseline', 'initial', 'refined', 'elaborated'):
|
||||
foreground = audition.excerpt((old if stage == 'baseline' else song)['parts'][part], first, count) if stage in ('baseline', 'elaborated') else audition.excerpt(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>')
|
||||
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_lead_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()
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,7 @@ class Policy:
|
||||
electric_activity: float = 1.375
|
||||
active_occupancy: float = 0.75
|
||||
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,11 @@ 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
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
+190
-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,17 @@ 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))
|
||||
# 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 +584,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 +605,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 +614,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 +622,155 @@ 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))
|
||||
for low, high in windows[-2:]:
|
||||
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'):
|
||||
actual = (*notes[:left], *route.notes, *notes[right + 1:])
|
||||
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
|
||||
chosen = min(viable, 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 +779,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 +796,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)
|
||||
@@ -722,8 +835,8 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
|
||||
# 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 +849,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 +864,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,115 @@
|
||||
"""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
|
||||
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'])
|
||||
|
||||
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 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:]))
|
||||
timing = sum(a != b for a, b in zip(rhythm, feature.rhythm)) / max(1, len(rhythm))
|
||||
contour = sum(((a > 0) - (a < 0)) != ((b > 0) - (b < 0)) or abs(a - b) > 2 for a, b in zip(movement, feature.intervals)) / max(1, len(movement))
|
||||
return (timing + contour) / 2
|
||||
|
||||
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)
|
||||
@@ -30,6 +30,9 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
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))
|
||||
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 +95,8 @@ 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])
|
||||
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])}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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.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'):
|
||||
"""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 3 <= len(notes) <= 16:
|
||||
return ()
|
||||
first, destination = notes[0], notes[-1]
|
||||
span = harmony.at(first.start)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
try:
|
||||
displacement = types.scale_index(span.scale, destination.pitch.number()) - types.scale_index(span.scale, first.pitch.number())
|
||||
displacements = tuple([displacement])
|
||||
except ValueError:
|
||||
# Enumerate possible degree routes when the destination changes collection;
|
||||
# actual endpoint equality below decides compatibility, not a pitch estimate.
|
||||
displacements = tuple(range(-7, 8))
|
||||
direction = (destination.pitch.number() > first.pitch.number()) - (destination.pitch.number() < first.pitch.number())
|
||||
paths = dict.fromkeys(path for d in displacements for path in library.retrieve(len(notes), d, direction))
|
||||
result = []
|
||||
low, high = generator.fretboard.single_register((register[0] - 7, register[1] + 5))
|
||||
allowed = {n.start: types.permitted(harmony.choices, n.start, n.end) for n in notes}
|
||||
for path in paths:
|
||||
realized = [first]
|
||||
for template, step in zip(notes[1:], path.steps):
|
||||
current = harmony.at(template.start)
|
||||
if not current or not current.scale:
|
||||
break
|
||||
options = advance(current.scale, realized[-1].pitch.number(), step)
|
||||
if not options:
|
||||
break
|
||||
pitch = options[0]
|
||||
if not low <= pitch.number() <= high or pitch.number() % 12 not in allowed[template.start]:
|
||||
break
|
||||
if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number():
|
||||
break
|
||||
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())):
|
||||
break
|
||||
realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None))
|
||||
if len(realized) != len(notes):
|
||||
continue
|
||||
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)
|
||||
identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16]
|
||||
result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, relationship, path.sources))
|
||||
return 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)
|
||||
@@ -78,9 +78,9 @@ 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)
|
||||
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)
|
||||
|
||||
@@ -160,6 +160,23 @@ 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
|
||||
|
||||
@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 +190,9 @@ class MelodicDraft:
|
||||
home: RealizedMotif | None
|
||||
relationship: str
|
||||
joins: tuple[fractions.Fraction, ...] = ()
|
||||
features: tuple[IdentifyingFeature, ...] = ()
|
||||
reference: str = ''
|
||||
reference_motif: Motif | RealizedMotif | None = None
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
@@ -244,6 +264,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:
|
||||
|
||||
Binary file not shown.
@@ -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):
|
||||
@@ -219,7 +220,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,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.acoustic_activity, policy.DEFAULT.electric_activity), (1.125, 1.375))
|
||||
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')
|
||||
Reference in New Issue
Block a user