Compare commits

...

4 Commits

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