Fret Limit
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)))
|
||||||
@@ -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
@@ -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',))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user