Generation
This commit is contained in:
+90
-43
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import itertools
|
||||
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.rhythm
|
||||
|
||||
@@ -18,7 +21,8 @@ class Context:
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Plan:
|
||||
acoustic: tuple[generator.rhythm.Bar, ...]
|
||||
acoustic_bass: tuple[generator.rhythm.Bar, ...]
|
||||
acoustic_upper: tuple[generator.rhythm.Bar, ...]
|
||||
electric: tuple[generator.rhythm.Bar, ...]
|
||||
|
||||
def active_chord(measure: dict, onset: fractions.Fraction, previous: dict | None) -> dict | None:
|
||||
@@ -56,37 +60,48 @@ def context(measure: dict, previous: dict | None) -> Context:
|
||||
occupied.append((start, end))
|
||||
return Context(measure, previous, offset, intervals, frozenset(attacks), tuple(occupied))
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def sounding_rhythm(bar, offset, intervals):
|
||||
attacks = tuple(t - offset for t in bar.attacks() if any(a <= t - offset < b for a, b in intervals))
|
||||
spans = tuple((max(event.onset - offset, a), min(event.onset + event.duration - offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > offset for a, b in intervals if a <= max(event.onset - offset, 0) < b)
|
||||
return attacks, spans
|
||||
|
||||
def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]:
|
||||
return tuple(t - item.offset for t in bar.attacks() if any(a <= t - item.offset < b for a, b in item.intervals))
|
||||
return sounding_rhythm(bar, item.offset, item.intervals)[0]
|
||||
|
||||
def sounding_spans(bar: generator.rhythm.Bar, item: Context) -> tuple[tuple[fractions.Fraction, fractions.Fraction], ...]:
|
||||
return tuple((max(event.onset - item.offset, a), min(event.onset + event.duration - item.offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > item.offset for a, b in item.intervals if a <= max(event.onset - item.offset, 0) < b)
|
||||
return sounding_rhythm(bar, item.offset, item.intervals)[1]
|
||||
|
||||
def rank(bar: generator.rhythm.Bar, contexts: list[tuple[Context, tuple[fractions.Fraction, ...]]], electric: bool) -> tuple:
|
||||
missing_bars, uncovered, density, collisions, occupied, pulse = 0, 0, fractions.Fraction(), 0, 0, 0
|
||||
for item, acoustic in contexts:
|
||||
attacks = sounding_attacks(bar, item)
|
||||
spans = sounding_spans(bar, item)
|
||||
missing_bars += bool(item.intervals) and not spans and not electric
|
||||
uncovered += sum(not any(start < b and end > a for start, end in spans) for a, b in item.intervals)
|
||||
duration = sum((b - a for a, b in item.intervals), fractions.Fraction())
|
||||
density += abs(len(attacks) - duration / (4 if electric else 2))
|
||||
collisions += sum((t in item.attacks) + (t in acoustic) for t in attacks)
|
||||
occupied += sum(any(a <= t < b for a, b in item.occupied) for t in attacks)
|
||||
pulse -= sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks)
|
||||
return missing_bars, uncovered, density, collisions, occupied, pulse
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def measure_rank(bar, offset, intervals, melody_attacks, melody_spans, companions, role):
|
||||
attacks, spans = sounding_rhythm(bar, offset, intervals)
|
||||
missing_bars = int(bool(intervals) and not spans and role != 'electric')
|
||||
uncovered = sum(not any(start < b and end > a for start, end in spans) for a, b in intervals)
|
||||
duration = sum((b - a for a, b in intervals), fractions.Fraction())
|
||||
density = abs(len(attacks) - duration / {'electric': 4, 'bass': 2, 'upper': 1}[role])
|
||||
collisions = sum((t in melody_attacks) + (t in companions) for t in attacks)
|
||||
occupied = sum(any(a <= t < b for a, b in melody_spans) for t in attacks)
|
||||
pulse = -sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks)
|
||||
if role == 'electric':
|
||||
return missing_bars, density, collisions, occupied, uncovered, pulse
|
||||
return missing_bars, uncovered, density, pulse if role == 'bass' else collisions, collisions if role == 'bass' else pulse, occupied
|
||||
|
||||
def rank(bar: generator.rhythm.Bar, contexts: list[tuple[Context, tuple[fractions.Fraction, ...]]], role: str) -> tuple:
|
||||
result = (0, 0, 0, 0, 0, 0)
|
||||
for item, companions in contexts:
|
||||
result = add_ranks(result, measure_rank(bar, item.offset, item.intervals, item.attacks, item.occupied, companions, role))
|
||||
return result
|
||||
|
||||
def add_ranks(left: tuple, right: tuple) -> tuple:
|
||||
return tuple(a + b for a, b in zip(left, right))
|
||||
|
||||
def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context], acoustic: list[generator.rhythm.Bar] | None = None) -> tuple[generator.rhythm.Bar, ...]:
|
||||
electric = acoustic is not None
|
||||
def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context], role: str, companions: list[tuple[fractions.Fraction, ...]] | None = None) -> tuple[generator.rhythm.Bar, ...]:
|
||||
bars = {}
|
||||
for snippet in snippets:
|
||||
for index, bar in enumerate(snippet.bars):
|
||||
bars.setdefault(bar, f'{snippet.id}:{index}')
|
||||
phases = [[(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 2 == phase] for phase in range(2)]
|
||||
scores = [{bar: rank(bar, phase, electric) for bar in bars} for phase in phases]
|
||||
phases = [[(item, companions[i] if companions else ()) for i, item in enumerate(contexts) if i % 2 == phase] for phase in range(2)]
|
||||
scores = [{bar: rank(bar, phase, role) for bar in bars} for phase in phases]
|
||||
candidates = [(add_ranks(scores[0][s.bars[0]], scores[1][s.bars[1]]), s.id, s.bars) for s in snippets if len(s.bars) == 2]
|
||||
single = [s for s in snippets if len(s.bars) == 1]
|
||||
if single:
|
||||
@@ -96,20 +111,20 @@ def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context
|
||||
if score[0]:
|
||||
failing = next(item.measure for i, item in enumerate(contexts) if item.intervals and not sounding_spans(pair[i % 2], item))
|
||||
raise ValueError(f'measure {failing["number"]}: no rhythm motif provides acoustic accompaniment in every harmonically active bar')
|
||||
fourth = [(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 4 == 3]
|
||||
fourth = [(item, companions[i] if companions else ()) for i, item in enumerate(contexts) if i % 4 == 3]
|
||||
second = pair[1]
|
||||
alternatives = [bar for bar in bars if sum(bar.beat(n) != second.beat(n) for n in range(int(second.length))) == 1]
|
||||
variation = min(alternatives, key=lambda bar: (rank(bar, fourth, electric), bars[bar])) if alternatives and fourth else second
|
||||
if rank(variation, fourth, electric)[0]:
|
||||
variation = min(alternatives, key=lambda bar: (rank(bar, fourth, role), bars[bar])) if alternatives and fourth else second
|
||||
if rank(variation, fourth, role)[0]:
|
||||
variation = second
|
||||
result = (pair[0], second, pair[0], variation)
|
||||
if electric and any(item.intervals for item in contexts) and not any(sounding_spans(result[i % 4], item) for i, item in enumerate(contexts)):
|
||||
if role == 'electric' and any(item.intervals for item in contexts) and not any(sounding_spans(result[i % 4], item) for i, item in enumerate(contexts)):
|
||||
raise ValueError(f'measure {contexts[0].measure["number"]}: rhythm library cannot provide an audible electric response')
|
||||
return result
|
||||
|
||||
def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True) -> Plan:
|
||||
previous = carried_chords(measures, initial)
|
||||
acoustic, responses = [], []
|
||||
basses, uppers, responses = [], [], []
|
||||
start = 0
|
||||
while start < len(measures):
|
||||
meter = measures[start]['time_signature']
|
||||
@@ -122,13 +137,15 @@ def plan_section(measures: list[dict], library: generator.rhythm.Library, *, ini
|
||||
if any('meter' in change and generator.model.fraction(change['onset']) != 0 for change in measure.get('attributes', [])):
|
||||
raise ValueError(f'measure {measure["number"]}: accompaniment does not support mid-measure meter changes')
|
||||
contexts = [context(m, previous[start + i]) for i, m in enumerate(segment)]
|
||||
foundation = cycle(snippets, contexts)
|
||||
chosen = [foundation[i % 4] for i in range(len(segment))]
|
||||
response = cycle(snippets, contexts, chosen) if electric else foundation
|
||||
acoustic.extend(chosen)
|
||||
bass = cycle(snippets, contexts, 'bass')
|
||||
upper = cycle(snippets, contexts, 'upper')
|
||||
companions = [tuple(sorted(set(sounding_attacks(bass[i % 4], item)) | set(sounding_attacks(upper[i % 4], item)))) for i, item in enumerate(contexts)]
|
||||
response = cycle(snippets, contexts, 'electric', companions) if electric else upper
|
||||
basses.extend(bass[i % 4] for i in range(len(segment)))
|
||||
uppers.extend(upper[i % 4] for i in range(len(segment)))
|
||||
responses.extend(response[i % 4] for i in range(len(segment)))
|
||||
start = end
|
||||
return Plan(tuple(acoustic), tuple(responses))
|
||||
return Plan(tuple(basses), tuple(uppers), tuple(responses))
|
||||
|
||||
def same_chord(left: dict | None, right: dict | None) -> bool:
|
||||
return left is not None and right is not None and all(left.get(k) == right.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion'))
|
||||
@@ -159,7 +176,7 @@ def normalize_beams(events: list[dict]) -> None:
|
||||
if not event.get('beams'):
|
||||
event.pop('beams', None)
|
||||
|
||||
def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None) -> list[dict]:
|
||||
def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *, lane: str = '') -> list[dict]:
|
||||
item = context(measure, previous)
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
events = []
|
||||
@@ -178,44 +195,74 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None) ->
|
||||
if not same_chord(chord, preceding):
|
||||
chord = None
|
||||
sounding_end = end
|
||||
if chord and lane == 'electric':
|
||||
if continuation:
|
||||
chord = None
|
||||
else:
|
||||
sounding_end = min(end, start + 1)
|
||||
for change in measure['harmonies']:
|
||||
time = generator.model.fraction(change['onset'])
|
||||
if chord and start < time < sounding_end and not same_chord(chord, active_chord(measure, time, previous)):
|
||||
sounding_end = time
|
||||
written = {'type': source.written_type, 'dots': source.dots} if end - start == source.duration and sounding_end == end else None
|
||||
event = generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', start, sounding_end - start, 'note' if chord else 'rest', written=written)
|
||||
event = generator.model.make_event(f'{measure["id"]}-rhythm-{lane}-{len(events)}', start, sounding_end - start, 'note' if chord else 'rest', written=written)
|
||||
if chord:
|
||||
event['_harmony'] = chord
|
||||
if lane:
|
||||
event.update(_guitar_profile='electric' if lane == 'electric' else 'acoustic', _guitar_lane=lane)
|
||||
if continuation:
|
||||
event['_rhythm_continuation'] = True
|
||||
if source.beams and written and chord:
|
||||
event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams]
|
||||
events.append(event)
|
||||
if sounding_end < end:
|
||||
events.append(generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', sounding_end, end - sounding_end, 'rest'))
|
||||
events.append(generator.model.make_event(f'{measure["id"]}-rhythm-{lane}-{len(events)}', sounding_end, end - sounding_end, 'rest'))
|
||||
normalize_beams(events)
|
||||
return events
|
||||
|
||||
def connect_continuations(document: dict) -> bool:
|
||||
previous = None
|
||||
previous = {}
|
||||
changed = False
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if measure.get('section_label'):
|
||||
previous = None
|
||||
previous.clear()
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
continuation = note.pop('_rhythm_continuation', False)
|
||||
if continuation and previous and previous['pitches'] and note['pitches']:
|
||||
note['pitches'] = copy.deepcopy(previous['pitches'])
|
||||
for index, tone in enumerate(note['pitches']):
|
||||
source = previous['pitches'][index]
|
||||
tone.update(id=f'{note["id"]}-p{index + 1}', duration=copy.deepcopy(note['duration']), written=copy.deepcopy(note['written']), tie_from=source['id'])
|
||||
tone.pop('tie_to', None)
|
||||
source_note = previous.get(voice['id'])
|
||||
if continuation and source_note and source_note['pitches'] and note['pitches']:
|
||||
old = {(p['tablature']['string'], p['tablature']['fret']): p for p in source_note['pitches']}
|
||||
for tone in note['pitches']:
|
||||
source = old[(tone['tablature']['string'], tone['tablature']['fret'])]
|
||||
tone['tie_from'] = source['id']
|
||||
source['tie_to'] = tone['id']
|
||||
changed = True
|
||||
elif continuation:
|
||||
note.update(kind='rest', pitches=[])
|
||||
note.pop('beams', None)
|
||||
changed = True
|
||||
previous = note
|
||||
previous[voice['id']] = note
|
||||
return changed
|
||||
|
||||
def prepare_basses(document: dict) -> None:
|
||||
previous, attack, current_bass = None, 0, None
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if measure.get('section_label'):
|
||||
previous, attack = None, 0
|
||||
notes = sorted((n for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic'), key=lambda n: generator.model.fraction(n['onset']))
|
||||
for _, group in itertools.groupby(notes, key=lambda n: generator.model.fraction(n['onset'])):
|
||||
group = list(group)
|
||||
chord = group[0]['_harmony']
|
||||
root = (generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) % 12
|
||||
if not same_chord(chord, previous):
|
||||
attack, current_bass = 0, root
|
||||
explicit = 'bass' in chord or 'inversion' in chord
|
||||
if any(n['_guitar_lane'] == 'bass' and not n.get('_rhythm_continuation') for n in group):
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
current_bass = (root + generator.harmony.degree_semitones(5, degrees[5])) % 12 if not explicit and attack % 2 and 5 in degrees else root
|
||||
attack += 1
|
||||
if explicit:
|
||||
current_bass = generator.guitar.chord_requirements(chord, 'acoustic')[2]
|
||||
for note in group:
|
||||
note['_bass_pc'] = current_bass
|
||||
previous = chord
|
||||
|
||||
@@ -7,6 +7,7 @@ import pathlib
|
||||
import generator.accompaniment
|
||||
import generator.arrange.form
|
||||
import generator.guitar
|
||||
import generator.melody
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
@@ -62,8 +63,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
rhythm = None
|
||||
if section.role == 'accompaniment':
|
||||
plan = plans[section.section_index]
|
||||
rhythm = (plan.acoustic if part_type == 'acoustic' else plan.electric)[len(segment)]
|
||||
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm)
|
||||
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)]
|
||||
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm, melody_octave=True)
|
||||
segment.append(result)
|
||||
identity_segment = segment
|
||||
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
|
||||
@@ -112,6 +113,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
result = generator.score.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':
|
||||
generator.accompaniment.prepare_basses(result)
|
||||
generator.melody.harmonize(result)
|
||||
generator.guitar.place_guitar(result)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
generator.guitar.place_guitar(result)
|
||||
|
||||
@@ -24,6 +24,10 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
fragments, primitives = {}, {}
|
||||
previous_end = fractions.Fraction()
|
||||
for page in layout.pages:
|
||||
if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1):
|
||||
raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system')
|
||||
if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01:
|
||||
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
|
||||
audit_primitives(page.primitives, page.width, page.height, fonts)
|
||||
for item in page.primitives:
|
||||
for source in item.sources:
|
||||
@@ -36,7 +40,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
for anchor in system.anchors:
|
||||
if anchor.fragment.kind == 'tone':
|
||||
expected = index.pitch_position(index.tones[anchor.source][1], anchor.time)
|
||||
if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01:
|
||||
if abs(anchor.y - (baseline - expected * layout.style.space * system.scale / 2)) > 0.01:
|
||||
raise generator.engraving.types.EngravingError(f'{anchor.source}: displayed staff position changed pitch')
|
||||
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]
|
||||
@@ -51,7 +55,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
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_y = tab_baseline + (4 - (6 - tab['string']) * layout.style.tab_space) * system.scale
|
||||
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:
|
||||
@@ -67,7 +71,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
continue
|
||||
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
|
||||
continue
|
||||
if item.box.overlaps(other.box, 0.1):
|
||||
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}')
|
||||
if previous_end != index.end:
|
||||
raise generator.engraving.types.EngravingError('layout omits the end of the score')
|
||||
|
||||
@@ -53,14 +53,14 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
|
||||
measure_start, _, measure = index.measure_at(time)
|
||||
for owner, _, item in index.harmonies_by_onset.get(time, []):
|
||||
if owner is measure:
|
||||
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],)))
|
||||
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
|
||||
for owner, i, value in index.tempos_by_onset.get(time, []):
|
||||
if owner is measure:
|
||||
items.extend(generator.engraving.attributes.tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}'))
|
||||
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'):
|
||||
items.append(fonts.text(label, 0, 0, 19, 'section', (measure['id'] + ':section',)))
|
||||
items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',)))
|
||||
return items, anchors, fragments
|
||||
|
||||
def column_metrics(index, time, start, end, fonts, style, columns):
|
||||
@@ -157,14 +157,12 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
|
||||
|
||||
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.Page, ...]:
|
||||
pages = []
|
||||
placed = []
|
||||
header = []
|
||||
cursor = style.margin_y
|
||||
first = True
|
||||
guitar = document['staves'][0].get('tablature', False)
|
||||
header_gap = 14 if guitar else 24
|
||||
|
||||
def page_header(number):
|
||||
title = document['metadata']['title']
|
||||
size = 26 if number == 1 else 17
|
||||
size = (20 if number == 1 else 16) if guitar else (26 if number == 1 else 17)
|
||||
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
|
||||
while item.box.right > style.width - style.margin_x and size > 12:
|
||||
size -= 1
|
||||
@@ -175,32 +173,23 @@ def paginate(systems: list[generator.engraving.types.System], document: dict, fo
|
||||
if number == 1:
|
||||
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
|
||||
if subtitle:
|
||||
result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + 22, 16, 'subtitle', ()))
|
||||
result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + (15 if guitar else 22), 12 if guitar else 16, 'subtitle', ()))
|
||||
return result
|
||||
|
||||
for system in systems:
|
||||
height = system.box.bottom - system.box.top
|
||||
if first:
|
||||
header = page_header(len(pages) + 1)
|
||||
cursor = generator.engraving.types.bounds(header).bottom + 24
|
||||
first = False
|
||||
if cursor + height > style.height - style.margin_y - 18:
|
||||
if not placed:
|
||||
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it')
|
||||
number = len(pages) + 1
|
||||
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
|
||||
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
|
||||
placed = []
|
||||
header = page_header(len(pages) + 1)
|
||||
cursor = generator.engraving.types.bounds(header).bottom + 24
|
||||
if cursor + height > style.height - style.margin_y - 18:
|
||||
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page')
|
||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||
moved = dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))
|
||||
placed.append(moved)
|
||||
cursor += height + style.system_gap
|
||||
if placed:
|
||||
for first in range(0, len(systems), 2):
|
||||
number = len(pages) + 1
|
||||
header = page_header(number)
|
||||
top = generator.engraving.types.bounds(header).bottom + header_gap
|
||||
row_height = (style.height - style.margin_y - 24 - top - style.system_gap) / 2
|
||||
if row_height <= 0:
|
||||
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
|
||||
pair = systems[first:first + 2]
|
||||
scale = min(1, row_height / max(s.box.bottom - s.box.top for s in pair))
|
||||
placed = []
|
||||
for row, system in enumerate(pair):
|
||||
system = system.scaled(scale)
|
||||
dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - system.box.top
|
||||
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
|
||||
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
|
||||
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
|
||||
return tuple(pages)
|
||||
@@ -240,6 +229,7 @@ def layout_systems(document: dict, style: generator.engraving.types.Style, fonts
|
||||
return tuple(systems)
|
||||
|
||||
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
|
||||
style = generator.engraving.types.score_style(document, style)
|
||||
systems = layout_systems(document, style, fonts)
|
||||
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
|
||||
generator.engraving.audit.audit_layout(document, layout, fonts)
|
||||
|
||||
@@ -39,7 +39,8 @@ def draw_event(index: generator.engraving.notation.ScoreIndex, event: generator.
|
||||
if position := note.get('rest_position'):
|
||||
y = -index.pitch_position(position, onset) * style.space / 2
|
||||
elif event.lanes > 1:
|
||||
y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * 3
|
||||
separation = 1 if index.staff.get('tablature') and event.lanes == 2 else 3
|
||||
y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * separation
|
||||
kind = 'slash' if note.get('notehead') == 'slash' else 'rest'
|
||||
fragment = generator.engraving.types.Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
|
||||
fragments.append(fragment)
|
||||
|
||||
@@ -120,6 +120,7 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
|
||||
for path, score_key in paths:
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
style = generator.engraving.types.score_style(document, generator.engraving.types.Style())
|
||||
destination = path.with_suffix('.pages')
|
||||
clear_pages(destination)
|
||||
destination.mkdir()
|
||||
|
||||
@@ -11,14 +11,13 @@ def column(index, event, anchors, fonts, style):
|
||||
sources = (event.value['id'],)
|
||||
if not event.value['pitches']:
|
||||
anchor = anchors[0]
|
||||
glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else generator.engraving.notes.rest_glyph(anchor.fragment.written_type)
|
||||
return [fonts.glyph(glyph, x, -2 * style.space, style.space, 'tab-' + anchor.fragment.kind, sources)]
|
||||
return [fonts.glyph('noteheadSlashHorizontalEnds', x, -2 * style.tab_space, style.space, 'tab-slash', sources)] if anchor.fragment.kind == 'slash' else []
|
||||
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'])))
|
||||
y = -(6 - tab['string']) * style.tab_space
|
||||
result.append(fonts.text(str(tab['fret']), x, y + 4, style.tab_font_size, 'tab', (event.value['id'], tone['id'])))
|
||||
return result
|
||||
|
||||
def staff(index, items, start, end, left, right, fonts, style):
|
||||
@@ -26,12 +25,12 @@ def staff(index, items, start, end, left, right, fonts, style):
|
||||
numbers = [p for p in items if p.role.startswith('tab')]
|
||||
if not index.staff.get('tablature'):
|
||||
return items
|
||||
offset = generator.engraving.types.bounds(notation).bottom + 32 + 5 * style.space
|
||||
offset = generator.engraving.types.bounds(notation).bottom + style.tab_gap + 5 * style.tab_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', ()))
|
||||
drawing.append(fonts.text('TAB', 0, offset - 2 * style.tab_space, style.tab_font_size, 'tab-label', ()))
|
||||
for string in range(1, 7):
|
||||
y = offset - (6 - string) * style.space
|
||||
y = offset - (6 - string) * style.tab_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)]:
|
||||
@@ -39,7 +38,7 @@ def staff(index, items, start, end, left, right, fonts, style):
|
||||
drawing.append(generator.engraving.types.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(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness))
|
||||
drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 * style.tab_space / (4 * style.space)) 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')
|
||||
@@ -53,7 +52,7 @@ def staff(index, items, start, end, left, right, fonts, style):
|
||||
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
|
||||
y = offset - (6 - tone['tablature']['string']) * style.tab_space + 6
|
||||
if x2 > x1:
|
||||
drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
|
||||
return notation + drawing
|
||||
|
||||
@@ -17,6 +17,13 @@ class Style:
|
||||
supersampling: int = 4
|
||||
column_gap: float = 10
|
||||
system_gap: float = 26
|
||||
guitar_space: float = 10
|
||||
tab_space: float = 10.5
|
||||
tab_gap: float = 12
|
||||
tab_font_size: float = 12
|
||||
|
||||
def score_style(document: dict, style: Style) -> Style:
|
||||
return dataclasses.replace(style, space=style.guitar_space, system_gap=14) if document['staves'][0].get('tablature') else style
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Box:
|
||||
@@ -26,6 +33,8 @@ class Box:
|
||||
bottom: float
|
||||
def moved(self, x: float, y: float) -> Box:
|
||||
return Box(self.left + x, self.top + y, self.right + x, self.bottom + y)
|
||||
def scaled(self, factor: float) -> Box:
|
||||
return Box(self.left * factor, self.top * factor, self.right * factor, self.bottom * factor)
|
||||
def overlaps(self, other: Box, clearance: float = 0) -> bool:
|
||||
return self.left < other.right + clearance and other.left < self.right + clearance and self.top < other.bottom + clearance and other.top < self.bottom + clearance
|
||||
def finite(self) -> bool:
|
||||
@@ -43,6 +52,8 @@ class Primitive:
|
||||
thickness: float = 0
|
||||
def moved(self, x: float, y: float) -> Primitive:
|
||||
return dataclasses.replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
|
||||
def scaled(self, factor: float) -> Primitive:
|
||||
return dataclasses.replace(self, box=self.box.scaled(factor), points=tuple((x * factor, y * factor) for x, y in self.points), size=self.size * factor, thickness=self.thickness * factor)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Fragment:
|
||||
@@ -75,6 +86,8 @@ class Anchor:
|
||||
fragment: Fragment
|
||||
def moved(self, x: float, y: float) -> Anchor:
|
||||
return dataclasses.replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
|
||||
def scaled(self, factor: float) -> Anchor:
|
||||
return dataclasses.replace(self, x=self.x * factor, y=self.y * factor, stem_x=self.stem_x * factor, stem_y=self.stem_y * factor)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class System:
|
||||
@@ -84,6 +97,9 @@ class System:
|
||||
anchors: tuple[Anchor, ...]
|
||||
fragments: tuple[Fragment, ...]
|
||||
box: Box
|
||||
scale: float = 1
|
||||
def scaled(self, factor: float) -> System:
|
||||
return dataclasses.replace(self, primitives=tuple(p.scaled(factor) for p in self.primitives), anchors=tuple(a.scaled(factor) for a in self.anchors), box=self.box.scaled(factor), scale=self.scale * factor)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Page:
|
||||
|
||||
+118
-27
@@ -4,6 +4,7 @@ import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import itertools
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import generator.harmony
|
||||
@@ -15,6 +16,8 @@ MIN_FRET = 0
|
||||
MAX_FRET = 20
|
||||
MAX_FRET_SPAN = 4
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
ACOUSTIC_STRINGS = ((6, 4, 3), (6, 4, 3, 2), (5, 4, 3), (5, 3, 2), (5, 4, 3, 2))
|
||||
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
|
||||
|
||||
class TabPosition(typing.NamedTuple):
|
||||
string_number: int
|
||||
@@ -39,14 +42,14 @@ def validate_tuning(state: dict, location: str) -> None:
|
||||
if tunings and (len(tunings) != 6 or {t['line']: generator.model.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]:
|
||||
def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int, ...], tuple[int, ...], int | None]:
|
||||
degrees = generator.harmony.degrees(definition)
|
||||
root = definition['root']
|
||||
root_pc = generator.model.NATURAL[root['letter']] + generator.model.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 + generator.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}
|
||||
required = {value for d, value in classes.items() if d not in optional and not (profile == 'electric' and d == 1)}
|
||||
allowed = set(classes.values())
|
||||
bass = definition.get('bass')
|
||||
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
|
||||
@@ -56,15 +59,19 @@ def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ..
|
||||
if not 0 <= inversion < len(ordered):
|
||||
raise ValueError('harmony inversion exceeds its chord degrees')
|
||||
bass_pc = classes[ordered[inversion]]
|
||||
if profile == 'electric':
|
||||
bass_pc = None
|
||||
if bass_pc is not None:
|
||||
allowed.add(bass_pc)
|
||||
required.add(bass_pc)
|
||||
if profile == 'acoustic' and bass_pc is None:
|
||||
bass_pc = root_pc % 12
|
||||
return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> tuple[tuple[TabPosition, ...], ...]:
|
||||
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> 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]
|
||||
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING]
|
||||
|
||||
def visit(index, selected, covered):
|
||||
if len(set(required) - covered) > len(options) - index:
|
||||
@@ -78,7 +85,17 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i
|
||||
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 profile:
|
||||
by_string = {string: options[i] for i, (string, _) in enumerate(STANDARD_TUNING)}
|
||||
for strings in ACOUSTIC_STRINGS if profile == 'acoustic' else ELECTRIC_STRINGS:
|
||||
for selected in itertools.product(*(by_string[s] for s in strings)):
|
||||
if not within_fret_span(selected) or not set(required) <= {p.midi % 12 for p in selected} or not set(melody) <= {p.midi for p in selected}:
|
||||
continue
|
||||
if bass is not None and (selected[0].midi % 12 != bass or selected[0].midi != min(p.midi for p in selected)):
|
||||
continue
|
||||
result.append(selected)
|
||||
else:
|
||||
visit(0, (), set())
|
||||
if not prune:
|
||||
return tuple(sorted(result))
|
||||
# Independent attacks with equal centers have identical movement costs.
|
||||
@@ -183,34 +200,83 @@ class AttackCandidate:
|
||||
shape: tuple[TabPosition, ...]
|
||||
missing: int
|
||||
|
||||
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool):
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class EventPlacement:
|
||||
note: dict
|
||||
exact: tuple
|
||||
added: tuple[TabPosition, ...] = ()
|
||||
|
||||
def continued_sources(notes):
|
||||
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
|
||||
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING)
|
||||
return result
|
||||
|
||||
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False):
|
||||
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
|
||||
profile = chord.get('_guitar_profile', '') if chord else ''
|
||||
if len(chords) > 1 and (profile != 'acoustic' or any(n['_harmony'] != chord['_harmony'] for n in chords)):
|
||||
raise ValueError('simultaneous generated voices must share one acoustic harmony')
|
||||
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
|
||||
if fallback and chord and chord.get('_harmonize'):
|
||||
chords, chord, profile = [], None, ''
|
||||
requirements = chord_requirements(chord['_harmony'], profile) if chord else ((), (), None)
|
||||
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
|
||||
basses = [chord.get('_bass_pc', requirements[2])] if chord else [None]
|
||||
if profile == 'acoustic' and requirements[2] not in basses:
|
||||
basses.append(requirements[2])
|
||||
for bass in basses:
|
||||
candidates = chord_positions(requirements[0], requirements[1], bass, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) if chord else [()]
|
||||
found = False
|
||||
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
|
||||
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}))
|
||||
for positions in candidates:
|
||||
if profile:
|
||||
if any(p not in positions for p in exact_positions.values()):
|
||||
continue
|
||||
assignment = dict(exact)
|
||||
choices = []
|
||||
compatible = True
|
||||
for note in notes:
|
||||
added = tuple(p for p in positions if p not in exact_positions.values() and (note in chords and (profile != 'acoustic' or (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))))
|
||||
if any(p.midi % 12 not in requirements[1] for p in added):
|
||||
compatible = False
|
||||
break
|
||||
if '_continue_from' in note:
|
||||
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')}
|
||||
if previous != set(added):
|
||||
compatible = False
|
||||
break
|
||||
assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added)
|
||||
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
|
||||
if not compatible:
|
||||
continue
|
||||
found = True
|
||||
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
|
||||
else:
|
||||
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
|
||||
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
|
||||
found = True
|
||||
yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
|
||||
if found:
|
||||
break
|
||||
|
||||
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
events, tones = [], {}
|
||||
absolute = fractions.Fraction()
|
||||
previous = {}
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if measure.get('section_label'):
|
||||
previous.clear()
|
||||
changes = sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset']))
|
||||
state = {k: measure[k] for k in ('transpose', 'staff_details')}
|
||||
for change in [state, *changes]:
|
||||
@@ -218,6 +284,16 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
validate_tuning(state, measure['id'])
|
||||
for voice in measure['voices']:
|
||||
for order, note in enumerate(voice['notes']):
|
||||
if note.get('_rhythm_continuation'):
|
||||
source = previous.get(voice['id'])
|
||||
if source and '_harmony' in source:
|
||||
note['_continue_from'] = source['id']
|
||||
for string, _ in STANDARD_TUNING:
|
||||
tones[f'{source["id"]}-s{string}'].pitch['tie_to'] = f'{note["id"]}-s{string}'
|
||||
elif not note['pitches']:
|
||||
note.pop('_harmony', None)
|
||||
note['kind'] = 'rest'
|
||||
previous[voice['id']] = note
|
||||
onset = generator.model.fraction(note['onset'])
|
||||
state = {k: measure[k] for k in ('transpose', 'staff_details')}
|
||||
for change in changes:
|
||||
@@ -226,6 +302,8 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
for tone in note['pitches']:
|
||||
sounding = generator.pitch.sounding_pitch(tone, state)
|
||||
options = positions_for_pitch(sounding)
|
||||
if tab := tone.get('tablature'):
|
||||
options = tuple(p for p in options if (p.string_number, p.fret) == (tab['string'], tab['fret']))
|
||||
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 + generator.model.fraction(tone['duration']), options)
|
||||
@@ -240,8 +318,8 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
|
||||
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
|
||||
|
||||
def advance_positions(notes: list[dict], onset: fractions.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}
|
||||
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict:
|
||||
continued = continued_sources(notes)
|
||||
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
|
||||
result = {}
|
||||
|
||||
@@ -252,7 +330,7 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
||||
|
||||
if not held_ids and not continued:
|
||||
nearest = nearest_positions(states)
|
||||
for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings):
|
||||
for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings, fallback):
|
||||
x, y = position_center(candidate.shape)
|
||||
cost, _, previous = nearest[x][y]
|
||||
keep(candidate, previous, cost)
|
||||
@@ -260,10 +338,12 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
||||
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):
|
||||
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback):
|
||||
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:
|
||||
if not fallback and any(n.get('_harmonize') for n in notes):
|
||||
return advance_positions(notes, onset, tones, states, preserve_strings, True)
|
||||
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
|
||||
|
||||
@@ -277,12 +357,23 @@ def place_guitar(document: dict) -> None:
|
||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.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))
|
||||
skipped = []
|
||||
while selected.previous is not None:
|
||||
for note, choices in selected.choices:
|
||||
for choice in selected.choices:
|
||||
note = choice.note
|
||||
for tone, position in choice.exact:
|
||||
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
if definition := note.pop('_harmony', None):
|
||||
note['pitches'] = generator.model.make_event(note['id'], generator.model.fraction(note['onset']), generator.model.fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches']
|
||||
else:
|
||||
for tone, position in choices:
|
||||
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
for pitch in chord_pitches(definition, choice.added):
|
||||
note['pitches'].append({**pitch, 'id': f'{note["id"]}-s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
|
||||
if note.get('_harmonize') and not choice.added and not note.get('_rhythm_continuation'):
|
||||
skipped.append(note['id'])
|
||||
selected = selected.previous
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
for field in ('_guitar_profile', '_guitar_lane', '_bass_pc', '_harmonize', '_continue_from'):
|
||||
note.pop(field, None)
|
||||
if skipped:
|
||||
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
|
||||
document['staves'][0]['tablature'] = True
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
|
||||
def harmonize(document: dict) -> None:
|
||||
"""Split long melody runs at harmony changes before choosing guitar positions."""
|
||||
staff = document['staves'][0]
|
||||
tones, events, changes = {}, [], []
|
||||
absolute = fractions.Fraction()
|
||||
previous = None
|
||||
for measure in staff['measures']:
|
||||
for chord in measure['harmonies']:
|
||||
value = chord if chord['kind'] != 'none' else None
|
||||
if value != previous and not generator.accompaniment.same_chord(value, previous):
|
||||
time = absolute + generator.model.fraction(chord['onset'])
|
||||
if changes and changes[-1][0] == time:
|
||||
changes[-1] = (time, value)
|
||||
else:
|
||||
changes.append((time, value))
|
||||
previous = value
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
start = absolute + generator.model.fraction(note['onset'])
|
||||
events.append((note, start))
|
||||
for tone in note['pitches']:
|
||||
tones[tone['id']] = tone
|
||||
absolute += generator.model.fraction(measure['duration'])
|
||||
eligible = set()
|
||||
visited = set()
|
||||
for note, _ in events:
|
||||
if not note.get('_melody') or note.get('grace') or note.get('cue') or len(note['pitches']) != 1 or note['pitches'][0].get('cue'):
|
||||
continue
|
||||
tone = note['pitches'][0]
|
||||
if tone['id'] in visited:
|
||||
continue
|
||||
while tone.get('tie_from'):
|
||||
tone = tones[tone['tie_from']]
|
||||
chain = []
|
||||
while True:
|
||||
chain.append(tone)
|
||||
if not tone.get('tie_to'):
|
||||
break
|
||||
tone = tones[tone['tie_to']]
|
||||
visited.update(p['id'] for p in chain)
|
||||
if sum((generator.model.fraction(p['duration']) for p in chain), fractions.Fraction()) > 1:
|
||||
eligible.update(p['id'] for p in chain)
|
||||
times = [time for time, _ in changes]
|
||||
reattacks = {time for time, chord in changes if chord}
|
||||
replacements, tone_pieces = {}, {}
|
||||
for note, start in events:
|
||||
melodic = note.pop('_melody', False)
|
||||
if not melodic or not note['pitches'] or note['pitches'][0]['id'] not in eligible:
|
||||
continue
|
||||
end = start + generator.model.fraction(note['duration'])
|
||||
cuts = [start, *times[bisect.bisect_right(times, start):bisect.bisect_left(times, end)], end]
|
||||
pieces = []
|
||||
for index, (a, b) in enumerate(zip(cuts, cuts[1:])):
|
||||
fragment = copy.deepcopy(note)
|
||||
fragment['id'] = note['id'] if index == 0 else f'{note["id"]}-h{index}'
|
||||
fragment['onset'] = generator.model.rational(generator.model.fraction(note['onset']) + a - start)
|
||||
fragment['duration'] = generator.model.rational(b - a)
|
||||
if len(cuts) > 2:
|
||||
fragment['written'] = generator.model.written_for(b - a)
|
||||
fragment.pop('beams', None)
|
||||
for tone in fragment['pitches']:
|
||||
original = tone['id']
|
||||
tone['id'] = original if index == 0 else f'{original}-h{index}'
|
||||
tone['duration'], tone['written'] = fragment['duration'], copy.deepcopy(fragment['written'])
|
||||
tone.pop('tie_from', None)
|
||||
tone.pop('tie_to', None)
|
||||
tone_pieces.setdefault(original, []).append((tone, a))
|
||||
if index:
|
||||
fragment.pop('lyrics', None)
|
||||
current = bisect.bisect_right(times, a) - 1
|
||||
chord = changes[current][1] if current >= 0 else None
|
||||
if chord:
|
||||
fragment.update(_harmony=chord, _guitar_profile='electric', _harmonize=True)
|
||||
pieces.append(fragment)
|
||||
replacements[note['id']] = pieces
|
||||
for identifier, pieces in tone_pieces.items():
|
||||
original = tones[identifier]
|
||||
links = list(zip(pieces, pieces[1:]))
|
||||
if source := original.get('tie_from'):
|
||||
previous_piece = tone_pieces[source][-1] if source in tone_pieces else (tones[source], None)
|
||||
links.insert(0, (previous_piece, pieces[0]))
|
||||
previous_piece[0].pop('tie_to', None)
|
||||
if target := original.get('tie_to'):
|
||||
if target not in tone_pieces:
|
||||
pieces[-1][0]['tie_to'] = target
|
||||
tones[target]['tie_from'] = pieces[-1][0]['id']
|
||||
for (first, _), (second, start) in links:
|
||||
if start not in reattacks:
|
||||
first['tie_to'], second['tie_from'] = second['id'], first['id']
|
||||
by_tone = {p['id']: note for original, _ in events for note in replacements.get(original['id'], [original]) for p in note['pitches']}
|
||||
for measure in staff['measures']:
|
||||
for voice in measure['voices']:
|
||||
voice['notes'] = [piece for note in voice['notes'] for piece in replacements.get(note['id'], [note])]
|
||||
for note in voice['notes']:
|
||||
if note.get('_harmonize') and (source := note['pitches'][0].get('tie_from')):
|
||||
earlier = by_tone[source]
|
||||
if generator.accompaniment.same_chord(earlier.get('_harmony'), note['_harmony']):
|
||||
note['_rhythm_continuation'] = True
|
||||
generator.accompaniment.normalize_beams(voice['notes'])
|
||||
for tuplet in staff.get('tuplets', []):
|
||||
if tuplet['end'] in replacements:
|
||||
tuplet['end'] = replacements[tuplet['end']][-1]['id']
|
||||
+11
-3
@@ -73,12 +73,20 @@ def validate_source(document: dict, *, practice: bool = False) -> None:
|
||||
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 = '', rhythm: generator.rhythm.Bar | None = None) -> dict:
|
||||
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | tuple[generator.rhythm.Bar, generator.rhythm.Bar] | None = None, *, melody_octave: bool = False) -> dict:
|
||||
voices = generator.score.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 generator.accompaniment.realize(source, rhythm, previous) if rhythm is not None else generated_voice(source, role, previous)
|
||||
if role == 'accompaniment' and part == 'acoustic':
|
||||
voices = [{'id': lane, 'notes': generator.accompaniment.realize(source, bar, previous, lane=lane)} for lane, bar in zip(('upper', 'bass'), (rhythm[1], rhythm[0]))]
|
||||
for voice in voices:
|
||||
for note in voice['notes']:
|
||||
note.update(voice=voice['id'], stem='down' if voice['id'] == 'bass' else 'up')
|
||||
notes = [n for v in voices for n in v['notes']] if voices else generator.accompaniment.realize(source, rhythm, previous, lane='electric') if rhythm is not None else generated_voice(source, role, previous)
|
||||
if role in ('melody', 'vocal'):
|
||||
for note in notes:
|
||||
note['pitches'] = [generator.pitch.shift_pitch(p, 7, 12) for p in note['pitches']]
|
||||
octaves = 2 if melody_octave and role == 'melody' else 1
|
||||
note['pitches'] = [generator.pitch.shift_pitch(p, 7 * octaves, 12 * octaves) for p in note['pitches']]
|
||||
if role == 'melody':
|
||||
note['_melody'] = True
|
||||
result = generator.score.make_measure(identifier, source, [] if voices else notes, section_label=label)
|
||||
if voices:
|
||||
result['voices'] = voices
|
||||
|
||||
@@ -4,6 +4,7 @@ import pathlib
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.guitar
|
||||
import generator.melody
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.rhythm
|
||||
@@ -23,7 +24,7 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani
|
||||
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
|
||||
if part_type == 'acoustic' and plan is None:
|
||||
raise ValueError('acoustic practice requires a rhythm plan')
|
||||
measures = [generator.parts.build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', ''), plan.acoustic[i] if part_type == 'acoustic' else None) for i, m in enumerate(staff['measures'])]
|
||||
measures = [generator.parts.build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', ''), (plan.acoustic_bass[i], plan.acoustic_upper[i]) if part_type == 'acoustic' else None) for i, m in enumerate(staff['measures'])]
|
||||
for measure in measures:
|
||||
for chord in measure['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
@@ -32,6 +33,8 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani
|
||||
if part_type == 'vocal':
|
||||
generator.scat.add_scat(result)
|
||||
else:
|
||||
generator.accompaniment.prepare_basses(result)
|
||||
generator.melody.harmonize(result)
|
||||
generator.guitar.place_guitar(result)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
generator.guitar.place_guitar(result)
|
||||
|
||||
Reference in New Issue
Block a user