from __future__ import annotations import dataclasses import fractions import hashlib import json import generator.harmony import generator.model import generator.score import generator.solo.types @dataclasses.dataclass(frozen=True) class Analysis: motifs: tuple[generator.solo.types.Motif, ...] boundaries: tuple[fractions.Fraction, ...] tempo: fractions.Fraction melody: tuple[generator.solo.types.Note, ...] def identity(analysis, measures, part): anchor = analysis.melody[0].pitch.number() if analysis.melody else 0 melody = [(str(n.start), str(n.end), n.pitch.number() - anchor) for n in analysis.melody] harmony = [] for measure in measures: for chord in measure['harmonies']: pitches = sorted((int(p) - anchor) % 12 for p in generator.harmony.pitch_classes(chord)) if chord['kind'] != 'none' else [] harmony.append((chord['kind'], pitches, chord['onset'])) return hashlib.sha256(json.dumps((part, melody, harmony), sort_keys=True).encode()).hexdigest() def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]: """Collapse tied tones before extracting monophonic melody fragments.""" result, pending = [], {} absolute = fractions.Fraction() for measure in generator.score.source_staff(document)['measures']: for event in generator.score.measure_notes(measure): if event.get('grace') or event.get('cue'): continue for tone in event['pitches']: number = generator.model.pitch_number(tone) if number.denominator != 1: continue start = absolute + generator.model.fraction(event['onset']) end = start + generator.model.fraction(tone['duration']) pitch = generator.solo.types.Pitch(tone['letter'], generator.model.alteration(tone['alter']), tone['octave']) source = pending.pop(tone.get('tie_from'), None) if source is not None: result[source] = dataclasses.replace(result[source], end=end, sources=(*result[source].sources, event['id'])) else: source = len(result) result.append(generator.solo.types.Note(start, end, pitch, (event['id'],))) if 'tie_to' in tone: pending[tone['id']] = source absolute += generator.model.fraction(measure['duration']) return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources))) def analyze(document: dict) -> Analysis: measures = generator.score.source_staff(document)['measures'] melody = attacks(document) boundaries = [fractions.Fraction()] for measure in measures: boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration'])) tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', [])), fractions.Fraction(120)) 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): 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] context = tuple(generator.harmony.symbol(h) for m in measures[index:index + count] for h in m['harmonies']) candidates.append(generator.solo.types.Motif(identifier, normalized, end - start, context)) unique = {m.id: m for m in reversed(candidates)} # 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)