Fix Generation

This commit is contained in:
khodowany
2026-09-26 22:59:14 -05:00
parent 83154b1a01
commit 3beeb87549
9 changed files with 239 additions and 9 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 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.
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 can be the root or the chord's fifth, whichever makes the complete grip easier; explicit slash basses and inversions remain authoritative. 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 targets about two stabs per 4/4 bar, scaled for other meters, favoring gaps around the melody and acoustic rhythms. Each stab lasts at most a quarter note and reuses the corresponding melody passage's chord shape when available. 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.
+4 -2
View File
@@ -66,7 +66,7 @@ The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and
`generator.rhythm.load_library()` parses the library once per asset generation. Immutable snippets retain exact event onsets, durations, written values, rests, beams, and continuations. Their placeholder pitches and tempo do not transpose chords or select playback BPM. A snippet must contain one or two complete sequential bars in one supported meter and voice, without grace notes, tuplets, time modifications, tremolo, or meter changes.
`generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. The immutable plan has separate `acoustic_bass`, `acoustic_upper`, and `electric` lanes. Density targets are one bass attack per two quarter-note beats, one upper-chord attack per beat, and one electric attack per four beats. Harmonic coverage and pulse support take priority for acoustic, while electric favors sparse responses around melody and acoustic attacks. Silent electric bars are allowed. Simultaneous bass and upper attacks are allowed, as are collisions with the melody. Source identity breaks ties deterministically; selection does not enumerate every pair of one-bar snippets or introduce randomness.
`generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. The immutable plan has separate `acoustic_bass`, `acoustic_upper`, and `electric` lanes. Density targets are one bass attack per two quarter-note beats, one upper-chord attack per beat, and one electric attack per two beats. These are scoring targets, not per-measure limits: electric aims for about two short stabs in 4/4, with metrical scaling and phrase variation. Harmonic coverage and pulse support take priority for acoustic, while electric favors responses around melody and acoustic attacks. Silent electric bars are allowed. Simultaneous bass and upper attacks are allowed, as are collisions with the melody. Source identity breaks ties deterministically; selection does not enumerate every pair of one-bar snippets or introduce randomness.
Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state carries across bars until changed or canceled by N.C.; a snippet attack sounds only when a chord is active. A held generated accompaniment chord stops before a different harmony or N.C., leaving silence until the next selected attack. Electric stabs last at most one quarter note and never continue a snippet tie; their remaining duration becomes rests. Melody attacks influence selection but do not directly delete acoustic attacks. Missing rhythm material, unsupported accompaniment meters, and mid-measure meter changes produce errors. The broader import and engraving model still accepts the meters described below.
@@ -136,7 +136,7 @@ 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. 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.
`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. Root-bass and fifth-bass candidates compete using the same complete-grip cost; fifth choices are not merely fallbacks. The fifth follows the chord's actual degree, including diminished or augmented fifths, and is unavailable if omitted from the chord. Explicit slash basses and inversions override this choice. All required chord tones remain mandatory, including the root when required in an upper voice; 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.
@@ -146,6 +146,8 @@ 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.
`electric.py` links song accompaniment attacks to harmonized melody events using authored source positions, not displayed measure numbers or a song-wide chord dictionary. References stay inside the corresponding source harmony span and must match its structured chord. Within a span, an attack uses the latest available melody grip at or before its source position, or the first later grip if it precedes all melody chords. This allows different shapes for the same chord in different passages. After guitar placement and melody tie resolution, linked stabs copy the actual melody pitches and TAB positions while retaining their own IDs, timing, and written durations; melody ties and markings are not copied. A melody that remains a single note provides no grip. Passages without a matching playable three- or four-string melody chord, including altered endings without a corresponding reference, retain independently generated electric shapes. No reference metadata reaches runtime JSON, and practice electric remains the melody part without an added accompaniment section.
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.
+1 -1
View File
@@ -75,7 +75,7 @@ def measure_rank(bar, offset, intervals, melody_attacks, melody_spans, companion
missing_bars = int(bool(intervals) and not spans and role != 'electric')
uncovered = sum(not any(start < b and end > a for start, end in spans) for a, b in intervals)
duration = sum((b - a for a, b in intervals), fractions.Fraction())
density = abs(len(attacks) - duration / {'electric': 4, 'bass': 2, 'upper': 1}[role])
density = abs(len(attacks) - duration / {'electric': 2, 'bass': 2, 'upper': 1}[role])
collisions = sum((t in melody_attacks) + (t in companions) for t in attacks)
occupied = sum(any(a <= t < b for a, b in melody_spans) for t in attacks)
pulse = -sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks)
+6 -1
View File
@@ -7,6 +7,7 @@ import pathlib
import generator.accompaniment
import generator.acoustic
import generator.arrange.form
import generator.electric
import generator.guitar
import generator.melody
import generator.model
@@ -48,7 +49,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
staff = generator.score.source_staff(document)
source = staff['measures']
sections = generator.arrange.form.full_arrangement_sections(source, part_type)
output, tuplets, octave_shifts = [], [], []
output, tuplets, octave_shifts, voicing_origins = [], [], [], []
absolute = fractions.Fraction()
source_starts = []
source_time = fractions.Fraction()
@@ -67,6 +68,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)]
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm, melody_octave=True)
segment.append(result)
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
identity_segment = segment
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
if section.role in ('vocal', 'melody'):
@@ -116,9 +119,11 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
if part_type != 'vocal':
generator.acoustic.prepare(result)
generator.melody.harmonize(result)
voicing_links = generator.electric.link_voicings(source, voicing_origins) if part_type == 'electric' else []
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
generator.electric.reuse_voicings(voicing_links)
generator.acoustic.combine(result)
return result
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import bisect
import copy
import dataclasses
import fractions
import generator.accompaniment
import generator.guitar
import generator.model
@dataclasses.dataclass(frozen=True)
class MeasureOrigin:
onset: fractions.Fraction
role: str
measure: dict
@dataclasses.dataclass(frozen=True)
class VoicingLink:
target: dict
references: tuple[dict, ...]
def link_voicings(source: list[dict], origins: list[MeasureOrigin]) -> list[VoicingLink]:
"""Link accompaniment to melody attacks in the same authored harmony span."""
boundaries, previous, start = [], None, fractions.Fraction()
for measure in source:
for harmony in measure['harmonies']:
if not generator.accompaniment.same_chord(previous, harmony):
boundaries.append(start + generator.model.fraction(harmony['onset']))
previous = harmony
start += generator.model.fraction(measure['duration'])
melody, targets = {}, []
for origin in origins:
for voice in origin.measure['voices']:
for note in voice['notes']:
if '_harmony' not in note:
continue
onset = origin.onset + generator.model.fraction(note['onset'])
span = bisect.bisect_right(boundaries, onset)
if origin.role == 'melody' and note.get('_harmonize'):
melody.setdefault(span, []).append((onset, note['_harmony'], note))
elif origin.role == 'accompaniment':
targets.append((span, onset, note))
links = []
for span, onset, note in targets:
matches = [(time, reference) for time, harmony, reference in melody.get(span, []) if generator.accompaniment.same_chord(harmony, note['_harmony'])]
# Before the first melody chord, use the first grip in this harmony span.
matches.sort(key=lambda item: (item[0] > onset, abs(item[0] - onset), item[1]['id']))
if matches:
links.append(VoicingLink(note, tuple(reference for _, reference in matches)))
return links
def reuse_voicings(links: list[VoicingLink]) -> None:
"""Copy placed melody grips without changing either part's rhythm or ties."""
for link in links:
for reference in link.references:
strings = tuple(sorted((p['tablature']['string'] for p in reference['pitches']), reverse=True))
if strings not in generator.guitar.ELECTRIC_STRINGS:
continue
note = link.target
note['pitches'] = [copy.deepcopy({**{k: p[k] for k in ('letter', 'alter', 'octave', 'tablature')}, 'id': f'{note["id"]}-s{p["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']}) for p in reference['pitches']]
break
+6 -1
View File
@@ -236,7 +236,12 @@ class EventPlacement:
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'):
basses = {bass}
degrees = generator.harmony.degrees(span.harmony)
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
basses.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
shapes = sorted({shape for value in sorted(basses) for shape in chord_positions(required, allowed, value, profile='acoustic')})
for shape in shapes:
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))
+9 -1
View File
@@ -179,7 +179,15 @@ class AccompanimentPlanningTests(unittest.TestCase):
measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures']
plan = generator.accompaniment.plan_section(measures, library)
self.assertEqual(plan.acoustic_upper, (sounding,) * 4)
self.assertEqual(plan.electric, (silent,) * 4)
self.assertEqual(plan.electric, (sounding,) * 4)
def test_electric_density_targets_two_stabs_per_four_quarter_beats(self):
library = tests.rhythm_fixtures.library()
for beats in (2, 3, 4, 5):
with self.subTest(beats=beats):
plan = generator.accompaniment.plan_section(melody_measures(4, beats=beats), library)
for bar in plan.electric[:3]:
self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2})
self.assertLessEqual(len(plan.electric[3].attacks()), (beats + 1) // 2 + 1)
def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self):
for beats in (2, 3, 4, 5):
measures = melody_measures(12, beats=beats, dense=True)
+25 -2
View File
@@ -53,11 +53,15 @@ class AcousticVoicingTests(unittest.TestCase):
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')
bass_classes = {bass}
degrees = generator.harmony.degrees(span.harmony)
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
bass_classes.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
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.assertIn(min(p.midi for p in grip) % 12, bass_classes)
self.assertTrue(generator.guitar.within_fret_span(grip))
self.assertTrue(all(0 <= p.fret <= 20 for p in grip))
for note in span.notes:
@@ -74,6 +78,25 @@ class AcousticVoicingTests(unittest.TestCase):
generator.acoustic.combine(document)
self.assertEqual(tests.test_acoustic.sounding(document), before)
generator.model.validate_document(document)
def test_simpler_fifth_bass_can_win_without_alternating(self):
document = planned_document([[(0, 'Cdim7')], []])
spans = self.place(document)
self.assertEqual(len(spans), 1)
bass = [p for n in spans[0].notes for p in shape(n) if p.string_number >= 5]
self.assertTrue(bass)
self.assertEqual({p.midi % 12 for p in bass}, {6})
root_shapes = generator.guitar.chord_positions(*generator.guitar.chord_requirements(spans[0].harmony, 'acoustic'), profile='acoustic')
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
self.assertLess(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
def test_bass_options_follow_defined_fifth_and_explicit_basses(self):
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
for chord, expected in (('C7', {0, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('C7/E', {4}), (inversion, {0})):
with self.subTest(chord=chord):
span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertEqual({min(p.midi for p in c.shape) % 12 for c in candidates}, expected)
required, allowed, _ = generator.guitar.chord_requirements(span.harmony, 'acoustic')
self.assertTrue(all(set(required) <= {p.midi % 12 for p in c.shape} <= set(allowed) for c in candidates))
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)
@@ -95,7 +118,7 @@ class AcousticVoicingTests(unittest.TestCase):
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])
self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 6])
def test_non_accompaniment_passage_ends_the_span(self):
document = planned_document([[(0, 'C7')], [], []])
measure = document['staves'][0]['measures'][1]
+125
View File
@@ -0,0 +1,125 @@
import copy
import fractions
import pathlib
import unittest
import unittest.mock
import generator.arrange.form
import generator.arrange.generate
import generator.electric
import generator.guitar
import generator.harmony
import generator.model
import generator.musicxml
import tests.rhythm_fixtures
def harmony(symbol, onset=0):
return generator.harmony.parse_symbol(symbol) | {'id': f'chord-{onset}', 'onset': generator.model.rational(onset)}
def note(identifier, onset, chord, frets, *, melody=False, duration=1):
tuning = dict(generator.guitar.STANDARD_TUNING)
positions = tuple(generator.guitar.TabPosition(string, fret, tuning[string] + fret) for string, fret in zip((4, 3, 2, 1), frets))
pitches = generator.guitar.chord_pitches(chord, positions)
result = generator.model.make_event(identifier, fractions.Fraction(onset), fractions.Fraction(duration), 'note', pitches)
result.update(_harmony=chord, _guitar_profile='electric')
if melody:
result['_harmonize'] = True
return result
def measure(notes, harmonies=()):
return {'number': '1', 'duration': generator.model.rational(4), 'harmonies': list(harmonies), 'voices': [{'id': 'voice', 'notes': notes}]}
def shape(event):
return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches']))
class ElectricVoicingTests(unittest.TestCase):
def test_corresponding_melody_shapes_change_within_one_harmony(self):
chord = harmony('C7')
first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2)
second = note('melody-2', 2, chord, (10, 9, 11, 8), melody=True, duration=2)
earlier = [note(f'accompaniment-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 2)]
later = [note(f'accompaniment-next-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 3)]
source = [measure([], [chord]), measure([], [copy.deepcopy(chord)])]
origins = [generator.electric.MeasureOrigin(fractions.Fraction(0), 'melody', measure([first])), generator.electric.MeasureOrigin(fractions.Fraction(4), 'melody', measure([second])), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure(earlier)), generator.electric.MeasureOrigin(fractions.Fraction(4), 'accompaniment', measure(later))]
links = generator.electric.link_voicings(source, origins)
self.assertEqual(len(links), 4)
before = copy.deepcopy((first, second))
generator.electric.reuse_voicings(links)
self.assertEqual((first, second), before)
self.assertEqual([shape(n) for n in earlier + later], [shape(first)] * 3 + [shape(second)])
self.assertTrue(all(p['duration'] == n['duration'] and p['written'] == n['written'] for n in earlier + later for p in n['pitches']))
def test_links_follow_actual_placement_without_copying_melody_ties(self):
chord = harmony('FM7')
reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True, duration=2)
target = note('stab', 1, chord, (3, 2, 1, 0))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
links = generator.electric.link_voicings([measure([], [chord])], origins)
for pitch in reference['pitches']:
pitch['tie_to'] = 'next-' + pitch['id']
before = copy.deepcopy(reference)
generator.electric.reuse_voicings(links)
self.assertEqual(reference, before)
self.assertEqual(shape(reference), shape(target))
self.assertTrue(all('tie_to' not in p and 'tie_from' not in p for p in target['pitches']))
self.assertEqual({p['id'] for p in target['pitches']}, {f'stab-s{i}' for i in range(1, 5)})
target['pitches'][0]['tablature']['fret'] = 20
self.assertEqual(reference, before)
def test_unharmonized_passage_does_not_borrow_from_another_occurrence(self):
chord = harmony('C7')
reference = note('melody', 0, chord, (2, 3, 1, 3), melody=True)
target = note('stab', 0, chord, (5, 3, 5, 3))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), 'melody', measure([reference])), generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', measure([target]))]
for middle in (harmony('G7'), {'kind': 'none', 'degrees': [], 'onset': generator.model.rational(0)}):
source = [measure([], [chord]), measure([], [middle]), measure([], [chord])]
self.assertEqual(generator.electric.link_voicings(source, origins), [])
def test_mid_measure_changes_and_omitted_turnaround_do_not_copy_wrong_chord(self):
first, second = harmony('C7'), harmony('FM7', 2)
source = [measure([], [first, second])]
reference = note('melody', 2, second, (3, 5, 5, 5), melody=True)
target = note('stab', 3, first, (2, 3, 1, 3))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
self.assertEqual(generator.electric.link_voicings(source, origins), [])
target['_harmony'] = second
links = generator.electric.link_voicings(source, origins)
self.assertEqual(len(links), 1)
generator.electric.reuse_voicings(links)
self.assertEqual(shape(reference), shape(target))
def test_failed_melody_harmonization_keeps_independent_accompaniment(self):
chord = harmony('FM7')
reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True)
target = note('stab', 1, chord, (3, 2, 1, 0))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
links = generator.electric.link_voicings([measure([], [chord])], origins)
reference['pitches'] = reference['pitches'][:1]
before = copy.deepcopy(target)
generator.electric.reuse_voicings(links)
self.assertEqual(target, before)
def test_real_arrangement_reuses_requested_grips_without_changing_melody(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
before = copy.deepcopy(source)
rhythms = tests.rhythm_fixtures.library()
with unittest.mock.patch('generator.electric.reuse_voicings'):
independent = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
generator.model.validate_document(result)
self.assertEqual(source, before)
start, checked = 0, set()
for section in generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'], 'electric'):
measures = result['staves'][0]['measures'][start:start + section.measure_count]
original = independent['staves'][0]['measures'][start:start + section.measure_count]
start += section.measure_count
if section.role == 'melody':
self.assertEqual(measures, original)
if section.role != 'accompaniment':
continue
for m, old in zip(measures, original):
for voice, old_voice in zip(m['voices'], old['voices']):
self.assertEqual([{k: v for k, v in n.items() if k != 'pitches'} for n in voice['notes']], [{k: v for k, v in n.items() if k != 'pitches'} for n in old_voice['notes']])
for n in voice['notes']:
if not n['pitches'] or m['number'] not in ('4', '12', '22', '24'):
continue
expected = (3, 3, 3, 3) if m['number'] == '22' else (3, 5, 5, 5)
self.assertEqual(tuple(f for _, f in sorted(shape(n), reverse=True)), expected)
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in n['pitches']))
checked.add(m['number'])
self.assertEqual(checked, {'4', '12', '22', '24'})