@@ -1,2 +1 @@
|
||||
"""Build-time score generation for Product Vamp."""
|
||||
|
||||
|
||||
@@ -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,2 +1 @@
|
||||
"""Performance arrangement generation."""
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,2 +1 @@
|
||||
"""Practice score generation."""
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user