Re-engraving is a rescue path for the handful of systems a scan cannot deliver, so the window is an editing surface rather than an automation project. Three full-width rows - the scanned system, the render, the form - because a system is wide and short and the job is comparing one against the other bar by bar. The render is shown scaled to the scan's staff height, which is what export does anyway, so it previews the real thing. A form rather than a text box. Key and time are slice-level, clef, notes and lyrics per voice: every staff in a system carries the same key signature, and Kaipaava proves it across five-staff and two-staff systems alike. Notes and lyrics stay raw LilyPond, so slurs, dynamics, tuplets and the laissezVibrer/repeatTie idiom for ties crossing into the next slice all work untouched. Notes are entered in \relative mode, referenced to the middle of each clef's staff, so a part needs no octave marks at all in the common case. The time signature is used for spacing and bar checks but not printed: the printed score repeats the key at every system and the time only at the first, so a re-engraved middle slice showing one would stand out. Seeded from what can be known reliably. Voice count comes from counting staves in the slice; key, time and clefs are inherited from the song, because the slices being re-engraved are the illegible ones and reading a key signature off them is exactly the measurement that fails. After the first replacement in a song only the notes need typing. Staff counting needed two corrections against the corpus: compare gaps against line spacing rather than staff height, since adjacent staves can sit closer together than one staff is tall; and require five lines in a group, since Engel's 'uh______' lyric extenders are long horizontal runs too and each counted as a staff. Kaipaava now reads 2,2,2,2,5 on page 1, Ketun 6, Engel 4. Also in this change: - Title is required for export, every other metadata field optional, enforced in bundle.write so the CLI and the editor both get it. Tempo added; noteman already has a free-form column for it. - The panel is a splitter rather than a fixed width, sections collapse under bold grey disclosure headers, and it scrolls. - A re-engraved slice is washed amber with an ENGRAVED badge, and markers get badges too. Thin coloured text was invisible against a scan. Closes #31 Closes #32 Closes #33 Closes #34
185 lines
6.1 KiB
Python
185 lines
6.1 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 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",
|
|
}
|
|
|
|
_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
|
|
|
|
staves = []
|
|
for voice in replacement.voices:
|
|
hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n"
|
|
body = voice.notes.strip() or "s1"
|
|
reference = RELATIVE_REFERENCE.get(voice.clef, "c'")
|
|
staff = (
|
|
" \\new Staff {\n"
|
|
f"{hide}"
|
|
f" \\clef {voice.clef}\n"
|
|
f" \\key {key} \\major\n"
|
|
f" \\time {time}\n"
|
|
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")
|