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