diff --git a/README.md b/README.md index 0d209c6..a2d8cf1 100644 --- a/README.md +++ b/README.md @@ -102,13 +102,13 @@ Six roles generate independently: acoustic accompaniment/solo; electric accompan Electric accompaniment and electric melody share compact chord families on strings 1–4. Melody harmonization adapts these to keep the original melody on the lowest-numbered feasible string, preferring it above the added chord tones. The separate Electric Solo stays monophonic. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Transposable degree families cover triads, sevenths/sixths, suspensions, diminished/augmented and extended/altered chords, with root/fifth bass alternatives on both bass strings. Plain major/minor chords also allow third basses; explicit slash basses and inversions take priority. Unplayable variants are discarded. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement. -Accompaniment uses independently authored two-bar jazz pattern families, chosen against each song's original melody and harmonic rhythm. Acoustic repeats two patterns (A–B); electric repeats four (A–B–C–D). Occasional source-phrase-ending fills recur at the same positions in every iteration; both instruments allow at most one per eight full measures, including the repeat join. Acoustic normally has 1–3 attack positions per full bar with quarter/eighth bass and upper-chord notes that may overlap or sound together. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustains and space. Cross-bar holds use ties; repeated attacks remain attacks. Pattern phase follows source-form position, including partial entries and odd-length forms. Meter changes use compatible cycles, with explicit diagnostics if fewer distinct patterns are available. Tempo changes do not reshuffle the accompaniment. +Accompaniment uses independently authored two-bar jazz pattern families, chosen against each song's original melody and harmonic rhythm. Acoustic repeats two patterns (A–B); electric repeats four (A–B–C–D). Occasional source-phrase-ending fills recur at the same positions in every iteration; both instruments allow at most one per eight full measures, including the repeat join. Acoustic normally has 1–3 attack positions per full bar with quarter/eighth bass and upper-chord notes played together or in sequential pairs. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustains and space. Cross-bar holds use ties; repeated attacks remain attacks. Pattern phase follows source-form position, including partial entries and odd-length forms. Meter changes use compatible cycles, with explicit diagnostics if fewer distinct patterns are available. Tempo changes do not reshuffle the accompaniment. Each instrument has 24 distinct two-bar patterns in 4/4, 24 in 3/4, and 24 for each of the 3+2 and 2+3 groupings in 5/4. Written additive grouping takes precedence. Otherwise, the original chord changes and melody choose one consistent 5/4 grouping per continuous meter section, shared as source analysis by the independently generated parts. Explicit meter matching prevents treating 6/8 as 3/4. The learned solo model and playback count-in are unchanged by this accompaniment expansion. 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. -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. +Acoustic attacks normally play bass and upper chord together. Consecutive attacks exactly an eighth note apart split into 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. Every new acoustic attack releases the previous sound: a bass eighth followed by an upper-chord quarter has no bass underneath the quarter. Complete-cycle selection, fills and final realization use this same release rule. Bass and upper notes share one engraved part; uninterrupted holds may still tie across barlines. 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. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 8f29936..6256c1c 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -145,17 +145,17 @@ Fills replace only a two-bar figure's ending bar, using a same-family variation 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`). -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. +Quarter/eighth articulations and explicitly authored sustains release at the earliest of their intended end, the next acoustic attack in either lane, or a required harmonic/handoff boundary. Bass cannot ring beneath a later upper attack, nor can an upper chord ring beneath a later bass attack. Combined attacks have one shared release. Shortening that would strand an anticipation first rebinds it to current harmony and recomputes assignment. `acoustic_context` and `realize_acoustic_form` implement the shared source-form contract used by cycle comparison, fill selection and final source-harmony variants, before grip placement or notation splitting. Raw candidates retain authored releases so a changed neighboring attack can be evaluated afresh. Fills must improve the resolved whole-form cost without changing events outside their editable second bar. 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 and rejects any overlap between distinct acoustic attacks, even across different lanes. Placement validates all simultaneously sounding strings, fret spans and anchors. Bass and upper events merge into one notated part. Uninterrupted cross-bar holds and the combined three-bar closing sustain remain intact. 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. -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. +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.retrieval_cost` is the isolated-figure estimate used for shortlisting. `cost` is the actual selected base cycle's cost before optional fills; acoustic cycles are assigned and released together before this comparison. Acoustic `final_cost` reports the complete source-form result after fills and any meter-section joins. The selected score is never overwritten by a post-selection report. 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. This release correction leaves musical data and role choice seeds unchanged; code fingerprints invalidate stale concrete caches. 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/`. +Acoustic sequential-release verification on October 7, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 451 tests in 162.318 seconds. Coverage includes release in both lane directions, cross-bar pairing in 3/4, 4/4 and both 5/4 groupings, expired and surviving anticipations, complete-cycle ranking versus isolated retrieval, fills protecting neighboring assignments, malformed overlap before/after notation merging, canonical reuse, cold/warm caches and parallel roles. `python3 modulus.py format --check` passed. `python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` passed both generator/asset checks; 394 score JSON/PNG artifacts match across targets. Acoustic composition took 2.1 seconds for All of Me and 2.2 seconds for Autumn Leaves; native assets finished in 31 seconds and the cached ARM build in 11 seconds. Compared with the saved pre-change output, all attack pitches, onsets and TAB positions are identical: only 67 Autumn Leaves bass releases are shorter. Its arrangement measure 3 now contains bass [0, 1/2), upper chord [1/2, 3/2), and combined chord [2, 3), without ties. All of Me and the ten other-role documents are unchanged. Canonical reuse audits pass, and both three-bar instrumental endings retain one twelve-beat attack. Both acoustic opening pages were visually inspected; no audio audition was performed. The comparison baseline remains outside the repository under `/tmp/vamp-sequential-acoustic-yt_irkfd`. 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. diff --git a/generator/acoustic.py b/generator/acoustic.py index 8b5ee84..59c96f0 100644 --- a/generator/acoustic.py +++ b/generator/acoustic.py @@ -162,12 +162,12 @@ def validate_comping(document: dict) -> None: raise ValueError('acoustic comping requires quarter/eighth notes or authored sustains') events.append((start, end)) events.sort() - # Distinct lanes can overlap. The physical validator still checks sounding - # strings and grip span, including staggered releases after voice merging. + # A combined attack is one interval with both lanes. Separate attacks must + # be sequential, including bass/upper alternation and cross-bar ties. for i, a in enumerate(events): for b in events[i + 1:]: - if a[1] > b[0] and groups[a] & groups[b]: - raise ValueError('acoustic attacks overlap within the same lane') + if a[1] > b[0]: + raise ValueError('acoustic sound must release before the next attack') spans = [] for span in generator.harmonic_time.read(document['staves'][0]): if spans and generator.accompaniment.same_chord(spans[-1].chord, span.chord): diff --git a/generator/comping.py b/generator/comping.py index 2983127..6da5eeb 100644 --- a/generator/comping.py +++ b/generator/comping.py @@ -208,10 +208,6 @@ class Canonical: size = 2 self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size))) index += size - choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed) - 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)} - selected = [r for _, _, r in choices] self.acoustic_cache = {} self.pair_breaks = {self.first, len(self.measures)} self.pair_stops = [] @@ -229,13 +225,11 @@ class Canonical: 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' + breaks = [(i, fractions.Fraction()) for i in self.pair_breaks] + self.pair_stops + choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed, breaks=breaks) + 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)} + selected = [r for _, _, r in choices] 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)] active = tuple(span for spans in prepared for span in spans if span.events) @@ -287,11 +281,10 @@ class Canonical: 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) + breaks = [(i, fractions.Fraction()) for i in self.pair_breaks | set(bounds or ())] + self.pair_stops if stop: - breaks.add(starts[stop[0]] + stop[1]) - events, raw = [], {} + breaks.append(stop) + groups, raw = [], [] for original in self.groups: group = tuple(full[i] for _, _, i in original) index = group[0][2] @@ -300,23 +293,11 @@ class Canonical: 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'))) + raw.append(rhythm) + groups.append(group) + context = generator.comping_rhythm.acoustic_context(groups, self.melody, breaks) + realized = generator.comping_rhythm.realize_acoustic_form(raw, context) + result = {g[0][2]: r for g, r in zip(groups, realized)} self.acoustic_cache[key] = result return result def prepare(self, inputs, rhythm): diff --git a/generator/comping_rhythm.py b/generator/comping_rhythm.py index 7af2da3..dcf26c9 100644 --- a/generator/comping_rhythm.py +++ b/generator/comping_rhythm.py @@ -137,8 +137,8 @@ def validate_events(events, part, lengths=None): raise ValueError('electric comping requires chord events') for i, a in enumerate(ordered): for b in ordered[i + 1:]: - 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') + if a.end > b.start: + raise ValueError('comping sound must release before the next attack') def assign_acoustic(events, harmony, breaks=()): """Assign attacks, not notation fragments, across a complete source span. @@ -164,10 +164,10 @@ def assign_acoustic(events, harmony, breaks=()): 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. + # One rhythm-guitar stream: every new attack releases the previous sound, + # including a change of lane. A combined attack has one shared release. 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) + following = result[i + 1].start if i + 1 < len(result) else 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. @@ -325,7 +325,53 @@ def identity(pattern): """Layer swaps and anticipation permissions alone are not different rhythms.""" return tuple((fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']) -def choose_cycles(groups, part, melody, seed): +def acoustic_context(groups, melody, breaks=()): + """Immutable source timing for assigning and evaluating a complete stream. + + Breaks are (source measure index, offset) pairs. Ordinary storage boundaries + are absent: adjacent figures must see each other's attacks before scoring. + """ + blocks, starts, cursor = [], {}, fractions.Fraction() + for group in groups: + start, context, lengths = cursor, [], [] + for m, _, i in group: + starts[i] = cursor + context.extend((a + cursor - start, b + cursor - start) for a, b in melody[i]) + lengths.append(generator.model.fraction(m['duration'])) + cursor += lengths[-1] + starts[i + 1] = cursor + blocks.append((start, cursor, tuple(context), spans(flatten(group), None), tuple(lengths))) + full = tuple(row for group in groups for row in group) + return {'blocks': tuple(blocks), 'harmony': spans(flatten(full), None), + 'breaks': tuple(sorted(starts[i] + t for i, t in breaks if i in starts))} + +def realize_acoustic_form(rhythms, context): + """The shared release/lane contract for selection, fills and final output. + + Inputs retain authored releases. Re-evaluate those releases whenever an + edited neighboring figure changes the next attack; never restore duration + from previously shortened notation. + """ + if len(rhythms) != len(context['blocks']): + raise ValueError('acoustic realization requires one rhythm per source block') + events = [] + for rhythm, (start, _, _, _, _) in zip(rhythms, context['blocks']): + events.extend(dataclasses.replace(e, start=e.start + start, end=e.end + start, harmonic_time=e.harmonic_time + start if e.harmonic_time is not None else None) for e in rhythm.events) + assigned = assign_acoustic(events, context['harmony'], context['breaks']) + result = [] + for rhythm, (start, end, melody, harmony, lengths) in zip(rhythms, context['blocks']): + local = tuple(dataclasses.replace(e, start=e.start - start, end=e.end - start, harmonic_time=e.harmonic_time - start if e.harmonic_time is not None else None) + for e in assigned if start <= e.start < end) + exceptions = tuple(e for e in rhythm.exceptions if e[0] != 'layer_assignment') + result.append(dataclasses.replace(rhythm, events=local, contributions=evaluate(local, 'acoustic', melody, harmony, lengths), + pattern_id=rhythm.pattern_id.removesuffix('.anticipated') + ('.anticipated' if any(e.harmonic_time is not None for e in local) else ''), + exceptions=(*exceptions, ('layer_assignment', 'combined attacks or bass/upper eighth pairs; release at every next attack')))) + return tuple(result) + +def form_cost(rhythms, groups): + return sum(r.cost * len(g) for r, g in zip(rhythms, groups)) + +def choose_cycles(groups, part, melody, seed, *, breaks=()): """Select a small recurring vocabulary against every occurrence in the form. No local shortlist precedes whole-form evaluation. A pattern/mode is retained @@ -349,12 +395,19 @@ def choose_cycles(groups, part, melody, seed): requested = settings['cycle_patterns'] identities = {p['id']: identity(p) for p in patterns} available = len(set(identities.values())) - cache = {} + cache, estimates = {}, {} + context = acoustic_context(run, melody, breaks) if part == 'acoustic' else None for i, group in enumerate(run): lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in group) + local_context = acoustic_context((group,), melody, breaks) if context else None for p in patterns: for anticipate in (False, True): - cache[i, p['id'], anticipate] = realize(project(p, fractions.Fraction(), lengths), group, part, melody, anticipate=anticipate) + value = realize(project(p, fractions.Fraction(), lengths), group, part, melody, anticipate=anticipate, assign=part != 'acoustic') + cache[i, p['id'], anticipate] = value + try: + estimates[i, p['id'], anticipate] = realize_acoustic_form((value,), local_context)[0] if context and value is not None else value + except ValueError: + estimates[i, p['id'], anticipate] = None winner, examined = None, 0 for width in range(min(requested, len(run), available), 0, -1): layers = [] @@ -362,7 +415,7 @@ def choose_cycles(groups, part, melody, seed): unique = {} for p in patterns: for anticipate in (False, True): - values = [cache[i, p['id'], anticipate] for i in range(slot, len(run), width)] + values = [estimates[i, p['id'], anticipate] for i in range(slot, len(run), width)] if any(v is None for v in values): continue cost = sum(v.cost * len(run[i]) for i, v in zip(range(slot, len(run), width), values)) @@ -383,7 +436,14 @@ def choose_cycles(groups, part, melody, seed): if len({identities[o[1]['id']] for o in cycle}) != width: continue examined += 1 - rank = (sum(o[0][0] for o in cycle), tuple(o[0][1] for o in cycle)) + cost = sum(o[0][0] for o in cycle) + if context: + values = [cache[i, cycle[i % width][1]['id'], cycle[i % width][2]] for i in range(len(run))] + try: + cost = form_cost(realize_acoustic_form(values, context), run) + except ValueError: + continue + rank = (cost, tuple(o[0][1] for o in cycle)) if winner is None or rank < winner[0]: winner = rank, cycle if winner is not None: @@ -395,16 +455,24 @@ def choose_cycles(groups, part, melody, seed): 'grouping': list(grouping), 'grouping_evidence': grouping_evidence, 'eligible_patterns': len(patterns), 'requested_patterns': requested, 'cycle_patterns': len(cycle), 'patterns': [o[1]['id'] for o in cycle], 'anticipation': [o[2] for o in cycle], 'cost': winner[0][0], 'complete_cycles_compared': examined, + 'retrieval_cost': sum(o[0][0] for o in cycle), 'reduction': 'available distinct, legal patterns or source length' if len(cycle) < requested else None} reports.append(report) for i, group in enumerate(run): _, p, anticipate = cycle[i % len(cycle)] selected.append((p, anticipate, cache[i, p['id'], anticipate])) locations.append({'cycle_start': run[0][0][2], 'cycle_slot': i % len(cycle), 'base_pattern': p['id'], 'fill': None}) - add_fills(groups, selected, locations, part, melody, seed) + context = acoustic_context(groups, melody, breaks) if part == 'acoustic' else None + add_fills(groups, selected, locations, part, melody, seed, context=context) + if context: + realized = realize_acoustic_form([r for _, _, r in selected], context) + selected = [(p, a, r) for (p, a, _), r in zip(selected, realized)] + for report in reports: + report['final_cost'] = sum(r.cost * len(g) for g, r in zip(groups, realized) if report['source_start'] <= g[0][2] <= report['source_end']) + report['attack_assignment'] = 'sequential_combined_except_eighth_pairs' return selected, locations, reports -def add_fills(groups, selected, locations, part, melody, seed): +def add_fills(groups, selected, locations, part, melody, seed, *, context=None): """Fixed source-position answers; no iteration seed or generated lead input.""" policy = generator.vocabulary.accompaniment(part) spacing = policy['groove']['fill_spacing_measures'] @@ -415,7 +483,29 @@ def add_fills(groups, selected, locations, part, melody, seed): options = [] if body_length < spacing: return - for i, (group, (base, anticipate, current)) in enumerate(zip(groups, selected)): + original = realize_acoustic_form([r for _, _, r in selected], context) if context else tuple(r for _, _, r in selected) + + def proposal(i, candidate, baseline): + if not context: + return candidate.cost < baseline[i].cost - 1e-9, baseline[i].cost - candidate.cost + raw = [r for _, _, r in selected] + raw[i] = candidate + try: + revised = realize_acoustic_form(raw, context) + except ValueError: + return False, 0 + middle = generator.model.fraction(groups[i][0][0]['duration']) + # A second-bar fill must not alter the preceding/following block's lane + # assignment or release, nor any part of its own first bar. + if any(a.events != b.events for j, (a, b) in enumerate(zip(baseline, revised)) if j != i): + return False, 0 + if tuple(e for e in baseline[i].events if e.start < middle) != tuple(e for e in revised[i].events if e.start < middle): + return False, 0 + gain = form_cost(baseline, groups) - form_cost(revised, groups) + return gain > 1e-9, gain + + for i, (group, (base, anticipate, _)) in enumerate(zip(groups, selected)): + current = original[i] if len(group) != 2: continue last, _, index = group[-1] @@ -434,17 +524,21 @@ def add_fills(groups, selected, locations, part, melody, seed): hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events': [r for r in base['events'] if fractions.Fraction(r['onset']) < middle] + [r for r in variation['events'] if fractions.Fraction(r['onset']) >= middle]} - candidate = realize(hybrid, group, part, melody, anticipate=anticipate) - if candidate is None or candidate.cost >= current.cost - 1e-9: + candidate = realize(hybrid, group, part, melody, anticipate=anticipate, assign=part != 'acoustic') + if candidate is None: continue - if tuple(e for e in candidate.events if e.start < middle) != tuple(e for e in current.events if e.start < middle): + if not context and tuple(e for e in candidate.events if e.start < middle) != tuple(e for e in current.events if e.start < middle): + continue + valid, gain = proposal(i, candidate, original) + if not valid: continue - gain = current.cost - candidate.cost options.append((-gain, generator.solo.policy.stable_rank(seed, (index, hybrid['id'])), i, index, hybrid, anticipate, candidate, reason)) chosen = [] for _, _, i, index, pattern, anticipate, candidate, reason in sorted(options): if any(min(abs(index - other), body_length - abs(index - other)) < spacing for other in chosen): continue + if context and not proposal(i, candidate, realize_acoustic_form([r for _, _, r in selected], context))[0]: + continue selected[i] = pattern, anticipate, candidate locations[i]['fill'] = {'pattern': pattern['id'], 'source_index': index, 'reason': reason} chosen.append(index) diff --git a/tests/test_acoustic_pairs.py b/tests/test_acoustic_pairs.py index 83937de..b86a39d 100644 --- a/tests/test_acoustic_pairs.py +++ b/tests/test_acoustic_pairs.py @@ -35,12 +35,13 @@ class AcousticPairs(unittest.TestCase): 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): + self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(3, 2))]) + self.assertEqual(events[0].end, events[1].start) + def test_each_attack_releases_previous_sound_in_both_directions(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))]) + self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('combined', F(2))]) + events = self.assign(('0', '1/2', '1', '3/2'), ('2', '2', '2', '2')) + self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('bass', F(3, 2)), ('upper', F(7, 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): @@ -66,17 +67,17 @@ class AcousticPairs(unittest.TestCase): {'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): + self.assertEqual(events[0].end, F(beats)) + def test_release_rebinds_expired_anticipation_to_direct_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) + self.assertEqual([e.lane for e in events], ['combined', 'combined']) + self.assertIsNone(events[0].harmonic_time) + self.assertEqual(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) @@ -84,6 +85,11 @@ class AcousticPairs(unittest.TestCase): self.assertEqual([e.lane for e in broken], ['combined', 'combined']) self.assertIsNone(broken[0].harmonic_time) self.assertEqual(broken[0].end, 4) + # With no intervening attack the bounded anticipated hold stays intact. + pattern['events'].pop() + held, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True) + self.assertEqual(held[0].harmonic_time, 4) + self.assertEqual(held[0].end, F(9, 2)) def resources(self): resources = copy.deepcopy(generator.vocabulary.load()) policy = resources['accompaniment']['instruments']['acoustic'] diff --git a/tests/test_comping_cycles.py b/tests/test_comping_cycles.py index b893519..64dfb26 100644 --- a/tests/test_comping_cycles.py +++ b/tests/test_comping_cycles.py @@ -65,6 +65,58 @@ class RecurringGrooves(unittest.TestCase): selected, _, report = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'coverage') self.assertTrue(all(p['id'] == 'electric.quarters.statement' for p, _, _ in selected)) self.assertEqual(report[0]['cost'], 0) + def test_acoustic_cycle_is_ranked_after_cross_block_assignment(self): + source = tests.solo_fixtures.song(count=4, chords=('C7',)) + resources = copy.deepcopy(generator.vocabulary.load()) + policy = resources['accompaniment']['instruments']['acoustic'] + policy['groove']['cycle_patterns'] = 1 + policy['patterns'] = [{'id': name, 'family': 'quarters', 'lengths': ['4', '4'], 'events': [ + {'onset': t, 'duration': '1', 'layer': 'acoustic'} for t in times]} + for name, times in (('crossing', ('0', '1/2', '15/2')), ('local', ('0', '2', '6')))] + def evaluated(events, *args): + # Isolated retrieval prefers 'local' (0) over 'crossing' (1). + # The complete stream permits a prepared upper entry in block two. + cost = -3 if events[0].lane == 'upper' else 1 if events[1].start == fractions.Fraction(1, 2) else 0 + return [('test_entry', cost)] + with generator.vocabulary.using(resources, 'acoustic_accompaniment'), unittest.mock.patch('generator.comping_rhythm.evaluate', side_effect=evaluated): + selected, _, reports = generator.comping_rhythm.choose_cycles(groups(source), 'acoustic', generator.comping_rhythm.melody_contexts(source), 'complete') + self.assertEqual([p['id'] for p, _, _ in selected], ['crossing', 'crossing']) + self.assertEqual([r.events[0].lane for _, _, r in selected], ['bass', 'upper']) + self.assertEqual(reports[0]['retrieval_cost'], 4) + self.assertEqual(reports[0]['cost'], -4) + self.assertEqual(reports[0]['cost'], sum(r.cost * 2 for _, _, r in selected)) + self.assertEqual(reports[0]['cost'], reports[0]['final_cost']) + for _, _, rhythm in selected: + generator.comping_rhythm.validate_events(rhythm.events, 'acoustic') + def test_acoustic_fill_cannot_reassign_neighboring_attacks(self): + source = tests.solo_fixtures.song(count=8, chords=('C7',)) + source['staves'][0]['measures'][2]['section_label'] = 'Source boundary' + resources = copy.deepcopy(generator.vocabulary.load()) + policy = resources['accompaniment']['instruments']['acoustic'] + policy['patterns'] = [{'id': name, 'family': 'quarters', 'lengths': ['4', '4'], 'events': [ + {'onset': t, 'duration': '1/2', 'layer': 'acoustic'} for t in ('0', '2', last)]} + for name, last in (('base', '5'), ('unsafe', '15/2'), ('safe', '7'))] + source_groups = groups(source) + melody = generator.comping_rhythm.melody_contexts(source) + context = generator.comping_rhythm.acoustic_context(source_groups, melody) + base = policy['patterns'][0] + selected = [(base, False, generator.comping_rhythm.realize(base, g, 'acoustic', melody, assign=False)) for g in source_groups] + locations = [{'fill': None} for _ in selected] + original = generator.comping_rhythm.realize_acoustic_form([r for _, _, r in selected], context) + def evaluated(events, part, melody, *args): + # Only the first block has this source entrance. The preferred local + # fill pairs with the next block's first attack, so it is ineligible. + eligible = melody[0][0] == 0 + cost = (-2 if events[-1].start == fractions.Fraction(15, 2) else -1 if events[-1].start == 7 else 0) if eligible else 0 + return [('test_fill', cost)] + # Distinguish source contexts without relying on the pattern or part ID. + contexts = tuple(m if i < 2 else tuple((a + fractions.Fraction(1, 4), b) for a, b in m) for i, m in enumerate(melody)) + context = generator.comping_rhythm.acoustic_context(source_groups, contexts) + with generator.vocabulary.using(resources, 'acoustic_accompaniment'), unittest.mock.patch('generator.comping_rhythm.evaluate', side_effect=evaluated): + generator.comping_rhythm.add_fills(source_groups, selected, locations, 'acoustic', contexts, 'fill', context=context) + self.assertEqual(locations[0]['fill']['pattern'], 'base.fill.safe') + final = generator.comping_rhythm.realize_acoustic_form([r for _, _, r in selected], context) + self.assertEqual([r.events for r in original[1:]], [r.events for r in final[1:]]) def test_melody_and_progression_influence_selection_with_fixed_seed(self): source = tests.solo_fixtures.song(count=16, chords=('C7', 'FM7')) sparse = copy.deepcopy(source) @@ -149,8 +201,9 @@ class RecurringGrooves(unittest.TestCase): after = canonical(changed[6], changed[5]['harmonies'][0], 6, sources=changed) self.assertEqual(signature(before), signature(after)) self.assertEqual(canonical.rhythm_report[-1]['base_pattern'], canonical.locations[6]['base_pattern']) - def test_overlap_survives_single_voice_merge_and_tablature(self): - source = tests.solo_fixtures.song(count=2, chords=('C7',)) + def test_bass_eighth_then_upper_quarter_survives_single_voice_merge(self): + source = tests.solo_fixtures.song(count=2, chords=('Dm7',)) + source['staves'][0]['measures'][0]['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'middle', 'onset': generator.model.rational(2)}) resources = copy.deepcopy(generator.vocabulary.load()) policy = resources['accompaniment']['instruments']['acoustic'] policy['patterns'] = [p for p in policy['patterns'] if p['id'] == 'acoustic.quarters.overlapping'] @@ -165,9 +218,14 @@ class RecurringGrooves(unittest.TestCase): generator.acoustic.validate_comping(source) first = source['staves'][0]['measures'][0] self.assertEqual(len(first['voices']), 1) - overlap = next(n for n in generator.score.measure_notes(first) if generator.model.fraction(n['onset']) == fractions.Fraction(1, 2)) - self.assertEqual(len(overlap['pitches']), 4) - self.assertTrue(any(p.get('tie_from') and p['tablature']['string'] >= 5 for p in overlap['pitches'])) + notes = [n for n in generator.score.measure_notes(first) if n['pitches']] + self.assertEqual([(generator.model.fraction(n['onset']), generator.model.fraction(n['duration'])) for n in notes], + [(0, fractions.Fraction(1, 2)), (fractions.Fraction(1, 2), 1), (2, 1)]) + strings = [{p['tablature']['string'] for p in n['pitches']} for n in notes] + self.assertIn(strings[0], ({5}, {6})) + self.assertEqual(strings[1], {2, 3, 4}) + self.assertIn(strings[2], ({2, 3, 4, 5}, {2, 3, 4, 6})) + self.assertFalse(any(p.get('tie_from') or p.get('tie_to') for n in notes for p in n['pitches'])) def test_mid_measure_chord_change_bass_without_forced_barline_attack(self): source = tests.solo_fixtures.song(count=2, chords=('C7',)) m = source['staves'][0]['measures'][0] diff --git a/tests/test_comping_rhythm.py b/tests/test_comping_rhythm.py index aa1b072..080e9f6 100644 --- a/tests/test_comping_rhythm.py +++ b/tests/test_comping_rhythm.py @@ -1,4 +1,4 @@ -"""Independent rhythm contracts, including real overlapping guitar voices.""" +"""Independent rhythm contracts and their complete sounding durations.""" import copy import dataclasses import fractions @@ -53,14 +53,13 @@ class LayeredComping(unittest.TestCase): self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic'])) self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events)) self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events)) - def test_separate_lanes_can_overlap_and_short_combined_attacks_are_valid(self): + def test_separate_attacks_cannot_overlap_and_combined_attacks_are_valid(self): F = fractions.Fraction attack = generator.comping_rhythm.Attack generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), 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('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(1, 3)),)): + for events in ((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), (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): generator.comping_rhythm.validate_events(events, 'acoustic') def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self): @@ -72,7 +71,10 @@ class LayeredComping(unittest.TestCase): if pattern['lengths'] != ['4', '4']: continue events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic') - self.assertEqual([(e.start, e.end - e.start) for e in events], [(fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']]) + self.assertEqual([e.start for e in events], [fractions.Fraction(r['onset']) for r in pattern['events']]) + for i, (e, r) in enumerate(zip(events, pattern['events'])): + intended = e.start + fractions.Fraction(r['duration']) + self.assertEqual(e.end, min(intended, events[i + 1].start) if i + 1 < len(events) else intended) if events: self.assertIn(events[0].lane, ('bass', 'combined')) pattern = {'lengths': ['4', '4'], 'events': [{'onset': '0', 'duration': '1', 'layer': 'bass'}, {'onset': '4', 'duration': '1', 'layer': 'upper'}]} @@ -104,7 +106,7 @@ class LayeredComping(unittest.TestCase): pattern = {'id': 'test.delayed', 'family': 'call_response', 'lengths': ['4', '4'], 'events': [ {'onset': '1', 'duration': '1/2', 'layer': 'upper'}, {'onset': '2', 'duration': '1', 'layer': 'bass'}, {'onset': '5', 'duration': '1', 'layer': 'combined'}]} - # A long combined event remains available alongside short overlaps. + # A long combined event remains available alongside short attacks. pattern['events'][-1]['duration'] = '2' resources = copy.deepcopy(self.resources) resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern] @@ -117,6 +119,21 @@ class LayeredComping(unittest.TestCase): first['voices'] = [v for v in first['voices'] if v['id'] != 'bass'] with self.assertRaisesRegex(ValueError, 'first acoustic attack'): generator.acoustic.validate_comping(source) + def test_completed_score_rejects_bass_under_upper_before_and_after_merge(self): + source = tests.solo_fixtures.song(count=1, chords=('C7',)) + source['staves'][0]['arrangement_part_type'] = 'acoustic' + source['staves'][0]['measures'][0]['voices'] = [ + {'id': 'bass', 'notes': [generator.model.make_event('bass', fractions.Fraction(), fractions.Fraction(1), 'note', [ + {'letter': 'c', 'alter': 0, 'octave': 3, 'tablature': {'string': 5, 'fret': 3}}])]}, + {'id': 'upper', 'notes': [generator.model.make_event('upper', fractions.Fraction(1, 2), fractions.Fraction(1), 'note', [ + {'letter': 'e', 'alter': 0, 'octave': 3, 'tablature': {'string': 4, 'fret': 2}}])]}] + with self.assertRaisesRegex(ValueError, 'release before the next attack'): + generator.acoustic.validate_comping(source) + generator.acoustic.combine(source) + # The generic merger preserves imported overlaps faithfully. Generated + # accompaniment must fail even when the overlap is encoded using ties. + with self.assertRaisesRegex(ValueError, 'release before the next attack'): + generator.acoustic.validate_comping(source) def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self): source = copy.deepcopy(self.source) for m in source['staves'][0]['measures'][:2]: @@ -178,7 +195,7 @@ class LayeredComping(unittest.TestCase): 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): + def test_assembly_keeps_releases_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;