Files
noteman-slicer/tests/test_lilypond.py
T
Esa Kataja 97c8e8a709 Add LilyPond slice replacement with a structured engrave window
Re-engraving is a rescue path for the handful of systems a scan cannot
deliver, so the window is an editing surface rather than an automation
project. Three full-width rows - the scanned system, the render, the
form - because a system is wide and short and the job is comparing one
against the other bar by bar. The render is shown scaled to the scan's
staff height, which is what export does anyway, so it previews the real
thing.

A form rather than a text box. Key and time are slice-level, clef,
notes and lyrics per voice: every staff in a system carries the same key
signature, and Kaipaava proves it across five-staff and two-staff
systems alike. Notes and lyrics stay raw LilyPond, so slurs, dynamics,
tuplets and the laissezVibrer/repeatTie idiom for ties crossing into the
next slice all work untouched.

Notes are entered in \relative mode, referenced to the middle of each
clef's staff, so a part needs no octave marks at all in the common case.

The time signature is used for spacing and bar checks but not printed:
the printed score repeats the key at every system and the time only at
the first, so a re-engraved middle slice showing one would stand out.

Seeded from what can be known reliably. Voice count comes from counting
staves in the slice; key, time and clefs are inherited from the song,
because the slices being re-engraved are the illegible ones and reading
a key signature off them is exactly the measurement that fails. After
the first replacement in a song only the notes need typing.

Staff counting needed two corrections against the corpus: compare gaps
against line spacing rather than staff height, since adjacent staves can
sit closer together than one staff is tall; and require five lines in a
group, since Engel's 'uh______' lyric extenders are long horizontal runs
too and each counted as a staff. Kaipaava now reads 2,2,2,2,5 on page 1,
Ketun 6, Engel 4.

Also in this change:

- Title is required for export, every other metadata field optional,
  enforced in bundle.write so the CLI and the editor both get it. Tempo
  added; noteman already has a free-form column for it.
- The panel is a splitter rather than a fixed width, sections collapse
  under bold grey disclosure headers, and it scrolls.
- A re-engraved slice is washed amber with an ENGRAVED badge, and
  markers get badges too. Thin coloured text was invisible against a
  scan.

Closes #31
Closes #32
Closes #33
Closes #34
2026-07-29 01:29:43 +03:00

161 lines
5.8 KiB
Python

"""Runnable check for LilyPond slice replacement.
Skips cleanly when LilyPond is not installed — that is the point of the
availability gate, so the check has to honour it.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pymupdf
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer import lilypond # 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.detect import staff_count # noqa: E402
from noteman_slicer.project import ( # noqa: E402
Cut,
Project,
Replacement,
Voice,
default_path,
)
from noteman_slicer.render import cut_slice, render_slices, scale_song, slice_mask # noqa: E402
# Notes are relative, so no octave marks except where a leap needs one.
SATB = Replacement(
voices=[
Voice("treble", "c4 d e f | g2 e2", "la la la la la la"),
Voice("bass", "c4 d e f | g2 c2", "la la la la la la"),
]
)
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:
if not lilypond.available():
print("ok (skipped: LilyPond not installed)")
return 0
tmp = Path(__file__).with_name("_tmp")
tmp.mkdir(exist_ok=True)
pdf = tmp / "scan.pdf"
_scan_pdf(pdf)
# A syntax error must come back readable rather than as a stack trace.
try:
lilypond.render("\\score { this is not lilypond }")
except lilypond.LilypondError as error:
assert str(error), "the error must carry LilyPond's own message"
else:
raise AssertionError("bad source should raise")
# The generator: key at slice level, time used but not printed.
source = lilypond.generate(SATB, "aes", "4/4")
assert source.count("\\new Staff") == 2
assert source.count("\\key aes \\major") == 2, "every staff carries the key"
assert "\\omit Staff.TimeSignature" in source, "a middle system prints no time signature"
assert "\\addlyrics" in source
# Relative entry, referenced to the middle of each clef's staff, so notes
# carry no octave marks.
assert "\\relative c'' { c4 d e f | g2 e2 }" in source
assert "\\relative c { c4 d e f | g2 c2 }" in source
printed = lilypond.generate(
Replacement(voices=SATB.voices, print_time=True), "aes", "4/4"
)
assert "\\omit Staff.TimeSignature" not in printed
override = lilypond.generate(Replacement(voices=SATB.voices, key="d"), "aes", "4/4")
assert "\\key d \\major" in override, "a slice-level key must win over the song's"
# Every key signature and clef the form offers must be real LilyPond.
assert len(lilypond.KEY_SIGNATURES) == 15
assert ("4♭ — A♭ major / F minor", "aes") in lilypond.KEY_SIGNATURES
assert [v for _, v in lilypond.CLEFS] == ["treble", "treble_8", "bass", "alto"]
engraved = lilypond.render(source, dpi=200)
assert engraved.ndim == 2 and engraved.dtype == np.uint8
# -dcrop trims to the ink, so the result is far smaller than a page.
assert engraved.shape[0] < 1200, engraved.shape
assert engraved.min() == 0 and engraved.max() == 255
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.replacements) == page.slice_count
kept = project.kept_slices()
(_, first), (_, second) = kept
# Voice count is seeded from the slice: the fixture draws two staves.
preview = cut_slice(gray, slice_mask(project, 0, second, gray.shape))
assert staff_count(preview) == 2, staff_count(preview)
page.replacements[second] = SATB
slices = render_slices(project, source)
assert len(slices) == 2
scanned, replaced = slices
assert scanned.staff and replaced.staff
# The whole point: after normalisation both sit at the same staff height,
# with no manual scaling, even though the sources differ wildly in scale.
factors = [target / s.staff for s, target in ((scanned, 1.0), (replaced, 1.0))]
assert factors # keep the intent readable
out = scale_song(slices)
heights = []
for image, original in zip(out, slices):
k = image.shape[0] / original.gray.shape[0]
heights.append(original.staff * k)
assert abs(heights[0] - heights[1]) < 2.0, f"staff heights should match: {heights}"
# Cut edits keep the replacement aligned with its slice.
index = page.add_cut(Cut.straight(0.97))
assert len(page.replacements) == page.slice_count
assert page.replacements[second] is SATB
page.remove_cut(index)
assert page.replacements[second] is SATB
# Round-trip, including the song-level engraving defaults.
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
saved = project.save()
reloaded = Project.load(saved)
assert (reloaded.key, reloaded.time, reloaded.clefs) == ("aes", "3/4", ["treble", "bass"])
restored = reloaded.pages[0].replacements[second]
assert restored is not None
assert [v.clef for v in restored.voices] == ["treble", "bass"]
assert restored.voices[0].lyrics == "la la la la la la"
assert reloaded.pages[0].replacements[first] is None
source.close()
for f in (pdf, saved, default_path(pdf)):
f.unlink(missing_ok=True)
tmp.rmdir()
print("ok")
return 0
if __name__ == "__main__":
sys.exit(main())