@@ -107,15 +107,15 @@ 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. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu.
|
||||
|
||||
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. Any defined chord tone can be the bass, including thirds, sevenths, and extensions; 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 attacks per 4/4 bar, scaled for other meters, favoring gaps around the melody and acoustic rhythms. Its notes retain the selected rhythm's duration without a one-beat cap, stopping at chord changes or barlines and reusing the corresponding melody passage's chord shape when available. Both parts remain tie-free; clipped binary durations that need ties shorten to one conventional note followed by silence. 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 voicing until the harmony changes within an arrangement iteration. Bass, upper voices, and TAB positions stay constant across rests, barlines, and repeated chord symbols. Any defined chord tone can serve as the bass; 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 attacks per 4/4 bar, scaled for other meters, favoring gaps around the melody and acoustic rhythms. Its notes retain the selected rhythm's duration without a one-beat cap, stopping at chord changes or barlines and reusing the corresponding melody passage's chord shape when available. Both parts remain tie-free; clipped binary durations that need ties shorten to one conventional note followed by silence. 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.
|
||||
|
||||
Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 1-20, and a maximum four-fret span. Open strings are excluded from chords and single-note melodies. Acoustic uses a bass on string 5 or 6 and two or three consecutive upper strings within 2-4, with zero or one unused string between them. Electric chords use strings 1-3 or 1-4 and may omit the root. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Electric melodies are raised one octave; vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment or exact melody pitches fail generation.
|
||||
Guitar parts share standard tuning and chord-tone helpers but use separate acoustic and electric placement systems. Both use frets 1-20, exclude open strings, and require a maximum fret difference of three. Frets 7-10 qualify; frets 7-11 do not. Acoustic requires a bass on string 5 or 6 plus all three upper strings 4, 3, and 2, with at least one occupied string at fret 12 or below. Electric uses chords on strings 1-3 or 1-4. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Electric melodies are raised one octave; vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment or exact melody pitches fail generation.
|
||||
|
||||
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. 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. Every generated guitar grip requires the root, the fifth, or both somewhere in the grip, not necessarily in the bass. Defining chord tones remain mandatory. Acoustic first prefers physical reach no wider than frets 7-10, accounting for narrower fret spacing higher on the neck. Wider grips are considered only if none meet that limit, using the least-reaching valid options with a diagnostic. Within that pool, complete major, minor, diminished, and augmented triads take priority when available; authored omissions and extended-chord omission rules remain intact. Movement, fret height, and distinct-fret count then choose the sequence, with no further reward for compressing an already comfortable grip. Acoustic per-string movement still has weight two, and both chord profiles retain the lower-fret preference and additional penalty above fret 12. Electric keeps its existing span and doubled-center movement costs. This is a geometric playability approximation, not a finger-assignment model. Exact melody pitches and rhythms remain unchanged.
|
||||
Electric melody prioritizes four-string chords, then melody on top. Every generated guitar voicing requires the root, the actual fifth, or both somewhere in the voicing, not necessarily in the bass. Defining chord tones remain mandatory. Acoustic chooses four connected string-voice journeys independently for each arrangement iteration, minimizing total fret travel across harmonic spans. Upper strings stay fixed; bass movement counts both fret distance and one step for switching between strings 5 and 6. All bass inversions are eligible. There is no acoustic register, physical-reach, or shape-effort penalty beyond its hard fret constraints. Complete triads are preferred when feasible; doubling is allowed, authored omissions are never restored, and missing optional tones break equal-movement ties. Electric retains its existing register, span, shape, and position-center movement costs. This is a fretboard-feasibility approximation, not a finger-assignment model. 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.
|
||||
|
||||
|
||||
+6
-6
@@ -135,25 +135,25 @@ All three parts use octave-transposing notation: written notes sound one octave
|
||||
|
||||
`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 1-20 inclusive, with at most four frets between the lowest and highest positions. Open strings are excluded from chords and single-note melodies alike. A pitch that only an open string could produce fails generation rather than being octave-shifted or omitted. 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. Any defined chord tone can serve as the acoustic bass, including thirds, sevenths, and extensions. Explicit acoustic slash basses and inversions override these choices, and the bass remains the lowest sounding pitch. There is no alternating-bass substitution.
|
||||
`fretboard.py` owns standard tuning E2 A2 D3 G3 B3 E4, fret positions, chord coverage, and pitch spelling. Both placement systems use frets 1-20 inclusive, exclude open strings, and share a maximum fret difference of three. `guitar.py` handles electric voicing and exact/held melody placement. Exact melody tones are never dropped or replaced, and sustained/tied tones retain their assignments. `acoustic_voicing.py` instead searches four named voices: a bass on string 5 or 6, and one voice on each of strings 4, 3, and 2. Acoustic assignments also require at least one occupied string at fret 12 or below. These are hard constraints: a failed voicing is not widened or reduced to two upper strings. Any defined chord tone, including thirds, sevenths, and extensions, can be the acoustic bass. Explicit slash basses and inversions override these choices; the bass remains the lowest sounding pitch.
|
||||
|
||||
Shared `ChordRequirements` separate mandatory pitch classes, allowed pitch classes, root-or-fifth alternatives, and bass constraints. Every complete generated guitar grip must contain the root, the fifth, or both, with no preference for the root itself. This rule applies equally to acoustic accompaniment, electric accompaniment, and electric melody chords, not isolated bass/upper attacks or unharmonized melody notes. The fifth follows the chord's actual degree, including diminished or augmented fifths. Explicitly removed degrees are never restored, and a harmony removing both root and fifth cannot generate a guitar chord. Defining thirds/suspensions, sixths/sevenths, alterations, and required extensions remain mandatory; an altered fifth cannot be omitted merely because the root is present.
|
||||
|
||||
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.
|
||||
Acoustic spans follow the harmony timeline, including changes during silence. Within an iteration, barlines, rests, and repeated identical harmonies do not reset the voicing. Changed chord kinds, roots, degrees, basses, or inversions start a new span; N.C. and non-accompaniment passages end a span. A formal section label starts a new iteration and independent search, even if the harmony repeats. There is no last-to-first transition or shared song-wide chord dictionary. Rests and no-chord passages add no assignments; the next sounding harmony within the iteration is measured from the preceding assignment. A span with attacks is placed once as a complete four-voice assignment, then each bass or upper attack receives its subset; simultaneous attacks receive all four strings. Every iteration is planned before pitches are materialized, so failure leaves the document unchanged. Existing staged asset publication preserves previously published assets. Iteration and search state are build-only and add no runtime fields.
|
||||
|
||||
Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voicings containing the fifth and octave doublings are ordinary candidates. An unaltered fifth may be omitted when the root is present, and unaltered intermediate ninth/eleventh extensions may be omitted. Exact melody pitches, including non-chord tones, remain unchanged.
|
||||
|
||||
`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. These melody rules are independent of accompaniment duration spelling. If no valid chord fits, the exact melody remains with a diagnostic; it is not octave-wrapped or omitted.
|
||||
|
||||
Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, combined effort, and missing optional pitch classes. These costs accumulate over the whole passage, so effort cannot outweigh the earlier electric 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 penalties: a simpler three-note grip can beat a more awkward four-note grip.
|
||||
Electric placement uses the ordered `VoicingCost` components: string shortfall from four, melody covered, number of tones above the melody, combined effort, and missing optional pitch classes. These costs accumulate over the whole passage, so effort cannot outweigh the earlier electric musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. The acoustic solver does not use this ranking.
|
||||
|
||||
Acoustic candidate selection separates harmonic validity from reach and sequence scoring. `physical_reach()` computes `round(1_000_000 * (2 ** (-lowest_fret / 12) - 2 ** (-highest_fret / 12)))`, normalized to scale length. The preferred limit is the reach from fret 7 to fret 10, inclusive. If any valid grip meets it, wider grips are excluded before movement optimization. Otherwise only the least-reaching valid grips remain, with a passage-specific warning. The hard four-fret difference and frets 1-20 still apply. Among the retained candidates, triads with exactly degrees 1, 3, and 5 prefer all three tones when feasible. Authored omissions are never restored; seventh, extended, and suspended chords retain their existing coverage rules. Complete triads do not justify exceeding the preferred reach when a reduced comfortable grip exists. Candidate filtering is cached independently of rhythmic events and does not alter electric placement.
|
||||
Acoustic candidates are generated from chord-tone positions on the four voices, not a predefined shape dictionary or the electric candidate search. All four positions must satisfy the shared harmonic requirements and acoustic fret constraints, even when only bass or upper notes attack. Triads whose effective degrees are exactly 1, 3, and 5 prefer complete three-tone coverage when feasible; `harmony.is_triad()` examines authored chord semantics rather than voicing size. Doubling chord tones can fill four strings without inventing additional harmony. Authored omissions are never restored. Seventh and extended chords keep their existing coverage rules. Candidate sets are cached by harmonic requirements and triad completeness; no physical-reach filter, fallback widening, or position-center pruning is applied.
|
||||
|
||||
`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.
|
||||
|
||||
After candidate filtering, acoustic effort is `highest_fret + 2 * max(0, highest_fret - 12) + 2 * max(0, distinct_frets - 1)`. There is no raw-span penalty or continuing reward for smaller physical reach within the preferred range. Electric retains the same formula plus `3 * fret_span` and its existing movement metric, the Manhattan distance between doubled fret/string position centers, with the exact small-grid distance transform used for independent attacks. Acoustic still adds twice its per-string movement: fret changes on retained strings plus one unit per added or released string. An unchanged grip costs zero movement. Exact candidate-to-candidate dynamic programming retains all preferred acoustic grips, including different grips with equal centers. Exact, unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`.
|
||||
Acoustic transition cost is the sum of absolute fret changes for all four named voices, plus one when the bass switches between strings 5 and 6. For example, bass string 5 fret 7 to string 6 fret 9 costs three steps, not just a string-change charge. An unchanged assignment costs zero. Exact dynamic programming minimizes total travel per iteration; equal travel costs prefer fewer missing optional chord tones, followed by stable position ordering. The starting assignment has no position cost. Harmony spans, not rhythmic attacks, determine the transitions. There are no acoustic register, distinct-fret, span, or physical-reach scoring terms. The three-fret limit and fret-12 anchor are eligibility checks, not preferences.
|
||||
|
||||
Generated chord height contributes one point per fret throughout the allowed range. The shared `PREFERRED_MAX_FRET` remains 12, above which each excess fret adds two more points. Lower positions still win when reach eligibility, shape simplicity, and movement are comparable. Autumn Leaves can use Em7b5/G at `6:3, 4:2, 3:3, 2:3` followed by A7b9/G at `6:3, 4:2, 3:3, 2:2`, moving only one note by one fret. The original mid-neck grips at `5:7, 4:8, 3:7, 2:8` and `5:7, 4:8, 3:6, 2:8` remain valid nearby alternatives. Dm/F at `5:8, 3:7, 2:10` meets the preferred reach limit exactly. These are regression examples, not mandatory song-specific shapes, and generated inversions do not rewrite the source chord labels. 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 or finger-assignment model. Stable position ordering resolves equal costs.
|
||||
Electric effort remains `highest_fret + 2 * max(0, highest_fret - 12) + 2 * max(0, distinct_frets - 1) + 3 * fret_span`, with Manhattan movement between doubled fret/string position centers. Its small-grid distance transform and exact melody constraints are unchanged. Unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`, and pure tie continuations add no fresh shape or movement cost. The acoustic Autumn Leaves lead-in can use Em7b5/G at `6:3, 4:2, 3:3, 2:3` followed by A7b9/G at `6:3, 4:2, 3:3, 2:2`, moving only the second string by one fret. This is a regression example, not a prescribed chord shape; generated bass inversions do not rewrite source chord labels.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -12,11 +12,16 @@ class HarmonySpan:
|
||||
harmony: dict
|
||||
location: str
|
||||
notes: list[dict] = dataclasses.field(default_factory=list)
|
||||
iteration: int = 0
|
||||
|
||||
def harmony_spans(document: dict) -> list[HarmonySpan]:
|
||||
"""Group attacks by continuous harmony, including changes during silence."""
|
||||
"""Group attacks by continuous harmony within each arrangement iteration."""
|
||||
spans, span, active = [], None, None
|
||||
iteration = 0
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if measure.get('section_label'):
|
||||
iteration += 1
|
||||
span = None
|
||||
if {v['id'] for v in measure['voices']} != {'bass', 'upper'}:
|
||||
span, active = None, None
|
||||
continue
|
||||
@@ -30,7 +35,7 @@ def harmony_spans(document: dict) -> list[HarmonySpan]:
|
||||
if kind == 0:
|
||||
continue
|
||||
if span is None:
|
||||
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}')
|
||||
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}', iteration=iteration)
|
||||
spans.append(span)
|
||||
span.notes.append(item)
|
||||
return spans
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
import itertools
|
||||
import typing
|
||||
|
||||
import generator.acoustic
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
|
||||
MAX_ANCHOR_FRET = 12
|
||||
|
||||
class Voicing(typing.NamedTuple):
|
||||
bass: generator.fretboard.TabPosition
|
||||
string4: generator.fretboard.TabPosition
|
||||
string3: generator.fretboard.TabPosition
|
||||
string2: generator.fretboard.TabPosition
|
||||
|
||||
def playable(voicing: Voicing) -> bool:
|
||||
return voicing.bass.string_number in (5, 6) and tuple(p.string_number for p in voicing[1:]) == (4, 3, 2) and all(generator.fretboard.MIN_FRET <= p.fret <= generator.fretboard.MAX_FRET for p in voicing) and min(p.fret for p in voicing) <= MAX_ANCHOR_FRET and generator.fretboard.within_fret_span(voicing) and voicing.bass.midi == min(p.midi for p in voicing)
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def candidates(requirements: generator.fretboard.ChordRequirements, complete_triad: bool = False) -> tuple[Voicing, ...]:
|
||||
options = {s: tuple(generator.fretboard.TabPosition(s, f, midi + f) for f in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (midi + f) % 12 in requirements.allowed) for s, midi in generator.fretboard.STANDARD_TUNING if s != 1}
|
||||
result = []
|
||||
for bass in (*options[5], *options[6]):
|
||||
if requirements.basses and bass.midi % 12 not in requirements.basses:
|
||||
continue
|
||||
for upper in itertools.product(*(options[s] for s in (4, 3, 2))):
|
||||
voicing = Voicing(bass, *upper)
|
||||
if playable(voicing) and requirements.covers({p.midi % 12 for p in voicing}):
|
||||
result.append(voicing)
|
||||
if complete_triad:
|
||||
complete = [v for v in result if set(requirements.allowed) <= {p.midi % 12 for p in v}]
|
||||
result = complete or result
|
||||
return tuple(sorted(result))
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def movement(previous: Voicing, current: Voicing) -> int:
|
||||
return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number)
|
||||
|
||||
class Cost(typing.NamedTuple):
|
||||
travel: int = 0
|
||||
missing_tones: int = 0
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Placement:
|
||||
cost: Cost
|
||||
voicing: Voicing | None = None
|
||||
previous: Placement | None = None
|
||||
|
||||
def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]:
|
||||
result = []
|
||||
for voicing in options:
|
||||
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
|
||||
cost, index = min((Cost(p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states))
|
||||
result.append(Placement(cost, voicing, states[index]))
|
||||
return result
|
||||
|
||||
def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
|
||||
states = [Placement(Cost())]
|
||||
for span in spans:
|
||||
try:
|
||||
requirements = generator.fretboard.chord_requirements(span.harmony)
|
||||
options = candidates(requirements, generator.harmony.is_triad(span.harmony))
|
||||
if not options:
|
||||
raise ValueError(f'no acoustic grip on strings 2-4 plus bass 5 or 6 within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, three-fret span, and at least one fret <= {MAX_ANCHOR_FRET}')
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{span.location}, {generator.harmony.symbol(span.harmony)}: {error}') from error
|
||||
states = advance(options, requirements, states)
|
||||
selected = min(states, key=lambda p: (p.cost, p.voicing))
|
||||
result = []
|
||||
while selected.previous is not None:
|
||||
result.append(selected.voicing)
|
||||
selected = selected.previous
|
||||
return list(reversed(result))
|
||||
|
||||
def place(document: dict) -> None:
|
||||
"""Plan every iteration before materializing its four string-voice journeys."""
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for state in (measure, *measure.get('attributes', [])):
|
||||
if 'staff_details' in state:
|
||||
generator.fretboard.validate_tuning(state, measure['id'])
|
||||
choices = []
|
||||
for _, group in itertools.groupby(generator.acoustic.harmony_spans(document), key=lambda span: span.iteration):
|
||||
spans = list(group)
|
||||
choices.extend(zip(spans, select(spans)))
|
||||
for span, voicing in choices:
|
||||
for note in span.notes:
|
||||
positions = (voicing.bass,) if note['_guitar_lane'] == 'bass' else voicing[1:]
|
||||
for pitch in generator.fretboard.chord_pitches(span.harmony, positions):
|
||||
note['pitches'].append({**pitch, 'id': f'{note["id"]}-s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
|
||||
for field in ('_harmony', '_guitar_profile', '_guitar_lane'):
|
||||
note.pop(field, None)
|
||||
document['staves'][0]['tablature'] = True
|
||||
@@ -6,6 +6,7 @@ import pathlib
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.electric
|
||||
@@ -109,15 +110,17 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
|
||||
result = generator.score.make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
|
||||
result['metadata'].setdefault('tempo', 124)
|
||||
if part_type != 'vocal':
|
||||
if part_type == 'acoustic':
|
||||
generator.acoustic.prepare(result)
|
||||
generator.acoustic_voicing.place(result)
|
||||
generator.acoustic.combine(result)
|
||||
elif part_type == 'electric':
|
||||
generator.melody.harmonize(result)
|
||||
voicing_links = generator.electric.link_voicings(source, voicing_origins) if part_type == 'electric' else []
|
||||
voicing_links = generator.electric.link_voicings(source, voicing_origins)
|
||||
generator.guitar.place_guitar(result)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
generator.guitar.place_guitar(result)
|
||||
generator.electric.reuse_voicings(voicing_links)
|
||||
generator.acoustic.combine(result)
|
||||
if scales and solos:
|
||||
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
|
||||
if reports is not None:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import typing
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 1
|
||||
MAX_FRET = 20
|
||||
MAX_FRET_SPAN = 3
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
|
||||
class TabPosition(typing.NamedTuple):
|
||||
string_number: int
|
||||
fret: int
|
||||
midi: int
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
frets = [position.fret for position in positions]
|
||||
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
|
||||
|
||||
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
|
||||
if midi.denominator != 1:
|
||||
return ()
|
||||
return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET)
|
||||
|
||||
def validate_tuning(state: dict, location: str) -> None:
|
||||
details = state['staff_details']
|
||||
if details.get('capo', 0):
|
||||
raise ValueError(f'{location}: guitar generation does not support capo configurations')
|
||||
tunings = details.get('tunings', [])
|
||||
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
|
||||
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
|
||||
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ChordRequirements:
|
||||
required: tuple[int, ...] = ()
|
||||
allowed: tuple[int, ...] = ()
|
||||
foundation: tuple[int, ...] = ()
|
||||
basses: tuple[int, ...] = ()
|
||||
def covers(self, classes: set[int]) -> bool:
|
||||
return set(self.required) <= classes and bool(set(self.foundation) & classes)
|
||||
|
||||
def chord_requirements(definition: dict, *, respect_bass: bool = True) -> ChordRequirements:
|
||||
degrees = generator.harmony.degrees(definition)
|
||||
root = definition['root']
|
||||
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
|
||||
explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'}
|
||||
optional = {d for d, a in degrees.items() if not a and d not in explicit and d in (9, 11) and d < max(degrees)}
|
||||
classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
|
||||
required = {value for d, value in classes.items() if d not in optional and (d not in (1, 5) or degrees[d])}
|
||||
allowed = set(classes.values())
|
||||
foundation = {value for d, value in classes.items() if d in (1, 5)}
|
||||
if not foundation:
|
||||
raise ValueError('generated guitar chords require a root or fifth in the harmony')
|
||||
bass = definition.get('bass')
|
||||
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
|
||||
if bass_pc is None and 'inversion' in definition:
|
||||
ordered = sorted(degrees)
|
||||
inversion = definition['inversion']
|
||||
if not 0 <= inversion < len(ordered):
|
||||
raise ValueError('harmony inversion exceeds its chord degrees')
|
||||
bass_pc = classes[ordered[inversion]]
|
||||
if not respect_bass:
|
||||
bass_pc = None
|
||||
if bass_pc is not None:
|
||||
allowed.add(bass_pc)
|
||||
required.add(bass_pc)
|
||||
basses = {bass_pc} if bass_pc is not None else set()
|
||||
return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
|
||||
|
||||
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
|
||||
spellings = {}
|
||||
for degree, alter in generator.harmony.degrees(definition).items():
|
||||
value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter))
|
||||
spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value
|
||||
if bass := definition.get('bass'):
|
||||
spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass
|
||||
result = []
|
||||
for position in sorted(positions, key=lambda p: p.midi):
|
||||
value = spellings[position.midi % 12].copy()
|
||||
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1)
|
||||
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
result.append(value)
|
||||
return result
|
||||
+34
-166
@@ -7,27 +7,13 @@ import itertools
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import generator.acoustic
|
||||
import generator.harmony
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 1
|
||||
MAX_FRET = 20
|
||||
PREFERRED_MAX_FRET = 12
|
||||
MAX_FRET_SPAN = 4
|
||||
# Scale-length-normalized reach, in millionths, using frets 7-10 as the reference.
|
||||
PREFERRED_ACOUSTIC_REACH = round(1_000_000 * (2 ** (-7 / 12) - 2 ** (-10 / 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))
|
||||
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
|
||||
|
||||
class TabPosition(typing.NamedTuple):
|
||||
string_number: int
|
||||
fret: int
|
||||
midi: int
|
||||
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
string_shortfall: int = 0
|
||||
covered_melody: int = 0
|
||||
@@ -41,85 +27,23 @@ class VoicingCost(typing.NamedTuple):
|
||||
return self._replace(effort=self.effort + distance)
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost:
|
||||
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), 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(0, max(frets, default=0) - PREFERRED_MAX_FRET)
|
||||
if profile in ('acoustic', 'electric'):
|
||||
if profile == 'electric':
|
||||
effort += max(frets, default=0) + 2 * max(0, len(frets) - 1)
|
||||
if profile == 'electric':
|
||||
effort += 3 * span
|
||||
effort += 3 * span
|
||||
else:
|
||||
effort += span
|
||||
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, len(set(allowed) - classes))
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
frets = [position.fret for position in positions]
|
||||
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
|
||||
|
||||
def physical_reach(positions: tuple[TabPosition, ...]) -> int:
|
||||
if not positions:
|
||||
return 0
|
||||
frets = [position.fret for position in positions]
|
||||
return round(1_000_000 * (2 ** (-min(frets) / 12) - 2 ** (-max(frets) / 12)))
|
||||
|
||||
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
|
||||
if midi.denominator != 1:
|
||||
return ()
|
||||
return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET)
|
||||
|
||||
def validate_tuning(state: dict, location: str) -> None:
|
||||
details = state['staff_details']
|
||||
if details.get('capo', 0):
|
||||
raise ValueError(f'{location}: guitar generation does not support capo configurations')
|
||||
tunings = details.get('tunings', [])
|
||||
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
|
||||
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
|
||||
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ChordRequirements:
|
||||
required: tuple[int, ...] = ()
|
||||
allowed: tuple[int, ...] = ()
|
||||
foundation: tuple[int, ...] = ()
|
||||
basses: tuple[int, ...] = ()
|
||||
def covers(self, classes: set[int]) -> bool:
|
||||
return set(self.required) <= classes and bool(set(self.foundation) & classes)
|
||||
|
||||
def chord_requirements(definition: dict, profile: str = '') -> ChordRequirements:
|
||||
degrees = generator.harmony.degrees(definition)
|
||||
root = definition['root']
|
||||
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
|
||||
explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'}
|
||||
optional = {d for d, a in degrees.items() if not a and d not in explicit and d in (9, 11) and d < max(degrees)}
|
||||
classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
|
||||
required = {value for d, value in classes.items() if d not in optional and (d not in (1, 5) or degrees[d])}
|
||||
allowed = set(classes.values())
|
||||
foundation = {value for d, value in classes.items() if d in (1, 5)}
|
||||
if not foundation:
|
||||
raise ValueError('generated guitar chords require a root or fifth in the harmony')
|
||||
bass = definition.get('bass')
|
||||
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
|
||||
if bass_pc is None and 'inversion' in definition:
|
||||
ordered = sorted(degrees)
|
||||
inversion = definition['inversion']
|
||||
if not 0 <= inversion < len(ordered):
|
||||
raise ValueError('harmony inversion exceeds its chord degrees')
|
||||
bass_pc = classes[ordered[inversion]]
|
||||
if profile == 'electric':
|
||||
bass_pc = None
|
||||
if bass_pc is not None:
|
||||
allowed.add(bass_pc)
|
||||
required.add(bass_pc)
|
||||
basses = {bass_pc} if bass_pc is not None else allowed if profile == 'acoustic' else set()
|
||||
return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def chord_positions(requirements: ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[TabPosition, ...], ...]:
|
||||
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
|
||||
result = []
|
||||
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING]
|
||||
options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING]
|
||||
|
||||
def visit(index, selected, covered):
|
||||
if len(set(requirements.required) - covered) > len(options) - index:
|
||||
@@ -130,21 +54,21 @@ def chord_positions(requirements: ChordRequirements, prune: bool = True, profile
|
||||
return
|
||||
visit(index + 1, selected, covered)
|
||||
for position in options[index]:
|
||||
if position.midi % 12 not in covered and within_fret_span((*selected, position)):
|
||||
if position.midi % 12 not in covered and generator.fretboard.within_fret_span((*selected, position)):
|
||||
visit(index + 1, (*selected, position), covered | {position.midi % 12})
|
||||
|
||||
if profile:
|
||||
by_string = {string: options[i] for i, (string, _) in enumerate(STANDARD_TUNING)}
|
||||
for strings in ACOUSTIC_STRINGS if profile == 'acoustic' else ELECTRIC_STRINGS:
|
||||
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
|
||||
for strings in ELECTRIC_STRINGS:
|
||||
for selected in itertools.product(*(by_string[s] for s in strings)):
|
||||
if not within_fret_span(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
if not generator.fretboard.within_fret_span(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
continue
|
||||
if requirements.basses and (selected[0].midi % 12 not in requirements.basses or selected[0].midi != min(p.midi for p in selected)):
|
||||
continue
|
||||
result.append(selected)
|
||||
else:
|
||||
visit(0, (), set())
|
||||
if not prune or profile == 'acoustic':
|
||||
if not prune:
|
||||
return tuple(sorted(result))
|
||||
# Independent attacks with equal centers have identical movement costs.
|
||||
# Held strings require the full candidate set instead of this reduction.
|
||||
@@ -156,47 +80,14 @@ def chord_positions(requirements: ChordRequirements, prune: bool = True, profile
|
||||
best[center] = (rank, positions)
|
||||
return tuple(sorted(item[1] for item in best.values()))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def acoustic_positions(requirements: ChordRequirements, complete_triad: bool = False) -> tuple[tuple[TabPosition, ...], ...]:
|
||||
reaches = {shape: physical_reach(shape) for shape in chord_positions(requirements, profile='acoustic')}
|
||||
limit = max(PREFERRED_ACOUSTIC_REACH, min(reaches.values(), default=PREFERRED_ACOUSTIC_REACH))
|
||||
shapes = tuple(shape for shape, reach in reaches.items() if reach <= limit)
|
||||
if complete_triad:
|
||||
complete = tuple(shape for shape in shapes if set(requirements.allowed) <= {p.midi % 12 for p in shape})
|
||||
shapes = complete or shapes
|
||||
return shapes
|
||||
|
||||
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
|
||||
spellings = {}
|
||||
for degree, alter in generator.harmony.degrees(definition).items():
|
||||
value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter))
|
||||
spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value
|
||||
if bass := definition.get('bass'):
|
||||
spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass
|
||||
result = []
|
||||
for position in sorted(positions, key=lambda p: p.midi):
|
||||
value = spellings[position.midi % 12].copy()
|
||||
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1)
|
||||
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
|
||||
def movement(previous: tuple[generator.fretboard.TabPosition, ...], current: tuple[generator.fretboard.TabPosition, ...]) -> int:
|
||||
if not previous or not current:
|
||||
return 0
|
||||
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def acoustic_movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
|
||||
if not previous or not current:
|
||||
return 0
|
||||
before = {p.string_number: p.fret for p in previous}
|
||||
after = {p.string_number: p.fret for p in current}
|
||||
return sum(abs(before[s] - after[s]) for s in before.keys() & after.keys()) + len(before.keys() ^ after.keys())
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
|
||||
def position_center(positions: tuple[generator.fretboard.TabPosition, ...]) -> tuple[int, int]:
|
||||
frets = [p.fret for p in positions] or [0]
|
||||
strings = [p.string_number for p in positions]
|
||||
return min(frets) + max(frets), min(strings) + max(strings) - 2
|
||||
@@ -212,7 +103,7 @@ def extend_positions(held, pending):
|
||||
identifier, options = pending[0]
|
||||
occupied = {p.string_number for p in held.values()}
|
||||
for position in options:
|
||||
if position.string_number not in occupied and within_fret_span((*held.values(), position)):
|
||||
if position.string_number not in occupied and generator.fretboard.within_fret_span((*held.values(), position)):
|
||||
yield from extend_positions({**held, identifier: position}, pending[1:])
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -225,7 +116,7 @@ class Placement:
|
||||
def nearest_positions(states: dict) -> list[list[tuple | None]]:
|
||||
"""Exact Manhattan distance transform on the small fret/string position grid."""
|
||||
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)]
|
||||
grid = [[initial for _ in range(11)] for _ in range(2 * generator.fretboard.MAX_FRET + 1)]
|
||||
for index, previous in enumerate(states.values()):
|
||||
if not previous.active:
|
||||
continue
|
||||
@@ -258,34 +149,24 @@ def nearest_positions(states: dict) -> list[list[tuple | None]]:
|
||||
class GuitarTone:
|
||||
pitch: dict
|
||||
end: fractions.Fraction
|
||||
positions: tuple[TabPosition, ...]
|
||||
positions: tuple[generator.fretboard.TabPosition, ...]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class AttackCandidate:
|
||||
active: tuple
|
||||
choices: tuple
|
||||
shape: tuple[TabPosition, ...]
|
||||
shape: tuple[generator.fretboard.TabPosition, ...]
|
||||
cost: VoicingCost
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class EventPlacement:
|
||||
note: dict
|
||||
exact: tuple
|
||||
added: tuple[TabPosition, ...] = ()
|
||||
|
||||
def acoustic_candidates(span: generator.acoustic.HarmonySpan):
|
||||
requirements = chord_requirements(span.harmony, 'acoustic')
|
||||
shapes = acoustic_positions(requirements, set(generator.harmony.degrees(span.harmony)) == {1, 3, 5})
|
||||
if shapes and physical_reach(shapes[0]) > PREFERRED_ACOUSTIC_REACH:
|
||||
logging.getLogger(__name__).warning('%s: %s requires a wider acoustic reach; using the least-reaching valid grips', span.location, generator.harmony.symbol(span.harmony))
|
||||
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', requirements.allowed))
|
||||
added: tuple[generator.fretboard.TabPosition, ...] = ()
|
||||
|
||||
def continued_sources(notes):
|
||||
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
|
||||
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING)
|
||||
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in generator.fretboard.STANDARD_TUNING)
|
||||
return result
|
||||
|
||||
def has_attack(notes: list[dict]) -> bool:
|
||||
@@ -306,7 +187,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
pending.append((tone['id'], options))
|
||||
if fallback and chord and chord.get('_harmonize'):
|
||||
chords, chord, profile = [], None, ''
|
||||
requirements = chord_requirements(chord['_harmony'], profile) if chord else ChordRequirements()
|
||||
requirements = generator.fretboard.chord_requirements(chord['_harmony'], respect_bass=profile != 'electric') if chord else generator.fretboard.ChordRequirements()
|
||||
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
|
||||
fresh = has_attack(notes)
|
||||
candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) if chord else [()]
|
||||
@@ -337,7 +218,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
|
||||
else:
|
||||
shape = tuple(p for _, p in exact) + positions
|
||||
if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
|
||||
if len({p.string_number for p in shape}) != len(shape) or not generator.fretboard.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)
|
||||
@@ -355,14 +236,14 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
state = {k: measure[k] for k in ('transpose', 'staff_details')}
|
||||
for change in [state, *changes]:
|
||||
state.update({k: change[k] for k in state if k in change})
|
||||
validate_tuning(state, measure['id'])
|
||||
generator.fretboard.validate_tuning(state, measure['id'])
|
||||
for voice in measure['voices']:
|
||||
for order, note in enumerate(voice['notes']):
|
||||
if note.get('_rhythm_continuation'):
|
||||
source = previous.get(voice['id'])
|
||||
if source and '_harmony' in source:
|
||||
note['_continue_from'] = source['id']
|
||||
for string, _ in STANDARD_TUNING:
|
||||
for string, _ in generator.fretboard.STANDARD_TUNING:
|
||||
tones[f'{source["id"]}-s{string}'].pitch['tie_to'] = f'{note["id"]}-s{string}'
|
||||
elif not note['pitches']:
|
||||
note.pop('_harmony', None)
|
||||
@@ -375,14 +256,14 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
state.update({k: change[k] for k in state if k in change})
|
||||
for tone in note['pitches']:
|
||||
sounding = generator.pitch.sounding_pitch(tone, state)
|
||||
options = positions_for_pitch(sounding)
|
||||
options = generator.fretboard.positions_for_pitch(sounding)
|
||||
if tab := tone.get('tablature'):
|
||||
options = tuple(p for p in options if (p.string_number, p.fret) == (tab['string'], tab['fret']))
|
||||
if not options:
|
||||
raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {MIN_FRET}-{MAX_FRET}')
|
||||
raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}')
|
||||
tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options)
|
||||
if '_harmony' in note:
|
||||
for string, _ in STANDARD_TUNING:
|
||||
for string, _ in generator.fretboard.STANDARD_TUNING:
|
||||
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ())
|
||||
if note['pitches'] or '_harmony' in note:
|
||||
events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note))
|
||||
@@ -403,13 +284,6 @@ def advance_attacks(candidates, states: dict) -> dict:
|
||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
||||
return result
|
||||
|
||||
def advance_acoustic(candidates, states: dict) -> dict:
|
||||
result = {}
|
||||
for candidate in candidates:
|
||||
cost, _, previous = min((p.cost.moved(2 * acoustic_movement(tuple(position for _, position in p.active), candidate.shape)), i, p) for i, p in enumerate(states.values()))
|
||||
result[candidate.active] = Placement(cost.plus(candidate.cost), candidate.active, previous, candidate.choices)
|
||||
return result
|
||||
|
||||
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict:
|
||||
continued = continued_sources(notes)
|
||||
fresh = has_attack(notes)
|
||||
@@ -433,24 +307,18 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
||||
if not result:
|
||||
if not fallback and any(n.get('_harmonize') for n in notes):
|
||||
return advance_positions(notes, onset, tones, states, preserve_strings, True)
|
||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {MIN_FRET}-{MAX_FRET} and four-fret span; held/tied strings must remain fixed')
|
||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET} and three-fret span; held/tied strings must remain fixed')
|
||||
return result
|
||||
|
||||
def place_guitar(document: dict) -> None:
|
||||
"""Choose acoustic grips per harmony span, or exact/held melody placements."""
|
||||
"""Choose electric voicings or exact/held melody placements."""
|
||||
events, tones = guitar_events(document)
|
||||
states = {(): Placement(VoicingCost(), (), None, ())}
|
||||
if document['staves'][0].get('arrangement_part_type') == 'acoustic':
|
||||
for span in generator.acoustic.harmony_spans(document):
|
||||
states = advance_acoustic(acoustic_candidates(span), states)
|
||||
if not states:
|
||||
raise ValueError(f'{span.location}: no acoustic grip within frets {MIN_FRET}-{MAX_FRET} and four-fret span for {span.harmony.get("symbol", span.harmony["kind"])}')
|
||||
else:
|
||||
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
|
||||
for index, (onset, notes) in enumerate(groups):
|
||||
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
|
||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings)
|
||||
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
|
||||
for index, (onset, notes) in enumerate(groups):
|
||||
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
|
||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings)
|
||||
selected = min(states.values(), key=lambda s: (s.cost, s.active))
|
||||
skipped = []
|
||||
while selected.previous is not None:
|
||||
@@ -459,7 +327,7 @@ def place_guitar(document: dict) -> None:
|
||||
for tone, position in choice.exact:
|
||||
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
|
||||
if definition := note.pop('_harmony', None):
|
||||
for pitch in chord_pitches(definition, choice.added):
|
||||
for pitch in generator.fretboard.chord_pitches(definition, choice.added):
|
||||
note['pitches'].append({**pitch, 'id': f'{note["id"]}-s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
|
||||
if note.get('_harmonize') and not choice.added and not note.get('_rhythm_continuation'):
|
||||
skipped.append(note['id'])
|
||||
|
||||
@@ -72,6 +72,9 @@ def degrees(harmony: dict) -> dict[int, int]:
|
||||
raise ValueError('harmony contains no pitch information')
|
||||
return result
|
||||
|
||||
def is_triad(harmony: dict) -> bool:
|
||||
return set(degrees(harmony)) == {1, 3, 5}
|
||||
|
||||
def degree_semitones(degree: int, alter: int) -> int:
|
||||
return 12 * ((degree - 1) // 7) + (0, 2, 4, 5, 7, 9, 11)[(degree - 1) % 7] + alter
|
||||
|
||||
|
||||
+3
-3
@@ -206,17 +206,17 @@ def validate_document(document: dict) -> None:
|
||||
else:
|
||||
pitch_number(p)
|
||||
if staves[0].get('tablature'):
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.pitch
|
||||
tab = p.get('tablature', {})
|
||||
string, fret = tab.get('string'), tab.get('fret')
|
||||
if type(string) is not int or type(fret) is not int or string not in dict(generator.guitar.STANDARD_TUNING) or not generator.guitar.MIN_FRET <= fret <= generator.guitar.MAX_FRET:
|
||||
if type(string) is not int or type(fret) is not int or string not in dict(generator.fretboard.STANDARD_TUNING) or not generator.fretboard.MIN_FRET <= fret <= generator.fretboard.MAX_FRET:
|
||||
raise ValueError(f'{p["id"]}: invalid generated tablature position')
|
||||
state = dict(measure)
|
||||
for change in measure.get('attributes', []):
|
||||
if fraction(change['onset']) <= onset:
|
||||
state.update(change)
|
||||
if dict(generator.guitar.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
|
||||
if dict(generator.fretboard.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
|
||||
raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch')
|
||||
pd = fraction(p['duration'])
|
||||
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import fractions
|
||||
import pathlib
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.rhythm
|
||||
@@ -58,7 +58,7 @@ def validate_source(document: dict) -> None:
|
||||
if state.get('clef', {}).get('sign') in ('TAB', 'percussion'):
|
||||
raise ValueError(f'{measure["id"]}: parts require a pitched, non-TAB source')
|
||||
if 'staff_details' in state:
|
||||
generator.guitar.validate_tuning(state, measure['id'])
|
||||
generator.fretboard.validate_tuning(state, measure['id'])
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
if any('tablature' in p or 'unpitched' in p for p in note['pitches']):
|
||||
|
||||
+163
-180
@@ -1,13 +1,18 @@
|
||||
import copy
|
||||
import fractions
|
||||
import itertools
|
||||
import pathlib
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.guitar
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.rhythm
|
||||
import tests.test_acoustic
|
||||
import tests.test_accompaniment
|
||||
|
||||
@@ -37,15 +42,23 @@ def planned_document(harmonies, attacks=None):
|
||||
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']))
|
||||
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
||||
return tuple(sorted(generator.fretboard.TabPosition(p['tablature']['string'], p['tablature']['fret'], tuning[p['tablature']['string']] + p['tablature']['fret']) for p in note['pitches']))
|
||||
|
||||
def voicing(frets, bass_string=5):
|
||||
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
||||
return generator.acoustic_voicing.Voicing(*(generator.fretboard.TabPosition(s, f, tuning[s] + f) for s, f in zip((bass_string, 4, 3, 2), frets)))
|
||||
|
||||
def candidates(chord):
|
||||
definition = generator.harmony.parse_symbol(chord) if isinstance(chord, str) else chord
|
||||
return generator.acoustic_voicing.candidates(generator.fretboard.chord_requirements(definition), generator.harmony.is_triad(definition))
|
||||
|
||||
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)
|
||||
generator.acoustic_voicing.place(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'}
|
||||
@@ -53,21 +66,22 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
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))
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
requirements = generator.fretboard.chord_requirements(span.harmony)
|
||||
if basses and uppers:
|
||||
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
|
||||
self.assertTrue(generator.acoustic_voicing.playable(generator.acoustic_voicing.Voicing(*grip)))
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in grip}))
|
||||
self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed))
|
||||
if basses:
|
||||
self.assertIn(min(p.midi for p in grip) % 12, requirements.basses)
|
||||
self.assertTrue(generator.guitar.within_fret_span(grip))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in grip))
|
||||
for note in span.notes:
|
||||
strings = sorted(p['tablature']['string'] for p in note['pitches'])
|
||||
if lanes[note['id']] == 'upper':
|
||||
self.assertEqual(strings, [2, 3, 4])
|
||||
else:
|
||||
self.assertIn(strings, ([5], [6]))
|
||||
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):
|
||||
def test_independent_attacks_keep_one_complete_voicing(self):
|
||||
document = planned_document([[(0, 'FM7')]])
|
||||
spans = self.place(document)
|
||||
self.assertEqual(len(spans), 1)
|
||||
@@ -76,37 +90,47 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
generator.acoustic.combine(document)
|
||||
self.assertEqual(tests.test_acoustic.sounding(document), before)
|
||||
generator.model.validate_document(document)
|
||||
def test_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(generator.harmony.parse_symbol('Csus/C'), 'acoustic'), profile='acoustic')
|
||||
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
|
||||
self.assertLessEqual(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
|
||||
def test_bass_options_follow_all_chord_tones_and_explicit_basses(self):
|
||||
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
|
||||
for chord, expected in (('C7', {0, 4, 7, 10}), ('Cdim7', {0, 3, 6, 9}), ('Caug', {0, 4, 8}), ('Cno5', {0, 4}), ('C7b9', {0, 1, 4, 7, 10}), ('Dmno1', {5, 9}), ('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)
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
|
||||
def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self):
|
||||
def test_four_strings_allow_all_defined_bass_inversions(self):
|
||||
for symbol in ('CM', 'Dm', 'Caug', 'Cdim', 'C7', 'Cdim7', 'Cno5', 'C7b9', 'Dmno1', 'C6', 'C13', 'Csus', 'Csus2'):
|
||||
with self.subTest(chord=symbol):
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
requirements = generator.fretboard.chord_requirements(chord)
|
||||
options = candidates(chord)
|
||||
self.assertTrue(options)
|
||||
self.assertFalse(requirements.basses)
|
||||
self.assertTrue(all(v.bass.midi % 12 in requirements.allowed for v in options))
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in v}) for v in options))
|
||||
self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in options))
|
||||
self.assertEqual({v.bass.midi % 12 for v in candidates('C7')}, {0, 4, 7, 10})
|
||||
self.assertIn(1, {v.bass.midi % 12 for v in candidates('C7b9')})
|
||||
self.assertIn(5, {v.bass.midi % 12 for v in candidates('Dm')})
|
||||
def test_same_voicing_survives_repeated_symbols_and_rest_bars(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):
|
||||
def test_iterations_are_independent_even_with_identical_boundary_chords(self):
|
||||
first, second = [[(0, s)] for s in ('C7', 'G7')], [[(0, s)] for s in ('G7', 'FM7')]
|
||||
document = planned_document(first + second)
|
||||
document['staves'][0]['measures'][2]['section_label'] = 'Next iteration'
|
||||
groups = [list(group) for _, group in itertools.groupby(generator.acoustic.harmony_spans(document), key=lambda s: s.iteration)]
|
||||
self.assertEqual([len(g) for g in groups], [2, 2])
|
||||
expected = []
|
||||
for harmonies in (first, second):
|
||||
separate = planned_document(harmonies)
|
||||
self.place(separate)
|
||||
expected.extend(separate['staves'][0]['measures'])
|
||||
self.place(document)
|
||||
def positions(measures):
|
||||
return [[shape(n) for v in m['voices'] for n in v['notes']] for m in measures]
|
||||
self.assertEqual(positions(document['staves'][0]['measures']), positions(expected))
|
||||
def test_changes_during_silence_end_the_harmony_span_not_the_journey(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])
|
||||
self.assertEqual(len({s.iteration for s in spans}), 1)
|
||||
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)
|
||||
@@ -117,9 +141,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
spans = self.place(document)
|
||||
self.assertEqual(len(spans), 4)
|
||||
basses = [min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans]
|
||||
self.assertIn(basses[0], (0, 4, 7, 10))
|
||||
self.assertEqual(basses[1:3], [4, 7])
|
||||
self.assertIn(basses[3], (0, 4, 6, 10))
|
||||
def test_non_accompaniment_passage_ends_the_span(self):
|
||||
document = planned_document([[(0, 'C7')], [], []])
|
||||
measure = document['staves'][0]['measures'][1]
|
||||
@@ -130,164 +152,125 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
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):
|
||||
def test_no_chord_and_empty_iterations_do_not_generate_notes(self):
|
||||
document = planned_document([[(0, 'N.C.')], [(0, 'C7')], [(0, 'N.C.')]])
|
||||
for index, measure in enumerate(document['staves'][0]['measures']):
|
||||
measure['section_label'] = f'Iteration {index}'
|
||||
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):
|
||||
self.assertEqual(generator.acoustic_voicing.select([]), [])
|
||||
def test_unsounded_voices_still_constrain_the_full_voicing(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)
|
||||
selected = generator.acoustic_voicing.select([span])[0]
|
||||
self.assertTrue(generator.acoustic_voicing.playable(selected))
|
||||
self.place(document)
|
||||
def test_rhythm_density_does_not_multiply_shape_cost(self):
|
||||
self.assertEqual(shape(span.notes[0]), tuple(sorted((selected.bass,) if lane == 'bass' else selected[1:])))
|
||||
def test_rhythm_density_does_not_multiply_movement_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(2 * generator.guitar.acoustic_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_acoustic(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):
|
||||
def test_bass_switch_counts_both_string_and_fret_travel(self):
|
||||
previous = voicing((7, 8, 7, 8))
|
||||
for current, expected in ((voicing((7, 8, 7, 8), 6), 1), (voicing((8, 8, 7, 8), 6), 2), (voicing((9, 8, 7, 8), 6), 3), (voicing((6, 8, 7, 8)), 1)):
|
||||
self.assertEqual(generator.acoustic_voicing.movement(previous, current), expected)
|
||||
self.assertEqual(generator.acoustic_voicing.movement(current, previous), expected)
|
||||
self.assertEqual(generator.acoustic_voicing.movement(previous, previous), 0)
|
||||
def test_each_upper_string_is_tracked_not_just_the_grip_center(self):
|
||||
before, after = voicing((5, 7, 5, 7)), voicing((7, 5, 7, 5))
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, after), 8)
|
||||
for index in (1, 2, 3):
|
||||
frets = [5, 7, 5, 7]
|
||||
frets[index] += 1
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1)
|
||||
def test_fret_and_register_limits_are_hard_constraints(self):
|
||||
for frets, valid in (((1, 1, 1, 1), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 15, 14, 12), True), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
|
||||
with self.subTest(frets=frets):
|
||||
self.assertEqual(generator.acoustic_voicing.playable(voicing(frets)), valid)
|
||||
for symbol in ('CM', 'C7b9', 'C13', 'Cdim7'):
|
||||
self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in candidates(symbol)))
|
||||
def test_acoustic_cost_has_no_register_span_or_shape_penalties(self):
|
||||
options = (voicing((1, 1, 1, 1)), voicing((1, 4, 3, 4)), voicing((12, 12, 12, 12)), voicing((12, 15, 14, 15)))
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
result = generator.acoustic_voicing.advance(options, generator.fretboard.ChordRequirements(), states)
|
||||
self.assertEqual([p.cost for p in result], [generator.acoustic_voicing.Cost()] * len(options))
|
||||
def test_optional_tones_break_equal_movement_costs_only(self):
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'))
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
result = generator.acoustic_voicing.advance(candidates('C7'), requirements, states)
|
||||
self.assertTrue(all(p.cost.travel == 0 for p in result))
|
||||
self.assertEqual({p.cost.missing_tones for p in result}, {0, 1})
|
||||
best = min(result, key=lambda p: (p.cost, p.voicing))
|
||||
self.assertEqual({p.midi % 12 for p in best.voicing}, {0, 4, 7, 10})
|
||||
def test_dynamic_program_matches_exhaustive_paths_and_is_deterministic(self):
|
||||
harmonies = ['FM7', 'Gm7', 'C7']
|
||||
spans = generator.acoustic.harmony_spans(planned_document([[(0, s)] for s in harmonies]))
|
||||
options = [candidates(s) for s in harmonies]
|
||||
requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans]
|
||||
def cost(path):
|
||||
return generator.acoustic_voicing.Cost(sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path)))
|
||||
selected = generator.acoustic_voicing.select(spans)
|
||||
self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options)))
|
||||
self.assertEqual(selected, generator.acoustic_voicing.select(spans))
|
||||
def test_complete_triads_and_doubling_use_only_authored_chord_tones(self):
|
||||
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm7no7'):
|
||||
with self.subTest(chord=symbol):
|
||||
classes = set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(symbol)))
|
||||
options = candidates(symbol)
|
||||
self.assertTrue(options)
|
||||
self.assertTrue(all(len(v) == 4 and {p.midi % 12 for p in v} == classes for v in options))
|
||||
for symbol, omitted in (('Dmno1', 2), ('C7no5', 7)):
|
||||
self.assertTrue(candidates(symbol))
|
||||
self.assertTrue(all(omitted not in {p.midi % 12 for p in v} for v in candidates(symbol)))
|
||||
def test_unplayable_omission_is_not_restored_to_fill_upper_strings(self):
|
||||
self.assertFalse(candidates('Dmno5'))
|
||||
document = planned_document([[(0, 'Dmno5')]])
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'Dmno5: no acoustic grip.*three-fret span'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
|
||||
for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('C7/E', 4), ('C7b9/Db', 1), ('Dm/B', 11)):
|
||||
with self.subTest(chord=chord):
|
||||
span = self.place(planned_document([[(0, chord)]]))[0]
|
||||
self.assertEqual({p.midi % 12 for n in span.notes for p in shape(n) if p.string_number >= 5}, {bass})
|
||||
def test_impossible_later_iteration_does_not_partially_materialize(self):
|
||||
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
|
||||
document['staves'][0]['measures'][1]['section_label'] = 'Later iteration'
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*1-20'):
|
||||
generator.guitar.place_guitar(document)
|
||||
generator.acoustic_voicing.place(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_g_minor_to_c_seventh_preserves_positions_with_fifth_bass(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
previous = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 10), (3, 10), (2, 11)))
|
||||
active = tuple((str(p.string_number), p) for p in previous)
|
||||
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'C7')]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
result = generator.guitar.advance_acoustic(candidates, states)
|
||||
best = min(result.values(), key=lambda p: (p.cost, p.active))
|
||||
chosen = tuple(p for _, p in best.active)
|
||||
self.assertEqual([(p.string_number, p.fret) for p in chosen], [(5, 10), (3, 9), (2, 11)])
|
||||
self.assertEqual(generator.guitar.acoustic_movement(previous, chosen), 1)
|
||||
self.assertEqual(generator.guitar.acoustic_movement(chosen, previous), 1)
|
||||
self.assertEqual(generator.guitar.acoustic_movement(previous, previous), 0)
|
||||
self.assertEqual(generator.guitar.acoustic_movement((), chosen), 0)
|
||||
def test_acoustic_candidates_are_not_pruned_by_grip_center(self):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic')
|
||||
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
self.assertEqual(candidates, generator.guitar.chord_positions(requirements, profile='acoustic', prune=False))
|
||||
self.assertGreater(len(candidates), len({generator.guitar.position_center(p) for p in candidates}))
|
||||
def test_physical_reach_uses_fret_spacing_and_an_inclusive_reference(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
low = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 3), (3, 2), (2, 5)))
|
||||
reference = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 8), (3, 7), (2, 10)))
|
||||
high = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 9), (3, 8), (2, 11)))
|
||||
self.assertEqual(generator.guitar.compactness(low), generator.guitar.compactness(reference))
|
||||
self.assertGreater(generator.guitar.physical_reach(low), generator.guitar.physical_reach(reference))
|
||||
self.assertGreater(generator.guitar.physical_reach(reference), generator.guitar.physical_reach(high))
|
||||
self.assertEqual(generator.guitar.physical_reach(reference), generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
self.assertEqual(generator.guitar.physical_reach(()), 0)
|
||||
self.assertEqual(generator.guitar.physical_reach(reference[:1]), 0)
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'Dm')]]))[0]
|
||||
self.assertIn(reference, [c.shape for c in generator.guitar.acoustic_candidates(span)])
|
||||
def test_acoustic_effort_does_not_reward_further_compression(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
shapes = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in zip((5, 4, 3, 2), frets)) for frets in ((5, 5, 7, 7), (6, 6, 7, 7))]
|
||||
self.assertGreater(generator.guitar.physical_reach(shapes[0]), generator.guitar.physical_reach(shapes[1]))
|
||||
self.assertTrue(all(generator.guitar.physical_reach(s) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for s in shapes))
|
||||
self.assertEqual(*(generator.guitar.voicing_cost(s, 'acoustic').effort for s in shapes))
|
||||
def test_comfortable_candidates_win_even_when_previous_grip_is_wider(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
wider = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((6, 6), (4, 5), (3, 3), (2, 3)))
|
||||
active = tuple((str(p.string_number), p) for p in wider)
|
||||
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'Em7b5')]]))[0]
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
self.assertIn(wider, generator.guitar.chord_positions(requirements, profile='acoustic'))
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertNotIn(wider, [c.shape for c in candidates])
|
||||
result = generator.guitar.advance_acoustic(candidates, states)
|
||||
chosen = min(result.values(), key=lambda p: (p.cost, p.active))
|
||||
self.assertLessEqual(generator.guitar.physical_reach(tuple(p for _, p in chosen.active)), generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
def test_complete_triads_are_preferred_within_comfortable_candidates(self):
|
||||
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F'):
|
||||
with self.subTest(chord=symbol):
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
classes = set(generator.harmony.pitch_classes(span.harmony))
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(classes <= {p.midi % 12 for p in c.shape} for c in candidates))
|
||||
self.assertTrue(all(generator.guitar.physical_reach(c.shape) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for c in candidates))
|
||||
def test_omissions_and_extended_chords_do_not_gain_complete_triad_requirement(self):
|
||||
for symbol, omitted in (('Dmno1', 2), ('Dmno5', 9)):
|
||||
with self.subTest(chord=symbol):
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(omitted not in {p.midi % 12 for p in c.shape} for c in candidates))
|
||||
for symbol in ('C7', 'C7b9', 'C13'):
|
||||
with self.subTest(chord=symbol):
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
self.assertTrue(any(0 not in {p.midi % 12 for p in c.shape} for c in candidates))
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
|
||||
def test_complete_triad_preference_cannot_force_a_wider_grip(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
reduced, complete = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in pairs) for pairs in (((6, 1), (4, 3), (3, 2)), ((5, 5), (4, 3), (3, 2)))]
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Dm'), 'acoustic')
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in s}) for s in (reduced, complete)))
|
||||
self.assertLessEqual(generator.guitar.physical_reach(reduced), generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
self.assertGreater(generator.guitar.physical_reach(complete), generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
generator.guitar.acoustic_positions.cache_clear()
|
||||
self.addCleanup(generator.guitar.acoustic_positions.cache_clear)
|
||||
with unittest.mock.patch('generator.guitar.chord_positions', return_value=(reduced, complete)):
|
||||
self.assertEqual(generator.guitar.acoustic_positions(requirements, complete_triad=True), (reduced,))
|
||||
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
|
||||
for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('Dm/B', 11)):
|
||||
with self.subTest(chord=chord):
|
||||
document = planned_document([[(0, chord)]])
|
||||
span = self.place(document)[0]
|
||||
self.assertEqual({p.midi % 12 for n in span.notes for p in shape(n) if p.string_number >= 5}, {bass})
|
||||
def test_wider_fallback_is_least_reaching_and_reported_with_context(self):
|
||||
document = planned_document([[(0, 'C13/E')]])
|
||||
span = generator.acoustic.harmony_spans(document)[0]
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
raw = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
minimum = min(generator.guitar.physical_reach(s) for s in raw)
|
||||
self.assertGreater(minimum, generator.guitar.PREFERRED_ACOUSTIC_REACH)
|
||||
with self.assertLogs('generator.guitar', level='WARNING') as logs:
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertTrue(candidates)
|
||||
self.assertEqual({generator.guitar.physical_reach(c.shape) for c in candidates}, {minimum})
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) and generator.guitar.within_fret_span(c.shape) for c in candidates))
|
||||
self.assertIn(span.location, logs.output[0])
|
||||
self.assertIn('C13/E requires a wider acoustic reach', logs.output[0])
|
||||
with self.assertLogs('generator.guitar', level='WARNING'):
|
||||
self.place(document)
|
||||
def test_inversions_still_follow_nearby_full_grip_positions(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
for previous, expected in ((((6, 3), (4, 2), (3, 3), (2, 3)), ((6, 3), (4, 2), (3, 3), (2, 2))), (((5, 7), (4, 8), (3, 7), (2, 8)), ((5, 7), (4, 8), (3, 6), (2, 8)))):
|
||||
with self.subTest(previous=previous):
|
||||
grip = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in previous)
|
||||
active = tuple((str(p.string_number), p) for p in grip)
|
||||
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'A7b9')]]))[0]
|
||||
result = generator.guitar.advance_acoustic(generator.guitar.acoustic_candidates(span), states)
|
||||
chosen = tuple(p for _, p in min(result.values(), key=lambda p: (p.cost, p.active)).active)
|
||||
self.assertEqual(tuple((p.string_number, p.fret) for p in chosen), expected)
|
||||
self.assertEqual(generator.guitar.acoustic_movement(grip, chosen), 1)
|
||||
def test_invalid_inversion_and_tuning_fail_with_context(self):
|
||||
invalid = generator.harmony.parse_symbol('C7') | {'inversion': 4}
|
||||
document = planned_document([[(0, invalid)]])
|
||||
with self.assertRaisesRegex(ValueError, 'measure 1.*inversion'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
document = planned_document([[(0, 'C7')]])
|
||||
document['staves'][0]['measures'][0]['staff_details']['capo'] = 1
|
||||
with self.assertRaisesRegex(ValueError, 'capo'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
def test_real_song_iterations_use_complete_upper_voices(self):
|
||||
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
|
||||
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
|
||||
source = generator.musicxml.load_musicxml(path)
|
||||
before = copy.deepcopy(source)
|
||||
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=rhythms)
|
||||
generator.model.validate_document(result)
|
||||
self.assertEqual(source, before)
|
||||
for measure in result['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
upper = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
|
||||
self.assertIn(upper, ([], [2, 3, 4]))
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
|
||||
if path.stem == 'autumn-leaves':
|
||||
for index, expected in ((0, voicing((3, 2, 3, 3), 6)), (1, voicing((3, 2, 3, 2), 6)), (4, voicing((3, 2, 3, 3), 6)), (5, voicing((3, 2, 3, 2), 6))):
|
||||
measure = result['staves'][0]['measures'][index]
|
||||
actual = {p for v in measure['voices'] for n in v['notes'] for p in shape(n)}
|
||||
self.assertEqual(actual, set(expected))
|
||||
self.assertEqual(result, generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=rhythms))
|
||||
|
||||
@@ -7,7 +7,7 @@ import unittest.mock
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.electric
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
@@ -17,9 +17,9 @@ 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)
|
||||
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
||||
positions = tuple(generator.fretboard.TabPosition(string, fret, tuning[string] + fret) for string, fret in zip((4, 3, 2, 1), frets))
|
||||
pitches = generator.fretboard.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:
|
||||
|
||||
+40
-30
@@ -8,7 +8,9 @@ import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.__main__
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
@@ -22,22 +24,28 @@ import tests.test_musicxml
|
||||
|
||||
class GuitarTests(unittest.TestCase):
|
||||
def test_fret_boundaries_and_span(self):
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(40)))
|
||||
self.assertIn(generator.guitar.TabPosition(6, 1, 41), generator.guitar.positions_for_pitch(fractions.Fraction(41)))
|
||||
self.assertIn(generator.guitar.TabPosition(1, 20, 84), generator.guitar.positions_for_pitch(fractions.Fraction(84)))
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(85)))
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(129, 2)))
|
||||
self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 11, 61), generator.guitar.TabPosition(3, 12, 67), generator.guitar.TabPosition(2, 14, 73))))
|
||||
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 10, 60), generator.guitar.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(40)))
|
||||
self.assertIn(generator.fretboard.TabPosition(6, 1, 41), generator.fretboard.positions_for_pitch(fractions.Fraction(41)))
|
||||
self.assertIn(generator.fretboard.TabPosition(1, 20, 84), generator.fretboard.positions_for_pitch(fractions.Fraction(84)))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(85)))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(129, 2)))
|
||||
self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(6, 0, 40), generator.fretboard.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 10, 60), generator.fretboard.TabPosition(1, 20, 84))))
|
||||
def test_fretted_positions_keep_span_and_proximity_scoring(self):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 5), True), ((1, 6), False), ((12, 1, 11), False), ((3, 3), True)):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 4), True), ((1, 5), False), ((7, 10), True), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True)):
|
||||
with self.subTest(frets=frets):
|
||||
positions = tuple(generator.guitar.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
|
||||
self.assertEqual(generator.guitar.within_fret_span(positions), valid)
|
||||
positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
|
||||
self.assertEqual(generator.fretboard.within_fret_span(positions), valid)
|
||||
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
|
||||
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
|
||||
self.assertTrue(generator.guitar.within_fret_span(()))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(()))
|
||||
def test_held_notes_obey_the_shared_three_fret_limit(self):
|
||||
held = {'held': generator.fretboard.TabPosition(4, 7, 57)}
|
||||
for fret, valid in ((10, True), (11, False)):
|
||||
with self.subTest(fret=fret):
|
||||
pending = [('attack', (generator.fretboard.TabPosition(2, fret, 59 + fret),))]
|
||||
self.assertEqual(bool(list(generator.guitar.extend_positions(held, pending))), valid)
|
||||
def test_low_open_e_fails_without_octave_fitting_or_mutation(self):
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=2)))
|
||||
before = copy.deepcopy(document)
|
||||
@@ -45,8 +53,8 @@ class GuitarTests(unittest.TestCase):
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_open_string_pitch_uses_a_fretted_alternative(self):
|
||||
for _, midi in generator.guitar.STANDARD_TUNING[1:]:
|
||||
positions = generator.guitar.positions_for_pitch(fractions.Fraction(midi))
|
||||
for _, midi in generator.fretboard.STANDARD_TUNING[1:]:
|
||||
positions = generator.fretboard.positions_for_pitch(fractions.Fraction(midi))
|
||||
self.assertTrue(positions)
|
||||
self.assertTrue(all(p.fret >= 1 and p.midi == midi for p in positions))
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
|
||||
@@ -67,7 +75,7 @@ class GuitarTests(unittest.TestCase):
|
||||
def test_theory_controls_omissions_and_bass(self):
|
||||
for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'):
|
||||
with self.subTest(symbol=symbol):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol))
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol))
|
||||
candidates = generator.guitar.chord_positions(requirements)
|
||||
self.assertTrue(candidates)
|
||||
for candidate in candidates:
|
||||
@@ -75,29 +83,29 @@ class GuitarTests(unittest.TestCase):
|
||||
self.assertTrue(requirements.covers(covered))
|
||||
self.assertTrue(covered <= set(requirements.allowed))
|
||||
self.assertEqual(len(candidate), len({p.string_number for p in candidate}))
|
||||
self.assertTrue(generator.guitar.within_fret_span(candidate))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(candidate))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
|
||||
if requirements.basses:
|
||||
self.assertIn(min(p.midi for p in candidate) % 12, requirements.basses)
|
||||
self.assertIn(2, generator.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9')).required)
|
||||
self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7')).required)
|
||||
self.assertEqual(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM')).foundation, (0, 7))
|
||||
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0}).basses, (0,))
|
||||
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1}).basses, (4,))
|
||||
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c))
|
||||
self.assertIn(2, generator.fretboard.chord_requirements(generator.harmony.parse_symbol('Cmadd9')).required)
|
||||
self.assertNotIn(7, generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7')).required)
|
||||
self.assertEqual(generator.fretboard.chord_requirements(generator.harmony.parse_symbol('CM')).foundation, (0, 7))
|
||||
self.assertEqual(generator.fretboard.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0}).basses, (0,))
|
||||
self.assertEqual(generator.fretboard.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1}).basses, (4,))
|
||||
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(generator.fretboard.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c))
|
||||
def test_root_or_fifth_is_required_for_every_generated_profile(self):
|
||||
for profile in ('', 'acoustic', 'electric'):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), profile)
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'), respect_bass=profile != 'electric')
|
||||
for classes in ({0, 4, 10}, {4, 7, 10}, {0, 4, 7, 10}):
|
||||
self.assertTrue(requirements.covers(classes))
|
||||
self.assertFalse(requirements.covers({4, 10}))
|
||||
candidates = generator.guitar.chord_positions(requirements, prune=False, profile=profile)
|
||||
candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else generator.guitar.chord_positions(requirements, prune=False, profile=profile)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all({p.midi % 12 for p in shape} & {0, 7} for shape in candidates))
|
||||
self.assertEqual({frozenset({p.midi % 12 for p in shape} & {0, 7}) for shape in candidates}, {frozenset({0}), frozenset({7}), frozenset({0, 7})})
|
||||
def test_altered_and_removed_fifths_keep_their_semantics(self):
|
||||
for symbol, fifth in (('Cdim7', 6), ('Caug7', 8)):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'electric')
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol), respect_bass=False)
|
||||
self.assertEqual(requirements.foundation, (0, fifth))
|
||||
self.assertIn(fifth, requirements.required)
|
||||
self.assertNotIn(7, requirements.allowed)
|
||||
@@ -105,17 +113,19 @@ class GuitarTests(unittest.TestCase):
|
||||
chord = generator.harmony.parse_symbol('C7')
|
||||
chord['degrees'].append({'value': omitted, 'alter': 0, 'type': 'subtract'})
|
||||
for profile in ('acoustic', 'electric'):
|
||||
requirements = generator.guitar.chord_requirements(chord, profile)
|
||||
requirements = generator.fretboard.chord_requirements(chord, respect_bass=profile != 'electric')
|
||||
self.assertEqual(requirements.foundation, (remaining,))
|
||||
self.assertTrue(all(remaining in {p.midi % 12 for p in c} for c in generator.guitar.chord_positions(requirements, profile=profile)))
|
||||
candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else generator.guitar.chord_positions(requirements, profile=profile)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(remaining in {p.midi % 12 for p in c} for c in candidates))
|
||||
chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'})
|
||||
with self.assertRaisesRegex(ValueError, 'root or fifth'):
|
||||
generator.guitar.chord_requirements(chord)
|
||||
generator.fretboard.chord_requirements(chord)
|
||||
def test_explicit_degree_chord_uses_the_same_root_or_fifth_rule(self):
|
||||
standard = generator.harmony.parse_symbol('C7')
|
||||
explicit = {**standard, 'kind': 'other', 'degrees': [{'value': d, 'alter': a, 'type': 'add'} for d, a in generator.harmony.degrees(standard).items()]}
|
||||
for profile in ('', 'acoustic', 'electric'):
|
||||
self.assertEqual(generator.guitar.chord_requirements(standard, profile), generator.guitar.chord_requirements(explicit, profile))
|
||||
self.assertEqual(generator.fretboard.chord_requirements(standard, respect_bass=profile != 'electric'), generator.fretboard.chord_requirements(explicit, respect_bass=profile != 'electric'))
|
||||
def test_melody_placement_preserves_notes_timing_and_sound(self):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4)))
|
||||
before = copy.deepcopy(source)
|
||||
@@ -185,7 +195,7 @@ class GuitarTests(unittest.TestCase):
|
||||
randomizer = random.Random(17)
|
||||
states = {}
|
||||
for index in range(40):
|
||||
positions = tuple(generator.guitar.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
|
||||
positions = tuple(generator.fretboard.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
|
||||
states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in generator.guitar.VoicingCost._fields)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
|
||||
grid = generator.guitar.nearest_positions(states)
|
||||
for x, row in enumerate(grid):
|
||||
|
||||
+16
-14
@@ -3,7 +3,9 @@ import fractions
|
||||
import unittest
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
@@ -27,19 +29,19 @@ class GuitarPartTests(unittest.TestCase):
|
||||
for profile in ('acoustic', 'electric'):
|
||||
with self.subTest(symbol=symbol, profile=profile):
|
||||
definition = generator.harmony.parse_symbol(symbol)
|
||||
requirements = generator.guitar.chord_requirements(definition, profile)
|
||||
candidates = generator.guitar.chord_positions(requirements, profile=profile)
|
||||
requirements = generator.fretboard.chord_requirements(definition, respect_bass=profile != 'electric')
|
||||
candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else generator.guitar.chord_positions(requirements, profile=profile)
|
||||
self.assertTrue(candidates)
|
||||
for candidate in candidates:
|
||||
strings = tuple(p.string_number for p in candidate)
|
||||
self.assertIn(strings, generator.guitar.ACOUSTIC_STRINGS if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS)
|
||||
self.assertIn(strings, ((5, 4, 3, 2), (6, 4, 3, 2)) if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS)
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in candidate}))
|
||||
self.assertTrue({p.midi % 12 for p in candidate} <= set(requirements.allowed))
|
||||
self.assertTrue(generator.guitar.within_fret_span(candidate))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(candidate))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
|
||||
chord = generator.harmony.parse_symbol('C7')
|
||||
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric').required)
|
||||
candidates = generator.guitar.chord_positions(generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
|
||||
self.assertNotIn(0, generator.fretboard.chord_requirements(chord, respect_bass=False).required)
|
||||
candidates = generator.guitar.chord_positions(generator.fretboard.chord_requirements(chord, respect_bass=False), profile='electric')
|
||||
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
|
||||
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
|
||||
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
|
||||
@@ -59,17 +61,17 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertNotIn('stem', note)
|
||||
strings = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
|
||||
if strings:
|
||||
self.assertIn(strings, ([2, 3], [3, 4], [2, 3, 4]))
|
||||
self.assertEqual(strings, [2, 3, 4])
|
||||
timeline = generator.playback.compile_timeline(result)['events']
|
||||
by_id = {p['id']: p for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
|
||||
for event in timeline:
|
||||
start = generator.model.fraction(event['onset'])
|
||||
simultaneous = [e for e in timeline if generator.model.fraction(e['onset']) <= start < generator.model.fraction(e['onset']) + generator.model.fraction(e['duration'])]
|
||||
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.fretboard.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous]
|
||||
self.assertEqual(len(positions), len({p.string_number for p in positions}))
|
||||
self.assertTrue(generator.guitar.within_fret_span(positions))
|
||||
def test_explicit_bass_and_default_root(self):
|
||||
for symbol, expected in (('CM/E', 4), ('C7b9', 0)):
|
||||
self.assertTrue(generator.fretboard.within_fret_span(positions))
|
||||
def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
|
||||
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
|
||||
@@ -88,9 +90,9 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertEqual([generator.model.fraction(n['onset']) for n in notes if n['pitches']], [0, 2])
|
||||
for onset in (0, 2):
|
||||
pitches = [p for n in notes if generator.model.fraction(n['onset']) == onset for p in n['pitches']]
|
||||
positions = [generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p) - 12)) for p in pitches]
|
||||
self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), generator.guitar.ACOUSTIC_STRINGS)
|
||||
self.assertTrue(generator.guitar.within_fret_span(positions))
|
||||
positions = [generator.fretboard.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p) - 12)) for p in pitches]
|
||||
self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), ((5, 4, 3, 2), (6, 4, 3, 2)))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(positions))
|
||||
def test_long_melody_chords_and_short_notes(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=4) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=5)
|
||||
notes = tests.test_musicxml.notes(self.section(content))
|
||||
|
||||
@@ -18,6 +18,15 @@ def chord_xml(text, kind='other', root='C', extra=''):
|
||||
return f'<harmony><root><root-step>{root}</root-step></root><kind text="{text}">{kind}</kind>{extra}</harmony>'
|
||||
|
||||
class HarmonyTests(unittest.TestCase):
|
||||
def test_triad_classification_uses_effective_degrees(self):
|
||||
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm/B', 'Dm7no7', 'C7no7'):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertTrue(generator.harmony.is_triad(generator.harmony.parse_symbol(symbol)))
|
||||
for symbol in ('C7', 'CM7', 'Cm7', 'Cdim7', 'C6', 'C13', 'Csus', 'Csus2', 'C7no5', 'Dmno1', 'Dmno5', 'Cadd9'):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertFalse(generator.harmony.is_triad(generator.harmony.parse_symbol(symbol)))
|
||||
custom = {'kind': 'other', 'degrees': [{'value': d, 'alter': a, 'type': 'add'} for d, a in ((1, 0), (3, -1), (5, 0))]}
|
||||
self.assertTrue(generator.harmony.is_triad(custom))
|
||||
def test_quality_spellings_are_canonical(self):
|
||||
cases = {'C': 'CM', 'Cmaj': 'CM', 'Cmin': 'Cm', 'C-': 'Cm', 'C+': 'Caug', 'Co': 'Cdim', 'Csus4': 'Csus', 'Csus2': 'Csus2',
|
||||
'Cmaj7': 'CM7', 'C-7': 'Cm7', 'C+7': 'Caug7', 'C+maj7': 'CaugM7', 'Co7': 'Cdim7', 'C-7b5': 'Cm7b5',
|
||||
|
||||
@@ -9,6 +9,7 @@ import generator.__main__
|
||||
import generator.accompaniment
|
||||
import generator.arrange.generate
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
@@ -40,7 +41,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
self.assertEqual(set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(label))), expected, label)
|
||||
for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]:
|
||||
definition = generator.harmony.parse_symbol(label)
|
||||
pitches = generator.guitar.chord_pitches(definition, generator.guitar.chord_positions(generator.guitar.chord_requirements(definition))[0])
|
||||
pitches = generator.fretboard.chord_pitches(definition, generator.guitar.chord_positions(generator.fretboard.chord_requirements(definition))[0])
|
||||
self.assertTrue(essentials.issubset({generator.model.pitch_number(p) % 12 for p in pitches}), label)
|
||||
with self.assertRaises(ValueError):
|
||||
generator.harmony.parse_symbol('Cunrecognized')
|
||||
|
||||
+27
-70
@@ -3,6 +3,7 @@ import pathlib
|
||||
import unittest
|
||||
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
@@ -13,16 +14,24 @@ 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))
|
||||
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
||||
return tuple(generator.fretboard.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)
|
||||
requirements = generator.guitar.chord_requirements(definition, profile)
|
||||
requirements = generator.fretboard.chord_requirements(definition, respect_bass=profile != 'electric')
|
||||
candidates = generator.guitar.chord_positions(requirements, prune=prune, profile=profile, melody=melody)
|
||||
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape))
|
||||
|
||||
class VoicingTests(unittest.TestCase):
|
||||
def test_electric_chords_use_the_shared_three_fret_limit(self):
|
||||
for prune in (False, True):
|
||||
candidates = ranked('CM7', prune=prune)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(generator.guitar.compactness(c) <= 3 for c in candidates))
|
||||
candidates = ranked('CM7')
|
||||
self.assertIn(grip((2, 4, 1, 3)), candidates)
|
||||
self.assertNotIn(grip((5, 9, 8, 7)), candidates)
|
||||
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):
|
||||
@@ -54,7 +63,7 @@ class VoicingTests(unittest.TestCase):
|
||||
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)
|
||||
requirements = generator.guitar.chord_requirements(definition, 'electric')
|
||||
requirements = generator.fretboard.chord_requirements(definition, respect_bass=False)
|
||||
for candidate in ranked(symbol):
|
||||
classes = {p.midi % 12 for p in candidate}
|
||||
self.assertTrue(requirements.covers(classes))
|
||||
@@ -72,91 +81,34 @@ class VoicingTests(unittest.TestCase):
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(all(p.midi % 12 != 0 for p in c.shape) and any(p.midi % 12 == 7 for p in c.shape) for c in candidates))
|
||||
def test_chord_height_cost_increases_throughout_the_fretboard(self):
|
||||
for profile in ('electric', 'acoustic'):
|
||||
for fret in range(1, 21):
|
||||
with self.subTest(profile=profile, fret=fret):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, fret + 2 * max(0, fret - 12))
|
||||
self.assertEqual(generator.guitar.voicing_cost((), profile).effort, 0)
|
||||
for fret in range(1, 21):
|
||||
with self.subTest(fret=fret):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), 'electric').effort, fret + 2 * max(0, fret - 12))
|
||||
self.assertEqual(generator.guitar.voicing_cost((), 'electric').effort, 0)
|
||||
def test_exact_melody_height_scoring_remains_unchanged(self):
|
||||
for fret in range(1, 21):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
|
||||
crossing = grip((11, 12, 13, 14))
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 33)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 24)
|
||||
def test_chord_shape_and_height_share_one_effort_cost(self):
|
||||
shape = grip((1, 3, 4, 5))
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 23)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 11)
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
for profile in ('', 'electric'):
|
||||
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
|
||||
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
|
||||
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
|
||||
for profile, expected in (('electric', 13), ('acoustic', 7)):
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple, profile).effort, expected)
|
||||
self.assertEqual(generator.guitar.voicing_cost(varied, profile).effort, expected + 2)
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple, 'electric').effort, 13)
|
||||
self.assertEqual(generator.guitar.voicing_cost(varied, 'electric').effort, 15)
|
||||
self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort)
|
||||
def test_acoustic_reach_excludes_stretched_low_pair_before_scoring(self):
|
||||
high = [grip((12, 12, 12, 11), (6, 4, 3, 2)), grip((12, 11, 12, 11), (6, 4, 3, 2))]
|
||||
requested = [grip((7, 8, 7, 8), (5, 4, 3, 2)), grip((7, 8, 6, 8), (5, 4, 3, 2))]
|
||||
low = [grip((6, 5, 3, 3), (6, 4, 3, 2)), grip((5, 5, 3, 2), (6, 4, 3, 2))]
|
||||
for symbol, upper, middle, lower in zip(('Em7b5', 'A7b9'), high, requested, low):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
|
||||
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
for shape in (upper, middle, lower):
|
||||
self.assertIn(shape, candidates)
|
||||
self.assertEqual({p.midi % 12 for p in upper}, {p.midi % 12 for p in middle})
|
||||
self.assertEqual(min(p.midi for p in upper), min(p.midi for p in middle))
|
||||
preferred = generator.guitar.acoustic_positions(requirements)
|
||||
self.assertIn(middle, preferred)
|
||||
self.assertIn(upper, preferred)
|
||||
self.assertNotIn(lower, preferred)
|
||||
costs = [sum(generator.guitar.voicing_cost(s, 'acoustic').effort for s in pair) + 2 * generator.guitar.acoustic_movement(*pair) for pair in (requested, high, low)]
|
||||
self.assertEqual(costs, [24, 30, 23])
|
||||
def test_real_autumn_leaves_cadences_use_comfortable_inversions_and_complete_triads(self):
|
||||
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
|
||||
before = copy.deepcopy(source)
|
||||
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms')))
|
||||
generator.model.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(measures[0]['section_label'], 'Rhythm Lead-in')
|
||||
for index, symbol, expected in ((0, 'Em7b5', ((6, 3), (4, 2), (3, 3), (2, 3))), (1, 'A7b9', ((6, 3), (4, 2), (3, 3), (2, 2))), (4, 'Em7b5', ((6, 3), (4, 2), (3, 3), (2, 3))), (5, 'A7b9', ((6, 3), (4, 2), (3, 3), (2, 2)))):
|
||||
measure = measures[index]
|
||||
with self.subTest(measure=measure['number'], chord=symbol):
|
||||
self.assertEqual(generator.harmony.symbol(measure['harmonies'][0]), symbol)
|
||||
positions = {(p['tablature']['string'], p['tablature']['fret']) for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
|
||||
self.assertEqual(tuple(sorted(positions, reverse=True)), expected)
|
||||
checked = set()
|
||||
for measure in measures:
|
||||
if measure['number'] not in ('26', '27', '30', '31', '32', '33'):
|
||||
continue
|
||||
pitches = [p for v in measure['voices'] for n in v['notes'] for p in n['pitches']]
|
||||
if not pitches:
|
||||
continue
|
||||
positions = tuple(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p)) - 12) for p in pitches)
|
||||
self.assertLessEqual(generator.guitar.physical_reach(positions), generator.guitar.PREFERRED_ACOUSTIC_REACH, measure['id'])
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in pitches))
|
||||
if measure['number'] in ('32', '33'):
|
||||
self.assertEqual({p.midi % 12 for p in positions}, {2, 5, 9})
|
||||
checked.add(measure['number'])
|
||||
self.assertEqual(checked, {'26', '27', '30', '31', '32', '33'})
|
||||
self.assertEqual(result, generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms'))))
|
||||
self.assertEqual(source, before)
|
||||
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
|
||||
candidates = ranked('Csus', profile='acoustic')
|
||||
best = candidates[0]
|
||||
self.assertEqual(len(best), 3)
|
||||
self.assertTrue(any(len(c) == 4 for c in candidates))
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus'), 'acoustic')
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in best}))
|
||||
def test_nearby_position_beats_lower_frets_within_comfort_range(self):
|
||||
low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9))
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
for profile in ('', 'electric'):
|
||||
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'):
|
||||
for profile in ('', 'electric'):
|
||||
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):
|
||||
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('Gm7', 'electric', (67,))):
|
||||
@@ -205,6 +157,11 @@ class VoicingTests(unittest.TestCase):
|
||||
generator.model.validate_document(result)
|
||||
section, checked = '', set()
|
||||
for measure in result['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
frets = [p['tablature']['fret'] for p in note['pitches']]
|
||||
if frets:
|
||||
self.assertLessEqual(max(frets) - min(frets), 3)
|
||||
section = measure.get('section_label') or section
|
||||
if section != 'Guitar Melody':
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user