139 lines
6.6 KiB
Python
139 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
import typing
|
|
|
|
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 = 1
|
|
MAX_FRET = 20
|
|
MAX_SINGLE_FRET = 12
|
|
MAX_FRET_SPAN = 3
|
|
MAX_ANCHOR_FRET = 12
|
|
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
|
|
|
class TabPosition(typing.NamedTuple):
|
|
string_number: int
|
|
fret: int
|
|
midi: int
|
|
|
|
def within_fret_span(positions) -> bool:
|
|
"""Count occupied fret positions inclusively: frets 1-3 fit, 1-4 do not."""
|
|
frets = [position.fret for position in positions]
|
|
return not frets or max(frets) - min(frets) + 1 <= MAX_FRET_SPAN
|
|
|
|
def has_fret_anchor(positions) -> bool:
|
|
return any(MIN_FRET <= position.fret <= MAX_ANCHOR_FRET for position in positions)
|
|
|
|
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)
|
|
|
|
def single_positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
|
|
return tuple(p for p in positions_for_pitch(midi) if p.fret <= MAX_SINGLE_FRET)
|
|
|
|
def single_register(register: tuple[int, int]) -> tuple[int, int]:
|
|
return max(register[0], min(p + MIN_FRET for _, p in STANDARD_TUNING)), min(register[1], max(p + MAX_SINGLE_FRET for _, p in STANDARD_TUNING))
|
|
|
|
def playable_attack(tones) -> bool:
|
|
"""Check a played note/chord, including tails after shorter tones release.
|
|
|
|
Each item is a (position, sounding duration) pair. Independent parts and
|
|
unsounded members of an underlying grip cannot supply the chord's anchor.
|
|
"""
|
|
tones = tuple(tones)
|
|
for time in {0, *(duration for _, duration in tones if duration > 0)}:
|
|
positions = tuple(p for p, duration in tones if duration > time or time == duration == 0)
|
|
if not positions:
|
|
continue
|
|
if any(p not in positions_for_pitch(fractions.Fraction(p.midi)) for p in positions) or len({p.string_number for p in positions}) != len(positions) or not within_fret_span(positions):
|
|
return False
|
|
if len(positions) == 1:
|
|
if positions[0].fret > MAX_SINGLE_FRET:
|
|
return False
|
|
elif not has_fret_anchor(positions):
|
|
return False
|
|
return True
|
|
|
|
def validate_document(document: dict) -> None:
|
|
"""Validate generated guitar policy separately from the score wire format."""
|
|
for staff in document['staves']:
|
|
if not staff.get('tablature'):
|
|
continue
|
|
for measure in staff['measures']:
|
|
for voice in measure['voices']:
|
|
for note in voice['notes']:
|
|
tones = []
|
|
for pitch in note['pitches']:
|
|
tab = pitch['tablature']
|
|
position = TabPosition(tab['string'], tab['fret'], dict(STANDARD_TUNING)[tab['string']] + tab['fret'])
|
|
tones.append((position, generator.model.fraction(pitch['duration'])))
|
|
if not playable_attack(tones):
|
|
raise ValueError(f'{measure["id"]}, event {note["id"]}: single notes require frets {MIN_FRET}-{MAX_SINGLE_FRET}; each sounding chord requires an anchor in {MIN_FRET}-{MAX_ANCHOR_FRET} and at most {MAX_FRET_SPAN} consecutive fret positions, including held/tied tails')
|
|
|
|
def validate_tuning(state: dict, location: str) -> None:
|
|
details = state['staff_details']
|
|
if details.get('capo', 0):
|
|
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']: generator.model.pitch_number(t) for t in tunings} != expected):
|
|
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ChordRequirements:
|
|
required: tuple[int, ...] = ()
|
|
allowed: tuple[int, ...] = ()
|
|
foundation: tuple[int, ...] = ()
|
|
basses: tuple[int, ...] = ()
|
|
def covers(self, classes: set[int]) -> bool:
|
|
return set(self.required) <= classes and bool(set(self.foundation) & classes)
|
|
|
|
def chord_requirements(definition: dict, *, respect_bass: bool = True) -> ChordRequirements:
|
|
degrees = generator.harmony.degrees(definition)
|
|
root = definition['root']
|
|
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 in (9, 11) and d < max(degrees)}
|
|
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 and (d not in (1, 5) or degrees[d])}
|
|
allowed = set(classes.values())
|
|
foundation = {value for d, value in classes.items() if d in (1, 5)}
|
|
if not foundation:
|
|
raise ValueError('generated guitar chords require a root or fifth in the harmony')
|
|
bass = definition.get('bass')
|
|
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']
|
|
if not 0 <= inversion < len(ordered):
|
|
raise ValueError('harmony inversion exceeds its chord degrees')
|
|
bass_pc = classes[ordered[inversion]]
|
|
if not respect_bass:
|
|
bass_pc = None
|
|
if bass_pc is not None:
|
|
allowed.add(bass_pc)
|
|
required.add(bass_pc)
|
|
basses = {bass_pc} if bass_pc is not None else set()
|
|
return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
|
|
|
|
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
|
|
spellings = {}
|
|
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[(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 - 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
|