diff --git a/README.md b/README.md index 5677cfa..7194ca8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 88e3364..49a848b 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -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. diff --git a/generator/accompaniment.py b/generator/accompaniment.py index 46a1005..bfda44c 100644 --- a/generator/accompaniment.py +++ b/generator/accompaniment.py @@ -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 diff --git a/generator/acoustic.py b/generator/acoustic.py index 7cb8649..24c3859 100644 --- a/generator/acoustic.py +++ b/generator/acoustic.py @@ -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) diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 4dee46d..1249e7d 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -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): diff --git a/generator/guitar.py b/generator/guitar.py index b87709d..f4e2839 100644 --- a/generator/guitar.py +++ b/generator/guitar.py @@ -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]) diff --git a/generator/practice/generate.py b/generator/practice/generate.py index 2bb2345..342bfde 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -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): diff --git a/tests/test_acoustic_voicing.py b/tests/test_acoustic_voicing.py new file mode 100644 index 0000000..52b757e --- /dev/null +++ b/tests/test_acoustic_voicing.py @@ -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) diff --git a/tests/test_guitar.py b/tests/test_guitar.py index 6deeb68..495adf0 100644 --- a/tests/test_guitar.py +++ b/tests/test_guitar.py @@ -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 = 'Cdominant9-1add111add' + (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') diff --git a/tests/test_guitar_parts.py b/tests/test_guitar_parts.py index c09f189..ab71292 100644 --- a/tests/test_guitar_parts.py +++ b/tests/test_guitar_parts.py @@ -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)