138 lines
6.9 KiB
Python
138 lines
6.9 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from fractions import Fraction
|
|
from itertools import groupby
|
|
from math import ceil, floor
|
|
|
|
from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, positions_for_pitch, within_fret_span
|
|
from generator.model import fraction, pitch_number
|
|
from generator.transposition import transpose_document, transposition_interval
|
|
|
|
@dataclass(frozen=True)
|
|
class GuitarTone:
|
|
pitch: dict
|
|
onset: Fraction
|
|
end: Fraction
|
|
midi: Fraction
|
|
location: str
|
|
grace_order: int
|
|
|
|
def validate_staff_details(state: dict, location: str) -> None:
|
|
details = state['staff_details']
|
|
if details.get('capo', 0):
|
|
raise ValueError(f'{location}: guitar practice does not support capo configurations')
|
|
if state['clef']['sign'] == 'TAB' and details.get('lines') != 6:
|
|
raise ValueError(f'{location}: guitar practice requires six-string tablature')
|
|
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):
|
|
raise ValueError(f'{location}: guitar practice requires standard six-string tuning')
|
|
|
|
def sounding_pitch(pitch: dict, state: dict) -> Fraction:
|
|
if 'unpitched' in pitch:
|
|
raise ValueError('unpitched notes cannot be checked on a fretted guitar')
|
|
transpose, clef = state['transpose'], state['clef']
|
|
offset = transpose['chromatic'] + 12 * transpose['octave_change']
|
|
if not any(transpose.values()) and clef['sign'] == 'G' and not clef['octave_change'] and 'tablature' not in pitch:
|
|
offset = -WRITTEN_GUITAR_OCTAVE_OFFSET
|
|
return pitch_number(pitch) + offset
|
|
|
|
def guitar_tones(document: dict) -> list[GuitarTone]:
|
|
tones = []
|
|
absolute = Fraction()
|
|
for measure in document['staves'][0]['measures']:
|
|
location = f'measure {measure.get("number", measure["id"])}'
|
|
state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')}
|
|
validate_staff_details(state, location)
|
|
changes = sorted(measure.get('attributes', []), key=lambda change: fraction(change['onset']))
|
|
for change in changes:
|
|
state.update({key: change[key] for key in state if key in change})
|
|
validate_staff_details(state, location)
|
|
for voice in measure['voices']:
|
|
state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')}
|
|
change_index = 0
|
|
for index, note in enumerate(voice['notes']):
|
|
onset = fraction(note['onset'])
|
|
while change_index < len(changes) and fraction(changes[change_index]['onset']) <= onset:
|
|
state.update({key: changes[change_index][key] for key in state if key in changes[change_index]})
|
|
change_index += 1
|
|
context = f'{location}, voice {voice["id"]}, event {note["id"]}, onset {onset}'
|
|
for pitch in note['pitches']:
|
|
try:
|
|
midi = sounding_pitch(pitch, state)
|
|
except ValueError as error:
|
|
raise ValueError(f'{context}: {error}') from error
|
|
tones.append(GuitarTone(pitch, absolute + onset, absolute + onset + fraction(pitch['duration']), midi, context, index if note.get('grace') else -1))
|
|
absolute += fraction(measure['duration'])
|
|
return tones
|
|
|
|
def tone_positions(tone: GuitarTone) -> tuple[TabPosition, ...]:
|
|
positions = positions_for_pitch(tone.midi)
|
|
if tab := tone.pitch.get('tablature'):
|
|
positions = tuple(p for p in positions if p.string_number == tab['string'] and p.fret == tab['fret'])
|
|
if not positions:
|
|
raise ValueError(f'{tone.location}: pitch {tone.midi} has no matching string/fret in {MIN_FRET}-{MAX_FRET}')
|
|
return positions
|
|
|
|
def extend_fingerings(held: dict[str, TabPosition], pending: list[tuple[str, tuple[TabPosition, ...]]]):
|
|
if not pending:
|
|
yield tuple(sorted(held.items()))
|
|
return
|
|
identifier, options = pending[0]
|
|
occupied = {position.string_number for position in held.values()}
|
|
for position in options:
|
|
if position.string_number not in occupied and within_fret_span((*held.values(), position)):
|
|
yield from extend_fingerings({**held, identifier: position}, pending[1:])
|
|
|
|
def validate_guitar_practice(document: dict) -> None:
|
|
tones = guitar_tones(document)
|
|
by_id = {tone.pitch['id']: tone for tone in tones}
|
|
options = {tone.pitch['id']: tone_positions(tone) for tone in tones}
|
|
states = {()}
|
|
# Keep every feasible assignment, so later entries cannot move a held or tied tone.
|
|
ordered = sorted(tones, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order))
|
|
for (onset, _, _), group in groupby(ordered, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order)):
|
|
attacks = list(group)
|
|
continued = {tone.pitch['tie_from'] for tone in attacks if 'tie_from' in tone.pitch}
|
|
next_states = set()
|
|
visited = set()
|
|
for previous in states:
|
|
previous = dict(previous)
|
|
held = {identifier: position for identifier, position in previous.items() if identifier not in continued and (by_id[identifier].end > onset or by_id[identifier].end == onset and by_id[identifier].pitch.get('tie_to'))}
|
|
pending = []
|
|
for tone in attacks:
|
|
positions = options[tone.pitch['id']]
|
|
if source := tone.pitch.get('tie_from'):
|
|
positions = tuple(p for p in positions if p == previous.get(source))
|
|
pending.append((tone.pitch['id'], positions))
|
|
pending.sort(key=lambda item: len(item[1]))
|
|
transition = (tuple(sorted(held.items())), tuple(pending))
|
|
if transition not in visited:
|
|
next_states.update(extend_fingerings(held, pending))
|
|
visited.add(transition)
|
|
if not next_states:
|
|
raise ValueError(f'{attacks[0].location}: no simultaneous fingering within frets {MIN_FRET}-{MAX_FRET} and the four-fret span; held/tied strings must remain fixed')
|
|
states = next_states
|
|
|
|
def transpose_guitar_practice(document: dict, target_key: str) -> dict:
|
|
_, semitones = transposition_interval(document, target_key)
|
|
tones = guitar_tones(document)
|
|
if not tones:
|
|
return transpose_document(document, target_key)
|
|
low = min(midi + MIN_FRET for _, midi in STANDARD_TUNING)
|
|
high = max(midi + MAX_FRET for _, midi in STANDARD_TUNING)
|
|
first = ceil((low - min(tone.midi for tone in tones) - semitones) / 12)
|
|
last = floor((high - max(tone.midi for tone in tones) - semitones) / 12)
|
|
candidates = sorted(range(first, last + 1), key=lambda octave: (abs(semitones + 12 * octave), semitones + 12 * octave < 0))
|
|
failures = []
|
|
for octave in candidates:
|
|
try:
|
|
result = transpose_document(document, target_key, octave_shift=octave)
|
|
validate_guitar_practice(result)
|
|
return result
|
|
except ValueError as error:
|
|
failures.append(f'octave {octave:+}: {error}')
|
|
detail = '; '.join(failures) if failures else f'{min(tones, key=lambda tone: tone.midi).location} through {max(tones, key=lambda tone: tone.midi).location}: pitch range exceeds the guitar range in every octave'
|
|
raise ValueError(f'no whole-exercise guitar register for {target_key}: {detail}')
|