Files
noteman-slicer/noteman_slicer/lilypond.py
T
Esa Kataja 61b8ec8301 Make a bundle reopenable, and number slices
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.
2026-07-29 14:30:54 +03:00

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")