diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py index ee6289f..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", ) @@ -72,8 +75,18 @@ def song_json(project: Project, files: list[str]) -> dict: 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 972f786..6829621 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -33,7 +33,6 @@ from PySide6.QtWidgets import ( QGraphicsScene, QGraphicsView, QComboBox, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, @@ -41,12 +40,16 @@ from PySide6.QtWidgets import ( 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 @@ -62,6 +65,7 @@ from .project import ( 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 @@ -71,6 +75,9 @@ _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): @@ -79,6 +86,7 @@ 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__() @@ -138,15 +146,63 @@ class PageView(QGraphicsView): scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen) for slot in range(self.page.slice_count): - markers = self.page.markers[slot] - if not markers: - continue above, _ = self.page.bounds(slot) top = 0 if above is None else int(above.lowest * h) - text = scene.addText(" · ".join(m.describe() for m in markers)) - text.setDefaultTextColor(_MARKER) - text.setScale(max(1.0, w / 900)) - text.setPos(w * 0.02, top + h * 0.004) + 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 @@ -304,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 @@ -337,26 +401,75 @@ class Editor(QMainWindow): 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() @@ -368,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) @@ -395,10 +509,8 @@ 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) - marker_box = QGroupBox("Markers on this slice") - marker_layout = QVBoxLayout(marker_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) @@ -424,18 +536,35 @@ class Editor(QMainWindow): for button in (add, remove, self.retarget): button_row.addWidget(button) marker_layout.addLayout(button_row) - box.addWidget(marker_box) self._marker_type_changed(self.marker_type.currentText()) - meta_box = QGroupBox("Song") - meta_form = QFormLayout(meta_box) + # 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) @@ -452,7 +581,8 @@ class Editor(QMainWindow): "Right-click: delete cut or vertex\n" "Click a slice, then D to discard\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);") box.addWidget(help_text) @@ -511,6 +641,7 @@ class Editor(QMainWindow): 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" @@ -593,6 +724,34 @@ class Editor(QMainWindow): 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() @@ -618,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 69cf698..93b39fb 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -120,6 +120,37 @@ class Marker: 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.""" @@ -129,6 +160,9 @@ class Page: 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 @@ -153,6 +187,7 @@ class Page: # 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: @@ -162,6 +197,15 @@ class Page: 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 @@ -172,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 @@ -236,6 +286,7 @@ class Project: 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, @@ -254,6 +305,9 @@ 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, @@ -274,6 +328,20 @@ class Project: ] 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, } @@ -309,6 +377,27 @@ class Project: ] 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, ) @@ -323,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 index e397866..a1d1e3c 100644 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -97,6 +97,7 @@ def main() -> int: 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")) 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()