Validation
ci / checks (push) Failing after 43m34s

This commit is contained in:
khodowany
2026-10-05 20:33:14 -05:00
parent e7180e557b
commit ffdbe48258
22 changed files with 541 additions and 409 deletions
+2
View File
@@ -120,6 +120,8 @@ Identical notation bodies share layout and body rasterization even when titles o
Solos use one pooled model trained on the paired scores in `data/training`. Both solo slots contribute equally; there is no learned acoustic/electric style distinction. **Registered Solo 1 is played exactly by acoustic and Solo 2 exactly by electric.** Unregistered song/slot pairs use deterministic model prediction. Exact registered playback is explicit authored reuse, not a claim of model memorization. Only Autumn Leaves and All of Me are included initially.
Training and playback use the same contextual harmony validator as predictions. It recognizes supported colors, passing notes, approaches and anticipations from the actual notes, durations and chords. No explanatory score labels or hand-authored justification tags are required. The four approved solos pass this validation; their explanatory labels have been removed without changing their music. Unsupported relationships still produce a diagnostic rather than silently changing a note.
Add a source and its authored solo without changing generator code:
```sh
+14 -8
View File
@@ -59,7 +59,7 @@ The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and
### Musical Data
`data/songs/` contains authoritative source scores. `data/training/manifest.json` pairs each authored solo with a source and a slot (1 or 2), and declares its pitch convention. Both slots train **one pooled model**. Slot 1 maps to acoustic playback and slot 2 to electric playback; slot and instrument names are not model features. The four XML files are preserved unchanged, including the Autumn Leaves pickup and All of Me's full-bar opening.
`data/songs/` contains authoritative source scores. `data/training/manifest.json` pairs each authored solo with a source and a slot (1 or 2), and declares its pitch convention. Both slots train **one pooled model**. Slot 1 maps to acoustic playback and slot 2 to electric playback; slot and instrument names are not model features. The four XML files retain their notes, harmony, timing, ties, rests, Autumn Leaves pickup and All of Me's full-bar opening. Explanatory labels such as `Lead in`, `b9` and `6` were removed; they were not model features. Written chord extensions remain intact.
`data/models/solo.json` is an explicitly trained, versioned statistical model. Its short-context counts, rhythm and register statistics are learned from the paired examples. Provenance records input hashes and the training version. This is supervised use of the supplied solos, not independence from them and not a neural network pretrained on an external corpus.
@@ -145,7 +145,7 @@ At runtime the shared on-demand score loader serves both display and audio. A ba
The production decision is explicit:
1. Look up the canonical source music and solo slot in the training manifest. A registered pair uses its authored solo **exactly**, with deterministic guitar placement and normal notation/export. This is library reuse, not a claim that a model reproduced it. An incompatible arrangement window, invalid tie or unplayable note fails rather than clipping, transposing or replacing it. Authored chromatic/color notes are preserved; generated-harmony checks are diagnostic for authored music.
1. Look up the canonical source music and solo slot in the training manifest. A registered pair uses its authored solo **exactly**, with deterministic guitar placement and normal notation/export. This is library reuse, not a claim that a model reproduced it. An incompatible arrangement window, invalid tie, unsupported harmonic relationship or unplayable note fails rather than clipping, transposing or replacing it. Authored chromatic/color notes pass the same contextual harmonic validation as predictions; there is no authored exemption.
2. For an unregistered pair, load the frozen pooled model and call `solo.composer.compose → decoder.Decoder → placement → validate → notation/export`. This path does not read authored notes or train during an ordinary build. `generate_role(..., settings={'predict': True})` explicitly exercises prediction even for a registered song.
The small conditional model backs off from up to four recent joint pitch/rhythm events through source-melody features, current/future chord quality, metrical phase and global evidence. Pitch relationships are relative; there are no song-name conditions or per-song prediction tables. All examples contribute to the same distribution. Source intervals, sounding state and chord-relative source pitches influence prediction. Register comes from pooled training evidence, not an acoustic/electric octave formula.
@@ -154,7 +154,13 @@ Prediction uses constrained autoregressive sampling with a stable seed derived f
Binary durations and complete triplet groups remain exact. Contiguous silence is one event, matching training tokenization; the decoder cannot repeatedly concatenate rest tokens to invent a long silent passage. Selected holds survive barline notation splits. Generic chromatic approaches/enclosures include their exact target as complete validated actions. Their event score is identical to emitting the constituent events separately, and equivalent actions are deduplicated before sampling. The report's negative log likelihood is diagnostic, not a ranking of complete solos or a model of listener preference.
Fixed scale maps no longer constrain prediction. Brief ordinary movement uses the source key and written chord tones; chord quality, alterations, omissions and slash-bass semantics remain authoritative. Long nonchord events require a supported color or resolution contract. Approaches resolve to their specified pitch and octave; anticipation validates the upcoming harmony. Missing harmony does not authorize arbitrary pitches. Search failure is explicit and never invokes the old composer. General harmonic validators support more contracts than this first decoder actively proposes; no claim is made that every validator has a learned production construction path.
Fixed scale maps no longer constrain prediction. Brief ordinary movement uses the source key and written chord tones; chord quality, alterations, omissions and slash-bass semantics remain authoritative. Long nonchord events require a supported color or resolution relationship. Approaches resolve to their specified pitch and octave; anticipation validates the upcoming harmony. Missing harmony does not authorize arbitrary pitches. Search failure is explicit and never invokes the old composer.
`solo.recognition` identifies these relationships automatically in a complete sounded line, using the versioned policies and `solo.expressions` validator. It checks actual preparation, pitch/register, duration, future harmony and resolution; annotations, filenames, authored status and source IDs grant no permission. A bounded deterministic search finds mutually consistent coverage of exposed events, without changing notes. Malformed explicit contracts remain errors. Tied notation fragments are merged before harmonic classification. Unsupported notes, missing harmony, expired resolutions and exhausted recognition budgets fail explicitly. Users do not need to supply explanations or tags.
Prediction proposes stable and resolving colors, anticipated colors, delayed approaches with real space or intervening valid motion, and chromatic passing notes between valid endpoints, alongside the existing complete enclosures and connections. These are joint actions containing their required destinations. Disabling expressions removes these options. Prepared suspensions are recognized and validated but do not yet have a dedicated prediction action. The model still selects from its small learned distribution; validation establishes supported musical relationships, not aesthetic quality.
Training checks the complete source-form harmonic context. In the repeated performance form, an unlabelled pickup inherits the final source-form chord; an explicit pickup chord or `N.C.` takes precedence. This is the same arrangement context used for playback, not an invented first chord or a rule tied to either song name.
The model has only four examples from two songs. Its four-event context does not establish long-range thematic memory, nor does training prove transfer to another standard. Phrase report boundaries describe audible breaths after selection; they are not another composer or an assertion of phrase-wide theme planning. This initial implementation can still repeat local patterns or choose weak continuations on unfamiliar harmony. Exact authored playback and novel model output are reported separately.
@@ -171,7 +177,7 @@ Register an additional pair and retrain without changing Python:
./modulus.py python -m generator.train --add-source data/songs/new-song.xml --add-solo /path/to/new-song-solo2.xml --slot 2
```
Files are copied into `data/songs` and `data/training` without overwriting different contents. Validation checks the exact measure/harmony correspondence, guitar-written convention and fret range before publishing the manifest. An existing source without a registered slot uses model prediction for that slot. Use `--pitch-convention encoded` only for files whose own transposition describes sounding pitch. Updating existing examples means editing their XML/manifest deliberately and rerunning training. The report under `build/training/report.json` records teacher-forced likelihood and optional leave-one-song-out likelihood; neither is a listening assessment or unassisted-generation accuracy.
Files are copied into `data/songs` and `data/training` without overwriting different contents. Validation checks the exact measure/harmony correspondence, contextual harmonic validity, guitar-written convention and fret range before publishing the manifest. An existing source without a registered slot uses model prediction for that slot. Use `--pitch-convention encoded` only for files whose own transposition describes sounding pitch. Updating existing examples means editing their XML/manifest deliberately and rerunning training. The report under `build/training/report.json` records automatically recognized harmonic treatments, teacher-forced likelihood and optional leave-one-song-out likelihood; none is a listening assessment or unassisted-generation accuracy.
Production input traces distinguish authored reuse from learned prediction. Caches include source, role, relevant data and code; authored cache keys include the selected XML's content and convention. Build and package inputs now include the model, manifest and authored repertoire. Changes to another solo do not change an already registered part's notes. Updating the pooled model can legitimately change predictions for any unregistered part. Build-time composition defaults to two independent processes; rendering and runtime remain unchanged.
@@ -182,7 +188,7 @@ Migration inventory:
| Kept or added | Consumer and reason |
| --- | --- |
| `data/songs/` (two songs) | Normal source importer and immutable arrangement analysis. |
| `data/training/` (four unchanged XMLs + manifest) | Explicit trainer and registered authored playback. All slots are pooled for learning. |
| `data/training/` (four XMLs + manifest) | Explicit trainer and registered authored playback. All slots are pooled for learning. Annotation-only cleanup preserves the music and learned counts. |
| `data/models/solo.json` | Frozen prediction model; reproducible by `generator.train`. |
| `data/composition/{roles,harmony,lead_ins}.json` | Physical/difficulty settings and generic harmonic validity. Fixed-map permissions removed. |
| `data/composition/accompaniment.json` | Live sparse comping figures, migrated from the former mixed rhythm file. |
@@ -191,11 +197,11 @@ Migration inventory:
Removed: `data/{scales,sequences,rhythms}`, old `composition/solo.json` and mixed `rhythms.json`, catalog import/cache code, old solo construction/phrase/selection/scoring/sequence modules, unused accompaniment catalog planner, three evaluation-song excerpts, and tests specific to those retired algorithms. Shared importer, timing, guitar, playback, engraving, arrangement and independence tests remain, with new pooled-learning, registration and exact-repertoire tests. No `docs/` or `audition/` directory is required. Disposable prior comparison outputs are replaced by one current comparison under `build/solo_comparison`.
Verification on October 5, 2026: `unittest discover -s tests -p 'test_*.py'` passed 347 tests in 112.99 seconds. The normal six-part asset build passed `--check`, including playback, engraving, TAB and catalog validation. On an Intel Core i7-1355U, a build with an empty composition cache, warm engraving cache and two composition workers took 24.31 seconds; performance-part generation occupied 5.65 seconds. Each registered solo took 0.59–0.68 seconds. A subsequent CMake build reused all role and engraving results and finished in approximately nine seconds. These measurements describe registered playback, not novel prediction. Detailed timings are in `build/training/build-profile.json`; isolated, cache-free prediction timings and input fingerprints are in `build/training/prediction-check.json`.
Verification on October 5, 2026: `unittest discover -s tests -p 'test_*.py'` passed 361 tests in 78.62 seconds. These include all four approved solos without annotations or authored-role privileges, source-form pickup context, invalid resolutions, passing endpoints, anticipated colors, tied-event partition invariance and active prediction construction. The normal six-part asset build passed `--check`, including playback, engraving, TAB and catalog validation. With two composition workers, newly generated solo results, cached accompaniment/melody and a warm engraving cache, it took 5.10 seconds; performance-part generation occupied 2.37 seconds. Each registered solo took 0.36–0.40 seconds. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` also passed. These measurements describe registered playback, not novel prediction. Detailed timings are in `build/training/build-profile.json`; isolated, cache-free prediction timings and input fingerprints are in `build/training/prediction-check.json`.
The optional exact comparison confirmed all four exports: 137/141 attacks for All of Me and 121/115 for Autumn Leaves, with no pitch, onset, release or rest differences. This verifies authored reuse rather than learned reproduction. The commands were the ordinary asset generator with `--export-solo-xml --check`, `cmake --build build/build_x86_debug --target vamp_generated_assets`, and `python -m tests.solo_compare` with four explicit `--reference` pairs, `--reference-pitch guitar-written` and `--require-exact`. No ear-based audition or claim of transfer quality was made.
The optional exact comparison confirmed all four exports: 137/141 attacks for All of Me and 121/115 for Autumn Leaves, with no pitch, onset, release or rest differences. All four now pass contextual harmony validation as well. Removing 69 explanatory annotations left the complete event streams and learned musical count tables identical to the pre-edit baseline; only model provenance hashes changed. This verifies authored reuse rather than learned reproduction. Commands used the ordinary asset generator with `--export-solo-xml --check` and `python -m tests.solo_compare` with four explicit `--reference` pairs, `--reference-pitch guitar-written` and `--require-exact`. No ear-based audition or claim of transfer quality was made.
Forced prediction, without reading the training XMLs, completed all four source/role combinations in 21.81–27.16 seconds each with one worker and no concrete-result cache. These were composition-only runs on the training-source songs, not held-out transfer tests. Their notes intentionally differ from registered playback and were not published as replacements. Training the pooled 2,860-context model took 0.89 seconds. Prediction remains the slower path, chiefly candidate harmonic validation and event-likelihood evaluation.
Forced prediction, without reading the training XMLs, completed all four source/role combinations with valid harmony. These were composition-only runs on the training-source songs, not held-out transfer tests. Their notes intentionally differ from registered playback and were not published as replacements. The expanded treatment candidates initially took 99.03–120.33 seconds per role with one worker and no concrete-result cache. Removing repeated validation of identical resolution endpoints reduced the isolated All of Me acoustic check from 104.30 to 60.13 seconds, with an identical complete output hash. Six additional contexts retained all 32,566 ordered action candidates exactly. These expanded prediction choices remain slower than the earlier 21.81–27.16-second decoder; registered playback is unaffected. Training the pooled 2,860-context model took 0.57 seconds. Prediction remains the slower path, chiefly candidate construction and event-likelihood evaluation; further profiling is needed before expanding its choices again.
### Engraving Boundary
+9
View File
@@ -286,5 +286,14 @@
"1/2",
"1"
]
},
"passing": {
"id": "chromatic_passing",
"max_note": "1/2",
"steps": [
-1,
1
],
"endpoints": "Source-key/chord motion or separately validated colors; contiguous same-direction semitones."
}
}
File diff suppressed because one or more lines are too long
-126
View File
@@ -56,11 +56,6 @@
<duration>24</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -128,11 +123,6 @@
<type>eighth</type>
<beam number="1">end</beam>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -229,11 +219,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>b13</words>
</direction-type>
</direction>
<note>
<pitch>
<step>G</step>
@@ -324,11 +309,6 @@
<type>eighth</type>
<beam number="1">continue</beam>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -399,11 +379,6 @@
<dot/>
<accidental>sharp</accidental>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -469,11 +444,6 @@
<duration>24</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>G</step>
@@ -628,11 +598,6 @@
</note>
</measure>
<measure number="10">
<direction placement="above">
<direction-type>
<words>#9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -641,11 +606,6 @@
<duration>48</duration>
<type>quarter</type>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -655,11 +615,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>b13</words>
</direction-type>
</direction>
<note>
<pitch>
<step>G</step>
@@ -853,12 +808,6 @@
<duration>24</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in
#11</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -929,11 +878,6 @@
</root>
<kind text="m7">minor-seventh</kind>
</harmony>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -997,11 +941,6 @@
</root>
<kind text="7">dominant</kind>
</harmony>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>G</step>
@@ -1046,11 +985,6 @@
<duration>24</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>E</step>
@@ -1124,11 +1058,6 @@
<type>quarter</type>
<dot/>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>G</step>
@@ -1213,11 +1142,6 @@
</note>
</measure>
<measure number="20">
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>E</step>
@@ -1263,11 +1187,6 @@
<duration>24</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -1375,11 +1294,6 @@
<type>eighth</type>
<beam number="1">end</beam>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -1420,11 +1334,6 @@
</root>
<kind text="m7">minor-seventh</kind>
</harmony>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -1488,11 +1397,6 @@
</note>
</measure>
<measure number="24">
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
@@ -1744,11 +1648,6 @@
</root>
<kind text="7">dominant</kind>
</harmony>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -1767,11 +1666,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>b13</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -1797,11 +1691,6 @@
<tied type="stop"/>
</notations>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>G</step>
@@ -1888,11 +1777,6 @@
<type>quarter</type>
<dot/>
</note>
<direction placement="above">
<direction-type>
<words>#9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -1903,11 +1787,6 @@
<accidental>natural</accidental>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>E</step>
@@ -1960,11 +1839,6 @@
</root>
<kind text="dim7)">other</kind>
</harmony>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
-133
View File
@@ -52,11 +52,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -109,11 +104,6 @@
</note>
</measure>
<measure number="2">
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -146,11 +136,6 @@
<duration>8</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -228,11 +213,6 @@
</note>
</measure>
<measure number="4">
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -242,11 +222,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>#9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -277,11 +252,6 @@
<type>eighth</type>
<beam number="1">end</beam>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>A</step>
@@ -437,11 +407,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>G</step>
@@ -630,11 +595,6 @@
<type>eighth</type>
<beam number="1">continue</beam>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -644,11 +604,6 @@
<type>eighth</type>
<beam number="1">continue</beam>
</note>
<direction placement="above">
<direction-type>
<words>#9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -702,11 +657,6 @@
</root>
<kind text="m7">minor-seventh</kind>
</harmony>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>A</step>
@@ -764,11 +714,6 @@
</note>
</measure>
<measure number="12">
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -831,12 +776,6 @@
<duration>8</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in
#11</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -876,12 +815,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>Lead in
#11</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -897,11 +830,6 @@
<duration>8</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>#11</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -922,11 +850,6 @@
<duration>16</duration>
<type>quarter</type>
</note>
<direction placement="above">
<direction-type>
<words>#11</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -952,11 +875,6 @@
<duration>8</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
@@ -1045,11 +963,6 @@
<type>eighth</type>
<beam number="1">continue</beam>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>E</step>
@@ -1251,12 +1164,6 @@
</note>
</measure>
<measure number="20">
<direction placement="above">
<direction-type>
<words>Lead in
b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -1335,11 +1242,6 @@ b9</words>
<duration>8</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -1365,11 +1267,6 @@ b9</words>
<duration>8</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -1395,11 +1292,6 @@ b9</words>
<type>quarter</type>
<dot/>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>G</step>
@@ -1463,11 +1355,6 @@ b9</words>
<type>eighth</type>
<beam number="1">end</beam>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
@@ -1694,11 +1581,6 @@ b9</words>
<bass-step>F</bass-step>
</bass>
</harmony>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>A</step>
@@ -1765,11 +1647,6 @@ b9</words>
<tied type="stop"/>
</notations>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -1813,11 +1690,6 @@ b9</words>
</root>
<kind text="m7">minor-seventh</kind>
</harmony>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -1908,11 +1780,6 @@ b9</words>
<type>eighth</type>
<beam number="1">end</beam>
</note>
<direction placement="above">
<direction-type>
<words>b13</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
-18
View File
@@ -518,11 +518,6 @@
<duration>8</duration>
<type>quarter</type>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>D</step>
@@ -719,11 +714,6 @@
<duration>8</duration>
<type>quarter</type>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
@@ -1000,11 +990,6 @@
<type>eighth</type>
<accidental>natural</accidental>
<beam number="1">end</beam>
<notations>
<technical>
<fingering>6</fingering>
</technical>
</notations>
</note>
</measure>
<measure number="21">
@@ -1600,9 +1585,6 @@
<notations>
<tied type="start"/>
<slur placement="above" type="stop" number="1"/>
<technical>
<fingering>6</fingering>
</technical>
</notations>
</note>
<note>
-71
View File
@@ -288,11 +288,6 @@
<duration>4</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>E</step>
@@ -422,11 +417,6 @@
<accidental>natural</accidental>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>b13</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
@@ -446,12 +436,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>b3
(original #9)</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
@@ -475,11 +459,6 @@
<slur placement="above" type="stop" number="1"/>
</notations>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>E</step>
@@ -518,11 +497,6 @@
<duration>4</duration>
<type>eighth</type>
</note>
<direction placement="above">
<direction-type>
<words>Lead in</words>
</direction-type>
</direction>
<note>
<pitch>
<step>A</step>
@@ -693,11 +667,6 @@
<duration>8</duration>
<type>quarter</type>
</note>
<direction placement="above">
<direction-type>
<words>Bluesy</words>
</direction-type>
</direction>
<note>
<pitch>
<step>A</step>
@@ -777,11 +746,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="below">
<direction-type>
<words>#9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -796,11 +760,6 @@
<duration>4</duration>
<type>eighth</type>
</note>
<direction placement="below">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
@@ -815,11 +774,6 @@
<duration>4</duration>
<type>eighth</type>
</note>
<direction placement="below">
<direction-type>
<words>#9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>C</step>
@@ -1411,11 +1365,6 @@
<type>eighth</type>
<beam number="1">begin</beam>
</note>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
@@ -1483,11 +1432,6 @@
</pitch>
<duration>8</duration>
<type>quarter</type>
<notations>
<technical>
<fingering>9</fingering>
</technical>
</notations>
</note>
<harmony>
<root>
@@ -1543,11 +1487,6 @@
<duration>8</duration>
<type>quarter</type>
</note>
<direction placement="above">
<direction-type>
<words>Bluesy</words>
</direction-type>
</direction>
<note>
<pitch>
<step>A</step>
@@ -1618,11 +1557,6 @@
</root>
<kind text="7b9">other</kind>
</harmony>
<direction placement="above">
<direction-type>
<words>b9</words>
</direction-type>
</direction>
<note>
<pitch>
<step>B</step>
@@ -1662,11 +1596,6 @@
<type>eighth</type>
<beam number="1">end</beam>
</note>
<direction placement="above">
<direction-type>
<words>b13</words>
</direction-type>
</direction>
<note>
<pitch>
<step>F</step>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"provenance": "User-authored source/solo pairs for pooled supervised learning and exact registered playback. XML files retained unchanged.",
"provenance": "User-authored source/solo pairs for pooled supervised learning and exact registered playback. Explanatory annotations removed; all sounding music and written harmony preserved.",
"examples": [
{
"id": "all-of-me/solo1",
+15
View File
@@ -5,6 +5,21 @@ import fractions
import generator.model
def solo_source(staff):
"""Source-form pickup context, shared by training and arranged choruses.
The arrangement repeats the source form before each solo. Only an unlabelled
pickup inherits its preceding source-form harmony. Scores without a pickup
retain their ordinary initial-harmony checks. Explicit pickup harmony
(including N.C.) still takes precedence in read().
"""
from generator.score import body_start_measure_index
measures=staff['measures'];initial=None
if body_start_measure_index(measures):
initial=next((h for m in reversed(measures) for h in reversed(m['harmonies'])),None)
return read(staff,initial=initial)
@dataclasses.dataclass(frozen=True)
class ChordSpan:
start: fractions.Fraction
+26 -11
View File
@@ -11,7 +11,25 @@ import json
from pathlib import Path
from fractions import Fraction as F
from generator import musicxml,parts,training_io,harmonic_time
from generator.solo import analysis,types,placement,profiles
from generator.solo import analysis,types,placement,profiles,harmonic
def sounding(evidence,offset=F()):
result=[]
for i,event in enumerate(evidence['sounding_events']):
p=event['pitch'];number=F(event['sounding_midi'])
if number.denominator!=1:raise ValueError('Microtonal guitar solo is unsupported')
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+(int(number)-int(F(event['encoded_midi'])))//12)
result.append(types.Note(F(event['start'])-offset,F(event['end'])-offset,pitch,role='authored',event_id=f'authored_{i}'))
return tuple(result)
def validate_evidence(document,evidence):
"""Training uses the same source-form harmonic context as solo playback."""
h=harmonic.Harmony(harmonic_time.solo_source(document['staves'][0]))
checked=h.evaluate(sounding(evidence),complete=True)
if not checked.valid:raise ValueError('Unsupported harmonic relationship in authored solo: '+checked.error)
return checked
def lookup(source,role,data_dir):
@@ -41,23 +59,20 @@ def realize(entry,source_analysis,measures,part,start,end,initial=None):
if evidence['discrepancies']:raise ValueError(entry['id']+': invalid authored timing/ties: '+str(evidence['discrepancies']))
expected=[(a,b-a) for a,b in zip(source_analysis.boundaries,source_analysis.boundaries[1:])]
if [(F(m['start']),F(m['duration'])) for m in evidence['measures']]!=expected:raise ValueError(entry['id']+': source/solo forms differ; register and retrain the correct pair')
notes=[]
for i,event in enumerate(evidence['sounding_events']):
a,b=F(event['start'])-source_analysis.body_offset,F(event['end'])-source_analysis.body_offset
if a<start or b>end:raise ValueError(f'{entry["id"]}: authored event {a}–{b} conflicts with arrangement window {start}–{end}; no clipping applied')
p=event['pitch'];number=F(event['sounding_midi'])
if number.denominator!=1:raise ValueError('Microtonal guitar solo is unsupported')
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+(int(number)-int(F(event['encoded_midi'])))//12)
notes.append(types.Note(a,b,pitch,('authored:'+entry['id'],),'authored',event_id=f'authored_{i}'))
notes=sounding(evidence,source_analysis.body_offset)
for n in notes:
if n.start<start or n.end>end:raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied')
profile=profiles.for_part(part,source_analysis)
illegal=next((n for n in notes if not placement.positions(n.pitch.number(),profile)),None)
if illegal:raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
placed=placement.place(tuple(notes),source_analysis.tempo,profile=profile)
if placed is None:raise ValueError(entry['id']+': no legal complete guitar placement')
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
phrase=types.Phrase('authored',start,end,placed[1],'authored','authored','handoff',theme=entry['id'])
checked=harmonic.Harmony(spans).evaluate(placed[1],complete=True)
if not checked.valid:raise ValueError(entry['id']+': '+checked.error)
phrase=types.Phrase('authored',start,end,placed[1],'authored','authored','handoff',theme=entry['id'],expressions=checked.contracts)
return types.Solo(part,spans,(phrase,),source_analysis.tempo,end,
{'construction':'authored_repertoire','authored_id':entry['id'],'authored_sha256':entry['sha256'],
'pitch_convention':entry['pitch_convention'],'exact_sounding_events':True,
'note':'Authored music is preserved, not reclassified as generated harmonic exceptions.',
'note':'Authored music is preserved and passes automatic contextual harmonic validation.',
'annotations':evidence['annotations']},profile,start)
+104 -17
View File
@@ -46,7 +46,7 @@ class Decoder:
self.durations=tuple(d for d in model.durations if 0<d<=4 and d.denominator in (1,2,4) and
(d.denominator!=4 or analysis.tempo<=limits['sixteenth_max_bpm']))
self.allow_triplets=analysis.tempo<=limits['triplet_max_bpm']
self._actions={};self._context={};self._pitches={};self.trace=[];self.counts=collections.Counter()
self._actions={};self._context={};self._pitches={};self._colors={};self._permissions={};self.trace=[];self.counts=collections.Counter()
def context(self,time):
if time not in self._context:
@@ -80,7 +80,37 @@ class Decoder:
pitch=p if isinstance(p,types.Pitch) else self.pitch(p,a)
return types.Note(a,b,pitch,role=role,event_id=f'{a}:{b}:{pitch.number()}')
def color_bases(self,n):
key=(n.start,n.end,n.pitch.number())
if key in self._colors:return self._colors[key]
bases=[]
regions=[(None,n)]
regions.extend((s.start,dataclasses.replace(n,start=s.start)) for s in self.harmony.spans
if n.start<s.start<n.end and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']))
for boundary,arrived in regions:
exposed=[s for s,_,_ in self.harmony.segments(arrived) if n.pitch.number()%12 not in s.roles.basic|s.roles.specified]
if not exposed:continue
for rule in vocabulary.data('harmony')['colors']:
if arrived.end-arrived.start>F(rule['max_beats']):continue
if any(s.chord=='N.C.' or not expressions.eligible_color(rule,s,self.harmony) or
expressions.degree_pitch(expressions.parsed_chord(s.chord),rule['degree'],rule['alter'],n.pitch.number()).number()!=n.pitch.number()
for s in exposed):continue
bases.append((rule,boundary))
self._colors[key]=tuple(bases)
return self._colors[key]
def color_contract(self,n,target,rule,boundary):
span=self.harmony.at(boundary if boundary is not None else n.start)
if not span:return None
kind='anticipated_color' if boundary is not None else 'color'
return types.ExpressionContract(n.event_id+':'+rule['id']+':'+str(boundary),kind,rule['id'],(n,),target,span.chord,boundary)
def permission(self,n):
key=(n.start,n.end,n.pitch.number())
if key not in self._permissions:self._permissions[key]=self._permission(n)
return self._permissions[key]
def _permission(self,n):
"""Find justified color/anticipation support, never authorize by label."""
# Source spans are contiguous. These are precisely the ordinary-event
# branches of Harmony.evaluate, without rebuilding an empty contract map.
@@ -90,23 +120,64 @@ class Decoder:
if not self.expressions_enabled:return None
span=self.harmony.at(n.start)
if not span or span.chord=='N.C.':return None
exposed=tuple(region for region,_,_ in self.harmony.segments(n)
if n.pitch.number()%12 not in region.roles.basic|region.roles.specified)
for rule in vocabulary.data('harmony')['colors']:
if rule.get('resolve') or n.end-n.start>F(rule['max_beats']):continue
# A held chord member can become a color only after a chord change.
# Test the regions needing permission, not just the attack's harmony.
if any(region.chord=='N.C.' or not expressions.eligible_color(rule,region,self.harmony) or
expressions.degree_pitch(expressions.parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number()).number()!=n.pitch.number()
for region in exposed):continue
c=types.ExpressionContract(n.event_id+':color','color',rule['id'],(n,),n,span.chord)
if self.harmony.evaluate((n,),complete=True,expressions=(c,)).valid:return (c,)
for rule,boundary in self.color_bases(n):
if rule.get('resolve'):continue
c=self.color_contract(n,n,rule,boundary)
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,)
for arriving in self.harmony.spans:
if n.start<arriving.start<n.end and arriving.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']):
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,arriving.chord,arriving.start)
if self.harmony.evaluate((n,),complete=True,expressions=(c,)).valid:return (c,)
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,)
return None
def tails(self,n,target):
"""Finite resolution timing: direct, real space, or one valid intervening note."""
gap=target.start-n.end
if gap<0:return
if gap==0:
yield ((n.start,n.end,n.pitch.number()),(target.start,target.end,target.pitch.number())),(n,target)
return
yield ((n.start,n.end,n.pitch.number()),(n.end,target.start,None),(target.start,target.end,target.pitch.number())),(n,target)
windows={(n.end,target.start)} if gap<=1 else set()
if gap>=1:windows|={(n.end,n.end+F(1,2)),(target.start-F(1,2),target.start)}
for a,b in sorted(windows):
for number in range(max(self.low,n.pitch.number()-3),min(self.high,n.pitch.number()+3)+1):
mid=self.note(a,b,number)
if number in (n.pitch.number(),target.pitch.number()) or not self.harmony.ordinary(mid):continue
events=[(n.start,n.end,n.pitch.number())]
if a>n.end:events.append((n.end,a,None))
events.append((a,b,number))
if b<target.start:events.append((b,target.start,None))
events.append((target.start,target.end,target.pitch.number()))
yield tuple(events),(n,mid,target)
def resolutions(self,n):
"""Joint color/delayed-approach actions; the destination is never optional."""
requests=[]
for rule,boundary in self.color_bases(n):
if rule.get('resolve'):
for gap in (F(),F(1,2),F(1)):
if gap<=F(vocabulary.data('harmony')['resolution_gap']):requests.append(('color',rule,boundary,n.end+gap,(-2,-1,1,2)))
delay=vocabulary.data('lead_ins')['delayed_approach']
if n.end-n.start<=F(delay['max_note']) and n.pitch.number()%12 not in self.harmony.permitted(n.start,n.end):
for arrival in map(F,delay['arrivals']):
if n.end<=n.start+arrival<=n.start+F(delay['deadline']):requests.append(('delayed',delay,None,n.start+arrival,delay['steps']))
for kind,rule,boundary,arrival,steps in requests:
for delta in steps:
number=n.pitch.number()+delta
if not self.low<=number<=self.high:continue
for hold in (F(1,2),F(1),F(2)):
if arrival+hold>self.end or number%12 not in self.harmony.members(arrival,arrival+hold):continue
target=self.note(arrival,arrival+hold,number,'destination')
c=self.color_contract(n,target,rule,boundary) if kind=='color' else types.ExpressionContract(n.event_id+':delayed','delayed_approach',rule['id'],(n,),target,self.harmony.at(arrival).chord)
# Color and delayed-approach contracts permit this intervening
# window. Validate the invariant endpoints once; tails() checks
# every inserted note's ordinary full-duration permission itself.
if not self.harmony.evaluate((n,target),complete=True,expressions=(c,),infer=False).valid:continue
for events,ns in self.tails(n,target):
if not groups.valid(tuple((a,b) for a,b,_ in events)):continue
yield events,ns,(c,)
def actions(self,time):
if time in self._actions:return self._actions[time]
result=[]
@@ -118,6 +189,19 @@ class Decoder:
for number in range(self.low,self.high+1):
n=self.note(time,end,number);support=self.permission(n)
if support is not None:add(((time,end,number),),(n,),support)
if self.expressions_enabled and not self.harmony.ordinary(n):
for events,ns,contracts in self.resolutions(n):add(events,ns,contracts)
rule=vocabulary.data('lead_ins')['passing']
if duration<=F(rule['max_note']):
for step in rule['steps']:
p=number+step
if not self.low<=p<=self.high:continue
for hold in (F(1,2),F(1),F(2)):
if end+hold>self.end:continue
target=self.note(end,end+hold,p);extra=self.permission(target)
if extra is None or any(c.kind not in ('color','anticipated_color') for c in extra):continue
c=types.ExpressionContract(n.event_id+':passing','passing',rule['id'],(n,),target,self.harmony.at(end).chord)
add(((time,end,number),(end,end+hold,p)),(n,target),(c,*extra))
if self.expressions_enabled:
limits=vocabulary.data('roles')['difficulty']
for rule in vocabulary.data('lead_ins')['patterns']:
@@ -147,7 +231,7 @@ class Decoder:
if rule['kind']=='connection':
# Validation waits for the actual incoming event.
add((*events,(target_time,end,number)),ns,(contract,))
elif self.harmony.evaluate(ns,complete=True,expressions=(contract,)).valid:
elif self.harmony.evaluate(ns,complete=True,expressions=(contract,),infer=False).valid:
add((*events,(target_time,end,number)),ns,(contract,))
# Complete ordinary triplet groups; do not leave a third-beat residual.
if self.allow_triplets and time+1<=self.end:
@@ -161,7 +245,7 @@ class Decoder:
numbers.append(nxt);cursor+=F(1,3)
if len(numbers)!=3:continue
ns=tuple(self.note(time+i*F(1,3),time+(i+1)*F(1,3),p) for i,p in enumerate(numbers))
if self.harmony.evaluate(ns,complete=True).valid:add(tuple((n.start,n.end,n.pitch.number()) for n in ns),ns)
if self.harmony.evaluate(ns,complete=True,infer=False).valid:add(tuple((n.start,n.end,n.pitch.number()) for n in ns),ns)
self._actions[time]=tuple(result);self.counts['valid_structures']+=len(result)
return self._actions[time]
@@ -171,10 +255,13 @@ class Decoder:
# rest-to-rest transition absent from that event representation.
if state.events and state.events[-1][2] is None and events[0][2] is None:return None
if notes and state.previous is not None and abs(notes[0].pitch.number()-state.previous)>12:return None
if contracts and contracts[0].kind=='connection':
if any(c.kind in ('connection','passing') and c.preparation is None for c in contracts):
if not state.notes:return None
c=dataclasses.replace(contracts[0],preparation=state.notes[-1]);contracts=(c,)
contracts=tuple(dataclasses.replace(c,preparation=state.notes[-1]) if c.kind in ('connection','passing') and c.preparation is None else c for c in contracts)
if self.harmony.ordinary(state.notes[-1]):
_,error=expressions.validate((state.notes[-1],*notes),contracts,self.harmony)
else:
_,error=expressions.validate((*state.notes,*notes),(*state.contracts,*contracts),self.harmony)
if error:return None
history=state.history;previous=state.previous;cost=state.cost
for a,b,p in events:
+15 -2
View File
@@ -91,8 +91,11 @@ 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.boundary is not None else target.start)
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else c.events[0].start if c.kind=='color' else target.start)
if not span or span.chord=='N.C.' or span.chord!=c.chord:return {},c.id+': wrong target harmony'
if c.kind in ('anticipation','anticipated_color'):
previous=next((s for s in harmony.spans if s.end==c.boundary),None)
if not previous or previous.chord.strip('()')==span.chord.strip('()'):return {},c.id+': anticipation requires a harmonic change'
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'
@@ -112,6 +115,16 @@ def validate(notes,contracts,harmony):
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside source-key/chord motion'
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=='passing':
rule=vocabulary.data('lead_ins')['passing']
if c.rule!=rule['id'] or len(c.events)!=1 or not c.preparation:return {},c.id+': unsupported passing figure'
n,=c.events;before=c.preparation
left=n.pitch.number()-before.pitch.number();right=target.pitch.number()-n.pitch.number()
if before.end!=n.start or n.end!=target.start or n.end-n.start>F(rule['max_note']) or left!=right or left not in rule['steps']:
return {},c.id+': interrupted/nonstepwise passing figure'
def supported(endpoint):
return harmony.ordinary(endpoint) or any(a.kind in ('color','anticipated_color') and endpoint in a.events for a in contracts)
if not supported(before) or not supported(target):return {},c.id+': unsupported passing endpoints'
elif c.kind=='delayed_approach':
rule=vocabulary.data('lead_ins').get('delayed_approach',{})
if c.rule!=rule.get('id') or len(c.events)!=1:return {},c.id+': unknown delayed approach'
@@ -126,7 +139,7 @@ def validate(notes,contracts,harmony):
for n in c.events:
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
for region,a,b in harmony.segments(n):
if n.pitch.number()%12 in region.roles.basic:continue
if n.pitch.number()%12 in region.roles.basic|region.roles.specified:continue
if region.chord=='N.C.' or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
p=degree_pitch(parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number())
# The rule retains the functional degree/spelling. Encoded
+31 -7
View File
@@ -45,6 +45,7 @@ class Result:
exposure:float
pending:tuple=()
error:str=''
contracts:tuple=()
class Harmony:
def __init__(self,spans,profile=None):
@@ -77,12 +78,34 @@ class Harmony:
if a.end!=n.start or n.end!=b.start:return ''
left=n.pitch.number()-a.pitch.number();right=b.pitch.number()-n.pitch.number()
return 'neighbor' if 0<abs(left)<=2 and left==-right else 'passing' if 0<abs(left)<=2 and 0<abs(right)<=2 and left*right>0 else ''
def evaluate(self,notes,*,until=None,complete=False,expressions=()):
@functools.lru_cache(maxsize=8192)
def _ordinary(self,start,end,number):
covered=start
for s in self.spans:
a,b=max(start,s.start),min(end,s.end)
if a>=b:continue
if a!=covered or s.chord=='N.C.':return False
covered=b;pc=number%12
if pc not in s.roles.basic|s.roles.specified and (pc not in s.pitches or end-start>=2):return False
return covered==end and end>start
def ordinary(self,n):return self._ordinary(n.start,n.end,n.pitch.number())
@staticmethod
def identified(n):
return n if n.event_id else dataclasses.replace(n,event_id=f'event:{n.start}:{n.end}:{n.pitch.label()}')
def evaluate(self,notes,*,until=None,complete=False,expressions=(),infer=True):
from generator.solo.expressions import validate_frontier
notes=types.sounding_notes(notes)
notes=tuple(self.identified(n) for n in types.sounding_notes(notes))
expressions=tuple(dataclasses.replace(c,events=tuple(self.identified(n) for n in c.events),
target=self.identified(c.target),preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions)
frontier=until if until is not None else max((n.end for n in notes),default=F())
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,complete or until is None)
if error:return Result(False,0,error=error)
if infer and not pending and (complete or until is None) and any(not self.ordinary(n) and n.event_id not in permissions for n in notes):
from generator.solo.recognition import recognize
expressions,error=recognize(notes,self,expressions)
if error:return Result(False,0,error=error)
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,True)
if error:return Result(False,0,error=error)
exposed=sounded=F()
for n in notes:
covered=n.start;duration=n.end-n.start;sounded+=duration
@@ -93,14 +116,15 @@ class Harmony:
pc=n.pitch.number()%12
if pc in s.roles.basic|s.roles.specified or permission:continue
if pc not in s.pitches:return Result(False,0,error=f'{n.event_id}: unsupported chromatic event')
if duration>=2 and n.role!='quote':return Result(False,0,error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment')
if duration>=2:return Result(False,0,error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment')
exposed+=(b-a)/4
if covered!=n.end:return Result(False,0,error=f'{n.event_id}: incomplete harmonic coverage')
return Result(True,float(exposed/max(F(1,1000),sounded)),pending)
return Result(True,float(exposed/max(F(1,1000),sounded)),pending,contracts=tuple(expressions))
def describe(self,notes,expressions=()):
from generator.solo.expressions import validate
permissions,error=validate(notes,expressions,self)
if error:raise ValueError(error)
notes=tuple(self.identified(n) for n in types.sounding_notes(notes))
checked=self.evaluate(notes,expressions=expressions,complete=True)
if not checked.valid:raise ValueError(checked.error)
permissions={n.event_id:c for c in checked.contracts for n in c.events}
return [{'start':str(a),'end':str(b),'attack':str(n.start),'sounding_duration':str(n.end-n.start),
'pitch':n.pitch.label(),'chord':s.chord,'function':self.function(n.pitch.number(),s),
'expression':permissions[n.event_id].kind if n.event_id in permissions else '',
+141
View File
@@ -0,0 +1,141 @@
"""Recognize bounded harmonic relationships in actual sounded music.
No filenames, annotations, learned examples or role exemptions enter here.
Existing expression validators prove each candidate; deterministic set-cover
search chooses a mutually consistent explanation, never edits the notes.
"""
from fractions import Fraction as F
from generator import vocabulary
from generator.solo import expressions, types
def signature(c):
return (c.kind,c.rule,tuple(n.event_id for n in c.events),c.target.event_id,
str(c.boundary),c.preparation.event_id if c.preparation else '')
def merge(*groups):
result={}
for group in groups:
for c in group:result.setdefault(signature(c),c)
return tuple(result.values())
def recognize(notes,harmony,provided=(),*,budget=4096):
required={n.event_id for n in notes if not harmony.ordinary(n)}
fixed={n.event_id for c in provided for n in c.events}
if required<=fixed:return tuple(provided),''
candidates={};single={};by_id={n.event_id:n for n in notes}
if len(by_id)!=len(notes):return (), 'Harmonic analysis requires unique sounding-event IDs'
def contract(kind,rule,events,target,boundary=None,preparation=None):
span=harmony.at(boundary if boundary is not None else target.start)
if not span or span.chord=='N.C.':return None
identifier=f'inferred:{kind}:{rule}:{events[0].event_id}:{target.event_id}:{boundary}'
return types.ExpressionContract(identifier,kind,rule,tuple(events),target,span.chord,boundary,preparation)
def add(c,extras=()):
if c is None:return False
bundle=merge(extras,(c,));covered=frozenset(n.event_id for a in bundle for n in a.events)
if not (covered & required):return False
combined=merge(provided,bundle)
_,error=expressions.validate(notes,combined,harmony)
if error:return False
candidates[tuple(signature(a) for a in bundle)]=(bundle,covered)
return True
# First discover independently supported colors and harmonic anticipations.
# These can also support a later approach's destination or passing endpoint.
for i,n in enumerate(notes):
if n.event_id not in required:continue
future=[t for t in notes[i+1:] if n.end<=t.start<=n.end+F(vocabulary.data('harmony')['resolution_gap'])
and 0<abs(t.pitch.number()-n.pitch.number())<=2]
regions=[(None,harmony.at(n.start))]
regions.extend((s.start,s) for s in harmony.spans if n.start<s.start<n.end
and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']))
for boundary,span in regions:
if not span or span.chord=='N.C.':continue
if boundary is not None:
c=contract('anticipation','early_chord_tone',(n,),n,boundary)
if add(c):single.setdefault(n.event_id,[]).append((c,))
for rule in vocabulary.data('harmony')['colors']:
if not expressions.eligible_color(rule,span,harmony) and boundary is not None:continue
kind='anticipated_color' if boundary is not None else 'color'
for target in future if rule.get('resolve') else (n,):
c=contract(kind,rule['id'],(n,),target,boundary)
# Non-anticipated color permission is scoped to the note's initial
# harmony, even when its resolution occurs after a chord boundary.
if boundary is None and c:c=types.ExpressionContract(c.id,c.kind,c.rule,c.events,c.target,span.chord)
if add(c):single.setdefault(n.event_id,[]).append((c,))
if i+1<len(notes):
target=notes[i+1]
for s in harmony.spans:
if n.start<s.start<n.end:
for rule in vocabulary.data('harmony').get('suspensions',[]):
add(contract('suspension',rule['id'],(n,),target,s.start))
elif s.start==n.end==target.start and n.pitch.number()==target.pitch.number():
add(contract('anticipation','early_reattack',(n,),target,s.start))
for i,target in enumerate(notes):
targets=[(None,())]
for support in single.get(target.event_id,()):
c=support[0]
# An approach into an anticipated color needs both permissions; that
# combination is not silently interpreted as a chord-tone anticipation.
if c.kind=='color':targets.append((None,support))
elif c.kind=='anticipation':targets.append((c.boundary,support))
for rule in vocabulary.data('lead_ins')['patterns']:
size=len(rule['semitones'])
if i<size:continue
ns=notes[i-size:i]
if not any(n.event_id in required for n in ns):continue
if tuple(n.pitch.number()-target.pitch.number() for n in ns)!=tuple(rule['semitones']):continue
preparation=notes[i-size-1] if i>size and rule['kind']=='connection' else None
for boundary,extras in targets:
add(contract(rule['kind'],rule['id'],ns,target,boundary,preparation),extras)
# Delayed approaches may include real space or intervening valid music.
# They still have an exact semitone target and a finite deadline.
rule=vocabulary.data('lead_ins')['delayed_approach']
for n in reversed(notes[:i]):
if target.start-n.start>F(rule['deadline']):break
if n.event_id in required and target.pitch.number()-n.pitch.number() in rule['steps']:
add(contract('delayed_approach',rule['id'],(n,),target))
if i>=2:
before,n=notes[i-2:i]
if n.event_id in required:
for left in [(),*single.get(before.event_id,())]:
for right in [(),*single.get(target.event_id,())]:
add(contract('passing',vocabulary.data('lead_ins')['passing']['id'],(n,),target,preparation=before),merge(left,right))
for rule in vocabulary.data('lead_ins')['blues']:
add(contract('blues',rule['id'],(n,),target,preparation=before))
options={identifier:[] for identifier in sorted(required-fixed,key=lambda identifier:(by_id[identifier].start,identifier))}
for bundle,covered in candidates.values():
for identifier in covered & options.keys():options[identifier].append((bundle,covered))
for identifier,choices in options.items():
if not choices:
n=by_id[identifier]
return (),f'{identifier} at {n.start}: unsupported harmonic relationship for {n.pitch.label()}'
choices.sort(key=lambda x:(sum((n.end-n.start for c in x[0] for n in c.events),F()),
sum((max(F(),c.target.start-c.events[-1].end) for c in x[0]),F()),tuple(signature(c) for c in x[0])))
visits=0;failed=set()
def solve(chosen,covered):
nonlocal visits
remaining=required-covered
if not remaining:return chosen
key=tuple(sorted(signature(c) for c in chosen))
if key in failed or visits>=budget:return None
visits+=1
identifier=min(remaining,key=lambda x:(len(options[x]),by_id[x].start,x))
for bundle,added in options[identifier]:
combined=merge(chosen,bundle)
_,error=expressions.validate(notes,combined,harmony)
if error:continue
result=solve(combined,covered|added)
if result is not None:return result
failed.add(key)
return None
result=solve(tuple(provided),fixed)
if result is None:return (), 'Harmonic treatment search budget exhausted' if visits>=budget else 'No mutually consistent harmonic treatment coverage'
return result,''
+1 -2
View File
@@ -14,7 +14,6 @@ def structure(solo):
harmony=harmonic.Harmony(solo.harmony)
contracts=tuple(c for p in solo.phrases for c in p.expressions)
checked=harmony.evaluate(solo.notes(),until=solo.end,complete=True,expressions=contracts)
authored=solo.diagnostics.get('construction')=='authored_repertoire'
phrases=[]
for p in solo.phrases:
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
@@ -29,7 +28,7 @@ def structure(solo):
'phrasing':{'attacks':len(ns),'allocation_beats':str(duration),
'attacks_per_four_beats':4*len(ns)/float(duration) if duration else 0,
'occupancy':sum(float(n.end-n.start) for n in ns)/float(duration) if duration else 0},
'harmonic_roles':([] if authored else harmony.describe(ns,expressions=contracts)),'harmonic_policy':'authored (generated policy diagnostic only)' if authored else 'generated', 'harmonic_valid':checked.valid,'harmonic_error':checked.error,
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_policy':'contextual (shared by authored and predicted music)', 'harmonic_valid':checked.valid,'harmonic_error':checked.error,
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
def describe(solo,measure_offset=0,time_offset=0,*,detail=None):
+6 -1
View File
@@ -1,6 +1,7 @@
"""Explicit, deterministic supervised training; never called by an asset build."""
from __future__ import annotations
import argparse
import collections
import dataclasses
import hashlib
import json
@@ -49,6 +50,8 @@ def load_examples(manifest):
if evidence['discrepancies']:raise ValueError(entry['id']+': '+str(evidence['discrepancies']))
ctx=Context(document)
training_io.validate_harmony(document,evidence,entry['id'])
from generator.solo.authored import validate_evidence
checked=validate_evidence(document,evidence)
events=[];cursor=F()
from generator import fretboard
for n in evidence['sounding_events']:
@@ -61,7 +64,8 @@ def load_examples(manifest):
events.append((start,end,pitch));cursor=end
if cursor<a.boundaries[-1]:events.append((cursor,a.boundaries[-1],None))
source_hash=hashlib.sha256(source.read_bytes()).hexdigest()
examples.append({'id':entry['id'],'song':source_hash,'role':role,'analysis':a,'harmony':ctx,'events':tuple(events)})
examples.append({'id':entry['id'],'song':source_hash,'role':role,'analysis':a,'harmony':ctx,'events':tuple(events),
'harmonic_validation':{'valid':checked.valid,'treatments':dict(sorted(collections.Counter(c.rule for c in checked.contracts).items()))}})
provenance.append({'id':entry['id'],'source_sha256':source_hash,'solo_sha256':evidence['sha256'],
'pitch_convention':entry['pitch_convention'],'measures':len(spans),'pickup_beats':str(a.body_offset),
'slot':entry['slot'],'attacks':len(evidence['sounding_events']),'annotations':len(evidence['annotations']),
@@ -136,6 +140,7 @@ def main():
value=learned.fit(examples,provenance);predictor=learned.Model(value)
report={'version':learned.VERSION,'training':provenance,'model_sha256':vocabulary.fingerprint(value),
'contexts':len(value['contexts']),'in_sample_event_nll':{e['id']:likelihood(predictor,e) for e in examples},
'harmonic_validation':{e['id']:e['harmonic_validation'] for e in examples},
'limitations':'A small corpus does not establish generalization. Likelihood is teacher-forced, not free generation or musical preference.'}
if args.leave_one_song_out:
report['held_out_event_nll']={}
+1 -4
View File
@@ -71,9 +71,6 @@ def read(path, role, form_start=None, pitch_convention='encoded'):
# Do not run the strict score importer over these references: annotations,
# unusual timing and high encoded pitches are evidence, not repair requests.
from generator import harmony, model, fretboard
def semantic(h):
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']]}
for event in result['notes']:
if not event['pitch']:continue
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter'])
@@ -89,7 +86,7 @@ def read(path, role, form_start=None, pitch_convention='encoded'):
info={'start':str(a),'end':str(b),'chord':current,'chord_degrees':[]}
if current and current['root']:
try:
chord=semantic(current);root=model.NATURAL[chord['root']['letter']]+F(current['alter'])
chord=harmony.parse_symbol(training_chord(current));root=model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter'])
info['chord_degrees']=[d for d,alter in harmony.degrees(chord).items() if (root+harmony.degree_semitones(d,alter)-number)%12==0]
info['root_relative_semitones']=str((number-root)%12)
info['spelled_simple_degree']=(model.LETTERS.index(p['step'].lower())-model.LETTERS.index(chord['root']['letter']))%7+1
+1 -1
View File
@@ -258,7 +258,7 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
rows.append('<tr><td>'+key.replace('_',' ')+'</td><td>'+str(r['relationships'][key])+'</td><td>'+str(r.get('baseline_relationships',{}).get(key,'—'))+'</td></tr>')
rows.append('</table><p>Lower edit distance means fewer insertions, deletions or substitutions on that axis. Sounding agreement is measured in quarter-note beats, without octave shifting or fitted alignment. These do not measure musical quality.</p>')
selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[]))
rows.append('<p>Selected production treatment contracts: '+html.escape(str(dict(selected)))+'. Registered authored music is preserved without inventing replacement contracts.</p>')
rows.append('<p>Validated production treatments: '+html.escape(str(dict(selected)))+'. Registered authored music is preserved; harmonic relationships are recognized from notes and chords without requiring explanatory annotations.</p>')
if r.get('reference_events_after_production_end'):
rows.append('<p>Reference material extends beyond this exported production allocation: '+str(len(r['reference_events_after_production_end']))+' events. The reference and source timing remain unchanged.</p>')
funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{})
+163
View File
@@ -0,0 +1,163 @@
"""Contextual musical validity needs notes and chords, not author annotations."""
import dataclasses
import json
from fractions import Fraction as F
from pathlib import Path
import unittest
from generator import train,training_io,vocabulary,harmonic_time,harmony as chords
from generator.solo import harmonic,types,decoder,learned,analysis,expressions
from tests.solo_fixtures import choices,note,song
ROOT=Path(__file__).resolve().parents[1]
class ContextualHarmony(unittest.TestCase):
def test_approved_corpus_passes_without_annotations_or_authored_privileges(self):
examples,_=train.load_examples(ROOT/'data/training/manifest.json')
manifest=json.loads((ROOT/'data/training/manifest.json').read_text())
from generator import musicxml,parts
from generator.solo.authored import sounding
for entry in manifest['examples']:
raw=training_io.read(ROOT/'data/training'/entry['solo'],'',pitch_convention=entry['pitch_convention'])
self.assertEqual(raw['annotations'],[])
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
h=harmonic.Harmony(harmonic_time.solo_source(doc['staves'][0]))
ns=tuple(dataclasses.replace(n,role='ordinary',sources=(),event_id='') for n in sounding(raw))
checked=h.evaluate(ns,complete=True)
self.assertTrue(checked.valid,(entry['id'],checked.error))
self.assertTrue(checked.contracts)
self.assertEqual(len(examples),4)
def test_passing_to_nonchord_scale_note(self):
h=harmonic.Harmony(choices(('G6',)))
notes=(note(0,F(1,2),'g'),note(F(1,2),1,'g',alter=1),note(1,2,'a'))
result=h.evaluate(notes)
self.assertTrue(result.valid,result.error)
self.assertEqual(result.contracts[0].kind,'passing')
self.assertFalse(h.evaluate(notes,infer=False).valid)
self.assertFalse(h.evaluate(notes[:-1]).valid)
leap=(*notes[:2],dataclasses.replace(notes[2],pitch=types.Pitch('b',F(),4)))
self.assertFalse(h.evaluate(leap).valid)
long=(notes[0],dataclasses.replace(notes[1],end=F(3,2)),dataclasses.replace(notes[2],start=F(3,2)))
self.assertFalse(h.evaluate(long).valid)
def test_passing_contract_requires_its_actual_preparation(self):
h=harmonic.Harmony(choices(('G6',)))
a=note(0,F(1,2),'g',event_id='a');b=note(F(1,2),1,'g',alter=1,event_id='b');c=note(1,2,'a',event_id='c')
contract=types.ExpressionContract('p','passing','chromatic_passing',(b,),c,'GM6',preparation=a)
self.assertTrue(h.evaluate((a,b,c),expressions=(contract,)).valid)
changed=dataclasses.replace(a,end=F(1,4))
wrong=dataclasses.replace(contract,preparation=changed)
self.assertFalse(h.evaluate((changed,b,c),expressions=(wrong,)).valid)
high=dataclasses.replace(c,pitch=dataclasses.replace(c.pitch,octave=5))
self.assertFalse(h.evaluate((a,b,high),expressions=(dataclasses.replace(contract,target=high),)).valid)
def test_color_resolution_can_have_intervening_valid_motion(self):
h=harmonic.Harmony(choices(('C7',)))
notes=(note(0,F(3,2),'f',alter=1),note(F(3,2),2,'a'),note(2,4,'g'))
checked=h.evaluate(notes)
self.assertTrue(checked.valid,checked.error)
self.assertEqual(checked.contracts[0].rule,'lydian_color')
self.assertFalse(h.evaluate(notes[:2]).valid)
bad_mid=dataclasses.replace(notes[1],pitch=types.Pitch('g',F(1),5))
self.assertFalse(h.evaluate((notes[0],bad_mid,notes[2])).valid)
def test_anticipated_stable_and_resolving_colors(self):
h=harmonic.Harmony(choices(('Bbdim7','Am7')))
n=note(F(7,2),6,'d')
result=h.evaluate((n,))
self.assertTrue(result.valid,result.error)
self.assertEqual(result.contracts[0].kind,'anticipated_color')
self.assertFalse(h.evaluate((dataclasses.replace(n,start=F(2)),)).valid)
h=harmonic.Harmony(choices(('Gm7','C7')))
color=note(F(7,2),F(11,2),'f',alter=1)
target=note(F(11,2),7,'g')
self.assertTrue(h.evaluate((color,target)).valid)
self.assertFalse(h.evaluate((color,)).valid)
flat=dataclasses.replace(color,pitch=types.Pitch('g',F(-1),4))
self.assertTrue(h.evaluate((flat,target)).valid)
def test_repeated_harmony_cannot_reset_color_duration(self):
h=harmonic.Harmony(choices(('C7','C7')))
color=note(3,6,'f',alter=1);target=note(6,7,'g')
self.assertFalse(h.evaluate((color,target)).valid)
def test_held_color_is_independent_of_notation_partition(self):
h=harmonic.Harmony(choices(('Bbdim7','Am7')))
whole=note(F(7,2),6,'d')
split=(dataclasses.replace(whole,end=F(4)),dataclasses.replace(whole,start=F(4),continuation=True))
self.assertEqual(h.evaluate((whole,)),h.evaluate(split))
self.assertEqual(h.describe((whole,)),h.describe(split))
def test_unsupported_notes_are_not_authorized_by_labels(self):
h=harmonic.Harmony(choices(('C7',)))
for role in ('ordinary','authored','quote','color','Lead in'):
self.assertFalse(h.evaluate((note(0,3,'c',alter=1,role=role),)).valid)
missing=harmonic.Harmony(())
self.assertFalse(missing.evaluate((note(0,1,'c'),)).valid)
def test_fully_expired_partial_treatments_remain_invalid(self):
h=harmonic.Harmony(choices(('C7',)))
notes=(note(0,F(1,2),'d',alter=1,event_id='a'),note(F(1,2),2,'e',event_id='b'))
contract=types.ExpressionContract('a','approach','below',(notes[0],),notes[1],'C7')
partial=h.evaluate(notes[:1],until=F(1,2),expressions=(contract,))
self.assertTrue(partial.valid)
self.assertEqual(len(partial.pending),1)
self.assertFalse(h.evaluate(notes[:1],until=F(2),expressions=(contract,)).valid)
def test_pickup_uses_source_form_context_without_overriding_written_harmony(self):
doc=song(count=3,chords=('C','Dm','G7'));staff=doc['staves'][0]
pickup=staff['measures'][0];pickup['implicit']=True;pickup['harmonies']=[]
self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'G7')
self.assertIsNone(harmonic_time.read(staff)[0].chord)
pickup['harmonies']=[dict(chords.parse_symbol('F'),onset={'numerator':0,'denominator':1})]
self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'FM')
pickup['harmonies']=[{'kind':'none','onset':{'numerator':0,'denominator':1}}]
self.assertIsNone(harmonic_time.solo_source(staff)[0].chord)
pickup['harmonies']=[];pickup['implicit']=False
self.assertIsNone(harmonic_time.solo_source(staff)[0].chord)
class PredictionReachability(unittest.TestCase):
def engine(self,chords):
doc=song(count=2,chords=chords)
return decoder.Decoder(analysis.analyze(doc),harmonic.Harmony(choices(chords)),
learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8))
def test_joint_resolved_color_reaches_the_real_action_builder(self):
d=self.engine(('C7','FM7'))
n=d.note(F(),F(3,2),66)
actions=list(d.resolutions(n))
self.assertTrue(any([p for _,_,p in events]==[66,69,67] for events,_,_ in actions))
for _,notes,contracts in actions:
self.assertTrue(d.harmony.evaluate(notes,expressions=contracts,infer=False).valid)
def test_anticipated_color_is_a_normal_single_event_option(self):
d=self.engine(('Bbdim7','Am7'));n=d.note(F(7,2),F(6),62)
support=d.permission(n)
self.assertTrue(support)
self.assertEqual(support[0].kind,'anticipated_color')
d.expressions_enabled=False;d._permissions.clear()
self.assertIsNone(d.permission(n))
def test_delayed_action_retains_space_and_intervening_motion(self):
d=self.engine(('C7','FM7'));n=d.note(F(),F(1,2),63)
actions=list(d.resolutions(n))
self.assertTrue(any(c[0].kind=='delayed_approach' and any(p is None for _,_,p in events) for events,_,c in actions))
self.assertTrue(any(c[0].kind=='delayed_approach' and len(notes)==3 for _,notes,c in actions))
def test_passing_action_uses_incoming_state(self):
d=self.engine(('G6','G6'));first=d.note(F(),F(1,2),67)
state=d.extend(decoder.State(),(((first.start,first.end,67),),(first,),()))
actions=[a for a in d.actions(F(1,2)) if a[2] and a[2][0].kind=='passing'
and a[0]==((F(1,2),F(1),68),(F(1),F(2),69))]
self.assertTrue(actions)
self.assertIsNotNone(d.extend(state,actions[0]))
self.assertIsNone(d.extend(decoder.State(),actions[0]))
wrong=d.note(F(),F(1,2),64)
prior=d.extend(decoder.State(),(((wrong.start,wrong.end,64),),(wrong,),()))
self.assertIsNone(d.extend(prior,actions[0]))
if __name__=='__main__':unittest.main()
+7 -4
View File
@@ -128,7 +128,8 @@ class TypedExpressionContracts(unittest.TestCase):
def test_complete_enclosure_and_orphans(self):
notes,c=self.make()
self.assertTrue(self.h.evaluate(notes,expressions=(c,)).valid)
self.assertFalse(self.h.evaluate(notes).valid)
self.assertTrue(self.h.evaluate(notes).valid)
self.assertFalse(self.h.evaluate(notes,infer=False).valid)
self.assertFalse(self.h.evaluate(notes[:-1],expressions=(c,)).valid)
changed=(*notes[:-1],dataclasses.replace(notes[-1],pitch=dataclasses.replace(notes[-1].pitch,octave=5)))
self.assertFalse(self.h.evaluate(changed,expressions=(c,)).valid)
@@ -137,19 +138,21 @@ class TypedExpressionContracts(unittest.TestCase):
n=note(F(7,2),6,'a',event_id='early')
c=types.ExpressionContract('anticipation','anticipation','early_chord_tone',(n,),n,'FM7',boundary=F(4))
self.assertTrue(self.h.evaluate((n,),expressions=(c,)).valid)
self.assertFalse(self.h.evaluate((n,)).valid)
self.assertTrue(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,),infer=False).valid)
self.assertFalse(self.h.evaluate((n,),expressions=(dataclasses.replace(c,boundary=F(3)),)).valid)
def test_optional_color_is_not_a_written_chord_requirement(self):
n=note(0,2,'d',event_id='ninth')
c=types.ExpressionContract('color','color','dominant_ninth',(n,),n,'C7')
self.assertTrue(self.h.evaluate((n,),expressions=(c,)).valid)
self.assertFalse(self.h.evaluate((n,)).valid)
self.assertTrue(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,),infer=False).valid)
self.assertNotIn(n.pitch.number()%12,self.h.spans[0].roles.basic)
self.assertNotIn(n.pitch.number()%12,self.h.spans[0].roles.specified)
def test_labels_do_not_authorize_unresolved_notes(self):
n=dataclasses.replace(note(0,2,'c',event_id='bad'),pitch=types.Pitch('c',F(1),4),role='color')
n=dataclasses.replace(note(0,2,'f',event_id='bad'),pitch=types.Pitch('f',F(1),4),role='color')
self.assertFalse(self.h.evaluate((n,)).valid)
c=types.ExpressionContract('bad','color','anything',(n,),n,'C7')
self.assertFalse(self.h.evaluate((n,),expressions=(c,)).valid)
+2 -1
View File
@@ -66,7 +66,8 @@ class PreparedSuspensions(unittest.TestCase):
target = note(F(9, 2), 6, 'e', event_id='resolution')
contract = types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=F(4))
self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid)
self.assertFalse(h.evaluate((held, target)).valid)
self.assertTrue(h.evaluate((held, target)).valid)
self.assertFalse(h.evaluate((held, target),infer=False).valid)
changed = dataclasses.replace(held, start=F(15, 4))
self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid)
self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)