Compare commits
2
Commits
29d9129bb7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce4fc8f99 | ||
|
|
8f670cf7db |
@@ -174,6 +174,7 @@ it came from, in an `engraving` object.
|
|||||||
"key": "aes",
|
"key": "aes",
|
||||||
"time": "4/4",
|
"time": "4/4",
|
||||||
"print_time": false,
|
"print_time": false,
|
||||||
|
"bar": 33,
|
||||||
"voices": [
|
"voices": [
|
||||||
{ "clef": "treble", "notes": "c4 des ees f | ees2. r4", "lyrics": "Kai -- paa -- va sy -- dän" },
|
{ "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" },
|
{ "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"`. |
|
| `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"`. |
|
| `time` | string | optional | Time signature, as `"4/4"`. |
|
||||||
| `print_time` | boolean | optional | Whether the time signature is printed on this system. Default `false`. |
|
| `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`:
|
Each entry of `voices`:
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ def _engraving(project: Project, page: int, slot: int) -> dict | None:
|
|||||||
"key": replacement.key or project.key,
|
"key": replacement.key or project.key,
|
||||||
"time": replacement.time or project.time,
|
"time": replacement.time or project.time,
|
||||||
"print_time": replacement.print_time,
|
"print_time": replacement.print_time,
|
||||||
|
**({"bar": replacement.bar} if replacement.bar else {}),
|
||||||
"voices": [
|
"voices": [
|
||||||
{"clef": v.clef, "notes": v.notes.strip()}
|
{"clef": v.clef, "notes": v.notes.strip()}
|
||||||
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
|
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
|
||||||
|
|||||||
+47
-46
@@ -84,6 +84,49 @@ _ENGRAVED_WASH = QColor(230, 160, 30, 55)
|
|||||||
_BADGE_Z = 10
|
_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):
|
class PageView(QGraphicsView):
|
||||||
"""Pan, zoom, and direct manipulation of cuts and the content rectangle."""
|
"""Pan, zoom, and direct manipulation of cuts and the content rectangle."""
|
||||||
|
|
||||||
@@ -426,48 +469,6 @@ class Editor(QMainWindow):
|
|||||||
|
|
||||||
# -- ui ---------------------------------------------------------------
|
# -- 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:
|
def _panel(self) -> QWidget:
|
||||||
inner = QWidget()
|
inner = QWidget()
|
||||||
box = QVBoxLayout(inner)
|
box = QVBoxLayout(inner)
|
||||||
@@ -486,7 +487,7 @@ class Editor(QMainWindow):
|
|||||||
nav.addWidget(nxt)
|
nav.addWidget(nxt)
|
||||||
box.addLayout(nav)
|
box.addLayout(nav)
|
||||||
|
|
||||||
page_section = self._section("Page", box)
|
page_section = section("Page", box)
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
page_section.addLayout(form)
|
page_section.addLayout(form)
|
||||||
self.skew = QDoubleSpinBox()
|
self.skew = QDoubleSpinBox()
|
||||||
@@ -515,7 +516,7 @@ class Editor(QMainWindow):
|
|||||||
reset.clicked.connect(self._reset_rect)
|
reset.clicked.connect(self._reset_rect)
|
||||||
form.addRow(reset)
|
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 = QListWidget()
|
||||||
self.marker_list.setMaximumHeight(110)
|
self.marker_list.setMaximumHeight(110)
|
||||||
marker_layout.addWidget(self.marker_list)
|
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.
|
# slices are never re-engraved, and it is the tallest block here.
|
||||||
self.ly_status = None
|
self.ly_status = None
|
||||||
if lilypond.available():
|
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 = QPushButton("Open engrave window…")
|
||||||
open_engrave.setToolTip("Or double-click the slice on the page")
|
open_engrave.setToolTip("Or double-click the slice on the page")
|
||||||
open_engrave.clicked.connect(self._open_engrave)
|
open_engrave.clicked.connect(self._open_engrave)
|
||||||
@@ -558,7 +559,7 @@ class Editor(QMainWindow):
|
|||||||
self.ly_status.setStyleSheet("color: #808080;")
|
self.ly_status.setStyleSheet("color: #808080;")
|
||||||
ly_layout.addWidget(self.ly_status)
|
ly_layout.addWidget(self.ly_status)
|
||||||
|
|
||||||
meta_layout = self._section("Song", box)
|
meta_layout = section("Song", box)
|
||||||
meta_form = QFormLayout()
|
meta_form = QFormLayout()
|
||||||
meta_layout.addLayout(meta_form)
|
meta_layout.addLayout(meta_form)
|
||||||
self.metadata: dict[str, QLineEdit] = {}
|
self.metadata: dict[str, QLineEdit] = {}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PySide6.QtCore import Qt
|
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 (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from . import lilypond
|
from . import lilypond
|
||||||
from .detect import staff_height
|
from .detect import staff_height
|
||||||
|
from .editor import section
|
||||||
from .project import Project, Replacement, Voice
|
from .project import Project, Replacement, Voice
|
||||||
|
|
||||||
|
|
||||||
@@ -180,6 +181,16 @@ class EngraveWindow(QDialog):
|
|||||||
self.print_time.toggled.connect(self._settings_changed)
|
self.print_time.toggled.connect(self._settings_changed)
|
||||||
row.addWidget(self.print_time, 1)
|
row.addWidget(self.print_time, 1)
|
||||||
top.addRow("Time", row)
|
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)
|
box.addLayout(top)
|
||||||
|
|
||||||
voices_label = QLabel("Voices")
|
voices_label = QLabel("Voices")
|
||||||
@@ -209,11 +220,15 @@ class EngraveWindow(QDialog):
|
|||||||
self.status.setWordWrap(True)
|
self.status.setWordWrap(True)
|
||||||
box.addWidget(self.status)
|
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 = QPlainTextEdit()
|
||||||
self.generated.setReadOnly(True)
|
self.generated.setReadOnly(True)
|
||||||
self.generated.setMaximumHeight(120)
|
self.generated.setMaximumHeight(220)
|
||||||
self.generated.setStyleSheet("color: #808080;")
|
self.generated.setStyleSheet("color: #808080;")
|
||||||
box.addWidget(self.generated)
|
raw.addWidget(self.generated)
|
||||||
self._refresh_source()
|
self._refresh_source()
|
||||||
return panel
|
return panel
|
||||||
|
|
||||||
@@ -240,6 +255,10 @@ class EngraveWindow(QDialog):
|
|||||||
row.setParent(None)
|
row.setParent(None)
|
||||||
self._refresh_source()
|
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:
|
def _settings_changed(self) -> None:
|
||||||
# Set on the song, not the slice: they are song properties in practice,
|
# 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.
|
# and this is what makes the next re-engraved slice open pre-filled.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ scaling.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -63,6 +64,25 @@ RELATIVE_REFERENCE = {
|
|||||||
"bass": "c",
|
"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"
|
_PREAMBLE = """\\version "2.24.0"
|
||||||
\\paper {
|
\\paper {
|
||||||
indent = 0\\mm
|
indent = 0\\mm
|
||||||
@@ -85,17 +105,35 @@ def generate(replacement, key: str, time: str) -> str:
|
|||||||
key = replacement.key or key
|
key = replacement.key or key
|
||||||
time = replacement.time or time
|
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 = []
|
staves = []
|
||||||
for voice in replacement.voices:
|
for voice in replacement.voices:
|
||||||
hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n"
|
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'")
|
reference = RELATIVE_REFERENCE.get(voice.clef, "c'")
|
||||||
staff = (
|
staff = (
|
||||||
" \\new Staff {\n"
|
" \\new Staff {\n"
|
||||||
f"{hide}"
|
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" \\key {key} \\major\n"
|
||||||
f" \\time {time}\n"
|
f" \\time {time}\n"
|
||||||
|
f"{number if not staves else ''}"
|
||||||
f" \\relative {reference} {{ {body} }}\n"
|
f" \\relative {reference} {{ {body} }}\n"
|
||||||
" }\n"
|
" }\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ class Replacement:
|
|||||||
voices: list[Voice] = field(default_factory=list)
|
voices: list[Voice] = field(default_factory=list)
|
||||||
key: str | None = None
|
key: str | None = None
|
||||||
time: 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
|
# 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.
|
# time signature, so a re-engraved middle slice must not show one.
|
||||||
print_time: bool = False
|
print_time: bool = False
|
||||||
@@ -356,6 +360,7 @@ class Project:
|
|||||||
**({"key": r.key} if r.key else {}),
|
**({"key": r.key} if r.key else {}),
|
||||||
**({"time": r.time} if r.time else {}),
|
**({"time": r.time} if r.time else {}),
|
||||||
**({"print_time": True} if r.print_time else {}),
|
**({"print_time": True} if r.print_time else {}),
|
||||||
|
**({"bar": r.bar} if r.bar else {}),
|
||||||
}
|
}
|
||||||
for r in page.replacements
|
for r in page.replacements
|
||||||
],
|
],
|
||||||
@@ -412,6 +417,7 @@ class Project:
|
|||||||
key=r.get("key"),
|
key=r.get("key"),
|
||||||
time=r.get("time"),
|
time=r.get("time"),
|
||||||
print_time=r.get("print_time", False),
|
print_time=r.get("print_time", False),
|
||||||
|
bar=r.get("bar"),
|
||||||
)
|
)
|
||||||
for r in page.get("replacements", [None] * len(page["discards"]))
|
for r in page.get("replacements", [None] * len(page["discards"]))
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -52,6 +52,39 @@ def _scan_pdf(path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
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():
|
if not lilypond.available():
|
||||||
print("ok (skipped: LilyPond not installed)")
|
print("ok (skipped: LilyPond not installed)")
|
||||||
return 0
|
return 0
|
||||||
@@ -138,6 +171,7 @@ def main() -> int:
|
|||||||
assert page.replacements[second] is SATB
|
assert page.replacements[second] is SATB
|
||||||
|
|
||||||
# Round-trip, including the song-level engraving defaults.
|
# Round-trip, including the song-level engraving defaults.
|
||||||
|
SATB.bar = 33
|
||||||
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
|
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
|
||||||
saved = project.save()
|
saved = project.save()
|
||||||
reloaded = Project.load(saved)
|
reloaded = Project.load(saved)
|
||||||
@@ -146,6 +180,7 @@ def main() -> int:
|
|||||||
assert restored is not None
|
assert restored is not None
|
||||||
assert [v.clef for v in restored.voices] == ["treble", "bass"]
|
assert [v.clef for v in restored.voices] == ["treble", "bass"]
|
||||||
assert restored.voices[0].lyrics == "la la la la la la"
|
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
|
assert reloaded.pages[0].replacements[first] is None
|
||||||
|
|
||||||
source.close()
|
source.close()
|
||||||
|
|||||||
@@ -98,14 +98,15 @@ def main() -> int:
|
|||||||
# from the bundle alone.
|
# from the bundle alone.
|
||||||
project.key, project.time = "aes", "3/4"
|
project.key, project.time = "aes", "3/4"
|
||||||
page.replacements[second] = Replacement(
|
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"]
|
engraved = song_json(project, names)["slices"]
|
||||||
assert "engraving" not in engraved[0], "a scanned slice has no notation"
|
assert "engraving" not in engraved[0], "a scanned slice has no notation"
|
||||||
ly = engraved[1]["engraving"]
|
ly = engraved[1]["engraving"]
|
||||||
assert ly["lang"] == "lilypond"
|
assert ly["lang"] == "lilypond"
|
||||||
# Song defaults are resolved per slice: reading one slice needs no context.
|
# 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 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 "lyrics" not in ly["voices"][1], "an empty field is absent, not empty"
|
||||||
assert ly["voices"][1]["notes"] == "c4 d e f"
|
assert ly["voices"][1]["notes"] == "c4 d e f"
|
||||||
|
|||||||
Reference in New Issue
Block a user