Files
noteman-slicer/tests/test_markers.py
T
Esa Kataja b8d93cee47 Specify the Score Bundle Format, and make tempo an integer
The bundle is documented as a standalone format rather than as a note
between two programs: producers and consumers are generic, the marker
vocabulary is defined musically rather than by what a viewer does with
it, and image properties are stated as guarantees with the reasoning
where it is not obvious. Anything that reads scores can implement it
without knowing this tool exists.

Taking that view changed the substance in three places. Marker types now
carry their musical meaning rather than a UI mapping. The rule against
re-importing became a statement about identity - indices mean something
only within one bundle, so two bundles of a piece are independent
documents. And forward-compatibility rules were added, which a protocol
needs and a handover note did not: ignore unknown fields and marker
types, refuse an unknown version.

Tempo is now an integer, beats per minute, exported as a JSON number and
omitted when blank; the editor accepts digits only. A figure can drive a
metronome or a click track where a verbal marking cannot, and readers do
not agree on what Andante means. This needs the consumer's column
changed from free-form text, which the format document flags.

Every figure in the document comes from a real export.
2026-07-29 09:22:43 +03:00

124 lines
4.3 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,
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 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())