This commit is contained in:
khodowany
2026-10-02 00:35:31 -05:00
parent f2f371b951
commit a0bd19b3ba
25 changed files with 1421 additions and 586 deletions
+57 -5
View File
@@ -16,6 +16,8 @@ class Analysis:
boundaries: tuple[fractions.Fraction, ...]
tempo: fractions.Fraction
melody: tuple[generator.solo.types.Note, ...]
body_offset: fractions.Fraction = fractions.Fraction()
source_phrases: tuple[generator.solo.types.SourcePhrase, ...] = ()
def identity(analysis, measures, part):
anchor = analysis.melody[0].pitch.number() if analysis.melody else 0
@@ -63,16 +65,14 @@ def analyze(document: dict) -> Analysis:
candidates = []
for index, measure in enumerate(measures):
start = boundaries[index]
# One- and two-bar ideas retain leading silence and the complete held ending.
for count in (1, 2):
# Legacy motif consumers also receive complete longer source material.
for count in (1, 2, 4, 6, 8):
if index + count >= len(boundaries):
continue
end = boundaries[index + count]
notes = tuple(n for n in melody if start <= n.start < end)
if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])):
continue
if len(notes) > 8:
continue
normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes)
identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii')
identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12]
@@ -82,4 +82,56 @@ def analyze(document: dict) -> Analysis:
# Preserve the opening idea, then favor a contrasting rhythm/contour with space.
ordered = list(unique.values())
ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration))
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody)
offset = boundaries[generator.score.body_start_measure_index(measures)]
phrases = source_phrases(melody, offset, boundaries[-1] - offset,
generator.model.fraction(measures[-1]['duration']))
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody, offset, phrases)
def source_phrases(melody, offset, end, bar):
"""Partition at musical releases, without slicing a sounding event at bars."""
notes = tuple(dataclasses.replace(n, start=n.start-offset, end=n.end-offset,
event_id=n.sources[0]) for n in melody if n.end > offset)
if any(a.end > b.start for a, b in zip(notes, notes[1:])):
raise ValueError('solo source requires a monophonic melody')
candidates = sorted({fractions.Fraction(), end, *(n.end for n in notes if 0 < n.end <= end),
*(n.start for n in notes if 0 < n.start < end)})
candidates = [t for t in candidates if not any(n.start < t < n.end for n in notes)]
if not candidates or candidates[0] != 0:
candidates.insert(0, fractions.Fraction())
best = {end: (0., ())}
for start in reversed(candidates[:-1]):
options = []
for finish in candidates:
duration = finish-start
if finish not in best or duration <= 0 or duration > 8*bar or duration < 2*bar and finish != end:
continue
prior = next((n for n in reversed(notes) if n.end <= finish), None)
after = next((n for n in notes if n.start >= finish), None)
gap = float(max(0, after.start-prior.end)) if prior and after else 0.
held = float(prior.end-prior.start) if prior else 0.
cost = abs(float(duration/bar)-4)/4 + .7*(held < 2) - .3*min(2,gap)
options.append((cost+best[finish][0], (finish,*best[finish][1])))
if options:
best[start] = min(options)
if 0 not in best:
# A single extended held event is one musical unit, regardless of length.
stops = (end,)
else:
stops = best[0][1]
result, start = [], fractions.Fraction()
for finish in stops:
actual = tuple(n for n in notes if n.start < finish and n.end > start)
cursor, rests = start, []
for n in actual:
if cursor < n.start:
rests.append((cursor,n.start))
cursor = n.end
if cursor < finish:
rests.append((cursor,finish))
origin = actual[0].pitch.number() if actual else 0
shape = tuple((n.start-start,n.end-start,n.pitch.number()-origin) for n in actual)
family = hashlib.sha256(repr(shape).encode()).hexdigest()[:12]
pickup = tuple(dataclasses.replace(n,start=n.start-offset,end=n.end-offset,event_id=n.sources[0]) for n in melody if n.end<=offset) if start==0 else ()
result.append(generator.solo.types.SourcePhrase(f'source_{start}_{finish}',start,finish,actual,tuple(rests),family,pickup))
start = finish
return tuple(result)