from __future__ import annotations import copy from dataclasses import dataclass from generator import harmony from generator.model import LETTERS, NATURAL, key_from_fifths, key_label, parse_pitch_name, pitch_number @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, (NATURAL[tonic['letter']] + 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']] + target['alter'] - NATURAL[source['letter']] - 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) 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'] = 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: output['accidental_display']['name'] = {-3:'triple-flat',-2:'flat-flat',-1:'flat',0:'natural',1:'sharp',2:'double-sharp',3:'triple-sharp'}.get(output['alter'], 'compound') 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 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 return result def transpose_document(document: dict, target_key_name: str) -> dict: output = copy.deepcopy(document) metadata = output['metadata'] key = metadata['key'] origin = key.get('tonic') or metadata.get('transpose_origin') if origin is None: raise ValueError('keyless melody practice requires a vamp:tonic score property') target = parse_pitch_name(target_key_name) steps, semitones = interval_between(origin, target) 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) 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']: note['pitches'] = [shift_pitch(p, steps, semitones) for p in note.get('pitches', [])] 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 measure['voices'][0]['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