A bundle without a source block cannot be round-tripped, but its PDF can still be cut from scratch. Offer that instead of refusing: confirm, run detection, and line the markers up by position when the slice counts match exactly.
449 lines
19 KiB
Markdown
449 lines
19 KiB
Markdown
# Score Bundle Format, version 1
|
||
|
||
A container for one musical score, prepared for continuous-scroll display.
|
||
|
||
A bundle holds the score as a sequence of images — one per system of music —
|
||
together with the metadata that names the piece and the markers that describe
|
||
how a performer navigates it. It is self-contained: nothing outside the file is
|
||
needed to present the score.
|
||
|
||
This document defines the format. It does not describe any particular program
|
||
that writes or reads one.
|
||
|
||
## Terminology
|
||
|
||
**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. 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.
|
||
|
||
**Producer** — anything that writes a bundle. **Consumer** — anything that reads
|
||
one.
|
||
|
||
## Container
|
||
|
||
A bundle is a ZIP archive.
|
||
|
||
```
|
||
<name>.zip
|
||
├── song.json manifest: metadata, slice order, markers
|
||
├── original.pdf the source document (optional)
|
||
├── 001.webp
|
||
├── 002.webp
|
||
└── … one file per slice
|
||
```
|
||
|
||
- `song.json` is required and must be at the archive root.
|
||
- Slice images are at the archive root. Their names are given in `song.json`;
|
||
the zero-padded numbering shown is conventional, not required.
|
||
- `original.pdf` is optional. When present it is the document the score was
|
||
prepared from, carried along for printing or archival. It is not required to
|
||
present the score and consumers may ignore it.
|
||
- No directories, and no entries beyond those referenced by the manifest plus
|
||
the optional PDF.
|
||
- Compression method is unconstrained. Producers typically deflate `song.json`
|
||
and store the images and PDF, which are already compressed.
|
||
|
||
For scale: a twelve-page, twenty-four-slice choral score runs about 2.4 MB, of
|
||
which roughly 830 KB is the source PDF and the rest slice images at ~20 KB each.
|
||
|
||
## Manifest
|
||
|
||
`song.json` is UTF-8 encoded JSON.
|
||
|
||
```json
|
||
{
|
||
"v": 1,
|
||
"title": "Ketun joululaulu",
|
||
"composer": "trad.",
|
||
"arranger": "P. Rapi",
|
||
"tempo": 92,
|
||
"slices": [
|
||
{
|
||
"file": "001.webp",
|
||
"markers": [
|
||
{ "type": "rehearsal_letter", "label": "A" }
|
||
]
|
||
},
|
||
{
|
||
"file": "002.webp",
|
||
"markers": [
|
||
{ "type": "segno" },
|
||
{ "type": "to_coda", "destination": 7 }
|
||
]
|
||
},
|
||
{ "file": "003.webp" }
|
||
]
|
||
}
|
||
```
|
||
|
||
### Top-level fields
|
||
|
||
| Field | Type | | |
|
||
|---|---|---|---|
|
||
| `v` | integer | required | Format version. `1` for this document. |
|
||
| `slices` | array | required | Ordered, at least one entry. See below. |
|
||
| `title` | string | required | The name of the piece. |
|
||
| `subtitle` | string | optional | Alternate or translated title. |
|
||
| `composer` | string | optional | Who wrote the music. |
|
||
| `original_artist` | string | optional | Who originally performed the work, where that differs from the composer. |
|
||
| `arranger` | string | optional | Who adapted it for these forces. |
|
||
| `lyricist` | string | optional | Who wrote the words. |
|
||
| `translator` | string | optional | Who translated the words. |
|
||
| `tempo` | integer | optional | Beats per minute. |
|
||
| `voices` | string | optional | The parts in this arrangement, as free text. |
|
||
| `source` | object | optional | How the slices were cut from the archived document. See [Source geometry](#source-geometry). |
|
||
|
||
**Optional fields are omitted when they have no value.** A consumer will not
|
||
encounter an empty string or a null in place of an absent field.
|
||
|
||
`tempo` is a number, never a word: a figure can drive a metronome or a click
|
||
track, and verbal markings are not interchangeable between readers.
|
||
|
||
Unrecognised top-level fields may be added by future versions. A consumer should
|
||
ignore fields it does not know rather than reject the bundle.
|
||
|
||
### Slices
|
||
|
||
Each entry of `slices` is an object:
|
||
|
||
| Field | Type | | |
|
||
|---|---|---|---|
|
||
| `file` | string | required | Name of the image entry in the archive. |
|
||
| `page` | integer | optional | Index into `source.pages` — the page this slice was cut from. Present whenever `source` is. |
|
||
| `slot` | integer | optional | Which slice of that page this is, counting from 0 between its cuts. Present whenever `source` is. |
|
||
| `bar` | integer | optional | The measure this slice starts at, as numbered in the score. Omitted when unknown. |
|
||
| `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
|
||
must not derive order from them.
|
||
|
||
A slice's **index** is its zero-based position in this array. Indices are the
|
||
only identifiers the format has, and they are meaningful only within one bundle.
|
||
|
||
`bar` is the score's own numbering, not the format's: it says which measure this
|
||
system begins at, so a consumer can answer "take it from bar 33" by scrolling to
|
||
the right slice. It is independent of `index`, may be absent on any slice, and
|
||
carries no promise of being consecutive — a score numbers the systems it chooses
|
||
to, and pickup bars, repeats and voltas all break arithmetic on it.
|
||
|
||
## Slice images
|
||
|
||
Every slice image in a bundle satisfies the following. A consumer can rely on
|
||
these and does not need to inspect the images to lay them out.
|
||
|
||
- **Format: WebP, losslessly encoded.** (Lossless rather than lossy because
|
||
engraved music is line art — large flat areas separated by thin high-contrast
|
||
strokes — which lossless encoders compress *better* than lossy ones as well as
|
||
exactly.)
|
||
- **RGBA, with all three colour channels zero.** The image is carried entirely
|
||
by the alpha channel: ink is opaque black, paper is fully transparent, and
|
||
antialiased edges are partially transparent. Compositing a slice over a
|
||
background of any colour reproduces the printed appearance on that colour of
|
||
paper.
|
||
- **Uniform width within a bundle.** Every slice has the same pixel width, so a
|
||
consumer can lay them out in a single column without measuring. Systems
|
||
shorter than the widest are padded on the right with transparent pixels; they
|
||
end early rather than stretching.
|
||
- **Width is at most 1920 pixels**, and is frequently less. A narrower bundle is
|
||
not a defect: images are never enlarged beyond the resolution of their source,
|
||
because that adds bytes and softness without adding detail. Consumers should
|
||
scale to fit their own layout and should not treat 1920 as a target.
|
||
- **Height varies per slice**, being the height of that system.
|
||
- **Slices need not be rectangular in content.** Where two systems interleave —
|
||
for example a section label printed level with the previous system's lyric
|
||
line — the boundary between them steps, and each slice is delivered as its
|
||
bounding box with the region belonging to its neighbour left transparent. This
|
||
requires nothing special from a consumer; it composites correctly.
|
||
|
||
Images are **presentation-ready**. They have already been deskewed, cropped,
|
||
levelled and scaled as a set. Re-encoding, re-cropping or re-scaling them
|
||
individually will at best waste work and at worst break the uniformity the
|
||
format guarantees.
|
||
|
||
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.
|
||
|
||
## Source geometry
|
||
|
||
A bundle can say how its slices were cut, in a `source` object. With it a
|
||
consumer can reopen the score for editing; without it the bundle is a one-way
|
||
trip, since the slice images are output and the decisions that produced them
|
||
would live only in whatever tool made them.
|
||
|
||
```json
|
||
"source": {
|
||
"file": "original.pdf",
|
||
"pages": [
|
||
{
|
||
"skew": -0.4,
|
||
"content": [0.083, 0.0, 0.947, 1.0],
|
||
"levels": [46, 173],
|
||
"cuts": [
|
||
[[0.0, 0.0449], [1.0, 0.0449]],
|
||
[[0.0, 0.3662], [0.35, 0.3662], [0.35, 0.3901], [1.0, 0.3901]]
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
| Field | Type | | |
|
||
|---|---|---|---|
|
||
| `file` | string | required | The archive entry the slices were cut from. `"original.pdf"` in practice. |
|
||
| `pages` | array | required | One entry per page of that document, in its own page order. |
|
||
|
||
Each entry of `pages`:
|
||
|
||
| Field | Type | | |
|
||
|---|---|---|---|
|
||
| `cuts` | array | required | The boundaries between slices, ordered top to bottom. May be empty: a page with no cuts is one slice. |
|
||
| `skew` | number | optional | Degrees the page was rotated by before cutting. Default `0`. |
|
||
| `content` | array | optional | `[x0, y0, x1, y1]` — the part of the page that is music. Default the whole page. |
|
||
| `levels` | array | optional | `[black, white]` — the black and white points applied. Default `[0, 255]`. |
|
||
|
||
**Everything here is in normalised page coordinates**, `0.0` to `1.0` on each
|
||
axis, origin top-left. Nothing is in pixels, so the geometry holds however the
|
||
document is rendered and at whatever resolution.
|
||
|
||
A **cut** is a polyline: a list of `[x, y]` points, left to right. Two points is
|
||
a straight cut; more steps around a system that interleaves with its neighbour —
|
||
a section label printed level with the previous system's lyrics. A slice's top
|
||
boundary is the cut above it and its bottom boundary the cut below it, with the
|
||
page edge standing in at either end.
|
||
|
||
A page with *n* cuts therefore has *n + 1* **slots**, numbered from 0 downward.
|
||
Each slice names the `page` and `slot` it came from. **A slot that no slice
|
||
claims was discarded** — a page header, a footer, a title block. That is stated
|
||
by omission rather than directly, because shipping a discarded slice's image
|
||
would defeat discarding it.
|
||
|
||
The archived document is the source of truth for reopening: the slice images are
|
||
output, and a consumer that reopens a bundle re-renders them rather than
|
||
importing them.
|
||
|
||
`source` is optional, so a bundle without one is still valid — it is simply not
|
||
reopenable, and a consumer should say so rather than pretend otherwise. What it
|
||
can still recover from such a bundle is the title block, and, if it cuts the
|
||
document again and happens to find exactly as many slices, the markers: the
|
||
slices array is in reading order, so it lines up with any other list in reading
|
||
order. One slice more or fewer and it does not, which is why that is a fallback
|
||
and not the design.
|
||
|
||
## 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",
|
||
"bar": 33,
|
||
"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.
|
||
|
||
| Field | Type | | |
|
||
|---|---|---|---|
|
||
| `type` | string | required | One of the vocabulary below. |
|
||
| `label` | string | optional | Free text. Meaningful for `rehearsal_letter`, `section_label` and `volta`. |
|
||
| `destination` | integer | optional | Index into `slices`. Present on jump types. |
|
||
|
||
A slice may carry several markers. Their order within the array is not
|
||
significant.
|
||
|
||
### Vocabulary
|
||
|
||
Named positions — places a performer may be directed to:
|
||
|
||
| `type` | Meaning |
|
||
|---|---|
|
||
| `rehearsal_letter` | A boxed letter or number printed above a system, used to say "from C". `label` holds it. |
|
||
| `section_label` | A named section: INTRO, VERSE, CHORUS. `label` holds the name. |
|
||
| `segno` | The 𝄋 sign, target of a *dal segno*. |
|
||
| `coda` | The 𝄌 sign, beginning of the closing section. |
|
||
| `fine` | The end of the piece when reached by a *da capo* or *dal segno*. |
|
||
|
||
Structural notation — printed context, affecting how the music is read but not
|
||
directing the reader elsewhere:
|
||
|
||
| `type` | Meaning |
|
||
|---|---|
|
||
| `repeat_start` | The start of a repeated passage. |
|
||
| `repeat_end` | The end of a repeated passage. |
|
||
| `volta` | An alternative ending bracket. `label` holds its number. |
|
||
|
||
Jumps — points where the reader is directed to another slice:
|
||
|
||
| `type` | Meaning |
|
||
|---|---|
|
||
| `to_coda` | "To Coda": leave here for the coda. |
|
||
| `ds_al_coda` | *Dal segno al coda*: return to the segno. |
|
||
| `ds_al_fine` | *Dal segno al fine*: return to the segno and play to the fine. |
|
||
| `dc_al_coda` | *Da capo al coda*: return to the beginning. |
|
||
| `dc_al_fine` | *Da capo al fine*: return to the beginning and play to the fine. |
|
||
| `generic_jump` | An unclassified jump. |
|
||
|
||
**Every jump marker states its destination explicitly**, as an index into
|
||
`slices`. A consumer does not need to infer where a jump leads by searching for
|
||
a matching `coda` or `segno`, and must not assume a bundle contains only one of
|
||
each. A `destination` always refers to an existing index.
|
||
|
||
Unrecognised marker types may be added by future versions. A consumer should
|
||
ignore markers it does not understand rather than reject the bundle.
|
||
|
||
## Versioning
|
||
|
||
`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, new `engraving` languages — do not increase it. `engraving` and `source`
|
||
were both added this way: a bundle carrying them is still a version 1 bundle,
|
||
and a consumer that has never heard of them presents the score unchanged.
|
||
|
||
A consumer should refuse a bundle whose `v` it does not recognise rather than
|
||
attempt to interpret it.
|
||
|
||
## Validating a bundle
|
||
|
||
A consumer is advised to check:
|
||
|
||
- `v` is a recognised version.
|
||
- `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.
|
||
- If `source` is present: its `file` names an entry in the archive, every slice
|
||
carries a `page` within `source.pages` and a `slot` within that page's slot
|
||
count, and no two slices claim the same one.
|
||
- Archive entry names contain no path separators, no `..`, and no absolute
|
||
paths, as with any archive from an untrusted source.
|
||
|
||
## Identity and updates
|
||
|
||
A bundle describes one complete score. The format has no notion of updating a
|
||
previously read bundle: there are no stable identifiers, and a slice's index is
|
||
meaningful only within the bundle that contains it.
|
||
|
||
Reopening a bundle for editing, via [`source`](#source-geometry), does not change
|
||
that. What comes out is a new document that happens to have been derived from an
|
||
old one, not a revision of it.
|
||
|
||
Two bundles of the same piece are therefore independent documents, not versions
|
||
of one. A consumer that stores imported bundles and assigns its own identifiers
|
||
should treat a second bundle as a new score rather than merging it into an
|
||
existing one — jump destinations resolved against the first bundle's slices do
|
||
not survive being repointed at a second bundle's.
|
||
|
||
## Complete example
|
||
|
||
A 24-slice bundle, abbreviated:
|
||
|
||
```
|
||
song.zip
|
||
├── song.json 1.4 KB
|
||
├── original.pdf 827 KB
|
||
├── 001.webp 21 KB 1489 × 1058
|
||
├── 002.webp 18 KB 1489 × 818
|
||
├── …
|
||
└── 024.webp 1489 px wide, like every other slice
|
||
```
|
||
|
||
```json
|
||
{
|
||
"v": 1,
|
||
"title": "Ketun joululaulu",
|
||
"composer": "trad.",
|
||
"arranger": "P. Rapi",
|
||
"tempo": 92,
|
||
"slices": [
|
||
{ "file": "001.webp", "markers": [ { "type": "rehearsal_letter", "label": "A" } ] },
|
||
{ "file": "002.webp", "markers": [ { "type": "segno" },
|
||
{ "type": "to_coda", "destination": 7 } ] },
|
||
{ "file": "003.webp" },
|
||
{ "file": "004.webp" },
|
||
{ "file": "005.webp" },
|
||
{ "file": "006.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" }
|
||
]
|
||
}
|
||
```
|
||
|
||
Reading the score means presenting `001.webp` through `024.webp` in that order,
|
||
in one column, each scaled to the same width. A reader who follows the `to_coda`
|
||
on slice index 1 continues at slice index 7.
|