@@ -1,225 +0,0 @@
|
||||
"""Read optional reference MusicXML literally, without repairing it or composing."""
|
||||
from __future__ import annotations
|
||||
import argparse, collections, html, os
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import fractions
|
||||
import xml.etree.ElementTree
|
||||
|
||||
import generator.training_io
|
||||
import generator.fretboard
|
||||
import generator.solo.placement
|
||||
import generator.solo.types
|
||||
|
||||
def events(review):
|
||||
offset = fractions.Fraction(review['alignment']['encoded_offset'] or 0)
|
||||
return [dict(start=str(fractions.Fraction(n['start']) - offset), end=str(fractions.Fraction(n['end']) - offset),
|
||||
pitch=n['sounding_midi'], spelling=n['pitch'], measure=n['measure']) for n in review['sounding_events']]
|
||||
|
||||
def rests(review):
|
||||
offset = fractions.Fraction(review['alignment']['encoded_offset'] or 0)
|
||||
cursor = -offset
|
||||
result = []
|
||||
for n in sorted(events(review), key=lambda n: fractions.Fraction(n['start'])):
|
||||
start, end = fractions.Fraction(n['start']), fractions.Fraction(n['end'])
|
||||
if start > cursor:
|
||||
result.append((str(cursor), str(start)))
|
||||
cursor = max(cursor, end)
|
||||
finish = max((fractions.Fraction(m['start']) + fractions.Fraction(m['duration']) for m in review['measures']), default=fractions.Fraction()) - offset
|
||||
if cursor < finish:
|
||||
result.append((str(cursor), str(finish)))
|
||||
return result
|
||||
|
||||
def playability(review, tempo=fractions.Fraction(120)):
|
||||
notes = []
|
||||
conflicts = []
|
||||
for i, n in enumerate(review['sounding_events']):
|
||||
p = n['pitch']
|
||||
shift = fractions.Fraction(n['sounding_midi']) - fractions.Fraction(n['encoded_midi'])
|
||||
if shift % 12:
|
||||
raise ValueError('Guitar comparison expects octave-only transposition')
|
||||
pitch = generator.solo.types.Pitch(p['step'].lower(), fractions.Fraction(p['alter']), p['octave'] + int(shift / 12))
|
||||
note = generator.solo.types.Note(fractions.Fraction(n['start']), fractions.Fraction(n['end']), pitch, event_id=str(i))
|
||||
notes.append(note)
|
||||
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())):
|
||||
conflicts.append({'measure': n['measure'], 'written': p, 'sounding_midi': pitch.number(),
|
||||
'constraint': 'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'})
|
||||
overlaps = [{'first': a.event_id, 'second': b.event_id} for a, b in zip(notes, notes[1:]) if a.end > b.start]
|
||||
placed = generator.solo.placement.place(tuple(notes), tempo) if notes and not conflicts and not overlaps else None
|
||||
return {'conflicts': conflicts, 'overlaps': overlaps, 'complete_path_found': placed is not None,
|
||||
'range': [min(n.pitch.number() for n in notes), max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'positions': [list(n.position[:2]) for n in placed[1]] if placed else [],
|
||||
'limitation': 'Legal placement only. Shift cost is a preference, not a verified human speed limit.'}
|
||||
|
||||
def musical_summary(review):
|
||||
ns = events(review)
|
||||
durations = collections.Counter(str(fractions.Fraction(n['end']) - fractions.Fraction(n['start'])) for n in ns)
|
||||
intervals = collections.Counter(str(fractions.Fraction(b['pitch']) - fractions.Fraction(a['pitch'])) for a, b in zip(ns, ns[1:]))
|
||||
holds = []
|
||||
for event, n in zip(review['sounding_events'], ns):
|
||||
if fractions.Fraction(n['end']) - fractions.Fraction(n['start']) < 2:
|
||||
continue
|
||||
spans = [s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']]
|
||||
holds.append({**n, 'harmonic_spans': spans})
|
||||
return {'attacks': len(ns), 'duration_counts': dict(durations), 'interval_counts': dict(intervals),
|
||||
'rests': rests(review), 'sustained_events': holds,
|
||||
'annotations': review['annotations'], 'interpretation': 'Post-generation diagnostics; does not train or reselect music.'}
|
||||
|
||||
def compare(generated, reference):
|
||||
a, b = events(generated), events(reference)
|
||||
|
||||
def grouped(ns):
|
||||
result = collections.defaultdict(list)
|
||||
for n in ns:
|
||||
result[n['start']].append(n)
|
||||
return result
|
||||
|
||||
left, right = grouped(a), grouped(b)
|
||||
differences = []
|
||||
spelling = []
|
||||
equal = 0
|
||||
for start in sorted(left.keys() | right.keys(), key=fractions.Fraction):
|
||||
x, y = left[start], right[start]
|
||||
|
||||
def sounded(ns):
|
||||
return sorted((n['pitch'], n['end']) for n in ns)
|
||||
|
||||
if sounded(x) != sounded(y):
|
||||
differences.append({'time': start, 'generated': x, 'reference': y,
|
||||
'context': 'reference lead-in before form' if fractions.Fraction(start) < 0 else 'aligned form'})
|
||||
else:
|
||||
equal += len(x)
|
||||
if [n['spelling'] for n in x] != [n['spelling'] for n in y]:
|
||||
spelling.append({'time': start, 'generated': x, 'reference': y})
|
||||
rest_difference = rests(generated) != rests(reference)
|
||||
return {'exact_musical_match': not differences and not rest_difference, 'exact_matching_attacks': equal,
|
||||
'relationships': relationships(a, b),
|
||||
'differences': differences, 'spelling_differences': spelling, 'rest_difference': rest_difference,
|
||||
'generated': musical_summary(generated), 'reference': musical_summary(reference),
|
||||
'reference_playability': playability(reference), 'reference_original': reference}
|
||||
|
||||
def relationships(a, b):
|
||||
"""Read-only comparisons on the aligned clock; no fitted aggregate loss."""
|
||||
|
||||
def distance(left, right):
|
||||
row = list(range(len(right) + 1))
|
||||
for i, x in enumerate(left, 1):
|
||||
next_row = [i]
|
||||
for j, y in enumerate(right, 1):
|
||||
next_row.append(min(next_row[-1] + 1, row[j] + 1, row[j - 1] + (x != y)))
|
||||
row = next_row
|
||||
return row[-1]
|
||||
|
||||
ns = [[dict(start=fractions.Fraction(n['start']), end=fractions.Fraction(n['end']), pitch=fractions.Fraction(n['pitch'])) for n in notes] for notes in (a, b)]
|
||||
points = sorted({n[k] for notes in ns for n in notes for k in ('start', 'end')})
|
||||
occupied = equal = classes = fractions.Fraction()
|
||||
for start, end in zip(points, points[1:]):
|
||||
pitches = [{n['pitch'] for n in notes if n['start'] <= start < n['end']} for notes in ns]
|
||||
if any(pitches):
|
||||
occupied += end - start
|
||||
if pitches[0] == pitches[1]:
|
||||
equal += end - start
|
||||
if {p % 12 for p in pitches[0]} == {p % 12 for p in pitches[1]}:
|
||||
classes += end - start
|
||||
pitches = [[n['pitch'] for n in notes] for notes in ns]
|
||||
rhythms = [[(n['start'], n['end'] - n['start']) for n in notes] for notes in ns]
|
||||
return {'pitch_sequence_edit_distance': distance(*pitches),
|
||||
'timed_attack_edit_distance': distance(*rhythms),
|
||||
'normalization_attacks': max(len(a), len(b)),
|
||||
'same_sounding_pitch_beats': str(equal), 'same_pitch_class_beats': str(classes),
|
||||
'either_sounding_beats': str(occupied),
|
||||
'interpretation': 'Separate descriptive comparisons, not a quality score. Pitch distance ignores timing; timing distance ignores pitch. Held overlap uses the original aligned register and includes lead-ins.'}
|
||||
|
||||
def run(generated_dir, reference_paths, starts, pitch_convention, output, baseline_dir=None):
|
||||
generated_dir, output = pathlib.Path(generated_dir), pathlib.Path(output)
|
||||
manifest = json.loads((generated_dir / 'manifest.solo').read_text())
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
baseline_manifest = json.loads((pathlib.Path(baseline_dir) / 'manifest.solo').read_text()) if baseline_dir else None
|
||||
result = {'comparison_only': True, 'feeds_generation': False, 'solos': {}}
|
||||
for role, path in sorted(reference_paths.items()):
|
||||
item = manifest['solos'][role]
|
||||
actual = generator.training_io.read(generated_dir / item['file'], role, item['form_start_measure'])
|
||||
expected = generator.training_io.read(path, role, starts.get(role), pitch_convention)
|
||||
review = compare(actual, expected)
|
||||
baseline = None
|
||||
if baseline_manifest:
|
||||
old = baseline_manifest['solos'][role]
|
||||
baseline = generator.training_io.read(pathlib.Path(baseline_dir) / old['file'], role, old['form_start_measure'])
|
||||
review['baseline'] = musical_summary(baseline)
|
||||
review['baseline_relationships'] = relationships(events(baseline), events(expected))
|
||||
review['production_trace'] = item['trace']
|
||||
review['production_phrases'] = item.get('phrase_report', {})
|
||||
candidates = review['production_phrases'].get('selection_trace', {}).get('opening_candidates', [])
|
||||
# Preserve the real role cutoff; reference material beyond it is not trimmed.
|
||||
phrases = review['production_phrases'].get('phrases', [])
|
||||
if phrases:
|
||||
cutoff = max(fractions.Fraction(p['end']) for p in phrases)
|
||||
review['production_end'] = str(cutoff)
|
||||
review['reference_events_after_production_end'] = [n for n in events(expected) if fractions.Fraction(n['end']) > cutoff]
|
||||
result['solos'][role] = review
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / 'comparison.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
rows = ['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
|
||||
'<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}td,th{padding:.5rem;text-align:left}pre{white-space:pre-wrap}</style>',
|
||||
'<h1>Completed solos and reference scores</h1><p>Differences are diagnostic. 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']
|
||||
reference_path = pathlib.Path(reference_paths[role])
|
||||
rows += ['<h2>' + html.escape(role) + '</h2>', f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
|
||||
'<p><a href="' + html.escape(os.path.relpath(actual_path, output)) + '">Generated MusicXML</a> · <a href="' + html.escape(os.path.relpath(reference_path, output)) + '">Untouched reference MusicXML</a></p>']
|
||||
if baseline_manifest:
|
||||
baseline_path = pathlib.Path(baseline_dir) / baseline_manifest['solos'][role]['file']
|
||||
rows.append('<p><a href="' + html.escape(os.path.relpath(baseline_path, output)) + '">Previous generated MusicXML</a></p>')
|
||||
rows.append('<table><tr><th>Diagnostic against reference</th><th>Current</th><th>Baseline</th></tr>')
|
||||
for key in ('pitch_sequence_edit_distance', 'timed_attack_edit_distance', 'same_sounding_pitch_beats', 'same_pitch_class_beats', 'either_sounding_beats'):
|
||||
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>Validated production treatments: ' + html.escape(str(dict(selected))) + '. Registered authored music is preserved; harmonic relationships are recognized from notes and chords without requiring explanatory annotations.</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', {})
|
||||
if funnel:
|
||||
stages = tuple(dict.fromkeys(k for counts in funnel.values() for k in counts))
|
||||
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th>' + ''.join('<th>' + k.replace('_', ' ') + '</th>' for k in stages) + '</tr>')
|
||||
for purpose, counts in sorted(funnel.items()):
|
||||
rows.append('<tr><td>' + html.escape(purpose) + '</td>' + ''.join('<td>' + str(counts.get(k, 0)) + '</td>' for k in stages) + '</tr>')
|
||||
rows.append('</table></details>')
|
||||
candidates = r['production_phrases'].get('selection_trace', {}).get('opening_candidates', [])
|
||||
if candidates:
|
||||
rows.append('<details open><summary>Completed source-only opening candidates</summary><p>All candidates were composed without the references. Selection accounts for later phrases; the final choice need not have the cheapest opening alone.</p><table><tr><th>Candidate</th><th>Final choice</th><th>Purpose</th><th>Score contributions</th></tr>')
|
||||
for index, c in enumerate(candidates, 1):
|
||||
rows.append('<tr>' + ''.join('<td>' + html.escape(str(v)) + '</td>' for v in (index, c['selected_in_final_solo'], c['purpose'], c['evaluation']['contributions'])) + '</tr>')
|
||||
rows.append('</table></details>')
|
||||
rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>')
|
||||
for d in r['differences']:
|
||||
|
||||
def describe(ns): return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack'
|
||||
|
||||
rows.append('<tr>' + ''.join('<td>' + html.escape(v) + '</td>' for v in (d['time'], describe(d['generated']), describe(d['reference']))) + '</tr>')
|
||||
rows.append('</table></details>')
|
||||
(output / 'index.html').write_text('\n'.join(rows))
|
||||
return result
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--generated-dir', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--reference', action='append', default=[], metavar='SONG/ROLE=PATH')
|
||||
parser.add_argument('--form-start', action='append', default=[], metavar='SONG/ROLE=MEASURE')
|
||||
parser.add_argument('--reference-pitch', choices=('encoded', 'guitar-written'), default='encoded')
|
||||
parser.add_argument('--output', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--baseline-dir', type=pathlib.Path, help='Optional previous completed solo exports; never generation inputs.')
|
||||
parser.add_argument('--require-exact', action='store_true', help='Fail unless every requested reference has identical complete sounding events and rests.')
|
||||
args = parser.parse_args()
|
||||
if args.require_exact and not args.reference:
|
||||
parser.error('--require-exact requires at least one explicit --reference')
|
||||
result = run(args.generated_dir, dict(x.split('=', 1) for x in args.reference),
|
||||
{k: int(v) for k, v in (x.split('=', 1) for x in args.form_start)}, args.reference_pitch, args.output, args.baseline_dir)
|
||||
for role, review in result['solos'].items():
|
||||
print(f'{role}: exact={review["exact_musical_match"]}; matching attacks={review["exact_matching_attacks"]}; differing onsets={len(review["differences"])}; rests differ={review["rest_difference"]}')
|
||||
return 1 if args.require_exact and any(not r['exact_musical_match'] for r in result['solos'].values()) else 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -357,8 +357,13 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2)
|
||||
generator.__main__.generate_assets(args)
|
||||
original = self.files(output)
|
||||
(output / 'solo_xml').mkdir()
|
||||
(output / 'solo_xml/stale.xml').write_text('obsolete solo export')
|
||||
(output / 'solo_xml/manifest.solo').write_text('{}')
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, self.files(output))
|
||||
self.assertFalse((output / 'solo_xml').exists())
|
||||
self.assertFalse(list(output.rglob('*.xml')))
|
||||
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)
|
||||
|
||||
+28
-11
@@ -11,6 +11,8 @@ import unittest.mock
|
||||
import generator.production
|
||||
import generator.parts
|
||||
import generator.musicxml
|
||||
import generator.model
|
||||
import generator.score
|
||||
import generator.train
|
||||
import generator.training_io
|
||||
import generator.vocabulary
|
||||
@@ -20,7 +22,7 @@ import generator.solo.composer
|
||||
import generator.solo.decoder
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.analysis
|
||||
import generator.solo.export
|
||||
import tests.score_events
|
||||
import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
import generator.harmonic_time
|
||||
@@ -45,23 +47,38 @@ class TrainingAndRepertoire(unittest.TestCase):
|
||||
self.assertNotIn('electric', json.dumps(baseline))
|
||||
frozen = json.loads((ROOT / 'data/models/solo.json').read_text())
|
||||
self.assertEqual(frozen, generator.solo.learned.fit(self.examples, self.provenance))
|
||||
def test_repertoire_exports_exact_sounding_music_for_every_registered_pair(self):
|
||||
def test_repertoire_preserves_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']:
|
||||
for entry in manifest['examples']:
|
||||
with self.subTest(pair=entry['id']):
|
||||
part = 'acoustic' if entry['slot'] == 1 else 'electric'
|
||||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
|
||||
source = generator.production.SourceAnalysis.read(doc)
|
||||
with unittest.mock.patch('generator.solo.composer.compose', side_effect=AssertionError('authored pair must not be predicted')):
|
||||
result = generator.production.generate_role(source, part + '_solo', ROOT / 'data')
|
||||
path = pathlib.Path(directory) / (part + '.xml')
|
||||
generator.solo.export.write_role(result, path)
|
||||
actual = generator.training_io.read(path, part)
|
||||
measures = result['document']['staves'][0]['measures']
|
||||
first = next(i for i, m in enumerate(measures) if m.get('section_label') == part.capitalize() + ' Solo')
|
||||
last = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
|
||||
first -= result['details']['allocation']['lead_in_measures']
|
||||
offset = sum((generator.model.fraction(m['duration']) for m in measures[:first]), fractions.Fraction())
|
||||
timeline = tests.score_events.timeline(result['document'])
|
||||
actual = tuple((generator.model.fraction(n['onset']) - offset,
|
||||
generator.model.fraction(n['onset']) + generator.model.fraction(n['duration']) - offset,
|
||||
generator.model.fraction(n['pitch'])) for n in timeline['events'])
|
||||
expected = generator.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']))
|
||||
self.assertEqual(actual, music(expected['sounding_events']))
|
||||
cursor, spans = fractions.Fraction(), []
|
||||
for measure in measures[first:last]:
|
||||
duration = generator.model.fraction(measure['duration'])
|
||||
spans.append((str(cursor), str(duration)))
|
||||
cursor += duration
|
||||
for note in generator.score.measure_notes(measure):
|
||||
for pitch in note['pitches']:
|
||||
position = pitch['tablature']
|
||||
self.assertIn(position['string'], range(1, 7))
|
||||
self.assertIn(position['fret'], range(1, 13))
|
||||
self.assertEqual(spans, [(m['start'], m['duration']) for m in expected['measures']])
|
||||
self.assertEqual(expected['discrepancies'], [])
|
||||
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 = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Independent musical fixtures; the four optional user scores are never read."""
|
||||
import copy
|
||||
import dataclasses
|
||||
import json
|
||||
import fractions
|
||||
import pathlib
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
import unittest.mock
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
import tests.solo_compare
|
||||
import generator.score
|
||||
import generator.training_io
|
||||
import generator.vocabulary
|
||||
import generator.production
|
||||
import generator.solo.export
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.notation
|
||||
import generator.solo.placement
|
||||
import generator.solo.types
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
|
||||
def xml(notes, *, transpose=False, leadin=''):
|
||||
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">' + leadin + '<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>' + ('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '') + '</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + notes + '</measure></part></score-partwise>'
|
||||
|
||||
def pitched(duration, tie='', octave=5):
|
||||
return f'<note><pitch><step>C</step><octave>{octave}</octave></pitch><duration>{duration}</duration>{tie}<type>quarter</type></note>'
|
||||
|
||||
class ComparisonTests(unittest.TestCase):
|
||||
def test_comparison_separates_pitch_timing_and_sounding_duration(self):
|
||||
def events(pitch, end):
|
||||
return [dict(start='0', end=end, pitch=pitch)]
|
||||
a = events(60, '2')
|
||||
octave = tests.solo_compare.relationships(a, events(72, '2'))
|
||||
self.assertEqual(octave['pitch_sequence_edit_distance'], 1)
|
||||
self.assertEqual(octave['timed_attack_edit_distance'], 0)
|
||||
self.assertEqual(octave['same_sounding_pitch_beats'], '0')
|
||||
self.assertEqual(octave['same_pitch_class_beats'], '2')
|
||||
shorter = tests.solo_compare.relationships(a, events(60, '1'))
|
||||
self.assertEqual(shorter['pitch_sequence_edit_distance'], 0)
|
||||
self.assertEqual(shorter['timed_attack_edit_distance'], 1)
|
||||
self.assertEqual(shorter['same_sounding_pitch_beats'], '1')
|
||||
self.assertEqual(shorter['either_sounding_beats'], '2')
|
||||
def test_exact_cli_is_a_real_exit_gate(self):
|
||||
args = ['solo_compare', '--generated-dir', 'unused', '--output', 'unused', '--reference', 'fixture/acoustic=unused', '--require-exact']
|
||||
review = {'exact_musical_match': False, 'exact_matching_attacks': 0, 'differences': [{}], 'rest_difference': True}
|
||||
with unittest.mock.patch('sys.argv', args), unittest.mock.patch.object(tests.solo_compare, 'run', return_value={'solos': {'fixture/acoustic': review}}), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(tests.solo_compare.main(), 1)
|
||||
review.update(exact_musical_match=True, differences=[], rest_difference=False)
|
||||
self.assertEqual(tests.solo_compare.main(), 0)
|
||||
with unittest.mock.patch('sys.argv', args[:-3] + ['--require-exact']), contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as error:
|
||||
tests.solo_compare.main()
|
||||
self.assertEqual(error.exception.code, 2)
|
||||
def test_note_container_does_not_turn_color_label_into_fingering(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
note_xml = pitched(24).replace('</note>', '<notations><technical><fingering>6</fingering></technical></notations></note>')
|
||||
r = self.review(root, 'annotation.xml', xml(note_xml), form_start=1)
|
||||
self.assertEqual(r['annotations'][0]['text'], '6')
|
||||
self.assertEqual(r['annotations'][0]['container'], 'notations/technical/fingering')
|
||||
self.assertIsNone(r['notes'][0]['tablature'])
|
||||
def review(self, directory, name, content, **kwargs):
|
||||
path = pathlib.Path(directory) / name
|
||||
path.write_text(content)
|
||||
return generator.training_io.read(path, 'fixture/acoustic', **kwargs)
|
||||
def test_ties_not_reattacks_and_one_guitar_transposition(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
a = self.review(root, 'actual.xml', xml(pitched(24), transpose=True), form_start=1)
|
||||
b = self.review(root, 'reference.xml', xml(pitched(12, '<tie type="start"/>') + pitched(12, '<tie type="stop"/>')), form_start=1, pitch_convention='guitar-written')
|
||||
self.assertTrue(tests.solo_compare.compare(a, b)['exact_musical_match'])
|
||||
self.assertEqual(a['sounding_events'][0]['sounding_midi'], '60')
|
||||
repeated = self.review(root, 'repeat.xml', xml(pitched(12) + pitched(12)), form_start=1, pitch_convention='guitar-written')
|
||||
self.assertFalse(tests.solo_compare.compare(a, repeated)['exact_musical_match'])
|
||||
def test_leadin_is_reported_without_harmony_or_alignment_padding(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
prelude = '<measure number="0"><attributes><divisions>6</divisions></attributes>' + pitched(6) + '<note><rest/><duration>18</duration></note></measure>'
|
||||
a = self.review(root, 'a.xml', xml(pitched(24), transpose=True), form_start=1)
|
||||
b = self.review(root, 'b.xml', xml(pitched(24), leadin=prelude), form_start=2, pitch_convention='guitar-written')
|
||||
result = tests.solo_compare.compare(a, b)
|
||||
self.assertEqual(result['differences'][0]['time'], '-4')
|
||||
self.assertEqual(result['exact_matching_attacks'], 1)
|
||||
self.assertIn('uncertainty', b['notes'][0]['harmonic_spans'][0])
|
||||
def test_unplayable_pitch_is_reported_not_folded(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
r = self.review(root, 'high.xml', xml(pitched(24, octave=7)), form_start=1, pitch_convention='guitar-written')
|
||||
checked = tests.solo_compare.playability(r)
|
||||
self.assertEqual(checked['conflicts'][0]['sounding_midi'], 84)
|
||||
self.assertEqual(r['notes'][0]['pitch']['octave'], 7)
|
||||
def test_triplet_time_and_annotations_are_literal(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
triplet = pitched(2).replace('<type>quarter</type>', '<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
|
||||
r = self.review(root, 'thirds.xml', xml('<direction><direction-type><words>Lead in</words></direction-type></direction>' + triplet * 3 + '<note><rest/><duration>18</duration></note>'), form_start=1)
|
||||
self.assertEqual([n['start'] for n in r['sounding_events']], ['0', '1/3', '2/3'])
|
||||
self.assertEqual(r['annotations'][0]['text'], 'Lead in')
|
||||
def test_export_roundtrip_preserves_actual_sounding_events_and_tab(self):
|
||||
document = tests.solo_fixtures.song(count=2, chords=('CM7',))
|
||||
raw = (tests.solo_fixtures.note(0, fractions.Fraction(1, 3), 'c'), tests.solo_fixtures.note(fractions.Fraction(1, 3), fractions.Fraction(2, 3), 'd'), tests.solo_fixtures.note(fractions.Fraction(2, 3), 1, 'e'), tests.solo_fixtures.note(fractions.Fraction(7, 2), 5, 'g'), tests.solo_fixtures.note(5, 6, 'g'))
|
||||
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
|
||||
generator.solo.notation.replace_region(document, 0, generator.solo.notation.measures(document['staves'][0]['measures'], placed, 'export'))
|
||||
document['staves'][0]['measures'][0]['section_label'] = 'Acoustic Solo'
|
||||
before = copy.deepcopy(document)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = pathlib.Path(root) / 'fixture-solo1.xml'
|
||||
info = generator.solo.export.write_role({'role': 'acoustic_solo', 'document': document, 'trace': {'source': 'fixture'}}, path)
|
||||
loaded = generator.training_io.read(path, 'fixture/acoustic', 1)
|
||||
sounded = [(fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in loaded['sounding_events']]
|
||||
self.assertEqual([(n.start, n.end, n.pitch.number()) for n in raw], sounded)
|
||||
def tab(d):
|
||||
return [p['tablature'] for m in d['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches']]
|
||||
self.assertEqual(tab(document), [n['tablature'] for n in loaded['notes'] if n['pitch']])
|
||||
self.assertEqual(info['role'], 'acoustic_solo')
|
||||
self.assertEqual(before, document)
|
||||
def test_export_manifests_are_not_discovered_as_runtime_scores(self):
|
||||
document = tests.solo_fixtures.song(count=1, chords=('CM7',))
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
root = pathlib.Path(root)
|
||||
(root / 'analysis').mkdir()
|
||||
roles = {}
|
||||
for part in ('acoustic', 'electric'):
|
||||
doc = copy.deepcopy(document)
|
||||
doc['staves'][0]['measures'][0]['section_label'] = part.capitalize() + ' Solo'
|
||||
roles[part + '_solo'] = {'role': part + '_solo', 'document': doc, 'trace': {'source': 'synthetic'}}
|
||||
(root / 'analysis' / 'fixture.roles').write_text(json.dumps(roles))
|
||||
generator.score.write_json(root / 'score.json', document)
|
||||
generator.solo.export.export_tree(root)
|
||||
self.assertEqual(generator.score.count_json_files(root), 1)
|
||||
generator.score.validate_generated_tree(root)
|
||||
self.assertTrue((root / 'solo_xml' / 'fixture-solo1.xml').is_file())
|
||||
self.assertTrue((root / 'solo_xml' / 'fixture-solo2.xml').is_file())
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Literal training-score import, timing, pitch convention and TAB checks."""
|
||||
import copy
|
||||
import fractions
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import generator.musicxml_writer
|
||||
import generator.score
|
||||
import generator.training_io
|
||||
import generator.solo.notation
|
||||
import generator.solo.placement
|
||||
import tests.solo_fixtures
|
||||
|
||||
def xml(notes, *, transpose=False, leadin=''):
|
||||
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">' + leadin + '<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>' + ('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '') + '</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + notes + '</measure></part></score-partwise>'
|
||||
|
||||
def pitched(duration, tie='', octave=5):
|
||||
return f'<note><pitch><step>C</step><octave>{octave}</octave></pitch><duration>{duration}</duration>{tie}<type>quarter</type></note>'
|
||||
|
||||
def events(review):
|
||||
return [(n['start'], n['end'], n['sounding_midi']) for n in review['sounding_events']]
|
||||
|
||||
class TrainingXmlTests(unittest.TestCase):
|
||||
def read(self, directory, name, content, **kwargs):
|
||||
path = pathlib.Path(directory) / name
|
||||
path.write_text(content)
|
||||
return generator.training_io.read(path, 'fixture/acoustic', **kwargs)
|
||||
def test_note_container_does_not_turn_color_label_into_fingering(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
note_xml = pitched(24).replace('</note>', '<notations><technical><fingering>6</fingering></technical></notations></note>')
|
||||
r = self.read(root, 'annotation.xml', xml(note_xml), form_start=1)
|
||||
self.assertEqual(r['annotations'][0]['text'], '6')
|
||||
self.assertEqual(r['annotations'][0]['container'], 'notations/technical/fingering')
|
||||
self.assertIsNone(r['notes'][0]['tablature'])
|
||||
def test_ties_not_reattacks_and_one_guitar_transposition(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
a = self.read(root, 'actual.xml', xml(pitched(24), transpose=True), form_start=1)
|
||||
b = self.read(root, 'authored.xml', xml(pitched(12, '<tie type="start"/>') + pitched(12, '<tie type="stop"/>')), form_start=1, pitch_convention='guitar-written')
|
||||
self.assertEqual(events(a), [('0', '4', '60')])
|
||||
self.assertEqual(events(a), events(b))
|
||||
repeated = self.read(root, 'repeat.xml', xml(pitched(12) + pitched(12)), form_start=1, pitch_convention='guitar-written')
|
||||
self.assertEqual(events(repeated), [('0', '2', '60'), ('2', '4', '60')])
|
||||
def test_leadin_is_reported_without_harmony_or_alignment_padding(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
prelude = '<measure number="0"><attributes><divisions>6</divisions></attributes>' + pitched(6) + '<note><rest/><duration>18</duration></note></measure>'
|
||||
r = self.read(root, 'leadin.xml', xml(pitched(24), leadin=prelude), form_start=2, pitch_convention='guitar-written')
|
||||
self.assertEqual(events(r), [('0', '1', '60'), ('4', '8', '60')])
|
||||
self.assertEqual(r['alignment']['encoded_offset'], '4')
|
||||
self.assertIn('uncertainty', r['notes'][0]['harmonic_spans'][0])
|
||||
def test_unplayable_pitch_is_reported_not_folded(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
r = self.read(root, 'high.xml', xml(pitched(24, octave=7)), form_start=1, pitch_convention='guitar-written')
|
||||
self.assertEqual(r['sounding_events'][0]['sounding_midi'], '84')
|
||||
self.assertFalse(r['notes'][0]['legal_positions_at_sounding_pitch'])
|
||||
self.assertEqual(r['notes'][0]['pitch']['octave'], 7)
|
||||
def test_triplet_time_and_annotations_are_literal(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
triplet = pitched(2).replace('<type>quarter</type>', '<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
|
||||
r = self.read(root, 'thirds.xml', xml('<direction><direction-type><words>Lead in</words></direction-type></direction>' + triplet * 3 + '<note><rest/><duration>18</duration></note>'), form_start=1)
|
||||
self.assertEqual([n['start'] for n in r['sounding_events']], ['0', '1/3', '2/3'])
|
||||
self.assertEqual(r['annotations'][0]['text'], 'Lead in')
|
||||
def test_roundtrip_preserves_actual_sounding_events_and_tab(self):
|
||||
document = tests.solo_fixtures.song(count=2, chords=('CM7',))
|
||||
raw = (tests.solo_fixtures.note(0, fractions.Fraction(1, 3), 'c'), tests.solo_fixtures.note(fractions.Fraction(1, 3), fractions.Fraction(2, 3), 'd'), tests.solo_fixtures.note(fractions.Fraction(2, 3), 1, 'e'), tests.solo_fixtures.note(fractions.Fraction(7, 2), 5, 'g'), tests.solo_fixtures.note(5, 6, 'g'))
|
||||
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
|
||||
generator.solo.notation.replace_region(document, 0, generator.solo.notation.measures(document['staves'][0]['measures'], placed, 'roundtrip'))
|
||||
before = copy.deepcopy(document)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = pathlib.Path(root) / 'fixture.xml'
|
||||
generator.musicxml_writer.write_musicxml(path, document, tablature=True)
|
||||
loaded = generator.training_io.read(path, 'fixture/acoustic', 1)
|
||||
sounded = [(fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in loaded['sounding_events']]
|
||||
self.assertEqual([(n.start, n.end, n.pitch.number()) for n in raw], sounded)
|
||||
tabs = [p['tablature'] for m in document['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches']]
|
||||
self.assertEqual(tabs, [n['tablature'] for n in loaded['notes'] if n['pitch']])
|
||||
self.assertEqual(before, document)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user