Music XML
ci / checks (push) Successful in 42m24s

This commit is contained in:
khodowany
2026-09-10 22:01:18 -05:00
parent ea4e0bbba1
commit f84717990b
47 changed files with 16739 additions and 8391 deletions
+33 -21
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import argparse
import shutil
import tempfile
from pathlib import Path
from generator.arrange.generate import generate_performance_scores
from generator.practice.generate import generate_practice_scores
from generator.score import count_json_files, validate_generated_tree
from generator.summary import validate_expected_performance_summary, validate_practice_variants
from generator.musicxml import load_musicxml
RUNTIME_FONT_FILES = ("Petaluma.otf", "PetalumaScript.otf", "OFL.txt")
@@ -30,33 +32,43 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args()
def main() -> int:
args = 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()
if args.clean and output_dir.exists():
shutil.rmtree(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
for source in sorted(data_dir.rglob('*.musicxml')):
load_musicxml(source)
copy_runtime_fonts(static_assets_dir, output_dir)
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'
if not args.clean and output_dir.exists():
shutil.copytree(output_dir, staged)
staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged)
performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs')
practice_count = generate_practice_scores(data_dir / 'practice', staged / 'practice')
validate_generated_tree(staged)
if args.check:
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
validate_practice_variants(staged / 'practice')
backup = Path(temporary) / 'previous'
if output_dir.exists():
output_dir.rename(backup)
try:
staged.rename(output_dir)
except OSError:
if backup.exists():
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.')
if args.check:
print('Generator check passed.')
performance_count = generate_performance_scores(data_dir / "songs", output_dir / "songs")
practice_count = generate_practice_scores(data_dir / "practice", output_dir / "practice")
validate_generated_tree(output_dir)
print(
"Generated Product Vamp assets: "
f"{performance_count} performance scores, "
f"{practice_count} practice score variants, "
f"{count_json_files(output_dir)} total JSON files."
)
if args.check:
validate_expected_performance_summary(data_dir / "expected" / "performance_summary.json", output_dir / "songs")
validate_practice_variants(output_dir / "practice")
print("Generator check passed.")
def main() -> int:
generate_assets(parse_args())
return 0
+41 -152
View File
@@ -4,6 +4,8 @@ from itertools import product
from typing import NamedTuple
from generator.transposition import mod12
from generator import harmony
from generator.model import NATURAL
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
@@ -15,134 +17,8 @@ class TabPosition(NamedTuple):
midi: int
class ChordSymbol(NamedTuple):
root: str
quality: str
bass: str
ROOTS = {
"C": 0,
"C#": 1,
"Db": 1,
"D": 2,
"D#": 3,
"Eb": 3,
"E": 4,
"F": 5,
"F#": 6,
"Gb": 6,
"G": 7,
"G#": 8,
"Ab": 8,
"A": 9,
"A#": 10,
"Bb": 10,
"B": 11,
}
def parse_root(symbol: str) -> tuple[str, str] | None:
symbol = symbol.strip()
if not symbol or symbol[0].upper() not in "ABCDEFG":
return None
length = 1
if len(symbol) > 1 and symbol[1] in {"#", "b"}:
length = 2
return symbol[:length], symbol[length:]
def parse_chord_symbol(symbol: str) -> ChordSymbol | None:
clean = symbol.strip().replace(" ", "")
base, _, slash = clean.partition("/")
parsed = parse_root(base)
if parsed is None:
return None
root, quality = parsed
bass = ""
if slash:
bass_parsed = parse_root(slash)
if bass_parsed is not None:
bass = bass_parsed[0]
return ChordSymbol(root, quality, bass)
def unique_intervals(values: tuple[int, ...]) -> tuple[int, ...]:
result: list[int] = []
seen: set[int] = set()
for value in values:
interval = mod12(value)
if interval in seen:
continue
seen.add(interval)
result.append(interval)
return tuple(result)
def color_intervals(value: str) -> tuple[int, ...]:
colors: list[int] = []
if "alt" in value:
colors.append(1)
if "b9" in value:
colors.append(1)
if "#9" in value:
colors.append(3)
if "9" in value and "b9" not in value and "#9" not in value:
colors.append(2)
if "#11" in value:
colors.append(6)
elif "11" in value:
colors.append(5)
if "b13" in value:
colors.append(8)
elif "13" in value:
colors.append(9)
return tuple(colors)
def chord_intervals(quality: str) -> tuple[int, ...]:
value = quality.lower().replace(" ", "")
if "7b5" in value or "ø" in value or "half" in value:
return (0, 3, 6, 10)
if "dim" in value or "o" in value:
return (0, 3, 6, 9)
colors = color_intervals(value)
if "aug" in value or "+" in value:
return unique_intervals((0, 4, 8, 10) + colors) if ("7" in value or colors) else (0, 4, 8)
if "sus" in value:
return unique_intervals((0, 5, 10) + colors) if ("7" in value or colors) else (0, 5, 7)
minor = value.startswith("-") or (value.startswith("m") and not value.startswith(("maj", "ma")))
if minor:
if "6" in value and "7" not in value:
return unique_intervals((0, 3, 7, 9) + colors)
if "7" in value or colors:
return unique_intervals((0, 3, 10) + colors)
return (0, 3, 7)
if "maj" in value or value.startswith("ma") or "Δ" in value:
return unique_intervals((0, 4, 11) + colors)
if "6" in value and "7" not in value:
return unique_intervals((0, 4, 7, 9) + colors)
if "7" in value or colors:
return unique_intervals((0, 4, 10) + colors)
return (0, 4, 7)
def pitch_class_for_root(root: str) -> int | None:
return ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
def chord_pitch_classes(symbol: str) -> tuple[int, ...]:
parsed = parse_chord_symbol(symbol)
if parsed is None:
return ()
root_pc = pitch_class_for_root(parsed.root)
if root_pc is None:
return ()
pitch_classes = [mod12(root_pc + interval) for interval in chord_intervals(parsed.quality)]
bass_pc = pitch_class_for_root(parsed.bass) if parsed.bass else None
if bass_pc is not None and bass_pc not in pitch_classes:
pitch_classes.append(bass_pc)
return tuple(pitch_classes)
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]:
@@ -168,8 +44,23 @@ def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int
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) -> list[TabPosition]:
pitch_classes = chord_pitch_classes(symbol)
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)
voicing = dict(semantic_degrees)
if 7 in voicing and voicing.get(5) == 0:
del voicing[5]
if len(voicing) > 4:
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_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()))
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 []
@@ -182,11 +73,13 @@ def find_playable_voicing(symbol: str) -> 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:
continue
fretted = [position.fret for position in candidate if position.fret > 0]
if fretted and max(fretted) - min(fretted) > 4:
continue
covered = {mod12(position.midi) for position in candidate}
if len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
if not set(pitch_classes).issubset(covered):
continue
score = candidate_score(candidate, pitch_classes)
if score < best_score:
@@ -195,24 +88,20 @@ def find_playable_voicing(symbol: str) -> list[TabPosition]:
return sorted(best or [], key=lambda item: item.midi)
def pitch_from_midi(midi: int) -> dict[str, int | str]:
names = (
("c", "n"),
("c", "#"),
("d", "n"),
("e", "b"),
("e", "n"),
("f", "n"),
("f", "#"),
("g", "n"),
("a", "b"),
("a", "n"),
("b", "b"),
("b", "n"),
)
letter, accidental = names[mod12(midi)]
return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1}
def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]:
return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]
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})
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
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']]+bass['alter']) % 12] = bass
result = []
for position in find_playable_voicing(definition):
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
p = spellings[midi % 12].copy()
p['octave'] = (midi - NATURAL[p['letter']] - p['alter']) // 12 - 1
result.append(p)
return result
+151 -250
View File
@@ -1,279 +1,180 @@
from __future__ import annotations
import copy
import warnings
from fractions import Fraction
from pathlib import Path
from generator.arrange.form import ArrangementSection, full_arrangement_sections
from generator.arrange.form import full_arrangement_sections
from generator.arrange.fretboard import pitches_for_voicing
from generator.score import (
QUARTER_TICKS,
apply_source_chord_markers,
active_chord_at_offset,
chord_markers,
chord_symbol_at_offset,
copy_source_voice,
first_chord_symbol,
first_voice,
leading_pickup_measure_index,
load_json,
make_arranged_document,
make_measure,
measure_capacity_ticks,
renderable_duration,
renderable_rest_notes,
rest_note,
sanitized_note,
slash_note,
slugify,
source_staff,
source_note_attack_at,
write_json,
)
PART_TYPES = ("acoustic", "electric", "vocal")
from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational
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')
FINAL_SECTION_INDEX = 6
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[str]:
result: list[str] = []
current = ""
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]:
result = []
current = None
for measure in measures:
current = first_chord_symbol(measure, current, include_optional=include_optional)
result.append(current)
for h in measure.get('harmonies', []):
if include_optional or not h.get('chord_optional'):
current = h
return result
def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol: str, visible_chord_symbol: str = "") -> dict:
note = {
"id": note_id,
"kind": "note",
"duration_ticks": duration_ticks,
"pitches": pitches_for_voicing(voicing_chord_symbol),
"hidden": False,
}
if not note["pitches"]:
return rest_note(note_id, duration_ticks, visible_chord_symbol)
if visible_chord_symbol:
note["chord_symbol"] = visible_chord_symbol
return note
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
hit_offsets = (0, QUARTER_TICKS + QUARTER_TICKS // 2, QUARTER_TICKS * 2, QUARTER_TICKS * 2 + QUARTER_TICKS // 2, QUARTER_TICKS * 3 + QUARTER_TICKS // 2)
hit_duration = QUARTER_TICKS // 2
notes: list[dict] = []
cursor = 0
for offset in hit_offsets:
if offset >= capacity:
def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None:
active = previous
for h in measure['harmonies']:
if fraction(h['onset']) > onset:
break
if offset > cursor:
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
notes.extend(rests)
cursor = offset
active_chord = active_chord_at_offset(measure, offset, chord_symbol, include_optional=include_optional)
visible_chord = chord_symbol_at_offset(measure, offset, include_optional=include_optional)
sequence += 1
notes.append(playable_chord_note(f"arr-note-{sequence}", min(hit_duration, capacity - offset), active_chord, visible_chord))
cursor = offset + min(hit_duration, capacity - offset)
if cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
active = h
return active if active and active['kind'] != 'none' else None
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
lead_rest = min(QUARTER_TICKS // 2, capacity)
if lead_rest:
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0, include_optional=include_optional))
notes.extend(rests)
if lead_rest < capacity:
hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
visible_chord = chord_symbol_at_offset(measure, lead_rest, include_optional=include_optional)
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol, include_optional=include_optional)
sequence += 1
if source_note_attack_at(measure, lead_rest):
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord))
else:
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
cursor = lead_rest + hit
if cursor < capacity:
markers = [marker for marker in chord_markers(measure, include_optional=include_optional) if marker[0] >= cursor]
if markers:
segment_cursor = cursor
for index, (start, _, chord) in enumerate(markers):
if start > segment_cursor:
rests, sequence = renderable_rest_notes("arr-rest", start - segment_cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
segment_cursor = next_start
if segment_cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - segment_cursor, sequence)
notes.extend(rests)
else:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
def slash_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
cursor = 0
while cursor < capacity:
sequence += 1
duration = min(QUARTER_TICKS, capacity - cursor)
notes.append(slash_note(f"arr-slash-{sequence}", duration))
cursor += duration
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
def rest_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
markers = chord_markers(measure, include_optional=include_optional)
if not markers:
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence)
return notes, sequence
notes: list[dict] = []
cursor = 0
for index, (start, _, chord) in enumerate(markers):
if start > cursor:
if not renderable_duration(start - cursor):
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
if not renderable_duration(next_start - start):
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
cursor = next_start
if cursor < capacity:
if not renderable_duration(capacity - cursor):
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return notes, sequence
def source_rest_skeleton_notes(measure: dict, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
notes: list[dict] = []
for source_note in first_voice(measure).get("notes", []):
if not isinstance(source_note, dict):
continue
sequence += 1
chord = "" if not include_optional and source_note.get("chord_optional") is True else str(source_note.get("chord_symbol", ""))
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), chord)
if "display_ticks" in source_note:
note["display_ticks"] = source_note["display_ticks"]
if "tuplet_id" in source_note:
note["tuplet_id"] = source_note["tuplet_id"]
notes.append(note)
if not notes:
sequence += 1
notes.append(rest_note(f"arr-rest-{sequence}", measure_capacity_ticks(str(measure.get("time_signature", "4/4")))))
return notes, sequence
def melody_voice(measure: dict, *, strip_lyrics: bool, include_optional: bool = True) -> dict:
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics, include_optional=include_optional)
voice["id"] = "voice-arranged-source"
voice["notes"] = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in first_voice(measure).get("notes", []) if isinstance(note, dict)]
return voice
def apply_vocal_pickup_overlap(output: list[dict], source_measures: list[dict], section: ArrangementSection) -> None:
pickup = leading_pickup_measure_index(source_measures)
if pickup is None or section.source_measure_index != pickup + 1 or not output:
return
voice = copy_source_voice(source_measures[pickup], strip_lyrics=False)
for note in voice["notes"]:
if isinstance(note, dict):
note.pop("chord_symbol", None)
output[-1]["voices"][0]["id"] = "voice-arranged-vocal"
output[-1]["voices"][0]["tuplets"] = voice["tuplets"]
output[-1]["voices"][0]["notes"] = voice["notes"]
def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[dict, int]:
if section.role == "vocal":
return melody_voice(measure, strip_lyrics=False, include_optional=include_optional), sequence
if section.role == "melody":
return melody_voice(measure, strip_lyrics=True, include_optional=include_optional), sequence
if section.role == "rhythm":
notes, sequence = rhythm_notes(measure, chord_symbol, sequence, include_optional=include_optional)
elif section.role == "accompaniment":
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence, include_optional=include_optional)
elif section.role == "slash":
notes, sequence = slash_notes(measure, chord_symbol, sequence, include_optional=include_optional)
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))]
else:
notes, sequence = rest_notes(measure, chord_symbol, sequence, include_optional=include_optional)
return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]:
sections = full_arrangement_sections(source_measures, part_type)
all_chords = propagated_chords(source_measures)
required_chords = propagated_chords(source_measures, include_optional=False)
output: list[dict] = []
sequence = 0
for section in sections:
include_optional = section.section_index != FINAL_SECTION_INDEX
chords = all_chords if include_optional else required_chords
if part_type == "vocal" and section.role == "vocal":
apply_vocal_pickup_overlap(output, source_measures, section)
for offset in range(section.measure_count):
source_index = section.source_measure_index + offset
source_measure = source_measures[source_index]
chord_symbol = chords[source_index] if source_index < len(chords) else ""
voice, sequence = voice_for_section(section, source_measure, chord_symbol, sequence, include_optional=include_optional)
output_measure = make_measure(
f"measure-{len(output) + 1}",
source_measure,
voice.get("notes", []),
section_label=section.label if offset == 0 else "",
tuplets=voice.get("tuplets", []),
)
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
output.append(output_measure)
return output
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
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))
attacks.append((onset, duration))
onset += beat
beat_index += 1
events = []
cursor = Fraction()
for onset, duration in attacks:
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')
event['notehead'] = 'slash'
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)
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'))
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)
measures = staff.get("measures")
if not isinstance(measures, list) or not measures:
raise ValueError("performance arrangement requires at least one source measure")
output = make_arranged_document(
document,
staff,
staff_id="staff-arranged-vocal" if part_type == "vocal" else "staff-arranged-guitar",
staff_name=part_type.capitalize(),
role="vocal" if part_type == "vocal" else "guitar",
arrangement_part_type=part_type,
measures=arranged_measures([measure for measure in measures if isinstance(measure, dict)], part_type),
)
metadata = output.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata.setdefault("tempo", 124)
return output
source = staff['measures']
sections = full_arrangement_sections(source,part_type)
output, tuplets, octave_shifts = [], [], []
absolute = Fraction()
source_starts = []
source_time = Fraction()
for m in source:
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)
segment = []
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')]
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])
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)
segment.append(result)
identity_segment = segment
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']))
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:
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']
for n in notes:
n['onset'] = rational(fraction(n['onset'])+pad)
if pad:
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)
destination['harmonies'].append(h)
state = {k:copy.deepcopy(pickup[k]) for k in ('key','clef','meter','transpose')}
if pad:
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']))
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',[])))
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)
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}")
raise ValueError(f'performance source directory not found: {source_dir}')
count = 0
for source_path in sorted(source_dir.glob("*.json")):
document = load_json(source_path)
for part_type in PART_TYPES:
output = arrange_document(document, part_type)
target = output_dir / f"{slugify(source_path.stem)}-{part_type}.json"
write_json(target, output)
for path in sorted(source_dir.glob('*.musicxml')):
document = load_musicxml(path)
for part in PART_TYPES:
write_json(output_dir/f'{slugify(path.stem)}-{part}.json',arrange_document(document,part))
count += 1
return count
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
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},
}
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)}}
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:
raise ValueError(f'unsupported harmony kind: {kind}')
result = KINDS[kind].copy()
for change in harmony.get('degrees', []):
degree, alter, operation = change['value'], change['alter'], change['type']
if type(degree) is not int or degree < 1 or type(alter) is not int:
raise ValueError('invalid harmony degree')
if operation == 'subtract':
if degree not in result:
raise ValueError(f'cannot subtract absent chord degree {degree}')
del result[degree]
elif operation == 'alter':
if degree not in result:
raise ValueError(f'cannot alter absent chord degree {degree}; use add')
result[degree] = alter
elif operation == 'add':
if degree in result:
raise ValueError(f'duplicate added chord degree {degree}')
result[degree] = alter
else:
raise ValueError(f'unsupported degree operation: {operation}')
if kind != 'none' and not result:
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
def pitch_classes(harmony: dict) -> tuple[int, ...]:
if harmony['kind'] == 'none':
return ()
root = harmony['root']
root_pc = NATURAL[root['letter']] + root['alter']
values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()]
if bass := harmony.get('bass'):
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.'
suffix = harmony.get('kind_text', SUFFIXES.get(harmony['kind'], harmony['kind']))
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'])
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')})
core = suffix
changes = []
while core not in aliases:
m = re.search(r'(add|no)?([b#]?)(\d+)$', core)
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'})
core = core[:m.start()]
kind = aliases[core]
for d in changes:
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}
if bass:
result['bass'] = parse_pitch_name(bass)
degrees(result)
return result
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
import copy
from fractions import Fraction
from typing import Any
SCHEMA_VERSION = 2
MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
TYPE_LENGTHS = {name: Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
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')
n, d = value['numerator'], value['denominator']
if type(n) is not int or type(d) is not int or d <= 0 or abs(n) > MAX_INTEGER or d > MAX_INTEGER:
raise ValueError('invalid musical time')
result = Fraction(n, d)
if rational(result) != value:
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):
if length == base * (2 - Fraction(1, 2 ** dots)):
return {'type': name, 'dots': dots}
# Generated rational spans need an explicit time modification, not rounded ticks.
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:
raise ValueError('written duration ratio must be positive')
if 'normal' in written:
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']
if letter not in NATURAL or type(alter) is not int or type(octave) is not int or not -3 <= alter <= 3 or not -4 <= octave <= 16:
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}')
if not -14 <= fifths <= 14:
raise ValueError('key signatures support up to fourteen fifths in either direction')
degree = fifths * 4 + MODE_STEPS.get(mode, 0)
letter = LETTERS[degree % 7]
signature = [0] * 7
order = 'fcgdaeb' if fifths > 0 else 'beadgcf'
for i in range(abs(fifths)):
signature[LETTERS.index(order[i % 7])] += 1 if fifths > 0 else -1
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', []):
beats, unit = group['beats'], group['unit']
if len(beats) > 1:
result.extend(Fraction(b * 4, unit) for b in beats)
elif beats[0] > 3 and beats[0] % 3 == 0 and unit >= 8:
result.extend([Fraction(12, unit)] * (beats[0] // 3))
else:
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'):
raise ValueError('meter requires beat groups or senza misura')
for group in groups:
if type(group['unit']) is not int or not 0 < group['unit'] <= 4096 or not group['beats']:
raise ValueError('meter unit must be between 1 and 4096 with nonempty beats')
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)})
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')
staves = document.get('staves', [])
if len(staves) != 1:
raise ValueError('exactly one staff is currently supported')
ids: set[str] = set()
events: dict[str, dict] = {}
tones: dict[str, tuple[dict, Fraction, Fraction]] = {}
absolute = Fraction()
for measure in staves[0]['measures']:
length = fraction(measure['duration'])
if length <= 0:
raise ValueError('measure duration must be positive')
voices = measure.get('voices', [])
if len(voices) != 1:
raise ValueError('exactly one rhythmic voice is currently supported')
previous_end = Fraction()
for note in voices[0]['notes']:
if not note['id'] or note['id'] in ids:
raise ValueError(f'duplicate event ID: {note["id"]}')
ids.add(note['id'])
events[note['id']] = note
onset, duration = fraction(note['onset']), fraction(note['duration'])
if onset < 0 or duration < 0 or onset + duration > length:
raise ValueError(f'event outside measure: {note["id"]}')
if note['kind'] not in ('note', 'rest', 'spacer'):
raise ValueError('invalid event kind')
if not note.get('grace'):
if duration == 0 or onset < previous_end:
raise ValueError('overlapping events in one rhythmic voice')
previous_end = onset + duration
elif duration != 0:
raise ValueError('grace notes must not advance metric time')
expected = metric_length(note['written'])
if not note.get('grace') and not note.get('measure_rest') and duration != expected:
raise ValueError('metric and written event durations disagree')
if note['kind'] != 'note' and note.get('pitches'):
raise ValueError('rests and spacers cannot contain pitches')
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
raise ValueError('pitched event requires at least one pitch')
for p in note.get('pitches', []):
pitch_number(p)
pd = fraction(p['duration'])
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
raise ValueError('invalid chord-tone duration or duplicate ID')
if not note.get('grace') and pd != metric_length(p['written']):
raise ValueError('metric and written chord-tone durations disagree')
tones[p['id']] = (p, absolute + onset, pd)
for harmony in measure.get('harmonies', []):
if not 0 <= fraction(harmony['onset']) < length:
raise ValueError('harmony outside measure')
for change in measure.get('attributes', []):
if not 0 <= fraction(change['onset']) < length:
raise ValueError('attribute change outside measure')
for state in [measure, *measure.get('attributes', [])]:
if 'meter' in state:
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):
raise ValueError('invalid pitched clef')
absolute += length
for identifier, (p, start, duration) in tones.items():
if target := p.get('tie_to'):
if target not in tones:
raise ValueError(f'dangling tie: {identifier} -> {target}')
other, next_start, _ = tones[target]
if other.get('tie_from') != identifier or pitch_number(p) != pitch_number(other) or next_start != start + duration:
raise ValueError(f'invalid tie: {identifier} -> {target}')
if source := p.get('tie_from'):
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)}
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'):
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:
raise ValueError('invalid measured tremolo')
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']:
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:
raise ValueError('invalid octave-shift span')
+562
View File
@@ -0,0 +1,562 @@
from __future__ import annotations
import copy
from fractions import Fraction
from pathlib import Path
from defusedxml import ElementTree as XML
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.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.sequence = 0
self.tuplet_sequence = 0
self.active_tuplets: dict[str, dict] = {}
self.tuplets: list[dict] = []
self.open_ties: dict[int, dict] = {}
self.open_octaves: dict[str, dict] = {}
self.octaves: list[dict] = []
self.voice_id = None
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 '')
if integer and result.denominator != 1 or positive and result <= 0:
raise ValueError()
rational(result)
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)
staff = node.findtext('staff', '1')
if staff != '1':
self.fail('multiple staves are not supported')
if voice is None:
return
if self.voice_id is None:
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)}
for item in node:
if item.get('number', '1') != '1':
self.fail('attributes for multiple staves are not supported')
if item.tag == 'divisions':
self.divisions = self.number(item.text, positive=True)
elif item.tag in ('staves', 'instruments'):
if item.text != '1':
self.fail(f'multiple {item.tag} are not supported')
elif item.tag == 'key':
self.attrs(item, {'number', 'print-object'})
self.children(item, {'cancel', 'fifths', 'mode', 'key-step', 'key-alter', 'key-accidental', 'key-octave'})
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}
pending = None
for field in item:
if field.tag == 'key-step':
pending = (field.text or '').lower()
if pending not in 'cdefgab' or len(pending) != 1:
self.fail('invalid custom key step')
elif field.tag == 'key-alter':
if pending is None:
self.fail('key alteration without a step')
self.key['alterations']['cdefgab'.index(pending)] = self.number(field.text, integer=True)
pending = None
elif field.tag in ('key-octave', 'key-accidental'):
self.fail(f'<{field.tag}> requires unsupported custom key placement')
if pending is not None:
self.fail('custom key step without alteration')
if item.find('cancel') is not None:
self.key['cancel'] = self.number(item.findtext('cancel'), integer=True)
result['key'] = copy.deepcopy(self.key)
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')}
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:
self.meter['unmetered'] = True
else:
pending = None
for field in item:
if field.tag == 'beats':
if pending is not None:
self.fail('beats without beat-type')
pending = [self.number(v, integer=True, positive=True) for v in (field.text or '').split('+')]
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)})
pending = None
if pending is not None or not self.meter['groups']:
self.fail('incomplete meter')
result['meter'] = copy.deepcopy(self.meter)
elif item.tag == 'clef':
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'):
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)
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)}
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'))}
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])
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'})
if len(node.findall('kind')) != 1 or len(node.findall('root')) > 1:
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':[]}
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'):
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.fail('invalid harmony root/bass')
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'})
value = self.number(degree.findtext('degree-value'), integer=True, positive=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:
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})
chord_degrees = harmony.degrees(result)
if 'kind_text' in result and result['kind'] != 'none':
from generator.model import pitch_name
label = pitch_name(result['root']) + result['kind_text']
try:
displayed = harmony.degrees(harmony.parse_symbol(label))
except ValueError:
self.fail(f'unmapped harmony display text {label!r}; use a standard chord kind/degree spelling')
if displayed != chord_degrees:
if displayed != harmony.KINDS[result['kind']]:
self.fail(f'contradictory harmony text {label!r} and structured chord degrees')
result.pop('kind_text')
if node.find('inversion') is not None:
inversion = self.number(node.findtext('inversion'), integer=True)
result['inversion'] = inversion
if not 0 <= inversion < len(chord_degrees):
self.fail('invalid chord inversion')
if inversion and not result.get('bass'):
degree = sorted(chord_degrees)[inversion]
root = result['root']
letter_index = 'cdefgab'.index(root['letter']) + degree - 1
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['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'})
onset = self.offset(node, cursor)
for group in node.findall('direction-type'):
self.children(group, {'words','octave-shift','rehearsal','metronome'})
for item in group:
if item.tag == 'words':
value = ''.join(item.itertext()).strip()
if value == 'vamp:optional-chord':
optional.append(onset)
elif value:
self.fail(f'unsupported staff text {value!r}; only Vamp directives are accepted')
elif item.tag == 'rehearsal':
if onset != 0:
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):
self.fail('octave shifts support 8, 15, or 22')
position = rational(self.absolute + onset)
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)}
self.open_octaves[number] = span
self.octaves.append(span)
elif action == 'stop':
if number not in self.open_octaves:
self.fail('octave-shift stop without start')
self.open_octaves.pop(number)['end'] = position
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'))}
written_length(written)
bpm = self.number(item.findtext('per-minute'), positive=True)
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))})
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'):
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:
self.fail('note cannot contain both pitch and rest')
self.identity(node)
is_chord = node.find('chord') is not None
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'])):
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'))}
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_length(written)
modulation = node.find('time-modification')
if modulation is not None:
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_length(written['normal'])
elif modulation.findall('normal-dot'):
self.fail('<normal-dot> requires <normal-type>')
measure_rest = node.find('rest') is not None and node.find('rest').get('measure') == 'yes'
if grace is None and not measure_rest:
expected = written_length(written) * fraction(written.get('ratio', rational(1)))
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':[]}
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'):
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'})
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)}
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}
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}
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'}
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'})
ties |= visual_ties
if ties - {'start','stop'}:
self.fail('unsupported tie notation')
if 'stop' in ties:
source = self.open_ties.pop(value, None)
if source is None or source['_absolute'] + fraction(source['duration']) != p['_absolute']:
self.fail('tie stop has no contiguous matching start')
source['tie_to'], p['tie_from'] = p['id'], source['id']
if 'start' in ties:
if value in self.open_ties:
self.fail('ambiguous duplicate tie at the same pitch')
self.open_ties[value] = p
note['pitches'].append(p)
if not is_chord:
if node.find('stem') is not None:
stem = node.findtext('stem')
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'):
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'):
self.fail('invalid beam')
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'})
number = lyric.get('number', '1')
if self.lyric_number is None:
self.lyric_number = number
if self.lyric_number != number or 'lyric' in note:
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'):
self.fail('invalid lyric syllabification')
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')
stops = []
for notations in node.findall('notations'):
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'})
if action == 'start':
if number in self.active_tuplets:
self.fail('duplicate tuplet start')
self.tuplet_sequence += 1
actual = item.findtext('tuplet-actual/tuplet-number', modulation.findtext('actual-notes') if modulation is not None else '')
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')}
self.active_tuplets[number] = span
self.tuplets.append(span)
elif action == 'stop':
stops.append(number)
else:
self.fail('invalid tuplet type')
elif item.tag == 'ornaments':
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':
self.fail('only measured tremolos are supported')
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')
self.tremolo_start = note
elif action == 'stop':
if self.tremolo_start is None:
self.fail('tremolo stop without start')
note['tremolo']['partner'] = self.tremolo_start['id']
self.tremolo_start['tremolo']['partner'] = note['id']
self.tremolo_start = None
elif action != 'single':
self.fail('unsupported tremolo type')
if not is_chord:
note['tuplet_ids'] = [t['id'] for t in self.active_tuplets.values()]
for number in stops:
if number not in self.active_tuplets:
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'):
self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0')
self.children(root, IGNORED_TOP | {'part'})
parts = root.findall('part')
definitions = root.findall('part-list/score-part')
if len(parts) != 1 or len(definitions) != 1 or parts[0].get('id') != definitions[0].get('id'):
self.fail('exactly one matching score part is required')
fields = {}
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
name = item.get('name','')
if name.startswith('vamp:'):
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'))}
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'})
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)}
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))
elif len(state) > 1:
measure['attributes'].append(state)
elif item.tag == 'note':
previous, cursor, is_chord = self.read_note(item, cursor, previous)
if not is_chord:
events.append(previous)
elif item.tag == 'harmony':
measure['harmonies'].append(self.read_harmony(item, cursor))
elif item.tag == 'direction':
self.direction(item, cursor, measure, optional)
elif item.tag == 'backup':
self.fail('backward timeline movement requires unsupported independent voices')
elif item.tag == 'forward':
self.children(item, {'duration','voice','staff'})
self.identity(item)
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'))
cursor += duration
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'):
self.fail('unsupported barline')
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'])
meter_changed = any('meter' in a for a in measure['attributes'])
if expected and cursor != expected and not measure['implicit'] and not meter_changed:
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']))
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:
self.fail('optional-chord marker must identify exactly one harmony and occur only once')
matches[0]['chord_optional'] = True
used.add(onset)
measures.append(measure)
self.absolute += cursor
if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_start:
self.fail('unterminated tie, tuplet, octave shift, or tremolo')
if not measures:
self.fail('score has no measures')
for measure in measures:
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}]}
validate_document(result)
return result
def load_musicxml(path: Path) -> dict:
importer = Importer(Path(path))
try:
return importer.read()
except MusicXMLError:
raise
except Exception as error:
raise MusicXMLError(f'{importer.location}: {error}') from error
+10 -8
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
from pathlib import Path
from generator.score import copy_source_voice, load_json, make_arranged_document, make_measure, slugify, source_staff, write_json
from generator.score import copy_source_voice, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
from generator.musicxml import load_musicxml
from generator.transposition import TARGET_KEYS, transpose_document
@@ -19,10 +20,10 @@ def practice_document(document: dict) -> dict:
measure,
voice["notes"],
section_label=str(measure.get("section_label", "")),
tuplets=voice["tuplets"],
)
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
measures.append(output_measure)
tuplets = reidentify(measures, staff.get('tuplets', []))
return make_arranged_document(
document,
staff,
@@ -31,15 +32,17 @@ def practice_document(document: dict) -> dict:
role="vocal",
arrangement_part_type="practice",
measures=measures,
tuplets=tuplets,
octave_shifts=staff.get('octave_shifts', []),
)
def key_file_name(key: str) -> str:
return key.lower().replace("#", "sharp").replace("b", "b") + ".json"
return slugify(key.replace("#", "sharp")) + ".json"
def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
document = practice_document(load_json(source_path))
document = practice_document(load_musicxml(source_path))
metadata = document.get("metadata", {})
key = str(metadata.get("key_signature", "C") if isinstance(metadata, dict) else "C")
relative = source_path.relative_to(source_root)
@@ -50,7 +53,7 @@ def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Pa
def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
base_document = practice_document(load_json(source_path))
base_document = practice_document(load_musicxml(source_path))
relative = source_path.relative_to(source_root)
lesson_group = relative.parent.name
exercise_id = slugify(source_path.stem)
@@ -67,11 +70,10 @@ def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
count = 0
chord_root = source_dir / "chord"
if chord_root.exists():
for path in sorted(chord_root.rglob("*.json")):
for path in sorted(chord_root.rglob("*.musicxml")):
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("*.json")):
for path in sorted(melody_root.rglob("*.musicxml")):
count += generate_melody_practice(path, melody_root, output_dir)
return count
+77 -772
View File
@@ -4,812 +4,117 @@ import copy
import json
import re
from pathlib import Path
from typing import Any
from generator.styles import canonical_style
WHOLE_TICKS = 16384
HALF_TICKS = WHOLE_TICKS // 2
QUARTER_TICKS = WHOLE_TICKS // 4
EIGHTH_TICKS = WHOLE_TICKS // 8
SIXTEENTH_TICKS = WHOLE_TICKS // 16
THIRTY_SECOND_TICKS = WHOLE_TICKS // 32
RENDERABLE_TICKS = (
WHOLE_TICKS + HALF_TICKS,
WHOLE_TICKS,
HALF_TICKS + QUARTER_TICKS,
HALF_TICKS,
QUARTER_TICKS + EIGHTH_TICKS,
QUARTER_TICKS,
EIGHTH_TICKS + SIXTEENTH_TICKS,
EIGHTH_TICKS,
SIXTEENTH_TICKS + THIRTY_SECOND_TICKS,
SIXTEENTH_TICKS,
THIRTY_SECOND_TICKS,
)
REST_PITCH = {"letter": "b", "accidental": "n", "octave": 4}
FORBIDDEN_OUTPUT_FIELDS = {
"chord_optional",
}
from generator.model import SCHEMA_VERSION, validate_document
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as stream:
document = json.load(stream)
if not isinstance(document, dict):
raise ValueError(f"{path} is not a score document")
return normalize_score_document(document, path)
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[str, Any]) -> None:
def write_json(path: Path, document: dict) -> None:
validate_document(document)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as stream:
json.dump(document, stream, indent=2, ensure_ascii=False)
stream.write("\n")
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def slugify(value: str) -> str:
value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "-", value)
value = value.strip("-")
return value or "score"
return re.sub(r'[^a-z0-9]+', '-', value.strip().lower()).strip('-') or 'score'
def human_label(value: str) -> str:
text = value.replace("_", " ").replace("-", " ").strip()
return " ".join(part.capitalize() for part in text.split()) or "General"
return ' '.join(v.capitalize() for v in value.replace('_',' ').replace('-',' ').split()) or 'General'
class CompactScoreError(ValueError):
pass
def source_staff(document: dict) -> dict:
if len(document.get('staves', [])) != 1:
raise ValueError('exactly one source staff is required')
return document['staves'][0]
DURATION_TICKS = {
"1": WHOLE_TICKS,
"2": HALF_TICKS,
"4": QUARTER_TICKS,
"8": EIGHTH_TICKS,
"16": SIXTEENTH_TICKS,
"32": THIRTY_SECOND_TICKS,
}
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 score_field(document: dict[str, Any], metadata: dict[str, Any], *names: str, fallback: str = "") -> str:
for name in names:
value = document.get(name)
if isinstance(value, str):
return value
value = metadata.get(name)
if isinstance(value, str):
return value
return fallback
def measure_notes(measure: dict) -> list[dict]:
return first_voice(measure)['notes']
def canonicalize_score_metadata(metadata: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
output = sanitize_json(metadata)
if not isinstance(output, dict):
output = {}
style_source = output.get("style")
try:
output["style"] = canonical_style(style_source)
except ValueError as error:
location = f"{path}: " if path is not None else ""
raise CompactScoreError(f"{location}{error}") from error
return output
def leading_pickup_measure_index(measures: list[dict]) -> int | None:
return 0 if len(measures)>1 and measures[0].get('implicit') else None
def normalize_full_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
output = copy.deepcopy(document)
output.pop("version", None)
metadata_value = output.get("metadata", {})
if metadata_value is not None and not isinstance(metadata_value, dict):
location = f"{path}: " if path is not None else ""
raise CompactScoreError(f"{location}metadata must be an object")
metadata = copy.deepcopy(metadata_value) if isinstance(metadata_value, dict) else {}
for key in ("style",):
if key in output and key not in metadata:
metadata[key] = output[key]
output.pop(key, None)
output["metadata"] = canonicalize_score_metadata(metadata, path)
return output
def body_start_measure_index(measures: list[dict]) -> int:
return 1 if leading_pickup_measure_index(measures) is not None else 0
def parse_duration_token(text: str, label: str) -> int:
dotted = text.endswith(".")
base_text = text[:-1] if dotted else text
if base_text not in DURATION_TICKS:
raise CompactScoreError(f"{label}: unsupported duration {text!r}")
ticks = DURATION_TICKS[base_text]
return ticks + ticks // 2 if dotted else ticks
def compact_pitch(letter: str, accidental: str, octave: int) -> dict[str, Any]:
return {
"letter": letter.lower(),
"accidental": accidental or "n",
"octave": octave,
}
class CompactMeasureParser:
def __init__(self, text: str, *, measure_number: int, time_signature: str) -> None:
self.text = text
self.index = 0
self.measure_number = measure_number
self.time_signature = time_signature
self.sequence = 0
self.tuplet_sequence = 0
self.pending_chord = ""
self.pending_chord_optional = False
self.last_note: dict[str, Any] | None = None
self.last_octave = 4
self.tuplets: list[dict[str, Any]] = []
def label(self) -> str:
return f"measure {self.measure_number}"
def error(self, message: str) -> CompactScoreError:
return CompactScoreError(f"{self.label()}, column {self.index + 1}: {message}")
def peek(self) -> str:
return self.text[self.index] if self.index < len(self.text) else ""
def skip_space(self) -> None:
while self.peek() and self.peek().isspace():
self.index += 1
def parse_until(self, close: str, label: str) -> str:
start = self.index
escaped = False
while self.index < len(self.text):
char = self.text[self.index]
if escaped:
escaped = False
self.index += 1
continue
if char == "\\":
escaped = True
self.index += 1
continue
if char == close:
value = self.text[start:self.index]
self.index += 1
return bytes(value, "utf-8").decode("unicode_escape")
self.index += 1
raise self.error(f"unterminated {label}")
def parse_chord(self) -> None:
self.index += 1
chord = self.parse_until("]", "chord symbol").strip()
optional = chord.startswith("(") and chord.endswith(")")
if optional:
chord = chord[1:-1].strip()
if not chord:
raise self.error("chord symbol must not be empty")
self.pending_chord = chord
self.pending_chord_optional = optional
def parse_lyric(self) -> None:
if self.last_note is None:
raise self.error("lyric must follow a note")
self.index += 1
lyric = self.parse_until('"', "lyric")
self.last_note["lyric"] = lyric
def parse_tuplet_header(self) -> tuple[int, int]:
self.skip_space()
match = re.match(r"(\d+)\s*:\s*(\d+)", self.text[self.index:])
if match is None:
raise self.error("tuplet must start with N:M")
self.index += match.end()
note_count = int(match.group(1))
space_count = int(match.group(2))
if note_count <= 0 or space_count <= 0:
raise self.error("tuplet counts must be positive")
return note_count, space_count
def parse_tuplet(self) -> list[dict[str, Any]]:
self.index += 1
note_count, space_count = self.parse_tuplet_header()
self.tuplet_sequence += 1
tuplet_id = f"tuplet-{self.measure_number}-{self.tuplet_sequence}"
notes: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
raise self.error("unterminated tuplet")
if self.peek() == "}":
self.index += 1
break
if self.peek() == "{":
raise self.error("nested tuplets are not supported")
notes.extend(self.parse_item())
if len(notes) != note_count:
raise self.error(f"tuplet {tuplet_id} contains {len(notes)} notes/rests, expected {note_count}")
self.apply_tuplet_duration(notes, note_count, space_count)
for note in notes:
note["tuplet_id"] = tuplet_id
self.tuplets.append({
"id": tuplet_id,
"note_count": note_count,
"space_count": space_count,
})
return notes
def apply_tuplet_duration(self, notes: list[dict[str, Any]], note_count: int, space_count: int) -> None:
numerators = [int(note["duration_ticks"]) * space_count for note in notes]
floors = [value // note_count for value in numerators]
target_total = sum(numerators) // note_count
remainder = target_total - sum(floors)
order = sorted(range(len(notes)), key=lambda index: numerators[index] % note_count, reverse=True)
actual = floors[:]
for index in order[:remainder]:
actual[index] += 1
for note, ticks in zip(notes, actual):
display = int(note["duration_ticks"])
note["duration_ticks"] = max(1, ticks)
note["display_ticks"] = display
def parse_pitch_atom(self) -> dict[str, Any]:
start = self.index
char = self.peek()
if char.lower() not in "abcdefg":
raise self.error("expected pitch")
self.index += 1
accidental = ""
if self.peek() in {"#", "b", "n"}:
accidental = self.peek()
self.index += 1
octave_start = self.index
while self.peek().isdigit():
self.index += 1
if self.index > octave_start:
self.last_octave = int(self.text[octave_start:self.index])
if self.index == start:
raise self.error("expected pitch")
return compact_pitch(char, accidental, self.last_octave)
def parse_pitch_group(self) -> list[dict[str, Any]]:
self.index += 1
pitches: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
raise self.error("unterminated pitch group")
if self.peek() == ")":
self.index += 1
break
pitches.append(self.parse_pitch_atom())
if not pitches:
raise self.error("pitch group must contain at least one pitch")
return pitches
def parse_note(self, tie_from_previous: bool = False) -> dict[str, Any]:
start = self.index
char = self.peek()
if not char:
raise self.error("expected note or rest")
kind = "rest" if char.lower() == "r" else "note"
if kind == "rest":
self.index += 1
pitches = [copy.deepcopy(REST_PITCH)]
elif char == "(":
pitches = self.parse_pitch_group()
else:
pitches = [self.parse_pitch_atom()]
if self.peek() != ":":
raise self.error(f"expected ':' after {self.text[start:self.index]!r}")
self.index += 1
duration_start = self.index
while self.peek().isdigit():
self.index += 1
if self.peek() == ".":
self.index += 1
duration_text = self.text[duration_start:self.index]
if not duration_text:
raise self.error("duration is required")
self.sequence += 1
note: dict[str, Any] = {
"id": f"note-{self.measure_number}-{self.sequence}",
"kind": kind,
"duration_ticks": parse_duration_token(duration_text, self.label()),
"pitches": pitches,
"hidden": False,
}
if self.pending_chord:
note["chord_symbol"] = self.pending_chord
if self.pending_chord_optional:
note["chord_optional"] = True
self.pending_chord = ""
self.pending_chord_optional = False
if tie_from_previous:
note["tie_from_previous"] = True
if self.peek() == "~":
note["tie_to_next"] = True
self.index += 1
self.last_note = note
return note
def parse_item(self) -> list[dict[str, Any]]:
self.skip_space()
char = self.peek()
if not char:
return []
if char == "[":
self.parse_chord()
return []
if char == '"':
self.parse_lyric()
return []
if char == "{":
return self.parse_tuplet()
if char == "}":
return []
if char == "~":
self.index += 1
self.skip_space()
next_char = self.peek()
if next_char and (next_char.lower() in "abcdefgr" or next_char == "("):
return [self.parse_note(tie_from_previous=True)]
if self.last_note is None:
raise self.error("tie marker must attach to a note")
self.last_note["tie_to_next"] = True
return []
if char.lower() in "abcdefgr" or char == "(":
return [self.parse_note()]
raise self.error("expected note, rest, chord, lyric, tie, or tuplet")
def parse(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
notes: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
break
notes.extend(self.parse_item())
if self.pending_chord:
raise self.error(f"chord {self.pending_chord!r} is not followed by a note or rest")
total = sum(int(note.get("duration_ticks", 0)) for note in notes)
capacity = measure_capacity_ticks(self.time_signature)
if total > capacity:
raise CompactScoreError(f"{self.label()}: duration total {total} exceeds {self.time_signature} capacity {capacity}")
return notes, self.tuplets
COMPACT_MEASURE_CONTROL_FIELDS = {
"notes",
"notation",
"section",
"section_label",
"key",
"key_signature",
"time",
"time_signature",
"id",
"voices",
}
GENERATED_MEASURE_FIELDS = {
"id",
"section_label",
"time_signature",
"key_signature",
"voices",
}
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str], dict[str, Any]]:
if isinstance(value, str):
return value, {}, {}
if isinstance(value, dict):
text = value.get("notes", value.get("notation", ""))
if not isinstance(text, str):
raise CompactScoreError(f"measure {index + 1}: notes must be a string")
overrides: dict[str, str] = {}
for source, target in (
("section", "section_label"),
("section_label", "section_label"),
("key", "key_signature"),
("key_signature", "key_signature"),
("time", "time_signature"),
("time_signature", "time_signature"),
):
field = value.get(source)
if isinstance(field, str):
overrides[target] = field
extra_fields = {
key: sanitize_json(extra_value)
for key, extra_value in value.items()
if key not in COMPACT_MEASURE_CONTROL_FIELDS
}
return text, overrides, extra_fields
raise CompactScoreError(f"measure {index + 1}: compact measure must be a string or object")
def normalize_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
if isinstance(document.get("staves"), list):
return normalize_full_score_document(document, path)
measures_value = document.get("measures")
if not isinstance(measures_value, list):
return normalize_full_score_document(document, path)
metadata_value = document.get("metadata", {})
metadata = metadata_value if isinstance(metadata_value, dict) else {}
title = score_field(document, metadata, "title", fallback=human_label(path.stem if path is not None else "score"))
composer = score_field(document, metadata, "composer")
key_signature = score_field(document, metadata, "key_signature", "key", fallback="C")
time_signature = score_field(document, metadata, "time_signature", "time", fallback="4/4")
clef = score_field(document, metadata, "clef", fallback="treble")
part = score_field(document, metadata, "part", "name", fallback="Vocal")
role = score_field(document, metadata, "role", fallback="vocal")
output_metadata = sanitize_json(metadata)
if not isinstance(output_metadata, dict):
output_metadata = {}
output_metadata.update({
"title": title,
"composer": composer,
"key_signature": key_signature,
"time_signature": time_signature,
})
for key, value in document.items():
if key in {"version", "title", "composer", "key", "key_signature", "time", "time_signature", "clef", "part", "name", "role", "measures", "metadata"}:
continue
output_metadata[key] = sanitize_json(value)
output_metadata = canonicalize_score_metadata(output_metadata, path)
measures: list[dict[str, Any]] = []
for index, value in enumerate(measures_value):
text, overrides, extra_fields = compact_measure_text(value, index)
measure_time = overrides.get("time_signature", time_signature)
measure_key = overrides.get("key_signature", key_signature)
parser = CompactMeasureParser(text, measure_number=index + 1, time_signature=measure_time)
notes, tuplets = parser.parse()
measure = {
"id": f"measure-{index + 1}",
"section_label": overrides.get("section_label", ""),
"time_signature": measure_time,
"key_signature": measure_key,
"voices": [{
"id": f"voice-{index + 1}",
"notes": notes,
"tuplets": tuplets,
}],
}
measure.update(extra_fields)
measures.append(measure)
return {
"metadata": output_metadata,
"staves": [{
"id": "staff-source",
"name": part,
"clef": clef,
"role": role,
"measures": measures,
}],
}
def source_staff(document: dict[str, Any]) -> dict[str, Any]:
staves = document.get("staves")
if not isinstance(staves, list) or not staves:
raise ValueError("score document must contain at least one staff")
first_staff: dict[str, Any] | None = None
for value in staves:
if not isinstance(value, dict):
continue
if first_staff is None:
first_staff = value
staff_id = str(value.get("id", ""))
if not value.get("arranged") and staff_id not in {"staff-arranged-guitar", "staff-arranged-vocal", "staff-arranged-practice"} and value.get("role") != "guitar":
return value
if first_staff is None:
raise ValueError("score document does not contain an object staff")
return first_staff
def measure_capacity_ticks(time_signature: str) -> int:
try:
beats_text, unit_text = time_signature.split("/", 1)
beats = int(beats_text)
unit = int(unit_text)
except ValueError:
return WHOLE_TICKS
if beats <= 0 or unit <= 0:
return WHOLE_TICKS
return WHOLE_TICKS * beats // unit
def note_duration(note: dict[str, Any]) -> int:
value = note.get("duration_ticks", QUARTER_TICKS)
return max(1, int(value)) if isinstance(value, int) else QUARTER_TICKS
def renderable_duration(duration_ticks: int) -> bool:
return duration_ticks in RENDERABLE_TICKS
def renderable_duration_parts(duration_ticks: int) -> list[int]:
parts: list[int] = []
remaining = duration_ticks
while remaining > 0:
match = next((duration for duration in RENDERABLE_TICKS if duration <= remaining), None)
if match is None:
return [duration_ticks]
parts.append(match)
remaining -= match
return parts
def first_voice(measure: dict[str, Any]) -> dict[str, Any]:
voices = measure.get("voices")
if isinstance(voices, list) and voices and isinstance(voices[0], dict):
return voices[0]
return {"id": "voice-empty", "tuplets": [], "notes": []}
def note_chord_symbol(note: dict[str, Any], *, include_optional: bool = True) -> str:
chord = note.get("chord_symbol")
if not isinstance(chord, str) or not chord:
return ""
if not include_optional and note.get("chord_optional") is True:
return ""
return chord
def first_chord_symbol(measure: dict[str, Any], fallback: str = "", *, include_optional: bool = True) -> str:
voice = first_voice(measure)
notes = voice.get("notes")
if not isinstance(notes, list):
return fallback
has_filtered_chord = False
for note in notes:
if isinstance(note, dict):
if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol"):
has_filtered_chord = True
chord = note_chord_symbol(note, include_optional=include_optional)
if chord:
return chord
if not include_optional and has_filtered_chord:
return ""
return fallback
def measure_notes(measure: dict[str, Any]) -> list[dict[str, Any]]:
voice = first_voice(measure)
notes = voice.get("notes")
return [note for note in notes if isinstance(note, dict)] if isinstance(notes, list) else []
def timed_measure_notes(measure: dict[str, Any]) -> list[tuple[int, dict[str, Any]]]:
cursor = 0
result: list[tuple[int, dict[str, Any]]] = []
for note in measure_notes(measure):
result.append((cursor, note))
cursor += note_duration(note)
return result
def chord_markers(measure: dict[str, Any], *, include_optional: bool = True) -> list[tuple[int, int, str]]:
markers: list[tuple[int, int, str]] = []
for start_ticks, note in timed_measure_notes(measure):
chord = note_chord_symbol(note, include_optional=include_optional)
if chord:
markers.append((start_ticks, note_duration(note), chord))
return markers
def chord_symbol_at_offset(measure: dict[str, Any], offset_ticks: int, *, include_optional: bool = True) -> str:
for start_ticks, _, chord in chord_markers(measure, include_optional=include_optional):
if start_ticks == offset_ticks:
return chord
return ""
def active_chord_at_offset(measure: dict[str, Any], offset_ticks: int, fallback: str = "", *, include_optional: bool = True) -> str:
current = fallback
for start_ticks, _, chord in chord_markers(measure, include_optional=include_optional):
if start_ticks > offset_ticks:
break
current = chord
return current
def source_note_attack_at(measure: dict[str, Any], offset_ticks: int) -> bool:
for start_ticks, note in timed_measure_notes(measure):
if start_ticks == offset_ticks and note.get("kind", "note") == "note" and isinstance(note.get("pitches"), list) and note["pitches"]:
return True
return False
def first_pitched_note_start(measure: dict[str, Any]) -> int | None:
cursor = 0
for note in measure_notes(measure):
if note.get("kind", "note") == "note" and isinstance(note.get("pitches"), list) and note["pitches"]:
return cursor
cursor += note_duration(note)
return None
def leading_pickup_measure_index(measures: list[dict[str, Any]]) -> int | None:
if len(measures) < 2 or first_chord_symbol(measures[0]):
return None
first_pitch = first_pitched_note_start(measures[0])
if first_pitch is None or first_pitch <= 0:
return None
for index, measure in enumerate(measures):
if first_chord_symbol(measure):
return 0 if index == 1 else None
return None
def body_start_measure_index(measures: list[dict[str, Any]]) -> int:
pickup = leading_pickup_measure_index(measures)
return pickup + 1 if pickup is not None else 0
def rest_note(note_id: str, duration_ticks: int, chord_symbol: str = "") -> dict[str, Any]:
note: dict[str, Any] = {
"id": note_id,
"kind": "rest",
"duration_ticks": duration_ticks,
"pitches": [copy.deepcopy(REST_PITCH)],
"hidden": False,
}
if chord_symbol:
note["chord_symbol"] = chord_symbol
return note
def renderable_rest_notes(note_id_prefix: str, duration_ticks: int, sequence: int, chord_symbol: str = "") -> tuple[list[dict[str, Any]], int]:
notes: list[dict[str, Any]] = []
for index, duration in enumerate(renderable_duration_parts(duration_ticks)):
sequence += 1
notes.append(rest_note(f"{note_id_prefix}-{sequence}", duration, chord_symbol if index == 0 else ""))
return notes, sequence
def apply_source_chord_markers(notes: list[dict[str, Any]], measure: dict[str, Any], *, include_optional: bool = True) -> list[dict[str, Any]]:
markers = [(start, chord) for start, _, chord in chord_markers(measure, include_optional=include_optional)]
if not markers:
result = [copy.deepcopy(note) for note in notes]
for note in result:
note.pop("chord_symbol", None)
return result
unique_markers: list[tuple[int, str]] = []
for start, chord in markers:
if unique_markers and unique_markers[-1][0] == start:
unique_markers[-1] = (start, chord)
else:
unique_markers.append((start, chord))
marker_by_start = dict(unique_markers)
marker_starts = [start for start, _ in unique_markers]
result: list[dict[str, Any]] = []
cursor = 0
split_sequence = 0
for source_note in notes:
note = copy.deepcopy(source_note)
note.pop("chord_symbol", None)
duration = note_duration(note)
end = cursor + duration
split_points = [cursor] + [start for start in marker_starts if cursor < start < end] + [end]
for index in range(len(split_points) - 1):
start = split_points[index]
stop = split_points[index + 1]
part_duration = stop - start
if part_duration <= 0:
continue
part = copy.deepcopy(note)
part["duration_ticks"] = part_duration
if len(split_points) > 2:
split_sequence += 1
part["id"] = f"{note.get('id', 'note')}-split-{split_sequence}"
if part.get("kind", "note") == "note":
part["tie_to_next"] = index + 2 < len(split_points)
part["tie_from_previous"] = index > 0
if start in marker_by_start:
part["chord_symbol"] = marker_by_start[start]
result.append(part)
cursor = end
return result
def slash_note(note_id: str, duration_ticks: int, chord_symbol: str = "") -> dict[str, Any]:
note: dict[str, Any] = {
"id": note_id,
"kind": "note",
"duration_ticks": duration_ticks,
"pitches": [copy.deepcopy(REST_PITCH)],
"notehead": "slash",
"hidden": False,
}
if chord_symbol:
note["chord_symbol"] = chord_symbol
return note
def sanitize_json(value: Any) -> Any:
if isinstance(value, dict):
return {key: sanitize_json(item) for key, item in value.items() if key not in FORBIDDEN_OUTPUT_FIELDS}
if isinstance(value, list):
return [sanitize_json(item) for item in value]
return copy.deepcopy(value)
def sanitized_note(note: dict[str, Any], *, strip_lyrics: bool, include_optional: bool = True) -> dict[str, Any]:
output = sanitize_json(note)
def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict:
voice = copy.deepcopy(first_voice(measure))
if strip_lyrics:
output.pop("lyric", None)
if not include_optional and note.get("chord_optional") is True:
output.pop("chord_symbol", None)
output.setdefault("hidden", False)
return output
for note in voice['notes']:
note.pop('lyric', None)
note.pop('lyric_notation', None)
return voice
def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool, include_optional: bool = True) -> dict[str, Any]:
voice = first_voice(measure)
tuplets = sanitize_json(voice.get("tuplets", []))
notes = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in measure_notes(measure)]
return {
"id": str(voice.get("id", "voice-source")),
"tuplets": tuplets if isinstance(tuplets, list) else [],
"notes": notes or [rest_note("arr-rest-empty", measure_capacity_ticks(str(measure.get("time_signature", "4/4"))))],
}
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}]})
return result
def measure_notation_metadata(source: dict[str, Any]) -> dict[str, Any]:
return {
key: sanitize_json(value)
for key, value in source.items()
if key not in GENERATED_MEASURE_FIELDS
}
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 = {}, {}
tuplets = []
for measure in measures:
for ni, note in enumerate(measure_notes(measure)):
old = note['id']
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}'
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'):
if p.get(field) in tone_map:
p[field] = tone_map[p[field]]
else:
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)
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})
for n in members:
n['tuplet_ids'] = [t['id'] if v==source['id'] else v for v in n['tuplet_ids']]
tuplets.append(t)
return tuplets
def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str, Any]], *, section_label: str = "", tuplets: list[Any] | None = None) -> dict[str, Any]:
measure: dict[str, Any] = {
"id": measure_id,
"time_signature": str(source.get("time_signature", "4/4")),
"key_signature": str(source.get("key_signature", "C")),
"voices": [{
"id": f"voice-{measure_id}",
"tuplets": tuplets or [],
"notes": notes,
}],
}
if section_label:
measure["section_label"] = section_label
measure.update(measure_notation_metadata(source))
return measure
def make_arranged_document(source_document: dict[str, Any], staff: dict[str, Any], *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict[str, Any]]) -> dict[str, Any]:
source_metadata = source_document.get("metadata", {})
metadata = canonicalize_score_metadata(source_metadata if isinstance(source_metadata, dict) else {})
output: dict[str, Any] = {
"metadata": metadata,
"staves": [{
"id": staff_id,
"name": staff_name,
"clef": str(staff.get("clef", "treble")),
"role": role,
"arranged": True,
"arrangement_part_type": arrangement_part_type,
"measures": measures,
}],
}
return sanitize_json(output)
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 []}]}
def count_json_files(root: Path) -> int:
return sum(1 for path in root.rglob("*.json") if path.is_file())
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"):
text = path.read_text(encoding="utf-8")
for field in FORBIDDEN_OUTPUT_FIELDS:
if f'"{field}"' in text:
raise ValueError(f"{path} contains forbidden generated field {field}")
json.loads(text)
for path in root.rglob('*.json'):
load_json(path)
+7 -7
View File
@@ -6,6 +6,7 @@ from typing import Any
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]:
@@ -20,17 +21,16 @@ def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_num
summary: dict[str, Any] = {
"measure": measure_number,
"section_label": str(measure.get("section_label", "")),
"chord_symbols": [str(note.get("chord_symbol", "")) for note in notes if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol")],
"harmonies": [{"onset": str(fraction(h["onset"])), "symbol": h["symbol"]} for h in measure["harmonies"]],
"duration": str(fraction(measure["duration"])),
"slash_count": sum(1 for note in notes if note.get("kind", "note") == "note" and note.get("notehead") == "slash"),
}
if staff.get("arrangement_part_type") == "vocal":
summary["vocal_skeleton"] = [
{
"kind": str(note.get("kind", "note")),
"duration_ticks": int(note.get("duration_ticks", 0)),
"display_ticks": int(note["display_ticks"]) if isinstance(note.get("display_ticks"), int) else None,
"has_lyric": isinstance(note.get("lyric"), str) and bool(note.get("lyric")),
}
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','')
for note in notes
]
return summary
+120 -166
View File
@@ -2,34 +2,20 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any
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
degrees: tuple[str, str, str, str, str, str, str]
prefer_flats: bool
fifths: int
KEY_PROFILES = (
KeyProfile("C", 0, ("C", "D", "E", "F", "G", "A", "B"), False),
KeyProfile("Db", 1, ("Db", "Eb", "F", "Gb", "Ab", "Bb", "C"), True),
KeyProfile("D", 2, ("D", "E", "F#", "G", "A", "B", "C#"), False),
KeyProfile("Eb", 3, ("Eb", "F", "G", "Ab", "Bb", "C", "D"), True),
KeyProfile("E", 4, ("E", "F#", "G#", "A", "B", "C#", "D#"), False),
KeyProfile("F", 5, ("F", "G", "A", "Bb", "C", "D", "E"), True),
KeyProfile("Gb", 6, ("Gb", "Ab", "Bb", "Cb", "Db", "Eb", "F"), True),
KeyProfile("G", 7, ("G", "A", "B", "C", "D", "E", "F#"), False),
KeyProfile("Ab", 8, ("Ab", "Bb", "C", "Db", "Eb", "F", "G"), True),
KeyProfile("A", 9, ("A", "B", "C#", "D", "E", "F#", "G#"), False),
KeyProfile("Bb", 10, ("Bb", "C", "D", "Eb", "F", "G", "A"), True),
KeyProfile("B", 11, ("B", "C#", "D#", "E", "F#", "G#", "A#"), False),
)
TARGET_KEYS = tuple(profile.name for profile in KEY_PROFILES)
NATURAL_PITCH_CLASSES = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
LETTER_DEGREES = {"c": 0, "d": 1, "e": 2, "f": 3, "g": 4, "a": 5, "b": 6}
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:
@@ -37,158 +23,126 @@ def mod12(value: int) -> int:
def find_key(name: str) -> KeyProfile:
normalized = name.lower()
aliases = {"c#": "db", "d#": "eb", "f#": "gb", "g#": "ab", "a#": "bb"}
normalized = aliases.get(normalized, normalized)
for profile in KEY_PROFILES:
if profile.name.lower() == normalized:
return profile
raise ValueError(f"unsupported key signature: {name}")
tonic = parse_pitch_name(name)
for fifths in range(-14, 15):
key = key_from_fifths(fifths)
if key['tonic'] == tonic:
return KeyProfile(name, (NATURAL[tonic['letter']] + tonic['alter']) % 12, fifths)
raise ValueError(f'unsupported key signature: {name}')
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
interval = mod12(target.root - source.root)
return interval - 12 if interval > 6 else interval
value = (target.root-source.root) % 12
return value-12 if value > 6 else value
def split_spelling(value: str) -> tuple[str, str]:
if not value:
return "C", ""
return value[0], value[1:]
def interval_between(source: dict, target: dict) -> tuple[int, int]:
semitones = (NATURAL[target['letter']] + target['alter'] - NATURAL[source['letter']] - source['alter']) % 12
if semitones > 6:
semitones -= 12
steps = (LETTERS.index(target['letter']) - LETTERS.index(source['letter'])) % 7
if semitones < 0 or semitones == 0 and steps > 3:
steps -= 7
return steps, semitones
def accidental_offset(accidental: str) -> int:
value = accidental.lower()
if value in {"#", "sharp"}:
return 1
if value in {"b", "flat"}:
return -1
return 0
def spelling_pitch_class(spelling: str) -> int:
letter, accidental = split_spelling(spelling)
return mod12(NATURAL_PITCH_CLASSES.get(letter.lower(), 0) + accidental_offset(accidental))
def pitch_class(pitch: dict[str, Any]) -> int:
letter = str(pitch.get("letter", "c")).lower()
accidental = str(pitch.get("accidental", "n"))
return mod12(NATURAL_PITCH_CLASSES.get(letter, 0) + accidental_offset(accidental))
def pitch_midi(pitch: dict[str, Any]) -> int:
octave = int(pitch.get("octave", 4))
return octave * 12 + pitch_class(pitch)
def octave_for(midi: int, pitch_class_value: int) -> int:
octave = (midi - pitch_class_value) // 12
while octave * 12 + pitch_class_value > midi:
octave -= 1
while (octave + 1) * 12 + pitch_class_value <= midi:
octave += 1
return octave
def matching_source_degree_for_pitch(pitch: dict[str, Any], source: KeyProfile) -> int | None:
actual = pitch_class(pitch)
source_letter_degree = LETTER_DEGREES.get(str(pitch.get("letter", "c")).lower(), 0)
for index, spelling in enumerate(source.degrees):
letter, _ = split_spelling(spelling)
if LETTER_DEGREES.get(letter.lower(), 0) == source_letter_degree and spelling_pitch_class(spelling) == actual:
return index
return None
def chromatic_spelling(pitch_class_value: int, prefer_flats: bool) -> str:
flats = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")
sharps = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
return flats[pitch_class_value] if prefer_flats else sharps[pitch_class_value]
def transpose_pitch(pitch: dict[str, Any], source: KeyProfile, target: KeyProfile, interval: int) -> dict[str, Any]:
transposed_midi = pitch_midi(pitch) + interval
degree = matching_source_degree_for_pitch(pitch, source)
spelling = target.degrees[degree] if degree is not None else chromatic_spelling(mod12(transposed_midi), target.prefer_flats)
letter, accidental = split_spelling(spelling)
pc = spelling_pitch_class(spelling)
return {
"letter": letter.lower(),
"accidental": accidental or "n",
"octave": octave_for(transposed_midi, pc),
}
def parse_chord_root(value: str, offset: int) -> tuple[int, str] | None:
if offset >= len(value) or value[offset].upper() not in "ABCDEFG":
return None
length = 1
if offset + length < len(value) and value[offset + length] in {"#", "b"}:
length += 1
return length, value[offset:offset + length]
def matching_source_degree_for_root(root: str, source: KeyProfile) -> int | None:
root_letter, _ = split_spelling(root)
actual = spelling_pitch_class(root)
for index, spelling in enumerate(source.degrees):
letter, _ = split_spelling(spelling)
if LETTER_DEGREES.get(letter.lower(), 0) == LETTER_DEGREES.get(root_letter.lower(), 0) and spelling_pitch_class(spelling) == actual:
return index
return None
def transpose_root(root: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
degree = matching_source_degree_for_root(root, source)
if degree is not None:
return target.degrees[degree]
return chromatic_spelling(mod12(spelling_pitch_class(root) + interval), target.prefer_flats)
def transpose_chord_symbol(symbol: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
root = parse_chord_root(symbol, 0)
if root is None:
return symbol
root_length, root_text = root
result = transpose_root(root_text, source, target, interval) + symbol[root_length:]
slash = result.find("/")
if slash < 0:
return result
bass = parse_chord_root(result, slash + 1)
if bass is None:
return result
bass_length, bass_text = bass
return result[:slash + 1] + transpose_root(bass_text, source, target, interval) + result[slash + 1 + bass_length:]
def transpose_document(document: dict[str, Any], target_key_name: str) -> dict[str, Any]:
output = copy.deepcopy(document)
metadata = output.setdefault("metadata", {})
if not isinstance(metadata, dict):
raise ValueError("score metadata must be an object")
source_key = find_key(str(metadata.get("key_signature", "C") or "C"))
target_key = find_key(target_key_name)
interval = signed_interval(source_key, target_key)
metadata["key_signature"] = target_key.name
for staff in output.get("staves", []):
if not isinstance(staff, dict):
continue
for measure in staff.get("measures", []):
if not isinstance(measure, dict):
continue
measure["key_signature"] = target_key.name
for voice in measure.get("voices", []):
if not isinstance(voice, dict):
continue
for note in voice.get("notes", []):
if not isinstance(note, dict):
continue
chord = note.get("chord_symbol")
if isinstance(chord, str) and chord:
note["chord_symbol"] = transpose_chord_symbol(chord, source_key, target_key, interval)
pitches = note.get("pitches")
if isinstance(pitches, list):
note["pitches"] = [transpose_pitch(pitch, source_key, target_key, interval) if isinstance(pitch, dict) else pitch for pitch in pitches]
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
output = copy.deepcopy(pitch)
original_octave = pitch.get('octave', 4)
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
octave, index = divmod(diatonic, 7)
output['letter'] = LETTERS[index]
output['octave'] = octave
output['alter'] = pitch_number({**pitch, 'octave':original_octave}) + semitones - (12 * (octave+1) + NATURAL[output['letter']])
if 'octave' not in pitch:
output.pop('octave')
if 'accidental_display' in output:
output['accidental_display']['name'] = {-3:'triple-flat',-2:'flat-flat',-1:'flat',0:'natural',1:'sharp',2:'double-sharp',3:'triple-sharp'}.get(output['alter'], 'compound')
return output
def shift_key(key: dict, steps: int, semitones: int) -> dict:
tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None
alterations = [0] * 7
for index, letter in enumerate(LETTERS):
p = shift_pitch({'letter':letter, 'alter':key['alterations'][index]}, steps, semitones)
alterations[LETTERS.index(p['letter'])] = p['alter']
result = {**copy.deepcopy(key), 'tonic':tonic, 'alterations':alterations}
if not key.get('custom') and tonic:
for fifths in range(-14,15):
candidate = key_from_fifths(fifths, key['mode'])
if candidate['tonic']==tonic and candidate['alterations']==alterations:
result['fifths'] = fifths
break
else:
result['custom'] = True
return result
def transpose_document(document: dict, target_key_name: str) -> dict:
output = copy.deepcopy(document)
metadata = output['metadata']
key = metadata['key']
origin = key.get('tonic') or metadata.get('transpose_origin')
if origin is None:
raise ValueError('keyless melody practice requires a vamp:tonic score property')
target = parse_pitch_name(target_key_name)
steps, semitones = interval_between(origin, target)
metadata['key'] = shift_key(key, steps, semitones)
metadata['key_signature'] = key_label(metadata['key'])
metadata['variant_tonic'] = target_key_name
for staff in output['staves']:
for measure in staff['measures']:
measure['key'] = shift_key(measure['key'], steps, semitones)
measure['key_signature'] = key_label(measure['key'])
for change in measure.get('attributes', []):
if 'key' in change:
change['key'] = shift_key(change['key'], steps, semitones)
for h in measure.get('harmonies', []):
for field in ('root','bass'):
if field in h:
h[field] = shift_pitch(h[field], steps, semitones)
h['symbol'] = harmony.symbol(h)
for voice in measure['voices']:
for note in voice['notes']:
note['pitches'] = [shift_pitch(p, steps, semitones) for p in note.get('pitches', [])]
return output
def concert_document(document: dict) -> dict:
"""Normalize MusicXML instrument transposition before concert-pitch arranging."""
output = copy.deepcopy(document)
zero = {'diatonic':0, 'chromatic':0, 'octave_change':0}
for staff in output['staves']:
for measure in staff['measures']:
initial = measure['transpose']
changes = copy.deepcopy(measure.get('attributes', []))
def interval(onset):
state = initial
from generator.model import fraction
for change in changes:
if fraction(change['onset']) <= onset and 'transpose' in change:
state = change['transpose']
return state['diatonic']+7*state['octave_change'], state['chromatic']+12*state['octave_change']
from generator.model import fraction
measure['key'] = shift_key(measure['key'], *interval(0))
measure['key_signature'] = key_label(measure['key'])
for n in measure['voices'][0]['notes']:
n['pitches'] = [shift_pitch(p,*interval(fraction(n['onset']))) for p in n['pitches']]
for h in measure['harmonies']:
for field in ('root','bass'):
if field in h:
h[field] = shift_pitch(h[field],*interval(fraction(h['onset'])))
h['symbol'] = harmony.symbol(h)
for change in measure.get('attributes',[]):
if 'key' in change:
change['key'] = shift_key(change['key'],*interval(fraction(change['onset'])))
if 'transpose' in change:
change['transpose'] = copy.deepcopy(zero)
measure['transpose'] = copy.deepcopy(zero)
first = output['staves'][0]['measures'][0]
output['metadata']['key'] = copy.deepcopy(first['key'])
output['metadata']['key_signature'] = first['key_signature']
return output