@@ -28,7 +28,6 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--data-dir", type=Path, required=True)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--static-assets-dir", type=Path, default=Path("package/assets"))
|
||||
parser.add_argument("--clean", action="store_true", help="Compatibility option; generated assets are always replaced as a complete bundle.")
|
||||
parser.add_argument("--check", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -44,7 +43,7 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs')
|
||||
practice_count = generate_practice_scores(data_dir / 'practice', staged / 'practice')
|
||||
practice_count = generate_practice_scores(data_dir, staged / 'practice')
|
||||
validate_generated_tree(staged)
|
||||
if args.check:
|
||||
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||
|
||||
+7
-25
@@ -7,7 +7,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
APPLICATION_ID = 0x56414D50
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 2
|
||||
COMMON_COLUMNS = """
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
@@ -20,17 +20,6 @@ COMMON_COLUMNS = """
|
||||
total_pages INTEGER NOT NULL CHECK(total_pages BETWEEN 1 AND 10000)
|
||||
"""
|
||||
|
||||
def human_label(value: str) -> str:
|
||||
result, capitalize = '', True
|
||||
for character in value:
|
||||
if character in '-_':
|
||||
result += ' '
|
||||
capitalize = True
|
||||
else:
|
||||
result += character.upper() if capitalize and 'a' <= character <= 'z' else character
|
||||
capitalize = False
|
||||
return result or 'Score'
|
||||
|
||||
def metadata(path: Path, root: Path) -> dict:
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise ValueError('score escapes the asset root')
|
||||
@@ -67,15 +56,12 @@ def populate(connection: sqlite3.Connection, root: Path) -> None:
|
||||
PRAGMA application_id = {APPLICATION_ID};
|
||||
PRAGMA user_version = {SCHEMA_VERSION};
|
||||
CREATE TABLE songs ({COMMON_COLUMNS}, part TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE practice_exercises ({COMMON_COLUMNS},
|
||||
exercise_id TEXT NOT NULL,
|
||||
practice_type_id TEXT NOT NULL, practice_type_title TEXT NOT NULL,
|
||||
lesson_group_id TEXT NOT NULL, lesson_group_title TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE practice_exercises ({COMMON_COLUMNS}, exercise_id TEXT NOT NULL) STRICT;
|
||||
CREATE INDEX songs_menu ON songs(title COLLATE NOCASE, composer COLLATE NOCASE, part COLLATE NOCASE);
|
||||
CREATE INDEX practice_menu ON practice_exercises(practice_type_id COLLATE NOCASE, lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE);
|
||||
CREATE INDEX practice_menu ON practice_exercises(exercise_id, id);
|
||||
''')
|
||||
identities, authored_keys = {}, {}
|
||||
for category, table, depth in (('songs', 'songs', 1), ('practice', 'practice_exercises', 4)):
|
||||
authored_keys = {}
|
||||
for category, table, depth in (('songs', 'songs', 1), ('practice', 'practice_exercises', 2)):
|
||||
folder = root / category
|
||||
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
|
||||
raise ValueError(f'{folder}: score asset directory not found')
|
||||
@@ -86,15 +72,11 @@ def populate(connection: sqlite3.Connection, root: Path) -> None:
|
||||
raise ValueError(f'unexpected {category} score path')
|
||||
row = metadata(path, root)
|
||||
if category == 'practice':
|
||||
identity = '-'.join(parts[:3])
|
||||
previous = identities.setdefault(identity, path.parent)
|
||||
if previous != path.parent:
|
||||
raise ValueError(f'practice exercise ID collision {identity!r}: {previous} and {path.parent}')
|
||||
identity = parts[0]
|
||||
if authored_keys.setdefault(identity, row['authored_key_signature']) != row['authored_key_signature']:
|
||||
raise ValueError(f'practice variants disagree on authored key: {identity}')
|
||||
row.pop('part')
|
||||
row.update(id=identity + '-' + path.stem, exercise_id=identity, practice_type_id=parts[0], practice_type_title=human_label(parts[0]) + ' Practice',
|
||||
lesson_group_id=parts[1], lesson_group_title=human_label(parts[1]))
|
||||
row.update(id=identity + '-' + path.stem, exercise_id=identity)
|
||||
connection.execute(f'INSERT INTO {table} ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values()))
|
||||
except (ValueError, KeyError, TypeError, sqlite3.Error) as error:
|
||||
raise ValueError(f'{path}: {error}') from error
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ 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 = 20
|
||||
MAX_FRET_SPAN = 4
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ def compile_tree(root: Path) -> None:
|
||||
for path in sorted(root.rglob('*.json')):
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
try:
|
||||
guitar = path.relative_to(root).parts[:2] == ('practice', 'chord') or document['staves'][0].get('arrangement_part_type') in ('acoustic', 'electric')
|
||||
guitar = document['staves'][0].get('arrangement_part_type') in ('practice', 'acoustic', 'electric')
|
||||
playback = compile_timeline(document, guitar=guitar)
|
||||
used = {}
|
||||
for event in playback['events']:
|
||||
|
||||
@@ -5,7 +5,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
|
||||
from generator.transposition import TARGET_KEYS
|
||||
|
||||
def practice_document(document: dict) -> dict:
|
||||
staff = source_staff(document)
|
||||
@@ -29,7 +29,7 @@ def practice_document(document: dict) -> dict:
|
||||
staff,
|
||||
staff_id="staff-arranged-practice",
|
||||
staff_name="Practice",
|
||||
role="vocal",
|
||||
role="guitar",
|
||||
arrangement_part_type="practice",
|
||||
measures=measures,
|
||||
tuplets=tuplets,
|
||||
@@ -41,47 +41,27 @@ def practice_document(document: dict) -> dict:
|
||||
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:
|
||||
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:
|
||||
def generate_key_variants(source_path: Path, output_dir: Path) -> 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:
|
||||
try:
|
||||
variant = transpose_guitar_practice(base_document, key) if practice_type == 'chord' else transpose_document(base_document, key)
|
||||
variant = transpose_guitar_practice(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)
|
||||
write_json(output_dir / source_path.stem / key_file_name(key), variant)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
|
||||
if not source_dir.exists():
|
||||
return 0
|
||||
sources = sorted(source_dir.rglob('*.xml'))
|
||||
destinations: dict[Path, Path] = {}
|
||||
identities: dict[str, Path] = {}
|
||||
for path in sources:
|
||||
if not source_dir.is_dir():
|
||||
raise ValueError(f'practice source directory not found: {source_dir}')
|
||||
sources = sorted(source_dir.glob('*.xml'))
|
||||
for path in source_dir.rglob('*.xml'):
|
||||
parts = path.relative_to(source_dir).parts
|
||||
if len(parts) != 3 or parts[0] not in ('chord', 'melody'):
|
||||
raise ValueError(f'{path}: practice source must be stored as practice/<chord|melody>/<lesson_group>/<exercise>.xml')
|
||||
destination = Path(parts[0]) / parts[1] / slugify(path.stem)
|
||||
if destination in destinations:
|
||||
raise ValueError(f'{path}: practice output path collides with {destinations[destination]}: {destination}')
|
||||
identity = '-'.join(destination.parts)
|
||||
if identity in identities:
|
||||
raise ValueError(f'{path}: practice exercise ID collision with {identities[identity]}: {identity}')
|
||||
identities[identity] = path
|
||||
destinations[destination] = path
|
||||
count = 0
|
||||
for destination, path in destinations.items():
|
||||
practice_type = destination.parts[0]
|
||||
count += generate_key_variants(path, source_dir / practice_type, output_dir, practice_type)
|
||||
return count
|
||||
if len(parts) != 1 and (len(parts) != 2 or parts[0] != 'songs'):
|
||||
raise ValueError(f'{path}: practice sources belong directly under the data directory')
|
||||
for path in sources:
|
||||
if len(path.stem) != 3 or not path.stem.isascii() or not path.stem.isdecimal() or int(path.stem) == 0:
|
||||
raise ValueError(f'{path}: practice sources require a three-digit positive filename such as 001.xml')
|
||||
return sum(generate_key_variants(path, output_dir) for path in sources)
|
||||
|
||||
@@ -42,9 +42,6 @@ def write_json(path: Path, document: dict) -> None:
|
||||
def slugify(value: str) -> str:
|
||||
return re.sub(r'[^a-z0-9]+', '-', value.strip().lower()).strip('-') or 'score'
|
||||
|
||||
def human_label(value: str) -> str:
|
||||
return ' '.join(v.capitalize() for v in value.replace('_', ' ').replace('-', ' ').split()) or 'General'
|
||||
|
||||
def source_staff(document: dict) -> dict:
|
||||
if len(document.get('staves', [])) != 1:
|
||||
raise ValueError('exactly one source staff is required')
|
||||
|
||||
@@ -89,11 +89,7 @@ def validate_practice_variants(generated_practice_dir: Path) -> None:
|
||||
if not generated_practice_dir.exists():
|
||||
return
|
||||
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"{practice_type} practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}")
|
||||
for exercise_dir in sorted(path for path in generated_practice_dir.iterdir() if path.is_dir()):
|
||||
json_names = {path.name for path in exercise_dir.glob("*.json")}
|
||||
if json_names != expected_names:
|
||||
raise ValueError(f"practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}")
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from generator import harmony
|
||||
from generator.model import LETTERS, NATURAL, alteration, key_from_fifths, key_label, parse_pitch_name, pitch_number, rational
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeyProfile:
|
||||
name: str
|
||||
root: int
|
||||
fifths: int
|
||||
|
||||
KEY_PROFILES = tuple(KeyProfile(name, root, fifths) for root, (name, fifths) in enumerate((('C', 0), ('Db', -5), ('D', 2), ('Eb', -3), ('E', 4), ('F', -1), ('Gb', -6), ('G', 1), ('Ab', -4), ('A', 3), ('Bb', -2), ('B', 5))))
|
||||
TARGET_KEYS = tuple(p.name for p in KEY_PROFILES)
|
||||
TARGET_KEYS = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B')
|
||||
|
||||
def mod12(value: int) -> int:
|
||||
return value % 12
|
||||
|
||||
def find_key(name: str) -> KeyProfile:
|
||||
tonic = parse_pitch_name(name)
|
||||
for fifths in range(-14, 15):
|
||||
key = key_from_fifths(fifths)
|
||||
if key['tonic'] == tonic:
|
||||
return KeyProfile(name, int(NATURAL[tonic['letter']] + alteration(tonic['alter'])) % 12, fifths)
|
||||
raise ValueError(f'unsupported key signature: {name}')
|
||||
|
||||
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
||||
value = (target.root - source.root) % 12
|
||||
return value - 12 if value > 6 else value
|
||||
|
||||
def interval_between(source: dict, target: dict) -> tuple[int, int]:
|
||||
semitones = (NATURAL[target['letter']] + alteration(target['alter']) - NATURAL[source['letter']] - alteration(source['alter'])) % 12
|
||||
if semitones > 6:
|
||||
|
||||
Reference in New Issue
Block a user