+33
-21
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user