+9
-7
@@ -190,23 +190,25 @@ Scale labels appear above chord symbols at collection changes and system starts.
|
|||||||
|
|
||||||
### Phrase Composition
|
### Phrase Composition
|
||||||
|
|
||||||
`generator/solo/` is build-only. `analysis.py` extracts one- and two-bar monophonic motifs from the normalized melody, collapsing ties while retaining repeated attacks, leading rests, source event IDs, and harmonic context. `planning.py` selects up to three recurring motif identities and plans statements, returns, variations and answers around two- to four-bar boundaries. Each phrase has active passages, a sustained arrival and optional intentional space, plus entrance, intermediate and arrival register windows. Melody breathing points and harmonic destinations influence phrase boundaries. `types.py` owns the exact rational timing models. No annotations are added to source XML.
|
`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.
|
||||||
|
|
||||||
`sequences.py` validates all 2-, 3- and 4-note paths in `data/sequences`, then retrieves representatives by displacement, direction, shape and destination. Longer paths chain catalog entries through a shared endpoint and report every source. Steps accumulate through octaves, including negative scale indices. Sequence meter describes the source container, not the realized duration. `realization.py` fits paths to attacks from `data/rhythms`, retaining connected eighth-note patterns and slicing at the actual bar offset without stretching time. It also supports single targets, repeated notes, holds, and silence. Thematic contour and harmonic adaptation continue across cells and collection changes; every held pitch must remain permitted for its entire duration.
|
`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.
|
||||||
|
|
||||||
`scoring.py` evaluates complete sounding prefixes against the activity planned so far. Attack density and sounding occupancy are separate, and explicit ties count as one attack. Active or sustained passages cannot disappear through a missing register value; intentional space and no-harmony regions have different obligations. A register corridor charges only pitches outside its bounds. Intermediate landmarks are evaluated once their windows close. Duration-normalized, bounded penalties use the curated weights in `policy.py`: activity 4, occupancy 4, motif fidelity 3, landmarks 3, register/contour/connection/repetition 1 each, guitar movement 0.5. Fixed evaluation windows and bounded caches avoid repeating harmonic analysis for every extension. Soft ranking uses floating-point penalties; timing, scale validity, protected quotes, required arrivals and guitar placement retain exact constraints.
|
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.
|
||||||
|
|
||||||
Motif fidelity follows the planned contract: `exact_quote` preserves pitches and attack/release timing, permitting only a whole-fragment octave relocation; `rhythmic_reference` preserves timing while changing pitches; `contour_reference` retains direction changes; `opening_reference` protects the beginning; `answer` permits a changed ending; and `invented` develops its own sequence-derived identity. Exact quotation remains explicitly available but is not scheduled automatically. Invented motifs depend on harmony, starting register, shape and destination. Stable musical-content identities ignore file paths and titles; reports record both identity and the policy fingerprint.
|
`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.
|
||||||
|
|
||||||
Phrase search retains at most 64 states and considers 32 diverse extensions per state. Its identity includes activity, rhythm, landmark and motif progress as well as the placed ending. Up to four completed alternatives are retained, and `composer.py` evaluates the next phrase from those endings before committing. Closed arrivals prefer defining chord tones; open statements may target a compatible fifth or extension, respecting explicit alterations and omissions. A bounded-search failure identifies the passage and limits, not mathematical impossibility.
|
A rolling sixteen-bar history measures exact and transposed pitch/rhythm recurrence, repeated oscillations, and cadence formulas. Recent reuse receives more pressure than distant reuse, and deliberate returns receive a discount rather than immunity. Ties collapse to one attack. Register goals offer wider acoustic development and lower electric answers after high passages. These are musical preferences, not range or string quotas. Phrase activity retains a broad preference of four to six attacks per four quarter-note beats, including its held ending; individual closing cells have no attack quota. Occupancy is diagnostic. Explicit active, sustained-arrival, and space spans prevent a silent candidate from satisfying a moving passage.
|
||||||
|
|
||||||
Acoustic begins with a transformed melody reference; electric begins with a contextual invention. Soft activity goals average 4.5 and 5.5 attacks per four beats respectively, distributed unevenly across movement and arrival. These are not per-bar quotas. Boundaries may connect, sustain or breathe; only the returning vocal pickup has mandatory protection. Sounding registers start around G3–D5 and B3–G5 respectively; written guitar notation is one octave above sound, exactly once. `placement.py` uses frets 1–20 with no open strings and preserves positions through explicit sustain continuations. Movement cost uses the first declared source tempo, or 120 quarter notes/minute. Runtime tempo remains adjustable, so this is not a guarantee of ease at every tempo. Chromatic ornaments, expressive articulation, dynamics and swing playback remain deferred.
|
`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.
|
||||||
|
|
||||||
`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. 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.
|
||||||
|
|
||||||
`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.
|
`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 --capture D` captures arrangements, reports, full-solo metrics, source hashes, policy and implementation fingerprints under `build/solo_auditions/D`. The build-only utility refuses to overwrite a snapshot. Capture `baseline`, `A`, `B` and `C` from their corresponding implementation checkpoints before capture D; these labels do not select runtime presets. `--render` creates eight-bar MusicXML and WAV comparisons for each song and guitar using the baseline backing, plus final responsive-backing clips. All clips use existing tone generation, source tempo and a shared gain. `index.html` provides playback and links to reports; `manifest.json` records reproducibility metadata. Metrics cover activity, occupancy, unplanned gaps, connected eighths, quotation and surviving candidate families. They diagnose behavior rather than establish musical quality.
|
`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.
|
||||||
|
|
||||||
### Engraving Boundary
|
### Engraving Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,20 @@ def render(root):
|
|||||||
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
||||||
clips.append((path, samples))
|
clips.append((path, samples))
|
||||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
||||||
|
if stage == 'after' and 'refined' in solo.get('lead_stages', {}):
|
||||||
|
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)
|
||||||
|
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)))
|
||||||
|
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'plain_theme', 'window': label})
|
||||||
|
if label == 'full':
|
||||||
|
for kind, document in (('plain', plain), ('elaborated', foreground)):
|
||||||
|
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})
|
||||||
for draft in solo.get('draft_documents', ()):
|
for draft in solo.get('draft_documents', ()):
|
||||||
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
||||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
||||||
@@ -314,14 +328,21 @@ def write_passage_index(root, snapshots):
|
|||||||
for stage in ('before', 'after'):
|
for stage in ('before', 'after'):
|
||||||
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
|
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>")
|
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>")
|
||||||
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th></tr>')
|
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>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
|
||||||
for window in ('full', 'opening8', 'closing4'):
|
for window in ('full', 'opening8', 'closing4'):
|
||||||
body.append(f'<tr><th>{window}</th>')
|
body.append(f'<tr><th>{window}</th>')
|
||||||
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
|
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
|
||||||
prefix = stage + '/' + name + '_' + part + '_' + window
|
prefix = stage + '/' + name + '_' + part + '_' + window
|
||||||
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
|
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
|
||||||
|
if plain_available:
|
||||||
|
prefix=f'after/{name}_{part}_{window}_plain'
|
||||||
|
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Plain theme notation</a></small></td>')
|
||||||
body.append('</tr>')
|
body.append('</tr>')
|
||||||
body.append('</table>')
|
body.append('</table>')
|
||||||
|
if plain_available:
|
||||||
|
prefix=f'after/{name}_{part}_full'
|
||||||
|
body.append(f'<p>Unaccompanied plain theme: <audio controls preload="none" src="{prefix}_plain_solo.wav"></audio> Elaborated: <audio controls preload="none" src="{prefix}_elaborated_solo.wav"></audio></p>')
|
||||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -350,6 +350,9 @@ def local_sounding(notes):
|
|||||||
|
|
||||||
def contract_valid(draft, notes):
|
def contract_valid(draft, notes):
|
||||||
by_id = {n.event_id: n for n in local_sounding(notes)}
|
by_id = {n.event_id: n for n in local_sounding(notes)}
|
||||||
|
for tail in draft.sustained_tails:
|
||||||
|
if not any(n.pitch.number() == tail.pitch and n.start <= tail.start and n.end >= tail.end for n in by_id.values()):
|
||||||
|
return False, f'{tail.start}-{tail.end}: required sounding arrival tail changed'
|
||||||
original = {n.event_id: n for n in draft.notes}
|
original = {n.event_id: n for n in draft.notes}
|
||||||
for identity in (*draft.structural, *draft.protected):
|
for identity in (*draft.structural, *draft.protected):
|
||||||
before, after = original[identity], by_id.get(identity)
|
before, after = original[identity], by_id.get(identity)
|
||||||
|
|||||||
+108
-50
@@ -10,7 +10,7 @@ import fractions
|
|||||||
|
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts
|
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references
|
||||||
|
|
||||||
F = fractions.Fraction
|
F = fractions.Fraction
|
||||||
|
|
||||||
@@ -160,49 +160,54 @@ def connection_patterns(start,end,entry,destination,rhythms,meter):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False):
|
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False, tails=None):
|
||||||
"""Edit an exposed held tail or a complete interior span, with both endpoints known."""
|
"""Replace complete internal spans while retaining identity and sounding tails."""
|
||||||
fixed={e for f in features for e in f.events}
|
legacy=tails is None
|
||||||
|
tails=tails or ()
|
||||||
|
fixed={e for f in features for e in f.events} if legacy else {n.event_id for n in notes if n.role=='quote'}
|
||||||
windows=[]
|
windows=[]
|
||||||
for i,(note,target) in enumerate(zip(notes,notes[1:])):
|
for i,(note,target) in enumerate(zip(notes,notes[1:])):
|
||||||
if note.event_id in fixed or note.end != target.start:
|
if note.event_id in fixed or note.end!=target.start:continue
|
||||||
continue
|
|
||||||
if note.end-note.start>=F(3,2):
|
if note.end-note.start>=F(3,2):
|
||||||
start=max(note.start+F(1,2),target.start-3)
|
start=max(note.start+F(1,2),target.start-3)
|
||||||
windows.append((i,start,target,False))
|
windows.append((i,i+1,start,target))
|
||||||
# A source-held ending can arrive later via a related in-scale turn.
|
if not legacy:
|
||||||
if notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
|
for j in range(i+2,min(i+5,len(notes))):
|
||||||
|
target=notes[j]
|
||||||
|
if not 1<=target.start-note.start<=3:continue
|
||||||
|
if any(n.event_id in fixed for n in notes[i:j]):continue
|
||||||
|
if any(t.start<target.start and t.end>note.start for t in tails):continue
|
||||||
|
windows.append((i,j,note.start,target))
|
||||||
|
if legacy and notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
|
||||||
tail=notes[-1]
|
tail=notes[-1]
|
||||||
for duration in (F(1,2),F(1),F(3,2),F(2)):
|
for duration in (F(1,2),F(1),F(3,2),F(2)):
|
||||||
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
|
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
|
||||||
start=max(tail.start+F(1,2),target.start-3)
|
start=max(tail.start+F(1,2),target.start-3)
|
||||||
if target.start-start>=1:
|
if target.start-start>=1:windows.append((len(notes)-1,len(notes)-1,start,target))
|
||||||
windows.append((len(notes)-1,start,target,True))
|
# Consequential edits only; do not exhaustively branch at each passing note.
|
||||||
|
windows=sequences.diverse(windows,lambda w:(policy.stable_rank(source.id,w),),lambda w:w[0],8)
|
||||||
result=[]
|
result=[]
|
||||||
for i,start,target,terminal in windows:
|
for i,j,start,target in windows:
|
||||||
entry=notes[i];end=target.start
|
entry=notes[i]
|
||||||
prefix=dataclasses.replace(entry,end=start)
|
prefix=dataclasses.replace(entry,end=start)
|
||||||
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
|
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
|
||||||
for pattern,rhythm_id in connection_patterns(start,end,seed,target,rhythms,meter):
|
for pattern,rhythm_id in connection_patterns(start,target.start,seed,target,rhythms,meter):
|
||||||
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
|
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
|
||||||
if sustain_entry:
|
if sustain_entry or start==entry.start:
|
||||||
# The route owns its complete sounding entry, including the old hold.
|
|
||||||
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
|
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
|
||||||
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
|
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
|
||||||
actual=(*notes[:i],*route.notes,*notes[i+1 if terminal else i+2:])
|
actual=(*notes[:i],*route.notes,*notes[j+1:])
|
||||||
else:
|
else:
|
||||||
actual=(*notes[:i],prefix,*route.notes,*notes[i+1 if terminal else i+2:])
|
actual=(*notes[:i],prefix,*route.notes,*notes[j+1:])
|
||||||
if not harmony.evaluate(actual,complete=True).valid:
|
if any(references.feature_error(f,actual) for f in features):continue
|
||||||
continue
|
if any(not any(n.pitch.number()==t.pitch and n.start<=t.start and n.end>=t.end for n in actual) for t in tails):continue
|
||||||
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):
|
if not harmony.evaluate(actual,complete=True).valid:continue
|
||||||
continue
|
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):continue
|
||||||
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
|
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
|
||||||
edit=types.DraftEdit('complete_route',start,target.start,(entry.event_id,),tuple(n.event_id for n in route.notes),
|
edit=types.DraftEdit('complete_route',start,target.start,tuple(n.event_id for n in notes[i:j+1]),tuple(n.event_id for n in route.notes),
|
||||||
source.id+'; '+rhythm_id+'; '+('later terminal arrival on ' if terminal else 'lead into ')+target.pitch.label())
|
source.id+'; '+rhythm_id+'; lead into '+target.pitch.label())
|
||||||
result.append((actual,edit,route))
|
result.append((actual,edit,route))
|
||||||
# Whole routes compete; bounded diversity retains different locations and rhythms.
|
return sequences.diverse(result,lambda v:(max(0,4-len(v[0])*4/float(source.end-source.start),len(v[0])*4/float(source.end-source.start)-6),v[2].id),
|
||||||
return sequences.diverse(result,lambda v:(abs(len(v[0])*4/float(source.end-source.start)-5),
|
|
||||||
sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(v[2].notes,v[2].notes[1:])),v[2].id),
|
|
||||||
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
|
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
|
||||||
|
|
||||||
|
|
||||||
@@ -315,13 +320,81 @@ def transition(first,second):
|
|||||||
return .2*leap+.15*bool(repeated_end)
|
return .2*leap+.15*bool(repeated_end)
|
||||||
|
|
||||||
|
|
||||||
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage):
|
||||||
from generator.solo import realization, themes
|
from generator.solo import realization,themes,phrasing
|
||||||
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
|
|
||||||
context.resources[id(library)]=library
|
context.resources[id(library)]=library
|
||||||
context.resources[id(rhythms)]=rhythms
|
context.resources[id(rhythms)]=rhythms
|
||||||
harmony=harmonic.Harmony(choices)
|
harmony=harmonic.Harmony(choices)
|
||||||
sources=themes.windows(analysis,measures,end,harmony) if mode=='thematic' else clipped_sources(analysis,end)
|
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||||
|
states=[(0.,())]
|
||||||
|
failures=[]
|
||||||
|
for _ in range(2*len(measures)+1):
|
||||||
|
if all(history and history[-1].plan.end==end for _,history in states):break
|
||||||
|
following=[]
|
||||||
|
for cost,history in states:
|
||||||
|
start=history[-1].plan.end if history else F()
|
||||||
|
if start==end:
|
||||||
|
following.append((cost,history));continue
|
||||||
|
incoming=history[-1].state if history else types.ContinuationState('')
|
||||||
|
sources=phrasing.window_options(analysis,measures,start,end,harmony,incoming)
|
||||||
|
elapsed=F();meter=measures[0]['time_signature']
|
||||||
|
for measure in measures:
|
||||||
|
elapsed+=generator.model.fraction(measure['duration'])
|
||||||
|
if elapsed>start:
|
||||||
|
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)
|
||||||
|
if key in context.results:
|
||||||
|
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||||
|
else:
|
||||||
|
pool=()
|
||||||
|
if not harmony.at(start) or not harmony.at(start).scale:
|
||||||
|
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
|
||||||
|
else:
|
||||||
|
for batch in range(policy.DEFAULT.replan_attempts):
|
||||||
|
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,
|
||||||
|
stage=stage,batch=batch,final=source.end==end,solo_end=end)
|
||||||
|
if pool:break
|
||||||
|
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
|
||||||
|
context.results[key]=pool
|
||||||
|
context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend(
|
||||||
|
{'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None,
|
||||||
|
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns)} for p in pool)
|
||||||
|
for candidate in pool:
|
||||||
|
span=float((source.end-source.start)/end)
|
||||||
|
total=cost+span*(candidate.score+phrasing.endpoint_cost(source.end,start,end,incoming.closures,harmony,bar))
|
||||||
|
total+=transition(history[-1] if history else None,candidate)
|
||||||
|
following.append((total,(*history,candidate)))
|
||||||
|
if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation')
|
||||||
|
if not following:
|
||||||
|
raise realization.SearchExhausted(f'{part}: {"; ".join(failures[-4:])} failed within the bounded search; not proof of incompatibility')
|
||||||
|
def rank(value):
|
||||||
|
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end
|
||||||
|
musical=cost/max(.001,float(elapsed/end))
|
||||||
|
if elapsed==end:
|
||||||
|
target=policy.DEFAULT.settling_target*float(end)/(32*4)
|
||||||
|
musical+=.1*max(0,abs(state.closures-target)-1)
|
||||||
|
musical+=.15*abs(len(state.returns)-policy.DEFAULT.return_target*float(end)/(32*4))
|
||||||
|
return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history))
|
||||||
|
states=sequences.diverse(following,rank,
|
||||||
|
lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier)
|
||||||
|
finished=[(cost,history) for cost,history in states if history[-1].plan.end==end]
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
||||||
|
from generator.solo import realization
|
||||||
|
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
|
||||||
|
if mode=='thematic':
|
||||||
|
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)
|
||||||
|
sources=clipped_sources(analysis,end)
|
||||||
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
|
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||||
states=[(0.,())]
|
states=[(0.,())]
|
||||||
for source in sources:
|
for source in sources:
|
||||||
@@ -329,30 +402,15 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
|
|||||||
following=[]
|
following=[]
|
||||||
for cost,history in states:
|
for cost,history in states:
|
||||||
incoming=history[-1].state if history else types.ContinuationState('')
|
incoming=history[-1].state if history else types.ContinuationState('')
|
||||||
key=(mode,source,incoming if mode=='thematic' else None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
|
key=(mode,source,None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
|
||||||
if key in context.results:
|
if key in context.results:
|
||||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||||
else:
|
else:
|
||||||
if mode=='thematic' and any(c.scale and c.span.start<source.end and c.span.end>source.start for c in choices):
|
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
|
||||||
pool=()
|
|
||||||
for batch in range(2):
|
|
||||||
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,stage=stage,batch=batch,final=source.end==end)
|
|
||||||
if pool:break
|
|
||||||
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
|
|
||||||
elif mode=='thematic':
|
|
||||||
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
|
|
||||||
else:
|
|
||||||
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
|
|
||||||
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
|
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
|
||||||
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
|
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
|
||||||
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
|
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
|
||||||
for candidate in pool:
|
for candidate in pool:
|
||||||
# The thematic constructor has already placed all decisions from the
|
|
||||||
# actual incoming state; do not collapse its motif groups into one bar.
|
|
||||||
if mode=='thematic':
|
|
||||||
total=cost+candidate.score*float(source.end-source.start)/float(end)+transition(history[-1] if history else None,candidate)
|
|
||||||
following.append((total,(*history,candidate)))
|
|
||||||
continue
|
|
||||||
last=incoming.last
|
last=incoming.last
|
||||||
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
|
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
|
||||||
for travel,notes in placements:
|
for travel,notes in placements:
|
||||||
@@ -364,8 +422,8 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
|
|||||||
if not following:
|
if not following:
|
||||||
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
|
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
|
||||||
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]))),
|
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:(v[1][-1].state.home.id if mode=='thematic' and v[1][-1].state.home else tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
|
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':'thematic_recomposition' if mode=='thematic' else 'source_phrase_variations','policy':policy.describe()['fingerprint']},
|
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))) for cost,phrases in states)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Sustained destinations and bounded memory for the existing theme composer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import functools
|
||||||
|
from fractions import Fraction as F
|
||||||
|
|
||||||
|
import generator.model
|
||||||
|
import generator.harmony
|
||||||
|
from generator.solo import types, policy, sequences
|
||||||
|
|
||||||
|
|
||||||
|
def heard(notes, operation, anchor=None):
|
||||||
|
notes = types.sounding_notes(notes)
|
||||||
|
first = notes[0].start
|
||||||
|
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
|
||||||
|
ending = (anchor.pitch.number()%12, anchor.earliest%4, anchor.duration) if anchor and anchor.role == 'terminal' else ()
|
||||||
|
return types.HeardFigure(first, notes[-1].end, events, operation, ending)
|
||||||
|
|
||||||
|
|
||||||
|
def remember(history, figure, bar):
|
||||||
|
return tuple(h for h in (*history, figure) if h.end > figure.end-policy.DEFAULT.history_bars*bar)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=4096)
|
||||||
|
def fingerprints(events, relative=False):
|
||||||
|
result = set()
|
||||||
|
for size in (3, 4, 6, 8):
|
||||||
|
for i in range(len(events)-size+1):
|
||||||
|
group = events[i:i+size]
|
||||||
|
start, _, pitch = group[0]
|
||||||
|
result.add(tuple((a-start, d, p-pitch if relative else p) for a,d,p in group))
|
||||||
|
return frozenset(result)
|
||||||
|
|
||||||
|
|
||||||
|
def repetition(figure, history, bar):
|
||||||
|
"""Bounded overuse, not a reward for arbitrary difference or extra attacks."""
|
||||||
|
exact, relative = fingerprints(figure.events), fingerprints(figure.events, True)
|
||||||
|
overuse = cadence = 0.
|
||||||
|
for old in history:
|
||||||
|
age = max(0., float((figure.start-old.end)/bar))
|
||||||
|
recent = max(0., 1-age/policy.DEFAULT.history_bars)
|
||||||
|
a,b = fingerprints(old.events), fingerprints(old.events, True)
|
||||||
|
exact_match = len(exact & a)/max(1,min(len(exact),len(a)))
|
||||||
|
shifted_match = len(relative & b)/max(1,min(len(relative),len(b)))
|
||||||
|
overuse += recent*(.65*exact_match+.35*shifted_match)
|
||||||
|
if figure.ending and figure.ending == old.ending:
|
||||||
|
cadence += recent
|
||||||
|
pitches = tuple(p for _,_,p in figure.events)
|
||||||
|
oscillation = sum(a==c and b==d for a,b,c,d in zip(pitches,pitches[1:],pitches[2:],pitches[3:]))/max(1,len(pitches)-3)
|
||||||
|
discount = .45 if figure.operation == 'return' else 1.
|
||||||
|
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
|
||||||
|
|
||||||
|
|
||||||
|
def register_goal(incoming, part, fallback):
|
||||||
|
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||||
|
if not recent:
|
||||||
|
return fallback
|
||||||
|
center = sum(recent)/len(recent)
|
||||||
|
if part == 'electric' and (center >= 67 or incoming.register_peak >= 72 and center >= 64):
|
||||||
|
return max(48, round(center)-7)
|
||||||
|
if part == 'acoustic' and max(recent)-min(recent) < 8:
|
||||||
|
home = sum(n.pitch.number() for n in incoming.home.notes)/len(incoming.home.notes) if incoming.home else center
|
||||||
|
return min(72, round(center)+5) if center <= home+7 else max(45, round(center)-5)
|
||||||
|
return round(center)
|
||||||
|
|
||||||
|
|
||||||
|
def endpoint_cost(finish, start, end, count, harmony, bar):
|
||||||
|
length = float((finish-start)/bar)
|
||||||
|
remaining = float((end-finish)/bar)
|
||||||
|
projected = count+1+remaining/(32/policy.DEFAULT.settling_target)
|
||||||
|
low, high = policy.DEFAULT.settling_range
|
||||||
|
pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target)
|
||||||
|
span = harmony.at(finish-F(1,100))
|
||||||
|
# A dominant may close an open question, but a nearby resolution is preferred.
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
|
||||||
|
from generator.solo.themes import source_notes
|
||||||
|
bounds=[F()]
|
||||||
|
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
|
||||||
|
index=next((i for i,b in enumerate(bounds[:-1]) if b<=start<bounds[i+1]),len(measures)-1)
|
||||||
|
bar=bounds[index+1]-bounds[index]
|
||||||
|
current=harmony.at(start)
|
||||||
|
if not current or not current.scale:
|
||||||
|
ends=(min(end,current.end if current else bounds[index+1]),)
|
||||||
|
else:
|
||||||
|
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
|
||||||
|
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (4,5,6,8)}))
|
||||||
|
if barrier>=end:
|
||||||
|
# The returning pickup may shorten the last measure. Do not strand a
|
||||||
|
# one-beat "new phrase" after an otherwise completed sustained close.
|
||||||
|
ends=tuple(sorted({end if 0<end-finish<bar else finish for finish in ends}))
|
||||||
|
melody=source_notes(analysis)
|
||||||
|
values=[]
|
||||||
|
for finish in ends:
|
||||||
|
if finish<=start:continue
|
||||||
|
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
|
||||||
|
for n in melody if n.start<finish and n.end>start)
|
||||||
|
values.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
|
||||||
|
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):
|
||||||
|
"""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)
|
||||||
|
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),)
|
||||||
|
for hold in holds:
|
||||||
|
for release in (F(),F(1,2),F(1),F(2)):
|
||||||
|
arrival=source.end-release-hold
|
||||||
|
if arrival-source.start<F(3,2):continue
|
||||||
|
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)
|
||||||
|
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
|
||||||
|
rank=abs(pitch.number()-goal)/24+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
|
||||||
|
values.append((rank,anchor))
|
||||||
|
selected=sequences.diverse(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
|
||||||
|
lambda x:(x[1].duration,x[1].earliest%bar,x[1].pitch.number()>goal),limit)
|
||||||
|
return tuple(a for _,a in selected)
|
||||||
|
|
||||||
|
|
||||||
|
def appearance_cost(operation, incoming, start, end):
|
||||||
|
expected=policy.DEFAULT.return_target*float(start/max(end,F(1)))
|
||||||
|
actual=len(incoming.returns)
|
||||||
|
if operation=='return':
|
||||||
|
gap=start-(incoming.returns[-1] if incoming.returns else F())
|
||||||
|
return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24)
|
||||||
|
return .45*max(0,expected-actual)
|
||||||
|
|
||||||
|
|
||||||
|
def activity(source, ending):
|
||||||
|
spans=[]
|
||||||
|
if ending.earliest>source.start:
|
||||||
|
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
|
||||||
|
spans.append(types.ActivitySpan(ending.earliest,ending.hold_until,'sustained_arrival',F(),F(1)))
|
||||||
|
if ending.hold_until<source.end:
|
||||||
|
spans.append(types.ActivitySpan(ending.hold_until,source.end,'space',F(),F()))
|
||||||
|
return tuple(spans)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(notes, incoming, harmony, start, end, bar):
|
||||||
|
"""Final musical cost uses a complete sounding phrase, never cell totals."""
|
||||||
|
notes=types.sounding_notes(notes)
|
||||||
|
checked=harmony.evaluate(notes,until=end,complete=True)
|
||||||
|
if not checked.valid or not notes:return float('inf')
|
||||||
|
density=4*len(notes)/float(end-start)
|
||||||
|
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
|
||||||
@@ -40,7 +40,15 @@ class Policy:
|
|||||||
theme_copy_attacks: int = 4
|
theme_copy_attacks: int = 4
|
||||||
theme_seed_limit: int = 8
|
theme_seed_limit: int = 8
|
||||||
theme_unit_alternatives: int = 8
|
theme_unit_alternatives: int = 8
|
||||||
composition_version: str = 'thematic-recomposition-1'
|
theme_rhythm_alternatives: int = 8
|
||||||
|
theme_destinations: int = 6
|
||||||
|
theme_continuations: int = 8
|
||||||
|
theme_route_jobs: int = 16
|
||||||
|
settling_range: tuple[int, int] = (5, 7)
|
||||||
|
settling_target: int = 6
|
||||||
|
return_target: int = 2
|
||||||
|
history_bars: int = 16
|
||||||
|
composition_version: str = 'thematic-phrasing-2'
|
||||||
|
|
||||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
|||||||
if solo.diagnostics:
|
if solo.diagnostics:
|
||||||
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True))
|
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True))
|
||||||
detail = structure(solo)
|
detail = structure(solo)
|
||||||
|
lines.append("Sustained phrasing: " + json.dumps(detail["phrasing"], sort_keys=True))
|
||||||
for passage in detail['passages']:
|
for passage in detail['passages']:
|
||||||
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
|
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
|
||||||
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
|
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
|
||||||
@@ -108,13 +109,61 @@ def structure(solo):
|
|||||||
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft}
|
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft}
|
||||||
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
|
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
|
||||||
if phrase.initial and phrase.refined:
|
if phrase.initial and phrase.refined:
|
||||||
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,phrase.initial.notes)
|
# A source hold may already be sounding at this generated phrase boundary.
|
||||||
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),phrase.initial.notes)
|
# Detach its clipped entrance for the local comparison only; the full
|
||||||
|
# source comparison below still merges its original tied continuations.
|
||||||
|
local_source=generator.solo.drafts.local_sounding(phrase.initial.notes)
|
||||||
|
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source)
|
||||||
|
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
|
||||||
if phrase.state and phrase.state.home:
|
if phrase.state and phrase.state.home:
|
||||||
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
|
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
|
||||||
entry['score'] = phrase.score
|
entry['score'] = phrase.score
|
||||||
phrases.append(entry)
|
phrases.append(entry)
|
||||||
return {'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, 'harmonic_roles': harmony.describe(solo.notes(), contracts), '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 {'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, 'harmonic_roles': harmony.describe(solo.notes(), contracts), '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):
|
||||||
|
import collections
|
||||||
|
from generator.solo import phrasing
|
||||||
|
notes=solo.notes()
|
||||||
|
figures=[phrasing.heard(notes,'chorus')] if notes else []
|
||||||
|
exact=collections.Counter();relative=collections.Counter()
|
||||||
|
for figure in figures:
|
||||||
|
# Count occurrences, not just unique vocabulary within each phrase.
|
||||||
|
for i in range(len(figure.events)-3):
|
||||||
|
group=figure.events[i:i+4];start,_,pitch=group[0]
|
||||||
|
exact[tuple((a-start,d,p) for a,d,p in group)]+=1
|
||||||
|
relative[tuple((a-start,d,p-pitch) for a,d,p in group)]+=1
|
||||||
|
endings=[];regions=[];appearances=[]
|
||||||
|
for phrase in solo.phrases:
|
||||||
|
local=tuple(n for d in phrase.decisions for n in d.notes)
|
||||||
|
for anchor in phrase.anchors:
|
||||||
|
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,
|
||||||
|
'release':str(phrase.plan.release),'completed':note is not None})
|
||||||
|
if local:
|
||||||
|
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
|
||||||
|
'low':min(n.pitch.number() for n in local),'high':max(n.pitch.number() for n in local),
|
||||||
|
'duration_weighted_mean':sum(n.pitch.number()*float(n.end-n.start) for n in local)/sum(float(n.end-n.start) for n in local)})
|
||||||
|
if phrase.draft:
|
||||||
|
for transform,feature in zip(phrase.draft.transformations,phrase.draft.features):
|
||||||
|
appearances.append({'start':str(transform.start),'operation':transform.operation,'home':transform.home,
|
||||||
|
'preserves':list(feature.preserves),'feature_source':feature.source})
|
||||||
|
activity={}
|
||||||
|
for intent in ('active','sustained_arrival','space'):
|
||||||
|
spans=[s for p in solo.phrases for s in p.plan.passages if s.intent==intent]
|
||||||
|
duration=sum(float(s.end-s.start) for s in spans)
|
||||||
|
attacks=sum(any(s.start<=n.start<s.end for s in spans) for n in notes)
|
||||||
|
occupancy=sum(float(max(0,min(n.end,s.end)-max(n.start,s.start))) for n in notes for s in spans)
|
||||||
|
activity[intent]={'beats':duration,'attacks':attacks,'attacks_per_four_beats':4*attacks/duration if duration else 0,
|
||||||
|
'occupancy':occupancy/duration if duration else 0}
|
||||||
|
return {'completed_sustained_endings':sum(e['completed'] and fractions.Fraction(e['sounding_beats'])>=2 for e in endings),
|
||||||
|
'endings':endings,'appearances':appearances,'clear_returns':sum(a['operation']=='return' for a in appearances),
|
||||||
|
'exact_four_note_recurrences':sum(max(0,n-1) for n in exact.values()),
|
||||||
|
'interval_rhythm_four_note_recurrences':sum(max(0,n-1) for n in relative.values()),
|
||||||
|
'activity':activity,'register_regions':regions}
|
||||||
|
|
||||||
|
|
||||||
def fretboard_report(solo):
|
def fretboard_report(solo):
|
||||||
|
|||||||
+71
-38
@@ -31,11 +31,34 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
|
|||||||
if not hasattr(harmony,'route_cache'):
|
if not hasattr(harmony,'route_cache'):
|
||||||
harmony.route_cache={}
|
harmony.route_cache={}
|
||||||
harmony.route_libraries={}
|
harmony.route_libraries={}
|
||||||
|
harmony.concrete_routes={}
|
||||||
harmony.route_libraries[id(library)]=library
|
harmony.route_libraries[id(library)]=library
|
||||||
key=(notes,frozenset(fixed),id(library),register)
|
concrete_key=(notes,frozenset(fixed),id(library),register)
|
||||||
|
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
|
||||||
|
# feasibility. Cache the pitch work across different thematic histories,
|
||||||
|
# then restore this caller's provenance before ranking complete routes.
|
||||||
|
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)
|
||||||
if key not in harmony.route_cache:
|
if key not in harmony.route_cache:
|
||||||
harmony.route_cache[key]=_candidates(notes,fixed,harmony,library,register)
|
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
|
||||||
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.route_cache[key])
|
result=[]
|
||||||
|
for route in harmony.route_cache[key]:
|
||||||
|
actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None)
|
||||||
|
for i,(n,r) in enumerate(zip(notes,route.notes)))
|
||||||
|
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16]
|
||||||
|
result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
|
||||||
|
selected=generator.solo.sequences.diverse(result,
|
||||||
|
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
|
||||||
|
lambda r:generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),
|
||||||
|
generator.solo.policy.DEFAULT.join_alternatives)
|
||||||
|
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes)))
|
||||||
|
if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
|
||||||
|
harmony.concrete_routes[concrete_key]=selected
|
||||||
|
return selected
|
||||||
|
|
||||||
def _candidates(notes, fixed, harmony, library, register):
|
def _candidates(notes, fixed, harmony, library, register):
|
||||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||||
@@ -49,41 +72,51 @@ def _candidates(notes, fixed, harmony, library, register):
|
|||||||
span = harmony.at(first.start)
|
span = harmony.at(first.start)
|
||||||
if not span or not span.scale:
|
if not span or not span.scale:
|
||||||
return ()
|
return ()
|
||||||
|
contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None,
|
||||||
|
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)
|
||||||
|
if key not in harmony.pitch_routes:
|
||||||
|
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register)
|
||||||
|
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)
|
||||||
|
result=[]
|
||||||
|
for pitches,sources in harmony.pitch_routes[key]:
|
||||||
|
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None)
|
||||||
|
for n,p in zip(notes[1:],pitches[1:])))
|
||||||
|
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
|
||||||
|
result.append(types.RouteCandidate(identity,realized,destination,route_spans,'connect theme',sources))
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_paths(first, contexts, library, register):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
destination=contexts[-1][2]
|
||||||
try:
|
try:
|
||||||
displacement = types.scale_index(span.scale, destination.pitch.number()) - types.scale_index(span.scale, first.pitch.number())
|
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
|
||||||
displacements = tuple([displacement])
|
displacements=(displacement,)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Enumerate possible degree routes when the destination changes collection;
|
displacements=tuple(range(-7,8))
|
||||||
# actual endpoint equality below decides compatibility, not a pitch estimate.
|
direction=(destination>first.number())-(destination<first.number())
|
||||||
displacements = tuple(range(-7, 8))
|
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
|
||||||
direction = (destination.pitch.number() > first.pitch.number()) - (destination.pitch.number() < first.pitch.number())
|
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
|
||||||
paths = dict.fromkeys(path for d in displacements for path in library.retrieve(len(notes), d, direction))
|
result=[]
|
||||||
result = []
|
|
||||||
low, high = generator.fretboard.single_register((register[0] - 7, register[1] + 5))
|
|
||||||
allowed = {n.start: types.permitted(harmony.choices, n.start, n.end) for n in notes}
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
realized = [first]
|
realized=[first]
|
||||||
for template, step in zip(notes[1:], path.steps):
|
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
|
||||||
current = harmony.at(template.start)
|
if not scale:break
|
||||||
if not current or not current.scale:
|
options=advance(scale,realized[-1].number(),step)
|
||||||
break
|
if not options:break
|
||||||
options = advance(current.scale, realized[-1].pitch.number(), step)
|
pitch=options[0];number=pitch.number()
|
||||||
if not options:
|
if not low<=number<=high or number%12 not in allowed:break
|
||||||
break
|
if fixed is not None and number!=fixed:break
|
||||||
pitch = options[0]
|
if not generator.solo.placement.positions(number):break
|
||||||
if not low <= pitch.number() <= high or pitch.number() % 12 not in allowed[template.start]:
|
realized.append(pitch)
|
||||||
break
|
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
|
||||||
if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number():
|
return tuple(result)
|
||||||
break
|
|
||||||
if not generator.solo.placement.positions(pitch.number()):
|
|
||||||
break
|
|
||||||
realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None))
|
|
||||||
if len(realized) != len(notes):
|
|
||||||
continue
|
|
||||||
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)
|
|
||||||
identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16]
|
|
||||||
result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, 'connect theme', path.sources))
|
|
||||||
return generator.solo.sequences.diverse(result,
|
|
||||||
lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id),
|
|
||||||
lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))),
|
|
||||||
generator.solo.policy.DEFAULT.join_alternatives)
|
|
||||||
|
|||||||
+182
-107
@@ -10,7 +10,7 @@ from fractions import Fraction as F
|
|||||||
|
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
from generator.solo import types, policy, sequences, routes, references, similarity
|
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing
|
||||||
|
|
||||||
|
|
||||||
def source_notes(analysis):
|
def source_notes(analysis):
|
||||||
@@ -18,34 +18,6 @@ def source_notes(analysis):
|
|||||||
event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset)
|
event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset)
|
||||||
|
|
||||||
|
|
||||||
def windows(analysis, measures, end, harmony):
|
|
||||||
"""Existing phrase representation; source extraction seams do not close it."""
|
|
||||||
bounds=[F()]
|
|
||||||
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
|
|
||||||
melody=source_notes(analysis); result=[]; index=0
|
|
||||||
while bounds[index]<end:
|
|
||||||
stop=min(index+4,len(measures))
|
|
||||||
# Permit an extension through a cadence rather than closing on its dominant.
|
|
||||||
last=harmony.at(min(end,bounds[stop])-F(1,100))
|
|
||||||
if last and last.chord!='N.C.' and generator.harmony.degrees(generator.harmony.parse_symbol(last.chord)).get(7)==-1 and generator.harmony.degrees(generator.harmony.parse_symbol(last.chord)).get(3)==0:
|
|
||||||
for size in (6,8):
|
|
||||||
candidate=min(index+size,len(measures)); span=harmony.at(min(end,bounds[candidate])-F(1,100))
|
|
||||||
if span and span.chord!='N.C.' and generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)).get(3)==-1:
|
|
||||||
stop=candidate;break
|
|
||||||
start,finish=bounds[index],min(end,bounds[stop])
|
|
||||||
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
|
|
||||||
for n in melody if n.start<finish and n.end>start)
|
|
||||||
result.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
|
|
||||||
index=stop
|
|
||||||
# Explicit missing harmony interrupts sounding material, not thematic memory.
|
|
||||||
divided=[]
|
|
||||||
for window in result:
|
|
||||||
cuts=sorted({window.start,window.end,*(max(window.start,s.start) for s in harmony.spans if not s.scale and s.start<window.end and s.end>window.start),*(min(window.end,s.end) for s in harmony.spans if not s.scale and s.start<window.end and s.end>window.start)})
|
|
||||||
for start,finish in zip(cuts,cuts[1:]):
|
|
||||||
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start) for n in window.notes if n.start<finish and n.end>start)
|
|
||||||
divided.append(dataclasses.replace(window,id=f'theme_window_{start}_{finish}',start=start,end=finish,notes=notes))
|
|
||||||
return tuple(divided)
|
|
||||||
|
|
||||||
|
|
||||||
def silence(source,incoming,part,harmony,tempo,seed):
|
def silence(source,incoming,part,harmony,tempo,seed):
|
||||||
from generator.solo import realization
|
from generator.solo import realization
|
||||||
@@ -55,7 +27,8 @@ def silence(source,incoming,part,harmony,tempo,seed):
|
|||||||
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
|
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)
|
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
|
||||||
if phrase is None:return ()
|
if phrase is None:return ()
|
||||||
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks)
|
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
|
||||||
|
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
|
||||||
original=dataclasses.replace(draft,notes=source.notes)
|
original=dataclasses.replace(draft,notes=source.notes)
|
||||||
return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),)
|
return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),)
|
||||||
|
|
||||||
@@ -76,7 +49,8 @@ def short_fragment(source,incoming,part,harmony,tempo,seed,final):
|
|||||||
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,(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)
|
||||||
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
|
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
|
||||||
if phrase:
|
if phrase:
|
||||||
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks)
|
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
|
||||||
|
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
|
||||||
result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft))
|
result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft))
|
||||||
return tuple(result)
|
return tuple(result)
|
||||||
|
|
||||||
@@ -107,7 +81,9 @@ def feature(reference, actual, *, treatment=None):
|
|||||||
required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',),
|
required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',),
|
||||||
'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',),
|
'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',),
|
||||||
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
|
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
|
||||||
'extend':('rhythm','contour'),'fragment':('rhythm',),'callback':('rhythm','pitches')}[treatment]
|
'extend':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'),
|
||||||
|
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
|
||||||
|
'develop_contour':('intervals',), 'continue':('contour',)}[treatment]
|
||||||
original=reference.notes
|
original=reference.notes
|
||||||
origin=original[0].start
|
origin=original[0].start
|
||||||
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
|
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
|
||||||
@@ -122,6 +98,17 @@ def rhythm_windows(library,meter,length,context):
|
|||||||
key=('theme-rhythm',id(library),meter,length)
|
key=('theme-rhythm',id(library),meter,length)
|
||||||
if key not in context.pools:
|
if key not in context.pools:
|
||||||
patterns={}
|
patterns={}
|
||||||
|
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||||
|
if length>bar:
|
||||||
|
# The two-bar catalog contains quarter-note masks. Include combinations
|
||||||
|
# of the richer one-bar material as complete timing alternatives, rather
|
||||||
|
# than accidentally excluding eighths/dotted values by snippet length.
|
||||||
|
# Pitch paths and feature contracts span the assembled rhythm's seams.
|
||||||
|
split=bar
|
||||||
|
for left,left_id in rhythm_windows(library,meter,split,context):
|
||||||
|
for right,right_id in rhythm_windows(library,meter,length-split,context):
|
||||||
|
combined=(*left,*((a+split,b+split) for a,b in right))
|
||||||
|
if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id)
|
||||||
for identifier,pattern,total in drafts.catalog(library,meter):
|
for identifier,pattern,total in drafts.catalog(library,meter):
|
||||||
if total<length:continue
|
if total<length:continue
|
||||||
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
||||||
@@ -134,13 +121,14 @@ def rhythm_windows(library,meter,length,context):
|
|||||||
return context.pools[key]
|
return context.pools[key]
|
||||||
|
|
||||||
|
|
||||||
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False):
|
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None):
|
||||||
length=finish-start; values={}
|
length=finish-start; values={}
|
||||||
if home and operation=='repeat_with_harmonic_adaptation':
|
if home and operation in ('repeat_with_harmonic_adaptation','return'):
|
||||||
pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length)
|
pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length)
|
||||||
if pattern:values[pattern]='heard-theme'
|
if pattern:values[pattern]='heard-theme'
|
||||||
prefix=tuple((n.start,n.end) for n in ref.notes)
|
prefix=tuple((n.start,n.end) for n in ref.notes)
|
||||||
if ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('extend','fragment','repeat_with_harmonic_adaptation'):
|
fixed_rhythm=ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('fragment','repeat_with_harmonic_adaptation','return','develop_rhythm')
|
||||||
|
if fixed_rhythm:
|
||||||
duration=prefix[-1][1]
|
duration=prefix[-1][1]
|
||||||
if duration==length:values[prefix]=ref.id
|
if duration==length:values[prefix]=ref.id
|
||||||
if duration<length:
|
if duration<length:
|
||||||
@@ -150,15 +138,28 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
|
|||||||
values.update(rhythm_windows(rhythms,meter,length,context))
|
values.update(rhythm_windows(rhythms,meter,length,context))
|
||||||
if home and operation=='answer':
|
if home and operation=='answer':
|
||||||
for p,label in rhythm_windows(rhythms,meter,length,context):values.setdefault(p,label)
|
for p,label in rhythm_windows(rhythms,meter,length,context):values.setdefault(p,label)
|
||||||
if terminal:
|
if not fixed_rhythm:
|
||||||
|
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted
|
||||||
|
# alternatives are actual contracts, not failed copies of a rhythmic seed.
|
||||||
for original,label in tuple(values.items()):
|
for original,label in tuple(values.items()):
|
||||||
for hold in (F(1,2),F(1),F(3,2),F(2)):
|
for i,(a,b) in enumerate(original):
|
||||||
for release in (F(),F(1,2)):
|
if b-a==1:
|
||||||
arrival=length-hold-release
|
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
|
||||||
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
|
for i,((a,b),(c,d)) in enumerate(zip(original,original[1:])):
|
||||||
if len(head)>=len(ref.notes):values[(*head,(arrival,length-release))]=label+f'; arrival {hold}, release {release}'
|
if b!=c:continue
|
||||||
return sequences.diverse(tuple(values.items()),lambda x:(max(0,len(x[0])-1.5*float(length),float(length)-len(x[0])),policy.stable_rank(ref.id,x[0])),
|
for split in (a+F(1,2),a+F(3,2),d-F(1,2)):
|
||||||
lambda x:(len(x[0]),x[0][-1][1]-x[0][-1][0],x[0][0][0]),4)
|
if a<split<d:
|
||||||
|
values[(*original[:i],(a,split),(split,d),*original[i+2:])]=label+'; rhythmic alteration'
|
||||||
|
if ending:
|
||||||
|
arrivals={}
|
||||||
|
arrival=ending.earliest-start
|
||||||
|
for original,label in values.items():
|
||||||
|
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
|
||||||
|
if len(head)>=len(ref.notes):
|
||||||
|
arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close'
|
||||||
|
values=arrivals
|
||||||
|
return sequences.diverse(tuple(values.items()),lambda x:(len(x[0])>16,policy.stable_rank(ref.id,x[0])),
|
||||||
|
lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives)
|
||||||
|
|
||||||
|
|
||||||
def pitches(harmony,note,base,*,members=False,limit=6):
|
def pitches(harmony,note,base,*,members=False,limit=6):
|
||||||
@@ -196,6 +197,14 @@ def instantiate(templates,first,steps,harmony):
|
|||||||
|
|
||||||
|
|
||||||
def prefix_paths(templates,ref,first,harmony,library):
|
def prefix_paths(templates,ref,first,harmony,library):
|
||||||
|
if not hasattr(harmony,'theme_prefixes'):harmony.theme_prefixes={}
|
||||||
|
key=(templates[:len(ref.notes)],ref,first,id(library))
|
||||||
|
if key not in harmony.theme_prefixes:
|
||||||
|
harmony.theme_prefixes[key]=_prefix_paths(templates,ref,first,harmony,library)
|
||||||
|
return harmony.theme_prefixes[key]
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_paths(templates,ref,first,harmony,library):
|
||||||
count=len(ref.notes); original=ref.notes
|
count=len(ref.notes); original=ref.notes
|
||||||
if count==1:return ((dataclasses.replace(templates[0],pitch=first),),)
|
if count==1:return ((dataclasses.replace(templates[0],pitch=first),),)
|
||||||
if ref.treatment in ('opening_fragment','characteristic_interval','callback'):
|
if ref.treatment in ('opening_fragment','characteristic_interval','callback'):
|
||||||
@@ -232,64 +241,91 @@ class Unit:
|
|||||||
routes: tuple[types.RouteCandidate,...] = ()
|
routes: tuple[types.RouteCandidate,...] = ()
|
||||||
|
|
||||||
|
|
||||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0):
|
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
|
||||||
# Position and the recent realization participate even when two paths share a last pitch.
|
# 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)
|
||||||
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,terminal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
||||||
if key in context.results:
|
if key in context.results:
|
||||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||||
result=[]; count=len(ref.notes)
|
result=[]; count=len(ref.notes)
|
||||||
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal):
|
jobs=[]
|
||||||
if len(pattern)<count:continue
|
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal,ending=ending):
|
||||||
|
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),
|
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))
|
'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 ref.notes[0].pitch.number()
|
||||||
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
|
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)
|
||||||
for first in firsts:
|
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+abs(target.number()-(goal if goal is not None else base))/48
|
||||||
|
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()
|
||||||
|
for job in jobs:
|
||||||
|
rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number()
|
||||||
|
if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen:
|
||||||
|
selected.append(job);rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry)
|
||||||
|
job_limit=policy.DEFAULT.theme_route_jobs*(batch+1)
|
||||||
|
for job in jobs:
|
||||||
|
if len(selected)>=job_limit:break
|
||||||
|
if job not in selected:selected.append(job)
|
||||||
|
for _,templates,label,first,target in selected[:job_limit]:
|
||||||
proposed=[]
|
proposed=[]
|
||||||
if home and operation=='repeat_with_harmonic_adaptation' and len(templates)==len(home.notes):
|
if home and operation in ('repeat_with_harmonic_adaptation','return') and len(templates)==len(home.notes):
|
||||||
heard=instantiate(templates,first,home.degree_steps,harmony)
|
heard=instantiate(templates,first,home.degree_steps,harmony)
|
||||||
if heard:proposed.append((heard,()))
|
if heard and heard[-1].pitch==target:proposed.append((heard,()))
|
||||||
for prefix in prefix_paths(templates,ref,first,harmony,library):
|
for prefix in prefix_paths(templates,ref,first,harmony,library)[:2+batch]:
|
||||||
if len(prefix)==len(templates):
|
if len(prefix)==len(templates):
|
||||||
proposed.append((prefix,()));continue
|
if prefix[-1].pitch==target:proposed.append((prefix,()))
|
||||||
base=prefix[-1].pitch.number()
|
continue
|
||||||
for target in pitches(harmony,templates[-1],base,members=True,limit=4 if batch else 2):
|
tail=(prefix[-1],*templates[count:])
|
||||||
tail=(prefix[-1],*templates[count:])
|
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
|
||||||
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,(41,76),operation+' '+ref.id):
|
proposed.append(((*prefix[:-1],*route.notes),(route,)))
|
||||||
proposed.append(((*prefix[:-1],*route.notes),(route,)))
|
seen=set()
|
||||||
for actual,connections in proposed:
|
for actual,connections in proposed:
|
||||||
|
identity=tuple(n.pitch.number() for n in actual)
|
||||||
|
if identity in seen:continue
|
||||||
|
seen.add(identity)
|
||||||
core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None)
|
core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None)
|
||||||
if references.feature_error(contract,actual):continue
|
if references.feature_error(contract,actual):continue
|
||||||
last=actual[-1]
|
last=actual[-1]
|
||||||
if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue
|
if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue
|
||||||
checked=harmony.evaluate(actual,until=finish,complete=True)
|
checked=harmony.evaluate(actual,until=finish,complete=True)
|
||||||
if not checked.valid:continue
|
if not checked.valid:continue
|
||||||
density=len(actual)*4/float(finish-start)
|
|
||||||
entry=max(0,abs(first.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
|
entry=max(0,abs(first.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(actual,actual[1:]))/max(1,len(actual)-1)
|
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)
|
||||||
# A repeat preserves the full heard idea. Answers and extensions
|
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
|
||||||
# preserve their declared fragment, not every event of the old ending.
|
# Activity belongs to the complete phrase; contracts are pass/fail.
|
||||||
development=references.relationship(actual,home) if home and operation=='repeat_with_harmonic_adaptation' else 0.
|
score=2*checked.exposure+entry+leaps+.35*direction
|
||||||
score=2*checked.exposure+entry+leaps+development+.35*max(0,4-density,density-6)
|
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
|
||||||
anchor=types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
|
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
|
||||||
last.end-last.start,'terminal' if terminal else 'intermediate',harmony.at(last.start).chord,hold_until=last.end)
|
|
||||||
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
|
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}
|
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
||||||
selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
||||||
lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number()%12,tuple(n.end-n.start for n in u.notes[:count])),8)
|
lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number(),tuple(n.end-n.start for n in u.notes[:count])),policy.DEFAULT.theme_unit_alternatives)
|
||||||
context.diagnostics['generated_candidates']+=len(selected)
|
context.diagnostics['generated_candidates']+=len(selected)
|
||||||
context.results[key]=selected
|
context.results[key]=selected
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def home_reference(home,operation):
|
def home_reference(home,operation):
|
||||||
notes=home.notes[:min(4,len(home.notes))]
|
# Prefer a distinguishing turn/leap wherever it actually occurred. The heard
|
||||||
# A short characteristic beginning leaves room for a newly composed answer.
|
# home remains immutable; a development never becomes the new source theme.
|
||||||
while len(notes)>3 and notes[-1].end>home.duration/2:notes=notes[:-1]
|
groups=[home.notes[i:i+3] for i in range(max(1,len(home.notes)-2))]
|
||||||
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,
|
def rank(group):
|
||||||
'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','extend','fragment') else 'contour_seed')
|
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)
|
||||||
|
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'
|
||||||
|
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
|
||||||
|
|
||||||
|
|
||||||
def make_home(unit,harmony,start=None,end=None):
|
def make_home(unit,harmony,start=None,end=None):
|
||||||
@@ -300,96 +336,135 @@ def make_home(unit,harmony,start=None,end=None):
|
|||||||
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony))
|
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony))
|
||||||
|
|
||||||
|
|
||||||
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False):
|
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
|
||||||
from generator.solo import leads,realization
|
from generator.solo import leads,realization
|
||||||
if source.end-source.start<2:
|
if source.end-source.start<2:
|
||||||
return short_fragment(source,incoming,part,harmony,tempo,seed,final)
|
return short_fragment(source,incoming,part,harmony,tempo,seed,final)
|
||||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||||
unit_length=2*bar
|
unit_length=2*bar
|
||||||
|
solo_end=solo_end or max(c.span.end for c in harmony.choices)
|
||||||
head=source_notes(analysis)
|
head=source_notes(analysis)
|
||||||
seed_key=('source-seeds',analysis,bar,seed)
|
seed_key=('source-seeds',analysis,bar,seed)
|
||||||
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
|
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
|
||||||
roots=context.pools[seed_key]
|
roots=context.pools[seed_key]
|
||||||
states=[(0.,(),incoming.home,incoming)]
|
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1))
|
||||||
|
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
|
||||||
cursor=source.start
|
cursor=source.start
|
||||||
while cursor<source.end:
|
while cursor<source.end and states:
|
||||||
finish=min(cursor+unit_length,source.end)
|
finish=min(cursor+unit_length,source.end)
|
||||||
|
if source.end-finish<6 or any(ending.earliest<finish for *_,ending in states):finish=source.end
|
||||||
next_states=[]
|
next_states=[]
|
||||||
for cost,units,home,state in states:
|
for cost,units,home,state,ending in states:
|
||||||
introducing=home is None
|
introducing=home is None
|
||||||
if introducing:
|
if introducing:
|
||||||
requests=[(r,'introduce') for r in roots]
|
requests=[(r,'introduce') for r in roots]
|
||||||
else:
|
else:
|
||||||
operations=('repeat_with_harmonic_adaptation',) if cursor==source.start and source.start<=4*bar else ('repeat_with_harmonic_adaptation','answer','extend')
|
operations=['continue'] if finish==source.end and cursor>source.start else ['develop_contour','develop_rhythm']
|
||||||
|
if cursor==source.start:operations.append('return')
|
||||||
requests=[(home_reference(home,op),op) for op in operations]
|
requests=[(home_reference(home,op),op) for op in operations]
|
||||||
used=incoming.callbacks+sum(u.operation=='callback' for u in units)
|
# A uniform duration alone is not identifying musical evidence.
|
||||||
if cursor>=8*bar and cursor==source.start and used<2:
|
requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))]
|
||||||
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:2] if r.end-r.start<=bar)
|
if cursor>=8*bar and cursor==source.start and incoming.callbacks<2:
|
||||||
|
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:1] if r.end-r.start<=bar)
|
||||||
for ref,operation in requests:
|
for ref,operation in requests:
|
||||||
|
if introducing and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
|
||||||
|
# A missing-harmony boundary can leave less than one full statement.
|
||||||
|
# Declare a smaller contour seed; never pretend it proves the full motif.
|
||||||
|
count=min(3,int(2*(ending.earliest-cursor)),len(ref.notes))
|
||||||
|
if count<2:
|
||||||
|
if len(ref.notes)!=1:continue
|
||||||
|
n=dataclasses.replace(ref.notes[0],end=min(ref.notes[0].end,F(1),ending.earliest-cursor))
|
||||||
|
ref=dataclasses.replace(ref,id=ref.id+':constrained_pitch',notes=(n,),treatment='opening_fragment')
|
||||||
|
else:
|
||||||
|
ref=dataclasses.replace(ref,id=ref.id+':constrained_contour',notes=ref.notes[:count],treatment='contour_seed')
|
||||||
|
if operation=='continue' and state.notes:
|
||||||
|
recent=state.notes[-min(3,len(state.notes)):]
|
||||||
|
origin=recent[0].start
|
||||||
|
ref=dataclasses.replace(ref,notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in recent),treatment='contour_seed')
|
||||||
if ref.notes[-1].end>finish-cursor:
|
if ref.notes[-1].end>finish-cursor:
|
||||||
reduced=tuple(n for n in ref.notes if n.end<=finish-cursor)
|
reduced=tuple(n for n in ref.notes if n.end<=finish-cursor)
|
||||||
if len(reduced)<2:continue
|
if len(reduced)<2:continue
|
||||||
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
|
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
|
||||||
operation='answer'
|
operation='continue'
|
||||||
|
terminal=finish==source.end
|
||||||
|
base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number()
|
||||||
|
goal=ending.pitch.number() if terminal else round(base+(ending.pitch.number()-base)*float((finish-cursor)/(source.end-cursor)))
|
||||||
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
||||||
terminal=final and finish==source.end,batch=batch)
|
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal)
|
||||||
for unit in options:
|
for unit in options:
|
||||||
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
|
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
|
||||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||||
actual_home=home or make_home(unit,harmony,cursor,finish)
|
actual_home=home or make_home(unit,harmony,cursor,finish)
|
||||||
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref)
|
figure=phrasing.heard(unit.notes,operation,unit.anchor)
|
||||||
next_states.append((cost+unit.score,(*units,unit),actual_home,following))
|
repetition=phrasing.repetition(figure,state.history,bar)
|
||||||
states=sequences.diverse(next_states,lambda v:(v[0],policy.stable_rank(seed,tuple(u.notes for u in v[1]))),
|
appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0.
|
||||||
lambda v:(v[2].id,v[1][-1].notes[-1].pitch.number()),2 if incoming.home and finish<source.end else 8)
|
history=phrasing.remember(state.history,figure,bar)
|
||||||
|
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref,
|
||||||
|
history=history,returns=(*state.returns,*((cursor,) if operation=='return' else ())),
|
||||||
|
register_peak=max(state.register_peak,*(n.pitch.number() for n in unit.notes)))
|
||||||
|
next_states.append((cost+unit.score+.8*repetition+appearance,(*units,unit),actual_home,following,ending))
|
||||||
|
def rank(value):
|
||||||
|
cost,units,home,state,ending=value
|
||||||
|
attacks=sum(len(u.notes) for u in units)
|
||||||
|
remaining=max(F(),ending.earliest-finish)
|
||||||
|
possible=attacks+2*float(remaining)+(0 if finish==source.end else 1)
|
||||||
|
expected=float(source.end-source.start)
|
||||||
|
activity_bound=.35*max(0,expected-possible,attacks-1.5*expected)/max(1,float(source.end-source.start)/4)
|
||||||
|
return cost/max(1,len(units))+activity_bound,policy.stable_rank(seed,tuple(u.notes for u in units))
|
||||||
|
states=sequences.diverse(next_states,rank,
|
||||||
|
lambda v:(v[2].id,v[4].pitch.number(),v[4].duration,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
|
||||||
if not states:return ()
|
if not states:return ()
|
||||||
cursor=finish
|
cursor=finish
|
||||||
result=[]
|
result=[]
|
||||||
for cost,units,home,state in states:
|
for cost,units,home,state,ending in states:
|
||||||
plain=tuple(n for u in units for n in u.notes)
|
plain=tuple(n for u in units for n in u.notes)
|
||||||
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
|
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
|
||||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||||
features=tuple(u.feature for u in units)
|
features=tuple(u.feature for u in units)
|
||||||
anchors=tuple(u.anchor for u in units)
|
# Cell tails are not compulsory melodic arrivals. Only the selected close
|
||||||
|
# has a sounding contract; intermediate turns remain editable.
|
||||||
|
anchors=(ending,)
|
||||||
|
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),)
|
||||||
options=[(plain,(),())]
|
options=[(plain,(),())]
|
||||||
if stage=='elaborated':
|
if stage=='elaborated':
|
||||||
protected=(*features,types.IdentifyingFeature('destinations',tuple(n.event_id for n in plain if any(n.event_id+':arrival'==a.id for a in anchors)),(),(),'',()))
|
for actual,edit,route in leads.elaborate(plain,features,harmony,library,rhythms,meter,source,sustain_entry=True,tails=tails):
|
||||||
for actual,edit,route in leads.elaborate(plain,protected,harmony,library,rhythms,meter,source,sustain_entry=True):
|
|
||||||
options.append((actual,(edit,),(route,)))
|
options.append((actual,(edit,),(route,)))
|
||||||
for notes,edits,selected_routes in options:
|
for notes,edits,selected_routes in options:
|
||||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
|
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
|
||||||
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
|
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
|
||||||
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
|
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
|
||||||
source_ref=state.source_reference
|
source_ref=state.source_reference
|
||||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,
|
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,(),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
||||||
tuple(a.id.removesuffix(':arrival') for a in anchors),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
|
||||||
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
|
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
|
||||||
source_links=tuple((n.event_id,n.sources) for n in notes))
|
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
|
||||||
# One shared musical context crosses computational/source boundaries.
|
|
||||||
activity=[];cursor=source.start
|
|
||||||
for n in notes:
|
|
||||||
if n.start>cursor:activity.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
|
|
||||||
activity.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
|
|
||||||
cursor=n.end
|
|
||||||
if cursor<source.end:activity.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
|
|
||||||
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
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,(41,76),F(5,4),
|
||||||
source.end-notes[-1].end,tuple(activity),(), 'sustain' if final else 'connected','closed' if final else 'open',seed,
|
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
|
||||||
source.id,source.start,source.end,(anchors,))
|
source.id,source.start,source.end,(anchors,))
|
||||||
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
||||||
if phrase is None:continue
|
if phrase is None:continue
|
||||||
density=len(notes)*4/float(source.end-source.start)
|
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)
|
||||||
score=cost/len(units)+.35*(max(0,4-density,density-6)-max(0,4-len(plain)*4/float(source.end-source.start),len(plain)*4/float(source.end-source.start)-6))
|
appearance_state=incoming
|
||||||
|
for unit in units:
|
||||||
|
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
|
||||||
|
if unit.operation=='return':
|
||||||
|
appearance_state=dataclasses.replace(appearance_state,returns=(*appearance_state.returns,unit.notes[0].start))
|
||||||
score+=.05*phrase.state.travel/max(1,len(notes))/2000
|
score+=.05*phrase.state.travel/max(1,len(notes))/2000
|
||||||
current=dataclasses.replace(phrase.state,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units))
|
history=incoming.history
|
||||||
# Store the actual introduced sound, including any legal elaboration.
|
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),
|
||||||
|
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:
|
if not incoming.home:
|
||||||
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
|
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),
|
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))
|
||||||
current=dataclasses.replace(current,home=heard,themes=(heard,))
|
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))
|
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=())
|
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
|
||||||
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
|
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,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)),
|
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(),sum(e.kind=='complete_route' for e in p.edits)),8)
|
lambda p:(p.state.home.id,p.state.last.pitch.number(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
|
||||||
|
|||||||
@@ -159,6 +159,24 @@ class ContinuationState:
|
|||||||
lead_identity: LeadIdentity | None = None
|
lead_identity: LeadIdentity | None = None
|
||||||
source_reference: SourceReference | None = None
|
source_reference: SourceReference | None = None
|
||||||
callbacks: int = 0
|
callbacks: int = 0
|
||||||
|
history: tuple[HeardFigure, ...] = ()
|
||||||
|
returns: tuple[fractions.Fraction, ...] = ()
|
||||||
|
closures: int = 0
|
||||||
|
register_peak: int = 0
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class HeardFigure:
|
||||||
|
start: fractions.Fraction
|
||||||
|
end: fractions.Fraction
|
||||||
|
events: tuple[tuple[fractions.Fraction, fractions.Fraction, int], ...]
|
||||||
|
operation: str
|
||||||
|
ending: tuple = ()
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class SustainedTail:
|
||||||
|
start: fractions.Fraction
|
||||||
|
end: fractions.Fraction
|
||||||
|
pitch: int
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class Transformation:
|
class Transformation:
|
||||||
@@ -233,6 +251,7 @@ class MelodicDraft:
|
|||||||
octave_transfer: int = 0
|
octave_transfer: int = 0
|
||||||
source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
|
source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
|
||||||
source_reference: SourceReference | None = None
|
source_reference: SourceReference | None = None
|
||||||
|
sustained_tails: tuple[SustainedTail, ...] = ()
|
||||||
def rests(self):
|
def rests(self):
|
||||||
cursor, result = self.start, []
|
cursor, result = self.start, []
|
||||||
for note in self.notes:
|
for note in self.notes:
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ add_test(
|
|||||||
COMMAND ${VAMP_PYTHON_COMMAND} -m unittest discover -s tests -p test_*.py
|
COMMAND ${VAMP_PYTHON_COMMAND} -m unittest discover -s tests -p test_*.py
|
||||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||||
)
|
)
|
||||||
set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 180)
|
# Complete deterministic phrase searches make this suite CPU-sensitive;
|
||||||
|
# leave headroom for concurrent asset generation on development machines.
|
||||||
|
set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 300)
|
||||||
add_custom_target(check
|
add_custom_target(check
|
||||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||||
--test-dir ${CMAKE_BINARY_DIR}
|
--test-dir ${CMAKE_BINARY_DIR}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import dataclasses
|
||||||
|
from fractions import Fraction as F
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from generator.solo import drafts, harmonic, phrasing, themes, types, leads, realization
|
||||||
|
from tests.test_solo_development import note
|
||||||
|
from tests.test_solo_passages import choices
|
||||||
|
from tests.test_solo_themes import melody
|
||||||
|
import tests.rhythm_fixtures
|
||||||
|
import tests.solo_fixtures
|
||||||
|
|
||||||
|
|
||||||
|
class SustainedPhrasing(unittest.TestCase):
|
||||||
|
def test_vocal_cutoff_does_not_strand_a_one_beat_final_phrase(self):
|
||||||
|
from generator.solo import analysis
|
||||||
|
from tests.test_solo import song
|
||||||
|
document=song(count=32,chords=('CM7',))
|
||||||
|
source=analysis.analyze(document)
|
||||||
|
harmony=harmonic.Harmony(choices(('CM7',)*32))
|
||||||
|
values=phrasing.window_options(source,document['staves'][0]['measures'],F(100),F(125),harmony,types.ContinuationState('',closures=5),limit=4)
|
||||||
|
self.assertEqual(values[0].end,125)
|
||||||
|
self.assertFalse(any(0<125-v.end<4 for v in values))
|
||||||
|
|
||||||
|
def test_production_cost_uses_complete_sounding_events_across_cells(self):
|
||||||
|
harmony=harmonic.Harmony(choices(('C7','C7')))
|
||||||
|
whole=(note(0,1,'g'),note(1,F(3,2),'f'),note(F(3,2),4,'e'))
|
||||||
|
split=(whole[0],whole[1],dataclasses.replace(whole[2],end=2),note(2,4,'e',continuation=True))
|
||||||
|
incoming=types.ContinuationState('')
|
||||||
|
self.assertEqual(phrasing.evaluate(whole,incoming,harmony,F(),F(4),F(4)),
|
||||||
|
phrasing.evaluate(split,incoming,harmony,F(),F(4),F(4)))
|
||||||
|
|
||||||
|
def test_route_cache_preserves_concrete_provenance_and_uncached_selection(self):
|
||||||
|
from generator.solo import routes,sequences,policy
|
||||||
|
harmony=harmonic.Harmony(choices(('C7',)))
|
||||||
|
notes=melody(((0,1,'g'),(1,2,'f'),(2,3,'e')))
|
||||||
|
library=tests.solo_fixtures.library()
|
||||||
|
raw=routes._candidates(notes,{'2'},harmony,library,(41,76))
|
||||||
|
expected=sequences.diverse(raw,
|
||||||
|
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
|
||||||
|
lambda r:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),policy.DEFAULT.join_alternatives)
|
||||||
|
cached=routes.candidates(notes,{'2'},harmony,library,(41,76))
|
||||||
|
self.assertEqual(tuple((r.id,r.notes) for r in expected),tuple((r.id,r.notes) for r in cached))
|
||||||
|
renamed=tuple(dataclasses.replace(n,event_id='other'+n.event_id,sources=('other-source',)) for n in notes)
|
||||||
|
changed=routes.candidates(renamed,{'other2'},harmony,library,(41,76))
|
||||||
|
self.assertEqual(len(harmony.route_cache),1)
|
||||||
|
self.assertTrue(all(n.sources==('other-source',) for r in changed for n in r.notes))
|
||||||
|
self.assertTrue(all(n.event_id.startswith('other') for r in changed for n in r.notes))
|
||||||
|
|
||||||
|
def test_extended_closing_unit_has_complete_timing_beyond_two_bars(self):
|
||||||
|
values=themes.rhythm_windows(tests.rhythm_fixtures.library(),'4/4',F(12),realization.SearchContext())
|
||||||
|
self.assertTrue(values)
|
||||||
|
self.assertTrue(any(any(a>=8 for a,b in pattern) for pattern,_ in values))
|
||||||
|
for pattern,_ in values:
|
||||||
|
self.assertTrue(all(0<=a<b<=12 for a,b in pattern))
|
||||||
|
self.assertTrue(all(b<=c for (a,b),(c,d) in zip(pattern,pattern[1:])))
|
||||||
|
|
||||||
|
def test_two_bar_retrieval_uses_the_richer_one_bar_vocabulary(self):
|
||||||
|
values=themes.rhythm_windows(tests.rhythm_fixtures.library(),'4/4',F(8),realization.SearchContext())
|
||||||
|
self.assertTrue(any(b-a==F(1,2) for pattern,_ in values for a,b in pattern))
|
||||||
|
|
||||||
|
def test_endpoint_ranking_distinguishes_major_seventh_from_dominant(self):
|
||||||
|
major=harmonic.Harmony(choices(('CM7',)*4))
|
||||||
|
dominant=harmonic.Harmony(choices(('C7',)*4))
|
||||||
|
self.assertLess(phrasing.endpoint_cost(F(16),F(),F(128),0,major,F(4)),
|
||||||
|
phrasing.endpoint_cost(F(16),F(),F(128),0,dominant,F(4)))
|
||||||
|
|
||||||
|
def test_protected_tail_is_sounding_time_not_an_immutable_whole_event(self):
|
||||||
|
original=(note(0,4,'e',event_id='arrival'),)
|
||||||
|
tail=types.SustainedTail(F(2),F(4),64)
|
||||||
|
draft=types.MelodicDraft('held',F(),F(4),original,(),(),(),(),None,'close',sustained_tails=(tail,))
|
||||||
|
revised=(note(0,1,'g',event_id='a'),note(1,2,'f',event_id='b'),note(2,4,'e',event_id='c'))
|
||||||
|
self.assertTrue(drafts.contract_valid(draft,revised)[0])
|
||||||
|
tied=(*revised[:-1],note(2,3,'e',event_id='c'),note(3,4,'e',event_id='c',continuation=True))
|
||||||
|
self.assertTrue(drafts.contract_valid(draft,tied)[0])
|
||||||
|
repeated=(*revised[:-1],note(2,3,'e',event_id='c'),note(3,4,'e',event_id='d'))
|
||||||
|
self.assertFalse(drafts.contract_valid(draft,repeated)[0])
|
||||||
|
|
||||||
|
def test_planned_endings_have_varied_timing_and_full_chord_permission(self):
|
||||||
|
harmony=harmonic.Harmony(choices(('Gm7','C7','FM7','BbM7')))
|
||||||
|
source=types.SourcePhrase('phrase',F(),F(16),(note(0,1,'d'),),(),'')
|
||||||
|
options=phrasing.endings(source,types.ContinuationState(''), 'acoustic',harmony,F(4))
|
||||||
|
self.assertTrue(options)
|
||||||
|
self.assertGreater(len({(a.earliest,a.duration) for a in options}),1)
|
||||||
|
for anchor in options:
|
||||||
|
self.assertGreaterEqual(anchor.duration,2)
|
||||||
|
self.assertLessEqual(anchor.duration,4)
|
||||||
|
self.assertIn(anchor.pitch.number()%12,harmony.members(anchor.earliest,anchor.hold_until))
|
||||||
|
self.assertTrue(harmony.evaluate((types.Note(anchor.earliest,anchor.hold_until,anchor.pitch),),complete=True).valid)
|
||||||
|
|
||||||
|
def test_rhythmic_development_keeps_a_heard_turn_without_copying_its_timing(self):
|
||||||
|
original=melody(((0,1,'d'),(1,2,'f'),(2,3,'e')))
|
||||||
|
home=types.RealizedMotif('home','seed',original,F(3),'introduce','contour')
|
||||||
|
ref=themes.home_reference(home,'develop_contour')
|
||||||
|
context=realization.SearchContext()
|
||||||
|
patterns=themes.patterns(ref,F(),F(8),tests.rhythm_fixtures.library(),'4/4',context,home=home,operation='develop_contour')
|
||||||
|
self.assertGreater(len({p[:3] for p,_ in patterns}),1)
|
||||||
|
actual=(note(0,F(1,2),'d',event_id='0'),note(F(1,2),2,'f',event_id='1'),note(2,3,'e',event_id='2'))
|
||||||
|
feature=themes.feature(ref,actual,treatment='develop_contour')
|
||||||
|
from generator.solo.references import feature_error
|
||||||
|
self.assertEqual(feature_error(feature,actual),0)
|
||||||
|
|
||||||
|
def test_repetition_remembers_recent_figures_but_not_tie_splits(self):
|
||||||
|
original=melody(((0,1,'d'),(1,2,'f'),(2,3,'e'),(3,4,'f')))
|
||||||
|
old=phrasing.heard(original,'introduce')
|
||||||
|
repeated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in original)
|
||||||
|
figure=phrasing.heard(repeated,'develop_contour')
|
||||||
|
self.assertGreater(phrasing.repetition(figure,(old,),F(4)),phrasing.repetition(figure,(),F(4)))
|
||||||
|
returned=dataclasses.replace(figure,operation='return')
|
||||||
|
self.assertGreater(phrasing.repetition(returned,(old,old,old),F(4)),phrasing.repetition(returned,(old,),F(4)))
|
||||||
|
hold=(note(0,4,'e'),)
|
||||||
|
split=(note(0,2,'e'),note(2,4,'e',continuation=True))
|
||||||
|
self.assertEqual(phrasing.heard(hold,'close'),phrasing.heard(split,'close'))
|
||||||
|
self.assertEqual(phrasing.repetition(phrasing.heard(hold,'close'),(),F(4)),0)
|
||||||
|
|
||||||
|
def test_high_electric_state_proposes_a_lower_goal(self):
|
||||||
|
high=melody(((0,1,'c'),(1,2,'e'),(2,3,'d')))
|
||||||
|
high=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=5)) for n in high)
|
||||||
|
state=types.ContinuationState('',last=high[-1],history=(phrasing.heard(high,'develop_contour'),),register_peak=76)
|
||||||
|
self.assertLess(phrasing.register_goal(state,'electric',74),min(n.pitch.number() for n in high))
|
||||||
|
|
||||||
|
def test_internal_short_notes_can_be_elaborated_without_consuming_arrival(self):
|
||||||
|
notes=melody(((0,1,'g'),(1,2,'f'),(2,3,'g'),(3,5,'e')))
|
||||||
|
source=types.SourcePhrase('internal',F(),F(5),notes,(),'')
|
||||||
|
harmony=harmonic.Harmony(choices(('C7','C7')))
|
||||||
|
tails=(types.SustainedTail(F(3),F(5),64),)
|
||||||
|
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True,tails=tails)
|
||||||
|
self.assertTrue(options)
|
||||||
|
for actual,_,_ in options:
|
||||||
|
self.assertTrue(any(n.pitch.number()==64 and n.start<=3 and n.end>=5 for n in actual))
|
||||||
|
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -93,10 +93,10 @@ class ThemeProduction(unittest.TestCase):
|
|||||||
for phrase in self.solo.phrases:
|
for phrase in self.solo.phrases:
|
||||||
self.assertTrue(drafts.contract_valid(phrase.draft,composer.phrase_notes(phrase))[0])
|
self.assertTrue(drafts.contract_valid(phrase.draft,composer.phrase_notes(phrase))[0])
|
||||||
self.assertFalse(similarity.near_copy(phrase.refined.notes,themes.source_notes(self.analysis),phrase.plan.start,phrase.plan.end)['near_copy'])
|
self.assertFalse(similarity.near_copy(phrase.refined.notes,themes.source_notes(self.analysis),phrase.plan.start,phrase.plan.end)['near_copy'])
|
||||||
self.assertEqual(self.solo.phrases[0].plan.closure,'open')
|
self.assertEqual(self.solo.phrases[0].plan.closure,'closed')
|
||||||
self.assertTrue(all(a.role=='intermediate' for a in self.solo.phrases[0].anchors))
|
self.assertTrue(all(a.duration>=2 for a in self.solo.phrases[0].anchors))
|
||||||
self.assertEqual(self.solo.phrases[-1].anchors[-1].role,'terminal')
|
self.assertEqual(self.solo.phrases[-1].anchors[-1].role,'terminal')
|
||||||
self.assertTrue(any(t.operation=='repeat_with_harmonic_adaptation' for p in self.solo.phrases for t in p.draft.transformations))
|
self.assertTrue(any(t.operation in ('develop_contour','develop_rhythm','continue','return') for p in self.solo.phrases for t in p.draft.transformations))
|
||||||
|
|
||||||
def test_library_routes_match_the_actual_complete_theme(self):
|
def test_library_routes_match_the_actual_complete_theme(self):
|
||||||
self.assertTrue(any(p.routes for p in self.solo.phrases))
|
self.assertTrue(any(p.routes for p in self.solo.phrases))
|
||||||
@@ -108,6 +108,17 @@ class ThemeProduction(unittest.TestCase):
|
|||||||
self.assertEqual((note.start,note.end,note.pitch),(actual.start,actual.end,actual.pitch))
|
self.assertEqual((note.start,note.end,note.pitch),(actual.start,actual.end,actual.pitch))
|
||||||
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
|
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
|
||||||
|
|
||||||
|
def test_report_can_compare_a_source_hold_cut_by_new_phrase_boundaries(self):
|
||||||
|
from generator.solo import report
|
||||||
|
phrases=[]
|
||||||
|
for i,p in enumerate(self.solo.phrases):
|
||||||
|
held=notes_fixture.note(p.plan.start,p.plan.end,'c',event_id='source_hold',continuation=i>0)
|
||||||
|
phrases.append(dataclasses.replace(p,initial=dataclasses.replace(p.initial,notes=(held,))))
|
||||||
|
solo=dataclasses.replace(self.solo,phrases=tuple(phrases))
|
||||||
|
detail=report.structure(solo)
|
||||||
|
self.assertEqual(len(detail['phrases']),len(phrases))
|
||||||
|
self.assertTrue(phrases[-1].initial.notes[0].continuation)
|
||||||
|
|
||||||
def test_incoming_pitch_changes_concrete_continuation_pool(self):
|
def test_incoming_pitch_changes_concrete_continuation_pool(self):
|
||||||
harmony=harmonic.Harmony(self.solo.choices)
|
harmony=harmonic.Harmony(self.solo.choices)
|
||||||
home=self.solo.phrases[0].state.home
|
home=self.solo.phrases[0].state.home
|
||||||
|
|||||||
Reference in New Issue
Block a user