A system can now be given the measure it starts at, in a First bar field beside key and time. Per slice, since it is the one thing about a replacement that cannot be inherited from the song. Bar numbering is a Score property, so it is set once on the first staff, and made visible only at a line beginning — that vector is fussy: #(#f #t #t) also prints a number mid-system and #(#f #t #f) prints the second bar's rather than the first's. It travels in the bundle's engraving object as `bar`. Two things LilyPond 2.24 was quietly refusing to draw: `\bar ":|"` and the other old repeat names produce nothing at all — no error, no warning, exit status 0, just a missing repeat that you find on the tablet. Every book and forum answer still uses them, so translate them to the modern spellings. `\clef treble_8` unquoted is not an octavated clef either. It parses as a plain treble plus a stray "8" markup that lands under the first note, and the staff then reads an octave off — a tenor line engraved at soprano pitch. Quote it. The source pane, which is where either of those would have been visible, is now a collapsed section at the bottom rather than a permanent slab. It uses the panel's own disclosure helper, lifted out of Editor so both can call it.
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""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)
|
||
|
||
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)
|
||
|
||
# Per slice, unlike key and time: which measure a system starts at is
|
||
# the one thing that changes with every slice and cannot be inherited.
|
||
self.bar = QLineEdit("" if self.replacement.bar is None else str(self.replacement.bar))
|
||
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.replacement.bar = 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 _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)
|