Generation

This commit is contained in:
khodowany
2026-09-26 18:00:51 -05:00
parent c8581ea51a
commit 83154b1a01
10 changed files with 263 additions and 90 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ Identical notation bodies share layout and body rasterization even when titles o
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated-tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Four-digit numbered sources in `data/practice/` (currently `0001.xml` through `0366.xml`; `0000.xml` is also valid) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises.
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing a playable grip. They combine into one notated voice with no ties: each grip releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Bass alternates root/fifth unless an explicit bass or playability requires otherwise. Electric accompaniment responds more sparsely with stabs lasting at most a quarter note. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable grip until the harmony changes. Its bass pitch, upper voicing, and TAB positions stay constant across rests, barlines, repeated chord symbols, and section labels while accompaniment continues. The bass is the root unless a slash bass or inversion specifies otherwise. The rhythms combine into one notated voice with no ties: each attack releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Electric accompaniment responds more sparsely with stabs lasting at most a quarter note. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
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.
+5 -3
View File
@@ -122,7 +122,7 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
### Shared Part Generation
`parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles and realizes independent bass/upper voices or electric stabs. Before guitar placement, `acoustic.py` shortens generated bass/upper attacks so they cannot overlap; afterward it combines them into one notated voice. `melody.py` identifies long tied melody runs and splits them at harmony changes before guitar placement. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer or separate practice fretboard solver.
`parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles and realizes independent bass/upper voices or electric stabs. Before guitar placement, `acoustic.py` shortens generated bass/upper attacks so they cannot overlap and groups them into continuous harmony spans; afterward it combines them into one notated voice. `guitar.py` selects one complete grip per acoustic span using its shared candidate generation and placement search. `melody.py` identifies long tied melody runs and splits them at harmony changes before guitar placement. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer or separate practice fretboard solver.
Generated accompaniment has no ties. Acoustic attacks release at the earliest of their planned end, the next combined bass/upper attack, or the barline. Simultaneous bass/upper attacks use the shorter planned duration for the entire grip. If a binary duration would need ties, it shortens to the longest conventional single note that fits, followed by silence; no extra attack or artificial tuplet is introduced. Rhythm-snippet continuations are silent, and the planner does not count them as acoustic coverage. After placement, consolidation preserves the prepared timing and TAB assignments, combines simultaneous tones under one stem, and writes rests only for complete silence. A silent full bar has one measure rest. Old lane stem directions and beam groups are removed in favor of single-voice engraving. Authored melody ties, solo slashes, and electric melody harmonization remain separate from this build-time accompaniment policy.
@@ -136,7 +136,9 @@ All three parts, for both songs and practice, use octave-transposing notation: w
`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.
`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between the lowest and highest positions, including fret 0. Open strings participate in span validation and proximity scoring: 0/2/3 and 0/4 are valid, while 0/5 and 0/11/12 are not. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Bass alternates root and chord fifth, restarting at harmony/section changes; explicit basses/inversions remain fixed. A fifth-bass choice falls back to the root if it cannot preserve chord identity and the held strings.
`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between the lowest and highest positions, including fret 0. Open strings participate in span validation and proximity scoring: 0/2/3 and 0/4 are valid, while 0/5 and 0/11/12 are not. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Each continuous acoustic harmony span keeps the same bass pitch, upper voicing, and string/fret positions. The bass is the root unless a slash bass or inversion specifies otherwise; there is no alternating-bass substitution.
Acoustic spans follow the harmony timeline, including changes during silence. Barline crossings, rests, repeated identical harmonies, and section labels do not reset the grip while accompaniment continues. Changed chord kinds, roots, degrees, basses, or inversions start a new span. N.C. and non-accompaniment passages end a span. Returning to a chord after a different harmony starts a new decision, not a song-wide fixed fingering. A span with attacks is placed once as a complete grip, then each bass or upper attack receives its corresponding subset; simultaneous attacks receive both. If no complete grip satisfies the existing harmonic and fretboard constraints, generation fails with passage context without replacing published assets. Span state is build-only and does not add runtime fields.
Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voicings and octave doublings are allowed as fallbacks; the third or suspension, sixth/seventh, alterations, and named extensions remain required. An unaltered fifth in extended chords and unaltered intermediate ninth/eleventh extensions may be omitted. Exact melody pitches, including non-chord tones, remain unchanged. Root preference never restores a degree explicitly subtracted from the harmony.
@@ -144,7 +146,7 @@ Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voici
Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, missing root, physical effort, and missing optional pitch classes. These costs accumulate over the whole passage, so movement cannot outweigh the earlier musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. Acoustic does not receive electric fullness/root penalties: a simpler three-note grip can beat a more awkward four-note grip while satisfying its hard harmonic requirements.
Physical effort is `movement + 2 * highest_fret + fret_span` for electric and exact melody placement, and `movement + 2 * highest_fret + 3 * fret_span + 2 * (distinct_frets - 1)` for acoustic. Movement is the Manhattan distance between doubled fret/string position centers. Open strings count in every fret measurement. Simultaneous bass/upper attacks are scored once as a full grip; pure tie continuations add no fresh shape or movement cost. Lower positions receive a continuous preference with no comfort threshold or automatic octave fitting. Candidate pruning and the exact small-grid distance transform share this ranking; held notes keep their occupied strings and all compatible candidate states. Stable position ordering resolves equal costs.
Physical effort is `movement + 2 * highest_fret + fret_span` for electric and exact melody placement, and `movement + 2 * highest_fret + 3 * fret_span + 2 * (distinct_frets - 1)` for acoustic. Movement is the Manhattan distance between doubled fret/string position centers. Open strings count in every fret measurement. Acoustic shape costs are charged once per harmony span, independent of rhythm density, and transitions compare complete grips rather than whichever lane last sounded. Pure melody tie continuations add no fresh shape or movement cost. Lower positions receive a continuous preference with no comfort threshold or automatic octave fitting. Candidate pruning and the exact small-grid distance transform share this ranking; held notes keep their occupied strings and all compatible candidate states. Stable position ordering resolves equal costs.
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.
-26
View File
@@ -3,10 +3,7 @@ from __future__ import annotations
import dataclasses
import fractions
import functools
import itertools
import generator.guitar
import generator.harmony
import generator.model
import generator.rhythm
@@ -232,26 +229,3 @@ def connect_continuations(document: dict) -> bool:
changed = True
previous[voice['id']] = note
return changed
def prepare_basses(document: dict) -> None:
previous, attack, current_bass = None, 0, None
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
previous, attack = None, 0
notes = sorted((n for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic'), key=lambda n: generator.model.fraction(n['onset']))
for _, group in itertools.groupby(notes, key=lambda n: generator.model.fraction(n['onset'])):
group = list(group)
chord = group[0]['_harmony']
root = (generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) % 12
if not same_chord(chord, previous):
attack, current_bass = 0, root
explicit = 'bass' in chord or 'inversion' in chord
if any(n['_guitar_lane'] == 'bass' for n in group):
degrees = generator.harmony.degrees(chord)
current_bass = (root + generator.harmony.degree_semitones(5, degrees[5])) % 12 if not explicit and attack % 2 and 5 in degrees else root
attack += 1
if explicit:
current_bass = generator.guitar.chord_requirements(chord, 'acoustic')[2]
for note in group:
note['_bass_pc'] = current_bass
previous = chord
+29
View File
@@ -1,11 +1,40 @@
from __future__ import annotations
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
@dataclasses.dataclass
class HarmonySpan:
harmony: dict
location: str
notes: list[dict] = dataclasses.field(default_factory=list)
def harmony_spans(document: dict) -> list[HarmonySpan]:
"""Group attacks by continuous harmony, including changes during silence."""
spans, span, active = [], None, None
for measure in document['staves'][0]['measures']:
if {v['id'] for v in measure['voices']} != {'bass', 'upper'}:
span, active = None, None
continue
events = [(generator.model.fraction(h['onset']), 0, h) for h in measure['harmonies']]
events.extend((generator.model.fraction(n['onset']), 1, n) for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic')
for onset, kind, item in sorted(events, key=lambda event: event[:2]):
harmony = item if kind == 0 else item['_harmony']
if not generator.accompaniment.same_chord(active, harmony):
span = None
active = harmony
if kind == 0:
continue
if span is None:
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}')
spans.append(span)
span.notes.append(item)
return spans
def written_durations(length: fractions.Fraction):
while length:
written = generator.model.written_for(length)
-1
View File
@@ -115,7 +115,6 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
result['metadata'].setdefault('tempo', 124)
if part_type != 'vocal':
generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
+67 -55
View File
@@ -7,6 +7,7 @@ import itertools
import logging
import typing
import generator.acoustic
import generator.harmony
import generator.model
import generator.pitch
@@ -233,6 +234,13 @@ class EventPlacement:
exact: tuple
added: tuple[TabPosition, ...] = ()
def acoustic_candidates(span: generator.acoustic.HarmonySpan):
required, allowed, bass = chord_requirements(span.harmony, 'acoustic')
for shape in chord_positions(required, allowed, bass, profile='acoustic'):
active = tuple((str(p.string_number), p) for p in shape)
choices = tuple(EventPlacement(note, (), tuple(p for p in shape if (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))) for note in span.notes)
yield AttackCandidate(active, choices, shape, voicing_cost(shape, 'acoustic', allowed))
def continued_sources(notes):
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING)
@@ -246,8 +254,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
chords = [n for n in notes if '_harmony' in n]
chord = chords[0] if chords else None
profile = chord.get('_guitar_profile', '') if chord else ''
if len(chords) > 1 and (profile != 'acoustic' or any(n['_harmony'] != chord['_harmony'] for n in chords)):
raise ValueError('simultaneous generated voices must share one acoustic harmony')
if len(chords) > 1:
raise ValueError('simultaneous generated chords require acoustic harmony-span placement')
for note in notes:
for tone in note['pitches']:
options = tones[tone['id']].positions
@@ -265,49 +273,40 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
root = value
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
fresh = has_attack(notes)
basses = [chord.get('_bass_pc', requirements[2])] if chord else [None]
if profile == 'acoustic' and requirements[2] not in basses:
basses.append(requirements[2])
for bass in basses:
candidates = chord_positions(requirements[0], requirements[1], bass, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, root=root) if chord else [()]
found = False
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
exact_positions = dict(exact)
for positions in candidates:
if profile:
if any(p not in positions for p in exact_positions.values()):
continue
assignment = dict(exact)
choices = []
compatible = True
for note in notes:
added = tuple(p for p in positions if p not in exact_positions.values() and (note in chords and (profile != 'acoustic' or (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))))
if any(p.midi % 12 not in requirements[1] for p in added):
candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, root=root) if chord else [()]
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
exact_positions = dict(exact)
for positions in candidates:
if profile:
if any(p not in positions for p in exact_positions.values()):
continue
assignment = dict(exact)
choices = []
compatible = True
for note in notes:
added = tuple(p for p in positions if p not in exact_positions.values() and note in chords)
if any(p.midi % 12 not in requirements[1] for p in added):
compatible = False
break
if '_continue_from' in note:
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')}
if previous != set(added):
compatible = False
break
if '_continue_from' in note:
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')}
if previous != set(added):
compatible = False
break
assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added)
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
if not compatible:
continue
found = True
cost = voicing_cost(positions, profile, requirements[1], root, melody) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
else:
shape = tuple(p for _, p in exact) + positions
if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
continue
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
found = True
cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
if found:
break
assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added)
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
if not compatible:
continue
cost = voicing_cost(positions, profile, requirements[1], root, melody) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
else:
shape = tuple(p for _, p in exact) + positions
if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
continue
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
events, tones = [], {}
@@ -357,6 +356,17 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
def advance_attacks(candidates, states: dict) -> dict:
nearest = nearest_positions(states)
result = {}
for candidate in candidates:
x, y = position_center(candidate.shape)
cost, _, previous = nearest[x][y]
cost = cost.plus(candidate.cost)
if candidate.active not in result or cost < result[candidate.active].cost:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
return result
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict:
continued = continued_sources(notes)
fresh = has_attack(notes)
@@ -369,11 +379,7 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
if not held_ids and not continued:
nearest = nearest_positions(states)
for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings, fallback):
x, y = position_center(candidate.shape)
cost, _, previous = nearest[x][y]
keep(candidate, previous, cost)
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback), states)
else:
for previous in states.values():
active = dict(previous.active)
@@ -388,14 +394,20 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
return result
def place_guitar(document: dict) -> None:
"""Choose a minimum-cost path, retaining held strings in each search state."""
"""Choose acoustic grips per harmony span, or exact/held melody placements."""
events, tones = guitar_events(document)
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
states = {(): Placement(VoicingCost(), (), None, ())}
for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
states = advance_positions(notes, onset, tones, states, preserve_strings)
if document['staves'][0].get('arrangement_part_type') == 'acoustic':
for span in generator.acoustic.harmony_spans(document):
states = advance_attacks(acoustic_candidates(span), states)
if not states:
raise ValueError(f'{span.location}: no acoustic grip within frets {MIN_FRET}-{MAX_FRET} and four-fret span for {span.harmony.get("symbol", span.harmony["kind"])}')
else:
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
states = advance_positions(notes, onset, tones, states, preserve_strings)
selected = min(states.values(), key=lambda s: (s.cost, s.active))
skipped = []
while selected.previous is not None:
@@ -412,7 +424,7 @@ def place_guitar(document: dict) -> None:
for measure in document['staves'][0]['measures']:
for voice in measure['voices']:
for note in voice['notes']:
for field in ('_guitar_profile', '_guitar_lane', '_bass_pc', '_harmonize', '_continue_from'):
for field in ('_guitar_profile', '_guitar_lane', '_harmonize', '_continue_from'):
note.pop(field, None)
if skipped:
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
-1
View File
@@ -35,7 +35,6 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani
generator.scat.add_scat(result)
else:
generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
+149
View File
@@ -0,0 +1,149 @@
import copy
import fractions
import unittest
import generator.accompaniment
import generator.acoustic
import generator.guitar
import generator.harmony
import generator.model
import tests.test_acoustic
import tests.test_accompaniment
def planned_document(harmonies, attacks=None):
if attacks is None:
attacks = [([(0, 1), (2, 1)], [(1, 1), (3, 1)])] * len(harmonies)
document = tests.test_acoustic.acoustic_document(attacks)
previous = None
for measure, changes in zip(document['staves'][0]['measures'], harmonies):
measure['harmonies'] = []
for onset, symbol in changes:
harmony = {'kind': 'none', 'degrees': []} if symbol == 'N.C.' else generator.harmony.parse_symbol(symbol) if isinstance(symbol, str) else copy.deepcopy(symbol)
measure['harmonies'].append(harmony | {'id': f'{measure["id"]}-h{len(measure["harmonies"])}', 'onset': generator.model.rational(onset)})
for voice in measure['voices']:
for note in voice['notes']:
if not note['pitches']:
continue
note['pitches'] = []
harmony = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous)
if harmony:
note.update(_harmony=harmony, _guitar_profile='acoustic', _guitar_lane=voice['id'])
else:
note['kind'] = 'rest'
if measure['harmonies']:
previous = measure['harmonies'][-1]
generator.acoustic.prepare(document)
return document
def shape(note):
tuning = dict(generator.guitar.STANDARD_TUNING)
return tuple(sorted(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], tuning[p['tablature']['string']] + p['tablature']['fret']) for p in note['pitches']))
class AcousticVoicingTests(unittest.TestCase):
def place(self, document):
spans = generator.acoustic.harmony_spans(document)
lanes = {n['id']: n['_guitar_lane'] for span in spans for n in span.notes}
before = [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']]
generator.guitar.place_guitar(document)
self.assertEqual(before, [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']])
for span in spans:
basses = {shape(n) for n in span.notes if lanes[n['id']] == 'bass'}
uppers = {shape(n) for n in span.notes if lanes[n['id']] == 'upper'}
self.assertLessEqual(len(basses), 1)
self.assertLessEqual(len(uppers), 1)
grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True))
required, allowed, bass = generator.guitar.chord_requirements(span.harmony, 'acoustic')
if basses and uppers:
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
self.assertTrue(set(required) <= {p.midi % 12 for p in grip} <= set(allowed))
if basses:
self.assertEqual(min(p.midi for p in grip) % 12, bass)
self.assertTrue(generator.guitar.within_fret_span(grip))
self.assertTrue(all(0 <= p.fret <= 20 for p in grip))
for note in span.notes:
self.assertFalse(any(k.startswith('_') for k in note))
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
generator.model.validate_document(document)
return spans
def test_independent_attacks_keep_one_complete_grip(self):
document = planned_document([[(0, 'FM7')]])
spans = self.place(document)
self.assertEqual(len(spans), 1)
self.assertEqual(len(spans[0].notes), 4)
before = tests.test_acoustic.sounding(document)
generator.acoustic.combine(document)
self.assertEqual(tests.test_acoustic.sounding(document), before)
generator.model.validate_document(document)
def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self):
attacks = [([(0, 1), (2, 1)], [(0, 1), (3, 1)]), ([], []), ([(2, 1)], [(1, 1), (3, 1)])]
document = planned_document([[(0, 'C7')], [], [(0, 'C7'), (2, 'C7')]], attacks)
document['staves'][0]['measures'][2]['section_label'] = 'Next section'
self.assertEqual(len(self.place(document)), 1)
def test_changes_during_silence_end_the_span(self):
for middle in ('G7', 'N.C.'):
with self.subTest(middle=middle):
document = planned_document([[(0, 'C7'), (1, middle), (2, 'C7')]], [([(0, 1), (2, 1)], [(0, 1), (3, 1)])])
spans = self.place(document)
self.assertEqual(len(spans), 2)
self.assertEqual([len(s.notes) for s in spans], [2, 2])
def test_returning_to_a_chord_is_a_new_span(self):
document = planned_document([[(0, 'C7')], [(0, 'G7')], [(0, 'C7')]])
self.assertEqual(len(self.place(document)), 3)
def test_slash_bass_inversion_and_alteration_changes_are_boundaries(self):
inversion = generator.harmony.parse_symbol('C7') | {'inversion': 2}
changes = [(0, 'C7'), (1, 'C7/E'), (2, inversion), (3, 'C7b5')]
document = planned_document([changes], [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])])
spans = self.place(document)
self.assertEqual(len(spans), 4)
self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 0])
def test_non_accompaniment_passage_ends_the_span(self):
document = planned_document([[(0, 'C7')], [], []])
measure = document['staves'][0]['measures'][1]
slash = generator.model.make_event('slash', fractions.Fraction(), generator.model.fraction(measure['duration']), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'}
measure['voices'] = [{'id': 'voice-arranged', 'notes': [slash]}]
self.assertEqual(len(self.place(document)), 2)
def test_carried_harmony_can_begin_an_arrangement_excerpt(self):
document = planned_document([[(0, 'C7')], []])
document['staves'][0]['measures'][0]['harmonies'] = []
self.assertEqual(len(self.place(document)), 1)
def test_no_chord_does_not_generate_an_attack(self):
document = planned_document([[(0, 'N.C.')], [(0, 'C7')], [(0, 'N.C.')]])
self.assertEqual(len(self.place(document)), 1)
for measure in (document['staves'][0]['measures'][0], document['staves'][0]['measures'][2]):
self.assertTrue(all(n['kind'] == 'rest' for v in measure['voices'] for n in v['notes']))
def test_candidate_includes_unsounded_half_of_the_grip(self):
for lane in ('bass', 'upper'):
document = planned_document([[(0, 'C7')]], [([(0, 1)] if lane == 'bass' else [], [(1, 1)] if lane == 'upper' else [])])
span = generator.acoustic.harmony_spans(document)[0]
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertTrue(candidates)
for candidate in candidates:
self.assertIn(tuple(p.string_number for p in candidate.shape), generator.guitar.ACOUSTIC_STRINGS)
self.assertEqual({p for _, p in candidate.active}, set(candidate.shape))
self.assertEqual(len(candidate.choices), 1)
self.assertEqual(len(candidate.choices[0].added), 1 if lane == 'bass' else len(candidate.shape) - 1)
self.place(document)
def test_rhythm_density_does_not_multiply_shape_cost(self):
harmonies = [[(0, symbol)] for symbol in ('FM7', 'Gm7', 'C7')]
sparse = planned_document(harmonies, [([(0, 1)], [(2, 1)])] * 3)
dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3)
selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)]
self.assertEqual(*selected)
def test_span_search_matches_exhaustive_full_grip_transitions(self):
document = planned_document([[(0, s)] for s in ('FM7', 'Gm7', 'C7', 'FM7')])
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
for span in generator.acoustic.harmony_spans(document):
candidates = list(generator.guitar.acoustic_candidates(span))
expected = {}
for candidate in candidates:
expected[candidate.active] = min((previous.cost.moved(generator.guitar.movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values()))
actual = generator.guitar.advance_attacks(candidates, states)
predecessors = {id(p): i for i, p in enumerate(states.values())}
self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected)
states = actual
def test_impossible_grip_fails_contextually_without_partial_materialization(self):
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*0-20'):
generator.guitar.place_guitar(document)
self.assertEqual(document, before)
+6
View File
@@ -199,3 +199,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>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>'
(root / 'data/practice/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:.*measure 1.*no acoustic grip.*0-20'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
+6 -3
View File
@@ -39,7 +39,7 @@ class GuitarPartTests(unittest.TestCase):
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0])
candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
def test_acoustic_combines_independent_rhythms_and_alternating_bass(self):
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
result = self.practice(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
measure = result['staves'][0]['measures'][0]
self.assertEqual(len(measure['voices']), 1)
@@ -47,7 +47,10 @@ class GuitarPartTests(unittest.TestCase):
bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p]
upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p}
self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper)
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [0, 7])
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [0, 0])
self.assertEqual(bass[0][1]['tablature'], bass[1][1]['tablature'])
upper_shapes = {tuple((p['tablature']['string'], p['tablature']['fret']) for p in n['pitches'] if p['tablature']['string'] <= 4) for n in notes}
self.assertEqual(len(upper_shapes - {()}), 1)
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4)
for note in notes:
self.assertNotIn('stem', note)
@@ -62,7 +65,7 @@ class GuitarPartTests(unittest.TestCase):
positions = [generator.guitar.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous]
self.assertEqual(len(positions), len({p.string_number for p in positions}))
self.assertTrue(generator.guitar.within_fret_span(positions))
def test_explicit_bass_and_fifth_fallback(self):
def test_explicit_bass_and_default_root(self):
for symbol, expected in (('CM/E', 4), ('C7b9', 0)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
chord = generator.harmony.parse_symbol(symbol)