This commit is contained in:
khodowany
2026-10-01 16:41:18 -05:00
parent 28fc67fba1
commit f5861fcdf6
22 changed files with 1473 additions and 122 deletions
+5 -3
View File
@@ -95,18 +95,20 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live
`data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts. `data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts.
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build uses these collections, the song melody, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with transformed motifs, connected melodic lines, varying activity, sustained arrivals, and planned space. A passage plan groups roughly four to eight bars, connects opening, intermediate and terminal chord-tone anchors, and carries realized motif identity across smaller search cells and phrase boundaries. A bounded search considers the next phrase before committing. Exact quotation is available but is no longer the default. The other guitar provides solo-aware accompaniment. Scale changes appear above the solo notation and TAB; detailed build-only phrase reports live in `generated_assets/analysis/`. No solo assignments are stored in song XML. Invalid patterns or a harmony without an exact permitted scale fail before replacing the previous output. `data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build uses these collections, the song melody, the accepted paired pitch/rhythm references in `data/lead_phrases`, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with transformed motifs, connected melodic lines, varying activity, sustained arrivals, and planned space. A passage plan groups roughly four to eight bars, connects opening, intermediate and terminal chord-tone anchors, and carries realized motif identity across smaller search cells and phrase boundaries. A bounded search considers the next phrase before committing. Exact quotation is available but is no longer the default. The other guitar provides solo-aware accompaniment. Scale changes appear above the solo notation and TAB; detailed build-only phrase reports live in `generated_assets/analysis/`. No solo assignments are stored in song XML. Invalid patterns or a harmony without an exact permitted scale fail before replacing the previous output.
Solo harmonic function is distinct from scale permission. Basic members respect the chord's suspensions, alterations and omissions; symbol-specified extensions require a planned color when sustained, and a non-chord slash bass is not automatically a melodic destination. Ordinary notes lasting two quarter-note beats or longer must remain chord members across every overlapping chord, even when the scale stays unchanged. Short scale motion remains available. A prepared suspension must resolve by the planned deadline to an adjacent chord member. These checks operate on complete sounding events, so notation ties do not change harmonic treatment or attack counts. Protected quotations keep their musical content and report exposed tensions. Solo harmonic function is distinct from scale permission. Basic members respect the chord's suspensions, alterations and omissions; symbol-specified extensions require a planned color when sustained, and a non-chord slash bass is not automatically a melodic destination. Ordinary notes lasting two quarter-note beats or longer must remain chord members across every overlapping chord, even when the scale stays unchanged. Short scale motion remains available. A prepared suspension must resolve by the planned deadline to an adjacent chord member. These checks operate on complete sounding events, so notation ties do not change harmonic treatment or attack counts. Protected quotations keep their musical content and report exposed tensions.
The composer builds complete timed melodic drafts before selecting elaboration. An identifying rhythm is repeated, extended, displaced, fragmented or answered across the phrase, including across barlines. Extensions can reopen provisional endings; committed anchors and protected quotations remain binding. The original realized home motif and its latest development are retained separately, including into later passages, where continuation, recall and contrast are explicit choices. Several anchor contours and terminal durations are considered with their rhythmic realization. Bounded revision can edit both sides of an internal join, followed by complete harmonic, motif, timing and guitar validation. The composer builds complete timed melodic drafts before selecting elaboration. An identifying rhythm is repeated, extended, displaced, fragmented or answered across the phrase, including across barlines. Extensions can reopen provisional endings; committed anchors and protected quotations remain binding. The original realized home motif and its latest development are retained separately, including into later passages, where continuation, recall and contrast are explicit choices. Several anchor contours and terminal durations are considered with their rhythmic realization. Ordinary invented openings are editable: bounded refinement can replace an entire two-bar idea with another jointly realized pitch/rhythm candidate, then edit both sides of internal joins. Only committed anchors, exact quotations and explicitly selected identifying relationships are protected during elaboration. Full harmonic, timing and guitar validation follows every edit. Development similarity has one weight; it is not also charged as fidelity.
Concrete continuations start from the actual incoming pitch and guitar position. Relative routes from `data/sequences` use exact octave-aware scale indices; a collection change adapts actual pitches without transferring stale degree numbers. Intermediate arrivals permit continuation; terminal arrivals must meet their duration and ending contract. Activity and occupancy targets remain unchanged. Search retains up to 64 pitch states within a musical group, 16 at completed groups, and eight complete phrase alternatives. Join revision considers up to eight proposals in two-beat windows on either side, with at most two sweeps. One-phrase lookahead also permits revisions across an uncommitted connected phrase seam. Up to two further construction batches may be tried without weakening musical contracts. Exhaustion reports the failed continuation or contract, not a claim of musical impossibility. Concrete continuations start from the actual incoming pitch and guitar position. Relative routes from `data/sequences` use exact octave-aware scale indices; a collection change adapts actual pitches without transferring stale degree numbers. Intermediate arrivals permit continuation; terminal arrivals must meet their duration and ending contract. Acoustic/electric activity targets remain 1.125/1.375 attacks per beat, with occupancy goals of 0.75 for active motion and 0.9 for arrivals. Soft comparison allows one attack per evaluated phrase and 0.1 occupancy deviation; these tolerances do not permit accidental silence. Ending candidates combine half-, one-, two- or three-beat holds with zero, half- or one-beat release. Only the chosen hold becomes an arrival region; the final quarter of every passage is no longer forced to slow down. Search retains up to 64 pitch states within a musical group, 16 at completed groups, and eight complete phrase alternatives. The four most promising complete alternatives receive two-bar replacement search. Join revision considers up to eight proposals at the two weakest joins, in two-beat windows on either side, with at most two sweeps. The other complete alternatives remain eligible. Complete runs retain their entire attack rhythm, pitch route and destination; an invalid route is rejected as a unit. Up to two approaches per phrase may be elaborated, using catalog routes and the accepted decorated reference’s approach rhythms. Later edits cannot dismantle selected routes. One-phrase lookahead compares the resulting complete alternatives. Up to two further construction batches may be tried without weakening musical contracts. Exhaustion reports the failed continuation or contract, not a claim of musical impossibility.
For reproducible listening comparisons, `python -m generator.solo.audition --output build/solo_passage_auditions --capture before` captures the previous implementation; run the corresponding `--capture after` after changing the engine, then `--render`. Use the repository's pinned Python runner. The output includes full solos, opening eight-bar and closing four-bar excerpts, MusicXML, passage reports and structured diagnostics. Fixed-backing comparisons keep the original accompaniment; responsive comparisons use the newly arranged accompaniment. These build-only tools do not add runtime data or playback expression. For reproducible listening comparisons, `python -m generator.solo.audition --output build/solo_passage_auditions --capture before` captures the previous implementation; run the corresponding `--capture after` after changing the engine, then `--render`. Use the repository's pinned Python runner. The output includes full solos, opening eight-bar and closing four-bar excerpts, MusicXML, passage reports and structured diagnostics. Fixed-backing comparisons keep the original accompaniment; responsive comparisons use the newly arranged accompaniment. These build-only tools do not add runtime data or playback expression.
For phrase-draft experiments, preserve a `--capture baseline` snapshot before changing the implementation, then run `--compare-phrases` and `--render` in that output directory. This compares complete phrase construction with and without join revision, sharing concrete draft pools when incoming musical states match. It also exports simple drafts, neutral-pitch rhythm diagnostics, motif lineage, selected edits and ending completion. Earlier revisions can change later incoming states and therefore require different draft pools. Capture timings include cache effects; normal production builds initialize their own search. Listening and playing, rather than the diagnostics alone, determine whether the result is better. For phrase-draft experiments, preserve a `--capture baseline` snapshot before changing the implementation, then run `--compare-phrases` and `--render` in that output directory. This compares complete phrase construction with and without join revision, sharing concrete draft pools when incoming musical states match. It also exports simple drafts, neutral-pitch rhythm diagnostics, motif lineage, selected edits and ending completion. Earlier revisions can change later incoming states and therefore require different draft pools. Capture timings include cache effects; normal production builds initialize their own search. Listening and playing, rather than the diagnostics alone, determine whether the result is better.
For lead-melody comparisons, preserve the old generator with `python -m generator.solo.audition --output build/solo_lead_auditions --capture baseline`, then use `python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_lead_auditions/index.html` to compare baseline, initial tune, refined tune and elaboration with fixed backing or alone; the final version also has responsive accompaniment. Intermediate exports belong to the selected production branches and retain their incoming contexts. They are not independent composition runs. The accepted original theme and decorated version are injected literally only in the diagnostic controls; normal generation adapts their timed movement to the current harmony. The controls verify exact sounding pitches and durations through placement, notation and playback, and also expose what refinement/elaboration changes. The temporary reference ZIP is not a build input and its script is never executed.
To inspect generated assets without building the full package, run: To inspect generated assets without building the full package, run:
```sh ```sh
+1
View File
@@ -137,6 +137,7 @@ configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY)
file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/data/*.xml ${CMAKE_CURRENT_SOURCE_DIR}/data/*.xml
${CMAKE_CURRENT_SOURCE_DIR}/data/expected/*.json ${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
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
${VAMP_SHARED_PYTHON_INPUTS} ${VAMP_SHARED_PYTHON_INPUTS}
+630
View File
@@ -0,0 +1,630 @@
{
"description": "Original hand-composed reference, not output from the user engine or a quotation of Autumn Leaves.",
"tempo_bpm": 120,
"meter": "4/4",
"pitch_convention": "sounding",
"time_units": "exact rational quarter-note beats",
"velocity_policy": "lead 80, backing 48; constant within each track; no accents or dynamics",
"scale_regions": [
{
"start": "0",
"end": "16",
"scale": "D Aeolian"
},
{
"start": "16",
"end": "24",
"scale": "D Harmonic Minor"
},
{
"start": "24",
"end": "32",
"scale": "D Aeolian"
}
],
"harmony": [
{
"bar": 1,
"chord": "Gm7",
"required_basic_pitch_classes": [
7,
10,
2,
5
]
},
{
"bar": 2,
"chord": "C7",
"required_basic_pitch_classes": [
0,
4,
7,
10
]
},
{
"bar": 3,
"chord": "Fmaj7",
"required_basic_pitch_classes": [
5,
9,
0,
4
]
},
{
"bar": 4,
"chord": "Bbmaj7",
"required_basic_pitch_classes": [
10,
2,
5,
9
]
},
{
"bar": 5,
"chord": "Em7b5",
"required_basic_pitch_classes": [
4,
7,
10,
2
]
},
{
"bar": 6,
"chord": "A7",
"required_basic_pitch_classes": [
9,
1,
4,
7
]
},
{
"bar": 7,
"chord": "Dm7",
"required_basic_pitch_classes": [
2,
5,
9,
0
]
},
{
"bar": 8,
"chord": "Dm7",
"required_basic_pitch_classes": [
2,
5,
9,
0
]
}
],
"variants": {
"lead_theme": {
"attacks": 31,
"events": [
{
"bar": 1,
"onset_beats": "0",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 1,
"onset_beats": "1",
"duration_beats": "3/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 1,
"onset_beats": "5/2",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 1,
"onset_beats": "3",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 2,
"onset_beats": "4",
"duration_beats": "2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 2,
"onset_beats": "6",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 2,
"onset_beats": "13/2",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 2,
"onset_beats": "7",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 3,
"onset_beats": "8",
"duration_beats": "1",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 3,
"onset_beats": "9",
"duration_beats": "3/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 3,
"onset_beats": "21/2",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 3,
"onset_beats": "11",
"duration_beats": "1",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 4,
"onset_beats": "12",
"duration_beats": "2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 4,
"onset_beats": "14",
"duration_beats": "1",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 4,
"onset_beats": "15",
"duration_beats": "1",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 5,
"onset_beats": "16",
"duration_beats": "3/2",
"pitch": "Bb4",
"midi_pitch": 70
},
{
"bar": 5,
"onset_beats": "35/2",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 5,
"onset_beats": "18",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 5,
"onset_beats": "19",
"duration_beats": "1",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "20",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 6,
"onset_beats": "21",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 6,
"onset_beats": "43/2",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "22",
"duration_beats": "1",
"pitch": "C#4",
"midi_pitch": 61
},
{
"bar": 6,
"onset_beats": "23",
"duration_beats": "1",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 7,
"onset_beats": "24",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 7,
"onset_beats": "25",
"duration_beats": "3/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 7,
"onset_beats": "53/2",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 7,
"onset_beats": "27",
"duration_beats": "1",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 8,
"onset_beats": "28",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 8,
"onset_beats": "57/2",
"duration_beats": "1/2",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 8,
"onset_beats": "29",
"duration_beats": "2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 8,
"onset_beats": "31",
"duration_beats": "1",
"pitch": null,
"midi_pitch": null
}
]
},
"lead_with_runs": {
"attacks": 36,
"events": [
{
"bar": 1,
"onset_beats": "0",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 1,
"onset_beats": "1",
"duration_beats": "3/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 1,
"onset_beats": "5/2",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 1,
"onset_beats": "3",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 2,
"onset_beats": "4",
"duration_beats": "1",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 2,
"onset_beats": "5",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 2,
"onset_beats": "11/2",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 2,
"onset_beats": "6",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 2,
"onset_beats": "13/2",
"duration_beats": "1/2",
"pitch": "Bb4",
"midi_pitch": 70
},
{
"bar": 2,
"onset_beats": "7",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 2,
"onset_beats": "15/2",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 3,
"onset_beats": "8",
"duration_beats": "1",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 3,
"onset_beats": "9",
"duration_beats": "3/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 3,
"onset_beats": "21/2",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 3,
"onset_beats": "11",
"duration_beats": "1",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 4,
"onset_beats": "12",
"duration_beats": "2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 4,
"onset_beats": "14",
"duration_beats": "1",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 4,
"onset_beats": "15",
"duration_beats": "1",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 5,
"onset_beats": "16",
"duration_beats": "3/2",
"pitch": "Bb4",
"midi_pitch": 70
},
{
"bar": 5,
"onset_beats": "35/2",
"duration_beats": "1/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 5,
"onset_beats": "18",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 5,
"onset_beats": "19",
"duration_beats": "1",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "20",
"duration_beats": "1",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 6,
"onset_beats": "21",
"duration_beats": "1/2",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 6,
"onset_beats": "43/2",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "22",
"duration_beats": "1/2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 6,
"onset_beats": "45/2",
"duration_beats": "1/2",
"pitch": "C#4",
"midi_pitch": 61
},
{
"bar": 6,
"onset_beats": "23",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 6,
"onset_beats": "47/2",
"duration_beats": "1/2",
"pitch": "C#4",
"midi_pitch": 61
},
{
"bar": 7,
"onset_beats": "24",
"duration_beats": "1",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 7,
"onset_beats": "25",
"duration_beats": "3/2",
"pitch": "A4",
"midi_pitch": 69
},
{
"bar": 7,
"onset_beats": "53/2",
"duration_beats": "1/2",
"pitch": "G4",
"midi_pitch": 67
},
{
"bar": 7,
"onset_beats": "27",
"duration_beats": "1",
"pitch": "F4",
"midi_pitch": 65
},
{
"bar": 8,
"onset_beats": "28",
"duration_beats": "1/2",
"pitch": "E4",
"midi_pitch": 64
},
{
"bar": 8,
"onset_beats": "57/2",
"duration_beats": "1/2",
"pitch": "C4",
"midi_pitch": 60
},
{
"bar": 8,
"onset_beats": "29",
"duration_beats": "2",
"pitch": "D4",
"midi_pitch": 62
},
{
"bar": 8,
"onset_beats": "31",
"duration_beats": "1",
"pitch": null,
"midi_pitch": null
}
]
}
},
"id": "original_lead_theme",
"acceptance": "User accepted plain theme and runs as references",
"tonic": "D",
"mode": "minor",
"run_windows": [
{
"start": "4",
"end": "8"
},
{
"start": "20",
"end": "24"
}
],
"features": [
{
"id": "leap_return",
"start": "0",
"end": "4",
"relationship": "timed leap and return"
},
{
"id": "restatement",
"start": "8",
"end": "12",
"relationship": "related rhythm and leap with harmonic adaptation"
}
]
}
+3 -1
View File
@@ -22,6 +22,7 @@ import generator.scales.library
import generator.score import generator.score
import generator.summary import generator.summary
import generator.solo.sequences import generator.solo.sequences
import generator.solo.references
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt") RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
@@ -75,13 +76,14 @@ def generate_assets(args: argparse.Namespace) -> None:
scales = generator.scales.library.load_library(data_dir / 'scales') scales = generator.scales.library.load_library(data_dir / 'scales')
report('loading solo interval sequences') report('loading solo interval sequences')
sequences = generator.solo.sequences.load_library(data_dir / 'sequences') sequences = generator.solo.sequences.load_library(data_dir / 'sequences')
references = generator.solo.references.load_library(data_dir / 'lead_phrases')
output_dir.parent.mkdir(parents=True, exist_ok=True) output_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary: with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
staged = pathlib.Path(temporary) / 'assets' staged = pathlib.Path(temporary) / 'assets'
staged.mkdir(parents=True, exist_ok=True) staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged) copy_runtime_fonts(static_assets_dir, staged)
report('generating performance parts') report('generating performance parts')
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences) performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences, references=references)
generator.score.validate_generated_tree(staged) generator.score.validate_generated_tree(staged)
if args.check: if args.check:
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
+2 -2
View File
@@ -136,7 +136,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
reports.append(report) reports.append(report)
return result return result
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library, 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 count = 0
for path in generator.parts.source_paths(source_dir): for path in generator.parts.source_paths(source_dir):
try: try:
@@ -151,7 +151,7 @@ def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Pa
except ValueError as error: except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error raise ValueError(f'{path}, part {part}: {error}') from error
try: 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: except ValueError as error:
raise ValueError(f'{path}: {error}') from error raise ValueError(f'{path}: {error}') from error
for part, result in parts.items(): for part, result in parts.items():
+2 -2
View File
@@ -85,7 +85,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
generator.model.validate_document(document) generator.model.validate_document(document)
return document['staves'][0]['measures'] 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.""" """Compose once per song, replacing only already-placed solo section windows."""
analysis = generator.solo.analysis.analyze(source) analysis = generator.solo.analysis.analyze(source)
sections = regions(source) sections = regions(source)
@@ -103,7 +103,7 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']] sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
if sounding: if sounding:
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding) cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, context=(contexts or {}).get(part)) solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, references=references, stage=stage, context=(contexts or {}).get(part))
if solos is not None: if solos is not None:
solos.append(solo) solos.append(solo)
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part) foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
+36 -3
View File
@@ -28,6 +28,8 @@ import generator.solo.arrangement
import generator.solo.report import generator.solo.report
import generator.solo.sequences import generator.solo.sequences
import generator.solo.realization import generator.solo.realization
import generator.solo.references
import generator.solo.harmonic
STAGES = ('baseline', 'A', 'B', 'C', 'D') STAGES = ('baseline', 'A', 'B', 'C', 'D')
@@ -81,12 +83,21 @@ def capture(root, stage, libraries=None, contexts=None):
if destination.exists(): if destination.exists():
raise ValueError(f'{destination}: snapshot already exists; choose a new output root') raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
rhythms, scales, sequences = libraries or resources() 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: try:
result['policy'] = importlib.import_module('generator.solo.policy').describe() result['policy'] = importlib.import_module('generator.solo.policy').describe()
except ModuleNotFoundError: except ModuleNotFoundError:
result['policy'] = {'baseline': True} result['policy'] = {'baseline': True}
for path in generator.parts.source_paths(pathlib.Path('data/songs')): 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() started = time.monotonic()
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path)) document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
plans = generator.arrange.generate.plan_arrangement(document, rhythms) plans = generator.arrange.generate.plan_arrangement(document, rhythms)
@@ -96,7 +107,7 @@ def capture(root, stage, libraries=None, contexts=None):
if shared: if shared:
for context in shared.values(): for context in shared.values():
context.diagnostics = generator.solo.realization.SearchContext().diagnostics 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} song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
regions = generator.solo.arrangement.regions(document) regions = generator.solo.arrangement.regions(document)
for solo, index in zip(solos, (3, 4)): for solo, index in zip(solos, (3, 4)):
@@ -123,8 +134,30 @@ def capture(root, stage, libraries=None, contexts=None):
generator.fretboard.validate_document(view) generator.fretboard.validate_document(view)
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view}) drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
song['solos'][solo.part]['draft_documents'] = drafts song['solos'][solo.part]['draft_documents'] = drafts
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True) stages = {}
for stage_name in ('initial', 'refined'):
if not all(getattr(p, stage_name) for p in solo.phrases):
continue
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
placed = generator.solo.placement.place(notes, solo.tempo)
if placed is None:
raise ValueError(stage_name + ': selected theme cannot be placed')
sounded = generator.solo.types.sounding_notes(placed[1])
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
if not checked.valid:
raise ValueError(stage_name + ': ' + checked.error)
view = excerpt(parts[solo.part], first, count)
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part)
generator.solo.notation.replace_region(view, 0, foreground)
generator.model.validate_document(view)
generator.fretboard.validate_document(view)
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure}
song['solos'][solo.part]['lead_stages'] = stages
print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True)
result['songs'][path.stem] = song 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.mkdir(parents=True)
(destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n') (destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n')
+3 -1
View File
@@ -15,7 +15,7 @@ import generator.solo.policy
import generator.solo.realization import generator.solo.realization
import generator.solo.types import generator.solo.types
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None): def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated'):
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial) timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo') region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
choices = generator.scales.selection.select(timeline, region, scales) choices = generator.scales.selection.select(timeline, region, scales)
@@ -31,6 +31,8 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices) choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices)
context = context if context is not None else generator.solo.realization.SearchContext() context = context if context is not None else generator.solo.realization.SearchContext()
context.revise = revise context.revise = revise
context.references = references
context.stage = stage
def realize(plan, previous): def realize(plan, previous):
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1): for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
+61 -17
View File
@@ -14,6 +14,7 @@ import generator.solo.policy
import generator.solo.scoring import generator.solo.scoring
import generator.solo.sequences import generator.solo.sequences
import generator.solo.types import generator.solo.types
import generator.solo.references
F = fractions.Fraction F = fractions.Fraction
@@ -77,7 +78,7 @@ def templates(plan, reference, library, meter):
result.append((reference.id, source, length)) result.append((reference.id, source, length))
choices = catalog(library, meter) choices = catalog(library, meter)
ranked = generator.solo.sequences.diverse(choices, 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) lambda item: (len(item[1]), bool(item[1][0][0]), item[2]), 4)
result.extend(ranked) result.extend(ranked)
return tuple(result) 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) terminal = next((a for a in anchors if a.role == 'terminal'), None)
if terminal is None or terminal.hold_until is None: if terminal is None or terminal.hold_until is None:
return tuple([anchors]) return tuple([anchors])
finish = terminal.hold_until passage_end = plan.passage_end or plan.end
last = next((c for c in choices if c.span.start < finish <= c.span.end), None) 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 = [] result = []
for release in (F(), F(1, 2), F(1)):
finish = harmonic_end - release
last = next((c for c in choices if c.span.start < finish <= c.span.end), None)
for duration in (F(1, 2), F(1), F(2), F(3)): for duration in (F(1, 2), F(1), F(2), F(3)):
start = finish - duration 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): 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 continue
changed = dataclasses.replace(terminal, earliest=start, latest=start, duration=duration) changed = dataclasses.replace(terminal, earliest=start, latest=start, duration=duration, hold_until=finish)
result.append(tuple(changed if a.id == terminal.id else a for a in anchors)) result.append(tuple(changed if a.id == terminal.id else a for a in anchors))
return tuple(result) or tuple([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): def align_anchors(slots, anchors, plan):
"""Choose arrival timing with the rhythm, before assigning any free pitches.""" """Choose arrival timing with the rhythm, before assigning any free pitches."""
slots = list(slots) slots = list(slots)
@@ -172,10 +207,12 @@ def construct(plan, reference, relationship, template, anchors, variant, choices
finish = plan.end - plan.release finish = plan.end - plan.release
slots, groups, cursor = (), [], plan.start slots, groups, cursor = (), [], plan.start
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference') protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
schedule = (('repeat', 'extend', 'answer'), ('extend', 'displace', 'recall'))[variant % 2]
while cursor < finish: while cursor < finish:
index = len(groups) 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) activity = next((p for p in plan.passages if p.start <= cursor < p.end), None)
rate = F(len(pattern), 1) / period 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(2) if activity and activity.activity < F(3, 4) else F(1, 2) if activity and activity.activity > rate + F(1, 4) and index else F(1)
@@ -250,7 +287,7 @@ def construct(plan, reference, relationship, template, anchors, variant, choices
identity = generator.solo.policy.stable_rank(plan.seed, (identifier, variant, anchors, relationship, octave))[:12] 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) 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) 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([()]) anchors = (incoming.selected_anchors,) if continuing and incoming.selected_anchors else plan.anchor_options or tuple([()])
references = [(plan.motif, 'statement')] references = [(plan.motif, 'statement')]
@@ -262,6 +299,8 @@ def rhythms(plan, incoming, measures, choices, library, batch=0):
references.append((plan.motif, 'contrast')) references.append((plan.motif, 'contrast'))
if incoming.themes and incoming.themes[0].id != incoming.home.id: if incoming.themes and incoming.themes[0].id != incoming.home.id:
references.append((incoming.themes[0], 'recall')) 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() elapsed = F()
meter = measures[0]['time_signature'] meter = measures[0]['time_signature']
for measure in measures: for measure in measures:
@@ -273,28 +312,29 @@ def rhythms(plan, incoming, measures, choices, library, batch=0):
result = [] result = []
for reference, relationship in references: for reference, relationship in references:
available_templates = templates(plan, reference, library, meter) available_templates = templates(plan, reference, library, meter)
if isinstance(reference, generator.solo.types.RealizedMotif): if isinstance(reference, generator.solo.types.RealizedMotif) or reference.origin == 'accepted reference profile':
available_templates = available_templates[:1] available_templates = tuple([(reference.id, tuple((n.start, n.end) for n in reference.notes), reference.duration)])
for template in available_templates: for template in available_templates:
for backbone in anchors: for backbone in anchors:
endings = (backbone,) if continuing else ending_options(backbone, plan, choices) endings = (backbone,) if continuing else ending_options(backbone, plan, choices)
for ending in endings: for ending in endings:
for variant in range(2): for variant in range(2):
for octave in ((0, 1, -1, 2, -2) if plan.treatment in ('quote', 'exact_quote') else tuple([0])): 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: if rhythm:
result.append(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): 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) expected = float(plan.activity * (plan.end - plan.start))
features = evaluation.features(dummy) # Broad rhythm feasibility only. Pitch/rhythm relationships are evaluated
return features['activity'] * 4 + features['occupancy'] * 4, generator.solo.policy.stable_rank(plan.seed, rhythm.id) # 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): def family(rhythm):
pitches = tuple(a.pitch.number() for a in rhythm.anchors) 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:]))) 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 limit = generator.solo.policy.DEFAULT.draft_frontier
return generator.solo.sequences.diverse(result, rank, family, limit * (batch + 1))[limit * batch:] return generator.solo.sequences.diverse(result, rank, family, limit * (batch + 1))[limit * batch:]
@@ -326,6 +366,9 @@ def contract_valid(draft, notes):
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(core, core[1:])) 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)]: if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]:
return False, f'{transform.id}: identifying contour changed' return False, f'{transform.id}: identifying contour changed'
for feature in draft.features:
if generator.solo.references.feature_error(feature, local_sounding(notes)):
return False, f'{feature.id}: selected identifying relationship changed'
return True, '' return True, ''
def relationship_features(draft, notes): def relationship_features(draft, notes):
@@ -338,8 +381,9 @@ def relationship_features(draft, notes):
group = [by_id[i] for i in transform.events if i in by_id] 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:])) 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)) 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'): reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
development.append(generator.solo.scoring.motif_fidelity(group[:3], draft.home, 'opening_reference')) if reference and transform.operation not in ('close', 'fragment', 'broaden_ending'):
development.append(generator.solo.references.relationship(group, reference))
joins = [] joins = []
for time in draft.joins: for time in draft.joins:
left = next((n for n in reversed(notes) if n.start < time), None) left = next((n for n in reversed(notes) if n.start < time), None)
+15 -3
View File
@@ -52,13 +52,25 @@ class Harmony:
def __init__(self, choices): def __init__(self, choices):
self.choices = choices self.choices = choices
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices) self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
self._at = {}
self._segments = {}
self._members = {}
self.evaluations = {}
def at(self, time): 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): 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): def members(self, start, end):
key = (start, end)
if key not in self._members:
spans = [s for s in self.spans if s.start < end and s.end > start] spans = [s for s in self.spans if s.start < end and s.end > start]
return frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset() self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
return self._members[key]
def function(self, number, span): def function(self, number, span):
pitch = number % 12 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' 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'
+155
View File
@@ -0,0 +1,155 @@
"""Audition selected lead stages and accepted controls through the normal output path."""
from __future__ import annotations
import argparse
import copy
import dataclasses
import fractions
import html
import json
import pathlib
import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.parts
import generator.playback
import generator.scales.timeline
import generator.scales.selection
import generator.solo.audition as audition
import generator.solo.drafts as drafts
import generator.solo.harmonic as harmonic
import generator.solo.notation as notation
import generator.solo.placement as placement
import generator.solo.realization as realization
import generator.solo.references as references
import generator.solo.types as types
F = fractions.Fraction
def document_for(template, notes, tempo, identifier):
placed = placement.place(notes, tempo)
if placed is None:
raise ValueError(identifier + ': no valid guitar placement')
document = copy.deepcopy(template)
notation.replace_region(document, 0, notation.measures(document['staves'][0]['measures'], placed[1], identifier))
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
timeline = generator.playback.compile_timeline(document)
expected = [(n.start, n.end - n.start, n.pitch.number()) for n in types.sounding_notes(placed[1])]
actual = [(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in timeline['events']]
if actual != expected:
raise ValueError(identifier + ': notation/playback changed the sounding melody')
return document
def controls(song, libraries):
"""Literal reference injection is restricted to this diagnostic, never production."""
_, scales, sequences = libraries
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
solo = song['solos']['acoustic']
measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']]
choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales)
harmony = harmonic.Harmony(choices)
template = audition.excerpt(song['parts']['acoustic'], solo['first'], 8)
result = []
for label, source in (('plain', reference.notes), ('runs', reference.decorated)):
checked = harmony.evaluate(source, until=reference.duration, complete=True)
if not checked.valid:
raise ValueError('accepted control: ' + checked.error)
theme = types.Motif(reference.id, source, reference.duration, tuple(c for _, c in reference.harmony), 'accepted control')
groups = tuple(types.Transformation(f'control_{i}', reference.id, 'control_0', 'statement' if i == 0 else 'answer', F(i * 16), F((i + 1) * 16), tuple(n.event_id for n in source if i * 16 <= n.start < (i + 1) * 16), tuple((n.start - i * 16, n.end - i * 16) for n in source if i * 16 <= n.start < (i + 1) * 16), preserves=(), source=reference.id) for i in range(2))
final = source[-1]
terminal = types.Anchor('accepted_terminal', final.start, final.start, final.pitch, frozenset({final.pitch.number() % 12}), (55, 79), final.end - final.start, 'terminal', 'Dm', hold_until=final.end)
home = realization.make_identity('control_0', reference.id, source[:len(groups[0].events)], F(), F(16), 'statement', harmony)
draft = types.MelodicDraft('accepted_' + label, F(), reference.duration, source, groups, (final.event_id,), (), (terminal,), home, 'statement', (F(16),), references.identifying_features(source, reference.id), reference.id, theme)
plan = types.PhrasePlan('accepted_' + label, F(), reference.duration, theme, 'statement', 'opening_reference', None, (55, 79), F(9, 8), F(1), passage_id='control', passage_start=F(), passage_end=reference.duration)
incoming = types.ContinuationState('')
phrase = realization.checked_phrase(plan, draft, source, (), incoming, harmony, F(solo['tempo']))
if phrase is None:
raise ValueError('accepted control rejected by phrase validation')
context = realization.SearchContext()
refined = realization.refine(phrase, (phrase,), incoming, harmony, sequences, F(solo['tempo']), context)
elaborated = realization.elaborate(refined, incoming, harmony, sequences, F(solo['tempo']), context)
for stage, notes in (('injected', source), ('processed', realization.emitted_notes(elaborated))):
identifier = 'accepted_' + label + '_' + stage
checked = harmony.evaluate(notes, until=reference.duration, complete=True)
if not checked.valid:
raise ValueError(identifier + ': ' + checked.error)
result.append({'id': identifier, 'document': document_for(template, notes, F(solo['tempo']), identifier), 'attacks': len(notes), 'harmonic_exposure': checked.exposure, 'changed_events': sum((a.start, a.end, a.pitch) != (b.start, b.end, b.pitch) for a, b in zip(source, notes)) + abs(len(source) - len(notes)), 'edits': [e.kind for e in elaborated.edits] if stage == 'processed' else [], 'acceptance': reference.acceptance, 'reference_version': reference.version})
return result
def render(root):
baseline = json.loads((root / 'baseline/snapshot.json').read_text())
current = json.loads((root / 'elaborated/snapshot.json').read_text())
cache, clips = {}, []
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Initial and refined are intermediate versions of the selected production branches, with the same incoming context. They are not independently rerun compositions.'}
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the tune before and after refinement, then hear whether the runs preserve it. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
def clip(prefix, foreground, tempo, backing=()):
path = root / (prefix + '.wav')
path.parent.mkdir(parents=True, exist_ok=True)
samples = audition.mix((foreground, *backing), tempo, root, cache)
peak = max(map(abs, samples), default=0) * gain
if peak >= 1:
raise ValueError(prefix + ': shared audition gain would clip')
generator.playback.write_pcm(path, (value * gain for value in samples))
clips.append({'path': path.relative_to(root).as_posix(), 'tempo': str(tempo), 'peak': peak, 'attacks': len(generator.playback.compile_timeline(foreground)['events'])})
return path.relative_to(root).as_posix()
for name, song in current['songs'].items():
old = baseline['songs'][name]
if old['source_sha256'] != song['source_sha256']:
raise ValueError(name + ': source changed during comparison')
for part, solo in song['solos'].items():
other = 'electric' if part == 'acoustic' else 'acoustic'
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Baseline</th><th>Initial tune</th><th>Refined tune</th><th>With runs</th></tr>')
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
first = solo['first'] + offset
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
body.append('<tr><th>' + window + '</th>')
for stage in ('baseline', 'initial', 'refined', 'elaborated'):
foreground = audition.excerpt((old if stage == 'baseline' else song)['parts'][part], first, count) if stage in ('baseline', 'elaborated') else audition.excerpt(solo['lead_stages'][stage]['document'], offset, count)
prefix = f'{stage}/{name}_{part}_{window}'
path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing)
solo_path = clip(prefix + '_solo', foreground, F(solo['tempo']))
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), foreground)
body.append(f'<td><audio controls preload="none" src="{path}"></audio><small><a href="{solo_path}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></small></td>')
if stage == 'elaborated':
clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal')))
body.append('</tr>')
body.append('</table>')
prefix = f'elaborated/{name}_{part}'
(root / (prefix + '.txt')).write_text(solo['report'])
(root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n')
body.append(f'<p><a href="{prefix}.txt">Passage report</a> · <a href="{prefix}.json">Anchors, motif lineage, stages and routes</a> · <a href="{prefix}_full_responsive.wav">Full solo with responsive backing</a></p>')
print('Rendered', name, part, flush=True)
song = current['songs']['autumn_leaves']
solo = song['solos']['acoustic']
backing = tuple(audition.excerpt(baseline['songs']['autumn_leaves']['parts'][p], solo['first'], 8) for p in ('electric', 'vocal'))
body.append('<h2>Accepted reference controls</h2><p>The exact accepted notes enter the ordinary validation, guitar placement, notation and playback path. Processed controls additionally run refinement and elaboration. Literal injection is confined to these controls.</p>')
results = controls(song, audition.resources())
manifest['controls'] = [{k: v for k, v in r.items() if k != 'document'} for r in results]
for control in results:
prefix = 'controls/' + control['id']
path = clip(prefix + '_fixed', control['document'], F(solo['tempo']), backing)
single = clip(prefix + '_solo', control['document'], F(solo['tempo']))
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), control['document'])
body.append(f'<p>{control["id"]}: <audio controls preload="none" src="{path}"></audio> <a href="{single}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></p>')
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_lead_auditions'))
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument('--capture', action='store_true')
action.add_argument('--render', action='store_true')
args = parser.parse_args()
if args.capture:
audition.capture(args.output, 'elaborated')
else:
render(args.output)
if __name__ == '__main__':
main()
+5 -7
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}) 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] 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:])) spans = list(zip(boundaries, boundaries[1:]))
arrival_rate = fractions.Fraction(3, 8) desired = activity * (end - start)
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))
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans[:-1]))
result = [] result = []
for i, (a, b) in enumerate(spans): for i, (a, b) in enumerate(spans):
arrival = i == len(spans) - 1 rate = fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
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.active_occupancy
occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy result.append(generator.solo.types.ActivitySpan(a, b, 'active', rate, fractions.Fraction(str(occupancy))))
result.append(generator.solo.types.ActivitySpan(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
if release: if release:
result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction())) result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
return tuple(result) return tuple(result)
+5 -1
View File
@@ -11,7 +11,7 @@ class Policy:
electric_activity: float = 1.375 electric_activity: float = 1.375
active_occupancy: float = 0.75 active_occupancy: float = 0.75
arrival_occupancy: float = 0.9 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 beam_width: int = 64
extensions: int = 32 extensions: int = 32
retrieval: int = 32 retrieval: int = 32
@@ -26,7 +26,11 @@ class Policy:
phrase_alternatives: int = 8 phrase_alternatives: int = 8
join_alternatives: int = 8 join_alternatives: int = 8
revision_sweeps: int = 2 revision_sweeps: int = 2
revision_joins: int = 2
refinement_alternatives: int = 4
join_window: float = 2.0 join_window: float = 2.0
activity_deadband: float = 1.0
occupancy_deadband: float = 0.1
# Preserve the former default root seeds while decoupling them from policy edits. # Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1' SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+190 -72
View File
@@ -6,6 +6,8 @@ import functools
import time import time
import generator.solo.drafts import generator.solo.drafts
import generator.solo.references
import generator.solo.routes
import generator.fretboard import generator.fretboard
import generator.model import generator.model
@@ -354,6 +356,8 @@ class SearchContext:
resources: dict = dataclasses.field(default_factory=dict) resources: dict = dataclasses.field(default_factory=dict)
revise: bool = True revise: bool = True
batch: int = 0 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': {}}) 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): 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() expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
anchor = next_anchor[index] anchor = next_anchor[index]
target = anchor.pitch.number() if anchor else expected target = anchor.pitch.number() if anchor else expected
route = expected # Theme invention follows a complete paired rhythmic/interval idea.
if prior and anchor and span and span.scale: # Authored sequence routes are instantiated as whole alternatives later.
try: route = expected if not anchor else target
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
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3] ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
if available: if available:
bridge = min(available, key=lambda p: (abs(p.number() - route), p)) 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 direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
intended = (step > 0) - (step < 0) intended = (step > 0) - (step < 0)
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced) 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 jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note 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) 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 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 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 () 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) note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
path = (*notes, note) 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) identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
if home is None: if home is None:
home = identity 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:])) 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. # Closing simplification explicitly releases the characteristic core.
preserves = () if group.operation == 'close' else ('rhythm', 'opening_contour') preserves = ()
if plan.treatment == 'rhythmic_reference': if plan.treatment == 'rhythmic_reference':
preserves = ('rhythm',) 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)) 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: if note:
structural.append(note.event_id) structural.append(note.event_id)
joins = tuple(t.start for t in transforms[1:]) 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=()): def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
valid, _ = generator.solo.drafts.contract_valid(draft, notes) valid, _ = generator.solo.drafts.contract_valid(draft, notes)
@@ -524,14 +516,17 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
return None return None
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id) 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) 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): if not evaluation.feasible(score_notes):
return None return None
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0)) 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)) features.update(generator.solo.drafts.relationship_features(draft, notes))
# Fidelity is checked against explicit transformed identities, not against # Fidelity is checked against explicit transformed identities, not against
# the source head as one undifferentiated phrase-long quotation. # the source head as one undifferentiated phrase-long quotation.
features['fidelity'] = features['development'] features.pop('fidelity', None)
features['harmonic_exposure'] = checked.exposure features['harmonic_exposure'] = checked.exposure
intended_repeats = 0 intended_repeats = 0
if draft.home: if draft.home:
@@ -589,32 +584,17 @@ def emitted_notes(phrase):
def window_variants(phrase, low, high, harmony, sequences, kind): def window_variants(phrase, low, high, harmony, sequences, kind):
"""Two fixed outside endpoints bound edits to the existing melody.""" """Two fixed outside endpoints bound edits to the existing melody."""
notes = emitted_notes(phrase) 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] 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] editable = [i for i in inside if notes[i].event_id not in protected]
if not editable: if not editable:
return () return ()
candidates = [] candidates = []
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1) 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. # Compare complete routes through both fixed endpoints. Reject an invalid
if 1 < right - left < 16: # path as a unit; never substitute nearest pitches for individual steps.
scale = harmony.at(notes[left].start).scale for route in generator.solo.routes.candidates(notes[left:right + 1], protected, harmony, sequences, phrase.plan.register):
try: candidates.append(((*notes[:left], *route.notes, *notes[right + 1:]), 'complete route ' + ' + '.join(route.sources)))
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)))
for index in editable: for index in editable:
note = notes[index] note = notes[index]
if index + 1 < len(notes) and notes[index + 1].start <= high: if index + 1 < len(notes) and notes[index + 1].start <= high:
@@ -625,20 +605,6 @@ def window_variants(phrase, low, high, harmony, sequences, kind):
# The tail and pickup move together, preserving total sounding time. # The tail and pickup move together, preserving total sounding time.
time = after.start - fractions.Fraction(1, 2) 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')) 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) return tuple(candidates)
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context): def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
@@ -648,7 +614,7 @@ def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind,
# Keep distinct rhythmic and pitch revisions; no generic unlimited search. # 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) 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: 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 continue
changed_before = tuple(n.event_id for n in original if n not in notes) 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) changed_after = tuple(n.event_id for n in notes if n not in original)
@@ -656,16 +622,155 @@ def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind,
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit)) 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 context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
if revised: 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)))) 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: if chosen is not phrase:
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1 context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
return chosen 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): def elaborate(phrase, incoming, harmony, sequences, tempo, context):
started = time.monotonic() started = time.monotonic()
for transform in phrase.draft.transformations: protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events)))
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, transform.start, transform.end, 'elaboration', context) if phrase.draft.protected:
return phrase
# Elaborate selected approaches in the existing tune, at most two per phrase.
notes = emitted_notes(phrase)
windows = []
for right in range(2, len(notes)):
if notes[right].event_id not in protected and right != len(notes) - 1:
continue
left = next((i for i in range(right - 1, -1, -1) if notes[right].start - notes[i].start >= 4), None)
if left is not None and notes[right].start - notes[left].start <= 4:
windows.append((notes[left].start, notes[right].start))
for low, high in windows[-2:]:
if any(low < r.destination.end and high > r.notes[0].start for r in phrase.routes):
continue
notes = emitted_notes(phrase)
left = next(i for i, n in enumerate(notes) if n.start == low)
right = next(i for i, n in enumerate(notes) if n.start == high)
patterns = [notes[left:right + 1]]
# A full approach rhythm, preserving every committed event. Existing
# silence remains a candidate; short-note motion is never compulsory.
moving = []
timepoint = low
while timepoint < high:
original = next((n for n in notes[left:right] if n.start == timepoint), None)
end = min(high, timepoint + fractions.Fraction(1, 2))
moving.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(timepoint, end, notes[left].pitch, role='run', event_id=f'route_{phrase.draft.id}_{timepoint}'))
timepoint = end
patterns.append((*moving, notes[right]))
for reference in context.references:
for a, b in reference.runs:
if high - low != b - a:
continue
profile = tuple(n for n in reference.decorated if a <= n.start and n.end <= b)
timed = []
for index, event in enumerate(profile):
start, end = low + event.start - a, low + event.end - a
original = next((n for n in notes[left:right] if n.start == start), None)
timed.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(start, end, notes[left].pitch, (reference.id,), 'run', event_id=f'profile_{phrase.draft.id}_{start}'))
if timed:
patterns.append((*timed, notes[right]))
viable = [phrase]
for pattern in dict.fromkeys(patterns):
# Fixed events retain their duration and attack identity as well as pitch.
if any(not any((n.event_id, n.start, n.end) == (p.event_id, p.start, p.end) for p in pattern) for n in notes[left:right + 1] if n.event_id in protected):
continue
for route in generator.solo.routes.candidates(pattern, protected, harmony, sequences, phrase.plan.register, 'approach the theme destination'):
actual = (*notes[:left], *route.notes, *notes[right + 1:])
context.diagnostics['complete_routes_tried'] = context.diagnostics.get('complete_routes_tried', 0) + 1
if not generator.solo.drafts.contract_valid(phrase.draft, actual)[0]:
continue
draft = rebase_draft(phrase.draft, actual, harmony)
edit = generator.solo.types.DraftEdit('complete_route', low, notes[right].end, tuple(n.event_id for n in notes[left:right + 1]), tuple(n.event_id for n in route.notes), ' + '.join(route.sources))
revised = checked_phrase(phrase.plan, draft, actual, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
if revised:
# Retain the refined theme, not the ornamental version, as home.
state = dataclasses.replace(revised.state, home=phrase.state.home, themes=phrase.state.themes)
viable.append(dataclasses.replace(revised, state=state, initial=phrase.initial, refined=phrase.refined, routes=(*phrase.routes, route)))
else:
context.diagnostics['complete_routes_rejected'] = context.diagnostics.get('complete_routes_rejected', 0) + 1
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple(n.pitch for n in emitted_notes(p))))
if chosen is not phrase:
context.diagnostics['complete_routes_selected'] = context.diagnostics.get('complete_routes_selected', 0) + 1
phrase = chosen
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
return phrase return phrase
@@ -674,7 +779,15 @@ def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window)) width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps): for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
before = phrase 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) 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: if phrase == before:
break break
@@ -683,7 +796,7 @@ def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
def revise_pair(first, second, incoming, choices, sequences, tempo, context): def revise_pair(first, second, incoming, choices, sequences, tempo, context):
"""Revise an uncommitted connected phrase seam, then rebuild both states.""" """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 return first, second
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming) incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
harmony = generator.solo.harmonic.Harmony(choices) harmony = generator.solo.harmonic.Harmony(choices)
@@ -722,8 +835,8 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
# library cannot accidentally reuse the identity of a collected object. # library cannot accidentally reuse the identity of a collected object.
context.resources[id(sequences)] = sequences context.resources[id(sequences)] = sequences
context.resources[id(rhythms)] = rhythms context.resources[id(rhythms)] = rhythms
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms)) 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) key = (*pool_key, context.revise, context.stage)
if key in context.results: if key in context.results:
context.diagnostics['cache_hits'] += 1 context.diagnostics['cache_hits'] += 1
return context.results[key] return context.results[key]
@@ -736,10 +849,11 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
selected = context.pools[pool_key] selected = context.pools[pool_key]
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1 context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
else: 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 = [], '' complete, failure = [], ''
for rhythm in constructions: 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 failure = error or failure
context.diagnostics['generated_candidates'] += len(paths) context.diagnostics['generated_candidates'] += len(paths)
for notes, cost, contracts in paths: for notes, cost, contracts in paths:
@@ -750,29 +864,33 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors): 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:])) realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
for actual in realizations: for actual in realizations:
draft = make_draft(rhythm, actual, plan, harmony, incoming) draft = make_draft(rhythm, actual, candidate_plan, harmony, incoming)
phrase = checked_phrase(plan, draft, actual, contracts, incoming, harmony, tempo) phrase = checked_phrase(candidate_plan, draft, actual, contracts, incoming, harmony, tempo)
if phrase: if phrase:
complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost)) complete.append(phrase)
if not complete: 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)') 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 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 = 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 context.pools[pool_key] = selected
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16] 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)) 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 context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
result = [] result = []
for phrase in selected: for index, phrase in enumerate(selected):
if context.revise: if context.stage == 'initial':
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context) 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.append(phrase)
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id))) result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
for phrase in result: for phrase in result:
family = note_family(emitted_notes(phrase)) family = note_family(emitted_notes(phrase))
counts = context.diagnostics['surviving_families'] counts = context.diagnostics['surviving_families']
counts[family] = counts.get(family, 0) + 1 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 context.results[key] = result
return result return result
+115
View File
@@ -0,0 +1,115 @@
"""Accepted, paired pitch/rhythm examples; production uses contextual profiles."""
from __future__ import annotations
import dataclasses
import fractions
import hashlib
import json
import pathlib
import generator.harmony
import generator.model
import generator.solo.types as types
F = fractions.Fraction
@dataclasses.dataclass(frozen=True)
class ReferencePhrase:
id: str
version: str
notes: tuple[types.Note, ...]
decorated: tuple[types.Note, ...]
duration: F
tonic: int
harmony: tuple[tuple[F, str], ...]
features: tuple[tuple[str, F, F], ...]
runs: tuple[tuple[F, F], ...]
acceptance: str
def windows(self):
# Windows retain their parent and context; they are not separate approvals.
result = []
for length in (F(8), F(16)):
for start in range(0, int(self.duration), int(length)):
end = min(self.duration, start + length)
selected = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in self.notes if start <= n.start and n.end <= end)
if len(selected) >= 2:
chords = tuple(chord for time, chord in self.harmony if start <= time < end)
result.append(types.Motif(f'{self.id}:{start}:{end}', selected, end - start, chords, 'accepted reference profile'))
return tuple(result)
def read(path):
raw = path.read_bytes()
data = json.loads(raw)
if data['pitch_convention'] != 'sounding' or not data.get('acceptance'):
raise ValueError(f'{path}: reference requires sounding pitches and explicit acceptance provenance')
def notes(variant):
result, cursor = [], F()
for i, event in enumerate(data['variants'][variant]['events']):
start, duration = F(event['onset_beats']), F(event['duration_beats'])
if start != cursor or duration <= 0:
raise ValueError(f'{path}: reference events must fill exact sequential time')
cursor = start + duration
if event['pitch'] is None:
continue
name = event['pitch']
pitch = types.Pitch(name[0].lower(), F(1 if '#' in name else -1 if 'b' in name else 0), int(name[-1]))
if pitch.number() != event['midi_pitch']:
raise ValueError(f'{path}: inconsistent sounding pitch {name}')
result.append(types.Note(start, cursor, pitch, (data['id'],), event_id=f'{variant}_{i}'))
return tuple(result), cursor
plain, duration = notes('lead_theme')
decorated, other_duration = notes('lead_with_runs')
if duration != other_duration or len(plain) != data['variants']['lead_theme']['attacks'] or len(decorated) != data['variants']['lead_with_runs']['attacks']:
raise ValueError(f'{path}: inconsistent reference variants')
tonic = generator.model.parse_pitch_name(data['tonic'])
number = int(generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter']))
harmony = tuple((F(4 * (h['bar'] - 1)), h['chord']) for h in data['harmony'])
for _, chord in harmony:
generator.harmony.parse_symbol(chord)
return ReferencePhrase(data['id'], hashlib.sha256(raw).hexdigest(), plain, decorated, duration, number, harmony,
tuple((f['id'], F(f['start']), F(f['end'])) for f in data['features']), tuple((F(w['start']), F(w['end'])) for w in data['run_windows']), data['acceptance'])
def load_library(root: pathlib.Path):
return tuple(read(path) for path in sorted(root.glob('*.json')))
def identifying_features(notes, source):
"""Select a characteristic leap followed by return, wherever it occurs."""
notes = tuple(notes)
candidates = []
for i in range(len(notes) - 2):
a, b, c = notes[i:i + 3]
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
group = notes[i:min(len(notes), i + 4)]
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
return tuple(candidates[:1])
def feature_error(feature, notes):
by_id = {n.event_id: n for n in notes}
actual = [by_id.get(identity) for identity in feature.events]
if not all(actual):
return 1.0
first = actual[0].start
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
timing = sum(a != b for a, b in zip(rhythm, feature.rhythm)) / max(1, len(rhythm))
contour = sum(((a > 0) - (a < 0)) != ((b > 0) - (b < 0)) or abs(a - b) > 2 for a, b in zip(movement, feature.intervals)) / max(1, len(movement))
return (timing + contour) / 2
def relationship(notes, reference):
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
notes = tuple(notes)
if len(notes) < 2 or len(reference.notes) < 2:
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = reference.contour()
count = min(len(actual), len(expected))
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
start, ref_start = notes[0].start, reference.notes[0].start
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
return max(evidence, (contour + rhythm) / 2)
+6
View File
@@ -30,6 +30,9 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
if phrase.draft: if phrase.draft:
draft = 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' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
lines.append(f' Lead reference: {draft.reference}; selected features: ' + ', '.join(f.id for f in draft.features))
for route in phrase.routes:
lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}')
for transform in draft.transformations: 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}') 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: for edit in phrase.edits:
@@ -92,5 +95,8 @@ def structure(solo):
entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])} 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: 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.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft}
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
entry['score'] = phrase.score
phrases.append(entry) 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 {'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])}
+76
View File
@@ -0,0 +1,76 @@
"""Instantiate complete authored routes; a failed path is never repaired note by note."""
from __future__ import annotations
import dataclasses
import fractions
import generator.fretboard
import generator.solo.policy
import generator.solo.sequences
import generator.solo.types as types
def advance(scale, previous, step):
try:
degree = types.scale_index(scale, previous)
return tuple([types.scale_pitch(scale, degree + step)])
except ValueError:
# At a changed collection count actual intervening scale tones from the
# incoming sound. No old degree number is transferred to the new scale.
if not step:
return ()
nearby = sorted((types.scale_pitch(scale, i) for i in range(-28, 35)), key=lambda p: p.number())
outgoing = [p for p in nearby if (p.number() - previous) * step > 0]
if step < 0:
outgoing.reverse()
return tuple(outgoing[abs(step) - 1:abs(step)])
def candidates(notes, fixed, harmony, library, register, relationship='connect theme'):
"""Every returned candidate includes its entry, complete rhythm and destination.
Callers validate the whole surrounding sounding context and guitar path.
This function applies exact scale paths, fixed events and range constraints.
"""
if not 3 <= len(notes) <= 16:
return ()
first, destination = notes[0], notes[-1]
span = harmony.at(first.start)
if not span or not span.scale:
return ()
try:
displacement = types.scale_index(span.scale, destination.pitch.number()) - types.scale_index(span.scale, first.pitch.number())
displacements = tuple([displacement])
except ValueError:
# Enumerate possible degree routes when the destination changes collection;
# actual endpoint equality below decides compatibility, not a pitch estimate.
displacements = tuple(range(-7, 8))
direction = (destination.pitch.number() > first.pitch.number()) - (destination.pitch.number() < first.pitch.number())
paths = dict.fromkeys(path for d in displacements for path in library.retrieve(len(notes), d, direction))
result = []
low, high = generator.fretboard.single_register((register[0] - 7, register[1] + 5))
allowed = {n.start: types.permitted(harmony.choices, n.start, n.end) for n in notes}
for path in paths:
realized = [first]
for template, step in zip(notes[1:], path.steps):
current = harmony.at(template.start)
if not current or not current.scale:
break
options = advance(current.scale, realized[-1].pitch.number(), step)
if not options:
break
pitch = options[0]
if not low <= pitch.number() <= high or pitch.number() % 12 not in allowed[template.start]:
break
if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number():
break
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())):
break
realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None))
if len(realized) != len(notes):
continue
route_spans = tuple((max(first.start, c.span.start), min(destination.end, c.span.end), c.scale.label()) for c in harmony.choices if c.scale and c.span.start < destination.end and c.span.end > first.start)
identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16]
result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, relationship, path.sources))
return generator.solo.sequences.diverse(result,
lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id),
lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))),
generator.solo.policy.DEFAULT.join_alternatives)
+2 -2
View File
@@ -78,9 +78,9 @@ class Evaluation:
for start, end, active, length, expected, occupied_goal, tolerance in self.windows: 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) 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) 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)) activity += length * min(1, deviation / max(1, expected))
occupancy += length * abs(occupied / length - occupied_goal) occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
start, end = float(self.plan.start), float(self.until) start, end = float(self.plan.start), float(self.until)
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded] 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) register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
+23
View File
@@ -160,6 +160,23 @@ class DraftEdit:
added: tuple[str, ...] added: tuple[str, ...]
source: str = '' source: str = ''
@dataclasses.dataclass(frozen=True)
class IdentifyingFeature:
id: str
events: tuple[str, ...]
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
intervals: tuple[int, ...]
source: str
@dataclasses.dataclass(frozen=True)
class RouteCandidate:
id: str
notes: tuple[Note, ...]
destination: Note
spans: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
relationship: str
sources: tuple[str, ...]
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class MelodicDraft: class MelodicDraft:
id: str id: str
@@ -173,6 +190,9 @@ class MelodicDraft:
home: RealizedMotif | None home: RealizedMotif | None
relationship: str relationship: str
joins: tuple[fractions.Fraction, ...] = () joins: tuple[fractions.Fraction, ...] = ()
features: tuple[IdentifyingFeature, ...] = ()
reference: str = ''
reference_motif: Motif | RealizedMotif | None = None
def rests(self): def rests(self):
cursor, result = self.start, [] cursor, result = self.start, []
for note in self.notes: for note in self.notes:
@@ -244,6 +264,9 @@ class Phrase:
anchors: tuple[Anchor, ...] = () anchors: tuple[Anchor, ...] = ()
draft: MelodicDraft | None = None draft: MelodicDraft | None = None
edits: tuple[DraftEdit, ...] = () edits: tuple[DraftEdit, ...] = ()
initial: MelodicDraft | None = None
refined: MelodicDraft | None = None
routes: tuple[RouteCandidate, ...] = ()
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Solo: class Solo:
Binary file not shown.
+3 -2
View File
@@ -89,7 +89,8 @@ class DraftRepresentationTests(unittest.TestCase):
plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16)) plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16))
endings = generator.solo.drafts.ending_options(draft.anchors, plan, choices) 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.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): def test_revision_cannot_change_terminal_hold_or_identifying_rhythm(self):
draft, _ = illustration() draft, _ = illustration()
for index in (2, len(draft.notes) - 1): for index in (2, len(draft.notes) - 1):
@@ -219,7 +220,7 @@ class DraftCompositionTests(unittest.TestCase):
self.assertTrue(generator.solo.drafts.contract_valid(phrase.draft, notes)[0]) self.assertTrue(generator.solo.drafts.contract_valid(phrase.draft, notes)[0])
self.assertTrue(all(1 <= n.position.fret <= 12 for n in notes)) self.assertTrue(all(1 <= n.position.fret <= 12 for n in notes))
for edit in phrase.edits: 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): def test_join_comparison_reuses_identical_upstream_draft_pool(self):
plan = self.solo.phrases[0].plan plan = self.solo.phrases[0].plan
measures = tests.test_solo.song(count=8)['staves'][0]['measures'] measures = tests.test_solo.song(count=8)['staves'][0]['measures']
+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.acoustic_activity, policy.DEFAULT.electric_activity), (1.125, 1.375))
def test_generated_opening_is_editable_until_a_feature_is_committed(self):
original = notes(('c', 'g', 'f', 'e'))
draft = types.MelodicDraft('invented', F(), F(2), original, (), (), (), (), None, 'statement')
changed = (dataclasses.replace(original[0], pitch=fixtures.note(0, 1, 'e').pitch), *original[1:])
self.assertTrue(drafts.contract_valid(draft, changed)[0])
selected = dataclasses.replace(draft, features=references.identifying_features(original, 'selected'))
self.assertFalse(drafts.contract_valid(selected, changed)[0])
def test_ending_activity_only_broadens_the_selected_hold(self):
plan = dataclasses.replace(fixtures.plan(), end=F(16), release=F())
anchor = dataclasses.replace(passages.anchor(), earliest=F(15), latest=F(15), duration=F(1), hold_until=F(16))
chosen = drafts.ending_plan(plan, (anchor,))
arrivals = [p for p in chosen.passages if p.intent == 'sustained_arrival']
self.assertEqual([(p.start, p.end) for p in arrivals], [(15, 16)])
self.assertTrue(all(p.intent == 'active' for p in chosen.passages if p.end <= 15))
def test_zero_passage_origin_retains_the_full_activity_budget(self):
plan = dataclasses.replace(fixtures.plan(), start=F(16), end=F(32), passage_start=F(), passage_end=F(32), activity=F(9, 8), passages=(types.ActivitySpan(F(), F(32), 'active', F(9, 8), F(3, 4)),))
anchor = dataclasses.replace(passages.anchor(), earliest=F(30), latest=F(30), duration=F(2), hold_until=F(32))
chosen = drafts.ending_plan(plan, (anchor,))
self.assertEqual(sum(p.activity * (p.end - p.start) for p in chosen.passages), F(36))
class CompleteRouteTests(unittest.TestCase):
def test_complete_route_reaches_endpoint_without_nearest_note_repairs(self):
harmony = harmonic.Harmony(passages.choices(('C7',)))
library = sequences.Library((sequences.Sequence('descending', (-1, -1)), sequences.Sequence('wrong', (1, 1))))
template = notes(('g', 'c', 'e'))
result = routes.candidates(template, {'n0', 'n2'}, harmony, library, (55, 79))
self.assertEqual(len(result), 1)
self.assertEqual([n.pitch.number() for n in result[0].notes], [67, 65, 64])
self.assertEqual(result[0].sources, ('descending',))
self.assertEqual(result[0].destination, template[-1])
self.assertTrue(harmony.evaluate(result[0].notes).valid)
def test_route_cannot_repair_a_forbidden_fixed_pitch(self):
harmony = harmonic.Harmony(passages.choices(('C7',)))
library = sequences.Library((sequences.Sequence('descending', (-1, -1)),))
self.assertEqual(routes.candidates(notes(('g', 'c', 'e')), {'n1'}, harmony, library, (55, 79)), ())
def test_collection_change_preserves_sounding_coordinate(self):
minor = tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')
aeolian = tests.scale_fixtures.library().scale('major', 6, 'D')
# C-sharp is absent in Aeolian: the first rising degree is actual D,
# rather than reinterpreting C-sharp's old harmonic-minor index.
self.assertEqual(routes.advance(aeolian, 61, 1)[0].number(), 62)
choices = list(passages.choices(('A7', 'Dm7'), duration=1, scale=minor))
choices[1] = dataclasses.replace(choices[1], scale=aeolian)
harmony = harmonic.Harmony(tuple(choices))
template = (dataclasses.replace(fixtures.note(0, F(1, 2), 'e'), event_id='a'), dataclasses.replace(fixtures.note(F(1, 2), 1, 'c', 1), event_id='b'), dataclasses.replace(fixtures.note(1, F(3, 2), 'd'), event_id='c'))
library = sequences.Library((sequences.Sequence('resolution', (-2, 1)),))
result = routes.candidates(template, {'a', 'c'}, harmony, library, (55, 79))
self.assertTrue(result)
self.assertEqual([n.pitch.number() for n in result[0].notes], [64, 61, 62])
self.assertEqual(len(result[0].spans), 2)
def test_whole_idea_replacement_can_change_an_ordinary_opening(self):
harmony = harmonic.Harmony(passages.choices(('C7', 'C7')))
plan = dataclasses.replace(fixtures.plan(), end=F(8), release=F(), passages=())
def phrase(names, identity):
actual = tuple(dataclasses.replace(n, start=2 * n.start, end=2 * n.end, event_id=identity + n.event_id) for n in notes(names))
group = types.Transformation(identity, '', '', 'statement', F(), F(8), tuple(n.event_id for n in actual), tuple((n.start, n.end) for n in actual), preserves=())
draft = types.MelodicDraft(identity, F(), F(8), actual, (group,), (), (), (), None, 'statement')
return realization.checked_phrase(plan, draft, actual, (), types.ContinuationState(''), harmony, F(120))
first = phrase(('c', 'c', 'c', 'e', 'g', 'g', 'g', 'e'), 'first')
donor = phrase(('e', 'g', 'e', 'c', 'e', 'g', 'f', 'e'), 'donor')
self.assertIsNotNone(first)
self.assertIsNotNone(donor)
revised = realization.replace_idea(first, donor, F(), F(8), types.ContinuationState(''), harmony, F(120))
self.assertIsNotNone(revised)
self.assertEqual([n.pitch for n in realization.emitted_notes(revised)], [n.pitch for n in realization.emitted_notes(donor)])
self.assertEqual(revised.edits[-1].kind, 'replace_idea')