@@ -0,0 +1,2 @@
|
||||
"""Build-time score generation for Product Vamp."""
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from generator.arrange.generate import generate_performance_scores
|
||||
from generator.practice.generate import generate_practice_scores
|
||||
from generator.score import count_json_files, validate_generated_tree
|
||||
from generator.summary import validate_expected_performance_summary, validate_practice_variants
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.")
|
||||
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")
|
||||
parser.add_argument("--check", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
data_dir = args.data_dir.resolve()
|
||||
output_dir = args.output_dir.resolve()
|
||||
static_assets_dir = args.static_assets_dir.resolve()
|
||||
|
||||
if args.clean and output_dir.exists():
|
||||
shutil.rmtree(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
fonts_src = static_assets_dir / "fonts"
|
||||
if fonts_src.exists():
|
||||
shutil.copytree(fonts_src, output_dir / "fonts", dirs_exist_ok=True)
|
||||
|
||||
performance_count = generate_performance_scores(data_dir / "songs", output_dir / "songs")
|
||||
practice_count = generate_practice_scores(data_dir / "practice", output_dir / "practice")
|
||||
|
||||
validate_generated_tree(output_dir)
|
||||
print(
|
||||
"Generated Product Vamp assets: "
|
||||
f"{performance_count} performance scores, "
|
||||
f"{practice_count} practice score variants, "
|
||||
f"{count_json_files(output_dir)} total JSON files."
|
||||
)
|
||||
|
||||
if args.check:
|
||||
validate_expected_performance_summary(data_dir / "expected" / "performance_summary.json", output_dir / "songs")
|
||||
validate_practice_variants(output_dir / "practice")
|
||||
print("Generator check passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Performance arrangement generation."""
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from generator.score import body_start_measure_index
|
||||
|
||||
SectionRole = Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArrangementSection:
|
||||
label: str
|
||||
source_measure_index: int
|
||||
measure_count: int
|
||||
section_index: int
|
||||
role: SectionRole
|
||||
|
||||
|
||||
def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
|
||||
if body_measure_count >= 8:
|
||||
return 8
|
||||
if body_measure_count >= 4:
|
||||
return 4
|
||||
return body_measure_count
|
||||
|
||||
|
||||
def section_role(part_type: str, section_index: int) -> SectionRole:
|
||||
if part_type == "acoustic":
|
||||
return "slash" if section_index == 3 else "rhythm"
|
||||
if part_type == "electric":
|
||||
if section_index == 4:
|
||||
return "slash"
|
||||
if section_index == 2:
|
||||
return "melody"
|
||||
if section_index in {1, 5, 6}:
|
||||
return "accompaniment"
|
||||
return "rest"
|
||||
return "vocal" if section_index in {1, 5, 6} else "rest"
|
||||
|
||||
|
||||
def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]:
|
||||
if not measures:
|
||||
return []
|
||||
body_start = body_start_measure_index(measures)
|
||||
if body_start >= len(measures):
|
||||
return []
|
||||
body_measure_count = len(measures) - body_start
|
||||
edge_measure_count = phrase_aware_edge_measure_count(body_measure_count)
|
||||
edge_source_measure = body_start + body_measure_count - edge_measure_count
|
||||
specs = (
|
||||
("Rhythm Lead-in", edge_source_measure, edge_measure_count, 0),
|
||||
("Vocal Melody", body_start, body_measure_count, 1),
|
||||
("Guitar Melody", body_start, body_measure_count, 2),
|
||||
("Guitar Solo", body_start, body_measure_count, 3),
|
||||
("Guitar Solo", body_start, body_measure_count, 4),
|
||||
("Vocal Melody", body_start, body_measure_count, 5),
|
||||
("Ending", edge_source_measure, edge_measure_count, 6),
|
||||
)
|
||||
return [
|
||||
ArrangementSection(label=label, source_measure_index=start, measure_count=count, section_index=index, role=section_role(part_type, index))
|
||||
for label, start, count, index in specs
|
||||
if count > 0
|
||||
]
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import product
|
||||
from typing import NamedTuple
|
||||
|
||||
from generator.transposition import mod12
|
||||
|
||||
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
|
||||
|
||||
|
||||
ROOTS = {
|
||||
"C": 0,
|
||||
"C#": 1,
|
||||
"Db": 1,
|
||||
"D": 2,
|
||||
"D#": 3,
|
||||
"Eb": 3,
|
||||
"E": 4,
|
||||
"F": 5,
|
||||
"F#": 6,
|
||||
"Gb": 6,
|
||||
"G": 7,
|
||||
"G#": 8,
|
||||
"Ab": 8,
|
||||
"A": 9,
|
||||
"A#": 10,
|
||||
"Bb": 10,
|
||||
"B": 11,
|
||||
}
|
||||
|
||||
|
||||
def parse_root(symbol: str) -> tuple[str, str] | None:
|
||||
if not symbol or symbol[0].upper() not in "ABCDEFG":
|
||||
return None
|
||||
length = 1
|
||||
if len(symbol) > 1 and symbol[1] in {"#", "b"}:
|
||||
length = 2
|
||||
return symbol[:length], symbol[length:]
|
||||
|
||||
|
||||
def chord_intervals(quality: str) -> tuple[int, ...]:
|
||||
value = quality.lower()
|
||||
if "7b5" in value or "ø" in value or "half" in value:
|
||||
return (0, 3, 6, 10)
|
||||
if "dim" in value or "o" in value:
|
||||
return (0, 3, 6, 9)
|
||||
if "aug" in value or "+" in value:
|
||||
return (0, 4, 8, 10)
|
||||
if "sus" in value:
|
||||
return (0, 5, 7, 10)
|
||||
if value.startswith("-") or (value.startswith("m") and not value.startswith("maj")):
|
||||
return (0, 3, 7, 10 if "7" in value else 10)
|
||||
if "maj7" in value or "ma7" in value or "Δ" in value:
|
||||
return (0, 4, 7, 11)
|
||||
if "6" in value and "7" not in value:
|
||||
return (0, 4, 7, 9)
|
||||
if "7" in value:
|
||||
return (0, 4, 7, 10)
|
||||
return (0, 4, 7)
|
||||
|
||||
|
||||
def chord_pitch_classes(symbol: str) -> tuple[int, ...]:
|
||||
parsed = parse_root(symbol)
|
||||
if parsed is None:
|
||||
return ()
|
||||
root, quality = parsed
|
||||
root_pc = ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
|
||||
if root_pc is None:
|
||||
return ()
|
||||
return tuple(mod12(root_pc + interval) for interval in chord_intervals(quality))
|
||||
|
||||
|
||||
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):
|
||||
midi = open_midi + fret
|
||||
if mod12(midi) in pitch_classes:
|
||||
result.append(TabPosition(string_number, fret, midi))
|
||||
return result
|
||||
|
||||
|
||||
def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int:
|
||||
fretted = [position.fret for position in candidate if position.fret > 0]
|
||||
if not fretted:
|
||||
span = 0
|
||||
low = 0
|
||||
else:
|
||||
span = max(fretted) - min(fretted)
|
||||
low = min(fretted)
|
||||
covered = {mod12(position.midi) for position in candidate}
|
||||
missing = len(set(pitch_classes) - covered)
|
||||
open_penalty = sum(1 for position in candidate if position.fret == 0)
|
||||
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
|
||||
|
||||
|
||||
def find_playable_voicing(symbol: str) -> list[TabPosition]:
|
||||
pitch_classes = chord_pitch_classes(symbol)
|
||||
if not pitch_classes:
|
||||
return []
|
||||
|
||||
string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1))
|
||||
by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING}
|
||||
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]
|
||||
if any(not item for item in options):
|
||||
continue
|
||||
for candidate in product(*options):
|
||||
fretted = [position.fret for position in candidate if position.fret > 0]
|
||||
if fretted and max(fretted) - min(fretted) > 4:
|
||||
continue
|
||||
covered = {mod12(position.midi) for position in candidate}
|
||||
if len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
|
||||
continue
|
||||
score = candidate_score(candidate, pitch_classes)
|
||||
if score < best_score:
|
||||
best = candidate
|
||||
best_score = score
|
||||
return sorted(best or [], key=lambda item: item.midi)
|
||||
|
||||
|
||||
def pitch_from_midi(midi: int) -> dict[str, int | str]:
|
||||
names = (
|
||||
("c", "n"),
|
||||
("c", "#"),
|
||||
("d", "n"),
|
||||
("e", "b"),
|
||||
("e", "n"),
|
||||
("f", "n"),
|
||||
("f", "#"),
|
||||
("g", "n"),
|
||||
("a", "b"),
|
||||
("a", "n"),
|
||||
("b", "b"),
|
||||
("b", "n"),
|
||||
)
|
||||
letter, accidental = names[mod12(midi)]
|
||||
return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1}
|
||||
|
||||
|
||||
def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]:
|
||||
return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from generator.arrange.form import ArrangementSection, full_arrangement_sections
|
||||
from generator.arrange.fretboard import pitches_for_voicing
|
||||
from generator.score import (
|
||||
QUARTER_TICKS,
|
||||
apply_source_chord_markers,
|
||||
active_chord_at_offset,
|
||||
chord_markers,
|
||||
chord_symbol_at_offset,
|
||||
copy_source_voice,
|
||||
first_chord_symbol,
|
||||
first_voice,
|
||||
leading_pickup_measure_index,
|
||||
load_json,
|
||||
make_arranged_document,
|
||||
make_measure,
|
||||
measure_capacity_ticks,
|
||||
renderable_duration,
|
||||
renderable_rest_notes,
|
||||
rest_note,
|
||||
sanitized_note,
|
||||
slash_note,
|
||||
slugify,
|
||||
source_staff,
|
||||
source_note_attack_at,
|
||||
write_json,
|
||||
)
|
||||
|
||||
PART_TYPES = ("acoustic", "electric", "vocal")
|
||||
|
||||
|
||||
def propagated_chords(measures: list[dict]) -> list[str]:
|
||||
result: list[str] = []
|
||||
current = ""
|
||||
for measure in measures:
|
||||
current = first_chord_symbol(measure, current)
|
||||
result.append(current)
|
||||
return result
|
||||
|
||||
|
||||
def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol: str, visible_chord_symbol: str = "") -> dict:
|
||||
note = {
|
||||
"id": note_id,
|
||||
"kind": "note",
|
||||
"duration_ticks": duration_ticks,
|
||||
"pitches": pitches_for_voicing(voicing_chord_symbol),
|
||||
"hidden": False,
|
||||
}
|
||||
if not note["pitches"]:
|
||||
return rest_note(note_id, duration_ticks, visible_chord_symbol)
|
||||
if visible_chord_symbol:
|
||||
note["chord_symbol"] = visible_chord_symbol
|
||||
return note
|
||||
|
||||
|
||||
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||
hit_offsets = (0, QUARTER_TICKS + QUARTER_TICKS // 2, QUARTER_TICKS * 2, QUARTER_TICKS * 2 + QUARTER_TICKS // 2, QUARTER_TICKS * 3 + QUARTER_TICKS // 2)
|
||||
hit_duration = QUARTER_TICKS // 2
|
||||
notes: list[dict] = []
|
||||
cursor = 0
|
||||
for offset in hit_offsets:
|
||||
if offset >= capacity:
|
||||
break
|
||||
if offset > cursor:
|
||||
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
|
||||
notes.extend(rests)
|
||||
cursor = offset
|
||||
active_chord = active_chord_at_offset(measure, offset, chord_symbol)
|
||||
visible_chord = chord_symbol_at_offset(measure, offset)
|
||||
sequence += 1
|
||||
notes.append(playable_chord_note(f"arr-note-{sequence}", min(hit_duration, capacity - offset), active_chord, visible_chord))
|
||||
cursor = offset + min(hit_duration, capacity - offset)
|
||||
if cursor < capacity:
|
||||
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
|
||||
notes.extend(rests)
|
||||
return apply_source_chord_markers(notes, measure), sequence
|
||||
|
||||
|
||||
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||
notes: list[dict] = []
|
||||
lead_rest = min(QUARTER_TICKS // 2, capacity)
|
||||
if lead_rest:
|
||||
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0))
|
||||
notes.extend(rests)
|
||||
if lead_rest < capacity:
|
||||
hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
|
||||
visible_chord = chord_symbol_at_offset(measure, lead_rest)
|
||||
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol)
|
||||
sequence += 1
|
||||
if source_note_attack_at(measure, lead_rest):
|
||||
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord))
|
||||
else:
|
||||
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
|
||||
cursor = lead_rest + hit
|
||||
if cursor < capacity:
|
||||
markers = [marker for marker in chord_markers(measure) if marker[0] >= cursor]
|
||||
if markers:
|
||||
segment_cursor = cursor
|
||||
for index, (start, _, chord) in enumerate(markers):
|
||||
if start > segment_cursor:
|
||||
rests, sequence = renderable_rest_notes("arr-rest", start - segment_cursor, sequence)
|
||||
notes.extend(rests)
|
||||
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
|
||||
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
|
||||
notes.extend(rests)
|
||||
segment_cursor = next_start
|
||||
if segment_cursor < capacity:
|
||||
rests, sequence = renderable_rest_notes("arr-rest", capacity - segment_cursor, sequence)
|
||||
notes.extend(rests)
|
||||
else:
|
||||
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
|
||||
notes.extend(rests)
|
||||
return apply_source_chord_markers(notes, measure), sequence
|
||||
|
||||
|
||||
def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||
notes: list[dict] = []
|
||||
cursor = 0
|
||||
while cursor < capacity:
|
||||
sequence += 1
|
||||
duration = min(QUARTER_TICKS, capacity - cursor)
|
||||
notes.append(slash_note(f"arr-slash-{sequence}", duration))
|
||||
cursor += duration
|
||||
return notes, sequence
|
||||
|
||||
|
||||
def rest_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||
markers = chord_markers(measure)
|
||||
if not markers:
|
||||
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence)
|
||||
return notes, sequence
|
||||
|
||||
notes: list[dict] = []
|
||||
cursor = 0
|
||||
for index, (start, _, chord) in enumerate(markers):
|
||||
if start > cursor:
|
||||
if not renderable_duration(start - cursor):
|
||||
return source_rest_skeleton_notes(measure, sequence)
|
||||
rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence)
|
||||
notes.extend(rests)
|
||||
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
|
||||
if not renderable_duration(next_start - start):
|
||||
return source_rest_skeleton_notes(measure, sequence)
|
||||
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
|
||||
notes.extend(rests)
|
||||
cursor = next_start
|
||||
if cursor < capacity:
|
||||
if not renderable_duration(capacity - cursor):
|
||||
return source_rest_skeleton_notes(measure, sequence)
|
||||
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
|
||||
notes.extend(rests)
|
||||
return notes, sequence
|
||||
|
||||
|
||||
def source_rest_skeleton_notes(measure: dict, sequence: int) -> tuple[list[dict], int]:
|
||||
notes: list[dict] = []
|
||||
for source_note in first_voice(measure).get("notes", []):
|
||||
if not isinstance(source_note, dict):
|
||||
continue
|
||||
sequence += 1
|
||||
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), str(source_note.get("chord_symbol", "")))
|
||||
if "display_ticks" in source_note:
|
||||
note["display_ticks"] = source_note["display_ticks"]
|
||||
if "tuplet_id" in source_note:
|
||||
note["tuplet_id"] = source_note["tuplet_id"]
|
||||
notes.append(note)
|
||||
if not notes:
|
||||
sequence += 1
|
||||
notes.append(rest_note(f"arr-rest-{sequence}", measure_capacity_ticks(str(measure.get("time_signature", "4/4")))))
|
||||
return notes, sequence
|
||||
|
||||
|
||||
def melody_voice(measure: dict, *, strip_lyrics: bool) -> dict:
|
||||
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics)
|
||||
voice["id"] = "voice-arranged-source"
|
||||
voice["notes"] = [sanitized_note(note, strip_lyrics=strip_lyrics) for note in first_voice(measure).get("notes", []) if isinstance(note, dict)]
|
||||
return voice
|
||||
|
||||
|
||||
def apply_vocal_pickup_overlap(output: list[dict], source_measures: list[dict], section: ArrangementSection) -> None:
|
||||
pickup = leading_pickup_measure_index(source_measures)
|
||||
if pickup is None or section.source_measure_index != pickup + 1 or not output:
|
||||
return
|
||||
voice = copy_source_voice(source_measures[pickup], strip_lyrics=False)
|
||||
for note in voice["notes"]:
|
||||
if isinstance(note, dict):
|
||||
note.pop("chord_symbol", None)
|
||||
output[-1]["voices"][0]["id"] = "voice-arranged-vocal"
|
||||
output[-1]["voices"][0]["tuplets"] = voice["tuplets"]
|
||||
output[-1]["voices"][0]["notes"] = voice["notes"]
|
||||
|
||||
|
||||
def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: str, sequence: int) -> tuple[dict, int]:
|
||||
if section.role == "vocal":
|
||||
return melody_voice(measure, strip_lyrics=False), sequence
|
||||
if section.role == "melody":
|
||||
return melody_voice(measure, strip_lyrics=True), sequence
|
||||
if section.role == "rhythm":
|
||||
notes, sequence = rhythm_notes(measure, chord_symbol, sequence)
|
||||
elif section.role == "accompaniment":
|
||||
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence)
|
||||
elif section.role == "slash":
|
||||
notes, sequence = slash_notes(measure, chord_symbol, sequence)
|
||||
else:
|
||||
notes, sequence = rest_notes(measure, chord_symbol, sequence)
|
||||
return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
|
||||
|
||||
|
||||
def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]:
|
||||
sections = full_arrangement_sections(source_measures, part_type)
|
||||
chords = propagated_chords(source_measures)
|
||||
output: list[dict] = []
|
||||
sequence = 0
|
||||
for section in sections:
|
||||
if part_type == "vocal" and section.role == "vocal":
|
||||
apply_vocal_pickup_overlap(output, source_measures, section)
|
||||
for offset in range(section.measure_count):
|
||||
source_index = section.source_measure_index + offset
|
||||
source_measure = source_measures[source_index]
|
||||
chord_symbol = chords[source_index] if source_index < len(chords) else ""
|
||||
voice, sequence = voice_for_section(section, source_measure, chord_symbol, sequence)
|
||||
output_measure = make_measure(
|
||||
f"measure-{len(output) + 1}",
|
||||
source_measure,
|
||||
voice.get("notes", []),
|
||||
section_label=section.label if offset == 0 else "",
|
||||
tuplets=voice.get("tuplets", []),
|
||||
)
|
||||
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
|
||||
output.append(output_measure)
|
||||
return output
|
||||
|
||||
|
||||
def arrange_document(document: dict, part_type: str) -> dict:
|
||||
staff = source_staff(document)
|
||||
measures = staff.get("measures")
|
||||
if not isinstance(measures, list) or not measures:
|
||||
raise ValueError("performance arrangement requires at least one source measure")
|
||||
output = make_arranged_document(
|
||||
document,
|
||||
staff,
|
||||
staff_id="staff-arranged-vocal" if part_type == "vocal" else "staff-arranged-guitar",
|
||||
staff_name=part_type.capitalize(),
|
||||
role="vocal" if part_type == "vocal" else "guitar",
|
||||
arrangement_part_type=part_type,
|
||||
measures=arranged_measures([measure for measure in measures if isinstance(measure, dict)], part_type),
|
||||
)
|
||||
metadata = output.setdefault("metadata", {})
|
||||
if isinstance(metadata, dict):
|
||||
metadata.setdefault("tempo", 124)
|
||||
return output
|
||||
|
||||
|
||||
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
|
||||
if not source_dir.exists():
|
||||
raise ValueError(f"performance source directory not found: {source_dir}")
|
||||
count = 0
|
||||
for source_path in sorted(source_dir.glob("*.json")):
|
||||
document = load_json(source_path)
|
||||
for part_type in PART_TYPES:
|
||||
output = arrange_document(document, part_type)
|
||||
target = output_dir / f"{slugify(source_path.stem)}-{part_type}.json"
|
||||
write_json(target, output)
|
||||
count += 1
|
||||
return count
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Practice score generation."""
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from generator.score import copy_source_voice, load_json, make_arranged_document, make_measure, slugify, source_staff, write_json
|
||||
from generator.transposition import TARGET_KEYS, transpose_document
|
||||
|
||||
|
||||
def practice_document(document: dict) -> dict:
|
||||
staff = source_staff(document)
|
||||
source_measures = staff.get("measures")
|
||||
if not isinstance(source_measures, list):
|
||||
raise ValueError("practice source must contain measures")
|
||||
measures: list[dict] = []
|
||||
for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)):
|
||||
voice = copy_source_voice(measure, strip_lyrics=True)
|
||||
output_measure = make_measure(
|
||||
f"measure-{index + 1}",
|
||||
measure,
|
||||
voice["notes"],
|
||||
section_label=str(measure.get("section_label", "")),
|
||||
tuplets=voice["tuplets"],
|
||||
)
|
||||
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
|
||||
measures.append(output_measure)
|
||||
return make_arranged_document(
|
||||
document,
|
||||
staff,
|
||||
staff_id="staff-arranged-practice",
|
||||
staff_name="Practice",
|
||||
role="vocal",
|
||||
arrangement_part_type="practice",
|
||||
measures=measures,
|
||||
)
|
||||
|
||||
|
||||
def key_file_name(key: str) -> str:
|
||||
return key.lower().replace("#", "sharp").replace("b", "b") + ".json"
|
||||
|
||||
|
||||
def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
||||
document = practice_document(load_json(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
|
||||
|
||||
|
||||
def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
||||
base_document = practice_document(load_json(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))
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
|
||||
if not source_dir.exists():
|
||||
return 0
|
||||
count = 0
|
||||
chord_root = source_dir / "chord"
|
||||
if chord_root.exists():
|
||||
for path in sorted(chord_root.rglob("*.json")):
|
||||
count += generate_chord_practice(path, chord_root, output_dir)
|
||||
melody_root = source_dir / "melody"
|
||||
if melody_root.exists():
|
||||
for path in sorted(melody_root.rglob("*.json")):
|
||||
count += generate_melody_practice(path, melody_root, output_dir)
|
||||
return count
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
WHOLE_TICKS = 16384
|
||||
HALF_TICKS = WHOLE_TICKS // 2
|
||||
QUARTER_TICKS = WHOLE_TICKS // 4
|
||||
EIGHTH_TICKS = WHOLE_TICKS // 8
|
||||
SIXTEENTH_TICKS = WHOLE_TICKS // 16
|
||||
THIRTY_SECOND_TICKS = WHOLE_TICKS // 32
|
||||
RENDERABLE_TICKS = (
|
||||
WHOLE_TICKS + HALF_TICKS,
|
||||
WHOLE_TICKS,
|
||||
HALF_TICKS + QUARTER_TICKS,
|
||||
HALF_TICKS,
|
||||
QUARTER_TICKS + EIGHTH_TICKS,
|
||||
QUARTER_TICKS,
|
||||
EIGHTH_TICKS + SIXTEENTH_TICKS,
|
||||
EIGHTH_TICKS,
|
||||
SIXTEENTH_TICKS + THIRTY_SECOND_TICKS,
|
||||
SIXTEENTH_TICKS,
|
||||
THIRTY_SECOND_TICKS,
|
||||
)
|
||||
|
||||
REST_PITCH = {"letter": "b", "accidental": "n", "octave": 4}
|
||||
FORBIDDEN_OUTPUT_FIELDS = {
|
||||
"tab_positions",
|
||||
"tab_frets",
|
||||
"finger",
|
||||
"fingers",
|
||||
"fingering",
|
||||
"left_hand_finger",
|
||||
"right_hand_finger",
|
||||
}
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
document = json.load(stream)
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError(f"{path} is not a score document")
|
||||
return document
|
||||
|
||||
|
||||
def write_json(path: Path, document: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as stream:
|
||||
json.dump(document, stream, indent=2, ensure_ascii=False)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
value = re.sub(r"[^a-z0-9]+", "-", value)
|
||||
value = value.strip("-")
|
||||
return value or "score"
|
||||
|
||||
|
||||
def human_label(value: str) -> str:
|
||||
text = value.replace("_", " ").replace("-", " ").strip()
|
||||
return " ".join(part.capitalize() for part in text.split()) or "General"
|
||||
|
||||
|
||||
def source_staff(document: dict[str, Any]) -> dict[str, Any]:
|
||||
staves = document.get("staves")
|
||||
if not isinstance(staves, list) or not staves:
|
||||
raise ValueError("score document must contain at least one staff")
|
||||
first_staff: dict[str, Any] | None = None
|
||||
for value in staves:
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
if first_staff is None:
|
||||
first_staff = value
|
||||
staff_id = str(value.get("id", ""))
|
||||
if not value.get("arranged") and staff_id not in {"staff-arranged-guitar", "staff-arranged-vocal", "staff-arranged-practice"} and value.get("role") != "guitar":
|
||||
return value
|
||||
if first_staff is None:
|
||||
raise ValueError("score document does not contain an object staff")
|
||||
return first_staff
|
||||
|
||||
|
||||
def measure_capacity_ticks(time_signature: str) -> int:
|
||||
try:
|
||||
beats_text, unit_text = time_signature.split("/", 1)
|
||||
beats = int(beats_text)
|
||||
unit = int(unit_text)
|
||||
except ValueError:
|
||||
return WHOLE_TICKS
|
||||
if beats <= 0 or unit <= 0:
|
||||
return WHOLE_TICKS
|
||||
return WHOLE_TICKS * beats // unit
|
||||
|
||||
|
||||
def note_duration(note: dict[str, Any]) -> int:
|
||||
value = note.get("duration_ticks", QUARTER_TICKS)
|
||||
return max(1, int(value)) if isinstance(value, int) else QUARTER_TICKS
|
||||
|
||||
|
||||
def renderable_duration(duration_ticks: int) -> bool:
|
||||
return duration_ticks in RENDERABLE_TICKS
|
||||
|
||||
|
||||
def renderable_duration_parts(duration_ticks: int) -> list[int]:
|
||||
parts: list[int] = []
|
||||
remaining = duration_ticks
|
||||
while remaining > 0:
|
||||
match = next((duration for duration in RENDERABLE_TICKS if duration <= remaining), None)
|
||||
if match is None:
|
||||
return [duration_ticks]
|
||||
parts.append(match)
|
||||
remaining -= match
|
||||
return parts
|
||||
|
||||
|
||||
def first_voice(measure: dict[str, Any]) -> dict[str, Any]:
|
||||
voices = measure.get("voices")
|
||||
if isinstance(voices, list) and voices and isinstance(voices[0], dict):
|
||||
return voices[0]
|
||||
return {"id": "voice-empty", "tuplets": [], "notes": []}
|
||||
|
||||
|
||||
def first_chord_symbol(measure: dict[str, Any], fallback: str = "") -> str:
|
||||
voice = first_voice(measure)
|
||||
notes = voice.get("notes")
|
||||
if not isinstance(notes, list):
|
||||
return fallback
|
||||
for note in notes:
|
||||
if isinstance(note, dict):
|
||||
chord = note.get("chord_symbol")
|
||||
if isinstance(chord, str) and chord:
|
||||
return chord
|
||||
return fallback
|
||||
|
||||
|
||||
def measure_notes(measure: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
voice = first_voice(measure)
|
||||
notes = voice.get("notes")
|
||||
return [note for note in notes if isinstance(note, dict)] if isinstance(notes, list) else []
|
||||
|
||||
|
||||
def timed_measure_notes(measure: dict[str, Any]) -> list[tuple[int, dict[str, Any]]]:
|
||||
cursor = 0
|
||||
result: list[tuple[int, dict[str, Any]]] = []
|
||||
for note in measure_notes(measure):
|
||||
result.append((cursor, note))
|
||||
cursor += note_duration(note)
|
||||
return result
|
||||
|
||||
|
||||
def chord_markers(measure: dict[str, Any]) -> list[tuple[int, int, str]]:
|
||||
markers: list[tuple[int, int, str]] = []
|
||||
for start_ticks, note in timed_measure_notes(measure):
|
||||
chord = note.get("chord_symbol")
|
||||
if isinstance(chord, str) and chord:
|
||||
markers.append((start_ticks, note_duration(note), chord))
|
||||
return markers
|
||||
|
||||
|
||||
def chord_symbol_at_offset(measure: dict[str, Any], offset_ticks: int) -> str:
|
||||
for start_ticks, _, chord in chord_markers(measure):
|
||||
if start_ticks == offset_ticks:
|
||||
return chord
|
||||
return ""
|
||||
|
||||
|
||||
def active_chord_at_offset(measure: dict[str, Any], offset_ticks: int, fallback: str = "") -> str:
|
||||
current = fallback
|
||||
for start_ticks, _, chord in chord_markers(measure):
|
||||
if start_ticks > offset_ticks:
|
||||
break
|
||||
current = chord
|
||||
return current
|
||||
|
||||
|
||||
def source_note_attack_at(measure: dict[str, Any], offset_ticks: int) -> bool:
|
||||
for start_ticks, note in timed_measure_notes(measure):
|
||||
if start_ticks == offset_ticks and note.get("kind", "note") == "note" and isinstance(note.get("pitches"), list) and note["pitches"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def first_pitched_note_start(measure: dict[str, Any]) -> int | None:
|
||||
cursor = 0
|
||||
for note in measure_notes(measure):
|
||||
if note.get("kind", "note") == "note" and isinstance(note.get("pitches"), list) and note["pitches"]:
|
||||
return cursor
|
||||
cursor += note_duration(note)
|
||||
return None
|
||||
|
||||
|
||||
def leading_pickup_measure_index(measures: list[dict[str, Any]]) -> int | None:
|
||||
if len(measures) < 2 or first_chord_symbol(measures[0]):
|
||||
return None
|
||||
first_pitch = first_pitched_note_start(measures[0])
|
||||
if first_pitch is None or first_pitch <= 0:
|
||||
return None
|
||||
for index, measure in enumerate(measures):
|
||||
if first_chord_symbol(measure):
|
||||
return 0 if index == 1 else None
|
||||
return None
|
||||
|
||||
|
||||
def body_start_measure_index(measures: list[dict[str, Any]]) -> int:
|
||||
pickup = leading_pickup_measure_index(measures)
|
||||
return pickup + 1 if pickup is not None else 0
|
||||
|
||||
|
||||
def rest_note(note_id: str, duration_ticks: int, chord_symbol: str = "") -> dict[str, Any]:
|
||||
note: dict[str, Any] = {
|
||||
"id": note_id,
|
||||
"kind": "rest",
|
||||
"duration_ticks": duration_ticks,
|
||||
"pitches": [copy.deepcopy(REST_PITCH)],
|
||||
"hidden": False,
|
||||
}
|
||||
if chord_symbol:
|
||||
note["chord_symbol"] = chord_symbol
|
||||
return note
|
||||
|
||||
|
||||
def renderable_rest_notes(note_id_prefix: str, duration_ticks: int, sequence: int, chord_symbol: str = "") -> tuple[list[dict[str, Any]], int]:
|
||||
notes: list[dict[str, Any]] = []
|
||||
for index, duration in enumerate(renderable_duration_parts(duration_ticks)):
|
||||
sequence += 1
|
||||
notes.append(rest_note(f"{note_id_prefix}-{sequence}", duration, chord_symbol if index == 0 else ""))
|
||||
return notes, sequence
|
||||
|
||||
|
||||
def apply_source_chord_markers(notes: list[dict[str, Any]], measure: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
markers = [(start, chord) for start, _, chord in chord_markers(measure)]
|
||||
if not markers:
|
||||
result = [copy.deepcopy(note) for note in notes]
|
||||
for note in result:
|
||||
note.pop("chord_symbol", None)
|
||||
return result
|
||||
|
||||
unique_markers: list[tuple[int, str]] = []
|
||||
for start, chord in markers:
|
||||
if unique_markers and unique_markers[-1][0] == start:
|
||||
unique_markers[-1] = (start, chord)
|
||||
else:
|
||||
unique_markers.append((start, chord))
|
||||
|
||||
marker_by_start = dict(unique_markers)
|
||||
marker_starts = [start for start, _ in unique_markers]
|
||||
result: list[dict[str, Any]] = []
|
||||
cursor = 0
|
||||
split_sequence = 0
|
||||
for source_note in notes:
|
||||
note = copy.deepcopy(source_note)
|
||||
note.pop("chord_symbol", None)
|
||||
duration = note_duration(note)
|
||||
end = cursor + duration
|
||||
split_points = [cursor] + [start for start in marker_starts if cursor < start < end] + [end]
|
||||
for index in range(len(split_points) - 1):
|
||||
start = split_points[index]
|
||||
stop = split_points[index + 1]
|
||||
part_duration = stop - start
|
||||
if part_duration <= 0:
|
||||
continue
|
||||
part = copy.deepcopy(note)
|
||||
part["duration_ticks"] = part_duration
|
||||
if len(split_points) > 2:
|
||||
split_sequence += 1
|
||||
part["id"] = f"{note.get('id', 'note')}-split-{split_sequence}"
|
||||
if part.get("kind", "note") == "note":
|
||||
part["tie_to_next"] = index + 2 < len(split_points)
|
||||
part["tie_from_previous"] = index > 0
|
||||
if start in marker_by_start:
|
||||
part["chord_symbol"] = marker_by_start[start]
|
||||
result.append(part)
|
||||
cursor = end
|
||||
return result
|
||||
|
||||
|
||||
def slash_note(note_id: str, duration_ticks: int, chord_symbol: str = "") -> dict[str, Any]:
|
||||
note: dict[str, Any] = {
|
||||
"id": note_id,
|
||||
"kind": "note",
|
||||
"duration_ticks": duration_ticks,
|
||||
"pitches": [copy.deepcopy(REST_PITCH)],
|
||||
"notehead": "slash",
|
||||
"hidden": False,
|
||||
}
|
||||
if chord_symbol:
|
||||
note["chord_symbol"] = chord_symbol
|
||||
return note
|
||||
|
||||
|
||||
def sanitize_json(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: sanitize_json(item) for key, item in value.items() if key not in FORBIDDEN_OUTPUT_FIELDS}
|
||||
if isinstance(value, list):
|
||||
return [sanitize_json(item) for item in value]
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def sanitized_note(note: dict[str, Any], *, strip_lyrics: bool) -> dict[str, Any]:
|
||||
output = sanitize_json(note)
|
||||
if strip_lyrics:
|
||||
output.pop("lyric", None)
|
||||
output.setdefault("hidden", False)
|
||||
return output
|
||||
|
||||
|
||||
def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool) -> dict[str, Any]:
|
||||
voice = first_voice(measure)
|
||||
tuplets = sanitize_json(voice.get("tuplets", []))
|
||||
notes = [sanitized_note(note, strip_lyrics=strip_lyrics) for note in measure_notes(measure)]
|
||||
return {
|
||||
"id": str(voice.get("id", "voice-source")),
|
||||
"tuplets": tuplets if isinstance(tuplets, list) else [],
|
||||
"notes": notes or [rest_note("arr-rest-empty", measure_capacity_ticks(str(measure.get("time_signature", "4/4"))))],
|
||||
}
|
||||
|
||||
|
||||
def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str, Any]], *, section_label: str = "", tuplets: list[Any] | None = None) -> dict[str, Any]:
|
||||
measure: dict[str, Any] = {
|
||||
"id": measure_id,
|
||||
"time_signature": str(source.get("time_signature", "4/4")),
|
||||
"key_signature": str(source.get("key_signature", "C")),
|
||||
"voices": [{
|
||||
"id": f"voice-{measure_id}",
|
||||
"tuplets": tuplets or [],
|
||||
"notes": notes,
|
||||
}],
|
||||
}
|
||||
if section_label:
|
||||
measure["section_label"] = section_label
|
||||
return measure
|
||||
|
||||
|
||||
def make_arranged_document(source_document: dict[str, Any], staff: dict[str, Any], *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
metadata = sanitize_json(source_document.get("metadata", {}))
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
output: dict[str, Any] = {
|
||||
"version": int(source_document.get("version", 1)) if isinstance(source_document.get("version", 1), int) else 1,
|
||||
"metadata": metadata,
|
||||
"staves": [{
|
||||
"id": staff_id,
|
||||
"name": staff_name,
|
||||
"clef": str(staff.get("clef", "treble")),
|
||||
"role": role,
|
||||
"arranged": True,
|
||||
"arrangement_part_type": arrangement_part_type,
|
||||
"measures": measures,
|
||||
}],
|
||||
}
|
||||
return sanitize_json(output)
|
||||
|
||||
|
||||
def count_json_files(root: Path) -> int:
|
||||
return sum(1 for path in root.rglob("*.json") if path.is_file())
|
||||
|
||||
|
||||
def validate_generated_tree(root: Path) -> None:
|
||||
for path in root.rglob("*.json"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for field in FORBIDDEN_OUTPUT_FIELDS:
|
||||
if f'"{field}"' in text:
|
||||
raise ValueError(f"{path} contains forbidden generated field {field}")
|
||||
json.loads(text)
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from generator.score import load_json, measure_notes
|
||||
from generator.transposition import TARGET_KEYS
|
||||
|
||||
|
||||
def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]:
|
||||
staves = document.get("staves")
|
||||
if not isinstance(staves, list) or not staves or not isinstance(staves[0], dict):
|
||||
raise ValueError("generated score document must contain an arranged staff")
|
||||
return staves[0]
|
||||
|
||||
|
||||
def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_number: int) -> dict[str, Any]:
|
||||
notes = measure_notes(measure)
|
||||
summary: dict[str, Any] = {
|
||||
"measure": measure_number,
|
||||
"section_label": str(measure.get("section_label", "")),
|
||||
"chord_symbols": [str(note.get("chord_symbol", "")) for note in notes if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol")],
|
||||
"slash_count": sum(1 for note in notes if note.get("kind", "note") == "note" and note.get("notehead") == "slash"),
|
||||
}
|
||||
if staff.get("arrangement_part_type") == "vocal":
|
||||
summary["vocal_skeleton"] = [
|
||||
{
|
||||
"kind": str(note.get("kind", "note")),
|
||||
"duration_ticks": int(note.get("duration_ticks", 0)),
|
||||
"display_ticks": int(note["display_ticks"]) if isinstance(note.get("display_ticks"), int) else None,
|
||||
"has_lyric": isinstance(note.get("lyric"), str) and bool(note.get("lyric")),
|
||||
}
|
||||
for note in notes
|
||||
]
|
||||
return summary
|
||||
|
||||
|
||||
def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata = document.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
staff = _arranged_staff(document)
|
||||
measures = staff.get("measures")
|
||||
if not isinstance(measures, list):
|
||||
measures = []
|
||||
measure_summaries = [
|
||||
_measure_summary(staff, measure, index + 1)
|
||||
for index, measure in enumerate(measures)
|
||||
if isinstance(measure, dict)
|
||||
]
|
||||
return {
|
||||
"title": str(metadata.get("title", "")),
|
||||
"composer": str(metadata.get("composer", "")),
|
||||
"key_signature": str(metadata.get("key_signature", "")),
|
||||
"part_type": str(staff.get("arrangement_part_type", "")),
|
||||
"measure_count": len(measure_summaries),
|
||||
"section_labels": [
|
||||
{"measure": item["measure"], "label": item["section_label"]}
|
||||
for item in measure_summaries
|
||||
if item["section_label"]
|
||||
],
|
||||
"measures": measure_summaries,
|
||||
}
|
||||
|
||||
|
||||
def performance_tree_summary(root: Path) -> dict[str, Any]:
|
||||
return {
|
||||
path.name: performance_document_summary(load_json(path))
|
||||
for path in sorted(root.glob("*.json"))
|
||||
}
|
||||
|
||||
|
||||
def validate_expected_performance_summary(expected_path: Path, generated_songs_dir: Path) -> None:
|
||||
if not expected_path.exists():
|
||||
raise ValueError(f"missing generator compatibility summary: {expected_path}")
|
||||
with expected_path.open("r", encoding="utf-8") as stream:
|
||||
expected = json.load(stream)
|
||||
actual = performance_tree_summary(generated_songs_dir)
|
||||
if actual == expected:
|
||||
return
|
||||
|
||||
expected_names = set(expected.keys()) if isinstance(expected, dict) else set()
|
||||
actual_names = set(actual.keys())
|
||||
missing = sorted(expected_names - actual_names)
|
||||
extra = sorted(actual_names - expected_names)
|
||||
if missing or extra:
|
||||
raise ValueError(f"performance generator drift: missing={missing}, extra={extra}")
|
||||
|
||||
for name in sorted(actual_names):
|
||||
if actual[name] != expected[name]:
|
||||
raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally")
|
||||
raise ValueError("performance generator drift")
|
||||
|
||||
|
||||
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()):
|
||||
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}")
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeyProfile:
|
||||
name: str
|
||||
root: int
|
||||
degrees: tuple[str, str, str, str, str, str, str]
|
||||
prefer_flats: bool
|
||||
|
||||
|
||||
KEY_PROFILES = (
|
||||
KeyProfile("C", 0, ("C", "D", "E", "F", "G", "A", "B"), False),
|
||||
KeyProfile("Db", 1, ("Db", "Eb", "F", "Gb", "Ab", "Bb", "C"), True),
|
||||
KeyProfile("D", 2, ("D", "E", "F#", "G", "A", "B", "C#"), False),
|
||||
KeyProfile("Eb", 3, ("Eb", "F", "G", "Ab", "Bb", "C", "D"), True),
|
||||
KeyProfile("E", 4, ("E", "F#", "G#", "A", "B", "C#", "D#"), False),
|
||||
KeyProfile("F", 5, ("F", "G", "A", "Bb", "C", "D", "E"), True),
|
||||
KeyProfile("Gb", 6, ("Gb", "Ab", "Bb", "Cb", "Db", "Eb", "F"), True),
|
||||
KeyProfile("G", 7, ("G", "A", "B", "C", "D", "E", "F#"), False),
|
||||
KeyProfile("Ab", 8, ("Ab", "Bb", "C", "Db", "Eb", "F", "G"), True),
|
||||
KeyProfile("A", 9, ("A", "B", "C#", "D", "E", "F#", "G#"), False),
|
||||
KeyProfile("Bb", 10, ("Bb", "C", "D", "Eb", "F", "G", "A"), True),
|
||||
KeyProfile("B", 11, ("B", "C#", "D#", "E", "F#", "G#", "A#"), False),
|
||||
)
|
||||
TARGET_KEYS = tuple(profile.name for profile in KEY_PROFILES)
|
||||
NATURAL_PITCH_CLASSES = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
|
||||
LETTER_DEGREES = {"c": 0, "d": 1, "e": 2, "f": 3, "g": 4, "a": 5, "b": 6}
|
||||
|
||||
|
||||
def mod12(value: int) -> int:
|
||||
return value % 12
|
||||
|
||||
|
||||
def find_key(name: str) -> KeyProfile:
|
||||
normalized = name.lower()
|
||||
aliases = {"c#": "db", "d#": "eb", "f#": "gb", "g#": "ab", "a#": "bb"}
|
||||
normalized = aliases.get(normalized, normalized)
|
||||
for profile in KEY_PROFILES:
|
||||
if profile.name.lower() == normalized:
|
||||
return profile
|
||||
raise ValueError(f"unsupported key signature: {name}")
|
||||
|
||||
|
||||
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
||||
interval = mod12(target.root - source.root)
|
||||
return interval - 12 if interval > 6 else interval
|
||||
|
||||
|
||||
def split_spelling(value: str) -> tuple[str, str]:
|
||||
if not value:
|
||||
return "C", ""
|
||||
return value[0], value[1:]
|
||||
|
||||
|
||||
def accidental_offset(accidental: str) -> int:
|
||||
value = accidental.lower()
|
||||
if value in {"#", "sharp"}:
|
||||
return 1
|
||||
if value in {"b", "flat"}:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
def spelling_pitch_class(spelling: str) -> int:
|
||||
letter, accidental = split_spelling(spelling)
|
||||
return mod12(NATURAL_PITCH_CLASSES.get(letter.lower(), 0) + accidental_offset(accidental))
|
||||
|
||||
|
||||
def pitch_class(pitch: dict[str, Any]) -> int:
|
||||
letter = str(pitch.get("letter", "c")).lower()
|
||||
accidental = str(pitch.get("accidental", "n"))
|
||||
return mod12(NATURAL_PITCH_CLASSES.get(letter, 0) + accidental_offset(accidental))
|
||||
|
||||
|
||||
def pitch_midi(pitch: dict[str, Any]) -> int:
|
||||
octave = int(pitch.get("octave", 4))
|
||||
return octave * 12 + pitch_class(pitch)
|
||||
|
||||
|
||||
def octave_for(midi: int, pitch_class_value: int) -> int:
|
||||
octave = (midi - pitch_class_value) // 12
|
||||
while octave * 12 + pitch_class_value > midi:
|
||||
octave -= 1
|
||||
while (octave + 1) * 12 + pitch_class_value <= midi:
|
||||
octave += 1
|
||||
return octave
|
||||
|
||||
|
||||
def matching_source_degree_for_pitch(pitch: dict[str, Any], source: KeyProfile) -> int | None:
|
||||
actual = pitch_class(pitch)
|
||||
source_letter_degree = LETTER_DEGREES.get(str(pitch.get("letter", "c")).lower(), 0)
|
||||
for index, spelling in enumerate(source.degrees):
|
||||
letter, _ = split_spelling(spelling)
|
||||
if LETTER_DEGREES.get(letter.lower(), 0) == source_letter_degree and spelling_pitch_class(spelling) == actual:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def chromatic_spelling(pitch_class_value: int, prefer_flats: bool) -> str:
|
||||
flats = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")
|
||||
sharps = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
|
||||
return flats[pitch_class_value] if prefer_flats else sharps[pitch_class_value]
|
||||
|
||||
|
||||
def transpose_pitch(pitch: dict[str, Any], source: KeyProfile, target: KeyProfile, interval: int) -> dict[str, Any]:
|
||||
transposed_midi = pitch_midi(pitch) + interval
|
||||
degree = matching_source_degree_for_pitch(pitch, source)
|
||||
spelling = target.degrees[degree] if degree is not None else chromatic_spelling(mod12(transposed_midi), target.prefer_flats)
|
||||
letter, accidental = split_spelling(spelling)
|
||||
pc = spelling_pitch_class(spelling)
|
||||
return {
|
||||
"letter": letter.lower(),
|
||||
"accidental": accidental or "n",
|
||||
"octave": octave_for(transposed_midi, pc),
|
||||
}
|
||||
|
||||
|
||||
def parse_chord_root(value: str, offset: int) -> tuple[int, str] | None:
|
||||
if offset >= len(value) or value[offset].upper() not in "ABCDEFG":
|
||||
return None
|
||||
length = 1
|
||||
if offset + length < len(value) and value[offset + length] in {"#", "b"}:
|
||||
length += 1
|
||||
return length, value[offset:offset + length]
|
||||
|
||||
|
||||
def matching_source_degree_for_root(root: str, source: KeyProfile) -> int | None:
|
||||
root_letter, _ = split_spelling(root)
|
||||
actual = spelling_pitch_class(root)
|
||||
for index, spelling in enumerate(source.degrees):
|
||||
letter, _ = split_spelling(spelling)
|
||||
if LETTER_DEGREES.get(letter.lower(), 0) == LETTER_DEGREES.get(root_letter.lower(), 0) and spelling_pitch_class(spelling) == actual:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def transpose_root(root: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
|
||||
degree = matching_source_degree_for_root(root, source)
|
||||
if degree is not None:
|
||||
return target.degrees[degree]
|
||||
return chromatic_spelling(mod12(spelling_pitch_class(root) + interval), target.prefer_flats)
|
||||
|
||||
|
||||
def transpose_chord_symbol(symbol: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
|
||||
root = parse_chord_root(symbol, 0)
|
||||
if root is None:
|
||||
return symbol
|
||||
root_length, root_text = root
|
||||
result = transpose_root(root_text, source, target, interval) + symbol[root_length:]
|
||||
slash = result.find("/")
|
||||
if slash < 0:
|
||||
return result
|
||||
bass = parse_chord_root(result, slash + 1)
|
||||
if bass is None:
|
||||
return result
|
||||
bass_length, bass_text = bass
|
||||
return result[:slash + 1] + transpose_root(bass_text, source, target, interval) + result[slash + 1 + bass_length:]
|
||||
|
||||
|
||||
def transpose_document(document: dict[str, Any], target_key_name: str) -> dict[str, Any]:
|
||||
output = copy.deepcopy(document)
|
||||
metadata = output.setdefault("metadata", {})
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError("score metadata must be an object")
|
||||
source_key = find_key(str(metadata.get("key_signature", "C") or "C"))
|
||||
target_key = find_key(target_key_name)
|
||||
interval = signed_interval(source_key, target_key)
|
||||
metadata["key_signature"] = target_key.name
|
||||
for staff in output.get("staves", []):
|
||||
if not isinstance(staff, dict):
|
||||
continue
|
||||
for measure in staff.get("measures", []):
|
||||
if not isinstance(measure, dict):
|
||||
continue
|
||||
measure["key_signature"] = target_key.name
|
||||
for voice in measure.get("voices", []):
|
||||
if not isinstance(voice, dict):
|
||||
continue
|
||||
for note in voice.get("notes", []):
|
||||
if not isinstance(note, dict):
|
||||
continue
|
||||
chord = note.get("chord_symbol")
|
||||
if isinstance(chord, str) and chord:
|
||||
note["chord_symbol"] = transpose_chord_symbol(chord, source_key, target_key, interval)
|
||||
pitches = note.get("pitches")
|
||||
if isinstance(pitches, list):
|
||||
note["pitches"] = [transpose_pitch(pitch, source_key, target_key, interval) if isinstance(pitch, dict) else pitch for pitch in pitches]
|
||||
return output
|
||||
|
||||
Reference in New Issue
Block a user