@@ -100,15 +100,19 @@ Uncompressed MusicXML 3.1/4.0 source scores exported from Soundslice or MuseScor
|
||||
|
||||
Six roles generate independently: acoustic accompaniment/solo; electric accompaniment/melody/solo; vocal melody. A process pool and per-role caches support parallel and incremental builds. Each role uses the source and its own inputs without reading another generated role. Canonical accompaniment repeats at corresponding source-form positions and does not react to generated solos. Vocal melody preserves the authored notes and lyrics. Electric melody preserves source pitches and harmonizes eligible sustained notes, reattacking them at chord changes.
|
||||
|
||||
Electric accompaniment uses the electric melody's three/four-note voicing families on strings 1–4. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Its bass can be the root or written fifth, or any triad member for plain major/minor chords; an explicit slash bass or inversion takes priority. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement.
|
||||
|
||||
Accompaniment rhythm is light at every tempo: normally 0–2 electric chord attacks, 1–2 acoustic bass attacks, and 1–3 acoustic upper-chord attacks per full measure. Bass attacks beat one wherever harmony is available. Stabs, sustains and space follow the original melody and harmonic rhythm. Separate acoustic voices allow bass and upper notes to overlap; their combined attacks are evaluated for crowding. An extra attack is permitted only for harmonic changes and reported. Compatible holds can tie across barlines. The selected accompaniment repeats at corresponding form positions, with source-defined pickup/handoff exceptions, and does not change when only tempo changes.
|
||||
|
||||
**Registered Solo 1 is notated exactly in the acoustic part and Solo 2 exactly in the electric part.** Unregistered song/slot pairs use deterministic prediction from one pooled statistical model trained on both slots. There is no learned acoustic/electric style distinction. Prediction uses up to four recent pitch/rhythm events, source-melody features, harmony, and metrical position; it does not plan complete themes or guarantee long-range phrase development. The current corpus is four authored solos from Autumn Leaves and All of Me.
|
||||
|
||||
Training, authored score reuse, and prediction share contextual harmony validation based on actual notes, timing, and chords. Explanatory labels and justification tags are not required. Unsupported relationships fail with a diagnostic rather than silently changing notes. Both solo roles use strings 1–6 and frets 1–12 without open strings. No dynamics, accents, velocity shaping, or random timing are added.
|
||||
|
||||
`--export-solo-xml` (CMake `VAMP_EXPORT_SOLO_XML=ON`) writes `solo_xml/*-solo1.xml` for acoustic and `*-solo2.xml` for electric under the generated asset directory, with actual notes and TAB. These exports do not recompose music. `./modulus.py python -m generator.compose --song ... --role acoustic_solo --data-dir data --output ... --solo-xml ...` builds one independent role. To exercise prediction for a registered song, pass `--settings` with a JSON file containing `{"predict": true}`.
|
||||
|
||||
Optional comparison runs through `./modulus.py python -m tests.solo_compare`. Point `--generated-dir` at the exported `solo_xml/` directory and supply explicit `--reference SONG/ROLE=PATH`, `--form-start SONG/ROLE=MEASURE`, `--reference-pitch guitar-written`, and `--output build/solo_comparison/review`. Keys match `manifest.solo`, such as `all_of_me/acoustic`. Add `--require-exact` to fail on sounding-note or rest differences, or `--baseline-dir` to compare an earlier MusicXML export. Comparison results never feed production; registered authored playback and explicit training use the paired solos separately. The supplied solos sound one octave below encoded pitches; All of Me aligns at measure 1 and Autumn Leaves at measure 2, preserving the lead-in. Matching notes or technical validity do not establish musical preference.
|
||||
Optional comparison runs through `./modulus.py python -m tests.solo_compare`. Point `--generated-dir` at the exported `solo_xml/` directory and supply explicit `--reference SONG/ROLE=PATH`, `--form-start SONG/ROLE=MEASURE`, `--reference-pitch guitar-written`, and `--output /tmp/product-vamp-solo-review`. Keys match `manifest.solo`, such as `all_of_me/acoustic`. Add `--require-exact` to fail on sounding-note or rest differences, or `--baseline-dir` to compare an earlier MusicXML export. Comparison results never feed production; registered authored playback and explicit training use the paired solos separately. The supplied solos sound one octave below encoded pitches; All of Me aligns at measure 1 and Autumn Leaves at measure 2, preserving the lead-in. Matching notes or technical validity do not establish musical preference.
|
||||
|
||||
See [Shared Part Generation](TECHNICAL_GUIDE.md#shared-part-generation) and [Authored Repertoire and Learned Solos](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) for the active pipeline and limitations. Keep just one compact baseline and the latest comparison under `build/solo_comparison/`.
|
||||
See [Shared Part Generation](TECHNICAL_GUIDE.md#shared-part-generation) and [Authored Repertoire and Learned Solos](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) for the active pipeline and limitations. Keep temporary comparisons and one-off diagnostics under `/tmp/`; `build/` retains product outputs, tools and dependencies.
|
||||
|
||||
To inspect generated assets without building the full package, run:
|
||||
|
||||
@@ -177,4 +181,3 @@ To inspect the fixed artwork independently, run `./modulus.py python -m generato
|
||||
## License
|
||||
|
||||
Product Vamp is released under the MIT License. Bundled third-party fonts keep their own licenses next to the font files.
|
||||
|
||||
|
||||
+20
-4
@@ -123,7 +123,17 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
|
||||
|
||||
`parts.py` retains exact source notes, rests, lyrics and ties. `melody.py` harmonizes eligible sustained electric source-melody notes using their actual chord, preserving source pitches while splitting and reattacking held notes at harmony changes. Guitar notation applies its octave transposition once. `comping.Canonical` independently chooses compact chord voicings and rhythm from the source form and shared policy, then reuses that part at the same source-form position throughout the arrangement. There is no generated-solo response or reuse of a generated melody's grips. Sparse bars and deliberate releases are legal.
|
||||
|
||||
Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20 with a sounding anchor at fret 12 or below and the existing inclusive three-fret span. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
|
||||
Electric accompaniment shares the electric melody's `guitar.chord_positions(..., profile='electric')` families: strings 1–3 or 1–4, with a preference for four-note grips. It uses the same written chord requirements without taking responsibility for the slash bass. Acoustic accompaniment uses `acoustic_voicing.candidates`: exactly one bass on string 5/6 and one upper note each on strings 4/3/2. Without an explicit slash bass or inversion, its bass is a root or written fifth (including an altered fifth); plain major/minor triads also permit the third. Plain triads retain all three chord members. Explicit bass/inversion requirements override this choice. Unsupported grips fail with the source measure, beat and chord rather than weakening the layout or harmonic requirements.
|
||||
|
||||
The active voicing selector compares complete paths with dynamic programming over concrete grips. Electric costs prioritize the shared melody family's string count, then grip effort plus hand-center travel, missing optional tones and register preference. Acoustic costs prioritize travel of the bass and all three upper voices, then optional-tone coverage and register preference. Stable role-specific keys resolve equal costs. Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected string/fret positions, bass degree and movement.
|
||||
|
||||
`comping_rhythm.py` constructs whole-measure rhythms from version 2 of `composition/accompaniment.json`. Normal full-measure ranges are 0–2 electric chord attacks, 1–2 acoustic bass attacks and 1–3 acoustic upper-chord attacks. Bass starts on beat one whenever harmony is available there. An absent harmony or N.C. remains silent. Candidates mix half-beat stabs, quarters, longer holds and alternating durations. Exact chord offsets constrain sounding intervals; short partial bars have bounded gestures. Rapid harmonic changes can authorize one extra attack per layer, recorded as an exception, without assigning a fresh density budget to every chord.
|
||||
|
||||
The original head supplies entrances, moving-note intervals and space; the actual progression supplies harmonic support requirements and release boundaries. Candidates retain distinct attack counts and articulation families before the cutoff. Acoustic bass/upper candidates are paired and scored for combined crowding, counting coincident attacks as one action. A bounded dynamic program selects the form's complete rhythm path using source-context costs and preferences against immediate whole-bar repetition. This chooses from retained candidates, not every possible rhythm. Compatible sustained neighbors with identical harmony and grip can then join across a barline without reattack; the upper layer retains at least one new attack per bar and bass always reattacks. Changing only tempo leaves these musical decisions unchanged. Tempo remains in the concrete asset cache key so score metadata refreshes correctly.
|
||||
|
||||
Acoustic bass and upper events retain independent voice lanes through assembly and notation, so either layer may sustain under another attack. Validation also checks the combined sounding grip across those voices. The product build audits canonical accompaniment reuse across arrangement iterations, allowing only source-derived changes in harmony, pickups and role handoffs. The `.roles` sidecars report per-measure families, attack counts, overlaps, ties and exceptions. Their `selection_cost` records local rhythm-candidate contributions before cross-bar joining; the form selector also applies the documented transition cost. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances.
|
||||
|
||||
Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20 with a sounding anchor at fret 12 or below and the existing inclusive three-fret span. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
|
||||
|
||||
### Recording Playback and Page Timing
|
||||
|
||||
@@ -189,13 +199,19 @@ Migration inventory:
|
||||
| `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. |
|
||||
| `data/composition/accompaniment.json` | Version 2: independently authored duration families, per-layer ranges and bounded retrieval settings, consumed by `comping_rhythm.py`. Replaces the old per-chord figure list; no reference-derived material. |
|
||||
| `generator/harmonic_time.py` | Exact chord/key spans retained from the removed scale subsystem. |
|
||||
| Solo event, expression, placement, notation and export helpers | Retained physical/timing safeguards shared by authored and predicted music. |
|
||||
|
||||
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`.
|
||||
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. Temporary comparisons and diagnostics belong under `/tmp/`; old comparison pages, diagnostic reports, formatter-debug scripts and the copied verification bundle have been removed from `build/`. Product builds, caches and toolchains remain. The installed `build/sonus_arm` dependency remains because the configured ARM build uses it; its unused separate compilation directory has been removed.
|
||||
|
||||
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`.
|
||||
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; the disposable diagnostic reports have since been removed.
|
||||
|
||||
Voicing-only verification on October 6, 2026, before the rhythm update: the suite passed 363 tests in 120.66 seconds, including 11 new voicing tests. Exhaustive small cases verify that the movement selector beats greedy selection and includes repeat/neighbor boundaries. That change retained the prior accompaniment onset/duration/rest sequences; source melody, vocal and both solo role documents were unchanged. Its six-score production rebuild passed `--check` in 11.54 seconds with two composition and two engraving workers: 2.95 seconds generating parts and 8.19 seconds engraving, with two unchanged score cache hits. This checks timing, harmonic/physical constraints and rendering, not a claim of ergonomic comfort from listening.
|
||||
|
||||
Rhythm verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed all 375 tests in 83.57 seconds. Coverage includes attack ranges, bass downbeats, mixed durations, harmonic-change budgets, overlapping voices, tied joins, 3/4 and 5/4, melody-conditioned rhythm costs, tempo invariance, canonical reuse, and serial/parallel role identity. A synthetic learned-model test also confirms that changing original melody rhythm or upcoming harmony changes solo duration probabilities with a fixed seed. Both songs' solo, electric melody and vocal role documents remain exactly equal to the saved pre-rhythm baseline; no model retraining was performed.
|
||||
|
||||
The ordinary six-score rebuild with `--check --export-solo-xml --composition-jobs 2 --jobs 2` passed in 13.39 seconds: 4.44 seconds generating parts and 8.48 seconds engraving. All role composition results were newly generated; the warm engraving namespace reused two unchanged vocal scores. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role/score caches reused. Actual selected acoustic upper rhythms use one or two attacks in these songs; three-attack alternatives are available and tested but are not forced. All of Me electric has three zero-attack source/harmony contexts; Autumn Leaves electric uses one or two attacks. Neither song required a density exception. Reuse audits checked 103/62 acoustic/electric repeated bars for Autumn Leaves and 102/62 for All of Me, with only source-defined pickups and final-harmony exceptions. Sample acoustic notation/TAB pages were visually inspected. Supplied audio recordings remain unchanged; no ear-based audition of the new accompaniment was performed. Disposable logs, profiles and comparisons are under `/tmp/`, outside product assets.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,60 +1,72 @@
|
||||
{
|
||||
"version": 1,
|
||||
"provenance": "Original rhythm cells authored as general transformations, not extracted from audition scores.",
|
||||
"version": 2,
|
||||
"provenance": "Independently authored sparse guitar rhythm rules: metrical placement, support of source melody, releases and harmonic changes. No training-score extraction.",
|
||||
"license": "MIT",
|
||||
"comping": [
|
||||
{
|
||||
"id": "establish_release",
|
||||
"attacks": [
|
||||
[
|
||||
"0",
|
||||
"1/2"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "broad_shell",
|
||||
"attacks": [
|
||||
[
|
||||
"0",
|
||||
"3/2"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "delayed_shell",
|
||||
"attacks": [
|
||||
[
|
||||
"1/2",
|
||||
"1"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "answer",
|
||||
"attacks": [
|
||||
[
|
||||
"0",
|
||||
"grid": "1/2",
|
||||
"lane_candidates": 24,
|
||||
"measure_candidates": 32,
|
||||
"families": {
|
||||
"stabs": [
|
||||
"1/2"
|
||||
],
|
||||
[
|
||||
"3/2",
|
||||
"quarter_stabs": [
|
||||
"1"
|
||||
],
|
||||
"sustain": [
|
||||
"3"
|
||||
],
|
||||
"broad": [
|
||||
"4"
|
||||
],
|
||||
"short_long": [
|
||||
"1/2",
|
||||
"2"
|
||||
],
|
||||
"long_short": [
|
||||
"2",
|
||||
"1/2"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sustain",
|
||||
"layers": {
|
||||
"electric": {
|
||||
"attacks": [
|
||||
[
|
||||
"0",
|
||||
"3"
|
||||
]
|
||||
0,
|
||||
2
|
||||
],
|
||||
"families": [
|
||||
"stabs",
|
||||
"quarter_stabs",
|
||||
"sustain",
|
||||
"broad",
|
||||
"short_long",
|
||||
"long_short"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "space",
|
||||
"attacks": []
|
||||
}
|
||||
"bass": {
|
||||
"attacks": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"families": [
|
||||
"quarter_stabs",
|
||||
"sustain",
|
||||
"broad",
|
||||
"long_short"
|
||||
]
|
||||
},
|
||||
"upper": {
|
||||
"attacks": [
|
||||
1,
|
||||
3
|
||||
],
|
||||
"families": [
|
||||
"stabs",
|
||||
"quarter_stabs",
|
||||
"sustain",
|
||||
"broad",
|
||||
"short_long",
|
||||
"long_short"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ def audit(source, documents):
|
||||
if part == 'vocal':
|
||||
continue
|
||||
sections = generator.arrange.form.full_arrangement_sections(authored, part)
|
||||
entry_positions = {s.source_measure_index for s in sections if s.role == 'accompaniment'}
|
||||
measures = document['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
seen, exceptions, offset, reused = {}, [], 0, 0
|
||||
@@ -45,10 +46,14 @@ def audit(source, documents):
|
||||
if j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
|
||||
continue
|
||||
if j == section.measure_count - 1 and following and following.role == 'slash' and generator.score.body_start_measure_index(authored):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
|
||||
continue
|
||||
position = section.source_measure_index + j
|
||||
key = (section.source_measure_index + j, repr(harmonic_key(m, carried[offset + j])))
|
||||
signature = tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
tuple((generator.model.pitch_number(p), p['tablature']['string'], p['tablature']['fret']) for p in n['pitches']))
|
||||
for n in generator.score.measure_notes(m))
|
||||
signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p['tablature']['string'], p['tablature']['fret'], bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False) for p in n['pitches']))
|
||||
for v in m['voices'] for n in v['notes'] if n['pitches'])
|
||||
if key in seen:
|
||||
if signature != seen[key]:
|
||||
raise ValueError(f'{part}, arrangement measure {m["number"]}: canonical accompaniment changed')
|
||||
|
||||
+227
-81
@@ -1,11 +1,15 @@
|
||||
"""Source-only sparse accompaniment, frozen once at each form position."""
|
||||
"""Source-only accompaniment: joint layered rhythms and connected grips."""
|
||||
from __future__ import annotations
|
||||
import copy
|
||||
import dataclasses
|
||||
import functools
|
||||
import json
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.comping_rhythm
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.form
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
@@ -18,114 +22,256 @@ import generator.solo.policy
|
||||
def semantic_key(chord):
|
||||
return json.dumps({k: chord[k] for k in ('root', 'kind', 'degrees', 'bass', 'inversion') if k in chord}, sort_keys=True)
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def voicings(key, bass):
|
||||
chord = json.loads(key)
|
||||
def requirements(chord, part):
|
||||
result = generator.fretboard.chord_requirements(chord, respect_bass=part != 'electric')
|
||||
if part == 'electric' or result.basses:
|
||||
return result
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0})
|
||||
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
|
||||
classes = {d: (root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
|
||||
required = {p for d, p in classes.items() if d in (2, 3, 4, 6, 7) or d == 5 and degrees[d] or d > 7}
|
||||
full = generator.fretboard.chord_requirements(chord)
|
||||
required.update(full.basses)
|
||||
if bass and 1 in classes:
|
||||
required.add(classes[1])
|
||||
if len(required) < 2:
|
||||
required.update(classes[d] for d in (1, 5) if d in classes)
|
||||
allowed = set(full.allowed)
|
||||
requirements = generator.fretboard.ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(allowed)), full.basses)
|
||||
options = generator.guitar.chord_positions(requirements, prune=False)
|
||||
size = max(2, len(required))
|
||||
options = [v for v in options if len(v) == size and generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v)]
|
||||
basses = tuple(sorted({(root + generator.harmony.degree_semitones(d, degrees[d])) % 12 for d in ((1, 3, 5) if plain else (1, 5)) if d in degrees}))
|
||||
if not basses:
|
||||
raise ValueError('acoustic accompaniment requires a written bass, root or fifth')
|
||||
return dataclasses.replace(result, basses=basses)
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def voicings(key, part):
|
||||
chord = json.loads(key)
|
||||
required = requirements(chord, part)
|
||||
if part == 'electric':
|
||||
options = generator.guitar.chord_positions(required, prune=False, profile='electric')
|
||||
elif part == 'acoustic':
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0})
|
||||
options = tuple(v for v in generator.acoustic_voicing.candidates(required, plain)
|
||||
if v.bass.fret <= generator.fretboard.MAX_SINGLE_FRET
|
||||
and generator.fretboard.has_fret_anchor(v[1:])
|
||||
and (not plain or set(required.allowed) <= {p.midi % 12 for p in v}))
|
||||
else:
|
||||
raise ValueError('Unknown accompaniment instrument: ' + part)
|
||||
options = tuple(v for v in options if generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v))
|
||||
if not options:
|
||||
raise ValueError(generator.harmony.symbol(chord) + ': no legal sparse voicing preserving essential written degrees')
|
||||
# Retain distinct hand regions and inversions, independent of enumeration.
|
||||
best = {}
|
||||
for v in options:
|
||||
family = (min(p.fret for p in v), min(p.midi for p in v) % 12)
|
||||
rank = (generator.guitar.compactness(v), generator.solo.policy.stable_rank('comp-voicing', v))
|
||||
if family not in best or rank < best[family][0]:
|
||||
best[family] = rank, v
|
||||
return tuple(v for _, v in sorted(best.values()))
|
||||
layout = 'bass on 5/6 plus upper strings 2/3/4' if part == 'acoustic' else 'melody chord families on strings 1–4'
|
||||
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and three consecutive frets remain binding')
|
||||
return tuple(sorted(options))
|
||||
|
||||
def movement(part, previous, current):
|
||||
if not previous or not current:
|
||||
return 0
|
||||
if part == 'acoustic':
|
||||
return generator.acoustic_voicing.movement(previous, current)
|
||||
return generator.guitar.movement(previous, current)
|
||||
|
||||
def local_cost(part, key, grip, center):
|
||||
required = requirements(json.loads(key), part)
|
||||
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
|
||||
if part == 'electric':
|
||||
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
|
||||
return (value.string_shortfall, value.effort, value.missing_tones, register)
|
||||
# As in acoustic harmony-span placement: travel first, then completeness.
|
||||
return (0, 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
|
||||
|
||||
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
|
||||
"""Exact small-state dynamic programming; never prune by hand center.
|
||||
|
||||
Electric effort includes its melody grip cost and hand-center travel.
|
||||
Acoustic effort follows all four voices, including a bass-string change.
|
||||
Fixing the first grip permits an exact repeat-join cost without a beam.
|
||||
"""
|
||||
if not layers:
|
||||
return (), (0, 0, 0, 0)
|
||||
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)]
|
||||
winners = []
|
||||
for first in layers[0] if close else [None]:
|
||||
states = {left: ((0, 0, 0, 0), (), ())}
|
||||
for i, layer in enumerate(layers):
|
||||
following = {}
|
||||
for grip in [first] if i == 0 and first is not None else layer:
|
||||
local = costs[i][grip]
|
||||
choices = []
|
||||
for previous, (cost, ties, path) in states.items():
|
||||
total = tuple(a + b for a, b in zip(cost, local))
|
||||
total = (total[0], total[1] + movement(part, previous, grip), *total[2:])
|
||||
choices.append((total, (*ties, ranks[i][grip]), (*path, grip)))
|
||||
following[grip] = min(choices)
|
||||
states = following
|
||||
for grip, (cost, ties, path) in states.items():
|
||||
travel = movement(part, grip, first if close else right)
|
||||
winners.append(((cost[0], cost[1] + travel, *cost[2:]), ties, path))
|
||||
cost, _, path = min(winners)
|
||||
return path, cost
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HarmonyChoice:
|
||||
index: int
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
chord: dict
|
||||
events: tuple
|
||||
figure: dict
|
||||
options: tuple
|
||||
@property
|
||||
def key(self):
|
||||
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
|
||||
|
||||
class Canonical:
|
||||
def __init__(self, document, part, seed):
|
||||
self.part, self.seed = part, seed
|
||||
self.policy = generator.vocabulary.role(part + '_accompaniment')
|
||||
self.cache, self.previous, self.report = {}, (), []
|
||||
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
|
||||
measures = generator.score.source_staff(document)['measures']
|
||||
self.first_index = generator.score.body_start_measure_index(measures)
|
||||
self.last_index = len(measures) - 1
|
||||
self.melody = generator.comping_rhythm.melody_contexts(document)
|
||||
first_index = generator.score.body_start_measure_index(measures)
|
||||
sections = generator.arrange.form.full_arrangement_sections(measures, part)
|
||||
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures)
|
||||
and b.source_measure_index == first_index for a, b in zip(sections, sections[1:]))
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
for index in range(generator.score.body_start_measure_index(measures), len(measures)):
|
||||
inputs = []
|
||||
for index in range(first_index, len(measures)):
|
||||
source = copy.deepcopy(measures[index])
|
||||
for h in source['harmonies']:
|
||||
h.pop('chord_optional', None)
|
||||
self(source, carried[index], index)
|
||||
def __call__(self, source, prior, index):
|
||||
key = (index, tuple((generator.model.fraction(h['onset']), semantic_key(h)) for h in source['harmonies']), semantic_key(prior) if prior else '', source['duration'].__repr__())
|
||||
if key in self.cache:
|
||||
return copy.deepcopy(self.cache[key])
|
||||
length = generator.model.fraction(source['duration'])
|
||||
boundaries = sorted({fractions.Fraction(), length, *(generator.model.fraction(h['onset']) for h in source['harmonies'])})
|
||||
attacks = []
|
||||
source_attacks = {generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source) if n['pitches'] and not all('tie_from' in p for p in n['pitches'])}
|
||||
for start, end in zip(boundaries, boundaries[1:]):
|
||||
chord = generator.accompaniment.active_chord(source, start, prior)
|
||||
for chord in source['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
inputs.append((source, carried[index], index))
|
||||
layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs)
|
||||
rhythms = generator.comping_rhythm.select(layers, seed)
|
||||
self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)}
|
||||
prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)]
|
||||
active = tuple(s for _, _, _, spans in prepared for s in spans if s.events)
|
||||
path, self.path_cost = self.select(active, close=self.closed)
|
||||
self.base = dict(zip((s.key for s in active), path))
|
||||
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
|
||||
previous = path[-1] if self.closed and path else ()
|
||||
for source, prior, index, spans in prepared:
|
||||
selected = tuple(self.base[s.key] for s in spans if s.events)
|
||||
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False)
|
||||
if selected:
|
||||
previous = selected[-1]
|
||||
self.connect_holds(inputs)
|
||||
@property
|
||||
def report(self):
|
||||
return [row for key in sorted(self._reports, key=lambda k: (k[0], repr(k))) for row in self._reports[key]]
|
||||
@property
|
||||
def rhythm_report(self):
|
||||
return [self._rhythm_reports[key] for key in sorted(self._rhythm_reports, key=lambda k: (k[0], repr(k)))]
|
||||
@staticmethod
|
||||
def key(source, prior, index):
|
||||
return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)),
|
||||
source['duration'].__repr__())
|
||||
def prepare(self, source, prior, index, rhythm):
|
||||
spans = []
|
||||
for start, end, chord in generator.comping_rhythm.spans(source, prior):
|
||||
if not chord:
|
||||
continue
|
||||
duration = end - start
|
||||
# Essential bass is a source-derived form responsibility, independent
|
||||
# of which other guitar happens to be playing or what it generated.
|
||||
bass = self.policy.get('bass') == 'primary' or bool(chord.get('bass')) or index in (self.first_index, self.last_index) or start > 0
|
||||
options = voicings(semantic_key(chord), bass)
|
||||
center = self.policy.get('center', 55)
|
||||
selected = min(options, key=lambda v: (
|
||||
.08 * abs(sum(p.midi for p in v) / len(v) - center)
|
||||
+ (.15 * generator.guitar.movement(self.previous, v) if self.previous else 0)
|
||||
+ .1 * generator.guitar.compactness(v), generator.solo.policy.stable_rank(self.seed, (index, start, v))))
|
||||
self.previous = selected
|
||||
patterns = []
|
||||
for figure in generator.vocabulary.data('accompaniment')['comping']:
|
||||
events = tuple((start + fractions.Fraction(a), min(end, start + fractions.Fraction(a) + fractions.Fraction(d))) for a, d in figure['attacks'] if fractions.Fraction(a) < duration)
|
||||
# Space is available on held harmony, never an accidental empty winner.
|
||||
if not events and (not prior or not generator.accompaniment.same_chord(chord, prior) or start):
|
||||
continue
|
||||
conflicts = sum(a in source_attacks for a, _ in events)
|
||||
target = 1 if duration <= 3 else 1.5
|
||||
cost = .2 * abs(len(events) - target) + .08 * conflicts
|
||||
if figure['id'] == 'sustain' and duration < 3:
|
||||
cost += .2
|
||||
patterns.append((cost, generator.solo.policy.stable_rank(self.seed, (index, start, figure['id'])), events, figure['id']))
|
||||
# Source-specific variation within a broad acceptability band, not a loop.
|
||||
low = min(p[0] for p in patterns)
|
||||
_, _, events, label = min((p for p in patterns if p[0] <= low + .25), key=lambda p: p[1])
|
||||
for a, b in events:
|
||||
attacks.append((a, b, selected, chord))
|
||||
self.report.append({'source_index': index, 'onset': str(start), 'chord': generator.harmony.symbol(chord), 'figure': label, 'tones': len(selected), 'bass': bass,
|
||||
'fuller_voicing_reason': 'explicit written degrees/bass' if len(selected) > 3 else ''})
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
events = tuple(e for e in rhythm.events if start <= e.start < end)
|
||||
try:
|
||||
options = voicings(semantic_key(chord), self.part)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'source measure {source["number"]}, beat {start + 1}: {error}') from error
|
||||
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
|
||||
self._rhythm_reports[self.key(source, prior, index)] = {
|
||||
'source_index': index, 'families': dict(rhythm.families), 'source_melody': [(str(a), str(b)) for a, b in self.melody[index]],
|
||||
'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, 'exceptions': rhythm.exceptions,
|
||||
'layers': {lane: {'attacks': sum(e.lane == lane for e in rhythm.events),
|
||||
'events': [{'onset': str(e.start), 'duration': str(e.end - e.start)} for e in rhythm.events if e.lane == lane], 'tied_continuations': 0} for lane, _ in rhythm.families},
|
||||
'overlap_beats': str(sum((max(0, min(a.end, b.end) - max(a.start, b.start)) for a in rhythm.events for b in rhythm.events if a.lane == 'bass' and b.lane == 'upper'), fractions.Fraction()))}
|
||||
return tuple(spans)
|
||||
def select(self, spans, *, left=(), right=(), close=False, fixed=None):
|
||||
layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans)
|
||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer}
|
||||
for s, layer in zip(spans, layers))
|
||||
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
|
||||
def __call__(self, source, prior, index):
|
||||
key = self.key(source, prior, index)
|
||||
if key not in self.cache:
|
||||
# Effective pickup/turnaround harmony may differ from the main form.
|
||||
# Solve that measure between fixed canonical neighbors. Request order
|
||||
# and previously generated arrangement sections cannot change it.
|
||||
choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index))
|
||||
rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0]
|
||||
spans = self.prepare(source, prior, index, rhythm)
|
||||
before = [v for i, _, v in self.order if i < index]
|
||||
after = [v for i, _, v in self.order if i > index]
|
||||
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
|
||||
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
|
||||
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right, fixed=self.base)
|
||||
self.cache[key] = self.render(source, prior, index, spans, selected, left, True)
|
||||
return copy.deepcopy(self.cache[key])
|
||||
def render(self, source, prior, index, spans, selected, previous, variant):
|
||||
length = generator.model.fraction(source['duration'])
|
||||
attacks, rows = [], []
|
||||
choices = iter(selected)
|
||||
for span in spans:
|
||||
grip = next(choices) if span.events else ()
|
||||
bass = grip[0] if grip and self.part == 'acoustic' else None
|
||||
degrees = generator.harmony.degrees(span.chord)
|
||||
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
|
||||
rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
|
||||
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
|
||||
'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip],
|
||||
'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
|
||||
'bass_string': bass.string_number if bass else None,
|
||||
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
|
||||
for event in span.events:
|
||||
positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip
|
||||
attacks.append((event.lane, event.start, event.end, positions, span.chord))
|
||||
if grip:
|
||||
previous = grip
|
||||
self._reports[self.key(source, prior, index)] = rows
|
||||
voices = []
|
||||
notes, cursor, lane = [], fractions.Fraction(), ''
|
||||
def emit(a, b, positions=(), chord=None):
|
||||
nonlocal cursor
|
||||
previous = None
|
||||
for duration, written in generator.accompaniment.written_durations(b - a):
|
||||
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
|
||||
n = generator.model.make_event(f'comp_{self.part}_{index}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
|
||||
if previous:
|
||||
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
|
||||
n['voice'] = lane
|
||||
if self.part == 'acoustic':
|
||||
n['stem'] = 'down' if lane == 'bass' else 'up'
|
||||
if previous and pitches:
|
||||
for old, new in zip(previous['pitches'], n['pitches']):
|
||||
old['tie_to'], new['tie_from'] = new['id'], old['id']
|
||||
notes.append(n)
|
||||
previous = n
|
||||
a += duration
|
||||
cursor = b
|
||||
for start, end, positions, chord in attacks:
|
||||
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
|
||||
if cursor < start:
|
||||
emit(cursor, start)
|
||||
emit(start, end, positions, chord)
|
||||
if cursor < length:
|
||||
emit(cursor, length)
|
||||
voices.append({'id': lane, 'notes': notes})
|
||||
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior)
|
||||
result['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
|
||||
for note in notes:
|
||||
note['voice'] = 'voice_arranged'
|
||||
self.cache[key] = copy.deepcopy(result)
|
||||
result['voices'] = voices
|
||||
return result
|
||||
def connect_holds(self, inputs):
|
||||
"""Only explicit sustained neighbors can share an attack across a barline.
|
||||
|
||||
These are canonical internal joins, never links across role boundaries.
|
||||
The direct reattack remains when a grip or required attack count differs.
|
||||
"""
|
||||
for left, right in zip(inputs, inputs[1:]):
|
||||
before = self.cache[self.key(*left)]
|
||||
after = self.cache[self.key(*right)]
|
||||
previous_chord = generator.accompaniment.active_chord(left[0], generator.model.fraction(left[0]['duration']), left[1])
|
||||
next_chord = generator.accompaniment.active_chord(right[0], fractions.Fraction(), right[1])
|
||||
if not generator.accompaniment.same_chord(previous_chord, next_chord):
|
||||
continue
|
||||
for a, b in zip(before['voices'], after['voices']):
|
||||
if a['id'] == 'bass':
|
||||
continue
|
||||
old, new = a['notes'][-1], b['notes'][0]
|
||||
minimum = 1 if a['id'] == 'upper' else 0
|
||||
attacks = sum(bool(n['pitches']) and not any('tie_from' in p for p in n['pitches']) for n in b['notes'])
|
||||
if not old['pitches'] or not new['pitches'] or attacks <= minimum or min(generator.model.fraction(n['duration']) for n in (old, new)) < 1:
|
||||
continue
|
||||
if [p['tablature'] for p in old['pitches']] != [p['tablature'] for p in new['pitches']]:
|
||||
continue
|
||||
for p, q in zip(old['pitches'], new['pitches']):
|
||||
p['tie_to'], q['tie_from'] = q['id'], p['id']
|
||||
row = self._rhythm_reports[self.key(*right)]['layers'][a['id']]
|
||||
row['attacks'] -= 1
|
||||
row['tied_continuations'] += 1
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Bounded, source-only measure rhythms; acoustic layers are selected jointly."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
import itertools
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
import generator.solo.analysis
|
||||
import generator.solo.policy
|
||||
import generator.vocabulary
|
||||
|
||||
@dataclasses.dataclass(frozen=True, order=True)
|
||||
class Attack:
|
||||
lane: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Rhythm:
|
||||
events: tuple
|
||||
families: tuple
|
||||
contributions: tuple
|
||||
exceptions: tuple = ()
|
||||
@property
|
||||
def cost(self):
|
||||
return sum(value for _, value in self.contributions)
|
||||
@property
|
||||
def signature(self):
|
||||
return tuple((e.lane, str(e.start), str(e.end - e.start)) for e in self.events)
|
||||
|
||||
def melody_contexts(document):
|
||||
melody = generator.solo.analysis.attacks(document)
|
||||
result, cursor = [], fractions.Fraction()
|
||||
for m in document['staves'][0]['measures']:
|
||||
length = generator.model.fraction(m['duration'])
|
||||
result.append(tuple((n.start - cursor, n.end - cursor) for n in melody if n.start < cursor + length and n.end > cursor))
|
||||
cursor += length
|
||||
return tuple(result)
|
||||
|
||||
def spans(measure, prior):
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
boundaries = sorted({fractions.Fraction(), length, *(generator.model.fraction(h['onset']) for h in measure['harmonies'])})
|
||||
result = []
|
||||
for a, b in zip(boundaries, boundaries[1:]):
|
||||
chord = generator.accompaniment.active_chord(measure, a, prior)
|
||||
if result and generator.accompaniment.same_chord(result[-1][2], chord):
|
||||
result[-1] = (result[-1][0], b, chord)
|
||||
else:
|
||||
result.append((a, b, chord))
|
||||
return tuple(result)
|
||||
|
||||
def lane_score(events, lane, melody, harmony, length):
|
||||
"""Named source-context preferences, independent of the deterministic seed."""
|
||||
attacks = [a for a, b in melody if a >= 0]
|
||||
occupied = sum((max(0, min(length, b) - max(0, a)) for a, b in melody), fractions.Fraction())
|
||||
space = max(0, length - occupied)
|
||||
busy = len(attacks) >= length
|
||||
if lane == 'bass':
|
||||
target = min(2, sum(c is not None and b - a >= 1 for a, b, c in harmony))
|
||||
elif lane == 'upper':
|
||||
target = 2.5 if space >= length / 2 else 1.5 if busy else 2
|
||||
else:
|
||||
target = .5 if busy else 1.5 if space >= length / 2 else 1
|
||||
# A half-attack tolerance makes neighboring densities genuine alternatives.
|
||||
# The objective does not always fill up to a layer's permitted maximum.
|
||||
activity = .35 * max(0, abs(len(events) - target) - .5)
|
||||
support, articulation = 0., 0.
|
||||
for e in events:
|
||||
# Supporting an entrance is different from doubling every moving note.
|
||||
entrance = any(a == e.start and (i == 0 and a >= 0 or i > 0 and a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
|
||||
moving = any(a <= e.start < b and b - a <= 1 for a, b in melody)
|
||||
gap = not any(a <= e.start < b for a, b in melody)
|
||||
if lane != 'bass':
|
||||
support += -.2 if entrance else .25 if moving and e.start in attacks else -.15 if gap else 0
|
||||
articulation += .12 if busy and e.end - e.start > 1 else .08 if not busy and e.end - e.start < 1 else 0
|
||||
elif e.start and e.start.denominator != 1:
|
||||
support += .15
|
||||
divisor = max(1, len(events))
|
||||
return (('activity', activity), ('melody_support', support / divisor), ('duration_contrast', articulation / divisor))
|
||||
|
||||
def representatives(candidates, limit, seed):
|
||||
"""Keep counts, placements and duration families before the final cutoff."""
|
||||
unique = {}
|
||||
for candidate in candidates:
|
||||
key = candidate.signature
|
||||
rank = (candidate.cost, generator.solo.policy.stable_rank(seed, key))
|
||||
if key not in unique or rank < unique[key][0]:
|
||||
unique[key] = rank, candidate
|
||||
ordered = [v for _, v in sorted(unique.values(), key=lambda item: item[0])]
|
||||
groups = {}
|
||||
shapes = set()
|
||||
for c in ordered:
|
||||
counts = tuple((lane, sum(e.lane == lane for e in c.events)) for lane, _ in c.families)
|
||||
shape = (counts, c.families, any(e.start.denominator != 1 for e in c.events))
|
||||
# Round-robin attack counts first. Otherwise many cosmetic variants of
|
||||
# the cheapest density can exclude every one- or three-attack idea.
|
||||
if shape not in shapes:
|
||||
groups.setdefault(counts, []).append(c)
|
||||
shapes.add(shape)
|
||||
result = []
|
||||
while groups and len(result) < limit:
|
||||
for key in list(groups):
|
||||
result.append(groups[key].pop(0))
|
||||
if not groups[key]:
|
||||
del groups[key]
|
||||
if len(result) == limit:
|
||||
break
|
||||
return tuple(result)
|
||||
|
||||
def lane_candidates(measure, prior, lane, melody, policy, seed):
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
harmony = spans(measure, prior)
|
||||
active = [(a, b, c) for a, b, c in harmony if c]
|
||||
if not active:
|
||||
return Rhythm((), ((lane, 'space'),), (('activity', 0.),)),
|
||||
low, high = policy['layers'][lane]['attacks']
|
||||
# Genuinely different harmony gets a gesture, not its own density budget.
|
||||
required = [(a, b) for a, b, c in active if b - a >= (2 if lane == 'bass' else 1)]
|
||||
if lane == 'bass' and active[0][0] == 0 and not any(a == 0 for a, _ in required):
|
||||
required.insert(0, active[0][:2])
|
||||
if lane == 'electric' and len(active) == 1 and generator.accompaniment.same_chord(active[0][2], prior):
|
||||
required = []
|
||||
# With very fast harmonic rhythm, establish the edges and longest spans.
|
||||
# Short intervening chords may be silent; no incompatible sustain is kept.
|
||||
if len(required) > high + 1:
|
||||
edges = [required[0], required[-1]]
|
||||
required = sorted(edges + sorted(required[1:-1], key=lambda ab: (-(ab[1] - ab[0]), ab[0]))[:high - 1])
|
||||
extra = int(len(required) > high)
|
||||
grid = fractions.Fraction(policy['grid'])
|
||||
positions = {i * grid for i in range(int(length / grid) + 1) if i * grid < length}
|
||||
positions.update(a for a, _, _ in active)
|
||||
positions = sorted(t for t in positions if any(a <= t < b for a, b, _ in active))
|
||||
result = []
|
||||
for count in range(low, high + extra + 1):
|
||||
for onsets in itertools.combinations(positions, count):
|
||||
if lane == 'bass' and active[0][0] == 0 and (not onsets or onsets[0] != 0):
|
||||
continue
|
||||
if any(not any(a <= t < b for t in onsets) for a, b in required):
|
||||
continue
|
||||
if any(b - a < 1 for a, b in zip(onsets, onsets[1:])):
|
||||
continue
|
||||
for family in policy['layers'][lane]['families']:
|
||||
durations = policy['families'][family]
|
||||
events = []
|
||||
for i, a in enumerate(onsets):
|
||||
end = next(b for start, b, c in active if start <= a < b)
|
||||
following = onsets[i + 1] if i + 1 < len(onsets) else length
|
||||
duration = fractions.Fraction(durations[i % len(durations)])
|
||||
events.append(Attack(lane, a, min(a + duration, following, end)))
|
||||
exceptions = ((lane, 'extra attack for harmonic changes'),) if count > high else ()
|
||||
contributions = (*lane_score(events, lane, melody, harmony, length), ('extra_attacks', .5 * bool(exceptions)))
|
||||
result.append(Rhythm(tuple(events), ((lane, family if count else 'space'),), contributions, exceptions))
|
||||
if not result:
|
||||
# A short partial bar can have fewer gestures, not invented subdivisions.
|
||||
if length < 1:
|
||||
a, b, _ = active[0]
|
||||
return Rhythm((Attack(lane, a, b),), ((lane, 'partial'),), (('activity', 0.),)),
|
||||
raise ValueError(f'measure {measure["number"]}: no {lane} rhythm within the bounded attack/spacing policy')
|
||||
return representatives(result, policy['lane_candidates'], seed + '/' + lane)
|
||||
|
||||
def crowding(events):
|
||||
times = sorted({e.start for e in events})
|
||||
cost = .22 * sum(y - x < 1 for x, y in zip(times, times[1:]))
|
||||
# Three successive half-beat changes are a busy figure even though each
|
||||
# layer is sparse. Coincident attacks count as one hand action.
|
||||
return cost + .5 * sum(z - x <= 1 for x, y, z in zip(times, times[1:], times[2:]))
|
||||
|
||||
def candidates(measure, prior, part, melody, seed):
|
||||
policy = generator.vocabulary.data('accompaniment')
|
||||
if part == 'electric':
|
||||
return lane_candidates(measure, prior, 'electric', melody, policy, seed)
|
||||
bass = lane_candidates(measure, prior, 'bass', melody, policy, seed)
|
||||
upper = lane_candidates(measure, prior, 'upper', melody, policy, seed)
|
||||
result = []
|
||||
for a, b in itertools.product(bass, upper):
|
||||
costs = tuple((lane + '_' + k, v) for lane, c in (('bass', a), ('upper', b)) for k, v in c.contributions)
|
||||
result.append(Rhythm(tuple(sorted((*a.events, *b.events))), (*a.families, *b.families), (*costs, ('combined_crowding', crowding((*a.events, *b.events)))), (*a.exceptions, *b.exceptions)))
|
||||
return representatives(result, policy['measure_candidates'], seed)
|
||||
|
||||
def transition(previous, current):
|
||||
if previous is None:
|
||||
return 0.
|
||||
# Recurrence is allowed; penalize repeating an entire bar more than sharing
|
||||
# a duration family. This stays separate from the source-context objective.
|
||||
same_counts = all(sum(e.lane == lane for e in previous.events) == sum(e.lane == lane for e in current.events) for lane, _ in current.families)
|
||||
return .45 * (previous.signature == current.signature) + .12 * (previous.families == current.families) + .08 * same_counts
|
||||
|
||||
def select(layers, seed, left=None, right=None):
|
||||
"""One state per concrete bar rhythm; compare complete equal-span paths."""
|
||||
states = [(0., (), (), left)]
|
||||
for i, layer in enumerate(layers):
|
||||
following = []
|
||||
for c in layer:
|
||||
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.signature))), (*path, c), c) for cost, ties, path, previous in states]
|
||||
following.append(min(options, key=lambda item: item[:2]))
|
||||
states = following
|
||||
return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2]
|
||||
@@ -66,6 +66,7 @@ def validate_document(document: dict) -> None:
|
||||
if not staff.get('tablature'):
|
||||
continue
|
||||
for measure in staff['measures']:
|
||||
sounding = []
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
tones = []
|
||||
@@ -73,8 +74,15 @@ def validate_document(document: dict) -> None:
|
||||
tab = pitch['tablature']
|
||||
position = TabPosition(tab['string'], tab['fret'], dict(STANDARD_TUNING)[tab['string']] + tab['fret'])
|
||||
tones.append((position, generator.model.fraction(pitch['duration'])))
|
||||
start = generator.model.fraction(note['onset'])
|
||||
sounding.append((start, start + generator.model.fraction(pitch['duration']), position))
|
||||
if not playable_attack(tones):
|
||||
raise ValueError(f'{measure["id"]}, event {note["id"]}: single notes require frets {MIN_FRET}-{MAX_SINGLE_FRET}; each sounding chord requires an anchor in {MIN_FRET}-{MAX_ANCHOR_FRET} and at most {MAX_FRET_SPAN} consecutive fret positions, including held/tied tails')
|
||||
if len(measure['voices']) > 1:
|
||||
for time in sorted({t for a, b, _ in sounding for t in (a, b)}):
|
||||
held = [(p, b - time) for a, b, p in sounding if a <= time < b]
|
||||
if not playable_attack(held):
|
||||
raise ValueError(f'{measure["id"]}, beat {time + 1}: overlapping guitar voices conflict in string, fret span or sounding anchor')
|
||||
|
||||
def validate_tuning(state: dict, location: str) -> None:
|
||||
details = state['staff_details']
|
||||
|
||||
+27
-6
@@ -25,6 +25,7 @@ import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.arrange.pickup
|
||||
@@ -62,6 +63,12 @@ class SourceAnalysis:
|
||||
return cls(json.dumps(prepared, sort_keys=True), key)
|
||||
def document(self):
|
||||
return json.loads(self.document_json)
|
||||
def accompaniment_key(self):
|
||||
document = self.document()
|
||||
document['metadata'].pop('tempo', None)
|
||||
for measure in generator.score.source_staff(document)['measures']:
|
||||
measure['tempos'] = []
|
||||
return SourceAnalysis.read(document).musical_key
|
||||
|
||||
def windows(document, part):
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
@@ -102,7 +109,9 @@ def _code_key(role):
|
||||
paths += list((root / 'solo').glob('*.py'))
|
||||
paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py']
|
||||
elif role.endswith('_accompaniment'):
|
||||
paths += [root / n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
|
||||
paths += [root / n for n in ('comping.py', 'comping_rhythm.py', 'accompaniment.py', 'guitar.py')]
|
||||
if role == 'acoustic_accompaniment':
|
||||
paths += [root / 'acoustic_voicing.py', root / 'acoustic.py']
|
||||
elif role == 'electric_melody':
|
||||
paths += [root / 'guitar.py', root / 'melody.py']
|
||||
return generator.vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
|
||||
@@ -199,7 +208,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
part = role.split('_')[0]
|
||||
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
|
||||
'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
|
||||
seed = generator.vocabulary.fingerprint({'source': source.musical_key, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
|
||||
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
|
||||
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
|
||||
details = {}
|
||||
solo_report = ''
|
||||
composition_timings = {}
|
||||
@@ -237,7 +247,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
elif role.endswith('_accompaniment'):
|
||||
canonical = generator.comping.Canonical(document, part, seed)
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'accompaniment'}, accompaniment=canonical)
|
||||
details = {'canonical_comping': canonical.report}
|
||||
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'rhythm_source': choice_source}
|
||||
else:
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'})
|
||||
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
|
||||
@@ -282,16 +292,24 @@ def assemble(source, results):
|
||||
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
|
||||
for i, m in enumerate(staff['measures']):
|
||||
# Tuplet rests belong to the source/role rhythm and must survive union.
|
||||
sounding = [copy.deepcopy(n) for d in selected for n in generator.score.measure_notes(d['staves'][0]['measures'][i]) if n['pitches'] or n.get('tuplet_ids')]
|
||||
m['voices'] = [{'id': 'voice_arranged', 'notes': _fill(sounding, generator.model.fraction(m['duration']), m['id'])}]
|
||||
lanes = {}
|
||||
for d in selected:
|
||||
for voice in d['staves'][0]['measures'][i]['voices']:
|
||||
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
|
||||
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids')]
|
||||
if sounding:
|
||||
lanes.setdefault(lane, []).extend(sounding)
|
||||
lanes = lanes or {'voice_arranged': []}
|
||||
m['voices'] = [{'id': lane, 'notes': _fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
|
||||
# Attribute changes belong to source melody pickups and are assembly metadata.
|
||||
for d in selected:
|
||||
other = d['staves'][0]['measures'][i]
|
||||
if other.get('attributes'):
|
||||
m['attributes'] = copy.deepcopy(other['attributes'])
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
|
||||
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
|
||||
for span in staff['tuplets']:
|
||||
span['voice'] = 'voice_arranged'
|
||||
span['voice'] = owners[span['start']]
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
documents[part] = document
|
||||
@@ -336,6 +354,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
|
||||
raise ValueError(f'{path.name}/{role}: {error}') from error
|
||||
for path, (original, source) in sources.items():
|
||||
assembled = assemble(source, results[path])
|
||||
reuse = generator.arrange.accompaniment.audit(original, assembled)
|
||||
for part, document in assembled.items():
|
||||
document['metadata'] = copy.deepcopy(original['metadata'])
|
||||
document['metadata']['song_id'] = generator.score.slugify(path.stem)
|
||||
@@ -345,5 +364,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
|
||||
(analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
|
||||
# Not a score file: diagnostics are deliberately outside the songs schema.
|
||||
stable = {role: {k: v for k, v in value.items() if k not in ('seconds', 'composition_timings', 'cache_hit', 'observed_reads')} for role, value in results[path].items()}
|
||||
for part, audit in reuse.items():
|
||||
stable[part + '_accompaniment']['details']['reuse_audit'] = audit
|
||||
(analysis_dir / (generator.score.slugify(path.stem) + '.roles')).write_text(json.dumps(stable, sort_keys=True, indent=2))
|
||||
return len(sources) * 3
|
||||
|
||||
+13
-1
@@ -23,8 +23,20 @@ def _load(contents):
|
||||
result = {}
|
||||
for name, text in contents:
|
||||
value = json.loads(text)
|
||||
if value.get('version') != 1 or not value.get('provenance') or not value.get('license'):
|
||||
if value.get('version') != (2 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||||
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
|
||||
if name == 'accompaniment':
|
||||
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2):
|
||||
raise ValueError('accompaniment requires an eighth-note placement grid')
|
||||
for lane in ('electric', 'bass', 'upper'):
|
||||
layer = value['layers'][lane]
|
||||
if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']:
|
||||
raise ValueError('invalid accompaniment attack range or duration families')
|
||||
for family in layer['families']:
|
||||
if not value['families'][family] or any(fractions.Fraction(d) <= 0 for d in value['families'][family]):
|
||||
raise ValueError('accompaniment durations must be positive')
|
||||
if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64:
|
||||
raise ValueError('accompaniment candidate budgets must be 1–64')
|
||||
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
|
||||
if len({v['id'] for v in entries}) != len(entries):
|
||||
raise ValueError(f'{name}: duplicate vocabulary ID')
|
||||
|
||||
@@ -131,7 +131,10 @@ class AcousticTests(unittest.TestCase):
|
||||
combined = builder(source, 'acoustic')
|
||||
self.assertEqual(sounding(separate), sounding(combined))
|
||||
generator.model.validate_document(combined)
|
||||
self.assertTrue(all(len(m['voices']) == 1 for m in combined['staves'][0]['measures']))
|
||||
for measure in combined['staves'][0]['measures']:
|
||||
self.assertIn(len(measure['voices']), (1, 2))
|
||||
if len(measure['voices']) == 2:
|
||||
self.assertEqual({v['id'] for v in measure['voices']}, {'upper', 'bass'})
|
||||
def test_engraved_chord_has_one_stem_and_no_competing_rests(self):
|
||||
document = acoustic_document([([(0, 4)], [(1, 2), (3, 1)])])
|
||||
self.combine(document)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Independent rhythm contracts, including real overlapping guitar voices."""
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.arrange.accompaniment
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
import generator.fretboard
|
||||
import generator.harmonic_time
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.score
|
||||
import generator.solo.analysis
|
||||
import generator.solo.decoder
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.learned
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
|
||||
def sounding(document):
|
||||
return [(m['number'], v['id'], n['onset'], n['duration'], [(p['letter'], p['alter'], p['octave'], p['duration'], p['tablature'], bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches']]) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
|
||||
|
||||
class LayeredComping(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7'))
|
||||
self.resources = generator.vocabulary.load()
|
||||
def test_candidates_preserve_all_counts_and_articulations(self):
|
||||
m = self.source['staves'][0]['measures'][0]
|
||||
policy = self.resources['accompaniment']
|
||||
for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})):
|
||||
candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test')
|
||||
self.assertEqual({len(c.events) for c in candidates}, expected)
|
||||
self.assertTrue(any(e.end - e.start <= fractions.Fraction(1, 2) for c in candidates for e in c.events) if lane != 'bass' else True)
|
||||
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events))
|
||||
if lane == 'bass':
|
||||
self.assertTrue(all(c.events[0].start == 0 for c in candidates))
|
||||
def test_ranges_downbeats_and_overlap(self):
|
||||
canonical = generator.comping.Canonical(self.source, 'acoustic', 'light')
|
||||
carried = generator.accompaniment.carried_chords(self.source['staves'][0]['measures'])
|
||||
for i, (m, prior) in enumerate(zip(self.source['staves'][0]['measures'], carried)):
|
||||
result = canonical(m, prior, i)
|
||||
voices = {v['id']: v['notes'] for v in result['voices']}
|
||||
self.assertEqual(set(voices), {'bass', 'upper'})
|
||||
self.assertTrue(voices['bass'][0]['pitches'])
|
||||
self.assertEqual(generator.model.fraction(voices['bass'][0]['onset']), 0)
|
||||
for lane, high in (('bass', 2), ('upper', 3)):
|
||||
attacks = [n for n in voices[lane] if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])]
|
||||
self.assertTrue(1 <= len(attacks) <= high)
|
||||
self.assertTrue(any(fractions.Fraction(r['overlap_beats']) > 0 for r in canonical.rhythm_report))
|
||||
self.assertTrue(all(abs(sum(r['contributions'].values()) - r['selection_cost']) < 1e-10 for r in canonical.rhythm_report))
|
||||
def test_combined_crowding_favors_shared_attacks(self):
|
||||
m = self.source['staves'][0]['measures'][0]
|
||||
candidates = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'joint')
|
||||
self.assertTrue(any(dict(c.contributions)['combined_crowding'] == 0 for c in candidates))
|
||||
self.assertTrue(any(len({e.start for e in c.events}) < len(c.events) for c in candidates))
|
||||
shared = (generator.comping_rhythm.Attack('bass', fractions.Fraction(), fractions.Fraction(2)), generator.comping_rhythm.Attack('upper', fractions.Fraction(), fractions.Fraction(1)))
|
||||
separated = (shared[0], dataclasses.replace(shared[1], start=fractions.Fraction(1, 2)))
|
||||
self.assertLess(generator.comping_rhythm.crowding(shared), generator.comping_rhythm.crowding(separated))
|
||||
def test_harmonic_changes_share_one_measure_budget(self):
|
||||
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
|
||||
m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))]
|
||||
for part in ('acoustic', 'electric'):
|
||||
choices = generator.comping_rhythm.candidates(m, None, part, (), 'changes')
|
||||
for c in choices:
|
||||
for lane, high in (('bass', 2), ('upper', 3), ('electric', 2)):
|
||||
count = sum(e.lane == lane for e in c.events)
|
||||
self.assertLessEqual(count, high + 1)
|
||||
if count > high:
|
||||
self.assertIn((lane, 'extra attack for harmonic changes'), c.exceptions)
|
||||
for e in c.events:
|
||||
self.assertLessEqual(e.end, 1 if e.start < 1 else 2 if e.start < 2 else 4)
|
||||
if part == 'electric':
|
||||
self.assertTrue(all(c.exceptions for c in choices))
|
||||
def test_no_chord_is_silent_and_partial_measure_is_bounded(self):
|
||||
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
|
||||
m['harmonies'][0]['kind'] = 'none'
|
||||
self.assertTrue(all(not c.events for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'silence')))
|
||||
m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)}
|
||||
m['duration'] = generator.model.rational(fractions.Fraction(1, 2))
|
||||
self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events))
|
||||
def test_two_other_meters_preserve_full_bar_bounds(self):
|
||||
for meter in (3, 5):
|
||||
source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',))
|
||||
for part in ('acoustic', 'electric'):
|
||||
canonical = generator.comping.Canonical(source, part, 'meter')
|
||||
for rhythm in canonical.rhythms.values():
|
||||
self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events))
|
||||
def test_melody_changes_the_objective_without_changing_seed(self):
|
||||
e = generator.comping_rhythm.Attack('electric', fractions.Fraction(1), fractions.Fraction(3, 2))
|
||||
m = self.source['staves'][0]['measures'][0]
|
||||
harmony = generator.comping_rhythm.spans(m, None)
|
||||
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(4))
|
||||
rest = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
|
||||
first = dict(generator.comping_rhythm.lane_score((e,), 'electric', busy, harmony, fractions.Fraction(4)))
|
||||
second = dict(generator.comping_rhythm.lane_score((e,), 'electric', rest, harmony, fractions.Fraction(4)))
|
||||
self.assertGreater(first['melody_support'], second['melody_support'])
|
||||
self.assertNotEqual(generator.comping_rhythm.candidates(m, None, 'electric', busy, 'same'), generator.comping_rhythm.candidates(m, None, 'electric', rest, 'same'))
|
||||
def test_tempo_changes_metadata_but_not_accompaniment_music(self):
|
||||
changed = copy.deepcopy(self.source)
|
||||
changed['metadata']['tempo'] = 240
|
||||
changed['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'bpm': generator.model.rational(240), 'written': {'type': 'quarter', 'dots': 0}}]
|
||||
a, b = map(generator.production.SourceAnalysis.read, (self.source, changed))
|
||||
self.assertEqual(a.accompaniment_key(), b.accompaniment_key())
|
||||
for role in ('acoustic_accompaniment', 'electric_accompaniment'):
|
||||
first = generator.production.generate_role(a, role, pathlib.Path('data'))
|
||||
second = generator.production.generate_role(b, role, pathlib.Path('data'))
|
||||
self.assertEqual(sounding(first['document']), sounding(second['document']))
|
||||
self.assertEqual(first['details'], second['details'])
|
||||
self.assertNotEqual(first['trace']['cache_key'], second['trace']['cache_key'])
|
||||
def test_assembly_keeps_overlaps_and_canonical_reuse(self):
|
||||
source = generator.production.SourceAnalysis.read(self.source)
|
||||
results = {role: generator.production.generate_role(source, role, pathlib.Path('data')) for role in generator.production.ROLES if not role.endswith('_solo')}
|
||||
# Silence in source-defined solo slots is sufficient to test union;
|
||||
# no generated solo participates in these accompaniment decisions.
|
||||
for part in ('acoustic', 'electric'):
|
||||
empty = copy.deepcopy(results[part + '_accompaniment'])
|
||||
for m in empty['document']['staves'][0]['measures']:
|
||||
for v in m['voices']:
|
||||
for n in v['notes']:
|
||||
n.update(kind='rest', pitches=[])
|
||||
results[part + '_solo'] = empty
|
||||
assembled = generator.production.assemble(source, results)
|
||||
self.assertTrue(any(len(m['voices']) == 2 for m in assembled['acoustic']['staves'][0]['measures']))
|
||||
audit = generator.arrange.accompaniment.audit(self.source, assembled)
|
||||
self.assertGreater(audit['acoustic']['reused_bars'], 0)
|
||||
for doc in assembled.values():
|
||||
generator.model.validate_document(doc)
|
||||
generator.fretboard.validate_document(doc)
|
||||
def test_combined_voices_reject_conflicting_strings_and_fret_spans(self):
|
||||
for string, fret in ((5, 3), (2, 10)):
|
||||
doc = tests.solo_fixtures.song(count=1, chords=('C',))
|
||||
staff = doc['staves'][0]
|
||||
staff['tablature'] = True
|
||||
m = staff['measures'][0]
|
||||
pitches = [{'letter': 'c', 'alter': 0, 'octave': 4, 'tablature': {'string': 5, 'fret': 3}}, {'letter': 'c', 'alter': 0, 'octave': 4, 'tablature': {'string': string, 'fret': fret}}]
|
||||
m['voices'] = [{'id': str(i), 'notes': [generator.model.make_event(str(i), fractions.Fraction(i), fractions.Fraction(2), 'note', [p])]} for i, p in enumerate(pitches)]
|
||||
with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'):
|
||||
generator.fretboard.validate_document(doc)
|
||||
def test_sustained_join_is_one_attack_and_keeps_position(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
canonical = generator.comping.Canonical(source, 'electric', 'tie')
|
||||
inputs = [(m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))]
|
||||
chord = inputs[0][0]['harmonies'][0]
|
||||
grip = generator.comping.voicings(generator.comping.semantic_key(chord), 'electric')[0]
|
||||
for m, p, i in inputs:
|
||||
note = generator.model.make_event('held' + str(i), fractions.Fraction(), fractions.Fraction(4), 'note', generator.fretboard.chord_pitches(chord, grip))
|
||||
note['voice'] = 'electric'
|
||||
canonical.cache[canonical.key(m, p, i)]['voices'] = [{'id': 'electric', 'notes': [note]}]
|
||||
canonical._rhythm_reports[canonical.key(m, p, i)]['layers']['electric'].update(attacks=1, tied_continuations=0)
|
||||
canonical.connect_holds(inputs)
|
||||
a, b = [canonical.cache[canonical.key(*args)]['voices'][0]['notes'][0] for args in inputs]
|
||||
for p, q in zip(a['pitches'], b['pitches']):
|
||||
self.assertEqual(p['tie_to'], q['id'])
|
||||
self.assertEqual(q['tie_from'], p['id'])
|
||||
self.assertEqual(p['tablature'], q['tablature'])
|
||||
self.assertEqual(canonical.rhythm_report[1]['layers']['electric']['attacks'], 0)
|
||||
|
||||
class SoloRhythmConditioning(unittest.TestCase):
|
||||
def test_melody_rhythm_and_progression_change_learned_probabilities(self):
|
||||
examples = []
|
||||
for held, future, duration in ((False, 'FM7', 1), (True, 'FM7', 2), (False, 'Dm7', 3)):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7', future))
|
||||
if held:
|
||||
m = source['staves'][0]['measures'][0]
|
||||
m['voices'][0]['notes'] = [generator.model.make_event('held', fractions.Fraction(), fractions.Fraction(4), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])]
|
||||
analysis = generator.solo.analysis.analyze(source)
|
||||
harmony = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
|
||||
examples.append({'analysis': analysis, 'harmony': harmony, 'events': [(fractions.Fraction(), fractions.Fraction(duration), 64), (fractions.Fraction(duration), fractions.Fraction(4), 67)]})
|
||||
model = generator.solo.learned.Model(generator.solo.learned.fit(examples, {'purpose': 'independently authored conditional-rhythm test'}))
|
||||
probabilities = []
|
||||
for example in examples:
|
||||
decoder = generator.solo.decoder.Decoder(example['analysis'], example['harmony'], model, 'acoustic', fractions.Fraction(), fractions.Fraction(4), seed='fixed')
|
||||
harmonic, root, phase, source, _ = decoder.context(fractions.Fraction())
|
||||
context = generator.solo.learned.contexts(harmonic, phase, source, ())
|
||||
probabilities.append(model.probability(generator.solo.learned.token(fractions.Fraction(1), 64, None, root), context))
|
||||
self.assertGreater(probabilities[0], probabilities[1])
|
||||
self.assertGreater(probabilities[0], probabilities[2])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Accompaniment geometry and whole-form movement, using authored small cases."""
|
||||
import copy
|
||||
import fractions
|
||||
import itertools
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.comping
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
|
||||
def options(symbol, part):
|
||||
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
|
||||
|
||||
def path_cost(path, costs, part, left=(), right=(), close=False):
|
||||
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(4)]
|
||||
total[1] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
|
||||
total[1] += generator.comping.movement(part, path[-1], path[0] if close else right)
|
||||
return tuple(total)
|
||||
|
||||
class CompingVoicings(unittest.TestCase):
|
||||
def test_acoustic_geometry_complete_triads_and_component_fret_limits(self):
|
||||
for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
|
||||
with self.subTest(chord=symbol):
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
required = generator.comping.requirements(chord, 'acoustic')
|
||||
for grip in options(symbol, 'acoustic'):
|
||||
self.assertIn(tuple(p.string_number for p in grip), ((5, 4, 3, 2), (6, 4, 3, 2)))
|
||||
self.assertLessEqual(grip.bass.fret, 12)
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in grip))
|
||||
self.assertTrue(generator.fretboard.has_fret_anchor(grip[1:]))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(grip))
|
||||
self.assertEqual(grip.bass.midi, min(p.midi for p in grip))
|
||||
self.assertTrue(required.covers({p.midi % 12 for p in grip}))
|
||||
self.assertTrue(generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in grip))
|
||||
if symbol in ('C', 'Cm', 'F#', 'Bbm'):
|
||||
self.assertEqual({p.midi % 12 for p in grip}, set(required.allowed))
|
||||
def test_bass_thirds_only_for_plain_major_and_minor(self):
|
||||
for symbol, expected in (('C', {0, 4, 7}), ('Cm', {0, 3, 7}), ('C7', {0, 7}), ('Cm6', {0, 7}), ('Em7b5', {4, 10})):
|
||||
with self.subTest(chord=symbol):
|
||||
required = generator.comping.requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
|
||||
self.assertEqual(set(required.basses), expected)
|
||||
self.assertEqual({v.bass.midi % 12 for v in options(symbol, 'acoustic')}, expected)
|
||||
self.assertEqual(set(generator.comping.requirements(generator.harmony.parse_symbol('Cdim'), 'acoustic').basses), {0, 6})
|
||||
def test_explicit_slash_or_inversion_overrides_default_bass(self):
|
||||
inversion = generator.harmony.parse_symbol('C7')
|
||||
inversion['inversion'] = 1
|
||||
for chord, expected in ((generator.harmony.parse_symbol('C7/E'), 4), (generator.harmony.parse_symbol('C/D'), 2), (inversion, 4)):
|
||||
self.assertEqual(generator.comping.requirements(chord, 'acoustic').basses, (expected,))
|
||||
grips = generator.comping.voicings(generator.comping.semantic_key(chord), 'acoustic')
|
||||
self.assertEqual({v.bass.midi % 12 for v in grips}, {expected})
|
||||
def test_electric_uses_melody_voicing_families_and_chord_semantics(self):
|
||||
for symbol in ('C', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'C/E'):
|
||||
with self.subTest(chord=symbol):
|
||||
required = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol), respect_bass=False)
|
||||
melody = generator.guitar.chord_positions(required, profile='electric', prune=False)
|
||||
self.assertEqual(set(options(symbol, 'electric')), set(melody))
|
||||
for grip in options(symbol, 'electric'):
|
||||
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ELECTRIC_STRINGS)
|
||||
self.assertTrue(required.covers({p.midi % 12 for p in grip}))
|
||||
# Bass responsibility belongs to the acoustic voicing, as in electric melody.
|
||||
self.assertEqual(options('C/E', 'electric'), options('C', 'electric'))
|
||||
def test_impossible_grip_reports_source_location_without_relaxation(self):
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C5',))
|
||||
for part in ('acoustic', 'electric'):
|
||||
with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C5: no legal'):
|
||||
generator.comping.Canonical(source, part, 'test')
|
||||
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
|
||||
for part in ('acoustic', 'electric'):
|
||||
with self.subTest(part=part):
|
||||
chords = ('C', 'C', 'Dm7')
|
||||
layers = tuple(options(s, part) for s in chords)
|
||||
costs = tuple({v: generator.comping.local_cost(part, generator.comping.semantic_key(generator.harmony.parse_symbol(s)), v, 55) for v in layer} for s, layer in zip(chords, layers))
|
||||
path, cost = generator.comping.select_grips(layers, costs, part, 'test')
|
||||
greedy = []
|
||||
for i, layer in enumerate(layers):
|
||||
greedy.append(min(layer, key=lambda v: (path_cost((*greedy, v), costs[:i + 1], part), v)))
|
||||
self.assertLess(cost, path_cost(greedy, costs, part))
|
||||
self.assertEqual(cost, min(path_cost(p, costs, part) for p in itertools.product(*layers)))
|
||||
self.assertEqual(cost, path_cost(path, costs, part))
|
||||
self.assertEqual((path, cost), generator.comping.select_grips(tuple(tuple(reversed(l)) for l in layers), costs, part, 'test'))
|
||||
def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self):
|
||||
for part in ('acoustic', 'electric'):
|
||||
layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7'))
|
||||
costs = tuple({v: (0, 0, 0, 0) for v in layer} for layer in layers)
|
||||
for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}):
|
||||
with self.subTest(part=part, endpoints=endpoints):
|
||||
path, cost = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
|
||||
self.assertEqual(cost, min(path_cost(p, costs, part, **endpoints) for p in itertools.product(*layers)))
|
||||
self.assertEqual(cost, path_cost(path, costs, part, **endpoints))
|
||||
def test_rest_does_not_reset_the_previous_hand_position(self):
|
||||
source = tests.solo_fixtures.song(count=3, chords=('C', 'C', 'Dm7'))
|
||||
source['staves'][0]['measures'][1]['harmonies'][0]['kind'] = 'none'
|
||||
for part in ('acoustic', 'electric'):
|
||||
canonical = generator.comping.Canonical(source, part, 'rest')
|
||||
self.assertEqual({i for i, _, _ in canonical.order}, {0, 2})
|
||||
expected = generator.comping.movement(part, canonical.order[0][2], canonical.order[1][2])
|
||||
self.assertEqual(next(r['movement'] for r in canonical.report if r['source_index'] == 2), expected)
|
||||
def test_harmony_variants_are_independent_of_request_order(self):
|
||||
source = tests.solo_fixtures.song(count=3, chords=('C7', 'FM7', 'G7'))
|
||||
measures = source['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
variants = []
|
||||
for index, symbol in ((1, 'Dm7'), (2, 'Am7')):
|
||||
changed = copy.deepcopy(measures[index])
|
||||
changed['harmonies'][0] = {**generator.harmony.parse_symbol(symbol), 'id': 'variant', 'onset': generator.model.rational(0)}
|
||||
variants.append((changed, carried[index], index))
|
||||
for part in ('acoustic', 'electric'):
|
||||
a, b = (generator.comping.Canonical(source, part, 'variants') for _ in range(2))
|
||||
forward = {args[2]: a(*args) for args in variants}
|
||||
reverse = {args[2]: b(*args) for args in reversed(variants)}
|
||||
self.assertEqual(forward, reverse)
|
||||
self.assertEqual(a.report, b.report)
|
||||
self.assertEqual(a.base, b.base)
|
||||
self.assertEqual(a(measures[0], carried[0], 0), b(measures[0], carried[0], 0))
|
||||
def test_register_preference_does_not_change_rhythm(self):
|
||||
resources = generator.vocabulary.load()
|
||||
source = tests.solo_fixtures.song(count=4)
|
||||
measures = source['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
def rhythm(canonical):
|
||||
return [[(n['onset'], n['duration'], n['written'], n['kind']) for n in generator.score.measure_notes(canonical(m, p, i))] for i, (m, p) in enumerate(zip(measures, carried))]
|
||||
for part in ('acoustic', 'electric'):
|
||||
with generator.vocabulary.using(resources, part + '_accompaniment', {'center': 45}):
|
||||
low = generator.comping.Canonical(source, part, 'fixed-rhythm')
|
||||
with generator.vocabulary.using(resources, part + '_accompaniment', {'center': 70}):
|
||||
high = generator.comping.Canonical(source, part, 'fixed-rhythm')
|
||||
self.assertEqual(rhythm(low), rhythm(high))
|
||||
def test_accompaniment_cache_matches_clean_generation(self):
|
||||
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=3))
|
||||
for part in ('acoustic', 'electric'):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
role = part + '_accompaniment'
|
||||
cold = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
|
||||
warm = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
|
||||
clean = generator.production.generate_role(source, role, pathlib.Path('data'))
|
||||
self.assertTrue(warm['cache_hit'])
|
||||
self.assertEqual(cold['document'], warm['document'])
|
||||
self.assertEqual(cold['document'], clean['document'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -46,20 +46,22 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertEqual(set(requirements.allowed), {0, 7})
|
||||
self.assertFalse(generator.acoustic_voicing.candidates(requirements))
|
||||
self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric'))
|
||||
def test_sparse_acoustic_uses_compact_playable_chords(self):
|
||||
def test_acoustic_uses_bass_and_three_upper_notes(self):
|
||||
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
|
||||
generator.model.validate_document(result)
|
||||
generator.fretboard.validate_document(result)
|
||||
sounding = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
|
||||
self.assertTrue(sounding)
|
||||
self.assertTrue(all(2 <= len(n['pitches']) <= 3 for n in sounding))
|
||||
self.assertTrue(all(len(n['pitches']) in (1, 3) for n in sounding))
|
||||
for n in sounding:
|
||||
self.assertIn(sorted(p['tablature']['string'] for p in n['pitches']), ([2, 3, 4], [5], [6]))
|
||||
def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
|
||||
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
|
||||
result = tests.arrangement_fixtures.section_document(source, 'acoustic')
|
||||
notes = tests.test_musicxml.notes(result)
|
||||
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] if v['id'] == 'bass' for n in v['notes']]
|
||||
self.assertEqual({min(generator.model.pitch_number(p) for p in n['pitches']) % 12 for n in notes if n['pitches']}, {expected})
|
||||
def test_long_melody_chords_and_short_notes(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=4) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=5)
|
||||
|
||||
@@ -125,7 +125,7 @@ class IndependentRoles(unittest.TestCase):
|
||||
def signature(m):
|
||||
return [(n['onset'], n['duration'], [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
|
||||
self.assertEqual([signature(m) for m in measures[4:8]], [signature(m) for m in measures[8:12]])
|
||||
self.assertTrue(all(len(n['pitches']) <= 3 for m in measures for v in m['voices'] for n in v['notes']))
|
||||
self.assertTrue(all(len(n['pitches']) in (0, 1, 3) for m in measures for v in m['voices'] for n in v['notes']))
|
||||
|
||||
class TypedExpressionContracts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
Reference in New Issue
Block a user