Formatting
ci / checks (push) Successful in 42m54s

This commit is contained in:
khodowany
2026-09-15 00:06:02 -05:00
parent 44e100985a
commit fe42d811fa
81 changed files with 4349 additions and 3340 deletions
-1
View File
@@ -1,2 +1 @@
"""Build-time score generation for Product Vamp."""
+2 -8
View File
@@ -13,7 +13,6 @@ from generator.musicxml import load_musicxml
RUNTIME_FONT_FILES = ("Petaluma.otf", "PetalumaScript.otf", "OFL.txt")
def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None:
source = static_assets_dir / "fonts" / "petaluma"
destination = output_dir / "fonts" / "petaluma"
@@ -21,7 +20,6 @@ def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None:
for name in RUNTIME_FONT_FILES:
shutil.copy2(source / name, destination / name)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.")
parser.add_argument("--data-dir", type=Path, required=True)
@@ -31,15 +29,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--check", action="store_true")
return parser.parse_args()
def generate_assets(args: argparse.Namespace) -> None:
data_dir = args.data_dir.resolve()
output_dir = args.output_dir.resolve()
static_assets_dir = args.static_assets_dir.resolve()
for source in sorted(data_dir.rglob('*.musicxml')):
for source in sorted(data_dir.rglob('*.xml')):
load_musicxml(source)
output_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
staged = Path(temporary) / 'assets'
@@ -63,7 +58,7 @@ def generate_assets(args: argparse.Namespace) -> None:
backup.rename(output_dir)
raise
print(f'Generated Product Vamp assets: {performance_count} performance scores, '
f'{practice_count} practice score variants, {count_json_files(output_dir)} total JSON files.')
f'{practice_count} practice score variants, {count_json_files(output_dir)} total JSON files.')
if args.check:
print('Generator check passed.')
@@ -71,6 +66,5 @@ def main() -> int:
generate_assets(parse_args())
return 0
if __name__ == "__main__":
raise SystemExit(main())
-1
View File
@@ -1,2 +1 @@
"""Performance arrangement generation."""
+2 -9
View File
@@ -5,17 +5,13 @@ from typing import Literal
from generator.score import body_start_measure_index
SectionRole = Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]
@dataclass(frozen=True)
class ArrangementSection:
label: str
source_measure_index: int
measure_count: int
section_index: int
role: SectionRole
role: Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]
def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
if body_measure_count >= 8:
@@ -24,8 +20,7 @@ def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
return 4
return body_measure_count
def section_role(part_type: str, section_index: int) -> SectionRole:
def section_role(part_type: str, section_index: int) -> Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]:
if part_type == "acoustic":
return "slash" if section_index == 3 else "rhythm"
if part_type == "electric":
@@ -38,7 +33,6 @@ def section_role(part_type: str, section_index: int) -> SectionRole:
return "rest"
return "vocal" if section_index in {1, 5, 6} else "rest"
def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]:
if not measures:
return []
@@ -62,4 +56,3 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra
for label, start, count, index in specs
if count > 0
]
+7 -14
View File
@@ -10,17 +10,14 @@ from generator.model import NATURAL
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
class TabPosition(NamedTuple):
string_number: int
fret: int
midi: int
def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]:
return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol)
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
result: list[TabPosition] = []
for fret in range(0, 13):
@@ -29,7 +26,6 @@ def positions_for_string(string_number: int, open_midi: int, pitch_classes: tupl
result.append(TabPosition(string_number, fret, midi))
return result
def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int:
fretted = [position.fret for position in candidate if position.fret > 0]
if not fretted:
@@ -43,7 +39,6 @@ def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int
open_penalty = sum(1 for position in candidate if position.fret == 0)
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
semantic_degrees = harmony.degrees(definition)
@@ -54,16 +49,15 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
for degree in (11, 9, 5):
if len(voicing) > 4 and voicing.get(degree) == 0:
del voicing[degree]
root = definition.get('root', {'letter':'c','alter':0})
root = definition.get('root', {'letter': 'c', 'alter': 0})
root_pc = NATURAL[root['letter']] + root['alter']
pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d,a)) % 12 for d,a in voicing.items()))
pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in voicing.items()))
bass = definition.get('bass')
bass_pc = (NATURAL[bass['letter']] + bass['alter']) % 12 if bass else None
if bass_pc is not None and bass_pc not in pitch_classes:
pitch_classes += (bass_pc,)
if not pitch_classes:
return []
string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1))
by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING}
best: tuple[TabPosition, ...] | None = None
@@ -73,7 +67,7 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
if any(not item for item in options):
continue
for candidate in product(*options):
if bass_pc is not None and min(candidate, key=lambda p:p.midi).midi % 12 != bass_pc:
if bass_pc is not None and min(candidate, key=lambda p: p.midi).midi % 12 != bass_pc:
continue
fretted = [position.fret for position in candidate if position.fret > 0]
if fretted and max(fretted) - min(fretted) > 4:
@@ -87,17 +81,16 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
best_score = score
return sorted(best or [], key=lambda item: item.midi)
def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]:
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
from generator.transposition import shift_pitch
root = definition.get('root', {'letter':'c', 'alter':0})
root = definition.get('root', {'letter': 'c', 'alter': 0})
spellings = {}
for degree, alter in harmony.degrees(definition).items():
p = shift_pitch(root, degree-1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[p['letter']]+p['alter']) % 12] = p
p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[p['letter']] + p['alter']) % 12] = p
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']]+bass['alter']) % 12] = bass
spellings[(NATURAL[bass['letter']] + bass['alter']) % 12] = bass
result = []
for position in find_playable_voicing(definition):
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
+56 -61
View File
@@ -12,10 +12,9 @@ from generator.musicxml import load_musicxml
from generator.transposition import concert_document, shift_pitch
from generator.score import copy_source_voice, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json
PART_TYPES = ('acoustic','electric','vocal')
PART_TYPES = ('acoustic', 'electric', 'vocal')
FINAL_SECTION_INDEX = 6
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]:
result = []
current = None
@@ -26,7 +25,6 @@ def propagated_chords(measures: list[dict], *, include_optional: bool = True) ->
current = h
return result
def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None:
active = previous
for h in measure['harmonies']:
@@ -35,26 +33,25 @@ def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict
active = h
return active if active and active['kind'] != 'none' else None
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
length = fraction(measure['duration'])
if role == 'rest':
return [make_event('rest', Fraction(), length, 'rest', written={'type':'whole','dots':0}) | {'measure_rest':True}]
if role == 'rhythm' and measure['time_signature']=='4/4' and not measure.get('attributes'):
attacks = [(Fraction(n,2), Fraction(1,2)) for n in (0,3,4,5,7)]
elif role == 'accompaniment' and measure['time_signature']=='4/4' and not measure.get('attributes'):
attacks = [(Fraction(1,2),Fraction(1,2))]
return [make_event('rest', Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
if role == 'rhythm' and measure['time_signature'] == '4/4' and not measure.get('attributes'):
attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)]
elif role == 'accompaniment' and measure['time_signature'] == '4/4' and not measure.get('attributes'):
attacks = [(Fraction(1, 2), Fraction(1, 2))]
else:
attacks = []
states = [(Fraction(), measure['meter'])]
states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
for index, (onset, meter) in enumerate(states):
end = states[index+1][0] if index+1 < len(states) else length
end = states[index + 1][0] if index + 1 < len(states) else length
beats = beat_lengths(meter)
beat_index = 0
while onset < end:
beat = min(beats[beat_index % len(beats)] if beats else end-onset, end-onset)
duration = beat if role == 'slash' or not beats else min(beat/2, Fraction(1,2))
beat = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset)
duration = beat if role == 'slash' or not beats else min(beat / 2, Fraction(1, 2))
attacks.append((onset, duration))
onset += beat
beat_index += 1
@@ -64,35 +61,34 @@ def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dic
if onset >= length:
break
if onset > cursor:
events.append(make_event(f'rest-{len(events)}',cursor,onset-cursor,'rest'))
duration = min(duration,length-onset)
chord = active_chord(measure,onset,previous)
source_attack = any(fraction(n['onset'])==onset and n['kind']=='note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure))
if role=='slash':
event = make_event(f'slash-{len(events)}',onset,duration,'note')
events.append(make_event(f'rest-{len(events)}', cursor, onset - cursor, 'rest'))
duration = min(duration, length - onset)
chord = active_chord(measure, onset, previous)
source_attack = any(fraction(n['onset']) == onset and n['kind'] == 'note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure))
if role == 'slash':
event = make_event(f'slash-{len(events)}', onset, duration, 'note')
event['notehead'] = 'slash'
elif not chord or role=='accompaniment' and source_attack:
event = make_event(f'rest-{len(events)}',onset,duration,'rest')
elif not chord or role == 'accompaniment' and source_attack:
event = make_event(f'rest-{len(events)}', onset, duration, 'rest')
else:
pitches = pitches_for_voicing(chord)
event = make_event(f'chord-{len(events)}',onset,duration,'note',pitches)
event = make_event(f'chord-{len(events)}', onset, duration, 'note', pitches)
if not pitches:
warnings.warn(f'{measure["id"]}: no playable voicing for {chord["symbol"]}; using labelled rhythmic slashes', stacklevel=2)
event['notehead'] = 'slash'
events.append(event)
cursor = onset + duration
if cursor < length:
events.append(make_event(f'rest-{len(events)}',cursor,length-cursor,'rest'))
events.append(make_event(f'rest-{len(events)}', cursor, length - cursor, 'rest'))
return events
def arrange_document(document: dict, part_type: str) -> dict:
if part_type not in PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}')
document = concert_document(document)
staff = source_staff(document)
source = staff['measures']
sections = full_arrangement_sections(source,part_type)
sections = full_arrangement_sections(source, part_type)
output, tuplets, octave_shifts = [], [], []
absolute = Fraction()
source_starts = []
@@ -101,80 +97,79 @@ def arrange_document(document: dict, part_type: str) -> dict:
source_starts.append(source_time)
source_time += fraction(m['duration'])
for section in sections:
previous = propagated_chords(source, include_optional=section.section_index!=FINAL_SECTION_INDEX)
previous = propagated_chords(source, include_optional=section.section_index != FINAL_SECTION_INDEX)
segment = []
for si in range(section.source_measure_index,section.source_measure_index+section.measure_count):
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = copy.deepcopy(source[si])
m['harmonies'] = [h for h in m['harmonies'] if section.section_index!=FINAL_SECTION_INDEX or not h.get('chord_optional')]
m['harmonies'] = [h for h in m['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')]
for h in m['harmonies']:
h.pop('chord_optional',None)
notes = copy_source_voice(m,strip_lyrics=section.role=='melody')['notes'] if section.role in ('vocal','melody') else generated_voice(m,section.role,previous[si])
h.pop('chord_optional', None)
notes = copy_source_voice(m, strip_lyrics=section.role == 'melody')['notes'] if section.role in ('vocal', 'melody') else generated_voice(m, section.role, previous[si])
if section.role == 'melody':
for note in notes:
note['pitches'] = [shift_pitch(p,7,12) for p in note['pitches']]
result = make_measure(f'measure-{len(output)+len(segment)+1}',m,notes,section_label=section.label if not segment else '')
if part_type!='vocal':
result['clef'] = {'sign':'G','line':2,'octave_change':0}
result['transpose'] = {'diatonic':0,'chromatic':0,'octave_change':-1}
for change in result.get('attributes',[]):
change.pop('clef',None)
change.pop('transpose',None)
note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']]
result = make_measure(f'measure-{len(output) + len(segment) + 1}', m, notes, section_label=section.label if not segment else '')
if part_type != 'vocal':
result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1}
for change in result.get('attributes', []):
change.pop('clef', None)
change.pop('transpose', None)
segment.append(result)
identity_segment = segment
segment_length = sum((fraction(m['duration']) for m in segment),Fraction())
if section.role in ('vocal','melody'):
segment_length = sum((fraction(m['duration']) for m in segment), Fraction())
if section.role in ('vocal', 'melody'):
start = source_starts[section.source_measure_index]
for shift in staff.get('octave_shifts',[]):
low, high = max(start,fraction(shift['start'])), min(start+segment_length,fraction(shift['end']))
for shift in staff.get('octave_shifts', []):
low, high = max(start, fraction(shift['start'])), min(start + segment_length, fraction(shift['end']))
if low < high:
octave_shifts.append({**shift,'id':f'section-{section.section_index}-{shift["id"]}','start':rational(absolute+low-start),'end':rational(absolute+high-start)})
if part_type=='vocal' and section.section_index in (1,5) and section.source_measure_index==1 and source[0].get('implicit') and output:
octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': rational(absolute + low - start), 'end': rational(absolute + high - start)})
if part_type == 'vocal' and section.section_index in (1, 5) and section.source_measure_index == 1 and source[0].get('implicit') and output:
pickup = copy.deepcopy(source[0])
destination = output[-1]
pad = fraction(destination['duration']) - fraction(pickup['duration'])
if pad < 0:
raise ValueError('pickup is longer than the preceding arrangement measure')
notes = copy_source_voice(pickup,strip_lyrics=False)['notes']
notes = copy_source_voice(pickup, strip_lyrics=False)['notes']
for n in notes:
n['onset'] = rational(fraction(n['onset'])+pad)
n['onset'] = rational(fraction(n['onset']) + pad)
if pad:
notes.insert(0,make_event('pickup-rest',Fraction(),pad,'rest'))
notes.insert(0, make_event('pickup-rest', Fraction(), pad, 'rest'))
destination['voices'][0]['notes'] = notes
identity_segment = [destination, *segment]
if pickup['harmonies']:
destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad]
for h in pickup['harmonies']:
h['onset'] = rational(fraction(h['onset'])+pad)
h['onset'] = rational(fraction(h['onset']) + pad)
destination['harmonies'].append(h)
state = {k:copy.deepcopy(pickup[k]) for k in ('key','clef','meter','transpose')}
state = {k: copy.deepcopy(pickup[k]) for k in ('key', 'clef', 'meter', 'transpose')}
if pad:
destination['attributes'].append({'onset':rational(pad), **state})
destination['attributes'].append({'onset': rational(pad), **state})
else:
destination.update(state)
destination['key_signature'] = key_label(state['key'])
destination['time_signature'] = meter_label(state['meter'])
for change in pickup.get('attributes',[]):
destination['attributes'].append({**change,'onset':rational(fraction(change['onset'])+pad)})
for shift in staff.get('octave_shifts',[]):
low, high = fraction(shift['start']), min(fraction(pickup['duration']),fraction(shift['end']))
for change in pickup.get('attributes', []):
destination['attributes'].append({**change, 'onset': rational(fraction(change['onset']) + pad)})
for shift in staff.get('octave_shifts', []):
low, high = fraction(shift['start']), min(fraction(pickup['duration']), fraction(shift['end']))
if low < high:
offset = absolute-fraction(pickup['duration'])
octave_shifts.append({**shift,'id':f'pickup-{section.section_index}-{shift["id"]}','start':rational(offset+low),'end':rational(offset+high)})
tuplets.extend(reidentify(identity_segment,staff.get('tuplets',[])))
offset = absolute - fraction(pickup['duration'])
octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': rational(offset + low), 'end': rational(offset + high)})
tuplets.extend(reidentify(identity_segment, staff.get('tuplets', [])))
output.extend(segment)
absolute += segment_length
result = make_arranged_document(document,staff,staff_id='staff-arranged-'+part_type,staff_name=part_type.capitalize(),role='vocal' if part_type=='vocal' else 'guitar',arrangement_part_type=part_type,measures=output,tuplets=tuplets,octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo',124)
result = make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124)
return result
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
if not source_dir.exists():
raise ValueError(f'performance source directory not found: {source_dir}')
count = 0
for path in sorted(source_dir.glob('*.musicxml')):
for path in sorted(source_dir.glob('*.xml')):
document = load_musicxml(path)
for part in PART_TYPES:
write_json(output_dir/f'{slugify(path.stem)}-{part}.json',arrange_document(document,part))
write_json(output_dir / f'{slugify(path.stem)}-{part}.json', arrange_document(document, part))
count += 1
return count
+23 -28
View File
@@ -4,28 +4,27 @@ from generator.model import NATURAL, parse_pitch_name, pitch_name
# Semantic degrees are independent of guitar voicing omissions.
KINDS = {
'major': {1:0, 3:0, 5:0}, 'minor': {1:0, 3:-1, 5:0},
'augmented': {1:0, 3:0, 5:1}, 'diminished': {1:0, 3:-1, 5:-1},
'dominant': {1:0, 3:0, 5:0, 7:-1}, 'major-seventh': {1:0, 3:0, 5:0, 7:0},
'minor-seventh': {1:0, 3:-1, 5:0, 7:-1}, 'diminished-seventh': {1:0, 3:-1, 5:-1, 7:-2},
'augmented-seventh': {1:0, 3:0, 5:1, 7:-1}, 'augmented-major-seventh': {1:0, 3:0, 5:1, 7:0},
'half-diminished': {1:0, 3:-1, 5:-1, 7:-1}, 'major-minor': {1:0, 3:-1, 5:0, 7:0},
'major-sixth': {1:0, 3:0, 5:0, 6:0}, 'minor-sixth': {1:0, 3:-1, 5:0, 6:0},
'suspended-second': {1:0, 2:0, 5:0}, 'suspended-fourth': {1:0, 4:0, 5:0},
'power': {1:0, 5:0}, 'pedal': {1:0}, 'other': {}, 'none': {},
'Neapolitan': {1:0, 3:0, 5:0}, 'Italian': {1:0, 3:0, 6:1},
'French': {1:0, 3:0, 4:1, 6:1}, 'German': {1:0, 3:0, 5:0, 6:1},
'major': {1: 0, 3: 0, 5: 0}, 'minor': {1: 0, 3: -1, 5: 0},
'augmented': {1: 0, 3: 0, 5: 1}, 'diminished': {1: 0, 3: -1, 5: -1},
'dominant': {1: 0, 3: 0, 5: 0, 7: -1}, 'major-seventh': {1: 0, 3: 0, 5: 0, 7: 0},
'minor-seventh': {1: 0, 3: -1, 5: 0, 7: -1}, 'diminished-seventh': {1: 0, 3: -1, 5: -1, 7: -2},
'augmented-seventh': {1: 0, 3: 0, 5: 1, 7: -1}, 'augmented-major-seventh': {1: 0, 3: 0, 5: 1, 7: 0},
'half-diminished': {1: 0, 3: -1, 5: -1, 7: -1}, 'major-minor': {1: 0, 3: -1, 5: 0, 7: 0},
'major-sixth': {1: 0, 3: 0, 5: 0, 6: 0}, 'minor-sixth': {1: 0, 3: -1, 5: 0, 6: 0},
'suspended-second': {1: 0, 2: 0, 5: 0}, 'suspended-fourth': {1: 0, 4: 0, 5: 0},
'power': {1: 0, 5: 0}, 'pedal': {1: 0}, 'other': {}, 'none': {},
'Neapolitan': {1: 0, 3: 0, 5: 0}, 'Italian': {1: 0, 3: 0, 6: 1},
'French': {1: 0, 3: 0, 4: 1, 6: 1}, 'German': {1: 0, 3: 0, 5: 0, 6: 1},
}
for family, base in (('dominant', 'dominant'), ('major', 'major-seventh'), ('minor', 'minor-seventh')):
for suffix, highest in (('ninth',9), ('11th',11), ('13th',13)):
KINDS[f'{family}-{suffix}'] = {**KINDS[base], **{i:0 for i in range(9, highest+1, 2)}}
for suffix, highest in (('ninth', 9), ('11th', 11), ('13th', 13)):
KINDS[f'{family}-{suffix}'] = {**KINDS[base], **{i: 0 for i in range(9, highest + 1, 2)}}
SUFFIXES = {'major':'', 'minor':'-', 'augmented':'+', 'diminished':'dim', 'dominant':'7', 'major-seventh':'maj7', 'minor-seventh':'-7', 'diminished-seventh':'dim7', 'augmented-seventh':'+7', 'augmented-major-seventh':'+maj7', 'half-diminished':'-7b5', 'major-minor':'-maj7', 'major-sixth':'6', 'minor-sixth':'-6', 'suspended-second':'sus2', 'suspended-fourth':'sus4', 'power':'5', 'pedal':'', 'other':''}
for family, prefix in (('dominant',''), ('major','maj'), ('minor','-')):
for suffix, number in (('ninth','9'), ('11th','11'), ('13th','13')):
SUFFIXES = {'major': '', 'minor': '-', 'augmented': '+', 'diminished': 'dim', 'dominant': '7', 'major-seventh': 'maj7', 'minor-seventh': '-7', 'diminished-seventh': 'dim7', 'augmented-seventh': '+7', 'augmented-major-seventh': '+maj7', 'half-diminished': '-7b5', 'major-minor': '-maj7', 'major-sixth': '6', 'minor-sixth': '-6', 'suspended-second': 'sus2', 'suspended-fourth': 'sus4', 'power': '5', 'pedal': '', 'other': ''}
for family, prefix in (('dominant', ''), ('major', 'maj'), ('minor', '-')):
for suffix, number in (('ninth', '9'), ('11th', '11'), ('13th', '13')):
SUFFIXES[f'{family}-{suffix}'] = prefix + number
def degrees(harmony: dict) -> dict[int, int]:
kind = harmony['kind']
if kind not in KINDS:
@@ -53,10 +52,8 @@ def degrees(harmony: dict) -> dict[int, int]:
raise ValueError('harmony contains no pitch information')
return result
def degree_semitones(degree: int, alter: int) -> int:
return 12 * ((degree-1)//7) + (0,2,4,5,7,9,11)[(degree-1)%7] + alter
return 12 * ((degree - 1) // 7) + (0, 2, 4, 5, 7, 9, 11)[(degree - 1) % 7] + alter
def pitch_classes(harmony: dict) -> tuple[int, ...]:
if harmony['kind'] == 'none':
@@ -68,7 +65,6 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]:
values.append((NATURAL[bass['letter']] + bass['alter']) % 12)
return tuple(dict.fromkeys(values))
def symbol(harmony: dict) -> str:
if harmony['kind'] == 'none':
return 'N.C.'
@@ -76,21 +72,20 @@ def symbol(harmony: dict) -> str:
if 'kind_text' not in harmony:
for d in harmony.get('degrees', []):
sign = '#' * max(d['alter'], 0) + 'b' * max(-d['alter'], 0)
suffix += ('no' if d['type']=='subtract' else 'add' if d['type']=='add' and not sign else '') + sign + str(d['value'])
suffix += ('no' if d['type'] == 'subtract' else 'add' if d['type'] == 'add' and not sign else '') + sign + str(d['value'])
result = pitch_name(harmony['root']) + suffix
if bass := harmony.get('bass'):
result += '/' + pitch_name(bass)
return result
def parse_symbol(text: str) -> dict:
import re
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/]*)(?:/([A-Ga-g][#b]*))?', text.strip())
if not match:
raise ValueError(f'invalid chord symbol: {text!r}')
root, suffix, bass = match.groups()
aliases = {'m':'minor', 'min':'minor', 'maj':'major', 'M':'major', 'm7':'minor-seventh', 'm6':'minor-sixth', 'm7b5':'half-diminished', 'o':'diminished', 'o7':'diminished-seventh', 'aug':'augmented', 'sus':'suspended-fourth'}
aliases.update({v:k for k,v in SUFFIXES.items() if k not in ('other','pedal')})
aliases = {'m': 'minor', 'min': 'minor', 'maj': 'major', 'M': 'major', 'm7': 'minor-seventh', 'm6': 'minor-sixth', 'm7b5': 'half-diminished', 'o': 'diminished', 'o7': 'diminished-seventh', 'aug': 'augmented', 'sus': 'suspended-fourth'}
aliases.update({v: k for k, v in SUFFIXES.items() if k not in ('other', 'pedal')})
core = suffix
changes = []
while core not in aliases:
@@ -98,13 +93,13 @@ def parse_symbol(text: str) -> dict:
if not m:
raise ValueError(f'unsupported chord spelling: {text!r}')
op, accidental, value = m.groups()
changes.insert(0, {'value':int(value), 'alter':1 if accidental=='#' else -1 if accidental=='b' else 0, 'type':'subtract' if op=='no' else 'add'})
changes.insert(0, {'value': int(value), 'alter': 1 if accidental == '#' else -1 if accidental == 'b' else 0, 'type': 'subtract' if op == 'no' else 'add'})
core = core[:m.start()]
kind = aliases[core]
for d in changes:
if d['type']=='add' and d['value'] in KINDS[kind]:
if d['type'] == 'add' and d['value'] in KINDS[kind]:
d['type'] = 'alter'
result = {'root':parse_pitch_name(root), 'kind':kind, 'degrees':changes, 'kind_text':suffix}
result = {'root': parse_pitch_name(root), 'kind': kind, 'degrees': changes, 'kind_text': suffix}
if bass:
result['bass'] = parse_pitch_name(bass)
degrees(result)
+7 -23
View File
@@ -12,14 +12,12 @@ NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11}
LETTERS = 'cdefgab'
MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6}
def rational(value: Fraction | int) -> dict[str, int]:
value = Fraction(value)
if abs(value.numerator) > MAX_INTEGER or value.denominator > MAX_INTEGER:
raise ValueError('musical time exceeds the supported 64-bit rational range')
return {'numerator': value.numerator, 'denominator': value.denominator}
def fraction(value: Any) -> Fraction:
if not isinstance(value, dict) or set(value) != {'numerator', 'denominator'}:
raise ValueError('musical time must be a numerator/denominator object')
@@ -31,14 +29,12 @@ def fraction(value: Any) -> Fraction:
raise ValueError('musical time must be reduced')
return result
def written_length(written: dict) -> Fraction:
kind, dots = written['type'], written.get('dots', 0)
if kind not in TYPE_LENGTHS or type(dots) is not int or not 0 <= dots <= 16:
raise ValueError('invalid written duration or augmentation-dot count (maximum 16)')
return TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots))
def written_for(length: Fraction) -> dict:
for name, base in TYPE_LENGTHS.items():
for dots in range(17):
@@ -48,7 +44,6 @@ def written_for(length: Fraction) -> dict:
base = next((name for name, size in TYPE_LENGTHS.items() if size <= length), '1024th')
return {'type': base, 'dots': 0, 'ratio': rational(length / TYPE_LENGTHS[base])}
def metric_length(written: dict) -> Fraction:
ratio = fraction(written.get('ratio', rational(1)))
if ratio <= 0:
@@ -57,7 +52,6 @@ def metric_length(written: dict) -> Fraction:
written_length(written['normal'])
return written_length(written) * ratio
def pitch_number(pitch: dict) -> int:
letter = pitch['letter'].lower()
alter, octave = pitch.get('alter', 0), pitch['octave']
@@ -65,18 +59,15 @@ def pitch_number(pitch: dict) -> int:
raise ValueError('invalid spelled pitch')
return 12 * (octave + 1) + NATURAL[letter] + alter
def pitch_name(pitch: dict) -> str:
alter = pitch.get('alter', 0)
return pitch['letter'].upper() + ('#' * alter if alter > 0 else 'b' * -alter)
def parse_pitch_name(name: str) -> dict:
if not name or name[0].lower() not in NATURAL or any(c not in '#b' for c in name[1:]):
raise ValueError(f'invalid pitch name: {name!r}')
return {'letter': name[0].lower(), 'alter': name[1:].count('#') - name[1:].count('b')}
def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
if mode not in MODE_STEPS and mode != 'none':
raise ValueError(f'unsupported mode: {mode}')
@@ -91,24 +82,20 @@ def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
tonic = {'letter': letter, 'alter': signature[LETTERS.index(letter)]}
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': signature}
def key_label(key: dict) -> str:
if not key.get('tonic'):
return 'No key'
label = pitch_name(key['tonic'])
return label if key['mode'] in ('major', 'ionian') else label + ' ' + key['mode']
def meter_length(meter: dict) -> Fraction:
return sum((Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), Fraction())
def meter_label(meter: dict) -> str:
if meter.get('unmetered'):
return 'senza misura'
return '+'.join('+'.join(map(str, group['beats'])) + '/' + str(group['unit']) for group in meter['groups'])
def beat_lengths(meter: dict) -> list[Fraction]:
result = []
for group in meter.get('groups', []):
@@ -121,7 +108,6 @@ def beat_lengths(meter: dict) -> list[Fraction]:
result.extend([Fraction(4, unit)] * beats[0])
return result
def validate_meter(meter: dict) -> None:
groups = meter.get('groups', [])
if not groups and not meter.get('unmetered'):
@@ -132,17 +118,15 @@ def validate_meter(meter: dict) -> None:
if any(type(b) is not int or not 0 < b <= 1024 for b in group['beats']):
raise ValueError('meter count must be between 1 and 1024')
def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str, pitches: list[dict] | None = None, written: dict | None = None) -> dict:
if duration <= 0:
raise ValueError('generated event duration must be positive')
notation = written or written_for(duration)
event = {'id': identifier, 'kind': kind, 'onset': rational(onset), 'duration': rational(duration), 'written': notation, 'hidden': False, 'pitches': []}
for i, p in enumerate(pitches or []):
event['pitches'].append({**copy.deepcopy(p), 'id': f'{identifier}-p{i+1}', 'duration': rational(duration), 'written': copy.deepcopy(notation)})
event['pitches'].append({**copy.deepcopy(p), 'id': f'{identifier}-p{i + 1}', 'duration': rational(duration), 'written': copy.deepcopy(notation)})
return event
def validate_document(document: dict) -> None:
if document.get('schema_version') != SCHEMA_VERSION:
raise ValueError('unsupported score schema; regenerate score assets with the current generator')
@@ -203,7 +187,7 @@ def validate_document(document: dict) -> None:
validate_meter(state['meter'])
if 'key' in state and (len(state['key']['alterations']) != 7 or any(type(a) is not int or not -3 <= a <= 3 for a in state['key']['alterations'])):
raise ValueError('key alterations must contain seven integers from -3 through 3')
if 'clef' in state and (state['clef']['sign'] not in ('G','F','C') or not 1 <= state['clef']['line'] <= 5 or not -3 <= state['clef']['octave_change'] <= 3):
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C') or not 1 <= state['clef']['line'] <= 5 or not -3 <= state['clef']['octave_change'] <= 3):
raise ValueError('invalid pitched clef')
absolute += length
for identifier, (p, start, duration) in tones.items():
@@ -217,26 +201,26 @@ def validate_document(document: dict) -> None:
if source not in tones or tones[source][0].get('tie_to') != identifier:
raise ValueError(f'dangling incoming tie: {identifier}')
span_ids = set()
event_order = {identifier:index for index, identifier in enumerate(events)}
event_order = {identifier: index for index, identifier in enumerate(events)}
for span in staves[0].get('tuplets', []):
if not span['id'] or span['id'] in span_ids or span['start'] not in ids or span['end'] not in ids or not 0 < span['note_count'] <= 1000000 or not 0 < span['space_count'] <= 1000000 or not 0 <= span['depth'] <= 16:
raise ValueError('invalid tuplet span')
members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])]
if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]:
raise ValueError('tuplet endpoints disagree with member events')
if span.get('bracket', 'auto') not in ('yes','no','auto') or span.get('show_number','actual') not in ('none','actual','both'):
if span.get('bracket', 'auto') not in ('yes', 'no', 'auto') or span.get('show_number', 'actual') not in ('none', 'actual', 'both'):
raise ValueError('invalid tuplet appearance')
span_ids.add(span['id'])
for note in events.values():
if any(t not in span_ids for t in note.get('tuplet_ids', [])):
raise ValueError('event refers to an unknown tuplet')
if tremolo := note.get('tremolo'):
if tremolo['type'] not in ('single','start','stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8:
if tremolo['type'] not in ('single', 'start', 'stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8:
raise ValueError('invalid measured tremolo')
if tremolo['type'] in ('start','stop'):
if tremolo['type'] in ('start', 'stop'):
other = events.get(tremolo.get('partner'))
expected_type = 'stop' if tremolo['type'] == 'start' else 'start'
if not other or other.get('tremolo',{}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']:
if not other or other.get('tremolo', {}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']:
raise ValueError('invalid paired tremolo')
for span in staves[0].get('octave_shifts', []):
if not 0 <= fraction(span['start']) < fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3:
+106 -121
View File
@@ -4,35 +4,28 @@ import copy
from fractions import Fraction
from pathlib import Path
from defusedxml import ElementTree as XML
from defusedxml import ElementTree
from generator import harmony
from generator.model import (
SCHEMA_VERSION, TYPE_LENGTHS, fraction, key_from_fifths, key_label,
meter_label, meter_length, pitch_number, rational, validate_document,
written_for, written_length,
)
from generator.model import (SCHEMA_VERSION, TYPE_LENGTHS, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
from generator.styles import canonical_style
class MusicXMLError(ValueError):
pass
# Ignoring a container never authorizes ignoring its musical descendants.
LAYOUT = {'default-x', 'default-y', 'relative-x', 'relative-y', 'color', 'font-family', 'font-size', 'font-style', 'font-weight', 'halign', 'valign', 'justify', 'placement', 'id'}
IGNORED_TOP = {'defaults', 'credit', 'work', 'movement-number', 'movement-title', 'identification', 'part-list'}
class Importer:
def __init__(self, path: Path) -> None:
self.path = path
self.location = str(path)
self.divisions = Fraction(1)
self.key = key_from_fifths(0)
self.meter = {'groups':[{'beats':[4], 'unit':4}], 'symbol':'normal'}
self.clef = {'sign':'G', 'line':2, 'octave_change':0}
self.transpose = {'diatonic':0, 'chromatic':0, 'octave_change':0}
self.meter = {'groups': [{'beats': [4], 'unit': 4}], 'symbol': 'normal'}
self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0}
self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0}
self.sequence = 0
self.tuplet_sequence = 0
self.active_tuplets: dict[str, dict] = {}
@@ -44,20 +37,16 @@ class Importer:
self.lyric_number = None
self.absolute = Fraction()
self.tremolo_start = None
def fail(self, text: str):
raise MusicXMLError(f'{self.location}: {text}')
def children(self, node, allowed: set[str]) -> None:
for item in node:
if item.tag not in allowed:
self.fail(f'unsupported musical element <{item.tag}> in <{node.tag}>')
def attrs(self, node, allowed=()) -> None:
for name in node.attrib:
if name not in LAYOUT and name not in allowed:
self.fail(f'unsupported {node.tag} attribute {name!r}')
def number(self, value: str | None, *, integer: bool = False, positive: bool = False) -> Fraction | int:
try:
result = Fraction(value or '')
@@ -67,12 +56,10 @@ class Importer:
return int(result) if integer else result
except (ValueError, ZeroDivisionError, OverflowError):
self.fail(f'invalid {"integer" if integer else "number"}: {value!r}')
def offset(self, node, cursor: Fraction) -> Fraction:
return cursor + self.number(node.findtext('offset', '0')) / self.divisions
def identity(self, node) -> None:
voice = node.findtext('voice', '1' if node.tag in ('note','forward') else self.voice_id)
voice = node.findtext('voice', '1' if node.tag in ('note', 'forward') else self.voice_id)
staff = node.findtext('staff', '1')
if staff != '1':
self.fail('multiple staves are not supported')
@@ -82,10 +69,9 @@ class Importer:
self.voice_id = voice
if voice != self.voice_id:
self.fail('multiple rhythmic voices are not supported')
def attributes(self, node, onset: Fraction) -> dict:
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments'})
result = {'onset':rational(onset)}
result = {'onset': rational(onset)}
for item in node:
if item.get('number', '1') != '1':
self.fail('attributes for multiple staves are not supported')
@@ -100,7 +86,7 @@ class Importer:
if item.find('fifths') is not None:
self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
else:
self.key = {'fifths':0, 'mode':'none', 'tonic':None, 'alterations':[0]*7, 'custom':True}
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [0] * 7, 'custom': True}
pending = None
for field in item:
if field.tag == 'key-step':
@@ -122,7 +108,7 @@ class Importer:
elif item.tag == 'time':
self.attrs(item, {'number', 'symbol', 'separator', 'print-object'})
self.children(item, {'beats', 'beat-type', 'senza-misura'})
self.meter = {'groups':[], 'symbol':item.get('symbol', 'normal')}
self.meter = {'groups': [], 'symbol': item.get('symbol', 'normal')}
if self.meter['symbol'] not in ('normal', 'common', 'cut', 'single-number', 'note', 'dotted-note'):
self.fail('unsupported meter symbol')
if item.find('senza-misura') is not None:
@@ -137,7 +123,7 @@ class Importer:
elif field.tag == 'beat-type':
if not pending:
self.fail('beat-type without beats')
self.meter['groups'].append({'beats':pending, 'unit':self.number(field.text, integer=True, positive=True)})
self.meter['groups'].append({'beats': pending, 'unit': self.number(field.text, integer=True, positive=True)})
pending = None
if pending is not None or not self.meter['groups']:
self.fail('incomplete meter')
@@ -146,25 +132,24 @@ class Importer:
self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'})
self.children(item, {'sign', 'line', 'clef-octave-change'})
sign = item.findtext('sign')
if sign not in ('G','F','C'):
if sign not in ('G', 'F', 'C'):
self.fail(f'unsupported clef {sign!r}; only pitched G/F/C staves are supported')
line = self.number(item.findtext('line', {'G':'2','F':'4','C':'3'}[sign]), integer=True)
line = self.number(item.findtext('line', {'G': '2', 'F': '4', 'C': '3'}[sign]), integer=True)
if not 1 <= line <= 5:
self.fail('clef line must be between 1 and 5')
self.clef = {'sign':sign, 'line':line, 'octave_change':self.number(item.findtext('clef-octave-change','0'), integer=True)}
self.clef = {'sign': sign, 'line': line, 'octave_change': self.number(item.findtext('clef-octave-change', '0'), integer=True)}
result['clef'] = copy.deepcopy(self.clef)
elif item.tag == 'transpose':
self.children(item, {'diatonic', 'chromatic', 'octave-change'})
self.transpose = {key:self.number(item.findtext(tag, '0'), integer=True) for key,tag in (('diatonic','diatonic'),('chromatic','chromatic'),('octave_change','octave-change'))}
self.transpose = {key: self.number(item.findtext(tag, '0'), integer=True) for key, tag in (('diatonic', 'diatonic'), ('chromatic', 'chromatic'), ('octave_change', 'octave-change'))}
if item.find('chromatic') is None:
self.fail('instrument transposition requires chromatic semitones')
if item.find('diatonic') is None:
semitones = self.transpose['chromatic']
magnitude = abs(semitones)
self.transpose['diatonic'] = (1 if semitones >= 0 else -1) * (7*(magnitude//12)+(0,0,1,2,2,3,3,4,5,5,6,6)[magnitude%12])
self.transpose['diatonic'] = (1 if semitones >= 0 else -1) * (7 * (magnitude // 12) + (0, 0, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6)[magnitude % 12])
result['transpose'] = copy.deepcopy(self.transpose)
return result
def read_harmony(self, node, cursor: Fraction) -> dict:
self.attrs(node, {'print-object', 'print-frame', 'type'})
self.children(node, {'root', 'kind', 'degree', 'bass', 'inversion', 'offset', 'staff'})
@@ -172,34 +157,34 @@ class Importer:
self.fail('stacked harmonies require separate harmony events')
self.identity(node)
kind = node.find('kind')
result = {'id':f'harmony-{self.sequence}-{cursor}', 'onset':rational(self.offset(node, cursor)), 'kind':kind.text, 'degrees':[]}
result = {'id': f'harmony-{self.sequence}-{cursor}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
if 'text' in kind.attrib:
result['kind_text'] = kind.get('text')
for tag in ('root','bass'):
for tag in ('root', 'bass'):
item = node.find(tag)
if item is None:
if tag == 'root' and kind.text != 'none':
self.fail('pitched harmony requires a root')
continue
self.children(item, {tag+'-step', tag+'-alter'})
step = item.findtext(tag+'-step','').lower()
if len(step)!=1 or step not in 'cdefgab':
self.children(item, {tag + '-step', tag + '-alter'})
step = item.findtext(tag + '-step', '').lower()
if len(step) != 1 or step not in 'cdefgab':
self.fail('invalid harmony root/bass')
result[tag] = {'letter':step, 'alter':self.number(item.findtext(tag+'-alter','0'), integer=True)}
result[tag] = {'letter': step, 'alter': self.number(item.findtext(tag + '-alter', '0'), integer=True)}
for degree in node.findall('degree'):
self.children(degree, {'degree-value','degree-alter','degree-type'})
self.children(degree, {'degree-value', 'degree-alter', 'degree-type'})
value = self.number(degree.findtext('degree-value'), integer=True, positive=True)
alteration = self.number(degree.findtext('degree-alter','0'), integer=True)
alteration = self.number(degree.findtext('degree-alter', '0'), integer=True)
operation = degree.findtext('degree-type')
# MusicXML alterations are relative to the kind; additions use dominant intervals.
if operation == 'alter':
alteration += harmony.KINDS.get(result['kind'], {}).get(value, 0)
elif operation == 'add' and (value-1)%7 == 6:
elif operation == 'add' and (value - 1) % 7 == 6:
alteration -= 1
elif operation == 'subtract' and alteration:
self.fail('cannot subtract a degree different from the one in the chord kind')
result['degrees'].append({'value':value, 'alter':alteration, 'type':operation})
result['degrees'].append({'value': value, 'alter': alteration, 'type': operation})
chord_degrees = harmony.degrees(result)
if 'kind_text' in result and result['kind'] != 'none':
from generator.model import pitch_name
@@ -224,16 +209,15 @@ class Importer:
bass_letter = 'cdefgab'[letter_index % 7]
from generator.model import NATURAL
target = NATURAL[root['letter']] + root['alter'] + harmony.degree_semitones(degree, chord_degrees[degree])
result['bass'] = {'letter':bass_letter, 'alter':target - (12*(letter_index//7)+NATURAL[bass_letter])}
result['bass'] = {'letter': bass_letter, 'alter': target - (12 * (letter_index // 7) + NATURAL[bass_letter])}
result['symbol'] = harmony.symbol(result)
return result
def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None:
self.attrs(node, {'print-object', 'directive'})
self.children(node, {'direction-type','offset','staff','voice','sound'})
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
onset = self.offset(node, cursor)
for group in node.findall('direction-type'):
self.children(group, {'words','octave-shift','rehearsal','metronome'})
self.children(group, {'words', 'octave-shift', 'rehearsal', 'metronome'})
for item in group:
if item.tag == 'words':
value = ''.join(item.itertext()).strip()
@@ -246,15 +230,15 @@ class Importer:
self.fail('section labels must be at measure starts')
measure['section_label'] = ''.join(item.itertext())
elif item.tag == 'octave-shift':
number, action = item.get('number','1'), item.get('type')
size = self.number(item.get('size','8'), integer=True)
if size not in (8,15,22):
number, action = item.get('number', '1'), item.get('type')
size = self.number(item.get('size', '8'), integer=True)
if size not in (8, 15, 22):
self.fail('octave shifts support 8, 15, or 22')
position = rational(self.absolute + onset)
if action in ('up','down'):
if action in ('up', 'down'):
if number in self.open_octaves:
self.fail('duplicate octave-shift start')
span = {'id':f'octave-{len(self.octaves)+1}', 'start':position, 'octaves':(size-1)//7 * (1 if action=='up' else -1)}
span = {'id': f'octave-{len(self.octaves) + 1}', 'start': position, 'octaves': (size - 1) // 7 * (1 if action == 'up' else -1)}
self.open_octaves[number] = span
self.octaves.append(span)
elif action == 'stop':
@@ -264,24 +248,25 @@ class Importer:
elif action != 'continue' or number not in self.open_octaves:
self.fail('invalid octave-shift continuation')
elif item.tag == 'metronome':
self.children(item, {'beat-unit','beat-unit-dot','per-minute'})
written = {'type':item.findtext('beat-unit'), 'dots':len(item.findall('beat-unit-dot'))}
self.children(item, {'beat-unit', 'beat-unit-dot', 'per-minute'})
written = {'type': item.findtext('beat-unit'), 'dots': len(item.findall('beat-unit-dot'))}
written_length(written)
bpm = self.number(item.findtext('per-minute'), positive=True)
measure.setdefault('tempos', []).append({'onset':rational(onset), 'written':written, 'bpm':rational(bpm)})
measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': written, 'bpm': rational(bpm)})
sound = node.find('sound')
if sound is not None:
self.attrs(sound, {'tempo'})
self.children(sound, set())
if sound.get('tempo') and not any(fraction(t['onset'])==onset for t in measure.get('tempos', [])):
measure.setdefault('tempos', []).append({'onset':rational(onset), 'written':{'type':'quarter','dots':0}, 'bpm':rational(self.number(sound.get('tempo'), positive=True))})
self.sound(sound, onset, measure)
def sound(self, node, onset: Fraction, measure: dict) -> None:
self.attrs(node, {'tempo'})
self.children(node, set())
if node.get('tempo') and not any(fraction(t['onset']) == onset for t in measure.get('tempos', [])):
measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': rational(self.number(node.get('tempo'), positive=True))})
def read_note(self, node, cursor: Fraction, previous: dict | None) -> tuple[dict, Fraction, bool]:
self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'})
if node.get('dynamics') or node.get('end-dynamics'):
self.fail('note dynamics are not supported')
self.children(node, {'pitch','rest','chord','duration','voice','type','dot','accidental','time-modification','stem','staff','beam','notations','lyric','tie','grace','cue','notehead'})
for tag in ('pitch','rest','chord','duration','voice','type','accidental','time-modification','stem','staff','grace','cue','notehead'):
self.children(node, {'pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'dot', 'accidental', 'time-modification', 'stem', 'staff', 'beam', 'notations', 'lyric', 'tie', 'grace', 'cue', 'notehead'})
for tag in ('pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'accidental', 'time-modification', 'stem', 'staff', 'grace', 'cue', 'notehead'):
if len(node.findall(tag)) > 1:
self.fail(f'duplicate <{tag}> in <note>')
if node.find('rest') is not None and node.find('pitch') is not None:
@@ -291,22 +276,22 @@ class Importer:
grace = node.find('grace')
duration = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions
onset = fraction(previous['onset']) if is_chord and previous else cursor
if is_chord and (previous is None or previous['kind']!='note' or duration > fraction(previous['duration'])):
if is_chord and (previous is None or previous['kind'] != 'note' or duration > fraction(previous['duration'])):
self.fail('invalid chord tone or chord tone longer than its anchor')
if is_chord and (grace is not None) != ('grace' in previous):
self.fail('chord tones must share grace/regular timing')
written = {'type':node.findtext('type'), 'dots':len(node.findall('dot'))}
written = {'type': node.findtext('type'), 'dots': len(node.findall('dot'))}
if written['type'] is None:
written = {'type':'whole','dots':0} if node.find('rest') is not None and node.find('rest').get('measure')=='yes' else written_for(duration)
written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else written_for(duration)
written_length(written)
modulation = node.find('time-modification')
if modulation is not None:
self.children(modulation, {'actual-notes','normal-notes','normal-type','normal-dot'})
self.children(modulation, {'actual-notes', 'normal-notes', 'normal-type', 'normal-dot'})
actual = self.number(modulation.findtext('actual-notes'), integer=True, positive=True)
normal = self.number(modulation.findtext('normal-notes'), integer=True, positive=True)
written['ratio'] = rational(Fraction(normal, actual))
if modulation.find('normal-type') is not None:
written['normal'] = {'type':modulation.findtext('normal-type'), 'dots':len(modulation.findall('normal-dot'))}
written['normal'] = {'type': modulation.findtext('normal-type'), 'dots': len(modulation.findall('normal-dot'))}
written_length(written['normal'])
elif modulation.findall('normal-dot'):
self.fail('<normal-dot> requires <normal-type>')
@@ -316,47 +301,47 @@ class Importer:
if duration != expected:
self.fail(f'<note> duration {duration} disagrees with written duration/time-modification {expected}')
self.sequence += 1
note = {'id':f'note-{self.sequence}', 'kind':'rest' if node.find('rest') is not None else 'note', 'onset':rational(onset), 'duration':rational(duration), 'written':written, 'hidden':node.get('print-object')=='no', 'pitches':[]}
note = {'id': f'note-{self.sequence}', 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': rational(onset), 'duration': rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
if is_chord:
note = previous
if grace is not None:
self.attrs(grace, {'slash','steal-time-previous','steal-time-following','make-time'})
note['grace'] = {'slash':grace.get('slash')=='yes'}
for name in ('steal-time-previous','steal-time-following','make-time'):
self.attrs(grace, {'slash', 'steal-time-previous', 'steal-time-following', 'make-time'})
note['grace'] = {'slash': grace.get('slash') == 'yes'}
for name in ('steal-time-previous', 'steal-time-following', 'make-time'):
if name in grace.attrib:
note['grace'][name] = rational(self.number(grace.get(name), positive=True))
if node.find('cue') is not None:
note['cue'] = True
if node.find('rest') is not None:
self.children(node.find('rest'), {'display-step','display-octave'})
self.children(node.find('rest'), {'display-step', 'display-octave'})
if node.find('rest').get('measure') == 'yes':
note['measure_rest'] = True
if node.find('rest/display-step') is not None:
note['rest_position'] = {'letter':node.findtext('rest/display-step').lower(), 'octave':self.number(node.findtext('rest/display-octave'), integer=True)}
note['rest_position'] = {'letter': node.findtext('rest/display-step').lower(), 'octave': self.number(node.findtext('rest/display-octave'), integer=True)}
else:
pitch_node = node.find('pitch')
if pitch_node is None:
self.fail('note has neither pitch nor rest')
self.children(pitch_node, {'step','alter','octave'})
p = {'id':f'{note["id"]}-p{len(note["pitches"])+1}', 'letter':pitch_node.findtext('step','').lower(), 'alter':self.number(pitch_node.findtext('alter','0'), integer=True), 'octave':self.number(pitch_node.findtext('octave'), integer=True), 'duration':rational(duration), 'written':written}
self.children(pitch_node, {'step', 'alter', 'octave'})
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'letter': pitch_node.findtext('step', '').lower(), 'alter': self.number(pitch_node.findtext('alter', '0'), integer=True), 'octave': self.number(pitch_node.findtext('octave'), integer=True), 'duration': rational(duration), 'written': written}
value = pitch_number(p)
p['_absolute'] = self.absolute + onset
accidental = node.find('accidental')
if accidental is not None:
self.attrs(accidental, {'cautionary','editorial','parentheses','bracket','size','smufl'})
allowed = {'natural':0,'sharp':1,'flat':-1,'double-sharp':2,'sharp-sharp':2,'flat-flat':-2,'triple-sharp':3,'triple-flat':-3,'natural-sharp':1,'natural-flat':-1}
self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'})
allowed = {'natural': 0, 'sharp': 1, 'flat': -1, 'double-sharp': 2, 'sharp-sharp': 2, 'flat-flat': -2, 'triple-sharp': 3, 'triple-flat': -3, 'natural-sharp': 1, 'natural-flat': -1}
if accidental.text not in allowed or allowed[accidental.text] != p['alter']:
self.fail('unsupported or contradictory accidental')
p['accidental_display'] = {'name':accidental.text, 'parentheses':accidental.get('parentheses')=='yes' or accidental.get('cautionary')=='yes', 'bracket':accidental.get('bracket')=='yes'}
p['accidental_display'] = {'name': accidental.text, 'parentheses': accidental.get('parentheses') == 'yes' or accidental.get('cautionary') == 'yes', 'bracket': accidental.get('bracket') == 'yes'}
ties = {t.get('type') for t in node.findall('tie')}
notations = node.find('notations')
if notations is not None:
visual_ties = {t.get('type') for t in notations.findall('tied')}
if 'continue' in visual_ties:
visual_ties.remove('continue')
visual_ties.update({'start','stop'})
visual_ties.update({'start', 'stop'})
ties |= visual_ties
if ties - {'start','stop'}:
if ties - {'start', 'stop'}:
self.fail('unsupported tie notation')
if 'stop' in ties:
source = self.open_ties.pop(value, None)
@@ -371,24 +356,24 @@ class Importer:
if not is_chord:
if node.find('stem') is not None:
stem = node.findtext('stem')
if stem not in ('up','down','none','double'):
if stem not in ('up', 'down', 'none', 'double'):
self.fail('unsupported stem')
note['stem'] = stem
head = node.findtext('notehead')
if head is not None:
if head not in ('normal','slash'):
if head not in ('normal', 'slash'):
self.fail(f'unsupported notehead: {head}')
note['notehead'] = head
beams = []
for beam in node.findall('beam'):
level = self.number(beam.get('number','1'), integer=True)
if not 1 <= level <= 8 or beam.text not in ('begin','continue','end','forward hook','backward hook'):
level = self.number(beam.get('number', '1'), integer=True)
if not 1 <= level <= 8 or beam.text not in ('begin', 'continue', 'end', 'forward hook', 'backward hook'):
self.fail('invalid beam')
beams.append({'level':level,'type':beam.text})
beams.append({'level': level, 'type': beam.text})
if beams:
note['beams'] = beams
for lyric in node.findall('lyric'):
self.children(lyric, {'syllabic','text','elision','extend','end-line','end-paragraph'})
self.children(lyric, {'syllabic', 'text', 'elision', 'extend', 'end-line', 'end-paragraph'})
number = lyric.get('number', '1')
if self.lyric_number is None:
self.lyric_number = number
@@ -396,22 +381,22 @@ class Importer:
self.fail('only one lyric verse is supported')
pieces = []
for item in lyric:
if item.tag in ('text','elision'):
pieces.append(item.text or (' ' if item.tag=='elision' else ''))
syllabic = lyric.findtext('syllabic','single')
if syllabic not in ('single','begin','middle','end'):
if item.tag in ('text', 'elision'):
pieces.append(item.text or (' ' if item.tag == 'elision' else ''))
syllabic = lyric.findtext('syllabic', 'single')
if syllabic not in ('single', 'begin', 'middle', 'end'):
self.fail('invalid lyric syllabification')
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin','middle') else '')
note['lyric_notation'] = {'text':''.join(pieces), 'syllabic':syllabic}
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '')
note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic}
if lyric.find('extend') is not None:
note['lyric_notation']['extend'] = lyric.find('extend').get('type','start')
note['lyric_notation']['extend'] = lyric.find('extend').get('type', 'start')
stops = []
for notations in node.findall('notations'):
self.children(notations, {'tied','tuplet','ornaments'})
self.children(notations, {'tied', 'tuplet', 'ornaments'})
for item in notations:
if item.tag == 'tuplet' and not is_chord:
number, action = item.get('number','1'), item.get('type')
self.children(item, {'tuplet-actual','tuplet-normal'})
number, action = item.get('number', '1'), item.get('type')
self.children(item, {'tuplet-actual', 'tuplet-normal'})
if action == 'start':
if number in self.active_tuplets:
self.fail('duplicate tuplet start')
@@ -420,7 +405,7 @@ class Importer:
normal = item.findtext('tuplet-normal/tuplet-number', modulation.findtext('normal-notes') if modulation is not None else '')
if self.active_tuplets and (item.find('tuplet-actual') is None or item.find('tuplet-normal') is None):
self.fail('nested tuplets require explicit tuplet-actual and tuplet-normal numbers')
span = {'id':f'tuplet-{self.tuplet_sequence}', 'start':note['id'], 'note_count':self.number(actual, integer=True, positive=True), 'space_count':self.number(normal, integer=True, positive=True), 'depth':len(self.active_tuplets), 'bracket':item.get('bracket','auto'), 'show_number':item.get('show-number','actual')}
span = {'id': f'tuplet-{self.tuplet_sequence}', 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(self.active_tuplets), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual')}
self.active_tuplets[number] = span
self.tuplets.append(span)
elif action == 'stop':
@@ -431,10 +416,10 @@ class Importer:
self.children(item, {'tremolo'})
for tremolo in item:
marks = self.number(tremolo.text, integer=True)
if not 0 <= marks <= 8 or tremolo.get('type','single') == 'unmeasured':
if not 0 <= marks <= 8 or tremolo.get('type', 'single') == 'unmeasured':
self.fail('only measured tremolos are supported')
action = tremolo.get('type','single')
note['tremolo'] = {'marks':marks, 'type':action}
action = tremolo.get('type', 'single')
note['tremolo'] = {'marks': marks, 'type': action}
if action == 'start':
if self.tremolo_start is not None:
self.fail('nested tremolo')
@@ -454,10 +439,9 @@ class Importer:
self.fail('tuplet stop without start')
self.active_tuplets.pop(number)['end'] = note['id']
return note, cursor if is_chord else cursor + duration, is_chord
def read(self) -> dict:
root = XML.parse(self.path, forbid_entities=True, forbid_external=True).getroot()
if root.tag != 'score-partwise' or root.get('version') not in ('3.1','4.0'):
root = ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot()
if root.tag != 'score-partwise' or root.get('version') not in ('3.1', '4.0'):
self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0')
self.children(root, IGNORED_TOP | {'part'})
parts = root.findall('part')
@@ -466,32 +450,32 @@ class Importer:
self.fail('exactly one matching score part is required')
fields = {}
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
name = item.get('name','')
name = item.get('name', '')
if name.startswith('vamp:'):
if name not in ('vamp:style','vamp:tonic') or name in fields:
if name not in ('vamp:style', 'vamp:tonic') or name in fields:
self.fail(f'unknown or duplicate Vamp property {name!r}')
fields[name] = item.text or ''
metadata = {'title':root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer':' / '.join(c.text for c in root.findall('identification/creator') if c.get('type')=='composer' and c.text), 'style':canonical_style(fields.get('vamp:style'))}
metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text), 'style': canonical_style(fields.get('vamp:style'))}
if 'vamp:tonic' in fields:
from generator.model import parse_pitch_name
metadata['transpose_origin'] = parse_pitch_name(fields['vamp:tonic'])
measures = []
self.children(parts[0], {'measure'})
for index, bar in enumerate(parts[0]):
self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number",str(index+1))}'
self.children(bar, {'attributes','note','direction','harmony','forward','backup','barline','print'})
self.attrs(bar, {'number','implicit','non-controlling','width','text'})
self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number", str(index + 1))}'
self.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'})
self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'})
cursor = Fraction()
events = []
previous = None
optional = []
measure = {'id':f'measure-{index+1}', 'number':bar.get('number',str(index+1)), 'implicit':bar.get('implicit')=='yes', 'harmonies':[], 'attributes':[], 'key':copy.deepcopy(self.key), 'meter':copy.deepcopy(self.meter), 'clef':copy.deepcopy(self.clef), 'transpose':copy.deepcopy(self.transpose)}
measure = {'id': f'measure-{index + 1}', 'number': bar.get('number', str(index + 1)), 'implicit': bar.get('implicit') == 'yes', 'harmonies': [], 'attributes': [], 'key': copy.deepcopy(self.key), 'meter': copy.deepcopy(self.meter), 'clef': copy.deepcopy(self.clef), 'transpose': copy.deepcopy(self.transpose)}
for item in bar:
if item.tag == 'attributes':
state = self.attributes(item, cursor)
if cursor == 0:
for k in ('key','meter','clef','transpose'):
measure[k] = copy.deepcopy(getattr(self,k))
for k in ('key', 'meter', 'clef', 'transpose'):
measure[k] = copy.deepcopy(getattr(self, k))
elif len(state) > 1:
measure['attributes'].append(state)
elif item.tag == 'note':
@@ -502,12 +486,14 @@ class Importer:
measure['harmonies'].append(self.read_harmony(item, cursor))
elif item.tag == 'direction':
self.direction(item, cursor, measure, optional)
elif item.tag == 'sound':
self.sound(item, cursor, measure)
elif item.tag == 'backup':
self.fail('backward timeline movement requires unsupported independent voices')
elif item.tag == 'forward':
self.children(item, {'duration','voice','staff'})
self.children(item, {'duration', 'voice', 'staff'})
self.identity(item)
duration = self.number(item.findtext('duration'), positive=True)/self.divisions
duration = self.number(item.findtext('duration'), positive=True) / self.divisions
self.sequence += 1
from generator.model import make_event
events.append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer'))
@@ -515,10 +501,10 @@ class Importer:
previous = None
elif item.tag == 'barline':
self.children(item, {'bar-style'})
style = item.findtext('bar-style','regular')
if style not in ('regular','light-light','light-heavy','heavy-light','heavy-heavy','heavy','dashed','dotted','none','tick','short'):
style = item.findtext('bar-style', 'regular')
if style not in ('regular', 'light-light', 'light-heavy', 'heavy-light', 'heavy-heavy', 'heavy', 'dashed', 'dotted', 'none', 'tick', 'short'):
self.fail('unsupported barline')
measure.setdefault('barlines', {})[item.get('location','right')] = style
measure.setdefault('barlines', {})[item.get('location', 'right')] = style
if not events or cursor <= 0:
self.fail('empty or zero-duration measure')
expected = meter_length(measure['meter'])
@@ -527,12 +513,12 @@ class Importer:
self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit')
measure['duration'] = rational(cursor)
measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter'])
measure['voices'] = [{'id':'voice-source', 'notes':events}]
measure['harmonies'].sort(key=lambda h:fraction(h['onset']))
measure['voices'] = [{'id': 'voice-source', 'notes': events}]
measure['harmonies'].sort(key=lambda h: fraction(h['onset']))
used = set()
for onset in optional:
matches = [h for h in measure['harmonies'] if fraction(h['onset'])==onset]
if len(matches)!=1 or onset in used:
matches = [h for h in measure['harmonies'] if fraction(h['onset']) == onset]
if len(matches) != 1 or onset in used:
self.fail('optional-chord marker must identify exactly one harmony and occur only once')
matches[0]['chord_optional'] = True
used.add(onset)
@@ -546,12 +532,11 @@ class Importer:
for n in measure['voices'][0]['notes']:
for p in n['pitches']:
p.pop('_absolute', None)
metadata.update({'key_signature':measures[0]['key_signature'], 'time_signature':measures[0]['time_signature'], 'key':copy.deepcopy(measures[0]['key'])})
result = {'schema_version':SCHEMA_VERSION, 'metadata':metadata, 'staves':[{'id':'staff-source', 'name':definitions[0].findtext('part-name','Source'), 'role':'vocal', 'clef':copy.deepcopy(measures[0]['clef']), 'tuplets':self.tuplets, 'octave_shifts':self.octaves, 'measures':measures}]}
metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])})
result = {'schema_version': SCHEMA_VERSION, 'metadata': metadata, 'staves': [{'id': 'staff-source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]}
validate_document(result)
return result
def load_musicxml(path: Path) -> dict:
importer = Importer(Path(path))
try:
-1
View File
@@ -1,2 +1 @@
"""Practice score generation."""
+2 -7
View File
@@ -6,7 +6,6 @@ from generator.score import copy_source_voice, make_arranged_document, make_meas
from generator.musicxml import load_musicxml
from generator.transposition import TARGET_KEYS, transpose_document
def practice_document(document: dict) -> dict:
staff = source_staff(document)
source_measures = staff.get("measures")
@@ -36,11 +35,9 @@ def practice_document(document: dict) -> dict:
octave_shifts=staff.get('octave_shifts', []),
)
def key_file_name(key: str) -> str:
return slugify(key.replace("#", "sharp")) + ".json"
def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
document = practice_document(load_musicxml(source_path))
metadata = document.get("metadata", {})
@@ -51,7 +48,6 @@ def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Pa
write_json(output_dir / "chord" / lesson_group / exercise_id / key_file_name(key), document)
return 1
def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
base_document = practice_document(load_musicxml(source_path))
relative = source_path.relative_to(source_root)
@@ -63,17 +59,16 @@ def generate_melody_practice(source_path: Path, source_root: Path, output_dir: P
count += 1
return count
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
if not source_dir.exists():
return 0
count = 0
chord_root = source_dir / "chord"
if chord_root.exists():
for path in sorted(chord_root.rglob("*.musicxml")):
for path in sorted(chord_root.rglob("*.xml")):
count += generate_chord_practice(path, chord_root, output_dir)
melody_root = source_dir / "melody"
if melody_root.exists():
for path in sorted(melody_root.rglob("*.musicxml")):
for path in sorted(melody_root.rglob("*.xml")):
count += generate_melody_practice(path, melody_root, output_dir)
return count
+12 -27
View File
@@ -7,51 +7,41 @@ from pathlib import Path
from generator.model import SCHEMA_VERSION, validate_document
def load_json(path: Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8'))
validate_document(document)
return document
def write_json(path: Path, document: dict) -> None:
validate_document(document)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def slugify(value: str) -> str:
return re.sub(r'[^a-z0-9]+', '-', value.strip().lower()).strip('-') or 'score'
def human_label(value: str) -> str:
return ' '.join(v.capitalize() for v in value.replace('_',' ').replace('-',' ').split()) or 'General'
return ' '.join(v.capitalize() for v in value.replace('_', ' ').replace('-', ' ').split()) or 'General'
def source_staff(document: dict) -> dict:
if len(document.get('staves', [])) != 1:
raise ValueError('exactly one source staff is required')
return document['staves'][0]
def first_voice(measure: dict) -> dict:
if len(measure.get('voices', [])) != 1:
raise ValueError('exactly one source voice is required')
return measure['voices'][0]
def measure_notes(measure: dict) -> list[dict]:
return first_voice(measure)['notes']
def leading_pickup_measure_index(measures: list[dict]) -> int | None:
return 0 if len(measures)>1 and measures[0].get('implicit') else None
return 0 if len(measures) > 1 and measures[0].get('implicit') else None
def body_start_measure_index(measures: list[dict]) -> int:
return 1 if leading_pickup_measure_index(measures) is not None else 0
def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict:
voice = copy.deepcopy(first_voice(measure))
if strip_lyrics:
@@ -60,13 +50,11 @@ def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict:
note.pop('lyric_notation', None)
return voice
def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict:
result = copy.deepcopy(source)
result.update({'id':measure_id, 'section_label':section_label, 'voices':[{'id':'voice-arranged', 'notes':notes}]})
result.update({'id': measure_id, 'section_label': section_label, 'voices': [{'id': 'voice-arranged', 'notes': notes}]})
return result
def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
"""Clone-local links survive; links cut by an arrangement boundary become attacks."""
tone_map, event_map = {}, {}
@@ -74,47 +62,44 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
for measure in measures:
for ni, note in enumerate(measure_notes(measure)):
old = note['id']
note['id'] = f'{measure["id"]}-n{ni+1}'
note['id'] = f'{measure["id"]}-n{ni + 1}'
event_map[old] = note['id']
for pi, pitch in enumerate(note['pitches']):
old_pitch = pitch['id']
pitch['id'] = f'{note["id"]}-p{pi+1}'
pitch['id'] = f'{note["id"]}-p{pi + 1}'
tone_map[old_pitch] = pitch['id']
for measure in measures:
for note in measure_notes(measure):
for p in note['pitches']:
for field in ('tie_from','tie_to'):
for field in ('tie_from', 'tie_to'):
if p.get(field) in tone_map:
p[field] = tone_map[p[field]]
else:
p.pop(field,None)
p.pop(field, None)
if note.get('tremolo', {}).get('partner'):
partner = note['tremolo']['partner']
if partner in event_map:
note['tremolo']['partner'] = event_map[partner]
else:
note['tremolo'] = {**note['tremolo'], 'type':'single'}
note['tremolo'].pop('partner',None)
note['tremolo'] = {**note['tremolo'], 'type': 'single'}
note['tremolo'].pop('partner', None)
for source in source_tuplets:
members = [n for m in measures for n in measure_notes(m) if source['id'] in n.get('tuplet_ids', [])]
if not members:
continue
t = copy.deepcopy(source)
t.update({'id':f'{measures[0]["id"]}-{source["id"]}', 'start':members[0]['id'], 'end':members[-1]['id'], 'continued_start':source['start'] not in event_map, 'continued_end':source['end'] not in event_map})
t.update({'id': f'{measures[0]["id"]}-{source["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': source['start'] not in event_map, 'continued_end': source['end'] not in event_map})
for n in members:
n['tuplet_ids'] = [t['id'] if v==source['id'] else v for v in n['tuplet_ids']]
n['tuplet_ids'] = [t['id'] if v == source['id'] else v for v in n['tuplet_ids']]
tuplets.append(t)
return tuplets
def make_arranged_document(source_document: dict, staff: dict, *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict], tuplets=None, octave_shifts=None) -> dict:
return {'schema_version':SCHEMA_VERSION, 'metadata':copy.deepcopy(source_document['metadata']), 'staves':[{'id':staff_id, 'name':staff_name, 'clef':copy.deepcopy(staff['clef']), 'role':role, 'arranged':True, 'arrangement_part_type':arrangement_part_type, 'measures':measures, 'tuplets':tuplets or [], 'octave_shifts':octave_shifts or []}]}
return {'schema_version': SCHEMA_VERSION, 'metadata': copy.deepcopy(source_document['metadata']), 'staves': [{'id': staff_id, 'name': staff_name, 'clef': copy.deepcopy(staff['clef']), 'role': role, 'arranged': True, 'arrangement_part_type': arrangement_part_type, 'measures': measures, 'tuplets': tuplets or [], 'octave_shifts': octave_shifts or []}]}
def count_json_files(root: Path) -> int:
return sum(1 for p in root.rglob('*.json') if p.is_file())
def validate_generated_tree(root: Path) -> None:
for path in root.rglob('*.json'):
load_json(path)
+67 -72
View File
@@ -3,83 +3,78 @@ from __future__ import annotations
DEFAULT_STYLE = "Medium Swing"
BUILT_IN_STYLES = (
# Jazz
"Afro 12/8",
"Ballad Double Time Feel",
"Ballad Even",
"Ballad Melodic",
"Ballad Swing",
"Blue Note",
"Bossa Nova",
"Doo Doo Cats",
"Double Time Swing",
"Even 8ths",
"Even 8ths Open",
"Even 16ths",
"Guitar Trio",
"Gypsy Jazz",
"Latin",
"Latin/Swing",
"Long Notes",
"Medium Swing",
"Medium Up Swing",
"Medium Up Swing 2",
"New Orleans Swing",
"Second Line",
"Slow Swing",
"Swing Two/Four",
"Trad Jazz",
"Up Tempo Swing",
"Up Tempo Swing 2",
# Latin
"Argentina: Tango",
"Brazil: Bossa Acoustic",
"Brazil: Bossa Electric",
"Brazil: Samba",
"Cuba: Bolero",
"Cuba: Cha Cha Cha",
"Cuba: Son Montuno 2-3",
"Cuba: Son Montuno 3-2",
# Pop
"Bluegrass",
"Country",
"Disco",
"Funk",
"Glam Funk",
"House",
"Reggae",
"RnB",
"Rock",
"Rock 12/8",
"Shuffle",
"Slow Rock",
"Smooth",
"Soul",
"Virtual Funk",
# Jazz
"Afro 12/8",
"Ballad Double Time Feel",
"Ballad Even",
"Ballad Melodic",
"Ballad Swing",
"Blue Note",
"Bossa Nova",
"Doo Doo Cats",
"Double Time Swing",
"Even 8ths",
"Even 8ths Open",
"Even 16ths",
"Guitar Trio",
"Gypsy Jazz",
"Latin",
"Latin/Swing",
"Long Notes",
"Medium Swing",
"Medium Up Swing",
"Medium Up Swing 2",
"New Orleans Swing",
"Second Line",
"Slow Swing",
"Swing Two/Four",
"Trad Jazz",
"Up Tempo Swing",
"Up Tempo Swing 2",
# Latin
"Argentina: Tango",
"Brazil: Bossa Acoustic",
"Brazil: Bossa Electric",
"Brazil: Samba",
"Cuba: Bolero",
"Cuba: Cha Cha Cha",
"Cuba: Son Montuno 2-3",
"Cuba: Son Montuno 3-2",
# Pop
"Bluegrass",
"Country",
"Disco",
"Funk",
"Glam Funk",
"House",
"Reggae",
"RnB",
"Rock",
"Rock 12/8",
"Shuffle",
"Slow Rock",
"Smooth",
"Soul",
"Virtual Funk",
)
BUILT_IN_STYLE_SET = frozenset(BUILT_IN_STYLES)
STYLE_ALIASES = {
"Med.": DEFAULT_STYLE,
"Med. Swing": DEFAULT_STYLE,
"Medium": DEFAULT_STYLE,
"Med.": DEFAULT_STYLE,
"Med. Swing": DEFAULT_STYLE,
"Medium": DEFAULT_STYLE,
}
def canonical_style(value: object) -> str:
if value is None:
return DEFAULT_STYLE
if not isinstance(value, str):
raise ValueError("style must be a string")
style = value.strip()
if not style:
return DEFAULT_STYLE
style = STYLE_ALIASES.get(style, style)
if style not in BUILT_IN_STYLE_SET:
raise ValueError(f"style {value!r} is not a supported player style")
return style
if value is None:
return DEFAULT_STYLE
if not isinstance(value, str):
raise ValueError("style must be a string")
style = value.strip()
if not style:
return DEFAULT_STYLE
style = STYLE_ALIASES.get(style, style)
if style not in BUILT_IN_STYLE_SET:
raise ValueError(f"style {value!r} is not a supported player style")
return style
+3 -12
View File
@@ -8,14 +8,12 @@ from generator.score import load_json, measure_notes
from generator.transposition import TARGET_KEYS
from generator.model import fraction, pitch_name
def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]:
staves = document.get("staves")
if not isinstance(staves, list) or not staves or not isinstance(staves[0], dict):
raise ValueError("generated score document must contain an arranged staff")
return staves[0]
def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_number: int) -> dict[str, Any]:
notes = measure_notes(measure)
summary: dict[str, Any] = {
@@ -28,14 +26,13 @@ def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_num
if staff.get("arrangement_part_type") == "vocal":
summary["vocal_skeleton"] = [
f'{fraction(note["onset"])}+{fraction(note["duration"])} '
+ note['written']['type'] + '.'*note['written'].get('dots',0)
+ ' ' + note['kind'] + ' ' + ','.join(pitch_name(p)+str(p['octave']) for p in note['pitches'])
+ ' ' + note.get('lyric','')
+ note['written']['type'] + '.' * note['written'].get('dots', 0)
+ ' ' + note['kind'] + ' ' + ','.join(pitch_name(p) + str(p['octave']) for p in note['pitches'])
+ ' ' + note.get('lyric', '')
for note in notes
]
return summary
def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]:
metadata = document.get("metadata")
if not isinstance(metadata, dict):
@@ -63,14 +60,12 @@ def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]:
"measures": measure_summaries,
}
def performance_tree_summary(root: Path) -> dict[str, Any]:
return {
path.name: performance_document_summary(load_json(path))
for path in sorted(root.glob("*.json"))
}
def validate_expected_performance_summary(expected_path: Path, generated_songs_dir: Path) -> None:
if not expected_path.exists():
raise ValueError(f"missing generator compatibility summary: {expected_path}")
@@ -79,20 +74,17 @@ def validate_expected_performance_summary(expected_path: Path, generated_songs_d
actual = performance_tree_summary(generated_songs_dir)
if actual == expected:
return
expected_names = set(expected.keys()) if isinstance(expected, dict) else set()
actual_names = set(actual.keys())
missing = sorted(expected_names - actual_names)
extra = sorted(actual_names - expected_names)
if missing or extra:
raise ValueError(f"performance generator drift: missing={missing}, extra={extra}")
for name in sorted(actual_names):
if actual[name] != expected[name]:
raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally")
raise ValueError("performance generator drift")
def validate_practice_variants(generated_practice_dir: Path) -> None:
if not generated_practice_dir.exists():
return
@@ -105,7 +97,6 @@ def validate_practice_variants(generated_practice_dir: Path) -> None:
continue
if json_names != expected_names:
raise ValueError(f"melody practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}")
chord_root = generated_practice_dir / "chord"
if chord_root.exists():
for exercise_dir in sorted(path for path in chord_root.rglob("*") if path.is_dir()):
+17 -27
View File
@@ -6,22 +6,18 @@ 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))))
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):
@@ -30,11 +26,9 @@ def find_key(name: str) -> KeyProfile:
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
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
@@ -45,7 +39,6 @@ def interval_between(source: dict, target: dict) -> tuple[int, int]:
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)
@@ -53,32 +46,30 @@ def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
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']])
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')
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)
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}
result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations}
if not key.get('custom') and tonic:
for fifths in range(-14,15):
for fifths in range(-14, 15):
candidate = key_from_fifths(fifths, key['mode'])
if candidate['tonic']==tonic and candidate['alterations']==alterations:
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']
@@ -99,7 +90,7 @@ def transpose_document(document: dict, target_key_name: str) -> dict:
if 'key' in change:
change['key'] = shift_key(change['key'], steps, semitones)
for h in measure.get('harmonies', []):
for field in ('root','bass'):
for field in ('root', 'bass'):
if field in h:
h[field] = shift_pitch(h[field], steps, semitones)
h['symbol'] = harmony.symbol(h)
@@ -108,11 +99,10 @@ def transpose_document(document: dict, target_key_name: str) -> dict:
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}
zero = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0}
for staff in output['staves']:
for measure in staff['measures']:
initial = measure['transpose']
@@ -124,21 +114,21 @@ def concert_document(document: dict) -> dict:
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']
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']]
n['pitches'] = [shift_pitch(p, *interval(fraction(n['onset']))) for p in n['pitches']]
for h in measure['harmonies']:
for field in ('root','bass'):
for field in ('root', 'bass'):
if field in h:
h[field] = shift_pitch(h[field],*interval(fraction(h['onset'])))
h[field] = shift_pitch(h[field], *interval(fraction(h['onset'])))
h['symbol'] = harmony.symbol(h)
for change in measure.get('attributes',[]):
for change in measure.get('attributes', []):
if 'key' in change:
change['key'] = shift_key(change['key'],*interval(fraction(change['onset'])))
change['key'] = shift_key(change['key'], *interval(fraction(change['onset'])))
if 'transpose' in change:
change['transpose'] = copy.deepcopy(zero)
measure['transpose'] = copy.deepcopy(zero)