Files
noteman-slicer/docs/bundle-format.md
T
Esa Kataja 61b8ec8301 Make a bundle reopenable, and number slices
A bundle was a one-way trip. The slice images are output and the cuts
that produced them lived only in the producer's own project file, so a
bundle someone handed you meant cutting the score again from scratch.

The manifest now carries the geometry, in a `source` block: per page the
cut polylines, skew, levels and content rectangle, all in normalised
coordinates so they survive any render resolution, and per slice the page
and slot it came from. Discards are stated by omission — a slot no slice
claims was discarded — since shipping a discarded slice's image would
defeat discarding it.

`noteman-slicer open song.zip` unpacks the archived PDF, rebuilds the
project from that geometry, restores markers, engravings and the title
block, and opens the editor. Jump destinations go back from an array
index to the (page, slot) the editor works in. The images in the zip are
discarded: the PDF is what the pipeline renders from. Re-exporting a
reopened bundle reproduces its manifest exactly. It refuses to overwrite
a PDF or project file already sitting there, because the obvious place to
unpack is where someone's unfinished cuts live.

Separately, every slice can now carry the measure it starts at, not just
a re-engraved one — a scanned system is numbered in the score the same
way, and noteman wants to answer "take it from bar 33" about either. It
moves off the replacement onto the page, alongside markers and discards,
and out of the bundle's engraving object onto the slice.
2026-07-29 14:30:54 +03:00

19 KiB
Raw Blame History

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.

{
  "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.

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.

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.

"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.

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.

{
  "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, 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
{
  "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.