166 lines
7.4 KiB
Python
166 lines
7.4 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
from dataclasses import dataclass
|
|
|
|
from generator import harmony
|
|
from generator.model import LETTERS, NATURAL, alteration, key_from_fifths, key_label, parse_pitch_name, pitch_number, rational
|
|
|
|
@dataclass(frozen=True)
|
|
class KeyProfile:
|
|
name: str
|
|
root: int
|
|
fifths: int
|
|
|
|
KEY_PROFILES = tuple(KeyProfile(name, root, fifths) for root, (name, fifths) in enumerate((('C', 0), ('Db', -5), ('D', 2), ('Eb', -3), ('E', 4), ('F', -1), ('Gb', -6), ('G', 1), ('Ab', -4), ('A', 3), ('Bb', -2), ('B', 5))))
|
|
TARGET_KEYS = tuple(p.name for p in KEY_PROFILES)
|
|
|
|
def mod12(value: int) -> int:
|
|
return value % 12
|
|
|
|
def find_key(name: str) -> KeyProfile:
|
|
tonic = parse_pitch_name(name)
|
|
for fifths in range(-14, 15):
|
|
key = key_from_fifths(fifths)
|
|
if key['tonic'] == tonic:
|
|
return KeyProfile(name, int(NATURAL[tonic['letter']] + alteration(tonic['alter'])) % 12, fifths)
|
|
raise ValueError(f'unsupported key signature: {name}')
|
|
|
|
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
|
value = (target.root - source.root) % 12
|
|
return value - 12 if value > 6 else value
|
|
|
|
def interval_between(source: dict, target: dict) -> tuple[int, int]:
|
|
semitones = (NATURAL[target['letter']] + alteration(target['alter']) - NATURAL[source['letter']] - alteration(source['alter'])) % 12
|
|
if semitones > 6:
|
|
semitones -= 12
|
|
steps = (LETTERS.index(target['letter']) - LETTERS.index(source['letter'])) % 7
|
|
if semitones < 0 or semitones == 0 and steps > 3:
|
|
steps -= 7
|
|
return steps, semitones
|
|
|
|
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
|
|
output = copy.deepcopy(pitch)
|
|
if 'unpitched' in pitch:
|
|
return output
|
|
original_octave = pitch.get('octave', 4)
|
|
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
|
|
octave, index = divmod(diatonic, 7)
|
|
output['letter'] = LETTERS[index]
|
|
output['octave'] = octave
|
|
output['alter'] = rational(pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']]))
|
|
if 'octave' not in pitch:
|
|
output.pop('octave')
|
|
if 'accidental_display' in output:
|
|
from generator.model import accidental_name
|
|
output['accidental_display']['name'] = accidental_name(alteration(output['alter']))
|
|
if 'tablature' in output and semitones:
|
|
fret = output['tablature']['fret'] + semitones
|
|
if fret < 0 or fret.denominator != 1:
|
|
raise ValueError('transposition cannot preserve the authored tablature string/fret')
|
|
output['tablature']['fret'] = int(fret)
|
|
return output
|
|
|
|
def shift_key(key: dict, steps: int, semitones: int) -> dict:
|
|
tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None
|
|
alterations = [0] * 7
|
|
for index, letter in enumerate(LETTERS):
|
|
p = shift_pitch({'letter': letter, 'alter': key['alterations'][index]}, steps, semitones)
|
|
alterations[LETTERS.index(p['letter'])] = p['alter']
|
|
result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations}
|
|
if 'steps' in key:
|
|
result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']]
|
|
if not key.get('custom') and tonic:
|
|
for fifths in range(-14, 15):
|
|
candidate = key_from_fifths(fifths, key['mode'])
|
|
if candidate['tonic'] == tonic and candidate['alterations'] == alterations:
|
|
result['fifths'] = fifths
|
|
break
|
|
else:
|
|
result['custom'] = True
|
|
if 'cancel' in key:
|
|
# A cancellation references the old signature, not a count of naturals.
|
|
cancelled = shift_key(key_from_fifths(key['cancel']), steps, semitones)
|
|
if cancelled.get('custom'):
|
|
raise ValueError('transposed key cancellation cannot be represented by fifths')
|
|
result['cancel'] = cancelled['fifths']
|
|
return result
|
|
|
|
def transposition_interval(document: dict, target_key_name: str) -> tuple[int, int]:
|
|
metadata = document['metadata']
|
|
origin = metadata['key'].get('tonic') or metadata.get('transpose_origin')
|
|
if origin is None:
|
|
raise ValueError('keyless practice requires a vamp:tonic score property')
|
|
return interval_between(origin, parse_pitch_name(target_key_name))
|
|
|
|
def transpose_document(document: dict, target_key_name: str, *, octave_shift: int = 0) -> dict:
|
|
if type(octave_shift) is not int:
|
|
raise ValueError('octave adjustment must be an integer')
|
|
steps, semitones = transposition_interval(document, target_key_name)
|
|
output = copy.deepcopy(document)
|
|
metadata = output['metadata']
|
|
key = metadata['key']
|
|
metadata['key'] = shift_key(key, steps, semitones)
|
|
metadata['key_signature'] = key_label(metadata['key'])
|
|
metadata['variant_tonic'] = target_key_name
|
|
for staff in output['staves']:
|
|
for measure in staff['measures']:
|
|
measure['key'] = shift_key(measure['key'], steps, semitones)
|
|
measure['key_signature'] = key_label(measure['key'])
|
|
for change in measure.get('attributes', []):
|
|
if 'key' in change:
|
|
change['key'] = shift_key(change['key'], steps, semitones)
|
|
initial = measure.get('initial_attributes', {})
|
|
if 'key' in initial:
|
|
initial['key'] = shift_key(initial['key'], steps, semitones)
|
|
for h in measure.get('harmonies', []):
|
|
for field in ('root', 'bass'):
|
|
if field in h:
|
|
h[field] = shift_pitch(h[field], steps, semitones)
|
|
h['symbol'] = harmony.symbol(h)
|
|
for voice in measure['voices']:
|
|
for note in voice['notes']:
|
|
try:
|
|
note['pitches'] = [shift_pitch(p, steps + 7 * octave_shift, semitones + 12 * octave_shift) for p in note.get('pitches', [])]
|
|
except ValueError as error:
|
|
raise ValueError(f'measure {measure.get("number", measure["id"])}, voice {voice["id"]}, event {note["id"]}: {error}') from error
|
|
return output
|
|
|
|
def concert_document(document: dict) -> dict:
|
|
"""Normalize MusicXML instrument transposition before concert-pitch arranging."""
|
|
output = copy.deepcopy(document)
|
|
zero = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0}
|
|
for staff in output['staves']:
|
|
for measure in staff['measures']:
|
|
initial = measure['transpose']
|
|
changes = copy.deepcopy(measure.get('attributes', []))
|
|
|
|
def interval(onset):
|
|
state = initial
|
|
from generator.model import fraction
|
|
for change in changes:
|
|
if fraction(change['onset']) <= onset and 'transpose' in change:
|
|
state = change['transpose']
|
|
return state['diatonic'] + 7 * state['octave_change'], state['chromatic'] + 12 * state['octave_change']
|
|
|
|
from generator.model import fraction
|
|
measure['key'] = shift_key(measure['key'], *interval(0))
|
|
measure['key_signature'] = key_label(measure['key'])
|
|
for n in (n for v in measure['voices'] for n in v['notes']):
|
|
n['pitches'] = [shift_pitch(p, *interval(fraction(n['onset']))) for p in n['pitches']]
|
|
for h in measure['harmonies']:
|
|
for field in ('root', 'bass'):
|
|
if field in h:
|
|
h[field] = shift_pitch(h[field], *interval(fraction(h['onset'])))
|
|
h['symbol'] = harmony.symbol(h)
|
|
for change in measure.get('attributes', []):
|
|
if 'key' in change:
|
|
change['key'] = shift_key(change['key'], *interval(fraction(change['onset'])))
|
|
if 'transpose' in change:
|
|
change['transpose'] = copy.deepcopy(zero)
|
|
measure['transpose'] = copy.deepcopy(zero)
|
|
first = output['staves'][0]['measures'][0]
|
|
output['metadata']['key'] = copy.deepcopy(first['key'])
|
|
output['metadata']['key_signature'] = first['key_signature']
|
|
return output
|