@@ -116,11 +116,11 @@ 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.
|
||||
|
||||
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 require a maximum fret difference of three. Frets 7-10 qualify; frets 7-11 do not. Acoustic requires a bass on string 5 or 6 plus all three upper strings 4, 3, and 2, with at least one occupied string at fret 12 or below. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Electric melody prioritizes four-string chords, then melody on top. Every generated guitar voicing requires the root, the actual fifth, or both somewhere in the voicing, not necessarily in the bass. Defining chord tones remain mandatory. Acoustic chooses four connected string-voice journeys independently for each arrangement iteration, minimizing total fret travel across harmonic spans. Upper strings stay fixed; bass movement counts both fret distance and one step for switching between strings 5 and 6. All bass inversions are eligible. There is no acoustic register, physical-reach, or shape-effort penalty beyond its hard fret constraints. Complete triads are preferred when feasible; doubling is allowed, authored omissions are never restored, and missing optional tones break equal-movement ties. Electric retains its existing register, span, shape, and position-center movement costs. This is a fretboard-feasibility approximation, not a finger-assignment model. Exact melody pitches and rhythms remain unchanged.
|
||||
Electric melody prioritizes four-string chords, then melody on top. Every generated guitar voicing requires the root, the actual fifth, or both somewhere in the voicing, not necessarily in the bass. Defining chord tones remain mandatory. Acoustic chooses four connected string-voice journeys independently for each arrangement iteration, minimizing total fret travel across harmonic spans. Upper strings stay fixed; bass movement counts both fret distance and one step for switching between strings 5 and 6. All bass inversions are eligible. There is no acoustic register, physical-reach, or shape-effort penalty beyond its hard fret constraints. Complete triads are preferred when feasible; doubling is allowed, authored omissions are never restored, and missing optional tones break equal-movement ties. Electric chord effort balances span and distinct frets against position-center movement, without an absolute fret-height penalty. Unharmonized melody placement retains its existing register and movement costs. This is a fretboard-feasibility approximation, not a finger-assignment model. Exact melody pitches and rhythms remain unchanged.
|
||||
|
||||
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending.
|
||||
|
||||
|
||||
+2
-2
@@ -138,7 +138,7 @@ All three parts use octave-transposing notation: written notes sound one octave
|
||||
|
||||
`pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation except for the intentional song electric melody octave increase. There is no automatic octave fitting, vocal-specific range shift, or key transposition.
|
||||
|
||||
`fretboard.py` owns standard tuning E2 A2 D3 G3 B3 E4, fret positions, chord coverage, and pitch spelling. Both placement systems use frets 1-20 inclusive, exclude open strings, and share a maximum fret difference of three. `guitar.py` handles electric voicing and exact/held melody placement. Exact melody tones are never dropped or replaced, and sustained/tied tones retain their assignments. `acoustic_voicing.py` instead searches four named voices: a bass on string 5 or 6, and one voice on each of strings 4, 3, and 2. Acoustic assignments also require at least one occupied string at fret 12 or below. These are hard constraints: a failed voicing is not widened or reduced to two upper strings. Any defined chord tone, including thirds, sevenths, and extensions, can be the acoustic bass. Explicit slash basses and inversions override these choices; the bass remains the lowest sounding pitch.
|
||||
`fretboard.py` owns standard tuning E2 A2 D3 G3 B3 E4, fret positions, chord coverage, and pitch spelling. Both placement systems use frets 1-20 inclusive, exclude open strings, and require `max(frets) - min(frets) + 1 <= 3`. The inclusive limit accepts frets 1-3 and rejects 1-4; it applies to complete grips, staggered acoustic voices, and overlapping or tied notes. Complete acoustic and electric chords also require at least one occupied fret in 1-12, checked before candidate pruning. Other chord notes may exceed fret 12 within the span limit. `guitar.py` handles electric voicing and exact/held melody placement. Exact melody tones are never dropped or replaced, sustained/tied tones retain their assignments, and unharmonized melody notes are exempt from the anchor rule. `acoustic_voicing.py` instead searches four named voices: a bass on string 5 or 6, and one voice on each of strings 4, 3, and 2. These are hard constraints: a failed voicing is not widened or reduced to two upper strings. Any defined chord tone, including thirds, sevenths, and extensions, can be the acoustic bass. Explicit slash basses and inversions override these choices; the bass remains the lowest sounding pitch.
|
||||
|
||||
Shared `ChordRequirements` separate mandatory pitch classes, allowed pitch classes, root-or-fifth alternatives, and bass constraints. Every complete generated guitar grip must contain the root, the fifth, or both, with no preference for the root itself. This rule applies equally to acoustic accompaniment, electric accompaniment, and electric melody chords, not isolated bass/upper attacks or unharmonized melody notes. The fifth follows the chord's actual degree, including diminished or augmented fifths. Explicitly removed degrees are never restored, and a harmony removing both root and fifth cannot generate a guitar chord. Defining thirds/suspensions, sixths/sevenths, alterations, and required extensions remain mandatory; an altered fifth cannot be omitted merely because the root is present.
|
||||
|
||||
@@ -156,7 +156,7 @@ Acoustic candidates are generated from chord-tone positions on the four voices,
|
||||
|
||||
Acoustic transition cost is the sum of absolute fret changes for all four named voices, plus one when the bass switches between strings 5 and 6. For example, bass string 5 fret 7 to string 6 fret 9 costs three steps, not just a string-change charge. An unchanged assignment costs zero. Exact dynamic programming minimizes total travel per iteration; equal travel costs prefer fewer missing optional chord tones, followed by stable position ordering. The starting assignment has no position cost. Harmony spans, not rhythmic attacks, determine the transitions. There are no acoustic register, distinct-fret, span, or physical-reach scoring terms. The three-fret limit and fret-12 anchor are eligibility checks, not preferences.
|
||||
|
||||
Electric effort remains `highest_fret + 2 * max(0, highest_fret - 12) + 2 * max(0, distinct_frets - 1) + 3 * fret_span`, with Manhattan movement between doubled fret/string position centers. Its small-grid distance transform and exact melody constraints are unchanged. Unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`, and pure tie continuations add no fresh shape or movement cost. The acoustic Autumn Leaves lead-in can use Em7b5/G at `6:3, 4:2, 3:3, 2:3` followed by A7b9/G at `6:3, 4:2, 3:3, 2:2`, moving only the second string by one fret. This is a regression example, not a prescribed chord shape; generated bass inversions do not rewrite source chord labels.
|
||||
Electric chord effort is `2 * max(0, distinct_frets - 1) + 3 * fret_span`, plus Manhattan movement between doubled fret/string position centers. Here `fret_span` remains `max(frets) - min(frets)` for scoring; eligibility counts fret positions inclusively. Neither the highest fret nor crossing fret 12 adds a chord cost. The small-grid distance transform and exact melody constraints are unchanged. Unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`, and pure tie continuations add no fresh shape or movement cost. The acoustic Autumn Leaves lead-in can use Em7b5/G at `6:3, 4:2, 3:3, 2:3` followed by A7b9/G at `6:3, 4:2, 3:3, 2:2`, moving only the second string by one fret. This is a regression example, not a prescribed chord shape; generated bass inversions do not rewrite source chord labels.
|
||||
|
||||
There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure.
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import generator.acoustic
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
|
||||
MAX_ANCHOR_FRET = 12
|
||||
|
||||
class Voicing(typing.NamedTuple):
|
||||
bass: generator.fretboard.TabPosition
|
||||
string4: generator.fretboard.TabPosition
|
||||
@@ -18,7 +16,7 @@ class Voicing(typing.NamedTuple):
|
||||
string2: generator.fretboard.TabPosition
|
||||
|
||||
def playable(voicing: Voicing) -> bool:
|
||||
return voicing.bass.string_number in (5, 6) and tuple(p.string_number for p in voicing[1:]) == (4, 3, 2) and all(generator.fretboard.MIN_FRET <= p.fret <= generator.fretboard.MAX_FRET for p in voicing) and min(p.fret for p in voicing) <= MAX_ANCHOR_FRET and generator.fretboard.within_fret_span(voicing) and voicing.bass.midi == min(p.midi for p in voicing)
|
||||
return voicing.bass.string_number in (5, 6) and tuple(p.string_number for p in voicing[1:]) == (4, 3, 2) and all(generator.fretboard.MIN_FRET <= p.fret <= generator.fretboard.MAX_FRET for p in voicing) and generator.fretboard.has_fret_anchor(voicing) and generator.fretboard.within_fret_span(voicing) and voicing.bass.midi == min(p.midi for p in voicing)
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def candidates(requirements: generator.fretboard.ChordRequirements, complete_triad: bool = False) -> tuple[Voicing, ...]:
|
||||
@@ -65,7 +63,7 @@ def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
|
||||
requirements = generator.fretboard.chord_requirements(span.harmony)
|
||||
options = candidates(requirements, generator.harmony.is_triad(span.harmony))
|
||||
if not options:
|
||||
raise ValueError(f'no acoustic grip on strings 2-4 plus bass 5 or 6 within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, three-fret span, and at least one fret <= {MAX_ANCHOR_FRET}')
|
||||
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}')
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{span.location}, {generator.harmony.symbol(span.harmony)}: {error}') from error
|
||||
states = advance(options, requirements, states)
|
||||
|
||||
@@ -12,6 +12,7 @@ STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 1
|
||||
MAX_FRET = 20
|
||||
MAX_FRET_SPAN = 3
|
||||
MAX_ANCHOR_FRET = 12
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
|
||||
class TabPosition(typing.NamedTuple):
|
||||
@@ -20,8 +21,12 @@ class TabPosition(typing.NamedTuple):
|
||||
midi: int
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
"""Count occupied fret positions inclusively: frets 1-3 fit, 1-4 do not."""
|
||||
frets = [position.fret for position in positions]
|
||||
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
|
||||
return not frets or max(frets) - min(frets) + 1 <= MAX_FRET_SPAN
|
||||
|
||||
def has_fret_anchor(positions) -> bool:
|
||||
return any(MIN_FRET <= position.fret <= MAX_ANCHOR_FRET for position in positions)
|
||||
|
||||
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
|
||||
if midi.denominator != 1:
|
||||
|
||||
+4
-6
@@ -32,12 +32,10 @@ def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile
|
||||
classes = {p.midi % 12 for p in positions}
|
||||
above = sum(p.midi > max(melody) for p in positions) if melody else 0
|
||||
span = compactness(positions)
|
||||
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET)
|
||||
if profile == 'electric':
|
||||
effort += max(frets, default=0) + 2 * max(0, len(frets) - 1)
|
||||
effort += 3 * span
|
||||
effort = 2 * max(0, len(frets) - 1) + 3 * span
|
||||
else:
|
||||
effort += span
|
||||
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
|
||||
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, len(set(allowed) - classes))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
@@ -61,7 +59,7 @@ def chord_positions(requirements: generator.fretboard.ChordRequirements, prune:
|
||||
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
|
||||
for strings in ELECTRIC_STRINGS:
|
||||
for selected in itertools.product(*(by_string[s] for s in strings)):
|
||||
if not generator.fretboard.within_fret_span(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
if not generator.fretboard.within_fret_span(selected) or not generator.fretboard.has_fret_anchor(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
continue
|
||||
if requirements.basses and (selected[0].midi % 12 not in requirements.basses or selected[0].midi != min(p.midi for p in selected)):
|
||||
continue
|
||||
@@ -307,7 +305,7 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
||||
if not result:
|
||||
if not fallback and any(n.get('_harmonize') for n in notes):
|
||||
return advance_positions(notes, onset, tones, states, preserve_strings, True)
|
||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET} and three-fret span; held/tied strings must remain fixed')
|
||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET} and 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')
|
||||
return result
|
||||
|
||||
def place_guitar(document: dict) -> None:
|
||||
|
||||
@@ -69,6 +69,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
requirements = generator.fretboard.chord_requirements(span.harmony)
|
||||
if basses and uppers:
|
||||
self.assertTrue(generator.acoustic_voicing.playable(generator.acoustic_voicing.Voicing(*grip)))
|
||||
self.assertLessEqual(max(p.fret for p in grip) - min(p.fret for p in grip) + 1, 3)
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in grip}))
|
||||
self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed))
|
||||
for note in span.notes:
|
||||
@@ -91,7 +92,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
self.assertEqual(tests.test_acoustic.sounding(document), before)
|
||||
generator.model.validate_document(document)
|
||||
def test_four_strings_allow_all_defined_bass_inversions(self):
|
||||
for symbol in ('CM', 'Dm', 'Caug', 'Cdim', 'C7', 'Cdim7', 'Cno5', 'C7b9', 'Dmno1', 'C6', 'C13', 'Csus', 'Csus2'):
|
||||
for symbol in ('CM', 'Dm', 'Caug', 'Cdim', 'C7', 'Cdim7', 'C7no5', 'C7b9', 'C7no1', 'C6', 'C13', 'Csus', 'Csus2'):
|
||||
with self.subTest(chord=symbol):
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
requirements = generator.fretboard.chord_requirements(chord)
|
||||
@@ -188,13 +189,19 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
frets[index] += 1
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1)
|
||||
def test_fret_and_register_limits_are_hard_constraints(self):
|
||||
for frets, valid in (((1, 1, 1, 1), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 15, 14, 12), True), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
|
||||
for frets, valid in (((1, 1, 1, 1), True), ((1, 3, 1, 3), True), ((1, 4, 1, 4), False), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((12, 15, 14, 12), False), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
|
||||
with self.subTest(frets=frets):
|
||||
self.assertEqual(generator.acoustic_voicing.playable(voicing(frets)), valid)
|
||||
for symbol in ('CM', 'C7b9', 'C13', 'Cdim7'):
|
||||
self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in candidates(symbol)))
|
||||
def test_autumn_leaves_cm7_and_f7_cannot_span_four_fret_positions(self):
|
||||
for symbol, frets in (('Cm7', (3, 1, 3, 4)), ('F7', (3, 1, 2, 4))):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertFalse(generator.acoustic_voicing.playable(voicing(frets)))
|
||||
self.assertNotIn(voicing(frets), candidates(symbol))
|
||||
self.place(planned_document([[(0, symbol)]]))
|
||||
def test_acoustic_cost_has_no_register_span_or_shape_penalties(self):
|
||||
options = (voicing((1, 1, 1, 1)), voicing((1, 4, 3, 4)), voicing((12, 12, 12, 12)), voicing((12, 15, 14, 15)))
|
||||
options = (voicing((1, 1, 1, 1)), voicing((1, 3, 2, 3)), voicing((12, 12, 12, 12)), voicing((12, 14, 13, 14)))
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
result = generator.acoustic_voicing.advance(options, generator.fretboard.ChordRequirements(), states)
|
||||
self.assertEqual([p.cost for p in result], [generator.acoustic_voicing.Cost()] * len(options))
|
||||
@@ -223,16 +230,18 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
options = candidates(symbol)
|
||||
self.assertTrue(options)
|
||||
self.assertTrue(all(len(v) == 4 and {p.midi % 12 for p in v} == classes for v in options))
|
||||
for symbol, omitted in (('Dmno1', 2), ('C7no5', 7)):
|
||||
for symbol, omitted in (('C7no1', 0), ('C7no5', 7)):
|
||||
self.assertTrue(candidates(symbol))
|
||||
self.assertTrue(all(omitted not in {p.midi % 12 for p in v} for v in candidates(symbol)))
|
||||
def test_unplayable_omission_is_not_restored_to_fill_upper_strings(self):
|
||||
self.assertFalse(candidates('Dmno5'))
|
||||
document = planned_document([[(0, 'Dmno5')]])
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'Dmno5: no acoustic grip.*three-fret span'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
self.assertEqual(document, before)
|
||||
for symbol in ('Dmno5', 'Dmno1', 'CMno5'):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertFalse(candidates(symbol))
|
||||
document = planned_document([[(0, symbol)]])
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, f'{symbol}: no acoustic grip.*3 consecutive fret positions'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
|
||||
for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('C7/E', 4), ('C7b9/Db', 1), ('Dm/B', 11)):
|
||||
with self.subTest(chord=chord):
|
||||
@@ -267,6 +276,9 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
for note in voice['notes']:
|
||||
upper = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
|
||||
self.assertIn(upper, ([], [2, 3, 4]))
|
||||
frets = [p['tablature']['fret'] for p in note['pitches']]
|
||||
if frets:
|
||||
self.assertLessEqual(max(frets) - min(frets) + 1, 3)
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
|
||||
if path.stem == 'autumn_leaves':
|
||||
for index, expected in ((0, voicing((3, 2, 3, 3), 6)), (1, voicing((3, 2, 3, 2), 6)), (4, voicing((3, 2, 3, 3), 6)), (5, voicing((3, 2, 3, 2), 6))):
|
||||
|
||||
@@ -117,10 +117,10 @@ class EngravingTests(unittest.TestCase):
|
||||
self.assert_paired_pages(self.layout(source))
|
||||
def test_two_voice_acoustic_systems_can_share_a_page(self):
|
||||
harmony = '<harmony><root><root-step>E</root-step></root><kind>major</kind></harmony>'
|
||||
content = harmony + tests.test_musicxml.xml_note('G', 2, 'half', alter=1) + tests.test_musicxml.xml_note('B', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'
|
||||
content += '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E', extra='<voice>2</voice>')
|
||||
content = harmony + tests.test_musicxml.xml_note('G', 2, 'half', alter=1) + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'
|
||||
content += '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('B', octave=3, extra='<voice>2</voice>')
|
||||
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 16))
|
||||
positions = {'e': {'string': 5, 'fret': 7}, 'g': {'string': 4, 'fret': 6}, 'b': {'string': 3, 'fret': 4}}
|
||||
positions = {'b': {'string': 5, 'fret': 2}, 'e': {'string': 4, 'fret': 2}, 'g': {'string': 3, 'fret': 1}}
|
||||
for measure in document['staves'][0]['measures']:
|
||||
measure['transpose']['octave_change'] = -1
|
||||
for voice in measure['voices']:
|
||||
@@ -413,7 +413,7 @@ class EngravingTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(x1, x2)
|
||||
self.assertAlmostEqual((y1 - baselines['staff']) * 5 * layout.style.tab_space / (4 * layout.style.space), y2 - baselines['tab_staff'])
|
||||
def test_adjacent_string_tab_numerals_do_not_overlap(self):
|
||||
content = ''.join(tests.test_musicxml.xml_note(step, octave=octave, prefix='<chord/>' if index else '') for index, (step, octave) in enumerate((('G', 3), ('C', 4), ('E', 4), ('G', 4), ('C', 5), ('E', 5))))
|
||||
content = ''.join(tests.test_musicxml.xml_note(step, octave=octave, prefix='<chord/>' if index else '') for index, (step, octave) in enumerate((('E', 3), ('B', 3), ('E', 4), ('G', 4), ('B', 4), ('E', 5))))
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
||||
generator.guitar.place_guitar(source)
|
||||
layout = self.layout(source)
|
||||
|
||||
+19
-4
@@ -29,11 +29,12 @@ class GuitarTests(unittest.TestCase):
|
||||
self.assertIn(generator.fretboard.TabPosition(1, 20, 84), generator.fretboard.positions_for_pitch(fractions.Fraction(84)))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(85)))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(129, 2)))
|
||||
self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73))))
|
||||
self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 13, 72))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(6, 0, 40), generator.fretboard.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 10, 60), generator.fretboard.TabPosition(1, 20, 84))))
|
||||
def test_fretted_positions_keep_span_and_proximity_scoring(self):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 4), True), ((1, 5), False), ((7, 10), True), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True)):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 4), False), ((1, 5), False), ((7, 9), True), ((7, 10), False), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True), ((18, 20), True)):
|
||||
with self.subTest(frets=frets):
|
||||
positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
|
||||
self.assertEqual(generator.fretboard.within_fret_span(positions), valid)
|
||||
@@ -42,7 +43,7 @@ class GuitarTests(unittest.TestCase):
|
||||
self.assertTrue(generator.fretboard.within_fret_span(()))
|
||||
def test_held_notes_obey_the_shared_three_fret_limit(self):
|
||||
held = {'held': generator.fretboard.TabPosition(4, 7, 57)}
|
||||
for fret, valid in ((10, True), (11, False)):
|
||||
for fret, valid in ((9, True), (10, False), (11, False)):
|
||||
with self.subTest(fret=fret):
|
||||
pending = [('attack', (generator.fretboard.TabPosition(2, fret, 59 + fret),))]
|
||||
self.assertEqual(bool(list(generator.guitar.extend_positions(held, pending))), valid)
|
||||
@@ -179,13 +180,21 @@ class GuitarTests(unittest.TestCase):
|
||||
self.assertTrue(all(m['transpose']['octave_change'] == -1 for m in document['staves'][0]['measures']))
|
||||
def test_ties_and_overlapping_voices_keep_strings(self):
|
||||
content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
|
||||
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
|
||||
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('G', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
||||
generator.guitar.place_guitar(document)
|
||||
first, continuation, other = [p['tablature'] for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']]
|
||||
self.assertEqual(first, continuation)
|
||||
self.assertNotEqual(continuation['string'], other['string'])
|
||||
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 2)
|
||||
def test_overlapping_tones_cannot_expand_the_span_or_relocate_a_tie(self):
|
||||
content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
|
||||
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, '3 consecutive fret positions.*held/tied strings'):
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertEqual(document, before)
|
||||
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)))
|
||||
generator.guitar.place_guitar(source)
|
||||
@@ -263,3 +272,9 @@ class GuitarTests(unittest.TestCase):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
harmony = '<harmony><root><root-step>C</root-step></root><kind>power</kind></harmony>'
|
||||
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
|
||||
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*3 consecutive fret positions'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
|
||||
@@ -25,7 +25,7 @@ class GuitarPartTests(unittest.TestCase):
|
||||
generator.model.validate_document(result)
|
||||
return result
|
||||
def test_profile_shapes_cover_defining_chord_tones(self):
|
||||
for symbol in ('CM', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'C5', 'CM/E'):
|
||||
for symbol in ('CM', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'CM/E'):
|
||||
for profile in ('acoustic', 'electric'):
|
||||
with self.subTest(symbol=symbol, profile=profile):
|
||||
definition = generator.harmony.parse_symbol(symbol)
|
||||
@@ -43,6 +43,11 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertNotIn(0, generator.fretboard.chord_requirements(chord, respect_bass=False).required)
|
||||
candidates = generator.guitar.chord_positions(generator.fretboard.chord_requirements(chord, respect_bass=False), profile='electric')
|
||||
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
|
||||
def test_power_chord_cannot_expand_span_or_invent_upper_tones(self):
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C5'))
|
||||
self.assertEqual(set(requirements.allowed), {0, 7})
|
||||
self.assertFalse(generator.acoustic_voicing.candidates(requirements))
|
||||
self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric'))
|
||||
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
|
||||
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
|
||||
measure = result['staves'][0]['measures'][0]
|
||||
|
||||
+61
-25
@@ -28,9 +28,10 @@ class VoicingTests(unittest.TestCase):
|
||||
for prune in (False, True):
|
||||
candidates = ranked('CM7', prune=prune)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(generator.guitar.compactness(c) <= 3 for c in candidates))
|
||||
self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 3 for c in candidates))
|
||||
candidates = ranked('CM7')
|
||||
self.assertIn(grip((2, 4, 1, 3)), candidates)
|
||||
self.assertIn(grip((5, 4, 5, 3)), candidates)
|
||||
self.assertNotIn(grip((2, 4, 1, 3)), candidates)
|
||||
self.assertNotIn(grip((5, 9, 8, 7)), candidates)
|
||||
def test_requested_shapes_win_without_a_shape_dictionary(self):
|
||||
for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))):
|
||||
@@ -52,14 +53,11 @@ class VoicingTests(unittest.TestCase):
|
||||
self.assertEqual(max(p.midi for p in best), 65)
|
||||
self.assertNotIn(1, {p.midi % 12 for p in best})
|
||||
self.assertTrue(any(1 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 65 for c in candidates))
|
||||
def test_compact_chord_can_beat_lower_frets_without_covering_melody(self):
|
||||
def test_wider_low_chord_cannot_displace_a_playable_melody_grip(self):
|
||||
candidates = ranked('CM7', (67,))
|
||||
self.assertEqual(candidates[0], grip((5, 4, 5, 3)))
|
||||
self.assertIn(grip((2, 4, 1, 3)), candidates)
|
||||
rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), (67,))
|
||||
rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 3)), 'electric', (0, 4, 7, 11), (67,))
|
||||
self.assertEqual((rooted.effort, rootless.effort), (19, 15))
|
||||
self.assertLess(rootless, rooted)
|
||||
self.assertNotIn(grip((2, 4, 1, 3)), candidates)
|
||||
self.assertEqual(max(p.midi for p in candidates[0]), 67)
|
||||
def test_essential_extensions_are_never_relaxed_to_add_the_root(self):
|
||||
for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'):
|
||||
definition = generator.harmony.parse_symbol(symbol)
|
||||
@@ -80,36 +78,72 @@ class VoicingTests(unittest.TestCase):
|
||||
candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False))
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(all(p.midi % 12 != 0 for p in c.shape) and any(p.midi % 12 == 7 for p in c.shape) for c in candidates))
|
||||
def test_chord_height_cost_increases_throughout_the_fretboard(self):
|
||||
for fret in range(1, 21):
|
||||
def test_anchored_chord_effort_is_independent_of_absolute_height(self):
|
||||
for fret in range(1, 13):
|
||||
with self.subTest(fret=fret):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), 'electric').effort, fret + 2 * max(0, fret - 12))
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), 'electric').effort, 0)
|
||||
shape = grip((fret, fret + 2, fret + 1, fret + 2))
|
||||
self.assertTrue(generator.fretboard.has_fret_anchor(shape))
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10)
|
||||
self.assertEqual(generator.guitar.voicing_cost((), 'electric').effort, 0)
|
||||
def test_electric_anchor_is_eligibility_not_a_highest_fret_cap(self):
|
||||
for prune in (False, True):
|
||||
candidates = ranked('CM7', prune=prune)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(min(p.fret for p in c) <= 12 for c in candidates))
|
||||
self.assertIn(grip((14, 12, 12, 12)), candidates)
|
||||
self.assertNotIn(grip((17, 17, 17, 19)), candidates)
|
||||
self.assertFalse(generator.fretboard.has_fret_anchor(grip((13, 13, 13, 13))))
|
||||
self.assertFalse(generator.fretboard.has_fret_anchor(()))
|
||||
def test_exact_melody_height_scoring_remains_unchanged(self):
|
||||
for fret in range(1, 21):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
|
||||
crossing = grip((11, 12, 13, 14))
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 33)
|
||||
def test_chord_shape_and_height_share_one_effort_cost(self):
|
||||
shape = grip((1, 3, 4, 5))
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 23)
|
||||
crossing = grip((12, 13, 14, 14))
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 6)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 10)
|
||||
def test_high_melody_keeps_its_exact_pitch_when_no_anchored_chord_fits(self):
|
||||
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.update(_harmony=generator.harmony.parse_symbol('CM'), _guitar_profile='electric', _harmonize=True)
|
||||
before = copy.deepcopy(note['pitches'])
|
||||
with self.assertLogs('generator.guitar', level='WARNING') as messages:
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertIn('1 melody harmonizations kept as single notes', messages.output[0])
|
||||
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):
|
||||
shape = grip((1, 2, 3, 3))
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 2)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10)
|
||||
for profile in ('', 'electric'):
|
||||
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
|
||||
cost = generator.guitar.voicing_cost(shape, profile)
|
||||
self.assertEqual(cost.moved(5).effort, cost.effort + 5)
|
||||
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), cost)
|
||||
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
|
||||
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple, 'electric').effort, 13)
|
||||
self.assertEqual(generator.guitar.voicing_cost(varied, 'electric').effort, 15)
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple, 'electric').effort, 8)
|
||||
self.assertEqual(generator.guitar.voicing_cost(varied, 'electric').effort, 10)
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort)
|
||||
def test_nearby_position_beats_lower_frets_within_comfort_range(self):
|
||||
low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9))
|
||||
for profile in ('', 'electric'):
|
||||
self.assertLess(generator.guitar.voicing_cost(nearby, profile).moved(generator.guitar.movement(previous, nearby)), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(previous, low)))
|
||||
def test_above_threshold_is_a_soft_cost_not_a_range_priority(self):
|
||||
def test_exact_melody_threshold_remains_a_soft_cost(self):
|
||||
low, high = (grip((f,) * 4) for f in (3, 13))
|
||||
for profile in ('', 'electric'):
|
||||
self.assertLess(generator.guitar.voicing_cost(high, profile), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(high, low)))
|
||||
self.assertLess(generator.guitar.voicing_cost(high), generator.guitar.voicing_cost(low).moved(generator.guitar.movement(high, low)))
|
||||
def test_anchor_is_checked_before_equal_center_pruning(self):
|
||||
# A permissive pitch set lets the ineligible fret-13 barre compete at center 26.
|
||||
requirements = generator.fretboard.ChordRequirements(allowed=tuple(range(12)), foundation=(0,))
|
||||
unpruned = generator.guitar.chord_positions(requirements, prune=False, profile='electric')
|
||||
ineligible = grip((13, 13, 13, 13))
|
||||
center = generator.guitar.position_center(ineligible)
|
||||
eligible = [c for c in unpruned if generator.guitar.position_center(c) == center]
|
||||
self.assertTrue(eligible)
|
||||
self.assertTrue(all(generator.guitar.voicing_cost(ineligible, 'electric', requirements.allowed) < generator.guitar.voicing_cost(c, 'electric', requirements.allowed) for c in eligible))
|
||||
best = min(eligible, key=lambda c: (generator.guitar.voicing_cost(c, 'electric', requirements.allowed), c))
|
||||
self.assertIn(best, generator.guitar.chord_positions(requirements, profile='electric'))
|
||||
self.assertNotIn(ineligible, unpruned)
|
||||
def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self):
|
||||
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('Gm7', 'electric', (67,))):
|
||||
best = {}
|
||||
@@ -161,7 +195,9 @@ class VoicingTests(unittest.TestCase):
|
||||
for note in voice['notes']:
|
||||
frets = [p['tablature']['fret'] for p in note['pitches']]
|
||||
if frets:
|
||||
self.assertLessEqual(max(frets) - min(frets), 3)
|
||||
self.assertLessEqual(max(frets) - min(frets) + 1, 3)
|
||||
if len(frets) > 1:
|
||||
self.assertLessEqual(min(frets), 12)
|
||||
section = measure.get('section_label') or section
|
||||
if section != 'Guitar Melody':
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user