@@ -12,11 +12,16 @@ class HarmonySpan:
|
||||
harmony: dict
|
||||
location: str
|
||||
notes: list[dict] = dataclasses.field(default_factory=list)
|
||||
iteration: int = 0
|
||||
|
||||
def harmony_spans(document: dict) -> list[HarmonySpan]:
|
||||
"""Group attacks by continuous harmony, including changes during silence."""
|
||||
"""Group attacks by continuous harmony within each arrangement iteration."""
|
||||
spans, span, active = [], None, None
|
||||
iteration = 0
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if measure.get('section_label'):
|
||||
iteration += 1
|
||||
span = None
|
||||
if {v['id'] for v in measure['voices']} != {'bass', 'upper'}:
|
||||
span, active = None, None
|
||||
continue
|
||||
@@ -30,7 +35,7 @@ def harmony_spans(document: dict) -> list[HarmonySpan]:
|
||||
if kind == 0:
|
||||
continue
|
||||
if span is None:
|
||||
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}')
|
||||
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}', iteration=iteration)
|
||||
spans.append(span)
|
||||
span.notes.append(item)
|
||||
return spans
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
import itertools
|
||||
import typing
|
||||
|
||||
import generator.acoustic
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
|
||||
MAX_ANCHOR_FRET = 12
|
||||
|
||||
class Voicing(typing.NamedTuple):
|
||||
bass: generator.fretboard.TabPosition
|
||||
string4: generator.fretboard.TabPosition
|
||||
string3: generator.fretboard.TabPosition
|
||||
string2: generator.fretboard.TabPosition
|
||||
|
||||
def playable(voicing: Voicing) -> bool:
|
||||
return voicing.bass.string_number in (5, 6) and tuple(p.string_number for p in voicing[1:]) == (4, 3, 2) and all(generator.fretboard.MIN_FRET <= p.fret <= generator.fretboard.MAX_FRET for p in voicing) and min(p.fret for p in voicing) <= MAX_ANCHOR_FRET and generator.fretboard.within_fret_span(voicing) and voicing.bass.midi == min(p.midi for p in voicing)
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def candidates(requirements: generator.fretboard.ChordRequirements, complete_triad: bool = False) -> tuple[Voicing, ...]:
|
||||
options = {s: tuple(generator.fretboard.TabPosition(s, f, midi + f) for f in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (midi + f) % 12 in requirements.allowed) for s, midi in generator.fretboard.STANDARD_TUNING if s != 1}
|
||||
result = []
|
||||
for bass in (*options[5], *options[6]):
|
||||
if requirements.basses and bass.midi % 12 not in requirements.basses:
|
||||
continue
|
||||
for upper in itertools.product(*(options[s] for s in (4, 3, 2))):
|
||||
voicing = Voicing(bass, *upper)
|
||||
if playable(voicing) and requirements.covers({p.midi % 12 for p in voicing}):
|
||||
result.append(voicing)
|
||||
if complete_triad:
|
||||
complete = [v for v in result if set(requirements.allowed) <= {p.midi % 12 for p in v}]
|
||||
result = complete or result
|
||||
return tuple(sorted(result))
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def movement(previous: Voicing, current: Voicing) -> int:
|
||||
return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number)
|
||||
|
||||
class Cost(typing.NamedTuple):
|
||||
travel: int = 0
|
||||
missing_tones: int = 0
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Placement:
|
||||
cost: Cost
|
||||
voicing: Voicing | None = None
|
||||
previous: Placement | None = None
|
||||
|
||||
def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]:
|
||||
result = []
|
||||
for voicing in options:
|
||||
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
|
||||
cost, index = min((Cost(p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states))
|
||||
result.append(Placement(cost, voicing, states[index]))
|
||||
return result
|
||||
|
||||
def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
|
||||
states = [Placement(Cost())]
|
||||
for span in spans:
|
||||
try:
|
||||
requirements = generator.fretboard.chord_requirements(span.harmony)
|
||||
options = candidates(requirements, generator.harmony.is_triad(span.harmony))
|
||||
if not options:
|
||||
raise ValueError(f'no acoustic grip on strings 2-4 plus bass 5 or 6 within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, three-fret span, and at least one fret <= {MAX_ANCHOR_FRET}')
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{span.location}, {generator.harmony.symbol(span.harmony)}: {error}') from error
|
||||
states = advance(options, requirements, states)
|
||||
selected = min(states, key=lambda p: (p.cost, p.voicing))
|
||||
result = []
|
||||
while selected.previous is not None:
|
||||
result.append(selected.voicing)
|
||||
selected = selected.previous
|
||||
return list(reversed(result))
|
||||
|
||||
def place(document: dict) -> None:
|
||||
"""Plan every iteration before materializing its four string-voice journeys."""
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for state in (measure, *measure.get('attributes', [])):
|
||||
if 'staff_details' in state:
|
||||
generator.fretboard.validate_tuning(state, measure['id'])
|
||||
choices = []
|
||||
for _, group in itertools.groupby(generator.acoustic.harmony_spans(document), key=lambda span: span.iteration):
|
||||
spans = list(group)
|
||||
choices.extend(zip(spans, select(spans)))
|
||||
for span, voicing in choices:
|
||||
for note in span.notes:
|
||||
positions = (voicing.bass,) if note['_guitar_lane'] == 'bass' else voicing[1:]
|
||||
for pitch in generator.fretboard.chord_pitches(span.harmony, positions):
|
||||
note['pitches'].append({**pitch, 'id': f'{note["id"]}-s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
|
||||
for field in ('_harmony', '_guitar_profile', '_guitar_lane'):
|
||||
note.pop(field, None)
|
||||
document['staves'][0]['tablature'] = True
|
||||
@@ -6,6 +6,7 @@ import pathlib
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.electric
|
||||
@@ -109,15 +110,17 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
|
||||
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':
|
||||
if part_type == 'acoustic':
|
||||
generator.acoustic.prepare(result)
|
||||
generator.acoustic_voicing.place(result)
|
||||
generator.acoustic.combine(result)
|
||||
elif part_type == 'electric':
|
||||
generator.melody.harmonize(result)
|
||||
voicing_links = generator.electric.link_voicings(source, voicing_origins) if part_type == 'electric' else []
|
||||
voicing_links = generator.electric.link_voicings(source, voicing_origins)
|
||||
generator.guitar.place_guitar(result)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import typing
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 1
|
||||
MAX_FRET = 20
|
||||
MAX_FRET_SPAN = 3
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
|
||||
class TabPosition(typing.NamedTuple):
|
||||
string_number: int
|
||||
fret: int
|
||||
midi: int
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
frets = [position.fret for position in positions]
|
||||
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
|
||||
|
||||
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
|
||||
if midi.denominator != 1:
|
||||
return ()
|
||||
return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET)
|
||||
|
||||
def validate_tuning(state: dict, location: str) -> None:
|
||||
details = state['staff_details']
|
||||
if details.get('capo', 0):
|
||||
raise ValueError(f'{location}: guitar generation does not support capo configurations')
|
||||
tunings = details.get('tunings', [])
|
||||
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
|
||||
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
|
||||
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ChordRequirements:
|
||||
required: tuple[int, ...] = ()
|
||||
allowed: tuple[int, ...] = ()
|
||||
foundation: tuple[int, ...] = ()
|
||||
basses: tuple[int, ...] = ()
|
||||
def covers(self, classes: set[int]) -> bool:
|
||||
return set(self.required) <= classes and bool(set(self.foundation) & classes)
|
||||
|
||||
def chord_requirements(definition: dict, *, respect_bass: bool = True) -> ChordRequirements:
|
||||
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 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 and (d not in (1, 5) or degrees[d])}
|
||||
allowed = set(classes.values())
|
||||
foundation = {value for d, value in classes.items() if d in (1, 5)}
|
||||
if not foundation:
|
||||
raise ValueError('generated guitar chords require a root or fifth in the harmony')
|
||||
bass = definition.get('bass')
|
||||
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
|
||||
if bass_pc is None and 'inversion' in definition:
|
||||
ordered = sorted(degrees)
|
||||
inversion = definition['inversion']
|
||||
if not 0 <= inversion < len(ordered):
|
||||
raise ValueError('harmony inversion exceeds its chord degrees')
|
||||
bass_pc = classes[ordered[inversion]]
|
||||
if not respect_bass:
|
||||
bass_pc = None
|
||||
if bass_pc is not None:
|
||||
allowed.add(bass_pc)
|
||||
required.add(bass_pc)
|
||||
basses = {bass_pc} if bass_pc is not None else set()
|
||||
return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
|
||||
|
||||
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
|
||||
spellings = {}
|
||||
for degree, alter in generator.harmony.degrees(definition).items():
|
||||
value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter))
|
||||
spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value
|
||||
if bass := definition.get('bass'):
|
||||
spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass
|
||||
result = []
|
||||
for position in sorted(positions, key=lambda p: p.midi):
|
||||
value = spellings[position.midi % 12].copy()
|
||||
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1)
|
||||
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
result.append(value)
|
||||
return result
|
||||
+34
-166
@@ -7,27 +7,13 @@ import itertools
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import generator.acoustic
|
||||
import generator.harmony
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 1
|
||||
MAX_FRET = 20
|
||||
PREFERRED_MAX_FRET = 12
|
||||
MAX_FRET_SPAN = 4
|
||||
# Scale-length-normalized reach, in millionths, using frets 7-10 as the reference.
|
||||
PREFERRED_ACOUSTIC_REACH = round(1_000_000 * (2 ** (-7 / 12) - 2 ** (-10 / 12)))
|
||||
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
|
||||
fret: int
|
||||
midi: int
|
||||
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
string_shortfall: int = 0
|
||||
covered_melody: int = 0
|
||||
@@ -41,85 +27,23 @@ class VoicingCost(typing.NamedTuple):
|
||||
return self._replace(effort=self.effort + distance)
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost:
|
||||
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost:
|
||||
frets = {p.fret for p in positions}
|
||||
classes = {p.midi % 12 for p in positions}
|
||||
above = sum(p.midi > max(melody) for p in positions) if melody else 0
|
||||
span = compactness(positions)
|
||||
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET)
|
||||
if profile in ('acoustic', 'electric'):
|
||||
if profile == 'electric':
|
||||
effort += max(frets, default=0) + 2 * max(0, len(frets) - 1)
|
||||
if profile == 'electric':
|
||||
effort += 3 * span
|
||||
effort += 3 * span
|
||||
else:
|
||||
effort += span
|
||||
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, len(set(allowed) - classes))
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
frets = [position.fret for position in positions]
|
||||
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
|
||||
|
||||
def physical_reach(positions: tuple[TabPosition, ...]) -> int:
|
||||
if not positions:
|
||||
return 0
|
||||
frets = [position.fret for position in positions]
|
||||
return round(1_000_000 * (2 ** (-min(frets) / 12) - 2 ** (-max(frets) / 12)))
|
||||
|
||||
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
|
||||
if midi.denominator != 1:
|
||||
return ()
|
||||
return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET)
|
||||
|
||||
def validate_tuning(state: dict, location: str) -> None:
|
||||
details = state['staff_details']
|
||||
if details.get('capo', 0):
|
||||
raise ValueError(f'{location}: guitar generation does not support capo configurations')
|
||||
tunings = details.get('tunings', [])
|
||||
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
|
||||
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
|
||||
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ChordRequirements:
|
||||
required: tuple[int, ...] = ()
|
||||
allowed: tuple[int, ...] = ()
|
||||
foundation: tuple[int, ...] = ()
|
||||
basses: tuple[int, ...] = ()
|
||||
def covers(self, classes: set[int]) -> bool:
|
||||
return set(self.required) <= classes and bool(set(self.foundation) & classes)
|
||||
|
||||
def chord_requirements(definition: dict, profile: str = '') -> ChordRequirements:
|
||||
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 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 and (d not in (1, 5) or degrees[d])}
|
||||
allowed = set(classes.values())
|
||||
foundation = {value for d, value in classes.items() if d in (1, 5)}
|
||||
if not foundation:
|
||||
raise ValueError('generated guitar chords require a root or fifth in the harmony')
|
||||
bass = definition.get('bass')
|
||||
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
|
||||
if bass_pc is None and 'inversion' in definition:
|
||||
ordered = sorted(degrees)
|
||||
inversion = definition['inversion']
|
||||
if not 0 <= inversion < len(ordered):
|
||||
raise ValueError('harmony inversion exceeds its chord degrees')
|
||||
bass_pc = classes[ordered[inversion]]
|
||||
if profile == 'electric':
|
||||
bass_pc = None
|
||||
if bass_pc is not None:
|
||||
allowed.add(bass_pc)
|
||||
required.add(bass_pc)
|
||||
basses = {bass_pc} if bass_pc is not None else allowed if profile == 'acoustic' else set()
|
||||
return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def chord_positions(requirements: ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[TabPosition, ...], ...]:
|
||||
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.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 requirements.allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING]
|
||||
options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING]
|
||||
|
||||
def visit(index, selected, covered):
|
||||
if len(set(requirements.required) - covered) > len(options) - index:
|
||||
@@ -130,21 +54,21 @@ def chord_positions(requirements: ChordRequirements, prune: bool = True, profile
|
||||
return
|
||||
visit(index + 1, selected, covered)
|
||||
for position in options[index]:
|
||||
if position.midi % 12 not in covered and within_fret_span((*selected, position)):
|
||||
if position.midi % 12 not in covered and generator.fretboard.within_fret_span((*selected, position)):
|
||||
visit(index + 1, (*selected, position), covered | {position.midi % 12})
|
||||
|
||||
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:
|
||||
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
|
||||
for strings in ELECTRIC_STRINGS:
|
||||
for selected in itertools.product(*(by_string[s] for s in strings)):
|
||||
if not within_fret_span(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
if not generator.fretboard.within_fret_span(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
continue
|
||||
if requirements.basses and (selected[0].midi % 12 not in requirements.basses or selected[0].midi != min(p.midi for p in selected)):
|
||||
continue
|
||||
result.append(selected)
|
||||
else:
|
||||
visit(0, (), set())
|
||||
if not prune or profile == 'acoustic':
|
||||
if not prune:
|
||||
return tuple(sorted(result))
|
||||
# Independent attacks with equal centers have identical movement costs.
|
||||
# Held strings require the full candidate set instead of this reduction.
|
||||
@@ -156,47 +80,14 @@ def chord_positions(requirements: ChordRequirements, prune: bool = True, profile
|
||||
best[center] = (rank, positions)
|
||||
return tuple(sorted(item[1] for item in best.values()))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def acoustic_positions(requirements: ChordRequirements, complete_triad: bool = False) -> tuple[tuple[TabPosition, ...], ...]:
|
||||
reaches = {shape: physical_reach(shape) for shape in chord_positions(requirements, profile='acoustic')}
|
||||
limit = max(PREFERRED_ACOUSTIC_REACH, min(reaches.values(), default=PREFERRED_ACOUSTIC_REACH))
|
||||
shapes = tuple(shape for shape, reach in reaches.items() if reach <= limit)
|
||||
if complete_triad:
|
||||
complete = tuple(shape for shape in shapes if set(requirements.allowed) <= {p.midi % 12 for p in shape})
|
||||
shapes = complete or shapes
|
||||
return shapes
|
||||
|
||||
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
|
||||
spellings = {}
|
||||
for degree, alter in generator.harmony.degrees(definition).items():
|
||||
value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter))
|
||||
spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value
|
||||
if bass := definition.get('bass'):
|
||||
spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass
|
||||
result = []
|
||||
for position in sorted(positions, key=lambda p: p.midi):
|
||||
value = spellings[position.midi % 12].copy()
|
||||
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1)
|
||||
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
|
||||
def movement(previous: tuple[generator.fretboard.TabPosition, ...], current: tuple[generator.fretboard.TabPosition, ...]) -> int:
|
||||
if not previous or not current:
|
||||
return 0
|
||||
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def acoustic_movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
|
||||
if not previous or not current:
|
||||
return 0
|
||||
before = {p.string_number: p.fret for p in previous}
|
||||
after = {p.string_number: p.fret for p in current}
|
||||
return sum(abs(before[s] - after[s]) for s in before.keys() & after.keys()) + len(before.keys() ^ after.keys())
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
|
||||
def position_center(positions: tuple[generator.fretboard.TabPosition, ...]) -> tuple[int, int]:
|
||||
frets = [p.fret for p in positions] or [0]
|
||||
strings = [p.string_number for p in positions]
|
||||
return min(frets) + max(frets), min(strings) + max(strings) - 2
|
||||
@@ -212,7 +103,7 @@ def extend_positions(held, pending):
|
||||
identifier, options = pending[0]
|
||||
occupied = {p.string_number for p in held.values()}
|
||||
for position in options:
|
||||
if position.string_number not in occupied and within_fret_span((*held.values(), position)):
|
||||
if position.string_number not in occupied and generator.fretboard.within_fret_span((*held.values(), position)):
|
||||
yield from extend_positions({**held, identifier: position}, pending[1:])
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -225,7 +116,7 @@ class Placement:
|
||||
def nearest_positions(states: dict) -> list[list[tuple | None]]:
|
||||
"""Exact Manhattan distance transform on the small fret/string position grid."""
|
||||
initial = min(((p.cost, i, p) for i, p in enumerate(states.values()) if not p.active), key=lambda item: item[:2], default=None)
|
||||
grid = [[initial for _ in range(11)] for _ in range(2 * MAX_FRET + 1)]
|
||||
grid = [[initial for _ in range(11)] for _ in range(2 * generator.fretboard.MAX_FRET + 1)]
|
||||
for index, previous in enumerate(states.values()):
|
||||
if not previous.active:
|
||||
continue
|
||||
@@ -258,34 +149,24 @@ def nearest_positions(states: dict) -> list[list[tuple | None]]:
|
||||
class GuitarTone:
|
||||
pitch: dict
|
||||
end: fractions.Fraction
|
||||
positions: tuple[TabPosition, ...]
|
||||
positions: tuple[generator.fretboard.TabPosition, ...]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class AttackCandidate:
|
||||
active: tuple
|
||||
choices: tuple
|
||||
shape: tuple[TabPosition, ...]
|
||||
shape: tuple[generator.fretboard.TabPosition, ...]
|
||||
cost: VoicingCost
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class EventPlacement:
|
||||
note: dict
|
||||
exact: tuple
|
||||
added: tuple[TabPosition, ...] = ()
|
||||
|
||||
def acoustic_candidates(span: generator.acoustic.HarmonySpan):
|
||||
requirements = chord_requirements(span.harmony, 'acoustic')
|
||||
shapes = acoustic_positions(requirements, set(generator.harmony.degrees(span.harmony)) == {1, 3, 5})
|
||||
if shapes and physical_reach(shapes[0]) > PREFERRED_ACOUSTIC_REACH:
|
||||
logging.getLogger(__name__).warning('%s: %s requires a wider acoustic reach; using the least-reaching valid grips', span.location, generator.harmony.symbol(span.harmony))
|
||||
for shape in shapes:
|
||||
active = tuple((str(p.string_number), p) for p in shape)
|
||||
choices = tuple(EventPlacement(note, (), tuple(p for p in shape if (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))) for note in span.notes)
|
||||
yield AttackCandidate(active, choices, shape, voicing_cost(shape, 'acoustic', requirements.allowed))
|
||||
added: tuple[generator.fretboard.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)
|
||||
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in generator.fretboard.STANDARD_TUNING)
|
||||
return result
|
||||
|
||||
def has_attack(notes: list[dict]) -> bool:
|
||||
@@ -306,7 +187,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
pending.append((tone['id'], options))
|
||||
if fallback and chord and chord.get('_harmonize'):
|
||||
chords, chord, profile = [], None, ''
|
||||
requirements = chord_requirements(chord['_harmony'], profile) if chord else ChordRequirements()
|
||||
requirements = generator.fretboard.chord_requirements(chord['_harmony'], respect_bass=profile != 'electric') if chord else generator.fretboard.ChordRequirements()
|
||||
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
|
||||
fresh = has_attack(notes)
|
||||
candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) if chord else [()]
|
||||
@@ -337,7 +218,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
|
||||
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):
|
||||
if len({p.string_number for p in shape}) != len(shape) or not generator.fretboard.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)
|
||||
@@ -355,14 +236,14 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
state = {k: measure[k] for k in ('transpose', 'staff_details')}
|
||||
for change in [state, *changes]:
|
||||
state.update({k: change[k] for k in state if k in change})
|
||||
validate_tuning(state, measure['id'])
|
||||
generator.fretboard.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:
|
||||
for string, _ in generator.fretboard.STANDARD_TUNING:
|
||||
tones[f'{source["id"]}-s{string}'].pitch['tie_to'] = f'{note["id"]}-s{string}'
|
||||
elif not note['pitches']:
|
||||
note.pop('_harmony', None)
|
||||
@@ -375,14 +256,14 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
state.update({k: change[k] for k in state if k in change})
|
||||
for tone in note['pitches']:
|
||||
sounding = generator.pitch.sounding_pitch(tone, state)
|
||||
options = positions_for_pitch(sounding)
|
||||
options = generator.fretboard.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}')
|
||||
raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}')
|
||||
tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options)
|
||||
if '_harmony' in note:
|
||||
for string, _ in STANDARD_TUNING:
|
||||
for string, _ in generator.fretboard.STANDARD_TUNING:
|
||||
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ())
|
||||
if note['pitches'] or '_harmony' in note:
|
||||
events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note))
|
||||
@@ -403,13 +284,6 @@ def advance_attacks(candidates, states: dict) -> dict:
|
||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
||||
return result
|
||||
|
||||
def advance_acoustic(candidates, states: dict) -> dict:
|
||||
result = {}
|
||||
for candidate in candidates:
|
||||
cost, _, previous = min((p.cost.moved(2 * acoustic_movement(tuple(position for _, position in p.active), candidate.shape)), i, p) for i, p in enumerate(states.values()))
|
||||
result[candidate.active] = Placement(cost.plus(candidate.cost), candidate.active, previous, candidate.choices)
|
||||
return result
|
||||
|
||||
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)
|
||||
fresh = has_attack(notes)
|
||||
@@ -433,24 +307,18 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
||||
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')
|
||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET} and three-fret span; held/tied strings must remain fixed')
|
||||
return result
|
||||
|
||||
def place_guitar(document: dict) -> None:
|
||||
"""Choose acoustic grips per harmony span, or exact/held melody placements."""
|
||||
"""Choose electric voicings or exact/held melody placements."""
|
||||
events, tones = guitar_events(document)
|
||||
states = {(): Placement(VoicingCost(), (), None, ())}
|
||||
if document['staves'][0].get('arrangement_part_type') == 'acoustic':
|
||||
for span in generator.acoustic.harmony_spans(document):
|
||||
states = advance_acoustic(acoustic_candidates(span), states)
|
||||
if not states:
|
||||
raise ValueError(f'{span.location}: no acoustic grip within frets {MIN_FRET}-{MAX_FRET} and four-fret span for {span.harmony.get("symbol", span.harmony["kind"])}')
|
||||
else:
|
||||
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
|
||||
for index, (onset, notes) in enumerate(groups):
|
||||
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
|
||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings)
|
||||
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
|
||||
for index, (onset, notes) in enumerate(groups):
|
||||
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
|
||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + 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:
|
||||
@@ -459,7 +327,7 @@ def place_guitar(document: dict) -> None:
|
||||
for tone, position in choice.exact:
|
||||
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
if definition := note.pop('_harmony', None):
|
||||
for pitch in chord_pitches(definition, choice.added):
|
||||
for pitch in generator.fretboard.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'])
|
||||
|
||||
@@ -72,6 +72,9 @@ def degrees(harmony: dict) -> dict[int, int]:
|
||||
raise ValueError('harmony contains no pitch information')
|
||||
return result
|
||||
|
||||
def is_triad(harmony: dict) -> bool:
|
||||
return set(degrees(harmony)) == {1, 3, 5}
|
||||
|
||||
def degree_semitones(degree: int, alter: int) -> int:
|
||||
return 12 * ((degree - 1) // 7) + (0, 2, 4, 5, 7, 9, 11)[(degree - 1) % 7] + alter
|
||||
|
||||
|
||||
+3
-3
@@ -206,17 +206,17 @@ def validate_document(document: dict) -> None:
|
||||
else:
|
||||
pitch_number(p)
|
||||
if staves[0].get('tablature'):
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.pitch
|
||||
tab = p.get('tablature', {})
|
||||
string, fret = tab.get('string'), tab.get('fret')
|
||||
if type(string) is not int or type(fret) is not int or string not in dict(generator.guitar.STANDARD_TUNING) or not generator.guitar.MIN_FRET <= fret <= generator.guitar.MAX_FRET:
|
||||
if type(string) is not int or type(fret) is not int or string not in dict(generator.fretboard.STANDARD_TUNING) or not generator.fretboard.MIN_FRET <= fret <= generator.fretboard.MAX_FRET:
|
||||
raise ValueError(f'{p["id"]}: invalid generated tablature position')
|
||||
state = dict(measure)
|
||||
for change in measure.get('attributes', []):
|
||||
if fraction(change['onset']) <= onset:
|
||||
state.update(change)
|
||||
if dict(generator.guitar.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
|
||||
if dict(generator.fretboard.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
|
||||
raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch')
|
||||
pd = fraction(p['duration'])
|
||||
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import fractions
|
||||
import pathlib
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.rhythm
|
||||
@@ -58,7 +58,7 @@ def validate_source(document: dict) -> None:
|
||||
if state.get('clef', {}).get('sign') in ('TAB', 'percussion'):
|
||||
raise ValueError(f'{measure["id"]}: parts require a pitched, non-TAB source')
|
||||
if 'staff_details' in state:
|
||||
generator.guitar.validate_tuning(state, measure['id'])
|
||||
generator.fretboard.validate_tuning(state, measure['id'])
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
if any('tablature' in p or 'unpitched' in p for p in note['pitches']):
|
||||
|
||||
Reference in New Issue
Block a user