Transpose
This commit is contained in:
@@ -1,26 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import product
|
||||
from typing import NamedTuple
|
||||
|
||||
from generator.transposition import mod12
|
||||
from generator import harmony
|
||||
from generator.model import NATURAL, alteration
|
||||
|
||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
|
||||
class TabPosition(NamedTuple):
|
||||
string_number: int
|
||||
fret: int
|
||||
midi: int
|
||||
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(0, 13):
|
||||
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))
|
||||
@@ -63,14 +55,13 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
|
||||
best: tuple[TabPosition, ...] | None = None
|
||||
best_score = 10**9
|
||||
for string_set in string_sets:
|
||||
options = [by_string[string_number][:8] for string_number in string_set]
|
||||
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
|
||||
fretted = [position.fret for position in candidate if position.fret > 0]
|
||||
if fretted and max(fretted) - min(fretted) > 4:
|
||||
if not within_fret_span(candidate):
|
||||
continue
|
||||
covered = {mod12(position.midi) for position in candidate}
|
||||
if not set(pitch_classes).issubset(covered):
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
from typing import NamedTuple
|
||||
|
||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 0
|
||||
MAX_FRET = 15
|
||||
MAX_FRET_SPAN = 4
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
|
||||
class TabPosition(NamedTuple):
|
||||
string_number: int
|
||||
fret: int
|
||||
midi: int
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
fretted = [position.fret for position in positions if position.fret > 0]
|
||||
return not fretted or max(fretted) - min(fretted) <= MAX_FRET_SPAN
|
||||
|
||||
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)
|
||||
@@ -158,6 +158,7 @@ class Importer:
|
||||
elif item.tag == 'transpose':
|
||||
self.children(item, {'diatonic', 'chromatic', 'octave-change'})
|
||||
self.transpose = {key: self.number(item.findtext(tag, '0'), integer=True) for key, tag in (('diatonic', 'diatonic'), ('chromatic', 'chromatic'), ('octave_change', 'octave-change'))}
|
||||
self.transpose['explicit'] = True
|
||||
if item.find('chromatic') is None:
|
||||
self.fail('instrument transposition requires chromatic semitones')
|
||||
if item.find('diatonic') is None:
|
||||
|
||||
@@ -73,7 +73,8 @@ def write_attributes(parent, state: dict, divisions=None) -> None:
|
||||
element(t, 'tuning-octave', tuning['octave'])
|
||||
if details.get('capo'):
|
||||
element(item, 'capo', details['capo'])
|
||||
if transpose := state.get('transpose'):
|
||||
transpose = state.get('transpose')
|
||||
if transpose and (transpose.get('explicit') or any(transpose[field] for field in ('diatonic', 'chromatic', 'octave_change'))):
|
||||
item = element(node, 'transpose')
|
||||
for field in ('diatonic', 'chromatic', 'octave_change'):
|
||||
element(item, field.replace('_', '-'), transpose[field])
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
from generator.score import copy_source_voices, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.practice.guitar import transpose_guitar_practice
|
||||
from generator.transposition import TARGET_KEYS, transpose_document
|
||||
|
||||
def practice_document(document: dict) -> dict:
|
||||
@@ -39,23 +40,23 @@ def key_file_name(key: str) -> str:
|
||||
return slugify(key.replace("#", "sharp")) + ".json"
|
||||
|
||||
def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
||||
document = practice_document(load_musicxml(source_path))
|
||||
metadata = document.get("metadata", {})
|
||||
key = str(metadata.get("key_signature", "C") if isinstance(metadata, dict) else "C")
|
||||
relative = source_path.relative_to(source_root)
|
||||
lesson_group = relative.parent.name
|
||||
exercise_id = slugify(source_path.stem)
|
||||
write_json(output_dir / "chord" / lesson_group / exercise_id / key_file_name(key), document)
|
||||
return 1
|
||||
return generate_key_variants(source_path, source_root, output_dir, 'chord')
|
||||
|
||||
def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
||||
return generate_key_variants(source_path, source_root, output_dir, 'melody')
|
||||
|
||||
def generate_key_variants(source_path: Path, source_root: Path, output_dir: Path, practice_type: str) -> int:
|
||||
base_document = practice_document(load_musicxml(source_path))
|
||||
relative = source_path.relative_to(source_root)
|
||||
lesson_group = relative.parent.name
|
||||
exercise_id = slugify(source_path.stem)
|
||||
count = 0
|
||||
for key in TARGET_KEYS:
|
||||
write_json(output_dir / "melody" / lesson_group / exercise_id / key_file_name(key), transpose_document(base_document, key))
|
||||
try:
|
||||
variant = transpose_guitar_practice(base_document, key) if practice_type == 'chord' else transpose_document(base_document, key)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{source_path}, target key {key}: {error}') from error
|
||||
write_json(output_dir / practice_type / lesson_group / exercise_id / key_file_name(key), variant)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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}')
|
||||
+5
-13
@@ -88,20 +88,12 @@ def validate_expected_performance_summary(expected_path: Path, generated_songs_d
|
||||
def validate_practice_variants(generated_practice_dir: Path) -> None:
|
||||
if not generated_practice_dir.exists():
|
||||
return
|
||||
melody_root = generated_practice_dir / "melody"
|
||||
if melody_root.exists():
|
||||
expected_names = {key.lower().replace("#", "sharp").replace("b", "b") + ".json" for key in TARGET_KEYS}
|
||||
for exercise_dir in sorted(path for path in melody_root.rglob("*") if path.is_dir()):
|
||||
expected_names = {key.lower().replace("#", "sharp") + ".json" for key in TARGET_KEYS}
|
||||
for practice_type in ('melody', 'chord'):
|
||||
root = generated_practice_dir / practice_type
|
||||
for exercise_dir in sorted(path for path in root.rglob("*") if path.is_dir()):
|
||||
json_names = {path.name for path in exercise_dir.glob("*.json")}
|
||||
if not json_names:
|
||||
continue
|
||||
if json_names != expected_names:
|
||||
raise ValueError(f"melody practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}")
|
||||
chord_root = generated_practice_dir / "chord"
|
||||
if chord_root.exists():
|
||||
for exercise_dir in sorted(path for path in chord_root.rglob("*") if path.is_dir()):
|
||||
json_names = [path.name for path in exercise_dir.glob("*.json")]
|
||||
if not json_names:
|
||||
continue
|
||||
if len(json_names) != 1:
|
||||
raise ValueError(f"chord practice generator drift in {exercise_dir}: expected one authored-key variant, found {json_names}")
|
||||
raise ValueError(f"{practice_type} practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}")
|
||||
|
||||
@@ -80,15 +80,20 @@ def shift_key(key: dict, steps: int, semitones: int) -> dict:
|
||||
result['custom'] = True
|
||||
return result
|
||||
|
||||
def transpose_document(document: dict, target_key_name: str) -> dict:
|
||||
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']
|
||||
origin = key.get('tonic') or metadata.get('transpose_origin')
|
||||
if origin is None:
|
||||
raise ValueError('keyless melody practice requires a vamp:tonic score property')
|
||||
target = parse_pitch_name(target_key_name)
|
||||
steps, semitones = interval_between(origin, target)
|
||||
metadata['key'] = shift_key(key, steps, semitones)
|
||||
metadata['key_signature'] = key_label(metadata['key'])
|
||||
metadata['variant_tonic'] = target_key_name
|
||||
@@ -106,7 +111,10 @@ def transpose_document(document: dict, target_key_name: str) -> dict:
|
||||
h['symbol'] = harmony.symbol(h)
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
note['pitches'] = [shift_pitch(p, steps, semitones) for p in note.get('pitches', [])]
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user