diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py index fd20223..c703204 100644 --- a/noteman_slicer/bundle.py +++ b/noteman_slicer/bundle.py @@ -29,6 +29,9 @@ METADATA_FIELDS = ( "arranger", "lyricist", "translator", + # Free-form, matching noteman's own column: scores notate tempo as a mix of + # BPM ("♩=72"), Italian ("Andante") and prose. + "tempo", "voices", ) @@ -39,13 +42,51 @@ def song_json(project: Project, files: list[str]) -> dict: value = project.metadata.get(field) if value: payload[field] = value - payload["slices"] = [{"file": name} for name in files] + + kept = project.kept_slices() + # Markers reference slices by (page, slot) while editing, because that is + # what survives adding and removing cuts. In the bundle they become the + # array index, which is the only cross-reference the format has. + index_of = {position: i for i, position in enumerate(kept)} + + slices: list[dict] = [] + for name, (page, slot) in zip(files, kept): + entry: dict = {"file": name} + markers = [] + for marker in project.pages[page].markers[slot]: + item: dict = {"type": marker.type} + if marker.label: + item["label"] = marker.label + if marker.destination is not None: + target = index_of.get(tuple(marker.destination)) + # A jump whose target was discarded or re-cut away is dropped + # rather than exported dangling: noteman would have nothing to + # resolve it to. + if target is None: + continue + item["destination"] = target + markers.append(item) + if markers: + entry["markers"] = markers + slices.append(entry) + + payload["slices"] = slices return payload 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) + 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))] path = Path(path) diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py index ee1ad57..a5cb66e 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -88,7 +88,13 @@ def _export(args: argparse.Namespace) -> int: print("WARNING: the PDF has changed since these cuts were made") 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 slices = len(project.kept_slices()) print(f"{out} {slices} slices, {size / 1024:.0f} KB ({size / max(slices, 1) / 1024:.1f} KB/slice)") diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py index 44bbcf9..7e389e1 100644 --- a/noteman_slicer/detect.py +++ b/noteman_slicer/detect.py @@ -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 _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 +_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 @@ -190,6 +192,46 @@ def content_columns( 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]]: """Rows containing ink, despeckled — specks are the known failure mode.""" profile = row_darkness(cv2.medianBlur(gray, 3)) diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py index 7fa3881..6829621 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -32,26 +32,40 @@ from PySide6.QtWidgets import ( QFormLayout, QGraphicsScene, QGraphicsView, - QGroupBox, + QComboBox, QHBoxLayout, QLabel, QLineEdit, + QListWidget, QMainWindow, QMessageBox, QPushButton, + QScrollArea, + QSizePolicy, QSlider, + QSplitter, + QToolButton, QVBoxLayout, QWidget, ) -from . import bundle +from . import bundle, lilypond from .bundle import METADATA_FIELDS from .detect import deskew, detect_page from .pdf import Source, open_source, page_raster -from .project import Cut, Project, open_project +from .project import ( + JUMP_TYPES, + LABELLED_TYPES, + MARKER_TYPES, + Cut, + Marker, + Project, + open_project, +) from .render import apply_levels 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 AUTOSAVE_MS = 800 @@ -60,6 +74,10 @@ _CUT_ACTIVE = QColor(255, 120, 0) _VERTEX = QColor(255, 200, 0) _DISCARD = QColor(120, 120, 140, 90) _RECT = QColor(40, 140, 220) +_MARKER = QColor(150, 60, 190) +_ENGRAVED = QColor(200, 120, 0) +_ENGRAVED_WASH = QColor(230, 160, 30, 55) +_BADGE_Z = 10 class PageView(QGraphicsView): @@ -67,6 +85,8 @@ class PageView(QGraphicsView): changed = Signal() selection_changed = Signal() + picked = Signal(int, int) # page, slot — a jump target chosen by clicking + engrave_requested = Signal() def __init__(self) -> None: super().__init__() @@ -80,6 +100,7 @@ class PageView(QGraphicsView): self.pixmap: QPixmap | None = None self.selected_cut: int | None = None self.selected_slice = 0 + self.picking = False self._drag: tuple[str, int, int] | None = None # -- state ------------------------------------------------------------ @@ -124,6 +145,65 @@ class PageView(QGraphicsView): pen.setCosmetic(True) scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen) + for slot in range(self.page.slice_count): + above, _ = self.page.bounds(slot) + top = 0 if above is None else int(above.lowest * h) + x = w * 0.015 + + if self.page.replacements[slot]: + # 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): colour = _CUT_ACTIVE if i == self.selected_cut else _CUT pen = QPen(colour, 2) @@ -189,6 +269,15 @@ class PageView(QGraphicsView): return super().mousePressEvent(event) x, y = self._norm(event.position().toPoint()) + if self.picking: + # Choosing a jump's target: click the slice it lands on. Cheaper + # than a thumbnail picker and it reads the score rather than a list. + if event.button() == Qt.LeftButton: + self.picked.emit(self.page_index, self._slice_at(x, y)) + self.picking = False + self.setCursor(Qt.ArrowCursor) + return + if event.button() == Qt.RightButton: hit = self._hit_cut(x, y) if hit: @@ -271,10 +360,18 @@ class PageView(QGraphicsView): if self.project is None or self.pixmap is None: return x, y = self._norm(event.position().toPoint()) - if self._hit_cut(x, y) is None: - self.selected_cut = self.page.add_cut(Cut.straight(y)) - self.redraw() - self.changed.emit() + if self._hit_cut(x, y) is not None: + return + if event.modifiers() & Qt.ShiftModifier: + # 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: factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 @@ -299,29 +396,80 @@ class Editor(QMainWindow): self._raw: dict[int, np.ndarray] = {} self.setWindowTitle(f"noteman-slicer — {source.path.name}") + self._targeting = 0 self.view = PageView() self.view.changed.connect(self._touched) self.view.selection_changed.connect(self._sync) + self.view.picked.connect(self._target_picked) + self.view.engrave_requested.connect(self._open_engrave) self.autosave = QTimer(self) self.autosave.setSingleShot(True) self.autosave.setInterval(AUTOSAVE_MS) self.autosave.timeout.connect(self._save) - central = QWidget() - layout = QHBoxLayout(central) - layout.addWidget(self.view, 1) - layout.addWidget(self._panel()) - self.setCentralWidget(central) + splitter = QSplitter(Qt.Horizontal) + splitter.addWidget(self.view) + splitter.addWidget(self._panel()) + splitter.setStretchFactor(0, 1) # the page takes the slack when resized + splitter.setStretchFactor(1, 0) + splitter.setSizes([1100, PANEL_WIDTH]) + splitter.setCollapsible(0, False) + self.setCentralWidget(splitter) self._shortcuts() self._load_page(0) # -- 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: - panel = QWidget() - panel.setFixedWidth(320) - box = QVBoxLayout(panel) + inner = QWidget() + box = QVBoxLayout(inner) + panel = QScrollArea() + panel.setWidget(inner) + panel.setWidgetResizable(True) + panel.setMinimumWidth(260) nav = QHBoxLayout() self.page_label = QLabel() @@ -333,8 +481,9 @@ class Editor(QMainWindow): nav.addWidget(nxt) box.addLayout(nav) - page_box = QGroupBox("Page") - form = QFormLayout(page_box) + page_section = self._section("Page", box) + form = QFormLayout() + page_section.addLayout(form) self.skew = QDoubleSpinBox() self.skew.setRange(-15.0, 15.0) self.skew.setSingleStep(0.1) @@ -360,17 +509,62 @@ class Editor(QMainWindow): reset.setToolTip("Back to the rectangle detection proposed for this page") reset.clicked.connect(self._reset_rect) form.addRow(reset) - box.addWidget(page_box) - meta_box = QGroupBox("Song") - meta_form = QFormLayout(meta_box) + marker_layout = self._section("Markers on this slice", box) + self.marker_list = QListWidget() + self.marker_list.setMaximumHeight(110) + marker_layout.addWidget(self.marker_list) + + add_row = QHBoxLayout() + self.marker_type = QComboBox() + self.marker_type.addItems(MARKER_TYPES) + self.marker_type.currentTextChanged.connect(self._marker_type_changed) + add_row.addWidget(self.marker_type, 1) + self.marker_label = QLineEdit() + self.marker_label.setPlaceholderText("label") + self.marker_label.setFixedWidth(70) + add_row.addWidget(self.marker_label) + marker_layout.addLayout(add_row) + + button_row = QHBoxLayout() + add = QPushButton("Add") + add.clicked.connect(self._add_marker) + remove = QPushButton("Remove") + remove.clicked.connect(self._remove_marker) + self.retarget = QPushButton("Set target…") + self.retarget.clicked.connect(self._pick_target) + for button in (add, remove, self.retarget): + button_row.addWidget(button) + marker_layout.addLayout(button_row) + self._marker_type_changed(self.marker_type.currentText()) + + # Optional feature: without LilyPond installed the pane never appears, + # 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] = {} for field in METADATA_FIELDS: edit = QLineEdit(self.project.metadata.get(field, "")) edit.textChanged.connect(self._metadata_changed) self.metadata[field] = edit - meta_form.addRow(field.replace("_", " ").title(), edit) - box.addWidget(meta_box) + required = field == "title" + if required: + edit.setPlaceholderText("required") + meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit) self.summary = QLabel() self.summary.setWordWrap(True) @@ -386,7 +580,9 @@ class Editor(QMainWindow): "Drag: move cut · Ctrl-click: add vertex\n" "Right-click: delete cut or vertex\n" "Click a slice, then D to discard\n" - "Drag the blue edges: content rectangle" + "Drag the blue edges: content rectangle\n" + "Jump markers: add, then click the target slice\n" + "Shift-double-click a slice: re-engrave it" ) help_text.setStyleSheet("color: palette(mid);") box.addWidget(help_text) @@ -444,6 +640,8 @@ class Editor(QMainWindow): widget.blockSignals(True) widget.setValue(value) widget.blockSignals(False) + self._sync_markers() + self._sync_replacement() kept = len(self.project.kept_slices()) total = sum(p.slice_count for p in self.project.pages) state = "discarded" if page.discards[self.view.selected_slice] else "kept" @@ -469,6 +667,91 @@ class Editor(QMainWindow): self.view.show_page(self.project, self.index, self._preview(self.index)) self._touched() + # -- markers ---------------------------------------------------------- + + def _slot_markers(self) -> list[Marker]: + return self.project.pages[self.index].markers[self.view.selected_slice] + + def _marker_type_changed(self, kind: str) -> None: + self.marker_label.setEnabled(kind in LABELLED_TYPES) + self.retarget.setEnabled(kind in JUMP_TYPES) + + def _add_marker(self) -> None: + kind = self.marker_type.currentText() + label = self.marker_label.text().strip() or None + marker = Marker(type=kind, label=label if kind in LABELLED_TYPES else None) + self._slot_markers().append(marker) + self.marker_label.clear() + self.view.redraw() + self._touched() + if marker.is_jump: + # A jump is useless without a target, so ask for it immediately + # rather than leaving it to be noticed at export. + self._pick_target() + + def _remove_marker(self) -> None: + row = self.marker_list.currentRow() + markers = self._slot_markers() + if 0 <= row < len(markers): + markers.pop(row) + self.view.redraw() + self._touched() + + def _pick_target(self) -> None: + """Arm click-to-pick for the selected jump marker.""" + markers = self._slot_markers() + row = self.marker_list.currentRow() + candidates = [i for i, m in enumerate(markers) if m.is_jump] + if not candidates: + return + self._targeting = row if row in candidates else candidates[-1] + self.view.picking = True + self.view.setCursor(Qt.CrossCursor) + self.statusBar().showMessage( + "Click the slice this jump goes to — any page, PageUp/PageDown to move" + ) + + def _target_picked(self, page: int, slot: int) -> None: + markers = self._slot_markers() + if 0 <= self._targeting < len(markers): + markers[self._targeting].destination = (page, slot) + self.view.redraw() + self._touched() + self.statusBar().showMessage(f"target set to p{page + 1} slice {slot + 1}", 2000) + + def _sync_markers(self) -> None: + self.marker_list.clear() + for marker in self._slot_markers(): + 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: self.project.metadata = { field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip() @@ -494,6 +777,12 @@ class Editor(QMainWindow): def _export(self) -> None: 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( self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)" ) diff --git a/noteman_slicer/engrave.py b/noteman_slicer/engrave.py new file mode 100644 index 0000000..f8142b8 --- /dev/null +++ b/noteman_slicer/engrave.py @@ -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) diff --git a/noteman_slicer/lilypond.py b/noteman_slicer/lilypond.py new file mode 100644 index 0000000..22248dd --- /dev/null +++ b/noteman_slicer/lilypond.py @@ -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") diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py index 7acbc6c..93b39fb 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -67,6 +67,90 @@ class Cut: return min(y for _, y in self.points) +# noteman's enum, verbatim. Real coupling between two repos: adding a type +# means changing both. Order is the order they appear in the editor's picker. +MARKER_TYPES = ( + "rehearsal_letter", + "section_label", + "segno", + "coda", + "fine", + "repeat_start", + "repeat_end", + "volta", + "to_coda", + "ds_al_coda", + "ds_al_fine", + "dc_al_coda", + "dc_al_fine", + "generic_jump", +) + +# The types that carry free text. +LABELLED_TYPES = frozenset({"rehearsal_letter", "section_label", "volta"}) + +# The types that send the reader elsewhere. Every one stores its target +# explicitly rather than resolving by type at read time, so the bundle is +# self-describing and a score with two codas simply works. +JUMP_TYPES = frozenset( + {"to_coda", "ds_al_coda", "ds_al_fine", "dc_al_coda", "dc_al_fine", "generic_jump"} +) + + +@dataclass +class Marker: + """A semantic tag on a slice, used by noteman's navigation.""" + + type: str + label: str | None = None + # (page, slot) of the target slice, for jump sources. Positional like the + # slices themselves; resolved to a bundle index at export. + destination: tuple[int, int] | None = None + + @property + def is_jump(self) -> bool: + return self.type in JUMP_TYPES + + def describe(self) -> str: + text = self.type + if self.label: + text += f" “{self.label}”" + if self.destination: + text += f" → p{self.destination[0] + 1}s{self.destination[1] + 1}" + 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 class Page: """One page's decisions. `cuts` are ordered top to bottom.""" @@ -74,6 +158,11 @@ class Page: skew: float = 0.0 cuts: list[Cut] = field(default_factory=list) discards: list[bool] = field(default_factory=lambda: [False]) + # One list per slice, parallel to `discards`. + 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 levels: tuple[int, int] | None = None @@ -92,8 +181,13 @@ class Page: y = cut.points[0][1] index = sum(1 for c in self.cuts if c.points[0][1] < y) self.cuts.insert(index, cut) - # The split slice keeps its flag on both halves. + # The split slice keeps its flag on both halves. Its markers stay with + # the upper half: a marker sits on a printed symbol, and splitting a + # slice cannot say which side that symbol landed on — leaving them put + # is at least predictable, and moving one is a click. self.discards.insert(index, self.discards[index]) + self.markers.insert(index + 1, []) + self.replacements.insert(index + 1, None) return index def remove_cut(self, index: int) -> None: @@ -102,6 +196,16 @@ class Page: merged = self.discards[index] and self.discards[index + 1] self.discards.pop(index + 1) self.discards[index] = merged + 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 @@ -112,6 +216,12 @@ class Project: content_rect: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) levels: tuple[int, int] = (0, 255) 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 # 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 @@ -175,6 +285,8 @@ class Project: skew=detection.skew, cuts=[Cut.straight(y / height) for y in ys], discards=discards, + markers=[[] for _ in discards], + replacements=[None] * len(discards), # Per page, not per song: scans drift, so the margin junk # sits in a different place on each one. content_rect=detection.content, @@ -193,11 +305,43 @@ class Project: "content_rect": list(self.content_rect), "levels": list(self.levels), "metadata": self.metadata, + "key": self.key, + "time": self.time, + "clefs": self.clefs, "pages": [ { "skew": page.skew, "cuts": [[list(p) for p in cut.points] for cut in page.cuts], "discards": page.discards, + "markers": [ + [ + { + "type": m.type, + **({"label": m.label} if m.label else {}), + **( + {"destination": list(m.destination)} + if m.destination + else {} + ), + } + for m in slot + ] + 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, "levels": list(page.levels) if page.levels else None, } @@ -222,6 +366,38 @@ class Project: skew=page["skew"], cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]], discards=page["discards"], + markers=[ + [ + Marker( + type=m["type"], + label=m.get("label"), + destination=tuple(m["destination"]) if m.get("destination") else None, + ) + for m in slot + ] + 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, levels=tuple(page["levels"]) if page["levels"] else None, ) @@ -236,6 +412,9 @@ class Project: metadata=data.get("metadata", {}), path=path, 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: diff --git a/noteman_slicer/render.py b/noteman_slicer/render.py index 49c0365..7659194 100644 --- a/noteman_slicer/render.py +++ b/noteman_slicer/render.py @@ -19,6 +19,7 @@ from dataclasses import dataclass import cv2 import numpy as np +from . import lilypond from .detect import deskew, staff_height from .pdf import Source, page_raster 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.""" out: list[SliceImage] = [] for index in range(len(project.pages)): - page = page_pixels(project, source, index) - for slot in range(project.pages[index].slice_count): - if project.pages[index].discards[slot]: + page_state = project.pages[index] + # Only rasterize the page if some slice on it still comes from the scan. + page = None + for slot in range(page_state.slice_count): + if page_state.discards[slot]: 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: continue # a kept slice that turned out to hold no ink out.append(SliceImage(index, slot, gray, staff_height(gray, 0, gray.shape[0]))) diff --git a/tests/test_lilypond.py b/tests/test_lilypond.py new file mode 100644 index 0000000..6645b76 --- /dev/null +++ b/tests/test_lilypond.py @@ -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()) diff --git a/tests/test_markers.py b/tests/test_markers.py new file mode 100644 index 0000000..a1d1e3c --- /dev/null +++ b/tests/test_markers.py @@ -0,0 +1,115 @@ +"""Runnable check for markers: model, cut edits, and export resolution.""" + +from __future__ import annotations + +import json +import sys +import zipfile +from pathlib import Path + +import numpy as np +import pymupdf + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from noteman_slicer import bundle # noqa: E402 +from noteman_slicer.bundle import song_json # 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.project import ( # noqa: E402 + JUMP_TYPES, + MARKER_TYPES, + Cut, + Marker, + Project, + default_path, +) + +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: + tmp = Path(__file__).with_name("_tmp") + tmp.mkdir(exist_ok=True) + pdf = tmp / "scan.pdf" + _scan_pdf(pdf) + + # noteman's enum, verbatim — this is real coupling between two repos. + assert len(MARKER_TYPES) == 14, MARKER_TYPES + assert len(JUMP_TYPES) == 6 + assert "generic_jump" in JUMP_TYPES and "segno" not in JUMP_TYPES + + 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.markers) == page.slice_count + + kept = project.kept_slices() + assert len(kept) == 2, kept + (_, first), (_, second) = kept + + page.markers[first].append(Marker("rehearsal_letter", label="A")) + page.markers[second].append(Marker("coda")) + page.markers[first].append(Marker("to_coda", destination=(0, second))) + + # Cut edits keep markers aligned with their slices. + before = list(page.markers[first]) + index = page.add_cut(Cut.straight(0.95)) + assert len(page.markers) == page.slice_count + assert page.markers[first] == before, "markers must not move when a later slice splits" + page.remove_cut(index) + assert len(page.markers) == page.slice_count + + # Export resolves (page, slot) to the slice's index in the bundle. + names = [f"{i + 1:03}.webp" for i in range(len(project.kept_slices()))] + payload = song_json(project, names) + slices = payload["slices"] + assert [s["file"] for s in slices] == names + assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"} + assert slices[1]["markers"][0] == {"type": "coda"} + assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1} + + # A jump whose target got discarded is dropped, not exported dangling. + project.pages[0].discards[second] = True + dropped = song_json(project, ["001.webp"]) + assert all(m["type"] != "to_coda" for m in dropped["slices"][0].get("markers", [])) + project.pages[0].discards[second] = False + + # Round-trip through the project file. + saved = project.save() + reloaded = Project.load(saved) + assert reloaded.pages[0].markers[first][0].label == "A" + assert reloaded.pages[0].markers[first][1].destination == (0, second) + assert reloaded.pages[0].markers[second][0].type == "coda" + + # And through a real bundle. + reloaded.metadata["title"] = "Test song" + out = bundle.write(reloaded, source, tmp / "song.zip") + with zipfile.ZipFile(out) as zf: + meta = json.loads(zf.read("song.json")) + assert meta["slices"][0]["markers"][1]["destination"] == 1, meta["slices"] + + source.close() + for f in (pdf, out, saved, default_path(pdf)): + f.unlink(missing_ok=True) + tmp.rmdir() + print("ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_render.py b/tests/test_render.py index a6130ad..2ba1510 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -139,7 +139,15 @@ def main() -> int: src2.close() 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") with zipfile.ZipFile(out) as zf: names = zf.namelist()