Files
noteman-slicer/noteman_slicer/engrave.py
T
Esa Kataja 61b8ec8301 Make a bundle reopenable, and number slices
A bundle was a one-way trip. The slice images are output and the cuts
that produced them lived only in the producer's own project file, so a
bundle someone handed you meant cutting the score again from scratch.

The manifest now carries the geometry, in a `source` block: per page the
cut polylines, skew, levels and content rectangle, all in normalised
coordinates so they survive any render resolution, and per slice the page
and slot it came from. Discards are stated by omission — a slot no slice
claims was discarded — since shipping a discarded slice's image would
defeat discarding it.

`noteman-slicer open song.zip` unpacks the archived PDF, rebuilds the
project from that geometry, restores markers, engravings and the title
block, and opens the editor. Jump destinations go back from an array
index to the (page, slot) the editor works in. The images in the zip are
discarded: the PDF is what the pipeline renders from. Re-exporting a
reopened bundle reproduces its manifest exactly. It refuses to overwrite
a PDF or project file already sitting there, because the obvious place to
unpack is where someone's unfinished cuts live.

Separately, every slice can now carry the measure it starts at, not just
a re-engraved one — a scanned system is numbered in the score the same
way, and noteman wants to answer "take it from bar 33" about either. It
moves off the replacement onto the page, alongside markers and discards,
and out of the bundle's engraving object onto the slice.
2026-07-29 14:30:54 +03:00

322 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, QIntValidator, 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 .editor import section
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)
self.state = project.pages[page]
self.replacement = self.state.replacements[slot] or self._seed()
self.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)
# A property of the slice, not of the replacement — the same field the
# main panel shows for a scanned slice — so it survives discarding the
# engraving. Unlike key and time it cannot be inherited from the song.
current = self.state.bars[self.slot]
self.bar = QLineEdit("" if current is None else str(current))
self.bar.setValidator(QIntValidator(1, 9999, self.bar))
self.bar.setFixedWidth(70)
self.bar.setPlaceholderText("none")
self.bar.setToolTip("Printed above the first bar, as a printed score numbers its systems")
self.bar.textChanged.connect(self._bar_changed)
top.addRow("First bar", self.bar)
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)
# Collapsed: the source is what the form writes for you, so it is for
# checking what a field did, not for working in. Open it and it stays
# open for the life of the window.
raw = section("LilyPond source", box, expanded=False)
self.generated = QPlainTextEdit()
self.generated.setReadOnly(True)
self.generated.setMaximumHeight(220)
self.generated.setStyleSheet("color: #808080;")
raw.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 _bar_changed(self, text: str) -> None:
self.state.bars[self.slot] = int(text) if text.strip().isdigit() else 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 _source(self) -> str:
return lilypond.generate(
self.replacement, self.project.key, self.project.time, self.state.bars[self.slot]
)
def _refresh_source(self) -> None:
self.generated.setPlainText(self._source())
# -- rendering --------------------------------------------------------
def render(self) -> None:
source = self._source()
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)