Remove XML Solo Gen
ci / checks (push) Successful in 49m36s

This commit is contained in:
khodowany
2026-10-06 04:27:29 -05:00
parent bb3bba6bbb
commit e15dc31568
11 changed files with 120 additions and 485 deletions
-225
View File
@@ -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())
+5
View File
@@ -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
View File
@@ -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']))
-138
View File
@@ -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()
+80
View File
@@ -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()