Compare commits
2
Commits
b594968bb8
...
cf1344c5bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf1344c5bf | ||
|
|
630541c0cd |
@@ -8,7 +8,8 @@ A slice is one *system* — one full line of music across all voices, typically
|
||||
scroll, so the slicer's job is to cut a printed page into systems, clean them up
|
||||
enough to read on a tablet, and tag them with the score's navigation symbols.
|
||||
|
||||
**Status: design only.** No code yet. The design is settled; see below.
|
||||
**New here? [docs/guide.md](docs/guide.md) walks you through making your first
|
||||
bundle.**
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -29,20 +30,25 @@ at it.
|
||||
|
||||
## Installation
|
||||
|
||||
Not yet installable. When it is:
|
||||
|
||||
```
|
||||
uv tool install --editable .
|
||||
```
|
||||
|
||||
That puts a `noteman-slicer` command on PATH which runs from any directory — no venv to
|
||||
activate. Dependencies (PyMuPDF, PySide6, OpenCV, numpy) are all wheels; nothing
|
||||
needs a system package.
|
||||
needs a system package. LilyPond is optional and only enables re-engraving.
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
noteman-slicer edit my-song.pdf
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [docs/guide.md](docs/guide.md) | How to use it: install, cut a score, place markers, export a bundle. Start here if you just want to make one. |
|
||||
| [CONTEXT.md](CONTEXT.md) | Glossary. What a slice, cut, discard, bundle and song scale actually mean here. Start here. |
|
||||
| [docs/spec.md](docs/spec.md) | The specification: pipeline, geometry model, detection, editor, bundle format, and what noteman has to change. |
|
||||
| [docs/bundle-format.md](docs/bundle-format.md) | The Score Bundle Format — a standalone specification of the export format, independent of this tool. |
|
||||
|
||||
+83
-3
@@ -14,7 +14,8 @@ that writes or reads one.
|
||||
|
||||
**Slice** — one *system* of music: a single line spanning all voices, typically
|
||||
four to twelve bars, with lyrics intact. A slice is the atomic unit of the
|
||||
format. Slices are images and carry no notion of pitch, voice or beat.
|
||||
format. A slice is presented as an image; a slice that was engraved rather than
|
||||
scanned may also carry the notation it was engraved from.
|
||||
|
||||
**Marker** — a semantic annotation attached to a slice, describing a navigational
|
||||
feature printed in the score: a rehearsal letter, a repeat, a jump.
|
||||
@@ -112,6 +113,7 @@ Each entry of `slices` is an object:
|
||||
|---|---|---|---|
|
||||
| `file` | string | required | Name of the image entry in the archive. |
|
||||
| `markers` | array | optional | Markers on this slice. Omitted when there are none. |
|
||||
| `engraving` | object | optional | The notation this slice's image was engraved from, when it was engraved rather than scanned. See [Engraving](#engraving). |
|
||||
|
||||
**The array order is the reading order of the score.** It is the only ordering
|
||||
the format defines. Filenames often sort into the same order, but a consumer
|
||||
@@ -158,6 +160,75 @@ One specific hazard is worth naming, because it is silent: an image pipeline
|
||||
that *discards* the alpha channel rather than compositing it will turn every
|
||||
slice into a solid black rectangle, since the colour channels are all zero.
|
||||
|
||||
## Engraving
|
||||
|
||||
Most slices are photographs of print: an image and nothing more. A slice that
|
||||
was *engraved* — set from notation rather than scanned — can carry the notation
|
||||
it came from, in an `engraving` object.
|
||||
|
||||
```json
|
||||
{
|
||||
"file": "007.webp",
|
||||
"engraving": {
|
||||
"lang": "lilypond",
|
||||
"key": "aes",
|
||||
"time": "4/4",
|
||||
"print_time": false,
|
||||
"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" },
|
||||
{ "clef": "bass", "notes": "aes,4 ges f ees | aes2. r4" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | | |
|
||||
|---|---|---|---|
|
||||
| `lang` | string | required | The notation language. `"lilypond"` is the only value defined by this version. |
|
||||
| `voices` | array | required | One entry per staff, in the order they are printed top to bottom. At least one. |
|
||||
| `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`. |
|
||||
|
||||
Each entry of `voices`:
|
||||
|
||||
| Field | Type | | |
|
||||
|---|---|---|---|
|
||||
| `notes` | string | required | The music for this staff, verbatim in `lang`. |
|
||||
| `clef` | string | optional | `"treble"`, `"treble_8"`, `"alto"`, `"bass"`. Default `"treble"`. |
|
||||
| `lyrics` | string | optional | The words under this staff, verbatim in `lang`. Omitted when the staff has none. |
|
||||
|
||||
Three properties make this worth carrying:
|
||||
|
||||
- **It is the source, not a transcription.** The image was engraved from exactly
|
||||
these strings. A consumer that re-engraves them gets the same system back.
|
||||
- **It is editable.** A wrong note can be corrected here and the slice engraved
|
||||
again, which a raster image does not allow.
|
||||
- **It is playable.** `voices` are separated per staff with pitches, durations
|
||||
and a key, so the passage can be sounded — a practice track, a click, a
|
||||
pitch reference — without anyone reading the image.
|
||||
|
||||
`notes` and `lyrics` are opaque to this format. They are whatever `lang` accepts,
|
||||
including constructs the fields above say nothing about: slurs, dynamics,
|
||||
tuplets, and the tie idioms that carry a note across a slice boundary. A consumer
|
||||
that does not speak `lang` must pass them through unaltered or ignore them, never
|
||||
attempt to repair them.
|
||||
|
||||
An `engraving` **describes the slice above it, not the whole song**. Each is
|
||||
self-contained: `key` and `time` are stated per slice, so nothing has to be
|
||||
inherited from a neighbour or from the bundle. Slices without an `engraving` are
|
||||
scanned, and the two kinds mix freely within one score — re-engraving a single
|
||||
ruined system is the ordinary case.
|
||||
|
||||
A consumer that only presents the score can ignore `engraving` entirely. The
|
||||
image is always the authority on what the slice looks like; where an image and
|
||||
its engraving disagree, the image is what the producer intended to be read.
|
||||
|
||||
Notation languages other than `lilypond` may be added by future versions. A
|
||||
consumer should ignore an `engraving` whose `lang` it does not know, and present
|
||||
the slice image as it would any other.
|
||||
|
||||
## Markers
|
||||
|
||||
A marker annotates the slice it appears on.
|
||||
@@ -215,7 +286,9 @@ ignore markers it does not understand rather than reject the bundle.
|
||||
|
||||
`v` is an integer that increases when a change would break an existing consumer.
|
||||
Additions that a consumer can safely ignore — new optional fields, new marker
|
||||
types — do not increase it.
|
||||
types, new `engraving` languages — do not increase it. `engraving` was added
|
||||
this way: a bundle carrying one is still a version 1 bundle, and a consumer that
|
||||
has never heard of it presents the score unchanged.
|
||||
|
||||
A consumer should refuse a bundle whose `v` it does not recognise rather than
|
||||
attempt to interpret it.
|
||||
@@ -228,6 +301,8 @@ A consumer is advised to check:
|
||||
- `title` is present and non-empty; `slices` is a non-empty array.
|
||||
- Every `file` names an entry present in the archive.
|
||||
- Every `destination` is within the bounds of `slices`.
|
||||
- Every `engraving` has a `lang` and a non-empty `voices`; unknown `lang` values
|
||||
are ignored rather than rejected.
|
||||
- Archive entry names contain no path separators, no `..`, and no absolute
|
||||
paths, as with any archive from an untrusted source.
|
||||
|
||||
@@ -272,7 +347,12 @@ song.zip
|
||||
{ "file": "004.webp" },
|
||||
{ "file": "005.webp" },
|
||||
{ "file": "006.webp" },
|
||||
{ "file": "007.webp" },
|
||||
{ "file": "007.webp", "engraving": { "lang": "lilypond", "key": "aes", "time": "4/4",
|
||||
"voices": [ { "clef": "treble",
|
||||
"notes": "c4 des ees f | ees2. r4",
|
||||
"lyrics": "Kai -- paa -- va sy -- dän" },
|
||||
{ "clef": "bass",
|
||||
"notes": "aes,4 ges f ees | aes2. r4" } ] } },
|
||||
{ "file": "008.webp", "markers": [ { "type": "coda" } ] },
|
||||
{ "file": "009.webp" }
|
||||
]
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
# Making a bundle
|
||||
|
||||
Start to finish: a score PDF in, one `.zip` out that noteman can open. Fifteen
|
||||
minutes for a typical four-page song, most of it spent nudging cuts.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
uv tool install --editable .
|
||||
```
|
||||
|
||||
That puts `noteman-slicer` on PATH; it runs from any directory. Everything it
|
||||
needs is a wheel — no system packages. LilyPond is optional and only enables
|
||||
re-engraving (below); without it the tool works the same minus that pane.
|
||||
|
||||
## The one command you need
|
||||
|
||||
```
|
||||
noteman-slicer edit my-song.pdf
|
||||
```
|
||||
|
||||
The editor opens on page 1 with detection's guesses already drawn: horizontal
|
||||
**cuts** between the systems, a **skew** correction, and a blue **content
|
||||
rectangle** marking what is music rather than page margin. All of it is a
|
||||
starting point — detection is an accelerator, not an authority. Fix whatever is
|
||||
wrong.
|
||||
|
||||
Your work is saved to `my-song.slicer.json` next to the PDF, automatically on
|
||||
export and with Ctrl+S any time. Closing and reopening picks up where you left
|
||||
off.
|
||||
|
||||
## What you do on each page
|
||||
|
||||
1. **Straighten it.** If the staff lines slope, turn the *Skew* dial until they
|
||||
are level. The preview updates live.
|
||||
2. **Fix the cuts.** One cut line per boundary between systems. Double-click to
|
||||
add one, drag to move it, right-click to delete it. A cut is a polyline, not
|
||||
a straight line — Ctrl-click on a cut adds a vertex, so it can bend around a
|
||||
low-hanging lyric or a slur that crosses the gap. Right-click a vertex to
|
||||
drop it.
|
||||
3. **Discard what isn't music.** Page headers, footers, page numbers and title
|
||||
blocks are slices too, and they should not reach the tablet. Click the slice,
|
||||
press <kbd>D</kbd>. Discarded slices show hatched. <kbd>D</kbd> again brings
|
||||
one back.
|
||||
4. **Set the content rectangle.** Drag the blue edges so they hold the music and
|
||||
nothing else. This is the horizontal crop for every slice on the page.
|
||||
5. **Set black and white points.** Pull the *White point* down until the paper
|
||||
goes pure white and its texture disappears; pull *Black point* up until the
|
||||
notes are solid black rather than grey mush. Scans need this; clean digital
|
||||
PDFs usually don't.
|
||||
|
||||
Page Up / Page Down move between pages. Levels carry over from the previous
|
||||
page, so a consistent scan only needs setting once.
|
||||
|
||||
## Markers
|
||||
|
||||
Markers are the navigation symbols noteman uses to jump around the score:
|
||||
rehearsal letters, section labels, segno, coda, fine, repeats, voltas, and the
|
||||
D.S./D.C. instructions. They belong to a slice.
|
||||
|
||||
Select the slice, pick the type, type a label if the type takes one (rehearsal
|
||||
letters, section labels and voltas do), and press **Add**.
|
||||
|
||||
Jump markers — *to coda*, *D.S. al coda*, *D.C. al fine* and friends — also need
|
||||
a destination. After adding one, press **Set target…** and click the slice it
|
||||
jumps to, on any page. That is what lets noteman follow the repeat structure
|
||||
instead of just scrolling.
|
||||
|
||||
## The title block
|
||||
|
||||
Fill in the *Song* section. **Title is required** — export refuses without one.
|
||||
The rest (subtitle, composer, original artist, arranger, lyricist, translator,
|
||||
voices) is optional and travels with the bundle into noteman's library.
|
||||
|
||||
*Tempo* is beats per minute, a number, because a number can drive a metronome
|
||||
and "Andante" cannot.
|
||||
|
||||
## Export
|
||||
|
||||
**Export bundle…**, choose where the `.zip` goes, done. Inside are the slice
|
||||
images in order, their markers, the song metadata, and the original PDF as the
|
||||
archive copy. That zip is the whole interface to noteman; hand it over and open
|
||||
it there.
|
||||
|
||||
Two things worth knowing:
|
||||
|
||||
- **Shrink the original PDF…** offers to store the archived PDF as bilevel,
|
||||
which is dramatically smaller for scans. It shows you a before/after crop
|
||||
first — check that the staff lines survived. It never touches the slices.
|
||||
- **An exported project is spent.** Reopening the same PDF starts fresh from
|
||||
detection rather than resuming decisions that already shipped. If you really
|
||||
want the old cuts back, `noteman-slicer edit my-song.pdf --resume`.
|
||||
|
||||
## Re-engraving a slice (optional, needs LilyPond)
|
||||
|
||||
When a system is beyond rescue — a bad scan, a wrong transposition, a passage
|
||||
you want rewritten — shift-double-click it. A window opens where you enter the
|
||||
music as LilyPond, one block per voice, render, and compare against the
|
||||
original. Accept and the rendered version replaces that slice in the bundle.
|
||||
|
||||
The LilyPond you typed travels in the bundle alongside the image, so the passage
|
||||
can be corrected and re-engraved later, or played, without the project file.
|
||||
|
||||
## Mouse and keyboard
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Double-click | add a cut |
|
||||
| Drag a cut | move it |
|
||||
| Ctrl-click a cut | add a vertex |
|
||||
| Right-click | delete the cut or vertex under the cursor |
|
||||
| Click a slice, then <kbd>D</kbd> | discard it (or bring it back) |
|
||||
| Drag the blue edges | resize the content rectangle |
|
||||
| Shift-double-click a slice | re-engrave it |
|
||||
| <kbd>Page Up</kbd> / <kbd>Page Down</kbd> | previous / next page |
|
||||
| <kbd>Ctrl</kbd>+<kbd>S</kbd> | save the project |
|
||||
|
||||
## What the tool won't do
|
||||
|
||||
Erasing a previous owner's pencil marks, chord letters and breath marks. Do that
|
||||
in GIMP before slicing — with a stylus it is quick, and no amount of thresholding
|
||||
substitutes for it.
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Detection found no systems, or one giant one | The score has no bracket joining the staves; add the cuts by hand. |
|
||||
| "The PDF has changed since these cuts were made" | The file was edited or replaced under an existing project. The cuts probably no longer line up — re-cut. |
|
||||
| Export says a title is required | Fill in *Song → Title*. |
|
||||
| Slices look grey and washed out | The white point is too high. |
|
||||
| Notes have holes in them | The black point is too high. |
|
||||
|
||||
## The command line
|
||||
|
||||
The editor is the tool; these exist for checking things quickly.
|
||||
|
||||
```
|
||||
noteman-slicer info my-song.pdf # source type and page rasters
|
||||
noteman-slicer detect my-song.pdf # detection results + debug overlays
|
||||
noteman-slicer project my-song.pdf # what the project file currently holds
|
||||
noteman-slicer export my-song.pdf # export without opening the editor
|
||||
```
|
||||
|
||||
Every command takes `--type raster|vector` to override source-type detection.
|
||||
@@ -39,6 +39,30 @@ METADATA_FIELDS = (
|
||||
NUMERIC_FIELDS = frozenset({"tempo"})
|
||||
|
||||
|
||||
def _engraving(project: Project, page: int, slot: int) -> dict | None:
|
||||
"""The notation behind a re-engraved slice, or None for a scanned one.
|
||||
|
||||
The slice image stays the presentation; this is the notation it was made
|
||||
from, carried so the music can be edited again or turned into sound. Key
|
||||
and time are resolved against the song defaults here — a consumer reading
|
||||
one slice should not have to know what the rest of the song inherited.
|
||||
"""
|
||||
replacement = project.pages[page].replacements[slot]
|
||||
if not replacement or not replacement.voices:
|
||||
return None
|
||||
return {
|
||||
"lang": "lilypond",
|
||||
"key": replacement.key or project.key,
|
||||
"time": replacement.time or project.time,
|
||||
"print_time": replacement.print_time,
|
||||
"voices": [
|
||||
{"clef": v.clef, "notes": v.notes.strip()}
|
||||
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
|
||||
for v in replacement.voices
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def song_json(project: Project, files: list[str]) -> dict:
|
||||
payload: dict = {"v": FORMAT_VERSION}
|
||||
for field in METADATA_FIELDS:
|
||||
@@ -62,6 +86,9 @@ def song_json(project: Project, files: list[str]) -> dict:
|
||||
slices: list[dict] = []
|
||||
for name, (page, slot) in zip(files, kept):
|
||||
entry: dict = {"file": name}
|
||||
engraving = _engraving(project, page, slot)
|
||||
if engraving:
|
||||
entry["engraving"] = engraving
|
||||
markers = []
|
||||
for marker in project.pages[page].markers[slot]:
|
||||
item: dict = {"type": marker.type}
|
||||
|
||||
@@ -22,6 +22,8 @@ from noteman_slicer.project import ( # noqa: E402
|
||||
Cut,
|
||||
Marker,
|
||||
Project,
|
||||
Replacement,
|
||||
Voice,
|
||||
default_path,
|
||||
)
|
||||
|
||||
@@ -91,6 +93,29 @@ def main() -> int:
|
||||
assert slices[1]["markers"][0] == {"type": "coda"}
|
||||
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1}
|
||||
|
||||
# A re-engraved slice carries its notation into the bundle; a scanned one
|
||||
# carries none. This is what makes a later edit or a MIDI render possible
|
||||
# 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 ", " ")]
|
||||
)
|
||||
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["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"
|
||||
|
||||
override = Replacement(voices=page.replacements[second].voices, key="d", print_time=True)
|
||||
page.replacements[second] = override
|
||||
ly = song_json(project, names)["slices"][1]["engraving"]
|
||||
assert (ly["key"], ly["time"], ly["print_time"]) == ("d", "3/4", True)
|
||||
page.replacements[second] = None
|
||||
|
||||
# A jump whose target got discarded is dropped, not exported dangling.
|
||||
project.pages[0].discards[second] = True
|
||||
dropped = song_json(project, ["001.webp"])
|
||||
|
||||
Reference in New Issue
Block a user