Rhythm Selection & Cleanup
ci / checks (push) Successful in 1h16m10s

This commit is contained in:
khodowany
2026-09-26 02:50:22 -05:00
parent 4d6348d354
commit 9fd5abd075
91 changed files with 4692 additions and 2484 deletions
+40 -39
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
from fractions import Fraction
from dataclasses import dataclass
from functools import lru_cache
from itertools import groupby
from typing import NamedTuple
import dataclasses
import fractions
import functools
import itertools
import typing
from generator import harmony, pitch
from generator.model import NATURAL, alteration, fraction, make_event, pitch_number
import generator.harmony
import generator.model
import generator.pitch
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 0
@@ -15,7 +16,7 @@ MAX_FRET = 20
MAX_FRET_SPAN = 4
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
class TabPosition(NamedTuple):
class TabPosition(typing.NamedTuple):
string_number: int
fret: int
midi: int
@@ -24,7 +25,7 @@ def within_fret_span(positions) -> bool:
fretted = [position.fret for position in positions if position.fret > 0]
return not fretted or max(fretted) - min(fretted) <= MAX_FRET_SPAN
def positions_for_pitch(midi: Fraction) -> tuple[TabPosition, ...]:
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
if midi.denominator != 1:
return ()
return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET)
@@ -35,20 +36,20 @@ def validate_tuning(state: dict, location: str) -> None:
raise ValueError(f'{location}: guitar generation does not support capo configurations')
tunings = details.get('tunings', [])
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
if tunings and (len(tunings) != 6 or {t['line']: pitch_number(t) for t in tunings} != expected):
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ...], int | None]:
degrees = harmony.degrees(definition)
degrees = generator.harmony.degrees(definition)
root = definition['root']
root_pc = NATURAL[root['letter']] + alteration(root['alter'])
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'}
optional = {d for d, a in degrees.items() if not a and d not in explicit and (d == 5 and len(degrees) > 3 or d in (9, 11) and d < max(degrees))}
classes = {d: (root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {value for d, value in classes.items() if d not in optional}
allowed = set(classes.values())
bass = definition.get('bass')
bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
if bass_pc is None and 'inversion' in definition:
ordered = sorted(degrees)
inversion = definition['inversion']
@@ -60,7 +61,7 @@ def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ..
required.add(bass_pc)
return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
@lru_cache(maxsize=256)
@functools.lru_cache(maxsize=256)
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> tuple[tuple[TabPosition, ...], ...]:
result = []
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed) for string, open_midi in STANDARD_TUNING]
@@ -92,26 +93,26 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
spellings = {}
for degree, alter in harmony.degrees(definition).items():
value = pitch.shift_pitch(definition['root'], degree - 1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[value['letter']] + alteration(value['alter'])) % 12] = value
for degree, alter in generator.harmony.degrees(definition).items():
value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter))
spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass
spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass
result = []
for position in sorted(positions, key=lambda p: p.midi):
value = spellings[position.midi % 12].copy()
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - NATURAL[value['letter']] - alteration(value['alter'])) // 12 - 1)
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1)
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
result.append(value)
return result
@lru_cache(maxsize=16384)
@functools.lru_cache(maxsize=16384)
def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
if not previous or not current:
return 0
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
@lru_cache(maxsize=4096)
@functools.lru_cache(maxsize=4096)
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
frets = [p.fret for p in positions if p.fret] or [0]
strings = [p.string_number for p in positions]
@@ -131,7 +132,7 @@ def extend_positions(held, pending):
if position.string_number not in occupied and within_fret_span((*held.values(), position)):
yield from extend_positions({**held, identifier: position}, pending[1:])
@dataclass
@dataclasses.dataclass
class Placement:
cost: tuple[int, int, int]
active: tuple
@@ -169,13 +170,13 @@ def nearest_positions(states: dict) -> list[list[tuple | None]]:
relax(x, y, x, y + 1)
return grid
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class GuitarTone:
pitch: dict
end: Fraction
end: fractions.Fraction
positions: tuple[TabPosition, ...]
@dataclass(frozen=True)
@dataclasses.dataclass(frozen=True)
class AttackCandidate:
active: tuple
choices: tuple
@@ -208,38 +209,38 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
events, tones = [], {}
absolute = Fraction()
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
changes = sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset']))
changes = sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset']))
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in [state, *changes]:
state.update({k: change[k] for k in state if k in change})
validate_tuning(state, measure['id'])
for voice in measure['voices']:
for order, note in enumerate(voice['notes']):
onset = fraction(note['onset'])
onset = generator.model.fraction(note['onset'])
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in changes:
if fraction(change['onset']) <= onset:
if generator.model.fraction(change['onset']) <= onset:
state.update({k: change[k] for k in state if k in change})
for tone in note['pitches']:
sounding = pitch.sounding_pitch(tone, state)
sounding = generator.pitch.sounding_pitch(tone, state)
options = positions_for_pitch(sounding)
if not options:
raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {MIN_FRET}-{MAX_FRET}')
tones[tone['id']] = GuitarTone(tone, absolute + onset + fraction(tone['duration']), options)
tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options)
if '_harmony' in note:
for string, _ in STANDARD_TUNING:
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + fraction(note['duration']), ())
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ())
if note['pitches'] or '_harmony' in note:
events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note))
absolute += fraction(measure['duration'])
absolute += generator.model.fraction(measure['duration'])
return sorted(events, key=lambda e: e[:3]), tones
def retain_tone(tone: GuitarTone, onset: Fraction) -> bool:
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict:
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict:
continued = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
result = {}
@@ -269,17 +270,17 @@ def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, Guita
def place_guitar(document: dict) -> None:
"""Choose a minimum-cost path, retaining held strings in each search state."""
events, tones = guitar_events(document)
groups = [(key[0], [item[3] for item in values]) for key, values in groupby(events, key=lambda e: e[:3])]
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
states = {(): Placement((0, 0, 0), (), None, ())}
for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
preserve_strings = next_onset is not None and any('_harmony' in n and onset + fraction(n['duration']) > next_onset for n in notes)
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
states = advance_positions(notes, onset, tones, states, preserve_strings)
selected = min(states.values(), key=lambda s: (s.cost, s.active))
while selected.previous is not None:
for note, choices in selected.choices:
if definition := note.pop('_harmony', None):
note['pitches'] = make_event(note['id'], fraction(note['onset']), fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches']
note['pitches'] = generator.model.make_event(note['id'], generator.model.fraction(note['onset']), generator.model.fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches']
else:
for tone, position in choices:
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}