A replaced slice shipped as pixels only, so the LilyPond behind it died with the project file — and a project is spent once exported. Correcting one wrong note meant retyping the system. Slices gain an optional `engraving` object: the language, key, time and one entry per voice holding the notes and lyrics verbatim. Structured per staff rather than one blob of source, because that is what both a later edit and a MIDI render want; song-level key and time are resolved per slice so reading one needs no context from its neighbours. Additive and ignorable, so the format stays at version 1.
149 lines
5.6 KiB
Python
149 lines
5.6 KiB
Python
"""Runnable check for markers: model, cut edits, and export resolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pymupdf
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from noteman_slicer import bundle # noqa: E402
|
|
from noteman_slicer.bundle import song_json # noqa: E402
|
|
from noteman_slicer.detect import detect_page # noqa: E402
|
|
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
|
from noteman_slicer.project import ( # noqa: E402
|
|
JUMP_TYPES,
|
|
MARKER_TYPES,
|
|
Cut,
|
|
Marker,
|
|
Project,
|
|
Replacement,
|
|
Voice,
|
|
default_path,
|
|
)
|
|
|
|
W, H = 1200, 1600
|
|
|
|
|
|
def _scan_pdf(path: Path) -> None:
|
|
art = np.full((H, W), 255, np.uint8)
|
|
for top in (300, 800):
|
|
art[top : top + 200, 100:104] = 0
|
|
for staff in (top, top + 140):
|
|
for i in range(5):
|
|
art[staff + i * 15 : staff + i * 15 + 2, 110:1100] = 0
|
|
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
|
|
doc = pymupdf.open()
|
|
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
|
|
doc.save(path)
|
|
|
|
|
|
def main() -> int:
|
|
tmp = Path(__file__).with_name("_tmp")
|
|
tmp.mkdir(exist_ok=True)
|
|
pdf = tmp / "scan.pdf"
|
|
_scan_pdf(pdf)
|
|
|
|
# noteman's enum, verbatim — this is real coupling between two repos.
|
|
assert len(MARKER_TYPES) == 14, MARKER_TYPES
|
|
assert len(JUMP_TYPES) == 6
|
|
assert "generic_jump" in JUMP_TYPES and "segno" not in JUMP_TYPES
|
|
|
|
source = open_source(pdf)
|
|
gray = page_raster(source, 0)
|
|
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
|
|
page = project.pages[0]
|
|
assert len(page.markers) == page.slice_count
|
|
|
|
kept = project.kept_slices()
|
|
assert len(kept) == 2, kept
|
|
(_, first), (_, second) = kept
|
|
|
|
page.markers[first].append(Marker("rehearsal_letter", label="A"))
|
|
page.markers[second].append(Marker("coda"))
|
|
page.markers[first].append(Marker("to_coda", destination=(0, second)))
|
|
|
|
# Cut edits keep markers aligned with their slices.
|
|
before = list(page.markers[first])
|
|
index = page.add_cut(Cut.straight(0.95))
|
|
assert len(page.markers) == page.slice_count
|
|
assert page.markers[first] == before, "markers must not move when a later slice splits"
|
|
page.remove_cut(index)
|
|
assert len(page.markers) == page.slice_count
|
|
|
|
# Export resolves (page, slot) to the slice's index in the bundle.
|
|
names = [f"{i + 1:03}.webp" for i in range(len(project.kept_slices()))]
|
|
project.metadata.update({"title": "Test song", "tempo": "92", "composer": ""})
|
|
payload = song_json(project, names)
|
|
# Tempo is a number, not a string; empty fields are absent, not "".
|
|
assert payload["tempo"] == 92, payload["tempo"]
|
|
assert "composer" not in payload
|
|
|
|
project.metadata["tempo"] = "Andante"
|
|
assert "tempo" not in song_json(project, names), "words are not a tempo"
|
|
project.metadata["tempo"] = "92"
|
|
slices = payload["slices"]
|
|
assert [s["file"] for s in slices] == names
|
|
assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"}
|
|
assert slices[1]["markers"][0] == {"type": "coda"}
|
|
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1}
|
|
|
|
# A re-engraved slice carries its notation into the bundle; a scanned one
|
|
# carries none. This is what makes a later edit or a MIDI render possible
|
|
# from the bundle alone.
|
|
project.key, project.time = "aes", "3/4"
|
|
page.replacements[second] = Replacement(
|
|
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")]
|
|
)
|
|
engraved = song_json(project, names)["slices"]
|
|
assert "engraving" not in engraved[0], "a scanned slice has no notation"
|
|
ly = engraved[1]["engraving"]
|
|
assert ly["lang"] == "lilypond"
|
|
# Song defaults are resolved per slice: reading one slice needs no context.
|
|
assert (ly["key"], ly["time"], ly["print_time"]) == ("aes", "3/4", False)
|
|
assert ly["voices"][0] == {"clef": "treble", "notes": "c4 d e f", "lyrics": "la la la la"}
|
|
assert "lyrics" not in ly["voices"][1], "an empty field is absent, not empty"
|
|
assert ly["voices"][1]["notes"] == "c4 d e f"
|
|
|
|
override = Replacement(voices=page.replacements[second].voices, key="d", print_time=True)
|
|
page.replacements[second] = override
|
|
ly = song_json(project, names)["slices"][1]["engraving"]
|
|
assert (ly["key"], ly["time"], ly["print_time"]) == ("d", "3/4", True)
|
|
page.replacements[second] = None
|
|
|
|
# A jump whose target got discarded is dropped, not exported dangling.
|
|
project.pages[0].discards[second] = True
|
|
dropped = song_json(project, ["001.webp"])
|
|
assert all(m["type"] != "to_coda" for m in dropped["slices"][0].get("markers", []))
|
|
project.pages[0].discards[second] = False
|
|
|
|
# Round-trip through the project file.
|
|
saved = project.save()
|
|
reloaded = Project.load(saved)
|
|
assert reloaded.pages[0].markers[first][0].label == "A"
|
|
assert reloaded.pages[0].markers[first][1].destination == (0, second)
|
|
assert reloaded.pages[0].markers[second][0].type == "coda"
|
|
|
|
# And through a real bundle.
|
|
reloaded.metadata["title"] = "Test song"
|
|
out = bundle.write(reloaded, source, tmp / "song.zip")
|
|
with zipfile.ZipFile(out) as zf:
|
|
meta = json.loads(zf.read("song.json"))
|
|
assert meta["slices"][0]["markers"][1]["destination"] == 1, meta["slices"]
|
|
|
|
source.close()
|
|
for f in (pdf, out, saved, default_path(pdf)):
|
|
f.unlink(missing_ok=True)
|
|
tmp.rmdir()
|
|
print("ok")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|