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

This commit is contained in:
khodowany
2026-09-10 22:01:18 -05:00
parent ea4e0bbba1
commit f84717990b
47 changed files with 16739 additions and 8391 deletions
+41 -152
View File
@@ -4,6 +4,8 @@ from itertools import product
from typing import NamedTuple
from generator.transposition import mod12
from generator import harmony
from generator.model import NATURAL
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
@@ -15,134 +17,8 @@ class TabPosition(NamedTuple):
midi: int
class ChordSymbol(NamedTuple):
root: str
quality: str
bass: str
ROOTS = {
"C": 0,
"C#": 1,
"Db": 1,
"D": 2,
"D#": 3,
"Eb": 3,
"E": 4,
"F": 5,
"F#": 6,
"Gb": 6,
"G": 7,
"G#": 8,
"Ab": 8,
"A": 9,
"A#": 10,
"Bb": 10,
"B": 11,
}
def parse_root(symbol: str) -> tuple[str, str] | None:
symbol = symbol.strip()
if not symbol or symbol[0].upper() not in "ABCDEFG":
return None
length = 1
if len(symbol) > 1 and symbol[1] in {"#", "b"}:
length = 2
return symbol[:length], symbol[length:]
def parse_chord_symbol(symbol: str) -> ChordSymbol | None:
clean = symbol.strip().replace(" ", "")
base, _, slash = clean.partition("/")
parsed = parse_root(base)
if parsed is None:
return None
root, quality = parsed
bass = ""
if slash:
bass_parsed = parse_root(slash)
if bass_parsed is not None:
bass = bass_parsed[0]
return ChordSymbol(root, quality, bass)
def unique_intervals(values: tuple[int, ...]) -> tuple[int, ...]:
result: list[int] = []
seen: set[int] = set()
for value in values:
interval = mod12(value)
if interval in seen:
continue
seen.add(interval)
result.append(interval)
return tuple(result)
def color_intervals(value: str) -> tuple[int, ...]:
colors: list[int] = []
if "alt" in value:
colors.append(1)
if "b9" in value:
colors.append(1)
if "#9" in value:
colors.append(3)
if "9" in value and "b9" not in value and "#9" not in value:
colors.append(2)
if "#11" in value:
colors.append(6)
elif "11" in value:
colors.append(5)
if "b13" in value:
colors.append(8)
elif "13" in value:
colors.append(9)
return tuple(colors)
def chord_intervals(quality: str) -> tuple[int, ...]:
value = quality.lower().replace(" ", "")
if "7b5" in value or "ø" in value or "half" in value:
return (0, 3, 6, 10)
if "dim" in value or "o" in value:
return (0, 3, 6, 9)
colors = color_intervals(value)
if "aug" in value or "+" in value:
return unique_intervals((0, 4, 8, 10) + colors) if ("7" in value or colors) else (0, 4, 8)
if "sus" in value:
return unique_intervals((0, 5, 10) + colors) if ("7" in value or colors) else (0, 5, 7)
minor = value.startswith("-") or (value.startswith("m") and not value.startswith(("maj", "ma")))
if minor:
if "6" in value and "7" not in value:
return unique_intervals((0, 3, 7, 9) + colors)
if "7" in value or colors:
return unique_intervals((0, 3, 10) + colors)
return (0, 3, 7)
if "maj" in value or value.startswith("ma") or "Δ" in value:
return unique_intervals((0, 4, 11) + colors)
if "6" in value and "7" not in value:
return unique_intervals((0, 4, 7, 9) + colors)
if "7" in value or colors:
return unique_intervals((0, 4, 10) + colors)
return (0, 4, 7)
def pitch_class_for_root(root: str) -> int | None:
return ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
def chord_pitch_classes(symbol: str) -> tuple[int, ...]:
parsed = parse_chord_symbol(symbol)
if parsed is None:
return ()
root_pc = pitch_class_for_root(parsed.root)
if root_pc is None:
return ()
pitch_classes = [mod12(root_pc + interval) for interval in chord_intervals(parsed.quality)]
bass_pc = pitch_class_for_root(parsed.bass) if parsed.bass else None
if bass_pc is not None and bass_pc not in pitch_classes:
pitch_classes.append(bass_pc)
return tuple(pitch_classes)
def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]:
return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol)
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
@@ -168,8 +44,23 @@ def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
def find_playable_voicing(symbol: str) -> list[TabPosition]:
pitch_classes = chord_pitch_classes(symbol)
def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
semantic_degrees = harmony.degrees(definition)
voicing = dict(semantic_degrees)
if 7 in voicing and voicing.get(5) == 0:
del voicing[5]
if len(voicing) > 4:
for degree in (11, 9, 5):
if len(voicing) > 4 and voicing.get(degree) == 0:
del voicing[degree]
root = definition.get('root', {'letter':'c','alter':0})
root_pc = NATURAL[root['letter']] + root['alter']
pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d,a)) % 12 for d,a in voicing.items()))
bass = definition.get('bass')
bass_pc = (NATURAL[bass['letter']] + bass['alter']) % 12 if bass else None
if bass_pc is not None and bass_pc not in pitch_classes:
pitch_classes += (bass_pc,)
if not pitch_classes:
return []
@@ -182,11 +73,13 @@ def find_playable_voicing(symbol: str) -> list[TabPosition]:
if any(not item for item in options):
continue
for candidate in product(*options):
if bass_pc is not None and min(candidate, key=lambda p:p.midi).midi % 12 != bass_pc:
continue
fretted = [position.fret for position in candidate if position.fret > 0]
if fretted and max(fretted) - min(fretted) > 4:
continue
covered = {mod12(position.midi) for position in candidate}
if len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
if not set(pitch_classes).issubset(covered):
continue
score = candidate_score(candidate, pitch_classes)
if score < best_score:
@@ -195,24 +88,20 @@ def find_playable_voicing(symbol: str) -> list[TabPosition]:
return sorted(best or [], key=lambda item: item.midi)
def pitch_from_midi(midi: int) -> dict[str, int | str]:
names = (
("c", "n"),
("c", "#"),
("d", "n"),
("e", "b"),
("e", "n"),
("f", "n"),
("f", "#"),
("g", "n"),
("a", "b"),
("a", "n"),
("b", "b"),
("b", "n"),
)
letter, accidental = names[mod12(midi)]
return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1}
def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]:
return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]
def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]:
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
from generator.transposition import shift_pitch
root = definition.get('root', {'letter':'c', 'alter':0})
spellings = {}
for degree, alter in harmony.degrees(definition).items():
p = shift_pitch(root, degree-1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[p['letter']]+p['alter']) % 12] = p
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']]+bass['alter']) % 12] = bass
result = []
for position in find_playable_voicing(definition):
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
p = spellings[midi % 12].copy()
p['octave'] = (midi - NATURAL[p['letter']] - p['alter']) // 12 - 1
result.append(p)
return result
+151 -250
View File
@@ -1,279 +1,180 @@
from __future__ import annotations
import copy
import warnings
from fractions import Fraction
from pathlib import Path
from generator.arrange.form import ArrangementSection, full_arrangement_sections
from generator.arrange.form import full_arrangement_sections
from generator.arrange.fretboard import pitches_for_voicing
from generator.score import (
QUARTER_TICKS,
apply_source_chord_markers,
active_chord_at_offset,
chord_markers,
chord_symbol_at_offset,
copy_source_voice,
first_chord_symbol,
first_voice,
leading_pickup_measure_index,
load_json,
make_arranged_document,
make_measure,
measure_capacity_ticks,
renderable_duration,
renderable_rest_notes,
rest_note,
sanitized_note,
slash_note,
slugify,
source_staff,
source_note_attack_at,
write_json,
)
PART_TYPES = ("acoustic", "electric", "vocal")
from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational
from generator.musicxml import load_musicxml
from generator.transposition import concert_document, shift_pitch
from generator.score import copy_source_voice, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json
PART_TYPES = ('acoustic','electric','vocal')
FINAL_SECTION_INDEX = 6
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[str]:
result: list[str] = []
current = ""
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]:
result = []
current = None
for measure in measures:
current = first_chord_symbol(measure, current, include_optional=include_optional)
result.append(current)
for h in measure.get('harmonies', []):
if include_optional or not h.get('chord_optional'):
current = h
return result
def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol: str, visible_chord_symbol: str = "") -> dict:
note = {
"id": note_id,
"kind": "note",
"duration_ticks": duration_ticks,
"pitches": pitches_for_voicing(voicing_chord_symbol),
"hidden": False,
}
if not note["pitches"]:
return rest_note(note_id, duration_ticks, visible_chord_symbol)
if visible_chord_symbol:
note["chord_symbol"] = visible_chord_symbol
return note
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
hit_offsets = (0, QUARTER_TICKS + QUARTER_TICKS // 2, QUARTER_TICKS * 2, QUARTER_TICKS * 2 + QUARTER_TICKS // 2, QUARTER_TICKS * 3 + QUARTER_TICKS // 2)
hit_duration = QUARTER_TICKS // 2
notes: list[dict] = []
cursor = 0
for offset in hit_offsets:
if offset >= capacity:
def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None:
active = previous
for h in measure['harmonies']:
if fraction(h['onset']) > onset:
break
if offset > cursor:
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
notes.extend(rests)
cursor = offset
active_chord = active_chord_at_offset(measure, offset, chord_symbol, include_optional=include_optional)
visible_chord = chord_symbol_at_offset(measure, offset, include_optional=include_optional)
sequence += 1
notes.append(playable_chord_note(f"arr-note-{sequence}", min(hit_duration, capacity - offset), active_chord, visible_chord))
cursor = offset + min(hit_duration, capacity - offset)
if cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
active = h
return active if active and active['kind'] != 'none' else None
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
lead_rest = min(QUARTER_TICKS // 2, capacity)
if lead_rest:
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0, include_optional=include_optional))
notes.extend(rests)
if lead_rest < capacity:
hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
visible_chord = chord_symbol_at_offset(measure, lead_rest, include_optional=include_optional)
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol, include_optional=include_optional)
sequence += 1
if source_note_attack_at(measure, lead_rest):
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord))
else:
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
cursor = lead_rest + hit
if cursor < capacity:
markers = [marker for marker in chord_markers(measure, include_optional=include_optional) if marker[0] >= cursor]
if markers:
segment_cursor = cursor
for index, (start, _, chord) in enumerate(markers):
if start > segment_cursor:
rests, sequence = renderable_rest_notes("arr-rest", start - segment_cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
segment_cursor = next_start
if segment_cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - segment_cursor, sequence)
notes.extend(rests)
else:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
def slash_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
cursor = 0
while cursor < capacity:
sequence += 1
duration = min(QUARTER_TICKS, capacity - cursor)
notes.append(slash_note(f"arr-slash-{sequence}", duration))
cursor += duration
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
def rest_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
markers = chord_markers(measure, include_optional=include_optional)
if not markers:
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence)
return notes, sequence
notes: list[dict] = []
cursor = 0
for index, (start, _, chord) in enumerate(markers):
if start > cursor:
if not renderable_duration(start - cursor):
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
if not renderable_duration(next_start - start):
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
cursor = next_start
if cursor < capacity:
if not renderable_duration(capacity - cursor):
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return notes, sequence
def source_rest_skeleton_notes(measure: dict, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
notes: list[dict] = []
for source_note in first_voice(measure).get("notes", []):
if not isinstance(source_note, dict):
continue
sequence += 1
chord = "" if not include_optional and source_note.get("chord_optional") is True else str(source_note.get("chord_symbol", ""))
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), chord)
if "display_ticks" in source_note:
note["display_ticks"] = source_note["display_ticks"]
if "tuplet_id" in source_note:
note["tuplet_id"] = source_note["tuplet_id"]
notes.append(note)
if not notes:
sequence += 1
notes.append(rest_note(f"arr-rest-{sequence}", measure_capacity_ticks(str(measure.get("time_signature", "4/4")))))
return notes, sequence
def melody_voice(measure: dict, *, strip_lyrics: bool, include_optional: bool = True) -> dict:
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics, include_optional=include_optional)
voice["id"] = "voice-arranged-source"
voice["notes"] = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in first_voice(measure).get("notes", []) if isinstance(note, dict)]
return voice
def apply_vocal_pickup_overlap(output: list[dict], source_measures: list[dict], section: ArrangementSection) -> None:
pickup = leading_pickup_measure_index(source_measures)
if pickup is None or section.source_measure_index != pickup + 1 or not output:
return
voice = copy_source_voice(source_measures[pickup], strip_lyrics=False)
for note in voice["notes"]:
if isinstance(note, dict):
note.pop("chord_symbol", None)
output[-1]["voices"][0]["id"] = "voice-arranged-vocal"
output[-1]["voices"][0]["tuplets"] = voice["tuplets"]
output[-1]["voices"][0]["notes"] = voice["notes"]
def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[dict, int]:
if section.role == "vocal":
return melody_voice(measure, strip_lyrics=False, include_optional=include_optional), sequence
if section.role == "melody":
return melody_voice(measure, strip_lyrics=True, include_optional=include_optional), sequence
if section.role == "rhythm":
notes, sequence = rhythm_notes(measure, chord_symbol, sequence, include_optional=include_optional)
elif section.role == "accompaniment":
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence, include_optional=include_optional)
elif section.role == "slash":
notes, sequence = slash_notes(measure, chord_symbol, sequence, include_optional=include_optional)
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
length = fraction(measure['duration'])
if role == 'rest':
return [make_event('rest', Fraction(), length, 'rest', written={'type':'whole','dots':0}) | {'measure_rest':True}]
if role == 'rhythm' and measure['time_signature']=='4/4' and not measure.get('attributes'):
attacks = [(Fraction(n,2), Fraction(1,2)) for n in (0,3,4,5,7)]
elif role == 'accompaniment' and measure['time_signature']=='4/4' and not measure.get('attributes'):
attacks = [(Fraction(1,2),Fraction(1,2))]
else:
notes, sequence = rest_notes(measure, chord_symbol, sequence, include_optional=include_optional)
return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]:
sections = full_arrangement_sections(source_measures, part_type)
all_chords = propagated_chords(source_measures)
required_chords = propagated_chords(source_measures, include_optional=False)
output: list[dict] = []
sequence = 0
for section in sections:
include_optional = section.section_index != FINAL_SECTION_INDEX
chords = all_chords if include_optional else required_chords
if part_type == "vocal" and section.role == "vocal":
apply_vocal_pickup_overlap(output, source_measures, section)
for offset in range(section.measure_count):
source_index = section.source_measure_index + offset
source_measure = source_measures[source_index]
chord_symbol = chords[source_index] if source_index < len(chords) else ""
voice, sequence = voice_for_section(section, source_measure, chord_symbol, sequence, include_optional=include_optional)
output_measure = make_measure(
f"measure-{len(output) + 1}",
source_measure,
voice.get("notes", []),
section_label=section.label if offset == 0 else "",
tuplets=voice.get("tuplets", []),
)
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
output.append(output_measure)
return output
attacks = []
states = [(Fraction(), measure['meter'])]
states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
for index, (onset, meter) in enumerate(states):
end = states[index+1][0] if index+1 < len(states) else length
beats = beat_lengths(meter)
beat_index = 0
while onset < end:
beat = min(beats[beat_index % len(beats)] if beats else end-onset, end-onset)
duration = beat if role == 'slash' or not beats else min(beat/2, Fraction(1,2))
attacks.append((onset, duration))
onset += beat
beat_index += 1
events = []
cursor = Fraction()
for onset, duration in attacks:
if onset >= length:
break
if onset > cursor:
events.append(make_event(f'rest-{len(events)}',cursor,onset-cursor,'rest'))
duration = min(duration,length-onset)
chord = active_chord(measure,onset,previous)
source_attack = any(fraction(n['onset'])==onset and n['kind']=='note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure))
if role=='slash':
event = make_event(f'slash-{len(events)}',onset,duration,'note')
event['notehead'] = 'slash'
elif not chord or role=='accompaniment' and source_attack:
event = make_event(f'rest-{len(events)}',onset,duration,'rest')
else:
pitches = pitches_for_voicing(chord)
event = make_event(f'chord-{len(events)}',onset,duration,'note',pitches)
if not pitches:
warnings.warn(f'{measure["id"]}: no playable voicing for {chord["symbol"]}; using labelled rhythmic slashes', stacklevel=2)
event['notehead'] = 'slash'
events.append(event)
cursor = onset + duration
if cursor < length:
events.append(make_event(f'rest-{len(events)}',cursor,length-cursor,'rest'))
return events
def arrange_document(document: dict, part_type: str) -> dict:
if part_type not in PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}')
document = concert_document(document)
staff = source_staff(document)
measures = staff.get("measures")
if not isinstance(measures, list) or not measures:
raise ValueError("performance arrangement requires at least one source measure")
output = make_arranged_document(
document,
staff,
staff_id="staff-arranged-vocal" if part_type == "vocal" else "staff-arranged-guitar",
staff_name=part_type.capitalize(),
role="vocal" if part_type == "vocal" else "guitar",
arrangement_part_type=part_type,
measures=arranged_measures([measure for measure in measures if isinstance(measure, dict)], part_type),
)
metadata = output.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata.setdefault("tempo", 124)
return output
source = staff['measures']
sections = full_arrangement_sections(source,part_type)
output, tuplets, octave_shifts = [], [], []
absolute = Fraction()
source_starts = []
source_time = Fraction()
for m in source:
source_starts.append(source_time)
source_time += fraction(m['duration'])
for section in sections:
previous = propagated_chords(source, include_optional=section.section_index!=FINAL_SECTION_INDEX)
segment = []
for si in range(section.source_measure_index,section.source_measure_index+section.measure_count):
m = copy.deepcopy(source[si])
m['harmonies'] = [h for h in m['harmonies'] if section.section_index!=FINAL_SECTION_INDEX or not h.get('chord_optional')]
for h in m['harmonies']:
h.pop('chord_optional',None)
notes = copy_source_voice(m,strip_lyrics=section.role=='melody')['notes'] if section.role in ('vocal','melody') else generated_voice(m,section.role,previous[si])
if section.role == 'melody':
for note in notes:
note['pitches'] = [shift_pitch(p,7,12) for p in note['pitches']]
result = make_measure(f'measure-{len(output)+len(segment)+1}',m,notes,section_label=section.label if not segment else '')
if part_type!='vocal':
result['clef'] = {'sign':'G','line':2,'octave_change':0}
result['transpose'] = {'diatonic':0,'chromatic':0,'octave_change':-1}
for change in result.get('attributes',[]):
change.pop('clef',None)
change.pop('transpose',None)
segment.append(result)
identity_segment = segment
segment_length = sum((fraction(m['duration']) for m in segment),Fraction())
if section.role in ('vocal','melody'):
start = source_starts[section.source_measure_index]
for shift in staff.get('octave_shifts',[]):
low, high = max(start,fraction(shift['start'])), min(start+segment_length,fraction(shift['end']))
if low < high:
octave_shifts.append({**shift,'id':f'section-{section.section_index}-{shift["id"]}','start':rational(absolute+low-start),'end':rational(absolute+high-start)})
if part_type=='vocal' and section.section_index in (1,5) and section.source_measure_index==1 and source[0].get('implicit') and output:
pickup = copy.deepcopy(source[0])
destination = output[-1]
pad = fraction(destination['duration']) - fraction(pickup['duration'])
if pad < 0:
raise ValueError('pickup is longer than the preceding arrangement measure')
notes = copy_source_voice(pickup,strip_lyrics=False)['notes']
for n in notes:
n['onset'] = rational(fraction(n['onset'])+pad)
if pad:
notes.insert(0,make_event('pickup-rest',Fraction(),pad,'rest'))
destination['voices'][0]['notes'] = notes
identity_segment = [destination, *segment]
if pickup['harmonies']:
destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad]
for h in pickup['harmonies']:
h['onset'] = rational(fraction(h['onset'])+pad)
destination['harmonies'].append(h)
state = {k:copy.deepcopy(pickup[k]) for k in ('key','clef','meter','transpose')}
if pad:
destination['attributes'].append({'onset':rational(pad), **state})
else:
destination.update(state)
destination['key_signature'] = key_label(state['key'])
destination['time_signature'] = meter_label(state['meter'])
for change in pickup.get('attributes',[]):
destination['attributes'].append({**change,'onset':rational(fraction(change['onset'])+pad)})
for shift in staff.get('octave_shifts',[]):
low, high = fraction(shift['start']), min(fraction(pickup['duration']),fraction(shift['end']))
if low < high:
offset = absolute-fraction(pickup['duration'])
octave_shifts.append({**shift,'id':f'pickup-{section.section_index}-{shift["id"]}','start':rational(offset+low),'end':rational(offset+high)})
tuplets.extend(reidentify(identity_segment,staff.get('tuplets',[])))
output.extend(segment)
absolute += segment_length
result = make_arranged_document(document,staff,staff_id='staff-arranged-'+part_type,staff_name=part_type.capitalize(),role='vocal' if part_type=='vocal' else 'guitar',arrangement_part_type=part_type,measures=output,tuplets=tuplets,octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo',124)
return result
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
if not source_dir.exists():
raise ValueError(f"performance source directory not found: {source_dir}")
raise ValueError(f'performance source directory not found: {source_dir}')
count = 0
for source_path in sorted(source_dir.glob("*.json")):
document = load_json(source_path)
for part_type in PART_TYPES:
output = arrange_document(document, part_type)
target = output_dir / f"{slugify(source_path.stem)}-{part_type}.json"
write_json(target, output)
for path in sorted(source_dir.glob('*.musicxml')):
document = load_musicxml(path)
for part in PART_TYPES:
write_json(output_dir/f'{slugify(path.stem)}-{part}.json',arrange_document(document,part))
count += 1
return count