This commit is contained in:
khodowany
2026-10-04 12:27:43 -05:00
parent 8fdd736e13
commit 405066eb43
18 changed files with 810 additions and 71 deletions
+5 -1
View File
@@ -93,7 +93,7 @@ Artifacts are published to the khodo generic package registry's required `latest
Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live in `data/`. Generated score JSON is not committed. Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live in `data/`. Generated score JSON is not committed.
`data/songs` supplies the original melody and harmony. The 21 scale patterns, 2,954 relative interval paths, meter-specific rhythm catalogs, and versioned rules in `data/composition` supply production vocabulary. Audition references are outside that path and never participate in construction, scoring, caching or selection. `data/songs` supplies the original melody and harmony. The 21 scale patterns, 2,954 relative interval paths, meter-specific rhythm catalogs, and versioned rules in `data/composition` supply production vocabulary. The melodic vocabulary adds purpose-bearing rhythm cells and transposable chord-member traversal operators, followed by mapped-scale connections to the chosen destination. Audition references are outside that path and never participate in construction, scoring, caching or selection.
Six roles generate independently from immutable source analysis: acoustic accompaniment/solo; electric accompaniment/melody/solo; vocal melody. A bounded process pool composes roles in parallel, with fully keyed per-role caches for incremental builds. `--composition-jobs 1` provides the serial equivalent; the default is two. There is no joint acoustic/electric selection, generated-solo-aware backing, or reuse of generated melody grips. Assembly respects source-defined role windows and the returning vocal pickup. Six roles generate independently from immutable source analysis: acoustic accompaniment/solo; electric accompaniment/melody/solo; vocal melody. A bounded process pool composes roles in parallel, with fully keyed per-role caches for incremental builds. `--composition-jobs 1` provides the serial equivalent; the default is two. There is no joint acoustic/electric selection, generated-solo-aware backing, or reuse of generated melody grips. Assembly respects source-defined role windows and the returning vocal pickup.
@@ -101,6 +101,10 @@ Each solo develops one realized theme from selected features across the actual h
Each accompaniment is a source-derived canonical part, reused unchanged at corresponding form positions. Sparse shells/dyads and compact voicings retain essential chord information, with deliberate releases and varied rhythms. Neither backing changes in response to generated solos. Source vocals and electric melody retain their established behavior. Each accompaniment is a source-derived canonical part, reused unchanged at corresponding form positions. Sparse shells/dyads and compact voicings retain essential chord information, with deliberate releases and varied rhythms. Neither backing changes in response to generated solos. Source vocals and electric melody retain their established behavior.
Optional solo exports are available with `-DVAMP_EXPORT_SOLO_XML=ON` in CMake (or `python -m generator --export-solo-xml ...`). They appear in `generated_assets/solo_xml/`: `*-solo1.xml` is acoustic and `*-solo2.xml` is electric. These contain the actual completed production notes, guitar octave transposition, and TAB positions; enabling export does not recompose music. The adjacent manifest records source and algorithm identities. A single-role build can add `--solo-xml build/example-solo1.xml`.
Reference comparison runs separately, after the build, through `python -m audition.solo_compare`. Its optional references never enter production selection or cache keys. For the four supplied scores, use `--reference-pitch guitar-written`: their notes sound one octave below the encoded pitches. Exact event differences are diagnostic, not a musical-quality score or a parameter-fitting objective.
See [Independent Musical Generation](TECHNICAL_GUIDE.md#independent-musical-generation) for the dependency graph, data inventory/provenance, algorithms, constraints, single-role CLI, cache behavior and optional audition/reference commands. Full comparison artifacts for this update are under `build/independent_generation_comparison/`; the baseline is never a production input. See [Independent Musical Generation](TECHNICAL_GUIDE.md#independent-musical-generation) for the dependency graph, data inventory/provenance, algorithms, constraints, single-role CLI, cache behavior and optional audition/reference commands. Full comparison artifacts for this update are under `build/independent_generation_comparison/`; the baseline is never a production input.
To inspect generated assets without building the full package, run: To inspect generated assets without building the full package, run:
+31 -1
View File
@@ -221,11 +221,12 @@ No accents, dynamics, velocity shaping, new swing interpretation or random timin
| `data/composition/harmony.json` | Add explicit optional tension/function/duration/resolution rules | Expression candidates and validation | | `data/composition/harmony.json` | Add explicit optional tension/function/duration/resolution rules | Expression candidates and validation |
| `data/composition/lead_ins.json` | Add generic target-relative formulas and blues resolutions | Complete expression candidates and validation | | `data/composition/lead_ins.json` | Add generic target-relative formulas and blues resolutions | Complete expression candidates and validation |
| `data/composition/rhythms.json` | Add purpose-bearing melodic cells and sparse comping figures | Solo joint realization, canonical comping | | `data/composition/rhythms.json` | Add purpose-bearing melodic cells and sparse comping figures | Solo joint realization, canonical comping |
| `data/composition/melodic_vocabulary.json` | Expand with 11 original purpose-bearing rhythm cells and four chord-member traversal operators; no fixed-pitch licks | Solo rhythm retrieval and complete routes only; excluded from accompaniment choice inputs |
| `data/lead_phrases/original_lead_theme.json` | Migrate unchanged to `audition/controls/`; remove from production data/build dependencies | Optional historical audition controls only | | `data/lead_phrases/original_lead_theme.json` | Migrate unchanged to `audition/controls/`; remove from production data/build dependencies | Optional historical audition controls only |
| `data/expected/performance_summary.json` | Keep validation-only form/harmony/vocal invariants; never used in selection | Post-generation compatibility check | | `data/expected/performance_summary.json` | Keep validation-only form/harmony/vocal invariants; never used in selection | Post-generation compatibility check |
| `build/*solo-[12].xml`, old generated assets, audition snapshots | Keep untouched, outside all musical input traces and caches | Explicit optional review only | | `build/*solo-[12].xml`, old generated assets, audition snapshots | Keep untouched, outside all musical input traces and caches | Explicit optional review only |
The five added JSON resources carry stable IDs, version 1, independent authorship provenance and MIT licensing. They were written from the musical brief and general principles, without extracting target notes, rhythm fingerprints, statistics or fitted weights. Existing catalog provenance is unchanged. No songs or useful combinatorial families were deleted. Legacy cell composition utilities remain for regression/explicit historical comparison; they are not a fallback production path. The six composition JSON resources carry stable IDs, version 1, independent authorship provenance and MIT licensing. They were written from the musical brief and general principles, without extracting target notes, rhythm fingerprints, statistics or fitted weights. Existing catalog provenance is unchanged. No songs or useful combinatorial families were deleted. Legacy cell composition utilities remain for regression/explicit historical comparison; they are not a fallback production path.
#### Commands and validation boundary #### Commands and validation boundary
@@ -258,6 +259,35 @@ Auditions export full solos plus opening/ending excerpts, solo stems, a common f
Current boundaries: source meter vocabulary is 2/4–5/4; unsupported meters or unrepresentable notation fail explicitly. The bounded composer cannot prove impossibility when it exhausts its budget. Expression vocabulary and local editing are deliberately finite, and the source-derived seven-section arrangement remains the existing product form. Independent parts can still collide or sound similar; diagnostics cannot automatically reselect either part. Playback remains one neutral mono tone profile, not a simulation of acoustic/electric timbre. Current boundaries: source meter vocabulary is 2/4–5/4; unsupported meters or unrepresentable notation fail explicitly. The bounded composer cannot prove impossibility when it exhausts its budget. Expression vocabulary and local editing are deliberately finite, and the source-derived seven-section arrangement remains the existing product form. Independent parts can still collide or sound similar; diagnostics cannot automatically reselect either part. Playback remains one neutral mono tone profile, not a simulation of acoustic/electric timbre.
#### Purposeful phrase refinement and optional solo exports
The `purposeful-phrases-2` solo policy retains the independent role architecture. Rhythm cells now retain their authored purpose through loading and eligibility filtering. A continuation favors grouped motion over isolated short notes; an arrival can broaden; an entrance or answer retains its own vocabulary. Diverse candidates reach joint pitch/rhythm realization before stable hash tie-breaking. There is no reference-derived attack or occupancy target. This revision expands the production vocabulary with 11 independently authored rhythm cells and four transposable chord-member traversal operators. The new resource is keyed only into solo roles, so additions do not change accompaniment choices. No target fragments, distributions, or fitted weights were migrated into it.
Search units remain bounded but can finish at the heard signature's duration instead of invariably ending after two bars. Different frontier states carry their own completed musical time. Unit costs are duration-weighted before pruning. The home identity, incoming pitch, route, history, and terminal contract survive these boundaries. A provisional same-pitch endpoint may share the next continuation's attack only when all feature, timing, harmony, and protected-tail contracts still validate. Intentional repeated attacks remain distinct. Route retrieval retains arpeggio families alongside ordinary mapped movement and does not charge every third as worse than a step. Data-defined operators traverse ascending/descending chord members, a member turn, or defining chord members, then connect the complete prefix to the selected destination through an ordinary scale route. Each operator uses the actual chord at each event, preserves fixed notes, and requires full-duration collection membership; it introduces no new chromatic exception.
`--export-solo-xml` writes completed solo role views under `solo_xml/` in the atomically published assets. The CMake equivalent is `VAMP_EXPORT_SOLO_XML=ON` (default OFF). Solo 1 maps to acoustic; Solo 2 maps to electric. The exporter neither generates nor changes notes. It preserves exact durations, ties, tuplets, harmony offsets, and technical string/fret annotations, and numbers this standalone view from one. Its manifest retains the original arrangement measure numbers and offset, source fingerprint, production settings/version, and full phrase report. The runtime format and full-arrangement numbering remain unchanged. The normal source importer still rejects authored TAB as a production input; optional inspection reads the exported technical annotations directly.
Example comparison after an export-enabled build:
```sh
./modulus.py python -m audition.solo_compare \
--generated-dir build/build_x86_debug/generated_assets/solo_xml \
--reference 'all_of_me/acoustic=build/all-of-me-solo-1.xml' \
--reference 'all_of_me/electric=build/all-of-me-solo-2.xml' \
--reference 'autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml' \
--reference 'autumn_leaves/electric=build/autumn-leaves-solo-2.xml' \
--form-start all_of_me/acoustic=1 --form-start all_of_me/electric=1 \
--form-start autumn_leaves/acoustic=2 --form-start autumn_leaves/electric=2 \
--reference-pitch guitar-written \
--assets build/build_x86_debug/generated_assets \
--output build/solo_comparison/review
```
This explicitly invoked, downstream command compares canonical sounding events with rational time. Tied notation fragments count as one event, repeated attacks do not. It reports pitch/onset/duration differences, separate spelling differences, rests, held-note harmonic roles, literal annotations, and legal guitar placement. The accepted user convention interprets these references as guitar-written notes; original encoded pitches remain in the report. No inferred conversion is based on their incidental `Vocal` part name. Autumn Leaves' initial material remains at negative form-relative time; no missing harmony is invented and no reference bar is discarded to manufacture equality. Reference notes beyond the source-derived solo cutoff are reported intact.
With `--assets`, it also renders generated/reference stems and identical-backing comparisons at the same neutral gains. The shared audition clock reserves room for reference-only lead-in material, explicitly reported in the manifest; this does not add notes to production or edit the reference XML. The existing production review exports baseline/new stages and final arrangements separately. All optional reports/audio live in build output and are never composition inputs. Exact matching is reported honestly and is not an acceptance requirement for independently invented melodies.
### Engraving Boundary ### Engraving Boundary
`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `generator.engraving.layout.layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `generator.engraving.raster.rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `generator.engraving.pipeline.engrave_tree(root, fonts)` is the asset-pipeline entry point. `fonts.Fonts`, `types.Style`, and `types.EngravingError` live in their respective engraving modules; page constants and `check_page` live in `validation`. Package initializers do not re-export these APIs. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving. `generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `generator.engraving.layout.layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `generator.engraving.raster.rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `generator.engraving.pipeline.engrave_tree(root, fonts)` is the asset-pipeline entry point. `fonts.Fonts`, `types.Style`, and `types.EngravingError` live in their respective engraving modules; page constants and `check_page` live in `validation`. Package initializers do not re-export these APIs. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving.
+17 -6
View File
@@ -8,12 +8,14 @@ from fractions import Fraction as F
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
def read(path, role, form_start=None): def read(path, role, form_start=None, pitch_convention='encoded'):
if pitch_convention not in ('encoded','guitar-written'):
raise ValueError('Unknown reference pitch convention: '+pitch_convention)
raw=Path(path).read_bytes();tree=ET.fromstring(raw) raw=Path(path).read_bytes();tree=ET.fromstring(raw)
result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role, result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role,
'pitch_convention':'encoded MusicXML; no inferred guitar octave','part_names':[n.text for n in tree.findall('./part-list/score-part/part-name')], 'pitch_convention':pitch_convention,'part_names':[n.text for n in tree.findall('./part-list/score-part/part-name')],
'measures':[],'notes':[],'harmonies':[],'annotations':[],'discrepancies':[]} 'measures':[],'notes':[],'harmonies':[],'annotations':[],'discrepancies':[]}
divisions=F(1);absolute=F();nominal=F(4);pending={};active=None divisions=F(1);absolute=F();nominal=F(4);pending={};active=None;transpose=F()
for index,m in enumerate(tree.findall('./part/measure')): for index,m in enumerate(tree.findall('./part/measure')):
cursor=F();extent=F();last_start=F();last_duration=F() cursor=F();extent=F();last_start=F();last_duration=F()
if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value) if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value)
@@ -22,7 +24,9 @@ def read(path, role, form_start=None):
nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type')) nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type'))
for item in m: for item in m:
offset=F(item.findtext('offset','0'))/divisions offset=F(item.findtext('offset','0'))/divisions
if item.tag=='backup':cursor-=F(item.findtext('duration'))/divisions if item.tag=='attributes' and item.find('transpose') is not None:
transpose=F(item.findtext('transpose/chromatic','0'))+12*F(item.findtext('transpose/octave-change','0'))
elif item.tag=='backup':cursor-=F(item.findtext('duration'))/divisions
elif item.tag=='forward':cursor+=F(item.findtext('duration'))/divisions;extent=max(extent,cursor) elif item.tag=='forward':cursor+=F(item.findtext('duration'))/divisions;extent=max(extent,cursor)
elif item.tag=='harmony': elif item.tag=='harmony':
active={'time':str(absolute+cursor+offset),'measure':index+1,'offset':str(cursor+offset), active={'time':str(absolute+cursor+offset),'measure':index+1,'offset':str(cursor+offset),
@@ -42,6 +46,8 @@ def read(path, role, form_start=None):
kinds={t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')} kinds={t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')}
event={'start':str(absolute+start),'end':str(absolute+end),'measure':index+1,'offset':str(start),'pitch':encoded,'rest':item.find('rest') is not None, event={'start':str(absolute+start),'end':str(absolute+end),'measure':index+1,'offset':str(start),'pitch':encoded,'rest':item.find('rest') is not None,
'written_type':item.findtext('type'),'dots':len(item.findall('dot')),'voice':voice,'ties':sorted(kinds), 'written_type':item.findtext('type'),'dots':len(item.findall('dot')),'voice':voice,'ties':sorted(kinds),
'sounding_transposition':str(-12 if pitch_convention=='guitar-written' else transpose),
'tablature':{'string':int(item.findtext('notations/technical/string')),'fret':int(item.findtext('notations/technical/fret'))} if item.find('notations/technical/string') is not None and item.find('notations/technical/fret') is not None else None,
'time_modification':ET.tostring(item.find('time-modification'),encoding='unicode') if item.find('time-modification') is not None else None, 'time_modification':ET.tostring(item.find('time-modification'),encoding='unicode') if item.find('time-modification') is not None else None,
'tuplets':[dict(t.attrib) for t in item.findall('notations/tuplet')],'grace':item.find('grace') is not None,'new_attack':'stop' not in kinds and p is not None} 'tuplets':[dict(t.attrib) for t in item.findall('notations/tuplet')],'grace':item.find('grace') is not None,'new_attack':'stop' not in kinds and p is not None}
key=(voice,json.dumps(encoded,sort_keys=True)) key=(voice,json.dumps(encoded,sort_keys=True))
@@ -59,6 +65,7 @@ def read(path, role, form_start=None):
result['measures'].append({'number':m.get('number'),'start':str(absolute),'duration':str(extent),'implicit':m.get('implicit'),'initial_harmony_known':any(F(h['time'])<=absolute for h in result['harmonies'])}) result['measures'].append({'number':m.get('number'),'start':str(absolute),'duration':str(extent),'implicit':m.get('implicit'),'initial_harmony_known':any(F(h['time'])<=absolute for h in result['harmonies'])})
absolute+=extent absolute+=extent
if pending:result['discrepancies'].append({'issue':'unterminated ties retained'}) if pending:result['discrepancies'].append({'issue':'unterminated ties retained'})
result['harmonies'].sort(key=lambda h:F(h['time']))
# Do not run the strict score importer over these references: annotations, # Do not run the strict score importer over these references: annotations,
# unusual timing and high encoded pitches are evidence, not repair requests. # unusual timing and high encoded pitches are evidence, not repair requests.
from generator import harmony, model, fretboard from generator import harmony, model, fretboard
@@ -69,7 +76,9 @@ def read(path, role, form_start=None):
if not event['pitch']:continue if not event['pitch']:continue
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter']) p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter'])
event['encoded_midi']=str(number) event['encoded_midi']=str(number)
event['sounding_midi']=str(number+F(event['sounding_transposition']))
event['legal_positions_at_encoded_pitch']=len(fretboard.single_positions_for_pitch(number)) event['legal_positions_at_encoded_pitch']=len(fretboard.single_positions_for_pitch(number))
event['legal_positions_at_sounding_pitch']=len(fretboard.single_positions_for_pitch(F(event['sounding_midi'])))
event['harmonic_spans']=[] event['harmonic_spans']=[]
start,end=F(event['start']),F(event['end']) start,end=F(event['start']),F(event['end'])
changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end] changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end]
@@ -93,7 +102,8 @@ def read(path, role, form_start=None):
if previous is not None and previous in tie_roots: if previous is not None and previous in tie_roots:
root=tie_roots[previous];sounded[root]['end']=n['end'];sounded[root]['notation_events'].append(i);tie_roots[i]=root root=tie_roots[previous];sounded[root]['end']=n['end'];sounded[root]['notation_events'].append(i);tie_roots[i]=root
else: else:
tie_roots[i]=len(sounded);sounded.append({'start':n['start'],'end':n['end'],'pitch':n['pitch'],'notation_events':[i]}) tie_roots[i]=len(sounded);sounded.append({'start':n['start'],'end':n['end'],'pitch':n['pitch'],
'encoded_midi':n['encoded_midi'],'sounding_midi':n['sounding_midi'],'measure':n['measure'],'notation_events':[i]})
result['sounding_events']=sounded result['sounding_events']=sounded
first=form_start or next((h['measure'] for h in result['harmonies']),None) first=form_start or next((h['measure'] for h in result['harmonies']),None)
offset=F(result['measures'][first-1]['start']) if first else None offset=F(result['measures'][first-1]['start']) if first else None
@@ -119,8 +129,9 @@ def main():
p.add_argument('--reference',action='append',default=[],metavar='ROLE=PATH') p.add_argument('--reference',action='append',default=[],metavar='ROLE=PATH')
p.add_argument('--form-start',action='append',default=[],metavar='ROLE=MEASURE') p.add_argument('--form-start',action='append',default=[],metavar='ROLE=MEASURE')
p.add_argument('--output',type=Path,required=True) p.add_argument('--output',type=Path,required=True)
p.add_argument('--reference-pitch',choices=('encoded','guitar-written'),default='encoded')
args=p.parse_args(); starts={k:int(v) for k,v in (x.split('=',1) for x in args.form_start)} args=p.parse_args(); starts={k:int(v) for k,v in (x.split('=',1) for x in args.form_start)}
results=[read(path,role,starts.get(role)) for role,path in (x.split('=',1) for x in args.reference)] results=[read(path,role,starts.get(role),args.reference_pitch) for role,path in (x.split('=',1) for x in args.reference)]
args.output.parent.mkdir(parents=True,exist_ok=True) args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps({'optional_validation_only':True,'references':results},indent=2)) args.output.write_text(json.dumps({'optional_validation_only':True,'references':results},indent=2))
+220
View File
@@ -0,0 +1,220 @@
"""Explicit post-generation comparison. Never imports or runs a composer."""
from __future__ import annotations
import argparse
import array
import collections
import html
import json
import os
import sys
import wave
from fractions import Fraction as F
from pathlib import Path
from audition.reference_review import read
from generator import fretboard
from generator.solo import placement, types
def events(review):
offset = F(review['alignment']['encoded_offset'] or 0)
return [dict(start=str(F(n['start'])-offset), end=str(F(n['end'])-offset),
pitch=n['sounding_midi'], spelling=n['pitch'], measure=n['measure']) for n in review['sounding_events']]
def rests(review):
offset = F(review['alignment']['encoded_offset'] or 0)
cursor = -offset
result = []
for n in sorted(events(review), key=lambda n:F(n['start'])):
start,end = F(n['start']),F(n['end'])
if start > cursor: result.append((str(cursor),str(start)))
cursor = max(cursor,end)
finish = max((F(m['start'])+F(m['duration']) for m in review['measures']),default=F())-offset
if cursor < finish: result.append((str(cursor),str(finish)))
return result
def playability(review, tempo=F(120)):
notes=[];conflicts=[]
for i,n in enumerate(review['sounding_events']):
p=n['pitch'];shift=F(n['sounding_midi'])-F(n['encoded_midi'])
if shift%12:
raise ValueError('Guitar audition expects octave-only transposition')
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+int(shift/12))
note=types.Note(F(n['start']),F(n['end']),pitch,event_id=str(i))
notes.append(note)
if not fretboard.single_positions_for_pitch(F(pitch.number())):
conflicts.append({'measure':n['measure'],'written':p,'sounding_midi':pitch.number(),
'constraint':'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'})
overlaps=[{'first':a.event_id,'second':b.event_id} for a,b in zip(notes,notes[1:]) if a.end>b.start]
placed=placement.place(tuple(notes),tempo) if notes and not conflicts and not overlaps else None
return {'conflicts':conflicts,'overlaps':overlaps,'complete_path_found':placed is not None,
'range':[min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
'positions':[list(n.position[:2]) for n in placed[1]] if placed else [],
'limitation':'Legal placement only. Shift cost is a preference, not a verified human speed limit.'}
def musical_summary(review):
ns=events(review)
durations=collections.Counter(str(F(n['end'])-F(n['start'])) for n in ns)
intervals=collections.Counter(str(F(b['pitch'])-F(a['pitch'])) for a,b in zip(ns,ns[1:]))
holds=[]
for event,n in zip(review['sounding_events'],ns):
if F(n['end'])-F(n['start'])<2:continue
spans=[s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']]
holds.append({**n,'harmonic_spans':spans})
return {'attacks':len(ns),'duration_counts':dict(durations),'interval_counts':dict(intervals),
'rests':rests(review),'sustained_events':holds,
'annotations':review['annotations'],'interpretation':'Post-generation diagnostics, never fitted production targets.'}
def compare(generated,reference):
a,b=events(generated),events(reference)
def grouped(ns):
result=collections.defaultdict(list)
for n in ns:result[n['start']].append(n)
return result
left,right=grouped(a),grouped(b)
differences=[];spelling=[];equal=0
for start in sorted(left.keys()|right.keys(),key=F):
x,y=left[start],right[start]
sounded=lambda ns:sorted((n['pitch'],n['end']) for n in ns)
if sounded(x)!=sounded(y):
differences.append({'time':start,'generated':x,'reference':y,
'context':'reference lead-in before form' if F(start)<0 else 'aligned form'})
else:
equal+=len(x)
if [n['spelling'] for n in x]!=[n['spelling'] for n in y]:
spelling.append({'time':start,'generated':x,'reference':y})
rest_difference=rests(generated)!=rests(reference)
return {'exact_musical_match':not differences and not rest_difference,'exact_matching_attacks':equal,
'differences':differences,'spelling_differences':spelling,'rest_difference':rest_difference,
'generated':musical_summary(generated),'reference':musical_summary(reference),
'reference_playability':playability(reference),'reference_original':reference}
def render_events(notes, duration, tempo, root, cache):
"""The same neutral note envelopes as production_review; no expression edits."""
from generator import playback
factor=F(60*playback.SAMPLE_RATE)/tempo
output=array.array('f',[0])* (round(duration*factor)+2400)
for start,end,pitch in notes:
if pitch not in cache:
sample=playback.generate_tone(root,pitch)
with wave.open(str(root/sample['path'])) as audio:
values=array.array('h',audio.readframes(audio.getnframes()))
if sys.byteorder!='little':values.byteswap()
cache[pitch]=(sample,values)
sample,values=cache[pitch]
first,last=round(start*factor),round(end*factor)
for frame in range(max(0,first),min(len(output),last+2400)):
age=frame-first
index=age if age<sample['loop_end'] else sample['loop_start']+(age-sample['loop_start'])%(sample['loop_end']-sample['loop_start'])
release=1 if frame<last else (1-(frame-last)/2399)**2
output[frame]+=release*values[index]/32767
return output
def audition_bounds(*reviews):
points=[(F(m['start'])-F(r['alignment']['encoded_offset'] or 0),F(m['duration']))
for r in reviews for m in r['measures']]
return min(a for a,b in points),max(a+b for a,b in points)
def audition_pair(actual,expected,role,assets,output,cache):
from generator import model, playback, score
from generator.solo.export import excerpt,region
song,part=role.split('/')
other='electric' if part=='acoustic' else 'acoustic'
completed=score.load_json(Path(assets)/'songs'/f'{song}_{part}.json')
first,count=region(completed,part)
backing=excerpt(score.load_json(Path(assets)/'songs'/f'{song}_{other}.json'),first,count)
tempo=next((model.fraction(t['bpm'])*model.written_length(t['written']) for m in completed['staves'][0]['measures'] for t in m.get('tempos',[]) if 'bpm' in t),F(120))
# Include the entire notated lead-in, even when it is a full bar of rest.
# Using the first attack here would silently erase a silent initial measure.
origin,end=audition_bounds(actual,expected)
duration=end-origin
timeline=playback.compile_timeline(backing)
back=render_events([(model.fraction(e['onset'])-origin,model.fraction(e['onset'])+model.fraction(e['duration'])-origin,model.fraction(e['pitch'])) for e in timeline['events']],duration,tempo,output,cache)
clips=[]
for label,r in (('generated',actual),('reference',expected)):
audio=render_events([(F(n['start'])-origin,F(n['end'])-origin,F(n['pitch'])) for n in events(r)],duration,tempo,output,cache)
combined=array.array('f',(a+.55*b for a,b in zip(audio,back)))
for context,values in (('solo',audio),('fixed_backing',combined)):
peak=.65*max(map(abs,values),default=0)
if peak>=1:raise ValueError('Comparison would clip at the shared rendering gain')
name=role.replace('/','_')+'_'+label+'_'+context+'.wav'
playback.write_pcm(output/name,(.65*v for v in values))
clips.append({'file':name,'version':label,'context':context,'peak':peak})
if context=='fixed_backing':
# An excerpt uses the completed source meter, never reference-derived
# rhythm statistics. Full references and their lead-ins remain above.
beats=sum((model.fraction(m['duration']) for m in backing['staves'][0]['measures'][:8]),F())
stop=round((min(end,beats)-origin)*60*playback.SAMPLE_RATE/tempo)
short=name.replace('.wav','_opening.wav')
playback.write_pcm(output/short,(.65*v for v in values[:stop]))
clips.append({'file':short,'version':label,'context':'opening with lead-in context','peak':peak})
return {'clips':clips,'tempo':str(tempo),'gain':.65,'backing_gain':.55,'form_begins_at_beat':str(-origin),
'alignment':'Shared audition clock includes reference-only lead-in; no harmony or production notes were added.',
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None):
generated_dir,output=Path(generated_dir),Path(output)
manifest=json.loads((generated_dir/'manifest.solo').read_text())
output.mkdir(parents=True,exist_ok=True)
tone_cache={}
result={'comparison_only':True,'feeds_generation':False,'solos':{}}
for role,path in sorted(reference_paths.items()):
item=manifest['solos'][role]
actual=read(generated_dir/item['file'],role,item['form_start_measure'])
expected=read(path,role,starts.get(role),pitch_convention)
review=compare(actual,expected)
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache)
review['production_trace']=item['trace']
review['production_phrases']=item.get('phrase_report',{})
# Preserve the real role cutoff; reference material beyond it is not trimmed.
phrases=review['production_phrases'].get('phrases',[])
if phrases:
cutoff=max(F(p['end']) for p in phrases)
review['production_end']=str(cutoff)
review['reference_events_after_production_end']=[n for n in events(expected) if F(n['end'])>cutoff]
result['solos'][role]=review
output.mkdir(parents=True,exist_ok=True)
(output/'comparison.json').write_text(json.dumps(result,indent=2)+'\n')
rows=['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
'<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}td,th{padding:.5rem;text-align:left}pre{white-space:pre-wrap}</style>',
'<h1>Completed solos and reference scores</h1><p>Differences are diagnostic. These files do not supply generation inputs or candidate scores. No listening verdict is claimed.</p>',
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a></p>']
for role,r in result['solos'].items():
actual_path=generated_dir/manifest['solos'][role]['file']
reference_path=Path(reference_paths[role])
rows += ['<h2>'+html.escape(role)+'</h2>',f'<p>{r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
'<p><a href="'+html.escape(os.path.relpath(actual_path,output))+'">Generated MusicXML</a> · <a href="'+html.escape(os.path.relpath(reference_path,output))+'">Untouched reference MusicXML</a></p>']
for clip in r.get('audition',{}).get('clips',[]):
rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>')
rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>')
for d in r['differences']:
def describe(ns):return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack'
rows.append('<tr>'+''.join('<td>'+html.escape(v)+'</td>' for v in (d['time'],describe(d['generated']),describe(d['reference'])))+'</tr>')
rows.append('</table></details>')
(output/'index.html').write_text('\n'.join(rows))
return result
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--generated-dir',type=Path,required=True)
parser.add_argument('--reference',action='append',default=[],metavar='SONG/ROLE=PATH')
parser.add_argument('--form-start',action='append',default=[],metavar='SONG/ROLE=MEASURE')
parser.add_argument('--reference-pitch',choices=('encoded','guitar-written'),default='encoded')
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--assets',type=Path,help='Optional completed assets for neutral reference/generated audio with identical backing.')
args=parser.parse_args()
run(args.generated_dir,dict(x.split('=',1) for x in args.reference),
{k:int(v) for k,v in (x.split('=',1) for x in args.form_start)},args.reference_pitch,args.output,args.assets)
if __name__=='__main__':main()
+7 -1
View File
@@ -152,6 +152,11 @@ set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated_assets)
set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.stamp) set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.stamp)
set(VAMP_ENGRAVING_JOBS 2 CACHE STRING "Maximum parallel Python engraving workers (1-16)") set(VAMP_ENGRAVING_JOBS 2 CACHE STRING "Maximum parallel Python engraving workers (1-16)")
set(VAMP_COMPOSITION_JOBS 2 CACHE STRING "Maximum independent musical role workers (1-16)") set(VAMP_COMPOSITION_JOBS 2 CACHE STRING "Maximum independent musical role workers (1-16)")
option(VAMP_EXPORT_SOLO_XML "Export completed solo roles as comparison MusicXML" OFF)
set(VAMP_SOLO_EXPORT_ARGS)
if(VAMP_EXPORT_SOLO_XML)
list(APPEND VAMP_SOLO_EXPORT_ARGS --export-solo-xml)
endif()
set(VAMP_ENGRAVING_CACHE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/engraving_cache" CACHE PATH "Shared build-only page cache; empty disables caching") set(VAMP_ENGRAVING_CACHE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/engraving_cache" CACHE PATH "Shared build-only page cache; empty disables caching")
set(VAMP_ENGRAVING_CACHE_ARGS) set(VAMP_ENGRAVING_CACHE_ARGS)
if(VAMP_ENGRAVING_CACHE_DIR) if(VAMP_ENGRAVING_CACHE_DIR)
@@ -159,7 +164,7 @@ if(VAMP_ENGRAVING_CACHE_DIR)
endif() endif()
string(JOIN "\n" VAMP_GENERATOR_INPUT_MANIFEST ${VAMP_GENERATOR_INPUTS}) string(JOIN "\n" VAMP_GENERATOR_INPUT_MANIFEST ${VAMP_GENERATOR_INPUTS})
file(CONFIGURE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.inputs file(CONFIGURE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.inputs
CONTENT "@VAMP_GENERATOR_INPUT_MANIFEST@\n" @ONLY) CONTENT "@VAMP_GENERATOR_INPUT_MANIFEST@\nsolo_xml=@VAMP_EXPORT_SOLO_XML@\n" @ONLY)
add_custom_command( add_custom_command(
OUTPUT ${VAMP_GENERATED_ASSETS_STAMP} OUTPUT ${VAMP_GENERATED_ASSETS_STAMP}
BYPRODUCTS ${VAMP_GENERATED_ASSETS_DIR}/catalog.db BYPRODUCTS ${VAMP_GENERATED_ASSETS_DIR}/catalog.db
@@ -174,6 +179,7 @@ add_custom_command(
--composition-cache ${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/composition_cache --composition-cache ${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/composition_cache
--progress --progress
${VAMP_ENGRAVING_CACHE_ARGS} ${VAMP_ENGRAVING_CACHE_ARGS}
${VAMP_SOLO_EXPORT_ARGS}
COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP} COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DEPENDS ${VAMP_GENERATOR_INPUTS} ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.inputs DEPENDS ${VAMP_GENERATOR_INPUTS} ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.inputs
+24
View File
@@ -0,0 +1,24 @@
{
"version": 1,
"provenance": "Independently authored, transposable rhythmic gestures and chord-member traversal operators. No notes, rhythms, measurements, or fitted parameters were extracted from the optional reference scores.",
"license": "MIT",
"cells": [
{"id": "pickup_to_statement", "purpose": "entrance", "values": ["r:1/2", "1/2", "1", "1/2", "1/2", "1"]},
{"id": "delayed_statement", "purpose": "entrance", "values": ["r:1", "1", "1/2", "1/2", "1"]},
{"id": "syncopated_connection", "purpose": "connect", "values": ["1/2", "1", "1/2", "1/2", "1/2"]},
{"id": "cross_bar_connection", "purpose": "connect", "values": ["r:3/2", "1/2", "1/2", "1/2", "1", "1/2", "1/2"]},
{"id": "quarter_eighth_development", "purpose": "continue", "values": ["1", "1/2", "1/2", "1", "1"]},
{"id": "held_note_continuation", "purpose": "continue", "values": ["3/2", "1/2", "1/2", "1/2", "1"]},
{"id": "answer_with_breath", "purpose": "answer", "values": ["1/2", "1/2", "3/2", "r:1/2", "1"]},
{"id": "short_then_broaden", "purpose": "answer", "values": ["1/2", "1", "3/2", "r:1"]},
{"id": "anticipated_sustain", "purpose": "arrival", "values": ["r:5/2", "1/2", "3"]},
{"id": "late_sustain", "purpose": "arrival", "values": ["1", "1/2", "5/2"]},
{"id": "triplet_resolution", "purpose": "arrival", "values": ["1/3", "1/3", "1/3", "2", "r:1"], "difficulty": "triplet"}
],
"chord_paths": [
{"id": "ascending_members", "purpose": ["entrance", "continue", "connect", "arrival"], "direction": "up", "member_steps": [1], "prefix_attacks": [2, 3], "max_interval": 7, "members": "basic", "require_chord_entry": true},
{"id": "descending_members", "purpose": ["entrance", "continue", "connect", "arrival"], "direction": "down", "member_steps": [1], "prefix_attacks": [2, 3], "max_interval": 7, "members": "basic", "require_chord_entry": true},
{"id": "member_turn", "purpose": ["continue", "connect", "answer"], "direction": "toward", "member_steps": [1, -1], "prefix_attacks": [3], "max_interval": 7, "members": "basic", "require_chord_entry": true},
{"id": "guide_connection", "purpose": ["continue", "connect", "answer", "arrival"], "direction": "toward", "member_steps": [1], "prefix_attacks": [2], "max_interval": 7, "members": "defining", "require_chord_entry": false}
]
}
+4
View File
@@ -43,6 +43,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16") parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-jobs", type=int, default=2, choices=range(1, 17), metavar="1-16") parser.add_argument("--composition-jobs", type=int, default=2, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-cache", type=pathlib.Path) parser.add_argument("--composition-cache", type=pathlib.Path)
parser.add_argument("--export-solo-xml", action="store_true", help="Export completed solo roles as comparison MusicXML, without recomposing.")
parser.add_argument("--progress", action="store_true") parser.add_argument("--progress", action="store_true")
args = parser.parse_args() args = parser.parse_args()
if not args.renderer_fingerprint and (args.data_dir is None or args.output_dir is None): if not args.renderer_fingerprint and (args.data_dir is None or args.output_dir is None):
@@ -90,6 +91,9 @@ def generate_assets(args: argparse.Namespace) -> None:
report('generating performance parts') report('generating performance parts')
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences, performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences,
composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress) composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress)
if getattr(args, 'export_solo_xml', False):
from generator.solo.export import export_tree
export_tree(staged)
generator.score.validate_generated_tree(staged) generator.score.validate_generated_tree(staged)
if args.check: if args.check:
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
+7
View File
@@ -12,13 +12,20 @@ def main():
parser.add_argument('--data-dir',type=Path,default=Path('data')) parser.add_argument('--data-dir',type=Path,default=Path('data'))
parser.add_argument('--output',type=Path,required=True) parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--cache-dir',type=Path) parser.add_argument('--cache-dir',type=Path)
parser.add_argument('--solo-xml',type=Path,help='Also export a completed solo role as MusicXML.')
parser.add_argument('--settings',type=Path) parser.add_argument('--settings',type=Path)
args=parser.parse_args() args=parser.parse_args()
if args.solo_xml and not args.role.endswith('_solo'):
parser.error('--solo-xml requires a solo role')
source=production.SourceAnalysis.read(parts.prepare_document(musicxml.load_musicxml(args.song))) source=production.SourceAnalysis.read(parts.prepare_document(musicxml.load_musicxml(args.song)))
start=time.monotonic() start=time.monotonic()
result=production.generate_role(source,args.role,args.data_dir,json.loads(args.settings.read_text()) if args.settings else {},args.cache_dir) result=production.generate_role(source,args.role,args.data_dir,json.loads(args.settings.read_text()) if args.settings else {},args.cache_dir)
args.output.parent.mkdir(parents=True,exist_ok=True) args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(result,indent=2)) args.output.write_text(json.dumps(result,indent=2))
if args.solo_xml:
from generator.solo.export import write_role
manifest=write_role(result,args.solo_xml)
args.solo_xml.with_suffix('.manifest.solo').write_text(json.dumps(manifest,indent=2)+'\n')
print(f'{args.role}: {time.monotonic()-start:.2f}s wall; cached={result["cache_hit"]}; original composition {result["seconds"]:.2f}s') print(f'{args.role}: {time.monotonic()-start:.2f}s wall; cached={result["cache_hit"]}; original composition {result["seconds"]:.2f}s')
if __name__=='__main__':main() if __name__=='__main__':main()
+9 -5
View File
@@ -104,7 +104,7 @@ def write_harmony(parent, value: dict, divisions: int) -> None:
element(item, 'degree-type', degree['type']) element(item, 'degree-type', degree['type'])
element(node, 'offset', int(generator.model.fraction(value['onset']) * divisions)) element(node, 'offset', int(generator.model.fraction(value['onset']) * divisions))
def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict]) -> None: def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict], tablature=False) -> None:
if event['kind'] == 'spacer': if event['kind'] == 'spacer':
item = element(parent, 'forward') item = element(parent, 'forward')
element(item, 'duration', int(generator.model.fraction(event['duration']) * divisions)) element(item, 'duration', int(generator.model.fraction(event['duration']) * divisions))
@@ -170,6 +170,10 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
for beam in event.get('beams', []): for beam in event.get('beams', []):
element(node, 'beam', beam['type'], number=beam['level']) element(node, 'beam', beam['type'], number=beam['level'])
notations = element(node, 'notations') notations = element(node, 'notations')
if tablature and pitch and pitch.get('tablature'):
technical = element(notations, 'technical')
element(technical, 'string', pitch['tablature']['string'])
element(technical, 'fret', pitch['tablature']['fret'])
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')): for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field): if pitch and pitch.get(field):
element(notations, 'tied', type=action) element(notations, 'tied', type=action)
@@ -204,7 +208,7 @@ def write_barline(bar, measure, location):
node = element(bar, 'barline', location=location) node = element(bar, 'barline', location=location)
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular')) element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree: def musicxml_tree(document: dict, *, tablature=False) -> xml.etree.ElementTree.ElementTree:
generator.model.validate_document(document) generator.model.validate_document(document)
root = xml.etree.ElementTree.Element('score-partwise', {'version': '4.0'}) root = xml.etree.ElementTree.Element('score-partwise', {'version': '4.0'})
metadata, staff = document['metadata'], document['staves'][0] metadata, staff = document['metadata'], document['staves'][0]
@@ -296,7 +300,7 @@ def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree:
onset = generator.model.fraction(event['onset']) onset = generator.model.fraction(event['onset'])
if onset > cursor: if onset > cursor:
element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions)) element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions))
write_note(bar, event, voice['id'], divisions, staff.get('tuplets', [])) write_note(bar, event, voice['id'], divisions, staff.get('tuplets', []), tablature)
cursor = onset + generator.model.fraction(event['duration']) cursor = onset + generator.model.fraction(event['duration'])
if cursor < generator.model.fraction(measure['duration']): if cursor < generator.model.fraction(measure['duration']):
element(element(bar, 'forward'), 'duration', int((generator.model.fraction(measure['duration']) - cursor) * divisions)) element(element(bar, 'forward'), 'duration', int((generator.model.fraction(measure['duration']) - cursor) * divisions))
@@ -305,7 +309,7 @@ def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree:
absolute += generator.model.fraction(measure['duration']) absolute += generator.model.fraction(measure['duration'])
return xml.etree.ElementTree.ElementTree(root) return xml.etree.ElementTree.ElementTree(root)
def write_musicxml(path: pathlib.Path, document: dict) -> None: def write_musicxml(path: pathlib.Path, document: dict, *, tablature=False) -> None:
tree = musicxml_tree(document) tree = musicxml_tree(document, tablature=tablature)
xml.etree.ElementTree.indent(tree, space=' ') xml.etree.ElementTree.indent(tree, space=' ')
tree.write(path, encoding='utf-8', xml_declaration=True) tree.write(path, encoding='utf-8', xml_declaration=True)
+6 -4
View File
@@ -78,7 +78,7 @@ def windows(document, part):
def dependencies(role, resources): def dependencies(role, resources):
names = ('grammar', 'harmony', 'lead_ins', 'rhythms') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else () names = ('grammar', 'harmony', 'lead_ins', 'rhythms', 'melodic_vocabulary') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else ()
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']}, return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**{name: resources[name] for name in names}} **{name: resources[name] for name in names}}
@@ -151,7 +151,9 @@ def generate_role(source, role, data_dir, settings=None, cache_dir=None):
if role not in ROLES: raise ValueError('Unknown generation role: '+role) if role not in ROLES: raise ValueError('Unknown generation role: '+role)
data_dir=Path(data_dir); resources=vocabulary.load(data_dir/'composition') data_dir=Path(data_dir); resources=vocabulary.load(data_dir/'composition')
settings=settings or {} settings=settings or {}
inputs={'version':VERSION,'source':source.musical_key,'role':role,'settings':settings, from generator.solo.policy import DEFAULT
algorithm=VERSION+'/'+DEFAULT.composition_version if role.endswith('_solo') else VERSION
inputs={'version':algorithm,'source':source.musical_key,'role':role,'settings':settings,
'vocabulary':dependencies(role,resources),'catalogs':_resource_key(data_dir,role),'code':_code_key(role)} 'vocabulary':dependencies(role,resources),'catalogs':_resource_key(data_dir,role),'code':_code_key(role)}
# Source notation/lyrics also invalidate cached score representation, never the musical seed. # Source notation/lyrics also invalidate cached score representation, never the musical seed.
key=vocabulary.fingerprint({'inputs':inputs,'notation':source.document_json}) key=vocabulary.fingerprint({'inputs':inputs,'notation':source.document_json})
@@ -165,8 +167,8 @@ def generate_role(source, role, data_dir, settings=None, cache_dir=None):
except (ValueError,KeyError,TypeError): pass except (ValueError,KeyError,TypeError): pass
started=time.monotonic(); document=source.document(); part=role.split('_')[0] started=time.monotonic(); document=source.document(); part=role.split('_')[0]
trace={'source':source.musical_key,'role':role,'production_resources':inputs['catalogs'], trace={'source':source.musical_key,'role':role,'production_resources':inputs['catalogs'],
'vocabulary':vocabulary.fingerprint(inputs['vocabulary']),'settings':settings,'algorithm':VERSION,'code':inputs['code'],'cache_key':key} 'vocabulary':vocabulary.fingerprint(inputs['vocabulary']),'settings':settings,'algorithm':algorithm,'code':inputs['code'],'cache_key':key}
seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':VERSION}) seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':algorithm})
details={}; solo_report='' details={}; solo_report=''
with vocabulary.using(resources,role,settings|{'choice_seed':seed}): with vocabulary.using(resources,role,settings|{'choice_seed':seed}):
if role.endswith('_solo'): if role.endswith('_solo'):
+84
View File
@@ -0,0 +1,84 @@
"""Optional MusicXML views of finished production roles; never composes."""
from __future__ import annotations
import copy
import json
from pathlib import Path
from fractions import Fraction as F
from generator import model, musicxml_writer, score
def region(document, part):
measures = document['staves'][0]['measures']
first = next(i for i, m in enumerate(measures) if m.get('section_label') == part.capitalize() + ' Solo')
end = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
return first, end - first
def excerpt(document, first, count):
result = copy.deepcopy(document)
staff = result['staves'][0]
original = staff['measures']
staff['measures'] = original[first:first + count]
if not staff['measures']:
raise ValueError('Empty solo export region')
notes = [n for m in staff['measures'] for n in score.measure_notes(m)]
identities = {p['id'] for n in notes for p in n['pitches']}
events = {n['id'] for n in notes}
for note in notes:
for pitch in note['pitches']:
for key in ('tie_from', 'tie_to'):
if pitch.get(key) not in identities:
pitch.pop(key, None)
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in events and t['end'] in events]
tuplets = {t['id'] for t in staff['tuplets']}
for note in notes:
if 'tuplet_ids' in note:
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
offset = sum((model.fraction(m['duration']) for m in original[:first]), F())
end = offset + sum((model.fraction(m['duration']) for m in staff['measures']), F())
for key in ('scale_spans', 'octave_shifts'):
staff[key] = [{**s, 'start': model.rational(max(offset, model.fraction(s['start'])) - offset),
'end': model.rational(min(end, model.fraction(s['end'])) - offset)} for s in staff.get(key, [])
if model.fraction(s['start']) < end and model.fraction(s['end']) > offset]
model.validate_document(result)
return result
def write_role(result, path):
role = result['role']
if role not in ('acoustic_solo', 'electric_solo'):
raise ValueError('Solo XML export requires an acoustic_solo or electric_solo role')
part = role.split('_')[0]
document = result['document']
first, count = region(document, part)
selected = excerpt(document, first, count)
numbers = [m['number'] for m in selected['staves'][0]['measures']]
for i, measure in enumerate(selected['staves'][0]['measures'], 1):
measure['number'] = str(i)
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
musicxml_writer.write_musicxml(path, selected, tablature=True)
return {'role': role, 'file': path.name, 'form_start_measure': 1,
'measure_count': count, 'arrangement_measures': numbers,
'arrangement_start_beat': str(sum((model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), F())),
'pitch_convention': 'guitar-written; explicit octave transposition -1',
'trace': result['trace'], 'phrase_report': result.get('details', {})}
def export_tree(assets, output=None):
assets = Path(assets)
output = Path(output) if output else assets / 'solo_xml'
output.mkdir(parents=True, exist_ok=True)
entries = {}
for path in sorted((assets / 'analysis').glob('*.roles')):
roles = json.loads(path.read_text())
for i, part in enumerate(('acoustic', 'electric'), 1):
name = path.stem.replace('_', '-') + f'-solo{i}.xml'
entries[path.stem + '/' + part] = write_role(roles[part + '_solo'], output / name)
manifest = {'version': 1, 'composition_inputs': False, 'solos': entries}
# Runtime score discovery treats *.json under assets as score documents.
# Diagnostic manifests deliberately use a non-score extension, like .roles.
(output / 'manifest.solo').write_text(json.dumps(manifest, sort_keys=True, indent=2) + '\n')
return manifest
+38 -2
View File
@@ -14,9 +14,45 @@ def fragmentation(notes):
return isolated + .1*gaps return isolated + .1*gaps
def shared_arrival(draft,harmony,treatments=()):
"""A provisional endpoint may also begin a continuation, without reattack.
Retain the continuation event's identity. A rhythm/exact-quote contract or
a committed destination can forbid sharing; full context is revalidated.
"""
notes=draft.notes
joins={t.start for t in draft.transformations[1:] if t.operation=='continue'}
fixed=set(draft.protected)|set(draft.structural)
fixed.update(n.event_id for n in notes if n.role=='quote')
fixed.update(e for f in draft.features if 'rhythm' in f.preserves for e in f.events)
edits=[]
for join in sorted(joins):
for i,(a,b) in enumerate(zip(notes,notes[1:])):
if b.start!=join or a.end!=b.start or a.pitch!=b.pitch:continue
if a.event_id in fixed or b.event_id in fixed:continue
actual=(*notes[:i],dataclasses.replace(b,start=a.start),*notes[i+2:])
if not drafts.contract_valid(draft,actual)[0]:continue
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid:continue
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]:continue
edits.append(types.DraftEdit('shared_arrival',a.start,b.end,(a.event_id,b.event_id),(b.event_id,),
'reopen provisional endpoint as the same sounding continuation; preserve feature and arrival contracts'))
notes=actual
break
if not edits:return draft,()
by_id={n.event_id:n for n in notes}
transforms=[]
for t in draft.transformations:
group=tuple(by_id[e] for e in t.events if e in by_id)
if group:
transforms.append(dataclasses.replace(t,start=group[0].start,end=group[-1].end,
events=tuple(n.event_id for n in group),rhythm=tuple((n.start-group[0].start,n.end-group[0].start) for n in group)))
return dataclasses.replace(draft,notes=notes,transformations=tuple(transforms)),tuple(edits)
def revise(draft, harmony, treatments=()): def revise(draft, harmony, treatments=()):
notes=draft.notes; edits=[] draft,shared=shared_arrival(draft,harmony,treatments)
if len(notes)<3: return draft, () notes=draft.notes; edits=list(shared)
if len(notes)<3: return draft, tuple(edits)
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'} protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
# An identifying rhythm also protects its internal rests. # An identifying rhythm also protects its internal rests.
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events) protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
+1 -1
View File
@@ -50,7 +50,7 @@ class Policy:
enclosure_routes: int = 4 enclosure_routes: int = 4
enclosure_frontier: int = 8 enclosure_frontier: int = 8
enclosure_max_per_32_bars: int = 4 enclosure_max_per_32_bars: int = 4
composition_version: str = 'independent-roles-1' composition_version: str = 'purposeful-phrases-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'
+72 -8
View File
@@ -9,6 +9,22 @@ import generator.solo.placement
import generator.solo.policy import generator.solo.policy
import generator.solo.sequences import generator.solo.sequences
import generator.solo.types as types import generator.solo.types as types
import generator.vocabulary
def path_cost(notes):
"""Penalize unexplained extremes, not every third or arpeggio interval."""
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:])]
excess=sum(max(0,abs(v)-7) for v in intervals)/max(1,len(intervals))
oscillation=sum(a!=0 and a==-b and a==c and b==d for a,b,c,d in zip(intervals,intervals[1:],intervals[2:],intervals[3:]))
return excess+.1*oscillation
def path_family(notes,harmony):
intervals=tuple(b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:]))
chordal=all(n.pitch.number()%12 in harmony.members(n.start,n.end) for n in notes)
return ('arpeggio' if chordal and any(abs(v)>=3 for v in intervals) else 'mapped',
generator.solo.sequences.family(intervals))
def advance(scale, previous, step): def advance(scale, previous, step):
try: try:
@@ -25,7 +41,7 @@ def advance(scale, previous, step):
outgoing.reverse() outgoing.reverse()
return tuple(outgoing[abs(step) - 1:abs(step)]) return tuple(outgoing[abs(step) - 1:abs(step)])
def candidates(notes, fixed, harmony, library, register, relationship='connect theme'): def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect'):
# Cache concrete routes only with their endpoints, timing, scale context and # Cache concrete routes only with their endpoints, timing, scale context and
# vocabulary. The per-Harmony cache cannot leak across collection changes. # vocabulary. The per-Harmony cache cannot leak across collection changes.
if not hasattr(harmony,'route_cache'): if not hasattr(harmony,'route_cache'):
@@ -33,7 +49,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
harmony.route_libraries={} harmony.route_libraries={}
harmony.concrete_routes={} harmony.concrete_routes={}
harmony.route_libraries[id(library)]=library harmony.route_libraries[id(library)]=library
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile) concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile,purpose)
if concrete_key in harmony.concrete_routes: if concrete_key in harmony.concrete_routes:
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key]) 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 # Event/source IDs and non-fixed placeholder pitches do not affect route
@@ -42,9 +58,9 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed) 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, 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)) 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,harmony.profile) key=(canonical,fixed_indices,id(library),register,harmony.profile,purpose)
if key not in harmony.route_cache: if key not in harmony.route_cache:
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register) harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register,purpose)
result=[] result=[]
for route in harmony.route_cache[key]: 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) 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)
@@ -52,15 +68,15 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16] 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)) result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
selected=generator.solo.sequences.diverse(result, 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:(path_cost(r.notes),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:]))), lambda r:path_family(r.notes,harmony),
generator.solo.policy.DEFAULT.join_alternatives) generator.solo.policy.DEFAULT.join_alternatives)
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes))) 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))) if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
harmony.concrete_routes[concrete_key]=selected harmony.concrete_routes[concrete_key]=selected
return selected return selected
def _candidates(notes, fixed, harmony, library, register): def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
"""Every returned candidate includes its entry, complete rhythm and destination. """Every returned candidate includes its entry, complete rhythm and destination.
Callers validate the whole surrounding sounding context and guitar path. Callers validate the whole surrounding sounding context and guitar path.
@@ -83,7 +99,24 @@ def _candidates(notes, fixed, harmony, library, register):
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label()) 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) for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
result=[] result=[]
for pitches,sources in harmony.pitch_routes[key]: paths=list(harmony.pitch_routes[key])
# These are traversal operators, not stored phrases. An arpeggio prefix
# adapts to the chord at each note and a complete scale route then connects
# it to this caller's actual destination. No endpoint is attacked twice.
for prefix,rule in chord_prefixes(notes,fixed,harmony,register,purpose):
i=len(prefix)-1
if i==len(notes)-1:
if prefix[-1].pitch.number()==destination.pitch.number():
paths.append((tuple(n.pitch for n in prefix),('chord-path:'+rule,)))
continue
suffix_key=(prefix[-1].pitch,contexts[i:],id(library),register,harmony.profile)
if suffix_key not in harmony.pitch_routes:
while len(harmony.pitch_routes)>=8192:
harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
harmony.pitch_routes[suffix_key]=pitch_paths(prefix[-1].pitch,contexts[i:],library,register,harmony.profile)
for tail,sources in harmony.pitch_routes[suffix_key][:2]:
paths.append((tuple(n.pitch for n in prefix[:-1])+tail,('chord-path:'+rule,*sources)))
for pitches,sources in paths:
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None) 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:]))) for n,p in zip(notes[1:],pitches[1:])))
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16] identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
@@ -91,6 +124,37 @@ def _candidates(notes, fixed, harmony, library, register):
return tuple(result) return tuple(result)
def chord_prefixes(notes,fixed,harmony,register,purpose):
from generator.solo import phrasing
if len(notes)<2:return ()
values=[]
first=notes[0];destination=notes[-1]
for rule in generator.vocabulary.data('melodic_vocabulary')['chord_paths']:
if purpose not in rule['purpose']:continue
if rule['require_chord_entry'] and first.pitch.number()%12 not in harmony.members(first.start,first.end):continue
displacement=destination.pitch.number()-first.pitch.number()
direction=1 if rule['direction']=='up' else -1 if rule['direction']=='down' else (1 if displacement>=0 else -1)
for count in rule['prefix_attacks']:
if count>len(notes):continue
prefix=[first]
for i,n in enumerate(notes[1:count]):
span=harmony.at(n.start)
if not span or not span.scale:break
eligible=harmony.members(n.start,n.end)&types.permitted(harmony.choices,n.start,n.end)
if rule['members']=='defining':eligible=eligible&phrasing.defining_members(span.chord)
step=rule['member_steps'][i%len(rule['member_steps'])]
old=prefix[-1].pitch.number()
pitches=sorted((types.scale_pitch(span.scale,j) for j in range(-21,29)),key=lambda p:p.number(),reverse=direction*step<0)
options=[p for p in pitches if p.number()%12 in eligible and 0<(p.number()-old)*direction*step
and abs(p.number()-old)<=rule['max_interval'] and register[0]<=p.number()<=register[1] and harmony.profile.permits(p.number())]
if len(options)<abs(step):break
selected=options[abs(step)-1]
if (n.event_id in fixed or i+1==len(notes)-1) and selected.number()!=n.pitch.number():break
prefix.append(dataclasses.replace(n,pitch=selected))
if len(prefix)==count:values.append((tuple(prefix),rule['id']))
return tuple(values)
def pitch_paths(first, contexts, library, register, profile=None): def pitch_paths(first, contexts, library, register, profile=None):
"""Time-independent pitch work; context includes every overlapped collection. """Time-independent pitch work; context includes every overlapped collection.
+75 -36
View File
@@ -99,10 +99,34 @@ def feature(reference, actual, *, treatment=None):
tuple(n.pitch.number()+octave for n in original)) tuple(n.pitch.number()+octave for n in original))
def rhythm_windows(library,meter,length,context): def rhythmic_purpose(operation, terminal=False):
if terminal:return 'arrival'
if operation in ('introduce','callback','return','repeat_with_harmonic_adaptation'):return 'entrance'
if operation in ('answer','fragment'):return 'answer'
return 'connect' if operation=='continue' else 'continue'
def rhythm_cost(pattern,length,purpose):
"""Evaluate a rhythmic job, not resemblance to any target score or density."""
if not pattern:return float('inf')
gaps=[b[0]-a[1] for a,b in zip(pattern,pattern[1:])]
isolated=sum(b-a<=F(1,2) and prev[1]<a and b<nxt[0]
for prev,(a,b),nxt in zip(pattern,pattern[1:],pattern[2:]))
values={b-a for a,b in pattern}
uniform=.15 if len(pattern)>=4 and len(values)==1 else 0.
if purpose in ('continue','connect'):
# Silence may articulate an idea; disconnected tiny islands do not carry
# unfinished motion. No minimum occupancy or chorus attack quota.
return isolated+.25*float(sum(gaps,F())/length)+uniform
if purpose=='arrival':
return isolated+float(max(F(),F(1)- (pattern[-1][1]-pattern[-1][0])))/2+uniform
return isolated+uniform
def rhythm_windows(library,meter,length,context,purpose='continue'):
from generator.solo import drafts from generator.solo import drafts
tempo=getattr(context,'tempo',120) tempo=getattr(context,'tempo',120)
key=('theme-rhythm',id(library),meter,length,tempo) key=('theme-rhythm',id(library),meter,length,tempo,purpose)
if key not in context.pools: if key not in context.pools:
patterns={} patterns={}
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
@@ -112,8 +136,8 @@ def rhythm_windows(library,meter,length,context):
# than accidentally excluding eighths/dotted values by snippet length. # than accidentally excluding eighths/dotted values by snippet length.
# Pitch paths and feature contracts span the assembled rhythm's seams. # Pitch paths and feature contracts span the assembled rhythm's seams.
split=bar split=bar
for left,left_id in rhythm_windows(library,meter,split,context): for left,left_id in rhythm_windows(library,meter,split,context,'continue' if purpose=='arrival' else purpose):
for right,right_id in rhythm_windows(library,meter,length-split,context): for right,right_id in rhythm_windows(library,meter,length-split,context,purpose):
combined=(*left,*((a+split,b+split) for a,b in right)) combined=(*left,*((a+split,b+split) for a,b in right))
if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id) 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):
@@ -123,22 +147,29 @@ def rhythm_windows(library,meter,length,context):
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length) selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if selected and len(selected)<=16:patterns.setdefault(selected,identifier) if selected and len(selected)<=16:patterns.setdefault(selected,identifier)
# Authored cells can start late and continue over computational barlines. # Authored cells can start late and continue over computational barlines.
for identifier,pattern,total in generator.vocabulary.rhythm_cells(tempo): for cell in generator.vocabulary.rhythm_cells(tempo,purpose):
identifier,pattern,total=cell.id,cell.pattern,cell.duration
if total>length:continue if total>length:continue
for offset in (F(),F(1,2),max(F(),length-total)): for offset in (F(),F(1,2),max(F(),length-total)):
if offset+total>length:continue if offset+total>length:continue
head=tuple((a+offset,b+offset) for a,b in pattern) head=tuple((a+offset,b+offset) for a,b in pattern)
tails=tuple((a,b) for a,b in next(iter(patterns),()) if a>=offset+total) # Keep representative compatible continuations; never let dictionary
combined=(*head,*tails) # insertion order secretly choose the only tail for an authored cell.
if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier) tails=sequences.diverse(tuple(patterns),lambda p:(rhythm_cost(p,length,purpose),policy.stable_rank('rhythm-tail',p)),
lambda p:(len(p),p[-1][1]-p[-1][0]),2) or ((),)
for tail in tails:
if any(a<offset+total<b for a,b in tail):continue
combined=(*head,*((a,b) for a,b in tail if a>=offset+total))
if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier)
context.pools[key]=sequences.diverse(tuple(patterns.items()), context.pools[key]=sequences.diverse(tuple(patterns.items()),
lambda x:(len(x[0])>16,policy.stable_rank('rhythm-window',x[0])), lambda x:(rhythm_cost(x[0],length,purpose)+(0 if x[1].startswith('authored:') else .05),policy.stable_rank('rhythm-window',x[0])),
lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8) lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
return context.pools[key] return context.pools[key]
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None): def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None):
length=finish-start; values={} length=finish-start; values={}
purpose=rhythmic_purpose(operation,terminal)
if home and operation in ('repeat_with_harmonic_adaptation','return'): 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'
@@ -148,12 +179,12 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
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:
for tail,label in rhythm_windows(rhythms,meter,length-duration,context): for tail,label in rhythm_windows(rhythms,meter,length-duration,context,purpose):
values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label
else: else:
values.update(rhythm_windows(rhythms,meter,length,context)) values.update(rhythm_windows(rhythms,meter,length,context,purpose))
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,purpose):values.setdefault(p,label)
if not fixed_rhythm: if not fixed_rhythm:
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted # Develop the heard timing, preserving total duration. Eighth/quarter/dotted
# alternatives are actual contracts, not failed copies of a rhythmic seed. # alternatives are actual contracts, not failed copies of a rhythmic seed.
@@ -174,7 +205,7 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
if len(head)>=len(ref.notes): if len(head)>=len(ref.notes):
arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close' arrivals[(*head,(arrival,ending.hold_until-start))]=label+'; planned sustained close'
values=arrivals values=arrivals
return sequences.diverse(tuple(values.items()),lambda x:(len(x[0])>16,policy.stable_rank(ref.id,x[0])), return sequences.diverse(tuple(values.items()),lambda x:(rhythm_cost(x[0],length,purpose),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) lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives)
@@ -251,10 +282,10 @@ def _prefix_paths(templates,ref,first,harmony,library):
source_motion=original[-1].pitch.number()-original[0].pitch.number() source_motion=original[-1].pitch.number()-original[0].pitch.number()
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4): for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count])) template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'seed '+ref.id)) paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'seed '+ref.id,purpose='entrance'))
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)] valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
return sequences.diverse(valid,lambda ns:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)), return sequences.diverse(valid,lambda ns:(routes.path_cost(ns),policy.stable_rank(ref.id,ns)),
lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4) lambda ns:routes.path_family(ns,harmony),4)
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
@@ -267,6 +298,8 @@ class Unit:
score: float score: float
source: str source: str
routes: tuple[types.RouteCandidate,...] = () routes: tuple[types.RouteCandidate,...] = ()
start: F = F()
end: F = F()
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None): def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
@@ -317,7 +350,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
continue continue
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,harmony.profile.register,operation+' '+ref.id)[:3+batch]: for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,harmony.profile.register,operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal))[:3+batch]:
proposed.append(((*prefix[:-1],*route.notes),(route,))) proposed.append(((*prefix[:-1],*route.notes),(route,)))
seen=set() seen=set()
for actual,connections in proposed: for actual,connections in proposed:
@@ -337,7 +370,8 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal) score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register, anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register,
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end) last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections)) score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections,start,finish))
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(),tuple(n.end-n.start for n in u.notes[:count])),policy.DEFAULT.theme_unit_alternatives) 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)
@@ -366,12 +400,25 @@ def make_home(unit,harmony,start=None,end=None):
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)]) return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)])
def unit_end(source,cursor,home,ending,bar):
"""Bound search by time without cutting the heard signature at a barline."""
length=2*bar
if home and home.signature:
length=home.signature[-1].end
while 0<length<bar:
length+=home.signature[-1].end
length=min(2*bar,max(bar,length))
finish=min(source.end,cursor+length)
if source.end-finish<bar+2 or ending.earliest<finish:
finish=source.end
return finish
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None): 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
solo_end=solo_end or max(c.span.end for c in harmony.choices) 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)
@@ -379,17 +426,14 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
roots=context.pools[seed_key] roots=context.pools[seed_key]
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1),final=final) ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1),final=final)
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options] states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
cursor=source.start while states and any(not units or units[-1].end<source.end for _,units,_,_,_ in states):
while cursor<source.end and states:
finish=min(cursor+unit_length,source.end)
if cursor==source.start and source.end-source.start>=3*bar:
# Selected home rhythms finish as objects, not at the nearest barline.
signature=incoming.home.signature if incoming.home else ()
if signature and signature[-1].end>unit_length:
finish=min(source.end,cursor+signature[-1].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,ending in states: for cost,units,home,state,ending in states:
cursor=units[-1].end if units else source.start
if cursor>=source.end:
next_states.append((cost,units,home,state,ending))
continue
finish=unit_end(source,cursor,home,ending,bar)
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]
@@ -437,19 +481,14 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref, 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 ())), history=history,returns=(*state.returns,*((cursor,) if operation=='return' else ())),
register_peak=max(state.register_peak,*(n.pitch.number() for n in unit.notes))) 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)) next_states.append((cost+float(finish-cursor)*(unit.score+.8*repetition+appearance),(*units,unit),actual_home,following,ending))
def rank(value): def rank(value):
cost,units,home,state,ending=value cost,units,home,state,ending=value
attacks=sum(len(u.notes) for u in units) elapsed=units[-1].end-source.start
remaining=max(F(),ending.earliest-finish) return cost/max(.001,float(elapsed)),policy.stable_rank(seed,tuple(u.notes for u in units))
possible=attacks+2*float(remaining)+(0 if finish==source.end else 1)
expected=float(source.end-source.start)
activity_bound=0.0 # Activity is a gesture/difficulty property, not a chorus quota.
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, 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) 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
result=[] result=[]
for cost,units,home,state,ending 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)
+31 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import contextvars import contextvars
import dataclasses
import functools import functools
import hashlib import hashlib
import json import json
@@ -14,7 +15,7 @@ ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
def load(root=ROOT): def load(root=ROOT):
# Content, not just the directory, keys memoization. Long-lived individual # Content, not just the directory, keys memoization. Long-lived individual
# role builds must observe edited production data without restarting Python. # role builds must observe edited production data without restarting Python.
names = ('roles', 'grammar', 'harmony', 'lead_ins', 'rhythms') names = ('roles', 'grammar', 'harmony', 'lead_ins', 'rhythms', 'melodic_vocabulary')
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names)) return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names))
@functools.lru_cache(maxsize=16) @functools.lru_cache(maxsize=16)
@@ -28,6 +29,21 @@ def _load(contents):
if len({v['id'] for v in entries}) != len(entries): if len({v['id'] for v in entries}) != len(entries):
raise ValueError(f'{name}: duplicate vocabulary ID') raise ValueError(f'{name}: duplicate vocabulary ID')
result[name] = value result[name] = value
cells=result['rhythms']['cells']+result['melodic_vocabulary']['cells']
if len({c['id'] for c in cells})!=len(cells):
raise ValueError('Duplicate rhythmic identity across production vocabularies')
for c in cells:
if c['purpose'] not in ('entrance','continue','connect','answer','arrival'):
raise ValueError(c['id']+': unknown rhythmic purpose')
paths=result['melodic_vocabulary']['chord_paths']
if len({p['id'] for p in paths})!=len(paths):raise ValueError('Duplicate chord-path operator')
for p in paths:
if p['direction'] not in ('up','down','toward') or p['members'] not in ('basic','defining'):
raise ValueError(p['id']+': invalid chord traversal')
if not p['member_steps'] or any(type(s)!=int or not 1<=abs(s)<=3 for s in p['member_steps']):
raise ValueError(p['id']+': invalid chord-member step')
if not p['prefix_attacks'] or any(n not in (2,3,4) for n in p['prefix_attacks']) or not 1<=p['max_interval']<=12:
raise ValueError(p['id']+': unsupported bounded chord prefix')
return result return result
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
@@ -56,9 +72,20 @@ def using(resources, name, settings=None):
def fingerprint(value): def fingerprint(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def rhythm_cells(tempo): @dataclasses.dataclass(frozen=True)
class RhythmCell:
id: str
pattern: tuple
duration: F
purpose: str
difficulty: str | None = None
def rhythm_cells(tempo, purpose=None):
limits = data('roles')['difficulty'] limits = data('roles')['difficulty']
for cell in data('rhythms')['cells']: for cell in (*data('rhythms')['cells'],*data('melodic_vocabulary')['cells']):
if purpose is not None and cell['purpose'] != purpose:
continue
difficulty = cell.get('difficulty') difficulty = cell.get('difficulty')
if difficulty and tempo > limits[difficulty + '_max_bpm']: if difficulty and tempo > limits[difficulty + '_max_bpm']:
continue continue
@@ -73,4 +100,4 @@ def rhythm_cells(tempo):
cursor += duration cursor += duration
if difficulty and len(notes) > limits['burst_attacks']: if difficulty and len(notes) > limits['burst_attacks']:
continue continue
yield cell['id'], tuple(notes), cursor yield RhythmCell(cell['id'], tuple(notes), cursor, cell['purpose'], difficulty)
+2 -2
View File
@@ -187,8 +187,8 @@ class TypedExpressionContracts(unittest.TestCase):
self.assertTrue(any('tie_from' in p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches'])) self.assertTrue(any('tie_from' in p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches']))
def test_authored_fast_cells_respect_tempo(self): def test_authored_fast_cells_respect_tempo(self):
fast={c[0] for c in vocabulary.rhythm_cells(180)} fast={c.id for c in vocabulary.rhythm_cells(180)}
slow={c[0] for c in vocabulary.rhythm_cells(100)} slow={c.id for c in vocabulary.rhythm_cells(100)}
self.assertNotIn('sixteenth_pickup',fast);self.assertNotIn('triplet_turn',fast) self.assertNotIn('sixteenth_pickup',fast);self.assertNotIn('triplet_turn',fast)
self.assertIn('sixteenth_pickup',slow);self.assertIn('triplet_turn',slow) self.assertIn('sixteenth_pickup',slow);self.assertIn('triplet_turn',slow)
+177
View File
@@ -0,0 +1,177 @@
"""Independent musical fixtures; the four optional user scores are never read."""
import copy
import dataclasses
import json
from fractions import Fraction as F
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from audition import reference_review, solo_compare
from generator import vocabulary, production
from generator.solo import export, gesture_rhythm, harmonic, notation, placement, routes, themes, types
from tests.test_solo import song
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from tests import solo_fixtures
def xml(notes, *, transpose=False, leadin=''):
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">'+leadin+'<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>'+('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '')+'</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>'+notes+'</measure></part></score-partwise>'
def pitched(duration, tie='', octave=5):
return f'<note><pitch><step>C</step><octave>{octave}</octave></pitch><duration>{duration}</duration>{tie}<type>quarter</type></note>'
class ComparisonTests(unittest.TestCase):
def review(self,directory,name,content,**kwargs):
path=Path(directory)/name;path.write_text(content)
return reference_review.read(path,'fixture/acoustic',**kwargs)
def test_ties_not_reattacks_and_one_guitar_transposition(self):
with tempfile.TemporaryDirectory() as root:
a=self.review(root,'actual.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'reference.xml',xml(pitched(12,'<tie type="start"/>')+pitched(12,'<tie type="stop"/>')),form_start=1,pitch_convention='guitar-written')
self.assertTrue(solo_compare.compare(a,b)['exact_musical_match'])
self.assertEqual(a['sounding_events'][0]['sounding_midi'],'60')
repeated=self.review(root,'repeat.xml',xml(pitched(12)+pitched(12)),form_start=1,pitch_convention='guitar-written')
self.assertFalse(solo_compare.compare(a,repeated)['exact_musical_match'])
def test_leadin_is_reported_without_harmony_or_alignment_padding(self):
with tempfile.TemporaryDirectory() as root:
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes>'+pitched(6)+'<note><rest/><duration>18</duration></note></measure>'
a=self.review(root,'a.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'b.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
result=solo_compare.compare(a,b)
self.assertEqual(result['differences'][0]['time'],'-4')
self.assertEqual(result['exact_matching_attacks'],1)
self.assertIn('uncertainty',b['notes'][0]['harmonic_spans'][0])
def test_unplayable_pitch_is_reported_not_folded(self):
with tempfile.TemporaryDirectory() as root:
r=self.review(root,'high.xml',xml(pitched(24,octave=7)),form_start=1,pitch_convention='guitar-written')
checked=solo_compare.playability(r)
self.assertEqual(checked['conflicts'][0]['sounding_midi'],84)
self.assertEqual(r['notes'][0]['pitch']['octave'],7)
def test_silent_reference_leadin_remains_in_audition_clock(self):
with tempfile.TemporaryDirectory() as root:
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes><note><rest/><duration>24</duration></note></measure>'
a=self.review(root,'actual.xml',xml(pitched(24),transpose=True),form_start=1)
b=self.review(root,'reference.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
self.assertEqual(solo_compare.audition_bounds(a,b),(F(-4),F(4)))
def test_triplet_time_and_annotations_are_literal(self):
with tempfile.TemporaryDirectory() as root:
triplet=pitched(2).replace('<type>quarter</type>','<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
r=self.review(root,'thirds.xml',xml('<direction><direction-type><words>Lead in</words></direction-type></direction>'+triplet*3+'<note><rest/><duration>18</duration></note>'),form_start=1)
self.assertEqual([n['start'] for n in r['sounding_events']],['0','1/3','2/3'])
self.assertEqual(r['annotations'][0]['text'],'Lead in')
def test_export_roundtrip_preserves_actual_sounding_events_and_tab(self):
document=song(count=2,chords=('CM7',))
raw=(note(0,F(1,3),'c'),note(F(1,3),F(2,3),'d'),note(F(2,3),1,'e'),note(F(7,2),5,'g'),note(5,6,'g'))
placed=placement.place(raw,F(120))[1]
notation.replace_region(document,0,notation.measures(document['staves'][0]['measures'],placed,'export'))
document['staves'][0]['measures'][0]['section_label']='Acoustic Solo'
before=copy.deepcopy(document)
with tempfile.TemporaryDirectory() as root:
path=Path(root)/'fixture-solo1.xml'
info=export.write_role({'role':'acoustic_solo','document':document,'trace':{'source':'fixture'}},path)
loaded=reference_review.read(path,'fixture/acoustic',1)
sounded=[(F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in loaded['sounding_events']]
self.assertEqual([(n.start,n.end,n.pitch.number()) for n in raw],sounded)
from generator import score
tab=lambda d:[p['tablature'] for m in d['staves'][0]['measures'] for n in score.measure_notes(m) for p in n['pitches']]
self.assertEqual(tab(document),[n['tablature'] for n in loaded['notes'] if n['pitch']])
self.assertEqual(info['role'],'acoustic_solo')
self.assertEqual(before,document)
def test_export_manifests_are_not_discovered_as_runtime_scores(self):
from generator import score
document=song(count=1,chords=('CM7',))
with tempfile.TemporaryDirectory() as root:
root=Path(root);(root/'analysis').mkdir()
roles={}
for part in ('acoustic','electric'):
doc=copy.deepcopy(document)
doc['staves'][0]['measures'][0]['section_label']=part.capitalize()+' Solo'
roles[part+'_solo']={'role':part+'_solo','document':doc,'trace':{'source':'synthetic'}}
(root/'analysis'/'fixture.roles').write_text(json.dumps(roles))
score.write_json(root/'score.json',document)
export.export_tree(root)
self.assertEqual(score.count_json_files(root),1)
score.validate_generated_tree(root)
self.assertTrue((root/'solo_xml'/'fixture-solo1.xml').is_file())
self.assertTrue((root/'solo_xml'/'fixture-solo2.xml').is_file())
class MusicalSelectionTests(unittest.TestCase):
def test_cell_purpose_survives_loading_and_filters_eligibility(self):
cells=tuple(vocabulary.rhythm_cells(120,'arrival'))
self.assertTrue(cells)
self.assertTrue(all(c.purpose=='arrival' for c in cells))
self.assertFalse(any(c.id=='short_answer' for c in cells))
def test_connecting_rhythm_prefers_groups_to_isolated_notes(self):
joined=((F(),F(1,2)),(F(1,2),F(3,2)),(F(3,2),F(2)))
fragmented=((F(),F(1,2)),(F(1),F(3,2)),(F(2),F(5,2)))
self.assertLess(themes.rhythm_cost(joined,F(4),'connect'),themes.rhythm_cost(fragmented,F(4),'connect'))
def test_complete_signature_boundary_can_cross_barline(self):
home=SimpleNamespace(signature=(note(0,2),note(2,5)))
source=SimpleNamespace(end=F(24));ending=SimpleNamespace(earliest=F(21))
self.assertEqual(themes.unit_end(source,F(8),home,ending,F(4)),13)
def test_arpeggio_is_not_charged_for_each_nonstep_interval(self):
chord=(note(0,F(1,2),'c'),note(F(1,2),1,'e'),note(1,F(3,2),'g'))
steps=(note(0,F(1,2),'c'),note(F(1,2),1,'d'),note(1,F(3,2),'e'))
self.assertEqual(routes.path_cost(chord),routes.path_cost(steps))
h=harmonic.Harmony(choices(('CM7',)))
self.assertEqual(routes.path_family(chord,h)[0],'arpeggio')
self.assertEqual(routes.path_family(steps,h)[0],'mapped')
run=tuple(note(i,F(i+1),p) for i,p in enumerate(('c','d','e','f','g','a')))
self.assertEqual(routes.path_cost(run),0)
oscillation=tuple(note(i,F(i+1),p) for i,p in enumerate(('c','d','c','d','c')))
self.assertGreater(routes.path_cost(oscillation),0)
def test_data_driven_arpeggio_reaches_exact_destination_as_one_route(self):
h=harmonic.Harmony(choices(('CM7',)))
notes=(note(0,F(1,2),'c',event_id='first'),note(F(1,2),1,'c',event_id='middle'),note(1,F(3,2),'g',event_id='target'))
paths=routes.candidates(notes,{'target'},h,solo_fixtures.library(),(41,76))
authored=[r for r in paths if any(s.startswith('chord-path:') for s in r.sources)]
self.assertTrue(authored)
self.assertTrue(any([n.pitch.number() for n in r.notes]==[60,64,67] for r in authored))
self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in paths))
self.assertTrue(all(h.evaluate(r.notes).valid for r in paths))
def test_solo_vocabulary_changes_do_not_change_accompaniment_inputs(self):
resources=vocabulary.load()
changed=copy.deepcopy(resources)
changed['melodic_vocabulary']['cells'].append({'id':'independent-test'})
for role in ('acoustic_accompaniment','electric_accompaniment','electric_melody','vocal_melody'):
self.assertEqual(production.dependencies(role,resources),production.dependencies(role,changed))
self.assertNotEqual(production.dependencies('acoustic_solo',resources),production.dependencies('acoustic_solo',changed))
def test_cross_bar_arrival_cell_remains_one_sounding_duration(self):
cell=next(c for c in vocabulary.rhythm_cells(120,'arrival') if c.id=='anticipated_sustain')
self.assertTrue(any(a<4<b for a,b in cell.pattern))
def test_shared_provisional_arrival_preserves_terminal_hold(self):
a=note(0,1,'c',event_id='a');b=note(1,2,'c',event_id='b');c=note(2,4,'e',event_id='c')
t=lambda id,start,end,operation,events:types.Transformation(id,'source','home',operation,F(start),F(end),events,(),preserves=())
draft=types.MelodicDraft('draft',F(),F(4),(a,b,c),(t('a',0,1,'introduce',('a',)),t('b',1,4,'continue',('b','c'))),(),(),(),None,'continue')
h=harmonic.Harmony(choices(('CM7',)))
revised,edits=gesture_rhythm.shared_arrival(draft,h)
self.assertEqual(len(revised.notes),2)
self.assertEqual(revised.notes[0].start,0)
self.assertEqual(revised.notes[-1],c)
self.assertEqual(edits[0].kind,'shared_arrival')
self.assertEqual(gesture_rhythm.revise(draft,h)[1][0].kind,'shared_arrival')
protected=dataclasses.replace(draft,protected=('a',))
self.assertEqual(gesture_rhythm.shared_arrival(protected,h)[0],protected)
if __name__=='__main__':unittest.main()