From e15dc31568b9b3886082d754ff77d4705b59ac40 Mon Sep 17 00:00:00 2001 From: khodowany Date: Tue, 6 Oct 2026 04:27:29 -0500 Subject: [PATCH] Remove XML Solo Gen --- README.md | 6 +- TECHNICAL_GUIDE.md | 8 +- cmake/zero.cmake | 8 +- generator/__main__.py | 4 - generator/compose.py | 7 -- generator/solo/export.py | 85 ------------- tests/solo_compare.py | 225 ---------------------------------- tests/test_engraving_cache.py | 5 + tests/test_learned_solos.py | 39 ++++-- tests/test_solo_comparison.py | 138 --------------------- tests/test_training_io.py | 80 ++++++++++++ 11 files changed, 120 insertions(+), 485 deletions(-) delete mode 100644 generator/solo/export.py delete mode 100644 tests/solo_compare.py delete mode 100644 tests/test_solo_comparison.py create mode 100644 tests/test_training_io.py diff --git a/README.md b/README.md index 9c89e17..0ab11a4 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,7 @@ Accompaniment rhythm is light at every tempo: normally 0–2 electric chord atta Training, authored score reuse, and prediction share contextual harmony validation based on actual notes, timing, and chords. Explanatory labels and justification tags are not required. Unsupported relationships fail with a diagnostic rather than silently changing notes. Both solo roles use strings 1–6 and frets 1–12 without open strings. No dynamics, accents, velocity shaping, or random timing are added. -`--export-solo-xml` (CMake `VAMP_EXPORT_SOLO_XML=ON`) writes `solo_xml/*-solo1.xml` for acoustic and `*-solo2.xml` for electric under the generated asset directory, with actual notes and TAB. These exports do not recompose music. `./modulus.py python -m generator.compose --song ... --role acoustic_solo --data-dir data --output ... --solo-xml ...` builds one independent role. To exercise prediction for a registered song, pass `--settings` with a JSON file containing `{"predict": true}`. - -Optional comparison runs through `./modulus.py python -m tests.solo_compare`. Point `--generated-dir` at the exported `solo_xml/` directory and supply explicit `--reference SONG/ROLE=PATH`, `--form-start SONG/ROLE=MEASURE`, `--reference-pitch guitar-written`, and `--output /tmp/product-vamp-solo-review`. Keys match `manifest.solo`, such as `all_of_me/acoustic`. Add `--require-exact` to fail on sounding-note or rest differences, or `--baseline-dir` to compare an earlier MusicXML export. Comparison results never feed production; registered authored playback and explicit training use the paired solos separately. The supplied solos sound one octave below encoded pitches; All of Me aligns at measure 1 and Autumn Leaves at measure 2, preserving the lead-in. Matching notes or technical validity do not establish musical preference. +`./modulus.py python -m generator.compose --song ... --role acoustic_solo --data-dir data --output ...` builds one independent role as JSON. To exercise prediction for a registered song, pass `--settings` with a JSON file containing `{"predict": true}`. Solos are included in the normal arranged scores and pages; separate solo MusicXML files are not generated. Authored XMLs remain under `data/training`, and regression tests compare their sounding events directly with generated score data. See [Shared Part Generation](TECHNICAL_GUIDE.md#shared-part-generation) and [Authored Repertoire and Learned Solos](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) for the active pipeline and limitations. Keep temporary comparisons and one-off diagnostics under `/tmp/`; `build/` retains product outputs, tools and dependencies. @@ -136,7 +134,7 @@ Add a source and its authored solo, then retrain the pooled model, without chang To retrain existing pairs, run `./modulus.py python -m generator.train`; add `--leave-one-song-out` for held-out likelihood diagnostics. Training writes `data/models/solo.json` and `build/training/report.json`. Ordinary builds use registered authored music or the saved model and never retrain automatically. Exact registered playback verifies authored reuse; it does not demonstrate model memorization or musical quality on unfamiliar songs. [Training and playback details](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) cover pairing, timing, pitch convention, data migration, and limitations. -The build-time pipeline is MusicXML -> source analysis -> independent roles -> assembled parts -> semantic JSON, supplied recordings, engraved PNG pages, Pico title artwork, and a read-only catalog database. Each source produces the six-section performance form as Acoustic, Electric, and Vocal parts in its authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure. Standalone comparison XMLs use local measure numbers and retain the original arrangement numbers in `manifest.solo`. +The build-time pipeline is MusicXML -> source analysis -> independent roles -> assembled parts -> semantic JSON, supplied recordings, engraved PNG pages, Pico title artwork, and a read-only catalog database. Each source produces the six-section performance form as Acoustic, Electric, and Vocal parts in its authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure. All generated monophonic guitar material uses frets 1–12 without open strings. Chords use two span tiers: **one to three consecutive fret positions share the primary tier; exactly four positions are a fallback** (e.g. 7–9 is primary, 7–10 is fallback). Five or more positions are forbidden. Selection minimizes fallback use before family, melody-string, movement or register preferences. A chord can extend to fret 20 while retaining at least one sounding anchor on frets 1–12 throughout its duration. Acoustic bass remains within frets 1–12 and upper chords need their own anchor when played separately. Some chord/key combinations still have no legal inversion; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index f853d07..a16a773 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -193,7 +193,7 @@ Files are copied into `data/songs` and `data/training` without overwriting diffe Production input traces distinguish authored reuse from learned prediction. Caches include source, role, relevant data and code; authored cache keys include the selected XML's content and convention. Build and package inputs include the model, manifest and authored repertoire. Changes to another solo do not change an already registered part's notes. Updating the pooled model can legitimately change predictions for any unregistered part. CMake defaults to two composition processes (`VAMP_COMPOSITION_JOBS`, 1–16) and caches each role under `build/build_common/composition_cache`. Direct generator runs use `--composition-jobs` and opt into persistent caching with `--composition-cache`; single-role builds use `--cache-dir`. -The generated `solo_xml/*-solo1.xml` and `*-solo2.xml` exports use local measure numbers, preserve pickups, and store whole-arrangement positions in `manifest.solo`. Exact reuse means complete sounding pitches, onsets, releases, repeated attacks and silence agree. XML whitespace, note splitting, measure labels and exporter-specific annotation containers need not be identical. Optional `./modulus.py python -m tests.solo_compare` compares these completed exports; it never feeds a production decision. Its `--generated-dir` is the directory containing `manifest.solo`, and `--reference`/`--form-start` use the manifest's `SONG/ROLE` keys. `--require-exact` fails if any requested reference differs in complete sounding events or rests. Training and authored reuse are the explicitly permitted dependencies on the supplied music. +Solos are published inside the arranged score JSON and engraved pages, without separate solo MusicXML exports or comparison manifests. Exact-repertoire tests compare generated sounding pitches, onsets, releases, repeated attacks, measure timing and silence directly with the authored XMLs. Tied notation fragments are merged into sounding events for this check, and generated TAB is checked against the guitar constraints. Training and authored reuse remain the explicitly permitted dependencies on the supplied music. Migration inventory: @@ -215,7 +215,7 @@ Voicing-only verification on October 6, 2026, before the rhythm update: the suit Rhythm verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed all 375 tests in 83.57 seconds. Coverage includes attack ranges, bass downbeats, mixed durations, harmonic-change budgets, overlapping voices, tied joins, 3/4 and 5/4, melody-conditioned rhythm costs, tempo invariance, canonical reuse, and serial/parallel role identity. A synthetic learned-model test also confirms that changing original melody rhythm or upcoming harmony changes solo duration probabilities with a fixed seed. Both songs' solo, electric melody and vocal role documents remain exactly equal to the saved pre-rhythm baseline; no model retraining was performed. -The ordinary six-score rebuild with `--check --export-solo-xml --composition-jobs 2 --jobs 2` passed in 13.39 seconds: 4.44 seconds generating parts and 8.48 seconds engraving. All role composition results were newly generated; the warm engraving namespace reused two unchanged vocal scores. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role/score caches reused. Actual selected acoustic upper rhythms use one or two attacks in these songs; three-attack alternatives are available and tested but are not forced. All of Me electric has three zero-attack source/harmony contexts; Autumn Leaves electric uses one or two attacks. Neither song required a density exception. Reuse audits checked 103/62 acoustic/electric repeated bars for Autumn Leaves and 102/62 for All of Me, with only source-defined pickups and final-harmony exceptions. Sample acoustic notation/TAB pages were visually inspected. Supplied audio recordings remain unchanged; no ear-based audition of the new accompaniment was performed. Disposable logs, profiles and comparisons are under `/tmp/`, outside product assets. +The ordinary six-score rebuild with `--check --composition-jobs 2 --jobs 2` passed in 13.39 seconds: 4.44 seconds generating parts and 8.48 seconds engraving. All role composition results were newly generated; the warm engraving namespace reused two unchanged vocal scores. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role/score caches reused. Actual selected acoustic upper rhythms use one or two attacks in these songs; three-attack alternatives are available and tested but are not forced. All of Me electric has three zero-attack source/harmony contexts; Autumn Leaves electric uses one or two attacks. Neither song required a density exception. Reuse audits checked 103/62 acoustic/electric repeated bars for Autumn Leaves and 102/62 for All of Me, with only source-defined pickups and final-harmony exceptions. Sample acoustic notation/TAB pages were visually inspected. Supplied audio recordings remain unchanged; no ear-based audition of the new accompaniment was performed. Disposable logs, profiles and comparisons are under `/tmp/`, outside product assets. Chord-family and unified-acoustic verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed 386 tests in 81.67 seconds. New cases cover 35 chord qualities in three keys for both instruments, discarded bass variants and explicit unsupported combinations, the six suggested acoustic grips, highest feasible electric melody strings, scoped policy dependencies, and one-voice tie preservation including repeated attacks and cross-bar ties. Quarter/eighth acoustic attack durations and available consecutive-eighth patterns are tested. The acoustic sounding events in both complete arrangements contain only quarter/eighth durations, even where notation splits them into ties. @@ -223,9 +223,9 @@ The full checked rebuild with two composition and two engraving workers took 13. Four-fret fallback verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed 392 tests in 85.65 seconds. This includes primary-tier precedence over family, arbitrary movement savings and melody-string height; a complete held-note path that needs an earlier four-fret option; tied-fragment cost invariance; and anchor release checks. The first full run exposed one obsolete C5 rejection expectation, now replaced by a test verifying its legal four-fret voicings contain only C/G. Coverage tests check 35 qualities in 12 keys for each instrument: 418/420 acoustic contexts and 420/420 electric contexts have a legal grip. Acoustic Bbm9/Bm9 remain explicitly unsupported; this is measured coverage, not a guarantee for every written extension or slash chord. -The checked six-score rebuild with `--composition-jobs 2 --jobs 2 --export-solo-xml` passed in 9.21 seconds (4.58 seconds composing all 12 roles, 3.90 seconds processing six cached engravings). All composition caches missed after the code change. Every resulting role document and accompaniment rhythm report exactly matches the saved baseline: current production chords already have primary grips, so none needed fallback substitutions. New sidecar span diagnostics identify the tiers and available candidates. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role caches reused and identical per-role musical hashes. Acoustic and electric score/TAB pages were visually checked; supplied recordings are unchanged. Formatter and diff checks passed. Temporary verification files remain under `/tmp/`. +The checked six-score rebuild with `--composition-jobs 2 --jobs 2` passed in 9.21 seconds (4.58 seconds composing all 12 roles, 3.90 seconds processing six cached engravings). All composition caches missed after the code change. Every resulting role document and accompaniment rhythm report exactly matches the saved baseline: current production chords already have primary grips, so none needed fallback substitutions. New sidecar span diagnostics identify the tiers and available candidates. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role caches reused and identical per-role musical hashes. Acoustic and electric score/TAB pages were visually checked; supplied recordings are unchanged. Formatter and diff checks passed. Temporary verification files remain under `/tmp/`. -The optional exact comparison confirmed all four exports: 137/141 attacks for All of Me and 121/115 for Autumn Leaves, with no pitch, onset, release or rest differences. All four now pass contextual harmony validation as well. Removing 69 explanatory annotations left the complete event streams and learned musical count tables identical to the pre-edit baseline; only model provenance hashes changed. This verifies authored reuse rather than learned reproduction. Commands used the ordinary asset generator with `--export-solo-xml --check` and `python -m tests.solo_compare` with four explicit `--reference` pairs, `--reference-pitch guitar-written` and `--require-exact`. No ear-based audition or claim of transfer quality was made. +Earlier exact-repertoire verification confirmed all four authored solos: 137/141 attacks for All of Me and 121/115 for Autumn Leaves, with no pitch, onset, release or rest differences. All four also passed contextual harmony validation. Removing 69 explanatory annotations left the complete event streams and learned musical count tables identical to the pre-edit baseline; only model provenance hashes changed. This verifies authored reuse rather than learned reproduction. These checks now compare generated score data directly with the training inputs instead of producing standalone XML exports. No ear-based audition or claim of transfer quality was made. Forced prediction, without reading the training XMLs, completed all four source/role combinations with valid harmony. These were composition-only runs on the training-source songs, not held-out transfer tests. Their notes intentionally differ from registered playback and were not published as replacements. The expanded treatment candidates initially took 99.03–120.33 seconds per role with one worker and no concrete-result cache. Removing repeated validation of identical resolution endpoints reduced the isolated All of Me acoustic check from 104.30 to 60.13 seconds, with an identical complete output hash. Six additional contexts retained all 32,566 ordered action candidates exactly. These expanded prediction choices remain slower than the earlier 21.81–27.16-second decoder; registered playback is unaffected. Training the pooled 2,860-context model took 0.57 seconds. Prediction remains the slower path, chiefly candidate construction and event-likelihood evaluation; further profiling is needed before expanding its choices again. diff --git a/cmake/zero.cmake b/cmake/zero.cmake index 42037ef..73055b9 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -157,11 +157,6 @@ set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated_assets) set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.stamp) set(VAMP_ENGRAVING_JOBS 2 CACHE STRING "Maximum parallel Python engraving workers (1-16)") set(VAMP_COMPOSITION_JOBS 2 CACHE STRING "Maximum independent musical role workers (1-16)") -option(VAMP_EXPORT_SOLO_XML "Export completed solo roles as comparison MusicXML" OFF) -set(VAMP_SOLO_EXPORT_ARGS) -if(VAMP_EXPORT_SOLO_XML) - list(APPEND VAMP_SOLO_EXPORT_ARGS --export-solo-xml) -endif() set(VAMP_ENGRAVING_CACHE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/engraving_cache" CACHE PATH "Shared build-only page cache; empty disables caching") set(VAMP_ENGRAVING_CACHE_ARGS) if(VAMP_ENGRAVING_CACHE_DIR) @@ -169,7 +164,7 @@ if(VAMP_ENGRAVING_CACHE_DIR) endif() string(JOIN "\n" VAMP_GENERATOR_INPUT_MANIFEST ${VAMP_GENERATOR_INPUTS}) file(CONFIGURE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.inputs - CONTENT "@VAMP_GENERATOR_INPUT_MANIFEST@\nsolo_xml=@VAMP_EXPORT_SOLO_XML@\n" @ONLY) + CONTENT "@VAMP_GENERATOR_INPUT_MANIFEST@\n" @ONLY) add_custom_command( OUTPUT ${VAMP_GENERATED_ASSETS_STAMP} BYPRODUCTS ${VAMP_GENERATED_ASSETS_DIR}/catalog.db @@ -184,7 +179,6 @@ add_custom_command( --composition-cache ${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/composition_cache --progress ${VAMP_ENGRAVING_CACHE_ARGS} - ${VAMP_SOLO_EXPORT_ARGS} COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} DEPENDS ${VAMP_GENERATOR_INPUTS} ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.inputs diff --git a/generator/__main__.py b/generator/__main__.py index 5f5a95c..f477978 100644 --- a/generator/__main__.py +++ b/generator/__main__.py @@ -20,7 +20,6 @@ import generator.pico_artwork import generator.performance import generator.score import generator.summary -import generator.solo.export import platform import os @@ -44,7 +43,6 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16") parser.add_argument("--composition-jobs", type=int, default=2, choices=range(1, 17), metavar="1-16") parser.add_argument("--composition-cache", type=pathlib.Path) - parser.add_argument("--export-solo-xml", action="store_true", help="Export completed solo roles as comparison MusicXML, without recomposing.") parser.add_argument("--trace-level", choices=("summary", "full"), default="summary", help="Diagnostic detail only; does not seed composition.") parser.add_argument("--profile-output", type=pathlib.Path, help="Write build and per-role elapsed timings (not a search timeout).") parser.add_argument("--progress", action="store_true") @@ -96,8 +94,6 @@ def generate_assets(args: argparse.Namespace) -> None: trace = role.get('details', {}).get('selection_trace', {}) trace.pop('decisions', None) path.write_text(json.dumps(roles, sort_keys=True, indent=2)) - if getattr(args, 'export_solo_xml', False): - generator.solo.export.export_tree(staged) generator.score.validate_generated_tree(staged) if args.check: generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs') diff --git a/generator/compose.py b/generator/compose.py index 84218d7..2363e57 100644 --- a/generator/compose.py +++ b/generator/compose.py @@ -6,7 +6,6 @@ import pathlib import generator.musicxml import generator.parts import generator.production -import generator.solo.export def main(): parser = argparse.ArgumentParser(description=__doc__) @@ -15,19 +14,13 @@ def main(): parser.add_argument('--data-dir', type=pathlib.Path, default=pathlib.Path('data')) parser.add_argument('--output', type=pathlib.Path, required=True) parser.add_argument('--cache-dir', type=pathlib.Path) - parser.add_argument('--solo-xml', type=pathlib.Path, help='Also export a completed solo role as MusicXML.') parser.add_argument('--settings', type=pathlib.Path) args = parser.parse_args() - if args.solo_xml and not args.role.endswith('_solo'): - parser.error('--solo-xml requires a solo role') source = generator.production.SourceAnalysis.read(generator.parts.prepare_document(generator.musicxml.load_musicxml(args.song))) start = time.monotonic() result = generator.production.generate_role(source, args.role, args.data_dir, json.loads(args.settings.read_text()) if args.settings else {}, args.cache_dir) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(result, indent=2)) - if args.solo_xml: - manifest = generator.solo.export.write_role(result, args.solo_xml) - args.solo_xml.with_suffix('.manifest.solo').write_text(json.dumps(manifest, indent=2) + '\n') print(f'{args.role}: {time.monotonic() - start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s') if __name__ == '__main__': diff --git a/generator/solo/export.py b/generator/solo/export.py deleted file mode 100644 index 63ac162..0000000 --- a/generator/solo/export.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Optional MusicXML views of finished production roles; never composes.""" -from __future__ import annotations - -import copy -import json -import pathlib -import fractions - -import generator.model -import generator.musicxml_writer -import generator.score - -def region(document, part): - measures = document['staves'][0]['measures'] - first = next(i for i, m in enumerate(measures) if m.get('section_label') == part.capitalize() + ' Solo') - end = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures)) - return first, end - first - -def excerpt(document, first, count): - result = copy.deepcopy(document) - staff = result['staves'][0] - original = staff['measures'] - staff['measures'] = original[first:first + count] - if not staff['measures']: - raise ValueError('Empty solo export region') - notes = [n for m in staff['measures'] for n in generator.score.measure_notes(m)] - identities = {p['id'] for n in notes for p in n['pitches']} - events = {n['id'] for n in notes} - for note in notes: - for pitch in note['pitches']: - for key in ('tie_from', 'tie_to'): - if pitch.get(key) not in identities: - pitch.pop(key, None) - staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in events and t['end'] in events] - tuplets = {t['id'] for t in staff['tuplets']} - for note in notes: - if 'tuplet_ids' in note: - note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets] - offset = sum((generator.model.fraction(m['duration']) for m in original[:first]), fractions.Fraction()) - end = offset + sum((generator.model.fraction(m['duration']) for m in staff['measures']), fractions.Fraction()) - for key in ('scale_spans', 'octave_shifts'): - staff[key] = [{**s, 'start': generator.model.rational(max(offset, generator.model.fraction(s['start'])) - offset), - 'end': generator.model.rational(min(end, generator.model.fraction(s['end'])) - offset)} for s in staff.get(key, []) - if generator.model.fraction(s['start']) < end and generator.model.fraction(s['end']) > offset] - generator.model.validate_document(result) - return result - -def write_role(result, path): - role = result['role'] - if role not in ('acoustic_solo', 'electric_solo'): - raise ValueError('Solo XML export requires an acoustic_solo or electric_solo role') - part = role.split('_')[0] - document = result['document'] - first, count = region(document, part) - leadin = int(result.get('details', {}).get('allocation', {}).get('lead_in_measures', 0)) - first -= leadin - count += leadin - selected = excerpt(document, first, count) - numbers = [m['number'] for m in selected['staves'][0]['measures']] - for i, measure in enumerate(selected['staves'][0]['measures'], 1): - measure['number'] = str(i) - path = pathlib.Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - generator.musicxml_writer.write_musicxml(path, selected, tablature=True) - return {'role': role, 'file': path.name, 'form_start_measure': 1 + leadin, - 'measure_count': count, 'arrangement_measures': numbers, - 'arrangement_start_beat': str(sum((generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), fractions.Fraction())), - 'pitch_convention': 'guitar-written; explicit octave transposition -1', - 'trace': result['trace'], 'phrase_report': result.get('details', {})} - -def export_tree(assets, output=None): - assets = pathlib.Path(assets) - output = pathlib.Path(output) if output else assets / 'solo_xml' - output.mkdir(parents=True, exist_ok=True) - entries = {} - for path in sorted((assets / 'analysis').glob('*.roles')): - roles = json.loads(path.read_text()) - for i, part in enumerate(('acoustic', 'electric'), 1): - name = path.stem.replace('_', '-') + f'-solo{i}.xml' - entries[path.stem + '/' + part] = write_role(roles[part + '_solo'], output / name) - manifest = {'version': 1, 'composition_inputs': False, 'solos': entries} - # Runtime score discovery treats *.json under assets as score documents. - # Diagnostic manifests deliberately use a non-score extension, like .roles. - (output / 'manifest.solo').write_text(json.dumps(manifest, sort_keys=True, indent=2) + '\n') - return manifest diff --git a/tests/solo_compare.py b/tests/solo_compare.py deleted file mode 100644 index bef5086..0000000 --- a/tests/solo_compare.py +++ /dev/null @@ -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 = ['Solo comparison', - '', - '

Completed solos and reference scores

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.

', - '

Exact timing, notes, harmony, annotations, placement, and phrase reports

'] - for role, r in result['solos'].items(): - actual_path = generated_dir / manifest['solos'][role]['file'] - reference_path = pathlib.Path(reference_paths[role]) - rows += ['

' + html.escape(role) + '

', f'

Exact event match: {"yes" if r["exact_musical_match"] else "no"}. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.

', - '

Generated MusicXML · Untouched reference MusicXML

'] - if baseline_manifest: - baseline_path = pathlib.Path(baseline_dir) / baseline_manifest['solos'][role]['file'] - rows.append('

Previous generated MusicXML

') - rows.append('') - for key in ('pitch_sequence_edit_distance', 'timed_attack_edit_distance', 'same_sounding_pitch_beats', 'same_pitch_class_beats', 'either_sounding_beats'): - rows.append('') - rows.append('
Diagnostic against referenceCurrentBaseline
' + key.replace('_', ' ') + '' + str(r['relationships'][key]) + '' + str(r.get('baseline_relationships', {}).get(key, '—')) + '

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.

') - selected = collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions', [])) - rows.append('

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.

') - if r.get('reference_events_after_production_end'): - rows.append('

Reference material extends beyond this exported production allocation: ' + str(len(r['reference_events_after_production_end'])) + ' events. The reference and source timing remain unchanged.

') - 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('
Construction coverage (not similarity scoring)' + ''.join('' for k in stages) + '') - for purpose, counts in sorted(funnel.items()): - rows.append('' + ''.join('' for k in stages) + '') - rows.append('
Purpose' + k.replace('_', ' ') + '
' + html.escape(purpose) + '' + str(counts.get(k, 0)) + '
') - candidates = r['production_phrases'].get('selection_trace', {}).get('opening_candidates', []) - if candidates: - rows.append('
Completed source-only opening candidates

All candidates were composed without the references. Selection accounts for later phrases; the final choice need not have the cheapest opening alone.

') - for index, c in enumerate(candidates, 1): - rows.append('' + ''.join('' for v in (index, c['selected_in_final_solo'], c['purpose'], c['evaluation']['contributions'])) + '') - rows.append('
CandidateFinal choicePurposeScore contributions
' + html.escape(str(v)) + '
') - rows.append('
Exact note differences') - 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('' + ''.join('' for v in (d['time'], describe(d['generated']), describe(d['reference']))) + '') - rows.append('
Beat relative to formGeneratedReference
' + html.escape(v) + '
') - (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()) diff --git a/tests/test_engraving_cache.py b/tests/test_engraving_cache.py index 40a5dc1..4c64b20 100644 --- a/tests/test_engraving_cache.py +++ b/tests/test_engraving_cache.py @@ -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='other'))) with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'): generator.__main__.generate_assets(args) diff --git a/tests/test_learned_solos.py b/tests/test_learned_solos.py index e3e3992..fa4b596 100644 --- a/tests/test_learned_solos.py +++ b/tests/test_learned_solos.py @@ -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'])) diff --git a/tests/test_solo_comparison.py b/tests/test_solo_comparison.py deleted file mode 100644 index f7a04fb..0000000 --- a/tests/test_solo_comparison.py +++ /dev/null @@ -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 'Vocal' + leadin + '60G2' + ('0-1' if transpose else '') + 'Cmajor-seventh' + notes + '' - -def pitched(duration, tie='', octave=5): - return f'C{octave}{duration}{tie}quarter' - -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('', '6') - 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, '') + pitched(12, '')), 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 = '6' + pitched(6) + '18' - 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('quarter', 'eighth32') - r = self.review(root, 'thirds.xml', xml('Lead in' + triplet * 3 + '18'), 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() diff --git a/tests/test_training_io.py b/tests/test_training_io.py new file mode 100644 index 0000000..d48070a --- /dev/null +++ b/tests/test_training_io.py @@ -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 'Vocal' + leadin + '60G2' + ('0-1' if transpose else '') + 'Cmajor-seventh' + notes + '' + +def pitched(duration, tie='', octave=5): + return f'C{octave}{duration}{tie}quarter' + +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('', '6') + 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, '') + pitched(12, '')), 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 = '6' + pitched(6) + '18' + 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('quarter', 'eighth32') + r = self.read(root, 'thirds.xml', xml('Lead in' + triplet * 3 + '18'), 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()