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