Files
Esa Kataja 8f670cf7db Engrave window: bar numbers, and two silent LilyPond faults
A system can now be given the measure it starts at, in a First bar field
beside key and time. Per slice, since it is the one thing about a
replacement that cannot be inherited from the song. Bar numbering is a
Score property, so it is set once on the first staff, and made visible
only at a line beginning — that vector is fussy: #(#f #t #t) also prints
a number mid-system and #(#f #t #f) prints the second bar's rather than
the first's. It travels in the bundle's engraving object as `bar`.

Two things LilyPond 2.24 was quietly refusing to draw:

`\bar ":|"` and the other old repeat names produce nothing at all — no
error, no warning, exit status 0, just a missing repeat that you find on
the tablet. Every book and forum answer still uses them, so translate
them to the modern spellings.

`\clef treble_8` unquoted is not an octavated clef either. It parses as a
plain treble plus a stray "8" markup that lands under the first note, and
the staff then reads an octave off — a tenor line engraved at soprano
pitch. Quote it.

The source pane, which is where either of those would have been visible,
is now a collapsed section at the bottom rather than a permanent slab. It
uses the panel's own disclosure helper, lifted out of Editor so both can
call it.
2026-07-29 13:44:42 +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) -> 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 replacement.bar:
number = (
f" \\set Score.currentBarNumber = #{int(replacement.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")