Revamp
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user