A bundle was a one-way trip. The slice images are output and the cuts that produced them lived only in the producer's own project file, so a bundle someone handed you meant cutting the score again from scratch. The manifest now carries the geometry, in a `source` block: per page the cut polylines, skew, levels and content rectangle, all in normalised coordinates so they survive any render resolution, and per slice the page and slot it came from. Discards are stated by omission — a slot no slice claims was discarded — since shipping a discarded slice's image would defeat discarding it. `noteman-slicer open song.zip` unpacks the archived PDF, rebuilds the project from that geometry, restores markers, engravings and the title block, and opens the editor. Jump destinations go back from an array index to the (page, slot) the editor works in. The images in the zip are discarded: the PDF is what the pipeline renders from. Re-exporting a reopened bundle reproduces its manifest exactly. It refuses to overwrite a PDF or project file already sitting there, because the obvious place to unpack is where someone's unfinished cuts live. Separately, every slice can now carry the measure it starts at, not just a re-engraved one — a scanned system is numbered in the score the same way, and noteman wants to answer "take it from bar 33" about either. It moves off the replacement onto the page, alongside markers and discards, and out of the bundle's engraving object onto the slice.
223 lines
7.6 KiB
Python
223 lines
7.6 KiB
Python
"""Re-engrave a slice with LilyPond, when the scan is past saving.
|
|
|
|
Optional. LilyPond is a system package rather than a wheel, so its absence
|
|
hides the feature and nothing else changes.
|
|
|
|
The tool renders a tight-cropped PNG and hands it to the ordinary render
|
|
pipeline at the trim stage, so a replaced slice flows through staff-height
|
|
normalisation, song scale, pad and encode untouched — which is what makes it
|
|
sit at the same note size as the scanned systems around it without any manual
|
|
scaling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
RENDER_DPI = 600
|
|
TIMEOUT_S = 120
|
|
|
|
# Read off the page by counting accidentals, which is how you actually read a
|
|
# key signature. Both names are shown because either identifies the same
|
|
# signature; the major spelling is what LilyPond gets, and it prints the same
|
|
# accidentals as the relative minor would.
|
|
KEY_SIGNATURES: tuple[tuple[str, str], ...] = (
|
|
("7♭ — C♭ major / A♭ minor", "ces"),
|
|
("6♭ — G♭ major / E♭ minor", "ges"),
|
|
("5♭ — D♭ major / B♭ minor", "des"),
|
|
("4♭ — A♭ major / F minor", "aes"),
|
|
("3♭ — E♭ major / C minor", "ees"),
|
|
("2♭ — B♭ major / G minor", "bes"),
|
|
("1♭ — F major / D minor", "f"),
|
|
("— C major / A minor", "c"),
|
|
("1♯ — G major / E minor", "g"),
|
|
("2♯ — D major / B minor", "d"),
|
|
("3♯ — A major / F♯ minor", "a"),
|
|
("4♯ — E major / C♯ minor", "e"),
|
|
("5♯ — B major / G♯ minor", "b"),
|
|
("6♯ — F♯ major / D♯ minor", "fis"),
|
|
("7♯ — C♯ major / A♯ minor", "cis"),
|
|
)
|
|
|
|
# Kaipaava's five-staff system uses all but the alto.
|
|
CLEFS: tuple[tuple[str, str], ...] = (
|
|
("Treble", "treble"),
|
|
("Treble 8 (tenor)", "treble_8"),
|
|
("Bass", "bass"),
|
|
("Alto", "alto"),
|
|
)
|
|
|
|
# Notes are entered in \relative mode, so only intervals larger than a fourth
|
|
# need an octave mark. The reference pitch is the middle of each clef's staff,
|
|
# so the first note of a part usually needs no mark either.
|
|
RELATIVE_REFERENCE = {
|
|
"treble": "c''",
|
|
"treble_8": "c'",
|
|
"alto": "c'",
|
|
"bass": "c",
|
|
}
|
|
|
|
# LilyPond renamed the repeat barlines and silently draws *nothing* for the old
|
|
# names — no error, no warning, just a missing repeat that you find on the
|
|
# tablet. Every book, every forum answer and every score anyone has typed before
|
|
# uses the old ones, so translate them.
|
|
_BAR_ALIASES = {
|
|
"|:": ".|:",
|
|
":|": ":|.",
|
|
":|:": ":|.|:",
|
|
"||:": ".|:",
|
|
":||": ":|.",
|
|
":||:": ":|.|:",
|
|
}
|
|
_BAR = re.compile(r'(\\bar\s*")([^"]*)(")')
|
|
|
|
|
|
def _modernise_bars(notes: str) -> str:
|
|
return _BAR.sub(lambda m: m[1] + _BAR_ALIASES.get(m[2], m[2]) + m[3], notes)
|
|
|
|
|
|
_PREAMBLE = """\\version "2.24.0"
|
|
\\paper {
|
|
indent = 0\\mm
|
|
ragged-right = ##f
|
|
oddHeaderMarkup = ##f evenHeaderMarkup = ##f
|
|
oddFooterMarkup = ##f evenFooterMarkup = ##f
|
|
print-page-number = ##f
|
|
}
|
|
"""
|
|
|
|
|
|
def generate(replacement, key: str, time: str, bar: int | None = None) -> str:
|
|
"""Build LilyPond source from a slice's structured replacement.
|
|
|
|
The time signature is used for spacing and bar checks but not printed
|
|
unless asked for: the printed score repeats the key at every system and the
|
|
time signature only at the first, so a re-engraved middle slice showing one
|
|
would stand out immediately in the scroll.
|
|
"""
|
|
key = replacement.key or key
|
|
time = replacement.time or time
|
|
|
|
# Bar numbering is a Score property, so it is set once, on the first staff.
|
|
# Visible at the beginning of a line and nowhere else — which in a
|
|
# one-system slice means exactly one number, above the first bar, the way a
|
|
# printed score numbers its systems. The empty bar line is what gives the
|
|
# number a line beginning to attach to.
|
|
number = ""
|
|
if bar:
|
|
number = (
|
|
f" \\set Score.currentBarNumber = #{int(bar)}\n"
|
|
" \\override Score.BarNumber.break-visibility = #'#(#f #f #t)\n"
|
|
' \\bar ""\n'
|
|
)
|
|
|
|
staves = []
|
|
for voice in replacement.voices:
|
|
hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n"
|
|
body = _modernise_bars(voice.notes.strip()) or "s1"
|
|
reference = RELATIVE_REFERENCE.get(voice.clef, "c'")
|
|
staff = (
|
|
" \\new Staff {\n"
|
|
f"{hide}"
|
|
# Quoted, because an octavated name has to be: unquoted,
|
|
# `\clef treble_8` parses as a plain treble clef with a stray "8"
|
|
# markup that lands under the first note, and the staff then reads
|
|
# an octave off.
|
|
f' \\clef "{voice.clef}"\n'
|
|
f" \\key {key} \\major\n"
|
|
f" \\time {time}\n"
|
|
f"{number if not staves else ''}"
|
|
f" \\relative {reference} {{ {body} }}\n"
|
|
" }\n"
|
|
)
|
|
if voice.lyrics.strip():
|
|
staff += f" \\addlyrics {{ {voice.lyrics.strip()} }}\n"
|
|
staves.append(staff)
|
|
|
|
if not staves:
|
|
staves.append(" \\new Staff { s1 }\n")
|
|
|
|
return (
|
|
_PREAMBLE
|
|
+ "\\score {\n \\new ChoirStaff <<\n"
|
|
+ "".join(staves)
|
|
+ " >>\n \\layout { }\n}\n"
|
|
)
|
|
|
|
|
|
class LilypondError(RuntimeError):
|
|
"""LilyPond refused the source. Carries its diagnostics verbatim."""
|
|
|
|
|
|
def available() -> bool:
|
|
return shutil.which("lilypond") is not None
|
|
|
|
|
|
def version() -> str | None:
|
|
if not available():
|
|
return None
|
|
try:
|
|
out = subprocess.run(
|
|
["lilypond", "--version"], capture_output=True, text=True, timeout=20
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
return out.stdout.splitlines()[0] if out.stdout else None
|
|
|
|
|
|
def render(source: str, dpi: int = RENDER_DPI) -> np.ndarray:
|
|
"""Engrave `source` and return it as a grayscale array, cropped to the ink.
|
|
|
|
Raises LilypondError with LilyPond's own message on failure — a syntax
|
|
error has to be readable without leaving the editor.
|
|
"""
|
|
if not available():
|
|
raise LilypondError("LilyPond is not installed")
|
|
|
|
with tempfile.TemporaryDirectory(prefix="noteman-slicer-ly-") as workdir:
|
|
work = Path(workdir)
|
|
(work / "slice.ly").write_text(source, encoding="utf-8")
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"lilypond",
|
|
"-dcrop=#t",
|
|
"-dbackend=cairo",
|
|
"--png",
|
|
f"-dresolution={dpi}",
|
|
"-o",
|
|
"out",
|
|
"slice.ly",
|
|
],
|
|
cwd=work,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=TIMEOUT_S,
|
|
)
|
|
except subprocess.TimeoutExpired as error:
|
|
raise LilypondError(f"LilyPond timed out after {TIMEOUT_S}s") from error
|
|
|
|
# LilyPond still writes a page when it rejects the source, so the exit
|
|
# code has to be checked first — otherwise a broken snippet silently
|
|
# becomes a garbage slice.
|
|
if result.returncode != 0:
|
|
raise LilypondError(result.stderr.strip() or f"exit status {result.returncode}")
|
|
|
|
# -dcrop writes out.cropped.png; the uncropped page is the fallback if
|
|
# a LilyPond build ever stops honouring it.
|
|
for name in ("out.cropped.png", "out.png"):
|
|
image = work / name
|
|
if image.exists():
|
|
gray = cv2.imread(str(image), cv2.IMREAD_GRAYSCALE)
|
|
if gray is not None:
|
|
return gray
|
|
|
|
raise LilypondError(result.stderr.strip() or result.stdout.strip() or "no output")
|