Add LilyPond slice replacement with a structured engrave window
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
This commit is contained in:
@@ -29,6 +29,9 @@ METADATA_FIELDS = (
|
|||||||
"arranger",
|
"arranger",
|
||||||
"lyricist",
|
"lyricist",
|
||||||
"translator",
|
"translator",
|
||||||
|
# Free-form, matching noteman's own column: scores notate tempo as a mix of
|
||||||
|
# BPM ("♩=72"), Italian ("Andante") and prose.
|
||||||
|
"tempo",
|
||||||
"voices",
|
"voices",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,8 +75,18 @@ def song_json(project: Project, files: list[str]) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def write(project: Project, source: Source, path: Path) -> Path:
|
def write(project: Project, source: Source, path: Path) -> Path:
|
||||||
"""Render the song and write the bundle. Returns the zip path."""
|
"""Render the song and write the bundle. Returns the zip path.
|
||||||
|
|
||||||
|
A title is required; every other metadata field is optional. noteman's own
|
||||||
|
rule is that a song needs a title and at least one slice, and a bundle that
|
||||||
|
cannot become a song is not worth writing.
|
||||||
|
"""
|
||||||
|
if not project.metadata.get("title", "").strip():
|
||||||
|
raise ValueError("a title is required before a song can be exported")
|
||||||
|
|
||||||
images = render_song(project, source)
|
images = render_song(project, source)
|
||||||
|
if not images:
|
||||||
|
raise ValueError("no slices to export — every slice is discarded")
|
||||||
names = [f"{i + 1:03}.webp" for i in range(len(images))]
|
names = [f"{i + 1:03}.webp" for i in range(len(images))]
|
||||||
|
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
|
|||||||
@@ -88,7 +88,13 @@ def _export(args: argparse.Namespace) -> int:
|
|||||||
print("WARNING: the PDF has changed since these cuts were made")
|
print("WARNING: the PDF has changed since these cuts were made")
|
||||||
|
|
||||||
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
|
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
|
||||||
bundle.write(project, source, out)
|
try:
|
||||||
|
bundle.write(project, source, out)
|
||||||
|
except ValueError as error:
|
||||||
|
print(f"cannot export: {error}")
|
||||||
|
print(" set one with: noteman-slicer edit … (Song → Title)")
|
||||||
|
source.close()
|
||||||
|
return 1
|
||||||
size = out.stat().st_size
|
size = out.stat().st_size
|
||||||
slices = len(project.kept_slices())
|
slices = len(project.kept_slices())
|
||||||
print(f"{out} {slices} slices, {size / 1024:.0f} KB ({size / max(slices, 1) / 1024:.1f} KB/slice)")
|
print(f"{out} {slices} slices, {size / 1024:.0f} KB ({size / max(slices, 1) / 1024:.1f} KB/slice)")
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ _STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width
|
|||||||
_STAFF_MIN_WIDTH = 0.2 # a staff line spans at least this share of the page
|
_STAFF_MIN_WIDTH = 0.2 # a staff line spans at least this share of the page
|
||||||
_CONTENT_MARGIN = 0.01 # slack past the staff ends, for ledger lines and lyrics
|
_CONTENT_MARGIN = 0.01 # slack past the staff ends, for ledger lines and lyrics
|
||||||
_EDGE_PERCENTILE = 15 # tolerate this share of staff lines merged into scan artefacts
|
_EDGE_PERCENTILE = 15 # tolerate this share of staff lines merged into scan artefacts
|
||||||
|
_STAFF_BREAK = 2.5 # a gap this many line-spacings wide separates two staves
|
||||||
|
_STAFF_LINES = 4 # lines a group needs to be a staff rather than an extender (5, minus one for a broken line)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -190,6 +192,46 @@ def content_columns(
|
|||||||
return max(0.0, left - margin) / width, min(float(width), right + margin) / width
|
return max(0.0, left - margin) / width, min(float(width), right + margin) / width
|
||||||
|
|
||||||
|
|
||||||
|
def staff_count(gray: np.ndarray) -> int:
|
||||||
|
"""How many staves are in this slice — i.e. how many voices it holds.
|
||||||
|
|
||||||
|
Kaipaava's first four systems have two staves and its fifth has five, so
|
||||||
|
this cannot be a song-level constant. Counts long horizontal runs and
|
||||||
|
divides by the five lines a staff has; the same signal that finds the music
|
||||||
|
area, so it degrades the same way and no worse.
|
||||||
|
"""
|
||||||
|
height, width = gray.shape
|
||||||
|
binary = (gray < _INK).astype(np.uint8)
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(3, int(width * _STAFF_KERNEL)), 1))
|
||||||
|
lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
|
||||||
|
|
||||||
|
count, _, stats, _ = cv2.connectedComponentsWithStats(lines, 8)
|
||||||
|
rows = sorted(
|
||||||
|
stats[i, cv2.CC_STAT_TOP]
|
||||||
|
for i in range(1, count)
|
||||||
|
if stats[i, cv2.CC_STAT_WIDTH] > width * _STAFF_MIN_WIDTH
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Compare against the *line* spacing, not the staff height: adjacent staves
|
||||||
|
# can sit closer together than one staff is tall, so a staff-height
|
||||||
|
# threshold merges them into one.
|
||||||
|
line_spacing = (staff_height(gray, 0, height) or height * 0.05) / 4
|
||||||
|
|
||||||
|
groups: list[list[int]] = [[rows[0]]]
|
||||||
|
for row in rows[1:]:
|
||||||
|
if row - groups[-1][-1] > line_spacing * _STAFF_BREAK:
|
||||||
|
groups.append([])
|
||||||
|
groups[-1].append(row)
|
||||||
|
|
||||||
|
# A staff is five evenly spaced lines. Lone long runs are lyric extenders —
|
||||||
|
# Engel's "uh______" — and hairpins, which are just as horizontal as a
|
||||||
|
# staff line and would otherwise each count as a staff.
|
||||||
|
staves = sum(1 for group in groups if len(group) >= _STAFF_LINES)
|
||||||
|
return max(1, staves)
|
||||||
|
|
||||||
|
|
||||||
def ink_runs(gray: np.ndarray) -> list[tuple[int, int]]:
|
def ink_runs(gray: np.ndarray) -> list[tuple[int, int]]:
|
||||||
"""Rows containing ink, despeckled — specks are the known failure mode."""
|
"""Rows containing ink, despeckled — specks are the known failure mode."""
|
||||||
profile = row_darkness(cv2.medianBlur(gray, 3))
|
profile = row_darkness(cv2.medianBlur(gray, 3))
|
||||||
|
|||||||
+197
-32
@@ -33,7 +33,6 @@ from PySide6.QtWidgets import (
|
|||||||
QGraphicsScene,
|
QGraphicsScene,
|
||||||
QGraphicsView,
|
QGraphicsView,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QGroupBox,
|
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
@@ -41,12 +40,16 @@ from PySide6.QtWidgets import (
|
|||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
QSlider,
|
QSlider,
|
||||||
|
QSplitter,
|
||||||
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import bundle
|
from . import bundle, lilypond
|
||||||
from .bundle import METADATA_FIELDS
|
from .bundle import METADATA_FIELDS
|
||||||
from .detect import deskew, detect_page
|
from .detect import deskew, detect_page
|
||||||
from .pdf import Source, open_source, page_raster
|
from .pdf import Source, open_source, page_raster
|
||||||
@@ -62,6 +65,7 @@ from .project import (
|
|||||||
from .render import apply_levels
|
from .render import apply_levels
|
||||||
|
|
||||||
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
||||||
|
PANEL_WIDTH = 340 # starting width only; the splitter takes over from there
|
||||||
HIT = 6 # grab distance in screen pixels
|
HIT = 6 # grab distance in screen pixels
|
||||||
AUTOSAVE_MS = 800
|
AUTOSAVE_MS = 800
|
||||||
|
|
||||||
@@ -71,6 +75,9 @@ _VERTEX = QColor(255, 200, 0)
|
|||||||
_DISCARD = QColor(120, 120, 140, 90)
|
_DISCARD = QColor(120, 120, 140, 90)
|
||||||
_RECT = QColor(40, 140, 220)
|
_RECT = QColor(40, 140, 220)
|
||||||
_MARKER = QColor(150, 60, 190)
|
_MARKER = QColor(150, 60, 190)
|
||||||
|
_ENGRAVED = QColor(200, 120, 0)
|
||||||
|
_ENGRAVED_WASH = QColor(230, 160, 30, 55)
|
||||||
|
_BADGE_Z = 10
|
||||||
|
|
||||||
|
|
||||||
class PageView(QGraphicsView):
|
class PageView(QGraphicsView):
|
||||||
@@ -79,6 +86,7 @@ class PageView(QGraphicsView):
|
|||||||
changed = Signal()
|
changed = Signal()
|
||||||
selection_changed = Signal()
|
selection_changed = Signal()
|
||||||
picked = Signal(int, int) # page, slot — a jump target chosen by clicking
|
picked = Signal(int, int) # page, slot — a jump target chosen by clicking
|
||||||
|
engrave_requested = Signal()
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -138,15 +146,63 @@ class PageView(QGraphicsView):
|
|||||||
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen)
|
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen)
|
||||||
|
|
||||||
for slot in range(self.page.slice_count):
|
for slot in range(self.page.slice_count):
|
||||||
markers = self.page.markers[slot]
|
|
||||||
if not markers:
|
|
||||||
continue
|
|
||||||
above, _ = self.page.bounds(slot)
|
above, _ = self.page.bounds(slot)
|
||||||
top = 0 if above is None else int(above.lowest * h)
|
top = 0 if above is None else int(above.lowest * h)
|
||||||
text = scene.addText(" · ".join(m.describe() for m in markers))
|
x = w * 0.015
|
||||||
text.setDefaultTextColor(_MARKER)
|
|
||||||
text.setScale(max(1.0, w / 900))
|
if self.page.replacements[slot]:
|
||||||
text.setPos(w * 0.02, top + h * 0.004)
|
# A wash over the whole slice, not just a label: this slice
|
||||||
|
# will not ship the pixels underneath it, which is worth
|
||||||
|
# noticing without hunting for small text.
|
||||||
|
scene.addPolygon(
|
||||||
|
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_ENGRAVED_WASH)
|
||||||
|
)
|
||||||
|
x = self._badge(scene, x, top + h * 0.004, "ENGRAVED", _ENGRAVED, w)
|
||||||
|
|
||||||
|
markers = self.page.markers[slot]
|
||||||
|
if markers:
|
||||||
|
self._badge(
|
||||||
|
scene, x, top + h * 0.004, " · ".join(m.describe() for m in markers), _MARKER, w
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, cut in enumerate(self.page.cuts):
|
||||||
|
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
|
||||||
|
pen = QPen(colour, 2)
|
||||||
|
pen.setCosmetic(True)
|
||||||
|
points = [QPointF(px * w, py * h) for px, py in cut.points]
|
||||||
|
for a, b in zip(points, points[1:]):
|
||||||
|
line = scene.addLine(a.x(), a.y(), b.x(), b.y(), pen)
|
||||||
|
line.setZValue(_BADGE_Z)
|
||||||
|
if i == self.selected_cut:
|
||||||
|
r = HIT * 1.5 / max(self.transform().m11(), 1e-6)
|
||||||
|
for p in points:
|
||||||
|
handle = scene.addEllipse(
|
||||||
|
p.x() - r, p.y() - r, r * 2, r * 2, QPen(Qt.NoPen), QBrush(_VERTEX)
|
||||||
|
)
|
||||||
|
handle.setZValue(_BADGE_Z)
|
||||||
|
|
||||||
|
def _badge(self, scene, x: float, y: float, label: str, colour: QColor, w: int) -> float:
|
||||||
|
"""A filled chip with light text. Returns the x to place the next one."""
|
||||||
|
text = scene.addText(label)
|
||||||
|
text.setDefaultTextColor(QColor(255, 255, 255))
|
||||||
|
scale = max(1.0, w / 900)
|
||||||
|
text.setScale(scale)
|
||||||
|
box = text.boundingRect()
|
||||||
|
pad = 4 * scale
|
||||||
|
plate = scene.addRect(
|
||||||
|
x - pad,
|
||||||
|
y - pad / 2,
|
||||||
|
box.width() * scale + pad * 2,
|
||||||
|
box.height() * scale + pad,
|
||||||
|
QPen(Qt.NoPen),
|
||||||
|
QBrush(colour),
|
||||||
|
)
|
||||||
|
# Above the page pixmap, which sits at z 0: a negative z would put the
|
||||||
|
# plate behind the scan and the white text with it.
|
||||||
|
plate.setZValue(_BADGE_Z - 1)
|
||||||
|
text.setZValue(_BADGE_Z)
|
||||||
|
text.setPos(x, y)
|
||||||
|
return x + box.width() * scale + pad * 3
|
||||||
|
|
||||||
for i, cut in enumerate(self.page.cuts):
|
for i, cut in enumerate(self.page.cuts):
|
||||||
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
|
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
|
||||||
@@ -304,10 +360,18 @@ class PageView(QGraphicsView):
|
|||||||
if self.project is None or self.pixmap is None:
|
if self.project is None or self.pixmap is None:
|
||||||
return
|
return
|
||||||
x, y = self._norm(event.position().toPoint())
|
x, y = self._norm(event.position().toPoint())
|
||||||
if self._hit_cut(x, y) is None:
|
if self._hit_cut(x, y) is not None:
|
||||||
self.selected_cut = self.page.add_cut(Cut.straight(y))
|
return
|
||||||
self.redraw()
|
if event.modifiers() & Qt.ShiftModifier:
|
||||||
self.changed.emit()
|
# Shift-double-click opens the engrave window on this slice; a
|
||||||
|
# plain double-click adds a cut, which is by far the commoner one.
|
||||||
|
self.selected_slice = self._slice_at(x, y)
|
||||||
|
self.selection_changed.emit()
|
||||||
|
self.engrave_requested.emit()
|
||||||
|
return
|
||||||
|
self.selected_cut = self.page.add_cut(Cut.straight(y))
|
||||||
|
self.redraw()
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
def wheelEvent(self, event) -> None:
|
def wheelEvent(self, event) -> None:
|
||||||
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
||||||
@@ -337,26 +401,75 @@ class Editor(QMainWindow):
|
|||||||
self.view.changed.connect(self._touched)
|
self.view.changed.connect(self._touched)
|
||||||
self.view.selection_changed.connect(self._sync)
|
self.view.selection_changed.connect(self._sync)
|
||||||
self.view.picked.connect(self._target_picked)
|
self.view.picked.connect(self._target_picked)
|
||||||
|
self.view.engrave_requested.connect(self._open_engrave)
|
||||||
|
|
||||||
self.autosave = QTimer(self)
|
self.autosave = QTimer(self)
|
||||||
self.autosave.setSingleShot(True)
|
self.autosave.setSingleShot(True)
|
||||||
self.autosave.setInterval(AUTOSAVE_MS)
|
self.autosave.setInterval(AUTOSAVE_MS)
|
||||||
self.autosave.timeout.connect(self._save)
|
self.autosave.timeout.connect(self._save)
|
||||||
|
|
||||||
central = QWidget()
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
layout = QHBoxLayout(central)
|
splitter.addWidget(self.view)
|
||||||
layout.addWidget(self.view, 1)
|
splitter.addWidget(self._panel())
|
||||||
layout.addWidget(self._panel())
|
splitter.setStretchFactor(0, 1) # the page takes the slack when resized
|
||||||
self.setCentralWidget(central)
|
splitter.setStretchFactor(1, 0)
|
||||||
|
splitter.setSizes([1100, PANEL_WIDTH])
|
||||||
|
splitter.setCollapsible(0, False)
|
||||||
|
self.setCentralWidget(splitter)
|
||||||
self._shortcuts()
|
self._shortcuts()
|
||||||
self._load_page(0)
|
self._load_page(0)
|
||||||
|
|
||||||
# -- ui ---------------------------------------------------------------
|
# -- ui ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _section(self, title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
|
||||||
|
"""A collapsible section. Returns the layout its contents go into.
|
||||||
|
|
||||||
|
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
|
||||||
|
header reads as "enable this feature" rather than "expand this", and a
|
||||||
|
column of framed boxes with checkboxes is hard to scan.
|
||||||
|
"""
|
||||||
|
header = QToolButton()
|
||||||
|
header.setText(title)
|
||||||
|
header.setCheckable(True)
|
||||||
|
header.setChecked(expanded)
|
||||||
|
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
|
||||||
|
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||||
|
header.setAutoRaise(True)
|
||||||
|
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
# Bold and greyed: a mid grey reads as a heading against both light and
|
||||||
|
# dark palettes without needing a second stylesheet.
|
||||||
|
header.setStyleSheet(
|
||||||
|
"QToolButton {"
|
||||||
|
" border: none;"
|
||||||
|
" font-weight: 700;"
|
||||||
|
" color: #808080;"
|
||||||
|
" padding: 7px 0 4px 0;"
|
||||||
|
" text-align: left;"
|
||||||
|
"}"
|
||||||
|
"QToolButton:hover { color: #a0a0a0; }"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = QWidget()
|
||||||
|
layout = QVBoxLayout(body)
|
||||||
|
layout.setContentsMargins(10, 6, 0, 10)
|
||||||
|
body.setVisible(expanded)
|
||||||
|
|
||||||
|
def toggled(open_: bool) -> None:
|
||||||
|
body.setVisible(open_)
|
||||||
|
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
|
||||||
|
|
||||||
|
header.toggled.connect(toggled)
|
||||||
|
box.addWidget(header)
|
||||||
|
box.addWidget(body)
|
||||||
|
return layout
|
||||||
|
|
||||||
def _panel(self) -> QWidget:
|
def _panel(self) -> QWidget:
|
||||||
panel = QWidget()
|
inner = QWidget()
|
||||||
panel.setFixedWidth(320)
|
box = QVBoxLayout(inner)
|
||||||
box = QVBoxLayout(panel)
|
panel = QScrollArea()
|
||||||
|
panel.setWidget(inner)
|
||||||
|
panel.setWidgetResizable(True)
|
||||||
|
panel.setMinimumWidth(260)
|
||||||
|
|
||||||
nav = QHBoxLayout()
|
nav = QHBoxLayout()
|
||||||
self.page_label = QLabel()
|
self.page_label = QLabel()
|
||||||
@@ -368,8 +481,9 @@ class Editor(QMainWindow):
|
|||||||
nav.addWidget(nxt)
|
nav.addWidget(nxt)
|
||||||
box.addLayout(nav)
|
box.addLayout(nav)
|
||||||
|
|
||||||
page_box = QGroupBox("Page")
|
page_section = self._section("Page", box)
|
||||||
form = QFormLayout(page_box)
|
form = QFormLayout()
|
||||||
|
page_section.addLayout(form)
|
||||||
self.skew = QDoubleSpinBox()
|
self.skew = QDoubleSpinBox()
|
||||||
self.skew.setRange(-15.0, 15.0)
|
self.skew.setRange(-15.0, 15.0)
|
||||||
self.skew.setSingleStep(0.1)
|
self.skew.setSingleStep(0.1)
|
||||||
@@ -395,10 +509,8 @@ class Editor(QMainWindow):
|
|||||||
reset.setToolTip("Back to the rectangle detection proposed for this page")
|
reset.setToolTip("Back to the rectangle detection proposed for this page")
|
||||||
reset.clicked.connect(self._reset_rect)
|
reset.clicked.connect(self._reset_rect)
|
||||||
form.addRow(reset)
|
form.addRow(reset)
|
||||||
box.addWidget(page_box)
|
|
||||||
|
|
||||||
marker_box = QGroupBox("Markers on this slice")
|
marker_layout = self._section("Markers on this slice", box)
|
||||||
marker_layout = QVBoxLayout(marker_box)
|
|
||||||
self.marker_list = QListWidget()
|
self.marker_list = QListWidget()
|
||||||
self.marker_list.setMaximumHeight(110)
|
self.marker_list.setMaximumHeight(110)
|
||||||
marker_layout.addWidget(self.marker_list)
|
marker_layout.addWidget(self.marker_list)
|
||||||
@@ -424,18 +536,35 @@ class Editor(QMainWindow):
|
|||||||
for button in (add, remove, self.retarget):
|
for button in (add, remove, self.retarget):
|
||||||
button_row.addWidget(button)
|
button_row.addWidget(button)
|
||||||
marker_layout.addLayout(button_row)
|
marker_layout.addLayout(button_row)
|
||||||
box.addWidget(marker_box)
|
|
||||||
self._marker_type_changed(self.marker_type.currentText())
|
self._marker_type_changed(self.marker_type.currentText())
|
||||||
|
|
||||||
meta_box = QGroupBox("Song")
|
# Optional feature: without LilyPond installed the pane never appears,
|
||||||
meta_form = QFormLayout(meta_box)
|
# and nothing else about the tool changes. Collapsed by default — most
|
||||||
|
# slices are never re-engraved, and it is the tallest block here.
|
||||||
|
self.ly_status = None
|
||||||
|
if lilypond.available():
|
||||||
|
ly_layout = self._section("Re-engrave this slice", box, expanded=False)
|
||||||
|
open_engrave = QPushButton("Open engrave window…")
|
||||||
|
open_engrave.setToolTip("Or double-click the slice on the page")
|
||||||
|
open_engrave.clicked.connect(self._open_engrave)
|
||||||
|
ly_layout.addWidget(open_engrave)
|
||||||
|
self.ly_status = QLabel()
|
||||||
|
self.ly_status.setWordWrap(True)
|
||||||
|
self.ly_status.setStyleSheet("color: #808080;")
|
||||||
|
ly_layout.addWidget(self.ly_status)
|
||||||
|
|
||||||
|
meta_layout = self._section("Song", box)
|
||||||
|
meta_form = QFormLayout()
|
||||||
|
meta_layout.addLayout(meta_form)
|
||||||
self.metadata: dict[str, QLineEdit] = {}
|
self.metadata: dict[str, QLineEdit] = {}
|
||||||
for field in METADATA_FIELDS:
|
for field in METADATA_FIELDS:
|
||||||
edit = QLineEdit(self.project.metadata.get(field, ""))
|
edit = QLineEdit(self.project.metadata.get(field, ""))
|
||||||
edit.textChanged.connect(self._metadata_changed)
|
edit.textChanged.connect(self._metadata_changed)
|
||||||
self.metadata[field] = edit
|
self.metadata[field] = edit
|
||||||
meta_form.addRow(field.replace("_", " ").title(), edit)
|
required = field == "title"
|
||||||
box.addWidget(meta_box)
|
if required:
|
||||||
|
edit.setPlaceholderText("required")
|
||||||
|
meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit)
|
||||||
|
|
||||||
self.summary = QLabel()
|
self.summary = QLabel()
|
||||||
self.summary.setWordWrap(True)
|
self.summary.setWordWrap(True)
|
||||||
@@ -452,7 +581,8 @@ class Editor(QMainWindow):
|
|||||||
"Right-click: delete cut or vertex\n"
|
"Right-click: delete cut or vertex\n"
|
||||||
"Click a slice, then D to discard\n"
|
"Click a slice, then D to discard\n"
|
||||||
"Drag the blue edges: content rectangle\n"
|
"Drag the blue edges: content rectangle\n"
|
||||||
"Jump markers: add, then click the target slice"
|
"Jump markers: add, then click the target slice\n"
|
||||||
|
"Shift-double-click a slice: re-engrave it"
|
||||||
)
|
)
|
||||||
help_text.setStyleSheet("color: palette(mid);")
|
help_text.setStyleSheet("color: palette(mid);")
|
||||||
box.addWidget(help_text)
|
box.addWidget(help_text)
|
||||||
@@ -511,6 +641,7 @@ class Editor(QMainWindow):
|
|||||||
widget.setValue(value)
|
widget.setValue(value)
|
||||||
widget.blockSignals(False)
|
widget.blockSignals(False)
|
||||||
self._sync_markers()
|
self._sync_markers()
|
||||||
|
self._sync_replacement()
|
||||||
kept = len(self.project.kept_slices())
|
kept = len(self.project.kept_slices())
|
||||||
total = sum(p.slice_count for p in self.project.pages)
|
total = sum(p.slice_count for p in self.project.pages)
|
||||||
state = "discarded" if page.discards[self.view.selected_slice] else "kept"
|
state = "discarded" if page.discards[self.view.selected_slice] else "kept"
|
||||||
@@ -593,6 +724,34 @@ class Editor(QMainWindow):
|
|||||||
for marker in self._slot_markers():
|
for marker in self._slot_markers():
|
||||||
self.marker_list.addItem(marker.describe())
|
self.marker_list.addItem(marker.describe())
|
||||||
|
|
||||||
|
# -- re-engraving -----------------------------------------------------
|
||||||
|
|
||||||
|
def _open_engrave(self) -> None:
|
||||||
|
"""Open the engrave window on the selected slice, showing its pixels."""
|
||||||
|
from .engrave import EngraveWindow
|
||||||
|
from .render import cut_slice, slice_mask
|
||||||
|
|
||||||
|
slot = self.view.selected_slice
|
||||||
|
page = self._preview(self.index)
|
||||||
|
original = cut_slice(page, slice_mask(self.project, self.index, slot, page.shape))
|
||||||
|
if original is None:
|
||||||
|
self.statusBar().showMessage("this slice has no ink to replace", 3000)
|
||||||
|
return
|
||||||
|
|
||||||
|
window = EngraveWindow(self.project, self.index, slot, original, self)
|
||||||
|
window.finished.connect(lambda _: (self.view.redraw(), self._touched()))
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
def _sync_replacement(self) -> None:
|
||||||
|
if self.ly_status is None:
|
||||||
|
return
|
||||||
|
replacement = self.project.pages[self.index].replacements[self.view.selected_slice]
|
||||||
|
if replacement is None:
|
||||||
|
self.ly_status.setText("scanned — not re-engraved")
|
||||||
|
else:
|
||||||
|
voices = len(replacement.voices)
|
||||||
|
self.ly_status.setText(f"re-engraved · {voices} voice{'s' if voices != 1 else ''}")
|
||||||
|
|
||||||
def _metadata_changed(self) -> None:
|
def _metadata_changed(self) -> None:
|
||||||
self.project.metadata = {
|
self.project.metadata = {
|
||||||
field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip()
|
field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip()
|
||||||
@@ -618,6 +777,12 @@ class Editor(QMainWindow):
|
|||||||
|
|
||||||
def _export(self) -> None:
|
def _export(self) -> None:
|
||||||
self._save()
|
self._save()
|
||||||
|
if not self.project.metadata.get("title", "").strip():
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Title required", "A song needs a title before it can be exported."
|
||||||
|
)
|
||||||
|
self.metadata["title"].setFocus()
|
||||||
|
return
|
||||||
target, _ = QFileDialog.getSaveFileName(
|
target, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)"
|
self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"""The engrave window: re-cut a system in LilyPond when the scan is past saving.
|
||||||
|
|
||||||
|
Three full-width rows — the scanned original, the render, and the form —
|
||||||
|
because a system is wide and short, and the job is comparing one against the
|
||||||
|
other bar by bar.
|
||||||
|
|
||||||
|
The form only builds the scaffolding: staff group, clef, key, time. Notes and
|
||||||
|
lyrics are raw LilyPond, so everything expressive still works, including the
|
||||||
|
`\\laissezVibrer` / `\\repeatTie` idiom for a tie crossing into the next slice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QImage, QKeySequence, QPixmap, QShortcut
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QSplitter,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import lilypond
|
||||||
|
from .detect import staff_height
|
||||||
|
from .project import Project, Replacement, Voice
|
||||||
|
|
||||||
|
|
||||||
|
def _pixmap(gray: np.ndarray, width: int = 1200) -> QPixmap:
|
||||||
|
if gray.shape[1] > width:
|
||||||
|
k = width / gray.shape[1]
|
||||||
|
gray = cv2.resize(gray, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
|
||||||
|
gray = np.ascontiguousarray(gray)
|
||||||
|
h, w = gray.shape
|
||||||
|
return QPixmap.fromImage(QImage(gray.data, w, h, w, QImage.Format_Grayscale8).copy())
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceRow(QWidget):
|
||||||
|
"""Clef, notes and lyrics for one staff."""
|
||||||
|
|
||||||
|
def __init__(self, voice: Voice, index: int, on_change) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.voice = voice
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 2, 0, 2)
|
||||||
|
|
||||||
|
self.number = QLabel(f"{index + 1}.")
|
||||||
|
self.number.setFixedWidth(20)
|
||||||
|
layout.addWidget(self.number)
|
||||||
|
|
||||||
|
self.clef = QComboBox()
|
||||||
|
for label, value in lilypond.CLEFS:
|
||||||
|
self.clef.addItem(label, value)
|
||||||
|
self.clef.setCurrentIndex(max(0, [v for _, v in lilypond.CLEFS].index(voice.clef)))
|
||||||
|
self.clef.setFixedWidth(130)
|
||||||
|
self.clef.currentIndexChanged.connect(lambda: (self._pull(), on_change()))
|
||||||
|
layout.addWidget(self.clef)
|
||||||
|
|
||||||
|
self.notes = QLineEdit(voice.notes)
|
||||||
|
self.notes.setPlaceholderText("notes — c4 d e f | g2 e2")
|
||||||
|
self.notes.textChanged.connect(lambda: (self._pull(), on_change()))
|
||||||
|
layout.addWidget(self.notes, 3)
|
||||||
|
|
||||||
|
self.lyrics = QLineEdit(voice.lyrics)
|
||||||
|
self.lyrics.setPlaceholderText("lyrics")
|
||||||
|
self.lyrics.textChanged.connect(lambda: (self._pull(), on_change()))
|
||||||
|
layout.addWidget(self.lyrics, 2)
|
||||||
|
|
||||||
|
def set_index(self, index: int) -> None:
|
||||||
|
self.number.setText(f"{index + 1}.")
|
||||||
|
|
||||||
|
def _pull(self) -> None:
|
||||||
|
self.voice.clef = self.clef.currentData()
|
||||||
|
self.voice.notes = self.notes.text()
|
||||||
|
self.voice.lyrics = self.lyrics.text()
|
||||||
|
|
||||||
|
|
||||||
|
class EngraveWindow(QDialog):
|
||||||
|
def __init__(self, project: Project, page: int, slot: int, original: np.ndarray, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.project = project
|
||||||
|
self.page_index = page
|
||||||
|
self.slot = slot
|
||||||
|
self.original = original
|
||||||
|
self.setWindowTitle(f"Re-engrave — page {page + 1}, slice {slot + 1}")
|
||||||
|
self.setModal(False)
|
||||||
|
|
||||||
|
state = project.pages[page]
|
||||||
|
self.replacement = state.replacements[slot] or self._seed()
|
||||||
|
state.replacements[slot] = self.replacement
|
||||||
|
|
||||||
|
rows = QSplitter(Qt.Vertical)
|
||||||
|
rows.addWidget(self._image_panel("Scanned", _pixmap(original)))
|
||||||
|
self.render_label = QLabel("not rendered yet")
|
||||||
|
self.render_label.setAlignment(Qt.AlignCenter)
|
||||||
|
rows.addWidget(self._image_panel("Engraved", None, self.render_label))
|
||||||
|
rows.addWidget(self._form())
|
||||||
|
rows.setSizes([260, 260, 420])
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(rows)
|
||||||
|
self.resize(1400, 980)
|
||||||
|
|
||||||
|
QShortcut(QKeySequence("Ctrl+Return"), self, self.render)
|
||||||
|
QShortcut(QKeySequence("Ctrl+Enter"), self, self.render)
|
||||||
|
if any(v.notes.strip() for v in self.replacement.voices):
|
||||||
|
self.render()
|
||||||
|
|
||||||
|
# -- construction -----------------------------------------------------
|
||||||
|
|
||||||
|
def _seed(self) -> Replacement:
|
||||||
|
"""A fresh replacement: voice count from the slice, the rest from the song.
|
||||||
|
|
||||||
|
Detecting key or clef on the slice itself would mean reading the very
|
||||||
|
scan that is too degraded to use, so those are inherited instead — the
|
||||||
|
song's key does not change, and the clef order repeats system to system.
|
||||||
|
"""
|
||||||
|
from .detect import staff_count
|
||||||
|
|
||||||
|
count = staff_count(self.original)
|
||||||
|
clefs = self.project.clefs
|
||||||
|
return Replacement(
|
||||||
|
voices=[
|
||||||
|
Voice(clef=clefs[i] if i < len(clefs) else "treble") for i in range(count)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _image_panel(self, title: str, pixmap: QPixmap | None, label: QLabel | None = None):
|
||||||
|
panel = QWidget()
|
||||||
|
box = QVBoxLayout(panel)
|
||||||
|
box.setContentsMargins(0, 0, 0, 0)
|
||||||
|
heading = QLabel(title)
|
||||||
|
heading.setStyleSheet("font-weight: 700; color: #808080;")
|
||||||
|
box.addWidget(heading)
|
||||||
|
|
||||||
|
view = label or QLabel()
|
||||||
|
view.setAlignment(Qt.AlignCenter)
|
||||||
|
if pixmap is not None:
|
||||||
|
view.setPixmap(pixmap)
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidget(view)
|
||||||
|
area.setWidgetResizable(True)
|
||||||
|
box.addWidget(area)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _form(self) -> QWidget:
|
||||||
|
panel = QWidget()
|
||||||
|
box = QVBoxLayout(panel)
|
||||||
|
|
||||||
|
top = QFormLayout()
|
||||||
|
self.key = QComboBox()
|
||||||
|
for label, value in lilypond.KEY_SIGNATURES:
|
||||||
|
self.key.addItem(label, value)
|
||||||
|
current = self.replacement.key or self.project.key
|
||||||
|
self.key.setCurrentIndex(
|
||||||
|
max(0, [v for _, v in lilypond.KEY_SIGNATURES].index(current))
|
||||||
|
if current in [v for _, v in lilypond.KEY_SIGNATURES]
|
||||||
|
else 7
|
||||||
|
)
|
||||||
|
self.key.currentIndexChanged.connect(self._settings_changed)
|
||||||
|
top.addRow("Key", self.key)
|
||||||
|
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self.time = QLineEdit(self.replacement.time or self.project.time)
|
||||||
|
self.time.setFixedWidth(70)
|
||||||
|
self.time.textChanged.connect(self._settings_changed)
|
||||||
|
row.addWidget(self.time)
|
||||||
|
self.print_time = QCheckBox("print it (only the song's first system shows one)")
|
||||||
|
self.print_time.setChecked(self.replacement.print_time)
|
||||||
|
self.print_time.toggled.connect(self._settings_changed)
|
||||||
|
row.addWidget(self.print_time, 1)
|
||||||
|
top.addRow("Time", row)
|
||||||
|
box.addLayout(top)
|
||||||
|
|
||||||
|
voices_label = QLabel("Voices")
|
||||||
|
voices_label.setStyleSheet("font-weight: 700; color: #808080;")
|
||||||
|
box.addWidget(voices_label)
|
||||||
|
|
||||||
|
self.voice_box = QVBoxLayout()
|
||||||
|
box.addLayout(self.voice_box)
|
||||||
|
self.rows: list[VoiceRow] = []
|
||||||
|
for voice in self.replacement.voices:
|
||||||
|
self._add_row(voice)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
add = QPushButton("Add voice")
|
||||||
|
add.clicked.connect(self._add_voice)
|
||||||
|
remove = QPushButton("Remove last voice")
|
||||||
|
remove.clicked.connect(self._remove_voice)
|
||||||
|
render = QPushButton("Render (Ctrl+↵)")
|
||||||
|
render.clicked.connect(self.render)
|
||||||
|
drop = QPushButton("Discard replacement")
|
||||||
|
drop.clicked.connect(self._discard)
|
||||||
|
for button in (add, remove, render, drop):
|
||||||
|
buttons.addWidget(button)
|
||||||
|
box.addLayout(buttons)
|
||||||
|
|
||||||
|
self.status = QLabel()
|
||||||
|
self.status.setWordWrap(True)
|
||||||
|
box.addWidget(self.status)
|
||||||
|
|
||||||
|
self.generated = QPlainTextEdit()
|
||||||
|
self.generated.setReadOnly(True)
|
||||||
|
self.generated.setMaximumHeight(120)
|
||||||
|
self.generated.setStyleSheet("color: #808080;")
|
||||||
|
box.addWidget(self.generated)
|
||||||
|
self._refresh_source()
|
||||||
|
return panel
|
||||||
|
|
||||||
|
# -- edits ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _add_row(self, voice: Voice) -> None:
|
||||||
|
row = VoiceRow(voice, len(self.rows), self._refresh_source)
|
||||||
|
self.rows.append(row)
|
||||||
|
self.voice_box.addWidget(row)
|
||||||
|
|
||||||
|
def _add_voice(self) -> None:
|
||||||
|
clefs = self.project.clefs
|
||||||
|
index = len(self.replacement.voices)
|
||||||
|
voice = Voice(clef=clefs[index] if index < len(clefs) else "treble")
|
||||||
|
self.replacement.voices.append(voice)
|
||||||
|
self._add_row(voice)
|
||||||
|
self._refresh_source()
|
||||||
|
|
||||||
|
def _remove_voice(self) -> None:
|
||||||
|
if not self.rows:
|
||||||
|
return
|
||||||
|
self.replacement.voices.pop()
|
||||||
|
row = self.rows.pop()
|
||||||
|
row.setParent(None)
|
||||||
|
self._refresh_source()
|
||||||
|
|
||||||
|
def _settings_changed(self) -> None:
|
||||||
|
# Set on the song, not the slice: they are song properties in practice,
|
||||||
|
# and this is what makes the next re-engraved slice open pre-filled.
|
||||||
|
self.project.key = self.key.currentData()
|
||||||
|
self.project.time = self.time.text().strip() or "4/4"
|
||||||
|
self.replacement.key = None
|
||||||
|
self.replacement.time = None
|
||||||
|
self.replacement.print_time = self.print_time.isChecked()
|
||||||
|
self._refresh_source()
|
||||||
|
|
||||||
|
def _discard(self) -> None:
|
||||||
|
self.project.pages[self.page_index].replacements[self.slot] = None
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _refresh_source(self) -> None:
|
||||||
|
self.generated.setPlainText(
|
||||||
|
lilypond.generate(self.replacement, self.project.key, self.project.time)
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- rendering --------------------------------------------------------
|
||||||
|
|
||||||
|
def render(self) -> None:
|
||||||
|
source = lilypond.generate(self.replacement, self.project.key, self.project.time)
|
||||||
|
self.status.setStyleSheet("color: #808080;")
|
||||||
|
self.status.setText("rendering…")
|
||||||
|
self.repaint()
|
||||||
|
try:
|
||||||
|
image = lilypond.render(source)
|
||||||
|
except lilypond.LilypondError as error:
|
||||||
|
self.status.setStyleSheet("color: #c0392b;")
|
||||||
|
self.status.setText(str(error)[-600:])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Shown at the original's staff height rather than its native size:
|
||||||
|
# LilyPond renders ~4300px wide against a ~1500px scan, and matching
|
||||||
|
# staff heights is what export does anyway — so this is a preview of
|
||||||
|
# the real thing rather than of an intermediate.
|
||||||
|
theirs = staff_height(image, 0, image.shape[0])
|
||||||
|
ours = staff_height(self.original, 0, self.original.shape[0])
|
||||||
|
if theirs and ours:
|
||||||
|
k = ours / theirs
|
||||||
|
image = cv2.resize(image, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
|
self.render_label.setPixmap(_pixmap(image))
|
||||||
|
self.status.setText(f"rendered — {image.shape[1]}×{image.shape[0]}px at the scan's scale")
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None:
|
||||||
|
replacement = self.project.pages[self.page_index].replacements[self.slot]
|
||||||
|
if replacement and not any(v.notes.strip() for v in replacement.voices):
|
||||||
|
# Nothing was written, so leave the slice as a scanned one rather
|
||||||
|
# than exporting an empty engraving.
|
||||||
|
self.project.pages[self.page_index].replacements[self.slot] = None
|
||||||
|
else:
|
||||||
|
self.project.pages[self.page_index].remember_clefs(self.project, self.slot)
|
||||||
|
super().closeEvent(event)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""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")
|
||||||
@@ -120,6 +120,37 @@ class Marker:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Voice:
|
||||||
|
"""One staff of a re-engraved system.
|
||||||
|
|
||||||
|
`notes` and `lyrics` are raw LilyPond, so slurs, dynamics, tuplets and the
|
||||||
|
`\\laissezVibrer` / `\\repeatTie` idiom for ties crossing a slice boundary
|
||||||
|
all work without the form knowing anything about them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
clef: str = "treble"
|
||||||
|
notes: str = ""
|
||||||
|
lyrics: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Replacement:
|
||||||
|
"""A system engraved with LilyPond in place of the scanned one.
|
||||||
|
|
||||||
|
Key and time are per song in practice — Kaipaava is 4♭ and 4/4 from first
|
||||||
|
system to last — so they live on the project and are only set here when a
|
||||||
|
slice genuinely differs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
voices: list[Voice] = field(default_factory=list)
|
||||||
|
key: str | None = None
|
||||||
|
time: str | None = None
|
||||||
|
# The printed score repeats the key signature at every system but not the
|
||||||
|
# time signature, so a re-engraved middle slice must not show one.
|
||||||
|
print_time: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Page:
|
class Page:
|
||||||
"""One page's decisions. `cuts` are ordered top to bottom."""
|
"""One page's decisions. `cuts` are ordered top to bottom."""
|
||||||
@@ -129,6 +160,9 @@ class Page:
|
|||||||
discards: list[bool] = field(default_factory=lambda: [False])
|
discards: list[bool] = field(default_factory=lambda: [False])
|
||||||
# One list per slice, parallel to `discards`.
|
# One list per slice, parallel to `discards`.
|
||||||
markers: list[list[Marker]] = field(default_factory=lambda: [[]])
|
markers: list[list[Marker]] = field(default_factory=lambda: [[]])
|
||||||
|
# A re-engraved system per slice, when the scan is past saving. None for
|
||||||
|
# the ordinary case, which is nearly all of them.
|
||||||
|
replacements: list[Replacement | None] = field(default_factory=lambda: [None])
|
||||||
content_rect: tuple[float, float, float, float] | None = None
|
content_rect: tuple[float, float, float, float] | None = None
|
||||||
levels: tuple[int, int] | None = None
|
levels: tuple[int, int] | None = None
|
||||||
|
|
||||||
@@ -153,6 +187,7 @@ class Page:
|
|||||||
# is at least predictable, and moving one is a click.
|
# is at least predictable, and moving one is a click.
|
||||||
self.discards.insert(index, self.discards[index])
|
self.discards.insert(index, self.discards[index])
|
||||||
self.markers.insert(index + 1, [])
|
self.markers.insert(index + 1, [])
|
||||||
|
self.replacements.insert(index + 1, None)
|
||||||
return index
|
return index
|
||||||
|
|
||||||
def remove_cut(self, index: int) -> None:
|
def remove_cut(self, index: int) -> None:
|
||||||
@@ -162,6 +197,15 @@ class Page:
|
|||||||
self.discards.pop(index + 1)
|
self.discards.pop(index + 1)
|
||||||
self.discards[index] = merged
|
self.discards[index] = merged
|
||||||
self.markers[index].extend(self.markers.pop(index + 1))
|
self.markers[index].extend(self.markers.pop(index + 1))
|
||||||
|
# Two engraved halves cannot be merged, so the upper one wins.
|
||||||
|
below = self.replacements.pop(index + 1)
|
||||||
|
self.replacements[index] = self.replacements[index] or below
|
||||||
|
|
||||||
|
def remember_clefs(self, project: Project, slot: int) -> None:
|
||||||
|
"""Carry this slice's clefs forward as the song's defaults."""
|
||||||
|
replacement = self.replacements[slot]
|
||||||
|
if replacement and replacement.voices:
|
||||||
|
project.clefs = [v.clef for v in replacement.voices]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -172,6 +216,12 @@ class Project:
|
|||||||
content_rect: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
|
content_rect: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
|
||||||
levels: tuple[int, int] = (0, 255)
|
levels: tuple[int, int] = (0, 255)
|
||||||
metadata: dict[str, str] = field(default_factory=dict)
|
metadata: dict[str, str] = field(default_factory=dict)
|
||||||
|
# Engraving defaults for the song. Key and time are set once and inherited
|
||||||
|
# by every replacement; `clefs` remembers what each voice position was last
|
||||||
|
# given, so the second re-engraved system in a song opens already filled in.
|
||||||
|
key: str = "c"
|
||||||
|
time: str = "4/4"
|
||||||
|
clefs: list[str] = field(default_factory=list)
|
||||||
path: Path | None = None
|
path: Path | None = None
|
||||||
# Set once the song has been exported. A project is spent at that point:
|
# Set once the song has been exported. A project is spent at that point:
|
||||||
# opening the PDF again starts a fresh session from detection rather than
|
# opening the PDF again starts a fresh session from detection rather than
|
||||||
@@ -236,6 +286,7 @@ class Project:
|
|||||||
cuts=[Cut.straight(y / height) for y in ys],
|
cuts=[Cut.straight(y / height) for y in ys],
|
||||||
discards=discards,
|
discards=discards,
|
||||||
markers=[[] for _ in discards],
|
markers=[[] for _ in discards],
|
||||||
|
replacements=[None] * len(discards),
|
||||||
# Per page, not per song: scans drift, so the margin junk
|
# Per page, not per song: scans drift, so the margin junk
|
||||||
# sits in a different place on each one.
|
# sits in a different place on each one.
|
||||||
content_rect=detection.content,
|
content_rect=detection.content,
|
||||||
@@ -254,6 +305,9 @@ class Project:
|
|||||||
"content_rect": list(self.content_rect),
|
"content_rect": list(self.content_rect),
|
||||||
"levels": list(self.levels),
|
"levels": list(self.levels),
|
||||||
"metadata": self.metadata,
|
"metadata": self.metadata,
|
||||||
|
"key": self.key,
|
||||||
|
"time": self.time,
|
||||||
|
"clefs": self.clefs,
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"skew": page.skew,
|
"skew": page.skew,
|
||||||
@@ -274,6 +328,20 @@ class Project:
|
|||||||
]
|
]
|
||||||
for slot in page.markers
|
for slot in page.markers
|
||||||
],
|
],
|
||||||
|
"replacements": [
|
||||||
|
None
|
||||||
|
if r is None
|
||||||
|
else {
|
||||||
|
"voices": [
|
||||||
|
{"clef": v.clef, "notes": v.notes, "lyrics": v.lyrics}
|
||||||
|
for v in r.voices
|
||||||
|
],
|
||||||
|
**({"key": r.key} if r.key else {}),
|
||||||
|
**({"time": r.time} if r.time else {}),
|
||||||
|
**({"print_time": True} if r.print_time else {}),
|
||||||
|
}
|
||||||
|
for r in page.replacements
|
||||||
|
],
|
||||||
"content_rect": list(page.content_rect) if page.content_rect else None,
|
"content_rect": list(page.content_rect) if page.content_rect else None,
|
||||||
"levels": list(page.levels) if page.levels else None,
|
"levels": list(page.levels) if page.levels else None,
|
||||||
}
|
}
|
||||||
@@ -309,6 +377,27 @@ class Project:
|
|||||||
]
|
]
|
||||||
for slot in page.get("markers", [[] for _ in page["discards"]])
|
for slot in page.get("markers", [[] for _ in page["discards"]])
|
||||||
],
|
],
|
||||||
|
replacements=[
|
||||||
|
# A bare string is the short-lived raw-source form, which
|
||||||
|
# never shipped: dropped rather than migrated, so the rest
|
||||||
|
# of the project still opens.
|
||||||
|
None
|
||||||
|
if not isinstance(r, dict)
|
||||||
|
else Replacement(
|
||||||
|
voices=[
|
||||||
|
Voice(
|
||||||
|
clef=v.get("clef", "treble"),
|
||||||
|
notes=v.get("notes", ""),
|
||||||
|
lyrics=v.get("lyrics", ""),
|
||||||
|
)
|
||||||
|
for v in r.get("voices", [])
|
||||||
|
],
|
||||||
|
key=r.get("key"),
|
||||||
|
time=r.get("time"),
|
||||||
|
print_time=r.get("print_time", False),
|
||||||
|
)
|
||||||
|
for r in page.get("replacements", [None] * len(page["discards"]))
|
||||||
|
],
|
||||||
content_rect=tuple(page["content_rect"]) if page["content_rect"] else None,
|
content_rect=tuple(page["content_rect"]) if page["content_rect"] else None,
|
||||||
levels=tuple(page["levels"]) if page["levels"] else None,
|
levels=tuple(page["levels"]) if page["levels"] else None,
|
||||||
)
|
)
|
||||||
@@ -323,6 +412,9 @@ class Project:
|
|||||||
metadata=data.get("metadata", {}),
|
metadata=data.get("metadata", {}),
|
||||||
path=path,
|
path=path,
|
||||||
exported=data.get("exported", False),
|
exported=data.get("exported", False),
|
||||||
|
key=data.get("key", "c"),
|
||||||
|
time=data.get("time", "4/4"),
|
||||||
|
clefs=data.get("clefs", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
def source_changed(self) -> bool:
|
def source_changed(self) -> bool:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from dataclasses import dataclass
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from . import lilypond
|
||||||
from .detect import deskew, staff_height
|
from .detect import deskew, staff_height
|
||||||
from .pdf import Source, page_raster
|
from .pdf import Source, page_raster
|
||||||
from .project import Cut, Project
|
from .project import Cut, Project
|
||||||
@@ -146,11 +147,25 @@ def render_slices(project: Project, source: Source) -> list[SliceImage]:
|
|||||||
"""Every kept slice, trimmed but not yet scaled."""
|
"""Every kept slice, trimmed but not yet scaled."""
|
||||||
out: list[SliceImage] = []
|
out: list[SliceImage] = []
|
||||||
for index in range(len(project.pages)):
|
for index in range(len(project.pages)):
|
||||||
page = page_pixels(project, source, index)
|
page_state = project.pages[index]
|
||||||
for slot in range(project.pages[index].slice_count):
|
# Only rasterize the page if some slice on it still comes from the scan.
|
||||||
if project.pages[index].discards[slot]:
|
page = None
|
||||||
|
for slot in range(page_state.slice_count):
|
||||||
|
if page_state.discards[slot]:
|
||||||
continue
|
continue
|
||||||
gray = cut_slice(page, slice_mask(project, index, slot, page.shape))
|
|
||||||
|
engraved = page_state.replacements[slot]
|
||||||
|
if engraved and engraved.voices:
|
||||||
|
# A re-engraved system enters here, at the trim stage, so it
|
||||||
|
# flows through staff-height normalisation and the rest exactly
|
||||||
|
# as a scanned one does.
|
||||||
|
gray = lilypond.render(
|
||||||
|
lilypond.generate(engraved, project.key, project.time)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if page is None:
|
||||||
|
page = page_pixels(project, source, index)
|
||||||
|
gray = cut_slice(page, slice_mask(project, index, slot, page.shape))
|
||||||
if gray is None:
|
if gray is None:
|
||||||
continue # a kept slice that turned out to hold no ink
|
continue # a kept slice that turned out to hold no ink
|
||||||
out.append(SliceImage(index, slot, gray, staff_height(gray, 0, gray.shape[0])))
|
out.append(SliceImage(index, slot, gray, staff_height(gray, 0, gray.shape[0])))
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Runnable check for LilyPond slice replacement.
|
||||||
|
|
||||||
|
Skips cleanly when LilyPond is not installed — that is the point of the
|
||||||
|
availability gate, so the check has to honour it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pymupdf
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from noteman_slicer import lilypond # noqa: E402
|
||||||
|
from noteman_slicer.detect import detect_page # noqa: E402
|
||||||
|
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
||||||
|
from noteman_slicer.detect import staff_count # noqa: E402
|
||||||
|
from noteman_slicer.project import ( # noqa: E402
|
||||||
|
Cut,
|
||||||
|
Project,
|
||||||
|
Replacement,
|
||||||
|
Voice,
|
||||||
|
default_path,
|
||||||
|
)
|
||||||
|
from noteman_slicer.render import cut_slice, render_slices, scale_song, slice_mask # noqa: E402
|
||||||
|
|
||||||
|
# Notes are relative, so no octave marks except where a leap needs one.
|
||||||
|
SATB = Replacement(
|
||||||
|
voices=[
|
||||||
|
Voice("treble", "c4 d e f | g2 e2", "la la la la la la"),
|
||||||
|
Voice("bass", "c4 d e f | g2 c2", "la la la la la la"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
W, H = 1200, 1600
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_pdf(path: Path) -> None:
|
||||||
|
art = np.full((H, W), 255, np.uint8)
|
||||||
|
for top in (300, 800):
|
||||||
|
art[top : top + 200, 100:104] = 0
|
||||||
|
for staff in (top, top + 140):
|
||||||
|
for i in range(5):
|
||||||
|
art[staff + i * 15 : staff + i * 15 + 2, 110:1100] = 0
|
||||||
|
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
|
||||||
|
doc = pymupdf.open()
|
||||||
|
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
|
||||||
|
doc.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not lilypond.available():
|
||||||
|
print("ok (skipped: LilyPond not installed)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
tmp = Path(__file__).with_name("_tmp")
|
||||||
|
tmp.mkdir(exist_ok=True)
|
||||||
|
pdf = tmp / "scan.pdf"
|
||||||
|
_scan_pdf(pdf)
|
||||||
|
|
||||||
|
# A syntax error must come back readable rather than as a stack trace.
|
||||||
|
try:
|
||||||
|
lilypond.render("\\score { this is not lilypond }")
|
||||||
|
except lilypond.LilypondError as error:
|
||||||
|
assert str(error), "the error must carry LilyPond's own message"
|
||||||
|
else:
|
||||||
|
raise AssertionError("bad source should raise")
|
||||||
|
|
||||||
|
# The generator: key at slice level, time used but not printed.
|
||||||
|
source = lilypond.generate(SATB, "aes", "4/4")
|
||||||
|
assert source.count("\\new Staff") == 2
|
||||||
|
assert source.count("\\key aes \\major") == 2, "every staff carries the key"
|
||||||
|
assert "\\omit Staff.TimeSignature" in source, "a middle system prints no time signature"
|
||||||
|
assert "\\addlyrics" in source
|
||||||
|
# Relative entry, referenced to the middle of each clef's staff, so notes
|
||||||
|
# carry no octave marks.
|
||||||
|
assert "\\relative c'' { c4 d e f | g2 e2 }" in source
|
||||||
|
assert "\\relative c { c4 d e f | g2 c2 }" in source
|
||||||
|
|
||||||
|
printed = lilypond.generate(
|
||||||
|
Replacement(voices=SATB.voices, print_time=True), "aes", "4/4"
|
||||||
|
)
|
||||||
|
assert "\\omit Staff.TimeSignature" not in printed
|
||||||
|
|
||||||
|
override = lilypond.generate(Replacement(voices=SATB.voices, key="d"), "aes", "4/4")
|
||||||
|
assert "\\key d \\major" in override, "a slice-level key must win over the song's"
|
||||||
|
|
||||||
|
# Every key signature and clef the form offers must be real LilyPond.
|
||||||
|
assert len(lilypond.KEY_SIGNATURES) == 15
|
||||||
|
assert ("4♭ — A♭ major / F minor", "aes") in lilypond.KEY_SIGNATURES
|
||||||
|
assert [v for _, v in lilypond.CLEFS] == ["treble", "treble_8", "bass", "alto"]
|
||||||
|
|
||||||
|
engraved = lilypond.render(source, dpi=200)
|
||||||
|
assert engraved.ndim == 2 and engraved.dtype == np.uint8
|
||||||
|
# -dcrop trims to the ink, so the result is far smaller than a page.
|
||||||
|
assert engraved.shape[0] < 1200, engraved.shape
|
||||||
|
assert engraved.min() == 0 and engraved.max() == 255
|
||||||
|
|
||||||
|
source = open_source(pdf)
|
||||||
|
gray = page_raster(source, 0)
|
||||||
|
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
|
||||||
|
page = project.pages[0]
|
||||||
|
assert len(page.replacements) == page.slice_count
|
||||||
|
|
||||||
|
kept = project.kept_slices()
|
||||||
|
(_, first), (_, second) = kept
|
||||||
|
|
||||||
|
# Voice count is seeded from the slice: the fixture draws two staves.
|
||||||
|
preview = cut_slice(gray, slice_mask(project, 0, second, gray.shape))
|
||||||
|
assert staff_count(preview) == 2, staff_count(preview)
|
||||||
|
|
||||||
|
page.replacements[second] = SATB
|
||||||
|
|
||||||
|
slices = render_slices(project, source)
|
||||||
|
assert len(slices) == 2
|
||||||
|
scanned, replaced = slices
|
||||||
|
assert scanned.staff and replaced.staff
|
||||||
|
|
||||||
|
# The whole point: after normalisation both sit at the same staff height,
|
||||||
|
# with no manual scaling, even though the sources differ wildly in scale.
|
||||||
|
factors = [target / s.staff for s, target in ((scanned, 1.0), (replaced, 1.0))]
|
||||||
|
assert factors # keep the intent readable
|
||||||
|
out = scale_song(slices)
|
||||||
|
heights = []
|
||||||
|
for image, original in zip(out, slices):
|
||||||
|
k = image.shape[0] / original.gray.shape[0]
|
||||||
|
heights.append(original.staff * k)
|
||||||
|
assert abs(heights[0] - heights[1]) < 2.0, f"staff heights should match: {heights}"
|
||||||
|
|
||||||
|
# Cut edits keep the replacement aligned with its slice.
|
||||||
|
index = page.add_cut(Cut.straight(0.97))
|
||||||
|
assert len(page.replacements) == page.slice_count
|
||||||
|
assert page.replacements[second] is SATB
|
||||||
|
page.remove_cut(index)
|
||||||
|
assert page.replacements[second] is SATB
|
||||||
|
|
||||||
|
# Round-trip, including the song-level engraving defaults.
|
||||||
|
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
|
||||||
|
saved = project.save()
|
||||||
|
reloaded = Project.load(saved)
|
||||||
|
assert (reloaded.key, reloaded.time, reloaded.clefs) == ("aes", "3/4", ["treble", "bass"])
|
||||||
|
restored = reloaded.pages[0].replacements[second]
|
||||||
|
assert restored is not None
|
||||||
|
assert [v.clef for v in restored.voices] == ["treble", "bass"]
|
||||||
|
assert restored.voices[0].lyrics == "la la la la la la"
|
||||||
|
assert reloaded.pages[0].replacements[first] is None
|
||||||
|
|
||||||
|
source.close()
|
||||||
|
for f in (pdf, saved, default_path(pdf)):
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
tmp.rmdir()
|
||||||
|
print("ok")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -97,6 +97,7 @@ def main() -> int:
|
|||||||
assert reloaded.pages[0].markers[second][0].type == "coda"
|
assert reloaded.pages[0].markers[second][0].type == "coda"
|
||||||
|
|
||||||
# And through a real bundle.
|
# And through a real bundle.
|
||||||
|
reloaded.metadata["title"] = "Test song"
|
||||||
out = bundle.write(reloaded, source, tmp / "song.zip")
|
out = bundle.write(reloaded, source, tmp / "song.zip")
|
||||||
with zipfile.ZipFile(out) as zf:
|
with zipfile.ZipFile(out) as zf:
|
||||||
meta = json.loads(zf.read("song.json"))
|
meta = json.loads(zf.read("song.json"))
|
||||||
|
|||||||
@@ -139,7 +139,15 @@ def main() -> int:
|
|||||||
src2.close()
|
src2.close()
|
||||||
labelled.unlink()
|
labelled.unlink()
|
||||||
|
|
||||||
# Bundle.
|
# Bundle. A title is required; everything else is optional.
|
||||||
|
try:
|
||||||
|
bundle.write(project, source, tmp / "untitled.zip")
|
||||||
|
except ValueError as error:
|
||||||
|
assert "title" in str(error)
|
||||||
|
else:
|
||||||
|
raise AssertionError("export without a title should be refused")
|
||||||
|
|
||||||
|
project.metadata["title"] = "Test song"
|
||||||
out = bundle.write(project, source, tmp / "song.zip")
|
out = bundle.write(project, source, tmp / "song.zip")
|
||||||
with zipfile.ZipFile(out) as zf:
|
with zipfile.ZipFile(out) as zf:
|
||||||
names = zf.namelist()
|
names = zf.namelist()
|
||||||
|
|||||||
Reference in New Issue
Block a user