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
+1 -1
View File
@@ -122,7 +122,7 @@ Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats
There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order. There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order.
Guitar parts share standard tuning and chord-tone helpers but use separate acoustic and electric placement systems. Both use frets 1-20, exclude open strings, and limit grips to three consecutive fret positions. Frets 7-9 qualify; frets 7-10 do not. Complete acoustic and electric chords require at least one occupied string at fret 12 or below; this anchor is an eligibility check, not a penalty for higher notes within the grip. Acoustic requires a bass on string 5 or 6 plus all three upper strings 4, 3, and 2. Electric uses chords on strings 1-3 or 1-4. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Electric melodies are raised one octave; vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment or exact melody pitches fail generation. Single-note melodies do not require a fret-12 anchor. Guitar parts share standard tuning and chord-tone helpers but use separate acoustic and electric placement systems. Single notes in every guitar part, including solos, melodies, bass notes, and harmonization fallbacks, must use frets 1-12. Chords may extend above fret 12, exclude open strings, and fit within three consecutive fret positions. Frets 7-9 qualify; frets 7-10 do not. Each played acoustic or electric chord requires at least one occupied string at fret 12 or below; this anchor is an eligibility check, not a penalty for higher notes within the grip. Acoustic requires a bass on string 5 or 6 plus all three upper strings 4, 3, and 2. Electric uses chords on strings 1-3 or 1-4. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Electric melodies are raised one octave; vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment or exact melody pitches fail generation. Acoustic bass attacks and upper-chord attacks qualify independently; an unsounded member of the underlying grip cannot supply the anchor. Held chord tones must retain a sounding anchor, including across notation ties. Solo registers, anchors, pitch paths, and placement all respect the single-note limit before selection. Unreachable exact pitches or explicit TAB fail without silent octave fitting.
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product. All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
+4 -1
View File
@@ -62,8 +62,10 @@ def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
try: try:
requirements = generator.fretboard.chord_requirements(span.harmony) requirements = generator.fretboard.chord_requirements(span.harmony)
options = candidates(requirements, generator.harmony.is_triad(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: 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: except ValueError as error:
raise ValueError(f'{span.location}, {generator.harmony.symbol(span.harmony)}: {error}') from error raise ValueError(f'{span.location}, {generator.harmony.symbol(span.harmony)}: {error}') from error
states = advance(options, requirements, states) states = advance(options, requirements, states)
@@ -92,3 +94,4 @@ def place(document: dict) -> None:
for field in ('_harmony', '_guitar_profile', '_guitar_lane'): for field in ('_harmony', '_guitar_profile', '_guitar_lane'):
note.pop(field, None) note.pop(field, None)
document['staves'][0]['tablature'] = True 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.form
import generator.arrange.pickup import generator.arrange.pickup
import generator.electric import generator.electric
import generator.fretboard
import generator.guitar import generator.guitar
import generator.melody import generator.melody
import generator.model 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): if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
generator.electric.reuse_voicings(voicing_links) generator.electric.reuse_voicings(voicing_links)
generator.fretboard.validate_document(result)
if scales and solos: if scales and solos:
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title']) report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
if reports is not None: 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)) STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 1 MIN_FRET = 1
MAX_FRET = 20 MAX_FRET = 20
MAX_SINGLE_FRET = 12
MAX_FRET_SPAN = 3 MAX_FRET_SPAN = 3
MAX_ANCHOR_FRET = 12 MAX_ANCHOR_FRET = 12
WRITTEN_GUITAR_OCTAVE_OFFSET = 12 WRITTEN_GUITAR_OCTAVE_OFFSET = 12
@@ -33,6 +34,48 @@ def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
return () 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) 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: def validate_tuning(state: dict, location: str) -> None:
details = state['staff_details'] details = state['staff_details']
if details.get('capo', 0): 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: if len(set(requirements.required) - covered) > len(options) - index:
return return
if index == len(options): 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)) result.append(tuple(selected))
return return
visit(index + 1, selected, covered) visit(index + 1, selected, covered)
@@ -162,6 +162,9 @@ class EventPlacement:
exact: tuple exact: tuple
added: tuple[generator.fretboard.TabPosition, ...] = () 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): def continued_sources(notes):
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p} 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) 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 break
assignment.update((f'{note["id"]}_s{p.string_number}', p) for p in added) 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)) 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 continue
cost = voicing_cost(positions, profile, requirements.allowed, melody) if fresh else VoicingCost() cost = voicing_cost(positions, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost) 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 continue
assignment = tuple(sorted((*exact, *((f'{chord["id"]}_s{p.string_number}', p) for p in positions)))) if chord else exact 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) 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() cost = voicing_cost(shape, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost) 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 result:
if not fallback and any(n.get('_harmonize') for n in notes): if not fallback and any(n.get('_harmonize') for n in notes):
return advance_positions(notes, onset, tones, states, preserve_strings, True) 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 return result
def place_guitar(document: dict) -> None: def place_guitar(document: dict) -> None:
@@ -338,3 +343,4 @@ def place_guitar(document: dict) -> None:
if skipped: if skipped:
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1]) 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 document['staves'][0]['tablature'] = True
generator.fretboard.validate_document(document)
+4 -1
View File
@@ -6,6 +6,7 @@ import pathlib
import re import re
import generator.engraving.validation import generator.engraving.validation
import generator.fretboard
import generator.model import generator.model
import generator.pico_artwork import generator.pico_artwork
import generator.playback 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: def write_json(path: pathlib.Path, document: dict) -> None:
generator.model.validate_document(document) generator.model.validate_document(document)
generator.fretboard.validate_document(document)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') 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 validated_pages = set() if validated_pages is None else validated_pages
for path in root.rglob('*.json'): for path in root.rglob('*.json'):
document = json.loads(path.read_text(encoding='utf-8')) 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: if 'presentation' in document:
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image')) generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
if 'playback' in document: if 'playback' in document:
+2
View File
@@ -8,6 +8,7 @@ import generator.acoustic
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.form import generator.arrange.form
import generator.electric import generator.electric
import generator.fretboard
import generator.guitar import generator.guitar
import generator.model import generator.model
import generator.parts 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)) reports.append(generator.solo.report.describe(solo, scales, first, offset))
for document in parts.values(): for document in parts.values():
generator.model.validate_document(document) generator.model.validate_document(document)
generator.fretboard.validate_document(document)
return '\n'.join(reports) 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}') 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): 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}') 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}') raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note previous = note
for phrase in solo.phrases: 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) @functools.lru_cache(maxsize=128)
def positions(number: int): 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 dataclasses
import fractions import fractions
import generator.fretboard
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.scales.selection import generator.scales.selection
@@ -50,7 +51,7 @@ def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fra
for path in paths: for path in paths:
for start in range(-14, 15): for start in range(-14, 15):
pitches = tuple(generator.solo.types.scale_pitch(scale, start + offset) for offset in path.offsets()) 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 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))) 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)) 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) 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) 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 = [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) desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
following = [] following = []
for cost, anchors in states: 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) low, high = (55, 74) if part == 'acoustic' else (59, 79)
phase = (start + finish) / (2 * end) 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 = (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 bar = boundaries[index + 1] - start
release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction() release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction()
release = min(release, (finish - start) / 8) 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) @functools.lru_cache(maxsize=2048)
def scale_options(scale, allowed, low, high): 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): 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) 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): def options(choices, start, end, register):
scale = next((c.scale for c in choices if c.span.start <= start < c.span.end), None) 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) 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): 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) return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
+4 -2
View File
@@ -100,6 +100,8 @@ class EngravingTests(unittest.TestCase):
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([wide] * 32)) document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([wide] * 32))
for measure in document['staves'][0]['measures']: for measure in document['staves'][0]['measures']:
measure['transpose']['octave_change'] = -1 measure['transpose']['octave_change'] = -1
# Keep the tall written range while sounding inside the single-note limit.
measure['attributes'].append({'onset': generator.model.rational(2), 'transpose': {**measure['transpose'], 'octave_change': -2}})
generator.guitar.place_guitar(document) generator.guitar.place_guitar(document)
tall = self.layout(document) tall = self.layout(document)
self.assertGreater(len(tall.pages), 1) self.assertGreater(len(tall.pages), 1)
@@ -380,8 +382,8 @@ class EngravingTests(unittest.TestCase):
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>' percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>')) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole') self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole')
tab = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6))), 'electric', rhythms=tests.rhythm_fixtures.library()) tab = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=5))), 'electric', rhythms=tests.rhythm_fixtures.library())
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '20') self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '12')
def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self): def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self):
content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8)) content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2)), 'electric', rhythms=tests.rhythm_fixtures.library()) source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2)), 'electric', rhythms=tests.rhythm_fixtures.library())
+163
View File
@@ -0,0 +1,163 @@
import copy
import fractions
import pathlib
import tempfile
import unittest
import unittest.mock
import generator.acoustic
import generator.acoustic_voicing
import generator.electric
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.model
import generator.score
import generator.solo.analysis
import generator.solo.planning
import generator.solo.placement
import generator.solo.realization
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_musicxml
import tests.test_solo
import tests.test_solo_development
def position(string, fret):
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
def document(positions):
result = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
note = tests.test_musicxml.notes(result)[0]
spellings = (('c', 0), ('c', 1), ('d', 0), ('e', -1), ('e', 0), ('f', 0), ('f', 1), ('g', 0), ('a', -1), ('a', 0), ('b', -1), ('b', 0))
pitches = []
for p in positions:
letter, alter = spellings[p.midi % 12]
pitches.append({'letter': letter, 'alter': alter, 'octave': p.midi // 12 - 1, 'tablature': {'string': p.string_number, 'fret': p.fret}})
note.update(generator.model.make_event(note['id'], fractions.Fraction(), fractions.Fraction(4), 'note', pitches))
result['staves'][0]['tablature'] = True
return result
class FretPolicyTests(unittest.TestCase):
def test_single_limits_and_alternate_string(self):
for midi, expected in ((40, False), (41, True), (72, True), (76, True), (77, False), (84, False)):
with self.subTest(midi=midi):
positions = generator.fretboard.single_positions_for_pitch(fractions.Fraction(midi))
self.assertEqual(bool(positions), expected)
self.assertTrue(all(1 <= p.fret <= 12 for p in positions))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=5)))
generator.guitar.place_guitar(source)
self.assertEqual(tests.test_musicxml.notes(source)[0]['pitches'][0]['tablature'], {'string': 1, 'fret': 8})
def test_explicit_high_single_and_unreachable_pitch_fail_without_rewriting(self):
for p in (position(2, 13), position(1, 13)):
source = document((p,))
before = copy.deepcopy(source)
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
generator.guitar.place_guitar(source)
self.assertEqual(source, before)
with self.assertRaisesRegex(ValueError, 'single notes require frets 1-12'):
generator.fretboard.validate_document(source)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('F', octave=5)))
before = copy.deepcopy(source)
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
generator.guitar.place_guitar(source)
self.assertEqual(source, before)
def test_chord_anchor_and_span_are_hard_constraints(self):
for frets, valid in (((12, 14), True), ((13, 14), False), ((12, 15), False), ((0, 2), False)):
with self.subTest(frets=frets):
source = document(tuple(position(s, f) for s, f in zip((2, 1), frets)))
if valid:
generator.guitar.place_guitar(source)
generator.model.validate_document(source)
generator.fretboard.validate_document(source)
else:
with self.assertRaises(ValueError):
generator.guitar.place_guitar(source)
with self.assertRaises(ValueError):
generator.fretboard.validate_document(source)
def test_high_chord_tail_cannot_outlast_anchor(self):
source = document((position(2, 12), position(1, 14)))
anchor = tests.test_musicxml.notes(source)[0]['pitches'][0]
anchor.update(duration=generator.model.rational(2), written=generator.model.written_for(fractions.Fraction(2)))
generator.model.validate_document(source)
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
generator.fretboard.validate_document(source)
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
generator.guitar.place_guitar(source)
def test_notation_ties_preserve_chord_policy_and_fixed_positions(self):
source = document((position(2, 12), position(1, 14)))
original = tests.test_musicxml.notes(source)[0]
split = [generator.model.make_event(f'split_{i}', fractions.Fraction(2 * i), fractions.Fraction(2), 'note', original['pitches']) for i in range(2)]
for a, b in zip(split[0]['pitches'], split[1]['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
source['staves'][0]['measures'][0]['voices'][0]['notes'] = split
generator.guitar.place_guitar(source)
generator.model.validate_document(source)
generator.fretboard.validate_document(source)
self.assertEqual([p['tablature'] for p in split[0]['pitches']], [p['tablature'] for p in split[1]['pitches']])
split[0]['pitches'][0].pop('tie_to')
split[1]['pitches'].pop(0)
generator.model.validate_document(source)
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
generator.fretboard.validate_document(source)
def test_another_voice_cannot_supply_a_single_notes_anchor(self):
source = document((position(2, 12), position(1, 13)))
measure = source['staves'][0]['measures'][0]
pitches = tests.test_musicxml.notes(source)[0]['pitches']
measure['voices'] = [{'id': f'voice_{i}', 'notes': [generator.model.make_event(f'n_{i}', fractions.Fraction(), fractions.Fraction(4), 'note', [p])]} for i, p in enumerate(pitches)]
with self.assertRaisesRegex(ValueError, 'single notes require'):
generator.fretboard.validate_document(source)
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
generator.guitar.place_guitar(source)
def test_harmonization_fallback_obeys_single_limit(self):
for p, valid in ((position(2, 13), False), (position(1, 8), True)):
source = document((p,))
note = tests.test_musicxml.notes(source)[0]
note.update(_harmony=generator.harmony.parse_symbol('CM'), _guitar_profile='electric', _harmonize=True)
with unittest.mock.patch('generator.guitar.chord_positions', return_value=()):
if valid:
with self.assertLogs('generator.guitar', level='WARNING'):
generator.guitar.place_guitar(source)
self.assertEqual(len(note['pitches']), 1)
else:
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
generator.guitar.place_guitar(source)
def test_acoustic_played_parts_need_independent_anchors(self):
source = tests.test_acoustic_voicing.planned_document([[(0, 'CM')]])
spans = generator.acoustic.harmony_spans(source)
for frets in ((13, 12, 12, 12), (12, 13, 13, 13)):
grip = tests.test_acoustic_voicing.voicing(frets)
self.assertTrue(generator.acoustic_voicing.playable(grip))
with unittest.mock.patch('generator.acoustic_voicing.candidates', return_value=(grip,)):
with self.assertRaisesRegex(ValueError, 'played bass notes.*played upper chords'):
generator.acoustic_voicing.select(spans)
generator.acoustic_voicing.place(source)
generator.fretboard.validate_document(source)
def test_copied_unanchored_voicing_cannot_be_published(self):
source = document(tuple(position(s, 13) for s in (3, 2, 1)))
target = document(tuple(position(s, 12) for s in (3, 2, 1)))
reference, note = tests.test_musicxml.notes(source)[0], tests.test_musicxml.notes(target)[0]
generator.electric.reuse_voicings([generator.electric.VoicingLink(note, (reference,))])
generator.model.validate_document(target)
with tempfile.TemporaryDirectory() as directory:
output = pathlib.Path(directory) / 'score.json'
with self.assertRaisesRegex(ValueError, 'each sounding chord requires an anchor'):
generator.score.write_json(output, target)
self.assertFalse(output.exists())
def test_solo_candidates_and_anchors_are_reachable_before_placement(self):
choices = tests.test_solo_development.choices(end=32)
options = generator.solo.realization.options(choices, fractions.Fraction(), fractions.Fraction(4), (59, 84))
self.assertTrue(options)
self.assertLessEqual(max(p.number() for p in options), 76)
source = tests.test_solo.song(count=8, chords=('CM7',))
plans = generator.solo.planning.plans(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], choices, fractions.Fraction(32), 'electric', tests.solo_fixtures.library())
self.assertTrue(plans)
for plan in plans:
self.assertLessEqual(plan.register[1], 76)
self.assertTrue(plan.anchor_options)
for anchors in plan.anchor_options:
self.assertTrue(all(generator.solo.placement.positions(a.pitch.number()) for a in anchors))
motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices, register=(70, 84), seed='high')
self.assertTrue(all(generator.solo.placement.positions(n.pitch.number()) for n in motif.notes))
high = tests.test_solo_development.note(0, 1, 'f', octave=5)
self.assertIsNone(generator.solo.placement.place((high,), fractions.Fraction(120)))
+7 -6
View File
@@ -86,6 +86,7 @@ class GuitarTests(unittest.TestCase):
self.assertTrue(covered <= set(requirements.allowed)) self.assertTrue(covered <= set(requirements.allowed))
self.assertEqual(len(candidate), len({p.string_number for p in candidate})) self.assertEqual(len(candidate), len({p.string_number for p in candidate}))
self.assertTrue(generator.fretboard.within_fret_span(candidate)) self.assertTrue(generator.fretboard.within_fret_span(candidate))
self.assertTrue(generator.fretboard.has_fret_anchor(candidate))
self.assertTrue(all(1 <= p.fret <= 20 for p in candidate)) self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
if requirements.basses: if requirements.basses:
self.assertIn(min(p.midi for p in candidate) % 12, requirements.basses) self.assertIn(min(p.midi for p in candidate) % 12, requirements.basses)
@@ -147,7 +148,7 @@ class GuitarTests(unittest.TestCase):
document = tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library()) document = tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library())
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0)) self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0))
def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self): def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self):
for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9}), (7, 0, 84, {'string': 1, 'fret': 20})): for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9})):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter)))
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
tone = tests.test_musicxml.notes(document)[0]['pitches'][0] tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
@@ -197,10 +198,10 @@ class GuitarTests(unittest.TestCase):
generator.guitar.place_guitar(document) generator.guitar.place_guitar(document)
self.assertEqual(document, before) self.assertEqual(document, before)
def test_proximity_chooses_a_path_without_relocating_tied_notes(self): def test_proximity_chooses_a_path_without_relocating_tied_notes(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('B', 2, 'half', octave=5) + tests.test_musicxml.xml_note('C', 2, 'half', octave=6))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D', 2, 'half', octave=5) + tests.test_musicxml.xml_note('E', 2, 'half', octave=5)))
generator.guitar.place_guitar(source) generator.guitar.place_guitar(source)
tabs = [n['pitches'][0]['tablature'] for n in tests.test_musicxml.notes(source)] tabs = [n['pitches'][0]['tablature'] for n in tests.test_musicxml.notes(source)]
self.assertEqual(tabs, [{'string': 1, 'fret': 19}, {'string': 1, 'fret': 20}]) self.assertEqual(tabs, [{'string': 1, 'fret': 10}, {'string': 1, 'fret': 12}])
def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self): def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self):
randomizer = random.Random(17) randomizer = random.Random(17)
states = {} states = {}
@@ -216,7 +217,7 @@ class GuitarTests(unittest.TestCase):
candidates.append((state.cost.moved(abs(x - a) + abs(y - b)), index)) candidates.append((state.cost.moved(abs(x - a) + abs(y - b)), index))
self.assertEqual(actual[:2], min(candidates)) self.assertEqual(actual[:2], min(candidates))
def test_generated_chord_retains_strings_when_a_melody_enters(self): def test_generated_chord_retains_strings_when_a_melody_enters(self):
content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('C', 2, 'half', octave=7, extra='<voice>2</voice>') content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('E', 2, 'half', octave=6, extra='<voice>2</voice>')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
measure = document['staves'][0]['measures'][0] measure = document['staves'][0]['measures'][0]
measure['transpose']['octave_change'] = -1 measure['transpose']['octave_change'] = -1
@@ -225,9 +226,9 @@ class GuitarTests(unittest.TestCase):
chord['_harmony'] = generator.harmony.parse_symbol('CM') chord['_harmony'] = generator.harmony.parse_symbol('CM')
generator.guitar.place_guitar(document) generator.guitar.place_guitar(document)
generator.model.validate_document(document) generator.model.validate_document(document)
self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 20}) self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 12})
self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']]) self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']])
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 4) self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 3)
def test_more_than_six_simultaneous_tones_fail_without_omission(self): def test_more_than_six_simultaneous_tones_fail_without_omission(self):
content = '<backup><duration>4</duration></backup>'.join(tests.test_musicxml.xml_note(extra=f'<voice>{i}</voice>') for i in range(7)) content = '<backup><duration>4</duration></backup>'.join(tests.test_musicxml.xml_note(extra=f'<voice>{i}</voice>') for i in range(7))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
+1 -1
View File
@@ -110,7 +110,7 @@ class SoloTests(unittest.TestCase):
generator.solo.composer.validate(result) generator.solo.composer.validate(result)
self.assertEqual(result.end, meter * 3) self.assertEqual(result.end, meter * 3)
self.assertTrue(result.notes()) self.assertTrue(result.notes())
self.assertTrue(all(1 <= n.position.fret <= 20 for n in result.notes())) self.assertTrue(all(1 <= n.position.fret <= 12 for n in result.notes()))
self.assertEqual(result, compose(source)) self.assertEqual(result, compose(source))
def test_motif_recurrence_and_whole_fragment_octave_quote(self): def test_motif_recurrence_and_whole_fragment_octave_quote(self):
source = song(count=6, chords=('CM7',)) source = song(count=6, chords=('CM7',))
+4 -7
View File
@@ -101,17 +101,14 @@ class VoicingTests(unittest.TestCase):
crossing = grip((12, 13, 14, 14)) crossing = grip((12, 13, 14, 14))
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 6) self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 6)
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 10) self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 10)
def test_high_melody_keeps_its_exact_pitch_when_no_anchored_chord_fits(self): def test_high_melody_fails_when_neither_anchored_chord_nor_single_note_fits(self):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6))) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6)))
note = tests.test_musicxml.notes(document)[0] note = tests.test_musicxml.notes(document)[0]
note.update(_harmony=generator.harmony.parse_symbol('CM'), _guitar_profile='electric', _harmonize=True) note.update(_harmony=generator.harmony.parse_symbol('CM'), _guitar_profile='electric', _harmonize=True)
before = copy.deepcopy(note['pitches']) before = copy.deepcopy(document)
with self.assertLogs('generator.guitar', level='WARNING') as messages: with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
generator.guitar.place_guitar(document) generator.guitar.place_guitar(document)
self.assertIn('1 melody harmonizations kept as single notes', messages.output[0]) self.assertEqual(document, before)
self.assertEqual([{k: v for k, v in p.items() if k != 'tablature'} for p in note['pitches']], before)
self.assertEqual(note['pitches'][0]['tablature'], {'string': 1, 'fret': 20})
generator.model.validate_document(document)
def test_shape_and_movement_share_one_effort_cost(self): def test_shape_and_movement_share_one_effort_cost(self):
shape = grip((1, 2, 3, 3)) shape = grip((1, 2, 3, 3))
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 2) self.assertEqual(generator.guitar.voicing_cost(shape).effort, 2)