Files
noteman-slicer/noteman_slicer/bundle.py
T
Esa Kataja cf1344c5bf Carry a re-engraved slice's notation in the bundle
A replaced slice shipped as pixels only, so the LilyPond behind it died
with the project file — and a project is spent once exported. Correcting
one wrong note meant retyping the system.

Slices gain an optional `engraving` object: the language, key, time and
one entry per voice holding the notes and lyrics verbatim. Structured per
staff rather than one blob of source, because that is what both a later
edit and a MIDI render want; song-level key and time are resolved per
slice so reading one needs no context from its neighbours.

Additive and ignorable, so the format stays at version 1.
2026-07-29 11:42:44 +03:00

158 lines
5.5 KiB
Python

"""Bundle export — the only channel to noteman (ADR 0001).
song.zip
song.json
original.pdf
001.webp 002.webp …
Array order in `song.json` *is* slice order: one ordering, not two. Markers
nest inside the slice they sit on, so an index appears in exactly one place —
a jump source's `destination`.
"""
from __future__ import annotations
import json
import zipfile
from pathlib import Path
from .pdf import Source
from .project import Project
from .render import render_song
FORMAT_VERSION = 1
METADATA_FIELDS = (
"title",
"subtitle",
"composer",
"original_artist",
"arranger",
"lyricist",
"translator",
"tempo",
"voices",
)
# Beats per minute, exported as a JSON number. A figure is worth more than a
# word here: "Andante" cannot drive a metronome and two people will not agree
# what it means.
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:
value = (project.metadata.get(field) or "").strip()
if not value:
continue
if field in NUMERIC_FIELDS:
try:
payload[field] = int(value)
except ValueError:
continue # not a number, so not worth exporting as one
else:
payload[field] = value
kept = project.kept_slices()
# Markers reference slices by (page, slot) while editing, because that is
# what survives adding and removing cuts. In the bundle they become the
# array index, which is the only cross-reference the format has.
index_of = {position: i for i, position in enumerate(kept)}
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}
if marker.label:
item["label"] = marker.label
if marker.destination is not None:
target = index_of.get(tuple(marker.destination))
# A jump whose target was discarded or re-cut away is dropped
# rather than exported dangling: noteman would have nothing to
# resolve it to.
if target is None:
continue
item["destination"] = target
markers.append(item)
if markers:
entry["markers"] = markers
slices.append(entry)
payload["slices"] = slices
return payload
def write(project: Project, source: Source, path: Path) -> Path:
"""Render the song and write the bundle. Returns the zip path.
A title is required; every other metadata field is optional. noteman's own
rule is that a song needs a title and at least one slice, and a bundle that
cannot become a song is not worth writing.
"""
if not project.metadata.get("title", "").strip():
raise ValueError("a title is required before a song can be exported")
images = render_song(project, source)
if not images:
raise ValueError("no slices to export — every slice is discarded")
names = [f"{i + 1:03}.webp" for i in range(len(images))]
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
# ZIP_STORED for the images: WebP is already compressed, so deflating it
# only costs time. The JSON is small enough not to care.
with zipfile.ZipFile(path, "w") as zf:
zf.writestr(
"song.json",
json.dumps(song_json(project, names), indent=2, ensure_ascii=False),
zipfile.ZIP_DEFLATED,
)
if project.source.exists():
pdf = project.source.read_bytes()
if project.optimise_pdf:
import pymupdf
from .pdfopt import optimise
shrunk, _ = optimise(pymupdf.open(project.source), len(pdf))
pdf = shrunk or pdf # empty means it found no saving
zf.writestr("original.pdf", pdf, zipfile.ZIP_STORED)
for name, data in zip(names, images):
zf.writestr(name, data, zipfile.ZIP_STORED)
# The project is spent once its song has been exported: the next edit
# session starts fresh from detection rather than resuming these decisions.
# Recorded here so no caller can forget it.
project.exported = True
project.save()
return path