diff --git a/README.md b/README.md index d9f035b..fdae82b 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated -- The `--check` path compares generation invariants against `data/expected/performance_summary.json`. The pinned build-only dependencies are in `tools/requirements.txt`. Configure/build automatically refresh the project build image when this file changes; direct builds must install it in their Python environment. -The build-time pipeline is MuseScore -> `.xml` -> musical model -> automatic arranger/transposer -> schema-v3 JSON and engraved PNG pages. The Pi displays pages without interpreting notation. Performance sources produce acoustic, electric, and vocal parts; melody exercises produce twelve tonic variants, and chord exercises keep their authored key. Independent voices and exact pitch/rhythm remain in the semantic JSON. +The build-time pipeline is MuseScore -> `.xml` -> musical model -> automatic arranger/transposer -> schema-v3 JSON and engraved PNG pages. The Pi displays pages without interpreting notation. Performance sources produce acoustic, electric, and vocal parts; melody and chord exercises produce twelve tonic variants relative to their authored key. Independent voices and exact pitch/rhythm remain in the semantic JSON. Chord practice preserves the authored voicings, shifting the entire exercise by an octave only when needed for guitar. Chord practice and generated guitar voicings use standard tuning, frets 0-15 inclusive, and a maximum four-fret span excluding open strings. Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index e1d5ab7..a1b9add 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -30,11 +30,11 @@ The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP cip On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console. -At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice////.json`, ensures the current SQLite schema with Noto, imports both catalogs, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures. +At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice////.json`, ensures the current SQLite schema with Noto, imports both catalogs, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody and chord practice both offer key selection by choosing a pre-generated score variant. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures. ## Score Generation -The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody practice sources are emitted in all 12 keys at build time. Chord practice sources are emitted once in their authored key. Generated JSON keeps notation metadata while removing generator-only optional chord markers; guitar playability logic is used only inside the arrangement generator. +The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The current catalogue produces six performance scores and 48 practice variants (54 JSON bundles). The pipeline is MuseScore -> uncompressed `.xml` -> musical model -> automatic arranger/transposer -> semantic JSON plus engraved pages. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. Independent rhythmic voices remain separate; simultaneous chord tones retain their own spelling, written duration, and tie links. The packaged music describes each transformed part or practice variant, not an archival copy of the source XML. @@ -67,7 +67,7 @@ In MuseScore's score properties, set `vamp:style` to a supported player style. I ``` -The default is Medium Swing; `Med.`, `Med. Swing`, and `Medium` remain aliases. A keyless melody exercise needs `vamp:tonic` (for example, `C`) as its transposition reference. Modes and local key changes are preserved when generating practice variants. +The default is Medium Swing; `Med.`, `Med. Swing`, and `Medium` remain aliases. A keyless melody or chord exercise needs `vamp:tonic` (for example, `C`) as its transposition reference. Modes and local key changes are preserved when generating practice variants. Keyless variants remain unkeyed in the music and show their selected transposition reference in menus, for example `D (no key)`. Place staff text `vamp:optional-chord` at the same onset as exactly one harmony to mark a turnaround omitted from the final arrangement section. The text may be hidden. Parenthesizing an ordinary chord does not make it optional. Orphaned/duplicate markers and unknown `vamp:` properties are errors; consumed directives never enter runtime JSON. @@ -81,9 +81,13 @@ A bundle has three top-level fields: Measure durations, event onsets/durations, harmony offsets, span times, and pitch alterations use `{ "numerator": N, "denominator": D }`. Times are in quarter-note units and alterations in semitones. There is no tick grid, display-duration surrogate, or runtime flattening into one voice. Written note types, dots, and time modifications remain distinct from metric duration. Every event owns a voice; every chord tone has its own ID, written duration, duration, and reciprocal tie links. Tuplets reference events and belong to one voice. -The arranger normalizes instrument transposition to concert pitch and preserves all source voices in source-copy sections. Generated guitar rhythms occupy one lane. The seven-section form, optional turnaround handling, twelve melody-practice keys, and authored-key chord exercises remain automatic. Unplayable guitar harmonies produce a warned rhythmic-slash fallback without changing the structured chord meaning or quantizing microtones. +The arranger normalizes instrument transposition to concert pitch and preserves all source voices in source-copy sections. Generated guitar rhythms occupy one lane. The seven-section form, optional turnaround handling, and twelve melody/chord practice keys remain automatic. Generated guitar voicings and chord-practice validation share standard six-string tuning (E2 A2 D3 G3 B3 E4), frets 0-15 inclusive, and a maximum four-fret difference between fretted positions; open strings do not count toward that span. The arranger searches all candidates in that range and retains its existing deterministic scoring and voicing policy. Unplayable performance harmonies still produce a warned rhythmic-slash fallback without changing the structured chord meaning or quantizing microtones. -Automatic performance arrangements require a pitched, non-TAB lead. Percussion and tablature remain available in the model and practice pipeline, but conversion into guitar/vocal parts fails explicitly rather than reinterpreting percussion positions or fret numbers as ordinary treble-staff notation. Practice transposition retains an authored string only when the resulting fret is an exact nonnegative integer; it does not invent a new fingering. +Chord practice transposes from the source key, not from its first chord: the current C-key cycle begins with G7, so selecting D produces an opening A7. It preserves every authored tone, interval, inversion, rhythmic voice, tie, and tuplet. The closest transposition is tried first, followed by whole-exercise octave alternatives ordered by absolute displacement, with upward movement preferred on ties. Register adjustment does not change harmony symbols or key signatures. Sustained and tied tones keep their strings as other voices enter. This is a fretboard feasibility check, not a guarantee of comfortable fingering, and does not apply the performance arranger's chord-tone omissions or revoicing. + +For the fretboard check, ordinary unmarked treble notation uses the project's written-guitar convention (one octave above sounding pitch). An explicit MusicXML instrument transposition is applied instead, once, including an explicit zero interval; the importer records this distinction with `transpose.explicit`. Octave-clef and TAB pitches do not receive the implicit octave subtraction. Nonstandard tunings, capo configurations, microtonal frets, or exercises that cannot retain their voicings in every key fail explicitly. Errors identify the source, target key, and failing passage; existing generated assets are preserved. The current G cycle variant needs a whole-exercise octave increase to stay above the guitar's lowest note. + +Automatic performance arrangements require a pitched, non-TAB lead. Percussion and tablature remain available in the model and melody-practice pipeline, but conversion into guitar/vocal parts fails explicitly rather than reinterpreting percussion positions or fret numbers as ordinary treble-staff notation. Chord practice requires pitched guitar notation. Practice transposition retains authored TAB strings and never invents a new fingering; chord practice additionally checks that the resulting exact integer frets lie within 0-15. Melody practice retains its existing nonnegative-fret behavior. ### Engraving Boundary diff --git a/generator/arrange/fretboard.py b/generator/arrange/fretboard.py index 8a91f5e..ab44664 100644 --- a/generator/arrange/fretboard.py +++ b/generator/arrange/fretboard.py @@ -1,26 +1,18 @@ from __future__ import annotations from itertools import product -from typing import NamedTuple from generator.transposition import mod12 from generator import harmony from generator.model import NATURAL, alteration - -STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) -WRITTEN_GUITAR_OCTAVE_OFFSET = 12 - -class TabPosition(NamedTuple): - string_number: int - fret: int - midi: int +from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, within_fret_span def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]: return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol) def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]: result: list[TabPosition] = [] - for fret in range(0, 13): + for fret in range(MIN_FRET, MAX_FRET + 1): midi = open_midi + fret if mod12(midi) in pitch_classes: result.append(TabPosition(string_number, fret, midi)) @@ -63,14 +55,13 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]: best: tuple[TabPosition, ...] | None = None best_score = 10**9 for string_set in string_sets: - options = [by_string[string_number][:8] for string_number in string_set] + options = [by_string[string_number] for string_number in string_set] if any(not item for item in options): continue for candidate in product(*options): if bass_pc is not None and min(candidate, key=lambda p: p.midi).midi % 12 != bass_pc: continue - fretted = [position.fret for position in candidate if position.fret > 0] - if fretted and max(fretted) - min(fretted) > 4: + if not within_fret_span(candidate): continue covered = {mod12(position.midi) for position in candidate} if not set(pitch_classes).issubset(covered): diff --git a/generator/guitar.py b/generator/guitar.py new file mode 100644 index 0000000..4d2bd38 --- /dev/null +++ b/generator/guitar.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from fractions import Fraction +from typing import NamedTuple + +STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) +MIN_FRET = 0 +MAX_FRET = 15 +MAX_FRET_SPAN = 4 +WRITTEN_GUITAR_OCTAVE_OFFSET = 12 + +class TabPosition(NamedTuple): + string_number: int + fret: int + midi: int + +def within_fret_span(positions) -> bool: + fretted = [position.fret for position in positions if position.fret > 0] + return not fretted or max(fretted) - min(fretted) <= MAX_FRET_SPAN + +def positions_for_pitch(midi: Fraction) -> tuple[TabPosition, ...]: + if midi.denominator != 1: + return () + return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET) diff --git a/generator/musicxml.py b/generator/musicxml.py index 8fea0bb..be11185 100644 --- a/generator/musicxml.py +++ b/generator/musicxml.py @@ -158,6 +158,7 @@ class Importer: elif item.tag == 'transpose': self.children(item, {'diatonic', 'chromatic', 'octave-change'}) self.transpose = {key: self.number(item.findtext(tag, '0'), integer=True) for key, tag in (('diatonic', 'diatonic'), ('chromatic', 'chromatic'), ('octave_change', 'octave-change'))} + self.transpose['explicit'] = True if item.find('chromatic') is None: self.fail('instrument transposition requires chromatic semitones') if item.find('diatonic') is None: diff --git a/generator/musicxml_writer.py b/generator/musicxml_writer.py index 267bc37..2601f83 100644 --- a/generator/musicxml_writer.py +++ b/generator/musicxml_writer.py @@ -73,7 +73,8 @@ def write_attributes(parent, state: dict, divisions=None) -> None: element(t, 'tuning-octave', tuning['octave']) if details.get('capo'): element(item, 'capo', details['capo']) - if transpose := state.get('transpose'): + transpose = state.get('transpose') + if transpose and (transpose.get('explicit') or any(transpose[field] for field in ('diatonic', 'chromatic', 'octave_change'))): item = element(node, 'transpose') for field in ('diatonic', 'chromatic', 'octave_change'): element(item, field.replace('_', '-'), transpose[field]) diff --git a/generator/practice/generate.py b/generator/practice/generate.py index 3fff1f5..a7afa2b 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -4,6 +4,7 @@ from pathlib import Path from generator.score import copy_source_voices, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json from generator.musicxml import load_musicxml +from generator.practice.guitar import transpose_guitar_practice from generator.transposition import TARGET_KEYS, transpose_document def practice_document(document: dict) -> dict: @@ -39,23 +40,23 @@ def key_file_name(key: str) -> str: return slugify(key.replace("#", "sharp")) + ".json" def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int: - document = practice_document(load_musicxml(source_path)) - metadata = document.get("metadata", {}) - key = str(metadata.get("key_signature", "C") if isinstance(metadata, dict) else "C") - relative = source_path.relative_to(source_root) - lesson_group = relative.parent.name - exercise_id = slugify(source_path.stem) - write_json(output_dir / "chord" / lesson_group / exercise_id / key_file_name(key), document) - return 1 + return generate_key_variants(source_path, source_root, output_dir, 'chord') def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int: + return generate_key_variants(source_path, source_root, output_dir, 'melody') + +def generate_key_variants(source_path: Path, source_root: Path, output_dir: Path, practice_type: str) -> int: base_document = practice_document(load_musicxml(source_path)) relative = source_path.relative_to(source_root) lesson_group = relative.parent.name exercise_id = slugify(source_path.stem) count = 0 for key in TARGET_KEYS: - write_json(output_dir / "melody" / lesson_group / exercise_id / key_file_name(key), transpose_document(base_document, key)) + try: + variant = transpose_guitar_practice(base_document, key) if practice_type == 'chord' else transpose_document(base_document, key) + except ValueError as error: + raise ValueError(f'{source_path}, target key {key}: {error}') from error + write_json(output_dir / practice_type / lesson_group / exercise_id / key_file_name(key), variant) count += 1 return count diff --git a/generator/practice/guitar.py b/generator/practice/guitar.py new file mode 100644 index 0000000..9a62944 --- /dev/null +++ b/generator/practice/guitar.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from itertools import groupby +from math import ceil, floor + +from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, positions_for_pitch, within_fret_span +from generator.model import fraction, pitch_number +from generator.transposition import transpose_document, transposition_interval + +@dataclass(frozen=True) +class GuitarTone: + pitch: dict + onset: Fraction + end: Fraction + midi: Fraction + location: str + grace_order: int + +def validate_staff_details(state: dict, location: str) -> None: + details = state['staff_details'] + if details.get('capo', 0): + raise ValueError(f'{location}: guitar practice does not support capo configurations') + if state['clef']['sign'] == 'TAB' and details.get('lines') != 6: + raise ValueError(f'{location}: guitar practice requires six-string tablature') + tunings = details.get('tunings', []) + expected = {7 - string: midi for string, midi in STANDARD_TUNING} + if tunings and (len(tunings) != 6 or {t['line']: pitch_number(t) for t in tunings} != expected): + raise ValueError(f'{location}: guitar practice requires standard six-string tuning') + +def sounding_pitch(pitch: dict, state: dict) -> Fraction: + if 'unpitched' in pitch: + raise ValueError('unpitched notes cannot be checked on a fretted guitar') + transpose, clef = state['transpose'], state['clef'] + offset = transpose['chromatic'] + 12 * transpose['octave_change'] + if not any(transpose.values()) and clef['sign'] == 'G' and not clef['octave_change'] and 'tablature' not in pitch: + offset = -WRITTEN_GUITAR_OCTAVE_OFFSET + return pitch_number(pitch) + offset + +def guitar_tones(document: dict) -> list[GuitarTone]: + tones = [] + absolute = Fraction() + for measure in document['staves'][0]['measures']: + location = f'measure {measure.get("number", measure["id"])}' + state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')} + validate_staff_details(state, location) + changes = sorted(measure.get('attributes', []), key=lambda change: fraction(change['onset'])) + for change in changes: + state.update({key: change[key] for key in state if key in change}) + validate_staff_details(state, location) + for voice in measure['voices']: + state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')} + change_index = 0 + for index, note in enumerate(voice['notes']): + onset = fraction(note['onset']) + while change_index < len(changes) and fraction(changes[change_index]['onset']) <= onset: + state.update({key: changes[change_index][key] for key in state if key in changes[change_index]}) + change_index += 1 + context = f'{location}, voice {voice["id"]}, event {note["id"]}, onset {onset}' + for pitch in note['pitches']: + try: + midi = sounding_pitch(pitch, state) + except ValueError as error: + raise ValueError(f'{context}: {error}') from error + tones.append(GuitarTone(pitch, absolute + onset, absolute + onset + fraction(pitch['duration']), midi, context, index if note.get('grace') else -1)) + absolute += fraction(measure['duration']) + return tones + +def tone_positions(tone: GuitarTone) -> tuple[TabPosition, ...]: + positions = positions_for_pitch(tone.midi) + if tab := tone.pitch.get('tablature'): + positions = tuple(p for p in positions if p.string_number == tab['string'] and p.fret == tab['fret']) + if not positions: + raise ValueError(f'{tone.location}: pitch {tone.midi} has no matching string/fret in {MIN_FRET}-{MAX_FRET}') + return positions + +def extend_fingerings(held: dict[str, TabPosition], pending: list[tuple[str, tuple[TabPosition, ...]]]): + if not pending: + yield tuple(sorted(held.items())) + return + identifier, options = pending[0] + occupied = {position.string_number for position in held.values()} + for position in options: + if position.string_number not in occupied and within_fret_span((*held.values(), position)): + yield from extend_fingerings({**held, identifier: position}, pending[1:]) + +def validate_guitar_practice(document: dict) -> None: + tones = guitar_tones(document) + by_id = {tone.pitch['id']: tone for tone in tones} + options = {tone.pitch['id']: tone_positions(tone) for tone in tones} + states = {()} + # Keep every feasible assignment, so later entries cannot move a held or tied tone. + ordered = sorted(tones, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order)) + for (onset, _, _), group in groupby(ordered, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order)): + attacks = list(group) + continued = {tone.pitch['tie_from'] for tone in attacks if 'tie_from' in tone.pitch} + next_states = set() + visited = set() + for previous in states: + previous = dict(previous) + held = {identifier: position for identifier, position in previous.items() if identifier not in continued and (by_id[identifier].end > onset or by_id[identifier].end == onset and by_id[identifier].pitch.get('tie_to'))} + pending = [] + for tone in attacks: + positions = options[tone.pitch['id']] + if source := tone.pitch.get('tie_from'): + positions = tuple(p for p in positions if p == previous.get(source)) + pending.append((tone.pitch['id'], positions)) + pending.sort(key=lambda item: len(item[1])) + transition = (tuple(sorted(held.items())), tuple(pending)) + if transition not in visited: + next_states.update(extend_fingerings(held, pending)) + visited.add(transition) + if not next_states: + raise ValueError(f'{attacks[0].location}: no simultaneous fingering within frets {MIN_FRET}-{MAX_FRET} and the four-fret span; held/tied strings must remain fixed') + states = next_states + +def transpose_guitar_practice(document: dict, target_key: str) -> dict: + _, semitones = transposition_interval(document, target_key) + tones = guitar_tones(document) + if not tones: + return transpose_document(document, target_key) + low = min(midi + MIN_FRET for _, midi in STANDARD_TUNING) + high = max(midi + MAX_FRET for _, midi in STANDARD_TUNING) + first = ceil((low - min(tone.midi for tone in tones) - semitones) / 12) + last = floor((high - max(tone.midi for tone in tones) - semitones) / 12) + candidates = sorted(range(first, last + 1), key=lambda octave: (abs(semitones + 12 * octave), semitones + 12 * octave < 0)) + failures = [] + for octave in candidates: + try: + result = transpose_document(document, target_key, octave_shift=octave) + validate_guitar_practice(result) + return result + except ValueError as error: + failures.append(f'octave {octave:+}: {error}') + detail = '; '.join(failures) if failures else f'{min(tones, key=lambda tone: tone.midi).location} through {max(tones, key=lambda tone: tone.midi).location}: pitch range exceeds the guitar range in every octave' + raise ValueError(f'no whole-exercise guitar register for {target_key}: {detail}') diff --git a/generator/summary.py b/generator/summary.py index 735996c..329c2df 100644 --- a/generator/summary.py +++ b/generator/summary.py @@ -88,20 +88,12 @@ def validate_expected_performance_summary(expected_path: Path, generated_songs_d def validate_practice_variants(generated_practice_dir: Path) -> None: if not generated_practice_dir.exists(): return - melody_root = generated_practice_dir / "melody" - if melody_root.exists(): - expected_names = {key.lower().replace("#", "sharp").replace("b", "b") + ".json" for key in TARGET_KEYS} - for exercise_dir in sorted(path for path in melody_root.rglob("*") if path.is_dir()): + expected_names = {key.lower().replace("#", "sharp") + ".json" for key in TARGET_KEYS} + for practice_type in ('melody', 'chord'): + root = generated_practice_dir / practice_type + for exercise_dir in sorted(path for path in root.rglob("*") if path.is_dir()): json_names = {path.name for path in exercise_dir.glob("*.json")} if not json_names: continue if json_names != expected_names: - raise ValueError(f"melody practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}") - chord_root = generated_practice_dir / "chord" - if chord_root.exists(): - for exercise_dir in sorted(path for path in chord_root.rglob("*") if path.is_dir()): - json_names = [path.name for path in exercise_dir.glob("*.json")] - if not json_names: - continue - if len(json_names) != 1: - raise ValueError(f"chord practice generator drift in {exercise_dir}: expected one authored-key variant, found {json_names}") + raise ValueError(f"{practice_type} practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}") diff --git a/generator/transposition.py b/generator/transposition.py index 208a7fa..f6f9c25 100644 --- a/generator/transposition.py +++ b/generator/transposition.py @@ -80,15 +80,20 @@ def shift_key(key: dict, steps: int, semitones: int) -> dict: result['custom'] = True return result -def transpose_document(document: dict, target_key_name: str) -> dict: +def transposition_interval(document: dict, target_key_name: str) -> tuple[int, int]: + metadata = document['metadata'] + origin = metadata['key'].get('tonic') or metadata.get('transpose_origin') + if origin is None: + raise ValueError('keyless practice requires a vamp:tonic score property') + return interval_between(origin, parse_pitch_name(target_key_name)) + +def transpose_document(document: dict, target_key_name: str, *, octave_shift: int = 0) -> dict: + if type(octave_shift) is not int: + raise ValueError('octave adjustment must be an integer') + steps, semitones = transposition_interval(document, target_key_name) output = copy.deepcopy(document) metadata = output['metadata'] key = metadata['key'] - origin = key.get('tonic') or metadata.get('transpose_origin') - if origin is None: - raise ValueError('keyless melody practice requires a vamp:tonic score property') - target = parse_pitch_name(target_key_name) - steps, semitones = interval_between(origin, target) metadata['key'] = shift_key(key, steps, semitones) metadata['key_signature'] = key_label(metadata['key']) metadata['variant_tonic'] = target_key_name @@ -106,7 +111,10 @@ def transpose_document(document: dict, target_key_name: str) -> dict: h['symbol'] = harmony.symbol(h) for voice in measure['voices']: for note in voice['notes']: - note['pitches'] = [shift_pitch(p, steps, semitones) for p in note.get('pitches', [])] + try: + note['pitches'] = [shift_pitch(p, steps + 7 * octave_shift, semitones + 12 * octave_shift) for p in note.get('pitches', [])] + except ValueError as error: + raise ValueError(f'measure {measure.get("number", measure["id"])}, voice {voice["id"]}, event {note["id"]}: {error}') from error return output def concert_document(document: dict) -> dict: diff --git a/src/vamp_zero/notation.cpp b/src/vamp_zero/notation.cpp index 3baddbe..d506658 100644 --- a/src/vamp_zero/notation.cpp +++ b/src/vamp_zero/notation.cpp @@ -93,6 +93,13 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc } document.arrangement_part_type = std::move(*type); } + if (document.arrangement_part_type == "practice" && document.key_signature == "No key" && metadata->if_contains("variant_tonic")) { + auto tonic = string_field(*metadata, "variant_tonic"); + if (!tonic) { + return invalid(std::string{tonic.error().message()}); + } + document.key_signature = *tonic + " (no key)"; + } for (auto const &item : *pages) { if (!item.is_object()) { return invalid("page must be an object"); diff --git a/src/vamp_zero/types.cpp b/src/vamp_zero/types.cpp index 0ccb62f..6caf203 100644 --- a/src/vamp_zero/types.cpp +++ b/src/vamp_zero/types.cpp @@ -57,5 +57,5 @@ auto vamp_zero::practice_key_names() -> std::vector const & { } auto vamp_zero::practice_type_supports_key_selection(std::string_view practice_type_id) -> std::bool_t { - return practice_type_id == "melody"; + return practice_type_id == "melody" || practice_type_id == "chord"; } diff --git a/tests/test.cpp b/tests/test.cpp index a109e88..5f663a2 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -321,6 +322,95 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) { BOOST_TEST(count > 0U); } +BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_display) { + for (auto type : {"chord", "melody"}) { + auto state = navigation_state(); + auto &exercise = state.practice.exercises.front(); + exercise.practice_type_id = type; + auto variant = exercise.variants.front(); + variant.id = "exercise-d"; + variant.key_signature = "D"; + variant.source_path = "exercise-d.json"; + variant.source_json = score_asset_json("Exercise", "D"); + variant.total_pages = 3U; + exercise.variants.push_back(variant); + state.practice.types.front().id = type; + state.practice.types.front().lesson_groups.front().exercises = {exercise}; + state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; + state.selected_practice_type_index = 1U; + state.selected_practice_lesson_index = 1U; + state.selected_practice_exercise_index = 1U; + state.audio.metronome_enabled = true; + BOOST_TEST(vamp_zero::supports_key_selection(exercise)); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU); + BOOST_TEST(vamp_zero::practice_key_row_count(state) == 3U); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_SCORE); + BOOST_TEST(state.audio.metronome_enabled); + auto selected = vamp_zero::selected_practice_variant(state, state.practice.exercises.front()); + BOOST_REQUIRE(selected != nullptr); + BOOST_TEST(selected->id == "exercise-d"); + BOOST_TEST(selected->source_path == "exercise-d.json"); + BOOST_TEST(vamp_zero::active_pages(state) == 3U); + auto remote = vamp_pico::remote_display_state{}; + BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote)); + BOOST_TEST(remote.active); + BOOST_TEST(std::string{remote.part} == "Key: D"); + BOOST_TEST(remote.pages == 3U); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); + BOOST_TEST(state.page_index == 1U); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU); + BOOST_TEST(state.selected_practice_key_index == 2U); + BOOST_TEST(state.active_practice_id.empty()); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); + state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); + BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU); + BOOST_REQUIRE(vamp_zero::validate_app_state(state)); + } +} + +BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) { + auto root = std::filesystem::temp_directory_path() / ("product-vamp-keys-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + auto config = test_database_config(root / "vamp.db"); + BOOST_REQUIRE(run_ensure_database(config)); + for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) { + BOOST_REQUIRE(run_import(config, VAMP_TEST_ASSETS_DIR)); + auto io = boost::asio::io_context{}; + auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future); + io.run(); + auto library = future.get(); + BOOST_REQUIRE(library); + BOOST_TEST(library->exercises.size() == 4U); + for (auto const &exercise : library->exercises) { + BOOST_TEST(vamp_zero::supports_key_selection(exercise)); + BOOST_REQUIRE_EQUAL(exercise.variants.size(), 12U); + auto keys = vamp_zero::practice_key_names(); + for (auto index = std::size_t{}; index < keys.size(); ++index) { + BOOST_TEST(exercise.variants[index].key_signature == keys[index]); + BOOST_REQUIRE(vamp_zero::parse_score_document(exercise.variants[index].source_json, exercise.variants[index].source_path)); + } + } + } + std::filesystem::remove_all(root); +} + +BOOST_AUTO_TEST_CASE(keyless_practice_variants_display_their_transposition_reference) { + auto source = boost::json::parse(score_asset_json("Keyless", "No key")); + auto &music = source.as_object()["music"].as_object(); + music["metadata"].as_object()["variant_tonic"] = "D"; + music["staves"].as_array().front().as_object()["arrangement_part_type"] = "practice"; + auto document = vamp_zero::parse_score_document(boost::json::serialize(source), "d.json"); + BOOST_REQUIRE(document); + BOOST_TEST(document->key_signature == "D (no key)"); + auto retained = boost::json::parse(document->source_json); + BOOST_TEST(retained.as_object()["music"].as_object()["metadata"].as_object()["key_signature"].as_string() == "No key"); +} + BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) { BOOST_TEST(vamp_zero::parse_integer("65535", "port").value_or(0) == 65535U); BOOST_TEST(vamp_zero::parse_integer("-2147483648", "octave").value_or(0) == std::numeric_limits::min()); diff --git a/tests/test_guitar_practice.py b/tests/test_guitar_practice.py new file mode 100644 index 0000000..5693c24 --- /dev/null +++ b/tests/test_guitar_practice.py @@ -0,0 +1,220 @@ +import argparse +import copy +from fractions import Fraction +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from generator.__main__ import generate_assets +from generator.arrange.fretboard import find_playable_voicing, positions_for_string +from generator.guitar import TabPosition, positions_for_pitch, within_fret_span +from generator.model import fraction, key_from_fifths, pitch_number, rational, validate_document +from generator.musicxml import load_musicxml +from generator.musicxml_writer import write_musicxml +from generator.practice.generate import generate_practice_scores, practice_document +from generator.practice.guitar import guitar_tones, transpose_guitar_practice, validate_guitar_practice +from generator.summary import validate_practice_variants +from generator.transposition import TARGET_KEYS, transpose_document, transposition_interval +from tests.test_musicxml import notes, parse, xml_note, xml_score + +OCTAVE_CLEF = 'G2-1' +GUITAR_TRANSPOSE = '00-1' +TAB_STAFF = 'TAB56' + +def technical(string, fret): + return f'{string}{fret}' + +def all_pitches(document): + return [p for staff in document['staves'] for measure in staff['measures'] for voice in measure['voices'] for note in voice['notes'] for p in note['pitches']] + +class GuitarPracticeTests(unittest.TestCase): + def test_fret_boundaries_and_crossing_twelve(self): + self.assertIn(TabPosition(6, 0, 40), positions_for_pitch(Fraction(40))) + self.assertIn(TabPosition(1, 15, 79), positions_for_pitch(Fraction(79))) + self.assertFalse(positions_for_pitch(Fraction(80))) + self.assertFalse(positions_for_pitch(Fraction(129, 2))) + self.assertEqual([p.fret for p in positions_for_string(1, 64, tuple(range(12)))], list(range(16))) + self.assertTrue(within_fret_span((TabPosition(4, 11, 61), TabPosition(3, 12, 67), TabPosition(2, 14, 73)))) + self.assertTrue(within_fret_span((TabPosition(6, 0, 40), TabPosition(1, 15, 79)))) + self.assertFalse(within_fret_span((TabPosition(4, 10, 60), TabPosition(1, 15, 79)))) + content = xml_note('G', extra=technical(1, 15), octave=5) + validate_guitar_practice(parse(xml_score(content, TAB_STAFF))) + with self.assertRaisesRegex(ValueError, '0-15'): + validate_guitar_practice(parse(xml_score(xml_note('G', alter=1, extra=technical(1, 16), octave=5), TAB_STAFF))) + def test_arranger_does_not_truncate_candidate_positions(self): + def candidates(string, open_midi, pitch_classes): + if string == 6: + return [TabPosition(6, 15, 48)] * 8 + [TabPosition(6, 1, 48)] + return {4: [TabPosition(4, 1, 52)], 3: [TabPosition(3, 1, 58)], 2: [TabPosition(2, 1, 60)]}.get(string, []) + with patch('generator.arrange.fretboard.positions_for_string', side_effect=candidates): + voicing = find_playable_voicing('C7') + self.assertEqual(len(voicing), 4) + self.assertTrue(all(p.fret == 1 for p in voicing)) + def test_current_catalogue_all_keys_preserve_exact_voicings(self): + source = practice_document(load_musicxml(Path('data/practice/chord/01_three_note_shell_voicings/01-cycles.xml'))) + original = copy.deepcopy(source) + for key in TARGET_KEYS: + with self.subTest(key=key): + result = transpose_guitar_practice(source, key) + validate_document(result) + validate_guitar_practice(result) + _, semitones = transposition_interval(source, key) + adjustment = 12 if key == 'G' else 0 + self.assertEqual(result, transpose_document(source, key, octave_shift=adjustment // 12)) + shifts = {pitch_number(b) - pitch_number(a) for a, b in zip(all_pitches(source), all_pitches(result))} + self.assertEqual(shifts, {semitones + adjustment}) + if key == 'D': + self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'A7') + self.assertEqual(source, original) + def test_non_c_source_modes_and_slash_harmony(self): + harmony = 'E-1majorG' + source = parse(xml_score(harmony + xml_note('E', alter=-1), '-3major')) + result = transpose_guitar_practice(source, 'D') + self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62) + self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'D/F#') + for mode, fifths in [('minor', -3), ('dorian', -2)]: + source = parse(xml_score(xml_note(), f'{fifths}{mode}')) + result = transpose_guitar_practice(source, 'D') + self.assertEqual(result['metadata']['key_signature'], f'D {mode}') + def test_local_keys_spelling_timing_and_ties_survive(self): + source = parse(xml_score(xml_note('B', 2, 'half', alter=1, extra='') + xml_note('B', 2, 'half', alter=1, extra=''))) + source['staves'][0]['measures'][0]['attributes'] = [{'onset': rational(2), 'key': key_from_fifths(-1)}] + result = transpose_guitar_practice(source, 'D') + validate_document(result) + self.assertEqual(result, transpose_document(source, 'D')) + self.assertEqual(notes(result)[0]['pitches'][0]['letter'], 'c') + self.assertEqual(fraction(notes(result)[0]['pitches'][0]['alter']), 2) + self.assertEqual(result['staves'][0]['measures'][0]['attributes'][0]['key'], key_from_fifths(1)) + def test_keyless_reference_required_for_both_practice_types(self): + source = parse(xml_score(xml_note(), '0none')) + with self.assertRaisesRegex(ValueError, 'vamp:tonic'): + transpose_guitar_practice(source, 'D') + source['metadata']['transpose_origin'] = {'letter': 'c', 'alter': rational(0)} + result = transpose_guitar_practice(source, 'D') + self.assertEqual(result['metadata']['key_signature'], 'No key') + self.assertEqual(result['metadata']['variant_tonic'], 'D') + self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62) + def test_octave_adjustment_does_not_change_harmony_or_key(self): + source = parse(xml_score(xml_note())) + low = transpose_document(source, 'D') + high = transpose_document(source, 'D', octave_shift=1) + self.assertEqual(low['metadata'], high['metadata']) + self.assertEqual(pitch_number(notes(high)[0]['pitches'][0]) - pitch_number(notes(low)[0]['pitches'][0]), 12) + with self.assertRaisesRegex(ValueError, 'integer'): + transpose_document(source, 'D', octave_shift=0.5) + def test_instrument_transposition_clefs_and_tab_are_applied_once(self): + plain = parse(xml_score(xml_note('E', octave=3))) + explicit = parse(xml_score(xml_note('E', octave=3), GUITAR_TRANSPOSE)) + octave_clef = parse(xml_score(xml_note('E', octave=2), OCTAVE_CLEF)) + tab = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF)) + both = parse(xml_score(xml_note('E', octave=3), GUITAR_TRANSPOSE + OCTAVE_CLEF)) + for document in (plain, explicit, octave_clef, tab, both): + self.assertEqual(guitar_tones(document)[0].midi, 40) + validate_guitar_practice(document) + source = parse(xml_score(xml_note('E', 2, 'half', octave=3) + '' + GUITAR_TRANSPOSE + '' + xml_note('E', 2, 'half', octave=3))) + self.assertEqual([tone.midi for tone in guitar_tones(source)], [40, 40]) + def test_explicit_zero_transposition_overrides_implicit_guitar_octave(self): + zero = '0' + source = parse(xml_score(xml_note('E', octave=2), zero)) + self.assertTrue(source['staves'][0]['measures'][0]['transpose']['explicit']) + self.assertEqual(guitar_tones(source)[0].midi, 40) + validate_guitar_practice(source) + content = xml_note('E', 2, 'half', octave=3) + '' + zero + '' + xml_note('E', 2, 'half', octave=2) + self.assertEqual([tone.midi for tone in guitar_tones(parse(xml_score(content, GUITAR_TRANSPOSE)))], [40, 40]) + def test_real_quintuplets_and_standard_tab_tuning_survive_transposition(self): + content = '' + for index in range(5): + tuplet = '' if index == 0 else '' if index == 4 else '' + extra = '54' + tuplet + '' + content += xml_note(duration=4, kind='quarter', extra=extra) + source = parse(xml_score(content, divisions=5)) + result = transpose_guitar_practice(source, 'Db') + validate_document(result) + self.assertEqual([fraction(n['duration']) for n in notes(result)], [Fraction(4, 5)] * 5) + self.assertEqual(source['staves'][0]['tuplets'], result['staves'][0]['tuplets']) + tuning = '6' + for line, (step, octave) in enumerate((('E', 2), ('A', 2), ('D', 3), ('G', 3), ('B', 3), ('E', 4)), 1): + tuning += f'{step}{octave}' + tuning += '' + source = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF + tuning)) + result = transpose_guitar_practice(source, 'D') + self.assertEqual(notes(result)[0]['pitches'][0]['tablature'], {'string': 6, 'fret': 2}) + self.assertEqual(result['staves'][0]['measures'][0]['staff_details'], source['staves'][0]['measures'][0]['staff_details']) + def test_xml_roundtrip_preserves_implicit_and_explicit_transposition(self): + for attributes in ('', GUITAR_TRANSPOSE, '0'): + source = parse(xml_score(xml_note(), attributes)) + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / 'roundtrip.xml' + write_musicxml(path, source) + result = load_musicxml(path) + self.assertEqual(source['staves'][0]['measures'][0]['transpose'], result['staves'][0]['measures'][0]['transpose']) + self.assertEqual(guitar_tones(source)[0].midi, guitar_tones(result)[0].midi) + def test_tab_full_interval_is_applied_before_validating_fret(self): + source = parse(xml_score(xml_note('E', octave=2, extra=technical(6, 0)), TAB_STAFF)) + with self.assertRaisesRegex(ValueError, 'measure 1.*tablature'): + transpose_document(source, 'G') + result = transpose_guitar_practice(source, 'G') + self.assertEqual(notes(result)[0]['pitches'][0]['tablature'], {'string': 6, 'fret': 7}) + self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 47) + def test_held_strings_cannot_be_reassigned_when_another_voice_enters(self): + content = xml_note('E', extra=technical(1, 0)) + '41' + content += xml_note('F', 3, 'half', extra='2' + technical(1, 1)) + source = parse(xml_score(content, OCTAVE_CLEF)) + with self.assertRaisesRegex(ValueError, 'held/tied strings'): + validate_guitar_practice(source) + notes(source)[0]['pitches'][0].pop('tablature') + validate_guitar_practice(source) + def test_tied_strings_and_unequal_chord_durations(self): + start = xml_note('E', 1, 'quarter', extra='' + technical(1, 0)) + stop = xml_note('E', 3, 'half', extra='') + other = xml_note('F', 3, 'half', prefix='', extra='' + technical(1, 1)) + source = parse(xml_score(start + stop + other, OCTAVE_CLEF)) + with self.assertRaisesRegex(ValueError, 'held/tied strings'): + validate_guitar_practice(source) + notes(source)[0]['pitches'][0].pop('tie_to') + notes(source)[1]['pitches'][0].pop('tie_from') + validate_guitar_practice(source) + content = xml_note('E', 2, 'half', extra=technical(1, 0)) + xml_note('B', 1, 'quarter', prefix='', extra=technical(2, 0), octave=3) + content += '21' + xml_note('C', 3, 'half', extra='2' + technical(2, 1)) + validate_guitar_practice(parse(xml_score(content, OCTAVE_CLEF))) + def test_grace_notes_at_tie_boundary_do_not_drop_the_held_string(self): + start = xml_note('E', 1, 'quarter', extra='' + technical(1, 0)) + grace = 'B3eighth' + stop = xml_note('E', 3, 'half', extra='') + validate_guitar_practice(parse(xml_score(start + grace + stop, OCTAVE_CLEF))) + def test_unsupported_tuning_capo_and_microtones_fail_explicitly(self): + for attributes, error in [('1', 'capo'), ('D2', 'standard six-string tuning')]: + with self.assertRaisesRegex(ValueError, error): + transpose_guitar_practice(parse(xml_score(xml_note(), attributes)), 'C') + with self.assertRaisesRegex(ValueError, 'no whole-exercise guitar register'): + transpose_guitar_practice(parse(xml_score(xml_note(alter='0.5'))), 'C') + def test_no_register_and_failed_generation_preserve_previous_assets(self): + content = xml_note('E', octave=2) + xml_note('E', octave=7, prefix='') + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / 'data/songs').mkdir(parents=True) + source = root / 'data/practice/chord/lesson/wide.xml' + source.parent.mkdir(parents=True) + source.write_text(xml_score(content), encoding='utf-8') + output = root / 'output' + output.mkdir() + (output / 'last-good').write_text('preserved') + args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False) + with patch('generator.__main__.engrave_tree') as engrave: + with self.assertRaisesRegex(ValueError, r'wide.xml, target key C:.*measure 1'): + generate_assets(args) + engrave.assert_not_called() + self.assertEqual((output / 'last-good').read_text(), 'preserved') + self.assertEqual(list(output.iterdir()), [output / 'last-good']) + def test_all_practice_types_require_twelve_deterministic_variants(self): + with tempfile.TemporaryDirectory() as temp: + output = Path(temp) + self.assertEqual(generate_practice_scores(Path('data/practice'), output), 48) + validate_practice_variants(output) + first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')} + generate_practice_scores(Path('data/practice'), output) + self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')}) + next((output / 'chord').rglob('g.json')).unlink() + with self.assertRaisesRegex(ValueError, 'chord practice generator drift'): + validate_practice_variants(output)