@@ -9,7 +9,7 @@ from time import monotonic
|
||||
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.summary import validate_expected_performance_summary, validate_practice_parts
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.engraving import engrave_tree
|
||||
from generator.playback import compile_tree
|
||||
@@ -52,19 +52,21 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
|
||||
report('validating MusicXML sources')
|
||||
for source in sorted(data_dir.rglob('*.xml')):
|
||||
if source.parent not in (data_dir / 'practice', data_dir / 'songs'):
|
||||
raise ValueError(f'{source}: MusicXML sources belong directly under data/practice or data/songs')
|
||||
load_musicxml(source)
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
|
||||
staged = Path(temporary) / 'assets'
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
report('generating performance parts and practice variants')
|
||||
report('generating performance and practice parts')
|
||||
performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs')
|
||||
practice_count = generate_practice_scores(data_dir, staged / 'practice')
|
||||
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')
|
||||
validate_practice_parts(staged / 'practice')
|
||||
report('compiling playback')
|
||||
compile_tree(staged)
|
||||
report('engraving pages')
|
||||
@@ -84,7 +86,7 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
raise
|
||||
report('publishing complete')
|
||||
print(f'Generated Product Vamp assets: {performance_count} performance scores, '
|
||||
f'{practice_count} practice score variants, {count_json_files(output_dir)} total JSON files.')
|
||||
f'{practice_count} practice parts, {count_json_files(output_dir)} total JSON files.')
|
||||
if args.check:
|
||||
print('Generator check passed.')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-15
@@ -7,13 +7,12 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
APPLICATION_ID = 0x56414D50
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 3
|
||||
COMMON_COLUMNS = """
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL,
|
||||
key_signature TEXT NOT NULL,
|
||||
authored_key_signature TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL UNIQUE CHECK(length(source_path) > 0),
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
measure_count INTEGER NOT NULL CHECK(measure_count > 0),
|
||||
@@ -34,19 +33,12 @@ def metadata(path: Path, root: Path) -> dict:
|
||||
for name in ('title', 'composer', 'key_signature', 'time_signature'):
|
||||
if not isinstance(meta[name], str):
|
||||
raise ValueError(f'{name} must be a string')
|
||||
authored = meta.get('authored_key_signature', '')
|
||||
if not isinstance(authored, str) or ('authored_key_signature' in meta and not authored):
|
||||
raise ValueError('authored_key_signature must be a nonempty string')
|
||||
key = meta['key_signature']
|
||||
if staff.get('arrangement_part_type') == 'practice' and key == 'No key' and 'variant_tonic' in meta:
|
||||
if not isinstance(meta['variant_tonic'], str):
|
||||
raise ValueError('variant_tonic must be a string')
|
||||
key = meta['variant_tonic'] + ' (no key)'
|
||||
if not isinstance(staff['name'], str):
|
||||
raise ValueError('staff name must be a string')
|
||||
relative = path.relative_to(root)
|
||||
return dict(id=path.stem, title=meta['title'], composer=meta['composer'], key_signature=key,
|
||||
authored_key_signature=authored, source_path=relative.as_posix(), source_json=text,
|
||||
source_path=relative.as_posix(), source_json=text,
|
||||
measure_count=len(staff['measures']), total_pages=len(presentation['pages']), part=staff['name'])
|
||||
|
||||
def populate(connection: sqlite3.Connection, root: Path) -> None:
|
||||
@@ -56,11 +48,11 @@ def populate(connection: sqlite3.Connection, root: Path) -> None:
|
||||
PRAGMA application_id = {APPLICATION_ID};
|
||||
PRAGMA user_version = {SCHEMA_VERSION};
|
||||
CREATE TABLE songs ({COMMON_COLUMNS}, part TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE practice_exercises ({COMMON_COLUMNS}, exercise_id TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE practice_exercises ({COMMON_COLUMNS}, exercise_id TEXT NOT NULL, part TEXT NOT NULL) STRICT;
|
||||
CREATE INDEX songs_menu ON songs(title COLLATE NOCASE, composer COLLATE NOCASE, part COLLATE NOCASE);
|
||||
CREATE INDEX practice_menu ON practice_exercises(exercise_id, id);
|
||||
''')
|
||||
authored_keys = {}
|
||||
practice_parts = {}
|
||||
for category, table, depth in (('songs', 'songs', 1), ('practice', 'practice_exercises', 2)):
|
||||
folder = root / category
|
||||
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
|
||||
@@ -73,13 +65,20 @@ def populate(connection: sqlite3.Connection, root: Path) -> None:
|
||||
row = metadata(path, root)
|
||||
if category == 'practice':
|
||||
identity = parts[0]
|
||||
if authored_keys.setdefault(identity, row['authored_key_signature']) != row['authored_key_signature']:
|
||||
raise ValueError(f'practice variants disagree on authored key: {identity}')
|
||||
row.pop('part')
|
||||
if path.stem not in ('acoustic', 'electric', 'vocal') or row['part'] != path.stem.capitalize():
|
||||
raise ValueError('practice requires an acoustic, electric, or vocal part')
|
||||
identity_metadata = (row['title'], row['composer'], row['key_signature'])
|
||||
group = practice_parts.setdefault(identity, (identity_metadata, set()))
|
||||
if group[0] != identity_metadata:
|
||||
raise ValueError(f'practice parts disagree on metadata: {identity}')
|
||||
group[1].add(path.stem)
|
||||
row.update(id=identity + '-' + path.stem, exercise_id=identity)
|
||||
connection.execute(f'INSERT INTO {table} ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values()))
|
||||
except (ValueError, KeyError, TypeError, sqlite3.Error) as error:
|
||||
raise ValueError(f'{path}: {error}') from error
|
||||
for identity, (_, parts) in practice_parts.items():
|
||||
if parts != {'acoustic', 'electric', 'vocal'}:
|
||||
raise ValueError(f'practice exercise requires all three parts: {identity}')
|
||||
connection.commit()
|
||||
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
|
||||
raise ValueError('catalogue integrity check failed')
|
||||
|
||||
@@ -15,7 +15,7 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
|
||||
lines = state['staff_details']['lines']
|
||||
if 'clef' in fields and clef['sign'] != 'none':
|
||||
sign = clef['sign']
|
||||
name = {'G': 'gClef', 'F': 'fClef', 'C': 'cClef', 'percussion': 'unpitchedPercussionClef1', 'TAB': '6stringTabClef' if lines != 4 else '4stringTabClef'}[sign]
|
||||
name = {'G': 'gClef', 'F': 'fClef', 'C': 'cClef', 'percussion': 'unpitchedPercussionClef1'}[sign]
|
||||
y = -space * (clef.get('line', 2) - 1) if sign in ('G', 'F', 'C') else -space * (lines - 1) / 2
|
||||
glyph = fonts.glyph(name, x, y, space, 'clef', references)
|
||||
result.append(glyph)
|
||||
@@ -23,7 +23,7 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font
|
||||
label = str(abs(octave) * 7 + 1)
|
||||
result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True))
|
||||
x = bounds(result).right + space
|
||||
if 'key' in fields and clef['sign'] not in ('percussion', 'TAB'):
|
||||
if 'key' in fields and clef['sign'] != 'percussion':
|
||||
key = state['key']
|
||||
steps = key.get('steps')
|
||||
if steps is None:
|
||||
|
||||
@@ -34,7 +34,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
expected = index.pitch_position(index.tones[anchor.source][1], anchor.time)
|
||||
if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01:
|
||||
raise EngravingError(f'{anchor.source}: displayed staff position changed pitch')
|
||||
role = 'tab' if anchor.fragment.kind == 'tone' and index.state(anchor.time)['clef']['sign'] == 'TAB' else 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind
|
||||
role = 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind
|
||||
candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01]
|
||||
if not candidates:
|
||||
raise EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
|
||||
@@ -43,6 +43,15 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
expected_glyph = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
|
||||
if candidates[0].value != expected_glyph:
|
||||
raise EngravingError(f'{anchor.source}: notehead changed written duration or shape')
|
||||
if index.staff.get('tablature') and anchor.fragment.kind == 'tone':
|
||||
tone = index.tones[anchor.source][1]
|
||||
tab = tone['tablature']
|
||||
tab_baseline = max(p.points[0][1] for p in system.primitives if p.role == 'tab-staff')
|
||||
expected_y = tab_baseline - (6 - tab['string']) * layout.style.space + 5
|
||||
expected_x = min(a.x for a in system.anchors if a.event == anchor.event and a.time == anchor.time)
|
||||
tabs = [p for p in system.primitives if p.role == 'tab' and anchor.source in p.sources]
|
||||
if len(tabs) != 1 or tabs[0].value != str(tab['fret']) or abs(tabs[0].points[0][1] - expected_y) > 0.01 or abs(tabs[0].points[0][0] - expected_x) > 0.01:
|
||||
raise EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment')
|
||||
for fragment in system.fragments:
|
||||
fragments.setdefault(fragment.source, []).append(fragment)
|
||||
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
||||
@@ -50,7 +59,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
|
||||
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric')]
|
||||
for i, item in enumerate(checked):
|
||||
for other in checked[i + 1:]:
|
||||
if set(item.sources) & set(other.sources):
|
||||
if set(item.sources) & set(other.sources) and (item.role != 'tab' or other.role != 'tab'):
|
||||
continue
|
||||
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
|
||||
continue
|
||||
|
||||
@@ -5,6 +5,7 @@ from fractions import Fraction
|
||||
import math
|
||||
|
||||
from generator import harmony
|
||||
from generator.engraving import tablature
|
||||
from generator.model import fraction, validate_document
|
||||
from generator.engraving.attributes import barline, staff_attributes, tempo
|
||||
from generator.engraving.fonts import Fonts
|
||||
@@ -36,6 +37,7 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo
|
||||
grace_width = max((p.box.right for p in drawn), default=grace_width) + 9
|
||||
occupied.extend(p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot'))
|
||||
items.extend(drawn)
|
||||
items.extend(tablature.column(index, event, placed, fonts, style))
|
||||
anchors.extend(placed)
|
||||
fragments.extend(pieces)
|
||||
if event.start == time and not event.value.get('hidden'):
|
||||
@@ -112,11 +114,12 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
width = available
|
||||
right = width - 5
|
||||
last_measure = next(m for a, b, m in index.measures if a < end <= b)
|
||||
tab_items = [p for p in items if p.role.startswith('tab')]
|
||||
items = [p for p in items if not p.role.startswith('tab')]
|
||||
items = draw_beams(index, anchors, items, start, end, content_left, right, style)
|
||||
items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
|
||||
items = place_text_lanes(items, fonts, style)
|
||||
items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
|
||||
# Staff lines are interrupted behind TAB numerals, not painted through them.
|
||||
changes = [(0, state['staff_details']['lines'])]
|
||||
for time, x in placements:
|
||||
lines = index.state(time)['staff_details']['lines']
|
||||
@@ -127,12 +130,7 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
x2 = changes[i + 1][0] if i + 1 < len(changes) else width - 1
|
||||
for number in range(lines):
|
||||
y = -number * style.space
|
||||
cuts = sorted((max(x1, p.box.left - 2), min(x2, p.box.right + 2)) for p in items if p.role == 'tab' and p.box.top <= y <= p.box.bottom and p.box.left < x2 and p.box.right > x1)
|
||||
cursor = x1
|
||||
for a, b in cuts + [(x2, x2)]:
|
||||
if a > cursor:
|
||||
staff_lines.append(line('staff', (), ((cursor, y), (a, y)), style.space * 0.13))
|
||||
cursor = max(cursor, b)
|
||||
staff_lines.append(line('staff', (), ((x1, y), (x2, y)), style.space * 0.13))
|
||||
items = staff_lines + items
|
||||
if end in index.boundaries:
|
||||
items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
|
||||
@@ -140,6 +138,7 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
|
||||
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
|
||||
else:
|
||||
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
|
||||
items = tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
|
||||
return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items))
|
||||
|
||||
def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) -> tuple[Page, ...]:
|
||||
|
||||
@@ -70,13 +70,6 @@ class ScoreIndex:
|
||||
def pitch_position(self, tone: dict, time: Fraction) -> float:
|
||||
state = self.state(time)
|
||||
clef = state['clef']
|
||||
if clef['sign'] == 'TAB':
|
||||
if 'tablature' not in tone:
|
||||
raise EngravingError(f'{tone["id"]}: TAB notes require explicit string and fret')
|
||||
string = tone['tablature']['string']
|
||||
if not 1 <= string <= state['staff_details']['lines']:
|
||||
raise EngravingError(f'{tone["id"]}: TAB string is outside the staff')
|
||||
return 2 * (state['staff_details']['lines'] - string)
|
||||
pitch = tone.get('unpitched', tone)
|
||||
sign = clef['sign']
|
||||
reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4)
|
||||
@@ -101,7 +94,7 @@ class ScoreIndex:
|
||||
if event.value.get('hidden') or tone.get('print', {}).get('print-object') == 'no' or hidden_head:
|
||||
result[tone['id']] = False
|
||||
continue
|
||||
if 'unpitched' in tone or state['clef']['sign'] == 'TAB':
|
||||
if 'unpitched' in tone:
|
||||
result[tone['id']] = False
|
||||
continue
|
||||
identity = (tone['letter'], tone['octave'])
|
||||
|
||||
@@ -95,9 +95,7 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
|
||||
fragments.append(fragment)
|
||||
if not displayed:
|
||||
continue
|
||||
if state['clef']['sign'] == 'TAB':
|
||||
head = fonts.text(str(tone['tablature']['fret']), x, y + 5, tone_size * 1.5, 'tab', sources)
|
||||
elif written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal':
|
||||
if written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal':
|
||||
width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4)
|
||||
head = line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
|
||||
else:
|
||||
@@ -105,15 +103,14 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction,
|
||||
group_items.append(head)
|
||||
if tone.get('notehead', {}).get('parentheses') == 'yes':
|
||||
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources)))
|
||||
if state['clef']['sign'] != 'TAB':
|
||||
ledger = -2
|
||||
while position <= ledger:
|
||||
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
|
||||
ledger -= 2
|
||||
ledger = lines * 2
|
||||
while position >= ledger:
|
||||
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
|
||||
ledger += 2
|
||||
ledger = -2
|
||||
while position <= ledger:
|
||||
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
|
||||
ledger -= 2
|
||||
ledger = lines * 2
|
||||
while position >= ledger:
|
||||
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
|
||||
ledger += 2
|
||||
if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start:
|
||||
accidental_items = accidental(fonts, alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
|
||||
box = bounds(accidental_items)
|
||||
|
||||
@@ -241,8 +241,12 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An
|
||||
extend = next((s['attributes'].get('type', 'start') for s in verse['syllables'] if s['kind'] == 'extend'), None)
|
||||
if syllabic not in ('begin', 'middle') and extend not in ('start', 'continue'):
|
||||
continue
|
||||
if 'extend_to' in verse and extend != 'start':
|
||||
continue
|
||||
target = following[0].start if following else max(e.end for e in index.events if e.voice == voice)
|
||||
if target < start or event.start >= end:
|
||||
if 'extend_to' in verse:
|
||||
target = fraction(verse['extend_to'])
|
||||
if target <= start or event.start >= end:
|
||||
continue
|
||||
identifier = event.value['id'] + ':lyric:' + number
|
||||
next_id = following[0].value['id'] + ':lyric:' + number if following else ''
|
||||
@@ -251,6 +255,9 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An
|
||||
x2 = b.box.left - 5 if b else right
|
||||
if b is None and following and following[0].value['id'] in positions:
|
||||
x2 = positions[following[0].value['id']].x
|
||||
if 'extend_to' in verse:
|
||||
endpoints = [a.x for a in anchors if a.time >= target]
|
||||
x2 = min(endpoints) - 5 if endpoints else right
|
||||
y = lyric_baseline + index.lyric_lanes.index((voice, number)) * 23
|
||||
if x2 <= x1:
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from generator.model import fraction
|
||||
from generator.engraving.notes import rest_glyph
|
||||
from generator.engraving.types import bounds, curve, line
|
||||
|
||||
def column(index, event, anchors, fonts, style):
|
||||
if not index.staff.get('tablature') or not anchors:
|
||||
return []
|
||||
x = min(a.x for a in anchors)
|
||||
sources = (event.value['id'],)
|
||||
if not event.value['pitches']:
|
||||
anchor = anchors[0]
|
||||
glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else rest_glyph(anchor.fragment.written_type)
|
||||
return [fonts.glyph(glyph, x, -2 * style.space, style.space, 'tab-' + anchor.fragment.kind, sources)]
|
||||
result = []
|
||||
for anchor in anchors:
|
||||
tone = index.tones[anchor.source][1]
|
||||
tab = tone['tablature']
|
||||
y = -(6 - tab['string']) * style.space
|
||||
result.append(fonts.text(str(tab['fret']), x, y + 5, style.space * 1.2, 'tab', (event.value['id'], tone['id'])))
|
||||
return result
|
||||
|
||||
def staff(index, items, start, end, left, right, fonts, style):
|
||||
notation = [p for p in items if not p.role.startswith('tab')]
|
||||
numbers = [p for p in items if p.role.startswith('tab')]
|
||||
if not index.staff.get('tablature'):
|
||||
return items
|
||||
offset = bounds(notation).bottom + 32 + 5 * style.space
|
||||
numbers = [p.moved(0, offset) for p in numbers]
|
||||
drawing = list(numbers)
|
||||
drawing.append(fonts.text('TAB', 0, offset - 2 * style.space, 15, 'tab-label', ()))
|
||||
for string in range(1, 7):
|
||||
y = offset - (6 - string) * style.space
|
||||
cuts = sorted((max(0, p.box.left - 2), min(right, p.box.right + 2)) for p in numbers if p.box.top <= y <= p.box.bottom)
|
||||
cursor = 0
|
||||
for a, b in cuts + [(right, right)]:
|
||||
if a > cursor:
|
||||
drawing.append(line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13))
|
||||
cursor = max(cursor, b)
|
||||
for bar in (p for p in notation if p.role == 'barline'):
|
||||
drawing.append(line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness))
|
||||
by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]}
|
||||
for identifier, (event, tone) in index.tones.items():
|
||||
target = tone.get('tie_to')
|
||||
finish = event.start + fraction(tone['duration'])
|
||||
if not target and not (event.start < start < finish or event.start < end < finish):
|
||||
continue
|
||||
if target:
|
||||
finish = index.tones[target][0].start
|
||||
if finish < start or event.start >= end:
|
||||
continue
|
||||
first, last = by_source.get(identifier), by_source.get(target)
|
||||
x1 = first.box.right + 2 if first else left
|
||||
x2 = last.box.left - 2 if last else right
|
||||
y = offset - (6 - tone['tablature']['string']) * style.space + 8
|
||||
if x2 > x1:
|
||||
drawing.append(curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
|
||||
return notation + drawing
|
||||
@@ -1,8 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from itertools import groupby
|
||||
from typing import NamedTuple
|
||||
|
||||
from generator import harmony, pitch
|
||||
from generator.model import NATURAL, alteration, fraction, make_event, pitch_number
|
||||
|
||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 0
|
||||
MAX_FRET = 20
|
||||
@@ -22,3 +28,260 @@ def positions_for_pitch(midi: Fraction) -> tuple[TabPosition, ...]:
|
||||
if midi.denominator != 1:
|
||||
return ()
|
||||
return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET)
|
||||
|
||||
def validate_tuning(state: dict, location: str) -> None:
|
||||
details = state['staff_details']
|
||||
if details.get('capo', 0):
|
||||
raise ValueError(f'{location}: guitar generation does not support capo configurations')
|
||||
tunings = details.get('tunings', [])
|
||||
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
|
||||
if tunings and (len(tunings) != 6 or {t['line']: pitch_number(t) for t in tunings} != expected):
|
||||
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
|
||||
|
||||
def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ...], int | None]:
|
||||
degrees = harmony.degrees(definition)
|
||||
root = definition['root']
|
||||
root_pc = NATURAL[root['letter']] + alteration(root['alter'])
|
||||
explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'}
|
||||
optional = {d for d, a in degrees.items() if not a and d not in explicit and (d == 5 and len(degrees) > 3 or d in (9, 11) and d < max(degrees))}
|
||||
classes = {d: (root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
|
||||
required = {value for d, value in classes.items() if d not in optional}
|
||||
allowed = set(classes.values())
|
||||
bass = definition.get('bass')
|
||||
bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None
|
||||
if bass_pc is None and 'inversion' in definition:
|
||||
ordered = sorted(degrees)
|
||||
inversion = definition['inversion']
|
||||
if not 0 <= inversion < len(ordered):
|
||||
raise ValueError('harmony inversion exceeds its chord degrees')
|
||||
bass_pc = classes[ordered[inversion]]
|
||||
if bass_pc is not None:
|
||||
allowed.add(bass_pc)
|
||||
required.add(bass_pc)
|
||||
return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> tuple[tuple[TabPosition, ...], ...]:
|
||||
result = []
|
||||
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed) for string, open_midi in STANDARD_TUNING]
|
||||
|
||||
def visit(index, selected, covered):
|
||||
if len(set(required) - covered) > len(options) - index:
|
||||
return
|
||||
if index == len(options):
|
||||
if selected and set(required) <= covered and (bass is None or min(selected, key=lambda p: p.midi).midi % 12 == bass):
|
||||
result.append(tuple(selected))
|
||||
return
|
||||
visit(index + 1, selected, covered)
|
||||
for position in options[index]:
|
||||
if position.midi % 12 not in covered and within_fret_span((*selected, position)):
|
||||
visit(index + 1, (*selected, position), covered | {position.midi % 12})
|
||||
|
||||
visit(0, (), set())
|
||||
if not prune:
|
||||
return tuple(sorted(result))
|
||||
# Independent attacks with equal centers have identical movement costs.
|
||||
# Held strings require the full candidate set instead of this reduction.
|
||||
best = {}
|
||||
for positions in result:
|
||||
center = position_center(positions)
|
||||
rank = (compactness(positions), len(set(allowed) - {p.midi % 12 for p in positions}), positions)
|
||||
if center not in best or rank < best[center][0]:
|
||||
best[center] = (rank, positions)
|
||||
return tuple(sorted(item[1] for item in best.values()))
|
||||
|
||||
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
|
||||
spellings = {}
|
||||
for degree, alter in harmony.degrees(definition).items():
|
||||
value = pitch.shift_pitch(definition['root'], degree - 1, harmony.degree_semitones(degree, alter))
|
||||
spellings[(NATURAL[value['letter']] + alteration(value['alter'])) % 12] = value
|
||||
if bass := definition.get('bass'):
|
||||
spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass
|
||||
result = []
|
||||
for position in sorted(positions, key=lambda p: p.midi):
|
||||
value = spellings[position.midi % 12].copy()
|
||||
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - NATURAL[value['letter']] - alteration(value['alter'])) // 12 - 1)
|
||||
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
@lru_cache(maxsize=16384)
|
||||
def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
|
||||
if not previous or not current:
|
||||
return 0
|
||||
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
|
||||
frets = [p.fret for p in positions if p.fret] or [0]
|
||||
strings = [p.string_number for p in positions]
|
||||
return min(frets) + max(frets), min(strings) + max(strings) - 2
|
||||
|
||||
def compactness(positions) -> int:
|
||||
frets = [p.fret for p in positions if p.fret]
|
||||
return max(frets) - min(frets) if frets else 0
|
||||
|
||||
def extend_positions(held, pending):
|
||||
if not pending:
|
||||
yield tuple(sorted(held.items()))
|
||||
return
|
||||
identifier, options = pending[0]
|
||||
occupied = {p.string_number for p in held.values()}
|
||||
for position in options:
|
||||
if position.string_number not in occupied and within_fret_span((*held.values(), position)):
|
||||
yield from extend_positions({**held, identifier: position}, pending[1:])
|
||||
|
||||
@dataclass
|
||||
class Placement:
|
||||
cost: tuple[int, int, int]
|
||||
active: tuple
|
||||
previous: Placement | None
|
||||
choices: tuple
|
||||
|
||||
def nearest_positions(states: dict) -> list[list[tuple | None]]:
|
||||
"""Exact Manhattan distance transform on the small fret/string position grid."""
|
||||
grid = [[None for _ in range(11)] for _ in range(2 * MAX_FRET + 1)]
|
||||
for index, previous in enumerate(states.values()):
|
||||
if not previous.active:
|
||||
return [[(previous.cost, index, previous) for _ in row] for row in grid]
|
||||
x, y = position_center(tuple(p for _, p in previous.active))
|
||||
candidate = (previous.cost, index, previous)
|
||||
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
|
||||
grid[x][y] = candidate
|
||||
|
||||
def relax(x, y, a, b):
|
||||
source = grid[a][b]
|
||||
if source is not None:
|
||||
cost, index, previous = source
|
||||
candidate = ((cost[0] + 1, cost[1], cost[2]), index, previous)
|
||||
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
|
||||
grid[x][y] = candidate
|
||||
|
||||
for y in range(11):
|
||||
for x in range(1, len(grid)):
|
||||
relax(x, y, x - 1, y)
|
||||
for x in range(len(grid) - 2, -1, -1):
|
||||
relax(x, y, x + 1, y)
|
||||
for x in range(len(grid)):
|
||||
for y in range(1, 11):
|
||||
relax(x, y, x, y - 1)
|
||||
for y in range(9, -1, -1):
|
||||
relax(x, y, x, y + 1)
|
||||
return grid
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuitarTone:
|
||||
pitch: dict
|
||||
end: Fraction
|
||||
positions: tuple[TabPosition, ...]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttackCandidate:
|
||||
active: tuple
|
||||
choices: tuple
|
||||
shape: tuple[TabPosition, ...]
|
||||
missing: int
|
||||
|
||||
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool):
|
||||
pending = []
|
||||
chords = [n for n in notes if '_harmony' in n]
|
||||
if len(chords) > 1:
|
||||
raise ValueError('generated accompaniment requires one harmonic event per onset')
|
||||
chord = chords[0] if chords else None
|
||||
for note in notes:
|
||||
for tone in note['pitches']:
|
||||
options = tones[tone['id']].positions
|
||||
if source := tone.get('tie_from'):
|
||||
options = tuple(p for p in options if p == active.get(source))
|
||||
pending.append((tone['id'], options))
|
||||
requirements = chord_requirements(chord['_harmony']) if chord else ((), (), None)
|
||||
candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending)) if chord else [()]
|
||||
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
|
||||
for positions in candidates:
|
||||
shape = tuple(p for _, p in exact) + positions
|
||||
if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
|
||||
continue
|
||||
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
|
||||
exact_positions = dict(exact)
|
||||
choices = tuple((n, positions if n is chord else tuple((p, exact_positions[p['id']]) for p in n['pitches'])) for n in notes)
|
||||
yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
|
||||
|
||||
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
events, tones = [], {}
|
||||
absolute = Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
changes = sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset']))
|
||||
state = {k: measure[k] for k in ('transpose', 'staff_details')}
|
||||
for change in [state, *changes]:
|
||||
state.update({k: change[k] for k in state if k in change})
|
||||
validate_tuning(state, measure['id'])
|
||||
for voice in measure['voices']:
|
||||
for order, note in enumerate(voice['notes']):
|
||||
onset = fraction(note['onset'])
|
||||
state = {k: measure[k] for k in ('transpose', 'staff_details')}
|
||||
for change in changes:
|
||||
if fraction(change['onset']) <= onset:
|
||||
state.update({k: change[k] for k in state if k in change})
|
||||
for tone in note['pitches']:
|
||||
sounding = pitch.sounding_pitch(tone, state)
|
||||
options = positions_for_pitch(sounding)
|
||||
if not options:
|
||||
raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {MIN_FRET}-{MAX_FRET}')
|
||||
tones[tone['id']] = GuitarTone(tone, absolute + onset + fraction(tone['duration']), options)
|
||||
if '_harmony' in note:
|
||||
for string, _ in STANDARD_TUNING:
|
||||
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + fraction(note['duration']), ())
|
||||
if note['pitches'] or '_harmony' in note:
|
||||
events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note))
|
||||
absolute += fraction(measure['duration'])
|
||||
return sorted(events, key=lambda e: e[:3]), tones
|
||||
|
||||
def retain_tone(tone: GuitarTone, onset: Fraction) -> bool:
|
||||
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
|
||||
|
||||
def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict:
|
||||
continued = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
|
||||
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
|
||||
result = {}
|
||||
|
||||
def keep(candidate, previous, cost):
|
||||
cost = (cost[0], cost[1] + compactness(candidate.shape), cost[2] + candidate.missing)
|
||||
if candidate.active not in result or cost < result[candidate.active].cost:
|
||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
||||
|
||||
if not held_ids and not continued:
|
||||
nearest = nearest_positions(states)
|
||||
for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings):
|
||||
x, y = position_center(candidate.shape)
|
||||
cost, _, previous = nearest[x][y]
|
||||
keep(candidate, previous, cost)
|
||||
else:
|
||||
for previous in states.values():
|
||||
active = dict(previous.active)
|
||||
held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
|
||||
for candidate in attack_candidates(notes, tones, active, held, preserve_strings):
|
||||
distance = movement(tuple(p for _, p in previous.active), candidate.shape)
|
||||
keep(candidate, previous, (previous.cost[0] + distance, previous.cost[1], previous.cost[2]))
|
||||
if not result:
|
||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {MIN_FRET}-{MAX_FRET} and four-fret span; held/tied strings must remain fixed')
|
||||
return result
|
||||
|
||||
def place_guitar(document: dict) -> None:
|
||||
"""Choose a minimum-cost path, retaining held strings in each search state."""
|
||||
events, tones = guitar_events(document)
|
||||
groups = [(key[0], [item[3] for item in values]) for key, values in groupby(events, key=lambda e: e[:3])]
|
||||
states = {(): Placement((0, 0, 0), (), None, ())}
|
||||
for index, (onset, notes) in enumerate(groups):
|
||||
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
|
||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + fraction(n['duration']) > next_onset for n in notes)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings)
|
||||
selected = min(states.values(), key=lambda s: (s.cost, s.active))
|
||||
while selected.previous is not None:
|
||||
for note, choices in selected.choices:
|
||||
if definition := note.pop('_harmony', None):
|
||||
note['pitches'] = make_event(note['id'], fraction(note['onset']), fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches']
|
||||
else:
|
||||
for tone, position in choices:
|
||||
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
selected = selected.previous
|
||||
document['staves'][0]['tablature'] = True
|
||||
|
||||
+20
-1
@@ -202,6 +202,19 @@ def validate_document(document: dict) -> None:
|
||||
raise ValueError('invalid unpitched display position')
|
||||
else:
|
||||
pitch_number(p)
|
||||
if staves[0].get('tablature'):
|
||||
from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING
|
||||
from generator.pitch import sounding_pitch
|
||||
tab = p.get('tablature', {})
|
||||
string, fret = tab.get('string'), tab.get('fret')
|
||||
if type(string) is not int or type(fret) is not int or string not in dict(STANDARD_TUNING) or not MIN_FRET <= fret <= MAX_FRET:
|
||||
raise ValueError(f'{p["id"]}: invalid generated tablature position')
|
||||
state = dict(measure)
|
||||
for change in measure.get('attributes', []):
|
||||
if fraction(change['onset']) <= onset:
|
||||
state.update(change)
|
||||
if dict(STANDARD_TUNING)[string] + fret != sounding_pitch(p, state):
|
||||
raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch')
|
||||
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')
|
||||
@@ -222,7 +235,7 @@ def validate_document(document: dict) -> None:
|
||||
raise ValueError('key alterations must contain seven exact alterations')
|
||||
for value in state['key']['alterations']:
|
||||
alteration(value)
|
||||
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'TAB', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3):
|
||||
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3):
|
||||
raise ValueError('invalid clef')
|
||||
absolute += length
|
||||
for identifier, (p, start, duration, voice) in tones.items():
|
||||
@@ -233,6 +246,8 @@ def validate_document(document: dict) -> None:
|
||||
same_pitch = all(p.get(k) == other.get(k) for k in ('unpitched', 'instrument')) if 'unpitched' in p else all(p.get(k) == other.get(k) for k in ('letter', 'alter', 'octave'))
|
||||
if other.get('tie_from') != identifier or not same_pitch or next_voice != voice or next_start != start + duration:
|
||||
raise ValueError(f'invalid tie: {identifier} -> {target}')
|
||||
if staves[0].get('tablature') and p['tablature'] != other['tablature']:
|
||||
raise ValueError(f'{identifier}: tied guitar tones must retain their string and fret')
|
||||
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}')
|
||||
@@ -250,6 +265,10 @@ def validate_document(document: dict) -> None:
|
||||
raise ValueError('invalid tuplet appearance')
|
||||
span_ids.add(span['id'])
|
||||
for note in events.values():
|
||||
for verse in note.get('lyrics', []):
|
||||
start = tones[note['pitches'][0]['id']][1] if note['pitches'] else Fraction()
|
||||
if 'extend_to' in verse and not start < fraction(verse['extend_to']) <= absolute:
|
||||
raise ValueError('lyric extension endpoint is outside the score')
|
||||
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'):
|
||||
|
||||
@@ -141,6 +141,8 @@ class Importer:
|
||||
self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'})
|
||||
self.children(item, {'sign', 'line', 'clef-octave-change'})
|
||||
sign = item.findtext('sign')
|
||||
if sign == 'TAB':
|
||||
self.fail('authored TAB is unsupported; guitar tablature is generated from pitches')
|
||||
if sign not in ('G', 'F', 'C', 'percussion', 'TAB', 'none'):
|
||||
self.fail(f'unsupported clef {sign!r}')
|
||||
line = self.number(item.findtext('line', {'G': '2', 'F': '4'}.get(sign, '3')), integer=True)
|
||||
@@ -434,10 +436,7 @@ class Importer:
|
||||
self.children(notations, {'tied', 'tuplet', 'ornaments', 'technical'})
|
||||
for item in notations:
|
||||
if item.tag == 'technical':
|
||||
self.children(item, {'string', 'fret'})
|
||||
if note['kind'] != 'note' or item.find('string') is None or item.find('fret') is None:
|
||||
self.fail('tablature requires a string and fret on a note')
|
||||
note['pitches'][-1]['tablature'] = {'string': self.number(item.findtext('string'), integer=True, positive=True), 'fret': self.number(item.findtext('fret'), integer=True)}
|
||||
self.fail('authored TAB and finger instructions are unsupported; guitar positions are generated')
|
||||
if item.tag == 'tuplet' and not is_chord:
|
||||
number, action = (voice, item.get('number', '1')), item.get('type')
|
||||
self.attrs(item, {'number', 'type', 'bracket', 'show-number', 'show-type', 'line-shape'})
|
||||
@@ -524,13 +523,10 @@ class Importer:
|
||||
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:
|
||||
if name != 'vamp:style' 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]):
|
||||
|
||||
@@ -190,10 +190,6 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
|
||||
element(group, 'tuplet-dot')
|
||||
if tremolo := event.get('tremolo'):
|
||||
element(element(notations, 'ornaments'), 'tremolo', tremolo['marks'], type=tremolo['type'])
|
||||
if pitch and (tab := pitch.get('tablature')):
|
||||
technical = element(notations, 'technical')
|
||||
element(technical, 'string', tab['string'])
|
||||
element(technical, 'fret', tab['fret'])
|
||||
if not len(notations):
|
||||
node.remove(notations)
|
||||
if index == 0:
|
||||
@@ -217,9 +213,6 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree:
|
||||
element(identification, 'creator', metadata.get('composer', ''), type='composer')
|
||||
miscellaneous = element(identification, 'miscellaneous')
|
||||
element(miscellaneous, 'miscellaneous-field', metadata.get('style', 'Medium Swing'), name='vamp:style')
|
||||
if metadata.get('transpose_origin'):
|
||||
from generator.model import pitch_name
|
||||
element(miscellaneous, 'miscellaneous-field', pitch_name(metadata['transpose_origin']), name='vamp:tonic')
|
||||
defaults = element(root, 'defaults')
|
||||
element(defaults, 'music-font', font_family='Petaluma')
|
||||
element(defaults, 'word-font', font_family='Petaluma Script')
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
from generator.guitar import validate_tuning
|
||||
from generator.model import beat_lengths, fraction, make_event
|
||||
from generator.pitch import shift_pitch
|
||||
from generator.score import copy_source_voices, make_measure, measure_notes, source_staff
|
||||
|
||||
PART_TYPES = ('acoustic', 'electric', 'vocal')
|
||||
|
||||
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}]
|
||||
common_time = measure['time_signature'] == '4/4' and not any('meter' in a for a in measure.get('attributes', []))
|
||||
if role == 'rhythm' and common_time:
|
||||
attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)]
|
||||
elif role == 'accompaniment' and common_time:
|
||||
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:
|
||||
event = make_event(f'chord-{len(events)}', onset, duration, 'note')
|
||||
event['_harmony'] = chord
|
||||
events.append(event)
|
||||
cursor = onset + duration
|
||||
if cursor < length:
|
||||
events.append(make_event(f'rest-{len(events)}', cursor, length - cursor, 'rest'))
|
||||
return events
|
||||
|
||||
def validate_source(document: dict, *, practice: bool = False) -> None:
|
||||
voices = set()
|
||||
for measure in source_staff(document)['measures']:
|
||||
for state in [measure, *measure.get('attributes', [])]:
|
||||
if state.get('clef', {}).get('sign') in ('TAB', 'percussion'):
|
||||
raise ValueError(f'{measure["id"]}: parts require a pitched, non-TAB source')
|
||||
if 'staff_details' in state:
|
||||
validate_tuning(state, measure['id'])
|
||||
for voice in measure['voices']:
|
||||
voices.add(voice['id'])
|
||||
for note in voice['notes']:
|
||||
if practice and len(note['pitches']) > 1:
|
||||
raise ValueError(f'{measure["id"]}: practice requires a monophonic melody, not chord stacks')
|
||||
if any('tablature' in p or 'unpitched' in p for p in note['pitches']):
|
||||
raise ValueError(f'{measure["id"]}: authored TAB and percussion are unsupported')
|
||||
if practice and len(voices) > 1:
|
||||
raise ValueError('practice requires one monophonic voice')
|
||||
|
||||
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '') -> dict:
|
||||
voices = copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
|
||||
notes = [n for v in voices for n in v['notes']] if voices else generated_voice(source, role, previous)
|
||||
if role in ('melody', 'vocal'):
|
||||
for note in notes:
|
||||
note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']]
|
||||
result = make_measure(identifier, source, [] if voices else notes, section_label=label)
|
||||
if voices:
|
||||
result['voices'] = voices
|
||||
result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1, 'explicit': True}
|
||||
result['clef']['octave_change'] = 0
|
||||
for change in result.get('attributes', []):
|
||||
change.pop('transpose', None)
|
||||
if 'clef' in change:
|
||||
change['clef']['octave_change'] = 0
|
||||
if part != 'vocal':
|
||||
result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
|
||||
result['staff_details'] = {'lines': 5, 'tunings': [], 'capo': 0}
|
||||
for change in result.get('attributes', []):
|
||||
change.pop('clef', None)
|
||||
change.pop('staff_details', None)
|
||||
return result
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from generator import harmony
|
||||
from generator.model import LETTERS, NATURAL, alteration, fraction, key_from_fifths, key_label, pitch_number, rational
|
||||
|
||||
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
|
||||
output = copy.deepcopy(pitch)
|
||||
if 'unpitched' in pitch:
|
||||
return output
|
||||
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'] = rational(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:
|
||||
from generator.model import accidental_name
|
||||
output['accidental_display']['name'] = accidental_name(alteration(output['alter']))
|
||||
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 'steps' in key:
|
||||
result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']]
|
||||
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
|
||||
if 'cancel' in key:
|
||||
# A cancellation references the old signature, not a count of naturals.
|
||||
cancelled = shift_key(key_from_fifths(key['cancel']), steps, semitones)
|
||||
if cancelled.get('custom'):
|
||||
raise ValueError('transposed key cancellation cannot be represented by fifths')
|
||||
result['cancel'] = cancelled['fifths']
|
||||
return result
|
||||
|
||||
def concert_document(document: dict, *, octave_transposing: bool = False) -> dict:
|
||||
"""Resolve source instrument pitch once, before arranging in concert pitch."""
|
||||
output = copy.deepcopy(document)
|
||||
zero = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0, 'explicit': True}
|
||||
for staff in output['staves']:
|
||||
for measure in staff['measures']:
|
||||
initial = {key: measure[key] for key in ('transpose', 'clef')}
|
||||
changes = copy.deepcopy(measure.get('attributes', []))
|
||||
|
||||
def interval(onset):
|
||||
state = dict(initial)
|
||||
for change in changes:
|
||||
if fraction(change['onset']) <= onset:
|
||||
state.update({key: change[key] for key in initial if key in change})
|
||||
transpose, clef = state['transpose'], state['clef']
|
||||
steps = transpose['diatonic'] + 7 * transpose['octave_change']
|
||||
semitones = transpose['chromatic'] + 12 * transpose['octave_change']
|
||||
if octave_transposing and not transpose.get('explicit') and not (steps or semitones) and not clef['octave_change']:
|
||||
return -7, -12
|
||||
return steps, semitones
|
||||
|
||||
measure['key'] = shift_key(measure['key'], *interval(0))
|
||||
measure['key_signature'] = key_label(measure['key'])
|
||||
for n in (n for v in measure['voices'] for n in v['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
|
||||
|
||||
def sounding_pitch(pitch: dict, state: dict):
|
||||
if 'unpitched' in pitch:
|
||||
raise ValueError('unpitched notes have no fretted or generated-tone interpretation')
|
||||
transpose = state['transpose']
|
||||
return pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
|
||||
+9
-14
@@ -9,7 +9,8 @@ from pathlib import Path
|
||||
import sys
|
||||
import wave
|
||||
|
||||
from generator.model import TYPE_LENGTHS, fraction, pitch_number, rational
|
||||
from generator.model import TYPE_LENGTHS, fraction, rational
|
||||
from generator import pitch
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
PROFILE = 'soft-struck-1'
|
||||
@@ -18,21 +19,16 @@ MAX_EVENTS = 1000000
|
||||
class PlaybackUnavailable(ValueError):
|
||||
pass
|
||||
|
||||
def sounding_pitch(pitch: dict, state: dict, guitar: bool) -> Fraction:
|
||||
if 'unpitched' in pitch:
|
||||
def sounding_pitch(tone: dict, state: dict) -> Fraction:
|
||||
if 'unpitched' in tone:
|
||||
raise PlaybackUnavailable('unpitched notation has no generated-tone interpretation')
|
||||
if guitar:
|
||||
import generator.practice.guitar
|
||||
value = generator.practice.guitar.sounding_pitch(pitch, state)
|
||||
else:
|
||||
transpose = state['transpose']
|
||||
value = pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
|
||||
value = pitch.sounding_pitch(tone, state)
|
||||
frequency = 440 * 2 ** (float(value - 69) / 12)
|
||||
if not 20 <= frequency <= 18000:
|
||||
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated-tone range (20-18000 Hz)')
|
||||
return value
|
||||
|
||||
def compile_timeline(document: dict, *, guitar: bool = False) -> dict:
|
||||
def compile_timeline(document: dict) -> dict:
|
||||
events, notes, linked = [], {}, {}
|
||||
absolute = Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
@@ -108,7 +104,7 @@ def compile_timeline(document: dict, *, guitar: bool = False) -> dict:
|
||||
length += sum((amount for time, amount in insertions.items() if onset < time < onset + fraction(pitch['duration'])), Fraction())
|
||||
if length <= 0:
|
||||
raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone')
|
||||
value = sounding_pitch(pitch, state, guitar)
|
||||
value = sounding_pitch(pitch, state)
|
||||
if pitch.get('tie_from'):
|
||||
previous = linked.get(pitch['tie_from'])
|
||||
if previous is None or fraction(previous['onset']) + fraction(previous['duration']) != performed or fraction(previous['pitch']) != value:
|
||||
@@ -132,7 +128,7 @@ def compile_timeline(document: dict, *, guitar: bool = False) -> dict:
|
||||
amount = value
|
||||
if tremolo['type'] == 'start' and attack % 2:
|
||||
other = partner[0]['pitches'][note['pitches'].index(pitch)]
|
||||
amount = sounding_pitch(other, partner[2], guitar)
|
||||
amount = sounding_pitch(other, partner[2])
|
||||
events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': rational(amount), 'onset': rational(performed + cursor), 'duration': rational(min(step, length - cursor))})
|
||||
cursor += step
|
||||
attack += 1
|
||||
@@ -181,8 +177,7 @@ def compile_tree(root: Path) -> None:
|
||||
for path in sorted(root.rglob('*.json')):
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
try:
|
||||
guitar = document['staves'][0].get('arrangement_part_type') in ('practice', 'acoustic', 'electric')
|
||||
playback = compile_timeline(document, guitar=guitar)
|
||||
playback = compile_timeline(document)
|
||||
used = {}
|
||||
for event in playback['events']:
|
||||
event['end'] = rational(fraction(event['onset']) + fraction(event.pop('duration')))
|
||||
|
||||
@@ -2,68 +2,45 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from generator.score import copy_source_voices, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
|
||||
from generator.guitar import place_guitar
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.practice.guitar import transpose_guitar_practice
|
||||
from generator.transposition import TARGET_KEYS
|
||||
from generator.parts import PART_TYPES, build_measure, propagated_chords, validate_source
|
||||
from generator.pitch import concert_document
|
||||
from generator.scat import add_scat
|
||||
from generator.score import make_arranged_document, reidentify, source_staff, write_json
|
||||
|
||||
def practice_document(document: dict) -> dict:
|
||||
def practice_document(document: dict, part_type: str) -> dict:
|
||||
if part_type not in PART_TYPES:
|
||||
raise ValueError(f'unknown practice part: {part_type}')
|
||||
validate_source(document, practice=True)
|
||||
document = concert_document(document, octave_transposing=True)
|
||||
staff = source_staff(document)
|
||||
source_measures = staff.get("measures")
|
||||
if not isinstance(source_measures, list):
|
||||
raise ValueError("practice source must contain measures")
|
||||
measures: list[dict] = []
|
||||
for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)):
|
||||
voices = copy_source_voices(measure, strip_lyrics=True)
|
||||
output_measure = make_measure(
|
||||
f"measure-{index + 1}",
|
||||
measure,
|
||||
[],
|
||||
section_label=str(measure.get("section_label", "")),
|
||||
)
|
||||
output_measure['voices'] = voices
|
||||
for chord in output_measure['harmonies']:
|
||||
previous = propagated_chords(staff['measures'])
|
||||
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
|
||||
measures = [build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', '')) for i, m in enumerate(staff['measures'])]
|
||||
for measure in measures:
|
||||
for chord in measure['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
measures.append(output_measure)
|
||||
tuplets = reidentify(measures, staff.get('tuplets', []))
|
||||
result = make_arranged_document(
|
||||
document,
|
||||
staff,
|
||||
staff_id="staff-arranged-practice",
|
||||
staff_name="Practice",
|
||||
role="guitar",
|
||||
arrangement_part_type="practice",
|
||||
measures=measures,
|
||||
tuplets=tuplets,
|
||||
octave_shifts=staff.get('octave_shifts', []),
|
||||
)
|
||||
result['metadata'].setdefault('authored_key_signature', document['metadata']['key_signature'])
|
||||
result = make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else [])
|
||||
if part_type == 'vocal':
|
||||
add_scat(result)
|
||||
else:
|
||||
place_guitar(result)
|
||||
return result
|
||||
|
||||
def key_file_name(key: str) -> str:
|
||||
return slugify(key.replace("#", "sharp")) + ".json"
|
||||
|
||||
def generate_key_variants(source_path: Path, output_dir: Path) -> int:
|
||||
base_document = practice_document(load_musicxml(source_path))
|
||||
count = 0
|
||||
for key in TARGET_KEYS:
|
||||
try:
|
||||
variant = transpose_guitar_practice(base_document, key)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{source_path}, target key {key}: {error}') from error
|
||||
write_json(output_dir / source_path.stem / key_file_name(key), variant)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
|
||||
if not source_dir.is_dir():
|
||||
raise ValueError(f'practice source directory not found: {source_dir}')
|
||||
sources = sorted(source_dir.glob('*.xml'))
|
||||
for path in source_dir.rglob('*.xml'):
|
||||
parts = path.relative_to(source_dir).parts
|
||||
if len(parts) != 1 and (len(parts) != 2 or parts[0] != 'songs'):
|
||||
raise ValueError(f'{path}: practice sources belong directly under the data directory')
|
||||
sources = sorted(source_dir.rglob('*.xml'))
|
||||
for path in sources:
|
||||
if len(path.stem) != 3 or not path.stem.isascii() or not path.stem.isdecimal() or int(path.stem) == 0:
|
||||
raise ValueError(f'{path}: practice sources require a three-digit positive filename such as 001.xml')
|
||||
return sum(generate_key_variants(path, output_dir) for path in sources)
|
||||
if path.parent != source_dir or len(path.stem) != 3 or not path.stem.isascii() or not path.stem.isdecimal():
|
||||
raise ValueError(f'{path}: practice sources require a three-digit filename directly under data/practice')
|
||||
for path in sources:
|
||||
document = load_musicxml(path)
|
||||
for part in PART_TYPES:
|
||||
try:
|
||||
write_json(output_dir / path.stem / (part + '.json'), practice_document(document, part))
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}, part {part}: {error}') from error
|
||||
return len(sources) * len(PART_TYPES)
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction
|
||||
from itertools import groupby
|
||||
from math import ceil, floor
|
||||
|
||||
from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, positions_for_pitch, within_fret_span
|
||||
from generator.model import fraction, pitch_number
|
||||
from generator.transposition import transpose_document, transposition_interval
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuitarTone:
|
||||
pitch: dict
|
||||
onset: Fraction
|
||||
end: Fraction
|
||||
midi: Fraction
|
||||
location: str
|
||||
grace_order: int
|
||||
|
||||
def validate_staff_details(state: dict, location: str) -> None:
|
||||
details = state['staff_details']
|
||||
if details.get('capo', 0):
|
||||
raise ValueError(f'{location}: guitar practice does not support capo configurations')
|
||||
if state['clef']['sign'] == 'TAB' and details.get('lines') != 6:
|
||||
raise ValueError(f'{location}: guitar practice requires six-string tablature')
|
||||
tunings = details.get('tunings', [])
|
||||
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
|
||||
if tunings and (len(tunings) != 6 or {t['line']: pitch_number(t) for t in tunings} != expected):
|
||||
raise ValueError(f'{location}: guitar practice requires standard six-string tuning')
|
||||
|
||||
def sounding_pitch(pitch: dict, state: dict) -> Fraction:
|
||||
if 'unpitched' in pitch:
|
||||
raise ValueError('unpitched notes cannot be checked on a fretted guitar')
|
||||
transpose, clef = state['transpose'], state['clef']
|
||||
offset = transpose['chromatic'] + 12 * transpose['octave_change']
|
||||
if not any(transpose.values()) and clef['sign'] == 'G' and not clef['octave_change'] and 'tablature' not in pitch:
|
||||
offset = -WRITTEN_GUITAR_OCTAVE_OFFSET
|
||||
return pitch_number(pitch) + offset
|
||||
|
||||
def guitar_tones(document: dict) -> list[GuitarTone]:
|
||||
tones = []
|
||||
absolute = Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
location = f'measure {measure.get("number", measure["id"])}'
|
||||
state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')}
|
||||
validate_staff_details(state, location)
|
||||
changes = sorted(measure.get('attributes', []), key=lambda change: fraction(change['onset']))
|
||||
for change in changes:
|
||||
state.update({key: change[key] for key in state if key in change})
|
||||
validate_staff_details(state, location)
|
||||
for voice in measure['voices']:
|
||||
state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')}
|
||||
change_index = 0
|
||||
for index, note in enumerate(voice['notes']):
|
||||
onset = fraction(note['onset'])
|
||||
while change_index < len(changes) and fraction(changes[change_index]['onset']) <= onset:
|
||||
state.update({key: changes[change_index][key] for key in state if key in changes[change_index]})
|
||||
change_index += 1
|
||||
context = f'{location}, voice {voice["id"]}, event {note["id"]}, onset {onset}'
|
||||
for pitch in note['pitches']:
|
||||
try:
|
||||
midi = sounding_pitch(pitch, state)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{context}: {error}') from error
|
||||
tones.append(GuitarTone(pitch, absolute + onset, absolute + onset + fraction(pitch['duration']), midi, context, index if note.get('grace') else -1))
|
||||
absolute += fraction(measure['duration'])
|
||||
return tones
|
||||
|
||||
def tone_positions(tone: GuitarTone) -> tuple[TabPosition, ...]:
|
||||
positions = positions_for_pitch(tone.midi)
|
||||
if tab := tone.pitch.get('tablature'):
|
||||
positions = tuple(p for p in positions if p.string_number == tab['string'] and p.fret == tab['fret'])
|
||||
if not positions:
|
||||
raise ValueError(f'{tone.location}: pitch {tone.midi} has no matching string/fret in {MIN_FRET}-{MAX_FRET}')
|
||||
return positions
|
||||
|
||||
def extend_fingerings(held: dict[str, TabPosition], pending: list[tuple[str, tuple[TabPosition, ...]]]):
|
||||
if not pending:
|
||||
yield tuple(sorted(held.items()))
|
||||
return
|
||||
identifier, options = pending[0]
|
||||
occupied = {position.string_number for position in held.values()}
|
||||
for position in options:
|
||||
if position.string_number not in occupied and within_fret_span((*held.values(), position)):
|
||||
yield from extend_fingerings({**held, identifier: position}, pending[1:])
|
||||
|
||||
def validate_guitar_practice(document: dict) -> None:
|
||||
tones = guitar_tones(document)
|
||||
by_id = {tone.pitch['id']: tone for tone in tones}
|
||||
options = {tone.pitch['id']: tone_positions(tone) for tone in tones}
|
||||
states = {()}
|
||||
# Keep every feasible assignment, so later entries cannot move a held or tied tone.
|
||||
ordered = sorted(tones, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order))
|
||||
for (onset, _, _), group in groupby(ordered, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order)):
|
||||
attacks = list(group)
|
||||
continued = {tone.pitch['tie_from'] for tone in attacks if 'tie_from' in tone.pitch}
|
||||
next_states = set()
|
||||
visited = set()
|
||||
for previous in states:
|
||||
previous = dict(previous)
|
||||
held = {identifier: position for identifier, position in previous.items() if identifier not in continued and (by_id[identifier].end > onset or by_id[identifier].end == onset and by_id[identifier].pitch.get('tie_to'))}
|
||||
pending = []
|
||||
for tone in attacks:
|
||||
positions = options[tone.pitch['id']]
|
||||
if source := tone.pitch.get('tie_from'):
|
||||
positions = tuple(p for p in positions if p == previous.get(source))
|
||||
pending.append((tone.pitch['id'], positions))
|
||||
pending.sort(key=lambda item: len(item[1]))
|
||||
transition = (tuple(sorted(held.items())), tuple(pending))
|
||||
if transition not in visited:
|
||||
next_states.update(extend_fingerings(held, pending))
|
||||
visited.add(transition)
|
||||
if not next_states:
|
||||
raise ValueError(f'{attacks[0].location}: no simultaneous fingering within frets {MIN_FRET}-{MAX_FRET} and the four-fret span; held/tied strings must remain fixed')
|
||||
states = next_states
|
||||
|
||||
def transpose_guitar_practice(document: dict, target_key: str) -> dict:
|
||||
_, semitones = transposition_interval(document, target_key)
|
||||
tones = guitar_tones(document)
|
||||
if not tones:
|
||||
return transpose_document(document, target_key)
|
||||
low = min(midi + MIN_FRET for _, midi in STANDARD_TUNING)
|
||||
high = max(midi + MAX_FRET for _, midi in STANDARD_TUNING)
|
||||
first = ceil((low - min(tone.midi for tone in tones) - semitones) / 12)
|
||||
last = floor((high - max(tone.midi for tone in tones) - semitones) / 12)
|
||||
candidates = sorted(range(first, last + 1), key=lambda octave: (abs(semitones + 12 * octave), semitones + 12 * octave < 0))
|
||||
failures = []
|
||||
for octave in candidates:
|
||||
try:
|
||||
result = transpose_document(document, target_key, octave_shift=octave)
|
||||
validate_guitar_practice(result)
|
||||
return result
|
||||
except ValueError as error:
|
||||
failures.append(f'octave {octave:+}: {error}')
|
||||
detail = '; '.join(failures) if failures else f'{min(tones, key=lambda tone: tone.midi).location} through {max(tones, key=lambda tone: tone.midi).location}: pitch range exceeds the guitar range in every octave'
|
||||
raise ValueError(f'no whole-exercise guitar register for {target_key}: {detail}')
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
from generator.model import beat_lengths, fraction, rational
|
||||
|
||||
def pulse(measure: dict, onset: Fraction) -> tuple[Fraction | None, bool]:
|
||||
start, meter = Fraction(), measure['meter']
|
||||
for change in measure.get('attributes', []):
|
||||
if fraction(change['onset']) <= onset and 'meter' in change:
|
||||
start, meter = fraction(change['onset']), change['meter']
|
||||
beats = beat_lengths(meter)
|
||||
if not beats:
|
||||
return None, False
|
||||
# Pickups occupy the final beats of their meter.
|
||||
phase = onset - start
|
||||
if measure.get('implicit') and not start:
|
||||
phase += max(Fraction(), sum(beats) - fraction(measure['duration']))
|
||||
phase %= sum(beats)
|
||||
for beat in beats:
|
||||
if phase < beat:
|
||||
return beat, phase == 0
|
||||
phase -= beat
|
||||
raise ValueError('invalid meter pulse')
|
||||
|
||||
def syllable(duration: Fraction, beat: Fraction | None, onbeat: bool, final: bool, previous: str) -> str:
|
||||
if duration >= (beat if beat is not None else 1):
|
||||
return 'doo'
|
||||
if final:
|
||||
return 'dat'
|
||||
result = 'dit' if beat is not None and not onbeat else 'ba'
|
||||
return 'da' if result == previous else result
|
||||
|
||||
def add_scat(document: dict) -> None:
|
||||
events, pitches = [], {}
|
||||
absolute = Fraction()
|
||||
boundaries = set()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if measure.get('section_label'):
|
||||
boundaries.add(absolute)
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
for field in ('lyrics', 'lyric', 'lyric_notation'):
|
||||
note.pop(field, None)
|
||||
onset = absolute + fraction(note['onset'])
|
||||
for tone in note['pitches']:
|
||||
pitches[tone['id']] = (note, tone, onset)
|
||||
events.append((note, measure, onset))
|
||||
absolute += fraction(measure['duration'])
|
||||
attacks = [(n, m, t) for n, m, t in events if n['pitches'] and not n.get('cue') and not n['pitches'][0].get('cue') and not n['pitches'][0].get('tie_from')]
|
||||
previous, previous_end = '', None
|
||||
for index, (note, measure, onset) in enumerate(attacks):
|
||||
tone = note['pitches'][0]
|
||||
end = onset + fraction(tone['duration'])
|
||||
chain = []
|
||||
while target := tone.get('tie_to'):
|
||||
continuation, tone, time = pitches[target]
|
||||
chain.append(continuation)
|
||||
end = time + fraction(tone['duration'])
|
||||
following = attacks[index + 1][2] if index + 1 < len(attacks) else None
|
||||
final = following is None or following > end or any(onset < b <= following for b in boundaries)
|
||||
if previous_end is None or onset > previous_end or onset in boundaries:
|
||||
previous = ''
|
||||
beat, onbeat = pulse(measure, fraction(note['onset']))
|
||||
text = syllable(end - onset, beat, onbeat, final, previous)
|
||||
values = [{'kind': 'syllabic', 'text': 'single', 'attributes': {}}, {'kind': 'text', 'text': text, 'attributes': {}}]
|
||||
verse = {'number': '1', 'syllables': values}
|
||||
if chain:
|
||||
values.append({'kind': 'extend', 'text': '', 'attributes': {'type': 'start'}})
|
||||
verse['extend_to'] = rational(end)
|
||||
for position, continuation in enumerate(chain):
|
||||
continuation['lyrics'] = [{'number': '1', 'syllables': [{'kind': 'extend', 'text': '', 'attributes': {'type': 'stop' if position == len(chain) - 1 else 'continue'}}], 'extend_to': rational(end)}]
|
||||
note['lyrics'] = [verse]
|
||||
note['lyric'] = text
|
||||
note['lyric_notation'] = {'text': text, 'syllabic': 'single', 'extend': 'start' if chain else ''}
|
||||
previous, previous_end = text, end
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from generator.score import load_json, measure_notes
|
||||
from generator.transposition import TARGET_KEYS
|
||||
from generator.parts import PART_TYPES
|
||||
from generator.model import fraction, pitch_name
|
||||
|
||||
def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -85,10 +85,10 @@ def validate_expected_performance_summary(expected_path: Path, generated_songs_d
|
||||
raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally")
|
||||
raise ValueError("performance generator drift")
|
||||
|
||||
def validate_practice_variants(generated_practice_dir: Path) -> None:
|
||||
def validate_practice_parts(generated_practice_dir: Path) -> None:
|
||||
if not generated_practice_dir.exists():
|
||||
return
|
||||
expected_names = {key.lower().replace("#", "sharp") + ".json" for key in TARGET_KEYS}
|
||||
expected_names = {part + '.json' for part in PART_TYPES}
|
||||
for exercise_dir in sorted(path for path in generated_practice_dir.iterdir() if path.is_dir()):
|
||||
json_names = {path.name for path in exercise_dir.glob("*.json")}
|
||||
if json_names != expected_names:
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from generator import harmony
|
||||
from generator.model import LETTERS, NATURAL, alteration, key_from_fifths, key_label, parse_pitch_name, pitch_number, rational
|
||||
|
||||
TARGET_KEYS = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B')
|
||||
|
||||
def mod12(value: int) -> int:
|
||||
return value % 12
|
||||
|
||||
def interval_between(source: dict, target: dict) -> tuple[int, int]:
|
||||
semitones = (NATURAL[target['letter']] + alteration(target['alter']) - NATURAL[source['letter']] - alteration(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 shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
|
||||
output = copy.deepcopy(pitch)
|
||||
if 'unpitched' in pitch:
|
||||
return output
|
||||
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'] = rational(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:
|
||||
from generator.model import accidental_name
|
||||
output['accidental_display']['name'] = accidental_name(alteration(output['alter']))
|
||||
if 'tablature' in output and semitones:
|
||||
fret = output['tablature']['fret'] + semitones
|
||||
if fret < 0 or fret.denominator != 1:
|
||||
raise ValueError('transposition cannot preserve the authored tablature string/fret')
|
||||
output['tablature']['fret'] = int(fret)
|
||||
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 'steps' in key:
|
||||
result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']]
|
||||
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
|
||||
if 'cancel' in key:
|
||||
# A cancellation references the old signature, not a count of naturals.
|
||||
cancelled = shift_key(key_from_fifths(key['cancel']), steps, semitones)
|
||||
if cancelled.get('custom'):
|
||||
raise ValueError('transposed key cancellation cannot be represented by fifths')
|
||||
result['cancel'] = cancelled['fifths']
|
||||
return result
|
||||
|
||||
def transposition_interval(document: dict, target_key_name: str) -> tuple[int, int]:
|
||||
metadata = document['metadata']
|
||||
origin = metadata['key'].get('tonic') or metadata.get('transpose_origin')
|
||||
if origin is None:
|
||||
raise ValueError('keyless practice requires a vamp:tonic score property')
|
||||
return interval_between(origin, parse_pitch_name(target_key_name))
|
||||
|
||||
def transpose_document(document: dict, target_key_name: str, *, octave_shift: int = 0) -> dict:
|
||||
if type(octave_shift) is not int:
|
||||
raise ValueError('octave adjustment must be an integer')
|
||||
steps, semitones = transposition_interval(document, target_key_name)
|
||||
output = copy.deepcopy(document)
|
||||
metadata = output['metadata']
|
||||
key = metadata['key']
|
||||
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']:
|
||||
try:
|
||||
note['pitches'] = [shift_pitch(p, steps + 7 * octave_shift, semitones + 12 * octave_shift) for p in note.get('pitches', [])]
|
||||
except ValueError as error:
|
||||
raise ValueError(f'measure {measure.get("number", measure["id"])}, voice {voice["id"]}, event {note["id"]}: {error}') from error
|
||||
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 (n for v in measure['voices'] for n in v['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