diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b86697f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.venv/ + +# Scores and their working state — copyright, and multi-MB +*.pdf +*.zip +*.slicer.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..33a8a9f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,122 @@ +# noteman-slicer — Context + +A local, single-user tool that turns a score PDF into the ordered slice images +noteman consumes, plus the navigation markers that sit on them. It automates the +mechanical part of noteman's ingestion boundary; it does not replace GIMP for +erasing previous-owner annotations. + +## Language + +**Score source**: +The original PDF as distributed to the choir. Either **vector** (engraved by a +notation program) or **raster** (a flatbed scan of a printed page). +_Avoid_: original, input file + +**Source type**: +Whether a score source is vector or raster. Determined once per PDF, not per +page; the slicer detects it and asks the user to confirm before routing. +_Avoid_: mode, format + +**Slice**: +The atomic visual unit of a song — one *system*, one full line of music across +all voices, typically 4–12 bars with lyrics intact. Same definition as noteman's. +Structurally, a horizontal region of a page: a page begins as a single slice and +each cut splits one slice into two, so slices always tile the page with no gaps +and no overlap. +_Avoid_: segment, strip, row, band + +**Discard**: +A flag on a slice meaning it produces no output. How headers, footers and blank +regions leave the song — they are slices that are simply not exported. Detection +pre-sets it on a page's top and bottom slice when they contain no system. +_Avoid_: delete, skip, exclude + +**Slice image**: +The rendered artifact of a slice. From a raster source: lossless WebP, RGB pure +black, `alpha = 255 − luminance`, width capped at 1920px — paper is transparency, +ink is alpha. From a vector source: SVG with text converted to paths. Both are +display-ready as produced; nothing downstream reprocesses them. +_Avoid_: PNG, page image, tile + +**Marker**: +A semantic tag placed on a slice for navigation. Vocabulary is noteman's enum +verbatim (`rehearsal_letter`, `section_label`, `segno`, `coda`, `fine`, +`repeat_start`, `repeat_end`, `volta`, `to_coda`, `ds_al_coda`, `ds_al_fine`, +`dc_al_coda`, `dc_al_fine`, `generic_jump`) and is shared coupling between the +two repos. + +**Project**: +The persistent state of slicing one song: the source PDF it points at, its cuts, +discards, content rectangle, levels, staff-height overrides, markers and +metadata. Autosaved beside the PDF; the bundle is generated from it, so any +export can be regenerated without repeating human work. One PDF, one song, one +project, one bundle — never a many-to-one in any direction. +_Avoid_: session, document, edit list + +**Bundle**: +The single compressed file that is the *only* channel between the slicer and +noteman. There is no API call, no direct upload. Carries the slice images in +order, their markers, the original PDF and the song-level text metadata. +Rehearsal MIDI and MP3s are deliberately out of the first bundle and stay +noteman's upload chore until a later phase. +_Avoid_: export, package, upload + +**Marker label**: +The free text some markers carry — `rehearsal_letter` ("C"), `section_label` +("CHORUS"), `volta` ("1."). All other types are bare tags. + +**Jump source**: +A marker that sends the reader elsewhere — `to_coda`, `ds_al_coda`, +`ds_al_fine`, `dc_al_coda`, `dc_al_fine`, `generic_jump`. Every jump source +stores its **target slice explicitly**; none is resolved by type at read time. +Authoring the target costs one click on a slice already on screen, and in +exchange the bundle is self-describing — no "exactly one Coda per song" rule +living unenforced in two repos, and a score with two codas simply works. +_Avoid_: link, reference, pointer + +**Cut**: +A horizontal line placed on a page that splits one slice into two. Straight at +first; a later polyline form handles pages where systems slant or interleave. +Placement is forgiving — anywhere inside the whitespace gap yields the same +output, because trim crops to ink afterwards. +_Avoid_: split, divider, break + +**Content rectangle**: +The region of a page that holds music. Set per PDF, adjustable per page, applied +before cutting. Everything outside it — scan-edge bands, spine shadows, margin +page numbers — is dropped. Handles margin junk structurally rather than +case-by-case, because margin junk is by definition outside the music. +_Avoid_: crop box, mask, margin + +**Detection**: +Any automatic proposal the slicer makes — skew angle, cut positions, source +type, ink bounds. Detection is always a *suggestion*: the human confirms or +modifies every result before it is committed. Source quality varies too widely +for unattended processing to be trustworthy. +_Avoid_: auto-slicing, automatic mode + +**Trim**: +Cropping a slice tight to its ink on all four sides, per slice. Normalises away +the left-margin drift between scanned pages, and flattens the engraved indent of +the first system — correct here, since the printed header it made room for is +stripped anyway. The viewer supplies margins with CSS padding. + +**Staff height**: +The distance between a system's top and bottom staff line. The invariant a +reader perceives as "the notes are the same size", and therefore what slices are +normalised against — not their width. Falls out of the same row-darkness profile +used for cut detection. Manually overridable, since it is detected on the same +speckled scans where detection already struggles. + +**Song scale**: +Two steps, both per song, never per slice. First every slice is normalised to a +common staff height, so a rescanned page or a re-engraved slice sits at the same +note size as its neighbours. Then the whole song is scaled uniformly so its +widest slice lands at 1920px — a ceiling, never a target: a song that comes out +narrower stays narrower, because upscaling a scan past its real resolution buys +softness and bytes and no detail. + +**Right pad**: +The transparent space added to the right of every slice narrower than the widest, +bringing all slices in a song to the same width. Keeps the music the same size +and flush left; a short system simply ends earlier. diff --git a/docs/adr/0001-slicer-owns-image-processing-bundle-is-the-only-channel.md b/docs/adr/0001-slicer-owns-image-processing-bundle-is-the-only-channel.md new file mode 100644 index 0000000..d7e78c5 --- /dev/null +++ b/docs/adr/0001-slicer-owns-image-processing-bundle-is-the-only-channel.md @@ -0,0 +1,48 @@ +# The slicer owns all image processing; the bundle is the only channel to noteman + +The slicer was originally scoped to share the work with noteman: it would cut the +pages, upload them to `POST /api/admin/songs/:id/slices`, and noteman's sharp +pipeline would trim, downscale and convert ink to alpha on arrival. We decided +instead that the slicer produces finished, display-ready artifacts and hands them +over as a single zip; noteman does no image manipulation beyond what plain HTML +and CSS do at runtime, and there is no API between the two tools. + +## Considered options + +**Split the work (rejected).** Slicer cuts, noteman normalises. This was the +existing shape and needed no changes on either side. It failed on inspection: +noteman's `normalizeSlice` calls sharp's `removeAlpha()` before its luminance +pass, and `removeAlpha()` discards the alpha channel rather than compositing it +over white — so any transparency the slicer produced would flatten to black ink. +A polygon-masked slice would have arrived as a solid black rectangle. More +fundamentally, the split put trim, downscale and encoding rules in two repos and +two languages, where they must agree exactly and nothing checks that they do. + +**Slicer produces final pixels, noteman stores them (chosen).** + +**Direct POST with a bypass flag (rejected).** Keeps the API but adds a +content-sniff or flag to skip normalisation. Preserves the two-language +duplication it was meant to remove, and needs an admin token in a local script. + +## Consequences + +Several rules that read as arbitrary in the slicer's code are only explicable +from here: + +- The slicer owns **trim, staff-height normalisation, the 1920px ceiling, right + padding, levels and the ink→alpha conversion.** Nothing downstream can rescue + a faint scan or rehabilitate a bad crop, so the slicer's output is final. +- **Scale is normalised on staff height, not width**, and derived once per song. + A per-slice factor would change note size line to line down a continuous + scroll; a width-based factor breaks the moment a slice comes from a different + source (a rescanned page, or a re-engraved replacement system). +- **noteman must delete its sharp pipeline** and accept a bundle instead. Until + that lands, the slicer produces artifacts nothing consumes. +- **The bundle is a versioned contract**, not an internal format. Two separately + maintained repos now agree on: the marker enum, slice ordering, and the shape + of `song.json`. That coupling is deliberately kept as small as possible — + which is why every jump source stores its target slice explicitly rather than + relying on an unwritten "exactly one Coda per song" resolution rule. +- **The split is reversible only in one direction.** Re-adding server-side + processing means re-deriving all of the above; going further the other way + (more processing in the slicer) is free. diff --git a/main.py b/main.py new file mode 100644 index 0000000..194820c --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from slicer!") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..88af297 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "noteman-slicer" +version = "0.1.0" +description = "Cuts score PDFs into noteman's slice images and markers" +readme = "README.md" +requires-python = ">=3.13" +dependencies = []