Training Model

This commit is contained in:
khodowany
2026-10-05 19:44:33 -05:00
parent db7966a430
commit e7180e557b
6603 changed files with 8919 additions and 523063 deletions
+9 -17
View File
@@ -1,19 +1,11 @@
import unittest.mock
from generator import comping, vocabulary, parts
from generator.arrange import form, generate
import generator.arrange.form
import generator.arrange.generate
import generator.parts
def section_document(document, part_type, *, rhythms, plan=None):
"""Exercise the real part pipeline with a single section instead of a full song."""
prepared = generator.parts.prepare_document(document)
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
section = generator.arrange.form.ArrangementSection('', 0, len(prepared['staves'][0]['measures']), 0, role)
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]):
if plan is not None:
plans = {0: plan}
elif part_type == 'acoustic':
plans = generator.arrange.generate.plan_arrangement(prepared, rhythms)
else:
plans = {}
return generator.arrange.generate._arrange_document(prepared, part_type, plans)
def section_document(document,part_type):
prepared=parts.prepare_document(document)
role={'acoustic':'accompaniment','electric':'melody','vocal':'vocal'}[part_type]
section=form.ArrangementSection('',0,len(prepared['staves'][0]['measures']),0,role)
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections',return_value=[section]), vocabulary.using(vocabulary.load(),part_type+'_accompaniment'):
backing=comping.Canonical(prepared,part_type,'fixture') if part_type=='acoustic' else None
return generate._arrange_document(prepared,part_type,accompaniment=backing)
-9
View File
@@ -1,9 +0,0 @@
# Independent source excerpts
These are **evaluation-only original-song inputs**, not expected solos, production vocabulary, or the four target solos. They are deliberately outside `data/` and are never loaded by ordinary generation or required tests. Source provenance is beside each XML. The original 1917/1918 editions are from Johns Hopkins' Levy collection.
The manually transcribed first four chorus measures retain the vocal rhythm, including tied values. Chord symbols are our declared harmonic reductions of the piano score. These short excerpts do not establish whole-form generalization. Preceding pickups/verses and piano figuration are outside the excerpt scope. Original scan downloads were removed with the historical audition artifacts; the provenance files retain their institutional source links.
St. Louis Blues was considered, but the institutional original-score download was unavailable (403). Hindustan supplies the third independent source instead; no target arrangement was used to reconstruct missing music.
Evaluate these inputs using the ordinary `python -m generator.compose --song ... --role ... --data-dir data --output ...` command. They are transfer checks, not sources of production vocabulary.
@@ -1,13 +0,0 @@
{
"title": "After You\u2019ve Gone",
"published": 1918,
"source": "https://levysheetmusic.mse.jhu.edu/collection/151/004",
"printed_page": 3,
"scope": "First four full chorus measures; preceding pickup omitted from this excerpt, not silently folded into the chorus. Not a complete standard/form transcription.",
"harmony": "Independently authored chord-symbol reduction of the original piano harmony; ornamental piano bass motion is not transcribed. Not a claim of a definitive modern jazz chart.",
"pitch": "Original vocal treble encoding, sounding; no guitar octave shift.",
"tempo": "108 quarter notes/minute is a test setting, not inferred performance evidence.",
"provenance": "Manual source-melody transcription from the original published score, after production vocabulary was authored. No target solo input.",
"rights": "Original 1917/1918 US published composition, public-domain age in the United States. New transcription/reduction: MIT. Original scans are not redistributed. Other jurisdictions not assessed.",
"archive_rights": "https://www.library.jhu.edu/policies/rights-and-reproductions/"
}
@@ -1,37 +0,0 @@
<?xml version="1.0"?><score-partwise version="4.0"><work><work-title>After You’ve Gone</work-title></work><part-list><score-part id="P1"><part-name>Source melody</part-name></score-part></part-list><part id="P1">
<measure number="1">
<attributes><divisions>4</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef></attributes><direction><direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>108</per-minute></metronome></direction-type><sound tempo="108"/></direction>
<harmony><root><root-step>F</root-step></root><kind>major</kind></harmony>
<note><pitch><step>A</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>C</step><alter>0</alter><octave>5</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>D</step><alter>0</alter><octave>5</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>E</step><alter>0</alter><octave>5</octave></pitch><duration>2</duration><tie type="start"/><type>eighth</type><notations><tied type="start"/></notations></note>
<note><pitch><step>E</step><alter>0</alter><octave>5</octave></pitch><duration>8</duration><tie type="stop"/><type>half</type><notations><tied type="stop"/></notations></note>
</measure>
<measure number="2">
<harmony><root><root-step>F</root-step></root><kind>minor</kind></harmony>
<note><rest/><duration>2</duration><type>eighth</type></note>
<note><pitch><step>D</step><alter>0</alter><octave>5</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>E</step><alter>0</alter><octave>5</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>D</step><alter>0</alter><octave>5</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>E</step><alter>0</alter><octave>5</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>D</step><alter>0</alter><octave>5</octave></pitch><duration>6</duration><type>quarter</type><dot/></note>
</measure>
<measure number="3">
<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>
<note><pitch><step>E</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>A</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>B</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><tie type="start"/><type>eighth</type><notations><tied type="start"/></notations></note>
<note><pitch><step>B</step><alter>0</alter><octave>4</octave></pitch><duration>8</duration><tie type="stop"/><type>half</type><notations><tied type="stop"/></notations></note>
</measure>
<measure number="4">
<harmony><root><root-step>E</root-step></root><kind>dominant</kind></harmony>
<note><rest/><duration>2</duration><type>eighth</type></note>
<note><pitch><step>A</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>B</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>A</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>B</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>A</step><alter>0</alter><octave>4</octave></pitch><duration>6</duration><type>quarter</type><dot/></note>
</measure>
</part></score-partwise>
@@ -1,13 +0,0 @@
{
"title": "Hindustan",
"published": 1918,
"source": "https://levysheetmusic.mse.jhu.edu/collection/152/084",
"printed_page": 3,
"scope": "First four full chorus measures; preceding pickup omitted from this excerpt, not silently folded into the chorus. Not a complete standard/form transcription.",
"harmony": "Independently authored chord-symbol reduction of the original piano harmony; ornamental piano bass motion is not transcribed. Not a claim of a definitive modern jazz chart.",
"pitch": "Original vocal treble encoding, sounding; no guitar octave shift.",
"tempo": "108 quarter notes/minute is a test setting, not inferred performance evidence.",
"provenance": "Manual source-melody transcription from the original published score, after production vocabulary was authored. No target solo input.",
"rights": "Original 1917/1918 US published composition, public-domain age in the United States. New transcription/reduction: MIT. Original scans are not redistributed. Other jurisdictions not assessed.",
"archive_rights": "https://www.library.jhu.edu/policies/rights-and-reproductions/"
}
-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0"?><score-partwise version="4.0"><work><work-title>Hindustan</work-title></work><part-list><score-part id="P1"><part-name>Source melody</part-name></score-part></part-list><part id="P1">
<measure number="1">
<attributes><divisions>4</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef></attributes><direction><direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>108</per-minute></metronome></direction-type><sound tempo="108"/></direction>
<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>
<note><pitch><step>C</step><alter>0</alter><octave>5</octave></pitch><duration>16</duration><tie type="start"/><type>whole</type><notations><tied type="start"/></notations></note>
</measure>
<measure number="2">
<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>
<note><pitch><step>C</step><alter>0</alter><octave>5</octave></pitch><duration>8</duration><tie type="stop"/><type>half</type><notations><tied type="stop"/></notations></note>
<note><pitch><step>A</step><alter>0</alter><octave>4</octave></pitch><duration>8</duration><type>half</type></note>
</measure>
<measure number="3">
<harmony><root><root-step>G</root-step></root><kind>dominant</kind></harmony>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>16</duration><tie type="start"/><type>whole</type><notations><tied type="start"/></notations></note>
</measure>
<measure number="4">
<harmony><root><root-step>G</root-step></root><kind>dominant</kind></harmony>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>8</duration><tie type="stop"/><type>half</type><notations><tied type="stop"/></notations></note>
<note><pitch><step>E</step><alter>0</alter><octave>4</octave></pitch><duration>4</duration><type>quarter</type></note>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>4</duration><type>quarter</type></note>
</measure>
</part></score-partwise>
@@ -1,13 +0,0 @@
{
"title": "Indiana",
"published": 1917,
"source": "https://levysheetmusic.mse.jhu.edu/collection/152/200",
"printed_page": 3,
"scope": "First four full chorus measures; preceding pickup omitted from this excerpt, not silently folded into the chorus. Not a complete standard/form transcription.",
"harmony": "Independently authored chord-symbol reduction of the original piano harmony; ornamental piano bass motion is not transcribed. Not a claim of a definitive modern jazz chart.",
"pitch": "Original vocal treble encoding, sounding; no guitar octave shift.",
"tempo": "108 quarter notes/minute is a test setting, not inferred performance evidence.",
"provenance": "Manual source-melody transcription from the original published score, after production vocabulary was authored. No target solo input.",
"rights": "Original 1917/1918 US published composition, public-domain age in the United States. New transcription/reduction: MIT. Original scans are not redistributed. Other jurisdictions not assessed.",
"archive_rights": "https://www.library.jhu.edu/policies/rights-and-reproductions/"
}
-33
View File
@@ -1,33 +0,0 @@
<?xml version="1.0"?><score-partwise version="4.0"><work><work-title>Indiana</work-title></work><part-list><score-part id="P1"><part-name>Source melody</part-name></score-part></part-list><part id="P1">
<measure number="1">
<attributes><divisions>4</divisions><key><fifths>1</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef></attributes><direction><direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>108</per-minute></metronome></direction-type><sound tempo="108"/></direction>
<harmony><root><root-step>G</root-step></root><kind>major</kind></harmony>
<note><pitch><step>D</step><alter>0</alter><octave>5</octave></pitch><duration>8</duration><tie type="start"/><type>half</type><notations><tied type="start"/></notations></note>
<note><pitch><step>D</step><alter>0</alter><octave>5</octave></pitch><duration>3</duration><tie type="stop"/><type>eighth</type><dot/><notations><tied type="stop"/></notations></note>
<note><pitch><step>E</step><alter>0</alter><octave>5</octave></pitch><duration>1</duration><type>16th</type></note>
<note><pitch><step>D</step><alter>0</alter><octave>5</octave></pitch><duration>3</duration><type>eighth</type><dot/></note>
<note><pitch><step>B</step><alter>0</alter><octave>4</octave></pitch><duration>1</duration><type>16th</type></note>
</measure>
<measure number="2">
<harmony><root><root-step>A</root-step></root><kind>dominant</kind></harmony>
<note><pitch><step>A</step><alter>0</alter><octave>4</octave></pitch><duration>4</duration><type>quarter</type></note>
<note><pitch><step>E</step><alter>0</alter><octave>4</octave></pitch><duration>8</duration><type>half</type></note>
<note><pitch><step>F</step><alter>1</alter><octave>4</octave></pitch><duration>3</duration><type>eighth</type><dot/></note>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>1</duration><type>16th</type></note>
</measure>
<measure number="3">
<harmony><root><root-step>D</root-step></root><kind>dominant</kind></harmony>
<note><pitch><step>B</step><alter>0</alter><octave>4</octave></pitch><duration>6</duration><type>quarter</type><dot/></note>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
<note><pitch><step>B</step><alter>0</alter><octave>4</octave></pitch><duration>6</duration><type>quarter</type><dot/></note>
<note><pitch><step>A</step><alter>0</alter><octave>4</octave></pitch><duration>2</duration><type>eighth</type></note>
</measure>
<measure number="4">
<harmony><root><root-step>G</root-step></root><kind>major</kind></harmony>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>8</duration><tie type="start"/><type>half</type><notations><tied type="start"/></notations></note>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>3</duration><tie type="stop"/><type>eighth</type><dot/><notations><tied type="stop"/></notations></note>
<note><pitch><step>G</step><alter>0</alter><octave>4</octave></pitch><duration>1</duration><type>16th</type></note>
<note><pitch><step>F</step><alter>1</alter><octave>4</octave></pitch><duration>3</duration><type>eighth</type><dot/></note>
<note><pitch><step>F</step><alter>0</alter><octave>4</octave></pitch><duration>1</duration><type>16th</type></note>
</measure>
</part></score-partwise>
+29 -8
View File
@@ -1,19 +1,40 @@
"""Small synthetic catalogs for infrastructure tests, never production fallback.
"""Cheap valid notes for renderer/packaging tests; never a production fallback.
Dependency/process tests separately exercise the real data path without mocks.
Learned decoding and authored-repertoire tests separately use the real path.
"""
from fractions import Fraction as F
from pathlib import Path
from unittest.mock import patch
from generator import production, vocabulary
from tests import rhythm_fixtures, scale_fixtures, solo_fixtures
from generator import production,vocabulary,harmonic_time
from generator.solo import types,placement,profiles,decoder,learned,harmonic
def compose(analysis,measures,part,end,*,initial=None,start=F(),**kwargs):
import dataclasses
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
h=harmonic.Harmony(spans);notes=[]
for s in h.spans:
a,b=max(start,s.start),min(end,s.end)
if a>=b or s.chord=='N.C.':continue
number=next(n for n in range(55,72) if n%12 in s.roles.basic)
from generator.solo import expressions
chord=expressions.parsed_chord(s.chord)
from generator import harmony
pitch=next(expressions.degree_pitch(chord,d,x,number) for d,x in harmony.degrees(chord).items() if expressions.degree_pitch(chord,d,x,number).number()==number)
notes.append(types.Note(a,b,pitch,event_id=str(a)))
placed=placement.place(tuple(notes),analysis.tempo)[1] if notes else ()
return types.Solo(part,spans,(types.Phrase('fixture',start,end,placed,'test','test','handoff'),),analysis.tempo,end,profile=profiles.for_part(part),start=start)
def install(test):
resources=vocabulary.load()
libraries=(scale_fixtures.library(),solo_fixtures.library(),rhythm_fixtures.library())
resources=vocabulary.load();predictor=learned.Model.load(Path(__file__).resolve().parents[1]/'data/models/solo.json')
tree=production.generate_tree
def serial(*args,**kwargs):
kwargs['jobs']=1
return tree(*args,**kwargs)
test.enterContext(patch('generator.production.generate_tree',side_effect=serial))
test.enterContext(patch('generator.vocabulary.load',return_value=resources))
test.enterContext(patch('generator.production._solo_libraries',return_value=libraries))
test.enterContext(patch('generator.production._resource_key',return_value={'fixture':'independently authored synthetic catalogs'}))
test.enterContext(patch('generator.production.repertoire.lookup',return_value=None))
test.enterContext(patch('generator.production._solo_model',return_value=predictor))
test.enterContext(patch('generator.production._resource_key',return_value={'model':'synthetic infrastructure fixture'}))
test.enterContext(patch('generator.solo.composer.compose',side_effect=compose))
-20
View File
@@ -1,20 +0,0 @@
import fractions
import functools
import generator.rhythm
@functools.cache
def library() -> generator.rhythm.Library:
snippets = []
for beats in (2, 3, 4, 5):
bars = []
for mask in range(1 << beats):
events = tuple(generator.rhythm.Event(fractions.Fraction(i), fractions.Fraction(1), bool(mask & (1 << i)), 'quarter') for i in range(beats))
bar = generator.rhythm.Bar(events, fractions.Fraction(beats))
bars.append(bar)
snippets.append(generator.rhythm.Snippet(f'{beats}_4/quarters/{mask}', f'{beats}/4', (bar,)))
for phase in (0, 1):
events = tuple(generator.rhythm.Event(fractions.Fraction(i, 2), fractions.Fraction(1, 2), i % 2 == phase, 'eighth') for i in range(beats * 2))
snippets.append(generator.rhythm.Snippet(f'{beats}_4/eighths/{phase}', f'{beats}/4', (generator.rhythm.Bar(events, fractions.Fraction(beats)),)))
snippets.append(generator.rhythm.Snippet(f'{beats}_4/pair', f'{beats}/4', (bars[1], bars[-1])))
return generator.rhythm.Library(tuple(snippets))
-8
View File
@@ -1,8 +0,0 @@
import functools
import pathlib
import generator.scales.library
@functools.cache
def library() -> generator.scales.library.Library:
return generator.scales.library.load_library(pathlib.Path(__file__).resolve().parents[1] / 'data/scales')
+6 -142
View File
@@ -1,6 +1,6 @@
"""Read optional reference MusicXML literally, without repairing it or composing."""
from __future__ import annotations
import argparse
import argparse,array,collections,html,os,sys,wave
import hashlib
import json
from pathlib import Path
@@ -8,145 +8,9 @@ from fractions import Fraction as F
import xml.etree.ElementTree as ET
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)
result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role,
'pitch_convention':pitch_convention,'part_names':[n.text for n in tree.findall('./part-list/score-part/part-name')],
'measures':[],'notes':[],'harmonies':[],'annotations':[],'discrepancies':[]}
divisions=F(1);absolute=F();nominal=F(4);pending={};active=None;transpose=F()
for index,m in enumerate(tree.findall('./part/measure')):
cursor=F();extent=F();last_start=F();last_duration=F()
if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value)
meter=m.find('attributes/time')
if meter is not None and meter.findtext('beats'):
nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type'))
for item in m:
offset=F(item.findtext('offset','0'))/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=='harmony':
active={'time':str(absolute+cursor+offset),'measure':index+1,'offset':str(cursor+offset),
'root':item.findtext('root/root-step'),'alter':item.findtext('root/root-alter','0'),
'kind':item.findtext('kind'),'display':item.find('kind').get('text') if item.find('kind') is not None else None,
'bass':ET.tostring(item.find('bass'),encoding='unicode') if item.find('bass') is not None else None,
'degrees':[{'value':d.findtext('degree-value'),'alter':d.findtext('degree-alter'),'type':d.findtext('degree-type')} for d in item.findall('degree')]}
result['harmonies'].append(active)
elif item.tag=='direction':
for words in item.findall('direction-type/words'):
result['annotations'].append({'time':str(absolute+cursor+offset),'measure':index+1,'text':''.join(words.itertext()),'interpretation':'descriptive; no production permission'})
elif item.tag=='note':
duration=F(item.findtext('duration','0'))/divisions
start=last_start if item.find('chord') is not None else cursor
end=start+duration;voice=item.findtext('voice','1');p=item.find('pitch')
encoded={'step':p.findtext('step'),'alter':p.findtext('alter','0'),'octave':int(p.findtext('octave'))} if p is not None else None
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,
'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,
'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))
if 'stop' in kinds:
old=pending.get(key)
if old is None or result['notes'][old]['end']!=event['start']:
result['discrepancies'].append({'measure':index+1,'issue':'unmatched/noncontiguous tie; retained literally'})
else:event['continuation_of']=old
if 'start' not in kinds:pending.pop(key,None)
if 'start' in kinds:pending[key]=len(result['notes'])
result['notes'].append(event)
for container in ('notations/technical/fingering','notations/technical/other-technical','notations/other-notation'):
for label in item.findall(container):
text=''.join(label.itertext()).strip()
if text:result['annotations'].append({'time':str(absolute+start),'measure':index+1,'text':text,
'container':container,'interpretation':'Original note annotation; container does not establish musical permission or guitar fingering.'})
if item.find('chord') is None and item.find('grace') is None:cursor=end
last_start,last_duration=start,duration;extent=max(extent,end,cursor)
if extent!=nominal and m.get('implicit')!='yes':result['discrepancies'].append({'measure':index+1,'issue':f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
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
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,
# unusual timing and high encoded pitches are evidence, not repair requests.
from generator import harmony, model, fretboard
def semantic(h):
return {'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],
'degrees':[{'value':int(d['value']),'alter':model.rational(F(d['alter'])),'type':d['type']} for d in h['degrees']]}
for event in result['notes']:
if not event['pitch']:continue
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter'])
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_sounding_pitch']=len(fretboard.single_positions_for_pitch(F(event['sounding_midi'])))
event['harmonic_spans']=[]
start,end=F(event['start']),F(event['end'])
changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end]
for a,b in zip([start,*changes],[*changes,end]):
current=next((h for h in reversed(result['harmonies']) if F(h['time'])<=a),None)
info={'start':str(a),'end':str(b),'chord':current,'chord_degrees':[]}
if current and current['root']:
try:
chord=semantic(current);root=model.NATURAL[chord['root']['letter']]+F(current['alter'])
info['chord_degrees']=[d for d,alter in harmony.degrees(chord).items() if (root+harmony.degree_semitones(d,alter)-number)%12==0]
info['root_relative_semitones']=str((number-root)%12)
info['spelled_simple_degree']=(model.LETTERS.index(p['step'].lower())-model.LETTERS.index(chord['root']['letter']))%7+1
except (ValueError,KeyError,AttributeError) as error:
info['uncertainty']=str(error)
else:info['uncertainty']='No written/carried harmony; none invented.'
event['harmonic_spans'].append(info)
sounded=[];tie_roots={}
for i,n in enumerate(result['notes']):
if not n['pitch']:continue
previous=n.get('continuation_of')
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
else:
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
first=form_start or next((h['measure'] for h in result['harmonies']),None)
offset=F(result['measures'][first-1]['start']) if first else None
result['alignment']={'form_start_measure':first,'encoded_offset':str(offset) if offset is not None else None,'basis':'explicit override' if form_start else 'first written harmony; review required','preceding_material':'retained, without invented harmony'}
for label in result['annotations']:
t=F(label['time']); current=[h for h in result['harmonies'] if F(h['time'])<=t]
future=next((h for h in result['harmonies'] if F(h['time'])>t),None)
label['current_harmony']=current[-1] if current else None
label['following_harmony']=future
label['events']=[n for n in result['notes'] if F(n['start'])<=t<F(n['end']) or t<=F(n['start'])<t+1]
label['uncertainty']='Label meaning must be assessed from these events and harmonic time; label preserved verbatim.'
expected={'b9':F(1),'#9':F(3),'b13':F(8),'#11':F(6),'6':F(9)}.get(label['text'].strip())
if expected is not None:
relationships=[s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans',[])]
label['literal_interval_present']=str(expected) in relationships
if not label['literal_interval_present']:label['uncertainty']='No matching interval in the immediate annotated context; label retained, intended scope uncertain.'
# Lead in and Bluesy stay contextual descriptions, never enclosure permits.
return result
"""Explicit post-generation comparison. Never imports or runs a composer."""
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 generator.training_io import read
from generator import fretboard
from generator.solo import placement, types
from generator.solo import placement,types
def events(review):
@@ -199,7 +63,7 @@ def musical_summary(review):
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.'}
'annotations':review['annotations'],'interpretation':'Post-generation diagnostics; does not train or reselect music.'}
def compare(generated,reference):
@@ -379,7 +243,7 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
(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>',
'<h1>Completed solos and reference scores</h1><p>Differences are diagnostic. This comparison is read-only; registered authored scores are explicit repertoire inputs, and pooled examples supply training evidence. 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']
@@ -394,7 +258,7 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
rows.append('<tr><td>'+key.replace('_',' ')+'</td><td>'+str(r['relationships'][key])+'</td><td>'+str(r.get('baseline_relationships',{}).get(key,'—'))+'</td></tr>')
rows.append('</table><p>Lower edit distance means fewer insertions, deletions or substitutions on that axis. Sounding agreement is measured in quarter-note beats, without octave shifting or fitted alignment. These do not measure musical quality.</p>')
selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[]))
rows.append('<p>Selected, validated production treatments: '+html.escape(str(dict(selected)))+'. Ordinary mapped material remains separate.</p>')
rows.append('<p>Selected production treatment contracts: '+html.escape(str(dict(selected)))+'. Registered authored music is preserved without inventing replacement contracts.</p>')
if r.get('reference_events_after_production_end'):
rows.append('<p>Reference material extends beyond this exported production allocation: '+str(len(r['reference_events_after_production_end']))+' events. The reference and source timing remain unchanged.</p>')
funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{})
+5 -15
View File
@@ -1,16 +1,7 @@
import functools
import itertools
import generator.solo.sequences
@functools.cache
def library():
return generator.solo.sequences.Library(tuple(generator.solo.sequences.Sequence(f'{count}_4/{index:04}.xml', steps) for count in (2, 3, 4) for index, steps in enumerate(itertools.product((1, -1, 2, -2), repeat=count - 1))))
import copy, fractions
import generator.model, generator.parts, generator.harmony
import generator.solo.analysis, generator.solo.composer, generator.solo.types
import tests.test_musicxml, tests.scale_fixtures, tests.rhythm_fixtures
import tests.test_musicxml
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
@@ -31,12 +22,11 @@ def compose(document, part='acoustic', cutoff=None):
analysis = generator.solo.analysis.analyze(document)
measures = document['staves'][0]['measures']
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=library(), rhythms=tests.rhythm_fixtures.library())
return generator.solo.composer.compose(analysis, measures, part, end)
def note(start,end,letter='g',alter=0,octave=4,**kwargs):
return generator.solo.types.Note(fractions.Fraction(start),fractions.Fraction(end),generator.solo.types.Pitch(letter,fractions.Fraction(alter),octave),**kwargs)
def choices(symbols,duration=4,scale=None):
from generator.scales import selection,timeline
scale=scale or tests.scale_fixtures.library().scale('major',6,'D')
return tuple(selection.Choice(timeline.ChordSpan(fractions.Fraction(i*duration),fractions.Fraction((i+1)*duration),i+1,str(i+1),fractions.Fraction(1),generator.model.key_from_fifths(-1),generator.harmony.parse_symbol(symbol)),scale,'independent fixture') for i,symbol in enumerate(symbols))
def choices(symbols,duration=4):
from generator import harmonic_time
return tuple(harmonic_time.ChordSpan(fractions.Fraction(i*duration),fractions.Fraction((i+1)*duration),i+1,str(i+1),fractions.Fraction(1),generator.model.key_from_fifths(-1),generator.harmony.parse_symbol(symbol)) for i,symbol in enumerate(symbols))
+1 -447
View File
@@ -1,18 +1,6 @@
import copy
import dataclasses
import fractions
import pathlib
import tempfile
import unittest
import xml.etree.ElementTree
import generator.accompaniment
import fractions
import generator.model
import generator.parts
import generator.playback
import tests.arrangement_fixtures
import generator.rhythm
import tests.rhythm_fixtures
import tests.test_musicxml
import tests.test_parts
@@ -27,439 +15,5 @@ def melody_measures(count, *, beats=4, dense=False):
content = tests.test_parts.xml_harmony() + notes
return tests.test_musicxml.parse(xml_bars([content] * count, beats=beats, divisions=2 if dense else 1))['staves'][0]['measures']
def quarter_bar(beats, attacks):
return generator.rhythm.Bar(tuple(generator.rhythm.Event(fractions.Fraction(i), fractions.Fraction(1), i in attacks, 'quarter') for i in range(beats)), fractions.Fraction(beats))
def timing(events):
return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']), n['kind']) for n in events]
def tied_library(continuation_duration):
first = tests.test_musicxml.xml_note(extra='<tie type="start"/>')
second = tests.test_musicxml.xml_note(duration=continuation_duration, kind='whole' if continuation_duration == 4 else 'half', extra='<tie type="stop"/>')
if continuation_duration == 2:
second += '<note><rest/><duration>2</duration><type>half</type></note>'
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
path = root / '4_4/2/tied.xml'
path.parent.mkdir(parents=True)
path.write_text(xml_bars([first, second]), encoding='utf-8')
return generator.rhythm.load_library(root)
def held_half_bar(sounding=True):
return generator.rhythm.Bar((
generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(2), sounding, 'half'),
generator.rhythm.Event(fractions.Fraction(2), fractions.Fraction(1), False, 'quarter'),
generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter'),
), fractions.Fraction(4))
class RhythmLibraryTests(unittest.TestCase):
def test_unbracketed_time_modifications_preserve_exact_ratios(self):
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
source = xml_bars([tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=extra) * 6], divisions=3)
library=self.load({'4_4/1/ratio.xml': source})
self.assertTrue(all(e.ratio==fractions.Fraction(2,3) for e in library.for_meter('4/4')[0].bars[0].events))
def load(self, sources):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
for relative, content in sources.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding='utf-8')
return generator.rhythm.load_library(root)
def test_snippet_retains_exact_timing_rests_dots_and_beams(self):
content = tests.test_musicxml.xml_note(duration=3, kind='eighth', extra='<dot/><beam number="1">begin</beam>')
content += tests.test_musicxml.xml_note(duration=1, kind='16th', extra='<beam number="1">end</beam><beam number="2">backward hook</beam>')
content += '<note><rest/><duration>4</duration><type>quarter</type></note>'
content += tests.test_musicxml.xml_note(duration=8, kind='half')
library = self.load({'4_4/1/dotted.xml': xml_bars([content], divisions=4)})
snippet = library.for_meter('4/4')[0]
self.assertEqual((snippet.id, snippet.meter), ('4_4/1/dotted.xml', '4/4'))
self.assertEqual(snippet.bars, (generator.rhythm.Bar((
generator.rhythm.Event(fractions.Fraction(0), fractions.Fraction(3, 4), True, 'eighth', 1, ((1, 'begin'),)),
generator.rhythm.Event(fractions.Fraction(3, 4), fractions.Fraction(1, 4), True, '16th', 0, ((1, 'end'), (2, 'backward hook'))),
generator.rhythm.Event(fractions.Fraction(1), fractions.Fraction(1), False, 'quarter'),
generator.rhythm.Event(fractions.Fraction(2), fractions.Fraction(2), True, 'half'),
), fractions.Fraction(4)),))
self.assertEqual(snippet.bars[0].attacks(), (0, fractions.Fraction(3, 4), 2))
self.assertTrue(all(type(e.onset) is fractions.Fraction and type(e.duration) is fractions.Fraction for e in snippet.bars[0].events))
def test_two_bar_ties_are_continuations_without_new_attacks(self):
content = xml_bars([tests.test_musicxml.xml_note(extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>')])
snippet = self.load({'4_4/2/tied.xml': content}).for_meter('4/4')[0]
self.assertEqual(len(snippet.bars), 2)
self.assertEqual([bar.attacks() for bar in snippet.bars], [(0,), ()])
self.assertEqual([bar.events[0].continuation for bar in snippet.bars], [False, True])
self.assertEqual([bar.length for bar in snippet.bars], [4, 4])
def test_third_series_supports_long_notes_and_rests_in_every_meter(self):
for beats, duration, kind, dots in ((2, 2, 'half', 0), (3, 3, 'half', 1), (4, 4, 'whole', 0), (5, 4, 'whole', 0)):
for sounding in (False, True):
with self.subTest(beats=beats, sounding=sounding):
content = tests.test_musicxml.xml_note(duration=duration, kind=kind, extra='<dot/>' if dots else '') if sounding else f'<note><rest/><duration>{duration}</duration><type>{kind}</type>{"<dot/>" if dots else ""}</note>'
if beats == 5:
content += tests.test_musicxml.xml_note(duration=1, kind='quarter')
snippet = self.load({f'{beats}_4/3/0001.xml': xml_bars([content], beats=beats)}).for_meter(f'{beats}/4')[0]
self.assertEqual(snippet.bars[0].events[0], generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(duration), sounding, kind, dots))
self.assertEqual(snippet.bars[0].length, beats)
def test_all_three_series_participate_in_deterministic_long_note_selection(self):
silence = '<note><rest/><duration>4</duration><type>whole</type></note>'
halves = tests.test_musicxml.xml_note(duration=2, kind='half') * 2
library = self.load({'4_4/1/0001.xml': xml_bars([silence] * 2), '4_4/2/0001.xml': xml_bars([silence]), '4_4/3/0001.xml': xml_bars([halves])})
self.assertEqual(len(library.for_meter('4/4')), 3)
measures = melody_measures(8)
plan = generator.accompaniment.plan_section(measures, library)
expected = library.for_meter('4/4')[-1].bars[0]
for lane in (plan.acoustic_bass, plan.acoustic_upper, plan.electric):
self.assertEqual(lane, (expected,) * 8)
reordered = generator.rhythm.Library(tuple(reversed(library.for_meter('4/4'))))
self.assertEqual(generator.accompaniment.plan_section(measures, reordered), plan)
def test_beat_comparison_counts_held_half_notes_and_quarter_reattacks(self):
half = held_half_bar()
silent_half = held_half_bar(False)
quarters = quarter_bar(4, {0, 1, 3})
silent_quarter = quarter_bar(4, {1, 3})
for left, right, changed in ((half, silent_half, [0, 1]), (half, quarters, [1]), (quarters, silent_quarter, [0])):
with self.subTest(left=left, right=right):
self.assertEqual([beat for beat in range(4) if left.beat(beat) != right.beat(beat)], changed)
def test_snippets_and_nested_events_are_immutable(self):
library = tests.rhythm_fixtures.library()
snippet = library.for_meter('4/4')[0]
for value, name, replacement in ((snippet, 'meter', '3/4'), (snippet, 'bars', ()), (snippet.bars[0], 'events', ()), (snippet.bars[0].events[0], 'duration', fractions.Fraction(9))):
with self.subTest(field=name), self.assertRaises(dataclasses.FrozenInstanceError):
setattr(value, name, replacement)
with self.assertRaises(TypeError):
library.snippets['4/4'] = ()
with self.assertRaises(TypeError):
snippet.bars[0].events[0] = snippet.bars[0].events[0]
def test_supported_meters_and_source_order(self):
sources = {}
for beats in (2, 3, 4, 5):
content = xml_bars([''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(beats))], beats=beats)
for name in ('z', 'a'):
sources[f'{beats}_4/1/{name}.xml'] = content
library = self.load(sources)
for beats in (2, 3, 4, 5):
self.assertEqual([s.id for s in library.for_meter(f'{beats}/4')], [f'{beats}_4/1/a.xml', f'{beats}_4/1/z.xml'])
self.assertTrue(all(s.bars[0].length == beats for s in library.for_meter(f'{beats}/4')))
def test_missing_empty_unsupported_and_misfiled_libraries_fail(self):
with tempfile.TemporaryDirectory() as temporary, self.assertRaisesRegex(ValueError, 'not found'):
generator.rhythm.load_library(pathlib.Path(temporary) / 'missing')
with self.assertRaisesRegex(ValueError, 'empty'):
self.load({})
source = xml_bars([tests.test_musicxml.xml_note()])
for path in ('snippet.xml', '4_4/snippet.xml', '4_4/4/snippet.xml', '4_4/1/nested/snippet.xml', '6-8/1/snippet.xml'):
with self.subTest(path=path), self.assertRaisesRegex(ValueError, 'meter/series'):
self.load({path: source})
with self.assertRaisesRegex(ValueError, 'meter disagrees'):
self.load({'3_4/1/snippet.xml': source})
library = self.load({'4_4/1/snippet.xml': source})
for meter in ('2/4', '6/8', '7/4', '4/8', 'invalid'):
with self.subTest(meter=meter), self.assertRaisesRegex(ValueError, 'rhythm snippets'):
library.for_meter(meter)
with self.assertRaisesRegex(ValueError, 'duplicate rhythm identity'):
generator.rhythm.Library(library.for_meter('4/4') * 2)
def test_invalid_source_formats_fail(self):
quarter = tests.test_musicxml.xml_note(duration=1, kind='quarter')
whole = tests.test_musicxml.xml_note()
triplet = ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra='<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification><notations><tuplet type="' + ('start' if i == 0 else 'stop') + '"/></notations>' if i in (0, 2) else '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification>') for i in range(3))
cases = {
'three bars': xml_bars([whole] * 3),
'pickup': xml_bars([quarter], implicit=True),
'multiple voices': xml_bars([whole + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E', extra='<voice>2</voice>')]),
'meter change': xml_bars([whole, '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + quarter * 3]),
'mid-measure meter': xml_bars([quarter + '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + quarter * 3]),
'grace': xml_bars(['<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>' + whole]),
'spacer': xml_bars(['<forward><duration>1</duration><voice>1</voice></forward>' + quarter * 3]),
'tremolo': xml_bars([tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo type="single">1</tremolo></ornaments></notations>')]),
'malformed XML': '<broken',
'wrong root': '<score-timewise version="4.0"/>',
}
for name, content in cases.items():
with self.subTest(source=name), self.assertRaises(ValueError):
self.load({'4_4/1/invalid.xml': content})
class AccompanimentPlanningTests(unittest.TestCase):
def test_pickup_coverage_excludes_notes_that_ended_before_the_excerpt(self):
measure = melody_measures(1)[0]
measure.update(implicit=True, duration=generator.model.rational(1))
item = generator.accompaniment.context(measure, None)
bar = quarter_bar(4, {0, 1})
self.assertEqual(generator.accompaniment.sounding_spans(bar, item), ())
self.assertEqual(generator.accompaniment.rank(bar, [(item, ())], 'upper')[0], 1)
def test_melody_context_uses_audible_hidden_notes_but_not_cues(self):
measure = melody_measures(1)[0]
notes = measure['voices'][0]['notes']
notes[0]['hidden'] = True
notes[1]['cue'] = True
notes[2]['pitches'][0]['cue'] = True
item = generator.accompaniment.context(measure, None)
self.assertEqual(item.attacks, frozenset((0, 3)))
self.assertEqual(item.occupied, ((0, 1), (3, 4)))
def test_fourth_bar_cannot_replace_a_held_half_note_as_one_beat_variation(self):
sounding, silent = held_half_bar(), held_half_bar(False)
library = generator.rhythm.Library((generator.rhythm.Snippet('sounding-half', '4/4', (sounding,)), generator.rhythm.Snippet('silent-half', '4/4', (silent,))))
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()
measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures']
plan = generator.accompaniment.plan_section(measures, library)
self.assertEqual(plan.acoustic_upper, (sounding,) * 4)
self.assertEqual(plan.electric, (sounding,) * 4)
def test_electric_density_targets_two_stabs_per_four_quarter_beats(self):
library = tests.rhythm_fixtures.library()
for beats in (2, 3, 4, 5):
with self.subTest(beats=beats):
plan = generator.accompaniment.plan_section(melody_measures(4, beats=beats), library)
for bar in plan.electric[:3]:
self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2})
self.assertLessEqual(len(plan.electric[3].attacks()), (beats + 1) // 2 + 1)
def test_upper_density_targets_three_attacks_per_four_quarter_beats(self):
library = tests.rhythm_fixtures.library()
for beats, counts in ((2, {1, 2}), (3, {2}), (4, {3}), (5, {4})):
with self.subTest(beats=beats):
plan = generator.accompaniment.plan_section(melody_measures(8, beats=beats, dense=True), library)
for bar in plan.acoustic_upper[:3]:
self.assertIn(len(bar.attacks()), counts)
for bar in plan.acoustic_bass[:3]:
self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2})
self.assertEqual(plan.acoustic_upper[:4], plan.acoustic_upper[4:])
def test_upper_density_scales_with_pickup_and_active_harmony_duration(self):
for beats in (1, 2, 3):
for pickup in (False, True):
with self.subTest(beats=beats, pickup=pickup):
length = beats if pickup else 4
content = tests.test_parts.xml_harmony(offset=0 if pickup else 4 - beats) + tests.test_musicxml.xml_note(duration=1, kind='quarter') * length
measure = tests.test_musicxml.parse(xml_bars([content], implicit=pickup))['staves'][0]['measures'][0]
item = generator.accompaniment.context(measure, None)
bar = quarter_bar(4, {0, 1, 2, 3})
self.assertEqual(item.offset, 4 - beats if pickup else 0)
self.assertEqual(len(generator.accompaniment.sounding_attacks(bar, item)), beats)
self.assertEqual(generator.accompaniment.rank(bar, [(item, ())], 'upper')[2], fractions.Fraction(beats, 4))
def test_harmonic_coverage_outweighs_upper_density_target(self):
content = ''.join(tests.test_parts.xml_harmony(root, offset=i) for i, root in enumerate(('C', 'F', 'G', 'C'))) + tests.test_musicxml.xml_note()
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
item = generator.accompaniment.context(measure, None)
sparse, covered = quarter_bar(4, {0, 1, 2}), quarter_bar(4, {0, 1, 2, 3})
sparse_rank = generator.accompaniment.rank(sparse, [(item, ())], 'upper')
covered_rank = generator.accompaniment.rank(covered, [(item, ())], 'upper')
self.assertEqual((sparse_rank[2], covered_rank[2]), (0, 1))
self.assertLess(covered_rank, sparse_rank)
library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(name, '4/4', (bar,)) for name, bar in (('sparse', sparse), ('covered', covered))))
plan = generator.accompaniment.plan_section([measure] * 3, library)
self.assertEqual(plan.acoustic_upper, (covered,) * 3)
def test_upper_density_is_not_an_attack_cap(self):
bar = quarter_bar(4, {0, 1, 2, 3})
library = generator.rhythm.Library((generator.rhythm.Snippet('dense', '4/4', (bar,)),))
plan = generator.accompaniment.plan_section(melody_measures(4, dense=True), library)
self.assertEqual(plan.acoustic_upper, (bar,) * 4)
def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self):
for beats in (2, 3, 4, 5):
measures = melody_measures(12, beats=beats, dense=True)
before = copy.deepcopy(measures)
library = tests.rhythm_fixtures.library()
plan = generator.accompaniment.plan_section(measures, library)
for part in (plan.acoustic_bass, plan.acoustic_upper, plan.electric):
with self.subTest(beats=beats, part=part):
self.assertEqual(len(part), 12)
self.assertEqual(part[:4], part[4:8])
self.assertEqual(part[:4], part[8:])
self.assertEqual(part[0], part[2])
self.assertLessEqual(sum(part[1].beat(i) != part[3].beat(i) for i in range(beats)), 1)
self.assertTrue(all(bar in [b for s in library.for_meter(f'{beats}/4') for b in s.bars] for bar in part))
reordered = generator.rhythm.Library(tuple(reversed([s for group in library.snippets.values() for s in group])))
self.assertEqual(generator.accompaniment.plan_section(measures, reordered), plan)
self.assertEqual(measures, before)
def test_acoustic_and_electric_coordinate_when_target_density_has_gaps(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()
measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures']
bass = quarter_bar(4, {0, 2})
electric = quarter_bar(4, {1, 3})
upper = generator.rhythm.Bar(tuple(generator.rhythm.Event(fractions.Fraction(i, 2), fractions.Fraction(1, 2), i in (1, 3, 5), 'eighth') for i in range(8)), fractions.Fraction(4))
library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(name, '4/4', (bar,)) for name, bar in (('bass', bass), ('electric', electric), ('upper', upper))))
plan = generator.accompaniment.plan_section(measures, library)
self.assertEqual(plan.acoustic_bass, (bass,) * 4)
self.assertEqual(plan.acoustic_upper, (upper,) * 4)
self.assertEqual(plan.electric, (electric,) * 4)
def test_equal_density_candidates_favor_gaps_around_melody_and_companions(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
item = generator.accompaniment.context(measure, None)
for role, companions, collision, gap in (('upper', (), {0, 1, 2}, {1, 2, 3}), ('electric', (2,), {0, 2}, {1, 3})):
with self.subTest(role=role):
ranks = [generator.accompaniment.rank(quarter_bar(4, attacks), [(item, companions)], role) for attacks in (gap, collision)]
self.assertLess(ranks[0], ranks[1])
def test_equal_density_candidates_consider_sustained_melody(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=2, kind='half') + '<note><rest/><duration>2</duration><type>half</type></note>'
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
item = generator.accompaniment.context(measure, None)
occupied, gap = quarter_bar(4, {1, 3}), quarter_bar(4, {2, 3})
for role in ('upper', 'electric'):
with self.subTest(role=role):
self.assertLess(generator.accompaniment.rank(gap, [(item, ())], role), generator.accompaniment.rank(occupied, [(item, ())], role))
def test_dense_melody_allows_collisions_and_keeps_accompaniment_audible(self):
measures = melody_measures(8, dense=True)
plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library())
for index, measure in enumerate(measures):
melody = {generator.model.fraction(n['onset']) for n in measure['voices'][0]['notes']}
for bars in (plan.acoustic_bass, plan.acoustic_upper, plan.electric):
events = generator.accompaniment.realize(measure, bars[index], None)
attacks = {generator.model.fraction(n['onset']) for n in events if n['kind'] == 'note'}
if bars is not plan.electric:
self.assertTrue(attacks, (index, bars))
self.assertTrue(attacks <= melody)
self.assertTrue(any(bar.attacks() for bar in plan.electric))
def test_section_and_local_meter_changes_restart_the_motif(self):
library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(f'{beats}_4/pair', f'{beats}/4', (quarter_bar(beats, {0}), quarter_bar(beats, {1}))) for beats in (3, 4)))
segments = [melody_measures(3), melody_measures(2, beats=3), melody_measures(3), melody_measures(4)]
segments[-1][0]['section_label'] = 'Bridge'
measures = [m for segment in segments for m in segment]
plan = generator.accompaniment.plan_section(measures, library)
start = 0
for segment in segments:
expected = generator.accompaniment.plan_section(segment, library)
self.assertEqual(plan.acoustic_upper[start:start + len(segment)], expected.acoustic_upper)
self.assertEqual(plan.acoustic_bass[start:start + len(segment)], expected.acoustic_bass)
self.assertEqual(plan.electric[start:start + len(segment)], expected.electric)
self.assertEqual(plan.acoustic_upper[start].attacks(), (0,))
start += len(segment)
def test_mid_measure_meter_changes_and_unsupported_meters_fail(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=1, kind='quarter') + '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
measures = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures']
with self.assertRaisesRegex(ValueError, 'measure 1.*mid-measure meter'):
generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library())
content = '<time><beats>6</beats><beat-type>8</beat-type></time>'
source = tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>'), content)
with self.assertRaisesRegex(ValueError, '6/8'):
generator.accompaniment.plan_section(tests.test_musicxml.parse(source)['staves'][0]['measures'], tests.rhythm_fixtures.library())
def test_carried_harmony_and_no_chord_control_active_intervals(self):
content = tests.test_musicxml.xml_note()
measures = tests.test_musicxml.parse(xml_bars([content, tests.test_parts.xml_harmony(kind='none', offset=2) + content, content]))['staves'][0]['measures']
initial = melody_measures(1)[0]['harmonies'][0]
previous = generator.accompaniment.carried_chords(measures, initial)
self.assertEqual(previous, [initial, initial, measures[1]['harmonies'][0]])
contexts = [generator.accompaniment.context(m, chord) for m, chord in zip(measures, previous)]
self.assertEqual([item.intervals for item in contexts], [((0, 4),), ((0, 2),), ()])
plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library(), initial=initial)
self.assertTrue(generator.accompaniment.sounding_attacks(plan.acoustic_upper[0], contexts[0]))
self.assertEqual(generator.accompaniment.sounding_attacks(plan.acoustic_upper[2], contexts[2]), ())
class AccompanimentRealizationTests(unittest.TestCase):
def test_electric_preserves_half_dotted_half_and_whole_durations(self):
for beats, duration, kind, dots in ((2, 2, 'half', 0), (3, 3, 'half', 1), (4, 4, 'whole', 0), (5, 4, 'whole', 0)):
with self.subTest(beats=beats):
measure = melody_measures(1, beats=beats)[0]
events = [generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(duration), True, kind, dots)]
if beats == 5:
events.append(generator.rhythm.Event(fractions.Fraction(4), fractions.Fraction(1), False, 'quarter'))
notes = generator.accompaniment.realize(measure, generator.rhythm.Bar(tuple(events), fractions.Fraction(beats)), None, lane='electric')
self.assertEqual(notes[0]['written'], {'type': kind, 'dots': dots})
self.assertEqual(generator.model.fraction(notes[0]['duration']), duration)
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), beats)
self.assertEqual([generator.model.fraction(n['onset']) for n in notes if n['kind'] == 'note'], [0])
def test_clipped_long_notes_use_single_note_spelling_then_rests(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
for kind in ('major', 'none'):
for offset, divisions, expected in ((5, 2, fractions.Fraction(2)), (7, 2, fractions.Fraction(7, 2)), (4, 3, fractions.Fraction(4, 3))):
with self.subTest(kind=kind, offset=offset, divisions=divisions):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', kind, offset=offset) + tests.test_musicxml.xml_note(duration=4 * divisions)
measure = tests.test_musicxml.parse(xml_bars([content], divisions=divisions))['staves'][0]['measures'][0]
notes = generator.accompaniment.realize(measure, bar, None, lane='electric')
self.assertEqual(generator.model.fraction(notes[0]['duration']), expected)
self.assertEqual([n['kind'] for n in notes], ['note'] + ['rest'] * (len(notes) - 1))
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4)
if divisions == 2:
self.assertTrue(all('ratio' not in n['written'] for n in notes))
for left, right in zip(notes, notes[1:]):
self.assertEqual(generator.model.fraction(left['onset']) + generator.model.fraction(left['duration']), generator.model.fraction(right['onset']))
def test_long_note_pickup_is_clipped_without_extending_the_measure(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
measure = tests.test_musicxml.parse(xml_bars([content], implicit=True))['staves'][0]['measures'][0]
notes = generator.accompaniment.realize(measure, bar, None, lane='electric')
self.assertEqual(timing(notes), [(0, 3, 'note')])
self.assertEqual(notes[0]['written'], {'type': 'half', 'dots': 1})
def test_harmonic_gating_removes_dangling_beams(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=1) + tests.test_musicxml.xml_note(duration=8)
measure = tests.test_musicxml.parse(xml_bars([content], divisions=2))['staves'][0]['measures'][0]
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(1, 2), True, 'eighth', beams=((1, 'begin'),)), generator.rhythm.Event(fractions.Fraction(1, 2), fractions.Fraction(1, 2), True, 'eighth', beams=((1, 'end'),)), generator.rhythm.Event(fractions.Fraction(1), fractions.Fraction(3), False, 'half', 1)), fractions.Fraction(4))
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual([n['kind'] for n in events], ['note', 'rest', 'rest'])
self.assertTrue(all('beams' not in n for n in events))
def test_harmony_and_no_chord_cut_sustain_without_injecting_attacks(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
for kind in ('major', 'none'):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', kind, offset=3) + tests.test_parts.xml_harmony('G', offset=5) + tests.test_musicxml.xml_note(duration=8)
measure = tests.test_musicxml.parse(xml_bars([content], divisions=2))['staves'][0]['measures'][0]
before = copy.deepcopy(measure)
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, fractions.Fraction(3, 2), 'note'), (fractions.Fraction(3, 2), 2, 'rest'), (fractions.Fraction(7, 2), fractions.Fraction(1, 2), 'rest')])
self.assertEqual(events[0]['_harmony']['root']['letter'], 'c')
self.assertEqual(measure, before)
def test_chord_changes_preserve_authored_rhythm_rests_until_next_attack(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=1) + tests.test_parts.xml_harmony(kind='none', offset=2) + tests.test_parts.xml_harmony('G', offset=3) + tests.test_musicxml.xml_note()
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, 3, 'rest'), (3, 1, 'note')])
self.assertEqual(events[1]['_harmony']['root']['letter'], 'g')
self.assertEqual(events[0]['written'], {'type': 'half', 'dots': 1})
def test_identical_and_coincident_chords_do_not_retrigger_sustains(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F') + tests.test_parts.xml_harmony('F', offset=2) + tests.test_musicxml.xml_note()
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
for lane in ('bass', 'upper', 'electric'):
events = generator.accompaniment.realize(measure, bar, None, lane=lane)
self.assertEqual(timing(events), [(0, 4, 'note')])
self.assertEqual(events[0]['_harmony']['root']['letter'], 'f')
def test_pickup_uses_right_edge_of_nominal_bar_with_exact_clipping(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='quarter', extra='<dot/>')
measure = tests.test_musicxml.parse(xml_bars([content], divisions=2, implicit=True))['staves'][0]['measures'][0]
self.assertEqual(generator.accompaniment.context(measure, None).offset, fractions.Fraction(5, 2))
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, fractions.Fraction(1, 2), 'rest'), (fractions.Fraction(1, 2), 1, 'note')])
self.assertEqual(events[1]['written'], {'type': 'quarter', 'dots': 0})
library = generator.rhythm.Library((generator.rhythm.Snippet('pickup', '4/4', (bar,)),))
plan = generator.accompaniment.plan_section([measure], library)
self.assertEqual(plan.acoustic_upper, (bar,))
self.assertEqual(plan.acoustic_bass, (bar,))
self.assertEqual(plan.electric, (bar,))
class DetachedAccompanimentTests(unittest.TestCase):
def source(self):
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="start"/>')
second = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="stop"/>')
return tests.test_musicxml.parse(xml_bars([first, second]))
def test_rhythm_continuations_are_silent_in_all_accompaniment_lanes(self):
source = self.source()
first, second = source['staves'][0]['measures']
bars = tied_library(2).for_meter('4/4')[0].bars
for lane in ('bass', 'upper', 'electric'):
events = generator.accompaniment.realize(second, bars[1], first['harmonies'][0], lane=lane)
self.assertTrue(all(n['kind'] == 'rest' and '_rhythm_continuation' not in n for n in events))
def test_continuation_only_bar_does_not_count_as_audible_accompaniment(self):
measures = self.source()['staves'][0]['measures']
with self.assertRaisesRegex(ValueError, 'no rhythm motif'):
generator.accompaniment.plan_section(measures, tied_library(4))
def test_planner_chooses_attacks_instead_of_continuation_only_bars(self):
source = self.source()
library = generator.rhythm.Library(tied_library(4).for_meter('4/4') + tests.rhythm_fixtures.library().for_meter('4/4'))
plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library)
for bar in (*plan.acoustic_bass, *plan.acoustic_upper):
self.assertTrue(bar.attacks())
def test_acoustic_stops_at_barline_without_inventing_continuation_attacks(self):
source = self.source()
before = copy.deepcopy(source)
bars = tied_library(2).for_meter('4/4')[0].bars
plan = generator.accompaniment.Plan(bars, bars, ())
prepared = generator.parts.prepare_document(source)
result = tests.arrangement_fixtures.section_document(prepared, 'acoustic', rhythms=tests.rhythm_fixtures.library(), plan=plan)
generator.model.validate_document(result)
self.assertEqual(timing(tests.test_musicxml.notes(result)), [(0, 4, 'note')])
self.assertEqual(timing(tests.test_musicxml.notes(result, 1)), [(0, 4, 'rest')])
timeline = generator.playback.compile_timeline(result)
self.assertTrue(timeline['events'])
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 for e in timeline['events']))
for measure in result['staves'][0]['measures']:
for voice in measure['voices']:
for note in voice['notes']:
for pitch in note['pitches']:
self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', pitch)
self.assertEqual(source, before)
+2 -16
View File
@@ -12,9 +12,7 @@ import generator.engraving.layout
import generator.engraving.types
import generator.model
import generator.playback
import generator.rhythm
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_accompaniment
import tests.test_musicxml
import tests.test_parts
@@ -110,17 +108,6 @@ class AcousticTests(unittest.TestCase):
notes = self.combine(acoustic_document([([(0, fractions.Fraction(5, 2))], [])]))[0]
self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'half', 'dots': 0}])
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'rest')])
def test_harmony_clipping_waits_for_combined_attacks_before_shortening(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=13) + tests.test_musicxml.xml_note(duration=16)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content], divisions=4))
bass = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
upper = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4))
plan = generator.accompaniment.Plan((bass,), (upper,), ())
result = tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library(), plan=plan)
generator.model.validate_document(result)
notes = tests.test_musicxml.notes(result)
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 3, 'note'), (3, fractions.Fraction(1, 4), 'note'), (fractions.Fraction(13, 4), fractions.Fraction(3, 4), 'rest')])
self.assertEqual(notes[0]['written'], {'type': 'half', 'dots': 1})
def test_unrelated_voices_and_solo_slashes_are_unchanged(self):
document = acoustic_document([([(0, 4)], [(0, 4)])])
for part in ('electric', 'vocal', None):
@@ -138,11 +125,10 @@ class AcousticTests(unittest.TestCase):
self.assertEqual(document, before)
def test_song_and_section_integration_preserve_playback(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()))
rhythms = tests.rhythm_fixtures.library()
for builder in (generator.arrange.generate.arrange_document, tests.arrangement_fixtures.section_document):
with unittest.mock.patch('generator.acoustic.combine'):
separate = builder(source, 'acoustic', rhythms=rhythms)
combined = builder(source, 'acoustic', rhythms=rhythms)
separate = builder(source, 'acoustic')
combined = builder(source, 'acoustic')
self.assertEqual(sounding(separate), sounding(combined))
generator.model.validate_document(combined)
self.assertTrue(all(len(m['voices']) == 1 for m in combined['staves'][0]['measures']))
-24
View File
@@ -12,7 +12,6 @@ import generator.fretboard
import generator.harmony
import generator.model
import generator.musicxml
import generator.rhythm
import tests.test_acoustic
import tests.test_accompaniment
@@ -263,26 +262,3 @@ class AcousticVoicingTests(unittest.TestCase):
document['staves'][0]['measures'][0]['staff_details']['capo'] = 1
with self.assertRaisesRegex(ValueError, 'capo'):
generator.acoustic_voicing.place(document)
def test_real_song_iterations_use_complete_upper_voices(self):
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
source = generator.musicxml.load_musicxml(path)
before = copy.deepcopy(source)
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=rhythms)
generator.model.validate_document(result)
self.assertEqual(source, before)
for measure in result['staves'][0]['measures']:
for voice in measure['voices']:
for note in voice['notes']:
upper = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
self.assertIn(upper, ([], [2, 3, 4]))
frets = [p['tablature']['fret'] for p in note['pitches']]
if frets:
self.assertLessEqual(max(frets) - min(frets) + 1, 3)
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
if path.stem == 'autumn_leaves':
for index, expected in ((0, voicing((3, 2, 3, 3), 6)), (1, voicing((3, 2, 3, 2), 6)), (4, voicing((3, 2, 3, 3), 6)), (5, voicing((3, 2, 3, 2), 6))):
measure = result['staves'][0]['measures'][index]
actual = {p for v in measure['voices'] for n in v['notes'] for p in shape(n)}
self.assertEqual(actual, set(expected))
self.assertEqual(result, generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=rhythms))
+6 -10
View File
@@ -11,7 +11,6 @@ import generator.musicxml
import generator.parts
import generator.playback
import generator.score
import tests.rhythm_fixtures
import tests.test_musicxml
import tests.test_parts
@@ -26,11 +25,10 @@ class ArrangementPickupTests(unittest.TestCase):
document = generator.musicxml.load_musicxml(path)
before = copy.deepcopy(document)
prepared = generator.parts.prepare_document(document)
plans = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
timelines = []
for part in generator.parts.PART_TYPES:
with self.subTest(song=path.stem, part=part):
result = generator.arrange.generate._arrange_document(prepared, part, plans)
result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 176)
@@ -50,7 +48,7 @@ class ArrangementPickupTests(unittest.TestCase):
def test_unmarked_leading_rests_are_not_pickups(self):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
document = source(opening, implicit=False)
result = generator.arrange.generate.arrange_document(document, 'vocal', rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(document, 'vocal')
self.assertEqual(len(result['staves'][0]['measures']), 14)
self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0)
def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self):
@@ -58,7 +56,7 @@ class ArrangementPickupTests(unittest.TestCase):
document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}]
for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))):
with self.subTest(part=part):
result = generator.arrange.generate.arrange_document(document, part, rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 7)
@@ -90,14 +88,12 @@ class ArrangementPickupTests(unittest.TestCase):
def test_pickup_harmonies_are_planned_for_every_part_at_the_same_offset(self):
document = source(tests.test_parts.xml_harmony('F') + tests.test_musicxml.xml_note('F', 1, 'quarter'))
prepared = generator.parts.prepare_document(document)
plans = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
for part in generator.parts.PART_TYPES:
result = generator.arrange.generate._arrange_document(prepared, part, plans)
result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result)
for index in (0, 1, 4):
chords = result['staves'][0]['measures'][index]['harmonies']
self.assertEqual([(generator.model.fraction(h['onset']), h['root']['letter']) for h in chords], [(0, 'c'), (3, 'f')])
self.assertIn(fractions.Fraction(3), [event.onset for event in plans[0].acoustic_upper[0].events])
def test_pickup_tuplets_keep_unique_identities_across_iterations(self):
notes = ''
for index, pitch in enumerate(('C', 'D', 'E')):
@@ -105,7 +101,7 @@ class ArrangementPickupTests(unittest.TestCase):
notes += tests.test_musicxml.xml_note(pitch, 1, 'eighth', extra='<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification>' + tuplet)
document = source(notes, divisions=3)
for part in ('vocal', 'electric'):
result = generator.arrange.generate.arrange_document(document, part, rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result)
spans = result['staves'][0]['tuplets']
self.assertEqual(len(spans), 2 if part == 'vocal' else 1)
@@ -117,4 +113,4 @@ class ArrangementPickupTests(unittest.TestCase):
def test_overlong_pickup_is_rejected_with_measure_context(self):
document = source(tests.test_musicxml.xml_note(duration=4), '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>'))
with self.assertRaisesRegex(ValueError, 'measure 2: pickup is longer'):
generator.arrange.generate.arrange_document(document, 'vocal', rhythms=tests.rhythm_fixtures.library())
generator.arrange.generate.arrange_document(document, 'vocal')
+1 -3
View File
@@ -5,8 +5,6 @@ import tempfile
import unittest
import generator.arrange.generate
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
@@ -18,7 +16,7 @@ class CatalogTests(unittest.TestCase):
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / 'song.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()), 3)
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3)
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
for path in output.iterdir():
metadata = json.loads(path.read_text())['metadata']
+1 -2
View File
@@ -11,7 +11,6 @@ import generator.fretboard
import generator.harmony
import generator.model
import generator.musicxml
import tests.rhythm_fixtures
def harmony(symbol, onset=0):
return generator.harmony.parse_symbol(symbol) | {'id': f'chord-{onset}', 'onset': generator.model.rational(onset)}
@@ -110,7 +109,7 @@ class ElectricVoicingTests(unittest.TestCase):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml'))
before = copy.deepcopy(source)
with unittest.mock.patch('generator.electric.reuse_voicings', side_effect=AssertionError('cross-part grip dependency')) as reuse:
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(source, 'electric')
generator.model.validate_document(result)
reuse.assert_not_called()
self.assertEqual(source, before)
+6 -7
View File
@@ -23,7 +23,6 @@ import generator.model
import generator.musicxml
import generator.playback
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_accompaniment
import tests.test_musicxml
@@ -41,7 +40,7 @@ class EngravingTests(unittest.TestCase):
def test_tab_omits_rests_but_keeps_standard_notation(self):
content = tests.test_musicxml.xml_note('E', 2, 'half', octave=5) + '<note><rest/><duration>2</duration><type>half</type></note>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
document = tests.arrangement_fixtures.section_document(source, 'electric')
layout = self.layout(document)
self.assertEqual(len(self.primitives(layout, 'rest')), 1)
self.assertEqual(self.primitives(layout, 'tab-rest'), [])
@@ -91,7 +90,7 @@ class EngravingTests(unittest.TestCase):
def test_guitar_pages_always_pair_systems_including_tall_passages(self):
content = tests.test_musicxml.xml_note('E', octave=5)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32))
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
document = tests.arrangement_fixtures.section_document(source, 'electric')
layout = self.layout(document)
self.assert_paired_pages(layout)
self.assertEqual(layout.style.space, 10)
@@ -146,7 +145,7 @@ class EngravingTests(unittest.TestCase):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2))
documents = [generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')),
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tied)),
tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())]
tests.arrangement_fixtures.section_document(source, 'electric')]
checked = 0
for sampling in (1, 2, 3, 4):
for document in documents:
@@ -382,11 +381,11 @@ class EngravingTests(unittest.TestCase):
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole')
tab = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=5))), 'electric', rhythms=tests.rhythm_fixtures.library())
tab = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=5))), 'electric')
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '12')
def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self):
content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2)), 'electric', rhythms=tests.rhythm_fixtures.library())
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2)), 'electric')
layout = self.layout(source)
systems = [s for page in layout.pages for s in page.systems]
self.assertGreater(len(systems), 1)
@@ -402,7 +401,7 @@ class EngravingTests(unittest.TestCase):
self.assertEqual(len(generator.playback.compile_timeline(source)['events']), 64)
def test_generated_tab_ties_and_barline_styles(self):
content = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric', rhythms=tests.rhythm_fixtures.library())
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric')
layout = self.layout(source)
self.assertEqual(len(self.primitives(layout, 'tab_tie')), 1)
self.assertEqual(len(self.primitives(layout, 'barline')), len(self.primitives(layout, 'tab_barline')))
+1 -6
View File
@@ -24,15 +24,13 @@ import generator.engraving.types
import generator.engraving.validation
import generator.pico_artwork
import generator.score
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
class EngravingCacheTests(unittest.TestCase):
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
workflow = pathlib.Path('.gitea/workflows/ci.yml').read_text()
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/rhythms/**/*.xml', 'data/scales/**/*.xml', 'data/sequences/**/*.xml')", workflow)
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/composition/*.json', 'data/models/*.json', 'data/training/*.json', 'data/training/*.xml')", workflow)
self.assertIn('python3 modulus.py python -m generator --renderer-fingerprint', workflow)
self.assertIn('restore-keys: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_', workflow)
self.assertIn('path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}', workflow)
@@ -350,9 +348,6 @@ class EngravingCacheTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'must not overlap'):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=cache)
def test_parallel_generation_preserves_assets_on_worker_failure(self):
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
data, output = self.root / 'data', self.root / 'output'
(data / 'songs').mkdir(parents=True)
source = data / 'songs/fixture.xml'
-1
View File
@@ -18,7 +18,6 @@ import generator.solo.placement
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_musicxml
import tests.test_solo
def position(string, fret):
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
+11 -15
View File
@@ -19,8 +19,6 @@ import generator.parts
import generator.pitch
import generator.playback
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
@@ -135,8 +133,8 @@ class GuitarTests(unittest.TestCase):
def test_melody_placement_preserves_notes_timing_and_sound(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4)))
before = copy.deepcopy(source)
electric = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
vocal = tests.arrangement_fixtures.section_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())
electric = tests.arrangement_fixtures.section_document(source, 'electric')
vocal = tests.arrangement_fixtures.section_document(source, 'vocal')
generator.model.validate_document(electric)
self.assertEqual(source, before)
self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in generator.playback.compile_timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in generator.playback.compile_timeline(vocal)['events']])
@@ -148,12 +146,12 @@ class GuitarTests(unittest.TestCase):
for attributes, expected in (('', 48), ('<transpose><chromatic>-12</chromatic><diatonic>-7</diatonic></transpose>', 48), ('<transpose><chromatic>0</chromatic></transpose>', 60), ('<clef><sign>G</sign><line>2</line><clef-octave-change>-1</clef-octave-change></clef>', 60), ('<clef><sign>F</sign><line>4</line></clef>', 48)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes))
for part in ('electric', 'vocal'):
document = tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library())
document = tests.arrangement_fixtures.section_document(source, part)
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0))
def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self):
for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9})):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter)))
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
document = tests.arrangement_fixtures.section_document(source, 'electric')
tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter)
self.assertEqual(tone['tablature'], tab)
@@ -166,7 +164,7 @@ class GuitarTests(unittest.TestCase):
concert = generator.pitch.concert_document(source, octave_transposing=True)
self.assertEqual(generator.pitch.concert_document(concert, octave_transposing=True), concert)
self.assertEqual(source, before)
document = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
document = tests.arrangement_fixtures.section_document(source, 'electric')
self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [60, 72])
def test_all_parts_share_written_octave_convention(self):
chord = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
@@ -174,7 +172,7 @@ class GuitarTests(unittest.TestCase):
for generate in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for part in generator.parts.PART_TYPES:
with self.subTest(generator=generate.__name__, part=part):
document = generate(source, part, rhythms=tests.rhythm_fixtures.library())
document = generate(source, part)
generator.model.validate_document(document)
written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
events = generator.playback.compile_timeline(document)['events']
@@ -244,7 +242,7 @@ class GuitarTests(unittest.TestCase):
(tests.test_musicxml.xml_note(), '<staff-details><capo>1</capo></staff-details>', 'capo')]
for content, attributes, error in cases:
with self.subTest(error=error), self.assertRaisesRegex(ValueError, error):
tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes)), 'electric', rhythms=tests.rhythm_fixtures.library())
tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes)), 'electric')
for content, attributes in [(tests.test_musicxml.xml_note(), '<clef><sign>TAB</sign></clef>'), (tests.test_musicxml.xml_note(extra='<notations><technical><fingering>1</fingering></technical></notations>'), '')]:
with self.assertRaisesRegex(ValueError, 'authored TAB'):
tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes))
@@ -253,13 +251,11 @@ class GuitarTests(unittest.TestCase):
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()), 3)
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3)
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
generator.arrange.generate.generate_performance_scores(source, output)
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
def test_unplayable_generation_preserves_previous_assets(self):
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
(root / 'data/songs').mkdir(parents=True)
@@ -268,11 +264,11 @@ class GuitarTests(unittest.TestCase):
output.mkdir()
(output / 'last-good').write_text('preserved')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml/electric_melody:.*1-20'):
with self.assertRaisesRegex(ValueError, '0001.xml/electric_melody:.*1-20'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
with unittest.mock.patch('generator.comping.voicings', side_effect=ValueError('no legal sparse voicing')), unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()):
with unittest.mock.patch('generator.comping.voicings', side_effect=ValueError('no legal sparse voicing')):
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>dominant</kind></harmony>'+tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, 'no legal sparse voicing'):
generator.__main__.generate_assets(args)
+12 -77
View File
@@ -12,8 +12,6 @@ import generator.model
import generator.parts
import generator.playback
import tests.arrangement_fixtures
import generator.rhythm
import tests.rhythm_fixtures
import tests.test_accompaniment
import tests.test_musicxml
import tests.test_parts
@@ -21,7 +19,7 @@ import tests.test_parts
class GuitarPartTests(unittest.TestCase):
def section(self, content, part='electric', divisions=1):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=divisions))
result = tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(source, part)
generator.model.validate_document(result)
return result
def test_profile_shapes_cover_defining_chord_tones(self):
@@ -48,56 +46,21 @@ class GuitarPartTests(unittest.TestCase):
self.assertEqual(set(requirements.allowed), {0, 7})
self.assertFalse(generator.acoustic_voicing.candidates(requirements))
self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric'))
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
measure = result['staves'][0]['measures'][0]
self.assertEqual(len(measure['voices']), 1)
notes = measure['voices'][0]['notes']
bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p]
upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p}
self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper)
self.assertEqual(len(bass), 2)
self.assertIn([generator.model.pitch_number(p) % 12 for _, p in bass], ([0, 0], [7, 7]))
self.assertEqual(bass[0][1]['tablature'], bass[1][1]['tablature'])
upper_shapes = {tuple((p['tablature']['string'], p['tablature']['fret']) for p in n['pitches'] if p['tablature']['string'] <= 4) for n in notes}
self.assertEqual(len(upper_shapes - {()}), 1)
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4)
for note in notes:
self.assertNotIn('stem', note)
strings = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
if strings:
self.assertEqual(strings, [2, 3, 4])
timeline = generator.playback.compile_timeline(result)['events']
by_id = {p['id']: p for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
for event in timeline:
start = generator.model.fraction(event['onset'])
simultaneous = [e for e in timeline if generator.model.fraction(e['onset']) <= start < generator.model.fraction(e['onset']) + generator.model.fraction(e['duration'])]
positions = [generator.fretboard.TabPosition(by_id[e['id']]['tablature']['string'], by_id[e['id']]['tablature']['fret'], int(generator.model.fraction(e['pitch']))) for e in simultaneous]
self.assertEqual(len(positions), len({p.string_number for p in positions}))
self.assertTrue(generator.fretboard.within_fret_span(positions))
def test_sparse_acoustic_uses_compact_playable_chords(self):
result=self.section(tests.test_parts.xml_harmony()+tests.test_musicxml.xml_note(),'acoustic')
generator.model.validate_document(result)
generator.fretboard.validate_document(result)
sounding=[n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
self.assertTrue(sounding)
self.assertTrue(all(2<=len(n['pitches'])<=3 for n in sounding))
def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
chord = generator.harmony.parse_symbol(symbol)
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
result = tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(source, 'acoustic')
notes = tests.test_musicxml.notes(result)
self.assertEqual({generator.model.pitch_number(p) % 12 for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5}, {expected})
def test_acoustic_bass_and_upper_chord_can_attack_together(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()))
prepared = generator.parts.prepare_document(source)
bar = tests.test_accompaniment.quarter_bar(4, {0, 2})
plan = generator.accompaniment.Plan((bar,), (bar,), ())
result = tests.arrangement_fixtures.section_document(prepared, 'acoustic', rhythms=tests.rhythm_fixtures.library(), plan=plan)
generator.model.validate_document(result)
self.assertEqual(len(result['staves'][0]['measures'][0]['voices']), 1)
notes = tests.test_musicxml.notes(result)
self.assertEqual([generator.model.fraction(n['onset']) for n in notes if n['pitches']], [0, 2])
for onset in (0, 2):
pitches = [p for n in notes if generator.model.fraction(n['onset']) == onset for p in n['pitches']]
positions = [generator.fretboard.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p) - 12)) for p in pitches]
self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), ((5, 4, 3, 2), (6, 4, 3, 2)))
self.assertTrue(generator.fretboard.within_fret_span(positions))
self.assertEqual({min(generator.model.pitch_number(p) for p in n['pitches']) % 12 for n in notes if n['pitches']}, {expected})
def test_long_melody_chords_and_short_notes(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=4) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=5)
notes = tests.test_musicxml.notes(self.section(content))
@@ -131,7 +94,7 @@ class GuitarPartTests(unittest.TestCase):
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', octave=4, extra='<tie type="start"/>')
second = tests.test_parts.xml_harmony(root) + tests.test_musicxml.xml_note('E', octave=4, extra='<tie type="stop"/>')
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([first, second]))
result = tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(source, 'electric')
generator.model.validate_document(result)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
@@ -172,36 +135,8 @@ class GuitarPartTests(unittest.TestCase):
before = copy.deepcopy(source)
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for part in ('electric', 'vocal'):
result = builder(source, part, rhythms=tests.rhythm_fixtures.library())
result = builder(source, part)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
expected = 76 if part == 'electric' else 64
self.assertEqual({generator.model.pitch_number(n['pitches'][0]) for n in notes}, {expected})
self.assertEqual(source, before)
def test_electric_whole_notes_are_uncapped_but_do_not_continue(self):
source = tests.test_accompaniment.melody_measures(1)[0]
for continuation in (False, True):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole', continuation=continuation),), fractions.Fraction(4))
events = generator.accompaniment.realize(source, bar, None, lane='electric')
self.assertEqual(tests.test_accompaniment.timing(events), [(0, 4, 'rest')] if continuation else [(0, 4, 'note')])
def test_long_electric_accompaniment_preserves_playback_durations_without_ties(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', octave=4)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8))
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
library = generator.rhythm.Library((generator.rhythm.Snippet('4_4/3/whole', '4/4', (bar,)),))
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=library)
generator.model.validate_document(result)
durations = {}
section = ''
for measure in result['staves'][0]['measures']:
section = measure.get('section_label') or section
if section not in ('Vocal Melody', 'Ending'):
continue
notes = measure['voices'][0]['notes']
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 4, 'note')])
for pitch in notes[0]['pitches']:
self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', pitch)
durations[pitch['id']] = generator.model.fraction(pitch['duration'])
self.assertTrue(durations)
timeline = generator.playback.compile_timeline(result)['events']
self.assertEqual({e['id']: generator.model.fraction(e['duration']) for e in timeline if e['id'] in durations}, durations)
+1 -2
View File
@@ -11,7 +11,6 @@ import generator.musicxml
import generator.musicxml_writer
import generator.pitch
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_musicxml
def chord_xml(text, kind='other', root='C', extra=''):
@@ -67,7 +66,7 @@ class HarmonyTests(unittest.TestCase):
self.assertEqual(chord['symbol'], label)
self.assertTrue(chord['chord_optional'])
self.assertTrue(chord['parenthesized'])
arranged = tests.arrangement_fixtures.section_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())['staves'][0]['measures'][0]['harmonies'][0]
arranged = tests.arrangement_fixtures.section_document(source, 'vocal')['staves'][0]['measures'][0]['harmonies'][0]
self.assertNotIn('chord_optional', arranged)
self.assertEqual(generator.harmony.symbol(arranged), label)
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
+1 -7
View File
@@ -172,8 +172,7 @@ class TypedExpressionContracts(unittest.TestCase):
self.assertFalse(self.h.evaluate((color,),expressions=(c,)).valid)
def test_mapped_semitone_needs_no_expression_exception(self):
from tests import scale_fixtures
h=harmonic.Harmony(choices(('A7','Dm7'),scale=scale_fixtures.library().scale('harmonic_minor',1,'D')))
h=harmonic.Harmony(choices(('A7','Dm7')))
a=dataclasses.replace(note(0,F(1,2),'c',event_id='csharp'),pitch=types.Pitch('c',F(1),4))
b=note(F(1,2),1,'d',event_id='d')
self.assertTrue(h.evaluate((a,b)).valid)
@@ -186,11 +185,6 @@ class TypedExpressionContracts(unittest.TestCase):
self.assertEqual(len(spans),1)
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):
fast={c.id for c in vocabulary.rhythm_cells(180)}
slow={c.id for c in vocabulary.rhythm_cells(100)}
self.assertNotIn('sixteenth_pickup',fast);self.assertNotIn('triplet_turn',fast)
self.assertIn('sixteenth_pickup',slow);self.assertIn('triplet_turn',slow)
if __name__=='__main__':unittest.main()
+144
View File
@@ -0,0 +1,144 @@
"""Learned inference and explicit authored reuse have different contracts."""
import copy
import dataclasses
import json
from pathlib import Path
from fractions import Fraction as F
import tempfile
import unittest
from unittest.mock import patch
from generator import production,parts,musicxml,train,training_io,vocabulary
from generator.solo import authored,learned,composer,decoder,harmonic,analysis,export
from tests.solo_fixtures import song
from tests.test_musicxml import xml_score,xml_note
ROOT=Path(__file__).resolve().parents[1]
def music(events):return tuple((F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in events)
class TrainingAndRepertoire(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.examples,cls.provenance=train.load_examples(ROOT/'data/training/manifest.json')
def test_all_four_examples_and_intentional_pickup_forms(self):
self.assertEqual(len(self.examples),4)
self.assertEqual(sorted(p['measures'] for p in self.provenance['examples']),[32,32,33,33])
self.assertEqual(sorted(p['pickup_beats'] for p in self.provenance['examples']),['0','0','4','4'])
def test_training_is_pooled_and_deterministic(self):
baseline=learned.fit(self.examples,{})
swapped=[dict(e,role='electric' if e['role']=='acoustic' else 'acoustic') for e in reversed(self.examples)]
self.assertEqual(baseline,learned.fit(swapped,{}))
self.assertNotIn('acoustic',json.dumps(baseline))
self.assertNotIn('electric',json.dumps(baseline))
frozen=json.loads((ROOT/'data/models/solo.json').read_text())
self.assertEqual(frozen,learned.fit(self.examples,self.provenance))
def test_repertoire_exports_exact_sounding_music_for_every_registered_pair(self):
manifest=json.loads((ROOT/'data/training/manifest.json').read_text())
with tempfile.TemporaryDirectory() as directory:
for entry in manifest['examples']:
part='acoustic' if entry['slot']==1 else 'electric'
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
source=production.SourceAnalysis.read(doc)
with patch('generator.solo.composer.compose',side_effect=AssertionError('authored pair must not be predicted')):
result=production.generate_role(source,part+'_solo',ROOT/'data')
path=Path(directory)/(part+'.xml');export.write_role(result,path)
actual=training_io.read(path,part);expected=training_io.read(ROOT/'data/training'/entry['solo'],part,pitch_convention=entry['pitch_convention'])
self.assertEqual(music(actual['sounding_events']),music(expected['sounding_events']),entry['id'])
self.assertEqual([(m['start'],m['duration']) for m in actual['measures']],[(m['start'],m['duration']) for m in expected['measures']])
self.assertEqual(actual['discrepancies'],[])
self.assertTrue(all(n['legal_positions_at_sounding_pitch'] for n in actual['notes'] if n['pitch']))
def test_lookup_uses_source_music_and_slot_not_title_or_path(self):
entry=json.loads((ROOT/'data/training/manifest.json').read_text())['examples'][0]
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
source=production.SourceAnalysis.read(doc)
part='acoustic' if entry['slot']==1 else 'electric'
self.assertIsNotNone(authored.lookup(source,part,ROOT/'data'))
doc['metadata'].update(title='Renamed standard',composer='Display only')
self.assertEqual(source,production.SourceAnalysis.read(doc))
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave']+=1
self.assertIsNone(authored.lookup(production.SourceAnalysis.read(doc),part,ROOT/'data'))
def test_adding_a_new_pair_requires_no_code_or_existing_song_names(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);source=root/'new-standard.xml';solo=root/'interpretation.xml'
content=xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'+xml_note('E',octave=5))
source.write_text(content);solo.write_text(content)
manifest=root/'data/training/manifest.json'
train.register_example(manifest,source,solo,1,'guitar-written')
examples,_=train.load_examples(manifest)
self.assertEqual(len(examples),1)
doc=parts.prepare_document(musicxml.load_musicxml(root/'data/songs'/source.name))
context=production.SourceAnalysis.read(doc)
self.assertIsNotNone(authored.lookup(context,'acoustic',root/'data'))
self.assertIsNone(authored.lookup(context,'electric',root/'data'))
self.assertEqual(solo.read_bytes(),(manifest.parent/solo.name).read_bytes())
before=manifest.read_bytes()
with self.assertRaisesRegex(ValueError,'already registered'):train.register_example(manifest,source,solo,1,'guitar-written')
self.assertEqual(manifest.read_bytes(),before)
def test_incorrect_form_registration_rolls_back(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);source=root/'song.xml';solo=root/'solo.xml'
source.write_text(xml_score(xml_note()));solo.write_text(xml_score(xml_note(duration=2,kind='half')))
manifest=root/'data/training/manifest.json'
with self.assertRaisesRegex(ValueError,'timing differs'):train.register_example(manifest,source,solo,2,'guitar-written')
self.assertFalse(manifest.exists());self.assertFalse((root/'data/songs/song.xml').exists());self.assertFalse((root/'data/training/solo.xml').exists())
class LearnedInference(unittest.TestCase):
def test_model_is_required_without_repertoire(self):
with self.assertRaisesRegex(ValueError,'run python -m generator.train'):learned.Model.load('/missing-model-file')
def test_decoder_runs_without_training_files_and_is_deterministic(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
predictor=learned.Model.load(ROOT/'data/models/solo.json')
with vocabulary.using(vocabulary.load(),'acoustic_solo',{'attempts':2}):
original=Path.open
def guarded(path,*args,**kwargs):
self.assertNotIn('training',str(path));return original(path,*args,**kwargs)
with patch.object(Path,'open',guarded):
first=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor)
second=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor)
self.assertEqual(first.notes(),second.notes());composer.validate(first)
self.assertTrue(first.notes());self.assertEqual(first.diagnostics['construction'],'learned_joint_events')
def test_event_score_does_not_depend_on_action_grouping(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
from generator.harmonic_time import read
h=harmonic.Harmony(read(source['staves'][0]));predictor=learned.Model.load(ROOT/'data/models/solo.json')
engine=decoder.Decoder(a,h,predictor,'acoustic',F(),F(4))
notes=(engine.note(F(),F(1),60),engine.note(F(1),F(2),64))
events=tuple((n.start,n.end,n.pitch.number()) for n in notes)
combined=engine.extend(decoder.State(),(events,notes,()))
split=engine.extend(engine.extend(decoder.State(),(events[:1],notes[:1],())),(events[1:],notes[1:],()))
self.assertEqual(combined,split)
def test_source_features_include_actual_melodic_intervals(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
changed=dataclasses.replace(a,melody=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+(i==1))) for i,n in enumerate(a.melody)))
self.assertNotEqual(learned.source_feature(a,F(1)),learned.source_feature(changed,F(1)))
def test_generated_long_nonchord_notes_still_need_support(self):
from tests.solo_fixtures import choices,note
h=harmonic.Harmony(choices(('C7',)))
self.assertTrue(h.evaluate((note(0,2,'e'),)).valid)
self.assertFalse(h.evaluate((note(0,2,'f'),)).valid)
self.assertTrue(h.evaluate((note(0,F(1,2),'g'),note(F(1,2),1,'f'),note(1,2,'e'))).valid)
def test_held_chord_member_can_become_a_supported_color(self):
from tests.solo_fixtures import choices
source=song(count=2,chords=('C','Dm'));a=analysis.analyze(source)
h=harmonic.Harmony(choices(('C','Dm')))
engine=decoder.Decoder(a,h,learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8))
n=engine.note(F(3),F(6),64) # E: C's third becomes D minor's ninth.
support=engine.permission(n)
self.assertIsNotNone(support)
self.assertEqual(support[0].rule,'minor_ninth')
self.assertTrue(h.evaluate((n,),complete=True,expressions=support).valid)
+2 -9
View File
@@ -16,25 +16,18 @@ import generator.model
import generator.musicxml
import generator.parts
import generator.pitch
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
class MusicModelTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree')
engraving.start()
self.addCleanup(engraving.stop)
catalog = unittest.mock.patch('generator.catalog.build_catalog')
catalog.start()
self.addCleanup(catalog.stop)
rhythms = unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library())
rhythms.start()
self.addCleanup(rhythms.stop)
artwork = unittest.mock.patch('generator.pico_artwork.generate_titles')
artwork.start()
self.addCleanup(artwork.stop)
@@ -88,7 +81,7 @@ class MusicModelTests(unittest.TestCase):
def test_optional_turnaround_and_form(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/all-of-me.xml'))
for part in ('acoustic', 'electric', 'vocal'):
result = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(source, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending'])
@@ -154,7 +147,7 @@ class MusicModelTests(unittest.TestCase):
def test_pickup_tie_survives_vocal_restoration(self):
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
source = source.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
result = generator.arrange.generate.arrange_document(tests.test_musicxml.parse(source), 'vocal', rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(tests.test_musicxml.parse(source), 'vocal')
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
start = measures[0]['voices'][0]['notes'][-1]['pitches'][0]
+10 -55
View File
@@ -18,8 +18,6 @@ import generator.parts
import generator.pitch
import generator.playback
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
@@ -38,7 +36,7 @@ class PartGenerationTests(unittest.TestCase):
self.assertEqual(cursor, generator.model.fraction(measure['duration']))
def test_dense_melody_keeps_rhythmic_accompaniment_without_sustaining_across_changes(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + xml_harmony('F', offset=3) + ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
for result in (tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library()), generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())):
for result in (tests.arrangement_fixtures.section_document(source, 'acoustic'), generator.arrange.generate.arrange_document(source, 'electric')):
generator.model.validate_document(result)
for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes']
@@ -57,7 +55,7 @@ class PartGenerationTests(unittest.TestCase):
def test_harmony_carry_and_explicit_silence(self):
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + tests.test_musicxml.xml_note()
source = tests.test_musicxml.xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + tests.test_musicxml.xml_note() + '</measure><measure number="4">' + tests.test_musicxml.xml_note() + '</measure></part>')
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(source), 'acoustic', rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(source), 'acoustic')
for measure in result['staves'][0]['measures']:
self.assert_complete_measure(measure)
sounding = [n for n in tests.test_musicxml.notes(result) if n['pitches']]
@@ -73,53 +71,14 @@ class PartGenerationTests(unittest.TestCase):
for index in (2, 3):
self.assertTrue(all(n['kind'] == 'rest' and not n['pitches'] for n in tests.test_musicxml.notes(result, index)))
generator.model.validate_document(result)
def test_accompaniment_handles_pickups_meters_and_coincident_markers(self):
for duration, kind, attributes, implicit in ((1, 'quarter', '', True), (3, 'half', '<time><beats>3</beats><beat-type>4</beat-type></time>', False)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + xml_harmony('F') + tests.test_musicxml.xml_note(duration=duration, kind=kind, extra='<dot/>' if duration == 3 else ''), attributes, implicit=implicit))
measure = source['staves'][0]['measures'][0]
plan = generator.accompaniment.plan_section([measure], tests.rhythm_fixtures.library(), electric=False)
events = generator.accompaniment.realize(measure, plan.acoustic_upper[0], None)
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in events), duration)
sounding = [n for n in events if n['kind'] == 'note']
self.assertTrue(sounding)
self.assertTrue(all(n['_harmony']['root']['letter'] == 'f' for n in sounding))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note(duration=1, kind='quarter') + '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')))
with self.assertRaisesRegex(ValueError, 'mid-measure meter'):
tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
def test_dense_melody_has_accompaniment_in_every_measure(self):
def test_sparse_accompaniment_preserves_form_and_intentional_space(self):
bar = xml_harmony() + ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4))
xml = tests.test_musicxml.xml_score(bar).replace('</part>', ''.join(f'<measure number="{i}">{bar}</measure>' for i in range(2, 13)) + '</part>')
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic', rhythms=tests.rhythm_fixtures.library())
self.assertEqual(len(result['staves'][0]['measures']), 12)
for measure in result['staves'][0]['measures']:
self.assertTrue(any(n['pitches'] for v in measure['voices'] for n in v['notes']), measure['id'])
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic')
self.assertEqual(len(result['staves'][0]['measures']),12)
generator.model.validate_document(result)
timeline = generator.playback.compile_timeline(result)
start = fractions.Fraction()
for measure in result['staves'][0]['measures']:
end = start + generator.model.fraction(measure['duration'])
self.assertTrue(any(generator.model.fraction(e['onset']) < end and generator.model.fraction(e['onset']) + generator.model.fraction(e['duration']) > start for e in timeline['events']), measure['id'])
start = end
def test_prepared_document_and_parts_have_independent_ownership(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note()))
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': generator.model.rational(0), 'end': generator.model.rational(4), 'octaves': -1}]
original = copy.deepcopy(source)
prepared = generator.parts.prepare_document(source)
before = copy.deepcopy(prepared)
plan = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
plan_before = copy.deepcopy(plan)
parts = [generator.arrange.generate._arrange_document(prepared, part, plan) for part in generator.parts.PART_TYPES]
self.assertEqual(prepared, before)
self.assertEqual(plan, plan_before)
self.assertEqual(parts, [generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library()) for part in generator.parts.PART_TYPES])
vocal = copy.deepcopy(parts[2])
parts[1]['metadata']['title'] = 'changed'
tests.test_musicxml.notes(parts[1])[0]['pitches'].clear()
if parts[1]['staves'][0]['octave_shifts']:
parts[1]['staves'][0]['octave_shifts'][0]['octaves'] = 9
self.assertEqual(parts[2], vocal)
self.assertEqual(prepared, before)
self.assertEqual(source, original)
self.assertTrue(generator.playback.compile_timeline(result)['events'])
self.assertTrue(any(n['kind']=='rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m)))
def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self):
content = ''
content += tests.test_musicxml.xml_note(duration=2, kind='quarter', alter=1, extra='<tie type="start"/>')
@@ -135,7 +94,7 @@ class PartGenerationTests(unittest.TestCase):
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for part in ('electric', 'vocal'):
with self.subTest(builder=builder.__module__, part=part):
result = builder(source, part, rhythms=tests.rhythm_fixtures.library())
result = builder(source, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
starts = [0] if builder is tests.arrangement_fixtures.section_document else [i for i, m in enumerate(measures) if m.get('section_label') == ('Guitar Melody' if part == 'electric' else 'Vocal Melody')]
@@ -170,13 +129,11 @@ class PartGenerationTests(unittest.TestCase):
for name in names:
(source / (name + '.xml')).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised:
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
generator.arrange.generate.generate_performance_scores(source, output)
for name in names:
self.assertIn(str(source / (name + '.xml')), str(raised.exception))
self.assertEqual([p.name for p in output.iterdir()], ['sentinel'])
def test_pipeline_parses_and_normalizes_each_source_once(self):
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
for category, name in (('songs', 'song'), ('songs', 'other')):
@@ -184,7 +141,6 @@ class PartGenerationTests(unittest.TestCase):
(root / 'data' / category / (name + '.xml')).write_text(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=root / 'data', output_dir=root / 'output', static_assets_dir=pathlib.Path('package/assets'), check=False)
with (
unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()) as load_library,
unittest.mock.patch('generator.musicxml.load_musicxml', wraps=generator.musicxml.load_musicxml) as load,
unittest.mock.patch('generator.pitch.concert_document', wraps=generator.pitch.concert_document) as normalize,
unittest.mock.patch('generator.engraving.pipeline.engrave_tree'),
@@ -195,12 +151,11 @@ class PartGenerationTests(unittest.TestCase):
generator.__main__.generate_assets(args)
# Role catalog loading is replaced by this infrastructure fixture. The
# parent asset pipeline must not perform a redundant catalog parse.
load_library.assert_not_called()
self.assertEqual(load.call_args_list, [unittest.mock.call(args.data_dir.resolve() / 'songs/other.xml'), unittest.mock.call(args.data_dir.resolve() / 'songs/song.xml')])
self.assertEqual(normalize.call_count, 2)
original = {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()}
(root / 'data/songs/broken.xml').write_text('<broken')
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, 'broken.xml'):
with self.assertRaisesRegex(ValueError, 'broken.xml'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()})
self.assertEqual(len(list(args.output_dir.rglob('*.json'))), 6)
-291
View File
@@ -1,291 +0,0 @@
"""Independent musical and selection contracts for complete phrase generation."""
import dataclasses
import fractions
import unittest
from unittest.mock import patch
from generator import model, vocabulary
from generator.scales import selection, timeline
from generator.solo import analysis, construction, harmonic, profiles, types, placement
from generator.solo import selection as evaluation
from tests import solo_fixtures as fixtures, rhythm_fixtures, scale_fixtures
def builder(symbols=('C7','FM7','Dm7','G7'),seed='independent',count=8):
source=fixtures.song(count=count,chords=symbols);a=analysis.analyze(source);ms=source['staves'][0]['measures']
end=sum(model.fraction(m['duration']) for m in ms)
cs=selection.select(timeline.read(source['staves'][0]),timeline.Solo(0,end,'test'),scale_fixtures.library())
return construction.Builder(a,ms,harmonic.Harmony(cs,profiles.Instrument(reference=60)),fixtures.library(),rhythm_fixtures.library(),end,seed)
class PhraseConstruction(unittest.TestCase):
def test_completed_candidates_are_valid_and_cover_the_requested_span(self):
b=builder(count=4)
values=b.candidates(fractions.Fraction(),b.end,construction.State())
self.assertGreater(len(values),1)
for p,home in values:
self.assertEqual((p.start,p.end),(0,b.end))
self.assertTrue(b.harmony.evaluate(p.notes,expressions=p.expressions,complete=True).valid)
self.assertEqual(p.evaluation.total,sum(p.evaluation.contributions.values()))
self.assertTrue(all(p.start<=n.start<n.end<=p.end for n in p.notes))
self.assertTrue(any(p.destination for p,_ in values))
def test_open_boundaries_are_real_and_dont_force_final_chord_tones(self):
b=builder(('C7',),count=8)
values=b.candidates(fractions.Fraction(),fractions.Fraction(16),construction.State())
self.assertTrue(any(p.ending=='open' and p.destination is None for p,_ in values))
self.assertTrue(any(p.ending!='open' for p,_ in values))
def test_realized_theme_is_retained_separately_from_recent_notes(self):
b=builder(('CM7',),count=8);b.compose()
for s in b.completed:
self.assertGreaterEqual(len(s.home),3)
self.assertTrue(set(s.home).issubset(set(s.phrases[0].notes)))
self.assertNotEqual(s.home,s.notes[-len(s.home):])
def test_return_and_answer_are_actual_candidate_operations(self):
b=builder(('CM7',),count=4)
home=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,3,'d'))
state=construction.State(home=home)
values=b.candidates(fractions.Fraction(4),fractions.Fraction(16),state)
self.assertIn('return',{p.purpose for p,_ in values})
self.assertIn('answer',{p.purpose for p,_ in values})
for p,_ in values:
if p.purpose=='return':self.assertEqual([n.end-n.start for n in p.notes[:3]],[1,1,1])
def test_disabled_expressions_are_strict_and_repeatable(self):
b=builder(count=2);b.phrases.enabled=False
a=b.compose();c=b.compose()
self.assertEqual(a,c)
self.assertFalse(any(p.expressions for p in a))
self.assertTrue(all(n.pitch.number()%12 in types.permitted(b.harmony.choices,n.start,n.end) for p in a for n in p.notes))
def test_actual_incoming_pitch_conditions_whole_routes(self):
b=builder(('CM7',),count=2)
rhythm=((fractions.Fraction(),fractions.Fraction(1,2)),(fractions.Fraction(1,2),fractions.Fraction(1)))
low=b.phrases.route(rhythm,(0,2),fixtures.note(-1,0,'c',octave=3),60,'connect')
high=b.phrases.route(rhythm,(0,2),fixtures.note(-1,0,'c',octave=5),60,'connect')
self.assertIsNotNone(low);self.assertIsNotNone(high)
self.assertNotEqual(low,high)
def test_arpeggio_routes_use_written_chord_members(self):
b=builder(('C7',),count=1)
rhythm=tuple((fractions.Fraction(i,2),fractions.Fraction(i+1,2)) for i in range(4))
result=b.phrases.route(rhythm,(0,4,7,10),None,60,'arpeggio')
self.assertIsNotNone(result)
self.assertTrue(all(n.pitch.number()%12 in b.harmony.at(n.start).roles.basic for n in result[0]))
def test_scale_routes_retain_exact_degrees_from_actual_pitch(self):
b=builder(('CM7',),count=1)
scale=scale_fixtures.library().scale('major',1,'C')
self.assertEqual(b.phrases.scale_step(scale,64,1).number(),65) # E to F
self.assertEqual(b.phrases.scale_step(scale,71,1).number(),72) # B to C
self.assertEqual(b.phrases.scale_step(scale,48,-1).number(),47)
self.assertEqual(b.phrases.scale_step(scale,61,1).number(),62) # enter a new map
rhythm=tuple((fractions.Fraction(i,2),fractions.Fraction(i+1,2)) for i in range(4))
realized=b.phrases.route(rhythm,(0,1,2,3),None,64,'connect',degree_path=True)
self.assertIsNotNone(realized)
indices=[types.scale_index(b.harmony.at(n.start).scale,n.pitch.number()) for n in realized[0]]
self.assertEqual([b-a for a,b in zip(indices,indices[1:])],[1,1,1])
def test_no_chord_is_explicit_space(self):
s=fixtures.compose(fixtures.song(count=2,chords=()))
self.assertFalse(s.notes());self.assertEqual(s.phrases[-1].end,8)
def test_source_features_retain_rhythm_and_order_without_absolute_onsets(self):
from generator.solo.phrases import source_ideas
b=builder(count=2);original=source_ideas(b.analysis)
moved=dataclasses.replace(b.analysis,melody=tuple(dataclasses.replace(n,start=n.start+20,end=n.end+20) for n in b.analysis.melody))
shifted=source_ideas(moved)
self.assertEqual({(v.rhythm,v.intervals) for v in original},{(v.rhythm,v.intervals) for v in shifted})
def test_local_best_can_lose_after_the_following_phrase(self):
b=builder(('CM7',),count=2)
def candidate(start,end,state):
pitches=('c','e') if start==0 else ('g',)
result=[]
for letter in pitches:
n=fixtures.note(start,end,letter)
cost=0 if letter=='c' else 1 if letter=='e' else 20 if state.notes[0].pitch.letter=='c' else 0
e=evaluation.evaluate((n,),start,end,b.harmony)
e=dataclasses.replace(e,total=fractions.Fraction(cost))
result.append((types.Phrase(letter,start,end,(n,),'statement','up','open',evaluation=e),(n,)))
return result
with patch.object(b.phrases,'horizon',side_effect=lambda start:start+4),patch.object(b,'candidates',side_effect=candidate):
result=b.compose()
self.assertEqual(result[0].notes[0].pitch.letter,'e')
def test_both_roles_use_all_strings_and_ties_keep_position(self):
for role in ('acoustic','electric'):
p=profiles.for_part(role);self.assertEqual(p.strings,(1,2,3,4,5,6))
for midi in range(41,77):self.assertTrue(placement.positions(midi,p))
n=fixtures.note(0,2,'e');placed=placement.place((n,dataclasses.replace(n,start=fractions.Fraction(2),end=fractions.Fraction(4),continuation=True)),120)[1]
self.assertEqual(placed[0].position,placed[1].position)
class EvaluationContract(unittest.TestCase):
def test_ties_and_partitions_preserve_evaluation(self):
b=builder(('C7',),count=1);n=fixtures.note(0,4,'e')
split=(dataclasses.replace(n,end=fractions.Fraction(2)),dataclasses.replace(n,start=fractions.Fraction(2),continuation=True))
self.assertEqual(evaluation.evaluate((n,),0,4,b.harmony),evaluation.evaluate(split,0,4,b.harmony))
reattacks=tuple(dataclasses.replace(n,continuation=False) for n in split)
self.assertNotEqual(evaluation.evaluate((n,),0,4,b.harmony),evaluation.evaluate(reattacks,0,4,b.harmony))
def test_target_touch_does_not_fulfill_sustained_ending(self):
n=fixtures.note(2,4,'e');anchor=types.Anchor('held',2,2,n.pitch,frozenset({4}),(41,76),2,'terminal','C7',hold_until=4)
reattacks=(dataclasses.replace(n,end=3),dataclasses.replace(n,start=3,event_id='again'))
self.assertFalse(harmonic.anchors_valid((anchor,),reattacks,4,complete=True,closure='closed')[0])
self.assertTrue(harmonic.anchors_valid((anchor,),(n,),4,complete=True,closure='closed')[0])
def test_silence_cannot_win_through_missing_register(self):
b=builder(('C7',),count=1)
silent=evaluation.evaluate((),0,4,b.harmony)
sounding=evaluation.evaluate((fixtures.note(0,4,'e'),),0,4,b.harmony)
self.assertGreater(silent.total,sounding.total)
class ExpressionConstruction(unittest.TestCase):
def test_prepared_suspension_is_constructed_and_keeps_terminal_hold(self):
b=builder(('Gm7','C7'),count=2)
held=fixtures.note(fractions.Fraction(5,2),4,'f',event_id='held')
target=fixtures.note(4,6,'e',event_id='target')
terminal=fixtures.note(6,8,'g',event_id='terminal')
original=b.phrases.choose
with patch.object(b.phrases,'choose',side_effect=lambda values,domain,key:target if domain=='expression_target' else original(values,domain,key)):
ns,cs=b.phrases.expression((held,target,terminal),(),(),0,1)
self.assertEqual(cs[0].kind,'suspension')
self.assertEqual(ns[-1],terminal)
self.assertTrue(b.harmony.evaluate(ns,expressions=cs,complete=True).valid)
def test_anticipation_is_constructed_without_consuming_target_hold(self):
b=builder(('C7','FM7'),count=2)
before=fixtures.note(3,4,'g',event_id='before')
target=fixtures.note(4,6,'a',event_id='target')
ns,cs=b.phrases.expression((before,target),(),(),0,1)
self.assertEqual(cs[0].kind,'anticipation')
self.assertLess(ns[-1].start,4);self.assertEqual(ns[-1].end,6)
self.assertTrue(b.harmony.evaluate(ns,expressions=cs,complete=True).valid)
class DirectedPhraseContracts(unittest.TestCase):
def test_different_destinations_change_the_complete_incoming_route(self):
b=builder(('CM7',),count=2)
rhythm=tuple((fractions.Fraction(i,2),fractions.Fraction(i+1,2)) for i in range(4))
incoming=fixtures.note(-1,0,'g')
low=fixtures.note(2,4,'c')
high=fixtures.note(2,4,'c',octave=5)
args=(rhythm,(0,1,2,3),incoming,60,'connect')
a=b.phrases.route(*args,degree_path=True,target=low)
z=b.phrases.route(*args,degree_path=True,target=high)
self.assertIsNotNone(a);self.assertIsNotNone(z)
self.assertNotEqual([n.pitch for n in a[0]],[n.pitch for n in z[0]])
self.assertLess(abs(a[0][-1].pitch.number()-low.pitch.number()),abs(z[0][-1].pitch.number()-low.pitch.number()))
def test_anticipated_destination_is_planned_before_any_body_exists(self):
b=builder(('G6','B7'),count=2)
choices=b.phrases.arrival_options(fractions.Fraction(7,2),fractions.Fraction(6),fractions.Fraction(4),64,'settle')
target=next(a for a in choices if a.note.pitch.number()==63)
self.assertEqual(target.note.end,6)
self.assertTrue(b.harmony.evaluate((target.note,),expressions=target.contracts).valid)
self.assertFalse(b.harmony.evaluate((target.note,)).valid)
with patch.object(b.phrases,'choose',return_value='enclosure'):
notes,contracts=b.phrases.approach(target,fractions.Fraction(),1)
self.assertTrue(notes)
self.assertTrue(b.harmony.evaluate((*notes,target.note),expressions=(*target.contracts,*contracts)).valid)
self.assertEqual(contracts[0].boundary,4)
self.assertFalse(b.harmony.evaluate(notes,expressions=(*target.contracts,*contracts)).valid)
def test_development_can_rephrase_source_hold_while_return_preserves_it(self):
from generator.solo.phrases import idea
b=builder(('CM7',),count=2)
original=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,6,'d'))
motif=idea(original,'synthetic')
developments=b.phrases.developments(motif,'develop')
self.assertTrue(any(max(y-x for x,y in d.rhythm)<4 for d in developments))
self.assertTrue(all(d.intervals==motif.intervals for d in developments))
self.assertEqual(b.phrases.developments(motif,'return'),(motif,))
extensions=b.phrases.developments(motif,'extend')
reopened=next(v for v in extensions if v.source.endswith(':extend'))
self.assertLess(reopened.rhythm[2][1]-reopened.rhythm[2][0],4)
self.assertGreater(len(reopened.rhythm),len(motif.rhythm))
self.assertEqual(reopened.intervals[:3],motif.intervals)
def test_harmonic_pool_memoization_preserves_complete_candidates(self):
from generator.solo.phrases import Phrases
b=builder(count=2)
cached=b.candidates(fractions.Fraction(),b.end,construction.State())
with patch.object(Phrases,'event_options',Phrases.event_options.__wrapped__):
clean=builder(count=2).candidates(fractions.Fraction(),b.end,construction.State())
self.assertEqual(cached,clean)
def test_colors_include_both_legal_octaves_at_a_midpoint(self):
b=builder(('C7','FM7'),count=2)
options=b.phrases.options(fractions.Fraction(),fractions.Fraction(1),50)
flat_six={n.pitch.number() for n,cs in options if any(c.rule=='dominant_flat_thirteenth' for c in cs)}
self.assertEqual(flat_six,{44,56})
def test_ordered_rhythmic_relationships_survive_retrieval(self):
from generator.solo.groups import descriptor
first=((fractions.Fraction(),fractions.Fraction(1,2)),(fractions.Fraction(1,2),fractions.Fraction(3,2)),(fractions.Fraction(3,2),fractions.Fraction(2)))
second=((fractions.Fraction(),fractions.Fraction(1)),(fractions.Fraction(1),fractions.Fraction(3,2)),(fractions.Fraction(3,2),fractions.Fraction(2)))
self.assertNotEqual(descriptor(first,2),descriptor(second,2))
self.assertNotEqual(descriptor(first,2),descriptor(first,3)) # trailing breath
def test_complete_candidate_contains_planned_local_and_final_arrivals(self):
b=builder(count=4)
candidates=b.candidates(fractions.Fraction(),b.end,construction.State())
self.assertTrue(any(len(p.arrivals)>1 for p,_ in candidates))
for p,_ in candidates:
self.assertTrue(harmonic.anchors_valid(p.arrivals,p.notes,p.end,complete=True,closure='closed')[0])
self.assertEqual(p.evaluation.total,sum(p.evaluation.contributions.values()))
def test_development_and_answer_require_real_identity_evidence(self):
home=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,3,'d'))
developed=(fixtures.note(4,fractions.Fraction(9,2),'d'),fixtures.note(fractions.Fraction(9,2),5,'f',alter=1),fixtures.note(5,6,'e'))
unrelated=(fixtures.note(4,5,'d'),fixtures.note(5,6,'c'),fixtures.note(6,7,'g'))
for purpose in ('develop','answer','extend'):
self.assertEqual(evaluation.theme_distance(home,developed,purpose),0)
self.assertGreater(evaluation.theme_distance(home,unrelated,purpose),0)
self.assertGreater(evaluation.theme_distance(home,developed[:1],purpose),0)
self.assertGreater(evaluation.theme_distance(home,developed,'return'),0)
def test_arrival_preparation_ranks_realized_motion_not_action_label(self):
b=builder(('CM7',),count=1)
target=fixtures.note(2,4,'e',event_id='destination')
anchor=types.Anchor('end',2,2,target.pitch,frozenset({4}),(41,76),2,'terminal','CM7',hold_until=4)
flowing=(fixtures.note(0,1,'c'),fixtures.note(1,2,'d'),target)
jumping=(fixtures.note(0,1,'c'),fixtures.note(1,2,'d',octave=3),target)
good=evaluation.evaluate(flowing,0,4,b.harmony,destinations=(anchor,))
poor=evaluation.evaluate(jumping,0,4,b.harmony,destinations=(anchor,))
self.assertLess(good.raw['arrival'],poor.raw['arrival'])
self.assertLess(good.total,poor.total)
self.assertEqual(good,evaluation.evaluate(flowing,0,4,b.harmony,destinations=(anchor,),purpose='arpeggio'))
def test_history_cost_distinguishes_attacks_from_one_sustain(self):
b=builder(('CM7',),count=4)
notes=tuple(fixtures.note(i,i+1,s) for i,s in enumerate(('c','e','d','g')))
prior=types.Phrase('old',0,4,notes,'statement','up','open')
current=tuple(dataclasses.replace(n,start=n.start+4,end=n.end+4) for n in notes)
fresh=evaluation.evaluate(current,4,8,b.harmony)
repeated=evaluation.evaluate(current,4,8,b.harmony,history=(prior,prior,prior))
self.assertGreater(repeated.raw['repetition'],fresh.raw['repetition'])
held=fixtures.note(4,8,'e')
single=evaluation.evaluate((held,),4,8,b.harmony,history=(prior,prior,prior))
self.assertEqual(single.raw['repetition'],0)
def test_held_destination_is_not_inactive_motion_and_is_binding(self):
b=builder(('CM7',),count=1)
target=fixtures.note(0,4,'e',event_id='held')
anchor=types.Anchor('end',0,0,target.pitch,frozenset({4}),(41,76),4,'terminal','CM7',hold_until=4)
planned=evaluation.evaluate((target,),0,4,b.harmony,destinations=(anchor,))
accidental=evaluation.evaluate((target,),0,4,b.harmony)
self.assertEqual(planned.raw['activity'],0)
self.assertGreater(accidental.raw['activity'],planned.raw['activity'])
self.assertFalse(evaluation.evaluate((dataclasses.replace(target,end=1),),0,4,b.harmony,destinations=(anchor,)).valid)
def test_configured_weights_change_the_authoritative_contribution(self):
b=builder(('CM7',),count=1)
ns=(fixtures.note(0,1,'c'),fixtures.note(1,2,'c'),fixtures.note(2,4,'e'))
before=evaluation.evaluate(ns,0,4,b.harmony)
with patch.dict(evaluation.WEIGHTS,{'repetition':evaluation.WEIGHTS['repetition']+1}):
after=evaluation.evaluate(ns,0,4,b.harmony)
self.assertEqual(after.total-before.total,before.raw['repetition'])
self.assertEqual(after.total,sum(after.contributions.values()))
-29
View File
@@ -1,29 +0,0 @@
import tempfile
import unittest
from unittest.mock import patch
from pathlib import Path
from generator import resources
from tests import scale_fixtures, rhythm_fixtures
from tests import solo_fixtures as fixture
class ResourceCache(unittest.TestCase):
def test_compiled_and_xml_libraries_have_identical_contents(self):
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
import json
encoded=json.loads(json.dumps(resources.encode(original),default=str))
restored=resources.decode(encoded)
self.assertEqual(original[0],restored[0])
self.assertEqual(original[1].paths,restored[1].paths)
self.assertEqual(original[2].snippets,restored[2].snippets)
def test_warm_cache_avoids_xml_import_and_corruption_recompiles(self):
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
with tempfile.TemporaryDirectory() as root:
with patch('generator.resources.scales.load_library',return_value=original[0]) as sc,patch('generator.resources.sequences.load_library',return_value=original[1]),patch('generator.resources.rhythm.load_library',return_value=original[2]):
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
self.assertEqual(sc.call_count,1)
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
self.assertEqual(sc.call_count,1)
next((Path(root)/'_resources').glob('*.json')).write_text('{}')
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
self.assertEqual(sc.call_count,2)
-198
View File
@@ -1,198 +0,0 @@
import contextlib
import copy
import dataclasses
import fractions
import io
import pathlib
import shutil
import tempfile
import unittest
import generator.arrange.form
import generator.arrange.generate
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.types
import generator.harmony
import generator.model
import generator.musicxml
import generator.parts
import generator.playback
import generator.scales.mapping
import generator.scales.library
import generator.scales.selection
import generator.scales.timeline
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.test_accompaniment
import tests.test_musicxml
def timeline(symbols, key=None, duration=4):
key = key or generator.model.key_from_fifths(-1, 'minor')
result = []
for index, symbol in enumerate(symbols):
result.append(generator.scales.timeline.ChordSpan(fractions.Fraction(index * duration), fractions.Fraction((index + 1) * duration), index + 1, str(index + 1), fractions.Fraction(1), key, generator.harmony.parse_symbol(symbol) if symbol else None))
return tuple(result)
def choose(symbols, key=None):
spans = timeline(symbols, key)
return generator.scales.selection.select(spans, generator.scales.timeline.Solo(spans[0].start, spans[-1].end, 'Solo'), tests.scale_fixtures.library())
class ScaleLibraryTests(unittest.TestCase):
def test_all_patterns_and_transpositions_preserve_spelling_and_rotations(self):
library = tests.scale_fixtures.library()
self.assertEqual(len(library.patterns), 21)
self.assertEqual(len({scale.pitches for scale in library.collections()}), 36)
for pattern in library.patterns:
for root in generator.scales.library.ROOTS:
scale = library.scale(pattern.family, pattern.mode, root)
self.assertEqual(len(scale.pitches), 7)
self.assertEqual(scale.notes[0], root)
self.assertEqual(library.chord_mode(scale, root), (1, scale.label()))
self.assertEqual(library.scale('harmonic_minor', 3, 'C').notes, ('C', 'D', 'E', 'F', 'G#', 'A', 'B'))
self.assertEqual(library.scale('harmonic_minor', 7, 'C').notes, ('C', 'Db', 'Eb', 'Fb', 'Gb', 'Ab', 'Bbb'))
self.assertEqual(library.scale('major', 1, 'C#').pitches, library.scale('major', 1, 'Db').pitches)
self.assertNotEqual(library.scale('major', 1, 'C#').notes, library.scale('major', 1, 'Db').notes)
def test_incomplete_unexpected_and_inconsistent_xml_patterns_are_rejected(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory) / 'scales'
shutil.copytree('data/scales', root)
path = root / 'harmonic_minor_scale_modes/0003.xml'
original = path.read_text()
path.unlink()
with self.assertRaisesRegex(ValueError, '21 scale patterns'):
generator.scales.library.load_library(root)
path.write_text(original.replace('<step>A</step>', '<step>G</step>'))
with self.assertRaisesRegex(ValueError, '0003.xml.*C through B'):
generator.scales.library.load_library(root)
path.write_text(original.replace('<alter>1</alter>', '<alter>0</alter>').replace('<accidental>sharp</accidental>', '<accidental>natural</accidental>'))
with self.assertRaisesRegex(ValueError, '0003.xml.*not mode 3'):
generator.scales.library.load_library(root)
path.write_text(original)
extra = root / 'unknown.xml'
extra.write_text(original)
with self.assertRaisesRegex(ValueError, 'unexpected'):
generator.scales.library.load_library(root)
class ScaleSelectionTests(unittest.TestCase):
def test_autumn_leaves_guide_collections_and_modes(self):
first = ['Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7']
bridge = ['Em7b5', 'A7', 'Dm7', 'Dm7', 'Gm7', 'C7', 'FM7', 'FM7']
labels = [c.scale.label() for c in choose(first + first + bridge)]
phrase = ['D Aeolian'] * 4 + ['D Harmonic Minor'] * 2 + ['D Aeolian'] * 2
self.assertEqual(labels, phrase * 2 + ['D Harmonic Minor'] * 2 + ['D Aeolian'] * 6)
ending = choose(['Em7b5', 'A7', 'Dm7', 'G7', 'Cm7', 'F7', 'Em7b5', 'A7', 'Dm7'])
self.assertEqual([c.scale.label() for c in ending], ['D Harmonic Minor'] * 2 + ['D Aeolian', 'C Harmonic Minor', 'Bb Ionian', 'Bb Ionian'] + ['D Harmonic Minor'] * 2 + ['D Aeolian'])
library = tests.scale_fixtures.library()
self.assertEqual(library.chord_mode(ending[0].scale, 'E'), (2, 'E Locrian natural 6'))
self.assertEqual(library.chord_mode(ending[1].scale, 'A'), (5, 'A Phrygian Dominant'))
self.assertEqual(library.chord_mode(ending[4].scale, 'C'), (2, 'C Dorian'))
def test_actual_song_uses_minor_tonic_triads_without_forcing_sevenths(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml'))
spans = generator.scales.timeline.read(source['staves'][0])
choices = generator.scales.selection.select(spans, generator.scales.timeline.Solo(4, spans[-1].end, 'Solo'), tests.scale_fixtures.library())
self.assertTrue(all(c.scale for c in choices))
for choice in choices:
chord = choice.span.chord
if chord['symbol'] == 'Dm':
self.assertNotIn(7, generator.harmony.degrees(chord))
self.assertEqual(choice.scale.label(), 'D Aeolian')
self.assertLessEqual(set(generator.harmony.pitch_classes(chord)), choice.scale.pitches)
half_bar = [(c.span.source_measure, c.span.beat, c.scale.label()) for c in choices if c.span.source_measure in ('28', '29')]
self.assertEqual(half_bar, [('28', 1, 'D Aeolian'), ('28', 3, 'C Harmonic Minor'), ('29', 1, 'Bb Ionian'), ('29', 3, 'Bb Ionian')])
def test_extensions_slash_basses_omissions_and_diminished_chords(self):
for symbol in ('Em9b5', 'DmM7', 'Dm6', 'FM7#5', 'C#dim7', 'Dm7/F', 'C7no5', 'C7b5b9'):
with self.subTest(symbol=symbol):
choice = choose([symbol])[0]
self.assertIsNotNone(choice.scale)
self.assertLessEqual(set(generator.harmony.pitch_classes(choice.span.chord)), choice.scale.pitches)
choices = choose(['C#dim7', 'Dm'])
self.assertEqual([c.scale.label() for c in choices], ['D Harmonic Minor', 'D Aeolian'])
self.assertIsNone(choose(['A13b9'])[0].scale)
with self.assertRaises(ValueError):
generator.harmony.parse_symbol('A7alt')
def test_shared_collection_wins_over_independent_compatible_colors(self):
choices = choose(['E7', 'F#7'], generator.model.key_from_fifths(0))
self.assertEqual([choice.scale.label() for choice in choices], ['B Melodic Minor'] * 2)
self.assertEqual(choices, choose(['E7', 'F#7'], generator.model.key_from_fifths(0)))
def test_carried_harmony_and_fractional_key_boundaries_are_exact(self):
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 3))
measures = document['staves'][0]['measures']
measures[0]['harmonies'] = [{'id': 'h1', 'onset': generator.model.rational(0), **generator.harmony.parse_symbol('CM7')}]
measures[1]['attributes'] = [{'onset': generator.model.rational(fractions.Fraction(3, 2)), 'key': generator.model.key_from_fifths(1)}]
measures[2]['harmonies'] = [{'id': 'h2', 'onset': generator.model.rational(0), 'kind': 'none', 'degrees': []}]
spans = generator.scales.timeline.read(document['staves'][0])
self.assertEqual([(s.start, s.end) for s in spans], [(0, 4), (4, fractions.Fraction(11, 2)), (fractions.Fraction(11, 2), 8), (8, 12)])
self.assertEqual(spans[1].chord, spans[0].chord)
self.assertEqual(spans[2].key['tonic']['letter'], 'g')
self.assertIsNone(spans[3].chord)
def test_microtonal_harmonies_are_explicitly_unmatched(self):
spans = timeline(['A7', 'Dm'])
for span in spans:
span.chord['root']['alter'] = generator.model.rational(fractions.Fraction(1, 2))
result = generator.scales.selection.select(spans, generator.scales.timeline.Solo(0, 8, 'Solo'), tests.scale_fixtures.library())
self.assertTrue(all(c.scale is None and 'No exact match' in c.reason for c in result))
def test_local_key_and_no_chord_break_continuity_and_missing_tonic_fails(self):
spans = list(timeline(['Dm', None, 'Cm']))
spans[-1] = dataclasses.replace(spans[-1], key=generator.model.key_from_fifths(-3, 'minor'))
solo = generator.scales.timeline.Solo(0, 12, 'Solo')
result = generator.scales.selection.select(tuple(spans), solo, tests.scale_fixtures.library())
self.assertEqual([c.scale.label() if c.scale else None for c in result], ['D Aeolian', None, 'C Aeolian'])
spans[0] = dataclasses.replace(spans[0], key=generator.model.key_from_fifths(0, 'none'))
with self.assertRaisesRegex(ValueError, 'measure 1.*declared tonic'):
generator.scales.selection.select(tuple(spans), solo, tests.scale_fixtures.library())
class ScaleOutputTests(unittest.TestCase):
def test_annotations_reports_and_warnings_preserve_music_and_playback(self):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 4))
staff = source['staves'][0]
staff.update(name='Acoustic', arrangement_part_type='acoustic')
for measure, symbol in zip(staff['measures'], ('CM7', 'CM7', 'A13b9', 'CM7')):
measure['harmonies'] = [{'id': measure['id'] + '-h1', 'onset': generator.model.rational(0), **generator.harmony.parse_symbol(symbol)}]
before = copy.deepcopy(source)
warnings = io.StringIO()
with contextlib.redirect_stdout(warnings):
report = generator.scales.mapping.annotate(source, [generator.scales.timeline.Solo(0, 12, 'Acoustic Solo')], tests.scale_fixtures.library(), 'fixture.xml')
self.assertTrue(report.startswith('Acoustic: Acoustic Solo\n'))
self.assertIn('fixture.xml, Acoustic, source measure 3', warnings.getvalue())
self.assertIn('No exact match within the permitted catalog', report)
self.assertIn('Parent degree: 1; chord-root mode: C Ionian', report)
spans = staff.pop('scale_spans')
self.assertEqual(source, before)
self.assertEqual([(generator.model.fraction(s['start']), generator.model.fraction(s['end']), s['label']) for s in spans], [(0, 8, 'C Ionian'), (8, 12, 'No exact scale match')])
staff['scale_spans'] = spans
generator.model.validate_document(source)
self.assertNotEqual(generator.engraving.cache.score_fingerprint(source, 'renderer'), generator.engraving.cache.score_fingerprint(before, 'renderer'))
self.assertEqual(generator.playback.compile_timeline(source), generator.playback.compile_timeline(before))
def test_layout_keeps_exact_offsets_continuations_and_uniform_size(self):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 40))
staff = source['staves'][0]
staff['measures'][0]['section_label'] = 'Electric Solo'
staff['scale_spans'] = [{'id': f'scale-{i}', 'start': generator.model.rational(a), 'end': generator.model.rational(b), 'label': label, 'matched': True} for i, (a, b, label) in enumerate(((0, fractions.Fraction(3, 2), 'C Ionian'), (fractions.Fraction(3, 2), 60, 'D Harmonic Minor')), 1)]
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
layout = generator.engraving.layout.layout_score(source, generator.engraving.types.Style(), fonts)
systems = [s for p in layout.pages for s in p.systems]
self.assertEqual(len({s.scale for s in systems}), 1)
self.assertTrue(all(len(p.systems) == 2 for p in layout.pages[:-1]))
self.assertGreater(len(systems), 2)
self.assertEqual([p.value for s in systems for p in s.primitives if p.role == 'section'], ['Electric Solo'])
for system in systems:
expected = [s['label'] for s in staff['scale_spans'] if generator.model.fraction(s['start']) < system.end and generator.model.fraction(s['end']) > system.start]
self.assertEqual([p.value for p in system.primitives if p.role == 'scale'], expected)
self.assertIn(fractions.Fraction(3, 2), generator.engraving.notation.ScoreIndex(source).column_times)
def test_only_soloing_part_is_annotated_without_revoicing(self):
content = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
roles = {'acoustic': ('slash', 'accompaniment'), 'electric': ('accompaniment', 'slash'), 'vocal': ('rest', 'rest')}
for part in ('acoustic', 'electric', 'vocal'):
before = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library())
sections = generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'], part)
self.assertEqual([(s.label, s.role) for s in sections[3:5]], list(zip(('Acoustic Solo', 'Electric Solo'), roles[part])))
spans = result['staves'][0].pop('scale_spans', [])
self.assertEqual(result, before)
self.assertEqual(len(spans), int(part != 'vocal'))
if spans:
self.assertEqual(generator.model.fraction(spans[0]['start']), 12 if part == 'acoustic' else 16)
self.assertEqual(generator.model.fraction(spans[0]['end']), 16 if part == 'acoustic' else 20)
+3 -78
View File
@@ -32,86 +32,11 @@ import tests.arrangement_fixtures
import generator.score
import generator.theme
import tests.musicxml_roundtrip
import tests.rhythm_fixtures
import tests.test_musicxml
class ScoreBundleTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
from generator import resources
# Validation now belongs to the catalog-consuming worker, not a redundant
# parent parse. Exercise its real content keys/importer in this rollback test.
self.enterContext(unittest.mock.patch('generator.production._solo_libraries',side_effect=lambda directory,keys,cache=None:resources.load(directory,cache_dir=cache)))
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
data, output = root / 'data', root / 'output'
(data / 'songs').mkdir(parents=True)
shutil.copytree(pathlib.Path('data/scales'), data / 'scales')
shutil.copytree(pathlib.Path('data/sequences/2_4'), data / 'sequences/2_4')
source = data / 'songs/fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
generator.__main__.generate_assets(args)
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
self.assertTrue(list(output.rglob('*.png')))
(data / 'rhythms' / 'drafts').mkdir(parents=True)
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml'):
(data / path).write_text('<unfinished')
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
sequence = data / 'sequences/2_4/0001.xml'
saved = sequence.read_bytes()
sequence.write_text('<unfinished')
with self.assertRaisesRegex(ValueError, '0001.xml'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
sequence.write_bytes(saved)
scale = data / 'scales/major_scale_modes/0002.xml'
previous = scale.read_bytes()
scale.write_text('<unfinished')
with self.assertRaisesRegex(ValueError, '0002.xml'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
scale.write_bytes(previous)
misplaced = data / '0001.xml'
misplaced.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, 'belong directly under'):
generator.__main__.generate_assets(args)
misplaced.unlink()
with unittest.mock.patch('generator.playback.compile_tree', side_effect=ValueError('invalid generated audio')):
with self.assertRaisesRegex(ValueError, 'audio'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + '<barline><repeat direction="backward"/></barline>'))
with self.assertRaisesRegex(ValueError, 'write the music out'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
source.write_text(tests.test_musicxml.xml_score(dense, divisions=16))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, r'songs/fixture_electric.json: measure 3 .*complete measure exceeds'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
renamed = data / 'songs/renamed.xml'
source.rename(renamed)
source = renamed
legacy_output = output / 'obsolete'
legacy_output.mkdir()
(legacy_output / 'electric.json').write_text('{}')
generator.__main__.generate_assets(args)
self.assertFalse(legacy_output.exists())
self.assertFalse(list((output / 'songs').glob('fixture*')))
self.assertEqual(len(list((output / 'songs').glob('renamed_*.json'))), 3)
source.unlink()
generator.__main__.generate_assets(args)
self.assertFalse(list(output.rglob('*.png')))
self.assertFalse(list(output.rglob('*.json')))
def round_trip(self, document):
with tempfile.TemporaryDirectory() as temporary:
path = pathlib.Path(temporary) / 'roundtrip.xml'
@@ -132,7 +57,7 @@ class ScoreBundleTests(unittest.TestCase):
measure = source['staves'][0]['measures'][0]
self.assertEqual([v['id'] for v in measure['voices']], ['1', '2'])
self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0])
for document in (source, generator.pitch.concert_document(source), generator.arrange.generate.arrange_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())):
for document in (source, generator.pitch.concert_document(source), generator.arrange.generate.arrange_document(source, 'vocal')):
generator.model.validate_document(document)
restored = self.round_trip(document)
generator.model.validate_document(restored)
@@ -193,9 +118,9 @@ class ScoreBundleTests(unittest.TestCase):
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
for part in ('vocal', 'acoustic', 'electric'):
with self.assertRaisesRegex(ValueError, 'pitched'):
tests.arrangement_fixtures.section_document(source, part, rhythms=tests.rhythm_fixtures.library())
tests.arrangement_fixtures.section_document(source, part)
with self.assertRaisesRegex(ValueError, 'pitched'):
generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
generator.arrange.generate.arrange_document(source, part)
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
tree = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score('<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><instrument id="drum"/><type>whole</type></note>', attributes='<clef><sign>percussion</sign></clef>'))
definition = tree.find('part-list/score-part')
+2 -32
View File
@@ -12,7 +12,6 @@ from generator.solo import analysis, profiles, placement, types, harmonic
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
import tests.rhythm_fixtures
def events(measure):
@@ -21,37 +20,8 @@ def events(measure):
for v in measure['voices'] for n in v['notes'])
class ReusableAccompaniment(unittest.TestCase):
def test_corresponding_bars_repeat_rhythm_pitch_and_fingering(self):
source=song(count=12,chords=('CM7','Dm7','G7'))
library=tests.rhythm_fixtures.library()
prepared=generator.parts.prepare_document(source)
plans=generator.arrange.generate.plan_arrangement(prepared,library)
for part in ('acoustic','electric'):
result=generator.arrange.generate._arrange_document(prepared,part,plans)
seen={};offset=0;checked=0
sections=generator.arrange.form.full_arrangement_sections(source['staves'][0]['measures'],part)
for section in sections:
for j,m in enumerate(result['staves'][0]['measures'][offset:offset+section.measure_count]):
if section.role!='accompaniment':continue
key=section.source_measure_index+j
if key in seen:
self.assertEqual(events(m),seen[key]);checked+=1
seen[key]=events(m)
self.assertFalse(any('tie_from' in p or 'tie_to' in p for v in m['voices'] for n in v['notes'] for p in n['pitches']))
offset+=section.measure_count
self.assertGreater(checked,12)
generator.model.validate_document(result)
def test_excerpt_rhythms_retain_form_phase(self):
source=generator.parts.prepare_document(song(count=13,chords=('CM7',)))
plans=generator.arrange.generate.plan_arrangement(source,tests.rhythm_fixtures.library())
for lane in ('acoustic_bass','acoustic_upper','electric'):
full=getattr(plans[1],lane)
self.assertEqual(getattr(plans[0],lane),full[-8:])
self.assertEqual(getattr(plans[6],lane),full[-8:])
self.assertEqual(getattr(plans[3],lane),full)
self.assertEqual(getattr(plans[4],lane),full)
class SoloInstrumentAndIdentity(unittest.TestCase):
@@ -71,5 +41,5 @@ class SoloInstrumentAndIdentity(unittest.TestCase):
def test_soft_overlapping_registers_do_not_inherit_a_mandatory_octave(self):
source=analysis.analyze(generator.parts.prepare_document(song(count=4,chords=('CM7',))))
acoustic=profiles.for_part('acoustic',source);electric=profiles.for_part('electric',source)
self.assertLess(electric.reference-acoustic.reference,12)
self.assertIsNone(electric.reference);self.assertIsNone(acoustic.reference)
self.assertEqual(acoustic.melody_range,electric.melody_range)
-199
View File
@@ -1,199 +0,0 @@
import tests.production_fixtures
import argparse
import copy
import dataclasses
import fractions
import pathlib
import tempfile
import unittest
import unittest.mock
import generator.__main__
import generator.arrange.generate
import generator.fretboard
import generator.harmony
import generator.model
import generator.parts
import generator.playback
import generator.scales.library
import generator.scales.selection
import generator.scales.timeline
import generator.score
import generator.solo.analysis
import generator.solo.arrangement
import generator.solo.composer
import generator.solo.notation
import generator.solo.placement
import generator.solo.report
import generator.solo.sequences
import generator.solo.types
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join((tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G')))))
first = document['staves'][0]['measures'][0]
measures = []
for index in range(count):
measure = copy.deepcopy(first)
measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4')
measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)]
measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else []
for harmony in measure['harmonies']:
harmony['symbol'] = generator.harmony.symbol(harmony)
measures.append(measure)
document['staves'][0]['measures'] = measures
return generator.parts.prepare_document(document)
def compose(document, part='acoustic', cutoff=None):
analysis = generator.solo.analysis.analyze(document)
measures = document['staves'][0]['measures']
end = sum((generator.model.fraction(m['duration']) for m in measures)) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
class SoloTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_catalog_intervals_and_negative_scale_indices(self):
library = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744})
self.assertEqual(len({s.steps for s in library.paths[4]}), 2744)
self.assertTrue(all((s.offsets()[-1] == 0 for s in library.connecting(3, 0))))
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
pitches = [generator.solo.types.scale_pitch(scale, i) for i in (2, 3, 5, 4)]
self.assertEqual([p.label() for p in pitches], ['F4', 'G4', 'Bb4', 'A4'])
self.assertEqual(generator.solo.types.scale_pitch(scale, -1).label(), 'C4')
self.assertEqual(generator.solo.types.scale_pitch(scale, -8).label(), 'C3')
self.assertEqual(generator.solo.types.scale_pitch(scale, 7).label(), 'D5')
diminished = tests.scale_fixtures.library().scale('harmonic_minor', 7, 'C')
self.assertEqual(generator.solo.types.scale_pitch(diminished, 6).label(), 'Bbb4')
def test_invalid_sequences_fail_with_source(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
with self.assertRaisesRegex(ValueError, 'empty or missing'):
generator.solo.sequences.load_library(root)
path = root / '2_4/0001.xml'
path.parent.mkdir()
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, '0001.xml.*2/4'):
generator.solo.sequences.load_library(root)
for steps in ((0,), (8,), (), (1, 1, 1, 1)):
with self.assertRaisesRegex(ValueError, 'invalid'):
generator.solo.sequences.Library((generator.solo.sequences.Sequence('bad', steps),))
def test_ties_are_one_attack_but_repeated_notes_are_two(self):
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>')
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
content += tests.test_musicxml.xml_note(duration=2, kind='half')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
attacks = generator.solo.analysis.attacks(document)
self.assertEqual([(n.start, n.end) for n in attacks], [(0, 2), (2, 4)])
self.assertEqual(len(attacks[0].sources), 2)
self.assertEqual(attacks[0].pitch, attacks[1].pitch)
def test_held_pitch_checked_across_every_collection(self):
document = song(count=2, chords=('CM7', 'DmM7'))
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
self.assertIn(0, generator.solo.types.permitted(choices, 0, 4))
self.assertNotIn(0, generator.solo.types.permitted(choices, 3, 5))
self.assertIn(2, generator.solo.types.permitted(choices, 3, 5))
self.assertFalse(generator.solo.types.permitted(choices, 7, 9))
def test_no_harmony_is_silence_and_conflict_is_an_error(self):
result = compose(song(chords=()))
self.assertFalse(result.notes())
with self.assertRaisesRegex(ValueError, 'source measure 1.*No exact match'):
compose(song(chords=('A13b9',)))
def test_microtones_are_not_rounded_into_the_catalog(self):
with self.assertRaisesRegex(ValueError, 'semitones'):
generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4)
def test_pickup_cutoff_is_reserved_before_composition(self):
result = compose(song(count=6), 'electric', fractions.Fraction(43, 2))
self.assertTrue(all((n.end <= fractions.Fraction(43, 2) for n in result.notes())))
self.assertEqual(result.phrases[-1].end, fractions.Fraction(43, 2))
def test_time_aware_placement_does_not_penalize_absolute_frets(self):
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
before = generator.solo.types.Note(0, 1, p, position=generator.fretboard.TabPosition(2, 1, 60))
after = generator.solo.types.Note(1, 2, p, position=generator.fretboard.TabPosition(2, 6, 65))
self.assertGreater(generator.solo.placement.travel(before, after, fractions.Fraction(180)), generator.solo.placement.travel(before, after, fractions.Fraction(90)))
shifted = dataclasses.replace(before, position=generator.fretboard.TabPosition(2, 8, 67))
current = dataclasses.replace(after, position=generator.fretboard.TabPosition(2, 13, 72))
self.assertEqual(generator.solo.placement.travel(before, after, fractions.Fraction(120)), generator.solo.placement.travel(shifted, current, fractions.Fraction(120)))
def test_notation_ties_cross_bars_and_keep_string_without_new_attack(self):
document = song(count=2, chords=('CM7',))
note = generator.solo.types.Note(fractions.Fraction(3), fractions.Fraction(6), generator.solo.types.Pitch('e', fractions.Fraction(), 4), position=generator.fretboard.TabPosition(2, 5, 64))
document['staves'][0]['measures'] = generator.solo.notation.measures(document['staves'][0]['measures'], (note,), 'test')
document['staves'][0]['tablature'] = True
generator.model.validate_document(document)
events = generator.playback.compile_timeline(document)['events']
self.assertEqual(len(events), 1)
self.assertEqual(generator.model.fraction(events[0]['duration']), 3)
self.assertEqual(generator.model.fraction(events[0]['pitch']), 64)
def test_rational_motif_rhythm_has_visible_tuplets_and_exact_playback(self):
document = song(count=1, chords=('CM7',))
pitch = generator.solo.types.Pitch('e', fractions.Fraction(), 4)
notes = tuple((generator.solo.types.Note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), pitch, position=generator.fretboard.TabPosition(2, 5, 64)) for i in range(3)))
replacement = generator.solo.notation.measures(document['staves'][0]['measures'], notes, 'triplet')
generator.solo.notation.replace_region(document, 0, replacement)
generator.model.validate_document(document)
self.assertEqual(document['staves'][0]['tuplets'][0]['note_count'], 3)
self.assertEqual(document['staves'][0]['tuplets'][0]['space_count'], 2)
timeline = generator.playback.compile_timeline(document)
self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']], [fractions.Fraction(1, 3)] * 3)
def test_source_tempo_and_fallback_are_quarter_note_rates(self):
source = song()
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 120)
source['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'written': {'type': 'half', 'dots': 0}, 'bpm': generator.model.rational(75)}]
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 150)
def test_harmony_carries_into_the_solo_until_an_explicit_no_chord(self):
source = song(count=2, chords=())
source['staves'][0]['measures'][1]['harmonies'] = [{'id': 'nc', 'onset': generator.model.rational(0), 'kind': 'none', 'symbol': 'N.C.'}]
result = generator.solo.composer.compose(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'acoustic', 8, initial=generator.harmony.parse_symbol('CM7'), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
self.assertTrue(result.notes())
self.assertTrue(all((n.end <= 4 for n in result.notes())))
def test_conflict_preserves_published_assets(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
(root / 'data/songs').mkdir(parents=True)
path = root / 'data/songs/conflict.xml'
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
output = root / 'assets'
output.mkdir()
(output / 'accepted').write_text('previous build')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.composer.compose', side_effect=ValueError('No exact match within the permitted catalog')):
with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['accepted'])
self.assertEqual((output / 'accepted').read_text(), 'previous build')
def test_unrelated_song_does_not_change_an_accepted_solo(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source, output = (root / 'songs', root / 'assets/songs')
source.mkdir()
xml = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + ''.join((tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
(source / 'accepted.xml').write_text(xml)
resources = {'rhythms': tests.rhythm_fixtures.library(), 'scales': tests.scale_fixtures.library(), 'sequences': tests.solo_fixtures.library()}
generator.arrange.generate.generate_performance_scores(source, output, **resources)
before = {p: p.read_bytes() for p in root.joinpath('assets').rglob('*') if p.is_file()}
(source / 'another.xml').write_text(xml.replace('Fixture', 'Another'))
generator.arrange.generate.generate_performance_scores(source, output, **resources)
self.assertEqual(before, {p: p.read_bytes() for p in before})
def test_sequence_sources_are_packaged_and_tracked(self):
self.assertIn('"data/sequences/**/*.xml"', pathlib.Path('conanfile.py').read_text())
self.assertIn("'data/sequences/**/*.xml'", pathlib.Path('.gitea/workflows/ci.yml').read_text())
self.assertIn('data/*.xml', pathlib.Path('cmake/zero.cmake').read_text())
-101
View File
@@ -1,101 +0,0 @@
import dataclasses, fractions, unittest
import generator.harmony, generator.model, generator.scales.selection, generator.scales.timeline
import generator.solo.harmonic, generator.solo.types
import tests.scale_fixtures
from tests.solo_fixtures import note,choices
F=fractions.Fraction
def anchor(role='terminal', pitch='e', earliest=2, latest=2, duration=2, onset=False):
return generator.solo.types.Anchor('arrival', F(earliest), F(latest), note(0, 1, pitch).pitch, frozenset({4}), (55, 79), F(duration), role, 'C7', onset, F(4) if role == 'terminal' else None)
class HarmonicPassageTests(unittest.TestCase):
def test_same_collection_distinguishes_chord_function(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7', 'FM7', 'BbM7')))
self.assertEqual([s.roles.basic for s in harmony.spans], [frozenset(v) for v in ({7, 10, 2, 5}, {0, 4, 7, 10}, {5, 9, 0, 4}, {10, 2, 5, 9})])
def test_semantic_degrees_keep_alterations_omissions_suspensions_and_slash_bass(self):
for symbol in ('C7b5', 'C9', 'Csus4', 'C7no5', 'C/E', 'C/D'):
chord = generator.harmony.parse_symbol(symbol)
roles = generator.solo.harmonic.roles(chord)
self.assertFalse(roles.basic & roles.specified)
self.assertNotIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C/D')).basic)
self.assertIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C9')).specified)
self.assertIn(5, generator.solo.harmonic.roles(generator.harmony.parse_symbol('Csus4')).basic)
self.assertNotIn(7, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C7no5')).basic)
def test_ordinary_long_destination_requires_chord_member(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertTrue(harmony.evaluate((note(0, 2, 'e'),)).valid)
self.assertFalse(harmony.evaluate((note(0, 2, 'f'),)).valid)
self.assertLess(harmony.evaluate((note(0, 1, 'e'),)).exposure, harmony.evaluate((note(0, 1, 'f'),)).exposure)
def test_connected_passing_motion_is_permitted(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, F(1, 2), 'g'), note(F(1, 2), 1, 'f'), note(1, 2, 'e'))
self.assertEqual(harmony.connected_motion(notes, 1), 'passing')
self.assertTrue(harmony.evaluate(notes).valid)
self.assertEqual(harmony.evaluate(notes).exposure, 0)
def test_common_tone_hold_needs_no_reattack(self):
harmony = generator.solo.harmonic.Harmony(choices(('Em7b5', 'A7'), scale=tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')))
self.assertTrue(harmony.evaluate((note(2, 6, 'g'),)).valid)
def test_held_tension_detected_without_scale_change(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
notes = (note(2, 6, 'f'),)
self.assertFalse(harmony.evaluate(notes).valid)
self.assertEqual([v['function'] for v in harmony.describe(notes)], ['basic', 'mapped_motion'])
def test_prepared_suspension_requires_actual_resolution(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
treatment = generator.solo.types.HarmonicTreatment('suspension', 'suspension', 65, F(4), F(5), 64, F(5))
notes = (note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'e'))
self.assertTrue(harmony.evaluate(notes, (treatment,), complete=True).valid)
partial = (note(2, F(9, 2), 'f'),)
result = harmony.evaluate(partial, (treatment,), until=F(9, 2))
self.assertTrue(result.valid)
self.assertEqual(result.pending, (treatment,))
self.assertFalse(harmony.evaluate(partial, (treatment,), until=6, complete=True).valid)
self.assertFalse(harmony.evaluate((note(4, F(9, 2), 'f'), note(F(9, 2), 6, 'e')), (treatment,), complete=True).valid)
self.assertFalse(harmony.evaluate((note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'g')), (treatment,), complete=True).valid)
def test_symbol_specified_color_requires_a_plan(self):
harmony = generator.solo.harmonic.Harmony(choices(('C9',)))
notes = (note(0, 2, 'd'),)
color = generator.solo.types.HarmonicTreatment('ninth', 'color', 62, F(0), F(2))
self.assertFalse(harmony.evaluate(notes).valid)
self.assertTrue(harmony.evaluate(notes, (color,)).valid)
ordinary = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertFalse(ordinary.evaluate(notes, (color,)).valid)
def test_quotation_tension_preserved_and_reported(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, 2, 'f', role='quote'),)
self.assertTrue(harmony.evaluate(notes).valid)
report = harmony.describe(notes)
self.assertTrue(report[0]['protected'])
self.assertEqual(report[0]['function'], 'mapped_motion')
def test_intermediate_arrival_does_not_require_closure(self):
notes = (note(0, 1, 'g'), note(1, 2, 'e'), note(2, 3, 'f'), note(3, 4, 'g'))
middle = anchor('intermediate', earliest=1, latest=1, duration=1)
self.assertTrue(generator.solo.harmonic.anchors_valid((middle,), notes, F(4), complete=True)[0])
def test_terminal_touch_then_departure_is_not_closure(self):
terminal = dataclasses.replace(anchor(), duration=F(1, 2), hold_until=None)
notes = (note(2, F(5, 2), 'e'), note(F(5, 2), 4, 'g'))
self.assertFalse(generator.solo.harmonic.anchors_valid((terminal,), notes, F(4), complete=True, closure='closed')[0])
self.assertTrue(generator.solo.harmonic.anchors_valid((anchor(),), (note(2, 4, 'e'),), F(4), complete=True, closure='closed')[0])
def test_existing_hold_can_satisfy_anchor_only_when_allowed(self):
notes = (note(0, 4, 'e'),)
self.assertIsNotNone(generator.solo.harmonic.anchor_note(anchor(), notes))
self.assertIsNone(generator.solo.harmonic.anchor_note(anchor(onset=True), notes))
def test_scale_index_round_trips_negative_and_octave_crossings(self):
for scale in (tests.scale_fixtures.library().scale('major', 6, 'D'), tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')):
for index in range(-21, 28):
self.assertEqual(generator.solo.types.scale_index(scale, generator.solo.types.scale_pitch(scale, index).number()), index)
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
+1 -3
View File
@@ -8,9 +8,7 @@ import generator.guitar
import generator.harmony
import generator.model
import generator.musicxml
import generator.rhythm
import generator.score
import tests.rhythm_fixtures
import tests.test_musicxml
import tests.test_parts
@@ -185,7 +183,7 @@ class VoicingTests(unittest.TestCase):
def test_real_autumn_leaves_melody_has_expected_shapes_and_unchanged_notes(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml'))
before = copy.deepcopy(source)
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
result = generator.arrange.generate.arrange_document(source, 'electric')
generator.model.validate_document(result)
section, checked = '', set()
source_index = generator.score.body_start_measure_index(source['staves'][0]['measures'])