139 lines
10 KiB
Python
139 lines
10 KiB
Python
"""Read optional reference MusicXML literally, without repairing it or composing."""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from fractions import Fraction as F
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
def read(path, role, form_start=None, pitch_convention='encoded'):
|
|
if pitch_convention not in ('encoded','guitar-written'):
|
|
raise ValueError('Unknown reference pitch convention: '+pitch_convention)
|
|
raw=Path(path).read_bytes();tree=ET.fromstring(raw)
|
|
result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role,
|
|
'pitch_convention':pitch_convention,'part_names':[n.text for n in tree.findall('./part-list/score-part/part-name')],
|
|
'measures':[],'notes':[],'harmonies':[],'annotations':[],'discrepancies':[]}
|
|
divisions=F(1);absolute=F();nominal=F(4);pending={};active=None;transpose=F()
|
|
for index,m in enumerate(tree.findall('./part/measure')):
|
|
cursor=F();extent=F();last_start=F();last_duration=F()
|
|
if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value)
|
|
meter=m.find('attributes/time')
|
|
if meter is not None and meter.findtext('beats'):
|
|
nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type'))
|
|
for item in m:
|
|
offset=F(item.findtext('offset','0'))/divisions
|
|
if item.tag=='attributes' and item.find('transpose') is not None:
|
|
transpose=F(item.findtext('transpose/chromatic','0'))+12*F(item.findtext('transpose/octave-change','0'))
|
|
elif item.tag=='backup':cursor-=F(item.findtext('duration'))/divisions
|
|
elif item.tag=='forward':cursor+=F(item.findtext('duration'))/divisions;extent=max(extent,cursor)
|
|
elif item.tag=='harmony':
|
|
active={'time':str(absolute+cursor+offset),'measure':index+1,'offset':str(cursor+offset),
|
|
'root':item.findtext('root/root-step'),'alter':item.findtext('root/root-alter','0'),
|
|
'kind':item.findtext('kind'),'display':item.find('kind').get('text') if item.find('kind') is not None else None,
|
|
'bass':ET.tostring(item.find('bass'),encoding='unicode') if item.find('bass') is not None else None,
|
|
'degrees':[{'value':d.findtext('degree-value'),'alter':d.findtext('degree-alter'),'type':d.findtext('degree-type')} for d in item.findall('degree')]}
|
|
result['harmonies'].append(active)
|
|
elif item.tag=='direction':
|
|
for words in item.findall('direction-type/words'):
|
|
result['annotations'].append({'time':str(absolute+cursor+offset),'measure':index+1,'text':''.join(words.itertext()),'interpretation':'descriptive; no production permission'})
|
|
elif item.tag=='note':
|
|
duration=F(item.findtext('duration','0'))/divisions
|
|
start=last_start if item.find('chord') is not None else cursor
|
|
end=start+duration;voice=item.findtext('voice','1');p=item.find('pitch')
|
|
encoded={'step':p.findtext('step'),'alter':p.findtext('alter','0'),'octave':int(p.findtext('octave'))} if p is not None else None
|
|
kinds={t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')}
|
|
event={'start':str(absolute+start),'end':str(absolute+end),'measure':index+1,'offset':str(start),'pitch':encoded,'rest':item.find('rest') is not None,
|
|
'written_type':item.findtext('type'),'dots':len(item.findall('dot')),'voice':voice,'ties':sorted(kinds),
|
|
'sounding_transposition':str(-12 if pitch_convention=='guitar-written' else transpose),
|
|
'tablature':{'string':int(item.findtext('notations/technical/string')),'fret':int(item.findtext('notations/technical/fret'))} if item.find('notations/technical/string') is not None and item.find('notations/technical/fret') is not None else None,
|
|
'time_modification':ET.tostring(item.find('time-modification'),encoding='unicode') if item.find('time-modification') is not None else None,
|
|
'tuplets':[dict(t.attrib) for t in item.findall('notations/tuplet')],'grace':item.find('grace') is not None,'new_attack':'stop' not in kinds and p is not None}
|
|
key=(voice,json.dumps(encoded,sort_keys=True))
|
|
if 'stop' in kinds:
|
|
old=pending.get(key)
|
|
if old is None or result['notes'][old]['end']!=event['start']:
|
|
result['discrepancies'].append({'measure':index+1,'issue':'unmatched/noncontiguous tie; retained literally'})
|
|
else:event['continuation_of']=old
|
|
if 'start' not in kinds:pending.pop(key,None)
|
|
if 'start' in kinds:pending[key]=len(result['notes'])
|
|
result['notes'].append(event)
|
|
if item.find('chord') is None and item.find('grace') is None:cursor=end
|
|
last_start,last_duration=start,duration;extent=max(extent,end,cursor)
|
|
if extent!=nominal and m.get('implicit')!='yes':result['discrepancies'].append({'measure':index+1,'issue':f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
|
|
result['measures'].append({'number':m.get('number'),'start':str(absolute),'duration':str(extent),'implicit':m.get('implicit'),'initial_harmony_known':any(F(h['time'])<=absolute for h in result['harmonies'])})
|
|
absolute+=extent
|
|
if pending:result['discrepancies'].append({'issue':'unterminated ties retained'})
|
|
result['harmonies'].sort(key=lambda h:F(h['time']))
|
|
# Do not run the strict score importer over these references: annotations,
|
|
# unusual timing and high encoded pitches are evidence, not repair requests.
|
|
from generator import harmony, model, fretboard
|
|
def semantic(h):
|
|
return {'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],
|
|
'degrees':[{'value':int(d['value']),'alter':model.rational(F(d['alter'])),'type':d['type']} for d in h['degrees']]}
|
|
for event in result['notes']:
|
|
if not event['pitch']:continue
|
|
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter'])
|
|
event['encoded_midi']=str(number)
|
|
event['sounding_midi']=str(number+F(event['sounding_transposition']))
|
|
event['legal_positions_at_encoded_pitch']=len(fretboard.single_positions_for_pitch(number))
|
|
event['legal_positions_at_sounding_pitch']=len(fretboard.single_positions_for_pitch(F(event['sounding_midi'])))
|
|
event['harmonic_spans']=[]
|
|
start,end=F(event['start']),F(event['end'])
|
|
changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end]
|
|
for a,b in zip([start,*changes],[*changes,end]):
|
|
current=next((h for h in reversed(result['harmonies']) if F(h['time'])<=a),None)
|
|
info={'start':str(a),'end':str(b),'chord':current,'chord_degrees':[]}
|
|
if current and current['root']:
|
|
try:
|
|
chord=semantic(current);root=model.NATURAL[chord['root']['letter']]+F(current['alter'])
|
|
info['chord_degrees']=[d for d,alter in harmony.degrees(chord).items() if (root+harmony.degree_semitones(d,alter)-number)%12==0]
|
|
info['root_relative_semitones']=str((number-root)%12)
|
|
info['spelled_simple_degree']=(model.LETTERS.index(p['step'].lower())-model.LETTERS.index(chord['root']['letter']))%7+1
|
|
except (ValueError,KeyError,AttributeError) as error:
|
|
info['uncertainty']=str(error)
|
|
else:info['uncertainty']='No written/carried harmony; none invented.'
|
|
event['harmonic_spans'].append(info)
|
|
sounded=[];tie_roots={}
|
|
for i,n in enumerate(result['notes']):
|
|
if not n['pitch']:continue
|
|
previous=n.get('continuation_of')
|
|
if previous is not None and previous in tie_roots:
|
|
root=tie_roots[previous];sounded[root]['end']=n['end'];sounded[root]['notation_events'].append(i);tie_roots[i]=root
|
|
else:
|
|
tie_roots[i]=len(sounded);sounded.append({'start':n['start'],'end':n['end'],'pitch':n['pitch'],
|
|
'encoded_midi':n['encoded_midi'],'sounding_midi':n['sounding_midi'],'measure':n['measure'],'notation_events':[i]})
|
|
result['sounding_events']=sounded
|
|
first=form_start or next((h['measure'] for h in result['harmonies']),None)
|
|
offset=F(result['measures'][first-1]['start']) if first else None
|
|
result['alignment']={'form_start_measure':first,'encoded_offset':str(offset) if offset is not None else None,'basis':'explicit override' if form_start else 'first written harmony; review required','preceding_material':'retained, without invented harmony'}
|
|
for label in result['annotations']:
|
|
t=F(label['time']); current=[h for h in result['harmonies'] if F(h['time'])<=t]
|
|
future=next((h for h in result['harmonies'] if F(h['time'])>t),None)
|
|
label['current_harmony']=current[-1] if current else None
|
|
label['following_harmony']=future
|
|
label['events']=[n for n in result['notes'] if F(n['start'])<=t<F(n['end']) or t<=F(n['start'])<t+1]
|
|
label['uncertainty']='Label meaning must be assessed from these events and harmonic time; label preserved verbatim.'
|
|
expected={'b9':F(1),'#9':F(3),'b13':F(8),'#11':F(6),'6':F(9)}.get(label['text'].strip())
|
|
if expected is not None:
|
|
relationships=[s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans',[])]
|
|
label['literal_interval_present']=str(expected) in relationships
|
|
if not label['literal_interval_present']:label['uncertainty']='No matching interval in the immediate annotated context; label retained, intended scope uncertain.'
|
|
# Lead in and Bluesy stay contextual descriptions, never enclosure permits.
|
|
return result
|
|
|
|
|
|
def main():
|
|
p=argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--reference',action='append',default=[],metavar='ROLE=PATH')
|
|
p.add_argument('--form-start',action='append',default=[],metavar='ROLE=MEASURE')
|
|
p.add_argument('--output',type=Path,required=True)
|
|
p.add_argument('--reference-pitch',choices=('encoded','guitar-written'),default='encoded')
|
|
args=p.parse_args(); starts={k:int(v) for k,v in (x.split('=',1) for x in args.form_start)}
|
|
results=[read(path,role,starts.get(role),args.reference_pitch) for role,path in (x.split('=',1) for x in args.reference)]
|
|
args.output.parent.mkdir(parents=True,exist_ok=True)
|
|
args.output.write_text(json.dumps({'optional_validation_only':True,'references':results},indent=2))
|
|
|
|
if __name__=='__main__':main()
|