"""Literal authored-score reader for training, registered repertoire and comparison.""" from __future__ import annotations import hashlib, json import pathlib import fractions import xml.etree.ElementTree import generator.fretboard 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 = pathlib.Path(path).read_bytes() tree = xml.etree.ElementTree.fromstring(raw) result = {'path': str(pathlib.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 = fractions.Fraction(1) absolute = fractions.Fraction() nominal = fractions.Fraction(4) pending = {} active = None transpose = fractions.Fraction() for index, m in enumerate(tree.findall('./part/measure')): cursor = fractions.Fraction() extent = fractions.Fraction() last_start = fractions.Fraction() last_duration = fractions.Fraction() if (value := m.findtext('attributes/divisions')) is not None: divisions = fractions.Fraction(value) meter = m.find('attributes/time') if meter is not None and meter.findtext('beats'): nominal = sum((fractions.Fraction(v) for v in meter.findtext('beats').split('+')), fractions.Fraction()) * 4 / fractions.Fraction(meter.findtext('beat-type')) for item in m: offset = fractions.Fraction(item.findtext('offset', '0')) / divisions if item.tag == 'attributes' and item.find('transpose') is not None: transpose = fractions.Fraction(item.findtext('transpose/chromatic', '0')) + 12 * fractions.Fraction(item.findtext('transpose/octave-change', '0')) elif item.tag == 'backup': cursor -= fractions.Fraction(item.findtext('duration')) / divisions elif item.tag == 'forward': cursor += fractions.Fraction(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': xml.etree.ElementTree.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 = fractions.Fraction(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': xml.etree.ElementTree.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) for container in ('notations/technical/fingering', 'notations/technical/other-technical', 'notations/other-notation'): for label in item.findall(container): text = ''.join(label.itertext()).strip() if text: result['annotations'].append({'time': str(absolute + start), 'measure': index + 1, 'text': text, 'container': container, 'interpretation': 'Original note annotation; container does not establish musical permission or guitar fingering.'}) 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(fractions.Fraction(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: fractions.Fraction(h['time'])) # Do not run the strict score importer over these references: annotations, # unusual timing and high encoded pitches are evidence, not repair requests. for event in result['notes']: if not event['pitch']: continue p = event['pitch'] number = 12 * (p['octave'] + 1) + generator.model.NATURAL[p['step'].lower()] + fractions.Fraction(p['alter']) event['encoded_midi'] = str(number) event['sounding_midi'] = str(number + fractions.Fraction(event['sounding_transposition'])) event['legal_positions_at_encoded_pitch'] = len(generator.fretboard.single_positions_for_pitch(number)) event['legal_positions_at_sounding_pitch'] = len(generator.fretboard.single_positions_for_pitch(fractions.Fraction(event['sounding_midi']))) event['harmonic_spans'] = [] start, end = fractions.Fraction(event['start']), fractions.Fraction(event['end']) changes = [fractions.Fraction(h['time']) for h in result['harmonies'] if start < fractions.Fraction(h['time']) < end] for a, b in zip([start, *changes], [*changes, end]): current = next((h for h in reversed(result['harmonies']) if fractions.Fraction(h['time']) <= a), None) info = {'start': str(a), 'end': str(b), 'chord': current, 'chord_degrees': []} if current and current['root']: try: chord = generator.harmony.parse_symbol(training_chord(current)) root = generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']) info['chord_degrees'] = [d for d, alter in generator.harmony.degrees(chord).items() if (root + generator.harmony.degree_semitones(d, alter) - number) % 12 == 0] info['root_relative_semitones'] = str((number - root) % 12) info['spelled_simple_degree'] = (generator.model.LETTERS.index(p['step'].lower()) - generator.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 = fractions.Fraction(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 = fractions.Fraction(label['time']) current = [h for h in result['harmonies'] if fractions.Fraction(h['time']) <= t] future = next((h for h in result['harmonies'] if fractions.Fraction(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 fractions.Fraction(n['start']) <= t < fractions.Fraction(n['end']) or t <= fractions.Fraction(n['start']) < t + 1] label['uncertainty'] = 'Label meaning must be assessed from these events and harmonic time; label preserved verbatim.' expected = {'b9': fractions.Fraction(1), '#9': fractions.Fraction(3), 'b13': fractions.Fraction(8), '#11': fractions.Fraction(6), '6': fractions.Fraction(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 import generator.harmony import generator.model def training_chord(h): result = {'root': {'letter': h['root'].lower(), 'alter': generator.model.rational(fractions.Fraction(h['alter']))}, 'kind': h['kind'], 'degrees': []} for d in h['degrees']: degree = int(d['value']) alter = fractions.Fraction(d['alter']) if d['type'] == 'alter': alter += generator.harmony.KINDS.get(result['kind'], {}).get(degree, 0) elif d['type'] == 'add' and (degree - 1) % 7 == 6: alter -= 1 result['degrees'].append({'value': degree, 'alter': generator.model.rational(alter), 'type': d['type']}) if h['bass']: bass = xml.etree.ElementTree.fromstring(h['bass']) result['bass'] = {'letter': bass.findtext('bass-step').lower(), 'alter': generator.model.rational(fractions.Fraction(bass.findtext('bass-alter', '0')))} display = h['display'] if display: # Soundslice exports an optional chord's suffix with a trailing closing # parenthesis, but its actual root separately. Evidence stays untouched. if display.endswith(')') and '(' not in display: display = display[:-1] generator.harmony.normalize_text(result, display) return generator.harmony.symbol(result).strip('()') def validate_harmony(document, evidence, identity): source_chords = [] cursor = fractions.Fraction() for measure in document['staves'][0]['measures']: source_chords.extend((cursor + generator.model.fraction(h['onset']), generator.harmony.symbol(h).strip('()')) for h in measure['harmonies']) cursor += generator.model.fraction(measure['duration']) authored = [(fractions.Fraction(h['time']), training_chord(h)) for h in evidence['harmonies']] if authored != source_chords: raise ValueError(identity + ': authored harmony differs from authoritative source')