Rhythm Comping Pairs
ci / checks (push) Successful in 56m36s

This commit is contained in:
khodowany
2026-10-07 20:57:23 -05:00
parent a0e13434e4
commit 3a01b4b6bd
10 changed files with 849 additions and 555 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ Each instrument has 24 distinct two-bar patterns in 4/4, 24 in 3/4, and 24 for e
Both accompaniment parts balance movement with sounding register and a soft cost for mean fret positions above nine; comfortable shifts can win over staying high. Acoustic uses a register center of MIDI 53 and electric uses 60. Compact spans and preferred chord families remain higher priorities, including for the final sustained chord. Electric melody's supporting chords use the same balance after the existing melody-string and foreground priorities; the original melody pitches and rhythm remain fixed. Voicing preferences do not reseed accompaniment rhythms. Both accompaniment parts balance movement with sounding register and a soft cost for mean fret positions above nine; comfortable shifts can win over staying high. Acoustic uses a register center of MIDI 53 and electric uses 60. Compact spans and preferred chord families remain higher priorities, including for the final sustained chord. Electric melody's supporting chords use the same balance after the existing melody-string and foreground priorities; the original melody pitches and rhythm remain fixed. Voicing preferences do not reseed accompaniment rhythms.
The first acoustic attack for each new chord includes bass, alone or paired with the upper chord. A mid-measure change follows this rule; an unchanged chord crossing a barline does not create a bass obligation. Delayed entrances remain delayed. A qualified half-beat anticipated chord includes its bass at the anticipated entrance. Bass and upper notes share one engraved part, with ties preserving overlaps and independent releases. Acoustic attacks normally play bass and upper chord together. Consecutive attacks exactly an eighth note apart split into non-overlapping bass–upper pairs within the same harmony; an odd leftover attack plays both together. Pairing continues across barlines and two-bar pattern boundaries, but stops at chord changes, chorus boundaries and source-defined entries/handoffs. The first attack for each new chord therefore includes bass, including a qualified anticipated entrance. Delayed entrances remain delayed. Bass may ring beneath the upper attack; only conflicting same-string releases are shortened. Bass and upper notes share one engraved part, with ties preserving overlaps and independent releases.
Each song has six canonical roles: electric melody, electric accompaniment, electric solo, acoustic accompaniment, acoustic solo, and vocal melody. Matching source-form positions reuse the same musical events and TAB whenever a role returns. Final-turnaround omission changes only affected harmony; source-defined pickups/handoffs and the three-bar instrumental closing sustain remain explicit exceptions. Each song has six canonical roles: electric melody, electric accompaniment, electric solo, acoustic accompaniment, acoustic solo, and vocal melody. Matching source-form positions reuse the same musical events and TAB whenever a role returns. Final-turnaround omission changes only affected harmony; source-defined pickups/handoffs and the three-bar instrumental closing sustain remain explicit exceptions.
+8 -4
View File
@@ -133,7 +133,7 @@ Silent spans retain the preceding hand position. A continuous accompaniment repe
`comping_rhythm.py` selects recurring source-only figures from version 6 of `composition/accompaniment.json`. The normal acoustic cycle has two two-bar slots; electric has four. Each template and anticipation mode is evaluated at every occurrence of its slot before retrieval limits apply. Up to eight diverse candidates per slot enter bounded complete-cycle comparison. Distinct slots differ in attack/duration relationships, not just IDs, bass/upper assignments or anticipation flags. Repetition is not penalized. The source melody and harmonic rhythm affect selection; title, iteration, generated solo and tempo do not. `groove_cycles` reports slot identities, anticipation modes, aggregate cost, compared cycles and any reduction caused by vocabulary or form length. `comping_rhythm.py` selects recurring source-only figures from version 6 of `composition/accompaniment.json`. The normal acoustic cycle has two two-bar slots; electric has four. Each template and anticipation mode is evaluated at every occurrence of its slot before retrieval limits apply. Up to eight diverse candidates per slot enter bounded complete-cycle comparison. Distinct slots differ in attack/duration relationships, not just IDs, bass/upper assignments or anticipation flags. Repetition is not penalized. The source melody and harmonic rhythm affect selection; title, iteration, generated solo and tempo do not. `groove_cycles` reports slot identities, anticipation modes, aggregate cost, compared cycles and any reduction caused by vocabulary or form length.
Meter-specific full patterns declare `meter` and, for 5/4, `grouping: [3, 2]` or `[2, 3]`; `meter: partial` marks the bounded one-bar pickup vocabulary. The loader checks nominal lengths, grouping, quarter/eighth arithmetic, lane overlap and distinct acoustic/electric onset patterns. Each role has 24 distinct attack/duration figures per selectable pool: 4/4 (11 families), 3/4 (12 families), and each 5/4 grouping (12 families). The 4/4 pool removes one duplicate acoustic figure, develops another previously duplicated answer, and adds three electric variants. Patterns are independently authored from general principles, with research URLs and provenance in the resource, rather than transcriptions. The counts describe available choices, not required musical activity. Meter-specific full patterns declare `meter` and, for 5/4, `grouping: [3, 2]` or `[2, 3]`; `meter: partial` marks the bounded one-bar pickup vocabulary. The loader checks nominal lengths, grouping, quarter/eighth arithmetic, electric lane overlap and distinct acoustic/electric onset patterns. Acoustic lane overlap is checked after automatic assignment. Each role has 24 distinct attack/duration figures per selectable pool: 4/4 (11 families), 3/4 (12 families), and each 5/4 grouping (12 families). The 4/4 pool removes one duplicate acoustic figure, develops another previously duplicated answer, and adds three electric variants. Patterns are independently authored from general principles, with research URLs and provenance in the resource, rather than transcriptions. The counts describe available choices, not required musical activity.
`grouping_for` is a pure source-only decision. It honors written 3+2 or 2+3, otherwise compares actual chord changes at the secondary boundary, then melody entrances following at least an eighth-note rest, then other melody attacks; equal evidence defaults to 3+2. An unchanged chord marking is not a change. The selection stays fixed through a continuous meter section, and each role derives it independently from the same source. Fills cannot borrow another grouping. No accents or playback swing changes are inferred. `groove_cycles` additionally reports grouping, its evidence and the eligible pattern count. Equal-duration compound meters (such as 6/8) do not borrow the 3/4 vocabulary; unsupported comping meters fail explicitly. `grouping_for` is a pure source-only decision. It honors written 3+2 or 2+3, otherwise compares actual chord changes at the secondary boundary, then melody entrances following at least an eighth-note rest, then other melody attacks; equal evidence defaults to 3+2. An unchanged chord marking is not a change. The selection stays fixed through a continuous meter section, and each role derives it independently from the same source. Fills cannot borrow another grouping. No accents or playback swing changes are inferred. `groove_cycles` additionally reports grouping, its evidence and the eligible pattern count. Equal-duration compound meters (such as 6/8) do not borrow the 3/4 vocabulary; unsupported comping meters fail explicitly.
@@ -143,16 +143,20 @@ The cycle begins at the first full source-body measure. A partial-form entry fir
Fills replace only a two-bar figure's ending bar, using a same-family variation that improves the existing source-fit objective. Eligible boundaries are source section endings, form endings, or a melody breath of at least one quarter-note beat at the block end. Carried events from the opening bar are protected. The largest improvements are retained with an eight-measure minimum separation around the whole cyclic form, equally for both roles; no fill quota applies. Fills do not change cycle phase and recur at identical source positions. Reports record the base slot, selected fill, source index and reason. Fills replace only a two-bar figure's ending bar, using a same-family variation that improves the existing source-fit objective. Eligible boundaries are source section endings, form endings, or a melody breath of at least one quarter-note beat at the block end. Carried events from the opening bar are protected. The largest improvements are retained with an eight-measure minimum separation around the whole cyclic form, equally for both roles; no fill quota applies. Fills do not change cycle phase and recur at identical source positions. Reports record the base slot, selected fill, source index and reason.
Normal acoustic patterns use 1–3 attack positions per full bar. Separate bass and upper events last quarters/eighths and may overlap; paired attacks can be short or sustained. Events cannot overlap within the same lane. First attacks of new harmonic spans include bass (`first_attack: bass_per_harmony`), including mid-bar changes; continued harmony does not reset at barlines or computational boundaries. Fitting can pair bass with an upper attack without changing its onset, with independent releases where necessary. A paired attack counts once in rhythm evaluation. The completed validator follows sounding tie chains; placement validates all simultaneously sounding strings, fret spans and anchors. The two lanes are merged into one notated part with tied fragments preserving their true durations. Normal acoustic patterns use 1–3 attack positions per full bar. Their event layer is `acoustic`; the instrument's versioned `attack_assignment` policy is `combined_except_eighth_pairs`. Authored bass/upper decisions are replaced by chronological assignment across the complete canonical source form: ordinary attacks are combined, while adjacent half-beat-separated attacks in the same effective harmony become bass then upper. Pairs do not overlap; an odd leftover attack is combined. Bar and two-bar pattern boundaries do not reset pairing. Source-defined entries, handoffs and chorus boundaries do; the same boundary set applies at repeated form positions. Chord changes break pairs, but repeated identical chord labels do not. An anticipation uses its upcoming harmonic region for this decision. First attacks of new harmonic spans still include bass (`first_attack: bass_per_harmony`).
Electric normally uses 0–2 attack positions per bar, with stabs, sustains and space. Direct events release at changed harmony. An eligible upper/electric template can anticipate a chord by half a beat when it continues into that chord; the grip comes from the upcoming harmony and releases by its following change. Acoustic anticipations also include bass on the new chord's first attack. Conflicting lane overlaps reject the candidate. Missing harmony remains silent. Protected participation limits retain the chosen figure, shortening only its affected releases and recording exceptions. Cross-bar sounding notes become ties, never automatic merging of repeated attacks. Quarter/eighth articulations and explicitly authored sustains retain their duration unless a chord boundary, handoff or fresh attack on the same strings requires release. Bass may ring beneath a later upper attack. Shortening that would strand an anticipation first rebinds it to current harmony and recomputes assignment. Complete-form assignment happens before grip placement and notation splitting, and is re-evaluated for source-harmony variants. Concrete block caches include their assigned event signature: a neighboring chord change can break a pair even when this block's own harmony is unchanged. The renderer verifies actual sounding lane events against the selected assignment. The completed validator follows sounding tie chains; placement validates all simultaneously sounding strings, fret spans and anchors. The two lanes are merged into one notated part with tied fragments preserving their true durations. The three-bar closing sustain remains combined.
Electric normally uses 0–2 attack positions per bar, with stabs, sustains and space. Direct events release at changed harmony. An eligible acoustic/electric template can anticipate a chord by half a beat when it continues into that chord; the grip comes from the upcoming harmony and releases by its following change. Acoustic anticipations also include bass on the new chord's first attack. Missing harmony remains silent. Protected participation limits retain the chosen figure, shortening only its affected releases and recording exceptions. Cross-bar sounding notes become ties, never automatic merging of repeated attacks.
All six production roles receive reuse audits. Musical comparisons include rests, pitch durations, ties and TAB while ignoring regenerated event IDs and global measure numbers. Source-defined pickup/participation changes and effective final-turnaround harmony are reported separately. The final three-bar hold remains instrumental. All six production roles receive reuse audits. Musical comparisons include rests, pitch durations, ties and TAB while ignoring regenerated event IDs and global measure numbers. Source-defined pickup/participation changes and effective final-turnaround harmony are reported separately. The final three-bar hold remains instrumental.
Named activity, melody-support, harmonic-support and articulation contributions evaluate complete figures. Cycle selection aggregates them over all occurrences, retaining family diversity before the final bounded comparison. `groove_cycles.cost` is the selected base cycle's aggregate cost before optional fills. Reports identify the base slot, realized pattern, projected events, continuations, anticipation target times and source-context exceptions. Tempo affects metadata/cache identity but never musical choice. Named activity, melody-support, harmonic-support and articulation contributions evaluate complete figures. Cycle selection aggregates them over all occurrences, retaining family diversity before the final bounded comparison. For electric, `groove_cycles.cost` is the selected base cycle's aggregate cost before optional fills. Acoustic reports preserve this as `retrieval_cost` and report `cost` for the final assigned source-form events, including selected fills and cross-block pairing. Reports identify the base slot, realized pattern, projected events, continuations, anticipation target times and source-context exceptions. Tempo affects metadata/cache identity but never musical choice. The acoustic assignment policy and data migrate independently; the electric choice domain and other roles' musical seeds are unchanged.
Acoustic events use temporary bass/upper notation lanes for projection; then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. 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 pattern IDs, exact projected layer events, continuations, anticipation targets and exceptions. Their `selection_cost` records the complete figure’s local contributions, repeated on each projected bar for context; the cycle selector aggregates these by the number of measures in each occurrence, without an anti-repetition transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances. Acoustic events use temporary bass/upper notation lanes for projection; then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. 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 pattern IDs, exact projected layer events, continuations, anticipation targets and exceptions. Their `selection_cost` records the complete figure’s local contributions, repeated on each projected bar for context; the cycle selector aggregates these by the number of measures in each occurrence, without an anti-repetition transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances.
Acoustic pair-assignment verification on October 7, 2026: the full containerized `unittest discover -s tests -p 'test_*.py'` passed 447 tests. After adding the explicit cross-bar meter case and retaining the existing long-sustain boundary semantics, the final targeted run (`tests.test_acoustic_pairs tests.test_comping_rhythm tests.test_comping_cycles tests.test_comping_meters tests.test_arrangement_ending tests.test_chord_families.CombinedAcoustic`) passed 63 tests. The first full run exposed a broken-tie check that had depended on rejecting a long separate event; explicit reciprocal-link, timing and pitch/TAB checks now reject it directly. A host-only targeted command could not import Modulus's build dependencies; the configured-container rerun passed. Native and ARM product builds passed asset checks, with 394 identical JSON/PNG artifacts. Both acoustic score/TAB pages were visually inspected. All ten other-role documents exactly match the saved pre-change baseline. The acoustic source-form reports show 64 combined attacks for All of Me, and 48 combined plus 16 bass/upper pairs for Autumn Leaves; these are observations, not quotas. No audio audition was performed. Comparison copies remain outside the repository under `/tmp/`.
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, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. 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. 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, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. 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.
Both accompaniment roles add `register_weight * abs(mean sounding MIDI - center)` and `high_position_weight * max(0, mean fret - comfortable_max_fret)` to their existing grip effort and movement tier, once per active harmonic span. The three role policies (`acoustic_accompaniment`, `electric_accompaniment`, `electric_melody`) use weights 1 and 1 and threshold 9; their register centers are 53, 60 and 60 respectively. All arithmetic is rational. Electric melody adds the same costs only to harmonized attacks, below compact-span, melody-string, foreground, chord-size and family priorities. Exact melody pitches are never transposed to meet the register center; monophonic placement is unchanged. Scoring and scored-pruning caches explicitly include the normalized preference tuple. Tied continuations incur no second chord cost. Existing travel costs and source-form joins remain in place; high positions can still win when avoiding a costly shift. The ending uses its own accompaniment role's local cost and incoming grip. Both accompaniment roles add `register_weight * abs(mean sounding MIDI - center)` and `high_position_weight * max(0, mean fret - comfortable_max_fret)` to their existing grip effort and movement tier, once per active harmonic span. The three role policies (`acoustic_accompaniment`, `electric_accompaniment`, `electric_melody`) use weights 1 and 1 and threshold 9; their register centers are 53, 60 and 60 respectively. All arithmetic is rational. Electric melody adds the same costs only to harmonized attacks, below compact-span, melody-string, foreground, chord-size and family priorities. Exact melody pitches are never transposed to meet the register center; monophonic placement is unchanged. Scoring and scored-pruning caches explicitly include the normalized preference tuple. Tied continuations incur no second chord cost. Existing travel costs and source-form joins remain in place; high positions can still win when avoiding a costly shift. The ending uses its own accompaniment role's local cost and incoming grip.
File diff suppressed because it is too large Load Diff
+26 -9
View File
@@ -110,10 +110,10 @@ def combine(document: dict) -> None:
b = fragments[end][0] if end in fragments else originals[end] b = fragments[end][0] if end in fragments else originals[end]
a['tie_to'], b['tie_from'] = b['id'], a['id'] a['tie_to'], b['tie_from'] = b['id'], a['id']
def validate_comping(document: dict) -> None: def sounding_lanes(measures):
"""Check complete sounding events, not barline or notation fragments.""" """Recover complete string-lane events, preserving ties versus reattacks."""
tones, starts, cursor = {}, {}, fractions.Fraction() tones, starts, cursor = {}, {}, fractions.Fraction()
for measure in document['staves'][0]['measures']: for measure in measures:
for note in generator.score.measure_notes(measure): for note in generator.score.measure_notes(measure):
for pitch in note['pitches']: for pitch in note['pitches']:
tones[pitch['id']] = pitch tones[pitch['id']] = pitch
@@ -122,6 +122,8 @@ def validate_comping(document: dict) -> None:
groups = {} groups = {}
for identifier, pitch in tones.items(): for identifier, pitch in tones.items():
if pitch.get('tie_from') in tones: if pitch.get('tie_from') in tones:
if tones[pitch['tie_from']].get('tie_to') != identifier:
raise ValueError('broken acoustic sustain link')
continue continue
start, tail = starts[identifier], pitch start, tail = starts[identifier], pitch
seen = {identifier} seen = {identifier}
@@ -129,20 +131,35 @@ def validate_comping(document: dict) -> None:
target = tail['tie_to'] target = tail['tie_to']
if target in seen: if target in seen:
raise ValueError('cyclic acoustic sustain') raise ValueError('cyclic acoustic sustain')
continuation = tones[target]
if continuation.get('tie_from') != tail['id'] or starts[target] != starts[tail['id']] + generator.model.fraction(tail['duration']):
raise ValueError('broken acoustic sustain link or timing')
if generator.model.pitch_number(continuation) != generator.model.pitch_number(tail) or continuation.get('tablature') != tail.get('tablature'):
raise ValueError('acoustic sustain changes pitch or position')
seen.add(target) seen.add(target)
tail = tones[target] tail = continuation
end = starts[tail['id']] + generator.model.fraction(tail['duration']) end = starts[tail['id']] + generator.model.fraction(tail['duration'])
string = pitch.get('tablature', {}).get('string') string = pitch.get('tablature', {}).get('string')
if string is None: if string is None:
raise ValueError('acoustic comping requires placed notes') raise ValueError('acoustic comping requires placed notes')
groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper') groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper')
return groups
def validate_assignment(measures, events):
expected = {}
for e in events:
lanes = ['bass', 'upper'] if e.lane == 'combined' else [e.lane]
expected.setdefault((e.start, e.end), set()).update(lanes)
if sounding_lanes(measures) != expected:
raise ValueError('rendered acoustic attacks disagree with the selected bass/upper assignment')
def validate_comping(document: dict) -> None:
"""Check complete sounding events, not barline or notation fragments."""
groups = sounding_lanes(document['staves'][0]['measures'])
events = [] events = []
for (start, end), lanes in groups.items(): for (start, end), lanes in groups.items():
if len(lanes) == 2: if end <= start or (end - start < 1 and end - start != fractions.Fraction(1, 2)):
if end - start < 1 and end - start != fractions.Fraction(1, 2): raise ValueError('acoustic comping requires quarter/eighth notes or authored sustains')
raise ValueError('combined acoustic attacks require quarter/eighth notes or sustains')
elif end - start not in (fractions.Fraction(1, 2), 1):
raise ValueError('separate acoustic comping requires quarter/eighth notes')
events.append((start, end)) events.append((start, end))
events.sort() events.sort()
# Distinct lanes can overlap. The physical validator still checks sounding # Distinct lanes can overlap. The physical validator still checks sounding
+90 -2
View File
@@ -8,10 +8,12 @@ import fractions
import typing import typing
import generator.accompaniment import generator.accompaniment
import generator.acoustic
import generator.comping_rhythm import generator.comping_rhythm
import generator.chord_families import generator.chord_families
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.form import generator.arrange.form
import generator.arrange.pickup
import generator.fretboard import generator.fretboard
import generator.guitar import generator.guitar
import generator.harmony import generator.harmony
@@ -210,6 +212,30 @@ class Canonical:
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)} self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)} self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
selected = [r for _, _, r in choices] selected = [r for _, _, r in choices]
self.acoustic_cache = {}
self.pair_breaks = {self.first, len(self.measures)}
self.pair_stops = []
for section in sections:
if section.role == 'accompaniment':
self.pair_breaks.update((section.source_measure_index, section.source_measure_index + section.measure_count))
for section, following in zip(sections, sections[1:]):
if section.role != 'accompaniment' or not self.first:
continue
last = section.source_measure_index + section.measure_count - 1
pad = generator.arrange.pickup.offset(self.measures[last], self.measures[0])
if following.role == 'slash':
self.pair_stops.append((last, pad))
elif following.role == 'melody' and generator.arrange.pickup.enters(following, self.measures):
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(self.measures[0]) if n['pitches']]
if attacks:
self.pair_stops.append((last, pad + min(attacks)))
if part == 'acoustic':
assigned = self.acoustic_realizations(self.measures)
selected = [assigned[g[0][2]] for g in self.groups]
for report in self.groove_report:
report['retrieval_cost'] = report['cost']
report['cost'] = sum(r.cost * len(g) for g, r in zip(self.groups, selected) if report['source_start'] <= g[0][2] <= report['source_end'])
report['attack_assignment'] = 'combined_except_eighth_pairs'
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)} self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)] prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
active = tuple(span for spans in prepared for span in spans if span.events) active = tuple(span for spans in prepared for span in spans if span.events)
@@ -221,7 +247,8 @@ class Canonical:
previous = path[-1] if self.closed and path else () previous = path[-1] if self.closed and path else ()
for group, rhythm, spans in zip(self.groups, selected, prepared): for group, rhythm, spans in zip(self.groups, selected, prepared):
grips = tuple(self.base[s.key] for s in spans if s.events) grips = tuple(self.base[s.key] for s in spans if s.events)
self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False) key = (self.key(group), rhythm.signature) if part == 'acoustic' else self.key(group)
self.cache[key] = self.render(group, rhythm, spans, grips, previous, False)
cursor = fractions.Fraction() cursor = fractions.Fraction()
for m, _, i in group: for m, _, i in group:
length = generator.model.fraction(m['duration']) length = generator.model.fraction(m['duration'])
@@ -246,6 +273,52 @@ class Canonical:
lengths.append(generator.model.fraction(m['duration'])) lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1] cursor += lengths[-1]
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit) return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit)
def acoustic_realizations(self, sources, bounds=None, stop=None):
"""Assign the canonical attack stream once, before splitting it into blocks.
A variant uses the same patterns with the source's actual harmony and
participation boundaries. No selected notes from another role enter here.
"""
carried = generator.accompaniment.carried_chords(sources)
full = tuple((m, carried[i], i) for i, m in enumerate(sources))
key = (self.key(full), bounds, stop)
if key in self.acoustic_cache:
return self.acoustic_cache[key]
starts = [fractions.Fraction()]
for m in sources:
starts.append(starts[-1] + generator.model.fraction(m['duration']))
breaks = {starts[i] for i in self.pair_breaks | set(bounds or ())}
breaks.update(starts[i] + t for i, t in self.pair_stops)
if stop:
breaks.add(starts[stop[0]] + stop[1])
events, raw = [], {}
for original in self.groups:
group = tuple(full[i] for _, _, i in original)
index = group[0][2]
pattern, anticipate = self.patterns[index]
limit = starts[stop[0]] + stop[1] - starts[index] if stop and any(i == stop[0] for _, _, i in group) else None
rhythm = generator.comping_rhythm.realize(pattern, group, 'acoustic', self.melody, anticipate=anticipate, limit=limit, boundary=True, assign=False)
if rhythm is None:
raise ValueError(f'source measure {sources[index]["number"]}: selected acoustic rhythm cannot fit written harmony')
raw[index] = rhythm
events.extend(dataclasses.replace(e, start=e.start + starts[index], end=e.end + starts[index],
harmonic_time=e.harmonic_time + starts[index] if e.harmonic_time is not None else None) for e in rhythm.events)
harmony = generator.comping_rhythm.spans(generator.comping_rhythm.flatten(full), None)
assigned = generator.comping_rhythm.assign_acoustic(events, harmony, breaks)
result = {}
for original in self.groups:
group = tuple(full[i] for _, _, i in original)
index, end = group[0][2], group[-1][2] + 1
local = tuple(dataclasses.replace(e, start=e.start - starts[index], end=e.end - starts[index],
harmonic_time=e.harmonic_time - starts[index] if e.harmonic_time is not None else None)
for e in assigned if starts[index] <= e.start < starts[end])
melody = tuple((a + starts[i] - starts[index], b + starts[i] - starts[index]) for _, _, i in group for a, b in self.melody[i])
flat = generator.comping_rhythm.flatten(group)
contributions = generator.comping_rhythm.evaluate(local, 'acoustic', melody, generator.comping_rhythm.spans(flat, None), tuple(starts[i + 1] - starts[i] for _, _, i in group))
result[index] = dataclasses.replace(raw[index], events=local, contributions=contributions,
exceptions=(*raw[index].exceptions, ('layer_assignment', 'source-form combined attacks with non-overlapping same-harmony eighth pairs')))
self.acoustic_cache[key] = result
return result
def prepare(self, inputs, rhythm): def prepare(self, inputs, rhythm):
flat = generator.comping_rhythm.flatten(inputs) flat = generator.comping_rhythm.flatten(inputs)
spans = [] spans = []
@@ -272,6 +345,8 @@ class Canonical:
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),)) group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
original_group = group original_group = group
sources = sources or self.measures sources = sources or self.measures
if self.part == 'acoustic' and source != sources[index]:
sources = [source if i == index else m for i, m in enumerate(sources)]
carried = generator.accompaniment.carried_chords(sources) carried = generator.accompaniment.carried_chords(sources)
low, high = bounds or (self.first, len(sources)) low, high = bounds or (self.first, len(sources))
group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high) group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high)
@@ -279,12 +354,20 @@ class Canonical:
if stop and any(i == stop[0] for _, _, i in group): if stop and any(i == stop[0] for _, _, i in group):
limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1] limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1]
key = self.key(group, limit) key = self.key(group, limit)
complete = None
if self.part == 'acoustic' and original_group[0][2] in self.patterns:
complete = self.acoustic_realizations(sources, (low, high), stop)[original_group[0][2]]
key = (key, complete.signature)
if key not in self.cache: if key not in self.cache:
start, end = group[0][2], group[-1][2] start, end = group[0][2], group[-1][2]
offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction()) offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction())
if original_group[0][2] in self.patterns: if original_group[0][2] in self.patterns:
pattern, anticipate = self.patterns[original_group[0][2]] pattern, anticipate = self.patterns[original_group[0][2]]
if offset: if self.part == 'acoustic':
end_time = offset + (limit if limit is not None else sum(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = dataclasses.replace(complete, events=tuple(dataclasses.replace(e, start=max(e.start, offset) - offset, end=min(e.end, end_time) - offset, harmonic_time=e.harmonic_time - offset if e.harmonic_time is not None and e.harmonic_time >= offset else None)
for e in complete.events if e.start < end_time and e.end > offset))
elif offset:
# Fit the incoming context before slicing. A raw template sustain # Fit the incoming context before slicing. A raw template sustain
# may already have ended at a chord change; projecting it first # may already have ended at a chord change; projecting it first
# would resurrect that note as an unintended new downbeat attack. # would resurrect that note as an unintended new downbeat attack.
@@ -295,6 +378,7 @@ class Canonical:
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit incoming harmony') raise ValueError(f'source measure {source["number"]}: selected groove cannot fit incoming harmony')
pattern = {**pattern, 'events': [{'onset': str(e.start), 'duration': str(e.end - e.start), pattern = {**pattern, 'events': [{'onset': str(e.start), 'duration': str(e.end - e.start),
'layer': e.lane, 'anticipate': e.harmonic_time is not None and e.lane in ('upper', 'electric')} for e in incoming.events]} 'layer': e.lane, 'anticipate': e.harmonic_time is not None and e.lane in ('upper', 'electric')} for e in incoming.events]}
if self.part != 'acoustic':
pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group)) pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True) rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
if rhythm is None: if rhythm is None:
@@ -354,6 +438,8 @@ class Canonical:
if grip: if grip:
previous = grip previous = grip
key = self.key(inputs, limit) key = self.key(inputs, limit)
if self.part == 'acoustic':
key = (key, rhythm.signature)
self._reports[key] = rows self._reports[key] = rows
voices = [] voices = []
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']: for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
@@ -380,6 +466,8 @@ class Canonical:
emit(cursor, length) emit(cursor, length)
voices.append({'id': voice_id, 'notes': notes}) voices.append({'id': voice_id, 'notes': notes})
result = split_measures({'voices': voices}, inputs, self.part) result = split_measures({'voices': voices}, inputs, self.part)
if self.part == 'acoustic':
generator.acoustic.validate_assignment(result, rhythm.events)
reports, cursor = [], fractions.Fraction() reports, cursor = [], fractions.Fraction()
for m, _, i in inputs: for m, _, i in inputs:
duration = generator.model.fraction(m['duration']) duration = generator.model.fraction(m['duration'])
+58 -35
View File
@@ -131,11 +131,8 @@ def validate_events(events, part, lengths=None):
if duration <= 0: if duration <= 0:
raise ValueError('comping event must have positive sounding duration') raise ValueError('comping event must have positive sounding duration')
if part == 'acoustic': if part == 'acoustic':
if e.lane == 'combined': if e.lane not in ('bass', 'upper', 'combined') or (duration < 1 and duration != fractions.Fraction(1, 2)):
if duration < 1 and duration != fractions.Fraction(1, 2): raise ValueError('acoustic attacks require quarter/eighth notes or authored sustains')
raise ValueError('combined acoustic attacks require quarter/eighth notes or sustains')
elif e.lane not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
raise ValueError('ordinary acoustic attacks require separate quarter/eighth notes')
elif e.lane != 'electric': elif e.lane != 'electric':
raise ValueError('electric comping requires chord events') raise ValueError('electric comping requires chord events')
for i, a in enumerate(ordered): for i, a in enumerate(ordered):
@@ -143,7 +140,50 @@ def validate_events(events, part, lengths=None):
if a.end > b.start and (part != 'acoustic' or a.lane == b.lane or 'combined' in (a.lane, b.lane)): if a.end > b.start and (part != 'acoustic' or a.lane == b.lane or 'combined' in (a.lane, b.lane)):
raise ValueError('comping attacks cannot overlap on the same strings') raise ValueError('comping attacks cannot overlap on the same strings')
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None, initial_bass=True): def assign_acoustic(events, harmony, breaks=()):
"""Assign attacks, not notation fragments, across a complete source span.
Pair left to right within an effective harmony. An anticipated attack is
already playing its target harmony. Form/participation boundaries break
pairs; ordinary barlines and computational blocks do not.
"""
ordered = sorted(events, key=lambda e: e.start)
if len({e.start for e in ordered}) != len(ordered):
raise ValueError('acoustic rhythm must have one event per attack position')
def region(event):
time = event.harmonic_time if event.harmonic_time is not None else event.start
return next(i for i, (a, b, _) in enumerate(harmony) if a <= time < b)
lanes, i = ['combined'] * len(ordered), 0
while i + 1 < len(ordered):
a, b = ordered[i:i + 2]
if b.start - a.start == fractions.Fraction(1, 2) and region(a) == region(b) and not any(a.start < t <= b.start for t in breaks):
lanes[i:i + 2] = ['bass', 'upper']
i += 2
else:
i += 1
result = [dataclasses.replace(e, lane=lane) for e, lane in zip(ordered, lanes)]
# Different strings may ring together, but never overlap a fresh attack on
# the same strings. Preserve the authored release whenever it is playable.
for i, a in enumerate(result):
following = next((b.start for b in result[i + 1:] if a.lane == b.lane or 'combined' in (a.lane, b.lane)), a.end)
result[i] = dataclasses.replace(a, end=min(a.end, following))
# Shortening must not strand an anticipation before its promised arrival.
# Rebind that event to the current chord, then reconsider its pair once.
expired = {e.start for e in result if e.harmonic_time is not None and e.end <= e.harmonic_time}
if expired:
rebound = []
for e in ordered:
if e.start in expired:
boundary = next(b for a, b, _ in harmony if a <= e.start < b)
e = dataclasses.replace(e, harmonic_time=None, end=min(e.end, boundary))
rebound.append(e)
return assign_acoustic(rebound, harmony, breaks)
validate_events(result, 'acoustic')
return tuple(result)
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None, assign=True):
"""Bind a complete figure to written harmony, with bounded early arrivals.""" """Bind a complete figure to written harmony, with bounded early arrivals."""
length = generator.model.fraction(measure['duration']) length = generator.model.fraction(measure['duration'])
limit = length if limit is None else min(length, limit) limit = length if limit is None else min(length, limit)
@@ -163,35 +203,19 @@ def fit(pattern, measure, prior, part, *, anticipate=False, limit=None, initial_
for row, start, end, active in available: for row, start, end, active in available:
_, boundary, chord = active _, boundary, chord = active
target = None target = None
if anticipate and row.get('anticipate') and row['layer'] in ('upper', 'electric'): if anticipate and row.get('anticipate'):
future = next(((a, b, c) for a, b, c in harmony if a == start + fractions.Fraction(1, 2) and c), None) future = next(((a, b, c) for a, b, c in harmony if a == start + fractions.Fraction(1, 2) and c), None)
if future and future[0] < end and not generator.accompaniment.same_chord(chord, future[2]): if future and future[0] < end and not generator.accompaniment.same_chord(chord, future[2]):
target, boundary = future[0], future[1] target, boundary = future[0], future[1]
end = min(end, boundary) end = min(end, boundary)
events.append(Attack(row['layer'], start, end, target)) events.append(Attack('acoustic' if part == 'acoustic' else row['layer'], start, end, target))
if part == 'acoustic':
for a, b, chord in harmony:
if chord is None or (a == 0 and not initial_bass):
continue
local = [e for e in events if a <= (e.harmonic_time if e.harmonic_time is not None else e.start) < b]
if not local:
continue
first = min(local, key=lambda e: e.start)
if any(e.start == first.start and e.lane in ('bass', 'combined') for e in local):
continue
# Preserve the comping rhythm and upper voice. Add bass at the same
# attack, allowing different releases when the next bass enters early.
stop = min([first.end, first.start + 1, *(e.start for e in events if e.start > first.start and e.lane in ('bass', 'combined'))])
duration = next((d for d in (fractions.Fraction(1), fractions.Fraction(1, 2)) if d <= stop - first.start), None)
if duration is None:
return None
if first.end - first.start == duration:
events[events.index(first)] = dataclasses.replace(first, lane='combined')
else:
events.append(Attack('bass', first.start, first.start + duration, first.harmonic_time))
exceptions.append(('layer_assignment', f'bass included at first attack for harmony {a}: {first.start}'))
events.sort(key=lambda e: (e.start, e.lane))
try: try:
if part == 'acoustic' and assign:
events = assign_acoustic(events, harmony)
exceptions.append(('layer_assignment', 'combined attacks; same-harmony eighth pairs use bass then upper'))
elif part == 'acoustic' and any(e.end <= e.start or (e.end - e.start < 1 and e.end - e.start != fractions.Fraction(1, 2)) for e in events):
raise ValueError('acoustic articulation cannot fit this boundary')
if part != 'acoustic' or assign:
validate_events(events, part, lengths) validate_events(events, part, lengths)
except ValueError: except ValueError:
return None return None
@@ -267,11 +291,10 @@ def project(pattern, offset, lengths):
rows.append({**row, 'onset': str(max(a, offset) - offset), 'duration': str(min(b, end) - max(a, offset))}) rows.append({**row, 'onset': str(max(a, offset) - offset), 'duration': str(min(b, end) - max(a, offset))})
return {**pattern, 'lengths': list(map(str, lengths)), 'events': rows} return {**pattern, 'lengths': list(map(str, lengths)), 'events': rows}
def realize(pattern, inputs, part, melody, *, anticipate=False, limit=None, boundary=False): def realize(pattern, inputs, part, melody, *, anticipate=False, limit=None, boundary=False, assign=True):
flat = flatten(inputs) flat = flatten(inputs)
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in inputs) lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in inputs)
initial_bass = not generator.accompaniment.same_chord(inputs[0][1], generator.accompaniment.active_chord(inputs[0][0], fractions.Fraction(), inputs[0][1])) fitted = fit(pattern, flat, None, part, anticipate=anticipate, limit=limit, assign=assign)
fitted = fit(pattern, flat, None, part, anticipate=anticipate, limit=limit, initial_bass=initial_bass)
if fitted is None and boundary: if fitted is None and boundary:
# Keep the selected onset/layers at a protected handoff, using the longest # Keep the selected onset/layers at a protected handoff, using the longest
# permitted short articulation that fits rather than another groove. # permitted short articulation that fits rather than another groove.
@@ -281,11 +304,11 @@ def realize(pattern, inputs, part, melody, *, anticipate=False, limit=None, boun
a, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration']) a, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
_, b, _ = next(s for s in spans(flat, None) if s[0] <= a < s[1]) _, b, _ = next(s for s in spans(flat, None) if s[0] <= a < s[1])
duration = min(duration, b - a) duration = min(duration, b - a)
if part == 'acoustic' and (row['layer'] != 'combined' or duration <= 1): if part == 'acoustic' and duration <= 1:
duration = next((d for d in (fractions.Fraction(1), fractions.Fraction(1, 2)) if d <= duration), fractions.Fraction()) duration = next((d for d in (fractions.Fraction(1), fractions.Fraction(1, 2)) if d <= duration), fractions.Fraction())
if duration: if duration:
rows.append({**row, 'duration': str(duration)}) rows.append({**row, 'duration': str(duration)})
fitted = fit({**pattern, 'events': rows}, flat, None, part, anticipate=False, limit=limit, initial_bass=initial_bass) fitted = fit({**pattern, 'events': rows}, flat, None, part, anticipate=False, limit=limit, assign=assign)
if fitted is not None: if fitted is not None:
fitted = fitted[0], (*fitted[1], ('release', 'shortened articulation at source-defined boundary')) fitted = fitted[0], (*fitted[1], ('release', 'shortened articulation at source-defined boundary'))
if fitted is None: if fitted is None:
+9 -8
View File
@@ -31,6 +31,8 @@ def _load(contents):
policy = value['instruments'][part] policy = value['instruments'][part]
if part == 'acoustic' and policy.get('first_attack') != 'bass_per_harmony': if part == 'acoustic' and policy.get('first_attack') != 'bass_per_harmony':
raise ValueError('acoustic patterns require bass at the first attack of each harmony') raise ValueError('acoustic patterns require bass at the first attack of each harmony')
if part == 'acoustic' and policy.get('attack_assignment') != {'version': 1, 'mode': 'combined_except_eighth_pairs'}:
raise ValueError('acoustic patterns require automatic eighth-pair assignment')
groove = policy['groove'] groove = policy['groove']
if any(type(groove[k]) is not int for k in ('cycle_patterns', 'slot_candidates', 'fill_spacing_measures')) or not 1 <= groove['cycle_patterns'] <= 4 or not 4 <= groove['slot_candidates'] <= 8 or groove['fill_spacing_measures'] < 8: if any(type(groove[k]) is not int for k in ('cycle_patterns', 'slot_candidates', 'fill_spacing_measures')) or not 1 <= groove['cycle_patterns'] <= 4 or not 4 <= groove['slot_candidates'] <= 8 or groove['fill_spacing_measures'] < 8:
raise ValueError('invalid bounded accompaniment groove policy') raise ValueError('invalid bounded accompaniment groove policy')
@@ -62,16 +64,15 @@ def _load(contents):
if onset.denominator > 2 or duration.denominator > 2: if onset.denominator > 2 or duration.denominator > 2:
raise ValueError('comping vocabulary uses a quarter/eighth grid') raise ValueError('comping vocabulary uses a quarter/eighth grid')
if part == 'acoustic': if part == 'acoustic':
if row['layer'] == 'combined': if row['layer'] != 'acoustic':
if duration < 1 and duration != fractions.Fraction(1, 2): raise ValueError('acoustic pattern layers are assigned from the complete rhythm')
raise ValueError('combined acoustic attacks require quarter/eighth notes or sustains')
elif row['layer'] not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
raise ValueError('acoustic short attacks require bass/upper quarter/eighth notes')
elif row['layer'] != 'electric': elif row['layer'] != 'electric':
raise ValueError('electric pattern must contain electric chord attacks') raise ValueError('electric pattern must contain electric chord attacks')
if row.get('anticipate') and row['layer'] not in ('upper', 'electric'): if row.get('anticipate') and row['layer'] not in ('acoustic', 'electric'):
raise ValueError('only upper/electric chords can anticipate') raise ValueError('only acoustic/electric chords can anticipate')
lanes = ('bass', 'upper') if row['layer'] == 'combined' else (row['layer'],) # Acoustic overlaps are checked after deterministic string-lane
# assignment, including neighboring figures in the source form.
lanes = () if part == 'acoustic' else (row['layer'],)
if any(releases.get(lane, fractions.Fraction()) > onset for lane in lanes): if any(releases.get(lane, fractions.Fraction()) > onset for lane in lanes):
raise ValueError('overlapping accompaniment attacks on the same strings') raise ValueError('overlapping accompaniment attacks on the same strings')
releases.update({lane: onset + duration for lane in lanes}) releases.update({lane: onset + duration for lane in lanes})
+157
View File
@@ -0,0 +1,157 @@
"""Acoustic attack assignment across musical, rather than storage, boundaries."""
import copy
import dataclasses
import fractions
import unittest
import generator.acoustic
import generator.comping
import generator.comping_rhythm
import generator.fretboard
import generator.harmony
import generator.model
import generator.score
import generator.vocabulary
import tests.solo_fixtures
F = fractions.Fraction
class AcousticPairs(unittest.TestCase):
def assign(self, onsets, durations=None, harmony=None, breaks=()):
events = tuple(generator.comping_rhythm.Attack('acoustic', F(a), F(a) + F(d)) for a, d in zip(onsets, durations or ['1/2'] * len(onsets)))
harmony = harmony or [(F(0), F(40), generator.harmony.parse_symbol('C7'))]
return generator.comping_rhythm.assign_acoustic(events, harmony, breaks)
def test_unpaired_and_nonoverlapping_pairs(self):
for onsets, lanes in (
(('0', '1', '2'), ('combined',) * 3),
(('0', '1/2'), ('bass', 'upper')),
(('0', '1/2', '1'), ('bass', 'upper', 'combined')),
(('0', '1/2', '1', '3/2'), ('bass', 'upper', 'bass', 'upper')),
(('0', '1/2', '1', '3/2', '2'), ('bass', 'upper', 'bass', 'upper', 'combined')),
(('0', '1', '3/2', '3'), ('combined', 'bass', 'upper', 'combined'))):
with self.subTest(onsets=onsets):
events = self.assign(onsets)
self.assertEqual(tuple(e.lane for e in events), lanes)
self.assertEqual(tuple(e.start for e in events), tuple(map(F, onsets)))
def test_pairing_uses_attack_spacing_not_written_duration(self):
events = self.assign(('0', '1/2'), ('1', '1'))
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1)), ('upper', F(3, 2))])
# Bass keeps ringing, without another bass attack under the upper chord.
self.assertGreater(events[0].end, events[1].start)
def test_same_strings_release_but_different_strings_can_ring(self):
events = self.assign(('0', '1/2', '1'), ('2', '2', '1'))
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1)), ('upper', F(1)), ('combined', F(2))])
events = self.assign(('0', '1/2'), ('1', '3'))
self.assertEqual(events[-1].end, F(7, 2))
def test_chord_change_breaks_pair_but_repeated_label_does_not(self):
source = tests.solo_fixtures.song(count=2, chords=('C7', 'C7'))
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
pattern = {'lengths': ['4', '4'], 'events': [{'onset': a, 'duration': '1/2', 'layer': 'acoustic'} for a in ('7/2', '4')]}
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
flat['harmonies'][-1].update(generator.harmony.parse_symbol('FM7'))
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
self.assertEqual([e.lane for e in events], ['combined', 'combined'])
def test_pairing_at_barline_in_each_supported_meter(self):
for beats, grouping in ((3, ()), (4, ()), (5, (3, 2)), (5, (2, 3))):
with self.subTest(beats=beats, grouping=grouping):
source = tests.solo_fixtures.song(count=2, meter=beats, chords=('C7',))
measures = source['staves'][0]['measures']
if grouping:
for m in measures:
m['meter']['groups'][0]['beats'] = list(grouping)
m['time_signature'] = generator.model.meter_label(m['meter'])
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(measures)))
pattern = {'lengths': [str(beats)] * 2, 'events': [
{'onset': str(t), 'duration': '1', 'layer': 'acoustic'} for t in (F(beats) - F(1, 2), F(beats))]}
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
self.assertEqual(events[0].end, F(beats) + F(1, 2))
def test_anticipated_pair_plays_same_future_harmony(self):
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
pattern = {'lengths': ['4', '4'], 'events': [
{'onset': '7/2', 'duration': '1', 'layer': 'acoustic', 'anticipate': True},
{'onset': '4', 'duration': '1', 'layer': 'acoustic'}]}
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True)
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
self.assertEqual(events[0].harmonic_time, 4)
self.assertGreater(events[0].end, 4)
# A participation break prevents pairing; an expired anticipation must
# revert to the current harmony rather than keep an unresolved label.
raw, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True, assign=False)
broken = generator.comping_rhythm.assign_acoustic(raw, generator.comping_rhythm.spans(flat, None), (F(4),))
self.assertEqual([e.lane for e in broken], ['combined', 'combined'])
self.assertIsNone(broken[0].harmonic_time)
self.assertEqual(broken[0].end, 4)
def resources(self):
resources = copy.deepcopy(generator.vocabulary.load())
policy = resources['accompaniment']['instruments']['acoustic']
policy['groove']['cycle_patterns'] = 1
policy['patterns'] = [{'id': 'test.cross_block', 'family': 'quarters', 'lengths': ['4', '4'],
'events': [{'onset': t, 'duration': '1/2', 'layer': 'acoustic'} for t in ('0', '1/2', '2', '15/2')]}]
return resources
def test_pattern_boundary_carries_pair_and_odd_leftover(self):
source = tests.solo_fixtures.song(count=8, chords=('C7',))
with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'):
canonical = generator.comping.Canonical(source, 'acoustic', 'cross-block')
self.assertEqual(canonical.block_rhythms[0].events[-1].lane, 'bass')
self.assertEqual([e.lane for e in canonical.block_rhythms[2].events[:2]], ['upper', 'combined'])
measures = source['staves'][0]['measures']
rendered = [canonical(m, None, i) for i, m in enumerate(measures)]
source['staves'][0]['measures'] = rendered
source['staves'][0].update(arrangement_part_type='acoustic', tablature=True)
generator.acoustic.combine(source)
generator.model.validate_document(source)
generator.fretboard.validate_document(source)
generator.acoustic.validate_comping(source)
attacks = [n for n in generator.score.measure_notes(rendered[2]) if n['pitches']]
strings = [{p['tablature']['string'] for p in n['pitches']} for n in attacks[:2]]
self.assertEqual(strings[0], {2, 3, 4})
self.assertIn(strings[1], ({2, 3, 4, 5}, {2, 3, 4, 6}))
def test_entry_and_handoff_break_pairs_without_restarting_pattern(self):
source = tests.solo_fixtures.song(count=8, chords=('C7',))
with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'):
canonical = generator.comping.Canonical(source, 'acoustic', 'entry')
measures = source['staves'][0]['measures']
entry = canonical.acoustic_realizations(measures, (2, 8))
self.assertEqual(entry[0].events[-1].lane, 'combined')
self.assertEqual([e.lane for e in entry[2].events[:2]], ['bass', 'upper'])
stop = canonical.acoustic_realizations(measures, stop=(0, F(1, 2)))
self.assertEqual([(e.start, e.lane) for e in stop[0].events], [(F(0), 'combined')])
repeated = canonical.acoustic_realizations(measures)
self.assertEqual(repeated[2].events, canonical.block_rhythms[2].events)
def test_source_defined_partial_entry_is_consistent_in_every_iteration(self):
source = tests.solo_fixtures.song(count=10, chords=('C7',))
with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'):
canonical = generator.comping.Canonical(source, 'acoustic', 'partial')
# The source-defined eight-bar introduction starts at measure index two.
self.assertEqual(canonical.block_rhythms[0].events[-1].lane, 'combined')
self.assertEqual([e.lane for e in canonical.block_rhythms[2].events[:2]], ['bass', 'upper'])
measures = source['staves'][0]['measures']
self.assertEqual(canonical(measures[2], None, 2), canonical(measures[2], None, 2, bounds=(2, 10)))
def test_neighbor_harmony_change_invalidates_cached_pair(self):
source = tests.solo_fixtures.song(count=8, chords=('C7',))
with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'):
canonical = generator.comping.Canonical(source, 'acoustic', 'neighbor')
measures = source['staves'][0]['measures']
before = canonical(measures[1], None, 1)
changed = copy.deepcopy(measures)
changed[2]['harmonies'][0].update(generator.harmony.parse_symbol('FM7'))
after = canonical(changed[1], None, 1, sources=changed)
def last_lanes(m):
return {v['id'] for v in m['voices'] for n in v['notes'] if n['pitches'] and generator.model.fraction(n['onset']) == F(7, 2)}
self.assertEqual(last_lanes(before), {'bass'})
self.assertEqual(last_lanes(after), {'bass', 'upper'})
def test_assignment_is_independent_of_computational_partition(self):
onsets = tuple(map(F, ('0', '1/2', '7/2', '4', '9/2', '15/2', '8')))
full = self.assign(onsets)
events = [generator.comping_rhythm.Attack('acoustic', a, a + F(1, 2)) for a in onsets]
harmony = ((F(0), F(20), generator.harmony.parse_symbol('C7')),)
for cut in (F(4), F(8)):
parts = [[e for e in events if e.start < cut], [dataclasses.replace(e, start=e.start - cut, end=e.end - cut) for e in events if e.start >= cut]]
restored = (*parts[0], *(dataclasses.replace(e, start=e.start + cut, end=e.end + cut) for e in parts[1]))
self.assertEqual(full, generator.comping_rhythm.assign_acoustic(restored, harmony))
if __name__ == '__main__':
unittest.main()
+2 -2
View File
@@ -176,10 +176,10 @@ class RecurringGrooves(unittest.TestCase):
{'onset': '1/2', 'duration': '1', 'layer': 'upper'}, {'onset': '5/2', 'duration': '1/2', 'layer': 'upper'}]} {'onset': '1/2', 'duration': '1', 'layer': 'upper'}, {'onset': '5/2', 'duration': '1/2', 'layer': 'upper'}]}
events, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic') events, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic')
self.assertEqual([(e.start, e.lane) for e in events], [(fractions.Fraction(1, 2), 'combined'), (fractions.Fraction(5, 2), 'combined')]) self.assertEqual([(e.start, e.lane) for e in events], [(fractions.Fraction(1, 2), 'combined'), (fractions.Fraction(5, 2), 'combined')])
# A computational boundary inside continuing harmony must not add bass. # Unpaired attacks include bass even when the harmony is unchanged.
inputs = ((source['staves'][0]['measures'][1], source['staves'][0]['measures'][1]['harmonies'][0], 1),) inputs = ((source['staves'][0]['measures'][1], source['staves'][0]['measures'][1]['harmonies'][0], 1),)
r = generator.comping_rhythm.realize(pattern, inputs, 'acoustic', generator.comping_rhythm.melody_contexts(source)) r = generator.comping_rhythm.realize(pattern, inputs, 'acoustic', generator.comping_rhythm.melody_contexts(source))
self.assertTrue(all(e.lane == 'upper' for e in r.events)) self.assertTrue(all(e.lane == 'combined' for e in r.events))
def test_clean_cached_parallel_and_other_role_settings_keep_groove(self): def test_clean_cached_parallel_and_other_role_settings_keep_groove(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=10, chords=('C7', 'FM7'))) source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=10, chords=('C7', 'FM7')))
root = pathlib.Path('data').resolve() root = pathlib.Path('data').resolve()
+3 -3
View File
@@ -60,7 +60,7 @@ class LayeredComping(unittest.TestCase):
generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic') generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
generator.comping_rhythm.validate_events((attack('combined', F(0), F(1)),), 'acoustic') generator.comping_rhythm.validate_events((attack('combined', F(0), F(1)),), 'acoustic')
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), 'acoustic') generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), 'acoustic')
for events in ((attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)): for events in ((attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(1, 3)),)):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
generator.comping_rhythm.validate_events(events, 'acoustic') generator.comping_rhythm.validate_events(events, 'acoustic')
def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self): def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self):
@@ -77,7 +77,7 @@ class LayeredComping(unittest.TestCase):
self.assertIn(events[0].lane, ('bass', 'combined')) self.assertIn(events[0].lane, ('bass', 'combined'))
pattern = {'lengths': ['4', '4'], 'events': [{'onset': '0', 'duration': '1', 'layer': 'bass'}, {'onset': '4', 'duration': '1', 'layer': 'upper'}]} pattern = {'lengths': ['4', '4'], 'events': [{'onset': '0', 'duration': '1', 'layer': 'bass'}, {'onset': '4', 'duration': '1', 'layer': 'upper'}]}
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic') events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
self.assertEqual(events[-1].lane, 'upper') self.assertEqual(events[-1].lane, 'combined')
self.assertEqual(before, patterns) self.assertEqual(before, patterns)
def test_missing_harmony_reassigns_first_survivor_before_anticipation(self): def test_missing_harmony_reassigns_first_survivor_before_anticipation(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0]) m = copy.deepcopy(self.source['staves'][0]['measures'][0])
@@ -89,7 +89,7 @@ class LayeredComping(unittest.TestCase):
{'onset': '3/2', 'duration': '1', 'layer': 'upper', 'anticipate': True}, {'onset': '3/2', 'duration': '1', 'layer': 'upper', 'anticipate': True},
{'onset': '7/2', 'duration': '1/2', 'layer': 'bass'}]} {'onset': '7/2', 'duration': '1/2', 'layer': 'bass'}]}
events, changes = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True) events, changes = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True)
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('combined', fractions.Fraction(3, 2), fractions.Fraction(5, 2), 2), ('bass', fractions.Fraction(7, 2), 4, None)]) self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('combined', fractions.Fraction(3, 2), fractions.Fraction(5, 2), 2), ('combined', fractions.Fraction(7, 2), 4, None)])
self.assertIn('layer_assignment', {kind for kind, _ in changes}) self.assertIn('layer_assignment', {kind for kind, _ in changes})
limited, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True, limit=fractions.Fraction(2)) limited, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True, limit=fractions.Fraction(2))
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in limited], [('combined', fractions.Fraction(3, 2), 2, None)]) self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in limited], [('combined', fractions.Fraction(3, 2), 2, None)])