Files
product-vamp/generator/rhythm.py
T
khodowany 9fd5abd075
ci / checks (push) Successful in 1h16m10s
Rhythm Selection & Cleanup
2026-09-26 02:50:22 -05:00

96 lines
4.4 KiB
Python

from __future__ import annotations
import dataclasses
import fractions
import pathlib
import types
import generator.model
import generator.musicxml
@dataclasses.dataclass(frozen=True)
class Event:
onset: fractions.Fraction
duration: fractions.Fraction
sounding: bool
written_type: str
dots: int = 0
beams: tuple[tuple[int, str], ...] = ()
continuation: bool = False
@dataclasses.dataclass(frozen=True)
class Bar:
events: tuple[Event, ...]
length: fractions.Fraction
def attacks(self) -> tuple[fractions.Fraction, ...]:
return tuple(e.onset for e in self.events if e.sounding and not e.continuation)
def beat(self, number: int) -> tuple:
return tuple((max(e.onset, number) - number, min(e.onset + e.duration, number + 1) - max(e.onset, number), e.sounding, e.continuation or e.onset < number) for e in self.events if e.onset < number + 1 and e.onset + e.duration > number)
@dataclasses.dataclass(frozen=True)
class Snippet:
id: str
meter: str
bars: tuple[Bar, ...]
@dataclasses.dataclass(frozen=True, init=False)
class Library:
snippets: types.MappingProxyType
def __init__(self, snippets: tuple[Snippet, ...]):
grouped = {}
identities = set()
for snippet in sorted(snippets, key=lambda s: s.id):
if snippet.id in identities:
raise ValueError(f'duplicate rhythm identity: {snippet.id}')
identities.add(snippet.id)
grouped.setdefault(snippet.meter, []).append(snippet)
object.__setattr__(self, 'snippets', types.MappingProxyType({meter: tuple(values) for meter, values in grouped.items()}))
def for_meter(self, meter: str) -> tuple[Snippet, ...]:
if meter not in ('2/4', '3/4', '4/4', '5/4') or meter not in self.snippets:
raise ValueError(f'accompaniment requires rhythm snippets for {meter}; supported meters are 2/4, 3/4, 4/4, and 5/4')
return self.snippets[meter]
def read_snippet(path: pathlib.Path, identifier: str) -> Snippet:
document = generator.musicxml.load_musicxml(path)
staff = document['staves'][0]
if len(staff['measures']) not in (1, 2) or staff.get('tuplets'):
raise ValueError(f'{path}: rhythm snippets require one or two bars without tuplets')
meter = staff['measures'][0]['time_signature']
bars = []
for measure in staff['measures']:
length = generator.model.fraction(measure['duration'])
if measure['time_signature'] != meter or length != generator.model.meter_length(measure['meter']):
raise ValueError(f'{path}, measure {measure["number"]}: rhythm bars must be complete and use one meter')
if len(measure['voices']) != 1 or any('meter' in a for a in measure.get('attributes', [])):
raise ValueError(f'{path}: rhythm snippets require one voice without meter changes')
events = []
cursor = fractions.Fraction()
for note in measure['voices'][0]['notes']:
onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration'])
if onset != cursor or duration <= 0 or note.get('grace') or note['kind'] not in ('note', 'rest') or note.get('tremolo'):
raise ValueError(f'{path}, measure {measure["number"]}: rhythm events must fill the bar sequentially')
if 'ratio' in note['written']:
raise ValueError(f'{path}, measure {measure["number"]}: rhythm snippets do not support time modifications')
events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches'])))
cursor += duration
if cursor != length:
raise ValueError(f'{path}: rhythm events do not fill the bar')
bars.append(Bar(tuple(events), length))
return Snippet(identifier, meter, tuple(bars))
def load_library(root: pathlib.Path) -> Library:
if not root.is_dir():
raise ValueError(f'{root}: rhythm library not found')
snippets = []
for path in sorted(root.rglob('*.xml')):
relative = path.relative_to(root)
if len(relative.parts) != 3 or relative.parts[0] not in ('2-4', '3-4', '4-4', '5-4') or relative.parts[1] not in ('1', '2'):
raise ValueError(f'{path}: rhythm sources belong under meter/series')
snippet = read_snippet(path, relative.as_posix())
if snippet.meter != relative.parts[0].replace('-', '/'):
raise ValueError(f'{path}: rhythm meter disagrees with its directory')
snippets.append(snippet)
if not snippets:
raise ValueError(f'{root}: rhythm library is empty')
return Library(tuple(snippets))