From c8581ea51a157ddb330294c700f4b40adbd45912 Mon Sep 17 00:00:00 2001 From: khodowany Date: Sat, 26 Sep 2026 17:26:22 -0500 Subject: [PATCH] Generation --- README.md | 4 +- TECHNICAL_GUIDE.md | 12 ++- generator/accompaniment.py | 19 +--- generator/acoustic.py | 105 +++++++++++----------- generator/arrange/generate.py | 1 + generator/guitar.py | 68 ++++++++++++--- generator/practice/generate.py | 1 + tests/test_accompaniment.py | 107 ++++++++--------------- tests/test_acoustic.py | 53 ++++++----- tests/test_guitar.py | 4 +- tests/test_voicing.py | 155 +++++++++++++++++++++++++++++++++ 11 files changed, 343 insertions(+), 186 deletions(-) create mode 100644 tests/test_voicing.py diff --git a/README.md b/README.md index 33c21b7..5677cfa 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: sustaining notes use ties, simultaneous tones share a stem, and rests appear only when the entire part is silent. 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; 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 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. 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. @@ -113,6 +113,8 @@ Guitar parts share a theory-based voicing and proximity search over standard tun All parts use two music systems per page, with a single system only when the score ends on an odd system. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product. +Electric melody prioritizes four-string chords, then melody on top, then root inclusion. Lower frets and compact shapes are preferred before optional chord tones; rootless voicings remain fallbacks, not equally desirable defaults. Electric accompaniment shares the fullness/root priorities. Acoustic instead favors simple grips, allowing bass plus two upper notes when that is easier than a four-note shape. Defining chord tones remain mandatory. All guitar parts prefer lower positions continuously rather than using a fret threshold; exact melody notes and necessary high positions remain available. + Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending. The main menu's **Playback** toggle plays the selected part once at the menu BPM, suppresses the metronome, and returns to the part menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. Audio sample sustain loops hold individual notes for their written duration; they never repeat musical passages. No SoundFonts or runtime synthesizer are required. Practice navigation is Exercise -> Part -> Score, with the filename number retained alongside an authored title. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 9f8213b..88e3364 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -122,9 +122,9 @@ 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. After guitar placement and continuation linking, `acoustic.py` combines generated bass/upper lanes 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; 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. -Acoustic consolidation preserves the exact sounding-note timeline and TAB assignments. It combines simultaneous tones, splits sustains at intervening attacks/releases, and ties only fragments of the same sounding note or existing tie chain. Repeated pitches remain fresh attacks. Rests represent complete silence, with one measure rest for a silent full bar. Independent stem directions and old beam groups are removed; standard single-voice engraving supplies shared stems and automatic beams. Binary durations that require multiple written values become ordinary tied notes rather than artificial tuplets. Exact nonbinary timing retains the model's rational time modifications. This pass leaves authored voices and solo slashes unchanged and runs entirely at build time. +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. Practice acoustic accompaniment is derived from structured harmony roots, kinds, degrees, inversions, and slash basses using the same generation rules as songs. A missing harmony or N.C. produces rests, not guessed chords. Electric retains the melody's register and spelling while adding playable chords to long notes. Vocal retains authored notes and receives scat lyrics. Song vocal lyrics are unchanged. Practice optional-turnaround harmonies are played once, without creating extra sections. @@ -138,11 +138,15 @@ All three parts, for both songs and practice, use octave-transposing notation: w `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. -Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voicings and octave doublings are allowed; 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. Melody notes can lie below added chord tones, including non-chord melody pitches that must remain unchanged. +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. `melody.py` treats the full tied duration as one note when testing whether it exceeds one quarter-note beat. Qualifying notes receive a chord at their initial attack and at each subsequent changed harmony, even if a resulting fragment is less than one beat. Changed harmonies break ties and reattack the melody; repeated identical harmonies do not. N.C. releases the added voices while preserving the melody's sustain. Added tones through unchanged harmony retain their own ties. The quarter-note cap on electric stabs does not apply to these melody chords. If no valid chord fits, the exact melody remains with a diagnostic; it is not octave-wrapped or omitted. -Placement minimizes total movement of fret/string position centers, then fret span and omitted optional degrees, with deterministic ties. All three calculations include open strings. Independent attacks use an exact small-grid distance transform; when notes sustain across attacks the search retains actual occupied strings. 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. +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. + +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. ### Practice Scat diff --git a/generator/accompaniment.py b/generator/accompaniment.py index e43290c..46a1005 100644 --- a/generator/accompaniment.py +++ b/generator/accompaniment.py @@ -63,7 +63,7 @@ def context(measure: dict, previous: dict | None) -> Context: @functools.lru_cache(maxsize=4096) def sounding_rhythm(bar, offset, intervals): attacks = tuple(t - offset for t in bar.attacks() if any(a <= t - offset < b for a, b in intervals)) - spans = tuple((max(event.onset - offset, a), min(event.onset + event.duration - offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > offset for a, b in intervals if a <= max(event.onset - offset, 0) < b) + spans = tuple((max(event.onset - offset, a), min(event.onset + event.duration - offset, b)) for event in bar.events if event.sounding and not event.continuation and event.onset + event.duration > offset for a, b in intervals if a <= max(event.onset - offset, 0) < b) return attacks, spans def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]: @@ -186,20 +186,11 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *, continue chord = active_chord(measure, start, previous) if source.sounding else None continuation = source.continuation and source.onset - item.offset == start - preceding = previous if continuation: - for change in measure['harmonies']: - if generator.model.fraction(change['onset']) >= start: - break - preceding = change - if not same_chord(chord, preceding): - chord = None + chord = None sounding_end = end if chord and lane == 'electric': - if continuation: - chord = None - else: - sounding_end = min(end, start + 1) + sounding_end = min(end, start + 1) for change in measure['harmonies']: time = generator.model.fraction(change['onset']) if chord and start < time < sounding_end and not same_chord(chord, active_chord(measure, time, previous)): @@ -210,8 +201,6 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *, event['_harmony'] = chord if lane: event.update(_guitar_profile='electric' if lane == 'electric' else 'acoustic', _guitar_lane=lane) - if continuation: - event['_rhythm_continuation'] = True if source.beams and written and chord: event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams] events.append(event) @@ -257,7 +246,7 @@ def prepare_basses(document: dict) -> None: 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' and not n.get('_rhythm_continuation') for n in group): + 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 diff --git a/generator/acoustic.py b/generator/acoustic.py index 79a23d4..7cb8649 100644 --- a/generator/acoustic.py +++ b/generator/acoustic.py @@ -1,71 +1,78 @@ from __future__ import annotations -import dataclasses import fractions +import itertools +import generator.accompaniment import generator.model -@dataclasses.dataclass -class SoundingTone: - pitch: dict - start: fractions.Fraction - end: fractions.Fraction - def written_durations(length: fractions.Fraction): while length: written = generator.model.written_for(length) duration = length - # Binary spans such as five eighths need ties, not an invented tuplet. if 'ratio' in written and length.denominator & (length.denominator - 1) == 0: duration = next((size for size in generator.model.TYPE_LENGTHS.values() if size < length), length) written = generator.model.written_for(duration) yield duration, written length -= duration -def combine(document: dict) -> None: - """Consolidate generated acoustic lanes without changing their sounding notes.""" +def measures(document: dict): staff = document['staves'][0] - if staff.get('arrangement_part_type') != 'acoustic': - return - measures, sounds, roots = [], {}, {} - absolute = fractions.Fraction() - for measure in staff['measures']: + if staff.get('arrangement_part_type') == 'acoustic': + yield from (m for m in staff['measures'] if {v['id'] for v in m['voices']} == {'bass', 'upper'}) + +def prepare(document: dict) -> None: + """Release each grip before the next attack; never require overlapping lanes.""" + for measure in measures(document): length = generator.model.fraction(measure['duration']) - if {voice['id'] for voice in measure['voices']} == {'bass', 'upper'}: - members = set() - for voice in measure['voices']: - for note in voice['notes']: - start = absolute + generator.model.fraction(note['onset']) + attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches'] or '_harmony' in n), key=lambda n: generator.model.fraction(n['onset'])) + groups = [(time, list(notes)) for time, notes in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset']))] + for index, (start, notes) in enumerate(groups): + end = min(start + generator.model.fraction(n['duration']) for n in notes) + end = min(end, groups[index + 1][0] if index + 1 < len(groups) else length) + # A duration needing ties becomes one shorter attack followed by silence. + duration, written = next(written_durations(end - start)) + for note in notes: + if generator.model.fraction(note['duration']) != duration: + note.update(duration=generator.model.rational(duration), written=written) + note.pop('beams', None) for pitch in note['pitches']: - end = start + generator.model.fraction(pitch['duration']) - if source := pitch.get('tie_from'): - root = roots[source] - sounds[root].end = end - else: - root = pitch['id'] - sounds[root] = SoundingTone(pitch, start, end) - roots[pitch['id']] = root - members.add(root) - measures.append((measure, absolute, members)) - absolute += length - previous = {} - for measure, absolute, members in measures: - length = generator.model.fraction(measure['duration']) - spans = [(root, max(fractions.Fraction(), sounds[root].start - absolute), min(length, sounds[root].end - absolute)) for root in sorted(members)] - boundaries = sorted({fractions.Fraction(), length} | {time for _, start, end in spans for time in (start, end)}) - notes = [] - for start, end in zip(boundaries, boundaries[1:]): - active = sorted((root for root, low, high in spans if low <= start and end <= high), key=lambda root: (generator.model.pitch_number(sounds[root].pitch), sounds[root].pitch['tablature']['string'], root)) - for duration, written in written_durations(end - start): - pitches = [{key: value for key, value in sounds[root].pitch.items() if key not in ('tie_from', 'tie_to', 'stem')} for root in active] - note = generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', start, duration, 'note' if active else 'rest', pitches, written) - note['voice'] = 'voice-arranged' - for root, pitch in zip(active, note['pitches']): - if root in previous: - previous[root]['tie_to'], pitch['tie_from'] = pitch['id'], previous[root]['id'] - previous[root] = pitch + pitch.update(duration=note['duration'], written=written) + for voice in measure['voices']: + notes, cursor = [], fractions.Fraction() + for note in voice['notes']: + start = generator.model.fraction(note['onset']) + if start > cursor: + notes.append(generator.model.make_event(f'{note["id"]}-release', cursor, start - cursor, 'rest') | {'voice': voice['id']}) notes.append(note) - start += duration - if not members and length == generator.model.meter_length(measure['meter']): + cursor = start + generator.model.fraction(note['duration']) + if cursor < length: + notes.append(generator.model.make_event(f'{measure["id"]}-{voice["id"]}-release', cursor, length - cursor, 'rest') | {'voice': voice['id']}) + voice['notes'] = notes + generator.accompaniment.normalize_beams(notes) + +def combine(document: dict) -> None: + """Write prepared bass/upper attacks as one voice with shared stems and rests.""" + for measure in measures(document): + length = generator.model.fraction(measure['duration']) + attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches']), key=lambda n: generator.model.fraction(n['onset'])) + notes, cursor = [], fractions.Fraction() + + def rest(end): + nonlocal cursor + for duration, written in written_durations(end - cursor): + notes.append(generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', cursor, duration, 'rest', written=written) | {'voice': 'voice-arranged'}) + cursor += duration + + for start, group in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset'])): + rest(start) + group = list(group) + pitches = sorted((p for n in group for p in n['pitches']), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string'])) + pitches = [{k: v for k, v in p.items() if k != 'stem'} for p in pitches] + duration = generator.model.fraction(group[0]['duration']) + notes.append(generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', start, duration, 'note', pitches, group[0]['written']) | {'voice': 'voice-arranged'}) + cursor = start + duration + rest(length) + if not attacks and length == generator.model.meter_length(measure['meter']): notes = [generator.model.make_event(f'{measure["id"]}-acoustic-1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice-arranged', 'measure_rest': True}] measure['voices'] = [{'id': 'voice-arranged', 'notes': notes}] diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 20d5c02..4dee46d 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -114,6 +114,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator result = generator.score.make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) 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) diff --git a/generator/guitar.py b/generator/guitar.py index d038c55..b87709d 100644 --- a/generator/guitar.py +++ b/generator/guitar.py @@ -24,6 +24,32 @@ class TabPosition(typing.NamedTuple): fret: int midi: int +class VoicingCost(typing.NamedTuple): + string_shortfall: int = 0 + covered_melody: int = 0 + covering_tones: int = 0 + missing_root: int = 0 + effort: int = 0 + missing_tones: int = 0 + def plus(self, other: VoicingCost) -> VoicingCost: + return VoicingCost(*(a + b for a, b in zip(self, other))) + @functools.lru_cache(maxsize=16384) + def moved(self, distance: int) -> VoicingCost: + return self._replace(effort=self.effort + distance) + +@functools.lru_cache(maxsize=8192) +def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), root: int | None = None, melody: tuple[int, ...] = ()) -> VoicingCost: + frets = {p.fret for p in positions} + classes = {p.midi % 12 for p in positions} + above = sum(p.midi > max(melody) for p in positions) if melody else 0 + span = compactness(positions) + effort = 2 * max(frets, default=0) + span + if profile == 'acoustic': + effort += 2 * span + 2 * max(0, len(frets) - 1) + return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, + int(bool(above)), above, int(profile == 'electric' and root is not None and root not in classes), + effort, len(set(allowed) - classes)) + def within_fret_span(positions) -> bool: frets = [position.fret for position in positions] return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN @@ -69,7 +95,7 @@ def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int, return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc @functools.lru_cache(maxsize=256) -def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[TabPosition, ...], ...]: +def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), root: int | None = None) -> tuple[tuple[TabPosition, ...], ...]: result = [] options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING] @@ -103,7 +129,7 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i best = {} for positions in result: center = position_center(positions) - rank = (compactness(positions), len(set(allowed) - {p.midi % 12 for p in positions}), positions) + rank = (voicing_cost(positions, profile, allowed, root, melody), positions) if center not in best or rank < best[center][0]: best[center] = (rank, positions) return tuple(sorted(item[1] for item in best.values())) @@ -151,17 +177,18 @@ def extend_positions(held, pending): @dataclasses.dataclass class Placement: - cost: tuple[int, int, int] + cost: VoicingCost active: tuple previous: Placement | None choices: tuple def nearest_positions(states: dict) -> list[list[tuple | None]]: """Exact Manhattan distance transform on the small fret/string position grid.""" - grid = [[None for _ in range(11)] for _ in range(2 * MAX_FRET + 1)] + initial = min(((p.cost, i, p) for i, p in enumerate(states.values()) if not p.active), key=lambda item: item[:2], default=None) + grid = [[initial for _ in range(11)] for _ in range(2 * MAX_FRET + 1)] for index, previous in enumerate(states.values()): if not previous.active: - return [[(previous.cost, index, previous) for _ in row] for row in grid] + continue x, y = position_center(tuple(p for _, p in previous.active)) candidate = (previous.cost, index, previous) if grid[x][y] is None or candidate[:2] < grid[x][y][:2]: @@ -171,7 +198,7 @@ def nearest_positions(states: dict) -> list[list[tuple | None]]: source = grid[a][b] if source is not None: cost, index, previous = source - candidate = ((cost[0] + 1, cost[1], cost[2]), index, previous) + candidate = (cost.moved(1), index, previous) if grid[x][y] is None or candidate[:2] < grid[x][y][:2]: grid[x][y] = candidate @@ -198,7 +225,7 @@ class AttackCandidate: active: tuple choices: tuple shape: tuple[TabPosition, ...] - missing: int + cost: VoicingCost @dataclasses.dataclass(frozen=True) class EventPlacement: @@ -211,6 +238,9 @@ def continued_sources(notes): result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING) return result +def has_attack(notes: list[dict]) -> bool: + return any('_harmony' in n and '_continue_from' not in n or any('tie_from' not in p for p in n['pitches']) for n in notes) + def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False): pending = [] chords = [n for n in notes if '_harmony' in n] @@ -227,12 +257,19 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d if fallback and chord and chord.get('_harmonize'): chords, chord, profile = [], None, '' requirements = chord_requirements(chord['_harmony'], profile) if chord else ((), (), None) + root = None + if chord and 1 in generator.harmony.degrees(chord['_harmony']): + pitch = chord['_harmony']['root'] + value = int((generator.model.NATURAL[pitch['letter']] + generator.model.alteration(pitch['alter'])) % 12) + if value in requirements[1]: + 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) if chord else [()] + 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) @@ -258,7 +295,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d if not compatible: continue found = True - yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, len(set(requirements[1]) - {p.midi % 12 for p in positions})) + 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): @@ -266,7 +304,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d 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 - yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions})) + cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost() + yield AttackCandidate(assignment, choices, shape, cost) if found: break @@ -320,11 +359,12 @@ def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool: 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) held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)} result = {} def keep(candidate, previous, cost): - cost = (cost[0], cost[1] + compactness(candidate.shape), cost[2] + candidate.missing) + 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) @@ -339,8 +379,8 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[ active = dict(previous.active) held = {i: p for i, p in previous.active if i not in continued and i in held_ids} for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback): - distance = movement(tuple(p for _, p in previous.active), candidate.shape) - keep(candidate, previous, (previous.cost[0] + distance, previous.cost[1], previous.cost[2])) + distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0 + keep(candidate, previous, previous.cost.moved(distance)) if not result: if not fallback and any(n.get('_harmonize') for n in notes): return advance_positions(notes, onset, tones, states, preserve_strings, True) @@ -351,7 +391,7 @@ def place_guitar(document: dict) -> None: """Choose a minimum-cost path, retaining held strings in each search state.""" 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((0, 0, 0), (), None, ())} + 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) diff --git a/generator/practice/generate.py b/generator/practice/generate.py index 15ffb22..2bb2345 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -34,6 +34,7 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani if part_type == 'vocal': generator.scat.add_scat(result) else: + generator.acoustic.prepare(result) generator.accompaniment.prepare_basses(result) generator.melody.harmonize(result) generator.guitar.place_guitar(result) diff --git a/tests/test_accompaniment.py b/tests/test_accompaniment.py index 9c76326..d2f13e1 100644 --- a/tests/test_accompaniment.py +++ b/tests/test_accompaniment.py @@ -8,6 +8,7 @@ import xml.etree.ElementTree import generator.accompaniment import generator.model +import generator.parts import generator.playback import generator.practice.generate import generator.rhythm @@ -301,85 +302,45 @@ class AccompanimentRealizationTests(unittest.TestCase): self.assertEqual(plan.acoustic_bass, (bar,)) self.assertEqual(plan.electric, (bar,)) -class TiedAccompanimentTests(unittest.TestCase): - def source(self, root='C', kind='major', offset=0): +class DetachedAccompanimentTests(unittest.TestCase): + def source(self): first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='') - second = tests.test_parts.xml_harmony(root, kind, offset) + tests.test_musicxml.xml_note(extra='') + second = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='') return tests.test_musicxml.parse(xml_bars([first, second])) - def assert_tied_chord(self, first, continuation): - self.assertTrue(first['pitches']) - self.assertEqual(len(first['pitches']), len(continuation['pitches'])) - for previous, current in zip(first['pitches'], continuation['pitches']): - self.assertEqual(current['tie_from'], previous['id']) - self.assertEqual(previous['tie_to'], current['id']) - self.assertEqual(generator.model.pitch_number(current), generator.model.pitch_number(previous)) - self.assertEqual(current['tablature'], previous['tablature']) - for note in (first, continuation): - self.assertNotIn('_rhythm_continuation', note) - for pitch in note['pitches']: - self.assertEqual(pitch['duration'], note['duration']) - self.assertEqual(pitch['written'], note['written']) - def test_whole_note_tie_keeps_second_bar_audible_without_a_new_attack(self): - self.check_tied_rhythm(4) - def test_half_note_continuation_preserves_tone_duration_strings_and_following_rest(self): - self.check_tied_rhythm(2) - def check_tied_rhythm(self, continuation_duration): + def test_rhythm_continuations_are_silent_in_all_accompaniment_lanes(self): + source = self.source() + first, second = source['staves'][0]['measures'] + bars = tied_library(2).for_meter('4/4')[0].bars + for lane in ('bass', 'upper', 'electric'): + events = generator.accompaniment.realize(second, bars[1], first['harmonies'][0], lane=lane) + self.assertTrue(all(n['kind'] == 'rest' and '_rhythm_continuation' not in n for n in events)) + def test_continuation_only_bar_does_not_count_as_audible_accompaniment(self): + measures = self.source()['staves'][0]['measures'] + with self.assertRaisesRegex(ValueError, 'no rhythm motif'): + generator.accompaniment.plan_section(measures, tied_library(4)) + def test_planner_chooses_attacks_instead_of_continuation_only_bars(self): + source = self.source() + library = generator.rhythm.Library(tied_library(4).for_meter('4/4') + tests.rhythm_fixtures.library().for_meter('4/4')) + plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library) + for bar in (*plan.acoustic_bass, *plan.acoustic_upper): + self.assertTrue(bar.attacks()) + def test_acoustic_stops_at_barline_without_inventing_continuation_attacks(self): source = self.source() before = copy.deepcopy(source) - library = tied_library(continuation_duration) - bars = library.for_meter('4/4')[0].bars - self.assertEqual(bars[1].attacks(), ()) - plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library) - self.assertEqual(plan.acoustic_upper, bars) - self.assertEqual(plan.acoustic_bass, bars) - self.assertEqual(plan.electric, bars) - result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=library) - first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1) - self.assertEqual(timing(first), [(0, 4, 'note')]) - expected = [(0, continuation_duration, 'note')] - if continuation_duration == 2: - expected.append((2, 2, 'rest')) - self.assertEqual(second[1]['pitches'], []) - self.assertEqual(timing(second), expected) - self.assert_tied_chord(first[0], second[0]) - first_tones = [p for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']] - self.assertEqual({generator.model.pitch_number(p) % 12 for p in first_tones}, {0, 4, 7}) - self.assertEqual(len(result['staves'][0]['measures'][0]['voices']), 1) + bars = tied_library(2).for_meter('4/4')[0].bars + plan = generator.accompaniment.Plan(bars, bars, ()) + prepared = generator.parts.prepare_document(source, practice=True) + result = generator.practice.generate._practice_document(prepared, 'acoustic', plan) generator.model.validate_document(result) + self.assertEqual(timing(tests.test_musicxml.notes(result)), [(0, 4, 'note')]) + self.assertEqual(timing(tests.test_musicxml.notes(result, 1)), [(0, 4, 'rest')]) timeline = generator.playback.compile_timeline(result) - self.assertEqual(generator.model.fraction(timeline['duration']), 8) - self.assertEqual(len(timeline['events']), len(first_tones)) - self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 + continuation_duration for e in timeline['events'])) - self.assertEqual(source, before) - self.assertEqual(library.for_meter('4/4')[0].bars, bars) - def test_changed_harmony_breaks_rhythm_ties_without_inventing_attacks(self): - for root, kind in (('F', 'major'), ('C', 'minor'), ('C', 'none')): - with self.subTest(root=root, kind=kind): - source = self.source(root, kind) - before = copy.deepcopy(source) - result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2)) - first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1) - self.assertEqual(timing(first), [(0, 4, 'note')]) - self.assertEqual(timing(second), [(0, 4, 'rest')]) - self.assertEqual({generator.model.pitch_number(p) % 12 for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']}, {0, 4, 7}) - self.assertEqual(second[0]['pitches'], []) - for note in first + second: + self.assertTrue(timeline['events']) + self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 for e in timeline['events'])) + for measure in result['staves'][0]['measures']: + for voice in measure['voices']: + for note in voice['notes']: for pitch in note['pitches']: self.assertNotIn('tie_from', pitch) self.assertNotIn('tie_to', pitch) - self.assertEqual(pitch['duration'], note['duration']) - generator.model.validate_document(result) - events = generator.playback.compile_timeline(result)['events'] - self.assertEqual({(generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in events}, {(0, 4)}) - self.assertEqual(source, before) - def test_harmony_change_inside_continuation_cuts_tied_sound_and_preserves_rest(self): - source = self.source('F', offset=1) - result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2)) - first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1) - self.assertEqual(timing(first), [(0, 4, 'note')]) - self.assertEqual(timing(second), [(0, 1, 'note'), (1, 3, 'rest')]) - self.assert_tied_chord(first[0], second[0]) - generator.model.validate_document(result) - events = generator.playback.compile_timeline(result)['events'] - self.assertEqual(len(events), sum(len(n['pitches']) for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'])) - self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 5 for e in events)) + self.assertEqual(source, before) diff --git a/tests/test_acoustic.py b/tests/test_acoustic.py index 836e6c1..f23577c 100644 --- a/tests/test_acoustic.py +++ b/tests/test_acoustic.py @@ -46,6 +46,11 @@ def acoustic_document(bars): class AcousticTests(unittest.TestCase): def combine(self, document): + generator.acoustic.prepare(document) + generator.model.validate_document(document) + prepared = copy.deepcopy(document) + generator.acoustic.prepare(document) + self.assertEqual(document, prepared) before = sounding(document) generator.acoustic.combine(document) generator.model.validate_document(document) @@ -57,6 +62,8 @@ class AcousticTests(unittest.TestCase): self.assertNotIn('beams', note) for pitch in note['pitches']: self.assertNotIn('stem', pitch) + self.assertNotIn('tie_from', pitch) + self.assertNotIn('tie_to', pitch) once = copy.deepcopy(document) generator.acoustic.combine(document) self.assertEqual(document, once) @@ -65,37 +72,25 @@ class AcousticTests(unittest.TestCase): notes = self.combine(acoustic_document([([(0, 2), (3, 1)], [(0, 2), (3, 1)])]))[0] self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 1, 'rest'), (3, 1, 'note')]) self.assertEqual([len(n['pitches']) for n in notes], [3, 0, 3]) - def test_sustained_bass_ties_through_upper_attacks_and_releases(self): + def test_bass_releases_at_next_upper_attack_without_reattacking(self): notes = self.combine(acoustic_document([([(0, 4)], [(1, 1), (3, 1)])]))[0] - self.assertTrue(all(n['kind'] == 'note' for n in notes)) - self.assertEqual([len(n['pitches']) for n in notes], [1, 3, 1, 3]) - for first, second in zip(notes, notes[1:]): - self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id']) - self.assertEqual(first['pitches'][0]['tablature'], second['pitches'][0]['tablature']) + self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 1, 'note'), (2, 1, 'rest'), (3, 1, 'note')]) + self.assertEqual([len(n['pitches']) for n in notes], [1, 2, 0, 2]) def test_repeated_pitch_is_an_attack_not_a_tie(self): notes = self.combine(acoustic_document([([(0, 2), (2, 2)], [(0, 4)])]))[0] self.assertEqual(len(notes), 2) self.assertNotIn('tie_to', notes[0]['pitches'][0]) self.assertNotIn('tie_from', notes[1]['pitches'][0]) - for first, second in zip(notes[0]['pitches'][1:], notes[1]['pitches'][1:]): - self.assertEqual(first['tie_to'], second['id']) - def test_existing_tie_boundary_does_not_add_a_redundant_event(self): - document = acoustic_document([([(0, 1), (1, 1)], [(2, 2)])]) - first, second = document['staves'][0]['measures'][0]['voices'][0]['notes'][:2] - first['pitches'][0]['tie_to'] = second['pitches'][0]['id'] - second['pitches'][0]['tie_from'] = first['pitches'][0]['id'] - notes = self.combine(document)[0] - self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'note')]) - def test_cross_measure_ties_preserve_both_rhythms(self): + self.assertEqual([len(n['pitches']) for n in notes], [3, 1]) + def test_simultaneous_tones_release_at_shortest_planned_duration(self): + notes = self.combine(acoustic_document([([(0, 4)], [(0, 1)])]))[0] + self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 3, 'rest')]) + self.assertEqual(len(notes[0]['pitches']), 3) + def test_barline_releases_notes_before_next_measures_attacks(self): document = acoustic_document([([(0, 4)], [(2, 2)]), ([(0, 2)], [(0, 1), (2, 1)])]) - first, second = document['staves'][0]['measures'] - for lane in range(2): - old = next(n for n in first['voices'][lane]['notes'] if n['pitches']) - new = second['voices'][lane]['notes'][0] - for a, b in zip(old['pitches'], new['pitches']): - a['tie_to'], b['tie_from'] = b['id'], a['id'] notes = self.combine(document) - self.assertTrue(all('tie_from' in p for p in notes[1][0]['pitches'])) + self.assertEqual(tests.test_accompaniment.timing(notes[0]), [(0, 2, 'note'), (2, 2, 'note')]) + self.assertEqual(tests.test_accompaniment.timing(notes[1]), [(0, 1, 'note'), (1, 1, 'rest'), (2, 1, 'note'), (3, 1, 'rest')]) self.assertEqual(notes[1][-1]['kind'], 'rest') def test_silence_collapses_to_a_single_measure_rest(self): notes = self.combine(acoustic_document([([], [])]))[0] @@ -109,21 +104,23 @@ class AcousticTests(unittest.TestCase): voice['notes'] = voice['notes'][:1] notes = self.combine(document)[0] self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note')]) - def test_five_eighths_uses_conventional_tied_notes_not_a_tuplet(self): + def test_five_eighths_shortens_to_one_note_without_ties_or_reattacks(self): notes = self.combine(acoustic_document([([(0, fractions.Fraction(5, 2))], [])]))[0] - self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'eighth', 'dots': 0}, {'type': 'quarter', 'dots': 1}]) - self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id']) + self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'half', 'dots': 0}]) + self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'rest')]) def test_unrelated_voices_and_solo_slashes_are_unchanged(self): document = acoustic_document([([(0, 4)], [(0, 4)])]) for part in ('electric', 'vocal', None): document['staves'][0]['arrangement_part_type'] = part before = copy.deepcopy(document) + generator.acoustic.prepare(document) generator.acoustic.combine(document) self.assertEqual(document, before) document['staves'][0]['arrangement_part_type'] = 'acoustic' measure = document['staves'][0]['measures'][0] measure['voices'] = [{'id': 'voice-arranged', 'notes': [generator.model.make_event('slash', fractions.Fraction(), fractions.Fraction(4), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'}]}] before = copy.deepcopy(document) + generator.acoustic.prepare(document) generator.acoustic.combine(document) self.assertEqual(document, before) def test_song_and_practice_integration_preserve_playback(self): @@ -137,7 +134,7 @@ class AcousticTests(unittest.TestCase): generator.model.validate_document(combined) self.assertTrue(all(len(m['voices']) == 1 for m in combined['staves'][0]['measures'])) def test_engraved_chord_has_one_stem_and_no_competing_rests(self): - document = acoustic_document([([(0, 4)], [(1, 1), (3, 1)])]) + document = acoustic_document([([(0, 4)], [(1, 2), (3, 1)])]) self.combine(document) fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')) layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts) @@ -145,4 +142,4 @@ class AcousticTests(unittest.TestCase): self.assertFalse([p for p in primitives if p.role in ('rest', 'tab-rest')]) for note in document['staves'][0]['measures'][0]['voices'][0]['notes']: self.assertEqual(len([p for p in primitives if p.role == 'stem' and note['id'] in p.sources]), 1) - self.assertTrue([p for p in primitives if p.role == 'tie']) + self.assertFalse([p for p in primitives if p.role in ('tie', 'tab-tie')]) diff --git a/tests/test_guitar.py b/tests/test_guitar.py index bcb4da1..6deeb68 100644 --- a/tests/test_guitar.py +++ b/tests/test_guitar.py @@ -132,14 +132,14 @@ class GuitarTests(unittest.TestCase): states = {} for index in range(40): positions = tuple(generator.guitar.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3)) - states[index] = generator.guitar.Placement(tuple(randomizer.randrange(8) for _ in range(3)), tuple((str(i), p) for i, p in enumerate(positions)), None, ()) + states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in range(6))), tuple((str(i), p) for i, p in enumerate(positions)), None, ()) grid = generator.guitar.nearest_positions(states) for x, row in enumerate(grid): for y, actual in enumerate(row): candidates = [] for index, state in enumerate(states.values()): a, b = generator.guitar.position_center(tuple(p for _, p in state.active)) - candidates.append(((state.cost[0] + abs(x - a) + abs(y - b), *state.cost[1:]), index)) + candidates.append((state.cost.moved(abs(x - a) + abs(y - b)), index)) self.assertEqual(actual[:2], min(candidates)) def test_generated_chord_retains_strings_when_a_melody_enters(self): content = tests.test_musicxml.xml_note() + '42' + tests.test_musicxml.xml_note('C', 2, 'half', octave=7, extra='2') diff --git a/tests/test_voicing.py b/tests/test_voicing.py new file mode 100644 index 0000000..10ee5f9 --- /dev/null +++ b/tests/test_voicing.py @@ -0,0 +1,155 @@ +import copy +import pathlib +import unittest + +import generator.arrange.generate +import generator.guitar +import generator.harmony +import generator.model +import generator.musicxml +import tests.rhythm_fixtures +import tests.test_musicxml +import tests.test_parts + +def grip(frets, strings=(4, 3, 2, 1)): + tuning = dict(generator.guitar.STANDARD_TUNING) + return tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in zip(strings, frets)) + +def ranked(symbol, melody=(), profile='electric', prune=False): + definition = generator.harmony.parse_symbol(symbol) + required, allowed, bass = generator.guitar.chord_requirements(definition, profile) + root = int((generator.model.NATURAL[definition['root']['letter']] + generator.model.alteration(definition['root']['alter'])) % 12) + candidates = generator.guitar.chord_positions(required, allowed, bass, prune=prune, profile=profile, melody=melody, root=root) + return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, allowed, root, melody), shape)) + +class VoicingTests(unittest.TestCase): + def test_requested_shapes_win_without_a_shape_dictionary(self): + for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))): + with self.subTest(symbol=symbol): + self.assertEqual(ranked(symbol, (melody,))[0], grip(expected)) + def test_four_strings_precede_melody_height_and_root_presence(self): + candidates = ranked('CM7', (64,)) + four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 64) + three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 64) + self.assertLess(candidates.index(four), candidates.index(three)) + candidates = ranked('FM7', (69,)) + rootless = next(c for c in candidates if len(c) == 4 and 5 not in {p.midi % 12 for p in c}) + rooted = next(c for c in candidates if len(c) == 3 and 5 in {p.midi % 12 for p in c}) + self.assertLess(candidates.index(rootless), candidates.index(rooted)) + def test_melody_on_top_precedes_root_among_four_string_shapes(self): + candidates = ranked('C7b9', (64,)) + best = candidates[0] + self.assertEqual(len(best), 4) + self.assertEqual(max(p.midi for p in best), 64) + self.assertNotIn(0, {p.midi % 12 for p in best}) + self.assertTrue(any(0 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 64 for c in candidates)) + def test_root_precedes_physical_cost_without_covering_the_melody(self): + self.assertEqual(ranked('CM7', (67,))[0], grip((2, 4, 1, 3))) + rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), 0, (67,)) + rootless = generator.guitar.voicing_cost(grip((2, 0, 0, 3)), 'electric', (0, 4, 7, 11), 0, (67,)) + self.assertGreater(rooted.effort, rootless.effort) + self.assertLess(rooted, rootless) + def test_essential_extensions_are_never_relaxed_to_add_the_root(self): + for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'): + definition = generator.harmony.parse_symbol(symbol) + required, allowed, _ = generator.guitar.chord_requirements(definition, 'electric') + for candidate in ranked(symbol): + classes = {p.midi % 12 for p in candidate} + self.assertTrue(set(required) <= classes <= set(allowed)) + def test_explicitly_removed_root_is_not_restored_or_penalized(self): + document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony(kind='major-seventh') + tests.test_musicxml.xml_note('E', octave=5))) + measure = document['staves'][0]['measures'][0] + chord = measure['harmonies'][0] + chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'}) + note = measure['voices'][0]['notes'][0] + measure['transpose']['octave_change'] = -1 + note.update(_harmony=chord, _guitar_profile='electric', _harmonize=True) + _, tones = generator.guitar.guitar_events(document) + candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False)) + self.assertTrue(candidates) + self.assertTrue(all(c.cost.missing_root == 0 and all(p.midi % 12 != 0 for p in c.shape) for c in candidates)) + def test_lower_frets_and_small_spans_have_explicit_costs(self): + for profile in ('', 'electric', 'acoustic'): + low = generator.guitar.voicing_cost(grip((3, 3, 3, 3)), profile) + high = generator.guitar.voicing_cost(grip((15, 15, 15, 15)), profile) + self.assertLess(low, high) + self.assertEqual(high.effort - low.effort, 24) + shape = grip((0, 2, 3, 4)) + self.assertEqual(generator.guitar.voicing_cost(shape).effort, 12) + self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 26) + def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self): + candidates = ranked('C7', profile='acoustic') + best = candidates[0] + self.assertEqual(len(best), 3) + self.assertTrue(any(len(c) == 4 for c in candidates)) + required, _, _ = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic') + self.assertTrue(set(required) <= {p.midi % 12 for p in best}) + def test_nearby_position_can_balance_lower_fret_preference(self): + low, nearby, previous = (grip((f,), (1,)) for f in (3, 4, 5)) + self.assertEqual(generator.guitar.voicing_cost(low).moved(generator.guitar.movement(previous, low)), generator.guitar.voicing_cost(nearby).moved(generator.guitar.movement(previous, nearby))) + def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self): + for symbol, profile, melody in (('FM7', 'electric', (69,)), ('C7', 'acoustic', ()), ('Gm7', 'electric', (67,))): + best = {} + for shape in ranked(symbol, melody, profile): + best.setdefault(generator.guitar.position_center(shape), shape) + self.assertEqual(set(ranked(symbol, melody, profile, prune=True)), set(best.values())) + def test_tie_continuations_do_not_charge_a_second_voicing_cost(self): + note = tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='') + tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='') + document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(note)) + events, tones = generator.guitar.guitar_events(document) + states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())} + first = generator.guitar.advance_positions([events[0][3]], events[0][0], tones, states, False) + second = generator.guitar.advance_positions([events[1][3]], events[1][0], tones, first, False) + self.assertEqual(sorted(p.cost for p in first.values()), sorted(p.cost for p in second.values())) + def test_empty_and_positioned_states_are_compared_by_the_same_grid_cost(self): + empty = generator.guitar.Placement(generator.guitar.VoicingCost(effort=20), (), None, ()) + placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', grip((3,), (1,))[0]),), None, ()) + grid = generator.guitar.nearest_positions({(): empty, 1: placed}) + self.assertIs(grid[6][0][2], placed) + self.assertIs(grid[40][10][2], empty) + def test_optimized_passage_search_matches_exhaustive_candidate_ranking(self): + contents = ''.join(tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5) for _ in range(4)) + document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(contents)) + measure = document['staves'][0]['measures'][0] + for note, symbol in zip(measure['voices'][0]['notes'], ('FM7', 'Gm7', 'C7b9', 'FM7')): + note.update(pitches=[], _harmony=generator.harmony.parse_symbol(symbol), _guitar_profile='electric') + events, tones = generator.guitar.guitar_events(document) + states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())} + for onset, _, _, note in events: + expected = {} + for candidate in generator.guitar.attack_candidates([note], tones, {}, {}, False): + choices = [] + for index, previous in enumerate(states.values()): + distance = generator.guitar.movement(tuple(p for _, p in previous.active), candidate.shape) + choices.append((previous.cost.moved(distance).plus(candidate.cost), index)) + expected[candidate.active] = min(choices) + actual = generator.guitar.advance_positions([note], onset, tones, states, False) + 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_real_autumn_leaves_melody_has_expected_shapes_and_unchanged_notes(self): + source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml')) + before = copy.deepcopy(source) + result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library()) + generator.model.validate_document(result) + section, checked = '', set() + for measure in result['staves'][0]['measures']: + section = measure.get('section_label') or section + if section != 'Guitar Melody': + continue + original = next(m for m in source['staves'][0]['measures'] if m['number'] == measure['number']) + for note in measure['voices'][0]['notes']: + if not note['pitches']: + continue + onset = generator.model.fraction(note['onset']) + authored = next(n for n in original['voices'][0]['notes'] if generator.model.fraction(n['onset']) <= onset < generator.model.fraction(n['onset']) + generator.model.fraction(n['duration'])) + self.assertEqual(generator.model.pitch_number(note['pitches'][0]), generator.model.pitch_number(authored['pitches'][0]) + 12) + if measure['number'] not in ('4', '12', '22', '24') or len(note['pitches']) == 1: + continue + expected = (3, 3, 3, 3) if measure['number'] == '22' else (3, 5, 5, 5) + frets = tuple(p['tablature']['fret'] for p in sorted(note['pitches'], key=lambda p: -p['tablature']['string'])) + self.assertEqual(frets, expected) + self.assertEqual(generator.model.pitch_number(note['pitches'][0]), max(generator.model.pitch_number(p) for p in note['pitches'])) + checked.add(measure['number']) + self.assertEqual(checked, {'4', '12', '22', '24'}) + self.assertEqual(source, before)