Cleanup
This commit is contained in:
@@ -105,7 +105,7 @@ Optional solo exports are available with `-DVAMP_EXPORT_SOLO_XML=ON` in CMake (o
|
||||
|
||||
Reference comparison runs separately, after the build, through `python -m audition.solo_compare`. Its optional references never enter production selection or cache keys. For the four supplied scores, use `--reference-pitch guitar-written`: their notes sound one octave below the encoded pitches. Exact event differences are diagnostic, not a musical-quality score or a parameter-fitting objective.
|
||||
|
||||
See [Independent Musical Generation](TECHNICAL_GUIDE.md#independent-musical-generation) for the dependency graph, data inventory/provenance, algorithms, constraints, single-role CLI, cache behavior and optional audition/reference commands. Full comparison artifacts for this update are under `build/independent_generation_comparison/`; the baseline is never a production input.
|
||||
See [Independent Musical Generation](TECHNICAL_GUIDE.md#independent-musical-generation) for the dependency graph, data inventory/provenance, algorithms, constraints, single-role CLI, cache behavior and optional audition/reference commands. The four supplied scores and current production exports are compared in `build/solo_reference_comparison/index.html`; a compact previous-output snapshot is retained there. See [the current repair report](docs/composer/implementation_report.md). Comparison outputs never become production inputs.
|
||||
|
||||
To inspect generated assets without building the full package, run:
|
||||
|
||||
|
||||
+9
-89
@@ -190,103 +190,23 @@ Scale labels appear above chord symbols at collection changes and system starts.
|
||||
|
||||
### Independent Musical Generation
|
||||
|
||||
The arrangement has six independently generated roles: acoustic accompaniment and solo; electric accompaniment, melody and solo; vocal melody. `generator.production.SourceAnalysis` contains a canonical immutable copy of the original song. Its harmony, melody, form, meter, tempo, and source-defined role windows are the only shared musical context. A role never reads another role's candidate pool, chosen notes, attacks, register, or grips. Assembly unions their reserved windows; it does not compose, reselect, or rewrite music. The original vocal pickup reserves time before solo composition.
|
||||
Production composes six roles independently from the original source and relevant resources in `data/`: acoustic accompaniment/solo, electric accompaniment/melody/solo, and vocal melody. `production.SourceAnalysis` provides immutable source harmony, melody, meter, tempo, form and participation windows. Assembly applies those windows and protects the returning vocal pickup without inspecting generated solos to compose another part.
|
||||
|
||||
`generator.production.generate_role` is the production entry point for each role. The ordinary build schedules these jobs in a bounded **spawn process pool**, default two workers. `--composition-jobs 1` is the serial reference; values through 16 are accepted. CMake exposes `VAMP_COMPOSITION_JOBS` separately from `VAMP_ENGRAVING_JOBS`. More workers can increase memory use and need not be faster on a small machine.
|
||||
`production.generate_role` runs in a bounded spawn process pool, default two workers (`VAMP_COMPOSITION_JOBS` / `--composition-jobs`). Role-specific keyed caches support incremental rebuilds. Serial and parallel modes use the same deterministic musical choices; worker completion order is not an input. Actual composition reads are audited. Optional targets, prior scores and other roles' caches are excluded.
|
||||
|
||||
Composition caches contain fully keyed role results under `build/build_common/composition_cache`. Keys cover canonical source content, role settings, relevant production data, algorithm version, and implementation files. Role-specific content hashes and named decision domains determine equivalent-choice tie breaks; output names, title, request order, references, previous scores, and wall-clock budgets do not determine musical choices. Concrete route caches include incoming state and endpoints. Rhythm-window indexing and per-worker parsed catalogs remove repeated full-catalog scans. Cache corruption is rejected, and clean generation remains available without a cache. Unchanged songs/roles can reuse their results; rendering and assembly remain validated. Trace records accompany completed output in `analysis/<song>.roles`; elapsed timings are progress output, not part of deterministic published scores.
|
||||
The active path is `composer.compose → leads.thematic_options → themes.phrase_candidates → themes.unit_candidates → gestures/routes → realization.checked_phrase → scoring.musical`. One source-related realized theme remains available for returns and development. Complete route candidates connect harmonic material through the mapped scales. New joint gesture proposals choose early/on-time/delayed arrivals, holds, releases and typed approaches/colors **before** pitch-route search. A complete approach can target an anticipated destination when both contracts validate. Binding targets and sounding tails survive revision; ordinary computational boundaries remain open.
|
||||
|
||||
The active solo construction is `composer.compose` → `leads.thematic_options` → `themes.phrase_candidates` → `realization.checked_phrase`. Source-feature retrieval now covers the complete head, including characteristic rhythm, rests, interval relationships and turns, rather than only its first eight bars. One realized theme drives later development and selected clear returns. The acoustic and electric policies independently prefer different source-feature treatments and soft, overlapping register trajectories. Pairwise uniqueness is diagnostic, never a selection constraint. All six strings are eligible for either solo; sounding notes require frets 1–12 without open strings. Placement compares complete hand-region paths, time-aware shifts and crossings, retaining ties in the same position. Electric melody/vocal transformations and the separate chord anchor policy remain unchanged.
|
||||
Both solo roles use strings 1–6, frets 1–12, no open strings, with soft overlapping register preferences. Exact timing, source transposition, ties, harmonic interpretation and runtime representation remain unchanged. The 21 scale patterns govern ordinary movement. Exceptions need actual typed preparation/target/duration evidence. A tonic/predominant natural minor sixth may be a bounded stable color; mapped-upper enclosures must verify their upper neighbor's collection membership. No chord is rewritten to excuse an ornament.
|
||||
|
||||
Complete phrase alternatives combine an identifying feature, continuation, harmonic destination, exact rhythm and sounding tail. Structural notes come from the written chord; mapped scale routes connect them with octave-aware indices. Complete paths retain their destination across cells, barlines and scale changes. The search remains bounded, with diverse rhythm/endpoint representatives before expensive realization and a limited replan on failure. An exhausted search reports its failed window; there is no generic fallback tune. Quarter/eighth movement, delayed entrances, offbeat/dotted values, rests, cross-bar holds and selected triplets or sixteenths come from catalogs plus independently authored general rhythm cells. Faster cells obey tempo/difficulty limits. Sustained endings remain binding, but there are no chorus quotas for attacks, closes, returns, pitch distribution or strings. Occupancy is diagnostic.
|
||||
The authoritative evaluator reports validity, normalized components, weights and their actual selected total. It does not use reference similarity. Rhythm/route retrieval heuristics are separate. Per-Harmony caches retain musical constraints and restore caller provenance; pure rhythm-group and legal-position checks are memoized.
|
||||
|
||||
Typed expression policies distinguish optional colors, semitone approaches from either side, both enclosure orders, short chromatic connections, delayed resolutions, anticipations and two conservative blues inflections. They are local alternatives to a direct approach around existing destinations, with complete timing and pitch contracts. Optional colors are conditioned on chord quality/function, duration and actual continuation; altered dominant ninths/thirteenths require a resolving dominant. A natural minor sixth needs tonic/predominant context and stepwise resolution. These policies do not rewrite the chord or force the backing to voice a color. Explicit alterations/omissions take precedence. Written extensions remain distinct from optional colors.
|
||||
Accompaniment remains source-only canonical sparse comping, reused at corresponding form positions. It does not respond to generated lead notes or borrow their grips. No new dynamics, velocity shaping, random timing or instrument effects are introduced.
|
||||
|
||||
Ordinary material still belongs to every mapped collection it overlaps. Only a validated expression can authorize an exception. Validation checks actual event IDs, exact pitch/octave, harmonic time, every overlapped chord/scale, preparation where required, resolution deadline, guitar placement, protected identity and terminal hold. An annotation or role label alone authorizes nothing. Ties merge before duration treatment; repeated attacks do not satisfy a hold. Missing harmony yields explicit space, unsupported chords fail visibly, and the 21 collections are not claimed to cover every possible chord. Expression selection is a bounded local pass over a valid theme, not a global search over all possible tensions. It may select no decoration.
|
||||
The resource inventory is documented in [data migration](docs/composer/data_migration.md). Scales, 2,954 relative paths and 3,538 meter-specific rhythms remain useful; the catalogs are not uniformly sampled. New resources store general musical purposes and target-relative rules, not reference phrases or bar recipes. The current user-authorized reference analysis informed these general rules; provenance states that explicitly.
|
||||
|
||||
`generator.comping.Canonical` constructs each accompaniment independently from source harmony, original melody attacks, form position and static policy. It considers dyads, shells and compact voicings, preserving defining written degrees and explicit bass requirements; complex written extensions can require more tones. Acoustic carries primary bass responsibility. Electric supplies bass at essential source-defined positions. Context selects releases, delayed shells, held figures or space; there is no compulsory four-bar loop. The canonical form is reused exactly when that instrument accompanies, including behind solos. It neither borrows electric melody grips nor follows generated solo gaps. Role windows prevent self-accompaniment. Ensemble overlap is reported downstream and never feeds selection.
|
||||
|
||||
No accents, dynamics, velocity shaping, new swing interpretation or random timing have been added. Runtime JSON, notation/TAB semantics, playback synthesis and transactional publication are unchanged. Source melody and vocal compatibility remain checked separately from generated music.
|
||||
|
||||
#### Data inventory and consumers
|
||||
|
||||
| Resource | Action and purpose | Active consumer |
|
||||
|---|---|---|
|
||||
| `data/songs/*.xml` (2 current sources) | Keep original songs; no reference-derived substitute sources | Source analysis, form, each role |
|
||||
| `data/scales/` (21 patterns) | Keep major, melodic-minor and harmonic-minor modes | Scale mapper, strict route validation |
|
||||
| `data/sequences/` (2,954 relative paths) | Keep combinatorial intervals; they are routes, not complete curated tunes | Endpoint-indexed solo routes |
|
||||
| `data/rhythms/` (3,538 snippets) | Keep all meters: 34 in 2/4, 146 in 3/4, 630 in 4/4, 2,728 in 5/4; retrieval is meter-specific, never weighted by file count | Solo rhythm windows; legacy rhythm regression fixtures |
|
||||
| `data/composition/roles.json` | Add versioned role preferences, register, bass and moderate difficulty policy | Independent role choices, placement preferences, fast-cell eligibility |
|
||||
| `data/composition/grammar.json` | Add transposable feature-preservation contracts and hold/release alternatives | Theme development and ending construction |
|
||||
| `data/composition/harmony.json` | Add explicit optional tension/function/duration/resolution rules | Expression candidates and validation |
|
||||
| `data/composition/lead_ins.json` | Add generic target-relative formulas and blues resolutions | Complete expression candidates and validation |
|
||||
| `data/composition/rhythms.json` | Add purpose-bearing melodic cells and sparse comping figures | Solo joint realization, canonical comping |
|
||||
| `data/composition/melodic_vocabulary.json` | Expand with 11 original purpose-bearing rhythm cells and four chord-member traversal operators; no fixed-pitch licks | Solo rhythm retrieval and complete routes only; excluded from accompaniment choice inputs |
|
||||
| `data/lead_phrases/original_lead_theme.json` | Migrate unchanged to `audition/controls/`; remove from production data/build dependencies | Optional historical audition controls only |
|
||||
| `data/expected/performance_summary.json` | Keep validation-only form/harmony/vocal invariants; never used in selection | Post-generation compatibility check |
|
||||
| `build/*solo-[12].xml`, old generated assets, audition snapshots | Keep untouched, outside all musical input traces and caches | Explicit optional review only |
|
||||
|
||||
The six composition JSON resources carry stable IDs, version 1, independent authorship provenance and MIT licensing. They were written from the musical brief and general principles, without extracting target notes, rhythm fingerprints, statistics or fitted weights. Existing catalog provenance is unchanged. No songs or useful combinatorial families were deleted. Legacy cell composition utilities remain for regression/explicit historical comparison; they are not a fallback production path.
|
||||
|
||||
#### Commands and validation boundary
|
||||
|
||||
```sh
|
||||
# One role; no other role is composed. A JSON settings file can override its policy.
|
||||
./modulus.py python -m generator.compose --song data/songs/autumn_leaves.xml \
|
||||
--role acoustic_solo --data-dir data --cache-dir build/build_common/composition_cache \
|
||||
--output build/acoustic-role.json
|
||||
|
||||
# Full production build, bounded processes and persistent role/page caches.
|
||||
./modulus.py python -m generator --data-dir data --output-dir build/build_x86_debug/generated_assets \
|
||||
--composition-jobs 2 --composition-cache build/build_common/composition_cache \
|
||||
--jobs 2 --engraving-cache build/build_common/engraving_cache --check --progress
|
||||
|
||||
# Auditions read completed output and an optional preserved baseline; never compose.
|
||||
./modulus.py python -m audition.production_review \
|
||||
--assets build/independent_generation_comparison/after \
|
||||
--baseline build/independent_generation_comparison/baseline \
|
||||
--output build/independent_generation_comparison/auditions
|
||||
|
||||
# Optional reference review: role association is explicit, not inferred from "Vocal".
|
||||
./modulus.py python -m audition.reference_review \
|
||||
--reference 'autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml' \
|
||||
--form-start autumn_leaves/acoustic=2 --output build/reference-review.json
|
||||
```
|
||||
|
||||
The actual four optional paths are `build/all-of-me-solo-1.xml`, `build/all-of-me-solo-2.xml`, `build/autumn-leaves-solo-1.xml`, and `build/autumn-leaves-solo-2.xml`. Solo 1 maps to acoustic; Solo 2 maps to electric for review only. All of Me has 32 notated measures. Autumn Leaves has 33, with its first Gm7 at measure 2; its initial material is retained without invented harmony. The reviewer records encoded pitches, literal annotations, rational offsets, carried harmony, tuplets, ties, sounding events and uncertain annotation relationships. No guitar-octave interpretation or repairs are applied. Encoded pitches outside the legal solo domain remain recorded as such. This optional command is never run by the build or required tests.
|
||||
|
||||
Auditions export full solos plus opening/ending excerpts, solo stems, a common fixed backing for comparisons and the final assembled backing. Plain themes and selected pre-expression themes are included. Source controls are explicitly placed in a legal guitar register for audition only. Neutral rendering and gains are shared; score overlap diagnostics are not claims of perceived masking or musical improvement. Required tests use independent synthetic musical fixtures and cover serial/parallel/order equality, role settings, clean/cache equality, reference isolation, exact timing, ordinary scale motion, typed exceptions, preserved holds, notation and placement. Listening and playing are still necessary to judge the result.
|
||||
|
||||
Current boundaries: source meter vocabulary is 2/4–5/4; unsupported meters or unrepresentable notation fail explicitly. The bounded composer cannot prove impossibility when it exhausts its budget. Expression vocabulary and local editing are deliberately finite, and the source-derived seven-section arrangement remains the existing product form. Independent parts can still collide or sound similar; diagnostics cannot automatically reselect either part. Playback remains one neutral mono tone profile, not a simulation of acoustic/electric timbre.
|
||||
|
||||
#### Purposeful phrase refinement and optional solo exports
|
||||
|
||||
The `purposeful-phrases-2` solo policy retains the independent role architecture. Rhythm cells now retain their authored purpose through loading and eligibility filtering. A continuation favors grouped motion over isolated short notes; an arrival can broaden; an entrance or answer retains its own vocabulary. Diverse candidates reach joint pitch/rhythm realization before stable hash tie-breaking. There is no reference-derived attack or occupancy target. This revision expands the production vocabulary with 11 independently authored rhythm cells and four transposable chord-member traversal operators. The new resource is keyed only into solo roles, so additions do not change accompaniment choices. No target fragments, distributions, or fitted weights were migrated into it.
|
||||
|
||||
Search units remain bounded but can finish at the heard signature's duration instead of invariably ending after two bars. Different frontier states carry their own completed musical time. Unit costs are duration-weighted before pruning. The home identity, incoming pitch, route, history, and terminal contract survive these boundaries. A provisional same-pitch endpoint may share the next continuation's attack only when all feature, timing, harmony, and protected-tail contracts still validate. Intentional repeated attacks remain distinct. Route retrieval retains arpeggio families alongside ordinary mapped movement and does not charge every third as worse than a step. Data-defined operators traverse ascending/descending chord members, a member turn, or defining chord members, then connect the complete prefix to the selected destination through an ordinary scale route. Each operator uses the actual chord at each event, preserves fixed notes, and requires full-duration collection membership; it introduces no new chromatic exception.
|
||||
|
||||
`--export-solo-xml` writes completed solo role views under `solo_xml/` in the atomically published assets. The CMake equivalent is `VAMP_EXPORT_SOLO_XML=ON` (default OFF). Solo 1 maps to acoustic; Solo 2 maps to electric. The exporter neither generates nor changes notes. It preserves exact durations, ties, tuplets, harmony offsets, and technical string/fret annotations, and numbers this standalone view from one. Its manifest retains the original arrangement measure numbers and offset, source fingerprint, production settings/version, and full phrase report. The runtime format and full-arrangement numbering remain unchanged. The normal source importer still rejects authored TAB as a production input; optional inspection reads the exported technical annotations directly.
|
||||
|
||||
Example comparison after an export-enabled build:
|
||||
|
||||
```sh
|
||||
./modulus.py python -m audition.solo_compare \
|
||||
--generated-dir build/build_x86_debug/generated_assets/solo_xml \
|
||||
--reference 'all_of_me/acoustic=build/all-of-me-solo-1.xml' \
|
||||
--reference 'all_of_me/electric=build/all-of-me-solo-2.xml' \
|
||||
--reference 'autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml' \
|
||||
--reference 'autumn_leaves/electric=build/autumn-leaves-solo-2.xml' \
|
||||
--form-start all_of_me/acoustic=1 --form-start all_of_me/electric=1 \
|
||||
--form-start autumn_leaves/acoustic=2 --form-start autumn_leaves/electric=2 \
|
||||
--reference-pitch guitar-written \
|
||||
--assets build/build_x86_debug/generated_assets \
|
||||
--output build/solo_comparison/review
|
||||
```
|
||||
|
||||
This explicitly invoked, downstream command compares canonical sounding events with rational time. Tied notation fragments count as one event, repeated attacks do not. It reports pitch/onset/duration differences, separate spelling differences, rests, held-note harmonic roles, literal annotations, and legal guitar placement. The accepted user convention interprets these references as guitar-written notes; original encoded pitches remain in the report. No inferred conversion is based on their incidental `Vocal` part name. Autumn Leaves' initial material remains at negative form-relative time; no missing harmony is invented and no reference bar is discarded to manufacture equality. Reference notes beyond the source-derived solo cutoff are reported intact.
|
||||
|
||||
With `--assets`, it also renders generated/reference stems and identical-backing comparisons at the same neutral gains. The shared audition clock reserves room for reference-only lead-in material, explicitly reported in the manifest; this does not add notes to production or edit the reference XML. The existing production review exports baseline/new stages and final arrangements separately. All optional reports/audio live in build output and are never composition inputs. Exact matching is reported honestly and is not an acceptance requirement for independently invented melodies.
|
||||
Production exports `*-solo1.xml` (acoustic) and `*-solo2.xml` (electric) plus `manifest.solo`. Optional `audition.solo_compare` and `audition.theory_review` accept explicit reference paths after generation. The four supplied scores use the separately confirmed guitar-written octave convention in this adapter only. Autumn Leaves aligns at measure 2 / first Gm7; preceding material is retained. References are untouched and missing files never change normal generation.
|
||||
|
||||
One review folder, `build/solo_reference_comparison/`, holds the previous four XMLs, the new four, the complete reference comparison and audio. Historical audition controllers and obsolete generated experiment trees were removed. [Commands](docs/composer/commands.md), [active path](docs/composer/active_path_audit.md), [evaluation contract](docs/composer/evaluation_contract.md), and [implementation evidence](docs/composer/implementation_report.md) describe the current behavior and limits. Technical validity and measured similarity do not establish listener preference.
|
||||
|
||||
### Engraving Boundary
|
||||
|
||||
|
||||
@@ -1,719 +0,0 @@
|
||||
{
|
||||
"description": "Original hand-composed reference, not output from the user engine or a quotation of Autumn Leaves.",
|
||||
"tempo_bpm": 120,
|
||||
"meter": "4/4",
|
||||
"pitch_convention": "sounding",
|
||||
"time_units": "exact rational quarter-note beats",
|
||||
"velocity_policy": "lead 80, backing 48; constant within each track; no accents or dynamics",
|
||||
"scale_regions": [
|
||||
{
|
||||
"start": "0",
|
||||
"end": "16",
|
||||
"scale": "D Aeolian"
|
||||
},
|
||||
{
|
||||
"start": "16",
|
||||
"end": "24",
|
||||
"scale": "D Harmonic Minor"
|
||||
},
|
||||
{
|
||||
"start": "24",
|
||||
"end": "32",
|
||||
"scale": "D Aeolian"
|
||||
}
|
||||
],
|
||||
"harmony": [
|
||||
{
|
||||
"bar": 1,
|
||||
"chord": "Gm7",
|
||||
"required_basic_pitch_classes": [
|
||||
7,
|
||||
10,
|
||||
2,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"chord": "C7",
|
||||
"required_basic_pitch_classes": [
|
||||
0,
|
||||
4,
|
||||
7,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"chord": "Fmaj7",
|
||||
"required_basic_pitch_classes": [
|
||||
5,
|
||||
9,
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"chord": "Bbmaj7",
|
||||
"required_basic_pitch_classes": [
|
||||
10,
|
||||
2,
|
||||
5,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"chord": "Em7b5",
|
||||
"required_basic_pitch_classes": [
|
||||
4,
|
||||
7,
|
||||
10,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"chord": "A7",
|
||||
"required_basic_pitch_classes": [
|
||||
9,
|
||||
1,
|
||||
4,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"chord": "Dm7",
|
||||
"required_basic_pitch_classes": [
|
||||
2,
|
||||
5,
|
||||
9,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"chord": "Dm7",
|
||||
"required_basic_pitch_classes": [
|
||||
2,
|
||||
5,
|
||||
9,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"variants": {
|
||||
"lead_theme": {
|
||||
"attacks": 31,
|
||||
"events": [
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "0",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "1",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "5/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "3",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "4",
|
||||
"duration_beats": "2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "6",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "13/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "7",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "8",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "9",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "21/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "11",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "12",
|
||||
"duration_beats": "2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "14",
|
||||
"duration_beats": "1",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "15",
|
||||
"duration_beats": "1",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "16",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "Bb4",
|
||||
"midi_pitch": 70
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "35/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "18",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "19",
|
||||
"duration_beats": "1",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "20",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "21",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "43/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "22",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C#4",
|
||||
"midi_pitch": 61
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "23",
|
||||
"duration_beats": "1",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "24",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "25",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "53/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "27",
|
||||
"duration_beats": "1",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "28",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "57/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "29",
|
||||
"duration_beats": "2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "31",
|
||||
"duration_beats": "1",
|
||||
"pitch": null,
|
||||
"midi_pitch": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"lead_with_runs": {
|
||||
"attacks": 36,
|
||||
"events": [
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "0",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "1",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "5/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 1,
|
||||
"onset_beats": "3",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "4",
|
||||
"duration_beats": "1",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "5",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "11/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "6",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "13/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "Bb4",
|
||||
"midi_pitch": 70
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "7",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 2,
|
||||
"onset_beats": "15/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "8",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "9",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "21/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 3,
|
||||
"onset_beats": "11",
|
||||
"duration_beats": "1",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "12",
|
||||
"duration_beats": "2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "14",
|
||||
"duration_beats": "1",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 4,
|
||||
"onset_beats": "15",
|
||||
"duration_beats": "1",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "16",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "Bb4",
|
||||
"midi_pitch": 70
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "35/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "18",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 5,
|
||||
"onset_beats": "19",
|
||||
"duration_beats": "1",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "20",
|
||||
"duration_beats": "1",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "21",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "43/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "22",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "45/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "C#4",
|
||||
"midi_pitch": 61
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "23",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 6,
|
||||
"onset_beats": "47/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "C#4",
|
||||
"midi_pitch": 61
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "24",
|
||||
"duration_beats": "1",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "25",
|
||||
"duration_beats": "3/2",
|
||||
"pitch": "A4",
|
||||
"midi_pitch": 69
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "53/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "G4",
|
||||
"midi_pitch": 67
|
||||
},
|
||||
{
|
||||
"bar": 7,
|
||||
"onset_beats": "27",
|
||||
"duration_beats": "1",
|
||||
"pitch": "F4",
|
||||
"midi_pitch": 65
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "28",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "E4",
|
||||
"midi_pitch": 64
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "57/2",
|
||||
"duration_beats": "1/2",
|
||||
"pitch": "C4",
|
||||
"midi_pitch": 60
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "29",
|
||||
"duration_beats": "2",
|
||||
"pitch": "D4",
|
||||
"midi_pitch": 62
|
||||
},
|
||||
{
|
||||
"bar": 8,
|
||||
"onset_beats": "31",
|
||||
"duration_beats": "1",
|
||||
"pitch": null,
|
||||
"midi_pitch": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "original_lead_theme",
|
||||
"acceptance": "User accepted plain theme and runs as references",
|
||||
"tonic": "D",
|
||||
"mode": "minor",
|
||||
"run_windows": [
|
||||
{
|
||||
"start": "4",
|
||||
"end": "8"
|
||||
},
|
||||
{
|
||||
"start": "20",
|
||||
"end": "24"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"id": "leap_return",
|
||||
"start": "0",
|
||||
"end": "4",
|
||||
"relationship": "timed leap and return"
|
||||
},
|
||||
{
|
||||
"id": "restatement",
|
||||
"start": "8",
|
||||
"end": "12",
|
||||
"relationship": "related rhythm and leap with harmonic adaptation"
|
||||
}
|
||||
],
|
||||
"development": [
|
||||
{
|
||||
"role": "statement",
|
||||
"start": "0",
|
||||
"end": "4",
|
||||
"degrees": [
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "answer",
|
||||
"start": "4",
|
||||
"end": "8",
|
||||
"degrees": [
|
||||
0,
|
||||
2,
|
||||
3,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "return",
|
||||
"start": "8",
|
||||
"end": "12",
|
||||
"degrees": [
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "extension",
|
||||
"start": "12",
|
||||
"end": "16",
|
||||
"degrees": [
|
||||
0,
|
||||
2,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "departure",
|
||||
"start": "16",
|
||||
"end": "20",
|
||||
"degrees": [
|
||||
0,
|
||||
-1,
|
||||
-2,
|
||||
-4
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "continuation",
|
||||
"start": "20",
|
||||
"end": "24",
|
||||
"degrees": [
|
||||
0,
|
||||
-1,
|
||||
-2,
|
||||
-4,
|
||||
-2
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "recall",
|
||||
"start": "24",
|
||||
"end": "28",
|
||||
"degrees": [
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "ending",
|
||||
"start": "28",
|
||||
"end": "32",
|
||||
"degrees": [
|
||||
0,
|
||||
-2,
|
||||
-1
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Export auditions from completed assets; this command never composes music."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import array
|
||||
import copy
|
||||
import dataclasses
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
from generator import model, musicxml_writer, playback, score
|
||||
from generator.solo import notation, placement, types, similarity
|
||||
from generator.solo.audition import excerpt, mix
|
||||
|
||||
|
||||
def region(document, part):
|
||||
measures=document['staves'][0]['measures']
|
||||
first=next(i for i,m in enumerate(measures) if m.get('section_label')==part.capitalize()+' Solo')
|
||||
finish=next((i for i in range(first+1,len(measures)) if measures[i].get('section_label')),len(measures))
|
||||
return first,finish-first
|
||||
|
||||
|
||||
def staged(document, details, stage, tempo):
|
||||
notes=[]
|
||||
for phrase in details['phrases']:
|
||||
selected=phrase['lead_stages'].get(stage) or phrase['draft']
|
||||
for n in selected.get('notes',[]):
|
||||
p=n['pitch']
|
||||
notes.append(types.Note(F(n['start']),F(n['end']),types.Pitch(p['letter'],F(p['alter']),p['octave']),
|
||||
tuple(n['sources']),n['role'],continuation=n['continuation'],event_id=n['event_id']))
|
||||
notes=types.sounding_notes(tuple(notes))
|
||||
placed=placement.place(notes,tempo)
|
||||
if notes and placed is None:raise ValueError(stage+': no legal audition placement')
|
||||
result=copy.deepcopy(document)
|
||||
notation.replace_region(result,0,notation.measures(result['staves'][0]['measures'],placed[1] if placed else (),stage))
|
||||
model.validate_document(result)
|
||||
return result
|
||||
|
||||
|
||||
def overlap(lead, backing):
|
||||
a=playback.compile_timeline(lead)['events'];b=playback.compile_timeline(backing)['events']
|
||||
total=covered=above=0.
|
||||
for n in a:
|
||||
start=model.fraction(n['onset']);end=start+model.fraction(n['duration']);pitch=model.fraction(n['pitch'])
|
||||
cuts={start,end}
|
||||
for other in b:
|
||||
s=model.fraction(other['onset']);e=s+model.fraction(other['duration'])
|
||||
if s<end and e>start:cuts.update((max(s,start),min(e,end)))
|
||||
points=sorted(cuts)
|
||||
for x,y in zip(points,points[1:]):
|
||||
simultaneous=[o for o in b if model.fraction(o['onset'])<=x<model.fraction(o['onset'])+model.fraction(o['duration'])]
|
||||
total+=float(y-x)
|
||||
if simultaneous:covered+=float(y-x)
|
||||
if any(model.fraction(o['pitch'])>pitch for o in simultaneous):above+=float(y-x)
|
||||
return {'sounding_beats':total,'backing_overlap_fraction':covered/max(.001,total),'backing_above_lead_fraction':above/max(.001,total),
|
||||
'interpretation':'score overlap only; not a perceptual masking measurement; no feedback to composition'}
|
||||
|
||||
|
||||
def export(assets,baseline,output):
|
||||
output.mkdir(parents=True,exist_ok=True)
|
||||
manifest={'composition_inputs':False,'gain':.65,'backing_gain':.55,'playback':'unchanged neutral constant-level synthesis',
|
||||
'listening_status':'not auditioned by the agent; score validation and rendered signal checks only','solos':{}}
|
||||
tone_cache={};clips=[];notes_report=[]
|
||||
for roles_path in sorted((assets/'analysis').glob('*.roles')):
|
||||
song=roles_path.stem;roles=json.loads(roles_path.read_text())
|
||||
for part in ('acoustic','electric'):
|
||||
print('Audition: '+song+'/'+part,flush=True)
|
||||
other='electric' if part=='acoustic' else 'acoustic'
|
||||
assembled=score.load_json(assets/'songs'/f'{song}_{part}.json')
|
||||
backing=score.load_json(assets/'songs'/f'{song}_{other}.json')
|
||||
first,count=region(assembled,part)
|
||||
lead=excerpt(assembled,first,count);backing=excerpt(backing,first,count)
|
||||
detail=roles[part+'_solo']['details']
|
||||
tempo=next((model.fraction(t['bpm'])*model.written_length(t['written']) for m in assembled['staves'][0]['measures'] for t in m.get('tempos',[]) if 'bpm' in t),F(120))
|
||||
versions={'final':lead, 'plain':staged(lead,detail,'refined',tempo), 'strict':staged(lead,detail,'before_enclosure',tempo), 'source':staged(lead,detail,'initial',tempo)}
|
||||
baseline_backing=None
|
||||
old=baseline/'songs'/f'{song}_{part}.json' if baseline else None
|
||||
if old and old.is_file():
|
||||
original=score.load_json(old);a,b=region(original,part)
|
||||
versions['baseline']=excerpt(original,a,b)
|
||||
baseline_backing=excerpt(score.load_json(baseline/'songs'/f'{song}_{other}.json'),a,b)
|
||||
key=song+'_'+part
|
||||
manifest['solos'][key]={'tempo':str(tempo),'source_form_measures':count,'details':detail,
|
||||
'input_trace':roles[part+'_solo']['trace'],'ensemble':overlap(lead,backing)}
|
||||
notes_report.append(key+' — solo-relative rational quarter-note time; sounding pitches\n')
|
||||
for phrase in detail['phrases']:
|
||||
notes_report.append(f"{phrase['id']}: {phrase['start']}–{phrase['end']}; {phrase['ending']}; completed={phrase['ending_completed']}\n")
|
||||
for n in phrase['draft']['notes']:
|
||||
p=n['pitch'];tone=types.Pitch(p['letter'],F(p['alter']),p['octave'])
|
||||
pos=n['position'];tab=f' string {pos[0]} fret {pos[1]}' if pos else ' (draft pitch; final TAB in XML)'
|
||||
notes_report.append(f" {n['start']}–{n['end']} {tone.label()} {n['role']}{tab}\n")
|
||||
back_audio=mix([backing],tempo,output,tone_cache)
|
||||
for name,document in versions.items():
|
||||
label=key+'_'+name
|
||||
score.write_json(output/(label+'.json'),document)
|
||||
musicxml_writer.write_musicxml(output/(label+'.xml'),document)
|
||||
solo_audio=mix([document],tempo,output,tone_cache)
|
||||
combined=array.array('f',(v+.55*(back_audio[i] if i<len(back_audio) else 0) for i,v in enumerate(solo_audio)))
|
||||
variants={'solo':solo_audio,'fixed_backing':combined}
|
||||
if name=='final':variants['final_arrangement']=combined
|
||||
if name=='baseline' and baseline_backing:
|
||||
oldback=mix([baseline_backing],tempo,output,tone_cache)
|
||||
variants['original_backing']=array.array('f',(v+.55*(oldback[i] if i<len(oldback) else 0) for i,v in enumerate(solo_audio)))
|
||||
for context,values in variants.items():
|
||||
peak=max(map(abs,values),default=0)*manifest['gain']
|
||||
if peak>=1:raise ValueError('Audition gain would clip; choose a lower shared gain')
|
||||
path=label+'_'+context+'.wav'
|
||||
playback.write_pcm(output/path,(v*manifest['gain'] for v in values))
|
||||
clips.append({'file':path,'song':song,'part':part,'version':name,'context':context,'peak':peak})
|
||||
if context=='fixed_backing':
|
||||
# Exact slices retain the same backing and gain. Counts come from
|
||||
# the completed source form, not a presumed 32-bar chorus.
|
||||
measures=document['staves'][0]['measures'];durations=[model.fraction(m['duration']) for m in measures]
|
||||
for excerpt_name,start,end in (('opening',F(),sum(durations[:8],F())),('ending',sum(durations[:-4],F()),sum(durations,F()))):
|
||||
factor=F(60*playback.SAMPLE_RATE)/tempo
|
||||
selection=values[round(start*factor):round(end*factor)]
|
||||
clip=label+'_'+excerpt_name+'.wav'
|
||||
playback.write_pcm(output/clip,(v*manifest['gain'] for v in selection))
|
||||
clips.append({'file':clip,'song':song,'part':part,'version':name,'context':excerpt_name,'peak':peak})
|
||||
manifest['pair_comparisons']={}
|
||||
for song in sorted({c['song'] for c in clips}):
|
||||
def events(part):
|
||||
detail=manifest['solos'][song+'_'+part]['details']
|
||||
return tuple((float(F(n['start'])),float(F(n['end'])),types.Pitch(n['pitch']['letter'],F(n['pitch']['alter']),n['pitch']['octave']).number())
|
||||
for p in detail['phrases'] for n in p['draft']['notes'])
|
||||
manifest['pair_comparisons'][song]=similarity.compare_events(events('electric'),events('acoustic'))
|
||||
manifest['clips']=clips
|
||||
(output/'report.json').write_text(json.dumps(manifest,indent=2))
|
||||
(output/'notes.txt').write_text(''.join(notes_report))
|
||||
rows=['<!doctype html><meta charset="utf-8"><title>Independent generation auditions</title><style>body{max-width:1100px;margin:2rem auto;font:16px system-ui}audio{width:420px}td{padding:.5rem}details{margin:1rem}</style>',
|
||||
'<h1>Independent generation auditions</h1><p>Baseline and new music, same neutral rendering and fixed comparison backing. All files share gain 0.65; backing gain 0.55. Final arrangements use the new independent canonical backing. No subjective listening verdict has been made.</p><p><a href="report.json">Full roles, contracts and diagnostics</a> · <a href="notes.txt">Sounding note report</a></p>']
|
||||
for key,detail in manifest['solos'].items():
|
||||
rows.append('<h2>'+html.escape(key)+'</h2><table>')
|
||||
for c in clips:
|
||||
if c['song']+'_'+c['part']!=key:continue
|
||||
label=c['version']+' / '+c['context']
|
||||
rows.append('<tr><td>'+html.escape(label)+'</td><td><audio controls preload="none" src="'+c['file']+'"></audio></td><td><a href="'+c['file']+'">WAV</a></td></tr>')
|
||||
rows.append('</table>')
|
||||
(output/'index.html').write_text('\n'.join(rows))
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--assets',type=Path,required=True);p.add_argument('--baseline',type=Path);p.add_argument('--output',type=Path,required=True)
|
||||
args=p.parse_args();export(args.assets,args.baseline,args.output)
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -59,6 +59,11 @@ def read(path, role, form_start=None, pitch_convention='encoded'):
|
||||
if 'start' not in kinds:pending.pop(key,None)
|
||||
if 'start' in kinds:pending[key]=len(result['notes'])
|
||||
result['notes'].append(event)
|
||||
for container in ('notations/technical/fingering','notations/technical/other-technical','notations/other-notation'):
|
||||
for label in item.findall(container):
|
||||
text=''.join(label.itertext()).strip()
|
||||
if text:result['annotations'].append({'time':str(absolute+start),'measure':index+1,'text':text,
|
||||
'container':container,'interpretation':'Original note annotation; container does not establish musical permission or guitar fingering.'})
|
||||
if item.find('chord') is None and item.find('grace') is None:cursor=end
|
||||
last_start,last_duration=start,duration;extent=max(extent,end,cursor)
|
||||
if extent!=nominal and m.get('implicit')!='yes':result['discrepancies'].append({'measure':index+1,'issue':f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Summarize finished production decisions; no composition or reference reading."""
|
||||
from __future__ import annotations
|
||||
import argparse,collections,json
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from generator import vocabulary
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--assets',type=Path,required=True);p.add_argument('--output',type=Path,required=True);args=p.parse_args();args.output.mkdir(parents=True,exist_ok=True)
|
||||
evidence={};text=['# Finished production evidence','', 'Times below are exact quarter-note beats relative to the solo. Counts describe the selected music; they are not targets or listening verdicts.','']
|
||||
for path in sorted((args.assets/'analysis').glob('*.roles')):
|
||||
roles=json.loads(path.read_text())
|
||||
for role,v in roles.items():
|
||||
key=path.stem+'/'+role
|
||||
evidence[key]={'document_hash':vocabulary.fingerprint(v['document']),'trace':v['trace']}
|
||||
if not role.endswith('_solo'):continue
|
||||
d=v['details'];ps=d['phrases'];ns=[n for p in ps for n in p['draft']['notes']]
|
||||
operations=collections.Counter(t['operation'] for p in ps for t in p['draft']['transformations'])
|
||||
span=max(F(p['end']) for p in ps)-min(F(p['start']) for p in ps)
|
||||
sounding=sum((F(n['end'])-F(n['start']) for n in ns),F())
|
||||
summary={'attacks':sum(not n['continuation'] for n in ns),'quarter_beats':str(span),'occupancy':float(sounding/span),'fretboard':d['fretboard'],'operations_selected':dict(operations),'operation_funnel':d['selection_trace'].get('operations',{}),'expressions_selected':dict(collections.Counter(e['kind'] for e in d['expressions'])),'expression_funnel':d['selection_trace'].get('expression_operations',{}),'evaluation_total':d['selection_trace']['selection_total'],'unresolved':d['unresolved_treatments'],'boundaries':[]}
|
||||
evidence[key].update(summary)
|
||||
text += ['## '+key,'',f"{summary['attacks']} attacks over {span} quarter beats; sounding occupancy {summary['occupancy']:.1%}; sounding MIDI range {d['fretboard']['range_midi']}. Harmonic valid: {d['harmonic_valid']}; unresolved obligations: {len(d['unresolved_treatments'])}.",'', '| Span | Behavior | Destination / actual hold | Home identity |','|---|---|---|---|']
|
||||
for phrase in ps:
|
||||
anchors=[{k:a[k] for k in ('role','effect','pitch','actual_start','actual_end','completed')} for a in phrase['anchors']]
|
||||
summary['boundaries'].append({'start':phrase['start'],'end':phrase['end'],'closure':phrase['closure'],'anchors':anchors,'home':phrase['home']})
|
||||
destination='; '.join(a['effect']+' '+a['pitch']+' '+str(a['actual_start'])+'–'+str(a['actual_end']) for a in anchors) or 'no forced arrival'
|
||||
text.append(f"| {phrase['start']}–{phrase['end']} | {phrase['closure']} / {phrase['ending']} | {destination} | `{phrase['home']}` |")
|
||||
text += ['', 'Selected actions: '+', '.join(f'{k}: {v}' for k,v in sorted(operations.items()))+'.','', 'Typed treatments: '+str(summary['expressions_selected'])+'.','', '### Representative evaluated phrase','']
|
||||
phrase=next(p for p in ps if p['draft']['notes']);evaluation=phrase['evaluation'];assert abs(phrase['score']-sum(evaluation['contributions'].values()))<1e-10
|
||||
text += [f"Selected `{phrase['id']}`; score {phrase['score']:.9f}. Identity is a hard feature contract.",'','| Contribution | Raw | Weight | Weighted |','|---|---:|---:|---:|']
|
||||
for k,value in evaluation['raw'].items():text.append(f"| {k} | {value:.9f} | {evaluation['weights'][k]} | {evaluation['contributions'][k]:.9f} |")
|
||||
pool=d['selection_trace'].get('source_candidate_pools',{}).get(phrase['passage'],[])
|
||||
alternatives=sorted((c for c in pool if c['id']!=phrase['draft']['id']),key=lambda c:(c['score'],c['id']))
|
||||
text += ['', 'Other retained alternatives for this source window (possibly different incoming histories):']
|
||||
for c in alternatives[:3]:text.append(f"- `{c['id']}`: {c['score']:.9f}, {c['attacks']} attacks; selected ending {c['ending']}.")
|
||||
text += ['', 'These are local alternatives, not a proof of a global optimum. The chorus frontier compares complete histories using duration-weighted phrase scores. The JSON retains every reported contribution, operation cutoff counts and source input trace.','']
|
||||
evidence[key]['winner_evaluation']=evaluation;evidence[key]['local_alternatives']=alternatives[:3]
|
||||
(args.output/'evidence.json').write_text(json.dumps(evidence,indent=2))
|
||||
(args.output/'phrase_traces.md').write_text('\n'.join(text)+'\n')
|
||||
if __name__=='__main__':main()
|
||||
@@ -95,8 +95,23 @@ def compare(generated,reference):
|
||||
'reference_playability':playability(reference),'reference_original':reference}
|
||||
|
||||
|
||||
def gesture_coverage(raw):
|
||||
"""Group timing-only retained-unit labels with their direct construction.
|
||||
|
||||
The source label can include rhythm edits such as '; diminution'. These
|
||||
describe direct candidates, not additional harmonic proposal families.
|
||||
Raw generation evidence remains untouched in the manifest.
|
||||
"""
|
||||
result={}
|
||||
for label,counts in raw.items():
|
||||
purpose=label if label.startswith(('direct','harmonic_arrival:','sustained_color')) else 'direct'
|
||||
target=result.setdefault(purpose,dict.fromkeys(('proposed','retained_jobs','valid_routes','retained_units'),0))
|
||||
for name,value in counts.items():target[name]+=value
|
||||
return result
|
||||
|
||||
|
||||
def render_events(notes, duration, tempo, root, cache):
|
||||
"""The same neutral note envelopes as production_review; no expression edits."""
|
||||
"""Use the production neutral note envelopes; no expression edits."""
|
||||
from generator import playback
|
||||
factor=F(60*playback.SAMPLE_RATE)/tempo
|
||||
output=array.array('f',[0])* (round(duration*factor)+2400)
|
||||
@@ -187,12 +202,22 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
|
||||
rows=['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
|
||||
'<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}td,th{padding:.5rem;text-align:left}pre{white-space:pre-wrap}</style>',
|
||||
'<h1>Completed solos and reference scores</h1><p>Differences are diagnostic. These files do not supply generation inputs or candidate scores. No listening verdict is claimed.</p>',
|
||||
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a></p>']
|
||||
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a> · <a href="analysis/theory.md">Complete musical relationship review</a></p>']
|
||||
for role,r in result['solos'].items():
|
||||
actual_path=generated_dir/manifest['solos'][role]['file']
|
||||
reference_path=Path(reference_paths[role])
|
||||
rows += ['<h2>'+html.escape(role)+'</h2>',f'<p>{r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
|
||||
rows += ['<h2>'+html.escape(role)+'</h2>',f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
|
||||
'<p><a href="'+html.escape(os.path.relpath(actual_path,output))+'">Generated MusicXML</a> · <a href="'+html.escape(os.path.relpath(reference_path,output))+'">Untouched reference MusicXML</a></p>']
|
||||
selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[]))
|
||||
rows.append('<p>Selected, validated production treatments: '+html.escape(str(dict(selected)))+'. Ordinary mapped material remains separate.</p>')
|
||||
if r.get('reference_events_after_production_end'):
|
||||
rows.append('<p>Reference material extends beyond the production vocal-protection window: '+str(len(r['reference_events_after_production_end']))+' events. The reference and vocal timing remain unchanged.</p>')
|
||||
funnel=gesture_coverage(r['production_phrases'].get('selection_trace',{}).get('joint_gestures',{}))
|
||||
if funnel:
|
||||
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th><th>Proposed structures</th><th>Retained jobs</th><th>Valid routes</th><th>Retained units</th></tr>')
|
||||
for purpose,counts in sorted(funnel.items()):
|
||||
rows.append('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts[k])+'</td>' for k in ('proposed','retained_jobs','valid_routes','retained_units'))+'</tr>')
|
||||
rows.append('</table></details>')
|
||||
for clip in r.get('audition',{}).get('clips',[]):
|
||||
rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>')
|
||||
rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>')
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Explain complete optional solos. No composer calls or production-data writes."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from audition import reference_review, solo_compare
|
||||
from generator import harmony, model
|
||||
|
||||
|
||||
def chord(h):
|
||||
if not h or not h['root']:return None
|
||||
root=h['root']+('#'*int(F(h['alter'])) if F(h['alter'])>0 else 'b'*-int(F(h['alter'])))
|
||||
if h['kind']=='other':
|
||||
# The explicit displayed quality is evidence; never substitute a target
|
||||
# chord for a missing source chord. Preserve any parse error in the report.
|
||||
return harmony.parse_symbol(root+(h['display'] or ''))
|
||||
return {'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],
|
||||
'degrees':[{'value':int(d['value']),'alter':model.rational(F(d['alter'])),'type':d['type']} for d in h['degrees']]}
|
||||
|
||||
|
||||
def members(h):
|
||||
c=chord(h)
|
||||
if not c:return {}
|
||||
root=model.NATURAL[c['root']['letter']]+model.alteration(c['root']['alter'])
|
||||
return {int(root+harmony.degree_semitones(d,a))%12:str(d) for d,a in harmony.degrees(c).items()}
|
||||
|
||||
|
||||
def explain(review, source=None):
|
||||
"""Observed relationships and hypotheses, not retrospective permissions."""
|
||||
notes=solo_compare.events(review);offset=F(review['alignment']['encoded_offset'] or 0)
|
||||
hs=[{**h,'time':str(F(h['time'])-offset)} for h in review['harmonies']]
|
||||
output=[]
|
||||
for i,n in enumerate(notes):
|
||||
start,end=F(n['start']),F(n['end']);p=int(F(n['pitch']))
|
||||
active=next((h for h in reversed(hs) if F(h['time'])<=start),None)
|
||||
future=next((h for h in hs if F(h['time'])>start),None)
|
||||
relation=[];uncertainty=[]
|
||||
try:
|
||||
cm=members(active);fm=members(future)
|
||||
except (ValueError,KeyError) as e:cm=fm={};uncertainty.append(str(e))
|
||||
if p%12 in cm:relation.append('chord member '+cm[p%12])
|
||||
else:relation.append('non-basic pitch: needs collection/color/motion analysis')
|
||||
if future and start<F(future['time'])<end and p%12 in fm:
|
||||
relation.append('anticipated future chord member '+fm[p%12])
|
||||
if i and i+1<len(notes):
|
||||
a,b=notes[i-1],notes[i+1];x=p-int(F(a['pitch']));y=int(F(b['pitch']))-p
|
||||
if F(a['end'])==start and end==F(b['start']) and abs(x)<=2 and abs(y)<=2:
|
||||
relation.append('neighbor motion' if x==-y else 'directional passing motion' if x*y>0 else 'turn')
|
||||
for target in notes[i+1:i+4]:
|
||||
t=F(target['start']);q=int(F(target['pitch']))
|
||||
if t-start>F(3,2):break
|
||||
if abs(q-p)==1 and end<=t and t-end<=F(1,2):
|
||||
relation.append('semitone approach to '+target['pitch']+' at '+target['start']);break
|
||||
if end-start>=2:relation.append('sustained event; '+('breath follows' if i+1<len(notes) and F(notes[i+1]['start'])>end else 'continuation follows'))
|
||||
label=[a['text'] for a in review['annotations'] if start<=F(a['time'])-offset<end]
|
||||
output.append({**n,'relationships':relation,'annotation':label,'uncertainty':uncertainty})
|
||||
def signature(ns):
|
||||
origin=F(ns[0]['start']);base=int(F(ns[0]['pitch']))
|
||||
return tuple((str(F(n['start'])-origin),str(F(n['end'])-F(n['start'])),int(F(n['pitch']))-base) for n in ns)
|
||||
repeats=collections.defaultdict(list)
|
||||
for i in range(len(notes)-3):repeats[signature(notes[i:i+4])].append(notes[i]['start'])
|
||||
source_matches=[]
|
||||
if source:
|
||||
original=solo_compare.events(source)
|
||||
fingerprints={signature(original[i:i+4]):original[i]['start'] for i in range(len(original)-3)}
|
||||
source_matches=[{'solo_start':n['start'],'source_start':fingerprints[signature(notes[i:i+4])]} for i,n in enumerate(notes[:-3]) if signature(notes[i:i+4]) in fingerprints]
|
||||
return {'events':output,'relative_four_event_returns':[{'signature':k,'starts':v} for k,v in repeats.items() if len(v)>1],
|
||||
'source_four_event_relationships':source_matches,'source_relationship_limit':'Exact relative four-event evidence only; absence does not disprove thematic influence.',
|
||||
'interpretation':'Descriptive hypotheses; no label waives production validation. No output from this command is loaded by production.'}
|
||||
|
||||
|
||||
def rule_review(review, choices, offset):
|
||||
"""Read-only recognition under production policies; never run composition.
|
||||
|
||||
Recognition deliberately need not cover every reference note. Failed or
|
||||
ambiguous relationships remain failures, rather than changing a policy.
|
||||
"""
|
||||
import dataclasses
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, harmonic, expressions
|
||||
choices=tuple(dataclasses.replace(c,span=dataclasses.replace(c.span,start=c.span.start-offset,end=c.span.end-offset)) for c in choices)
|
||||
h=harmonic.Harmony(choices);ns=[]
|
||||
for i,n in enumerate(solo_compare.events(review)):
|
||||
p=n['spelling'];ns.append(types.Note(F(n['start']),F(n['end']),types.Pitch(p['step'].lower(),F(p['alter']),p['octave']-1),event_id=str(i)))
|
||||
contracts=[];rejections=collections.defaultdict(list)
|
||||
def accept(c):
|
||||
if expressions.validate(tuple(ns),(*contracts,c),h)[1]:return False
|
||||
contracts.append(c);return True
|
||||
for n in ns:
|
||||
for boundary in (s.start for s in h.spans if n.start<s.start<n.end):
|
||||
accept(types.ExpressionContract('recognition:'+n.event_id,'anticipation','early_chord_tone',(n,),n,h.at(boundary).chord,boundary=boundary))
|
||||
for i,n in enumerate(ns):
|
||||
span=h.at(n.start)
|
||||
if not span or not span.scale:continue
|
||||
if any(n in c.events for c in contracts):continue
|
||||
if n.pitch.number()%12 in span.roles.basic:continue
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
target=ns[i+1] if rule.get('resolve') and i+1<len(ns) else n
|
||||
c=types.ExpressionContract('recognition:'+n.event_id,'color',rule['id'],(n,),target,h.at(target.start).chord if h.at(target.start) else '')
|
||||
if accept(c):break
|
||||
written=harmony.parse_symbol(span.chord)
|
||||
proposed=expressions.degree_pitch(written,rule['degree'],rule['alter'],n.pitch.number())
|
||||
if expressions.quality(written) in rule['qualities'] and proposed.number()==n.pitch.number():
|
||||
error=expressions.validate(tuple(ns),(*contracts,c),h)[1]
|
||||
rejections[n.event_id].append({'rule':rule['id'],'expected_spelling':proposed.label(),'rejection':error})
|
||||
for target in ns:
|
||||
anticipated=next((c for c in contracts if c.kind=='anticipation' and c.target==target),None)
|
||||
span=h.at(anticipated.boundary if anticipated else target.start)
|
||||
if not span or not span.scale:continue
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
start=target.start-F(rule['gap'])-sum(map(F,rule['durations']),F())
|
||||
events=tuple(n for n in ns if start<=n.start<target.start)
|
||||
if not events:continue
|
||||
prior=next((n for n in reversed(ns) if n.end<=start),None)
|
||||
c=types.ExpressionContract('recognition:'+target.event_id+':'+rule['id'],rule['kind'],rule['id'],events,target,span.chord,
|
||||
boundary=anticipated.boundary if anticipated else None,preparation=prior if rule['kind']=='connection' else None)
|
||||
if accept(c):break
|
||||
permissions,error=expressions.validate(tuple(ns),tuple(contracts),h)
|
||||
if error:raise ValueError(error)
|
||||
events=[]
|
||||
for n in ns:
|
||||
spans=h.segments(n);mapped=bool(spans) and spans[0][1]==n.start and spans[-1][2]==n.end and all(s.scale and n.pitch.number()%12 in s.scale.pitches for s,a,b in spans)
|
||||
basic=bool(spans) and all(n.pitch.number()%12 in s.roles.basic for s,a,b in spans)
|
||||
permission=permissions.get(n.event_id)
|
||||
status='recognized_treatment' if permission else 'ordinary_valid' if mapped and (basic or n.end-n.start<2) else 'unexplained_or_unsupported'
|
||||
events.append({'start':str(n.start),'end':str(n.end),'pitch':n.pitch.label(),'mapped_throughout':mapped,
|
||||
'chord_member_throughout':basic,'status':status,'rule':permission.rule if permission else None,
|
||||
'candidate_rejections':rejections.get(n.event_id,[]) if status=='unexplained_or_unsupported' else [],
|
||||
'harmonies':[s.chord for s,a,b in spans],'collections':[s.scale.label() if s.scale else None for s,a,b in spans]})
|
||||
return {'events':events,'rules':dict(collections.Counter(c.rule for c in contracts)),
|
||||
'statuses':dict(collections.Counter(n['status'] for n in events)),
|
||||
'limit':'Conservative recognition, not optimal parsing. Unrecognized events may be valid under a relationship not recognized here. Production harmony comes from the original source, never the target.'}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--reference',action='append',required=True,metavar='ROLE=XML')
|
||||
p.add_argument('--source',action='append',required=True,metavar='SONG=XML')
|
||||
p.add_argument('--generated-dir',type=Path,required=True)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
args=p.parse_args();args.output.mkdir(parents=True,exist_ok=True)
|
||||
sources={k:reference_review.read(v,'original') for k,v in (s.split('=',1) for s in args.source)}
|
||||
from generator import musicxml
|
||||
from generator.scales import timeline, selection, library
|
||||
scales=library.load_library(Path('data/scales'));source_maps={}
|
||||
for name,path in (s.split('=',1) for s in args.source):
|
||||
document=musicxml.load_musicxml(path);staff=document['staves'][0]
|
||||
end=sum((model.fraction(m['duration']) for m in staff['measures']),F())
|
||||
source_maps[name]=selection.select(timeline.read(staff),timeline.Solo(F(),end,'analysis only'),scales)
|
||||
manifest=json.loads((args.generated_dir/'manifest.solo').read_text())
|
||||
results={};lines=['# Musical decisions in the four supplied solos','','Sounding pitch is encoded guitar pitch minus one octave. Times are exact quarter beats from the first written harmony; preceding material remains separate. All measures and all sounding events are retained.','',
|
||||
'The relationships below are observations/hypotheses, not harmonic permission or a claim of listening. Source comparisons inspect relative pitch AND rhythm; annotations remain verbatim.','']
|
||||
for role,path in (s.split('=',1) for s in args.reference):
|
||||
ref=reference_review.read(path,role,pitch_convention='guitar-written')
|
||||
item=manifest['solos'][role];actual=reference_review.read(args.generated_dir/item['file'],role,item['form_start_measure'])
|
||||
analysis=explain(ref,sources[role.split('/')[0]]);comparison=solo_compare.compare(actual,ref)
|
||||
source=sources[role.split('/')[0]]
|
||||
recognized=rule_review(ref,source_maps[role.split('/')[0]],F(source['alignment']['encoded_offset'] or 0))
|
||||
results[role]={'reference':ref,'analysis':analysis,'rule_review':recognized,'generated_analysis':explain(actual),
|
||||
'exact_musical_match':comparison['exact_musical_match'],'exact_matching_attacks':comparison['exact_matching_attacks'],
|
||||
'different_onset_positions':len(comparison['differences']),'reference_playability':comparison['reference_playability']}
|
||||
lines.extend(['## '+role,'',f"Exact output match: **{comparison['exact_musical_match']}**; equal pitch/onset/release attacks: {comparison['exact_matching_attacks']}. This is not a perceptual similarity score.",'',
|
||||
'| Original measure | Aligned onset | Sounding pitch / duration | Observed relationship | Original annotation |','|---|---|---|---|---|'])
|
||||
for n in analysis['events']:
|
||||
raw=n['spelling'];pitch=raw['step']+('#'*int(F(raw['alter'])) if F(raw['alter'])>0 else 'b'*-int(F(raw['alter'])))+str(raw['octave']-1)
|
||||
lines.append(f"| {n['measure']} | {n['start']} | {pitch} / {F(n['end'])-F(n['start'])} | {'; '.join(n['relationships'])} | {'; '.join(n['annotation']).replace(chr(10),' / ')} |")
|
||||
lines.extend(['',f"Relative four-event returns: {analysis['relative_four_event_returns']}",'',f"Exact relative source relationships: {analysis['source_four_event_relationships']}",'',
|
||||
'General rule recognition against ORIGINAL source harmony: '+str(recognized['statuses']),
|
||||
'Recognized policies: '+str(recognized['rules']),'',recognized['limit'],'',
|
||||
'| Onset | Pitch | Collection-valid throughout | Recognition | Policy / source harmony |','|---|---|---|---|---|'])
|
||||
for n in recognized['events']:
|
||||
failures='; '.join(r['rule']+': '+r['rejection'].split(': ',1)[-1]+' (expected '+r['expected_spelling']+')' for r in n['candidate_rejections'])
|
||||
lines.append(f"| {n['start']} | {n['pitch']} | {n['mapped_throughout']} | {n['status']} | {n['rule'] or ''} / {', '.join(n['harmonies'])} {failures} |")
|
||||
(args.output/'theory.json').write_text(json.dumps(results,indent=2))
|
||||
(args.output/'theory.md').write_text('\n'.join(lines)+'\n')
|
||||
print('Reviewed every sounding event in',len(results),'complete reference solos; no composer invoked.')
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"provenance": "Original conservative chord/function rules; not learned from supplied reference scores.",
|
||||
"provenance": "Original conservative chord/function rules; not learned from supplied reference scores. Review of reference colors prompted the general distinction between a stable minor sixth and a resolving chromatic neighbor; no reference timing distribution was fitted.",
|
||||
"license": "MIT",
|
||||
"colors": [
|
||||
{
|
||||
@@ -63,10 +63,10 @@
|
||||
],
|
||||
"degree": 6,
|
||||
"alter": 0,
|
||||
"max_beats": "2",
|
||||
"max_beats": "4",
|
||||
"outside": true,
|
||||
"resolve": true,
|
||||
"function": "tonic_or_predominant"
|
||||
"function": "tonic_or_predominant",
|
||||
"continuation": "Stable color on tonic/predominant minor harmony; duration and explicit chord exclusions remain binding."
|
||||
},
|
||||
{
|
||||
"id": "dominant_ninth",
|
||||
|
||||
@@ -1,16 +1,139 @@
|
||||
{
|
||||
"version": 1,
|
||||
"provenance": "Original target-relative formulas; no reference-score licks or statistics.",
|
||||
"provenance": "Original target-relative formulas; no reference-score licks or statistics. Reference analysis motivated independently parameterized mapped-neighbor variants; no reference phrases or positions are stored.",
|
||||
"license": "MIT",
|
||||
"patterns": [
|
||||
{"id": "below", "kind": "approach", "semitones": [-1], "durations": ["1/2"], "gap": "0"},
|
||||
{"id": "above", "kind": "approach", "semitones": [1], "durations": ["1/2"], "gap": "0"},
|
||||
{"id": "upper_lower", "kind": "enclosure", "semitones": [1, -1], "durations": ["1/2", "1/2"], "gap": "0"},
|
||||
{"id": "lower_upper", "kind": "enclosure", "semitones": [-1, 1], "durations": ["1/2", "1/2"], "gap": "0"},
|
||||
{"id": "ascending", "kind": "connection", "semitones": [-2, -1], "durations": ["1/2", "1/2"], "gap": "0"},
|
||||
{"id": "descending", "kind": "connection", "semitones": [2, 1], "durations": ["1/2", "1/2"], "gap": "0"},
|
||||
{"id": "delayed_below", "kind": "approach", "semitones": [-1], "durations": ["1/2"], "gap": "1/2"}
|
||||
{
|
||||
"id": "below",
|
||||
"kind": "approach",
|
||||
"semitones": [
|
||||
-1
|
||||
],
|
||||
"durations": [
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0"
|
||||
},
|
||||
{
|
||||
"id": "above",
|
||||
"kind": "approach",
|
||||
"semitones": [
|
||||
1
|
||||
],
|
||||
"durations": [
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0"
|
||||
},
|
||||
{
|
||||
"id": "upper_lower",
|
||||
"kind": "enclosure",
|
||||
"semitones": [
|
||||
1,
|
||||
-1
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0"
|
||||
},
|
||||
{
|
||||
"id": "lower_upper",
|
||||
"kind": "enclosure",
|
||||
"semitones": [
|
||||
-1,
|
||||
1
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0"
|
||||
},
|
||||
{
|
||||
"id": "ascending",
|
||||
"kind": "connection",
|
||||
"semitones": [
|
||||
-2,
|
||||
-1
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0"
|
||||
},
|
||||
{
|
||||
"id": "descending",
|
||||
"kind": "connection",
|
||||
"semitones": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0"
|
||||
},
|
||||
{
|
||||
"id": "delayed_below",
|
||||
"kind": "approach",
|
||||
"semitones": [
|
||||
-1
|
||||
],
|
||||
"durations": [
|
||||
"1/2"
|
||||
],
|
||||
"gap": "1/2"
|
||||
},
|
||||
{
|
||||
"id": "mapped_upper_lower",
|
||||
"kind": "enclosure",
|
||||
"semitones": [
|
||||
2,
|
||||
-1
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0",
|
||||
"mapped_indices": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lower_mapped_upper",
|
||||
"kind": "enclosure",
|
||||
"semitones": [
|
||||
-1,
|
||||
2
|
||||
],
|
||||
"durations": [
|
||||
"1/2",
|
||||
"1/2"
|
||||
],
|
||||
"gap": "0",
|
||||
"mapped_indices": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"blues": [
|
||||
{
|
||||
"id": "minor_to_major_third",
|
||||
"degree": 3,
|
||||
"alter": -1,
|
||||
"target_degree": 3
|
||||
},
|
||||
{
|
||||
"id": "flat_fifth_to_fifth",
|
||||
"degree": 5,
|
||||
"alter": -1,
|
||||
"target_degree": 5
|
||||
}
|
||||
],
|
||||
"blues": [{"id": "minor_to_major_third", "degree": 3, "alter": -1, "target_degree": 3}, {"id": "flat_fifth_to_fifth", "degree": 5, "alter": -1, "target_degree": 5}],
|
||||
"max_approach": "3/2"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"provenance": "Independently authored, transposable rhythmic gestures and chord-member traversal operators. No notes, rhythms, measurements, or fitted parameters were extracted from the optional reference scores.",
|
||||
"provenance": "Independently authored, transposable rhythmic gestures and chord-member traversal operators. No notes, rhythms, measurements, or fitted parameters were extracted from the optional reference scores. Joint gesture operators were motivated by analysis of the supplied validation solos, then authored as general harmonic/rhythmic transformations. No target phrase, position, pitch sequence or frequency is stored.",
|
||||
"license": "MIT",
|
||||
"cells": [
|
||||
{
|
||||
@@ -172,7 +172,8 @@
|
||||
],
|
||||
"prefix_attacks": [
|
||||
2,
|
||||
3
|
||||
3,
|
||||
4
|
||||
],
|
||||
"max_interval": 7,
|
||||
"members": "basic",
|
||||
@@ -192,7 +193,8 @@
|
||||
],
|
||||
"prefix_attacks": [
|
||||
2,
|
||||
3
|
||||
3,
|
||||
4
|
||||
],
|
||||
"max_interval": 7,
|
||||
"members": "basic",
|
||||
@@ -236,5 +238,33 @@
|
||||
"members": "defining",
|
||||
"require_chord_entry": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"gesture_rules": [
|
||||
{
|
||||
"id": "harmonic_arrival",
|
||||
"kind": "harmonic_arrival",
|
||||
"arrival_offsets": [
|
||||
"-1/2",
|
||||
"0",
|
||||
"1/2"
|
||||
],
|
||||
"holds": [
|
||||
"3/2",
|
||||
"5/2"
|
||||
],
|
||||
"release": "1/2",
|
||||
"purpose": "Approach a harmonic change, settle, then release; earlier arrivals require an anticipation contract."
|
||||
},
|
||||
{
|
||||
"id": "sustained_color",
|
||||
"kind": "sustained_color",
|
||||
"purpose": "Choose an approved exposed color with its required continuation before realizing the route."
|
||||
}
|
||||
],
|
||||
"motion_subdivision": {
|
||||
"minimum_value": "2",
|
||||
"pulse": "1/2",
|
||||
"max_attacks": 16,
|
||||
"purpose": "Reopen one provisional long value into continuous mapped movement; committed arrivals and exact rhythmic identities are excluded."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,31 @@
|
||||
# Active production path audit
|
||||
# Current active path
|
||||
|
||||
Baseline: revision `405066e` (`Revamp`), initially clean working tree. The immutable copy is `build/composer_repair/405066e/baseline/`; `baseline_inputs.json` records source/data and optional-reference hashes. These are evidence, not production inputs. The named ZIP archives and external remediation/comparison Markdown files were absent. The extracted source copies in `data/songs/` remain authoritative and unchanged.
|
||||
Starting revision: `f234dd6`. Compact baseline: `build/solo_reference_comparison/previous/`; source/reference hashes: `preserved_inputs.json`.
|
||||
|
||||
Optional reference paths actually present:
|
||||
Current authorization permits studying the four supplied solos to identify reusable musical relationships. It does not permit production reads, stored reference phrases, target-loss selection or song-specific recipes. References remain unchanged.
|
||||
|
||||
- `build/all-of-me-solo-1.xml` — acoustic
|
||||
- `build/all-of-me-solo-2.xml` — electric
|
||||
- `build/autumn-leaves-solo-1.xml` — acoustic
|
||||
- `build/autumn-leaves-solo-2.xml` — electric
|
||||
## Verified findings
|
||||
|
||||
No reference contents were inspected to author the production changes. Optional comparison commands run after generation, preserve raw XML, and explicitly apply the separately confirmed guitar-written octave convention. Source import keeps its own contract.
|
||||
|
||||
| Reported issue | Reproduction / earliest failing stage | Repair |
|
||||
| Finding | Stage | Repair |
|
||||
|---|---|---|
|
||||
| Broad score overwritten | Confirmed: `checked_phrase` score replaced in `themes.phrase_candidates`; selection | One `MusicalEvaluation` supplies the actual score; no subsequent replacement or extra chorus transition cost |
|
||||
| Density calculated but unused | Confirmed in removed `phrasing.evaluate`; selection | Active-span attack-range contribution in authoritative evaluator; sustains and planned space have separate intents |
|
||||
| Absolute onset “rhythm diversity” | Confirmed in `themes.seeds`; retrieval | Duration/rest descriptors invariant under time translation |
|
||||
| Chord tone at every internal endpoint | Confirmed in `unit_candidates`; construction and validity | Ordinary endpoints use mapped pitches; sustained notes and declared destinations retain their contracts |
|
||||
| Every window sustained/closed | Confirmed; representation/construction | Open alternative; local settles are intermediate; cadential completions and source handoffs are terminal |
|
||||
| Answer vocabulary unreachable | Confirmed: operation chooser omitted answer; construction | Data-defined chooser requests answer, extension, fragment, free continuation, development and return |
|
||||
| Residual third/sixth-beat offsets | Reproduced from half-beat edits of triplet material; construction | Complete group checks; binary-only boundary alteration; complete displaced triplets preserved |
|
||||
| Expressions after strict winner | Confirmed in `composer.compose`; construction | Typed alternatives and direct versions compete before phrase selection |
|
||||
| Narrow starting/route choices | Confirmed: three first pitches, prefix of two paths, suffix of three; pruning | Whole-route actions, register representatives, named cutoff counts; no independently selected suffix |
|
||||
| Source register / electric +12 | Confirmed in role data and `profiles.for_part`; construction | Soft overlapping role centers and phrase alternatives, all six strings |
|
||||
| Role dependency / reference lookup | Not reproduced in baseline normal path | Existing independent process workers retained; runtime read boundary added |
|
||||
| Overly full responsive accompaniment | Not reproduced in current production | Current source-only canonical sparse comping retained; no generated-solo response reintroduced |
|
||||
| Shape-changing expressions used an already realized provisional route | Construction | Plan timing, destination, hold/release and contracts before route realization |
|
||||
| An approach could not resolve into an anticipated target | Validity/construction | Exact shared target and harmonic boundary; require both contracts |
|
||||
| New on-time arrivals could lose their pitch during route filling | Construction | Fix selected arrival pitches and protect their sounding tails |
|
||||
| Enclosures lacked a mapped whole-tone upper neighbor | Vocabulary | Two additional target-relative orders; actual collection membership required |
|
||||
| Minor sixth always demanded immediate neighbor resolution | Policy | Bounded stable tonic/predominant color |
|
||||
| Uniform eighth movement inherited a long-equal-value penalty | Retrieval | Eighth movement remains available without that penalty |
|
||||
| Internal provisional holds excluded flowing alternatives | Construction | Subdivide a suitable internal span; preserve committed ending tails |
|
||||
| Reference labels in technical fingering containers were missed | Presentation | Preserve verbatim label and original container |
|
||||
| Repeated approach/rhythm/fret checks dominated new work | Performance | Fully keyed bounded caches; caller provenance restored |
|
||||
|
||||
## Before and after call graph
|
||||
The older score overwrite, inert density term, absolute-onset rhythm proxy, string bans and generated-part dependencies were already repaired before this revision. They are not claimed as new work.
|
||||
|
||||
Both enter through `generator.__main__ → arrange.generate.generate_performance_scores → production.generate_tree → generate_role`. A spawn process pool already existed (default two workers). Roles use immutable original-song analysis and independently keyed policies/caches. Assembly applies source-defined role masks.
|
||||
## Production call graph
|
||||
|
||||
Previously the solo path was `composer.compose → leads.thematic_options → themes.phrase_candidates → unit_candidates → protected prefix + route suffix → checked_phrase → overwritten score → solo winner → expressions.refine`.
|
||||
`generator → arrange.generate → production.generate_tree → generate_role → composer.compose → leads.thematic_options → themes.phrase_candidates → themes.unit_candidates → gestures.select / routes.candidates → checked_phrase / scoring.musical → independent winner → notation → assembly`.
|
||||
|
||||
Now it is `composer.compose → leads.thematic_options → themes.phrase_candidates → whole joint unit_candidates → complete routes → provisional two-sided join alternatives / typed expression alternatives → checked_phrase → scoring.musical → phrase frontier → independent solo winner → validation → notation → assembly`. Source masks, vocal pickup, scales and physical checks are retained.
|
||||
Source-feature retrieval covers the original head. A realized home theme guides later development. Meter-specific rhythms and general grammar supply alternatives. Four selected timing templates can request joint gestures; the total route-job budget remains 16. Complete planned routes keep eight intermediate representatives. Ordinary routes retain sequence-catalog and chord-traversal support. Bounded join/timing/local-decoration revisions preserve contracts.
|
||||
|
||||
| Module/function | Live responsibility | Production inputs / budgets |
|
||||
|---|---|---|
|
||||
| `production.SourceAnalysis.read` | Canonical melody, harmony, exact meter/tempo/form | Original source only; corrected `meter` key to actual `time_signature` |
|
||||
| `themes.seeds` | Source feature evidence throughout the head | Source notes, preferred feature roles; eight diverse seeds |
|
||||
| `themes.rhythm_windows` / `patterns` | Complete rhythm alternatives and lawful transformations | Meter-filtered catalog, authored cells and difficulty; eight representatives |
|
||||
| `themes.operation_choices` | Request development purposes | `grammar.operations`, actual home/incoming state |
|
||||
| `themes.unit_candidates` | Complete joint actions | Actual incoming note, theme, destination, register; 16 jobs, eight retained gestures, deterministic retry |
|
||||
| `routes.candidates` | Full octave-aware scale/chord route | Relative sequence catalog, chord traversal operators, exact sounding span; eight route representatives |
|
||||
| `gesture_rhythm.join_variants` | Bounded edits across internal joins | Up to two beats on each side; eight legal alternatives; no protected hold consumed |
|
||||
| `expressions.joint_candidates` | Approach/color/anticipation/suspension alternatives | Versioned rules; eight representatives per provisional phrase, with direct version retained |
|
||||
| `realization.checked_phrase` | Identity, harmony, hold, placement, final evaluation | No optional reference inputs; one final evaluation result |
|
||||
| `leads.thematic_options` | Bounded continuation across windows | Eight chorus alternatives; duration-weighted complete evaluations |
|
||||
| `comping.Canonical` | Source-only reusable accompaniment | Written harmony/source rhythm/form, sparse voicing and comping resources |
|
||||
`selection_trace.operations` reports operation eligibility, proposals, validity and pruning. `joint_gestures` separately reports structural proposals, retained jobs, valid routes and retained units. Counts do not imply a selected use.
|
||||
|
||||
`themes.prefix_paths`, `_prefix_paths`, and unused `instantiate` were removed. The old thematic `phrasing.evaluate` was removed. The generic cell/draft realizer and explicit `mode='paraphrase'` API remain compatibility/test/audition paths; ordinary production does not call them or silently fall back to them. Their existence is not claimed as a new production capability.
|
||||
|
||||
A further pruning defect appeared during the real-song build: the seed category combined treatment and form position. Its first layer could fill the entire budget with eight exact-interval references and discard rhythm/contour contracts. The corrected cutoff preserves treatment families first; a synthetic reachability regression covers this. Open windows now also request continuing rhythm, rather than using arrival rhythm simply because a computational window ends.
|
||||
|
||||
Profiling found repeated `diverse` ranking and interval-family classification among the major costs. Ranking is now decorated once per item and immutable path descriptors are memoized. A stable-order comparison test proves that the optimized cutoff preserves the old ordering for tied ranks; no larger beam or wall-clock-dependent choice was added.
|
||||
|
||||
## Baseline export correspondence
|
||||
|
||||
A fresh read of each preserved MusicXML export was compared with `playback.compile_timeline` of its saved role document (explicit guitar octave convention and merged sounding ties). All four matched exactly: All of Me acoustic 117 attacks, electric 121; Autumn Leaves acoustic 83, electric 110. Evidence is `baseline-export-proof.json`. This confirms the saved baseline's exported music; it is not a claim that XML formatting or those counts predict musical quality.
|
||||
Canonical accompaniment remains source-only and independent. Explicit legacy paraphrase/cell APIs remain compatibility/test paths, without production fallback. Unused historical audition controllers were removed.
|
||||
|
||||
+16
-28
@@ -1,46 +1,34 @@
|
||||
# Reproduction commands
|
||||
|
||||
Run from the repository root. Prefix module invocations with:
|
||||
Use this prefix from the repository root:
|
||||
|
||||
```
|
||||
```sh
|
||||
build/build_common/python/bin/python3 build/build_tools/python_runner.py build/build_common/modulus
|
||||
```
|
||||
|
||||
Full staged production build actually run:
|
||||
Staged generation:
|
||||
|
||||
```
|
||||
-m generator --data-dir data --output-dir build/composer_repair/405066e/candidate --static-assets-dir package/assets --check --jobs 2 --composition-jobs 2 --composition-cache build/composer_repair/405066e/cache --engraving-cache build/build_common/engraving_cache --export-solo-xml --trace-level full --profile-output build/composer_repair/405066e/candidate-profile.json --progress
|
||||
```sh
|
||||
-m generator --data-dir data --output-dir build/solo_reference_comparison/.staging --static-assets-dir package/assets --check --jobs 2 --composition-jobs 2 --composition-cache build/build_common/composition_cache --engraving-cache build/build_common/engraving_cache --export-solo-xml --trace-level full --profile-output build/solo_reference_comparison/build-profile.json --progress
|
||||
```
|
||||
|
||||
Post-generation comparison actually run, with explicit role and alignment conventions:
|
||||
Publish normally with `cmake --build build/build_x86_debug --target vamp_generated_assets -j2`. Copy completed `generated_assets/solo_xml/` into `build/solo_reference_comparison/generated/` for the comparison.
|
||||
|
||||
```
|
||||
-m audition.solo_compare --generated-dir build/composer_repair/405066e/candidate/solo_xml --reference all_of_me/acoustic=build/all-of-me-solo-1.xml --reference all_of_me/electric=build/all-of-me-solo-2.xml --reference autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml --reference autumn_leaves/electric=build/autumn-leaves-solo-2.xml --form-start autumn_leaves/acoustic=2 --form-start autumn_leaves/electric=2 --form-start all_of_me/acoustic=1 --form-start all_of_me/electric=1 --reference-pitch guitar-written --assets build/composer_repair/405066e/candidate --output build/composer_repair/405066e/references
|
||||
```sh
|
||||
-m audition.solo_compare --generated-dir build/solo_reference_comparison/generated --reference all_of_me/acoustic=build/all-of-me-solo-1.xml --reference all_of_me/electric=build/all-of-me-solo-2.xml --reference autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml --reference autumn_leaves/electric=build/autumn-leaves-solo-2.xml --form-start autumn_leaves/acoustic=2 --form-start autumn_leaves/electric=2 --form-start all_of_me/acoustic=1 --form-start all_of_me/electric=1 --reference-pitch guitar-written --assets build/build_x86_debug/generated_assets --output build/solo_reference_comparison
|
||||
```
|
||||
|
||||
The standalone production role interface remains available (example, not part of the benchmark measurements):
|
||||
Full relationship/rule review:
|
||||
|
||||
```
|
||||
-m generator.compose --song data/songs/autumn_leaves.xml --role electric_solo --data-dir data --cache-dir build/build_common/composition_cache --output build/electric-role.json --solo-xml build/electric-role.xml
|
||||
```sh
|
||||
-m audition.theory_review --reference all_of_me/acoustic=build/all-of-me-solo-1.xml --reference all_of_me/electric=build/all-of-me-solo-2.xml --reference autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml --reference autumn_leaves/electric=build/autumn-leaves-solo-2.xml --source all_of_me=data/songs/all-of-me.xml --source autumn_leaves=data/songs/autumn_leaves.xml --generated-dir build/solo_reference_comparison/generated --output build/solo_reference_comparison/analysis
|
||||
```
|
||||
|
||||
Both CLI and CMake support independent process workers. The CMake setting is `VAMP_COMPOSITION_JOBS`; default 2, accepted range 1–16. More workers can increase memory/CPU contention. Worker completion order is not a musical input.
|
||||
Tests:
|
||||
|
||||
All baseline/reference/audition paths are downstream evidence only. Deleting them is not a cache invalidation or musical configuration change. To test cold generation, use an empty **composition cache** directory, not a previous output score as an input.
|
||||
|
||||
|
||||
Isolated single-role measurements actually run sequentially after regression and audition work completed:
|
||||
|
||||
```
|
||||
-m audition.benchmark --source tests/evaluation_sources/after-youve-gone.xml --data-dir data --role electric_solo --cache build/composer_repair/405066e/benchmark-cache --output build/composer_repair/405066e/profile-cold --profile
|
||||
-m audition.benchmark --source tests/evaluation_sources/after-youve-gone.xml --data-dir data --role electric_solo --cache build/composer_repair/405066e/benchmark-cache --output build/composer_repair/405066e/profile-warm
|
||||
-m audition.benchmark --source tests/evaluation_sources/after-youve-gone.xml --data-dir data --role electric_solo --cache build/composer_repair/405066e/benchmark-unprofiled-cache --output build/composer_repair/405066e/benchmark-cold
|
||||
```sh
|
||||
-m unittest discover -s tests -p 'test_*.py'
|
||||
-m audition.dependency_check --output build/solo_reference_comparison/dependency
|
||||
```
|
||||
|
||||
The first run measures profiler overhead as well as generation. The third uses a separate initially empty cache to measure unprofiled cold generation. All three canonical musical hashes are checked in `isolated-role-proof.json`.
|
||||
|
||||
Isolated full warm-cache build actually run:
|
||||
|
||||
```
|
||||
-m generator --data-dir data --output-dir build/composer_repair/405066e/warm --static-assets-dir package/assets --check --jobs 2 --composition-jobs 2 --composition-cache build/composer_repair/405066e/cache --engraving-cache build/build_common/engraving_cache --export-solo-xml --trace-level full --profile-output build/composer_repair/405066e/warm-profile.json --progress
|
||||
```
|
||||
Individual-role generation remains `-m generator.compose --song ... --role acoustic_solo --data-dir data --cache-dir ... --output ... --solo-xml ...`. No other part is generated as a musical input. The one comparison folder is downstream and disposable.
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
# Production data and consumer inventory
|
||||
# Data and cleanup inventory
|
||||
|
||||
All additions are independently authored general rules under the repository MIT license. No optional target pitches, rhythm fingerprints, statistics, fitted weights, or derivatives were added. Existing source songs and target files are untouched.
|
||||
Rules were authored as transposable theory relationships under the repository license. Inspection of the four solos motivated the repair; provenance states that openly. No reference phrases, bar recipes, fingerprints, aggregate distributions or fitted weights were stored.
|
||||
|
||||
| Resource | Action / count | Live consumer and reason |
|
||||
| Resource | Action | Live consumer |
|
||||
|---|---|---|
|
||||
| `data/songs/*.xml` | Keep both authoritative sources | Import/immutable analysis; no source archive overwrite |
|
||||
| `data/scales` | Keep 21 patterns | Seven modes each of major, melodic minor, harmonic minor; ordinary mapped permission |
|
||||
| `data/sequences` | Keep 2,954 paths (14/196/2,744) | Exact degree-route utility; not uniform lick sampling |
|
||||
| `data/rhythms` | Keep 3,538 snippets (34 2/4, 146 3/4, 630 4/4, 2,728 5/4) | Meter-indexed retrieval; 5/4 does not compete in 4/4 |
|
||||
| `composition/rhythms.json` | Extend nine cells with entry/exit/subdivision semantics; keep six comping figures | `vocabulary.rhythm_cells`, `themes.rhythm_windows`, `comping.Canonical` |
|
||||
| `composition/melodic_vocabulary.json` | Extend eleven cells similarly; retain four chord traversal rules | Complete rhythmic groups plus transposable chord-member paths |
|
||||
| `composition/grammar.json` | Add reachable operation chooser, answer/extend/fragment contracts, authoritative evaluation configuration | `themes.operation_choices`, `feature`, `scoring.musical`; preserves existing development contracts where applicable |
|
||||
| `composition/harmony.json` | Keep eleven optional color policies; add bounded prepared step-down suspension policy | Typed full-duration expression validation and candidate construction |
|
||||
| `composition/lead_ins.json` | Keep seven formulas and two blues rules | Whole approach/target contracts, direct alternatives always available |
|
||||
| `composition/roles.json` | Replace source octave offsets and fixed register cycles with overlapping soft centers and alternative regions | Both instruments use all six strings, frets 1–12; no equal-use quotas |
|
||||
| `data/expected/performance_summary.json` | Keep validation-only | Form/layout compatibility check, never winner selection |
|
||||
| `audition/controls/original_lead_theme.json` | Already migrated outside production; keep optional | No production read; runtime read boundary excludes audition files |
|
||||
| Saved outputs and references under `build/` | Keep comparison-only | Separate explicitly invoked audition commands |
|
||||
| `data/songs/*.xml` | Keep both unchanged | Original source analysis |
|
||||
| `data/scales/` | Keep 21 patterns | Mapper / ordinary permission |
|
||||
| `data/sequences/` | Keep 2,954 paths | Complete relative routes |
|
||||
| `data/rhythms/` | Keep 3,538 meter-specific snippets | Rhythm retrieval |
|
||||
| `composition/rhythms.json` | Keep nine melodic cells/six comping figures | Themes and canonical accompaniment |
|
||||
| `composition/melodic_vocabulary.json` | Keep eleven cells/four traversals; extend two traversal prefix lengths | Chord-route construction |
|
||||
| Same | Add two joint gesture rules and internal subdivision policy | `gestures.proposals`, `themes.patterns` |
|
||||
| `composition/lead_ins.json` | Seven formulas become nine; keep two blues rules | Construction and typed validation |
|
||||
| `composition/harmony.json` | Keep eleven colors; minor sixth gains bounded stable use | Harmonic validator and construction |
|
||||
| `composition/grammar.json`, `roles.json` | Keep | Evaluation, development and independent role preferences |
|
||||
| `data/expected/` | Keep validation-only | Compatibility checks |
|
||||
| Four supplied `build/*-solo-1/2.xml` | Keep untouched | Explicit optional review |
|
||||
| `audition/theory_review.py` | Add | Full event/relationship analysis against original source |
|
||||
| `audition/reference_review.py` | Extend note label evidence | Preserve raw label containers |
|
||||
| `audition/controls/original_lead_theme.json` | Remove | Superseded unused audition control |
|
||||
| `audition/production_review.py`, `release_evidence.py` | Remove | Historical multi-experiment exporters |
|
||||
| `generator/solo/audition.py`, `lead_audition.py` | Remove | Historical unused audition entry points |
|
||||
| Old generated experiment trees/logs | Remove | 1.82 GiB initially; exact paths/bytes in cleanup manifest |
|
||||
|
||||
The JSON version remains 1 with additive named fields; loader rejects missing provenance, duplicate IDs and unsupported subdivision groups. Event/runtime/score schemas did not change. `composer-report-2` and `joint-musical-1` identify diagnostic and evaluation schemas. Semantic generation is `joint-gestures-1`; instrumentation does not intentionally reseed music.
|
||||
|
||||
The old “prefix then cheap suffix” constructor and overriding thematic scorer were removed. Legacy nonproduction fixture helpers remain explicit, with no fallback from the current production route. Exhaustive path inventories and other meters are useful and were not deleted for directory size.
|
||||
|
||||
Evaluation-only additions: `tests/evaluation_sources/{after-youve-gone,indiana,hindustan}.xml` plus provenance JSON and README; `audition.transfer_review` is their sole consumer. These are four-measure original-song excerpts with declared piano-harmony reductions, not expected generated output or a production phrase bank. They were transcribed from cited original editions after the production vocabulary was authored. Neither ordinary builds nor required regression tests load them. The separate optional command records generation failures without relaxing policy.
|
||||
Schema 1 gains required gesture fields checked by the loader. Composition version: `joint-arrivals-2`. Runtime score/notation schemas are unchanged. No accompaniment vocabulary or role policy changed. Independent positive/negative tests do not read targets. Optional transfer fixtures remain outside production data.
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
# Independent-input and reproducibility evidence
|
||||
# Independent inputs
|
||||
|
||||
The normal dependency is `original source → immutable SourceAnalysis → generate_role(role, settings, data) → assembly`. No pairwise winner selection, generated foreground prediction, responsive comping or other-role grip reuse was introduced. Acoustic/electric accompaniment remains canonical by source form position. Source-defined windows suppress a guitar's accompaniment while that same guitar solos and protect the returning vocal pickup before construction.
|
||||
The preserved contract is `original source + relevant data + own role/settings + algorithm → part`. Six roles use a bounded spawn-process pool, normally two workers. No pairwise selection, generated-solo-responsive comping or other-role grip reuse exists in the active path.
|
||||
|
||||
`generator.input_audit.composition` installs a scoped Python audit hook. Composition reads are restricted to production data, generator code, Python runtime and that role's cache directory. Read/write handles do not bypass the rule. Reads of optional references, audition snapshots, previously published scores or another role's concrete cache are rejected. Original source content is supplied explicitly as immutable canonical analysis, before this scope. Rendering/assembly legitimately consume finished assets outside the scope.
|
||||
The scoped `input_audit.composition` guard checks actual reads. References, previous output and other-role concrete caches are prohibited composition inputs. Source analysis is supplied explicitly; assembly/rendering legitimately consume finished roles afterward.
|
||||
|
||||
This is an internal dependency guard, not a hostile-code security sandbox. Provenance review is still needed: a runtime deletion test alone would not detect target-derived material already copied into production data. All newly authored rules were reviewed as general operations; no target score contents were consulted during production authoring.
|
||||
New per-Harmony caches include exact timing/pitches/roles, continuation, profile, fixed endpoints, obligations and ending constraints. Only caller IDs/provenance are normalized for reuse, then restored. Focused tests compare uncached/cached proposals and complete routes. Pure rhythm validity and fret-position feasibility use bounded memoization.
|
||||
|
||||
Command actually run through the repository Python runner:
|
||||
The optional dependency command tests all six roles alone, reversed, cold/warm, in two processes, with invalid targets present/deleted, and after an electric-only setting change. Actual reads and document hashes accompany equality assertions. This is a one-bar independent fixture with real catalogs, not a whole-song quality test.
|
||||
|
||||
```
|
||||
-m audition.dependency_check --output build/composer_repair/405066e/dependency
|
||||
```
|
||||
Current evidence lives in `build/solo_reference_comparison/dependency/`. The implementation report lists actual completed checks. Full output traces live in `generated_assets/analysis/*.roles`.
|
||||
|
||||
`dependency/dependency.json` records actual read paths and canonical hashes for six roles using an independently authored one-bar C7 source and **real production catalogs**. All roles matched across standalone cold builds, reversed warm builds, reversed two-process cold builds, deliberately invalid optional target files present, targets deleted, and generation after an electric-only setting change. This is a dependency test, not a claim of whole-chorus artistic equivalence.
|
||||
|
||||
| Role | Stable document SHA-256 |
|
||||
|---|---|
|
||||
| Acoustic accompaniment | `2c3cc9efe4c6fbe7c1f8206d57e7f7d3a66bdadc9dbc2c657ea3d2d600425741` |
|
||||
| Electric accompaniment | `b89eedfd82fcfe83475c13cdf461c7123a8c99137bb0452875e3d2509267e183` |
|
||||
| Electric melody | `b8d18ef6fd65b204bd2fc2b69e660068890cb30aea6db827c3f3b506284ea2a1` |
|
||||
| Vocal melody | `e3348117b9c63771cfa0439f276bdadf0d26dd6e560c9676c97e12a4a713737b` |
|
||||
| Acoustic solo | `2d91f4f14615f6aa65c8f46ff0c8d858da1e808d8d132723c37342c20ea13e5b` |
|
||||
| Electric solo | `78640b8c8776735638141f1d34cf820a737b2891cae4c4529b6907a448c272f3` |
|
||||
|
||||
Required tests also check irrelevant title/export-ID changes, changed original melody under unchanged harmony, unrelated song addition, role-specific settings, tuplets through assembly, and cache digest validation. Optional reference adapters remain under `audition/`; production rejects supplied reference objects. Performance measurements and observed read lists are excluded from stable asset reports, so timing variation does not make otherwise equal assets differ.
|
||||
|
||||
Cache identity covers source semantics/notation, relevant role data, shared musical resources, role settings, algorithm version and relevant code. Code edits can invalidate caches; instrumentation does not intentionally alter musical choice seeds. Concrete route caches retain sounding timing/endpoints/collection context, and phrase caches retain incoming history and theme. Process completion order only determines reporting order, never a musical winner.
|
||||
|
||||
|
||||
Full-song cold/warm evidence: the isolated warm two-process rebuild of both current songs matched **all twelve complete role documents and stable reports**, plus all four exported solo XML files, against the staged build with cold solo caches. [The proof and per-role hashes](../../build/composer_repair/405066e/warm-equivalence.json) record 12.045 seconds and twelve cache hits. The separate four-bar cold/profiled/warm role runs also match one canonical document hash; see [isolated role evidence](../../build/composer_repair/405066e/isolated-role-proof.json). These checks supplement the role-order/parallel/reference-presence exercise; the full-song warm check by itself does not exercise every role-order permutation cold.
|
||||
References informed general rule reasoning under the current authorization, but production never reads them. A deletion test cannot detect an embedded phrase bank; provenance and the small explicit resource diff supply that separate check.
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
# Authoritative musical evaluation
|
||||
# Evaluation and validity
|
||||
|
||||
Normal thematic production uses `scoring.musical` and its immutable `MusicalEvaluation` result (`joint-musical-1`). `Phrase.score` is exactly `evaluation.total`. A complete solo is selected by the duration-weighted sum of those complete phrase evaluations. No later post-pass changes the winning notes or adds an undisclosed transition penalty.
|
||||
The active final selector remains `scoring.musical` / `MusicalEvaluation`, version `joint-musical-1`. Each phrase score is its evaluation total; the complete solo uses duration-weighted completed phrase evaluations. This revision changes construction coverage, not the final weights.
|
||||
|
||||
The result exposes validity, pending obligations, exact scope, raw values, denominators, weights, contributions, total and an error. Invalid harmony, identity, protected sounding tails, placement, or activity intent cannot be traded against a low cost. Identity is a contract, not two differently named resemblance rewards. Consequently the proposed redundant soft “theme” weight was not retained: fulfilled identity does not earn ever-increasing credit for copying more events.
|
||||
| Component | Weight |
|
||||
|---|---:|
|
||||
| Rhythmic grouping/interruptions | 3 |
|
||||
| Destination preparation | 2 |
|
||||
| Harmonic exposure | 2 |
|
||||
| Soft register preference | 1 |
|
||||
| Contextual repetition | 1 |
|
||||
| Activity | 1 |
|
||||
| Approximate playing difficulty | 0.5 |
|
||||
|
||||
| Contribution | Initial weight | Meaning |
|
||||
|---|---:|---|
|
||||
| Rhythm | 3 | Internal isolated short events / interruptions and actual incoming connection; entrance/release are distinct |
|
||||
| Destination | 2 | Preparation of declared arrivals, including the final approach; no destination obligation at an ordinary frontier |
|
||||
| Harmony | 2 | Time-normalized unexplained exposure; validated typed treatments have their own hard checks |
|
||||
| Register | 1 | Soft overlapping role corridor, weighted by sounding duration |
|
||||
| Repetition | 1 | Bounded recent exact/relative figure and oscillation history; sustains are separate from attacks |
|
||||
| Activity | 1 | Configured broad activity range over active spans, excluding held-arrival/intentional-space demands |
|
||||
| Difficulty | 0.5 | Approximate time-aware fretboard travel, normalized by attacks |
|
||||
Reports expose raw values, denominators, weights, contributions, total and validity. Identity, expression completion, physical domain and protected holds are hard contracts, not duplicate resemblance costs. Early retrieval estimates remain separate.
|
||||
|
||||
Weights reside in `data/composition/grammar.json`. These independently authored preferences are not fitted to optional targets and are not a model of aesthetic quality. Source time and chord semantics remain exact rational values. Floating-point arithmetic is confined to soft costs.
|
||||
Planned arrival spans have arrival activity semantics; they need no attack on every held beat. Silence cannot exploit a missing register value. Ties merge before harmonic duration/attack evaluation; repeated attacks cannot fulfill a hold.
|
||||
|
||||
Retrieval uses explicitly separate estimates to keep purposeful rhythmic, register and directional alternatives before expensive route/placement work. Unit and phrase frontiers use elapsed-duration rates when their coverage differs; only complete equal-span candidates enter the authoritative comparison. This is a bounded heuristic, not an admissible global-optimality proof. Cutoff counts are recorded under `selection_trace.operations` and `expression_operations`.
|
||||
Joint arrival/color/approach candidates exist before route search. An approach into future harmony requires the exact shared anticipation target. Mapped upper neighbors must actually belong to the collection. The stable minor-sixth policy is bounded by harmony/function/duration and does not legalize unrelated notes.
|
||||
|
||||
Tie continuations merge before durations, attack count, harmonic exposure and evaluation. Repeated attacks remain separate. Empty active passages are invalid; intentional space has no fabricated pitch/register cost. Partial expressive gestures carry a pending contract until the actual target/hold is heard; a missing past event or expired deadline fails.
|
||||
Selected ordinary arrival pitches and tails are also protected through refinement; ordinary computational endpoints remain provisional. Disabling expressions removes joint expression proposals and local ornaments and may choose another valid complete phrase. It does not mean deleting exceptional notes from a winner.
|
||||
|
||||
`--trace-level full` preserves candidate evaluation tables and operation counts in `.roles` reports. Summary mode removes heavy candidate tables only; it does not change generation. Optional profiling reports are outside musical inputs. The report schema is `composer-report-2` and runtime score/notation schemas are unchanged.
|
||||
|
||||
Tests: `tests/test_composer_repair.py`, existing sustained-note/quotation/placement/expression tests, and production score-equals-evaluation assertions. Selected and competing evaluation examples are exported in the release evidence; their aggregate scores establish explainable selection, not listener preference.
|
||||
|
||||
Counterfactual fixtures cover every configured contribution, including nonzero rhythm interruption, destination approach and recent-pattern recurrence. Identity evidence is a hard treatment contract, not a second hidden cost. Exact sustained notes and tie-split versions return equal evaluations. Ordinary mapped nonmember notes may end an open computational unit; they do not acquire an accidental anchor requirement.
|
||||
|
||||
The expressive toggle now means **exclude jointly composed expression candidates**. It can select a different valid complete phrase. It does not promise the decorated winner minus ornament notes. The exported `before_enclosure` stage preserves the selected phrase's actual strict provisional realization for a local controlled comparison; undecorated phrases retain their complete notes rather than becoming silent in that view.
|
||||
|
||||
`Policy.weights` still belongs to the explicit legacy draft/paraphrase API and is not the active thematic evaluator's weight table. The normal score uses `data/composition/grammar.json → evaluation.weights`. The audit distinguishes those consumers; modifying a legacy-only weight is not claimed to affect the new production winner.
|
||||
Reference recognition, proposed candidate, validity, retention, selected use and exact event matching are reported separately. The optional comparison has no scoring-return channel. Exact agreement is not a production loss function.
|
||||
|
||||
@@ -1,106 +1,94 @@
|
||||
# Deterministic composer repair — implementation report
|
||||
# Reference-focused composer repair
|
||||
|
||||
The active production composer was refactored in place. The four complete new solos and independently generated accompaniment are published through the normal CMake target in `build/build_x86_debug/generated_assets/`. The previous release remains under `build/composer_repair/405066e/baseline/`. This is a technically validated candidate musical version, not a claim of listener approval or resemblance to the optional target compositions.
|
||||
Starting revision: `f234dd6`. The goal of this revision is to make the active composer construct the musical relationships seen in the four supplied solos, using general rules and original song inputs. It does not import their notes or optimize candidate similarity to them. Exact reconstruction from general theory alone is not claimed.
|
||||
|
||||
## What changed
|
||||
## Actual changes
|
||||
|
||||
The previous final-score overwrite is removed. One versioned evaluator supplies the actual selected total, with visible raw values, denominators, weights and contributions. Activity now affects selection. Relative rhythm descriptors replace the absolute-onset proxy. Explicit identity and hold contracts remain hard requirements and are not counted again as duplicate fidelity costs.
|
||||
The active `themes → gestures → routes` path now chooses harmonic arrival timing, target pitch, sounding duration and release before pitch realization. It can construct an approach into an anticipated chord-tone target with both obligations present. Color destinations are available before route filling. Several compatible treatments can survive within one phrase; the local decoration stage preserves existing contracts instead of replacing them.
|
||||
|
||||
The normal constructor proposes complete pitch/rhythm routes from the incoming state, then checks the declared source/theme relationship. It no longer chooses a motif prefix and a separate cheap suffix. Source features are considered throughout the head; treatment diversity survives seed retrieval. This fixed a real-song failure where all eight retained seeds could demand exact intervals and exclude every rhythm/contour alternative.
|
||||
On-time/delayed arrival pitches and their uninterrupted tails remain fixed through route filling and refinement. Other cell endings stay provisional. Internal long values can be reopened into eighth-note motion where the selected thematic contract permits it; exact rhythm identities and committed closing holds remain protected. Four-member chord traversals are available alongside the existing mapped paths.
|
||||
|
||||
Generated rhythmic edits preserve complete binary/tuplet groups. Open windows request continuation rhythm and have no compulsory chord-tone stop. Local settles remain open; cadential completion and handoff have actual held-ending contracts. The original realized theme stays available for returns, while answer, extension, fragment and free continuation are reachable operations. Bounded two-sided join revisions compete with the original phrase.
|
||||
Two general enclosure formulas use an actually mapped whole-tone upper neighbor. A natural sixth on tonic/predominant minor harmony can be a bounded stable color rather than always requiring immediate neighbor resolution. These rules retain written harmony, omissions, scale restrictions on ordinary notes and physical validation. No new scale families or performance effects were added.
|
||||
|
||||
Approaches, enclosures, chromatic connections, colors, anticipations and prepared suspensions now compete over provisional phrases **before selection of the phrase/chorus**. Direct alternatives remain. Exact timing, target octave, preparation, resolution, complete sounding span and protected tails are checked. This is still bounded rewriting of complete provisional phrases, not exhaustive joint search over every possible expression and note.
|
||||
Reference inspection now includes every one of the 514 sounding events across the four complete scores. Original labels in technical-fingering and other-notation containers are retained. The review checks recognized treatments against **original source harmony**, preserves missing initial harmony, and keeps hypotheses separate from validated rules. Remaining unexplained events are reported instead of automatically authorized.
|
||||
|
||||
Acoustic and electric have overlapping soft register preferences instead of source-octave anchoring and compulsory electric transposition. Both retain strings 1–6, frets 1–12, no open strings. The existing phrase-placement path, sparse canonical source-only accompaniment and independent spawn-process architecture were retained. No generated role supplies another role's musical inputs.
|
||||
## One comparison location
|
||||
|
||||
The runtime/notation representation is unchanged. Timing, source harmony, the 21 collection patterns, vocal handoff, source melody/vocal content and physical constraints remain. All four new exported XML sounding streams match their saved role documents exactly. Vocal and electric head-melody role documents are byte-equivalent as parsed JSON to the baseline for both songs.
|
||||
`build/solo_reference_comparison/` contains the compact previous four XMLs/manifest, current output, full reference comparison/audio, complete theory review and verification logs. Start with `index.html`; `analysis/theory.md` supplies the all-event analysis. Solo 1 means acoustic; Solo 2 means electric.
|
||||
|
||||
## Evidence and review artifacts
|
||||
The references are `build/all-of-me-solo-1.xml`, `all-of-me-solo-2.xml`, `autumn-leaves-solo-1.xml`, and `autumn-leaves-solo-2.xml`. Source songs remain authoritative in `data/songs/`. All six input hashes were recorded before work. The optional adapter applies the separately confirmed guitar-written minus-one-octave convention; production import is unchanged. Autumn Leaves aligns at measure 2 / first Gm7 and retains the preceding measure. Four Autumn electric reference events conflict with the production vocal cutoff; neither input is rewritten.
|
||||
|
||||
- [Active call path, confirmed findings and budgets](active_path_audit.md)
|
||||
- [Production data inventory and provenance](data_migration.md)
|
||||
- [Authoritative evaluator and toggle semantics](evaluation_contract.md)
|
||||
- [Capabilities, tests and limits](musical_capability_matrix.md)
|
||||
- [Independent-input proof](dependency_proof.md)
|
||||
- [Selected phrase traces and local alternative score breakdowns](../../build/composer_repair/405066e/evidence/phrase_traces.md)
|
||||
- [Machine-readable operation funnels, hashes and evaluations](../../build/composer_repair/405066e/evidence/evidence.json)
|
||||
- [Baseline/new/source/plain/strict/final listening page](../../build/composer_repair/405066e/auditions/index.html)
|
||||
- [Separate optional target comparison](../../build/composer_repair/405066e/references/index.html)
|
||||
Cleanup removed 1.82 GiB of obsolete generated comparison trees/logs and five superseded tracked audition files. Exact removed paths and sizes are recorded in `cleanup.json`. Build toolchains, reusable composition/engraving caches, source songs and the four references were preserved. The old multi-experiment documentation was replaced with current commands and contracts.
|
||||
|
||||
The listening page contains complete solos, excerpts, solo-only audio, fixed comparison backing, and independently assembled final backing. Rendering uses consistent neutral synthesis and levels. Signal checks covered 112 WAV files with zero full-scale samples (largest absolute 16-bit sample 16,377); this checks clipping, not perceptual balance or musical quality. The strict view restores each selected phrase's valid provisional version; it does not remove chromatic notes and leave timing holes. A phrase without decoration remains audible in that view.
|
||||
## Evidence and boundaries
|
||||
|
||||
The local strict/decorated comparison isolates selected expression edits. Baseline versus new production changes multiple mechanisms and cannot identify which one caused a subjective improvement. Separate whole-chorus ablations restoring each superseded defect were **not** produced; broken old objectives were not retained as normal production branches. No ear-based audition was performed or claimed.
|
||||
- [Active path and verified defects](active_path_audit.md)
|
||||
- [Resource provenance, consumers and cleanup](data_migration.md)
|
||||
- [Authoritative evaluation](evaluation_contract.md)
|
||||
- [Musical capability and limits](musical_capability_matrix.md)
|
||||
- [Independent input contract](dependency_proof.md)
|
||||
- [Reproduction commands](commands.md)
|
||||
|
||||
## Selected output facts
|
||||
The six-role independent-input exercise passed with real catalogs: standalone, reversed, cold/warm, two-process, optional targets present/deleted, and after an electric-only setting change. Current hashes and actual read traces are in `dependency/dependency.json`. New proposal and route cache tests compare actual uncached/cached results and caller provenance. This does not establish perceptual quality.
|
||||
|
||||
| Song / role | New attacks | Sounding span available | Range, sounding MIDI |
|
||||
|---|---:|---:|---|
|
||||
| All of Me acoustic | 120 | 128 quarter beats | 42–64 |
|
||||
| All of Me electric | 106 | 128 quarter beats | 48–67 |
|
||||
| Autumn Leaves acoustic | 100 | 128 quarter beats | 49–69 |
|
||||
| Autumn Leaves electric | 103 | 125 quarter beats | 46–70 |
|
||||
The final score weights were preserved. Construction coverage is reported separately from selection under `selection_trace.joint_gestures`. A supported relationship may never be selected; matching a reference attack is a different fact again. Complete per-phrase evaluation totals remain inspectable.
|
||||
|
||||
All four pass full harmonic validation with no unresolved obligations. These counts/ranges are observations, not quotas or evidence of musical preference. The three protected quarter beats at the returning Autumn Leaves vocal are explicitly excluded from the electric solo's allocation.
|
||||
The first diagnostic profile exposed repeated approach/rhythm/fretboard work. Bounded caches now reuse exact immutable constraints and restore each caller's provenance. No search beam was widened and no machine-speed timeout selects music. Full build costs and final test outcomes are recorded below; concurrent checks are not treated as an isolated benchmark.
|
||||
|
||||
For example, All of Me electric has an open continuation over beats 20–42, a local settle on B3 at 104.5–107.5, and a final G3 handoff at 123–126 followed by space. Its selected actions include rhythm and contour development, answers, fragments, a clear return and a free continuation. All of Me acoustic selects a prepared suspension and an anticipation; these are real validated events, not unused vocabulary labels. The full trace reports proposed/valid/retained candidates and the selected actions separately.
|
||||
This remains a bounded rule-based composer. Some expressive reference relationships are unsupported or not recognized by the conservative optional parser; this is not proof those notes are musically wrong. Guitar placement is a legal-path check, not full ergonomic certification. No ear-based audition or listener approval is claimed.
|
||||
|
||||
Autumn Leaves electric selects more free continuations and no explicitly labeled full return. That is a useful listening question about identity retention, not an invitation to enforce a return quota. Autumn Leaves acoustic repeatedly resolves to D4 in its earlier cadential passages; the report exposes this rather than interpreting pitch diversity as automatic improvement.
|
||||
## Completed checks
|
||||
|
||||
## Reference and source handling
|
||||
- Full Python suite: **576 tests passed in 1700.428s**. Command: repository Python runner plus `-m unittest discover -s tests -p 'test_*.py'`; full log in `build/solo_reference_comparison/tests.log`. This overlapped the full production build.
|
||||
- Focused joint-arrival/evaluator/comparison tests: **33 passed**; eight comparison-adapter tests also passed after the presentation update.
|
||||
- Six-role real-catalog dependency exercise: **passed**, including two spawned processes and reference presence/absence; evidence in `dependency/dependency.json`.
|
||||
- `git diff --check`: passed at the pre-publication checkpoint.
|
||||
|
||||
The authoritative source files are still `data/songs/all-of-me.xml` and `data/songs/autumn_leaves.xml`. Named source/target ZIP archives and supplied review Markdown were not found; existing extracted files were used only in their authorized roles. No source was overwritten from another arrangement.
|
||||
The comparison table aggregates timing-only retained-unit labels (such as diminution) into their direct construction family. The manifest preserves the original raw diagnostic labels. This reporting normalization does not select or edit music.
|
||||
|
||||
The four optional `build/*-solo-1.xml` / `*-solo-2.xml` references are unchanged, verified by SHA-256. Their contents were first used here by a separate post-generation comparison, not to author production rules. Solo 1 maps to acoustic; Solo 2 maps to electric. The adapter preserves raw pitches and applies the explicitly confirmed guitar-written minus-one-octave convention only to its normalized comparison. Autumn Leaves aligns at measure 2 / first Gm7 and retains its preceding measure; All of Me starts at measure 1. All four normalized reference solos have a legal placement under the current domain. This is not a complete ergonomic assessment. Four Autumn electric reference events extend beyond the production vocal-protection cutoff and are reported rather than clipped or imported.
|
||||
## Published results and comparison
|
||||
|
||||
The additional transfer review uses independently transcribed four-measure excerpts from the original editions of After You've Gone, Indiana and Hindustan, with declared harmonic reductions and provenance outside `data/`. All six roles completed for each excerpt. These are limited transfer probes, not complete standard/form coverage or pristine external judgments of quality. No production policy was fitted to those outputs.
|
||||
Normal production assets are published at `build/build_x86_debug/generated_assets/`. The four MusicXML exports in its `solo_xml/` directory are byte-identical to the copies in `build/solo_reference_comparison/generated/`.
|
||||
|
||||
## Performance and reproducibility
|
||||
| Solo | New attacks | Sounding MIDI range | Exact matching reference attacks | Differing onset positions |
|
||||
|---|---:|---|---:|---:|
|
||||
| All of Me acoustic | 104 | 50–69 | 1 | 186 |
|
||||
| All of Me electric | 100 | 51–67 | 3 | 187 |
|
||||
| Autumn Leaves acoustic | 106 | 43–62 | 3 | 182 |
|
||||
| Autumn Leaves electric | 97 | 55–70 | 2 | 170 |
|
||||
|
||||
Hardware: Intel Core i7-1355U, 12 logical CPUs; two composition processes and two engraving workers. Deterministic expansion budgets are used; elapsed time never selects a winner.
|
||||
**None is an exact reference match.** These counts are descriptive, not evidence that the new music is closer perceptually. The earlier outputs had 0/3/0/1 exact matching attacks and 202/191/179/182 differing onset positions respectively. Different attack counts change these comparisons; they must not become a production optimization target.
|
||||
|
||||
The full candidate build took **1,576.3 seconds (26.3 minutes)**. Solo caches were cold, accompaniment/head caches were warm, and regression/diagnostic work overlapped; this is an observed build cost, **not an isolated cold benchmark or a clean before/after speedup claim**. Individual cold-role elapsed times were 828.6s / 645.1s for All of Me acoustic/electric and 809.8s / 722.9s for Autumn Leaves acoustic/electric. Worker times overlap and must not be added as total wall time. Engraving rendered 92 new pages, reused two complete scores and took 11.3s wall time.
|
||||
The new winners contain 9/12/13/13 validated expressive contracts respectively. All of Me electric selects one anticipation; the other three winners select none. Early-arrival candidates are constructed, validate and survive pruning in the traces, so their infrequent final selection is now visible separately from lack of capability. No quota was imposed to conceal that result.
|
||||
|
||||
The normal cached CMake rebuild took **25.4 seconds**, with all twelve role caches and all six engraving results reused, while regression tests were running. Both source songs' published solo XML files are identical to the staged validated candidates. The profile files distinguish cache lookup from original search work. A later **isolated warm full build took 12.045 seconds** with twelve role-cache hits and two workers. All twelve role documents, their stable reports, and all four solo XML files matched the staged build exactly; `warm-equivalence.json` records their hashes.
|
||||
All of Me acoustic contains a complete ascending A3–A#3–B3 connection at beats 12–13, and a mapped A3–F#3 approach to G3 at 64–65. All of Me electric sustains B3 as a valid minor ninth over its Am7 context at beats 28–31. These are selected events, not inserted examples or borrowed reference notes. `analysis/selected_phrases.md` records actual themes, full opening rhythms, every selected treatment, hold completion and alternative score breakdowns. Alternatives shown over equal spans are not falsely presented as the complete solo's globally ranked runners-up.
|
||||
|
||||
An isolated single-process four-bar **After You've Gone electric solo** benchmark took **19.924s cold** and **0.930s warm**, excluding interpreter startup and output-report writing. Source analysis took about 0.006s separately. Both used the real production catalogs and identical settings; the cold, warm and profiled runs produced the same canonical document hash (`5b2525a214ed7f30971752434faad2a39ca22adddcfc9947c009e1431b77187b`). These are excerpt-role measurements, not estimates for a complete chorus. No concurrent tests or audition rendering ran during these benchmarks.
|
||||
The first All of Me acoustic theme still begins with a 4.5-beat source-derived rhythmic hold. This and the small number of selected anticipations demonstrate remaining distance from the supplied examples' phrase construction. The next narrow investigation is the interaction between retained source-rhythm contracts and complete-phrase selection: inspect valid arrival alternatives that lose, and source features that prevent editing a provisional opening. Do not respond by adding an enclosure quota, changing accompaniment, fitting reference counts or claiming that more vocabulary alone solved the problem.
|
||||
|
||||
A separate cProfile run of that same cold role took 63.770s including profiler overhead. Nested cumulative times were catalog loading 17.889s, complete composer 44.310s, route candidates 23.073s, harmonic validation 2.949s, expression alternatives 1.625s, placement 0.694s and final evaluation 0.509s. JSON writing was about 0.108s; report/provenance construction is also distributed among composer calls and is not fully isolated by that number. These nested times must not be summed. Full function evidence is in `profile-cold/{role.prof,profile.txt,stages.json}`; the measured cold/warm hash proof is `isolated-role-proof.json`.
|
||||
## Final verification
|
||||
|
||||
A construction profile identified sequence retrieval, repeated ranking/representation and harmonic checks as costs. Ranking now computes each key once per cutoff; immutable interval descriptors are memoized. Stable-order tests verify unchanged ordering for tied ranks. A narrow 800-item/30-repeat microbenchmark returned identical selections (0.826s original versus 0.806s optimized); this is deliberately not presented as a whole-build speedup. More efficient cold search remains unfinished performance work; no beam was widened to hide candidate defects.
|
||||
- All **12 role documents** agree between the cold staged build and the warm normal publication, including notes, timing and TAB. Evidence: `verification.json`.
|
||||
- All eight non-solo role documents (accompaniment, electric source melody and vocal for both songs) match the previous release exactly as parsed JSON.
|
||||
- All four MusicXML sounding streams match their selected production role documents. Full-duration harmonic validity, ending completion, and evaluation-contribution sums pass.
|
||||
- The two original song files and four supplied references remain SHA-256 identical to their preserved inputs.
|
||||
- Native command `ctest --test-dir build/build_x86_debug -R '^product_vamp$' --output-on-failure` passed in **33.54s**, with the required localhost socket access.
|
||||
- The single comparison page contains 24 WAVs: complete generated/reference solos, identical-backing mixes, and opening excerpts. All passed PCM clipping checks. All 36 local page links exist. No subjective listening verdict is claimed.
|
||||
|
||||
The dependency exercise used real catalogs and an independently authored source. All six role hashes matched when built alone, in reverse order, in two spawned processes, warm/cold, with invalid optional target files present/absent, and after an electric-only setting change. Actual read traces and a runtime guard accompany equality tests. This does not by itself prove absence of an already embedded derivative; the independently authored data provenance is separately reviewed.
|
||||
## Measured cost
|
||||
|
||||
## Commands and verification
|
||||
Hardware: Intel Core i7-1355U, 12 logical CPUs; two composition workers. Cold solo generation plus full staged asset build took **2249.06s (37.5 minutes)**. Regression and dependency checks overlapped part of that run, so this is not an isolated speed comparison with the earlier release.
|
||||
|
||||
All Python commands below were run with:
|
||||
| Cold solo role | Seconds |
|
||||
|---|---:|
|
||||
| All of Me acoustic | 1041.56 |
|
||||
| All of Me electric | 850.48 |
|
||||
| Autumn Leaves acoustic | 1328.58 |
|
||||
| Autumn Leaves electric | 1170.46 |
|
||||
|
||||
```
|
||||
build/build_common/python/bin/python3 build/build_tools/python_runner.py build/build_common/modulus
|
||||
```
|
||||
Worker times overlap. Four All of Me non-solo caches were warm; the four Autumn Leaves non-solo roles regenerated in under one second each. Engraving rendered 90 new pages, reused two scores and took 16.65s wall time. Stage/input/read evidence is in `build-profile.json` and `build-inputs.json`.
|
||||
|
||||
Append the relevant module/arguments:
|
||||
The isolated warm CMake publication took **31.49s**, including about eight seconds of CMake/configuration overhead; asset generation reported 23s. All twelve role caches and all six engraving results were reused. Musical equality was verified, not assumed from cache-hit flags.
|
||||
|
||||
```
|
||||
-m unittest discover -s tests -p 'test_*.py'
|
||||
-m unittest tests.test_composer_repair tests.test_solo_leads.CompleteRouteTests
|
||||
-m unittest tests.test_solo_themes.ThemeProduction.test_library_routes_match_the_actual_complete_theme
|
||||
-m audition.dependency_check --output build/composer_repair/405066e/dependency
|
||||
-m audition.transfer_review --output build/composer_repair/405066e/transfer-final
|
||||
-m audition.release_evidence --assets build/composer_repair/405066e/candidate --output build/composer_repair/405066e/evidence
|
||||
-m audition.production_review --assets build/composer_repair/405066e/candidate --baseline build/composer_repair/405066e/baseline --output build/composer_repair/405066e/auditions
|
||||
```
|
||||
An isolated 30-repeat structural-proposal microbenchmark returned exactly equal results: **0.4422s** without the proposal cache versus **0.02193s** with it (shared rhythm/position caches already warm). This is a repeated-calculation measurement, **not** a claimed whole-composer speedup. Cold search is still expensive; candidate construction/validation and repeated thematic alternatives remain performance work.
|
||||
|
||||
The full generator invocation and reference comparison are recorded in [reproduction commands](commands.md). Publication used `cmake --build build/build_x86_debug --target vamp_generated_assets -j2`. Native verification used `ctest --test-dir build/build_x86_debug -R '^product_vamp$' --output-on-failure`.
|
||||
|
||||
Earlier runs deliberately remain in the evidence directory: the first suite exposed timer leakage in supposedly stable reports and obsolete fixed-closure/phrase-count expectations. Those were repaired. A later 565-test run passed 564 tests and exposed the obsolete requirement for an unedited route in every selected fixture. The corrected provenance test passed (115.4s including class construction); the final full-suite rerun passed **all 565 tests in 737.334 seconds**, recorded in `tests-complete.log`. The focused 19-test repair/route run passed. The native suite initially failed sandbox-denied localhost socket operations; rerunning with the required local socket access passed. After publication, the native CTest target passed again in **18.91 seconds**. These infrastructure failures were not musical validation failures.
|
||||
|
||||
## Boundaries and next experiment
|
||||
|
||||
- Cold construction is still costly. The next performance step should profile purpose-conditioned route retrieval and repeated provenance work with fixed musical hashes, rather than indiscriminately widening or shrinking search.
|
||||
- The frontier is bounded and can miss a viable theme/destination combination. Exhaustion is reported as budget exhaustion, not a proof of incompatibility; there is no hidden old-generator fallback.
|
||||
- The final evaluator is inspectable but intentionally small. It does not establish memorability or predict listener preference.
|
||||
- Current production chooses complete actions before crossing the search frontier; the partial-treatment API supports bounded pending obligations, but this is not an unbounded multi-phrase optimizer.
|
||||
- Unsupported written chord stacks still produce explicit no-exact-map diagnostics. Complex importer/form conventions beyond existing support are not newly certified. Original rhythms are never silently quantized.
|
||||
- Legal TAB and approximate travel costs are not a complete biomechanical model. Independent backing cannot guarantee exact gaps around a solo's realized holds; no reactive recomposition is claimed.
|
||||
- Listen first to whether the introduced idea remains recognizable in Autumn Leaves electric's freer continuation. Compare the plain/strict/final versions before changing density, return counts or chromatic vocabulary. The optional target comparisons remain outside the selection process.
|
||||
After verification, duplicate full asset stages, two interrupted stages, temporary tone samples and the binary diagnostic profile were removed. The comparison retains the four current scores, compact previous scores, references through links, useful audio and verification evidence in one directory. No previous output is a generation input.
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
# Musical capability and limits
|
||||
# Capabilities and boundaries
|
||||
|
||||
| Capability | Representation / live operation | Validation |
|
||||
| Relationship | Active support | Limit |
|
||||
|---|---|---|
|
||||
| New source-influenced theme | Features throughout original head → complete joint pitch/rhythm statement | Source change test; explicit feature evidence; whole-head near-copy check |
|
||||
| Recognizable return | Original realized signature retained, with harmonic adaptation | Multi-event rhythm/interval contract; no one-note perfect match |
|
||||
| Answer / extension / fragment | Data-defined chooser in normal `themes.phrase_candidates` | Independent positive operation-realization tests |
|
||||
| Free connected movement | Complete route conditioned on actual incoming pitch and future destination | All mapped spans, full route export correspondence; no mandatory basic endpoint |
|
||||
| Rhythm groups | Binary, complete ternary and supported sixteenth groups; delayed entrances and cross-bar notes | Incomplete tuplet/residual drift rejected; imported timing never quantized |
|
||||
| Open / local settle / completion / handoff | Open alternative; intermediate holds; cadential and source-boundary terminal contracts | Actual sounding hold, release and post-target motion checks |
|
||||
| Two-sided join revision | Fixed outer endpoints, editable material on both sides | Full affected harmony, identifying features and protected tails revalidated |
|
||||
| Approaches / enclosures / connections | Complete target-relative formulas in joint phrase alternatives | Exact octave, timing, preparation where required and complete resolution |
|
||||
| Optional color | Quality/function/degree policy, distinct from written chord requirements | Full duration, exclusions/omissions, bounded exposure and specified resolution |
|
||||
| Anticipation | Bounded early onset into actual future harmony | Pre-boundary contract and arriving collection/chord checked independently |
|
||||
| Prepared suspension | Explicit actual preparation and delayed downward resolution | Preparation, deadline, target and mapped sounding interval |
|
||||
| Blues inflection | Two conservative prepared semitone-resolution rules | Actual preparation and appropriate chord target; no blanket exemption |
|
||||
| Guitar domain | Both roles all six strings, frets 1–12, no open strings | Full phrase path; ties keep exact position |
|
||||
| Sparse accompaniment | Source-only canonical shells/compact voicings with rhythmic space | Written degrees/bass responsibility and repeated-form consistency |
|
||||
| Theme/return | Source-derived feature and actual home realization | Technical similarity does not prove recognition |
|
||||
| Mapped/chord movement | Complete sequences, chord traversal, contract-conditioned routes | Bounded search can miss alternatives |
|
||||
| Anticipation | Joint arrival/hold and future-harmony contract | Bounded early window |
|
||||
| On-time/delayed arrival | Selected pitch and uninterrupted tail | No compulsory arrival per cell |
|
||||
| Approach into anticipation | Shared exact future target, both contracts | Wrong octave/changed target rejects |
|
||||
| Enclosure | Both orders, semitone or mapped whole-tone upper | Must surround under the exact rule |
|
||||
| Minor sixth | Bounded stable tonic/predominant color | Written semantics/function authoritative |
|
||||
| Other colors/suspensions/blues | Existing explicit policies | Annotation is not permission |
|
||||
| Rhythm | Coherent binary/tuplet groups and editable internal subdivision | No arbitrary tuplet slicing |
|
||||
| Guitar | Strings 1–6, frets 1–12, no open strings | Not full ergonomic certification |
|
||||
| Accompaniment | Independent canonical sparse comping | No reactive solo-gap filling |
|
||||
| Reference review | All events, original labels, rule recognition | Observations distinguished from validated support |
|
||||
|
||||
Not every eligible operation is guaranteed to survive a bounded search or occur in either current song. Trace counts distinguish proposed, valid and retained candidates. A zero selected count is not evidence of a feature being absent from the code, and merely loading data is not evidence of reachability.
|
||||
Independent tests in `tests/test_joint_arrivals.py` extend the existing timing, evaluator, importer, physical, expression and dependency coverage. The four target files are not required fixtures.
|
||||
|
||||
Known boundaries: sources must satisfy the existing importer/arrangement form contract; ambiguous exporter harmony is not repaired from a target. Exact unsupported chord stacks still fail scale mapping contextually. Physical validation is a limited hand-region/travel model, not a complete ergonomic guarantee. Shape-changing treatments compete over provisional complete phrases; this is not an unbounded jointly optimized chorus. Cross-window continuation still uses bounded lookahead, so phrase partition choices can change winners even though evaluating the same sounded candidate is partition-invariant.
|
||||
All four target solos have legal positions under the explicitly confirmed guitar-written octave convention. Four Autumn electric events extend beyond the source-derived production vocal cutoff. This is reported, not repaired by changing either input.
|
||||
|
||||
The additional source review uses independently transcribed historic-source excerpts, kept outside `data/`, after production vocabulary freeze. These are transfer diagnostics rather than a claim of comprehensive coverage of every standard, meter or arrangement convention. Optional four-target comparison uses guitar-written normalization once and explicit form alignment; original encoded evidence remains available. No auditory judgment is claimed from event/signal checks alone.
|
||||
|
||||
Final additional-source run: all six independent roles completed for each of After You've Gone, Indiana and Hindustan (18 role generations), with exact source excerpt timing and ordinary physical validation. [the final transfer report](../../build/composer_repair/405066e/transfer-final/report.json) records hashes, times and scope. No vocabulary was fitted from this run. Short excerpts are useful evidence of source dependence and basic transfer, not evidence that the entire standards' forms or every original piano chord are supported.
|
||||
|
||||
A repeated attack is now a named low-level primitive, distinct from a zero step smuggled into the nonzero interval catalog and distinct from one tied sustain. The complete-route test retains the exact authored descending path while permitting additional explicitly constructed chord/repetition routes. This deliberately changes the old one-candidate expectation, not the endpoint or scale constraint.
|
||||
Unexplained reference events remain explicit. Recognizing a relationship does not inject it into construction; selecting more expression contracts does not prove a better melody. Audition remains necessary.
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
"""Build-only snapshots and like-for-like listening comparisons; no engine presets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import array
|
||||
import collections
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import html
|
||||
import importlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
|
||||
import generator.arrange.generate
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.musicxml_writer
|
||||
import generator.parts
|
||||
import generator.playback
|
||||
import generator.rhythm
|
||||
import generator.scales.library
|
||||
import generator.solo.arrangement
|
||||
import generator.solo.report
|
||||
import generator.solo.sequences
|
||||
import generator.solo.realization
|
||||
import generator.solo.references
|
||||
import generator.solo.harmonic
|
||||
|
||||
STAGES = ('baseline', 'A', 'B', 'C', 'D')
|
||||
|
||||
def metrics(solo):
|
||||
notes = solo.notes()
|
||||
gaps, cursor, run, runs = [], fractions.Fraction(), 0, []
|
||||
for note in notes:
|
||||
if note.start > cursor:
|
||||
gaps.append((cursor, note.start))
|
||||
if note.end - note.start == fractions.Fraction(1, 2):
|
||||
run = run + 1 if note.start == cursor else 1
|
||||
else:
|
||||
run = 0
|
||||
runs.append(run)
|
||||
cursor = note.end
|
||||
if cursor < solo.end:
|
||||
gaps.append((cursor, solo.end))
|
||||
planned = [(p.start, p.end) for phrase in solo.phrases for p in getattr(phrase.plan, 'passages', ()) if p.intent == 'space']
|
||||
planned.extend((p.plan.end - p.plan.release, p.plan.end) for p in solo.phrases if p.plan.release)
|
||||
planned.extend((c.span.start, c.span.end) for c in solo.choices if c.scale is None)
|
||||
unplanned = []
|
||||
for start, end in gaps:
|
||||
fragments = [(start, end)]
|
||||
for a, b in planned:
|
||||
fragments = [(x, y) for low, high in fragments for x, y in ((low, min(high, a)), (max(low, b), high)) if y > x]
|
||||
unplanned.extend(b - a for a, b in fragments)
|
||||
quoted = sum(n.end - n.start for n in notes if n.role == 'quote')
|
||||
return {
|
||||
'phrases': len(solo.phrases), 'attacks': len(notes),
|
||||
'attacks_per_four_beats': float(4 * len(notes) / solo.end),
|
||||
'sounding_occupancy': float(sum(n.end - n.start for n in notes) / solo.end),
|
||||
'longest_unplanned_gap_beats': float(max(unplanned, default=0)),
|
||||
'longest_connected_eighth_run': max(runs, default=0),
|
||||
'quotation_coverage': float(quoted / solo.end),
|
||||
'treatments': dict(collections.Counter(d.treatment for p in solo.phrases for d in p.decisions)),
|
||||
'search': getattr(solo, 'diagnostics', {}),
|
||||
}
|
||||
|
||||
def fingerprint():
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(pathlib.Path('generator').rglob('*.py')):
|
||||
digest.update(path.as_posix().encode())
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
def resources():
|
||||
return (generator.rhythm.load_library(pathlib.Path('data/rhythms')), generator.scales.library.load_library(pathlib.Path('data/scales')), generator.solo.sequences.load_library(pathlib.Path('data/sequences')))
|
||||
|
||||
def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=True):
|
||||
destination = root / stage
|
||||
if destination.exists():
|
||||
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
|
||||
rhythms, scales, sequences = libraries or resources()
|
||||
reference_library = generator.solo.references.load_library(pathlib.Path('audition/controls'))
|
||||
result = {'enclosures_enabled':enclosures_enabled,'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}}
|
||||
try:
|
||||
result['policy'] = importlib.import_module('generator.solo.policy').describe()
|
||||
except ModuleNotFoundError:
|
||||
result['policy'] = {'baseline': True}
|
||||
for path in generator.parts.source_paths(pathlib.Path('data/songs')):
|
||||
source_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json'
|
||||
if checkpoint.exists():
|
||||
saved = json.loads(checkpoint.read_text())
|
||||
if saved.get('enclosures_enabled',True)==enclosures_enabled and saved['implementation'] == result['implementation'] and saved.get('reference_versions') == result['reference_versions'] and saved['song']['source_sha256'] == source_hash:
|
||||
result['songs'][path.stem] = saved['song']
|
||||
result.setdefault('resumed_songs', []).append(path.stem)
|
||||
continue
|
||||
started = time.monotonic()
|
||||
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||||
plans = generator.arrange.generate.plan_arrangement(document, rhythms)
|
||||
parts = {part: generator.arrange.generate._arrange_document(document, part, plans) for part in generator.parts.PART_TYPES}
|
||||
solos = []
|
||||
shared = contexts.setdefault(path.stem, {p: generator.solo.realization.SearchContext() for p in ('acoustic', 'electric')}) if contexts is not None else None
|
||||
if shared:
|
||||
for context in shared.values():
|
||||
context.diagnostics = generator.solo.realization.SearchContext().diagnostics
|
||||
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared,enclosures_enabled=enclosures_enabled)
|
||||
song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
|
||||
regions = generator.solo.arrangement.regions(document)
|
||||
for solo, index in zip(solos, (3, 4)):
|
||||
first, count, offset, length = regions[index]
|
||||
song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales), 'structure': generator.solo.report.structure(solo)}
|
||||
drafts = []
|
||||
template = parts[solo.part]['staves'][0]['measures'][first:first + count]
|
||||
boundaries = [fractions.Fraction()]
|
||||
for measure in template:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||
for phrase in solo.phrases:
|
||||
if not phrase.draft:
|
||||
continue
|
||||
begin = max(i for i,boundary in enumerate(boundaries) if boundary <= phrase.plan.start)
|
||||
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
|
||||
view = excerpt(parts[solo.part], first + begin, stop - begin)
|
||||
local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
|
||||
placed = generator.solo.placement.place(local, solo.tempo, profile=solo.profile)
|
||||
if placed is None:
|
||||
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.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})
|
||||
song['solos'][solo.part]['draft_documents'] = drafts
|
||||
stages = {}
|
||||
for stage_name in ('initial', 'refined', 'before_rhythm', 'before_enclosure'):
|
||||
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, profile=solo.profile if stage_name != 'initial' else None)
|
||||
if placed is None:
|
||||
raise ValueError(stage_name + ': selected theme cannot be placed')
|
||||
sounded = generator.solo.types.sounding_notes(placed[1])
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
|
||||
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
|
||||
if not checked.valid and stage_name != 'initial':
|
||||
raise ValueError(stage_name + ': ' + checked.error)
|
||||
view = excerpt(parts[solo.part], first, count)
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error}
|
||||
song['solos'][solo.part]['lead_stages'] = stages
|
||||
print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True)
|
||||
result['songs'][path.stem] = song
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
checkpoint.write_text(json.dumps({'enclosures_enabled':enclosures_enabled,'implementation': result['implementation'], 'reference_versions': result['reference_versions'], 'song': song}) + '\n')
|
||||
destination.mkdir(parents=True)
|
||||
(destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
|
||||
def excerpt(document, first, count):
|
||||
result = copy.deepcopy(document)
|
||||
staff = result['staves'][0]
|
||||
staff['measures'] = staff['measures'][first:first + count]
|
||||
notes = [n for m in staff['measures'] for v in m['voices'] for n in v['notes']]
|
||||
identities = {p['id'] for n in notes for p in n['pitches']}
|
||||
events = {n['id'] for n in notes}
|
||||
for note in notes:
|
||||
for pitch in note['pitches']:
|
||||
for key in ('tie_from', 'tie_to'):
|
||||
if pitch.get(key) not in identities:
|
||||
pitch.pop(key, None)
|
||||
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in events and t['end'] in events]
|
||||
tuplets = {t['id'] for t in staff['tuplets']}
|
||||
for note in notes:
|
||||
if 'tuplet_ids' in note:
|
||||
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
|
||||
offset = sum(generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first])
|
||||
end = offset + sum(generator.model.fraction(m['duration']) for m in staff['measures'])
|
||||
for key in ('scale_spans', 'octave_shifts'):
|
||||
staff[key] = [{**s, 'start': generator.model.rational(max(offset, generator.model.fraction(s['start'])) - offset), 'end': generator.model.rational(min(end, generator.model.fraction(s['end'])) - offset)} for s in staff.get(key, []) if generator.model.fraction(s['start']) < end and generator.model.fraction(s['end']) > offset]
|
||||
generator.model.validate_document(result)
|
||||
return result
|
||||
|
||||
def mix(documents, tempo, root, cache):
|
||||
timelines = [generator.playback.compile_timeline(document) for document in documents]
|
||||
rate = generator.playback.SAMPLE_RATE
|
||||
factor = fractions.Fraction(60 * rate, tempo)
|
||||
frames = round(max(generator.model.fraction(t['duration']) for t in timelines) * factor)
|
||||
output = array.array('f', [0]) * (frames + 2400)
|
||||
for index, timeline in enumerate(timelines):
|
||||
gain = 1.0 if index == 0 else 0.55
|
||||
for event in timeline['events']:
|
||||
pitch = generator.model.fraction(event['pitch'])
|
||||
if pitch not in cache:
|
||||
sample = generator.playback.generate_tone(root, pitch)
|
||||
with wave.open(str(root / sample['path'])) as audio:
|
||||
values = array.array('h', audio.readframes(audio.getnframes()))
|
||||
if sys.byteorder != 'little':
|
||||
values.byteswap()
|
||||
cache[pitch] = sample, values
|
||||
sample, values = cache[pitch]
|
||||
start = round(generator.model.fraction(event['onset']) * factor)
|
||||
end = round((generator.model.fraction(event['onset']) + generator.model.fraction(event['duration'])) * factor)
|
||||
for frame in range(start, min(len(output), end + 2400)):
|
||||
age = frame - start
|
||||
position = age if age < sample['loop_end'] else sample['loop_start'] + (age - sample['loop_start']) % (sample['loop_end'] - sample['loop_start'])
|
||||
release = 1 if frame < end else (1 - (frame - end) / 2399) ** 2
|
||||
output[frame] += gain * release * values[position] / 32767
|
||||
return output
|
||||
|
||||
def register_control(baseline,song,part):
|
||||
"""Whole-phrase octave transfers of the preserved baseline, never new tunes."""
|
||||
from generator.solo import profiles,types,placement,notation
|
||||
detail=song['solos'][part]
|
||||
raw=detail['structure'].get('instrument_profile')
|
||||
if not raw:return None
|
||||
profile=profiles.Instrument(raw['part'],tuple(raw['strings']),raw['reference'],tuple(raw['melody_range']))
|
||||
result=[]
|
||||
for phrase in baseline['solos'][part]['structure']['phrases']:
|
||||
notes=[]
|
||||
for n in phrase['draft']['notes']:
|
||||
p=n['pitch']
|
||||
notes.append(types.Note(fractions.Fraction(n['start']),fractions.Fraction(n['end']),
|
||||
types.Pitch(p['letter'],fractions.Fraction(p['alter']),p['octave']),tuple(n['sources']),n['role'],
|
||||
continuation=n['continuation'],event_id=n['event_id']))
|
||||
options=[]
|
||||
for octave in range(-2,3):
|
||||
moved=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+octave)) for n in notes)
|
||||
if not all(profile.permits(n.pitch.number()) for n in moved):continue
|
||||
placed=placement.place(moved,fractions.Fraction(detail['tempo']),result[-1] if result else None,profile)
|
||||
if placed:
|
||||
rank=profile.cost(profiles.weighted_center(moved))+.02*abs(octave)+.001*placed[0]/max(1,len(moved))
|
||||
options.append((rank,octave,placed[1]))
|
||||
if not options:raise ValueError(part+': no whole-phrase register control within the instrument limits')
|
||||
result.extend(min(options,key=lambda x:x[:2])[2])
|
||||
view=excerpt(song['parts'][part],detail['first'],detail['count'])
|
||||
notation.replace_region(view,0,notation.measures(view['staves'][0]['measures'],types.sounding_notes(result),'register_control_'+part))
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
return view
|
||||
|
||||
|
||||
def render(root):
|
||||
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
|
||||
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
|
||||
cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []}
|
||||
for stage, snapshot in snapshots.items():
|
||||
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
|
||||
for song_name, song in snapshot['songs'].items():
|
||||
baseline = snapshots[stages[0]]['songs'][song_name]
|
||||
comparison = snapshots[stages[-1]]['songs'][song_name]
|
||||
if song['source_sha256'] != baseline['source_sha256']:
|
||||
raise ValueError(f'{song_name}: source changed between audition stages')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' or stages[-1] == 'revised' else [('', 0, min(8, solo['count']))]
|
||||
for label, offset, count in windows:
|
||||
first = solo['first'] + offset
|
||||
foreground = excerpt(song['parts'][part], first, count)
|
||||
prefix = root / stage / (f'{song_name}_{part}' + ('_' + label if label else ''))
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
|
||||
prefix.with_suffix('.txt').write_text(solo['report'])
|
||||
if 'structure' in solo:
|
||||
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
||||
for responsive in [False, True] if stage == stages[-1] else [False]:
|
||||
backing = excerpt((song if responsive else comparison)['parts'][other], first, count)
|
||||
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
||||
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
|
||||
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
||||
clips.append((path, samples))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
||||
if stage == 'after' and 'before_enclosure' in solo.get('lead_stages', {}):
|
||||
strict = excerpt(solo['lead_stages']['before_enclosure']['document'], offset, count)
|
||||
strict_prefix=prefix.with_name(prefix.name+'_strict')
|
||||
generator.musicxml_writer.write_musicxml(strict_prefix.with_suffix('.musicxml'),strict)
|
||||
backing=excerpt(comparison['parts'][other],first,count)
|
||||
vocal=excerpt(baseline['parts']['vocal'],first,count)
|
||||
path=strict_prefix.with_name(strict_prefix.name+'_fixed').with_suffix('.wav')
|
||||
clips.append((path,mix((strict,backing,vocal),fractions.Fraction(solo['tempo']),root,cache)))
|
||||
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':'same_selected_theme_before_enclosures','window':label})
|
||||
if label=='full':
|
||||
path=strict_prefix.with_name(strict_prefix.name+'_solo').with_suffix('.wav')
|
||||
clips.append((path,mix((strict,),fractions.Fraction(solo['tempo']),root,cache)))
|
||||
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':'strict_unaccompanied','window':label})
|
||||
if stage == 'after' and 'refined' in solo.get('lead_stages', {}):
|
||||
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
|
||||
plain_prefix = prefix.with_name(prefix.name + '_plain')
|
||||
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
|
||||
backing = excerpt(comparison['parts'][other], first, count)
|
||||
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
||||
path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav')
|
||||
clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'plain_theme', 'window': label})
|
||||
if label == 'full':
|
||||
for kind, document in (('plain', plain), ('elaborated', foreground)):
|
||||
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
|
||||
clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label})
|
||||
if stage == 'after':
|
||||
for control, document in (('register_control', register_control(baseline,song,part)),
|
||||
('before_rhythm',solo.get('lead_stages',{}).get('before_rhythm',{}).get('document'))):
|
||||
if document is None: continue
|
||||
prefix=root/stage/f'{song_name}_{part}_{control}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'),document)
|
||||
backing=excerpt(comparison['parts'][other],solo['first'],solo['count'])
|
||||
path=prefix.with_suffix('.wav')
|
||||
clips.append((path,mix((document,backing),fractions.Fraction(solo['tempo']),root,cache)))
|
||||
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':control,'window':'full'})
|
||||
for draft in solo.get('draft_documents', ()):
|
||||
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
||||
if draft['first'] < 8:
|
||||
backing = excerpt(comparison['parts'][other], solo['first'] + draft['first'], draft['count'])
|
||||
path = prefix.with_suffix('.wav')
|
||||
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
|
||||
if stage in ('phrase', 'revised'):
|
||||
neutral = excerpt(song['parts'][part], solo['first'], min(8, solo['count']))
|
||||
for measure in neutral['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for event in voice['notes']:
|
||||
for pitch in event['pitches']:
|
||||
pitch.update({'letter': 'c', 'alter': generator.model.rational(0), 'octave': 5, 'tablature': {'string': 2, 'fret': 1}})
|
||||
path = root / stage / f'{song_name}_{part}_rhythm_only.wav'
|
||||
clips.append((path, mix((neutral,), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'rhythm_only_diagnostic', 'valid_harmonic_solo': False})
|
||||
# One common attenuation preserves relative loudness and identical backing.
|
||||
peak = max(abs(value) for _, samples in clips for value in samples)
|
||||
gain = min(1, 0.95 / peak) if peak else 1
|
||||
manifest['shared_gain'] = gain
|
||||
for path, samples in clips:
|
||||
generator.playback.write_pcm(path, (value * gain for value in samples))
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
write_phrase_index(root, snapshots) if stages[-1] == 'revised' else write_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots)
|
||||
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
|
||||
|
||||
def write_phrase_index(root, snapshots):
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Melodic phrase comparisons</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin-bottom:2rem}td,th{text-align:left;padding:.7rem;border-bottom:1px solid #ccd1c9}audio{width:250px}small{display:block}</style><h1>Melodic phrase comparisons</h1><p>Compare the current baseline, complete phrase construction, and phrase construction with two-sided join revision. Tempo, scale policy, activity targets and seed inputs are unchanged. Fixed mixes use baseline accompaniment and one shared gain. Revised mixes also include the normal responsive backing.</p><p>Draft pools are shared when incoming musical states match; earlier edits can legitimately change later incoming states. Pool fingerprints are recorded in the reports. Simple drafts are auditioned individually, with a held entrance restarted at the excerpt boundary. Rhythm-only clips use a neutral pitch and are not valid harmonic solos.</p><p>Listen for one recognizable idea developing toward a prepared destination. Metrics do not establish musical improvement. <a href="manifest.json">Manifest and diagnostics</a>.</p>']
|
||||
for name, song in snapshots['revised']['songs'].items():
|
||||
for part in song['solos']:
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
|
||||
body.append('<table><tr><th>Excerpt</th><th>Baseline</th><th>Phrase construction</th><th>With join revision</th></tr>')
|
||||
for window in ('opening8', 'full', 'closing4'):
|
||||
body.append(f'<tr><th>{window}</th>')
|
||||
for stage in ('baseline', 'phrase', 'revised'):
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a> · <a href="{prefix}.json">Structure</a></small></td>')
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
for stage in ('phrase', 'revised'):
|
||||
detail = snapshots[stage]['songs'][name]['solos'][part]
|
||||
metric = detail['metrics']
|
||||
body.append(f'<p>{stage}: {metric["attacks_per_four_beats"]:.2f} attacks / four beats; {metric["sounding_occupancy"]:.1%} occupancy. Rhythm only: <audio controls preload="none" src="{stage}/{name}_{part}_rhythm_only.wav"></audio></p>')
|
||||
for draft in detail.get('draft_documents', ()):
|
||||
if draft['first'] < 8:
|
||||
prefix = f'{stage}/{name}_{part}_draft_{draft["id"]}'
|
||||
body.append(f'<p>Simple draft {draft["id"]}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">MusicXML</a></p>')
|
||||
body.append(f'<p>Complete solo with responsive backing: <audio controls preload="none" src="revised/{name}_{part}_full_responsive.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def write_index(root, snapshots):
|
||||
labels = {'baseline': 'Original engine', 'A': 'Scoring repair', 'B': 'Activity shapes', 'C': 'Motifs and retrieval', 'D': 'Phrase lookahead'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Solo comparisons</title><style>body{font:16px system-ui;max-width:1200px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:1rem;border-bottom:1px solid #ccd1c9}audio{width:280px}small{display:block;margin-top:.5rem}</style><h1>Solo comparisons</h1><p>Eight bars from the same solo entrance at the source tempo. All stages use the original backing and one shared mix gain. D also includes the normal backing that responds to the new solo.</p><p>A: scoring and register. B: activity and breathing. C: transformation contracts, contextual motifs and diverse paths. D: one-phrase lookahead. Expressive playback is deferred.</p><p>Metrics below describe the complete solo, not just the excerpt. These are diagnostics; listening and playing determine musical quality. <a href="manifest.json">Build fingerprints and diagnostics</a>.</p>']
|
||||
for name, song in snapshots['D']['songs'].items():
|
||||
for part in song['solos']:
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2><table><tr><th>Stage</th><th>Same backing</th><th>Complete solo</th></tr>')
|
||||
for stage in STAGES:
|
||||
prefix = stage + '/' + name + '_' + part
|
||||
metrics = snapshots[stage]['songs'][name]['solos'][part]['metrics']
|
||||
stats = f"{metrics['attacks_per_four_beats']:.2f} attacks / four beats<br>{metrics['sounding_occupancy']:.0%} sounding occupancy<br>{metrics['longest_connected_eighth_run']} connected eighths<br>{metrics['longest_unplanned_gap_beats']:g}-beat longest unplanned gap"
|
||||
body.append(f'<tr><td>{stage}: {labels[stage]}<small><a href="{prefix}.musicxml">Notation</a> · <a href="{prefix}.txt">Phrase report</a></small></td><td><audio controls preload="none" src="{prefix}_fixed.wav"></audio></td><td>{stats}</td></tr>')
|
||||
body.append(f'</table><p>D with responsive backing: <audio controls preload="none" src="D/{name}_{part}_responsive.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def write_passage_index(root, snapshots):
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. All fixed comparisons use the final canonical backing. The published arrangement reuses that same learned accompaniment throughout the form. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
|
||||
for name, song in snapshots['after']['songs'].items():
|
||||
for part in song['solos']:
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
|
||||
for stage in ('before', 'after'):
|
||||
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
|
||||
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
|
||||
for control,label in (('register_control','Original phrases with register/string changes only'),('before_rhythm','Selected strict theme before rhythmic revision')):
|
||||
if control=='before_rhythm' and control not in song['solos'][part].get('lead_stages',{}):continue
|
||||
prefix=f'after/{name}_{part}_{control}'
|
||||
body.append(f'<p>{label}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">Notation</a></p>')
|
||||
strict_available='before_enclosure' in song['solos'][part].get('lead_stages',{})
|
||||
plain_available='refined' in song['solos'][part].get('lead_stages',{})
|
||||
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>Final canonical arrangement</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
|
||||
for window in ('full', 'opening8', 'closing4'):
|
||||
body.append(f'<tr><th>{window}</th>')
|
||||
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
|
||||
prefix = stage + '/' + name + '_' + part + '_' + window
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
|
||||
if strict_available:
|
||||
prefix=f'after/{name}_{part}_{window}_strict'
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Strict notation</a></small></td>')
|
||||
if plain_available:
|
||||
prefix=f'after/{name}_{part}_{window}_plain'
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Plain theme notation</a></small></td>')
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
if plain_available:
|
||||
prefix=f'after/{name}_{part}_full'
|
||||
body.append(f'<p>Unaccompanied plain theme: <audio controls preload="none" src="{prefix}_plain_solo.wav"></audio> Elaborated: <audio controls preload="none" src="{prefix}_elaborated_solo.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_auditions'))
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument('--capture', choices=(*STAGES, 'before', 'after', 'phrase', 'revised'))
|
||||
action.add_argument('--compare-phrases', action='store_true', help='capture both new stages using shared concrete draft pools')
|
||||
action.add_argument('--render', action='store_true')
|
||||
parser.add_argument('--no-enclosures',action='store_true',help='retain the identical selected strict melody before ornamentation')
|
||||
args = parser.parse_args()
|
||||
if args.compare_phrases:
|
||||
libraries, contexts = resources(), {}
|
||||
capture(args.output, 'phrase', libraries, contexts)
|
||||
capture(args.output, 'revised', libraries, contexts)
|
||||
elif args.capture:
|
||||
capture(args.output, args.capture,enclosures_enabled=not args.no_enclosures)
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -63,17 +63,22 @@ def validate(notes,contracts,harmony):
|
||||
return {},c.id+': missing or changed expression event/target'
|
||||
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation'
|
||||
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions'
|
||||
target=c.target; span=harmony.at(c.boundary if c.kind=='anticipation' else target.start)
|
||||
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else target.start)
|
||||
if not span or not span.scale or span.chord!=c.chord:return {},c.id+': wrong target harmony'
|
||||
if c.kind in ('approach','enclosure','connection'):
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None)
|
||||
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid destination hold'
|
||||
arrival=c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
|
||||
if target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end):return {},c.id+': invalid destination hold'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())
|
||||
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
|
||||
for n,step,duration in zip(c.events,rule['semitones'],rule['durations']):
|
||||
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing'
|
||||
cursor=n.end
|
||||
for index in rule.get('mapped_indices',[]):
|
||||
n=c.events[index]
|
||||
if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
|
||||
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
|
||||
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
|
||||
elif c.kind=='color':
|
||||
@@ -88,7 +93,7 @@ def validate(notes,contracts,harmony):
|
||||
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
|
||||
last=c.events[-1]
|
||||
if rule.get('resolve'):
|
||||
if target is last or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
|
||||
if target.event_id==last.event_id or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
|
||||
elif c.kind=='suspension':
|
||||
rule=next((r for r in vocabulary.data('harmony').get('suspensions',[]) if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary is None:return {},c.id+': unsupported suspension'
|
||||
@@ -232,7 +237,7 @@ def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
if not drafts.contract_valid(phrase.draft,actual)[0]:
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,))
|
||||
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(*phrase.draft.expressions,c))
|
||||
if not checked.valid:
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
counts['valid']+=1
|
||||
@@ -249,7 +254,7 @@ def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
|
||||
for t in phrase.draft.transformations)
|
||||
changed=dataclasses.replace(phrase.draft,id=phrase.draft.id+':'+c.rule,
|
||||
notes=actual,expressions=(c,),transformations=transforms)
|
||||
notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
|
||||
candidate=realization.checked_phrase(phrase.plan,changed,actual,incoming.treatments,incoming,harmony,tempo,phrase.edits)
|
||||
if candidate is None:continue
|
||||
trace[c.kind]['retained']+=1
|
||||
@@ -304,7 +309,7 @@ def refine(solo,enabled=True):
|
||||
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
|
||||
for t in draft.transformations)
|
||||
changed=dataclasses.replace(draft,notes=actual,expressions=(c,),transformations=transforms)
|
||||
changed=dataclasses.replace(draft,notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
|
||||
routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
|
||||
options.append((cost,dataclasses.replace(phrase,draft=changed,decisions=decisions,routes=routes),c))
|
||||
_,chosen,c=min(options,key=lambda v:(round(v[0],8),policy.stable_rank(seed,(phrase.plan.id,v[2]))))
|
||||
|
||||
@@ -16,6 +16,7 @@ def join_variants(draft,harmony,library):
|
||||
"""
|
||||
from generator.solo import routes, policy
|
||||
fixed=set(draft.protected)|set(draft.structural)|{e for f in draft.features for e in f.events}
|
||||
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
produced=0
|
||||
for join in (t.start for t in draft.transformations[1:]):
|
||||
inside=tuple(n for n in draft.notes if join-2<=n.start and n.end<=join+2)
|
||||
@@ -34,7 +35,7 @@ def join_variants(draft,harmony,library):
|
||||
for route in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'two-sided join'):
|
||||
if route.notes==inside:continue
|
||||
actual=tuple(n for n in draft.notes if n.end<=inside[0].start)+route.notes+tuple(n for n in draft.notes if n.start>=inside[-1].end)
|
||||
if not drafts.contract_valid(draft,actual)[0] or not harmony.evaluate(actual,until=draft.end,complete=True).valid:continue
|
||||
if not drafts.contract_valid(draft,actual)[0] or not harmony.evaluate(actual,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
|
||||
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
|
||||
changed=dataclasses.replace(draft,id=draft.id+':join:'+route.id,notes=actual,transformations=transforms)
|
||||
@@ -61,6 +62,7 @@ def shared_arrival(draft,harmony,treatments=()):
|
||||
joins={t.start for t in draft.transformations[1:] if t.operation=='continue'}
|
||||
fixed=set(draft.protected)|set(draft.structural)
|
||||
fixed.update(n.event_id for n in notes if n.role=='quote')
|
||||
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
fixed.update(e for f in draft.features if 'rhythm' in f.preserves for e in f.events)
|
||||
edits=[]
|
||||
for join in sorted(joins):
|
||||
@@ -69,7 +71,7 @@ def shared_arrival(draft,harmony,treatments=()):
|
||||
if a.event_id in fixed or b.event_id in fixed:continue
|
||||
actual=(*notes[:i],dataclasses.replace(b,start=a.start),*notes[i+2:])
|
||||
if not drafts.contract_valid(draft,actual)[0]:continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid:continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
|
||||
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]:continue
|
||||
edits.append(types.DraftEdit('shared_arrival',a.start,b.end,(a.event_id,b.event_id),(b.event_id,),
|
||||
'reopen provisional endpoint as the same sounding continuation; preserve feature and arrival contracts'))
|
||||
@@ -92,6 +94,7 @@ def revise(draft, harmony, treatments=()):
|
||||
if len(notes)<3: return draft, tuple(edits)
|
||||
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
|
||||
# An identifying rhythm also protects its internal rests.
|
||||
protected.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
|
||||
for _ in range(2):
|
||||
proposals=[]
|
||||
@@ -111,7 +114,7 @@ def revise(draft, harmony, treatments=()):
|
||||
if not groups.valid(tuple((n.start-draft.start,n.end-draft.start) for n in actual)):continue
|
||||
if fragmentation(actual)>=fragmentation(notes): continue
|
||||
if not drafts.contract_valid(draft,actual)[0]: continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid: continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid: continue
|
||||
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]: continue
|
||||
valid.append(actual)
|
||||
if not valid: break
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Joint rhythmic and harmonic actions, constructed before pitch-route search.
|
||||
|
||||
Rules describe musical relationships, never reference-score phrases. Contracts
|
||||
are bound to the selected whole route and validated before a candidate survives.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary, harmony as chords
|
||||
from generator.solo import types, expressions, groups, sequences, policy
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Proposal:
|
||||
notes: tuple
|
||||
contracts: tuple
|
||||
purpose: str
|
||||
|
||||
def bind(self, notes):
|
||||
actual={n.event_id:n for n in notes}
|
||||
return tuple(dataclasses.replace(c,events=tuple(actual[n.event_id] for n in c.events),
|
||||
target=actual[c.target.event_id],preparation=actual[c.preparation.event_id] if c.preparation else None) for c in self.contracts)
|
||||
|
||||
|
||||
def replace(notes, start, end, events):
|
||||
# Provisional rhythm is editable, but a sliced tuplet is not an explanation.
|
||||
before=tuple(dataclasses.replace(n,end=start) if n.end>start else n for n in notes if n.start<start)
|
||||
after=tuple(dataclasses.replace(n,start=end,event_id=n.event_id+':resumed') if n.start<end else n for n in notes if n.end>end)
|
||||
actual=(*before,*events,*after)
|
||||
if any(n.end<=n.start for n in actual):return ()
|
||||
origin=notes[0].start
|
||||
if not groups.valid(tuple((n.start-origin,n.end-origin) for n in actual)):return ()
|
||||
return actual
|
||||
|
||||
|
||||
def proposals(notes,harmony,start,finish,ending=None):
|
||||
"""Plan future arrivals, colors and approaches together with exact rhythm."""
|
||||
rules=vocabulary.data('melodic_vocabulary')['gesture_rules']
|
||||
fixed_end=ending.earliest if ending else finish
|
||||
for rule in rules:
|
||||
if rule['kind']=='harmonic_arrival':
|
||||
for span in harmony.spans:
|
||||
boundary=span.start
|
||||
prior=harmony.at(boundary-F(1,100))
|
||||
if not span.scale or not prior or not prior.scale or prior.chord==span.chord:continue
|
||||
if not start+1<boundary<fixed_end:continue
|
||||
for offset in map(F,rule['arrival_offsets']):
|
||||
arrival=boundary+offset
|
||||
if arrival<start+1:continue
|
||||
for hold in map(F,rule['holds']):
|
||||
end=arrival+hold
|
||||
if end>min(fixed_end,span.end) or end<=boundary:continue
|
||||
breath=min(F(rule['release']),fixed_end-end)
|
||||
near=next((n.pitch.number() for n in notes if n.start<=arrival<n.end),notes[-1].pitch.number())
|
||||
members=harmony.members(boundary if offset<0 else arrival,end)&types.permitted(harmony.choices,boundary if offset<0 else arrival,end)
|
||||
candidates=tuple(types.scale_pitch(span.scale,i) for i in range(-21,29))
|
||||
candidates=tuple(p for p in candidates if p.number()%12 in members and harmony.profile.permits(p.number()))
|
||||
# Third/seventh, root/fifth and register alternatives remain real
|
||||
# competitors; no target name or song position determines a winner.
|
||||
targets=sequences.diverse(candidates,lambda p:(abs(p.number()-near),p.number()),
|
||||
lambda p:next((d for pc,d in span.roles.degrees if pc==p.number()%12),()),3)
|
||||
for pitch in targets:
|
||||
target=types.Note(arrival,end,pitch,notes[0].sources,'arrival',event_id=notes[0].event_id+f':{rule["id"]}:{arrival}:{pitch.number()}')
|
||||
actual=replace(notes,arrival,end+breath,(target,))
|
||||
if len(actual)<2:continue
|
||||
contracts=()
|
||||
if offset<0:
|
||||
contracts=(types.ExpressionContract(target.event_id,'anticipation','early_chord_tone',(target,),target,span.chord,boundary=boundary),)
|
||||
yield Proposal(actual,contracts,rule['id']+':'+str(offset))
|
||||
elif rule['kind']=='sustained_color':
|
||||
for i,n in enumerate(notes):
|
||||
if n.start>=fixed_end or n.end-n.start<1:continue
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:continue
|
||||
for color in vocabulary.data('harmony')['colors']:
|
||||
if n.end-n.start>F(color['max_beats']) or not expressions.eligible_color(color,span,harmony):continue
|
||||
pitch=expressions.degree_pitch(chords.parse_symbol(span.chord),color['degree'],color['alter'],n.pitch.number())
|
||||
if not harmony.profile.permits(pitch.number()) or pitch==n.pitch:continue
|
||||
changed=dataclasses.replace(n,pitch=pitch,role='color')
|
||||
actual=(*notes[:i],changed,*notes[i+1:]);target=changed
|
||||
if color.get('resolve'):
|
||||
if i+1==len(notes):continue
|
||||
next_note=notes[i+1];future=harmony.at(next_note.start)
|
||||
if not future or not future.scale:continue
|
||||
targets=[types.scale_pitch(future.scale,j) for j in range(-21,29)]
|
||||
targets=[p for p in targets if 0<abs(p.number()-pitch.number())<=2 and p.number()%12 in harmony.members(next_note.start,next_note.end) and harmony.profile.permits(p.number())]
|
||||
if not targets:continue
|
||||
target=dataclasses.replace(next_note,pitch=min(targets,key=lambda p:(abs(p.number()-next_note.pitch.number()),p.number())))
|
||||
actual=(*notes[:i],changed,target,*notes[i+2:])
|
||||
c=types.ExpressionContract(changed.event_id+':'+color['id'],'color',color['id'],(changed,),target,span.chord)
|
||||
yield Proposal(actual,(c,),rule['id'])
|
||||
|
||||
|
||||
def approach(proposal,harmony,start,ending=None):
|
||||
"""An approach may share its target with an anticipated destination."""
|
||||
for i,target in enumerate(proposal.notes):
|
||||
if target.end-target.start<1:continue
|
||||
anticipated=next((c for c in proposal.contracts if c.kind=='anticipation' and c.target.event_id==target.event_id),None)
|
||||
time=anticipated.boundary if anticipated else target.start
|
||||
if target.pitch.number()%12 not in harmony.members(time,target.end):continue
|
||||
span=harmony.at(time)
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
arrival=target.start;cursor=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F())
|
||||
if cursor<start+F(1,2):continue
|
||||
if ending and cursor>=ending.earliest:continue
|
||||
events=[]
|
||||
for j,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(j)))
|
||||
cursor+=F(duration)
|
||||
actual=replace(proposal.notes,events[0].start,target.start,tuple(events))
|
||||
if not actual:continue
|
||||
if any(n.event_id not in {a.event_id for a in actual} for c in proposal.contracts for n in (*c.events,c.target)):continue
|
||||
prior=next((n for n in reversed(actual) if n.end<=events[0].start),None)
|
||||
c=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,span.chord,
|
||||
boundary=anticipated.boundary if anticipated else None,preparation=prior if rule['kind']=='connection' else None)
|
||||
yield Proposal(actual,(*proposal.contracts,c),proposal.purpose+' + '+rule['kind'])
|
||||
|
||||
|
||||
def select(notes,harmony,start,finish,ending=None,limit=4):
|
||||
# Provenance differs across candidate histories; musical work does not.
|
||||
# This cache belongs to one immutable Harmony and includes all timing,
|
||||
# pitches, roles, continuation flags, profile and ending constraints.
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=f'@{i}@') for i,n in enumerate(notes))
|
||||
key=(canonical,start,finish,ending,limit,harmony.profile)
|
||||
if not hasattr(harmony,'gesture_cache'):harmony.gesture_cache={}
|
||||
if key not in harmony.gesture_cache:
|
||||
harmony.gesture_cache[key]=_select(canonical,harmony,start,finish,ending,limit)
|
||||
if len(harmony.gesture_cache)>4096:harmony.gesture_cache.pop(next(iter(harmony.gesture_cache)))
|
||||
def identity(value):
|
||||
for i,n in enumerate(notes):value=value.replace(f'@{i}@',n.event_id)
|
||||
return value
|
||||
def restore(n):
|
||||
i=int(n.event_id.split('@')[1])
|
||||
return dataclasses.replace(n,event_id=identity(n.event_id),sources=notes[i].sources)
|
||||
return tuple(Proposal(tuple(map(restore,p.notes)),tuple(dataclasses.replace(c,id=identity(c.id),
|
||||
events=tuple(map(restore,c.events)),target=restore(c.target),
|
||||
preparation=restore(c.preparation) if c.preparation else None) for c in p.contracts),p.purpose)
|
||||
for p in harmony.gesture_cache[key])
|
||||
|
||||
|
||||
def _select(notes,harmony,start,finish,ending=None,limit=4):
|
||||
plain=Proposal(notes,(),'direct')
|
||||
base=sequences.diverse(proposals(notes,harmony,start,finish,ending),
|
||||
lambda p:(abs(len(p.notes)-len(notes)),tuple((n.start,n.end,n.pitch.number()) for n in p.notes)),
|
||||
lambda p:(p.purpose,tuple(c.kind for c in p.contracts)),8)
|
||||
values=(*base,*approach(plain,harmony,start,ending),*(p for b in base if b.purpose.startswith('harmonic') for p in approach(b,harmony,start,ending)))
|
||||
# Complete structural choices compete, not one replacement at each attack.
|
||||
return sequences.diverse(values,lambda p:(abs(len(p.notes)-len(notes)),policy.stable_rank('joint-purpose',tuple((n.start,n.end,n.pitch) for n in p.notes))),
|
||||
lambda p:tuple(c.kind for c in p.contracts) or (p.purpose,),limit)
|
||||
@@ -6,6 +6,7 @@ not a rhythmic boundary and must not be used to clip a group.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ def describe(pattern, *, origin=F(), source='', imported=False):
|
||||
return tuple(result)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def valid(pattern):
|
||||
try:describe(pattern);return True
|
||||
except ValueError:return False
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"""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('audition/controls'))
|
||||
solo = song['solos']['acoustic']
|
||||
measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']]
|
||||
choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales)
|
||||
harmony = harmonic.Harmony(choices)
|
||||
template = audition.excerpt(song['parts']['acoustic'], solo['first'], 8)
|
||||
result = []
|
||||
for label, source in (('plain', reference.notes), ('runs', reference.decorated)):
|
||||
checked = harmony.evaluate(source, until=reference.duration, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError('accepted control: ' + checked.error)
|
||||
theme = types.Motif(reference.id, source, reference.duration, tuple(c for _, c in reference.harmony), 'accepted control')
|
||||
groups = tuple(types.Transformation(f'control_{i}', reference.id, 'control_0', 'statement' if i == 0 else 'answer', F(i * 16), F((i + 1) * 16), tuple(n.event_id for n in source if i * 16 <= n.start < (i + 1) * 16), tuple((n.start - i * 16, n.end - i * 16) for n in source if i * 16 <= n.start < (i + 1) * 16), preserves=(), source=reference.id) for i in range(2))
|
||||
final = source[-1]
|
||||
terminal = types.Anchor('accepted_terminal', final.start, final.start, final.pitch, frozenset({final.pitch.number() % 12}), (55, 79), final.end - final.start, 'terminal', 'Dm', hold_until=final.end)
|
||||
home = realization.make_identity('control_0', reference.id, source[:len(groups[0].events)], F(), F(16), 'statement', harmony)
|
||||
draft = types.MelodicDraft('accepted_' + label, F(), reference.duration, source, groups, (final.event_id,), (), (terminal,), home, 'statement', (F(16),), references.identifying_features(source, reference.id), reference.id, theme)
|
||||
plan = types.PhrasePlan('accepted_' + label, F(), reference.duration, theme, 'statement', 'opening_reference', None, (55, 79), F(9, 8), F(1), passage_id='control', passage_start=F(), passage_end=reference.duration)
|
||||
incoming = types.ContinuationState('')
|
||||
phrase = realization.checked_phrase(plan, draft, source, (), incoming, harmony, F(solo['tempo']))
|
||||
if phrase is None:
|
||||
raise ValueError('accepted control rejected by phrase validation')
|
||||
context = realization.SearchContext()
|
||||
refined = realization.refine(phrase, (phrase,), incoming, harmony, sequences, F(solo['tempo']), context)
|
||||
elaborated = realization.elaborate(refined, incoming, harmony, sequences, F(solo['tempo']), context)
|
||||
for stage, notes in (('injected', source), ('processed', realization.emitted_notes(elaborated))):
|
||||
identifier = 'accepted_' + label + '_' + stage
|
||||
checked = harmony.evaluate(notes, until=reference.duration, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(identifier + ': ' + checked.error)
|
||||
result.append({'id': identifier, 'document': document_for(template, notes, F(solo['tempo']), identifier), 'attacks': len(notes), 'harmonic_exposure': checked.exposure, 'changed_events': sum((a.start, a.end, a.pitch) != (b.start, b.end, b.pitch) for a, b in zip(source, notes)) + abs(len(source) - len(notes)), 'edits': [e.kind for e in elaborated.edits] if stage == 'processed' else [], 'acceptance': reference.acceptance, 'reference_version': reference.version})
|
||||
return result
|
||||
|
||||
def render(root):
|
||||
baseline = json.loads((root / 'paraphrase_baseline/snapshot.json').read_text())
|
||||
current = json.loads((root / 'elaborated/snapshot.json').read_text())
|
||||
cache, clips = {}, []
|
||||
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
|
||||
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Source is the original head, close paraphrase is the preserved previous plain theme, refined is the newly recomposed theme, and elaborated includes optional in-scale runs. The selected production branch supplies both new stages.'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the original head, the previous close paraphrase, the new solo theme, and its optional in-scale runs. Source controls may contain original harmonic tensions that production explicitly adapts. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
|
||||
|
||||
def clip(prefix, foreground, tempo, backing=()):
|
||||
path = root / (prefix + '.wav')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
samples = audition.mix((foreground, *backing), tempo, root, cache)
|
||||
peak = max(map(abs, samples), default=0) * gain
|
||||
if peak >= 1:
|
||||
raise ValueError(prefix + ': shared audition gain would clip')
|
||||
generator.playback.write_pcm(path, (value * gain for value in samples))
|
||||
clips.append({'path': path.relative_to(root).as_posix(), 'tempo': str(tempo), 'peak': peak, 'attacks': len(generator.playback.compile_timeline(foreground)['events'])})
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
for name, song in current['songs'].items():
|
||||
old = baseline['songs'][name]
|
||||
if old['source_sha256'] != song['source_sha256']:
|
||||
raise ValueError(name + ': source changed during comparison')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Song source</th><th>Previous close paraphrase</th><th>New theme</th><th>Theme with runs</th></tr>')
|
||||
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
|
||||
first = solo['first'] + offset
|
||||
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
|
||||
body.append('<tr><th>' + window + '</th>')
|
||||
for stage in ('initial', 'close_paraphrase', 'refined', 'elaborated'):
|
||||
foreground = audition.excerpt(song['parts'][part], first, count) if stage=='elaborated' else audition.excerpt(old['solos'][part]['lead_stages']['refined']['document'] if stage=='close_paraphrase' else solo['lead_stages'][stage]['document'], offset, count)
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing)
|
||||
solo_path = clip(prefix + '_solo', foreground, F(solo['tempo']))
|
||||
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), foreground)
|
||||
body.append(f'<td><audio controls preload="none" src="{path}"></audio><small><a href="{solo_path}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></small></td>')
|
||||
if stage == 'elaborated':
|
||||
clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal')))
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
body.append(f'<p><a href="paraphrase_baseline/{name}_{part}_full_solo.wav">Previous complete production solo</a></p>')
|
||||
prefix = f'elaborated/{name}_{part}'
|
||||
(root / (prefix + '.txt')).write_text(solo['report'])
|
||||
(root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
||||
body.append(f'<p><a href="{prefix}.txt">Passage report</a> · <a href="{prefix}.json">Anchors, motif lineage, stages and routes</a> · <a href="{prefix}_full_responsive.wav">Full solo with responsive backing</a></p>')
|
||||
print('Rendered', name, part, flush=True)
|
||||
song = current['songs']['autumn_leaves']
|
||||
solo = song['solos']['acoustic']
|
||||
backing = tuple(audition.excerpt(baseline['songs']['autumn_leaves']['parts'][p], solo['first'], 8) for p in ('electric', 'vocal'))
|
||||
body.append('<h2>Accepted reference controls</h2><p>The exact accepted notes enter the ordinary validation, guitar placement, notation and playback path. Processed controls additionally run refinement and elaboration. Literal injection is confined to these controls.</p>')
|
||||
results = controls(song, audition.resources())
|
||||
manifest['controls'] = [{k: v for k, v in r.items() if k != 'document'} for r in results]
|
||||
for control in results:
|
||||
prefix = 'controls/' + control['id']
|
||||
path = clip(prefix + '_fixed', control['document'], F(solo['tempo']), backing)
|
||||
single = clip(prefix + '_solo', control['document'], F(solo['tempo']))
|
||||
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), control['document'])
|
||||
body.append(f'<p>{control["id"]}: <audio controls preload="none" src="{path}"></audio> <a href="{single}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></p>')
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_melody_auditions'))
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument('--capture', action='store_true')
|
||||
action.add_argument('--render', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.capture:
|
||||
audition.capture(args.output, 'elaborated')
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -148,7 +148,18 @@ def appearance_cost(operation, incoming, start, end):
|
||||
return .1*min(1,max(0,float(gap)/32-1))
|
||||
|
||||
|
||||
def activity(source, ending):
|
||||
def activity(source, ending, arrivals=()):
|
||||
if arrivals:
|
||||
holds=[(n.start,n.end) for n in arrivals]
|
||||
if ending:holds.append((ending.earliest,ending.hold_until))
|
||||
points=sorted({source.start,source.end,*(t for a,b in holds for t in (a,b)),*([ending.hold_until] if ending else [])})
|
||||
result=[]
|
||||
for a,b in zip(points,points[1:]):
|
||||
held=any(x<=a and b<=y for x,y in holds)
|
||||
space=bool(ending and a>=ending.hold_until)
|
||||
result.append(types.ActivitySpan(a,b,'space' if space else 'sustained_arrival' if held else 'active',
|
||||
F() if held or space else F(5,4),F(1) if held else F()))
|
||||
return tuple(result)
|
||||
if ending is None:return (types.ActivitySpan(source.start,source.end,'active',F(5,4),F()),)
|
||||
spans=[]
|
||||
if ending.earliest>source.start:
|
||||
|
||||
@@ -50,7 +50,7 @@ class Policy:
|
||||
enclosure_routes: int = 4
|
||||
enclosure_frontier: int = 8
|
||||
enclosure_max_per_32_bars: int = 4
|
||||
composition_version: str = 'joint-gestures-1'
|
||||
composition_version: str = 'joint-arrivals-2'
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.fretboard
|
||||
@@ -20,6 +21,7 @@ class Instrument:
|
||||
tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings]
|
||||
return min(tuning) + 1, max(tuning) + 12
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def permits(self, number):
|
||||
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ def advance(scale, previous, step):
|
||||
outgoing.reverse()
|
||||
return tuple(outgoing[abs(step) - 1:abs(step)])
|
||||
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect'):
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect', expressions=()):
|
||||
if expressions:
|
||||
return planned_candidates(notes,fixed,harmony,register,relationship,expressions)
|
||||
# Cache concrete routes only with their endpoints, timing, scale context and
|
||||
# vocabulary. The per-Harmony cache cannot leak across collection changes.
|
||||
if not hasattr(harmony,'route_cache'):
|
||||
@@ -76,6 +78,80 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
|
||||
harmony.concrete_routes[concrete_key]=selected
|
||||
return selected
|
||||
|
||||
|
||||
def planned_candidates(notes,fixed,harmony,register,relationship,contracts):
|
||||
# Cache complete contract-conditioned pitch work, not a next-note suggestion.
|
||||
# IDs/provenance are restored below; timing, pitch, role, obligations and
|
||||
# profile remain in the key. The cache is owned by this harmonic timeline.
|
||||
ids={n.event_id:str(i) for i,n in enumerate(notes)}
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i)) for i,n in enumerate(notes))
|
||||
def note(n):return canonical[int(ids[n.event_id])]
|
||||
obligations=tuple(dataclasses.replace(c,id=str(i),events=tuple(map(note,c.events)),
|
||||
target=note(c.target),preparation=note(c.preparation) if c.preparation else None) for i,c in enumerate(contracts))
|
||||
locked=frozenset(ids[i] for i in fixed)
|
||||
key=(canonical,locked,register,harmony.profile,obligations)
|
||||
if not hasattr(harmony,'planned_route_cache'):harmony.planned_route_cache={}
|
||||
if key not in harmony.planned_route_cache:
|
||||
harmony.planned_route_cache[key]=_planned_candidates(canonical,locked,harmony,register,'',obligations)
|
||||
if len(harmony.planned_route_cache)>8192:harmony.planned_route_cache.pop(next(iter(harmony.planned_route_cache)))
|
||||
result=[]
|
||||
for route in harmony.planned_route_cache[key]:
|
||||
actual=tuple(dataclasses.replace(original,pitch=realized.pitch,position=realized.position) for original,realized in zip(notes,route.notes))
|
||||
result.append(dataclasses.replace(route,notes=actual,destination=actual[-1],relationship=relationship))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _planned_candidates(notes,fixed,harmony,register,relationship,contracts):
|
||||
"""Solve the whole route around explicit, preselected harmonic obligations.
|
||||
|
||||
Fixed expressive pitches have narrow permissions; all other attacks use the
|
||||
map. Complete contracts are checked after realization, never inferred merely
|
||||
from a label. No incomplete route is handed back to the next cell.
|
||||
"""
|
||||
from generator.solo import gestures
|
||||
proposal=gestures.Proposal(notes,contracts,relationship)
|
||||
locked={n.event_id for c in contracts for n in (*c.events,c.target)}|set(fixed)|{notes[0].event_id,notes[-1].event_id}
|
||||
special={n.event_id for c in contracts for n in c.events}
|
||||
states=[(0.,(notes[0],))]
|
||||
for i,n in enumerate(notes[1:],1):
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:return ()
|
||||
allowed=types.permitted(harmony.choices,n.start,n.end)
|
||||
if n.end-n.start>=2 and n.event_id not in special:allowed &= harmony.members(n.start,n.end)
|
||||
target=next((a for a in notes[i:] if a.event_id in locked),notes[-1])
|
||||
remaining=max(1,sum(a.start<target.start for a in notes[i:]))
|
||||
following=[]
|
||||
for cost,path in states:
|
||||
prior=path[-1]
|
||||
if n.event_id in locked:options=(n.pitch,)
|
||||
else:
|
||||
options=tuple(p for step in (-3,-2,-1,1,2,3) for p in advance(span.scale,prior.pitch.number(),step))
|
||||
if prior.pitch.number()%12 in allowed:options=(*options,prior.pitch)
|
||||
for pitch in options:
|
||||
number=pitch.number()
|
||||
if not harmony.profile.permits(number) or not register[0]<=number<=register[1]:continue
|
||||
if n.event_id not in special and number%12 not in allowed:continue
|
||||
motion=number-prior.pitch.number()
|
||||
if abs(motion)>12:continue
|
||||
# Continuation aims at the known future destination. Selected thirds
|
||||
# and fourths remain feasible; repeated oscillation is not progress.
|
||||
distance=abs(target.pitch.number()-number)
|
||||
remaining_cost=max(0,distance-5*remaining)/12
|
||||
repeating=len(path)>1 and path[-2].pitch==prior.pitch==pitch
|
||||
value=cost+max(0,abs(motion)-7)/12+.2*repeating
|
||||
following.append((value,(*path,dataclasses.replace(n,pitch=pitch,position=None)),remaining_cost))
|
||||
chosen=generator.solo.sequences.diverse(following,lambda v:(v[0]+v[2],tuple(n.pitch.number() for n in v[1])),
|
||||
lambda v:(generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(v[1],v[1][1:]))),v[1][-1].pitch.number()),8)
|
||||
states=[(cost,path) for cost,path,_ in chosen]
|
||||
if not states:return ()
|
||||
result=[]
|
||||
for _,actual in states:
|
||||
bound=proposal.bind(actual)
|
||||
if not harmony.evaluate(actual,until=actual[-1].end,complete=True,expressions=bound).valid:continue
|
||||
identity=generator.solo.policy.stable_rank('planned-route',tuple((n.start,n.end,n.pitch) for n in actual))[:16]
|
||||
result.append(types.RouteCandidate(identity,actual,actual[-1],(),relationship,tuple('gesture:'+c.rule for c in bound)))
|
||||
return tuple(result)
|
||||
|
||||
def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
|
||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||
|
||||
|
||||
+49
-15
@@ -116,7 +116,9 @@ def rhythm_cost(pattern,length,purpose):
|
||||
isolated=sum(b-a<=F(1,2) and prev[1]<a and b<nxt[0]
|
||||
for prev,(a,b),nxt in zip(pattern,pattern[1:],pattern[2:]))
|
||||
values={b-a for a,b in pattern}
|
||||
uniform=.15 if len(pattern)>=4 and len(values)==1 else 0.
|
||||
# Equal eighths can be a single directional run. Uniformity alone is not a
|
||||
# musical defect; aimless long-value packets are addressed by construction.
|
||||
uniform=.15 if len(pattern)>=4 and len(values)==1 and next(iter(values))>=1 else 0.
|
||||
if purpose in ('continue','connect'):
|
||||
# Silence may articulate an idea; disconnected tiny islands do not carry
|
||||
# unfinished motion. No minimum occupancy or chorus attack quota.
|
||||
@@ -194,6 +196,13 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
|
||||
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted
|
||||
# alternatives are actual contracts, not failed copies of a rhythmic seed.
|
||||
for original,label in tuple(values.items()):
|
||||
movement=generator.vocabulary.data('melodic_vocabulary')['motion_subdivision']
|
||||
for i,(a,b) in enumerate(original[:-1]):
|
||||
pulse=F(movement['pulse'])
|
||||
if b-a<F(movement['minimum_value']) or (a/pulse).denominator!=1 or ((b-a)/pulse).denominator!=1:continue
|
||||
moving=tuple((a+j*pulse,a+(j+1)*pulse) for j in range(int((b-a)/pulse)))
|
||||
revised=(*original[:i],*moving,*original[i+1:])
|
||||
if len(revised)<=movement['max_attacks']:values[revised]=label+'; moving continuation'
|
||||
for i,(a,b) in enumerate(original):
|
||||
if b-a==1:
|
||||
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
|
||||
@@ -252,9 +261,10 @@ class Unit:
|
||||
routes: tuple[types.RouteCandidate,...] = ()
|
||||
start: F = F()
|
||||
end: F = F()
|
||||
expressions: tuple[types.ExpressionContract,...] = ()
|
||||
|
||||
|
||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
|
||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None,allow_expressions=True):
|
||||
"""Joint whole-gesture pitch/rhythm actions, conditioned on the actual entry.
|
||||
|
||||
A complete route is proposed before thematic evidence is checked. No motif
|
||||
@@ -263,7 +273,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
||||
"""
|
||||
from generator.solo import leads
|
||||
key=('joint-gesture-1',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,
|
||||
home,operation,ending,goal,batch,id(harmony),id(library),id(rhythms))
|
||||
home,operation,ending,goal,batch,allow_expressions,getattr(context,'expressions_enabled',True),id(harmony),id(library),id(rhythms))
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||
trace=context.diagnostics.setdefault('operations',{}).setdefault(operation,
|
||||
@@ -298,13 +308,31 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
||||
(j[4].number()>j[3].number())-(j[4].number()<j[3].number())),policy.DEFAULT.theme_route_jobs*(batch+1))
|
||||
trace['jobs']+=len(jobs);trace['pruned_jobs']+=len(jobs)-len(selected)
|
||||
result=[]
|
||||
for _,templates,label,first,target in selected:
|
||||
from generator.solo import gestures
|
||||
scheduled=[]
|
||||
for rank,templates,label,first,target in selected:
|
||||
timed=(dataclasses.replace(templates[0],pitch=first),*templates[1:-1],dataclasses.replace(templates[-1],pitch=target))
|
||||
proposed=list(routes.candidates(timed,{timed[-1].event_id},harmony,library,harmony.profile.register,
|
||||
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal)))
|
||||
scheduled.append((rank,gestures.Proposal(timed,(),'direct'),label))
|
||||
if allow_expressions and getattr(context,'expressions_enabled',True):
|
||||
for rank,plain,label in tuple(scheduled[:4]):
|
||||
scheduled.extend((rank,p,label+'; '+p.purpose) for p in gestures.select(plain.notes,harmony,start,finish,ending,limit=4))
|
||||
funnel=context.diagnostics.setdefault('joint_gestures',{})
|
||||
def record(purpose,stage,count=1):
|
||||
values=funnel.setdefault(purpose,dict(proposed=0,retained_jobs=0,valid_routes=0,retained_units=0))
|
||||
values[stage]+=count
|
||||
for _,p,_ in scheduled:record(p.purpose,'proposed')
|
||||
scheduled=sequences.diverse(scheduled,lambda v:(v[0],policy.stable_rank(ref.id,(v[1].purpose,tuple((n.start,n.end,n.pitch) for n in v[1].notes)))),
|
||||
lambda v:tuple(c.kind for c in v[1].contracts) or ('direct',),policy.DEFAULT.theme_route_jobs*(batch+1))
|
||||
for _,proposal,label in scheduled:
|
||||
record(proposal.purpose,'retained_jobs')
|
||||
timed=proposal.notes;first=timed[0].pitch;target=timed[-1].pitch;templates=timed
|
||||
if not max(2,count)<=len(timed)<=16:continue
|
||||
fixed={timed[-1].event_id}|{n.event_id for n in timed if n.role=='arrival'}
|
||||
proposed=list(routes.candidates(timed,fixed,harmony,library,harmony.profile.register,
|
||||
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal),expressions=proposal.contracts))
|
||||
# A substantial literal return is a musical action, not merely the hope
|
||||
# that a general interval search will accidentally reproduce the theme.
|
||||
if require_feature and len(templates)==len(ref.notes):
|
||||
if proposal.purpose=='direct' and require_feature and len(templates)==len(ref.notes):
|
||||
delta=first.number()-ref.notes[0].pitch.number()
|
||||
actual=[]
|
||||
for template,n in zip(templates,ref.notes):
|
||||
@@ -320,18 +348,22 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
||||
contract=feature(ref,actual[:count],treatment=operation if operation!='introduce' else None) if count else None
|
||||
if contract and references.feature_error(contract,actual):
|
||||
trace['rejected_by_rule']+=1;continue
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True)
|
||||
contracts=proposal.bind(actual)
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True,expressions=contracts)
|
||||
if not checked.valid:trace['rejected_by_rule']+=1;continue
|
||||
trace['valid']+=1
|
||||
record(proposal.purpose,'valid_routes')
|
||||
entry=max(0,abs(first.number()-incoming.last.pitch.number())-7)/12 if incoming.last else 0.
|
||||
score=2*checked.exposure+entry+.1*routes.path_cost(actual)
|
||||
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
|
||||
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish))
|
||||
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish,contracts))
|
||||
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
||||
chosen=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
||||
lambda u:(routes.path_family(u.notes,harmony),u.notes[0].pitch.number()//12,
|
||||
tuple(n.end-n.start for n in u.notes)),policy.DEFAULT.theme_unit_alternatives)
|
||||
tuple(n.end-n.start for n in u.notes),tuple(c.kind for c in u.expressions)),policy.DEFAULT.theme_unit_alternatives)
|
||||
trace['retained']+=len(chosen)
|
||||
for u in chosen:
|
||||
record(u.source.split('; ')[-1] if '; ' in u.source else 'direct','retained_units')
|
||||
context.diagnostics['generated_candidates']+=len(chosen)
|
||||
context.results[key]=chosen
|
||||
return chosen
|
||||
@@ -437,7 +469,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
destination=ending.pitch.number() if ending else phrasing.register_goal(state,part,base,harmony.profile)
|
||||
goal=destination if finish==source.end else round(base+(destination-base)*float((finish-cursor)/(source.end-cursor)))
|
||||
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
||||
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal)
|
||||
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal,allow_expressions=stage=='elaborated')
|
||||
for unit in options:
|
||||
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
|
||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||
@@ -466,21 +498,23 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
# Cell tails are not compulsory melodic arrivals. Only the selected close
|
||||
# has a sounding contract; intermediate turns remain editable.
|
||||
anchors=(ending,) if ending else ()
|
||||
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),) if ending else ()
|
||||
tails=tuple(types.SustainedTail(n.start,n.end,n.pitch.number()) for n in plain if n.role=='arrival')
|
||||
if ending:tails=(*tails,types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()))
|
||||
options=[(plain,(),())]
|
||||
for notes,edits,selected_routes in options:
|
||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
|
||||
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
|
||||
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
|
||||
source_ref=state.source_reference
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,(),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,tuple(n.event_id for n in notes if n.role=='arrival'),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
||||
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails,
|
||||
expressions=tuple(c for u in units for c in u.expressions))
|
||||
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
||||
landmarks=(types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sounding destination'),) if ending else ()
|
||||
closed=bool(ending and ending.role=='terminal')
|
||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
|
||||
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending),landmarks,
|
||||
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending,tuple(n for n in notes if n.role=='arrival')),landmarks,
|
||||
'sustain' if ending else 'connected','closed' if closed else 'open',seed,
|
||||
source.id,source.start,source.end,(anchors,))
|
||||
before_rhythm=draft
|
||||
|
||||
@@ -44,6 +44,13 @@ def _load(contents):
|
||||
raise ValueError(p['id']+': invalid chord-member step')
|
||||
if not p['prefix_attacks'] or any(n not in (2,3,4) for n in p['prefix_attacks']) or not 1<=p['max_interval']<=12:
|
||||
raise ValueError(p['id']+': unsupported bounded chord prefix')
|
||||
gestures=result['melodic_vocabulary']['gesture_rules']
|
||||
if len({g['id'] for g in gestures})!=len(gestures):raise ValueError('Duplicate joint gesture rule')
|
||||
for g in gestures:
|
||||
if g['kind'] not in ('harmonic_arrival','sustained_color'):raise ValueError(g['id']+': unsupported joint gesture')
|
||||
if g['kind']=='harmonic_arrival':
|
||||
if not g['arrival_offsets'] or any(abs(F(v))>F(result['harmony']['anticipation_max']) for v in g['arrival_offsets']):raise ValueError(g['id']+': arrival outside bounded policy')
|
||||
if not g['holds'] or any(F(v)<=0 for v in g['holds']) or F(g['release'])<0:raise ValueError(g['id']+': invalid sounding interval')
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
These are **evaluation-only original-song inputs**, not expected solos, production vocabulary, or the four target solos. They are deliberately outside `data/` and are never loaded by ordinary generation or required tests. Source provenance is beside each XML. The original 1917/1918 editions are from Johns Hopkins' Levy collection.
|
||||
|
||||
The manually transcribed first four chorus measures retain the vocal rhythm, including tied values. Chord symbols are our declared harmonic reductions of the piano score. These short excerpts do not establish whole-form generalization. Preceding pickups/verses and piano figuration are outside the excerpt scope. Original scans remain in ignored `build/composer_repair/405066e/evaluation_sources/` for review.
|
||||
The manually transcribed first four chorus measures retain the vocal rhythm, including tied values. Chord symbols are our declared harmonic reductions of the piano score. These short excerpts do not establish whole-form generalization. Preceding pickups/verses and piano figuration are outside the excerpt scope. Original scan downloads were removed with the historical audition artifacts; the provenance files retain their institutional source links.
|
||||
|
||||
St. Louis Blues was considered, but the institutional original-score download was unavailable (403). Hindustan supplies the third independent source instead; no target arrangement was used to reconstruct missing music.
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Independent harmonic relationships; no optional reference reads."""
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import unittest
|
||||
from generator.solo import gestures, harmonic, routes, types, expressions, placement, themes, realization
|
||||
from tests.test_solo_passages import choices
|
||||
from tests.test_solo_development import note
|
||||
import tests.solo_fixtures
|
||||
import tests.scale_fixtures
|
||||
import tests.rhythm_fixtures
|
||||
|
||||
|
||||
class JointArrivals(unittest.TestCase):
|
||||
def fixture(self):
|
||||
h=harmonic.Harmony(choices(('Dm7','G7'),scale=tests.scale_fixtures.library().scale('major',1,'C')))
|
||||
ns=tuple(note(F(i,2),F(i+1,2),'d' if i<8 else 'g',event_id=str(i)) for i in range(16))
|
||||
return h,ns
|
||||
|
||||
def test_anticipation_is_planned_before_route_and_requires_its_contract(self):
|
||||
h,ns=self.fixture()
|
||||
alternatives=list(gestures.proposals(ns,h,F(),F(8)))
|
||||
p=next(p for p in alternatives if p.contracts and p.contracts[0].kind=='anticipation' and p.contracts[0].target.pitch.letter=='b' and p.contracts[0].target.end-p.contracts[0].target.start>=2)
|
||||
result=routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts)
|
||||
self.assertTrue(result)
|
||||
for r in result:
|
||||
bound=p.bind(r.notes)
|
||||
self.assertTrue(h.evaluate(r.notes,expressions=bound,complete=True).valid)
|
||||
self.assertFalse(h.evaluate(r.notes,complete=True).valid)
|
||||
self.assertLess(bound[0].target.start,F(4));self.assertGreater(bound[0].target.end,F(4))
|
||||
self.assertIsNotNone(placement.place(r.notes,F(120)))
|
||||
|
||||
def test_approach_and_anticipation_share_exact_future_target(self):
|
||||
h,ns=self.fixture()
|
||||
base=next(p for p in gestures.proposals(ns,h,F(),F(8)) if p.contracts and p.contracts[0].kind=='anticipation')
|
||||
proposals=list(gestures.approach(base,h,F()))
|
||||
self.assertTrue(proposals)
|
||||
realized=[]
|
||||
for p in proposals:
|
||||
for r in routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts):
|
||||
bound=p.bind(r.notes);self.assertTrue(h.evaluate(r.notes,expressions=bound,complete=True).valid)
|
||||
self.assertFalse(h.evaluate(r.notes,expressions=bound[1:],complete=True).valid)
|
||||
realized.append((r,bound))
|
||||
self.assertTrue(realized)
|
||||
r,contracts=realized[0];target=contracts[0].target
|
||||
changed=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+1)) if n.event_id==target.event_id else n for n in r.notes)
|
||||
self.assertFalse(h.evaluate(changed,expressions=contracts,complete=True).valid)
|
||||
|
||||
def test_on_time_and_delayed_arrivals_are_real_alternatives(self):
|
||||
h,ns=self.fixture();ps=list(gestures.proposals(ns,h,F(),F(8)))
|
||||
offsets={p.notes[next(i for i,n in enumerate(p.notes) if n.role=='arrival')].start-4 for p in ps if p.purpose.startswith('harmonic_arrival')}
|
||||
self.assertEqual(offsets,{F(-1,2),F(),F(1,2)})
|
||||
|
||||
def test_proposal_cache_preserves_music_and_restores_each_callers_provenance(self):
|
||||
h,ns=self.fixture()
|
||||
direct=gestures._select(ns,h,F(),F(8))
|
||||
cached=gestures.select(ns,h,F(),F(8))
|
||||
self.assertEqual(direct,cached)
|
||||
changed=tuple(dataclasses.replace(n,event_id='caller:'+n.event_id,sources=('new-source',)) for n in ns)
|
||||
self.assertEqual(gestures.select(changed,h,F(),F(8)),gestures._select(changed,h,F(),F(8)))
|
||||
self.assertEqual(len(h.gesture_cache),1)
|
||||
|
||||
def test_route_cache_preserves_contract_conditioned_realization(self):
|
||||
h,ns=self.fixture()
|
||||
p=next(p for p in gestures.proposals(ns,h,F(),F(8)) if p.contracts and p.contracts[0].kind=='anticipation')
|
||||
direct=routes._planned_candidates(p.notes,(),h,(41,76),'test',p.contracts)
|
||||
cached=routes.planned_candidates(p.notes,(),h,(41,76),'test',p.contracts)
|
||||
self.assertTrue(cached)
|
||||
self.assertEqual(cached,direct)
|
||||
self.assertEqual(cached,routes.planned_candidates(p.notes,(),h,(41,76),'test',p.contracts))
|
||||
|
||||
def test_color_is_a_planned_route_destination_not_a_blanket_permission(self):
|
||||
h=harmonic.Harmony(choices(('CM7','CM7'),scale=tests.scale_fixtures.library().scale('major',1,'C')))
|
||||
ns=(note(0,1,'c',event_id='a'),note(1,3,'e',event_id='b'),note(3,4,'g',event_id='c'))
|
||||
p=next(p for p in gestures.proposals(ns,h,F(),F(4)) if p.contracts and p.contracts[0].rule=='major_ninth' and p.contracts[0].events[0].end-p.contracts[0].events[0].start>=2)
|
||||
rs=routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts)
|
||||
self.assertTrue(rs)
|
||||
self.assertFalse(h.evaluate(rs[0].notes).valid)
|
||||
self.assertTrue(h.evaluate(rs[0].notes,expressions=p.bind(rs[0].notes),complete=True).valid)
|
||||
|
||||
def test_independent_transpositions_and_harmonic_rhythms(self):
|
||||
for tonic,symbols in [('C',('Dm7','G7')),('F',('Gm7','C7')),('G',('Am7','D7'))]:
|
||||
for length in (3,4):
|
||||
h=harmonic.Harmony(choices(symbols,duration=length,scale=tests.scale_fixtures.library().scale('major',1,tonic)))
|
||||
ns=tuple(note(F(i,2),F(i+1,2),'g',event_id=str(i)) for i in range(length*4))
|
||||
ps=[p for p in gestures.proposals(ns,h,F(),F(length*2)) if p.contracts and p.contracts[0].kind=='anticipation']
|
||||
self.assertTrue(ps,(tonic,length))
|
||||
self.assertTrue(any(routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts) for p in ps),(tonic,length))
|
||||
|
||||
def test_closed_hold_cannot_be_consumed_by_a_joint_action(self):
|
||||
h,ns=self.fixture();target=ns[-1]
|
||||
ending=types.Anchor('close',F(6),F(6),target.pitch,frozenset({7}),(41,76),F(2),'terminal','G7',hold_until=F(8))
|
||||
for p in gestures.proposals(ns,h,F(),F(8),ending):
|
||||
self.assertFalse(any(n.role=='arrival' and n.end>6 for n in p.notes))
|
||||
|
||||
def test_minor_sixth_is_bounded_stable_color_not_required_neighbor_resolution(self):
|
||||
h=harmonic.Harmony(choices(('Dm','Dm')))
|
||||
n=note(0,3,'b',octave=3,event_id='color')
|
||||
c=types.ExpressionContract('sixth','color','minor_sixth',(n,),n,'Dm')
|
||||
self.assertTrue(h.evaluate((n,),expressions=(c,),complete=True).valid)
|
||||
self.assertFalse(h.evaluate((n,),complete=True).valid)
|
||||
too_long=dataclasses.replace(n,end=F(5));bad=dataclasses.replace(c,events=(too_long,),target=too_long)
|
||||
self.assertFalse(h.evaluate((too_long,),expressions=(bad,),complete=True).valid)
|
||||
|
||||
def test_whole_tone_enclosure_neighbor_must_actually_be_mapped(self):
|
||||
h=harmonic.Harmony(choices(('C7',),scale=tests.scale_fixtures.library().scale('major',5,'C')))
|
||||
target=note(2,4,'g',event_id='target')
|
||||
upper=note(1,F(3,2),'a',event_id='upper');lower=note(F(3,2),2,'f',alter=1,event_id='lower')
|
||||
c=types.ExpressionContract('enclosure','enclosure','mapped_upper_lower',(upper,lower),target,'C7')
|
||||
self.assertTrue(h.evaluate((upper,lower,target),expressions=(c,),complete=True).valid)
|
||||
altered=harmonic.Harmony(choices(('C7',),scale=tests.scale_fixtures.library().scale('harmonic_minor',5,'C')))
|
||||
self.assertFalse(altered.evaluate((upper,lower,target),expressions=(c,),complete=True).valid)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -26,6 +26,14 @@ def pitched(duration, tie='', octave=5):
|
||||
|
||||
|
||||
class ComparisonTests(unittest.TestCase):
|
||||
def test_note_container_does_not_turn_color_label_into_fingering(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
note_xml=pitched(24).replace('</note>','<notations><technical><fingering>6</fingering></technical></notations></note>')
|
||||
r=self.review(root,'annotation.xml',xml(note_xml),form_start=1)
|
||||
self.assertEqual(r['annotations'][0]['text'],'6')
|
||||
self.assertEqual(r['annotations'][0]['container'],'notations/technical/fingering')
|
||||
self.assertIsNone(r['notes'][0]['tablature'])
|
||||
|
||||
def review(self,directory,name,content,**kwargs):
|
||||
path=Path(directory)/name;path.write_text(content)
|
||||
return reference_review.read(path,'fixture/acoustic',**kwargs)
|
||||
|
||||
Reference in New Issue
Block a user