diff --git a/docs/bundle-format.md b/docs/bundle-format.md index e496374..e1d29f8 100644 --- a/docs/bundle-format.md +++ b/docs/bundle-format.md @@ -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" } ] diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py index 2b05447..38a534a 100644 --- a/noteman_slicer/bundle.py +++ b/noteman_slicer/bundle.py @@ -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} diff --git a/tests/test_markers.py b/tests/test_markers.py index 08607cc..ee1e23a 100644 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -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"])