Fret Limit

This commit is contained in:
khodowany
2026-10-01 12:06:02 -05:00
parent 5414caf30a
commit a7b64c5361
16 changed files with 252 additions and 28 deletions
+4 -1
View File
@@ -62,8 +62,10 @@ def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
try:
requirements = generator.fretboard.chord_requirements(span.harmony)
options = candidates(requirements, generator.harmony.is_triad(span.harmony))
lanes = {n['_guitar_lane'] for n in span.notes}
options = tuple(v for v in options if ('bass' not in lanes or v.bass.fret <= generator.fretboard.MAX_SINGLE_FRET) and ('upper' not in lanes or generator.fretboard.has_fret_anchor(v[1:])))
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}, at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions, and at least one fret <= {generator.fretboard.MAX_ANCHOR_FRET}')
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}, at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; played bass notes need frets 1-{generator.fretboard.MAX_SINGLE_FRET} and played upper chords need their own anchor <= {generator.fretboard.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)
@@ -92,3 +94,4 @@ def place(document: dict) -> None:
for field in ('_harmony', '_guitar_profile', '_guitar_lane'):
note.pop(field, None)
document['staves'][0]['tablature'] = True
generator.fretboard.validate_document(document)
+2
View File
@@ -10,6 +10,7 @@ import generator.acoustic_voicing
import generator.arrange.form
import generator.arrange.pickup
import generator.electric
import generator.fretboard
import generator.guitar
import generator.melody
import generator.model
@@ -124,6 +125,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
generator.electric.reuse_voicings(voicing_links)
generator.fretboard.validate_document(result)
if scales and solos:
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
if reports is not None:
+43
View File
@@ -11,6 +11,7 @@ import generator.pitch
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 1
MAX_FRET = 20
MAX_SINGLE_FRET = 12
MAX_FRET_SPAN = 3
MAX_ANCHOR_FRET = 12
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
@@ -33,6 +34,48 @@ def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
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 single_positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
return tuple(p for p in positions_for_pitch(midi) if p.fret <= MAX_SINGLE_FRET)
def single_register(register: tuple[int, int]) -> tuple[int, int]:
return max(register[0], min(p + MIN_FRET for _, p in STANDARD_TUNING)), min(register[1], max(p + MAX_SINGLE_FRET for _, p in STANDARD_TUNING))
def playable_attack(tones) -> bool:
"""Check a played note/chord, including tails after shorter tones release.
Each item is a (position, sounding duration) pair. Independent parts and
unsounded members of an underlying grip cannot supply the chord's anchor.
"""
tones = tuple(tones)
for time in {0, *(duration for _, duration in tones if duration > 0)}:
positions = tuple(p for p, duration in tones if duration > time or time == duration == 0)
if not positions:
continue
if any(p not in positions_for_pitch(fractions.Fraction(p.midi)) for p in positions) or len({p.string_number for p in positions}) != len(positions) or not within_fret_span(positions):
return False
if len(positions) == 1:
if positions[0].fret > MAX_SINGLE_FRET:
return False
elif not has_fret_anchor(positions):
return False
return True
def validate_document(document: dict) -> None:
"""Validate generated guitar policy separately from the score wire format."""
for staff in document['staves']:
if not staff.get('tablature'):
continue
for measure in staff['measures']:
for voice in measure['voices']:
for note in voice['notes']:
tones = []
for pitch in note['pitches']:
tab = pitch['tablature']
position = TabPosition(tab['string'], tab['fret'], dict(STANDARD_TUNING)[tab['string']] + tab['fret'])
tones.append((position, generator.model.fraction(pitch['duration'])))
if not playable_attack(tones):
raise ValueError(f'{measure["id"]}, event {note["id"]}: single notes require frets {MIN_FRET}-{MAX_SINGLE_FRET}; each sounding chord requires an anchor in {MIN_FRET}-{MAX_ANCHOR_FRET} and at most {MAX_FRET_SPAN} consecutive fret positions, including held/tied tails')
def validate_tuning(state: dict, location: str) -> None:
details = state['staff_details']
if details.get('capo', 0):
+9 -3
View File
@@ -47,7 +47,7 @@ def chord_positions(requirements: generator.fretboard.ChordRequirements, prune:
if len(set(requirements.required) - covered) > len(options) - index:
return
if index == len(options):
if selected and requirements.covers(covered) and (not requirements.basses or min(selected, key=lambda p: p.midi).midi % 12 in requirements.basses):
if selected and generator.fretboard.has_fret_anchor(selected) and requirements.covers(covered) and (not requirements.basses or min(selected, key=lambda p: p.midi).midi % 12 in requirements.basses):
result.append(tuple(selected))
return
visit(index + 1, selected, covered)
@@ -162,6 +162,9 @@ class EventPlacement:
exact: tuple
added: tuple[generator.fretboard.TabPosition, ...] = ()
def playable_choices(choices) -> bool:
return all(generator.fretboard.playable_attack((*((position, generator.model.fraction(tone['duration'])) for tone, position in choice.exact), *((position, generator.model.fraction(choice.note['duration'])) for position in choice.added))) for choice in choices)
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 generator.fretboard.STANDARD_TUNING)
@@ -210,7 +213,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
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:
if not compatible or not playable_choices(choices):
continue
cost = voicing_cost(positions, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
@@ -220,6 +223,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
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)
if not playable_choices(choices):
continue
cost = voicing_cost(shape, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
@@ -305,7 +310,7 @@ 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 {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET} and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; electric chords need a fret <= {generator.fretboard.MAX_ANCHOR_FRET}; held/tied strings must remain fixed')
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed')
return result
def place_guitar(document: dict) -> None:
@@ -338,3 +343,4 @@ def place_guitar(document: dict) -> 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
generator.fretboard.validate_document(document)
+4 -1
View File
@@ -6,6 +6,7 @@ import pathlib
import re
import generator.engraving.validation
import generator.fretboard
import generator.model
import generator.pico_artwork
import generator.playback
@@ -40,6 +41,7 @@ def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str]
def write_json(path: pathlib.Path, document: dict) -> None:
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
@@ -128,7 +130,8 @@ def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None
validated_pages = set() if validated_pages is None else validated_pages
for path in root.rglob('*.json'):
document = json.loads(path.read_text(encoding='utf-8'))
validate_json(document, path, validated_pages)
music = validate_json(document, path, validated_pages)
generator.fretboard.validate_document(music)
if 'presentation' in document:
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
if 'playback' in document:
+2
View File
@@ -8,6 +8,7 @@ import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.form
import generator.electric
import generator.fretboard
import generator.guitar
import generator.model
import generator.parts
@@ -122,4 +123,5 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None):
reports.append(generator.solo.report.describe(solo, scales, first, offset))
for document in parts.values():
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
return '\n'.join(reports)
+1 -1
View File
@@ -118,7 +118,7 @@ def validate(solo):
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
raise ValueError(f'{solo.part} solo: held pitch {note.pitch.label()} violates a scale span at {note.start}-{note.end}')
if note.position not in generator.fretboard.positions_for_pitch(fractions.Fraction(note.pitch.number())):
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note
for phrase in solo.phrases:
+1 -1
View File
@@ -41,4 +41,4 @@ def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fractio
@functools.lru_cache(maxsize=128)
def positions(number: int):
return generator.fretboard.positions_for_pitch(fractions.Fraction(number))
return generator.fretboard.single_positions_for_pitch(fractions.Fraction(number))
+4 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import dataclasses
import fractions
import generator.fretboard
import generator.harmony
import generator.model
import generator.scales.selection
@@ -50,7 +51,7 @@ def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fra
for path in paths:
for start in range(-14, 15):
pitches = tuple(generator.solo.types.scale_pitch(scale, start + offset) for offset in path.offsets())
if not all(register[0] <= pitch.number() <= register[1] for pitch in pitches):
if not all(register[0] <= pitch.number() <= register[1] and generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())) for pitch in pitches):
continue
score = (generator.solo.sequences.family(path.steps) != shape, pitches[-1].number() % 12 not in target, abs(path.offsets()[-1] - displacement), abs(pitches[0].number() - (register[0] + 5)), generator.solo.policy.stable_rank(seed, (variant, path.steps, start)))
candidates.append((score, pitches, path))
@@ -137,7 +138,7 @@ def anchor_backbones(identifier, start, end, release, register, choices, bar, se
duration = terminal_duration if role == 'terminal' else fractions.Fraction(1, 2)
latest = time if role == 'terminal' else min(time + fractions.Fraction(3, 2), choice.span.end - duration, terminal_start - duration)
pitches = [generator.solo.types.scale_pitch(choice.scale, degree) for degree in range(-21, 28)]
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible]
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible and generator.fretboard.single_positions_for_pitch(fractions.Fraction(p.number()))]
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
following = []
for cost, anchors in states:
@@ -182,6 +183,7 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
low, high = (55, 74) if part == 'acoustic' else (59, 79)
phase = (start + finish) / (2 * end)
register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4)
register = generator.fretboard.single_register(register)
bar = boundaries[index + 1] - start
release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction()
release = min(release, (finish - start) / 8)
+2 -2
View File
@@ -103,7 +103,7 @@ def cells(plan, measures, rhythms, choices=()):
@functools.lru_cache(maxsize=2048)
def scale_options(scale, allowed, low, high):
return tuple(generator.solo.types.scale_pitch(scale, index) for index in range(-21, 28) if low <= generator.solo.types.scale_pitch(scale, index).number() <= high and generator.solo.types.scale_pitch(scale, index).number() % 12 in allowed and generator.fretboard.positions_for_pitch(fractions.Fraction(generator.solo.types.scale_pitch(scale, index).number())))
return tuple(generator.solo.types.scale_pitch(scale, index) for index in range(-21, 28) if low <= generator.solo.types.scale_pitch(scale, index).number() <= high and generator.solo.types.scale_pitch(scale, index).number() % 12 in allowed and generator.fretboard.single_positions_for_pitch(fractions.Fraction(generator.solo.types.scale_pitch(scale, index).number())))
def valid_notes(notes, choices):
return all(n.pitch.number() % 12 in generator.solo.types.permitted(choices, n.start, n.end) for n in notes)
@@ -111,7 +111,7 @@ def valid_notes(notes, choices):
def options(choices, start, end, register):
scale = next((c.scale for c in choices if c.span.start <= start < c.span.end), None)
allowed = generator.solo.types.permitted(choices, start, end)
return scale_options(scale, allowed, max(41, register[0] - 7), min(84, register[1] + 5)) if scale and allowed else ()
return scale_options(scale, allowed, *generator.fretboard.single_register((register[0] - 7, register[1] + 5))) if scale and allowed else ()
def target_ok(notes, target):
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)