This commit is contained in:
khodowany
2026-10-03 12:44:53 -05:00
parent 59afd78205
commit 04c6ea2e00
25 changed files with 673 additions and 185 deletions
+10 -6
View File
@@ -97,6 +97,8 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. Build-time Python uses each song's actual melody, its mapped harmony, `data/sequences`, and `data/rhythms` to compose recognizable acoustic and electric variations. The standalone `data/lead_phrases` material is an audition control, not a production seed. No solo assignments are stored in song XML, and the runtime format is unchanged.
The arrangement reuses six musical parts: acoustic accompaniment and solo; electric accompaniment, melody and solo; and vocal melody. Each guitar learns one accompaniment over the full form. Corresponding bars reuse identical rhythms, durations, pitches and TAB fingerings, including behind solos. Only authored harmony changes and protected pickup/ending boundaries create local exceptions.
Source analysis retains complete sounding notes, rests, pickups and cross-bar holds. Production uses **thematic recomposition**: a short rhythm, contour, opening, or characteristic interval from the head initializes a new solo theme. Later phrases develop that realization through harmonic adaptation, extension, answers, or optional brief callbacks. Each instrument establishes a distinct related theme instead of restarting from the corresponding head phrase at every source boundary. Close paraphrase remains available through `composer.compose(..., mode='paraphrase')` for comparison.
Treatment-specific contracts preserve the selected features only. Whole-source deviation applies to close paraphrase, including sounding durations; it does not penalize recomposition for changing unrelated notes. Before optional runs, realized pitch/time overlap and interval/rhythm fragments reject substantial near-copies (initial settings: 80% agreement across a two-bar window with at least four source attacks). Octave shifts, reattacks and extra attacks cannot dilute the duration comparison. Additional distance earns no bonus. The original source reference and the solo's realized theme remain separate in state and reports.
@@ -109,19 +111,21 @@ Destination retrieval retains representatives of the actual chord degrees before
After selecting the strict acoustic/electric pair and its mapped runs, `solo/enclosures.py` compares sparse local ornaments with direct arrivals. The initial vocabulary is a mapped upper neighbor followed by a lower semitone neighbor, each an eighth note, then the exact preselected chord-tone target. The upper is at most three semitones above the target. The lower can already be mapped; chromatic status earns no extra reward. Each candidate replaces a continuously sounding beat before an existing intermediate stop or terminal hold, optionally revising a complete mapped route up to two beats earlier. Identifying features, protected quotations, rests, target timing and the whole required hold remain intact. No target is reattacked merely to make an ornament possible.
Validation checks the complete sounded gesture, all crossed harmony/scale spans, exact pitch and octave, arrival and release, and actual preparation/resolution timing. Only the contracted lower event can receive an exception. Missing, changed, clipped or unresolved targets invalidate the entire gesture. Tied splits do not change classification. A small chorus frontier retains direct options, with at most one enclosure per phrase and four per 32 bars; sparse use is a soft preference, not a quota. The home theme and development history stay strict. Placement is resolved across the final solo, and backing responds to the decorated foreground. Build-time `enclosures_enabled=False` on compose/compose_pair/arrangement skips only this final pass; no runtime fields or performance expression are added.
Validation checks the complete sounded gesture, all crossed harmony/scale spans, exact pitch and octave, arrival and release, and actual preparation/resolution timing. Only the contracted lower event can receive an exception. Missing, changed, clipped or unresolved targets invalidate the entire gesture. Tied splits do not change classification. A small chorus frontier retains direct options, with at most one enclosure per phrase and four per 32 bars; sparse use is a soft preference, not a quota. The home theme and development history stay strict. A previously sounding mapped upper neighbor may supply the final eighth-note approach segment without a new attack, but its complete duration still obeys ordinary sustained-note rules. Incidental repeated upper-neighbor attacks carry a local cost; deliberate repeated attacks remain available. Placement is resolved across the final solo; ornamentation never changes accompaniment. Build-time `enclosures_enabled=False` on compose/compose_pair/arrangement skips only this final pass; no runtime fields or performance expression are added.
The plain recomposed theme is exported independently of optional runs. Activity retains a soft 4–6 attacks-per-four-beats preference; occupancy remains diagnostic. No accents, dynamics, swing or other performance-expression changes are applied.
Search retains eight phrase alternatives and an eight-state chorus frontier, with two intermediate continuations per established theme. Up to 64 acoustic/electric pairs compete; equivalent themes after transposition are excluded while individual musical costs and pair similarity remain separate. Concrete caches include incoming state, harmony, timing and vocabulary. One bounded replan broadens eligible start and destination pitches. Exhaustion identifies the failed window without invoking a generic composer. Stable ordering and final tie-breaking preserve determinism.
Phrase register alternatives cover the available F2–E5 sounding range. Placement uses all six strings, frets 1–12 without open notes, with a four-fret hand-position model and alternative endpoint fingerings. Movement costs use available time; they do not claim a maximum playable speed. Equal-cost tie-breaking has no first-string preference. Chords elsewhere retain their existing 1–12 anchor policy. The other guitarist is recomposed around the selected foreground, and the returning vocal pickup remains protected.
Electric solos use strings 1–4 and favor roughly the electric melody's sounding register. Acoustic solos use strings 2–6 and favor about one octave below that melody. Both require frets 1–12 with no open notes. The register reference uses the original melody voice, excluding added chord voices, with a four-semitone median deadband and bounded preference; it is not a hard pitch corridor. Instrument profiles constrain pitch/anchor retrieval, complete routes, placement and validation, with cache keys separated by profile. Whole phrases can explore and return without a string-use quota. The four-fret hand-position model retains alternative endpoints and uses available time without claiming a maximum playable speed. Chords retain their existing 1–12 anchor policy, and the returning vocal pickup remains protected.
A clear return refers to the introduced theme's stored opening signature (normally three to five attacks with rhythm/rests), rather than selecting a different later three-note turn. Freer development retains a smaller core. Bounded gesture revisions can reconnect editable isolated notes without changing pitch order, attack count, or required arrivals. Existing anchors distinguish local settling, cadential resolution and handoff; a local settle carries the next harmonic destination into subsequent selection. These distinctions affect the solo, never its accompaniment.
Build reports in `generated_assets/analysis/` include selected source features, recurring solo themes, development lineage, anchors, complete routes, plain-versus-head and elaborated-versus-head comparisons, candidate diagnostics, destination degrees and complete enclosure contracts. Out-of-map approach notes are reported separately. Audition reports additionally show string use weighted by sounding duration and distinguish forced first-string notes from optional placements. These measurements establish technical behavior, not musical quality.
For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Full final mixes also use responsive accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`.
For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Historical mixes retain their original backing; new production uses canonical accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`.
The current enclosure comparison is under `build/solo_enclosure_auditions/index.html`: previous production, the new selected strict solo, and that same solo with enclosures, with fixed backing and the final responsive arrangement. `before_enclosure` retains the complete pre-ornament realization, distinct from the plain theme before runs. To capture a new comparison, preserve the prior snapshot as `before/snapshot.json`, then run `./modulus.py python -m generator.solo.audition --output build/solo_enclosure_auditions --capture after` and `--render`. `--no-enclosures` provides a strict capture. These build artifacts are ignored by Git.
The current comparison is under `build/solo_foreground_auditions/index.html`: preserved production, a whole-phrase register/string-only control, the new plain theme, the selected strict theme before rhythmic revision and enclosures, and the final solo. Fixed comparisons use the same canonical backing and neutral playback. Full, opening-eight-bar and closing-four-bar comparisons include notation and passage reports; full solos also have unaccompanied versions. `before_enclosure` retains the exact selected pre-ornament realization, and `before_rhythm` isolates the timing revision. Preserve a previous snapshot as `before/snapshot.json`, then run `./modulus.py python -m generator.solo.audition --output build/solo_foreground_auditions --capture after` and `--render`. `--no-enclosures` provides a strict capture. Artifacts remain ignored by Git. Older enclosure comparisons remain under `build/solo_enclosure_auditions/`.
To inspect generated assets without building the full package, run:
@@ -138,7 +142,7 @@ Identical notation bodies share layout and body rasterization even when titles o
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated_tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu. Measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure, and exported excerpts retain their arrangement measure labels.
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 foreground. 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 and scale-compatible. 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. The seven-section form is unchanged; soloing guitars play single-note phrases without self-accompaniment. 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 separately planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable voicing until the harmony changes in the canonical form. That complete part repeats unchanged across arrangement iterations. 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. The original song informs its one-time rhythm choice; generated solos do not alter it. 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 and scale-compatible. 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. The seven-section form is unchanged; soloing guitars play single-note phrases without self-accompaniment. 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.
@@ -146,7 +150,7 @@ Guitar parts share standard tuning and chord-tone helpers but use separate acous
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 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 chord effort balances span and distinct frets against position-center movement, without an absolute fret-height penalty. Unharmonized melody placement retains its existing register and movement costs. This is a fretboard-feasibility 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 once across the canonical form, minimizing total fret travel across harmonic spans, then reuses them in every accompaniment iteration. 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 chord effort balances span and distinct frets against position-center movement, without an absolute fret-height penalty. Unharmonized melody placement retains its existing register and 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.
+11 -5
View File
@@ -154,7 +154,7 @@ Acoustic candidates are generated from chord-tone positions on the four voices,
`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.
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.
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 across the canonical form; accompaniment iterations copy the selected fingerings. Within that selection, 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.
Electric chord effort is `2 * max(0, distinct_frets - 1) + 3 * fret_span`, plus Manhattan movement between doubled fret/string position centers. Here `fret_span` remains `max(frets) - min(frets)` for scoring; eligibility counts fret positions inclusively. Neither the highest fret nor crossing fret 12 adds a chord cost. The 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.
@@ -190,11 +190,17 @@ Scale labels appear above chord symbols at collection changes and system starts.
### Phrase Composition
The musical arrangement has six reusable parts: acoustic accompaniment/solo, electric accompaniment/melody/solo, and vocal melody. `arrange.accompaniment.install` builds each accompaniment once and copies its events at matching authored form positions, retaining cycle phase in lead-in/ending excerpts. It preserves the actual guitar pickup's event identities, ties and tuplets. Changed effective harmony is revoiced locally; a structural audit rejects unexplained differences among repeated bars. `solo.arrangement.apply` installs only the soloist's notes and validates the unchanged supporting part against the solo scale map. No solo-dependent accompaniment planning, voicing, or dynamics remains in production.
Solo `profiles.Instrument` carries hard string limits, the sounding register, and a melody-derived preferred center. The electric reference is the prepared concert melody plus twelve semitones; acoustic is twelve semitones below electric. This reproduces the existing electric-melody sounding transformation without using the highest harmonization tone. The soft center has a four-semitone deadband, and excursions remain possible. Concrete search and placement caches include the profile.
`gesture_rhythm.revise` compares up to eight alternatives in each of two editable windows, retaining the same pitch sequence and attack count. Feature contracts, complete-duration harmony checks, anchor checks and required tails all remain binding. `Phrase.before_rhythm` preserves the exact pre-revision candidate for comparison. Anchor `effect` distinguishes local settling, cadential arrival and handoff; local settling carries a small next-destination preference into the following phrase. An enclosure may reuse a contiguous mapped upper note through an explicit approach segment; extending that note does not waive its ordinary duration-sensitive harmonic policy.
`generator/solo/` is build-only. The active path is `arrangement.apply` → `composer.compose_pair` → `leads.thematic_options` → `themes.phrase_candidates` → `realization.checked_phrase`. The song's actual melody supplies a selected rhythmic, contour, opening, or interval seed. Each instrument introduces its own realized solo theme; later development refers to that heard identity, not each successive phrase of the head. Close paraphrase remains available through `mode='paraphrase'` for comparisons. The older cell-level planner/refiner is not the production theme constructor.
`phrasing.py` proposes four-, five-, six-, and eight-bar endpoints from actual measure boundaries and harmony, with explicit missing-harmony interruptions and a trimmed final handoff. Two promising endpoint alternatives compete through realization from each incoming history. A soft preference aims for approximately six substantial closes per 32 bars (five to seven is the initial range). Ending alternatives choose a chord-valid pitch, arrival, two-to-four-beat sounding interval, and optional release together before constructing the approach. Intermediate search units do not close the thought. A held target must remain one sounding event for the promised interval; repeated attacks or touching and abandoning it do not fulfill the contract. Cross-bar holds must remain valid across every chord and mapped collection they overlap.
Theme appearances distinguish clear returns, developed appearances, and freer continuation. A clear return preserves an identifying melodic relationship with its timing; development can preserve melodic intervals while altering rhythm, or a characteristic rhythm while recomposing pitches. Feature selection looks for a heard turn or leap rather than automatically protecting the first events. A uniform quarter-note rhythm alone is not sufficient identity for a rhythmic development. Approximately two clear returns are preferred, independently of phrase closures. Once a feature contract is fulfilled, additional copying is not rewarded.
Theme appearances distinguish clear returns, developed appearances, and freer continuation. A clear return preserves an identifying melodic relationship with its timing; development can preserve melodic intervals while altering rhythm, or a characteristic rhythm while recomposing pitches. A stored opening signature from the realized introduction supplies clear returns, including its rhythm and rests; development may use its smaller three-note core. Ordinary generated events outside that declared signature remain editable. A uniform quarter-note rhythm alone is not sufficient identity for a rhythmic development. Approximately two clear returns are preferred, independently of phrase closures. Once a feature contract is fulfilled, additional copying is not rewarded.
`sequences.py` validates relative paths in `data/sequences` and chains them into complete routes using octave-aware scale indices. `themes.py` combines these with exact rhythms from `data/rhythms`, including rhythmic alteration, diminution, displacement already present in catalog windows, and sustained endings. Concrete routes retain their complete entry, pitch path, timing, and destination. Up to eight rhythm alternatives and six destinations are retrieved; sixteen joint rhythm/start/destination jobs preserve coverage before expensive realization, expanding to thirty-two during a bounded replan. Eight intermediate, phrase, and chorus alternatives are retained. Cache keys include the actual musical state used in construction; history-dependent ranking occurs after shared concrete retrieval.
@@ -202,13 +208,13 @@ A rolling sixteen-bar history measures exact and transposed pitch/rhythm recurre
`leads.elaborate` may replace a complete internal span of shorter notes, as well as an editable portion of a hold. Feature contracts and protected sounding tails are rechecked against the complete result. Exact source quotations remain immutable. The plain theme is a complete playable line, and elaboration is optional. Harmonic validity, exact timing, scale membership, monophony, required holds, and guitar placement remain hard constraints. A bounded replan may reconsider eligible ending alternatives; exhausted search reports its failed passage without claiming mathematical incompatibility or falling back to generic material.
Acoustic/electric selection compares complete alternatives, preserving different related themes and discouraging unintended long copies. Placement uses sounding pitches on all six strings, frets 1–12 without open strings; written guitar pitch is one octave above sound exactly once. Ties retain string and fret. The first declared source tempo, or 120 quarter notes/minute, informs movement costs; runtime tempo remains adjustable. No out-of-collection chromatic substitutions, accents, dynamics, velocity shaping, or swing changes are part of this composer.
Acoustic/electric selection compares complete alternatives, preserving different related themes and discouraging unintended long copies. Placement uses sounding pitches on strings 2–6 for acoustic solos and 1–4 for electric solos, frets 1–12 without open strings; written guitar pitch is one octave above sound exactly once. Ties retain string and fret. The first declared source tempo, or 120 quarter notes/minute, informs movement costs; runtime tempo remains adjustable. No out-of-collection chromatic substitutions, accents, dynamics, velocity shaping, or swing changes are part of this composer.
`arrangement.py` analyzes each song once and replaces only the two solo-section windows after normal head/accompaniment placement. Electric backs Acoustic Solo; acoustic backs Electric Solo. Neither guitarist accompanies itself. Backing rhythms use the generated solo's foreground and the reserved vocal return, not the absent original melody. Electric may reuse the corresponding placed head grip only when its pitches fit the active solo collection; otherwise ordinary chord-tone placement applies. Existing chord constraints remain unchanged. All non-solo measures and the complete vocal part are regression boundaries. The vocal pickup cutoff is reserved before composing the electric ending.
`arrangement.py` analyzes each song once and replaces only the two solo-section windows after normal head/accompaniment placement. Electric backs Acoustic Solo; acoustic backs Electric Solo. Neither guitarist accompanies itself. Supporting parts retain the canonical accompaniment chosen from the original song, including its exact rhythm, pitches and fingerings. The electric canonical part may reuse a corresponding melody grip only when it satisfies the written chord requirements and contains chord tones. Solo installation validates the existing backing against the mapped collections and audits repeated form positions; it does not regenerate backing from foreground activity. The complete vocal part remains unchanged, and its pickup cutoff is reserved before composing the electric ending.
`notation.py` converts placed attack spans into ordinary score events, rests, and tied continuations across notation/bar boundaries. Engraving, playback and C++ consumption retain their existing formats. `report.py` appends motif/source IDs, transformation contracts, activity and landmark plans, target windows, sounding pitches, TAB decisions and search diagnostics. Generation still stages and validates the complete output before publication. Listening and playing remain necessary to accept the musical result.
`python -m generator.solo.audition --output build/solo_phrasing_auditions --capture before` preserves the current implementation, and `--capture after` captures its successor. Existing snapshots are never overwritten. `--render` exports full solos, opening eight bars, and closing four bars as MusicXML and WAV, with baseline backing held constant and a separate responsive arrangement. It also exports the new plain theme and unaccompanied plain/elaborated full solos. Source hashes, implementation/policy fingerprints, tempo, and shared mix gain are recorded. Reports distinguish completed sustained endings from ordinary long notes, show retained theme features, recurrence counts, movement/arrival activity, local register distribution, and forced versus optional first-string use. These diagnostics establish behavior; listening is still needed to judge musical quality.
`python -m generator.solo.audition --output build/solo_foreground_auditions --capture before` preserves the current implementation, and `--capture after` captures its successor. Existing snapshots are never overwritten. `--render` exports full solos, opening eight bars, and closing four bars as MusicXML and WAV, with the final canonical backing held constant. The published arrangement reuses that same backing instead of recomposing around the foreground. It also exports whole-phrase register transfers of the baseline, the selected theme before rhythmic revision, the new plain theme and unaccompanied plain/elaborated full solos. Source hashes, implementation/policy fingerprints, tempo, and shared mix gain are recorded. Reports distinguish completed sustained endings from ordinary long notes, show retained theme features, recurrence counts, movement/arrival activity, local register distribution, and forced versus optional first-string use. These diagnostics establish behavior; listening is still needed to judge musical quality.
### Engraving Boundary
+172
View File
@@ -0,0 +1,172 @@
"""One learned accompaniment per instrument, reused at authored form positions."""
from __future__ import annotations
import copy
from fractions import Fraction as F
import generator.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.electric
import generator.guitar
import generator.fretboard
import generator.model
import generator.parts
import generator.score
from generator.arrange import form
import generator.arrange.pickup
def harmonic_key(measure, previous):
def key(chord):
return repr({k: chord.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')}) if chord else ''
active = generator.accompaniment.active_chord(measure, F(), previous)
return (key(active), tuple((generator.model.fraction(h['onset']), key(h)) for h in measure['harmonies']
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
def voice(source, document, measures, part, initial=None, source_offset=0):
staff = document['staves'][0]
result = generator.score.make_arranged_document(source, staff, staff_id=staff['id'],
staff_name=staff['name'], role='guitar', arrangement_part_type=part, measures=measures)
if part == 'acoustic':
generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result)
generator.acoustic.combine(result)
else:
generator.guitar.place_guitar(result)
# Melody grips remain optional candidates from the same source position.
# Only genuine chord tones are copied: exposed melody tensions must not
# become a repeated accompaniment tension behind every solo.
sections = form.full_arrangement_sections(generator.score.source_staff(source)['measures'], part)
first = sum(s.measure_count for s in sections if s.section_index < 2)
head = document['staves'][0]['measures'][first + source_offset:first + source_offset + len(measures)]
previous = generator.accompaniment.carried_chords(measures, initial)
links = []
for i, measure in enumerate(measures):
if i >= len(head): break
for note in generator.score.measure_notes(measure):
if not note['pitches']: continue
chord = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous[i])
requirements = generator.fretboard.chord_requirements(chord)
allowed = set(requirements.allowed)
matches = [n for n in generator.score.measure_notes(head[i]) if len(n['pitches']) >= 3
and {int(generator.model.pitch_number(p)) % 12 for p in n['pitches']} <= allowed
and requirements.covers({int(generator.model.pitch_number(p)) % 12 for p in n['pitches']})]
links.append(generator.electric.VoicingLink(note, tuple(matches)))
generator.electric.reuse_voicings(links)
generator.model.validate_document(result)
generator.fretboard.validate_document(result)
return result['staves'][0]['measures']
def install(source, document, plans):
"""Freeze complete canonical grips; copies are never placed a second time."""
from generator.arrange.generate import section_source
part = document['staves'][0]['arrangement_part_type']
if part == 'vocal': return
authored = generator.score.source_staff(source)['measures']
sections = form.full_arrangement_sections(authored, part)
if 1 not in plans or not any(s.section_index == 1 for s in sections):
return [] # A standalone section already is its own canonical part.
body = generator.score.body_start_measure_index(authored)
body_measures = copy.deepcopy(authored[body:])
for m in body_measures:
for h in m['harmonies']: h.pop('chord_optional', None)
previous = generator.accompaniment.carried_chords(authored)
plan = plans[1]
def build(measures, prior, offset=0):
carried = generator.accompaniment.carried_chords(measures, prior)
result = []
for i, m in enumerate(measures):
j = offset + i
rhythm = (plan.acoustic_bass[j], plan.acoustic_upper[j]) if part == 'acoustic' else plan.electric[j]
result.append(generator.parts.build_measure(m, f'canonical_{part}_{j}', 'accompaniment', part, carried[i], rhythm=rhythm))
return result
canonical = voice(source, document, build(body_measures, previous[body]), part, previous[body])
canonical_previous = generator.accompaniment.carried_chords(body_measures, previous[body])
keys = [harmonic_key(m, p) for m, p in zip(body_measures, canonical_previous)]
cache, exceptions = {}, []
destination = 0
for si, section in enumerate(sections):
if section.role != 'accompaniment':
destination += section.measure_count
continue
effective = section_source(authored, section, sections[si + 1] if si + 1 < len(sections) else None)
carried = generator.accompaniment.carried_chords(effective)
for j in range(section.measure_count):
source_index = section.source_measure_index + j
index = source_index - body
old = document['staves'][0]['measures'][destination + j]
key = harmonic_key(effective[source_index], carried[source_index])
if key == keys[index]:
template = canonical[index]
else:
# Changed pickups/ending harmony reuse the rhythm and only revoice the
# affected measure. These are chart differences, never solo responses.
identity = (index, repr(key))
if identity not in cache:
cache[identity] = voice(source, document,
build([effective[source_index]], carried[source_index], index), part, carried[source_index], index)[0]
template = cache[identity]
exceptions.append({'measure': old['number'], 'source_measure': authored[source_index]['number'], 'reason': 'changed harmony or arrangement boundary'})
replacement = copy.deepcopy(old)
replacement['voices'] = copy.deepcopy(template['voices'])
replacement['id'] = 'canonical_' + old['id']
generator.score.reidentify([replacement], [])
replacement['id'] = old['id']
following = sections[si + 1] if si + 1 < len(sections) else None
if j == section.measure_count-1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(authored[0]) if n['pitches']]
if sounding:
cutoff = generator.arrange.pickup.offset(old, authored[0]) + min(sounding)
prefix = [n for n in generator.score.measure_notes(replacement) if generator.model.fraction(n['onset']) < cutoff]
for n in prefix: generator.arrange.pickup.truncate(n, cutoff)
# Preserve original event IDs: incoming melody ties and tuplets refer
# to these exact pickup events, not to a freshly voiced copy.
tail = copy.deepcopy([n for n in generator.score.measure_notes(old) if generator.model.fraction(n['onset']) >= cutoff])
identifier = old['voices'][0]['id']
for n in prefix: n['voice'] = identifier
replacement['voices'] = [{'id': identifier, 'notes': prefix + tail}]
exceptions.append({'measure': old['number'], 'source_measure': authored[source_index]['number'], 'reason': 'protected melody pickup'})
document['staves'][0]['measures'][destination + j] = replacement
destination += section.measure_count
# Build-only diagnostics live outside the published musical schema.
return exceptions
def audit(source, documents):
"""Compare music, not generated IDs, at every reused form position."""
authored = generator.score.source_staff(source)['measures']
result = {}
for part, document in documents.items():
if part == 'vocal': continue
sections = form.full_arrangement_sections(authored, part)
measures = document['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
seen, exceptions, offset, reused = {}, [], 0, 0
for si, section in enumerate(sections):
following = sections[si + 1] if si + 1 < len(sections) else None
for j in range(section.measure_count):
if section.role != 'accompaniment': continue
m = measures[offset+j]
if j == section.measure_count-1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
continue
key = (section.source_measure_index+j, repr(harmonic_key(m, carried[offset+j])))
signature = tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
tuple((generator.model.pitch_number(p),p['tablature']['string'],p['tablature']['fret']) for p in n['pitches']))
for n in generator.score.measure_notes(m))
if key in seen:
if signature != seen[key]:
raise ValueError(f'{part}, arrangement measure {m["number"]}: canonical accompaniment changed')
reused += 1
else:
if any(k[0] == key[0] for k in seen):
exceptions.append({'measure': m['number'], 'reason': 'changed effective harmony or duration'})
seen[key] = signature
offset += section.measure_count
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
return result
+1 -1
View File
@@ -28,7 +28,7 @@ def section_role(part_type: str, section_index: int) -> typing.Literal["accompan
return "slash"
if section_index == 2:
return "melody"
if section_index in {1, 5, 6}:
if section_index in {1, 3, 5, 6}:
return "accompaniment"
return "rest"
return "vocal" if section_index in {1, 5, 6} else "rest"
+11 -8
View File
@@ -5,6 +5,7 @@ import fractions
import pathlib
import generator.accompaniment
import generator.arrange.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.form
@@ -45,15 +46,15 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]:
source = generator.score.source_staff(document)['measures']
body = generator.score.body_start_measure_index(source)
previous = generator.accompaniment.carried_chords(source)
canonical = generator.accompaniment.plan_section(source[body:], rhythms, initial=previous[body], electric=True)
result = {}
sections = generator.arrange.form.full_arrangement_sections(source, 'acoustic')
for index, section in enumerate(sections):
if section.role == 'slash':
continue
measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(measures)
start = section.source_measure_index
result[section.section_index] = generator.accompaniment.plan_section(measures[start:start + section.measure_count], rhythms, initial=previous[start], electric=section.section_index in (1, 5, 6))
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'):
start = section.source_measure_index - body
end = start + section.measure_count
result[section.section_index] = generator.accompaniment.Plan(canonical.acoustic_bass[start:end],
canonical.acoustic_upper[start:end], canonical.electric[start:end])
return result
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan], *, scales: generator.scales.library.Library | None = None, reports: list[str] | None = None, source_name: str = '') -> dict:
@@ -129,6 +130,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
# the complete performance, including its lead-in and repeated choruses.
for number, measure in enumerate(output, 1):
measure['number'] = str(number)
if part_type != 'vocal':
generator.arrange.accompaniment.install(document, result, plans)
generator.fretboard.validate_document(result)
if scales and solos:
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
+16 -61
View File
@@ -4,14 +4,11 @@ import copy
import fractions
import generator.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.accompaniment
import generator.arrange.form
import generator.electric
import generator.fretboard
import generator.guitar
import generator.model
import generator.parts
import generator.score
import generator.solo.analysis
import generator.solo.composer
@@ -41,57 +38,11 @@ def annotate(document, solo, offset):
spans.append({'id': f'{solo.part}_scale_{len(spans) + 1}', 'start': generator.model.rational(offset + choice.span.start), 'end': generator.model.rational(offset + choice.span.end), 'label': label, 'matched': True})
document['staves'][0]['scale_spans'] = spans
def reuse_head_grips(document, references, choices):
previous = generator.accompaniment.carried_chords(references)
target_previous = generator.accompaniment.carried_chords(document['staves'][0]['measures'])
links, absolute = [], fractions.Fraction()
for index, measure in enumerate(document['staves'][0]['measures']):
for note in generator.score.measure_notes(measure):
onset = generator.model.fraction(note['onset'])
chord = generator.accompaniment.active_chord(measure, onset, target_previous[index])
if not note['pitches'] or chord is None:
continue
allowed = generator.solo.types.permitted(choices, absolute + onset, absolute + onset + generator.model.fraction(note['duration']))
matches = []
for candidate in generator.score.measure_notes(references[index]):
if len(candidate['pitches']) < 3:
continue
time = generator.model.fraction(candidate['onset'])
head_chord = generator.accompaniment.active_chord(references[index], time, previous[index])
if generator.accompaniment.same_chord(chord, head_chord) and {int(generator.model.pitch_number(p)) % 12 for p in candidate['pitches']} <= allowed:
matches.append((abs(time - onset), candidate['id'], candidate))
links.append(generator.electric.VoicingLink(note, tuple(item[2] for item in sorted(matches))))
absolute += generator.model.fraction(measure['duration'])
generator.electric.reuse_voicings(links)
def backing(baseline, foreground, part, rhythms, references, choices, initial=None):
plan = generator.accompaniment.plan_section(foreground, rhythms, initial=initial, electric=part == 'electric', acoustic_companions=False)
previous = generator.accompaniment.carried_chords(foreground, initial)
measures = []
for index, measure in enumerate(foreground):
rhythm = (plan.acoustic_bass[index], plan.acoustic_upper[index]) if part == 'acoustic' else plan.electric[index]
result = generator.parts.build_measure(measure, measure['id'], 'accompaniment', part, previous[index], measure.get('section_label', ''), rhythm)
measures.append(result)
generator.score.reidentify(measures, [])
staff = baseline['staves'][0]
document = generator.score.make_arranged_document(baseline, staff, staff_id=staff['id'], staff_name=staff['name'], role='guitar', arrangement_part_type=part, measures=measures)
if part == 'acoustic':
generator.acoustic.prepare(document)
generator.acoustic_voicing.place(document)
generator.acoustic.combine(document)
else:
generator.guitar.place_guitar(document)
reuse_head_grips(document, references, choices)
generator.model.validate_document(document)
return document['staves'][0]['measures']
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True):
"""Compose once per song, replacing only already-placed solo section windows."""
analysis = generator.solo.analysis.analyze(source)
sections = regions(source)
source_measures = generator.score.source_staff(source)['measures']
head_first, head_count, _, _ = sections[2]
head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count]
reports = []
requests = {}
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
@@ -115,17 +66,21 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
generator.solo.notation.replace_region(parts[part], first, foreground)
annotate(parts[part], solo, offset)
# The other guitarist responds to the actual foreground, including vocal return.
if cutoff < length:
vocal = parts['vocal']['staves'][0]['measures'][first:first + count]
time = fractions.Fraction()
for measure, voice_measure in zip(foreground, vocal):
returning = [copy.deepcopy(event) | {'voice': 'vocal_return'} for event in generator.score.measure_notes(voice_measure) if event['pitches'] and time + generator.model.fraction(event['onset']) >= cutoff]
if returning:
measure['voices'].append({'id': 'vocal_return', 'notes': returning})
time += generator.model.fraction(measure['duration'])
accompaniment = backing(parts[other], foreground, other, rhythms, head, solo.choices, initial)
generator.solo.notation.replace_region(parts[other], first, accompaniment)
# The other instrument already contains its canonical accompaniment.
# Installing a solo must never rewrite the learned supporting part.
# Canonical supporting notes must remain compatible throughout every
# overlapped mapped span, independent of the foreground realization.
elapsed = fractions.Fraction()
for measure in parts[other]['staves'][0]['measures'][first:first+count]:
for event in generator.score.measure_notes(measure):
start = elapsed + generator.model.fraction(event['onset'])
end = min(cutoff, start + generator.model.fraction(event['duration']))
if start >= end: continue
allowed = generator.solo.types.permitted(solo.choices,start,end)
if any(int(generator.model.pitch_number(p)) % 12 not in allowed for p in event['pitches']):
raise ValueError(f'{other} accompaniment, measure {measure["number"]}: canonical voicing outside solo map')
elapsed += generator.model.fraction(measure['duration'])
solo.diagnostics['canonical_accompaniment'] = generator.arrange.accompaniment.audit(source, parts)
reports.append(generator.solo.report.describe(solo, scales, first, offset))
for document in parts.values():
generator.model.validate_document(document)
+56 -9
View File
@@ -125,7 +125,7 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
view = excerpt(parts[solo.part], first + begin, stop - begin)
local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
placed = generator.solo.placement.place(local, solo.tempo)
placed = generator.solo.placement.place(local, solo.tempo, profile=solo.profile)
if placed is None:
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
@@ -135,11 +135,11 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
song['solos'][solo.part]['draft_documents'] = drafts
stages = {}
for stage_name in ('initial', 'refined', 'before_enclosure'):
for stage_name in ('initial', 'refined', 'before_rhythm', 'before_enclosure'):
if not all(getattr(p, stage_name) for p in solo.phrases):
continue
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
placed = generator.solo.placement.place(notes, solo.tempo)
placed = generator.solo.placement.place(notes, solo.tempo, profile=solo.profile if stage_name != 'initial' else None)
if placed is None:
raise ValueError(stage_name + ': selected theme cannot be placed')
sounded = generator.solo.types.sounding_notes(placed[1])
@@ -212,6 +212,38 @@ def mix(documents, tempo, root, cache):
output[frame] += gain * release * values[position] / 32767
return output
def register_control(baseline,song,part):
"""Whole-phrase octave transfers of the preserved baseline, never new tunes."""
from generator.solo import profiles,types,placement,notation
detail=song['solos'][part]
raw=detail['structure'].get('instrument_profile')
if not raw:return None
profile=profiles.Instrument(raw['part'],tuple(raw['strings']),raw['reference'],tuple(raw['melody_range']))
result=[]
for phrase in baseline['solos'][part]['structure']['phrases']:
notes=[]
for n in phrase['draft']['notes']:
p=n['pitch']
notes.append(types.Note(fractions.Fraction(n['start']),fractions.Fraction(n['end']),
types.Pitch(p['letter'],fractions.Fraction(p['alter']),p['octave']),tuple(n['sources']),n['role'],
continuation=n['continuation'],event_id=n['event_id']))
options=[]
for octave in range(-2,3):
moved=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+octave)) for n in notes)
if not all(profile.permits(n.pitch.number()) for n in moved):continue
placed=placement.place(moved,fractions.Fraction(detail['tempo']),result[-1] if result else None,profile)
if placed:
rank=profile.cost(profiles.weighted_center(moved))+.02*abs(octave)+.001*placed[0]/max(1,len(moved))
options.append((rank,octave,placed[1]))
if not options:raise ValueError(part+': no whole-phrase register control within the instrument limits')
result.extend(min(options,key=lambda x:x[:2])[2])
view=excerpt(song['parts'][part],detail['first'],detail['count'])
notation.replace_region(view,0,notation.measures(view['staves'][0]['measures'],types.sounding_notes(result),'register_control_'+part))
generator.model.validate_document(view)
generator.fretboard.validate_document(view)
return view
def render(root):
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
@@ -220,6 +252,7 @@ def render(root):
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
for song_name, song in snapshot['songs'].items():
baseline = snapshots[stages[0]]['songs'][song_name]
comparison = snapshots[stages[-1]]['songs'][song_name]
if song['source_sha256'] != baseline['source_sha256']:
raise ValueError(f'{song_name}: source changed between audition stages')
for part, solo in song['solos'].items():
@@ -234,7 +267,7 @@ def render(root):
if 'structure' in solo:
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
for responsive in [False, True] if stage == stages[-1] else [False]:
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
backing = excerpt((song if responsive else comparison)['parts'][other], first, count)
vocal = excerpt(baseline['parts']['vocal'], first, count)
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
@@ -244,7 +277,7 @@ def render(root):
strict = excerpt(solo['lead_stages']['before_enclosure']['document'], offset, count)
strict_prefix=prefix.with_name(prefix.name+'_strict')
generator.musicxml_writer.write_musicxml(strict_prefix.with_suffix('.musicxml'),strict)
backing=excerpt(baseline['parts'][other],first,count)
backing=excerpt(comparison['parts'][other],first,count)
vocal=excerpt(baseline['parts']['vocal'],first,count)
path=strict_prefix.with_name(strict_prefix.name+'_fixed').with_suffix('.wav')
clips.append((path,mix((strict,backing,vocal),fractions.Fraction(solo['tempo']),root,cache)))
@@ -257,7 +290,7 @@ def render(root):
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
plain_prefix = prefix.with_name(prefix.name + '_plain')
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
backing = excerpt(baseline['parts'][other], first, count)
backing = excerpt(comparison['parts'][other], first, count)
vocal = excerpt(baseline['parts']['vocal'], first, count)
path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav')
clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)))
@@ -267,11 +300,21 @@ def render(root):
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache)))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label})
if stage == 'after':
for control, document in (('register_control', register_control(baseline,song,part)),
('before_rhythm',solo.get('lead_stages',{}).get('before_rhythm',{}).get('document'))):
if document is None: continue
prefix=root/stage/f'{song_name}_{part}_{control}'
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'),document)
backing=excerpt(comparison['parts'][other],solo['first'],solo['count'])
path=prefix.with_suffix('.wav')
clips.append((path,mix((document,backing),fractions.Fraction(solo['tempo']),root,cache)))
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':control,'window':'full'})
for draft in solo.get('draft_documents', ()):
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
if draft['first'] < 8:
backing = excerpt(baseline['parts'][other], solo['first'] + draft['first'], draft['count'])
backing = excerpt(comparison['parts'][other], solo['first'] + draft['first'], draft['count'])
path = prefix.with_suffix('.wav')
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
@@ -334,16 +377,20 @@ def write_index(root, snapshots):
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def write_passage_index(root, snapshots):
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. Fixed comparisons retain the original backing. Responsive comparisons include accompaniment recomposed around the new foreground. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. All fixed comparisons use the final canonical backing. The published arrangement reuses that same learned accompaniment throughout the form. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
for name, song in snapshots['after']['songs'].items():
for part in song['solos']:
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
for stage in ('before', 'after'):
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
for control,label in (('register_control','Original phrases with register/string changes only'),('before_rhythm','Selected strict theme before rhythmic revision')):
if control=='before_rhythm' and control not in song['solos'][part].get('lead_stages',{}):continue
prefix=f'after/{name}_{part}_{control}'
body.append(f'<p>{label}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">Notation</a></p>')
strict_available='before_enclosure' in song['solos'][part].get('lead_stages',{})
plain_available='refined' in song['solos'][part].get('lead_stages',{})
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>Final canonical arrangement</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
for window in ('full', 'opening8', 'closing4'):
body.append(f'<tr><th>{window}</th>')
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
+3 -1
View File
@@ -5,6 +5,7 @@ import fractions
import time
import generator.solo.enclosures
import generator.solo.profiles
import generator.fretboard
import generator.harmony
@@ -129,10 +130,11 @@ def choose_phrases(plans, realize, revise_pair=None):
def validate(solo):
previous = None
profile = solo.profile or generator.solo.profiles.for_part(solo.part)
for note in solo.notes():
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
if note.position not in generator.solo.placement.positions(note.pitch.number(), profile):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note
for phrase in solo.phrases:
+19 -7
View File
@@ -34,7 +34,7 @@ def approach(target, harmony):
letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0)
alter=number-1-generator.model.NATURAL[letter]-12*(octave+1)
lower=types.Pitch(letter,F(alter),octave)
if not all(placement.positions(p.number()) for p in (upper,lower)):return None
if not all(placement.positions(p.number(),harmony.profile) for p in (upper,lower)):return None
identity=target.event_id+':enclosure'
return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'),
types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower'))
@@ -113,22 +113,34 @@ def candidates(phrase,harmony,library,tempo,rejections):
for begin,replacement,route in proposals[:1+policy.DEFAULT.enclosure_routes]:
actual=replace_window(notes,begin,target.start,replacement)
if actual is None:continue
variants=[(actual,contract)]
upper_index=next((i for i,n in enumerate(actual) if n.event_id==upper.event_id),0)
if upper_index:
prior=actual[upper_index-1]
if prior.end==upper.start and prior.pitch==upper.pitch and editable(draft,prior.start,upper.start):
shared=dataclasses.replace(prior,end=upper.end)
combined=(*actual[:upper_index-1],shared,*actual[upper_index+1:])
variants.append((combined,dataclasses.replace(contract,upper=shared,approach_start=upper.start)))
for actual,actual_contract in variants:
valid,error=drafts.contract_valid(draft,actual)
if not valid:continue
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(contract,))
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(actual_contract,))
if not checked.valid:continue
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
if not valid:continue
placed=placement.place(actual,tempo)
placed=placement.place(actual,tempo,profile=harmony.profile)
if placed is None:continue
old_travel=placement.place(notes,tempo)[0]
old_travel=placement.place(notes,tempo,profile=harmony.profile)[0]
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(actual,actual[1:]))
old_repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(notes,notes[1:]))
delta+=.15*max(0,repeated-old_repeated)
delta+=.1*(max(0,density-6)-max(0,before_density-6))
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(contract,))
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(actual_contract,))
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
if tuple(n for d in decisions for n in d.notes)!=actual:continue
retained=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
@@ -152,7 +164,7 @@ def strict(solo):
def refine(solo,library,enabled=True):
started=time.monotonic();harmony=harmonic.Harmony(solo.choices);rejections=[]
started=time.monotonic();harmony=harmonic.Harmony(solo.choices,solo.profile);rejections=[]
baseline=[]
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes)) if phrase.draft else None
@@ -179,7 +191,7 @@ def refine(solo,library,enabled=True):
_,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))
# Resolve the complete guitar path once, including both sides of phrase joins.
raw=tuple(n for p in phrases for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo)
placed=placement.place(raw,solo.tempo,profile=harmony.profile)
if placed is None:raise ValueError('selected enclosure solo has no complete guitar path')
by_id={n.event_id:n for n in placed[1]};final=[]
for phrase in phrases:
+52
View File
@@ -0,0 +1,52 @@
"""Bounded timing revisions of complete realized gestures, with fixed pitches."""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator.solo import drafts, harmonic, types
def fragmentation(notes):
isolated = sum(n.end-n.start <= F(1,2) and a.end < n.start and n.end < b.start
for a,n,b in zip(notes, notes[1:], notes[2:]))
gaps = sum(0 < b.start-a.end <= 1 for a,b in zip(notes, notes[1:]))
return isolated + .1*gaps
def revise(draft, harmony, treatments=()):
notes=draft.notes; edits=[]
if len(notes)<3: return draft, ()
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
# An identifying rhythm also protects its internal rests.
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
for _ in range(2):
proposals=[]
for i,(a,n,b) in enumerate(zip(notes,notes[1:],notes[2:]),1):
if n.event_id in protected or n.end-n.start>1: continue
if not (a.end<n.start or n.end<b.start): continue
if a.event_id not in protected and 0<n.start-a.end<=1:
proposals.append((*notes[:i-1],dataclasses.replace(a,end=n.start),*notes[i:]))
if 0<b.start-n.end<=1:
proposals.append((*notes[:i],dataclasses.replace(n,end=b.start),*notes[i+1:]))
for delta in (F(-1,2),F(1,2)):
if a.end<=n.start+delta and n.end+delta<=b.start:
proposals.append((*notes[:i],dataclasses.replace(n,start=n.start+delta,end=n.end+delta),*notes[i+1:]))
if len(proposals)>=8: break
valid=[]
for actual in proposals[:8]:
if fragmentation(actual)>=fragmentation(notes): continue
if not drafts.contract_valid(draft,actual)[0]: continue
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid: continue
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]: continue
valid.append(actual)
if not valid: break
actual=min(valid,key=lambda ns:(fragmentation(ns),tuple((n.start,n.end,n.pitch.number()) for n in ns)))
changed=[(a,b) for a,b in zip(notes,actual) if (a.start,a.end)!=(b.start,b.end)]
edits.append(types.DraftEdit('connected_rhythm',min(min(a.start,b.start) for a,b in changed),
max(max(a.end,b.end) for a,b in changed),tuple(a.event_id for a,b in changed),
tuple(b.event_id for a,b in changed),'same pitch sequence and attack count; connect an editable gesture'))
notes=actual
transforms=tuple(dataclasses.replace(t,rhythm=tuple((n.start-t.start,n.end-t.start) for n in notes if n.event_id in t.events))
if not t.preserves else t for t in draft.transformations)
return dataclasses.replace(draft,notes=notes,transformations=transforms),tuple(edits)
+7 -3
View File
@@ -49,7 +49,9 @@ class Result:
error: str = ''
class Harmony:
def __init__(self, choices):
def __init__(self, choices, profile=None):
from generator.solo.profiles import UNRESTRICTED
self.profile = profile or UNRESTRICTED
self.choices = choices
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
self._at = {}
@@ -110,7 +112,8 @@ class Harmony:
actual = events.get(expected.event_id)
if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch):
return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id
if upper.end-upper.start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
approach_start = contract.approach_start if contract.approach_start is not None else upper.start
if not upper.start <= approach_start or upper.end-approach_start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target'
if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1:
return {}, contract.id + ': enclosure does not surround the exact target register'
@@ -192,7 +195,8 @@ class Harmony:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
covered = end
function = self.function(number, span)
if function == 'basic' or note.role == 'quote' or enclosure:
brief_enclosure = enclosure and (exception or enclosure.approach_start is None or enclosure.approach_start == note.start)
if function == 'basic' or note.role == 'quote' or brief_enclosure:
continue
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
+7 -7
View File
@@ -10,7 +10,7 @@ import fractions
import generator.model
import generator.fretboard
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references, profiles
F = fractions.Fraction
@@ -31,7 +31,7 @@ def available(harmony, start, end, *, members=False):
allowed &= harmony.members(start, end)
if not span or not span.scale:
return ()
return tuple(p for i in range(-35, 28) if 41 <= (p := types.scale_pitch(span.scale, i)).number() <= 76 and p.number()%12 in allowed)
return tuple(p for i in range(-35, 28) if harmony.profile.register[0] <= (p := types.scale_pitch(span.scale, i)).number() <= harmony.profile.register[1] and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
def adapt(notes, harmony):
@@ -324,7 +324,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
from generator.solo import realization,themes,phrasing
context.resources[id(library)]=library
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices)
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
states=[(0.,())]
failures=[]
@@ -344,7 +344,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
meter=measure['time_signature'];break
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
for source in sources:
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT)
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT,harmony.profile)
if key in context.results:
pool=context.results[key];context.diagnostics['cache_hits']+=1
else:
@@ -383,7 +383,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
{**context.diagnostics,'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in finished)
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in finished)
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
@@ -393,7 +393,7 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
context.resources[id(library)]=library
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices)
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
sources=clipped_sources(analysis,end)
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
states=[(0.,())]
@@ -424,7 +424,7 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states)
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in states)
def similarity(first,second):
+38 -10
View File
@@ -7,7 +7,7 @@ from fractions import Fraction as F
import generator.model
import generator.harmony
from generator.solo import types, policy, sequences
from generator.solo import types, policy, sequences, profiles
def heard(notes, operation, anchor=None):
@@ -52,7 +52,11 @@ def repetition(figure, history, bar):
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
def register_goal(incoming, part, fallback):
def register_goal(incoming, part, fallback, profile=None):
if profile and profile.reference is not None:
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
center = round(sum(recent)/len(recent)) if recent else profile.reference
return max(profile.reference-4,min(profile.reference+4,center))
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
if not recent:
return fallback
@@ -76,7 +80,7 @@ def endpoint_cost(finish, start, end, count, harmony, bar):
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
dominant=degrees.get(3)==0 and degrees.get(7)==-1
short_remainder=.2*max(0,4-remaining) if remaining else 0.
return pacing+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
return pacing+(.08 if ending_effect(finish-F(1,100),harmony,False)=='local_settle' else 0)+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
@@ -105,11 +109,11 @@ def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
def endings(source, incoming, part, harmony, bar, *, limit=8):
def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
"""Choose pitch AND sounding interval before generating the approach."""
from generator.solo.leads import available
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
goal=register_goal(incoming,part,base)
goal=register_goal(incoming,part,base,harmony.profile)
values=[]
duration=source.end-source.start
holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),)
@@ -120,10 +124,10 @@ def endings(source, incoming, part, harmony, bar, *, limit=8):
for pitch in available(harmony,arrival,arrival+hold,members=True):
if abs(pitch.number()-goal)>12:continue
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
harmony.members(arrival,arrival+hold),(41,76),hold,'terminal',harmony.at(arrival).chord,
hold_until=arrival+hold)
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord,
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
rank=destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
values.append((rank,anchor))
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
@@ -158,7 +162,7 @@ def evaluate(notes, incoming, harmony, start, end, bar):
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
return 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
return harmony.profile.cost(profiles.weighted_center(notes))+2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
@functools.lru_cache(maxsize=512)
@@ -197,7 +201,8 @@ def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, termi
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
return function+register+entry+recurrence+lookahead+direction
pending=.08 if incoming and incoming.next_destination and pc not in incoming.next_destination else 0.
return function+register+entry+recurrence+lookahead+direction+pending
def destination_family(pitch, time, harmony):
@@ -217,3 +222,26 @@ def destination_options(values, rank, pitch, time, harmony, limit):
if value not in selected:selected.append(value)
if len(selected)==limit:break
return tuple(selected)
def ending_effect(time, harmony, final=False):
if final: return 'handoff'
current=harmony.at(time)
previous=next((s for s in reversed(harmony.spans) if current and s.end<=current.start and s.chord!=current.chord and s.scale),None)
if not current or not current.scale or not previous: return 'local_settle'
old=generator.harmony.parse_symbol(previous.chord)
new=generator.harmony.parse_symbol(current.chord)
degrees=generator.harmony.degrees(old)
def root(chord):
p=chord['root'];return int(generator.model.NATURAL[p['letter']]+generator.model.alteration(p['alter']))%12
if degrees.get(3)==0 and degrees.get(7)==-1 and (root(old)-root(new))%12==7:
return 'cadential_arrival'
return 'local_settle'
def next_destination(anchor,harmony):
if anchor.effect!='local_settle': return ()
following=[s for s in harmony.spans if s.start>=anchor.hold_until and s.scale]
resolution=next((s for s in following[:2] if ending_effect(s.start,harmony)=='cadential_arrival'),None)
selected=resolution or next(iter(following),None)
return tuple(sorted(selected.roles.basic & selected.scale.pitches)) if selected else ()
+9 -7
View File
@@ -6,6 +6,7 @@ import functools
import generator.fretboard
import generator.solo.types
from generator.solo.profiles import UNRESTRICTED
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
if previous is None:
@@ -33,12 +34,12 @@ def stable_path(path):
@functools.lru_cache(maxsize=4096)
def alternatives(notes, tempo, previous=None, limit=2):
def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED):
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
for note in notes:
following = {}
for position in positions(note.pitch.number()):
for position in positions(note.pitch.number(), profile):
current = dataclasses.replace(note, position=position)
for hand in hand_positions(position):
best = None
@@ -73,10 +74,11 @@ def alternatives(notes, tempo, previous=None, limit=2):
@functools.lru_cache(maxsize=4096)
def place(notes, tempo, previous=None):
result = alternatives(notes,tempo,previous,1)
def place(notes, tempo, previous=None, profile=UNRESTRICTED):
result = alternatives(notes,tempo,previous,1,profile)
return result[0] if result else None
@functools.lru_cache(maxsize=128)
def positions(number: int):
return generator.fretboard.single_positions_for_pitch(fractions.Fraction(number))
@functools.lru_cache(maxsize=2048)
def positions(number: int, profile=UNRESTRICTED):
profile = profile or UNRESTRICTED
return tuple(p for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)) if p.string_number in profile.strings)
+1 -1
View File
@@ -52,7 +52,7 @@ class Policy:
enclosure_routes: int = 4
enclosure_frontier: int = 8
enclosure_max_per_32_bars: int = 4
composition_version: str = 'chord-destinations-enclosures-1'
composition_version: str = 'six-parts-register-signatures-1'
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+55
View File
@@ -0,0 +1,55 @@
"""Sounding-register references and hard solo instrument domains."""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
import generator.fretboard
@dataclasses.dataclass(frozen=True)
class Instrument:
part: str = ''
strings: tuple[int, ...] = (1, 2, 3, 4, 5, 6)
reference: int | None = None
melody_range: tuple[int, int] | None = None
@property
def register(self):
tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings]
return min(tuning) + 1, max(tuning) + 12
def permits(self, number):
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
def cost(self, number):
return min(1., max(0, abs(number - self.reference) - 4) / 12) if self.reference is not None else 0.
UNRESTRICTED = Instrument()
def weighted_center(notes):
values = sorted((n.pitch.number(), n.end - n.start) for n in notes)
total = sum((d for _, d in values), F())
elapsed = F()
for number, duration in values:
elapsed += duration
if elapsed >= total / 2:
return number
return 60
def for_part(part, analysis=None):
if part not in ('acoustic', 'electric'):
raise ValueError('unknown solo instrument: ' + part)
strings = (2, 3, 4, 5, 6) if part == 'acoustic' else (1, 2, 3, 4)
if analysis is None or not analysis.melody:
return Instrument(part, strings)
# Analysis contains the prepared concert source. Electric melody is written
# two octaves up and sounds one down (parts.build_measure), hence +12.
# Use the source voice, never the highest added harmonization voice.
shift = 12 if part == 'electric' else 0
notes = tuple(n for n in analysis.melody if n.end > analysis.body_offset)
return Instrument(part, strings, weighted_center(notes) + shift,
(min(n.pitch.number() for n in notes) + shift, max(n.pitch.number() for n in notes) + shift))
+1 -1
View File
@@ -501,7 +501,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
if not valid:
return None
placed = generator.solo.placement.place(notes, tempo, incoming.last)
placed = generator.solo.placement.place(notes, tempo, incoming.last, harmony.profile)
if placed is None:
return None
travel, notes = placed
+5 -4
View File
@@ -14,6 +14,7 @@ import generator.solo.harmonic
import generator.solo.drafts
import generator.solo.similarity
import generator.solo.references
import generator.solo.placement
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
@@ -58,7 +59,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
for anchor in phrase.anchors:
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
lines.append(f' Anchor {anchor.id}: {anchor.role}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
lines.append(f' Anchor {anchor.id}: {anchor.role}/{anchor.effect}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
if phrase.state:
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
@@ -124,7 +125,7 @@ def structure(solo):
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
entry['score'] = phrase.score
phrases.append(entry)
return {'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
return {'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
def phrasing_report(solo):
@@ -146,7 +147,7 @@ def phrasing_report(solo):
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
note=generator.solo.harmonic.anchor_note(anchor,phrase.state.notes)
endings.append({'phrase':phrase.plan.id,'arrival':str(note.start) if note else None,'pitch':anchor.pitch.label(),
'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
'effect':anchor.effect,'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
'release':str(phrase.plan.release),'completed':note is not None})
if local:
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
@@ -180,7 +181,7 @@ def fretboard_report(solo):
string=note.position.string_number
attacks[string]+=1;durations[string]+=float(note.end-note.start)
if string==1:
if len(generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())))==1:forced+=1
if len(generator.solo.placement.positions(note.pitch.number(),solo.profile))==1:forced+=1
else:optional+=1
total=sum(durations.values())
return {'attacks_by_string':dict(sorted(attacks.items())),
+10 -6
View File
@@ -33,7 +33,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
harmony.route_libraries={}
harmony.concrete_routes={}
harmony.route_libraries[id(library)]=library
concrete_key=(notes,frozenset(fixed),id(library),register)
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile)
if concrete_key in harmony.concrete_routes:
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
# Event/source IDs and non-fixed placeholder pitches do not affect route
@@ -42,7 +42,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
key=(canonical,fixed_indices,id(library),register)
key=(canonical,fixed_indices,id(library),register,harmony.profile)
if key not in harmony.route_cache:
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
result=[]
@@ -76,9 +76,9 @@ def _candidates(notes, fixed, harmony, library, register):
types.permitted(harmony.choices,n.start,n.end),
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
key=(first.pitch,contexts,id(library),register)
key=(first.pitch,contexts,id(library),register,harmony.profile)
if key not in harmony.pitch_routes:
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register)
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register,harmony.profile)
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
@@ -91,12 +91,15 @@ def _candidates(notes, fixed, harmony, library, register):
return tuple(result)
def pitch_paths(first, contexts, library, register):
def pitch_paths(first, contexts, library, register, profile=None):
"""Time-independent pitch work; context includes every overlapped collection.
Sounding durations and chord roles are still validated on each concrete
realization. Equal collection contexts can reuse paths across rhythmic edits.
"""
from generator.solo.profiles import UNRESTRICTED
profile = profile or UNRESTRICTED
if not profile.permits(first.number()): return ()
destination=contexts[-1][2]
try:
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
@@ -106,6 +109,7 @@ def pitch_paths(first, contexts, library, register):
direction=(destination>first.number())-(destination<first.number())
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
low, high = max(low, profile.register[0]), min(high, profile.register[1])
result=[]
for path in paths:
realized=[first]
@@ -116,7 +120,7 @@ def pitch_paths(first, contexts, library, register):
pitch=options[0];number=pitch.number()
if not low<=number<=high or number%12 not in allowed:break
if fixed is not None and number!=fixed:break
if not generator.solo.placement.positions(number):break
if not generator.solo.placement.positions(number,profile):break
realized.append(pitch)
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
return tuple(result)
+40 -29
View File
@@ -10,7 +10,7 @@ from fractions import Fraction as F
import generator.model
import generator.harmony
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing, profiles
def source_notes(analysis):
@@ -22,7 +22,7 @@ def source_notes(analysis):
def silence(source,incoming,part,harmony,tempo,seed):
from generator.solo import realization
motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony')
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,(41,76),F(),F(),
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,harmony.profile.register,F(),F(),
(types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end)
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
@@ -43,10 +43,10 @@ def short_fragment(source,incoming,part,harmony,tempo,seed,final):
for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True):
note=dataclasses.replace(template,pitch=pitch)
motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity')
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),(41,76),note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),harmony.profile.register,note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=())
draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference)
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,(41,76),F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,harmony.profile.register,F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
if phrase:
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
@@ -194,7 +194,7 @@ def instantiate(templates,first,steps,harmony):
options=routes.advance(span.scale,result[-1].pitch.number(),step)
if not options:return ()
p=options[0]
if not 41<=p.number()<=76 or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
if not harmony.profile.permits(p.number()) or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
result.append(dataclasses.replace(note,pitch=p))
return tuple(result) if len(result)==len(templates) else ()
@@ -222,11 +222,20 @@ def _prefix_paths(templates,ref,first,harmony,library):
result.append(dataclasses.replace(template,pitch=p))
return (tuple(result),)
paths=[]
if ref.treatment == 'return':
from generator.solo.leads import available
delta=first.number()-original[0].pitch.number()
direct=[]
for template,n in zip(templates,original):
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==n.pitch.number()+delta),None)
if p is None: break
direct.append(dataclasses.replace(template,pitch=p))
if len(direct)==count: paths.append(tuple(direct))
end_template=templates[count-1]
source_motion=original[-1].pitch.number()-original[0].pitch.number()
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,(41,76),'seed '+ref.id))
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'seed '+ref.id))
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
return sequences.diverse(valid,lambda ns:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)),
lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4)
@@ -247,7 +256,7 @@ class Unit:
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
# History is evaluated by the caller after retrieving these concrete options.
# Every state component used to construct this pool is in its cache key.
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices)
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',(harmony.choices,harmony.profile))
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
if key in context.results:
context.diagnostics['cache_hits']+=1;return context.results[key]
@@ -257,14 +266,14 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
if len(pattern)<count or len(pattern)>16:continue
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources),
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
base=incoming.last.pitch.number() if incoming.last else ref.notes[0].pitch.number()
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference if harmony.profile.reference is not None else ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
for first in firsts:
for target in targets:
# Rank joint jobs, retaining rhythm and destination coverage before
# expensive path instantiation. The budget is spent on complete routes.
rank=max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
rank=harmony.profile.cost(first.number())+max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
jobs.append((rank,templates,label,first,target))
jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))))
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set()
@@ -292,7 +301,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
continue
tail=(prefix[-1],*templates[count:])
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,(41,76),operation+' '+ref.id)[:3+batch]:
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,harmony.profile.register,operation+' '+ref.id)[:3+batch]:
proposed.append(((*prefix[:-1],*route.notes),(route,)))
seen=set()
for actual,connections in proposed:
@@ -309,8 +318,8 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1)
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
# Activity belongs to the complete phrase; contracts are pass/fail.
score=2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register,
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
@@ -322,17 +331,14 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
def home_reference(home,operation):
# Prefer a distinguishing turn/leap wherever it actually occurred. The heard
# home remains immutable; a development never becomes the new source theme.
groups=[home.notes[i:i+3] for i in range(max(1,len(home.notes)-2))]
def rank(group):
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])]
turn=len(intervals)>1 and intervals[0]*intervals[1]<0
return (-int(turn),-max((abs(x) for x in intervals),default=0),group[0].start)
notes=min(groups,key=rank)
# A return refers to the opening the listener heard, not whichever later
# three-note turn happens to rank best. Development may use its smaller core.
notes = home.signature or home.notes[:min(4, len(home.notes))]
if operation not in ('return', 'repeat_with_harmonic_adaptation'):
notes = notes[:3]
origin=notes[0].start
notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes)
treatment='rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','return','develop_rhythm') else 'contour_seed'
treatment='return' if operation=='return' else 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','develop_rhythm') else 'contour_seed'
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
@@ -341,7 +347,7 @@ def make_home(unit,harmony,start=None,end=None):
start=unit.notes[0].start if start is None else start
identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16]
home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony)
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony))
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)])
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
@@ -355,7 +361,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
seed_key=('source-seeds',analysis,bar,seed)
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
roots=context.pools[seed_key]
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1))
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1),final=final)
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
cursor=source.start
while cursor<source.end and states:
@@ -447,13 +453,18 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
landmark=types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sustained destination')
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,(41,76),F(5,4),
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
source.id,source.start,source.end,(anchors,))
before_rhythm=draft
from generator.solo import gesture_rhythm
if stage=='elaborated':
draft,rhythm_edits=gesture_rhythm.revise(draft,harmony,incoming.treatments)
notes=draft.notes;edits=(*edits,*rhythm_edits);transforms=draft.transformations
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
if phrase is None:continue
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number()),u.anchor.role=='terminal') for u in units)/len(units)
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)+.1*gesture_rhythm.fragmentation(notes)
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number(),harmony.profile),u.anchor.role=='terminal') for u in units)/len(units)
appearance_state=incoming
for unit in units:
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
@@ -464,16 +475,16 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
for transform in transforms:
group=tuple(n for n in notes if n.event_id in transform.events)
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None),bar)
current=dataclasses.replace(phrase.state,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony),home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
history=history,returns=state.returns,closures=incoming.closures+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
if not incoming.home:
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
degree_steps=relative_steps(introduced,harmony))
degree_steps=relative_steps(introduced,harmony),signature=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced[:len(units[0].reference.notes)]))
current=dataclasses.replace(current,home=heard,themes=(heard,))
source_control=leads.shift(source.notes,next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
return sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:(p.state.home.id,p.state.last.pitch.number(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
+7
View File
@@ -8,6 +8,7 @@ import generator.fretboard
import generator.model
import generator.scales.library
import generator.scales.selection
import generator.solo.profiles
@dataclasses.dataclass(frozen=True, order=True)
class Pitch:
@@ -116,6 +117,7 @@ class Anchor:
chord: str
onset_required: bool = False
hold_until: fractions.Fraction | None = None
effect: str = 'local_settle'
@dataclasses.dataclass(frozen=True)
class Gesture:
@@ -136,6 +138,7 @@ class RealizedMotif:
chord_degrees: tuple[tuple[int, ...], ...] = ()
source_start: fractions.Fraction = fractions.Fraction()
degree_steps: tuple[int, ...] = ()
signature: tuple[Note, ...] = ()
def contour(self):
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@@ -163,6 +166,7 @@ class ContinuationState:
returns: tuple[fractions.Fraction, ...] = ()
closures: int = 0
register_peak: int = 0
next_destination: tuple[int, ...] = ()
@dataclasses.dataclass(frozen=True)
class HeardFigure:
@@ -243,6 +247,7 @@ class EnclosureContract:
chord: str
target_role: str
protected_tail: SustainedTail
approach_start: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class MelodicDraft:
@@ -343,6 +348,7 @@ class Phrase:
refined: MelodicDraft | None = None
routes: tuple[RouteCandidate, ...] = ()
before_enclosure: MelodicDraft | None = None
before_rhythm: MelodicDraft | None = None
@dataclasses.dataclass(frozen=True)
class Solo:
@@ -353,6 +359,7 @@ class Solo:
end: fractions.Fraction
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
passages: tuple[MelodicPassagePlan, ...] = ()
profile: generator.solo.profiles.Instrument | None = None
def notes(self) -> tuple[Note, ...]:
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
+1 -1
View File
@@ -184,7 +184,7 @@ class ScaleOutputTests(unittest.TestCase):
def test_only_soloing_part_is_annotated_without_revoicing(self):
content = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
roles = {'acoustic': ('slash', 'accompaniment'), 'electric': ('rest', 'slash'), 'vocal': ('rest', 'rest')}
roles = {'acoustic': ('slash', 'accompaniment'), 'electric': ('accompaniment', 'slash'), 'vocal': ('rest', 'rest')}
for part in ('acoustic', 'electric', 'vocal'):
before = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library())
+108
View File
@@ -0,0 +1,108 @@
import copy
import dataclasses
from fractions import Fraction as F
import unittest
import generator.arrange.generate
import generator.arrange.form
import generator.model
import generator.parts
import generator.solo.arrangement
from generator.solo import analysis, profiles, placement, themes, types, harmonic, gesture_rhythm, phrasing
from tests.test_solo import song
from tests.test_solo_development import note
from tests.test_solo_passages import choices
import tests.rhythm_fixtures
def events(measure):
return tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
tuple((p['letter'],p['alter']['numerator'],p['octave'],p['tablature']['string'],p['tablature']['fret']) for p in n['pitches']))
for v in measure['voices'] for n in v['notes'])
class ReusableAccompaniment(unittest.TestCase):
def test_corresponding_bars_repeat_rhythm_pitch_and_fingering(self):
source=song(count=12,chords=('CM7','Dm7','G7'))
library=tests.rhythm_fixtures.library()
prepared=generator.parts.prepare_document(source)
plans=generator.arrange.generate.plan_arrangement(prepared,library)
for part in ('acoustic','electric'):
result=generator.arrange.generate._arrange_document(prepared,part,plans)
seen={};offset=0;checked=0
sections=generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'],part)
for section in sections:
for j,m in enumerate(result['staves'][0]['measures'][offset:offset+section.measure_count]):
if section.role!='accompaniment':continue
key=section.source_measure_index+j
if key in seen:
self.assertEqual(events(m),seen[key]);checked+=1
seen[key]=events(m)
self.assertFalse(any('tie_from' in p or 'tie_to' in p for v in m['voices'] for n in v['notes'] for p in n['pitches']))
offset+=section.measure_count
self.assertGreater(checked,12)
generator.model.validate_document(result)
def test_excerpt_rhythms_retain_form_phase(self):
source=generator.parts.prepare_document(song(count=13,chords=('CM7',)))
plans=generator.arrange.generate.plan_arrangement(source,tests.rhythm_fixtures.library())
for lane in ('acoustic_bass','acoustic_upper','electric'):
full=getattr(plans[1],lane)
self.assertEqual(getattr(plans[0],lane),full[-8:])
self.assertEqual(getattr(plans[6],lane),full[-8:])
self.assertEqual(getattr(plans[3],lane),full)
self.assertEqual(getattr(plans[4],lane),full)
class SoloInstrumentAndIdentity(unittest.TestCase):
def test_role_ranges_and_no_open_strings(self):
acoustic=profiles.for_part('acoustic');electric=profiles.for_part('electric')
self.assertEqual(acoustic.register,(41,71));self.assertEqual(electric.register,(51,76))
self.assertTrue(acoustic.permits(41));self.assertFalse(electric.permits(50))
self.assertFalse(acoustic.permits(72))
for profile in (acoustic,electric):
for midi in range(40,78):
for p in placement.positions(midi,profile):
self.assertIn(p.string_number,profile.strings)
self.assertTrue(1<=p.fret<=12)
self.assertTrue(any(p.string_number==1 for p in placement.positions(65,electric)))
self.assertFalse(any(p.string_number==1 for p in placement.positions(65,acoustic)))
def test_sounding_melody_reference_and_octave_relation(self):
source=analysis.analyze(generator.parts.prepare_document(song(count=4,chords=('CM7',))))
acoustic=profiles.for_part('acoustic',source);electric=profiles.for_part('electric',source)
self.assertEqual(electric.reference-acoustic.reference,12)
self.assertEqual(electric.reference,profiles.weighted_center(source.melody)+12)
self.assertEqual(acoustic.cost(acoustic.reference+4),0)
self.assertGreater(acoustic.cost(acoustic.reference+9),0)
def test_return_keeps_opening_instead_of_later_largest_turn(self):
notes=tuple(note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('d','f','g','f','c','b')))
home=types.RealizedMotif('home','seed',notes,F(6),'introduce','contour',signature=notes[:4])
reference=themes.home_reference(home,'return')
self.assertEqual(reference.notes,notes[:4])
self.assertEqual(reference.treatment,'return')
self.assertEqual(len(themes.home_reference(home,'develop_contour').notes),3)
def test_endings_distinguish_local_cadence_and_handoff(self):
harmony=harmonic.Harmony(choices(('Em7b5','A7','Dm','Dm')))
self.assertEqual(phrasing.ending_effect(F(2),harmony),'local_settle')
self.assertEqual(phrasing.ending_effect(F(9),harmony),'cadential_arrival')
self.assertEqual(phrasing.ending_effect(F(15),harmony,True),'handoff')
anchor=types.Anchor('comma',F(1),F(1),note(0,1,'g').pitch,frozenset({7}),(41,76),F(2),'terminal','Em7b5',hold_until=F(3))
self.assertEqual(set(phrasing.next_destination(anchor,harmony)),{2,5,9})
def test_rhythm_revision_connects_without_changing_attacks_or_arrival(self):
notes=(note(0,1,'g',event_id='a'),note(F(3,2),2,'e',event_id='b'),note(F(5,2),3,'g',event_id='c'),note(3,6,'e',event_id='end'))
anchor=types.Anchor('end',F(3),F(3),notes[-1].pitch,frozenset({4}),(41,76),F(3),'terminal','C7',hold_until=F(6))
draft=types.MelodicDraft('gesture',F(),F(6),notes,(),(),(),(anchor,),None,'develop',sustained_tails=(types.SustainedTail(F(3),F(6),64),))
revised,edits=gesture_rhythm.revise(draft,harmonic.Harmony(choices(('C7','C7'))))
self.assertTrue(edits)
self.assertEqual(tuple(n.pitch for n in revised.notes),tuple(n.pitch for n in notes))
self.assertEqual(revised.notes[-1],notes[-1])
self.assertLess(gesture_rhythm.fragmentation(revised.notes),gesture_rhythm.fragmentation(notes))
protected=dataclasses.replace(draft,protected=tuple(n.event_id for n in notes))
self.assertEqual(gesture_rhythm.revise(protected,harmonic.Harmony(choices(('C7','C7'))))[0].notes,notes)
if __name__=='__main__':unittest.main()
+3
View File
@@ -210,6 +210,9 @@ class SoloTests(unittest.TestCase):
old, new = before[part]['staves'][0]['measures'], parts[part]['staves'][0]['measures']
self.assertEqual(old[:low], new[:low])
self.assertEqual(old[high:], new[high:])
support = 4 if part == 'acoustic' else 3
first, count, _, _ = regions[support]
self.assertEqual(old[first:first+count],new[first:first+count])
for section in (3, 4):
first, count, _, _ = regions[section]
self.assertTrue(any(n['pitches'] for m in new[first:first + count] for n in generator.score.measure_notes(m)))
+12
View File
@@ -24,6 +24,18 @@ def fixture(symbol='C7',target=None,scale=None):
class EnclosureContracts(unittest.TestCase):
def test_existing_upper_can_continue_without_a_new_attack(self):
h,notes,c=fixture('Dm',note(4,8,'d'))
upper=dataclasses.replace(notes[0],start=F(5,2))
contract=dataclasses.replace(c,upper=upper,approach_start=F(3))
self.assertTrue(h.evaluate((upper,*notes[1:]),enclosures=(contract,)).valid)
# Joining a tie must not hide an exposed long non-chord upper neighbor.
long_upper=dataclasses.replace(upper,start=F(1))
invalid=dataclasses.replace(contract,upper=long_upper)
self.assertFalse(h.evaluate((long_upper,*notes[1:]),enclosures=(invalid,)).valid)
split=(dataclasses.replace(upper,end=F(3)),dataclasses.replace(upper,start=F(3),continuation=True,event_id='tie'),*notes[1:])
self.assertEqual(h.evaluate(split,enclosures=(contract,)),h.evaluate((upper,*notes[1:]),enclosures=(contract,)))
def test_worked_examples_and_actual_collection_membership(self):
hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
for symbol,target,scale,expected in (