diff --git a/docs/bundle-format.md b/docs/bundle-format.md index e1d29f8..4ee874a 100644 --- a/docs/bundle-format.md +++ b/docs/bundle-format.md @@ -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`: diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py index 74d55d4..c5cc4b7 100644 --- a/noteman_slicer/bundle.py +++ b/noteman_slicer/bundle.py @@ -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 {}) diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py index 308c8fe..45ebf85 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -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] = {} diff --git a/noteman_slicer/engrave.py b/noteman_slicer/engrave.py index f8142b8..de69bb2 100644 --- a/noteman_slicer/engrave.py +++ b/noteman_slicer/engrave.py @@ -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. diff --git a/noteman_slicer/lilypond.py b/noteman_slicer/lilypond.py index 22248dd..56ce189 100644 --- a/noteman_slicer/lilypond.py +++ b/noteman_slicer/lilypond.py @@ -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" ) diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py index cd3d804..632874c 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -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"])) ], diff --git a/tests/test_lilypond.py b/tests/test_lilypond.py index 6645b76..b1ec9fa 100644 --- a/tests/test_lilypond.py +++ b/tests/test_lilypond.py @@ -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() diff --git a/tests/test_markers.py b/tests/test_markers.py index ee1e23a..d8a09cf 100644 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -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"