Code: github.com/pension-plan/score-scan-reader · Site: pension-plan.github.io/score-scan-reader (app, this page, design doc) · pushed after every update · releases via release-please
A browser app that reads scanned (image) PDFs of sheet music and writes MusicXML. It keeps lyrics, and when one staff carries two voices, as in closed-score SATB hymns, it splits them into separate parts. Piano grand staves stay as one part.
Live now: Score Scan Reader v1. It opens with a 1905 hymnal scan of St. Anselm already converted. Drop in your own scanned PDF, PNG or JPG.
Each limit has an owner in the fix-forward rounds. A limit is marked resolved only with evidence: a measured improvement or a test scan that now passes.
A generic wireframe of the converter, drawn in Excalidraw and redrawn here from the same scene data with rough.js. It is the layout the UI builder follows: upload area, a seven-step conversion stepper, the scan with detection overlay, parts, lyrics and warnings, the MusicXML output and a notation preview.
Three components were retrieved from the 21st.dev catalogue. They are React, so the build ports their patterns to plain JavaScript. Notes are in docs/ui-refs/21st-dev.md.
| Where | 21st.dev component | Pattern taken |
|---|---|---|
| Upload area | Upload Dropzone Empty State | Drag-depth counter so the highlight doesn't flicker, state-dependent title, a live region saying "Release to convert", file row with size and progress meter |
| Conversion progress | Stepper | Ordered list in a labelled nav, aria-current on the active stage, spoken state after each number, only the current title shown on phones |
| MusicXML output | Code Block | Header with file label and Copy button that flips to Copied, line-number gutter, scrolling inside the block only |
One entry per research item: what it found, and what was decided because of it. Items still running say so.
Draft 0.9, written before the design phase finished. Where a later trial or design document decides something, it is marked as open. Source: docs/SDD.md.
Version 0.9, 2026-11-08. Status: draft written before the design phase has finished. Where a later document (docs/DESIGN.md, docs/spike-*.md, docs/proposal-*.md) has not been written yet, the open choice is marked To be decided by the design phase and the candidates are listed. Nothing here reports a measured result that the research documents do not already contain. When this SDD and docs/DESIGN.md disagree on a module contract, docs/DESIGN.md wins and this document should be updated.
Sources used: docs/research-omr.md (cited as [OMR §n]), docs/research-voices.md ([VOI §n]), the orchestration workflow scan-pdf-to-musicxml ([WF]), and the corpus as it stands on disk (corpus/real/, corpus/synthetic/_tools/).
This document describes the design of a client-side web application that converts scanned sheet-music PDFs (pages that are images, such as IMSLP, CPDL or Internet Archive scans, photocopies and phone scans) into MusicXML 4.0 score-partwise. It is written for the people and agents who build, review and test the system, and it fixes the module boundaries, data shapes, algorithms and acceptance criteria they work to.
In scope:
<part>s when the staff is a vocal closed score, while keeping keyboard grand staves as one part with two staves.Out of scope for v1: handwritten music, tablature, early (mensural or chant) notation, chord symbols and figured bass, dynamics and articulations beyond fermatas, editing the recognised score inside the app, and any server-side processing.
| Term | Definition |
|---|---|
Staff space (s) | Distance between the centres of two adjacent staff lines, in pixels. All geometric thresholds are expressed in s. |
Line thickness (t) | Thickness of a staff line in pixels, the most frequent vertical black run [OMR §2.4]. |
Staff position (pos) | Vertical position of a notehead in half staff spaces relative to the middle line; positive is up. Line 3 (middle) is 0, the top line is +4, the bottom line is −4. |
| Staff | Five lines, modelled as one polyline per line. |
| System | The staves that are played together and read left to right before the next line of music; joined by a brace, a bracket, a left-edge line or barlines running through the gap. |
| Staff group | Staves inside a system joined by one brace or bracket [VOI §1]. |
| Logical part | One performer or section followed across systems and pages [VOI §1]. |
| Closed score | Two or more vocal parts sharing a staff, typically S+A on a treble staff and T+B on a bass staff [VOI §1, §3]. |
| Open score | One staff per vocal part. |
| Grand staff | Two staves braced for one keyboard player. Never split into parts. |
| Divisi | One section temporarily divided into two lines, often on one staff [VOI §4]. |
| Voice | A monophonic sequence of events in time within a staff; MusicXML <voice> [VOI §1]. |
| Slot | The set of events on a system that share an onset and are roughly x-aligned [VOI §1]. |
| Unison (shared head) | One notehead sung by two voices, drawn with a stem on each side or as a single stem with no opposite-stem event [VOI §2.2]. |
| Syllabic | MusicXML lyric attribute: single, begin, middle or end, from the syllable's place in its word. |
| Extender | A horizontal line after a word's last syllable showing that the syllable is held over several notes (melisma); MusicXML <extend>. |
| Melisma | One syllable sung over several notes. |
| Verse | One lyric line; MusicXML <lyric number="n">. |
| Divisions | MusicXML ticks per quarter note, set per part in <attributes>. |
| Backup / forward | MusicXML elements that move the time cursor back or forward so several voices can be written in one part. |
| OMR-NED | (I + D) / (N_pred + N_ref) over visual symbols, a substitution counting as one deletion plus one insertion; built on musicdiff, from the Sheet Music Benchmark, ISMIR 2025 [OMR §8]. Lower is better. |
| TEDn | Normalised tree edit distance on MusicXML (Hajič et al. 2016) [OMR §8]. |
| SER / CER | Symbol and character error rates: edit distance divided by reference length. |
| MV2H | Joint multi-pitch, voice, meter, value and harmony score (McLeod & Steedman 2018). Its voice component measures part splitting [OMR §8]. |
| Artifact | A page published on claude.ai: one HTML file plus supporting files, under a strict Content Security Policy. |
| ID | Requirement |
|---|---|
| F1 | Accept a PDF (image-only or vector), PNG or JPEG by drag-and-drop or file picker. Multi-page PDFs are processed page by page. |
| F2 | On first open, convert a bundled public-domain scanned closed-score SATB hymn with lyrics, so the page opens in a working state. |
| F3 | Detect staves, systems, staff groups, barlines and measures on each page. |
| F4 | Recognise noteheads (filled, hollow, whole), stems, beams, flags, augmentation dots, rests, accidentals, clefs, key and time signatures, ties, ledger lines, fermatas and repeat barlines. Tuplets with a printed number are in scope; unmarked tuplets are handled by rhythm repair. |
| F5 | Produce pitch (clef, key, accidentals, octave clefs) and duration for every note, with chords and voices per staff. |
| F6 | Make every voice in every measure sum to the measure length, or pad it and flag the measure. |
| F7 | OCR lyric rows, split them into syllables with the correct syllabic, detect extenders and verse numbers, and attach syllables to notes. |
| F8 | Decide per logical staff group whether to split voices into separate parts (closed score) or keep them (keyboard, ambiguous), using the procedure in §6. Copy lyrics to every voice that sings them (§7). |
| F9 | Name parts from OCR'd system-start labels, or infer names from layout, and mark inferred names. |
| F10 | Emit MusicXML 4.0 score-partwise that validates against the official XSD, with <part-group>, <divisions>, <voice>, <backup>/<forward>, <staff>, <lyric>, and implicit="yes" on pickup measures. |
| F11 | Show the scanned page with a toggleable overlay of detections, a notation preview of the result, a per-part summary (including which parts were split from shared staves), a lyrics preview, warnings with confidence, progress per stage, and the MusicXML in a selectable text area with a Copy button. |
| F12 | Let the user override an ambiguous split decision with a toggle (§6.5), and re-emit MusicXML without re-running recognition. |
| F13 | The same core runs in node for the accuracy harness. |
Accuracy targets. The v1 targets come from the research recommendations and are not yet measured:
| Measure | Target | Source |
|---|---|---|
| Note F1 (onset, pitch, duration) on clean synthetic closed-score hymns | ≥ 95 % | [OMR §9.4] |
| Part assignment (share of truth notes in the right S/A/T/B part), clean synthetic | ≥ 97 % | [OMR §9.4] |
| Lyric syllable-to-note attachment, clean synthetic | ≥ 90 % | [OMR §9.4] |
| Voice assignment with ground-truth symbols (isolates the voice logic) | ≥ 99 % of notes in the correct voice on chorales | [VOI §12] |
| Split decision per staff group, hymns and Lieder | 100 % | [VOI §12] |
| XSD validity of output | 100 % of outputs | §10 |
| Crashes over the corpus | 0 | [WF] |
| Degraded synthetic and real scans | Reported separately; thresholds to be decided by the design phase once the spikes report numbers | — |
For context, the best open system reports OMR-NED of 11.1–11.2 % on quartet scans and 17.0–17.8 % on Lieder scans for its 2M-parameter model (copisteria, *self-reported*), and Audiveris 5.11 reports 66.9 % and 51.8 % on the same sets [OMR §3.2]. No published system produces lyrics and voice-separated parts together [OMR §0].
Performance. Budget per 300 dpi page on a mid-range laptop, single-thread WASM [OMR §7.4] (estimates to be verified by the harness):
| Stage | Budget |
|---|---|
| Classical image stages (binarise to symbol assembly) | 0.3–1.5 s |
| Lyric OCR, tesseract fast model | 0.1–0.3 s per row; 2–5 s for a hymn page with 4 systems and 3–4 verses |
| Optional tiny CNN classifier on a few hundred crops | < 0.5 s |
| Optional 2M-parameter detector at 1536 px long side | 1–3 s |
| Whole page, v1 (no ML) | target ≤ 8 s; hard ceiling 20 s before a "this page is slow" notice |
Heavy work runs off the main thread in a same-origin Web Worker, so the UI stays responsive. Peak memory for one 300 dpi A4 page (about 8.7 Mpx) should stay below 300 MB: gray, binary and staff-removed copies are Uint8Arrays, and the integral images are Float64Arrays that are freed after binarisation.
Size budget. Each supporting file ≤ 15 MB, the total ≤ 64 MB [WF]. Planned: phase 1 about 13–20 MB, phase 2 with ONNX about 30–40 MB [OMR §9.3]. The build fails if a limit is exceeded (§9).
Licensing. Everything shipped must be open source and compatible with public distribution: MIT, Apache-2.0, BSD or OFL. Nothing AGPL, GPL, LGPL or non-commercial is shipped (§11).
Accessibility. WCAG 2.2 AA for the page: keyboard operation of every control, visible focus, text contrast ≥ 4.5:1, an aria-live progress region, no information carried by colour alone (voice overlays also differ by marker shape and are listed in text), prefers-reduced-motion respected, and a usable layout at 400 px width with no horizontal page scroll (§8.5).
Privacy. The user's file never leaves the browser. There are no network requests after the page and its assets have loaded.
Portability. Current Chrome, Edge, Firefox and Safari. No SharedArrayBuffer and no crossOriginIsolated requirement.
The core is a pure function from page bitmaps to a score model and MusicXML. It holds no DOM references. Environment-specific work (PDF rasterising, image decoding, OCR engines, ONNX sessions) is reached through an injected environment adapter.
flowchart TD IN["Input: PDF / PNG / JPEG bytes"] --> R["raster.js: rasterise, gray, shading normalise, Sauvola, deskew"] R --> S["staff.js: staff metrics s,t; staves; systems; staff removal; barlines; measures"] S --> Y["symbols.js: connected components, heads, stems, beams, flags, dots, rests, accidentals, clefs, key/time, ties"] S --> L1["lyrics.js (pass 1): text-row detection in inter-staff bands"] Y --> RC["recognize.js: chords, slots, pitch, voices per staff, rhythm repair"] L1 --> OCR["env.ocr: tesseract.js line OCR"] OCR --> L2["lyrics.js (pass 2): syllables, extenders, verses, alignment to reference voice"] RC --> P["parts.js: staff-group classification, split decision, naming, lyric copy"] L2 --> P P --> X["musicxml.js: MusicXML 4.0 partwise"] X --> OUT["convert() result: musicxml, score, overlays, warnings, stats"] ENV["env adapter: env-browser.js or env-node.js"] -.-> R ENV -.-> OCR ENV -.-> Y
Each arrow is a typed data structure defined in §5. Stages are run per page by index.js, except recognize (cross-system part continuity), parts (piece-level decisions) and musicxml, which run once over all pages.
The layout is fixed by the build workflow, with one owner per file [WF]:
| File | Responsibility |
|---|---|
src/raster.js | Input bytes to page bitmaps via the env adapter; gray, binarise, deskew, denoise, scale normalisation. |
src/staff.js | Staff lines, staff space and thickness, systems and staff groups, staff removal, barlines, measures. |
src/symbols.js | Symbol detection and classification; model loading through env. |
src/recognize.js | Symbols to score model: pitch, duration, chords, voices per staff, rhythm repair, cross-system continuity. |
src/lyrics.js | Lyric strip location, OCR through env, syllables, extenders, verses, alignment, voice assignment. |
src/parts.js | Split decision, part naming, lyric duplication policy. |
src/musicxml.js | Score model to XSD-valid MusicXML 4.0. |
src/index.js | convert(input, {env, onProgress, options}); stage orchestration, timing, error containment. |
src/env-node.js, src/env-browser.js | Environment adapters. |
ui/template.html, ui/ui.js, ui/styles.css | The page. |
build.mjs | Bundles to dist/index.html (all app JS inlined) and dist/assets/, enforces size limits. |
test/run.mjs, test/schema/ | Harness and MusicXML 4.0 XSD. |
interface Env {
name: 'browser' | 'node'
rasterize(bytes: Uint8Array, opts: {targetDpi?: number, maxPixels?: number})
-> AsyncIterable<{pageIndex, bitmap: Bitmap, sourceDpi: number | null, isImageOnly: boolean}>
decodeImage(bytes: Uint8Array, mime: string) -> Promise<Bitmap>
createOcr(opts: {langs: string[]}) -> Promise<OcrEngine | null>
createOnnxSession(modelName: string) -> Promise<OnnxSession | null> // null when not shipped
now() -> number // ms, for stats
yield() -> Promise<void> // lets the UI breathe
}
interface OcrEngine {
recognizeLine(bitmap: Bitmap, opts: {whitelist?: string, psm?: 7})
-> Promise<{text, conf, words: [{text, conf, bbox: [x0,y0,x1,y1]}]}>
terminate() -> Promise<void>
}
| Concern | Browser (env-browser.js) | Node (env-node.js) |
|---|---|---|
pdf.js 3.11.174 from cdnjs as plain <script> tags, worker script also as a tag so pdf.js runs its fake worker on the main thread [OMR §7.3]. For image-only pages render at the embedded image's native resolution; otherwise about 300 dpi. | pdfimages -png for embedded scans at native resolution, pdftoppm -r 300 -gray for vector pages [OMR §2.1]; decode with pngjs. | |
| Images | createImageBitmap → OffscreenCanvas → getImageData → gray. | pngjs / jpeg decoder. |
| OCR | tesseract.js 7.0.0 with workerPath, corePath (a directory), langPath all relative to assets/, workerBlobURL: false, gzip: false [OMR §6.2]. | tesseract.js in node with the same traineddata. |
| ONNX (optional) | onnxruntime-web 1.30.0 script from cdnjs; ort.env.wasm.wasmPaths = 'assets/' (or wasmBinary from a decompressed .wasm.gz); numThreads = 1; no proxy worker [OMR §7.1]. | onnxruntime-node, same .onnx files. |
The core never imports either adapter. index.js receives one as an argument, which makes the harness and the browser run byte-identical core code.
The browser runs the core in one same-origin module worker (assets/omr-worker.js, or a blob: worker built from the inlined bundle). pdf.js runs on the main thread (fake worker) and transfers page bitmaps to the OMR worker as ArrayBuffers. Tesseract runs in its own worker created by tesseract.js. ORT runs single-threaded inside the OMR worker. Progress events are posted back to the UI thread.
Every stage is wrapped by index.js. A stage that throws on one page or one system yields an empty result for that unit plus a warning of severity error, and the pipeline continues. convert() returns partial MusicXML whenever at least one measure was recognised. Only input-level failures (unreadable file, encrypted PDF, zero pages) end the run with an error state.
Each module lists its responsibility, inputs and outputs, algorithms, failure modes and confidence. Thresholds are written in staff spaces s and line thickness t, and are starting values for tuning on the corpus.
Responsibility. Turn input bytes into normalised page images: gray, binary, deskewed, at a scale where s is in a workable range.
Inputs/outputs. Uint8Array + MIME → Page[] (§5.1) holding gray, binary, skewDeg, scale (working pixels per source pixel) and the transform back to source coordinates.
Algorithms.
env.rasterize or env.decodeImage. Reject pages above 40 Mpx with a warning and downscale them to 40 Mpx.0.299R + 0.587G + 0.114B, or the single channel when already gray.T(x,y) = m·(1 + k·(σ/R − 1)), R = 128, k = 0.2–0.4, window about 2–4 s (31–61 px at 300 dpi), computed in O(1) per pixel from integral images of I and I² (Shafait, Keysers & Breuel 2008) [OMR §2.2]. Before s is known, use a 41 px window, then re-run with a window of 3 s once staff metrics exist. Compute Otsu as well and keep the image whose vertical run-length histogram gives the sharper t+s peak.staff.js models it with polylines.0.02 s² that do not touch a staff line (salt-and-pepper), after s is known.s < 10 px, upscale the page 2× before recognition [OMR §9.5]; if s > 40 px, downscale towards 20 px. Rule-based steps do best around s = 16–24 px [OMR §2.1].Failure modes. Very dark or very light scans (Sauvola leaves speckle or erases thin lines); colour scans with red rubrics; 2-up landscape pages; strong gutter curvature; pages with no music (title pages). Confidence. rasterConf = sharpness of the staff-line run-length peak, normalised to [0,1]; low values add a warning ("faint or noisy scan").
Responsibility. Staff metrics, staves as line polylines, systems and staff groups, staff removal, barlines and measures.
Inputs/outputs. Page → StaffLayout (§5.2, §5.3): staves[], systems[], measures[], s, t, plus a staff-removed binary image noStaff.
Algorithms.
t) and white runs (mode ≈ s − t). Following Cardoso & Rebelo (ICPR 2010), take the most common sum of a black run and the following white run as t + s, which is robust when one of the two histograms is noisy [OMR §2.4].t + s ± 20 % [OMR §2.5]. Link five-line groups across adjacent strips by vertical overlap, which tolerates skew and curvature. Fit each line as a polyline through the strip centres.1.75 t and is centred on it; keep longer runs because they belong to symbols (Dalitz et al., TPAMI 2008, "line track height") [OMR §2.6]. Keep the un-removed gray and binary images for notehead matching, because removal fragments hollow heads.0.4 s that span the staff from the top line to the bottom line (± 0.5 s) and have no notehead attached. Classify thin, thick, double, final and repeat (two dots in spaces 2 and 3 next to a thick-thin pair).s; a bracket is a thick vertical bar with serifs at both ends. Record groupSymbol: 'brace' | 'bracket' | 'line' | 'none'.recognize.js repair.Failure modes. Staves that touch text or ornaments; lyric hyphens or extenders read as staff fragments; partial staves (ossia, cue staves); systems split across a gutter; very short systems (codas); barlines merged with stems in dense music. Confidence. Per staff: regularity of line spacing (coefficient of variation of the five gaps) and line coverage across the width; per system: group-symbol detection score.
Responsibility. Find and classify music symbols on each staff and attach a confidence to each.
Inputs/outputs. Page + StaffLayout → Symbol[] (§5.4) in page coordinates, each with staff index, pos (for heads, rests, dots and accidentals), class, bbox and confidence; plus a list of text-like components handed to lyrics.js.
Algorithms.
noStaff, recording bbox, area, centroid, density and moments in s units. A second pass on the inverted image inside each component's bbox finds enclosed holes (hollow heads, whole notes, flats, sharps, naturals) [OMR §2.7].s above the top line or below the bottom line, excluding ledger-line regions) whose height is about 0.6–1.2 s and which line up on a common baseline are marked as text and masked out of music recognition [OMR §2.7].s, restricted to staff positions (line or space, half-space quantisation) [OMR §2.8]. Templates for filled, hollow and whole heads (2–3 variants each) are rendered from Bravura or Leland (SIL OFL-1.1). Peaks above threshold, after non-maximum suppression, become head candidates. Hollow versus filled is checked by the hole test. Heads on ledger lines are found by extending the position grid above and below the staff where short horizontal runs of length about 1.5–2 s exist.s and width ≤ about 0.3 s touching a head: stem at the head's right edge means stem up, at its left edge stem down. A head with stems on both sides, or a head with an up stem and a down stem, is marked stemDir: 'both' (a unison of two voices) [OMR §2.9; VOI §2.2]. A single stem with heads at both ends or a head at its middle is tested as two aligned stems (the known merge failure [VOI §2.7]).s) between stem ends. The number of beam lines crossing a stem gives the beam level (1 = eighth, 2 = sixteenth). Flags are separate components at the free end of a stem [OMR §2.9].s right of a head, in a space; a dot for a head on a line sits in the space above. Dots above or below a head on the stem-free side are staccato and are ignored in v1 [OMR §2.9].both and a voice slot per head) used as an extra vote [OMR §3.1, §9.4];docs/spike-ml.md and docs/spike-classical.md, which have not been written yet.Failure modes. Heads touching each other (seconds, unisons offset sideways); stems broken by staff removal; beams merged with slurs; accidentals touching heads; lyric hyphens read as ledger lines or ties [VOI §2.7]; fermatas read as heads; verse numbers read as fingering. Confidence. Each symbol carries its classifier score in [0,1]; heads also carry the NCC peak value and stems their length ratio.
Responsibility. Assemble symbols into notes, chords, rests and voices, assign pitch and duration, repair rhythm per measure, and keep parts continuous across systems and pages.
Inputs/outputs. Symbol[] + StaffLayout for every page → Score draft (§5.10) with measures, voices per staff, events with onsets and durations, and per-measure repair records.
Algorithms.
octaveChange = −1 [OMR §2.11].s [VOI §10 step 2; Audiveris SlotsRetriever described in VOI §2.5].s above the middle line → U, ≤ −1 s below → L, centred → shared unless one voice already covers that time; a rest inside a beam group takes the beam's voice [VOI §2.3];implicit; in hymns the pickup and the final measure usually add up to one full measure [OMR §2.10].<forward> (or an invisible rest), and flag the measure with confidence < 0.5 [VOI §11].Failure modes. Missed dots and beams (the classic desynchronisation), unmarked triplets (copisteria gets 65 % of tuplet notes right when marks are removed against 96 % with them [OMR §2.10]), merged aligned stems, grace notes, cross-staff beams. Confidence. Per measure: 1.0 if the measure passed without repair, reduced by the cost of the repair, < 0.5 if padded. Per voice: share of its measures that passed without repair.
Responsibility. Find lyric rows, OCR them, split into syllables, assign verses and syllabic, detect extenders, and align syllables to the reference voice's onsets. Detail in §7.
Inputs/outputs. Pass 1: Page + StaffLayout + text components → LyricRow[] (bands with bboxes and verse order). Pass 2: rows + env.ocr + Score draft → LyricLine[] of Syllable (§5.9) bound to slots of a reference voice, with conflict counts per receiving voice.
Algorithms (summary; §7 has the full design): text-row detection by horizontal projection of masked small components; crop with 0.3 s margin and upscale to x-height ≥ 20 px; tesseract single-line mode (tessedit_pageseg_mode = 7) with word boxes; verse numbers stripped; hyphen and gap tokenisation; geometric extender detection; monotonic DP alignment of syllables to onsets [OMR §6.3; VOI §7.2].
Failure modes. One-letter syllables ("a", "O") missed by OCR [OMR §3.3]; hyphens dropped; italic refrains; lyric rows touching low notes or ledger lines; verses 4 and later printed as stanzas below the music, which are not underlay [OMR §6.3]. Confidence. Per syllable: OCR word confidence × alignment cost factor; per part and verse: coverage (syllables attached / syllables OCR'd).
Responsibility. Classify each logical staff group, decide whether to split voices into parts, name the parts, apply the lyric copy policy, and record provenance. Detail in §6.
Inputs/outputs. Score draft + LyricLine[] + label OCR → final Part[] (§5.8) with splitFrom provenance, names (read or inferred), part groups and per-part confidence.
Algorithms. The weighted vocal-score classifier and thresholds of [VOI §5], the decision procedure of [VOI §11], the naming procedure of [VOI §6], and the lyric copy rules of [VOI §7.2].
Failure modes. A hymn wrongly kept as one keyboard part; a piano accompaniment wrongly split; part count changing between systems; labels misread. Confidence. Per part: the minimum of classification confidence (sigmoid of the vocal score), voice-assignment confidence and stem-detection confidence [VOI §11].
Responsibility. Serialise the final score model as XSD-valid MusicXML 4.0 score-partwise.
Inputs/outputs. Score → string. Pure and deterministic: the same input gives byte-identical output.
Algorithms.
divisions = 48 / gcd(48, all durations in the part); written in the first measure's <attributes> and only re-written if it changes.<part-list> with <part-group type="start" number="n"> (group-name, group-symbol bracket for a split choir or brace for a keyboard part, group-barline yes), one <score-part> per part with <part-name> and <part-abbreviation>, and the matching type="stop" [VOI §8].<fifths>, <mode> when known), time (<beats>, <beat-type>, or symbol="common" / "cut"), clefs (with number for multi-staff parts), <staves>2</staves> for grand staves. Split tenor parts use treble-8 (G, line 2, clef-octave-change −1) by default, with an option to keep the printed bass clef [VOI §8.1].<backup> by the summed duration written so far, then voice 2, and so on. <backup> never crosses a measure boundary and its duration is positive [VOI §8]. Voice numbering on multi-staff parts: 1–4 for voices starting on staff 1, 5–8 on staff 2 [VOI §0 item 5]. Every note in a multi-staff part carries <staff>.<pitch> (step, alter, octave) or <rest/> (with measure="yes" for whole-measure rests), <duration>, <tie type> plus <notations><tied>, <voice>, <type>, <dot/>, <time-modification> for tuplets, <stem> only in unsplit multi-voice parts, <chord/> for the second and later heads of a chord, <accidental> when printed, <notations><fermata/>. Padding uses <forward> with <voice>, or a rest with print-object="no".<lyric number="v"> with <syllabic>, <text>, optional <elision>, and <extend type="start|stop"/>; melisma continuation notes get no lyric element [VOI §8].number sequential from 1 (0 for a pickup), implicit="yes" on pickups, <barline> for repeats, double and final bars. <print new-system="yes"> and new-page="yes" mirror the scan's layout.<identification><encoding> with software name and date; <work-title> from OCR of the largest text block on page 1 if confident, otherwise the file name.Failure modes. Voice durations not matching after an upstream bug (guarded: the writer re-checks each voice sum and inserts <forward> with a warning rather than writing an inconsistent measure); invalid character data from OCR (stripped of control characters). Confidence. Not applicable; validity is checked in the harness with xmllint against the 4.0 XSD.
convert(input, {env, onProgress, options}) → {musicxml, score, pages: Overlay[], warnings, stats}. Runs stages in order, reports progress as {stage, page, pageCount, fraction}, records per-stage timings, contains errors (§3.5), and exposes reemit(score, overrides) so the UI can toggle a split decision and get new MusicXML without re-running recognition.
Conventions:
src* (source image pixels). Each page stores the affine transform from working to source pixels for the overlay.s unless the field name ends in Px.pos: half staff spaces from the middle line, positive up (§1.3).TPQ = 48 ticks per quarter note. 48 covers dotted values down to 32nds and triplets down to 16th triplets; quintuplets and finer tuplets would need a change of TPQ (to be decided by the design phase if the corpus contains them).conf.st3, sy1, h204, n88).// comments added for reading; a string such as "Bitmap" or "up | down" names a type or lists the allowed values, and x0, y0 placeholders stand for numbers.// Bitmap: the only image type that crosses module boundaries
{ "width": 2480, "height": 3508, "data": "Uint8ClampedArray(width*height), 0 = black, 255 = white" }
// Page
{
"index": 0,
"gray": "Bitmap",
"binary": "Bitmap (0 = ink, 255 = paper)",
"noStaff": "Bitmap | null (set by staff.js)",
"srcWidth": 2550, "srcHeight": 3300, "sourceDpi": 300,
"skewDeg": -0.35,
"scale": 1.0,
"toSource": [1, 0, 0, 1, 0, 0],
"binarization": "sauvola | otsu",
"conf": 0.92
}
{
"id": "st3", "page": 0, "system": "sy1", "indexInSystem": 0,
"lines": [[[120, 402.0], [600, 401.2], [2380, 399.8]], "... 5 polylines, top line first"],
"s": 21.4, "t": 2.6,
"x0": 118, "x1": 2382,
"clefAtStart": { "sign": "G", "line": 2, "octaveChange": 0 },
"lineCountDetected": 5,
"conf": 0.97
}
// System
{
"id": "sy1", "page": 0, "staves": ["st3", "st4"],
"groups": [{ "id": "g1", "staves": ["st3", "st4"], "symbol": "brace | bracket | line | none", "conf": 0.8 }],
"barlines": [{ "id": "bl7", "x": 640, "kind": "single | double | final | repeat-start | repeat-end", "staves": ["st3", "st4"], "conf": 0.95 }],
"measures": ["m1", "m2", "m3"],
"labels": [{ "text": "S. A.", "bbox": [20, 380, 100, 440], "staff": "st3", "conf": 0.7 }]
}
// Measure (geometric span, shared by all staves of the system)
{ "id": "m2", "system": "sy1", "number": 2, "x0": 640, "x1": 1180,
"pickup": false, "timeTicks": 192, "repair": null, "conf": 1.0 }
{
"id": "h204", "page": 0, "staff": "st3",
"kind": "head | stem | beam | flag | dot | rest | accidental | clef | keySig | timeSig | tie | slur | ledger | fermata | barline | text",
"subtype": "filled | hollow | whole | quarter-rest | eighth-rest | sharp | flat | natural | G | F | C | 3 | ...",
"bbox": [812, 371, 834, 391],
"cx": 823, "cy": 381,
"pos": 3,
"stemDir": "up | down | both | none",
"links": { "stem": "sm33", "dots": ["d9"], "accidental": "ac2", "beams": ["bm4"] },
"conf": 0.88,
"source": "ncc | features | cnn | detector"
}
// Note
{
"id": "n88", "head": "h204",
"pitch": { "step": "E", "alter": 0, "octave": 5 },
"accidentalPrinted": null,
"durTicks": 96, "type": "half", "dots": 0,
"tuplet": null,
"tie": { "start": false, "stop": false },
"fermata": false,
"lyrics": ["Syllable refs"],
"conf": 0.9
}
// Rest
{ "id": "r12", "durTicks": 48, "type": "quarter", "dots": 0, "measureRest": false,
"vertical": "up | down | centre", "shared": false, "hidden": false, "conf": 0.85 }
// Chord: one onset, one stem (or stemless group), one voice
{ "id": "c40", "staff": "st3", "onsetTicks": 0, "durTicks": 96,
"notes": ["n88"], "stemDir": "up", "beamGroup": "bg5", "slot": "sl3",
"mirrorOf": null, "copiedFrom": null, "conf": 0.9 }
{
"id": "v-st3-U", "staff": "st3", "key": "U | L | K1 | K2 | K3 | K4",
"events": [{ "measure": "m2", "chord": "c40" }, { "measure": "m2", "rest": "r12" }],
"unisonCopies": 3, "decombinedChords": 5, "divisiFlags": 0,
"conf": 0.94
}
U/L are the upper and lower voices of a two-voice staff; K1–K4 are keyboard voices.
{
"measure": "m2", "group": "g1",
"voices": {
"v-st3-U": { "events": ["c40", "c41", "r12"], "sumTicks": 192, "padTicks": 0 },
"v-st3-L": { "events": ["c42", "c43"], "sumTicks": 192, "padTicks": 0 }
},
"repair": { "applied": true, "states": 37, "changes": ["dot added to n91"], "cost": 1.6 },
"conf": 0.78
}
{
"id": "P2",
"name": "Alto", "abbreviation": "A.", "nameSource": "label | inferred",
"kind": "vocal | keyboard | instrument | unknown",
"staves": 1,
"clef": { "sign": "G", "line": 2, "octaveChange": 0 },
"voices": ["v-st3-L"],
"splitFrom": { "logicalGroup": "LG1", "staffIndex": 0, "voiceKey": "L", "stem": "down",
"reason": "lyrics between staves; opposite stems in 92% of bars", "vocalScore": 7.0 },
"group": "PG1",
"lyricSource": "copied | read | none",
"conf": 0.86
}
// Part group
{ "id": "PG1", "name": "Choir", "symbol": "bracket | brace", "barline": true, "parts": ["P1", "P2", "P3", "P4"] }
// LyricRow (pass 1, geometry only)
{ "id": "lr4", "system": "sy1", "aboveStaff": "st3", "belowStaff": "st4",
"bbox": [130, 470, 2370, 505], "baselineY": 500, "xHeightPx": 13,
"verseIndex": 1, "verseLabel": "1." }
// LyricLine (pass 2)
{ "id": "ll4", "row": "lr4", "verse": 1, "ocrText": "Praise God from whom all bless - ings flow",
"ocrConf": 0.83, "syllables": ["sb51", "..."],
"referenceVoice": "v-st3-U", "receivers": ["v-st3-U", "v-st3-L", "v-st4-U", "v-st4-L"],
"conflicts": { "v-st3-L": 1, "v-st4-U": 0, "v-st4-L": 2 } }
// Syllable
{ "id": "sb51", "text": "bless", "syllabic": "begin", "verse": 1,
"bbox": [1500, 478, 1560, 500], "cx": 1530,
"extender": false, "elision": false,
"slot": "sl14", "attachedTo": { "v-st3-U": "n120", "v-st3-L": "n121" },
"conf": 0.79 }
{
"meta": { "title": "Old Hundredth", "titleConf": 0.6, "source": "upload.pdf", "pages": 1,
"software": "scan2mxl 0.1", "created": "2026-11-08" },
"pages": ["Page"], "staves": ["Staff"], "systems": ["System"], "measures": ["Measure"],
"symbols": ["Symbol"], "chords": ["Chord"], "notes": ["Note"], "rests": ["Rest"],
"voices": ["Voice"], "measureContent": ["MeasureContent"],
"logicalGroups": [{ "id": "LG1", "staffCount": 2, "systems": ["sy1", "sy2"], "classification": "vocal-closed | keyboard | ambiguous | single", "vocalScore": 7.0, "features": {} }],
"parts": ["Part"], "partGroups": ["PartGroup"],
"lyricLines": ["LyricLine"], "syllables": ["Syllable"],
"key": { "fifths": 1, "mode": "major" }, "time": { "beats": 4, "beatType": 4, "symbol": null },
"tpq": 48,
"overrides": { "LG1": "auto | split | keep" }
}
Geometry for the UI, in source pixels so it can be drawn over the original scan.
{
"page": 0, "width": 2550, "height": 3300,
"layers": {
"staves": [{ "id": "st3", "polylines": [[[x, y], "..."]], "conf": 0.97 }],
"systems": [{ "id": "sy1", "bbox": [x0, y0, x1, y1], "groupSymbol": "brace" }],
"barlines": [{ "x": 640, "y0": 380, "y1": 760 }],
"heads": [{ "bbox": [x0, y0, x1, y1], "part": "P2", "voiceKey": "L", "marker": "triangle-down", "conf": 0.88 }],
"rests": [{ "bbox": [x0, y0, x1, y1], "part": "P1", "conf": 0.8 }],
"lyrics": [{ "bbox": [x0, y0, x1, y1], "text": "bless", "verse": 1, "attached": true }],
"flags": [{ "bbox": [x0, y0, x1, y1], "measure": "m7", "reason": "padded", "severity": "warn" }]
}
}
{
"code": "MEASURE_PADDED | LYRICS_NOT_COPIED | SPLIT_AMBIGUOUS | NAME_INFERRED | FAINT_SCAN | STANZA_TEXT_UNATTACHED | DIVISI_KEPT_AS_CHORD | STAGE_FAILED | LOW_DPI_UPSCALED | PAGE_NO_MUSIC",
"severity": "info | warn | error",
"message": "Lyrics not applied to Tenor: rhythm differs in 4 places",
"where": { "page": 0, "system": "sy2", "measure": "m9", "part": "P3" },
"conf": 0.4
}
| Pair | Slots | Unison | Crossing | Rhythm diverges | One voice resting |
|---|---|---|---|---|---|
| S/A | 24,381 | 2.46 % | 0.24 % | 31.76 % | 0.82 % |
| T/B | 27,182 | 3.58 % | 0.78 % | 30.58 % | 0.80 % |
Consequences: unisons are common enough to need explicit copying; crossings are rare but real, so stem direction must win over pitch order; about 31 % of slots have an onset in only one voice, so lyric copying must align by slot, not by note index. Lyrics appear on the soprano only in 320 of the 368 files, so lower-voice lyric truth has to be derived [VOI §0].
Computed per logical staff group, summed over all of its systems. Weights are the research recommendation and are tuned on the corpus [VOI §5]:
| Feature | Weight |
|---|---|
| Vocal label tokens at system start (S, A, T, B, Soprano, Alto, Tenor, Bass, Sopran, Alt, Contralto, Basso, Cantus, Altus, Bassus, Women, Men, Choir, Chorus, Coro, Chor, SATB) | +3 |
| Lyric rows adjacent to the group (below staff 1 or between its staves) | +2.5 |
| Per staff: both stem directions co-occur in ≥ 30 % of measures | +1.5 per staff |
| ≥ 98 % of stems have ≤ 2 heads | +1 |
| Stream ranges fit SATB (S ≈ C4–A5, A ≈ F3–D5, T ≈ C3–G4, B ≈ E2–D4) | +1 |
| Treble-8 clef present | +0.5 |
| Bracket rather than brace | +0.5 |
| Keyboard label (Piano, Pno., Pf., Klavier, Organ, Org., Orgel, Manual, Ped., Harmonium, Harp, Accomp.) | −3 |
| Lyrics in the system, but only on a different staff | −2.5 |
| Pedal marks, arpeggio lines, fingering digits, cross-staff or kneed beams, 8va lines | −2 |
| ≥ 5 % of stems with ≥ 3 heads; octave doublings in the bottom stream | −1.5 |
| Brace and no lyrics anywhere near | −0.5 |
A brace alone must not veto a split, because hymnals often brace SA/TB staves so the short score can double as an organ part [VOI §5]. A typical unlabelled hymn scores about +7; a song accompaniment about −7.
for each logical staff group G (whole piece):
s = vocalScore(G)
if G.staves == 1:
twoVoice = share of measures (where the staff has notes) with >= 2 concurrent streams
names = labelNames(G)
if names.count >= 2 and (s >= 1 or twoVoice >= 0.2): SPLIT into names
elif twoVoice >= 0.5 and allStreamsMonophonic and s >= 3: SPLIT into "<Name> 1", "<Name> 2"
else: KEEP one part, voices 1/2 (warn if twoVoice > 0)
elif G.staves == 2:
if s >= 3: SPLIT: per staff k voices (k = max concurrent streams, usually 2; SAB gives 2+1)
elif s <= -1: KEEP as keyboard part: <staves>2</staves>, voices 1-4 / 5-8
else: KEEP as closed score (two parts, voices 1/2) and flag SPLIT_AMBIGUOUS
elif G.staves >= 3: KEEP (organ) unless every staff qualifies as vocal on its own
part count is fixed for the piece; never split per measure;
measures with fewer visible voices get unison copies or rests.
Source: [VOI §11]. Decisions are made once per logical group for the whole piece. Part layout is planned on the fullest system and every other system is mapped onto it, so a system where a voice drops out still yields the same parts, with rests [OMR §5.2 step 5].
| Form | When | MusicXML |
|---|---|---|
| A: split choir | s ≥ 3 | One <score-part> per voice inside <part-group> with group-symbol bracket, each part monophonic <voice>1</voice>, unisons and shared rests duplicated, lyrics copied [VOI §8.1]. |
| B: closed score kept | ambiguous band, or user choice | One part per staff with voices 1 and 2 and <backup>; lossless; split available as a toggle [VOI §8.2]. |
| C: keyboard | s ≤ −1 | One part, <staves>2</staves>, <staff> on every note, voices 1–4 on staff 1 and 5–8 on staff 2 [VOI §8.3]. |
| Case | Action | User-facing note |
|---|---|---|
| Braced hymn staves, no labels, lyrics between staves | Split | "Split from shared staves: lyrics between staves, opposite stems in N % of bars" |
| Single-stem two-note chords through a passage of a closed staff | De-combine top/bottom | Silent unless a chord has ≥ 3 heads |
| ≥ 3 heads on a closed staff (local divisi) | Extra head stays a chord in the nearer voice | "Divisi in bar N kept as chord in Alto" |
| Two-voice texture on an open-score staff in < 50 % of bars, unlabelled | Keep one part with voices | "Soprano has divisi in bars …" |
| Voice count differs between systems | Use the maximum for the piece; fill with unison copies or rests | — |
| Lyric conflicts > 15 % of a verse's syllables for a voice | Do not copy that verse to that voice | "Lyrics not applied to Tenor (N conflicts)" |
| Bar fails after repair | <forward> padding, measure confidence < 0.5 | Bar highlighted in the overlay |
| Score in (−1, 3) | Output B, toggle offered | "Could be piano or choir: currently kept together" |
| Vocal score with piano reduction below | Choir group split, piano kept | "Piano reduction kept as one part" |
| Instrumental shared staff labelled "1.2" or "a2" | Keep unless labelled with two names and monophonic | "Flutes 1.2 kept as one part (two voices)" |
Source: [VOI §11]. The user toggle sets score.overrides[LG] = 'split' | 'keep' and calls reemit.
Label regions are text boxes left of the first barline that overlap a staff or sit centred on a group symbol. They are normalised (lowercase, diacritics and punctuation stripped, split on whitespace, /, &, ,, +, .) and looked up in a multilingual lexicon (soprano, alto, tenor, baritone, bass, collectives such as Women → S+A and Men → T+B, divisi markers, keyboard tokens). On a two-voice staff the first name goes to the upper voice; four names on a two-staff group map (1,2) to staff 1 and (3,4) to staff 2. Abbreviations on later systems are matched by prefix and never change the part count. Unlabelled vocal groups get S, A, T, B (treble + bass), Tenor I, Tenor II, Baritone, Bass (treble-8 + bass) or S, A, Baritone (SAB), and the UI shows "(inferred from layout)" [VOI §6].
s, a common baseline, and horizontal extent over at least a quarter of the system width [OMR §6.3].STANZA_TEXT_UNATTACHED; no underlay is invented [OMR §6.3].tessdata_fast traineddata: eng 4.11 MB, lat 3.19 MB, deu 1.53 MB [OMR §0, §6.2]. tessdata_best eng (15.4 MB) is over the per-file cap. Since v6 only text output is on by default, so the call requests {blocks: true} for word boxes and confidences [OMR §6.2].s margin, upscale so the x-height is ≥ 20 px, binarise, run with tessedit_pageseg_mode = 7 (single line), and restrict the character set to letters (including accented Latin), punctuation, hyphen, apostrophe and digits [OMR §6.3].docs/spike-ocr.md (not yet written). Candidates: eng only (4.11 MB); eng + lat + deu (8.83 MB); PP-OCRv5 mobile Latin recogniser via ORT (det 4.8 MB + rec 8.0 MB, Apache-2.0) as the upgrade for degraded scans [OMR §6.2].- tokens or inside words ("glo-ry"). Word gaps are wider than syllable gaps. Tokens are split at hyphens, and word boundaries come from the gaps measured on the word boxes [OMR §6.3].syllabic: a word with no hyphen → single; first part → begin; inner parts → middle; last → end.t and length ≥ 1 s [OMR §6.3].<elision>), apostrophes ("pray'r"), isolated punctuation merged into the previous syllable, italic refrains, capitals at verse starts [OMR §6.3, §3.3].A monotonic dynamic-programming alignment runs between the syllables of one verse line and the onset slots of the reference voice, which is the upper voice of the staff above the row [VOI §7.2 step 3]:
s, with a variant that allows a left-aligned first syllable of a word;Notes inside a melisma get no <text>. The syllable that starts a melisma gets <extend type="start"/> and the last note <extend type="stop"/> (after word-final syllables only).
This replaces Audiveris' nearest-chord rule, which on a closed staff tends to bind lyrics to the lower voice because its heads are closer to the text [VOI §7.1].
syllabic and verse number. If the voice is sustaining a note there, it does not take the syllable and a conflict is counted. Onsets after a syllable with no syllable of their own are a melisma for that voice and get extender marks [VOI §7.2 step 4].read for the reference voice, copied otherwise) is stored per part for the lyrics preview.The 31 % rhythm-divergence figure in Bach chorales (§6.1) is why copying is by slot rather than by note index.
One page, two columns above 900 px and one column below. Left: the scanned page with overlay. Right: tabs or stacked panels for the per-part summary, notation preview, lyrics preview, warnings and MusicXML. The visual language draws on engraving: staff-line rules as dividers, measure-number-style counters for stages, and terms such as "closed score", "divisi" and "staff space" in the summary. Fonts come from Google Fonts only. Colours are theme tokens defined on :root, redefined for dark mode, with the body background taken from a token.
| State | What the user sees | Exit |
|---|---|---|
| Sample loading | Sample name, "Converting the sample hymn", stage list with the first stage active | Results (sample) |
| Idle with results | Results of the last conversion, drop zone collapsed to a "Convert another file" button | File chosen |
| Drag over | Drop zone outlined, text "Drop a PDF, PNG or JPG" | Drop, or leave |
| Uploading / reading | File name, size, page count once known | Converting |
| Converting | Stage list: Reading pages, Staves, Symbols, Rhythm and voices, Lyrics OCR, Parts, MusicXML; each with a state (waiting, running with page n of N, done with time, failed) and an overall bar; Cancel button | Results, Partial results, Error, Cancelled |
| Results | Page view with overlay, part summary, preview, lyrics, warnings, MusicXML with Copy | New file; split toggle re-emits |
| Partial results | As Results, with a banner listing stages or pages that failed, and the warnings expanded | Same |
| Error | Plain message naming the cause (not a PDF or image, encrypted PDF, no pages, no staves found, file too large) and what to try; the previous results stay visible below | New file |
<textarea> and a Copy button. Copy uses navigator.clipboard.writeText inside the click handler; on failure it selects the text and says "Press Ctrl+C / Cmd+C to copy". There is no download link, because downloads are inert in the artifact.No alert, confirm, prompt or print. Long work runs in the worker; the main thread only renders. The page opens in a working state by converting the bundled sample. Toggling a split decision re-emits MusicXML and re-renders the preview within about one second, without re-running recognition.
aria-live="polite" region; errors use role="alert" text in the page (not dialogs).prefers-reduced-motion turns off progress animations and smooth scrolling.max(16px, env(safe-area-inset-left)).build.mjs produces:
dist/index.html: no <!doctype>, <html>, <head> or <body> tags; it starts with <title> (a two to four word name), <meta name="claude:viewport-fit-sides" content="cover"> and <style>; all app JS is inlined [WF].dist/assets/: everything fetched at run time, loaded by relative URL.| Script | Host | Version | Licence |
|---|---|---|---|
pdf.js pdf.min.js and pdf.worker.min.js | cdnjs | 3.11.174 | Apache-2.0 |
tesseract.js tesseract.min.js | cdnjs | 7.0.0 | Apache-2.0 |
| OpenSheetMusicDisplay UMD | jsdelivr | 2.3.0 | BSD-3-Clause |
onnxruntime-web ort.wasm.min.js (phase 2 only) | cdnjs | 1.30.0 | MIT |
Fetch, XHR, WASM and worker loads from CDNs are blocked by the artifact CSP, so all WASM, traineddata, models and worker scripts are self-hosted in assets/ [WF].
| Asset | Size | Licence | Phase |
|---|---|---|---|
assets/omr-worker.js (core bundle) | < 0.5 MB | ours | 1 |
tesseract.js worker.min.js | 0.11 MB | Apache-2.0 | 1 |
tesseract core tesseract-core-simd-lstm.wasm.js + tesseract-core-lstm.wasm.js (relaxed-SIMD optional) | 2 × 3.9 MB (+ 3.9) | Apache-2.0 | 1 |
eng.traineddata (tessdata_fast) | 4.11 MB | Apache-2.0 | 1 |
lat.traineddata, deu.traineddata (optional) | 3.19 MB, 1.53 MB | Apache-2.0 | 1 or 2 |
| Sample scanned SATB hymn PDF (public domain) | < 0.4 MB target | PD | 1 |
| Notehead, rest, clef, accidental templates (rendered from Bravura or Leland) | < 0.2 MB | OFL-1.1 | 1 |
ORT ort-wasm-simd-threaded.wasm 1.30.0 (or .wasm.gz + wasmBinary) | 14.24 MB (about 2.9–3.5 MB gzip) | MIT | 2 |
| Tiny CNN (int8) or copista-2m detector (fp16 ONNX, about 4 MB) | 0.1–8 MB | ours / Apache-2.0 + NOTICE | 2 |
Sizes from [OMR §0, §6.2, §7.1, §9.3]. Phase 1 totals about 13–20 MB; phase 2 about 30–40 MB. The ORT WebGPU binary (28.3 MB) cannot ship as one file, so the WASM CPU execution provider with one thread is the plan; threads would need crossOriginIsolated, which the artifact frame is not expected to have [OMR §7.1].
build.mjs fails the build if any binary file exceeds 15 MB, any text file exceeds 16 MB, or the total exceeds 64 MB, and prints a size table.
assets/worker.min.js and its core from assets/ lazily, at the first lyric row, so the staff and symbol stages are not delayed.assets/ and converted.Serve dist/ with a CSP header that simulates the artifact (script-src the three CDN hosts plus 'unsafe-inline' 'wasm-unsafe-eval' blob:; connect-src 'self' blob: data:; worker-src 'self' blob:), and check in Playwright that the sample converts, overlay and preview render, lyrics appear, and the console has no errors or blocked loads [WF].
Synthetic degraded scans with exact truth (corpus/synthetic/<id>/, manifest corpus/manifest-synthetic.json). Being built at the time of writing: the generator tools exist in corpus/synthetic/_tools/synth/ (m21import.py, harmonize.py, dsl.py, lily.py, degrade.py, mxl.py, midicheck.py), and no items or manifest have been written yet. The plan [WF]: at least 14 items from public-domain or CC0 MusicXML (music21 Bach chorales, OpenScore Lieder, folk songs and hymns), engraved with LilyPond 2.24; closed-score SATB written with \voiceOne/\voiceTwo on two staves and \lyricsto, so the truth has four parts; rasterised at 150, 200 and 300 dpi; degraded with rotation, noise, blur, salt-and-pepper, JPEG, lighting gradients and slight curvature; wrapped as image-only PDFs (checked with pdffonts returning nothing). Coverage: single melody with lyrics, multi-verse hymns, closed SATB, open SATB, piano solo (must not split), voice and piano.
Real public-domain scans (corpus/real/<id>/, manifest corpus/manifest-real.json, not yet written). Eight Internet Archive pages are on disk, fetched by corpus/real/tools/fetch_scans.sh and wrapped unmodified into image-only PDFs:
| ID | Source | Ground truth |
|---|---|---|
| golden1923-america | archive.org goldenbookoffavo00beat_0, leaf 2 | truth.musicxml present |
| golden1923-auld-lang-syne | archive.org goldenbookoffavo00beat_0, leaf 36 | not yet |
| methodist1905-st-anselm | archive.org methodisthymnalo00meth, leaf 63 | not yet |
| methodist1905-tappan-dennis | archive.org methodisthymnalo00meth, leaf 82 | not yet |
| haupt1867-aus-meines-herzens-grunde | archive.org bub_gb_T75IAAAAcAAJ, leaf 22 | not yet |
| klauser1867-schumann-melodie | archive.org b26976821, leaf 2 | not yet |
| efss1906-cold-blows-the-wind | archive.org englishfolksongs00goul, leaf 23 | not yet |
| sharp1911-rambling-sailor | archive.org folksongsfromsom00shar, leaf 17 | not yet |
The plan calls for hand-transcribed truth for at least three short items, verified by engraving the transcription and comparing it with the scan [WF].
Both manifests are merged into corpus/manifest.json with entries {id, title, source_url, license, origin: 'synthetic-scan' | 'real-scan', dpi, degradations, has_ground_truth, features, expected_parts}.
Negative set. OpenScore Lieder (CC0) voice-and-piano renders: the piano must never split and the voice staff must not split [VOI §12].
| Metric | Definition |
|---|---|
| Pitch accuracy | Share of truth notes, after alignment per part, with correct step, alter and octave. |
| Duration accuracy | Share of aligned notes with correct duration and dots. |
| Note F1 | F1 over (onset, pitch, duration) matches per part [OMR §8]. |
| Rest accuracy | Share of truth rests matched. |
| Measure validity | Share of measures whose voices sum to the time signature without padding. |
| Part count and names | Exact match of part count; name accuracy. |
| Part assignment | Share of truth notes landing in the correct part [OMR §8]. |
| Split decision | Per staff group, correct split / keep [VOI §12]. |
| Lyric CER | Character error rate over syllable text per verse. |
| Syllabic accuracy | Share of matched syllables with correct syllabic. |
| Lyric attachment | Share of truth syllables attached to the correct note per part and verse [OMR §8]. |
| XSD validity | xmllint --schema test/schema/musicxml.xsd pass. |
| Runtime | Per stage and per page. |
| Optional offline | musicdiff OMR-NED (MIT) and MV2H voice score (MIT) through the project's Python venv; Audiveris as an offline lyric-capable baseline only, never shipped [OMR §8]. |
The harness reports every metric split by origin (synthetic versus real) and by feature (lyrics, satb_closed, piano, …), plus an aggregate in [0,1]. The aggregate's weights are to be decided by the design phase; the workflow's improvement loop stops when the aggregate reaches 0.95 or fails to improve by more than 0.005 for two rounds [WF].
node test/run.mjs [--out test/results.json] [--only id1,id2] [--filter satb_closed]
[--debug test/debug/] [--no-ocr] [--json]
--out writes results to a named file so concurrent agents do not collide [WF];--only and --filter restrict items by id or feature;--debug writes overlay PNGs per page and stage;--no-ocr skips tesseract for fast symbol-only runs;Per-item results hold every metric, warnings, timings and the diff of parts; the aggregate block matches the workflow's metrics schema (aggregate, pitch, duration, lyrics, parts, xsd_valid_ratio, crashes, real_scan_aggregate, worst_items) [WF].
Each module owner keeps fixtures under test/fixtures/<module>/: recognise from symbol fixtures, parts from score fixtures (closed SATB, open SATB, piano, voice and piano, single melody, ambiguous cases), MusicXML fixtures validated against the XSD, lyric strips cropped from corpus scans [WF]. The voice logic is also tested on ground-truth symbols generated from the chorales, against the ≥ 99 % target [VOI §12].
| Gate | Threshold |
|---|---|
| Crashes over the whole corpus | 0 |
| XSD-valid outputs | 100 % |
| Split decision on synthetic hymns and Lieder | 100 % |
| Note F1, clean synthetic closed SATB | ≥ 95 % |
| Part assignment, clean synthetic closed SATB | ≥ 97 % |
| Lyric attachment, clean synthetic | ≥ 90 % |
| Degraded synthetic and real scans | Reported; thresholds to be decided by the design phase |
| Browser check | Sample converts with no console errors or blocked loads under simulated CSP, at 1280 and 400 px, light and dark |
| Size | Build size check passes |
| Component | Licence | Use | Obligations |
|---|---|---|---|
| pdf.js 3.11.174 | Apache-2.0 | PDF rasterising (browser) | Notice in THIRD_PARTY.md |
| tesseract.js 7.0.0, tesseract core | Apache-2.0 | Lyric and label OCR | Notice |
| tessdata_fast traineddata | Apache-2.0 | OCR models | Notice |
| OpenSheetMusicDisplay 2.3.0 | BSD-3-Clause | Notation preview | Copyright notice |
| onnxruntime-web 1.30.0 (phase 2) | MIT | Optional ML | Notice |
| Bravura / Leland | SIL OFL-1.1 | Templates rendered from glyphs | Notice; font not renamed or sold |
| copista-2m weights (phase 2, if chosen) | Apache-2.0 | Optional detector | Keep the NOTICE file (Apache-2.0 §4(d)) [OMR §9.4] |
| Sample hymn scan | Public domain | Default sample | Attribution file |
pdftoppm and pdfimages (poppler, GPL; run as external processes), LilyPond 2.24 (GPL; its output is not encumbered [OMR §4]), music21 (BSD-3), musicdiff (MIT), MV2H (MIT), Audiveris (AGPL-3.0; optional baseline run as a separate process).
| Component | Reason |
|---|---|
| Audiveris code and constants | AGPL-3.0; ideas only, re-derived from public descriptions; its VoiceDistance weights are not copied [VOI §2.1, §13] |
| homr code and weights | AGPL-3.0; transformer data includes Camera-PrIMuS (HF mirror says CC BY-NC) [OMR §0] |
| Ultralytics YOLOv8 / YOLO11 code and weights | AGPL-3.0 [OMR §0] |
| YOLOv7, YOLOv9 | GPL-3.0 [OMR §0] |
| Verovio | LGPL-3.0 and a 28 MB package; OSMD used instead [OMR §7.3] |
| LEGATO | Needs the Llama-3.2-11B-Vision encoder under the Llama Community Licence (not OSI); not feasible in a browser [OMR §3.1] |
| Weights trained on MUSCIMA++ or Camera-PrIMuS | CC BY-NC-SA / CC BY-NC [OMR §4] |
| BeadSolver code | No licence stated; ideas only [VOI §13] |
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Rhythm desynchronisation from one missed dot or beam | High | High | Per-measure constraint search with slot coincidence across voices (§4.4); padding and flagging instead of shifting later notes |
| Shared-stem S/A chords and unisons mis-split | Medium | High | Explicit de-combining rules and unison copying (§4.4 step 3); warn for chords of ≥ 3 heads |
| Hymn classified as keyboard (Audiveris' staff-count heuristic fails here [VOI §5]) | Medium | High | Weighted vocal score with lyrics and stem features; brace does not veto; ambiguous band keeps a lossless form and offers a toggle |
| Piano accompaniment split | Low | High | Negative weights for keyboard labels, ≥ 3-head chords, pedal marks, cross-staff beams; Lieder negative test set |
| Lyric rows touching low notes or ledger lines | Medium | Medium | Detect rows after masking assembled music; x-height and baseline consistency checks [OMR §9.5] |
| OCR misses one-letter syllables and hyphens | High | Medium | Geometric hyphen and extender detection; DP alignment tolerates OCR splits and merges; per-verse coverage reported |
| Stanzas printed below the music | Medium | Low | Reported as unattached text; no invented underlay |
Low-dpi scans (s < 10 px) | Medium | Medium | Upscale 2× before recognition [OMR §9.5] |
| Warped phone scans | Medium | Medium | Per-strip line tracking, stable-paths fallback, coordinate mapping instead of image warping |
| Main-thread stalls or memory spikes on large pages | Medium | Medium | Worker execution, 40 Mpx cap, free integral images early |
| Asset over 15 MB, or CSP blocks a load | Medium | High | Build-time size check; CSP-simulated Playwright check; all WASM and data self-hosted |
| ML option adds size but little accuracy | Medium | Low | ML is behind the same interfaces and is shipped only if the harness shows a gain |
| Licence contamination | Low | High | Excluded list (§11.3); THIRD_PARTY.md reviewed before each publish |
| Thin real-scan ground truth (one item so far) | High | Medium | Hand-transcribe at least three; keep synthetic and real metrics separate; do not tune on the real test items |
| Overfitting to the corpus | Medium | Medium | Review phase runs fresh unseen scans [WF]; no corpus-specific code |
mvp/A classical pipeline built now, in mvp/, separate from the multi-agent build so it cannot collide with module owners' files. Scope: Sauvola, projection staff detection, run-length metrics, staff removal, connected components, NCC noteheads, stems and beams, barlines, a simple rhythm solver, stem-direction voices, the closed-score split rules of §6, tesseract lyrics with DP alignment, and the MusicXML writer, published as an artifact with the sample hymn. Its purpose is to give a working page early and to provide a baseline for the harness. It is replaced by Track 2's output when Track 2 beats it on the harness.
The scan-pdf-to-musicxml workflow [WF] runs these phases:
| Phase | Work | Output |
|---|---|---|
| Research | 7 parallel agents: OMR and voice web research (done: docs/research-omr.md, docs/research-voices.md); ML, classical and OCR spikes; synthetic and real corpora | docs/spike-ml.md, spike-classical.md, spike-ocr.md (pending); corpus manifests (pending) |
| Design | 3 competing proposals (ML-first, classical-first, hybrid risk-first), then a judge picks a winner and grafts ideas | docs/proposal-1..3.md, docs/DESIGN.md, corpus/manifest.json |
| Build | 9 parallel module builders, one owner per file (§3.2) | src/, ui/, build.mjs, test/ |
| Integrate | Integrator makes modules work together and runs the browser check; then up to 6 improvement rounds by 5 module owners in parallel, stopping at aggregate ≥ 0.95 or after 2 rounds without a gain > 0.005 | test/results*.json |
| Review | 6 adversarial lenses: MusicXML correctness, lyrics, voice/part separation, robustness on unseen scans, artifact compliance, UX | Reproduced findings with evidence |
| Fix | 7 owners fix findings in their files in parallel; final integration, README with accuracy table | dist/, README.md |
| # | Question | Candidates | Decided by |
|---|---|---|---|
| Q1 | Symbol classification approach | NCC templates + features; tiny CNN; copista-2m detector; oemer UNets (quantised) | Design phase, from docs/spike-classical.md and docs/spike-ml.md (not yet written) |
| Q2 | Ship ORT at all in v1? | No ORT in v1; ORT 1.30.0 wasm as a plain file (14.24 MB); .wasm.gz + wasmBinary | Design phase, after Q1 |
| Q3 | Default OCR languages and engine | eng only; eng + lat + deu; PP-OCRv5 Latin rec | Design phase, from docs/spike-ocr.md (not yet written) |
| Q4 | Rhythm search algorithm | Branch-and-bound with 200-state budget; Viterbi over 48ths of a quarter (copisteria style) | Design phase / recognise builder, by harness timing and accuracy |
| Q5 | Tenor clef in split output | Treble-8 (research recommendation) or printed bass clef; setting either way | Design phase; default treble-8 [VOI §8.1] |
| Q6 | TPQ value | 48 (covers 16th triplets) or a larger value for quintuplets | Design phase, from corpus features |
| Q7 | Aggregate score weights | Equal weights across pitch, duration, lyrics, parts, validity; or weighted towards parts and lyrics | Design phase (harness builder) |
| Q8 | Thresholds for degraded and real scans | Set after the first integrated harness run | Integrate phase |
| Q9 | How much real-scan truth to transcribe | Minimum 3 items (plan); more if time allows | Corpus agent |
| Q10 | Lyric copy when the alto has its own text (rare in hymns) | Conflict threshold 15 % (research); or detect a separate row per voice | Lyrics builder, tuned on corpus |
| Q11 | Do the vocal-score weights in §6.2 hold on real hymnals? | Keep research weights; retune on corpus | Parts owner during Integrate |
| Q12 | Main-thread pdf.js fake worker versus a blob: worker for pdf.js | Plain script tag (research default); blob worker | UI builder, by browser check |
Every agent started for this build, including runs that were stopped. Generated from the workflow journals and agent transcripts each time the page is updated. Open a row to read the agent's full outcome.