Compare commits

..

3 Commits

Author SHA1 Message Date
khodowany a416a80dd9 Fix Generation
ci / checks (push) Successful in 1h10m16s
2026-09-26 23:28:13 -05:00
khodowany 3beeb87549 Fix Generation 2026-09-26 22:59:14 -05:00
khodowany 83154b1a01 Generation 2026-09-26 18:00:51 -05:00
14 changed files with 529 additions and 109 deletions
+2 -2
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 with no ties: each grip releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Bass alternates root/fifth unless an explicit bass or playability requires otherwise. Electric accompaniment responds more sparsely with stabs lasting at most a quarter note. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly. Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable grip until the harmony changes. Its bass pitch, upper voicing, and TAB positions stay constant across rests, barlines, repeated chord symbols, and section labels while accompaniment continues. The bass can be the root or the chord's fifth, whichever makes the complete grip easier; explicit slash basses and inversions remain authoritative. The rhythms combine into one notated voice with no ties: each attack releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Electric accompaniment targets about two stabs per 4/4 bar, scaled for other meters, favoring gaps around the melody and acoustic rhythms. Each stab lasts at most a quarter note and reuses the corresponding melody passage's chord shape when available. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order. 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,7 +113,7 @@ 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. Electric melody prioritizes four-string chords, then melody on top, then root inclusion. Compact shapes, nearby positions, and fret comfort 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 placement uses a fret comfort threshold: frets 0-12 inclusive have no height penalty, and each fret above 12 adds two effort points based on the grip's highest fret. Frets 13-20 remain available; the soft penalty does not override musical priorities or force a less convenient move to a lower position. Exact melody pitches and rhythms remain unchanged.
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.
+8 -4
View File
@@ -66,7 +66,7 @@ The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and
`generator.rhythm.load_library()` parses the library once per asset generation. Immutable snippets retain exact event onsets, durations, written values, rests, beams, and continuations. Their placeholder pitches and tempo do not transpose chords or select playback BPM. A snippet must contain one or two complete sequential bars in one supported meter and voice, without grace notes, tuplets, time modifications, tremolo, or meter changes. `generator.rhythm.load_library()` parses the library once per asset generation. Immutable snippets retain exact event onsets, durations, written values, rests, beams, and continuations. Their placeholder pitches and tempo do not transpose chords or select playback BPM. A snippet must contain one or two complete sequential bars in one supported meter and voice, without grace notes, tuplets, time modifications, tremolo, or meter changes.
`generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. The immutable plan has separate `acoustic_bass`, `acoustic_upper`, and `electric` lanes. Density targets are one bass attack per two quarter-note beats, one upper-chord attack per beat, and one electric attack per four beats. Harmonic coverage and pulse support take priority for acoustic, while electric favors sparse responses around melody and acoustic attacks. Silent electric bars are allowed. Simultaneous bass and upper attacks are allowed, as are collisions with the melody. Source identity breaks ties deterministically; selection does not enumerate every pair of one-bar snippets or introduce randomness. `generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. The immutable plan has separate `acoustic_bass`, `acoustic_upper`, and `electric` lanes. Density targets are one bass attack per two quarter-note beats, one upper-chord attack per beat, and one electric attack per two beats. These are scoring targets, not per-measure limits: electric aims for about two short stabs in 4/4, with metrical scaling and phrase variation. Harmonic coverage and pulse support take priority for acoustic, while electric favors responses around melody and acoustic attacks. Silent electric bars are allowed. Simultaneous bass and upper attacks are allowed, as are collisions with the melody. Source identity breaks ties deterministically; selection does not enumerate every pair of one-bar snippets or introduce randomness.
Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state carries across bars until changed or canceled by N.C.; a snippet attack sounds only when a chord is active. A held generated accompaniment chord stops before a different harmony or N.C., leaving silence until the next selected attack. Electric stabs last at most one quarter note and never continue a snippet tie; their remaining duration becomes rests. Melody attacks influence selection but do not directly delete acoustic attacks. Missing rhythm material, unsupported accompaniment meters, and mid-measure meter changes produce errors. The broader import and engraving model still accepts the meters described below. Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state carries across bars until changed or canceled by N.C.; a snippet attack sounds only when a chord is active. A held generated accompaniment chord stops before a different harmony or N.C., leaving silence until the next selected attack. Electric stabs last at most one quarter note and never continue a snippet tie; their remaining duration becomes rests. Melody attacks influence selection but do not directly delete acoustic attacks. Missing rhythm material, unsupported accompaniment meters, and mid-measure meter changes produce errors. The broader import and engraving model still accepts the meters described below.
@@ -122,7 +122,7 @@ 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. Before guitar placement, `acoustic.py` shortens generated bass/upper attacks so they cannot overlap; afterward it combines them into one notated voice. `melody.py` identifies long tied melody runs and splits them at harmony changes before guitar placement. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer or separate practice fretboard solver. `parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles and realizes independent bass/upper voices or electric stabs. Before guitar placement, `acoustic.py` shortens generated bass/upper attacks so they cannot overlap and groups them into continuous harmony spans; afterward it combines them into one notated voice. `guitar.py` selects one complete grip per acoustic span using its shared candidate generation and placement search. `melody.py` identifies long tied melody runs and splits them at harmony changes before guitar placement. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer or separate practice fretboard solver.
Generated accompaniment has no ties. Acoustic attacks release at the earliest of their planned end, the next combined bass/upper attack, or the barline. Simultaneous bass/upper attacks use the shorter planned duration for the entire grip. If a binary duration would need ties, it shortens to the longest conventional single note that fits, followed by silence; no extra attack or artificial tuplet is introduced. Rhythm-snippet continuations are silent, and the planner does not count them as acoustic coverage. After placement, consolidation preserves the prepared timing and TAB assignments, combines simultaneous tones under one stem, and writes rests only for complete silence. A silent full bar has one measure rest. Old lane stem directions and beam groups are removed in favor of single-voice engraving. Authored melody ties, solo slashes, and electric melody harmonization remain separate from this build-time accompaniment policy. Generated accompaniment has no ties. Acoustic attacks release at the earliest of their planned end, the next combined bass/upper attack, or the barline. Simultaneous bass/upper attacks use the shorter planned duration for the entire grip. If a binary duration would need ties, it shortens to the longest conventional single note that fits, followed by silence; no extra attack or artificial tuplet is introduced. Rhythm-snippet continuations are silent, and the planner does not count them as acoustic coverage. After placement, consolidation preserves the prepared timing and TAB assignments, combines simultaneous tones under one stem, and writes rests only for complete silence. A silent full bar has one measure rest. Old lane stem directions and beam groups are removed in favor of single-voice engraving. Authored melody ties, solo slashes, and electric melody harmonization remain separate from this build-time accompaniment policy.
@@ -136,7 +136,9 @@ All three parts, for both songs and practice, use octave-transposing notation: w
`pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation except for the intentional song electric melody octave increase. There is no automatic octave fitting, vocal-specific range shift, or key transposition. `pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation except for the intentional song electric melody octave increase. There is no automatic octave fitting, vocal-specific range shift, or key transposition.
`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between the lowest and highest positions, including fret 0. Open strings participate in span validation and proximity scoring: 0/2/3 and 0/4 are valid, while 0/5 and 0/11/12 are not. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Bass alternates root and chord fifth, restarting at harmony/section changes; explicit basses/inversions remain fixed. A fifth-bass choice falls back to the root if it cannot preserve chord identity and the held strings. `guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between the lowest and highest positions, including fret 0. Open strings participate in span validation and proximity scoring: 0/2/3 and 0/4 are valid, while 0/5 and 0/11/12 are not. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Each continuous acoustic harmony span keeps the same bass pitch, upper voicing, and string/fret positions. Root-bass and fifth-bass candidates compete using the same complete-grip cost; fifth choices are not merely fallbacks. The fifth follows the chord's actual degree, including diminished or augmented fifths, and is unavailable if omitted from the chord. Explicit slash basses and inversions override this choice. All required chord tones remain mandatory, including the root when required in an upper voice; there is no alternating-bass substitution.
Acoustic spans follow the harmony timeline, including changes during silence. Barline crossings, rests, repeated identical harmonies, and section labels do not reset the grip while accompaniment continues. Changed chord kinds, roots, degrees, basses, or inversions start a new span. N.C. and non-accompaniment passages end a span. Returning to a chord after a different harmony starts a new decision, not a song-wide fixed fingering. A span with attacks is placed once as a complete grip, then each bass or upper attack receives its corresponding subset; simultaneous attacks receive both. If no complete grip satisfies the existing harmonic and fretboard constraints, generation fails with passage context without replacing published assets. Span state is build-only and does not add runtime fields.
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. Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voicings and octave doublings are allowed as fallbacks; the third or suspension, sixth/seventh, alterations, and named extensions remain required. An unaltered fifth in extended chords and unaltered intermediate ninth/eleventh extensions may be omitted. Exact melody pitches, including non-chord tones, remain unchanged. Root preference never restores a degree explicitly subtracted from the harmony.
@@ -144,7 +146,9 @@ Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voici
Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, missing root, physical effort, and missing optional pitch classes. These costs accumulate over the whole passage, so movement cannot outweigh the earlier musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. Acoustic does not receive electric fullness/root penalties: a simpler three-note grip can beat a more awkward four-note grip while satisfying its hard harmonic requirements. 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. `electric.py` links song accompaniment attacks to harmonized melody events using authored source positions, not displayed measure numbers or a song-wide chord dictionary. References stay inside the corresponding source harmony span and must match its structured chord. Within a span, an attack uses the latest available melody grip at or before its source position, or the first later grip if it precedes all melody chords. This allows different shapes for the same chord in different passages. After guitar placement and melody tie resolution, linked stabs copy the actual melody pitches and TAB positions while retaining their own IDs, timing, and written durations; melody ties and markings are not copied. A melody that remains a single note provides no grip. Passages without a matching playable three- or four-string melody chord, including altered endings without a corresponding reference, retain independently generated electric shapes. No reference metadata reaches runtime JSON, and practice electric remains the melody part without an added accompaniment section.
Physical effort is `movement + 2 * max(0, highest_fret - 12) + fret_span` for electric and exact melody placement, and `movement + 2 * max(0, highest_fret - 12) + 3 * fret_span + 2 * (distinct_frets - 1)` for acoustic. The shared `PREFERRED_MAX_FRET` is 12: height alone adds no cost within frets 0-12 inclusive, then adds two points per excess fret through the hard maximum of 20. This is a soft contribution to effort, not a range priority overriding musical correctness, grip simplicity, or movement. Movement is the Manhattan distance between doubled fret/string position centers. Open strings count in every fret measurement. Acoustic shape costs are charged once per harmony span, independent of rhythm density, and transitions compare complete grips rather than whichever lane last sounded. Pure melody tie continuations add no fresh shape or movement cost. There is no 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. There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure.
+1 -27
View File
@@ -3,10 +3,7 @@ from __future__ import annotations
import dataclasses import dataclasses
import fractions import fractions
import functools import functools
import itertools
import generator.guitar
import generator.harmony
import generator.model import generator.model
import generator.rhythm import generator.rhythm
@@ -78,7 +75,7 @@ def measure_rank(bar, offset, intervals, melody_attacks, melody_spans, companion
missing_bars = int(bool(intervals) and not spans and role != 'electric') missing_bars = int(bool(intervals) and not spans and role != 'electric')
uncovered = sum(not any(start < b and end > a for start, end in spans) for a, b in intervals) uncovered = sum(not any(start < b and end > a for start, end in spans) for a, b in intervals)
duration = sum((b - a for a, b in intervals), fractions.Fraction()) duration = sum((b - a for a, b in intervals), fractions.Fraction())
density = abs(len(attacks) - duration / {'electric': 4, 'bass': 2, 'upper': 1}[role]) density = abs(len(attacks) - duration / {'electric': 2, 'bass': 2, 'upper': 1}[role])
collisions = sum((t in melody_attacks) + (t in companions) for t in attacks) collisions = sum((t in melody_attacks) + (t in companions) for t in attacks)
occupied = sum(any(a <= t < b for a, b in melody_spans) for t in attacks) occupied = sum(any(a <= t < b for a, b in melody_spans) for t in attacks)
pulse = -sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks) pulse = -sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks)
@@ -232,26 +229,3 @@ def connect_continuations(document: dict) -> bool:
changed = True changed = True
previous[voice['id']] = note previous[voice['id']] = note
return changed return changed
def prepare_basses(document: dict) -> None:
previous, attack, current_bass = None, 0, None
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
previous, attack = None, 0
notes = sorted((n for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic'), key=lambda n: generator.model.fraction(n['onset']))
for _, group in itertools.groupby(notes, key=lambda n: generator.model.fraction(n['onset'])):
group = list(group)
chord = group[0]['_harmony']
root = (generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) % 12
if not same_chord(chord, previous):
attack, current_bass = 0, root
explicit = 'bass' in chord or 'inversion' in chord
if any(n['_guitar_lane'] == 'bass' for n in group):
degrees = generator.harmony.degrees(chord)
current_bass = (root + generator.harmony.degree_semitones(5, degrees[5])) % 12 if not explicit and attack % 2 and 5 in degrees else root
attack += 1
if explicit:
current_bass = generator.guitar.chord_requirements(chord, 'acoustic')[2]
for note in group:
note['_bass_pc'] = current_bass
previous = chord
+29
View File
@@ -1,11 +1,40 @@
from __future__ import annotations from __future__ import annotations
import dataclasses
import fractions import fractions
import itertools import itertools
import generator.accompaniment import generator.accompaniment
import generator.model import generator.model
@dataclasses.dataclass
class HarmonySpan:
harmony: dict
location: str
notes: list[dict] = dataclasses.field(default_factory=list)
def harmony_spans(document: dict) -> list[HarmonySpan]:
"""Group attacks by continuous harmony, including changes during silence."""
spans, span, active = [], None, None
for measure in document['staves'][0]['measures']:
if {v['id'] for v in measure['voices']} != {'bass', 'upper'}:
span, active = None, None
continue
events = [(generator.model.fraction(h['onset']), 0, h) for h in measure['harmonies']]
events.extend((generator.model.fraction(n['onset']), 1, n) for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic')
for onset, kind, item in sorted(events, key=lambda event: event[:2]):
harmony = item if kind == 0 else item['_harmony']
if not generator.accompaniment.same_chord(active, harmony):
span = None
active = harmony
if kind == 0:
continue
if span is None:
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}')
spans.append(span)
span.notes.append(item)
return spans
def written_durations(length: fractions.Fraction): def written_durations(length: fractions.Fraction):
while length: while length:
written = generator.model.written_for(length) written = generator.model.written_for(length)
+6 -2
View File
@@ -7,6 +7,7 @@ import pathlib
import generator.accompaniment import generator.accompaniment
import generator.acoustic import generator.acoustic
import generator.arrange.form import generator.arrange.form
import generator.electric
import generator.guitar import generator.guitar
import generator.melody import generator.melody
import generator.model import generator.model
@@ -48,7 +49,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
staff = generator.score.source_staff(document) staff = generator.score.source_staff(document)
source = staff['measures'] source = staff['measures']
sections = generator.arrange.form.full_arrangement_sections(source, part_type) sections = generator.arrange.form.full_arrangement_sections(source, part_type)
output, tuplets, octave_shifts = [], [], [] output, tuplets, octave_shifts, voicing_origins = [], [], [], []
absolute = fractions.Fraction() absolute = fractions.Fraction()
source_starts = [] source_starts = []
source_time = fractions.Fraction() source_time = fractions.Fraction()
@@ -67,6 +68,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)] rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)]
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm, melody_octave=True) result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm, melody_octave=True)
segment.append(result) segment.append(result)
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
identity_segment = segment identity_segment = segment
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction()) segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
if section.role in ('vocal', 'melody'): if section.role in ('vocal', 'melody'):
@@ -115,11 +118,12 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
result['metadata'].setdefault('tempo', 124) result['metadata'].setdefault('tempo', 124)
if part_type != 'vocal': if part_type != 'vocal':
generator.acoustic.prepare(result) generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result) generator.melody.harmonize(result)
voicing_links = generator.electric.link_voicings(source, voicing_origins) if part_type == 'electric' else []
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result): if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
generator.electric.reuse_voicings(voicing_links)
generator.acoustic.combine(result) generator.acoustic.combine(result)
return result return result
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import bisect
import copy
import dataclasses
import fractions
import generator.accompaniment
import generator.guitar
import generator.model
@dataclasses.dataclass(frozen=True)
class MeasureOrigin:
onset: fractions.Fraction
role: str
measure: dict
@dataclasses.dataclass(frozen=True)
class VoicingLink:
target: dict
references: tuple[dict, ...]
def link_voicings(source: list[dict], origins: list[MeasureOrigin]) -> list[VoicingLink]:
"""Link accompaniment to melody attacks in the same authored harmony span."""
boundaries, previous, start = [], None, fractions.Fraction()
for measure in source:
for harmony in measure['harmonies']:
if not generator.accompaniment.same_chord(previous, harmony):
boundaries.append(start + generator.model.fraction(harmony['onset']))
previous = harmony
start += generator.model.fraction(measure['duration'])
melody, targets = {}, []
for origin in origins:
for voice in origin.measure['voices']:
for note in voice['notes']:
if '_harmony' not in note:
continue
onset = origin.onset + generator.model.fraction(note['onset'])
span = bisect.bisect_right(boundaries, onset)
if origin.role == 'melody' and note.get('_harmonize'):
melody.setdefault(span, []).append((onset, note['_harmony'], note))
elif origin.role == 'accompaniment':
targets.append((span, onset, note))
links = []
for span, onset, note in targets:
matches = [(time, reference) for time, harmony, reference in melody.get(span, []) if generator.accompaniment.same_chord(harmony, note['_harmony'])]
# Before the first melody chord, use the first grip in this harmony span.
matches.sort(key=lambda item: (item[0] > onset, abs(item[0] - onset), item[1]['id']))
if matches:
links.append(VoicingLink(note, tuple(reference for _, reference in matches)))
return links
def reuse_voicings(links: list[VoicingLink]) -> None:
"""Copy placed melody grips without changing either part's rhythm or ties."""
for link in links:
for reference in link.references:
strings = tuple(sorted((p['tablature']['string'] for p in reference['pitches']), reverse=True))
if strings not in generator.guitar.ELECTRIC_STRINGS:
continue
note = link.target
note['pitches'] = [copy.deepcopy({**{k: p[k] for k in ('letter', 'alter', 'octave', 'tablature')}, 'id': f'{note["id"]}-s{p["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']}) for p in reference['pitches']]
break
+74 -56
View File
@@ -7,6 +7,7 @@ import itertools
import logging import logging
import typing import typing
import generator.acoustic
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.pitch import generator.pitch
@@ -14,6 +15,7 @@ import generator.pitch
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 0 MIN_FRET = 0
MAX_FRET = 20 MAX_FRET = 20
PREFERRED_MAX_FRET = 12
MAX_FRET_SPAN = 4 MAX_FRET_SPAN = 4
WRITTEN_GUITAR_OCTAVE_OFFSET = 12 WRITTEN_GUITAR_OCTAVE_OFFSET = 12
ACOUSTIC_STRINGS = ((6, 4, 3), (6, 4, 3, 2), (5, 4, 3), (5, 3, 2), (5, 4, 3, 2)) ACOUSTIC_STRINGS = ((6, 4, 3), (6, 4, 3, 2), (5, 4, 3), (5, 3, 2), (5, 4, 3, 2))
@@ -43,7 +45,7 @@ def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed:
classes = {p.midi % 12 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 above = sum(p.midi > max(melody) for p in positions) if melody else 0
span = compactness(positions) span = compactness(positions)
effort = 2 * max(frets, default=0) + span effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
if profile == 'acoustic': if profile == 'acoustic':
effort += 2 * span + 2 * max(0, len(frets) - 1) effort += 2 * span + 2 * max(0, len(frets) - 1)
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0,
@@ -233,6 +235,18 @@ class EventPlacement:
exact: tuple exact: tuple
added: tuple[TabPosition, ...] = () added: tuple[TabPosition, ...] = ()
def acoustic_candidates(span: generator.acoustic.HarmonySpan):
required, allowed, bass = chord_requirements(span.harmony, 'acoustic')
basses = {bass}
degrees = generator.harmony.degrees(span.harmony)
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
basses.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
shapes = sorted({shape for value in sorted(basses) for shape in chord_positions(required, allowed, value, profile='acoustic')})
for shape in shapes:
active = tuple((str(p.string_number), p) for p in shape)
choices = tuple(EventPlacement(note, (), tuple(p for p in shape if (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))) for note in span.notes)
yield AttackCandidate(active, choices, shape, voicing_cost(shape, 'acoustic', allowed))
def continued_sources(notes): def continued_sources(notes):
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p} result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING) result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING)
@@ -246,8 +260,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
chords = [n for n in notes if '_harmony' in n] chords = [n for n in notes if '_harmony' in n]
chord = chords[0] if chords else None chord = chords[0] if chords else None
profile = chord.get('_guitar_profile', '') if chord else '' profile = chord.get('_guitar_profile', '') if chord else ''
if len(chords) > 1 and (profile != 'acoustic' or any(n['_harmony'] != chord['_harmony'] for n in chords)): if len(chords) > 1:
raise ValueError('simultaneous generated voices must share one acoustic harmony') raise ValueError('simultaneous generated chords require acoustic harmony-span placement')
for note in notes: for note in notes:
for tone in note['pitches']: for tone in note['pitches']:
options = tones[tone['id']].positions options = tones[tone['id']].positions
@@ -265,49 +279,40 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
root = value 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) fresh = has_attack(notes)
basses = [chord.get('_bass_pc', requirements[2])] if chord else [None] candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, root=root) if chord else [()]
if profile == 'acoustic' and requirements[2] not in basses: for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
basses.append(requirements[2]) exact_positions = dict(exact)
for bass in basses: for positions in candidates:
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 [()] if profile:
found = False if any(p not in positions for p in exact_positions.values()):
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))): continue
exact_positions = dict(exact) assignment = dict(exact)
for positions in candidates: choices = []
if profile: compatible = True
if any(p not in positions for p in exact_positions.values()): for note in notes:
continue added = tuple(p for p in positions if p not in exact_positions.values() and note in chords)
assignment = dict(exact) if any(p.midi % 12 not in requirements[1] for p in added):
choices = [] compatible = False
compatible = True break
for note in notes: if '_continue_from' in note:
added = tuple(p for p in positions if p not in exact_positions.values() and (note in chords and (profile != 'acoustic' or (p.string_number >= 5) == (note['_guitar_lane'] == 'bass')))) previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')}
if any(p.midi % 12 not in requirements[1] for p in added): if previous != set(added):
compatible = False compatible = False
break break
if '_continue_from' in note: assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added)
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')} choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
if previous != set(added): if not compatible:
compatible = False continue
break cost = voicing_cost(positions, profile, requirements[1], root, melody) if fresh else VoicingCost()
assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added) yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added)) else:
if not compatible: shape = tuple(p for _, p in exact) + positions
continue if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
found = True continue
cost = voicing_cost(positions, profile, requirements[1], root, melody) if fresh else VoicingCost() assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost) 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)
else: cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost()
shape = tuple(p for _, p in exact) + positions yield AttackCandidate(assignment, choices, shape, cost)
if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
continue
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
found = True
cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
if found:
break
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]: def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
events, tones = [], {} events, tones = [], {}
@@ -357,6 +362,17 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool: def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to')) return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
def advance_attacks(candidates, states: dict) -> dict:
nearest = nearest_positions(states)
result = {}
for candidate in candidates:
x, y = position_center(candidate.shape)
cost, _, previous = nearest[x][y]
cost = cost.plus(candidate.cost)
if candidate.active not in result or cost < result[candidate.active].cost:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
return result
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict: 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) fresh = has_attack(notes)
@@ -369,11 +385,7 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices) result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
if not held_ids and not continued: if not held_ids and not continued:
nearest = nearest_positions(states) result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback), states)
for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings, fallback):
x, y = position_center(candidate.shape)
cost, _, previous = nearest[x][y]
keep(candidate, previous, cost)
else: else:
for previous in states.values(): for previous in states.values():
active = dict(previous.active) active = dict(previous.active)
@@ -388,14 +400,20 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
return result return result
def place_guitar(document: dict) -> None: def place_guitar(document: dict) -> None:
"""Choose a minimum-cost path, retaining held strings in each search state.""" """Choose acoustic grips per harmony span, or exact/held melody placements."""
events, tones = guitar_events(document) events, tones = guitar_events(document)
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
states = {(): Placement(VoicingCost(), (), None, ())} states = {(): Placement(VoicingCost(), (), None, ())}
for index, (onset, notes) in enumerate(groups): if document['staves'][0].get('arrangement_part_type') == 'acoustic':
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None for span in generator.acoustic.harmony_spans(document):
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes) states = advance_attacks(acoustic_candidates(span), states)
states = advance_positions(notes, onset, tones, states, preserve_strings) if not states:
raise ValueError(f'{span.location}: no acoustic grip within frets {MIN_FRET}-{MAX_FRET} and four-fret span for {span.harmony.get("symbol", span.harmony["kind"])}')
else:
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
states = advance_positions(notes, onset, tones, states, preserve_strings)
selected = min(states.values(), key=lambda s: (s.cost, s.active)) selected = min(states.values(), key=lambda s: (s.cost, s.active))
skipped = [] skipped = []
while selected.previous is not None: while selected.previous is not None:
@@ -412,7 +430,7 @@ def place_guitar(document: dict) -> None:
for measure in document['staves'][0]['measures']: for measure in document['staves'][0]['measures']:
for voice in measure['voices']: for voice in measure['voices']:
for note in voice['notes']: for note in voice['notes']:
for field in ('_guitar_profile', '_guitar_lane', '_bass_pc', '_harmonize', '_continue_from'): for field in ('_guitar_profile', '_guitar_lane', '_harmonize', '_continue_from'):
note.pop(field, None) note.pop(field, None)
if skipped: if skipped:
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1]) logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
-1
View File
@@ -35,7 +35,6 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani
generator.scat.add_scat(result) generator.scat.add_scat(result)
else: else:
generator.acoustic.prepare(result) generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result) generator.melody.harmonize(result)
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result): if generator.accompaniment.connect_continuations(result):
+9 -1
View File
@@ -179,7 +179,15 @@ class AccompanimentPlanningTests(unittest.TestCase):
measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures'] measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures']
plan = generator.accompaniment.plan_section(measures, library) plan = generator.accompaniment.plan_section(measures, library)
self.assertEqual(plan.acoustic_upper, (sounding,) * 4) self.assertEqual(plan.acoustic_upper, (sounding,) * 4)
self.assertEqual(plan.electric, (silent,) * 4) self.assertEqual(plan.electric, (sounding,) * 4)
def test_electric_density_targets_two_stabs_per_four_quarter_beats(self):
library = tests.rhythm_fixtures.library()
for beats in (2, 3, 4, 5):
with self.subTest(beats=beats):
plan = generator.accompaniment.plan_section(melody_measures(4, beats=beats), library)
for bar in plan.electric[:3]:
self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2})
self.assertLessEqual(len(plan.electric[3].attacks()), (beats + 1) // 2 + 1)
def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self): def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self):
for beats in (2, 3, 4, 5): for beats in (2, 3, 4, 5):
measures = melody_measures(12, beats=beats, dense=True) measures = melody_measures(12, beats=beats, dense=True)
+172
View File
@@ -0,0 +1,172 @@
import copy
import fractions
import unittest
import generator.accompaniment
import generator.acoustic
import generator.guitar
import generator.harmony
import generator.model
import tests.test_acoustic
import tests.test_accompaniment
def planned_document(harmonies, attacks=None):
if attacks is None:
attacks = [([(0, 1), (2, 1)], [(1, 1), (3, 1)])] * len(harmonies)
document = tests.test_acoustic.acoustic_document(attacks)
previous = None
for measure, changes in zip(document['staves'][0]['measures'], harmonies):
measure['harmonies'] = []
for onset, symbol in changes:
harmony = {'kind': 'none', 'degrees': []} if symbol == 'N.C.' else generator.harmony.parse_symbol(symbol) if isinstance(symbol, str) else copy.deepcopy(symbol)
measure['harmonies'].append(harmony | {'id': f'{measure["id"]}-h{len(measure["harmonies"])}', 'onset': generator.model.rational(onset)})
for voice in measure['voices']:
for note in voice['notes']:
if not note['pitches']:
continue
note['pitches'] = []
harmony = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous)
if harmony:
note.update(_harmony=harmony, _guitar_profile='acoustic', _guitar_lane=voice['id'])
else:
note['kind'] = 'rest'
if measure['harmonies']:
previous = measure['harmonies'][-1]
generator.acoustic.prepare(document)
return document
def shape(note):
tuning = dict(generator.guitar.STANDARD_TUNING)
return tuple(sorted(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], tuning[p['tablature']['string']] + p['tablature']['fret']) for p in note['pitches']))
class AcousticVoicingTests(unittest.TestCase):
def place(self, document):
spans = generator.acoustic.harmony_spans(document)
lanes = {n['id']: n['_guitar_lane'] for span in spans for n in span.notes}
before = [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']]
generator.guitar.place_guitar(document)
self.assertEqual(before, [tests.test_accompaniment.timing(v['notes']) for m in document['staves'][0]['measures'] for v in m['voices']])
for span in spans:
basses = {shape(n) for n in span.notes if lanes[n['id']] == 'bass'}
uppers = {shape(n) for n in span.notes if lanes[n['id']] == 'upper'}
self.assertLessEqual(len(basses), 1)
self.assertLessEqual(len(uppers), 1)
grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True))
required, allowed, bass = generator.guitar.chord_requirements(span.harmony, 'acoustic')
bass_classes = {bass}
degrees = generator.harmony.degrees(span.harmony)
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
bass_classes.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
if basses and uppers:
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
self.assertTrue(set(required) <= {p.midi % 12 for p in grip} <= set(allowed))
if basses:
self.assertIn(min(p.midi for p in grip) % 12, bass_classes)
self.assertTrue(generator.guitar.within_fret_span(grip))
self.assertTrue(all(0 <= p.fret <= 20 for p in grip))
for note in span.notes:
self.assertFalse(any(k.startswith('_') for k in note))
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
generator.model.validate_document(document)
return spans
def test_independent_attacks_keep_one_complete_grip(self):
document = planned_document([[(0, 'FM7')]])
spans = self.place(document)
self.assertEqual(len(spans), 1)
self.assertEqual(len(spans[0].notes), 4)
before = tests.test_acoustic.sounding(document)
generator.acoustic.combine(document)
self.assertEqual(tests.test_acoustic.sounding(document), before)
generator.model.validate_document(document)
def test_simpler_fifth_bass_can_win_without_alternating(self):
document = planned_document([[(0, 'Csus')], []])
spans = self.place(document)
self.assertEqual(len(spans), 1)
bass = [p for n in spans[0].notes for p in shape(n) if p.string_number >= 5]
self.assertTrue(bass)
self.assertEqual({p.midi % 12 for p in bass}, {7})
root_shapes = generator.guitar.chord_positions(*generator.guitar.chord_requirements(spans[0].harmony, 'acoustic'), profile='acoustic')
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
self.assertLess(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
def test_bass_options_follow_defined_fifth_and_explicit_basses(self):
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
for chord, expected in (('C7', {0, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('C7/E', {4}), (inversion, {0})):
with self.subTest(chord=chord):
span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertEqual({min(p.midi for p in c.shape) % 12 for c in candidates}, expected)
required, allowed, _ = generator.guitar.chord_requirements(span.harmony, 'acoustic')
self.assertTrue(all(set(required) <= {p.midi % 12 for p in c.shape} <= set(allowed) for c in candidates))
def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self):
attacks = [([(0, 1), (2, 1)], [(0, 1), (3, 1)]), ([], []), ([(2, 1)], [(1, 1), (3, 1)])]
document = planned_document([[(0, 'C7')], [], [(0, 'C7'), (2, 'C7')]], attacks)
document['staves'][0]['measures'][2]['section_label'] = 'Next section'
self.assertEqual(len(self.place(document)), 1)
def test_changes_during_silence_end_the_span(self):
for middle in ('G7', 'N.C.'):
with self.subTest(middle=middle):
document = planned_document([[(0, 'C7'), (1, middle), (2, 'C7')]], [([(0, 1), (2, 1)], [(0, 1), (3, 1)])])
spans = self.place(document)
self.assertEqual(len(spans), 2)
self.assertEqual([len(s.notes) for s in spans], [2, 2])
def test_returning_to_a_chord_is_a_new_span(self):
document = planned_document([[(0, 'C7')], [(0, 'G7')], [(0, 'C7')]])
self.assertEqual(len(self.place(document)), 3)
def test_slash_bass_inversion_and_alteration_changes_are_boundaries(self):
inversion = generator.harmony.parse_symbol('C7') | {'inversion': 2}
changes = [(0, 'C7'), (1, 'C7/E'), (2, inversion), (3, 'C7b5')]
document = planned_document([changes], [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])])
spans = self.place(document)
self.assertEqual(len(spans), 4)
self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 6])
def test_non_accompaniment_passage_ends_the_span(self):
document = planned_document([[(0, 'C7')], [], []])
measure = document['staves'][0]['measures'][1]
slash = generator.model.make_event('slash', fractions.Fraction(), generator.model.fraction(measure['duration']), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'}
measure['voices'] = [{'id': 'voice-arranged', 'notes': [slash]}]
self.assertEqual(len(self.place(document)), 2)
def test_carried_harmony_can_begin_an_arrangement_excerpt(self):
document = planned_document([[(0, 'C7')], []])
document['staves'][0]['measures'][0]['harmonies'] = []
self.assertEqual(len(self.place(document)), 1)
def test_no_chord_does_not_generate_an_attack(self):
document = planned_document([[(0, 'N.C.')], [(0, 'C7')], [(0, 'N.C.')]])
self.assertEqual(len(self.place(document)), 1)
for measure in (document['staves'][0]['measures'][0], document['staves'][0]['measures'][2]):
self.assertTrue(all(n['kind'] == 'rest' for v in measure['voices'] for n in v['notes']))
def test_candidate_includes_unsounded_half_of_the_grip(self):
for lane in ('bass', 'upper'):
document = planned_document([[(0, 'C7')]], [([(0, 1)] if lane == 'bass' else [], [(1, 1)] if lane == 'upper' else [])])
span = generator.acoustic.harmony_spans(document)[0]
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertTrue(candidates)
for candidate in candidates:
self.assertIn(tuple(p.string_number for p in candidate.shape), generator.guitar.ACOUSTIC_STRINGS)
self.assertEqual({p for _, p in candidate.active}, set(candidate.shape))
self.assertEqual(len(candidate.choices), 1)
self.assertEqual(len(candidate.choices[0].added), 1 if lane == 'bass' else len(candidate.shape) - 1)
self.place(document)
def test_rhythm_density_does_not_multiply_shape_cost(self):
harmonies = [[(0, symbol)] for symbol in ('FM7', 'Gm7', 'C7')]
sparse = planned_document(harmonies, [([(0, 1)], [(2, 1)])] * 3)
dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3)
selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)]
self.assertEqual(*selected)
def test_span_search_matches_exhaustive_full_grip_transitions(self):
document = planned_document([[(0, s)] for s in ('FM7', 'Gm7', 'C7', 'FM7')])
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
for span in generator.acoustic.harmony_spans(document):
candidates = list(generator.guitar.acoustic_candidates(span))
expected = {}
for candidate in candidates:
expected[candidate.active] = min((previous.cost.moved(generator.guitar.movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values()))
actual = generator.guitar.advance_attacks(candidates, states)
predecessors = {id(p): i for i, p in enumerate(states.values())}
self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected)
states = actual
def test_impossible_grip_fails_contextually_without_partial_materialization(self):
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*0-20'):
generator.guitar.place_guitar(document)
self.assertEqual(document, before)
+125
View File
@@ -0,0 +1,125 @@
import copy
import fractions
import pathlib
import unittest
import unittest.mock
import generator.arrange.form
import generator.arrange.generate
import generator.electric
import generator.guitar
import generator.harmony
import generator.model
import generator.musicxml
import tests.rhythm_fixtures
def harmony(symbol, onset=0):
return generator.harmony.parse_symbol(symbol) | {'id': f'chord-{onset}', 'onset': generator.model.rational(onset)}
def note(identifier, onset, chord, frets, *, melody=False, duration=1):
tuning = dict(generator.guitar.STANDARD_TUNING)
positions = tuple(generator.guitar.TabPosition(string, fret, tuning[string] + fret) for string, fret in zip((4, 3, 2, 1), frets))
pitches = generator.guitar.chord_pitches(chord, positions)
result = generator.model.make_event(identifier, fractions.Fraction(onset), fractions.Fraction(duration), 'note', pitches)
result.update(_harmony=chord, _guitar_profile='electric')
if melody:
result['_harmonize'] = True
return result
def measure(notes, harmonies=()):
return {'number': '1', 'duration': generator.model.rational(4), 'harmonies': list(harmonies), 'voices': [{'id': 'voice', 'notes': notes}]}
def shape(event):
return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches']))
class ElectricVoicingTests(unittest.TestCase):
def test_corresponding_melody_shapes_change_within_one_harmony(self):
chord = harmony('C7')
first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2)
second = note('melody-2', 2, chord, (10, 9, 11, 8), melody=True, duration=2)
earlier = [note(f'accompaniment-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 2)]
later = [note(f'accompaniment-next-{i}', i, chord, (5, 3, 5, 3)) for i in (0, 3)]
source = [measure([], [chord]), measure([], [copy.deepcopy(chord)])]
origins = [generator.electric.MeasureOrigin(fractions.Fraction(0), 'melody', measure([first])), generator.electric.MeasureOrigin(fractions.Fraction(4), 'melody', measure([second])), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure(earlier)), generator.electric.MeasureOrigin(fractions.Fraction(4), 'accompaniment', measure(later))]
links = generator.electric.link_voicings(source, origins)
self.assertEqual(len(links), 4)
before = copy.deepcopy((first, second))
generator.electric.reuse_voicings(links)
self.assertEqual((first, second), before)
self.assertEqual([shape(n) for n in earlier + later], [shape(first)] * 3 + [shape(second)])
self.assertTrue(all(p['duration'] == n['duration'] and p['written'] == n['written'] for n in earlier + later for p in n['pitches']))
def test_links_follow_actual_placement_without_copying_melody_ties(self):
chord = harmony('FM7')
reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True, duration=2)
target = note('stab', 1, chord, (3, 2, 1, 0))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
links = generator.electric.link_voicings([measure([], [chord])], origins)
for pitch in reference['pitches']:
pitch['tie_to'] = 'next-' + pitch['id']
before = copy.deepcopy(reference)
generator.electric.reuse_voicings(links)
self.assertEqual(reference, before)
self.assertEqual(shape(reference), shape(target))
self.assertTrue(all('tie_to' not in p and 'tie_from' not in p for p in target['pitches']))
self.assertEqual({p['id'] for p in target['pitches']}, {f'stab-s{i}' for i in range(1, 5)})
target['pitches'][0]['tablature']['fret'] = 20
self.assertEqual(reference, before)
def test_unharmonized_passage_does_not_borrow_from_another_occurrence(self):
chord = harmony('C7')
reference = note('melody', 0, chord, (2, 3, 1, 3), melody=True)
target = note('stab', 0, chord, (5, 3, 5, 3))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), 'melody', measure([reference])), generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', measure([target]))]
for middle in (harmony('G7'), {'kind': 'none', 'degrees': [], 'onset': generator.model.rational(0)}):
source = [measure([], [chord]), measure([], [middle]), measure([], [chord])]
self.assertEqual(generator.electric.link_voicings(source, origins), [])
def test_mid_measure_changes_and_omitted_turnaround_do_not_copy_wrong_chord(self):
first, second = harmony('C7'), harmony('FM7', 2)
source = [measure([], [first, second])]
reference = note('melody', 2, second, (3, 5, 5, 5), melody=True)
target = note('stab', 3, first, (2, 3, 1, 3))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
self.assertEqual(generator.electric.link_voicings(source, origins), [])
target['_harmony'] = second
links = generator.electric.link_voicings(source, origins)
self.assertEqual(len(links), 1)
generator.electric.reuse_voicings(links)
self.assertEqual(shape(reference), shape(target))
def test_failed_melody_harmonization_keeps_independent_accompaniment(self):
chord = harmony('FM7')
reference = note('melody', 0, chord, (3, 5, 5, 5), melody=True)
target = note('stab', 1, chord, (3, 2, 1, 0))
origins = [generator.electric.MeasureOrigin(fractions.Fraction(), role, measure([n])) for role, n in (('melody', reference), ('accompaniment', target))]
links = generator.electric.link_voicings([measure([], [chord])], origins)
reference['pitches'] = reference['pitches'][:1]
before = copy.deepcopy(target)
generator.electric.reuse_voicings(links)
self.assertEqual(target, before)
def test_real_arrangement_reuses_requested_grips_without_changing_melody(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
before = copy.deepcopy(source)
rhythms = tests.rhythm_fixtures.library()
with unittest.mock.patch('generator.electric.reuse_voicings'):
independent = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=rhythms)
generator.model.validate_document(result)
self.assertEqual(source, before)
start, checked = 0, set()
for section in generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'], 'electric'):
measures = result['staves'][0]['measures'][start:start + section.measure_count]
original = independent['staves'][0]['measures'][start:start + section.measure_count]
start += section.measure_count
if section.role == 'melody':
self.assertEqual(measures, original)
if section.role != 'accompaniment':
continue
for m, old in zip(measures, original):
for voice, old_voice in zip(m['voices'], old['voices']):
self.assertEqual([{k: v for k, v in n.items() if k != 'pitches'} for n in voice['notes']], [{k: v for k, v in n.items() if k != 'pitches'} for n in old_voice['notes']])
for n in voice['notes']:
if not n['pitches'] or m['number'] not in ('4', '12', '22', '24'):
continue
expected = (3, 3, 3, 3) if m['number'] == '22' else (3, 5, 5, 5)
self.assertEqual(tuple(f for _, f in sorted(shape(n), reverse=True)), expected)
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in n['pitches']))
checked.add(m['number'])
self.assertEqual(checked, {'4', '12', '22', '24'})
+6
View File
@@ -199,3 +199,9 @@ class GuitarTests(unittest.TestCase):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good']) self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved') self.assertEqual((output / 'last-good').read_text(), 'preserved')
harmony = '<harmony><root><root-step>C</root-step></root><kind>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>'
(root / 'data/practice/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*0-20'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
+6 -3
View File
@@ -39,7 +39,7 @@ class GuitarPartTests(unittest.TestCase):
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0]) self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0])
candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric') candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates)) self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
def test_acoustic_combines_independent_rhythms_and_alternating_bass(self): def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
result = self.practice(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic') result = self.practice(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
measure = result['staves'][0]['measures'][0] measure = result['staves'][0]['measures'][0]
self.assertEqual(len(measure['voices']), 1) self.assertEqual(len(measure['voices']), 1)
@@ -47,7 +47,10 @@ class GuitarPartTests(unittest.TestCase):
bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p] bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p]
upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p} upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p}
self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper) self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper)
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [0, 7]) self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [7, 7])
self.assertEqual(bass[0][1]['tablature'], bass[1][1]['tablature'])
upper_shapes = {tuple((p['tablature']['string'], p['tablature']['fret']) for p in n['pitches'] if p['tablature']['string'] <= 4) for n in notes}
self.assertEqual(len(upper_shapes - {()}), 1)
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4) self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4)
for note in notes: for note in notes:
self.assertNotIn('stem', note) self.assertNotIn('stem', note)
@@ -62,7 +65,7 @@ class GuitarPartTests(unittest.TestCase):
positions = [generator.guitar.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous] positions = [generator.guitar.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous]
self.assertEqual(len(positions), len({p.string_number for p in positions})) self.assertEqual(len(positions), len({p.string_number for p in positions}))
self.assertTrue(generator.guitar.within_fret_span(positions)) self.assertTrue(generator.guitar.within_fret_span(positions))
def test_explicit_bass_and_fifth_fallback(self): def test_explicit_bass_and_default_root(self):
for symbol, expected in (('CM/E', 4), ('C7b9', 0)): for symbol, expected in (('CM/E', 4), ('C7b9', 0)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
chord = generator.harmony.parse_symbol(symbol) chord = generator.harmony.parse_symbol(symbol)
+29 -13
View File
@@ -46,7 +46,7 @@ class VoicingTests(unittest.TestCase):
def test_root_precedes_physical_cost_without_covering_the_melody(self): def test_root_precedes_physical_cost_without_covering_the_melody(self):
self.assertEqual(ranked('CM7', (67,))[0], grip((2, 4, 1, 3))) 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,)) 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,)) rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 3)), 'electric', (0, 4, 7, 11), 0, (67,))
self.assertGreater(rooted.effort, rootless.effort) self.assertGreater(rooted.effort, rootless.effort)
self.assertLess(rooted, rootless) self.assertLess(rooted, rootless)
def test_essential_extensions_are_never_relaxed_to_add_the_root(self): def test_essential_extensions_are_never_relaxed_to_add_the_root(self):
@@ -68,25 +68,41 @@ class VoicingTests(unittest.TestCase):
candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False)) candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False))
self.assertTrue(candidates) 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)) 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): def test_frets_zero_through_twelve_have_no_height_cost(self):
for profile in ('', 'electric', 'acoustic'): for profile in ('', 'electric', 'acoustic'):
low = generator.guitar.voicing_cost(grip((3, 3, 3, 3)), profile) for fret in range(13):
high = generator.guitar.voicing_cost(grip((15, 15, 15, 15)), profile) with self.subTest(profile=profile, fret=fret):
self.assertLess(low, high) self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, 0)
self.assertEqual(high.effort - low.effort, 24) self.assertEqual(generator.guitar.voicing_cost((), profile).effort, 0)
def test_height_cost_only_counts_frets_above_twelve(self):
for profile in ('', 'electric', 'acoustic'):
for fret in range(13, 21):
with self.subTest(profile=profile, fret=fret):
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, 2 * (fret - 12))
crossing = grip((11, 12, 13, 14))
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 19)
def test_span_and_acoustic_simplicity_costs_remain_unchanged(self):
shape = grip((0, 2, 3, 4)) shape = grip((0, 2, 3, 4))
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 12) self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 26) self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 18)
for profile in ('', 'electric', 'acoustic'):
self.assertLess(generator.guitar.voicing_cost(grip((8, 8, 8, 8)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self): def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
candidates = ranked('C7', profile='acoustic') candidates = ranked('C6', profile='acoustic')
best = candidates[0] best = candidates[0]
self.assertEqual(len(best), 3) self.assertEqual(len(best), 3)
self.assertTrue(any(len(c) == 4 for c in candidates)) self.assertTrue(any(len(c) == 4 for c in candidates))
required, _, _ = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic') required, _, _ = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C6'), 'acoustic')
self.assertTrue(set(required) <= {p.midi % 12 for p in best}) self.assertTrue(set(required) <= {p.midi % 12 for p in best})
def test_nearby_position_can_balance_lower_fret_preference(self): def test_nearby_position_beats_lower_frets_within_comfort_range(self):
low, nearby, previous = (grip((f,), (1,)) for f in (3, 4, 5)) low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9))
self.assertEqual(generator.guitar.voicing_cost(low).moved(generator.guitar.movement(previous, low)), generator.guitar.voicing_cost(nearby).moved(generator.guitar.movement(previous, nearby))) for profile in ('', 'electric', 'acoustic'):
self.assertLess(generator.guitar.voicing_cost(nearby, profile).moved(generator.guitar.movement(previous, nearby)), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(previous, low)))
def test_above_threshold_is_a_soft_cost_not_a_range_priority(self):
low, high = (grip((f,) * 4) for f in (3, 13))
for profile in ('', 'electric', 'acoustic'):
self.assertLess(generator.guitar.voicing_cost(high, profile), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(high, low)))
def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self): 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,))): for symbol, profile, melody in (('FM7', 'electric', (69,)), ('C7', 'acoustic', ()), ('Gm7', 'electric', (67,))):
best = {} best = {}