Tab Generation
ci / checks (push) Successful in 1h19m14s

This commit is contained in:
khodowany
2026-09-20 16:20:43 -05:00
parent 68ded4d8c8
commit cf7c0c010c
423 changed files with 1422 additions and 1332 deletions
+7 -5
View File
@@ -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.')
-91
View File
@@ -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
+15 -95
View File
@@ -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
View File
@@ -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')
+2 -2
View File
@@ -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:
+11 -2
View File
@@ -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
+6 -7
View File
@@ -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, ...]:
+1 -8
View File
@@ -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'])
+9 -12
View File
@@ -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)
+8 -1
View File
@@ -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
+59
View File
@@ -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
+263
View File
@@ -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
View File
@@ -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'):
+4 -8
View File
@@ -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]):
-7
View File
@@ -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')
+116
View File
@@ -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
+95
View File
@@ -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
View File
@@ -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')))
+31 -54
View File
@@ -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)
-137
View File
@@ -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}')
+76
View File
@@ -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
+3 -3
View File
@@ -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:
-142
View File
@@ -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