"""Constrained, deterministically seeded autoregressive musical sampling. The pooled model proposes joint note/rest, duration and pitch events. Complete expression actions include their real resolution. Guitar and chord/function validity precede sampling. No target access, hand-authored melody scorer or legacy fallback. A bounded restart handles an exhausted/entirely silent sample. """ from __future__ import annotations import collections import dataclasses import fractions import hashlib import math import random import generator.harmony import generator.model import generator.solo.expressions import generator.solo.groups import generator.solo.learned import generator.solo.types import generator.vocabulary class SearchExhausted(ValueError): pass @dataclasses.dataclass(frozen=True) class State: events: tuple = () notes: tuple = () contracts: tuple = () history: tuple = () previous: int | None = None cost: float = 0. def event_key(events): return tuple((a, b, -1 if p is None else p) for a, b, p in events) class Decoder: def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed='', composition=None): self.composition = composition or generator.vocabulary.current() if type(attempts) is not int or not 1 <= attempts <= 16: raise ValueError('attempts must be between 1 and 16') self.analysis, self.harmony, self.model, self.role = analysis, harmony, model, role self.start, self.end = start, end self.attempts = attempts source = tuple((str(n.start), str(n.end), n.pitch.number()) for n in analysis.melody) self.seed = seed or hashlib.sha256(repr((source, role, generator.solo.learned.VERSION)).encode()).hexdigest() self.expressions_enabled = expressions_enabled self.low, self.high = harmony.profile.register limits = self.composition.data('roles')['difficulty'] self.durations = tuple(d for d in model.durations if 0 < d <= 4 and d.denominator in (1, 2, 4) and (d.denominator != 4 or analysis.tempo <= limits['sixteenth_max_bpm'])) self.allow_triplets = analysis.tempo <= limits['triplet_max_bpm'] self._actions = {} self._context = {} self._pitches = {} self._colors = {} self._permissions = {} self.trace = [] self.counts = collections.Counter() def context(self, time): if time not in self._context: harmonic, root, future = generator.solo.learned.chord_context(self.harmony, time) self._context[time] = (harmonic, root, generator.solo.learned.phase_at(self.analysis, time), generator.solo.learned.source_feature(self.analysis, time, root), future) return self._context[time] def pitch(self, number, time): span = self.harmony.at(time) identity = (number, span.start if span else None) if identity not in self._pitches: self._pitches[identity] = self._spell(number, time) return self._pitches[identity] def _spell(self, number, time): span = self.harmony.at(time) if span and span.chord != 'N.C.': chord = generator.solo.expressions.parsed_chord(span.chord) for degree, alter in generator.harmony.degrees(chord).items(): p = generator.solo.expressions.degree_pitch(chord, degree, alter, number) if p.number() == number: return p for letter, alter in zip(generator.model.LETTERS, span.key['alterations']): accidental = generator.model.fraction(alter) if (generator.model.NATURAL[letter] + accidental) % 12 == number % 12: return generator.solo.types.Pitch(letter, accidental, int((number - generator.model.NATURAL[letter] - accidental) // 12) - 1) names = (('C', 0), ('D', -1), ('D', 0), ('E', -1), ('E', 0), ('F', 0), ('F', 1), ('G', 0), ('A', -1), ('A', 0), ('B', -1), ('B', 0)) letter, alter = names[number % 12] return generator.solo.types.Pitch(letter.lower(), fractions.Fraction(alter), number // 12 - 1) def note(self, a, b, p, role='learned'): pitch = p if isinstance(p, generator.solo.types.Pitch) else self.pitch(p, a) return generator.solo.types.Note(a, b, pitch, role=role, event_id=f'{a}:{b}:{pitch.number()}') def color_bases(self, n): key = (n.start, n.end, n.pitch.number()) if key in self._colors: return self._colors[key] bases = [] regions = [(None, n)] regions.extend((s.start, dataclasses.replace(n, start=s.start)) for s in self.harmony.spans if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(self.composition.data('harmony')['anticipation_max'])) for boundary, arrived in regions: exposed = [s for s, _, _ in self.harmony.segments(arrived) if n.pitch.number() % 12 not in s.roles.basic | s.roles.specified] if not exposed: continue for rule in self.composition.data('harmony')['colors']: if arrived.end - arrived.start > fractions.Fraction(rule['max_beats']): continue if any(s.chord == 'N.C.' or not generator.solo.expressions.eligible_color(rule, s, self.harmony) or generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(s.chord), rule['degree'], rule['alter'], n.pitch.number()).number() != n.pitch.number() for s in exposed): continue bases.append((rule, boundary)) self._colors[key] = tuple(bases) return self._colors[key] def color_contract(self, n, target, rule, boundary): span = self.harmony.at(boundary if boundary is not None else n.start) if not span: return None kind = 'anticipated_color' if boundary is not None else 'color' return generator.solo.types.ExpressionContract(n.event_id + ':' + rule['id'] + ':' + str(boundary), kind, rule['id'], (n,), target, span.chord, boundary) def permission(self, n): key = (n.start, n.end, n.pitch.number()) if key not in self._permissions: self._permissions[key] = self._permission(n) return self._permissions[key] def _permission(self, n): """Find justified color/anticipation support, never authorize by label.""" # Source spans are contiguous. These are precisely the ordinary-event # branches of Harmony.evaluate, without rebuilding an empty contract map. if self.harmony.at(n.start) and n.end <= self.harmony.spans[-1].end: if n.pitch.number() % 12 in self.harmony.members(n.start, n.end): return () if n.end - n.start < 2 and n.pitch.number() % 12 in self.harmony.permitted(n.start, n.end): return () if not self.expressions_enabled: return None span = self.harmony.at(n.start) if not span or span.chord == 'N.C.': return None for rule, boundary in self.color_bases(n): if rule.get('resolve'): continue c = self.color_contract(n, n, rule, boundary) if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid: return tuple([c]) for arriving in self.harmony.spans: if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(self.composition.data('harmony')['anticipation_max']): c = generator.solo.types.ExpressionContract(n.event_id + ':anticipation', 'anticipation', 'early_chord_tone', (n,), n, arriving.chord, arriving.start) if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid: return tuple([c]) return None def tails(self, n, target): """Finite resolution timing: direct, real space, or one valid intervening note.""" gap = target.start - n.end if gap < 0: return if gap == 0: yield ((n.start, n.end, n.pitch.number()), (target.start, target.end, target.pitch.number())), (n, target) return yield ((n.start, n.end, n.pitch.number()), (n.end, target.start, None), (target.start, target.end, target.pitch.number())), (n, target) windows = {(n.end, target.start)} if gap <= 1 else set() if gap >= 1: windows |= {(n.end, n.end + fractions.Fraction(1, 2)), (target.start - fractions.Fraction(1, 2), target.start)} for a, b in sorted(windows): for number in range(max(self.low, n.pitch.number() - 3), min(self.high, n.pitch.number() + 3) + 1): mid = self.note(a, b, number) if number in (n.pitch.number(), target.pitch.number()) or not self.harmony.ordinary(mid): continue events = [(n.start, n.end, n.pitch.number())] if a > n.end: events.append((n.end, a, None)) events.append((a, b, number)) if b < target.start: events.append((b, target.start, None)) events.append((target.start, target.end, target.pitch.number())) yield tuple(events), (n, mid, target) def resolutions(self, n): """Joint color/delayed-approach actions; the destination is never optional.""" requests = [] for rule, boundary in self.color_bases(n): if rule.get('resolve'): for gap in (fractions.Fraction(), fractions.Fraction(1, 2), fractions.Fraction(1)): if gap <= fractions.Fraction(self.composition.data('harmony')['resolution_gap']): requests.append(('color', rule, boundary, n.end + gap, (-2, -1, 1, 2))) delay = self.composition.data('lead_ins')['delayed_approach'] if n.end - n.start <= fractions.Fraction(delay['max_note']) and n.pitch.number() % 12 not in self.harmony.permitted(n.start, n.end): for arrival in map(fractions.Fraction, delay['arrivals']): if n.end <= n.start + arrival <= n.start + fractions.Fraction(delay['deadline']): requests.append(('delayed', delay, None, n.start + arrival, delay['steps'])) for kind, rule, boundary, arrival, steps in requests: for delta in steps: number = n.pitch.number() + delta if not self.low <= number <= self.high: continue for hold in (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)): if arrival + hold > self.end or number % 12 not in self.harmony.members(arrival, arrival + hold): continue target = self.note(arrival, arrival + hold, number, 'destination') c = self.color_contract(n, target, rule, boundary) if kind == 'color' else generator.solo.types.ExpressionContract(n.event_id + ':delayed', 'delayed_approach', rule['id'], (n,), target, self.harmony.at(arrival).chord) # Color and delayed-approach contracts permit this intervening # window. Validate the invariant endpoints once; tails() checks # every inserted note's ordinary full-duration permission itself. if not self.harmony.evaluate((n, target), complete=True, expressions=(c,), infer=False).valid: continue for events, ns in self.tails(n, target): if not generator.solo.groups.valid(tuple((a, b) for a, b, _ in events)): continue yield events, ns, (c,) def actions(self, time): if time in self._actions: return self._actions[time] result = [] def add(events, notes, contracts=()): result.append((events, notes, contracts)) for duration in self.durations: end = time + duration if end > self.end: continue add(((time, end, None),), ()) for number in range(self.low, self.high + 1): n = self.note(time, end, number) support = self.permission(n) if support is not None: add(((time, end, number),), (n,), support) if self.expressions_enabled and not self.harmony.ordinary(n): for events, ns, contracts in self.resolutions(n): add(events, ns, contracts) rule = self.composition.data('lead_ins')['passing'] if duration <= fractions.Fraction(rule['max_note']): for step in rule['steps']: p = number + step if not self.low <= p <= self.high: continue for hold in (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)): if end + hold > self.end: continue target = self.note(end, end + hold, p) extra = self.permission(target) if extra is None or any(c.kind not in ('color', 'anticipated_color') for c in extra): continue c = generator.solo.types.ExpressionContract(n.event_id + ':passing', 'passing', rule['id'], (n,), target, self.harmony.at(end).chord) add(((time, end, number), (end, end + hold, p)), (n, target), (c, *extra)) if self.expressions_enabled: limits = self.composition.data('roles')['difficulty'] for rule in self.composition.data('lead_ins')['patterns']: difficulty = rule.get('difficulty') if difficulty and self.analysis.tempo > limits[difficulty + '_max_bpm']: continue # Connections require a concrete preceding note, checked at expansion. durations = tuple(map(fractions.Fraction, rule['durations'])) gaps = tuple(map(fractions.Fraction, rule.get('internal_gaps', ['0'] * (len(durations) - 1)))) target_time = time + sum(durations, fractions.Fraction()) + sum(gaps, fractions.Fraction()) + fractions.Fraction(rule['gap']) span = self.harmony.at(target_time) if not span or span.chord == 'N.C.': continue holds = (fractions.Fraction(1, 3),) if difficulty == 'triplet' else (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)) for duration in holds: end = target_time + duration if end > self.end: continue for number in range(self.low, self.high + 1): if number % 12 not in self.harmony.members(target_time, end): continue target = self.note(target_time, end, number, 'destination') cursor = time notes = [] events = [] for i, (delta, d) in enumerate(zip(rule['semitones'], durations)): n = self.note(cursor, cursor + d, generator.solo.expressions.chromatic(target.pitch, delta), rule['kind']) notes.append(n) events.append((n.start, n.end, n.pitch.number())) cursor = n.end gap = gaps[i] if i < len(gaps) else fractions.Fraction(rule['gap']) if gap: events.append((cursor, cursor + gap, None)) cursor += gap if any(not self.low <= n.pitch.number() <= self.high for n in notes): continue if not generator.solo.groups.valid(tuple((a, b) for a, b, p in (*events, (target_time, end, number)))): continue contract = generator.solo.types.ExpressionContract(f'{time}:{number}:{rule["id"]}', rule['kind'], rule['id'], tuple(notes), target, span.chord) ns = (*notes, target) if rule['kind'] == 'connection': # Validation waits for the actual incoming event. add((*events, (target_time, end, number)), ns, (contract,)) elif self.harmony.evaluate(ns, complete=True, expressions=(contract,), infer=False).valid: add((*events, (target_time, end, number)), ns, (contract,)) # Complete ordinary triplet groups; do not leave a third-beat residual. if self.allow_triplets and time + 1 <= self.end: for number in range(self.low, self.high + 1): for direction in (-1, 0, 1): numbers = [number] cursor = time for _ in range(2): nxt = next((p for p in range(numbers[-1] + direction, numbers[-1] + direction * 4, direction or 1) if self.low <= p <= self.high and p % 12 in self.harmony.permitted(cursor + fractions.Fraction(1, 3), cursor + fractions.Fraction(2, 3))), None) if direction else number if nxt is None: break numbers.append(nxt) cursor += fractions.Fraction(1, 3) if len(numbers) != 3: continue ns = tuple(self.note(time + i * fractions.Fraction(1, 3), time + (i + 1) * fractions.Fraction(1, 3), p) for i, p in enumerate(numbers)) if self.harmony.evaluate(ns, complete=True, infer=False).valid: add(tuple((n.start, n.end, n.pitch.number()) for n in ns), ns) self._actions[time] = tuple(result) self.counts['valid_structures'] += len(result) return self._actions[time] def extend(self, state, action): events, notes, contracts = action # Training tokenization merges contiguous silence. Never manufacture a # rest-to-rest transition absent from that event representation. if state.events and state.events[-1][2] is None and events[0][2] is None: return None if notes and state.previous is not None and abs(notes[0].pitch.number() - state.previous) > 12: return None if any(c.kind in ('connection', 'passing') and c.preparation is None for c in contracts): if not state.notes: return None contracts = tuple(dataclasses.replace(c, preparation=state.notes[-1]) if c.kind in ('connection', 'passing') and c.preparation is None else c for c in contracts) if self.harmony.ordinary(state.notes[-1]): _, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony, composition=self.composition) else: _, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony, composition=self.composition) if error: return None history = state.history previous = state.previous cost = state.cost for a, b, p in events: harmonic, root, phase, source, _ = self.context(a) action = generator.solo.learned.token(b - a, p, previous, root) context = generator.solo.learned.contexts(harmonic, phase, source, history) cost += self.model.cost(action, context) if p is not None: if previous is None: cost += self.model.register_cost(p) previous = p history = (*history, action)[-4:] return State((*state.events, *events), (*state.notes, *notes), (*state.contracts, *contracts), history, previous, cost) def compose(self): for attempt in range(self.attempts): rng = random.Random(self.seed + ':' + str(attempt)) state = State() time = self.start trace = [] while time < self.end: actions = self.actions(time) options = [s for a in actions if (s := self.extend(state, a)) is not None] # Different operator names cannot multiply the probability of identical # sounded music. Prefer plain permission when no exception is needed. unique = {} for s in options: signature = event_key(s.events[len(state.events):]) if signature not in unique or len(s.contracts) < len(unique[signature].contracts): unique[signature] = s options = sorted(unique.values(), key=lambda s: event_key(s.events[len(state.events):])) self.counts['scored_continuations'] += len(options) if not options: break low = min(s.cost - state.cost for s in options) weights = [math.exp(-(s.cost - state.cost - low)) for s in options] draw = rng.random() * sum(weights) selected = options[-1] for s, w in zip(options, weights): draw -= w if draw <= 0: selected = s break trace.append({'time': str(time), 'valid_joint_actions': len(options), 'selected_events': [(str(a), str(b), p) for a, b, p in selected.events[len(state.events):]], 'negative_log_likelihood': selected.cost - state.cost}) state = selected time = state.events[-1][1] if time == self.end and state.notes: self.trace = trace self.completed = (state,) self.counts['attempts'] = attempt + 1 return state raise SearchExhausted(f'{self.role}: no complete non-silent sample within {self.attempts} deterministic attempts; end={self.end}')