Add LilyPond slice replacement with a structured engrave window

Re-engraving is a rescue path for the handful of systems a scan cannot
deliver, so the window is an editing surface rather than an automation
project. Three full-width rows - the scanned system, the render, the
form - because a system is wide and short and the job is comparing one
against the other bar by bar. The render is shown scaled to the scan's
staff height, which is what export does anyway, so it previews the real
thing.

A form rather than a text box. Key and time are slice-level, clef,
notes and lyrics per voice: every staff in a system carries the same key
signature, and Kaipaava proves it across five-staff and two-staff
systems alike. Notes and lyrics stay raw LilyPond, so slurs, dynamics,
tuplets and the laissezVibrer/repeatTie idiom for ties crossing into the
next slice all work untouched.

Notes are entered in \relative mode, referenced to the middle of each
clef's staff, so a part needs no octave marks at all in the common case.

The time signature is used for spacing and bar checks but not printed:
the printed score repeats the key at every system and the time only at
the first, so a re-engraved middle slice showing one would stand out.

Seeded from what can be known reliably. Voice count comes from counting
staves in the slice; key, time and clefs are inherited from the song,
because the slices being re-engraved are the illegible ones and reading
a key signature off them is exactly the measurement that fails. After
the first replacement in a song only the notes need typing.

Staff counting needed two corrections against the corpus: compare gaps
against line spacing rather than staff height, since adjacent staves can
sit closer together than one staff is tall; and require five lines in a
group, since Engel's 'uh______' lyric extenders are long horizontal runs
too and each counted as a staff. Kaipaava now reads 2,2,2,2,5 on page 1,
Ketun 6, Engel 4.

Also in this change:

- Title is required for export, every other metadata field optional,
  enforced in bundle.write so the CLI and the editor both get it. Tempo
  added; noteman already has a free-form column for it.
- The panel is a splitter rather than a fixed width, sections collapse
  under bold grey disclosure headers, and it scrolls.
- A re-engraved slice is washed amber with an ENGRAVED badge, and
  markers get badges too. Thin coloured text was invisible against a
  scan.

Closes #31
Closes #32
Closes #33
Closes #34
This commit is contained in:
Esa Kataja
2026-07-29 01:29:43 +03:00
parent ff1cc6740e
commit 97c8e8a709
11 changed files with 1022 additions and 39 deletions
+297
View File
@@ -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)