@@ -16,6 +16,7 @@ import generator.parts
|
||||
import generator.pico_artwork
|
||||
import generator.playback
|
||||
import generator.rhythm
|
||||
import generator.scales.library
|
||||
import generator.score
|
||||
import generator.summary
|
||||
|
||||
@@ -67,13 +68,15 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
generator.parts.source_paths(data_dir / 'songs')
|
||||
report('loading rhythm snippets')
|
||||
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
|
||||
report('loading solo scale patterns')
|
||||
scales = generator.scales.library.load_library(data_dir / 'scales')
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
|
||||
staged = pathlib.Path(temporary) / 'assets'
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
report('generating performance parts')
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms)
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales)
|
||||
generator.score.validate_generated_tree(staged)
|
||||
if args.check:
|
||||
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||
|
||||
@@ -15,14 +15,17 @@ import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.rhythm
|
||||
import generator.scales.library
|
||||
import generator.scales.mapping
|
||||
import generator.scales.timeline
|
||||
import generator.score
|
||||
|
||||
FINAL_SECTION_INDEX = 6
|
||||
|
||||
def arrange_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library) -> dict:
|
||||
def arrange_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library | None = None) -> dict:
|
||||
document = generator.parts.prepare_document(document)
|
||||
plans = plan_arrangement(document, rhythms) if part_type != 'vocal' else {}
|
||||
return _arrange_document(document, part_type, plans)
|
||||
return _arrange_document(document, part_type, plans, scales=scales)
|
||||
|
||||
def section_source(source: list[dict], section, next_section=None) -> list[dict]:
|
||||
result = copy.deepcopy(source)
|
||||
@@ -48,13 +51,14 @@ def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[
|
||||
result[section.section_index] = generator.accompaniment.plan_section(measures[start:start + section.measure_count], rhythms, initial=previous[start], electric=section.section_index in (1, 5, 6))
|
||||
return result
|
||||
|
||||
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan]) -> dict:
|
||||
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan], *, scales: generator.scales.library.Library | None = None, reports: list[str] | None = None, source_name: str = '') -> dict:
|
||||
if part_type not in generator.parts.PART_TYPES:
|
||||
raise ValueError(f'unknown arrangement part: {part_type}')
|
||||
staff = generator.score.source_staff(document)
|
||||
source = staff['measures']
|
||||
sections = generator.arrange.form.full_arrangement_sections(source, part_type)
|
||||
output, tuplets, octave_shifts, voicing_origins = [], [], [], []
|
||||
solos = []
|
||||
absolute = fractions.Fraction()
|
||||
source_starts = []
|
||||
source_time = fractions.Fraction()
|
||||
@@ -76,6 +80,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
|
||||
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
|
||||
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
|
||||
if section.role == 'slash':
|
||||
solos.append(generator.scales.timeline.Solo(absolute, absolute + segment_length, section.label))
|
||||
if section.role in ('vocal', 'melody'):
|
||||
start = source_starts[section.source_measure_index]
|
||||
for shift in staff.get('octave_shifts', []):
|
||||
@@ -112,9 +118,13 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
generator.guitar.place_guitar(result)
|
||||
generator.electric.reuse_voicings(voicing_links)
|
||||
generator.acoustic.combine(result)
|
||||
if scales and solos:
|
||||
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
|
||||
if reports is not None:
|
||||
reports.append(report)
|
||||
return result
|
||||
|
||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int:
|
||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library | None = None) -> int:
|
||||
count = 0
|
||||
for path in generator.parts.source_paths(source_dir):
|
||||
try:
|
||||
@@ -122,10 +132,15 @@ def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Pa
|
||||
plans = plan_arrangement(document, rhythms)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}: {error}') from error
|
||||
reports = []
|
||||
for part in generator.parts.PART_TYPES:
|
||||
try:
|
||||
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}-{part}.json', _arrange_document(document, part, plans))
|
||||
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}-{part}.json', _arrange_document(document, part, plans, scales=scales, reports=reports, source_name=path.name))
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}, part {part}: {error}') from error
|
||||
count += 1
|
||||
if reports:
|
||||
report_path = output_dir.parent / 'analysis' / f'{generator.score.slugify(path.stem)}.txt'
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(document['metadata']['title'] + '\nSource: ' + path.name + '\n\n' + '\n'.join(reports), encoding='utf-8')
|
||||
return count
|
||||
|
||||
@@ -78,15 +78,19 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
fragments.setdefault(fragment.source, []).append(fragment)
|
||||
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
||||
# noteheads, accidentals and text in the same lane must remain separate.
|
||||
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric')]
|
||||
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric', 'scale', 'section')]
|
||||
for i, item in enumerate(checked):
|
||||
for other in checked[i + 1:]:
|
||||
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'}:
|
||||
if item.role != other.role and 'scale' not in (item.role, other.role) and {item.role, other.role} - {'head', 'tab', 'accidental'}:
|
||||
continue
|
||||
if item.box.overlaps(other.box, 0.1 * system.scale):
|
||||
raise generator.engraving.types.EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
|
||||
expected_scales = [span for span in index.scale_spans if generator.model.fraction(span['start']) < system.end and generator.model.fraction(span['end']) > system.start]
|
||||
drawn_scales = [(p.sources[0], p.value) for p in system.primitives if p.role == 'scale']
|
||||
if drawn_scales != [(span['id'], span['label']) for span in expected_scales]:
|
||||
raise generator.engraving.types.EngravingError('solo scale labels are missing, duplicated, or changed')
|
||||
if previous_end != index.end:
|
||||
raise generator.engraving.types.EngravingError('layout omits the end of the score')
|
||||
for event in index.events:
|
||||
|
||||
@@ -11,6 +11,7 @@ import generator.engraving.audit
|
||||
import generator.engraving.fonts
|
||||
import generator.engraving.notation
|
||||
import generator.engraving.notes
|
||||
import generator.engraving.scales
|
||||
import generator.engraving.spans
|
||||
import generator.engraving.tablature
|
||||
import generator.engraving.types
|
||||
@@ -54,6 +55,7 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
|
||||
for owner, _, item in index.harmonies_by_onset.get(time, []):
|
||||
if owner is measure:
|
||||
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
|
||||
items.extend(generator.engraving.scales.column(index, time, start, fonts))
|
||||
if time == measure_start:
|
||||
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],)))
|
||||
if label := measure.get('section_label'):
|
||||
|
||||
@@ -57,6 +57,9 @@ class ScoreIndex:
|
||||
bucket.setdefault(onset, []).append((measure, index, item))
|
||||
time = end
|
||||
self.end = time
|
||||
self.scale_spans = self.staff.get('scale_spans', [])
|
||||
self.scale_starts = [generator.model.fraction(span['start']) for span in self.scale_spans]
|
||||
self.columns.update(self.scale_starts)
|
||||
self.measure_starts = [item[0] for item in self.measures]
|
||||
self.measure_starts_by_id = {measure['id']: start for start, _, measure in self.measures}
|
||||
self.events.sort(key=lambda e: (e.start, not bool(e.value.get('grace')), e.lane))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
|
||||
import generator.model
|
||||
|
||||
def column(index, time, system_start, fonts):
|
||||
position = bisect.bisect_right(index.scale_starts, time) - 1
|
||||
if position < 0:
|
||||
return []
|
||||
span = index.scale_spans[position]
|
||||
if time >= generator.model.fraction(span['end']) or time not in (system_start, index.scale_starts[position]):
|
||||
return []
|
||||
return [fonts.text(span['label'], 0, 0, 16, 'scale', (span['id'],))]
|
||||
@@ -123,7 +123,7 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
|
||||
y = anchor.y - anchor.direction * 8
|
||||
if anchor.x - 2 > left:
|
||||
result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
|
||||
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')), default=0))
|
||||
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'tempo')), default=0))
|
||||
tuplet_lanes = []
|
||||
for span in index.staff.get('tuplets', []):
|
||||
a, b = index.by_id[span['start']], index.by_id[span['end']]
|
||||
@@ -203,11 +203,11 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
|
||||
return items + result
|
||||
|
||||
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
|
||||
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section')]
|
||||
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale')]
|
||||
box = generator.engraving.types.bounds(music)
|
||||
result = list(music)
|
||||
top = box.top - 12
|
||||
for role in ('harmony', 'section'):
|
||||
for role in ('harmony', 'scale', 'section'):
|
||||
lane = [p for p in items if p.role == role]
|
||||
if lane:
|
||||
extent = generator.engraving.types.bounds(lane)
|
||||
|
||||
@@ -285,3 +285,12 @@ def validate_document(document: dict) -> None:
|
||||
for span in staves[0].get('octave_shifts', []):
|
||||
if not 0 <= fraction(span['start']) < fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3:
|
||||
raise ValueError('invalid octave-shift span')
|
||||
previous_end = fractions.Fraction()
|
||||
for span in staves[0].get('scale_spans', []):
|
||||
start, end = fraction(span['start']), fraction(span['end'])
|
||||
if not previous_end <= start < end <= absolute or not isinstance(span.get('label'), str) or not span['label'].strip() or type(span.get('matched')) is not bool:
|
||||
raise ValueError('invalid solo scale span')
|
||||
if not isinstance(span.get('id'), str) or not span['id'] or span['id'] in ids:
|
||||
raise ValueError('solo scale spans require unique IDs')
|
||||
ids.add(span['id'])
|
||||
previous_end = end
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import pathlib
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.pitch
|
||||
|
||||
NAMES = {
|
||||
'major': ('Ionian', 'Dorian', 'Phrygian', 'Lydian', 'Mixolydian', 'Aeolian', 'Locrian'),
|
||||
'melodic-minor': ('Melodic Minor', 'Dorian b2', 'Lydian Augmented', 'Lydian Dominant', 'Mixolydian b6', 'Locrian natural 2', 'Altered'),
|
||||
'harmonic-minor': ('Harmonic Minor', 'Locrian natural 6', 'Ionian #5', 'Dorian #4', 'Phrygian Dominant', 'Lydian #2', 'Ultra Locrian'),
|
||||
}
|
||||
ROOTS = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B')
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Pattern:
|
||||
family: str
|
||||
mode: int
|
||||
intervals: tuple[int, ...]
|
||||
def name(self) -> str:
|
||||
return NAMES[self.family][self.mode - 1]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Scale:
|
||||
pattern: Pattern
|
||||
root: str
|
||||
notes: tuple[str, ...]
|
||||
pitches: frozenset[int]
|
||||
def label(self) -> str:
|
||||
return self.root + ' ' + self.pattern.name()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Library:
|
||||
patterns: tuple[Pattern, ...]
|
||||
def scale(self, family: str, mode: int, root: str) -> Scale:
|
||||
pattern = next(p for p in self.patterns if (p.family, p.mode) == (family, mode))
|
||||
tonic = generator.model.parse_pitch_name(root)
|
||||
number = generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter'])
|
||||
notes = tuple(generator.model.pitch_name(generator.pitch.shift_pitch({**tonic, 'octave': 4}, degree, interval)) for degree, interval in enumerate(pattern.intervals))
|
||||
return Scale(pattern, root, notes, frozenset(int(number + n) % 12 for n in pattern.intervals))
|
||||
def collections(self) -> tuple[Scale, ...]:
|
||||
# Each mode is a rotation of its family, so only the three parents need searching.
|
||||
return tuple(self.scale(family, 1, root) for family in NAMES for root in ROOTS)
|
||||
def for_key(self, key: dict) -> Scale:
|
||||
if not key.get('tonic') or key.get('mode') not in generator.model.MODE_STEPS:
|
||||
raise ValueError('solo scale analysis requires a declared tonic and mode in the MusicXML key')
|
||||
return self.scale('major', generator.model.MODE_STEPS[key['mode']] + 1, generator.model.pitch_name(key['tonic']))
|
||||
def chord_mode(self, scale: Scale, root: str) -> tuple[int, str]:
|
||||
pitch = generator.model.parse_pitch_name(root)
|
||||
number = (generator.model.NATURAL[pitch['letter']] + generator.model.alteration(pitch['alter'])) % 12
|
||||
values = [generator.model.parse_pitch_name(note) for note in scale.notes]
|
||||
degree = next(i for i, note in enumerate(values) if (generator.model.NATURAL[note['letter']] + generator.model.alteration(note['alter'])) % 12 == number)
|
||||
mode = (scale.pattern.mode - 1 + degree) % 7 + 1
|
||||
return degree + 1, root + ' ' + NAMES[scale.pattern.family][mode - 1]
|
||||
|
||||
def read_pattern(path: pathlib.Path, family: str, mode: int) -> Pattern:
|
||||
document = generator.musicxml.load_musicxml(path)
|
||||
if len(document['staves']) != 1:
|
||||
raise ValueError(f'{path}: scale patterns require one staff')
|
||||
notes = []
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if len(measure['voices']) != 1:
|
||||
raise ValueError(f'{path}: scale patterns require one melodic voice')
|
||||
for event in measure['voices'][0]['notes']:
|
||||
if event['kind'] != 'note' or len(event['pitches']) != 1 or event.get('grace'):
|
||||
raise ValueError(f'{path}: scale patterns require eight ascending notes, including the octave')
|
||||
notes.append(event['pitches'][0])
|
||||
if len(notes) != 8 or ''.join(note.get('letter', '?') for note in notes) != 'cdefgabc':
|
||||
raise ValueError(f'{path}: scale patterns must spell C through B followed by C')
|
||||
numbers = [generator.model.pitch_number(note) for note in notes]
|
||||
intervals = tuple(number - numbers[0] for number in numbers)
|
||||
if generator.model.alteration(notes[0]['alter']) or intervals[-1] != 12 or any(n.denominator != 1 for n in intervals) or any(a >= b for a, b in zip(intervals, intervals[1:])):
|
||||
raise ValueError(f'{path}: expected seven distinct ascending semitones rooted on C and octave closure')
|
||||
return Pattern(family, mode, tuple(int(n) for n in intervals[:-1]))
|
||||
|
||||
def load_library(root: pathlib.Path) -> Library:
|
||||
expected = {root / f'{family}-scale-modes' / f'{mode:04}.xml' for family in NAMES for mode in range(1, 8)}
|
||||
actual = set(root.rglob('*.xml'))
|
||||
if actual != expected:
|
||||
missing, unexpected = sorted(expected - actual), sorted(actual - expected)
|
||||
raise ValueError(f'{root}: expected all 21 scale patterns; missing: {missing}; unexpected: {unexpected}')
|
||||
patterns = tuple(read_pattern(root / f'{family}-scale-modes' / f'{mode:04}.xml', family, mode) for family in NAMES for mode in range(1, 8))
|
||||
for family in NAMES:
|
||||
modes = [pattern for pattern in patterns if pattern.family == family]
|
||||
parent = modes[0].intervals
|
||||
for index, pattern in enumerate(modes):
|
||||
rotation = tuple((parent[(index + n) % 7] - parent[index]) % 12 for n in range(7))
|
||||
if pattern.intervals != rotation:
|
||||
raise ValueError(f'{root / (family + "-scale-modes") / f"{pattern.mode:04}.xml"}: pattern is not mode {pattern.mode} of its family parent')
|
||||
return Library(patterns)
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.scales.library
|
||||
import generator.scales.report
|
||||
import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
|
||||
def annotate(document: dict, solos: list[generator.scales.timeline.Solo], library: generator.scales.library.Library, source: str) -> str:
|
||||
staff = document['staves'][0]
|
||||
timeline = generator.scales.timeline.read(staff)
|
||||
spans, reports = [], []
|
||||
for solo in solos:
|
||||
choices = generator.scales.selection.select(timeline, solo, library)
|
||||
previous = None
|
||||
for choice in choices:
|
||||
if choice.span.chord is None:
|
||||
previous = None
|
||||
continue
|
||||
label = choice.scale.label() if choice.scale else 'No exact scale match'
|
||||
if previous is not None and previous['label'] == label and generator.model.fraction(previous['end']) == choice.span.start:
|
||||
previous['end'] = generator.model.rational(choice.span.end)
|
||||
else:
|
||||
previous = {'id': f'scale-{len(spans) + 1}', 'start': generator.model.rational(choice.span.start), 'end': generator.model.rational(choice.span.end), 'label': label, 'matched': choice.scale is not None}
|
||||
spans.append(previous)
|
||||
if choice.scale is None:
|
||||
print(f'{source}, {staff["name"]}, source measure {choice.span.source_measure}, beat {choice.span.beat}: {choice.reason} Chord: {generator.harmony.symbol(choice.span.chord)}', flush=True)
|
||||
reports.append(generator.scales.report.passage(staff['arrangement_part_type'], solo, choices, library))
|
||||
if spans:
|
||||
staff['scale_spans'] = spans
|
||||
return '\n'.join(reports)
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
def chord_notes(chord: dict) -> tuple[tuple[int, str], ...]:
|
||||
return tuple((degree, generator.model.pitch_name(generator.pitch.shift_pitch({**chord['root'], 'octave': 4}, degree - 1, generator.harmony.degree_semitones(degree, alter)))) for degree, alter in generator.harmony.degrees(chord).items())
|
||||
|
||||
def passage(part: str, solo, choices, library) -> str:
|
||||
lines = [f'{part.capitalize()}: {solo.label}', 'Positions use source measure numbers and quarter-note beats; arranged measure numbers are also shown.', '']
|
||||
lines.append('Scale map:')
|
||||
previous = None
|
||||
for choice in choices:
|
||||
label = choice.scale.label() if choice.scale else 'No exact match within the permitted catalog.' if choice.span.chord else 'N.C.'
|
||||
if label != previous:
|
||||
lines.append(f' Source measure {choice.span.source_measure}, beat {choice.span.beat}: {label}')
|
||||
lines.append(' ' + choice.reason)
|
||||
previous = label
|
||||
lines.extend(('', 'Chord detail:'))
|
||||
for choice in choices:
|
||||
span, scale = choice.span, choice.scale
|
||||
if span.chord is None:
|
||||
continue
|
||||
chord = span.chord
|
||||
notes = chord_notes(chord)
|
||||
required = [name for _, name in notes]
|
||||
if bass := chord.get('bass'):
|
||||
required.append(generator.model.pitch_name(bass))
|
||||
targets = [name for degree, name in notes if degree in (3, 7)] or [name for _, name in notes]
|
||||
lines.append(f'Source measure {span.source_measure}, beat {span.beat}; arranged measure {span.measure}; offset {span.start} through {span.end}')
|
||||
lines.append(' Chord: ' + generator.harmony.symbol(chord) + '; required pitches: ' + ', '.join(dict.fromkeys(required)))
|
||||
if scale:
|
||||
root = generator.model.pitch_number({**chord['root'], 'octave': 4}) % 12
|
||||
degree, mode = library.chord_mode(scale, generator.model.pitch_name(chord['root'])) if root in scale.pitches else (None, None)
|
||||
lines.append(' Parent: ' + scale.label() + ' [' + ', '.join(scale.notes) + ']')
|
||||
lines.append(f' Parent degree: {degree or "omitted root"}; chord-root mode: {mode or "not assigned"}')
|
||||
lines.append(' Chord-tone targets: ' + ', '.join(targets))
|
||||
if generator.model.pitch_name(chord['root']) == scale.root:
|
||||
lines.append(' Tonic resolution target: ' + scale.root)
|
||||
else:
|
||||
lines.append(' No exact match within the permitted catalog.')
|
||||
lines.extend((' Reason: ' + choice.reason, ''))
|
||||
return '\n'.join(lines)
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.scales.library
|
||||
import generator.scales.timeline
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Choice:
|
||||
span: generator.scales.timeline.ChordSpan
|
||||
scale: generator.scales.library.Scale | None
|
||||
reason: str
|
||||
|
||||
def root_number(chord: dict) -> fractions.Fraction:
|
||||
root = chord['root']
|
||||
return (generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])) % 12
|
||||
|
||||
def dominant(chord: dict) -> bool:
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
return degrees.get(3) == 0 and degrees.get(7) == -1
|
||||
|
||||
def destination(chord: dict, following: dict | None, library: generator.scales.library.Library):
|
||||
if following and dominant(chord) and (root_number(chord) - root_number(following)) % 12 == 7:
|
||||
third = generator.harmony.degrees(following).get(3)
|
||||
if third in (-1, 0):
|
||||
return library.scale('harmonic-minor' if third == -1 else 'major', 1, generator.model.pitch_name(following['root']))
|
||||
return None
|
||||
|
||||
def preference(chord: dict, following: dict | None, after: dict | None, home: generator.scales.library.Scale, library: generator.scales.library.Library) -> tuple[generator.scales.library.Scale, str]:
|
||||
if any(root_number(item).denominator != 1 for item in (chord, following, after) if item):
|
||||
return home, 'non-semitone harmony cannot establish a destination in the permitted catalog'
|
||||
if target := destination(chord, following, library):
|
||||
return target, 'dominant approaching ' + generator.model.pitch_name(following['root']) + (' minor' if target.pattern.family == 'harmonic-minor' else ' major')
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
if following and degrees.get(3) == -1 and degrees.get(7) == -1 and dominant(following) and (root_number(following) - root_number(chord)) % 12 == 5:
|
||||
tonic = generator.pitch.shift_pitch({**chord['root'], 'octave': 4}, -1, -2)
|
||||
target = destination(following, after, library) or library.scale('harmonic-minor' if degrees.get(5) == -1 else 'major', 1, generator.model.pitch_name(tonic))
|
||||
if set(generator.harmony.pitch_classes(chord)) | set(generator.harmony.pitch_classes(following)) <= target.pitches:
|
||||
return target, 'shared ' + ('minor ii-V' if target.pattern.family == 'harmonic-minor' else 'major ii-V') + ' destination ' + target.root
|
||||
if following and degrees.get(3) == -1 and degrees.get(5) == -1 and degrees.get(7) == -2 and (root_number(following) - root_number(chord)) % 12 == 1:
|
||||
return library.scale('harmonic-minor', 1, generator.model.pitch_name(following['root'])), 'leading-tone diminished resolution to ' + generator.model.pitch_name(following['root'])
|
||||
return home, 'declared tonal center ' + home.label()
|
||||
|
||||
def following_indices(timeline: tuple[generator.scales.timeline.ChordSpan, ...]) -> list[int | None]:
|
||||
result = [None] * len(timeline)
|
||||
for index in range(len(timeline) - 2, -1, -1):
|
||||
here, there = timeline[index], timeline[index + 1]
|
||||
if not there.chord or here.key != there.key:
|
||||
continue
|
||||
fields = ('root', 'kind', 'degrees', 'bass')
|
||||
same = here.chord and all(here.chord.get(k) == there.chord.get(k) for k in fields)
|
||||
result[index] = result[index + 1] if same else index + 1
|
||||
return result
|
||||
|
||||
def select(timeline: tuple[generator.scales.timeline.ChordSpan, ...], solo: generator.scales.timeline.Solo, library: generator.scales.library.Library) -> tuple[Choice, ...]:
|
||||
collections = library.collections()
|
||||
following = following_indices(timeline)
|
||||
rows, layers = [], []
|
||||
for index, original in enumerate(timeline):
|
||||
if original.end <= solo.start or original.start >= solo.end:
|
||||
continue
|
||||
span = dataclasses.replace(original, start=max(original.start, solo.start), end=min(original.end, solo.end), beat=original.beat + max(0, solo.start - original.start))
|
||||
try:
|
||||
home = library.for_key(span.key)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'measure {span.source_measure}, beat {span.beat}: {error}') from error
|
||||
next_index = following[index]
|
||||
after_index = following[next_index] if next_index is not None else None
|
||||
next_chord = timeline[next_index].chord if next_index is not None else None
|
||||
after = timeline[after_index].chord if after_index is not None else None
|
||||
target, reason = preference(span.chord, next_chord, after, home, library) if span.chord else (home, 'no harmony')
|
||||
# Name the home collection from the declared tonic, not its relative major.
|
||||
if target.pitches == home.pitches:
|
||||
target = home
|
||||
required = set(generator.harmony.pitch_classes(span.chord)) if span.chord else set()
|
||||
options = [target if scale.pitches == target.pitches else home if scale.pitches == home.pitches else scale for scale in collections if required <= scale.pitches] if span.chord else []
|
||||
options = options or [None]
|
||||
layer = []
|
||||
for scale in options:
|
||||
local = (int(scale is not None and scale.pitches != target.pitches), 0, len(scale.pitches ^ target.pitches) if scale else 0)
|
||||
predecessors = []
|
||||
for previous_index, (previous_scale, cost, _) in enumerate(layers[-1] if layers else [(None, (0, 0, 0), None)]):
|
||||
changed = int(scale is not None and previous_scale is not None and scale.pitches != previous_scale.pitches)
|
||||
predecessors.append((tuple(a + b for a, b in zip(cost, (local[0], changed, local[2]))), previous_index))
|
||||
cost, predecessor = min(predecessors)
|
||||
layer.append((scale, cost, predecessor))
|
||||
rows.append((span, target, reason))
|
||||
layers.append(layer)
|
||||
if not layers:
|
||||
return ()
|
||||
position = min(range(len(layers[-1])), key=lambda i: layers[-1][i][1])
|
||||
choices = []
|
||||
for (span, target, reason), layer in reversed(list(zip(rows, layers))):
|
||||
scale, _, position = layer[position]
|
||||
if span.chord and scale is None:
|
||||
reason = 'No exact match within the permitted catalog.'
|
||||
elif scale and scale.pitches != target.pitches:
|
||||
reason = 'compatible alternative to ' + target.label() + '; selected for collection continuity and closest contextual pitches (' + reason + ')'
|
||||
choices.append(Choice(span, scale, reason))
|
||||
result = []
|
||||
for choice in reversed(choices):
|
||||
if result and choice.scale and result[-1].scale and choice.scale.pitches == result[-1].scale.pitches:
|
||||
choice = dataclasses.replace(choice, scale=result[-1].scale)
|
||||
result.append(choice)
|
||||
return tuple(result)
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ChordSpan:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
measure: int
|
||||
source_measure: str
|
||||
beat: fractions.Fraction
|
||||
key: dict
|
||||
chord: dict | None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
label: str
|
||||
|
||||
def read(staff: dict) -> tuple[ChordSpan, ...]:
|
||||
result = []
|
||||
absolute = fractions.Fraction()
|
||||
active = None
|
||||
for number, measure in enumerate(staff['measures'], 1):
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
harmonies = {generator.model.fraction(h['onset']): h for h in measure['harmonies']}
|
||||
changes = {generator.model.fraction(a['onset']): a['key'] for a in measure.get('attributes', []) if 'key' in a}
|
||||
boundaries = sorted({fractions.Fraction(), length} | set(harmonies) | set(changes))
|
||||
key = measure['key']
|
||||
for start, end in zip(boundaries, boundaries[1:]):
|
||||
key = changes.get(start, key)
|
||||
active = harmonies.get(start, active)
|
||||
result.append(ChordSpan(absolute + start, absolute + end, number, str(measure['number']), start + 1, key, active if active and active['kind'] != 'none' else None))
|
||||
absolute += length
|
||||
return tuple(result)
|
||||
Reference in New Issue
Block a user