Generation
ci / checks (push) Successful in 1h10m52s

This commit is contained in:
khodowany
2026-09-26 17:26:22 -05:00
parent 11222a9f8c
commit c8581ea51a
11 changed files with 343 additions and 186 deletions
+3 -1
View File
@@ -105,7 +105,7 @@ Identical notation bodies share layout and body rasterization even when titles o
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated-tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Four-digit numbered sources in `data/practice/` (currently `0001.xml` through `0366.xml`; `0000.xml` is also valid) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises. 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. 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. 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. 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. 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.
+8 -4
View File
@@ -122,9 +122,9 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
### Shared Part Generation ### 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. 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. `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. `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 ### Practice Scat
+2 -13
View File
@@ -63,7 +63,7 @@ def context(measure: dict, previous: dict | None) -> Context:
@functools.lru_cache(maxsize=4096) @functools.lru_cache(maxsize=4096)
def sounding_rhythm(bar, offset, intervals): 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)) 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 return attacks, spans
def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]: def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]:
@@ -186,19 +186,10 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *,
continue continue
chord = active_chord(measure, start, previous) if source.sounding else None chord = active_chord(measure, start, previous) if source.sounding else None
continuation = source.continuation and source.onset - item.offset == start continuation = source.continuation and source.onset - item.offset == start
preceding = previous
if continuation: 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 sounding_end = end
if chord and lane == 'electric': 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']: for change in measure['harmonies']:
time = generator.model.fraction(change['onset']) time = generator.model.fraction(change['onset'])
@@ -210,8 +201,6 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *,
event['_harmony'] = chord event['_harmony'] = chord
if lane: if lane:
event.update(_guitar_profile='electric' if lane == 'electric' else 'acoustic', _guitar_lane=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: if source.beams and written and chord:
event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams] event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams]
events.append(event) events.append(event)
@@ -257,7 +246,7 @@ def prepare_basses(document: dict) -> None:
if not same_chord(chord, previous): if not same_chord(chord, previous):
attack, current_bass = 0, root attack, current_bass = 0, root
explicit = 'bass' in chord or 'inversion' in chord 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) 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 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 attack += 1
+56 -49
View File
@@ -1,71 +1,78 @@
from __future__ import annotations from __future__ import annotations
import dataclasses
import fractions import fractions
import itertools
import generator.accompaniment
import generator.model import generator.model
@dataclasses.dataclass
class SoundingTone:
pitch: dict
start: fractions.Fraction
end: fractions.Fraction
def written_durations(length: fractions.Fraction): def written_durations(length: fractions.Fraction):
while length: while length:
written = generator.model.written_for(length) written = generator.model.written_for(length)
duration = 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: 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) duration = next((size for size in generator.model.TYPE_LENGTHS.values() if size < length), length)
written = generator.model.written_for(duration) written = generator.model.written_for(duration)
yield duration, written yield duration, written
length -= duration length -= duration
def combine(document: dict) -> None: def measures(document: dict):
"""Consolidate generated acoustic lanes without changing their sounding notes."""
staff = document['staves'][0] staff = document['staves'][0]
if staff.get('arrangement_part_type') != 'acoustic': if staff.get('arrangement_part_type') == 'acoustic':
return yield from (m for m in staff['measures'] if {v['id'] for v in m['voices']} == {'bass', 'upper'})
measures, sounds, roots = [], {}, {}
absolute = fractions.Fraction() def prepare(document: dict) -> None:
for measure in staff['measures']: """Release each grip before the next attack; never require overlapping lanes."""
for measure in measures(document):
length = generator.model.fraction(measure['duration']) length = generator.model.fraction(measure['duration'])
if {voice['id'] for voice in measure['voices']} == {'bass', 'upper'}: 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']))
members = set() groups = [(time, list(notes)) for time, notes in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset']))]
for voice in measure['voices']: for index, (start, notes) in enumerate(groups):
for note in voice['notes']: end = min(start + generator.model.fraction(n['duration']) for n in notes)
start = absolute + generator.model.fraction(note['onset']) 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']: for pitch in note['pitches']:
end = start + generator.model.fraction(pitch['duration']) pitch.update(duration=note['duration'], written=written)
if source := pitch.get('tie_from'): for voice in measure['voices']:
root = roots[source] notes, cursor = [], fractions.Fraction()
sounds[root].end = end for note in voice['notes']:
else: start = generator.model.fraction(note['onset'])
root = pitch['id'] if start > cursor:
sounds[root] = SoundingTone(pitch, start, end) notes.append(generator.model.make_event(f'{note["id"]}-release', cursor, start - cursor, 'rest') | {'voice': voice['id']})
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
notes.append(note) notes.append(note)
start += duration cursor = start + generator.model.fraction(note['duration'])
if not members and length == generator.model.meter_length(measure['meter']): 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}] 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}] measure['voices'] = [{'id': 'voice-arranged', 'notes': notes}]
+1
View File
@@ -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 = 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) result['metadata'].setdefault('tempo', 124)
if part_type != 'vocal': if part_type != 'vocal':
generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result) generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result) generator.melody.harmonize(result)
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
+54 -14
View File
@@ -24,6 +24,32 @@ class TabPosition(typing.NamedTuple):
fret: int fret: int
midi: 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: def within_fret_span(positions) -> bool:
frets = [position.fret for position in positions] frets = [position.fret for position in positions]
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN return not frets or max(frets) - min(frets) <= 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 return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
@functools.lru_cache(maxsize=256) @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 = [] 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] 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 = {} best = {}
for positions in result: for positions in result:
center = position_center(positions) 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]: if center not in best or rank < best[center][0]:
best[center] = (rank, positions) best[center] = (rank, positions)
return tuple(sorted(item[1] for item in best.values())) return tuple(sorted(item[1] for item in best.values()))
@@ -151,17 +177,18 @@ def extend_positions(held, pending):
@dataclasses.dataclass @dataclasses.dataclass
class Placement: class Placement:
cost: tuple[int, int, int] cost: VoicingCost
active: tuple active: tuple
previous: Placement | None previous: Placement | None
choices: tuple choices: tuple
def nearest_positions(states: dict) -> list[list[tuple | None]]: def nearest_positions(states: dict) -> list[list[tuple | None]]:
"""Exact Manhattan distance transform on the small fret/string position grid.""" """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()): for index, previous in enumerate(states.values()):
if not previous.active: 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)) x, y = position_center(tuple(p for _, p in previous.active))
candidate = (previous.cost, index, previous) candidate = (previous.cost, index, previous)
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]: 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] source = grid[a][b]
if source is not None: if source is not None:
cost, index, previous = source 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]: if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
grid[x][y] = candidate grid[x][y] = candidate
@@ -198,7 +225,7 @@ class AttackCandidate:
active: tuple active: tuple
choices: tuple choices: tuple
shape: tuple[TabPosition, ...] shape: tuple[TabPosition, ...]
missing: int cost: VoicingCost
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class EventPlacement: 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) result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING)
return result 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): def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False):
pending = [] pending = []
chords = [n for n in notes if '_harmony' in n] 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'): if fallback and chord and chord.get('_harmonize'):
chords, chord, profile = [], None, '' chords, chord, profile = [], None, ''
requirements = chord_requirements(chord['_harmony'], profile) if chord else ((), (), 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 () 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] basses = [chord.get('_bass_pc', requirements[2])] if chord else [None]
if profile == 'acoustic' and requirements[2] not in basses: if profile == 'acoustic' and requirements[2] not in basses:
basses.append(requirements[2]) basses.append(requirements[2])
for bass in basses: 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 found = False
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))): for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
exact_positions = dict(exact) exact_positions = dict(exact)
@@ -258,7 +295,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
if not compatible: if not compatible:
continue continue
found = True 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: else:
shape = tuple(p for _, p in exact) + positions 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): 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 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) 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 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: if found:
break 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: 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) 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)} held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
result = {} result = {}
def keep(candidate, previous, cost): 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: if candidate.active not in result or cost < result[candidate.active].cost:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices) 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) active = dict(previous.active)
held = {i: p for i, p in previous.active if i not in continued and i in held_ids} 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): for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback):
distance = movement(tuple(p for _, p in previous.active), candidate.shape) distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0
keep(candidate, previous, (previous.cost[0] + distance, previous.cost[1], previous.cost[2])) keep(candidate, previous, previous.cost.moved(distance))
if not result: if not result:
if not fallback and any(n.get('_harmonize') for n in notes): if not fallback and any(n.get('_harmonize') for n in notes):
return advance_positions(notes, onset, tones, states, preserve_strings, True) 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.""" """Choose a minimum-cost path, retaining held strings in each search state."""
events, tones = guitar_events(document) 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])] 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): for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None 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) 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)
+1
View File
@@ -34,6 +34,7 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani
if part_type == 'vocal': if part_type == 'vocal':
generator.scat.add_scat(result) generator.scat.add_scat(result)
else: else:
generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result) generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result) generator.melody.harmonize(result)
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
+33 -72
View File
@@ -8,6 +8,7 @@ import xml.etree.ElementTree
import generator.accompaniment import generator.accompaniment
import generator.model import generator.model
import generator.parts
import generator.playback import generator.playback
import generator.practice.generate import generator.practice.generate
import generator.rhythm import generator.rhythm
@@ -301,85 +302,45 @@ class AccompanimentRealizationTests(unittest.TestCase):
self.assertEqual(plan.acoustic_bass, (bar,)) self.assertEqual(plan.acoustic_bass, (bar,))
self.assertEqual(plan.electric, (bar,)) self.assertEqual(plan.electric, (bar,))
class TiedAccompanimentTests(unittest.TestCase): class DetachedAccompanimentTests(unittest.TestCase):
def source(self, root='C', kind='major', offset=0): def source(self):
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="start"/>') first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="start"/>')
second = tests.test_parts.xml_harmony(root, kind, offset) + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') second = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="stop"/>')
return tests.test_musicxml.parse(xml_bars([first, second])) return tests.test_musicxml.parse(xml_bars([first, second]))
def assert_tied_chord(self, first, continuation): def test_rhythm_continuations_are_silent_in_all_accompaniment_lanes(self):
self.assertTrue(first['pitches']) source = self.source()
self.assertEqual(len(first['pitches']), len(continuation['pitches'])) first, second = source['staves'][0]['measures']
for previous, current in zip(first['pitches'], continuation['pitches']): bars = tied_library(2).for_meter('4/4')[0].bars
self.assertEqual(current['tie_from'], previous['id']) for lane in ('bass', 'upper', 'electric'):
self.assertEqual(previous['tie_to'], current['id']) events = generator.accompaniment.realize(second, bars[1], first['harmonies'][0], lane=lane)
self.assertEqual(generator.model.pitch_number(current), generator.model.pitch_number(previous)) self.assertTrue(all(n['kind'] == 'rest' and '_rhythm_continuation' not in n for n in events))
self.assertEqual(current['tablature'], previous['tablature']) def test_continuation_only_bar_does_not_count_as_audible_accompaniment(self):
for note in (first, continuation): measures = self.source()['staves'][0]['measures']
self.assertNotIn('_rhythm_continuation', note) with self.assertRaisesRegex(ValueError, 'no rhythm motif'):
for pitch in note['pitches']: generator.accompaniment.plan_section(measures, tied_library(4))
self.assertEqual(pitch['duration'], note['duration']) def test_planner_chooses_attacks_instead_of_continuation_only_bars(self):
self.assertEqual(pitch['written'], note['written']) source = self.source()
def test_whole_note_tie_keeps_second_bar_audible_without_a_new_attack(self): library = generator.rhythm.Library(tied_library(4).for_meter('4/4') + tests.rhythm_fixtures.library().for_meter('4/4'))
self.check_tied_rhythm(4) plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library)
def test_half_note_continuation_preserves_tone_duration_strings_and_following_rest(self): for bar in (*plan.acoustic_bass, *plan.acoustic_upper):
self.check_tied_rhythm(2) self.assertTrue(bar.attacks())
def check_tied_rhythm(self, continuation_duration): def test_acoustic_stops_at_barline_without_inventing_continuation_attacks(self):
source = self.source() source = self.source()
before = copy.deepcopy(source) before = copy.deepcopy(source)
library = tied_library(continuation_duration) bars = tied_library(2).for_meter('4/4')[0].bars
bars = library.for_meter('4/4')[0].bars plan = generator.accompaniment.Plan(bars, bars, ())
self.assertEqual(bars[1].attacks(), ()) prepared = generator.parts.prepare_document(source, practice=True)
plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library) result = generator.practice.generate._practice_document(prepared, 'acoustic', plan)
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)
generator.model.validate_document(result) 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) timeline = generator.playback.compile_timeline(result)
self.assertEqual(generator.model.fraction(timeline['duration']), 8) self.assertTrue(timeline['events'])
self.assertEqual(len(timeline['events']), len(first_tones)) self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 for e in timeline['events']))
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 + continuation_duration for e in timeline['events'])) for measure in result['staves'][0]['measures']:
self.assertEqual(source, before) for voice in measure['voices']:
self.assertEqual(library.for_meter('4/4')[0].bars, bars) for note in voice['notes']:
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:
for pitch in note['pitches']: for pitch in note['pitches']:
self.assertNotIn('tie_from', pitch) self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', 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) 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))
+25 -28
View File
@@ -46,6 +46,11 @@ def acoustic_document(bars):
class AcousticTests(unittest.TestCase): class AcousticTests(unittest.TestCase):
def combine(self, document): 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) before = sounding(document)
generator.acoustic.combine(document) generator.acoustic.combine(document)
generator.model.validate_document(document) generator.model.validate_document(document)
@@ -57,6 +62,8 @@ class AcousticTests(unittest.TestCase):
self.assertNotIn('beams', note) self.assertNotIn('beams', note)
for pitch in note['pitches']: for pitch in note['pitches']:
self.assertNotIn('stem', pitch) self.assertNotIn('stem', pitch)
self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', pitch)
once = copy.deepcopy(document) once = copy.deepcopy(document)
generator.acoustic.combine(document) generator.acoustic.combine(document)
self.assertEqual(document, once) 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] 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(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]) 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] notes = self.combine(acoustic_document([([(0, 4)], [(1, 1), (3, 1)])]))[0]
self.assertTrue(all(n['kind'] == 'note' for n in notes)) 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, 3, 1, 3]) self.assertEqual([len(n['pitches']) for n in notes], [1, 2, 0, 2])
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'])
def test_repeated_pitch_is_an_attack_not_a_tie(self): def test_repeated_pitch_is_an_attack_not_a_tie(self):
notes = self.combine(acoustic_document([([(0, 2), (2, 2)], [(0, 4)])]))[0] notes = self.combine(acoustic_document([([(0, 2), (2, 2)], [(0, 4)])]))[0]
self.assertEqual(len(notes), 2) self.assertEqual(len(notes), 2)
self.assertNotIn('tie_to', notes[0]['pitches'][0]) self.assertNotIn('tie_to', notes[0]['pitches'][0])
self.assertNotIn('tie_from', notes[1]['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([len(n['pitches']) for n in notes], [3, 1])
self.assertEqual(first['tie_to'], second['id']) def test_simultaneous_tones_release_at_shortest_planned_duration(self):
def test_existing_tie_boundary_does_not_add_a_redundant_event(self): notes = self.combine(acoustic_document([([(0, 4)], [(0, 1)])]))[0]
document = acoustic_document([([(0, 1), (1, 1)], [(2, 2)])]) self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 3, 'rest')])
first, second = document['staves'][0]['measures'][0]['voices'][0]['notes'][:2] self.assertEqual(len(notes[0]['pitches']), 3)
first['pitches'][0]['tie_to'] = second['pitches'][0]['id'] def test_barline_releases_notes_before_next_measures_attacks(self):
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):
document = acoustic_document([([(0, 4)], [(2, 2)]), ([(0, 2)], [(0, 1), (2, 1)])]) 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) 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') self.assertEqual(notes[1][-1]['kind'], 'rest')
def test_silence_collapses_to_a_single_measure_rest(self): def test_silence_collapses_to_a_single_measure_rest(self):
notes = self.combine(acoustic_document([([], [])]))[0] notes = self.combine(acoustic_document([([], [])]))[0]
@@ -109,21 +104,23 @@ class AcousticTests(unittest.TestCase):
voice['notes'] = voice['notes'][:1] voice['notes'] = voice['notes'][:1]
notes = self.combine(document)[0] notes = self.combine(document)[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note')]) 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] 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([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'half', 'dots': 0}])
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id']) self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'rest')])
def test_unrelated_voices_and_solo_slashes_are_unchanged(self): def test_unrelated_voices_and_solo_slashes_are_unchanged(self):
document = acoustic_document([([(0, 4)], [(0, 4)])]) document = acoustic_document([([(0, 4)], [(0, 4)])])
for part in ('electric', 'vocal', None): for part in ('electric', 'vocal', None):
document['staves'][0]['arrangement_part_type'] = part document['staves'][0]['arrangement_part_type'] = part
before = copy.deepcopy(document) before = copy.deepcopy(document)
generator.acoustic.prepare(document)
generator.acoustic.combine(document) generator.acoustic.combine(document)
self.assertEqual(document, before) self.assertEqual(document, before)
document['staves'][0]['arrangement_part_type'] = 'acoustic' document['staves'][0]['arrangement_part_type'] = 'acoustic'
measure = document['staves'][0]['measures'][0] 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'}]}] 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) before = copy.deepcopy(document)
generator.acoustic.prepare(document)
generator.acoustic.combine(document) generator.acoustic.combine(document)
self.assertEqual(document, before) self.assertEqual(document, before)
def test_song_and_practice_integration_preserve_playback(self): def test_song_and_practice_integration_preserve_playback(self):
@@ -137,7 +134,7 @@ class AcousticTests(unittest.TestCase):
generator.model.validate_document(combined) generator.model.validate_document(combined)
self.assertTrue(all(len(m['voices']) == 1 for m in combined['staves'][0]['measures'])) 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): 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) self.combine(document)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')) fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts) 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')]) 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']: 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.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')])
+2 -2
View File
@@ -132,14 +132,14 @@ class GuitarTests(unittest.TestCase):
states = {} states = {}
for index in range(40): for index in range(40):
positions = tuple(generator.guitar.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3)) 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) grid = generator.guitar.nearest_positions(states)
for x, row in enumerate(grid): for x, row in enumerate(grid):
for y, actual in enumerate(row): for y, actual in enumerate(row):
candidates = [] candidates = []
for index, state in enumerate(states.values()): for index, state in enumerate(states.values()):
a, b = generator.guitar.position_center(tuple(p for _, p in state.active)) 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)) self.assertEqual(actual[:2], min(candidates))
def test_generated_chord_retains_strings_when_a_melody_enters(self): def test_generated_chord_retains_strings_when_a_melody_enters(self):
content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('C', 2, 'half', octave=7, extra='<voice>2</voice>') content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('C', 2, 'half', octave=7, extra='<voice>2</voice>')
+155
View File
@@ -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='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="stop"/>')
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)