@@ -1,91 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import product
|
||||
|
||||
from generator.transposition import mod12
|
||||
from generator import harmony
|
||||
from generator.model import NATURAL, alteration
|
||||
from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, within_fret_span
|
||||
|
||||
def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]:
|
||||
return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol)
|
||||
|
||||
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
|
||||
result: list[TabPosition] = []
|
||||
for fret in range(MIN_FRET, MAX_FRET + 1):
|
||||
midi = open_midi + fret
|
||||
if mod12(midi) in pitch_classes:
|
||||
result.append(TabPosition(string_number, fret, midi))
|
||||
return result
|
||||
|
||||
def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int:
|
||||
fretted = [position.fret for position in candidate if position.fret > 0]
|
||||
if not fretted:
|
||||
span = 0
|
||||
low = 0
|
||||
else:
|
||||
span = max(fretted) - min(fretted)
|
||||
low = min(fretted)
|
||||
covered = {mod12(position.midi) for position in candidate}
|
||||
missing = len(set(pitch_classes) - covered)
|
||||
open_penalty = sum(1 for position in candidate if position.fret == 0)
|
||||
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
|
||||
|
||||
def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
|
||||
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
|
||||
semantic_degrees = harmony.degrees(definition)
|
||||
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']] + alteration(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']] + alteration(bass['alter'])) % 12 if bass else None
|
||||
if bass_pc is not None and bass_pc not in pitch_classes:
|
||||
pitch_classes += (bass_pc,)
|
||||
if not pitch_classes:
|
||||
return []
|
||||
string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1))
|
||||
by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING}
|
||||
best: tuple[TabPosition, ...] | None = None
|
||||
best_score = 10**9
|
||||
for string_set in string_sets:
|
||||
options = [by_string[string_number] for string_number in string_set]
|
||||
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
|
||||
if not within_fret_span(candidate):
|
||||
continue
|
||||
covered = {mod12(position.midi) for position in candidate}
|
||||
if not set(pitch_classes).issubset(covered):
|
||||
continue
|
||||
score = candidate_score(candidate, pitch_classes)
|
||||
if score < best_score:
|
||||
best = candidate
|
||||
best_score = score
|
||||
return sorted(best or [], key=lambda item: item.midi)
|
||||
|
||||
def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]:
|
||||
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
|
||||
from generator.transposition import shift_pitch
|
||||
root = definition.get('root', {'letter': 'c', 'alter': 0})
|
||||
spellings = {}
|
||||
for degree, alter in harmony.degrees(definition).items():
|
||||
p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter))
|
||||
spellings[(NATURAL[p['letter']] + alteration(p['alter'])) % 12] = p
|
||||
if bass := definition.get('bass'):
|
||||
spellings[(NATURAL[bass['letter']] + alteration(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'] = int((midi - NATURAL[p['letter']] - alteration(p['alter'])) // 12 - 1)
|
||||
result.append(p)
|
||||
return result
|
||||
@@ -1,96 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import warnings
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
|
||||
from generator.arrange.form import full_arrangement_sections
|
||||
from generator.arrange.fretboard import pitches_for_voicing
|
||||
from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational
|
||||
from generator.guitar import place_guitar
|
||||
from generator.parts import PART_TYPES, build_measure, propagated_chords, validate_source
|
||||
from generator.model import 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_voices, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json
|
||||
from generator.pitch import concert_document
|
||||
from generator.score import copy_source_voices, make_arranged_document, 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[dict | None]:
|
||||
result = []
|
||||
current = None
|
||||
for measure in measures:
|
||||
result.append(current)
|
||||
for h in measure.get('harmonies', []):
|
||||
if include_optional or not h.get('chord_optional'):
|
||||
current = h
|
||||
return result
|
||||
|
||||
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
|
||||
active = h
|
||||
return active if active and active['kind'] != 'none' else None
|
||||
|
||||
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
|
||||
length = fraction(measure['duration'])
|
||||
if role == 'rest':
|
||||
return [make_event('rest', Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
|
||||
if role == 'rhythm' and measure['time_signature'] == '4/4' and not measure.get('attributes'):
|
||||
attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)]
|
||||
elif role == 'accompaniment' and measure['time_signature'] == '4/4' and not measure.get('attributes'):
|
||||
attacks = [(Fraction(1, 2), Fraction(1, 2))]
|
||||
else:
|
||||
attacks = []
|
||||
states = [(Fraction(), measure['meter'])]
|
||||
states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
|
||||
for index, (onset, meter) in enumerate(states):
|
||||
end = states[index + 1][0] if index + 1 < len(states) else length
|
||||
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}')
|
||||
for measure in source_staff(document)['measures']:
|
||||
specialized_staff = any(state.get('clef', {}).get('sign') in ('TAB', 'percussion') for state in [measure, *measure.get('attributes', [])])
|
||||
specialized_notes = any('unpitched' in pitch or 'tablature' in pitch for note in measure_notes(measure) for pitch in note['pitches'])
|
||||
if specialized_staff or specialized_notes:
|
||||
raise ValueError(f'{measure["id"]}: automatic performance arranging requires a pitched non-TAB lead; percussion/TAB conversion is not qualified')
|
||||
document = concert_document(document)
|
||||
validate_source(document)
|
||||
document = concert_document(document, octave_transposing=True)
|
||||
staff = source_staff(document)
|
||||
source = staff['measures']
|
||||
sections = full_arrangement_sections(source, part_type)
|
||||
@@ -109,20 +37,7 @@ def arrange_document(document: dict, part_type: str) -> dict:
|
||||
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)
|
||||
source_voices = copy_source_voices(m, strip_lyrics=section.role == 'melody') if section.role in ('vocal', 'melody') else None
|
||||
notes = [n for v in source_voices for n in v['notes']] if source_voices 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, [] if source_voices else notes, section_label=section.label if not segment else '')
|
||||
if source_voices:
|
||||
result['voices'] = source_voices
|
||||
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)
|
||||
result = build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '')
|
||||
segment.append(result)
|
||||
identity_segment = segment
|
||||
segment_length = sum((fraction(m['duration']) for m in segment), Fraction())
|
||||
@@ -133,7 +48,7 @@ def arrange_document(document: dict, part_type: str) -> dict:
|
||||
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])
|
||||
pickup = build_measure(source[0], source[0]['id'], 'vocal', part_type, None)
|
||||
destination = output[-1]
|
||||
pad = fraction(destination['duration']) - fraction(pickup['duration'])
|
||||
if pad < 0:
|
||||
@@ -170,6 +85,8 @@ def arrange_document(document: dict, part_type: str) -> dict:
|
||||
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)
|
||||
if part_type != 'vocal':
|
||||
place_guitar(result)
|
||||
return result
|
||||
|
||||
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
|
||||
@@ -179,6 +96,9 @@ def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
|
||||
for path in sorted(source_dir.glob('*.xml')):
|
||||
document = load_musicxml(path)
|
||||
for part in PART_TYPES:
|
||||
write_json(output_dir / f'{slugify(path.stem)}-{part}.json', arrange_document(document, part))
|
||||
try:
|
||||
write_json(output_dir / f'{slugify(path.stem)}-{part}.json', arrange_document(document, part))
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}, part {part}: {error}') from error
|
||||
count += 1
|
||||
return count
|
||||
|
||||
Reference in New Issue
Block a user