Compare commits

...
2 Commits
Author SHA1 Message Date
Esa Kataja 6ce4fc8f99 Merge dev: engrave window bar numbers and LilyPond fixes 2026-07-29 13:46:41 +03:00
Esa Kataja 8f670cf7db Engrave window: bar numbers, and two silent LilyPond faults
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.
2026-07-29 13:44:42 +03:00
8 changed files with 156 additions and 53 deletions
+2
View File
@@ -174,6 +174,7 @@ it came from, in an `engraving` object.
"key": "aes",
"time": "4/4",
"print_time": false,
"bar": 33,
"voices": [
{ "clef": "treble", "notes": "c4 des ees f | ees2. r4", "lyrics": "Kai -- paa -- va sy -- dän" },
{ "clef": "treble_8", "notes": "aes,4 aes aes aes | aes2. r4" },
@@ -190,6 +191,7 @@ it came from, in an `engraving` object.
| `key` | string | optional | Key signature, in `lang`'s spelling. For `lilypond`, the tonic of the major spelling: `"aes"`, `"c"`, `"fis"`. |
| `time` | string | optional | Time signature, as `"4/4"`. |
| `print_time` | boolean | optional | Whether the time signature is printed on this system. Default `false`. |
| `bar` | integer | optional | The measure this system starts at, as printed above its first bar. Omitted when the system is not numbered. |
Each entry of `voices`:
+1
View File
@@ -69,6 +69,7 @@ def _engraving(project: Project, page: int, slot: int) -> dict | None:
"key": replacement.key or project.key,
"time": replacement.time or project.time,
"print_time": replacement.print_time,
**({"bar": replacement.bar} if replacement.bar else {}),
"voices": [
{"clef": v.clef, "notes": v.notes.strip()}
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
+47 -46
View File
@@ -84,6 +84,49 @@ _ENGRAVED_WASH = QColor(230, 160, 30, 55)
_BADGE_Z = 10
def section(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
class PageView(QGraphicsView):
"""Pan, zoom, and direct manipulation of cuts and the content rectangle."""
@@ -426,48 +469,6 @@ class Editor(QMainWindow):
# -- 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:
inner = QWidget()
box = QVBoxLayout(inner)
@@ -486,7 +487,7 @@ class Editor(QMainWindow):
nav.addWidget(nxt)
box.addLayout(nav)
page_section = self._section("Page", box)
page_section = section("Page", box)
form = QFormLayout()
page_section.addLayout(form)
self.skew = QDoubleSpinBox()
@@ -515,7 +516,7 @@ class Editor(QMainWindow):
reset.clicked.connect(self._reset_rect)
form.addRow(reset)
marker_layout = self._section("Markers on this slice", box)
marker_layout = section("Markers on this slice", box)
self.marker_list = QListWidget()
self.marker_list.setMaximumHeight(110)
marker_layout.addWidget(self.marker_list)
@@ -548,7 +549,7 @@ class Editor(QMainWindow):
# 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)
ly_layout = 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)
@@ -558,7 +559,7 @@ class Editor(QMainWindow):
self.ly_status.setStyleSheet("color: #808080;")
ly_layout.addWidget(self.ly_status)
meta_layout = self._section("Song", box)
meta_layout = section("Song", box)
meta_form = QFormLayout()
meta_layout.addLayout(meta_form)
self.metadata: dict[str, QLineEdit] = {}
+22 -3
View File
@@ -14,7 +14,7 @@ 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.QtGui import QImage, QIntValidator, QKeySequence, QPixmap, QShortcut
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
from . import lilypond
from .detect import staff_height
from .editor import section
from .project import Project, Replacement, Voice
@@ -180,6 +181,16 @@ class EngraveWindow(QDialog):
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")
@@ -209,11 +220,15 @@ class EngraveWindow(QDialog):
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(120)
self.generated.setMaximumHeight(220)
self.generated.setStyleSheet("color: #808080;")
box.addWidget(self.generated)
raw.addWidget(self.generated)
self._refresh_source()
return panel
@@ -240,6 +255,10 @@ class EngraveWindow(QDialog):
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.
+40 -2
View File
@@ -12,6 +12,7 @@ scaling.
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
@@ -63,6 +64,25 @@ RELATIVE_REFERENCE = {
"bass": "c",
}
# LilyPond renamed the repeat barlines and silently draws *nothing* for the old
# names — no error, no warning, just a missing repeat that you find on the
# tablet. Every book, every forum answer and every score anyone has typed before
# uses the old ones, so translate them.
_BAR_ALIASES = {
"|:": ".|:",
":|": ":|.",
":|:": ":|.|:",
"||:": ".|:",
":||": ":|.",
":||:": ":|.|:",
}
_BAR = re.compile(r'(\\bar\s*")([^"]*)(")')
def _modernise_bars(notes: str) -> str:
return _BAR.sub(lambda m: m[1] + _BAR_ALIASES.get(m[2], m[2]) + m[3], notes)
_PREAMBLE = """\\version "2.24.0"
\\paper {
indent = 0\\mm
@@ -85,17 +105,35 @@ def generate(replacement, key: str, time: str) -> str:
key = replacement.key or key
time = replacement.time or time
# Bar numbering is a Score property, so it is set once, on the first staff.
# Visible at the beginning of a line and nowhere else — which in a
# one-system slice means exactly one number, above the first bar, the way a
# printed score numbers its systems. The empty bar line is what gives the
# number a line beginning to attach to.
number = ""
if replacement.bar:
number = (
f" \\set Score.currentBarNumber = #{int(replacement.bar)}\n"
" \\override Score.BarNumber.break-visibility = #'#(#f #f #t)\n"
' \\bar ""\n'
)
staves = []
for voice in replacement.voices:
hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n"
body = voice.notes.strip() or "s1"
body = _modernise_bars(voice.notes.strip()) or "s1"
reference = RELATIVE_REFERENCE.get(voice.clef, "c'")
staff = (
" \\new Staff {\n"
f"{hide}"
f" \\clef {voice.clef}\n"
# Quoted, because an octavated name has to be: unquoted,
# `\clef treble_8` parses as a plain treble clef with a stray "8"
# markup that lands under the first note, and the staff then reads
# an octave off.
f' \\clef "{voice.clef}"\n'
f" \\key {key} \\major\n"
f" \\time {time}\n"
f"{number if not staves else ''}"
f" \\relative {reference} {{ {body} }}\n"
" }\n"
)
+6
View File
@@ -147,6 +147,10 @@ class Replacement:
voices: list[Voice] = field(default_factory=list)
key: str | None = None
time: str | None = None
# The measure this system starts at, printed above its first bar the way a
# score numbers its systems. Per slice and nothing else: it is the one thing
# about a replacement that cannot be inherited or guessed.
bar: int | 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
@@ -356,6 +360,7 @@ class Project:
**({"key": r.key} if r.key else {}),
**({"time": r.time} if r.time else {}),
**({"print_time": True} if r.print_time else {}),
**({"bar": r.bar} if r.bar else {}),
}
for r in page.replacements
],
@@ -412,6 +417,7 @@ class Project:
key=r.get("key"),
time=r.get("time"),
print_time=r.get("print_time", False),
bar=r.get("bar"),
)
for r in page.get("replacements", [None] * len(page["discards"]))
],
+35
View File
@@ -52,6 +52,39 @@ def _scan_pdf(path: Path) -> None:
def main() -> int:
# Bar aliases are string work, so they are checked whether or not LilyPond
# is installed. The old repeat names draw nothing at all in 2.24 — silently,
# which is how a missing repeat reaches a tablet.
aliased = lilypond.generate(
Replacement(voices=[Voice("treble", 'c4 d \\bar ":|" e f \\bar "|:" g', "")]), "c", "4/4"
)
assert '\\bar ":|."' in aliased and '\\bar ".|:"' in aliased, aliased
kept = lilypond.generate(
Replacement(voices=[Voice("treble", 'c4 \\bar "|." d', "")]), "c", "4/4"
)
assert '\\bar "|."' in kept, "a name LilyPond still knows is left alone"
# An octavated clef name must be quoted. Unquoted, `\clef treble_8` is a
# plain treble with a stray "8" markup under the first note, an octave off.
tenor = lilypond.generate(
Replacement(voices=[Voice("treble_8", "c4 d", "")]), "c", "4/4"
)
assert '\\clef "treble_8"' in tenor, tenor
# A bar number is set once, on the first staff, since it is a Score
# property, and is visible only at a line beginning — one number above the
# first bar, as a printed score numbers its systems.
numbered = lilypond.generate(
Replacement(voices=[Voice("treble", "c4 d", ""), Voice("bass", "c4 d", "")], bar=33),
"c",
"4/4",
)
assert numbered.count("currentBarNumber = #33") == 1, numbered
assert "break-visibility = #'#(#f #f #t)" in numbered
assert "currentBarNumber" not in lilypond.generate(
Replacement(voices=[Voice("treble", "c4 d", "")]), "c", "4/4"
), "an unnumbered system prints no number"
if not lilypond.available():
print("ok (skipped: LilyPond not installed)")
return 0
@@ -138,6 +171,7 @@ def main() -> int:
assert page.replacements[second] is SATB
# Round-trip, including the song-level engraving defaults.
SATB.bar = 33
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
saved = project.save()
reloaded = Project.load(saved)
@@ -146,6 +180,7 @@ def main() -> int:
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 restored.bar == 33, "the slice's bar number survives a save"
assert reloaded.pages[0].replacements[first] is None
source.close()
+3 -2
View File
@@ -98,14 +98,15 @@ def main() -> int:
# from the bundle alone.
project.key, project.time = "aes", "3/4"
page.replacements[second] = Replacement(
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")]
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")],
bar=33,
)
engraved = song_json(project, names)["slices"]
assert "engraving" not in engraved[0], "a scanned slice has no notation"
ly = engraved[1]["engraving"]
assert ly["lang"] == "lilypond"
# Song defaults are resolved per slice: reading one slice needs no context.
assert (ly["key"], ly["time"], ly["print_time"]) == ("aes", "3/4", False)
assert (ly["key"], ly["time"], ly["print_time"], ly["bar"]) == ("aes", "3/4", False, 33)
assert ly["voices"][0] == {"clef": "treble", "notes": "c4 d e f", "lyrics": "la la la la"}
assert "lyrics" not in ly["voices"][1], "an empty field is absent, not empty"
assert ly["voices"][1]["notes"] == "c4 d e f"