@real-music-packages/web-core 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/promo.d.ts CHANGED
@@ -54,14 +54,27 @@ interface RenderNotationOpts {
54
54
  /** Host div width in CSS px. Default 560 (RSR). RMT uses 620. */
55
55
  hostWidth?: number;
56
56
  /**
57
- * Engrave all measures on ONE horizontal staffline (OSMD
58
- * RenderSingleHorizontalStaffline) instead of wrapping into stacked systems.
59
- * This makes the follow window a purely HORIZONTAL slice so the scroll-cursor
60
- * pans left→right with no vertical row-break jump. Default true.
57
+ * Notation scroll mode drives BOTH the engraving layout here and the
58
+ * follow-camera in the scroll-cursor/notation layers.
61
59
  *
62
- * When true the canvas is laid out as a single very wide row, so the rasterized
63
- * bitmap is much wider than `hostWidth`; geometry (systems/measures) all share
64
- * one row of y. Set false to restore the legacy multi-row page layout.
60
+ * 'hstack' (default) engrave all measures on ONE horizontal staffline
61
+ * (OSMD RenderSingleHorizontalStaffline). The follow window is a purely
62
+ * HORIZONTAL slice so the playhead pans left→right with no vertical
63
+ * row-break jump. The rasterized bitmap is one very wide row; geometry
64
+ * (systems/measures) all share one row of y.
65
+ *
66
+ * 'vstack' — engrave the classic STACKED systems (normal page wrap into
67
+ * multiple rows). The follow camera frames the ACTIVE system at a fixed
68
+ * band position and scrolls VERTICALLY (eased) to the next system as the
69
+ * playhead crosses systems — never a vertical leap across staff lines.
70
+ *
71
+ * Default 'hstack' (preserves the current single-row behavior).
72
+ */
73
+ scrollMode?: 'hstack' | 'vstack';
74
+ /**
75
+ * @deprecated Use `scrollMode` instead. Back-compat alias: `singleRow:true`
76
+ * ⇔ `scrollMode:'hstack'`, `singleRow:false` ⇔ `scrollMode:'vstack'`.
77
+ * When both are given, `scrollMode` wins.
65
78
  */
66
79
  singleRow?: boolean;
67
80
  }
package/dist/promo.js CHANGED
@@ -256,7 +256,8 @@ async function renderNotation(xml, opts) {
256
256
  const paper = opts?.paper ?? "#faf7f0";
257
257
  const inkSumThreshold = opts?.inkSumThreshold ?? 690;
258
258
  const hostWidth = opts?.hostWidth ?? 560;
259
- const singleRow = opts?.singleRow ?? true;
259
+ const scrollMode = opts?.scrollMode ?? (opts?.singleRow === false ? "vstack" : "hstack");
260
+ const singleRow = scrollMode === "hstack";
260
261
  const { OpenSheetMusicDisplay } = await import("opensheetmusicdisplay");
261
262
  const host = document.createElement("div");
262
263
  host.style.cssText = `position:fixed;left:-9999px;top:0;width:${hostWidth}px;background:${paper};`;
package/dist/promo.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/promo.ts"],"sourcesContent":["// Promo utilities — browser-only except for parseMidi/midiDurationMs which are\n// pure (no DOM).\n//\n// Exports:\n// parseMidi / midiDurationMs — pure MIDI parser (no DOM; testable in Node)\n// renderNotation — OSMD canvas renderer (browser/OSMD only)\n// createPromoSampler — Tone.js sampler factory (browser/Tone only)\n//\n// Unit tests cover parseMidi (tests/midi.test.ts).\n// renderNotation and createPromoSampler are browser-only — consumers' dom tests\n// cover them (OSMD and Tone.js require a browser context).\n\n// ─── MIDI parser ──────────────────────────────────────────────────────────────\n// Moved verbatim from realmusictheory/site/src/lib/promo/midiDuration.ts.\n\nexport interface MidiNote {\n midi: number;\n startMs: number;\n /** Audible duration in ms — extended while the sustain pedal is held. */\n durMs: number;\n /** 0..1 */\n velocity: number;\n}\n\nexport interface ParsedMidi {\n /** When the last notated note is released (pre-pedal-tail), in ms — paces the playhead. */\n durationMs: number;\n notes: MidiNote[];\n}\n\ninterface RawEvent {\n tick: number;\n kind: 'on' | 'off' | 'sustain' | 'tempo';\n midi?: number;\n velocity?: number;\n on?: boolean; // sustain down?\n us?: number; // tempo in µs/beat\n}\n\nexport function parseMidi(buf: ArrayBuffer): ParsedMidi {\n try {\n const dv = new DataView(buf);\n let p = 0;\n const u8 = () => dv.getUint8(p++);\n const u16 = () => { const v = dv.getUint16(p); p += 2; return v; };\n const u32 = () => { const v = dv.getUint32(p); p += 4; return v; };\n\n if (u32() !== 0x4d546864) return { durationMs: 0, notes: [] }; // 'MThd'\n const headerLen = u32();\n u16(); // format\n const ntrk = u16();\n const division = u16();\n p = 8 + headerLen;\n if (division & 0x8000) return { durationMs: 0, notes: [] }; // SMPTE — not handled\n const tpq = division || 480;\n\n const events: RawEvent[] = [];\n for (let t = 0; t < ntrk; t++) {\n if (p + 8 > dv.byteLength || u32() !== 0x4d54726b) break; // 'MTrk'\n const len = u32();\n const end = Math.min(p + len, dv.byteLength);\n let tick = 0;\n let running = 0;\n while (p < end) {\n let dt = 0, b: number;\n do { b = u8(); dt = (dt << 7) | (b & 0x7f); } while (b & 0x80 && p < end);\n tick += dt;\n let status = dv.getUint8(p);\n if (status & 0x80) { p++; running = status; } else { status = running; }\n if (status === 0xff) {\n const type = u8();\n let l = 0, bb: number;\n do { bb = u8(); l = (l << 7) | (bb & 0x7f); } while (bb & 0x80 && p < end);\n if (type === 0x51 && l === 3) {\n events.push({\n tick,\n kind: 'tempo',\n us: (dv.getUint8(p) << 16) | (dv.getUint8(p + 1) << 8) | dv.getUint8(p + 2),\n });\n }\n p += l;\n } else if (status === 0xf0 || status === 0xf7) {\n let l = 0, bb: number;\n do { bb = u8(); l = (l << 7) | (bb & 0x7f); } while (bb & 0x80 && p < end);\n p += l;\n } else {\n const hi = status & 0xf0;\n if (hi === 0x90 || hi === 0x80) {\n const midi = u8();\n const vel = u8();\n if (hi === 0x90 && vel > 0) events.push({ tick, kind: 'on', midi, velocity: vel / 127 });\n else events.push({ tick, kind: 'off', midi });\n } else if (hi === 0xb0) {\n const cc = u8();\n const val = u8();\n if (cc === 64) events.push({ tick, kind: 'sustain', on: val >= 64 });\n } else {\n p += hi === 0xc0 || hi === 0xd0 ? 1 : 2;\n }\n }\n }\n p = end;\n }\n\n // tick → ms via the tempo map\n const tempos = events.filter((e) => e.kind === 'tempo').sort((a, b) => a.tick - b.tick);\n if (!tempos.length || tempos[0].tick > 0) tempos.unshift({ tick: 0, kind: 'tempo', us: 500000 });\n const tickToMs = (tick: number): number => {\n let ms = 0;\n for (let i = 0; i < tempos.length; i++) {\n const segStart = tempos[i].tick;\n if (segStart >= tick) break;\n const segEnd = i + 1 < tempos.length ? Math.min(tempos[i + 1].tick, tick) : tick;\n ms += ((segEnd - segStart) / tpq) * ((tempos[i].us ?? 500000) / 1000);\n }\n return ms;\n };\n\n // Sustain spans (tick ranges where the pedal is down)\n const sustainEvents = events.filter((e) => e.kind === 'sustain').sort((a, b) => a.tick - b.tick);\n const pedalUpAfter = (tick: number): number | null => {\n for (const s of sustainEvents) if (!s.on && s.tick >= tick) return s.tick;\n return null;\n };\n const pedalDownAt = (tick: number): boolean => {\n let down = false;\n for (const s of sustainEvents) { if (s.tick > tick) break; down = !!s.on; }\n return down;\n };\n\n // Pair note-ons with the next matching note-off\n const ordered = events.filter((e) => e.kind === 'on' || e.kind === 'off').sort((a, b) => a.tick - b.tick);\n const open: Record<number, { tick: number; vel: number }[]> = {};\n const notes: MidiNote[] = [];\n let lastOffTick = 0;\n for (const e of ordered) {\n const m = e.midi!;\n if (e.kind === 'on') {\n (open[m] ??= []).push({ tick: e.tick, vel: e.velocity ?? 0.7 });\n } else {\n const stack = open[m];\n if (stack && stack.length) {\n const start = stack.shift()!;\n let endTick = e.tick;\n lastOffTick = Math.max(lastOffTick, endTick);\n // Extend while pedal is held past the note-off.\n if (pedalDownAt(endTick)) {\n const up = pedalUpAfter(endTick);\n if (up != null) endTick = up;\n }\n const startMs = tickToMs(start.tick);\n notes.push({\n midi: m,\n startMs,\n durMs: Math.max(60, tickToMs(endTick) - startMs),\n velocity: start.vel,\n });\n }\n }\n }\n\n return { durationMs: tickToMs(lastOffTick), notes };\n } catch {\n return { durationMs: 0, notes: [] };\n }\n}\n\n/** Total playback duration in ms (0 if unparseable). */\nexport function midiDurationMs(buf: ArrayBuffer): number {\n return parseMidi(buf).durationMs;\n}\n\n// ─── Notation renderer ────────────────────────────────────────────────────────\n// Superset of RSR (stave-web-sightread/src/lib/promo/notation.ts) and RMT\n// (realmusictheory/site/src/lib/promo/notation.ts). Both per-staff measure boxes\n// (RSR) and per-measure column union boxes (RMT) are computed every render.\n//\n// Browser-only: requires document + opensheetmusicdisplay. No unit tests here —\n// consumers' dom tests cover renderNotation.\n\nexport interface Box {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\n/** One staff's slice of a measure, in canvas px. */\nexport interface StaffMeasureBox {\n index: number; // 0-based measure position within the rendered range\n staff: number; // 0 = top staff (RH/treble), 1 = bottom staff (LH/bass)\n box: Box;\n /** x of the measure's FIRST note (canvas px) — past any clef/key/time signature. */\n noteStartX: number;\n}\n\n/** Per-measure column box: union across all staves for that measure, in canvas px. */\nexport interface MeasureColumnBox extends Box {\n noteStartX: number;\n}\n\nexport interface RenderedNotation {\n canvas: HTMLCanvasElement;\n /** Per-row grand-staff system boxes in canvas px, top-to-bottom. */\n systems: Box[];\n /** Per-(measure, staff) boxes — RSR's geometry. */\n measures: StaffMeasureBox[];\n /** Per-measure column union across staves — RMT's geometry. */\n measureColumns: MeasureColumnBox[];\n /** Tight bounding box of all notation in canvas px (page whitespace cropped). */\n content: Box;\n}\n\nexport interface RenderNotationOpts {\n /** OSMD drawFrom/drawUpToMeasureNumber. Applied only when provided. */\n bars?: [number, number];\n /** Background fill colour. Default '#faf7f0' (RSR paper). */\n paper?: string;\n /** Pixel-sum threshold below which a pixel is counted as ink.\n * Default 690 (RSR: paper #faf7f0 sum ≈ 737). RMT uses 620. */\n inkSumThreshold?: number;\n /** Host div width in CSS px. Default 560 (RSR). RMT uses 620. */\n hostWidth?: number;\n /**\n * Engrave all measures on ONE horizontal staffline (OSMD\n * RenderSingleHorizontalStaffline) instead of wrapping into stacked systems.\n * This makes the follow window a purely HORIZONTAL slice so the scroll-cursor\n * pans left→right with no vertical row-break jump. Default true.\n *\n * When true the canvas is laid out as a single very wide row, so the rasterized\n * bitmap is much wider than `hostWidth`; geometry (systems/measures) all share\n * one row of y. Set false to restore the legacy multi-row page layout.\n */\n singleRow?: boolean;\n}\n\n/** Padded union of boxes, clamped to the canvas. */\nfunction unionBox(boxes: Box[], canvas: HTMLCanvasElement, pad: number): Box {\n if (!boxes.length) return { x: 0, y: 0, w: canvas.width, h: canvas.height };\n const minX = Math.min(...boxes.map((b) => b.x));\n const minY = Math.min(...boxes.map((b) => b.y));\n const maxX = Math.max(...boxes.map((b) => b.x + b.w));\n const maxY = Math.max(...boxes.map((b) => b.y + b.h));\n const x = Math.max(0, minX - pad);\n const y = Math.max(0, minY - pad);\n return {\n x,\n y,\n w: Math.min(canvas.width, maxX + pad) - x,\n h: Math.min(canvas.height, maxY + pad) - y,\n };\n}\n\n/** Tight box around all non-paper pixels (notes, ledger lines, stems), padded. */\nfunction inkBoundingBox(canvas: HTMLCanvasElement, inkSumThreshold: number): Box | null {\n try {\n const ctx = canvas.getContext('2d', { willReadFrequently: true });\n if (!ctx) return null;\n const { width: W, height: H } = canvas;\n const data = ctx.getImageData(0, 0, W, H).data;\n let minX = W, minY = H, maxX = -1, maxY = -1;\n const step = 2;\n for (let y = 0; y < H; y += step) {\n for (let x = 0; x < W; x += step) {\n const i = (y * W + x) * 4;\n if (data[i + 3] < 16) continue;\n if (data[i] + data[i + 1] + data[i + 2] < inkSumThreshold) {\n if (x < minX) minX = x;\n if (x > maxX) maxX = x;\n if (y < minY) minY = y;\n if (y > maxY) maxY = y;\n }\n }\n }\n if (maxX < 0) return null;\n const pad = 16;\n const x = Math.max(0, minX - pad);\n const y = Math.max(0, minY - pad);\n return {\n x,\n y,\n w: Math.min(W, maxX + pad) - x,\n h: Math.min(H, maxY + pad) - y,\n };\n } catch {\n return null;\n }\n}\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\nfunction extractGeometry(\n osmd: any,\n canvas: HTMLCanvasElement,\n inkSumThreshold: number,\n): { systems: Box[]; measures: StaffMeasureBox[]; measureColumns: MeasureColumnBox[]; content: Box } {\n const full: Box = { x: 0, y: 0, w: canvas.width, h: canvas.height };\n try {\n const graphic: any = osmd.GraphicSheet;\n const page: any = graphic?.MusicPages?.[0];\n const pageW: number = page?.PositionAndShape?.Size?.width;\n const musicSystems: any[] = page?.MusicSystems ?? [];\n if (!pageW || !musicSystems.length) return { systems: [], measures: [], measureColumns: [], content: full };\n\n // OSMD units → canvas px\n const f = canvas.width / pageW;\n const toBox = (pas: any): Box | null => {\n const p = pas?.AbsolutePosition;\n const sz = pas?.Size;\n if (!p || !sz) return null;\n return { x: p.x * f, y: p.y * f, w: sz.width * f, h: sz.height * f };\n };\n\n const systems: Box[] = musicSystems\n .map((s) => toBox(s?.PositionAndShape))\n .filter((b): b is Box => !!b && b.w > 1 && b.h > 1)\n .sort((a, b) => a.y - b.y);\n if (!systems.length) return { systems: [], measures: [], measureColumns: [], content: full };\n\n const measureList: any[][] = graphic?.MeasureList ?? [];\n\n // RSR geometry: per-(measure, staff) individual boxes\n const measures: StaffMeasureBox[] = [];\n measureList.forEach((staves, index) => {\n (staves ?? []).forEach((m: any, staff: number) => {\n const box = toBox(m?.PositionAndShape);\n if (box && box.w > 1 && box.h > 1) {\n const seX = (m?.staffEntries ?? [])[0]?.PositionAndShape?.AbsolutePosition?.x;\n const noteStartX = typeof seX === 'number' ? seX * f : box.x;\n measures.push({ index, staff, box, noteStartX });\n }\n });\n });\n\n // RMT geometry: per-measure column boxes (union across staves)\n const measureColumns: MeasureColumnBox[] = measureList\n .map((staves) => {\n const arr = staves ?? [];\n const boxes = arr\n .map((m: any) => toBox(m?.PositionAndShape))\n .filter((b: Box | null): b is Box => !!b && b.w > 1 && b.h > 1);\n if (!boxes.length) return null;\n const x0 = Math.min(...boxes.map((b) => b.x));\n const y0 = Math.min(...boxes.map((b) => b.y));\n const x1 = Math.max(...boxes.map((b) => b.x + b.w));\n const y1 = Math.max(...boxes.map((b) => b.y + b.h));\n // First note x across all staves; clamped into the column box\n let nx = Infinity;\n for (const m of arr) {\n const seX = (m?.staffEntries ?? [])[0]?.PositionAndShape?.AbsolutePosition?.x;\n if (typeof seX === 'number') nx = Math.min(nx, seX * f);\n }\n const noteStartX = Number.isFinite(nx) ? Math.max(x0, Math.min(nx, x1)) : x0;\n return { x: x0, y: y0, w: x1 - x0, h: y1 - y0, noteStartX };\n })\n .filter((b): b is MeasureColumnBox => !!b);\n\n const content = inkBoundingBox(canvas, inkSumThreshold) ?? unionBox(systems, canvas, 14);\n return { systems, measures, measureColumns, content };\n } catch {\n return { systems: [], measures: [], measureColumns: [], content: full };\n }\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n/** Render a MusicXML string to a detached canvas + geometry.\n * Browser-only (requires document + opensheetmusicdisplay dynamic import). */\nexport async function renderNotation(\n xml: string,\n opts?: RenderNotationOpts,\n): Promise<RenderedNotation> {\n const paper = opts?.paper ?? '#faf7f0';\n const inkSumThreshold = opts?.inkSumThreshold ?? 690;\n const hostWidth = opts?.hostWidth ?? 560;\n const singleRow = opts?.singleRow ?? true;\n\n // Literal dynamic import: consumers' bundlers (Vite/Rollup) must be able to\n // statically see the specifier to resolve + code-split it — a variable\n // specifier would reach the browser as a bare import and fail at runtime.\n const { OpenSheetMusicDisplay } = await import('opensheetmusicdisplay');\n\n const host = document.createElement('div');\n host.style.cssText = `position:fixed;left:-9999px;top:0;width:${hostWidth}px;background:${paper};`;\n document.body.appendChild(host);\n\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const osmd: any = new OpenSheetMusicDisplay(host, {\n backend: 'canvas',\n autoResize: false,\n drawTitle: false,\n drawSubtitle: false,\n drawComposer: false,\n drawLyricist: false,\n drawPartNames: false,\n });\n\n await osmd.load(xml);\n\n // Engrave on a single horizontal staffline so the follow window pans purely\n // HORIZONTALLY (no stacked-system row breaks → no vertical playhead jump).\n // Must be set before render(); the rasterized canvas then becomes one wide row.\n if (singleRow) {\n osmd.setOptions({ renderSingleHorizontalStaffline: true } as never);\n }\n\n // Apply bar range only when provided (RSR's drawFrom/drawUpTo).\n if (opts?.bars) {\n osmd.setOptions({\n drawFromMeasureNumber: opts.bars[0],\n drawUpToMeasureNumber: opts.bars[1],\n } as never);\n }\n\n osmd.render();\n\n const canvas = host.querySelector('canvas');\n if (canvas) {\n const { systems, measures, measureColumns, content } = extractGeometry(osmd, canvas, inkSumThreshold);\n document.body.removeChild(host);\n return { canvas, systems, measures, measureColumns, content };\n }\n\n // SVG fallback: rasterize into a canvas so callers always get a canvas.\n const svg = host.querySelector('svg');\n if (svg) {\n const w = svg.clientWidth || hostWidth;\n const h = svg.clientHeight || 300;\n const svgStr = new XMLSerializer().serializeToString(svg);\n const dataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));\n const img = new Image();\n await new Promise<void>((resolve, reject) => {\n img.onload = () => resolve();\n img.onerror = () => reject(new Error('svg rasterize failed'));\n img.src = dataUrl;\n });\n const out = document.createElement('canvas');\n out.width = w;\n out.height = h;\n const c2d = out.getContext('2d');\n if (!c2d) throw new Error('2d context unavailable');\n c2d.fillStyle = paper;\n c2d.fillRect(0, 0, w, h);\n c2d.drawImage(img, 0, 0, w, h);\n document.body.removeChild(host);\n return {\n canvas: out,\n systems: [],\n measures: [],\n measureColumns: [],\n content: { x: 0, y: 0, w, h },\n };\n }\n\n document.body.removeChild(host);\n throw new Error('OSMD produced neither canvas nor SVG');\n } catch (e) {\n if (host.parentNode) document.body.removeChild(host);\n throw e;\n }\n}\n\n// ─── Promo sampler ────────────────────────────────────────────────────────────\n// The shared Tone.js setup prefix under both apps' createPromoAudio.\n// Scheduling (playEvents / playMidi / playWindowSolo / playForeground) stays\n// in each app.\n//\n// Browser-only: requires Tone.js dynamic import. No unit tests here —\n// consumers' dom tests cover createPromoSampler.\n\nimport { createSalamanderSampler, generateReverb } from './audioHelpers';\nimport { SALAMANDER_CDN_BASE, SALAMANDER_URLS_8, SALAMANDER_URLS_FULL } from './salamander';\n\nexport interface PromoSampler {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n Tone: any; // typeof Tone — typed loose like audioHelpers.ts does for Tone\n sampler: unknown; // Tone.Sampler; typed loose like audioHelpers\n getStream(): MediaStream;\n /** Master-bus analyser (post-chain, sink-only) for reactive visualizers, or\n * null if `analyser` wasn't requested. whozart's \"listen\" spectrum reads it. */\n getAnalyser(): AnalyserNode | null;\n /** Current audio-clock time in seconds (Tone.now()). */\n audioNow(): number;\n /** releaseAll on the sampler; safe no-op on failure. */\n stop(): void;\n}\n\n/** Per-app tone shaping for the shared promo mastering bus. Ported from\n * whozart's audio-impl chain (the richest of the fleet); `reading` is a drier,\n * closer voicing so sight-reading notation stays legible note-to-note. Apps\n * pick one via `createPromoSampler({ voicing })`. */\nexport type PromoVoicing = 'concertHall' | 'reading' | 'dry';\n\ninterface VoicingPreset {\n release: number;\n attack: number;\n eq: { low: number; mid: number; high: number; lowFrequency: number; highFrequency: number };\n comp: { threshold: number; ratio: number; attack: number; release: number; knee: number };\n widen: number;\n reverb: { decay: number; preDelay: number; wet: number };\n}\n\nconst PROMO_VOICINGS: Record<PromoVoicing, VoicingPreset> = {\n // whozart's hall sound: long ringing release, lifted lows/air, gentle glue\n // compression, wide stereo, a real-hall reverb tail.\n concertHall: {\n release: 1.8, attack: 0.005,\n eq: { low: 1.5, mid: -1, high: 1.5, lowFrequency: 280, highFrequency: 4500 },\n comp: { threshold: -18, ratio: 1.6, attack: 0.012, release: 0.3, knee: 18 },\n widen: 0.30,\n reverb: { decay: 3.5, preDelay: 0.04, wet: 0.22 },\n },\n // Sight-reading: shorter release + much drier/shorter reverb so consecutive\n // notes don't smear into each other while the eye tracks the staff. Small\n // presence bump in the mids for note-attack clarity, narrower stereo.\n reading: {\n release: 1.1, attack: 0.004,\n eq: { low: 0.5, mid: 1, high: 1, lowFrequency: 280, highFrequency: 5000 },\n comp: { threshold: -16, ratio: 2.0, attack: 0.008, release: 0.25, knee: 12 },\n widen: 0.18,\n reverb: { decay: 1.4, preDelay: 0.02, wet: 0.10 },\n },\n // Bone-dry — no reverb at all (e.g. a debugging/reference voicing).\n dry: {\n release: 1.0, attack: 0.004,\n eq: { low: 0, mid: 0, high: 0, lowFrequency: 280, highFrequency: 4500 },\n comp: { threshold: -14, ratio: 2.0, attack: 0.008, release: 0.25, knee: 10 },\n widen: 0,\n reverb: { decay: 1.0, preDelay: 0, wet: 0 },\n },\n};\n\nexport async function createPromoSampler(opts?: {\n /** Feed a 0-value ConstantSource into the capture stream so the recorder's\n * audio track is live from t=0 (silent intro scenes aren't dropped).\n * Default false (RSR behavior). RMT passes true. */\n keepAlive?: boolean;\n /** Override the voicing's sampler release time (seconds). */\n release?: number;\n /** Sampler volume in dB. Default -2. */\n volumeDb?: number;\n /** Per-app tone shaping (see PromoVoicing). Default 'concertHall'. */\n voicing?: PromoVoicing;\n /** Use the full A0..C8 Salamander anchor set instead of the 8-anchor set —\n * fuller tone, more samples to fetch. Default false (8-anchor). */\n fullSamples?: boolean;\n /** Expose a master-bus analyser (for reactive visualizers). Default false. */\n analyser?: boolean;\n}): Promise<PromoSampler> {\n const voicing = PROMO_VOICINGS[opts?.voicing ?? 'concertHall'];\n const release = opts?.release ?? voicing.release;\n const volumeDb = opts?.volumeDb ?? -2;\n const keepAlive = opts?.keepAlive ?? false;\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const Tone = (await import('tone')) as any;\n await Tone.start();\n\n // Unrouted sampler — we build the mastering bus off it (don't send the dry\n // sampler to the speakers, which would bypass the chain).\n const sampler = createSalamanderSampler(Tone, {\n urls: opts?.fullSamples ? SALAMANDER_URLS_FULL : SALAMANDER_URLS_8,\n baseUrl: SALAMANDER_CDN_BASE,\n release,\n attack: voicing.attack,\n volumeDb,\n connectToDestination: false,\n });\n\n // Mastering bus (ported from whozart): EQ tilt → glue compressor → stereo\n // widener → reverb → brick-wall limiter. Reverb is generated with a timeout\n // fallback so a slow/again-failing IR can never hang the render; on failure\n // the chain simply omits reverb.\n const eq = new Tone.EQ3(voicing.eq);\n const compressor = new Tone.Compressor(voicing.comp);\n const widener = new Tone.StereoWidener(voicing.widen);\n const limiter = new Tone.Limiter(-1.5);\n\n const reverb =\n voicing.reverb.wet > 0\n ? await generateReverb(\n Tone,\n { decay: voicing.reverb.decay, wet: voicing.reverb.wet },\n 4000,\n )\n : null;\n if (reverb) reverb.preDelay = voicing.reverb.preDelay;\n\n // chain() wires node→node in order; tail node feeds the taps below.\n const chainNodes = reverb ? [eq, compressor, widener, reverb, limiter] : [eq, compressor, widener, limiter];\n sampler.chain(...chainNodes);\n\n const ctx = Tone.getContext().rawContext as AudioContext;\n const mediaDest = ctx.createMediaStreamDestination();\n limiter.connect(mediaDest);\n // Also monitor through the speakers so a non-headless preview is audible.\n limiter.connect(Tone.getDestination());\n\n // Optional master-bus analyser (sink-only — no onward connection so it can't\n // double the signal). Drives whozart's reactive \"listen\" visualizer.\n let analyserNode: AnalyserNode | null = null;\n if (opts?.analyser) {\n analyserNode = ctx.createAnalyser();\n analyserNode.fftSize = 2048;\n analyserNode.smoothingTimeConstant = 0.8;\n limiter.connect(analyserNode);\n }\n\n // Optional: keep the audio track alive from t=0 so the recorder doesn't drop\n // silent intro scenes. RMT uses this; RSR does not.\n if (keepAlive) {\n const source = ctx.createConstantSource();\n source.offset.value = 0;\n source.connect(mediaDest);\n source.start();\n }\n\n await Tone.loaded();\n\n return {\n Tone,\n sampler,\n getStream(): MediaStream {\n return mediaDest.stream;\n },\n getAnalyser(): AnalyserNode | null {\n return analyserNode;\n },\n audioNow(): number {\n return Tone.now();\n },\n stop(): void {\n try {\n (sampler as unknown as { releaseAll?: () => void }).releaseAll?.();\n } catch {\n /* no-op */\n }\n },\n };\n}\n"],"mappings":"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","names":["y","x"]}
1
+ {"version":3,"sources":["../src/promo.ts"],"sourcesContent":["// Promo utilities — browser-only except for parseMidi/midiDurationMs which are\n// pure (no DOM).\n//\n// Exports:\n// parseMidi / midiDurationMs — pure MIDI parser (no DOM; testable in Node)\n// renderNotation — OSMD canvas renderer (browser/OSMD only)\n// createPromoSampler — Tone.js sampler factory (browser/Tone only)\n//\n// Unit tests cover parseMidi (tests/midi.test.ts).\n// renderNotation and createPromoSampler are browser-only — consumers' dom tests\n// cover them (OSMD and Tone.js require a browser context).\n\n// ─── MIDI parser ──────────────────────────────────────────────────────────────\n// Moved verbatim from realmusictheory/site/src/lib/promo/midiDuration.ts.\n\nexport interface MidiNote {\n midi: number;\n startMs: number;\n /** Audible duration in ms — extended while the sustain pedal is held. */\n durMs: number;\n /** 0..1 */\n velocity: number;\n}\n\nexport interface ParsedMidi {\n /** When the last notated note is released (pre-pedal-tail), in ms — paces the playhead. */\n durationMs: number;\n notes: MidiNote[];\n}\n\ninterface RawEvent {\n tick: number;\n kind: 'on' | 'off' | 'sustain' | 'tempo';\n midi?: number;\n velocity?: number;\n on?: boolean; // sustain down?\n us?: number; // tempo in µs/beat\n}\n\nexport function parseMidi(buf: ArrayBuffer): ParsedMidi {\n try {\n const dv = new DataView(buf);\n let p = 0;\n const u8 = () => dv.getUint8(p++);\n const u16 = () => { const v = dv.getUint16(p); p += 2; return v; };\n const u32 = () => { const v = dv.getUint32(p); p += 4; return v; };\n\n if (u32() !== 0x4d546864) return { durationMs: 0, notes: [] }; // 'MThd'\n const headerLen = u32();\n u16(); // format\n const ntrk = u16();\n const division = u16();\n p = 8 + headerLen;\n if (division & 0x8000) return { durationMs: 0, notes: [] }; // SMPTE — not handled\n const tpq = division || 480;\n\n const events: RawEvent[] = [];\n for (let t = 0; t < ntrk; t++) {\n if (p + 8 > dv.byteLength || u32() !== 0x4d54726b) break; // 'MTrk'\n const len = u32();\n const end = Math.min(p + len, dv.byteLength);\n let tick = 0;\n let running = 0;\n while (p < end) {\n let dt = 0, b: number;\n do { b = u8(); dt = (dt << 7) | (b & 0x7f); } while (b & 0x80 && p < end);\n tick += dt;\n let status = dv.getUint8(p);\n if (status & 0x80) { p++; running = status; } else { status = running; }\n if (status === 0xff) {\n const type = u8();\n let l = 0, bb: number;\n do { bb = u8(); l = (l << 7) | (bb & 0x7f); } while (bb & 0x80 && p < end);\n if (type === 0x51 && l === 3) {\n events.push({\n tick,\n kind: 'tempo',\n us: (dv.getUint8(p) << 16) | (dv.getUint8(p + 1) << 8) | dv.getUint8(p + 2),\n });\n }\n p += l;\n } else if (status === 0xf0 || status === 0xf7) {\n let l = 0, bb: number;\n do { bb = u8(); l = (l << 7) | (bb & 0x7f); } while (bb & 0x80 && p < end);\n p += l;\n } else {\n const hi = status & 0xf0;\n if (hi === 0x90 || hi === 0x80) {\n const midi = u8();\n const vel = u8();\n if (hi === 0x90 && vel > 0) events.push({ tick, kind: 'on', midi, velocity: vel / 127 });\n else events.push({ tick, kind: 'off', midi });\n } else if (hi === 0xb0) {\n const cc = u8();\n const val = u8();\n if (cc === 64) events.push({ tick, kind: 'sustain', on: val >= 64 });\n } else {\n p += hi === 0xc0 || hi === 0xd0 ? 1 : 2;\n }\n }\n }\n p = end;\n }\n\n // tick → ms via the tempo map\n const tempos = events.filter((e) => e.kind === 'tempo').sort((a, b) => a.tick - b.tick);\n if (!tempos.length || tempos[0].tick > 0) tempos.unshift({ tick: 0, kind: 'tempo', us: 500000 });\n const tickToMs = (tick: number): number => {\n let ms = 0;\n for (let i = 0; i < tempos.length; i++) {\n const segStart = tempos[i].tick;\n if (segStart >= tick) break;\n const segEnd = i + 1 < tempos.length ? Math.min(tempos[i + 1].tick, tick) : tick;\n ms += ((segEnd - segStart) / tpq) * ((tempos[i].us ?? 500000) / 1000);\n }\n return ms;\n };\n\n // Sustain spans (tick ranges where the pedal is down)\n const sustainEvents = events.filter((e) => e.kind === 'sustain').sort((a, b) => a.tick - b.tick);\n const pedalUpAfter = (tick: number): number | null => {\n for (const s of sustainEvents) if (!s.on && s.tick >= tick) return s.tick;\n return null;\n };\n const pedalDownAt = (tick: number): boolean => {\n let down = false;\n for (const s of sustainEvents) { if (s.tick > tick) break; down = !!s.on; }\n return down;\n };\n\n // Pair note-ons with the next matching note-off\n const ordered = events.filter((e) => e.kind === 'on' || e.kind === 'off').sort((a, b) => a.tick - b.tick);\n const open: Record<number, { tick: number; vel: number }[]> = {};\n const notes: MidiNote[] = [];\n let lastOffTick = 0;\n for (const e of ordered) {\n const m = e.midi!;\n if (e.kind === 'on') {\n (open[m] ??= []).push({ tick: e.tick, vel: e.velocity ?? 0.7 });\n } else {\n const stack = open[m];\n if (stack && stack.length) {\n const start = stack.shift()!;\n let endTick = e.tick;\n lastOffTick = Math.max(lastOffTick, endTick);\n // Extend while pedal is held past the note-off.\n if (pedalDownAt(endTick)) {\n const up = pedalUpAfter(endTick);\n if (up != null) endTick = up;\n }\n const startMs = tickToMs(start.tick);\n notes.push({\n midi: m,\n startMs,\n durMs: Math.max(60, tickToMs(endTick) - startMs),\n velocity: start.vel,\n });\n }\n }\n }\n\n return { durationMs: tickToMs(lastOffTick), notes };\n } catch {\n return { durationMs: 0, notes: [] };\n }\n}\n\n/** Total playback duration in ms (0 if unparseable). */\nexport function midiDurationMs(buf: ArrayBuffer): number {\n return parseMidi(buf).durationMs;\n}\n\n// ─── Notation renderer ────────────────────────────────────────────────────────\n// Superset of RSR (stave-web-sightread/src/lib/promo/notation.ts) and RMT\n// (realmusictheory/site/src/lib/promo/notation.ts). Both per-staff measure boxes\n// (RSR) and per-measure column union boxes (RMT) are computed every render.\n//\n// Browser-only: requires document + opensheetmusicdisplay. No unit tests here —\n// consumers' dom tests cover renderNotation.\n\nexport interface Box {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\n/** One staff's slice of a measure, in canvas px. */\nexport interface StaffMeasureBox {\n index: number; // 0-based measure position within the rendered range\n staff: number; // 0 = top staff (RH/treble), 1 = bottom staff (LH/bass)\n box: Box;\n /** x of the measure's FIRST note (canvas px) — past any clef/key/time signature. */\n noteStartX: number;\n}\n\n/** Per-measure column box: union across all staves for that measure, in canvas px. */\nexport interface MeasureColumnBox extends Box {\n noteStartX: number;\n}\n\nexport interface RenderedNotation {\n canvas: HTMLCanvasElement;\n /** Per-row grand-staff system boxes in canvas px, top-to-bottom. */\n systems: Box[];\n /** Per-(measure, staff) boxes — RSR's geometry. */\n measures: StaffMeasureBox[];\n /** Per-measure column union across staves — RMT's geometry. */\n measureColumns: MeasureColumnBox[];\n /** Tight bounding box of all notation in canvas px (page whitespace cropped). */\n content: Box;\n}\n\nexport interface RenderNotationOpts {\n /** OSMD drawFrom/drawUpToMeasureNumber. Applied only when provided. */\n bars?: [number, number];\n /** Background fill colour. Default '#faf7f0' (RSR paper). */\n paper?: string;\n /** Pixel-sum threshold below which a pixel is counted as ink.\n * Default 690 (RSR: paper #faf7f0 sum ≈ 737). RMT uses 620. */\n inkSumThreshold?: number;\n /** Host div width in CSS px. Default 560 (RSR). RMT uses 620. */\n hostWidth?: number;\n /**\n * Notation scroll mode — drives BOTH the engraving layout here and the\n * follow-camera in the scroll-cursor/notation layers.\n *\n * 'hstack' (default) — engrave all measures on ONE horizontal staffline\n * (OSMD RenderSingleHorizontalStaffline). The follow window is a purely\n * HORIZONTAL slice so the playhead pans left→right with no vertical\n * row-break jump. The rasterized bitmap is one very wide row; geometry\n * (systems/measures) all share one row of y.\n *\n * 'vstack' — engrave the classic STACKED systems (normal page wrap into\n * multiple rows). The follow camera frames the ACTIVE system at a fixed\n * band position and scrolls VERTICALLY (eased) to the next system as the\n * playhead crosses systems — never a vertical leap across staff lines.\n *\n * Default 'hstack' (preserves the current single-row behavior).\n */\n scrollMode?: 'hstack' | 'vstack';\n /**\n * @deprecated Use `scrollMode` instead. Back-compat alias: `singleRow:true`\n * ⇔ `scrollMode:'hstack'`, `singleRow:false` ⇔ `scrollMode:'vstack'`.\n * When both are given, `scrollMode` wins.\n */\n singleRow?: boolean;\n}\n\n/** Padded union of boxes, clamped to the canvas. */\nfunction unionBox(boxes: Box[], canvas: HTMLCanvasElement, pad: number): Box {\n if (!boxes.length) return { x: 0, y: 0, w: canvas.width, h: canvas.height };\n const minX = Math.min(...boxes.map((b) => b.x));\n const minY = Math.min(...boxes.map((b) => b.y));\n const maxX = Math.max(...boxes.map((b) => b.x + b.w));\n const maxY = Math.max(...boxes.map((b) => b.y + b.h));\n const x = Math.max(0, minX - pad);\n const y = Math.max(0, minY - pad);\n return {\n x,\n y,\n w: Math.min(canvas.width, maxX + pad) - x,\n h: Math.min(canvas.height, maxY + pad) - y,\n };\n}\n\n/** Tight box around all non-paper pixels (notes, ledger lines, stems), padded. */\nfunction inkBoundingBox(canvas: HTMLCanvasElement, inkSumThreshold: number): Box | null {\n try {\n const ctx = canvas.getContext('2d', { willReadFrequently: true });\n if (!ctx) return null;\n const { width: W, height: H } = canvas;\n const data = ctx.getImageData(0, 0, W, H).data;\n let minX = W, minY = H, maxX = -1, maxY = -1;\n const step = 2;\n for (let y = 0; y < H; y += step) {\n for (let x = 0; x < W; x += step) {\n const i = (y * W + x) * 4;\n if (data[i + 3] < 16) continue;\n if (data[i] + data[i + 1] + data[i + 2] < inkSumThreshold) {\n if (x < minX) minX = x;\n if (x > maxX) maxX = x;\n if (y < minY) minY = y;\n if (y > maxY) maxY = y;\n }\n }\n }\n if (maxX < 0) return null;\n const pad = 16;\n const x = Math.max(0, minX - pad);\n const y = Math.max(0, minY - pad);\n return {\n x,\n y,\n w: Math.min(W, maxX + pad) - x,\n h: Math.min(H, maxY + pad) - y,\n };\n } catch {\n return null;\n }\n}\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\nfunction extractGeometry(\n osmd: any,\n canvas: HTMLCanvasElement,\n inkSumThreshold: number,\n): { systems: Box[]; measures: StaffMeasureBox[]; measureColumns: MeasureColumnBox[]; content: Box } {\n const full: Box = { x: 0, y: 0, w: canvas.width, h: canvas.height };\n try {\n const graphic: any = osmd.GraphicSheet;\n const page: any = graphic?.MusicPages?.[0];\n const pageW: number = page?.PositionAndShape?.Size?.width;\n const musicSystems: any[] = page?.MusicSystems ?? [];\n if (!pageW || !musicSystems.length) return { systems: [], measures: [], measureColumns: [], content: full };\n\n // OSMD units → canvas px\n const f = canvas.width / pageW;\n const toBox = (pas: any): Box | null => {\n const p = pas?.AbsolutePosition;\n const sz = pas?.Size;\n if (!p || !sz) return null;\n return { x: p.x * f, y: p.y * f, w: sz.width * f, h: sz.height * f };\n };\n\n const systems: Box[] = musicSystems\n .map((s) => toBox(s?.PositionAndShape))\n .filter((b): b is Box => !!b && b.w > 1 && b.h > 1)\n .sort((a, b) => a.y - b.y);\n if (!systems.length) return { systems: [], measures: [], measureColumns: [], content: full };\n\n const measureList: any[][] = graphic?.MeasureList ?? [];\n\n // RSR geometry: per-(measure, staff) individual boxes\n const measures: StaffMeasureBox[] = [];\n measureList.forEach((staves, index) => {\n (staves ?? []).forEach((m: any, staff: number) => {\n const box = toBox(m?.PositionAndShape);\n if (box && box.w > 1 && box.h > 1) {\n const seX = (m?.staffEntries ?? [])[0]?.PositionAndShape?.AbsolutePosition?.x;\n const noteStartX = typeof seX === 'number' ? seX * f : box.x;\n measures.push({ index, staff, box, noteStartX });\n }\n });\n });\n\n // RMT geometry: per-measure column boxes (union across staves)\n const measureColumns: MeasureColumnBox[] = measureList\n .map((staves) => {\n const arr = staves ?? [];\n const boxes = arr\n .map((m: any) => toBox(m?.PositionAndShape))\n .filter((b: Box | null): b is Box => !!b && b.w > 1 && b.h > 1);\n if (!boxes.length) return null;\n const x0 = Math.min(...boxes.map((b) => b.x));\n const y0 = Math.min(...boxes.map((b) => b.y));\n const x1 = Math.max(...boxes.map((b) => b.x + b.w));\n const y1 = Math.max(...boxes.map((b) => b.y + b.h));\n // First note x across all staves; clamped into the column box\n let nx = Infinity;\n for (const m of arr) {\n const seX = (m?.staffEntries ?? [])[0]?.PositionAndShape?.AbsolutePosition?.x;\n if (typeof seX === 'number') nx = Math.min(nx, seX * f);\n }\n const noteStartX = Number.isFinite(nx) ? Math.max(x0, Math.min(nx, x1)) : x0;\n return { x: x0, y: y0, w: x1 - x0, h: y1 - y0, noteStartX };\n })\n .filter((b): b is MeasureColumnBox => !!b);\n\n const content = inkBoundingBox(canvas, inkSumThreshold) ?? unionBox(systems, canvas, 14);\n return { systems, measures, measureColumns, content };\n } catch {\n return { systems: [], measures: [], measureColumns: [], content: full };\n }\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n/** Render a MusicXML string to a detached canvas + geometry.\n * Browser-only (requires document + opensheetmusicdisplay dynamic import). */\nexport async function renderNotation(\n xml: string,\n opts?: RenderNotationOpts,\n): Promise<RenderedNotation> {\n const paper = opts?.paper ?? '#faf7f0';\n const inkSumThreshold = opts?.inkSumThreshold ?? 690;\n const hostWidth = opts?.hostWidth ?? 560;\n // scrollMode drives the engraving layout; singleRow is the deprecated alias.\n // Default 'hstack' (single horizontal staffline) preserves current behavior.\n const scrollMode: 'hstack' | 'vstack' =\n opts?.scrollMode ?? (opts?.singleRow === false ? 'vstack' : 'hstack');\n const singleRow = scrollMode === 'hstack';\n\n // Literal dynamic import: consumers' bundlers (Vite/Rollup) must be able to\n // statically see the specifier to resolve + code-split it — a variable\n // specifier would reach the browser as a bare import and fail at runtime.\n const { OpenSheetMusicDisplay } = await import('opensheetmusicdisplay');\n\n const host = document.createElement('div');\n host.style.cssText = `position:fixed;left:-9999px;top:0;width:${hostWidth}px;background:${paper};`;\n document.body.appendChild(host);\n\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const osmd: any = new OpenSheetMusicDisplay(host, {\n backend: 'canvas',\n autoResize: false,\n drawTitle: false,\n drawSubtitle: false,\n drawComposer: false,\n drawLyricist: false,\n drawPartNames: false,\n });\n\n await osmd.load(xml);\n\n // hstack: engrave on a single horizontal staffline so the follow window pans\n // purely HORIZONTALLY (no stacked-system row breaks → no vertical playhead\n // jump). vstack: leave OSMD's default page wrap → classic stacked systems\n // (the follow camera then scrolls vertically between systems). Must be set\n // before render(); when on, the rasterized canvas becomes one wide row.\n if (singleRow) {\n osmd.setOptions({ renderSingleHorizontalStaffline: true } as never);\n }\n\n // Apply bar range only when provided (RSR's drawFrom/drawUpTo).\n if (opts?.bars) {\n osmd.setOptions({\n drawFromMeasureNumber: opts.bars[0],\n drawUpToMeasureNumber: opts.bars[1],\n } as never);\n }\n\n osmd.render();\n\n const canvas = host.querySelector('canvas');\n if (canvas) {\n const { systems, measures, measureColumns, content } = extractGeometry(osmd, canvas, inkSumThreshold);\n document.body.removeChild(host);\n return { canvas, systems, measures, measureColumns, content };\n }\n\n // SVG fallback: rasterize into a canvas so callers always get a canvas.\n const svg = host.querySelector('svg');\n if (svg) {\n const w = svg.clientWidth || hostWidth;\n const h = svg.clientHeight || 300;\n const svgStr = new XMLSerializer().serializeToString(svg);\n const dataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));\n const img = new Image();\n await new Promise<void>((resolve, reject) => {\n img.onload = () => resolve();\n img.onerror = () => reject(new Error('svg rasterize failed'));\n img.src = dataUrl;\n });\n const out = document.createElement('canvas');\n out.width = w;\n out.height = h;\n const c2d = out.getContext('2d');\n if (!c2d) throw new Error('2d context unavailable');\n c2d.fillStyle = paper;\n c2d.fillRect(0, 0, w, h);\n c2d.drawImage(img, 0, 0, w, h);\n document.body.removeChild(host);\n return {\n canvas: out,\n systems: [],\n measures: [],\n measureColumns: [],\n content: { x: 0, y: 0, w, h },\n };\n }\n\n document.body.removeChild(host);\n throw new Error('OSMD produced neither canvas nor SVG');\n } catch (e) {\n if (host.parentNode) document.body.removeChild(host);\n throw e;\n }\n}\n\n// ─── Promo sampler ────────────────────────────────────────────────────────────\n// The shared Tone.js setup prefix under both apps' createPromoAudio.\n// Scheduling (playEvents / playMidi / playWindowSolo / playForeground) stays\n// in each app.\n//\n// Browser-only: requires Tone.js dynamic import. No unit tests here —\n// consumers' dom tests cover createPromoSampler.\n\nimport { createSalamanderSampler, generateReverb } from './audioHelpers';\nimport { SALAMANDER_CDN_BASE, SALAMANDER_URLS_8, SALAMANDER_URLS_FULL } from './salamander';\n\nexport interface PromoSampler {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n Tone: any; // typeof Tone — typed loose like audioHelpers.ts does for Tone\n sampler: unknown; // Tone.Sampler; typed loose like audioHelpers\n getStream(): MediaStream;\n /** Master-bus analyser (post-chain, sink-only) for reactive visualizers, or\n * null if `analyser` wasn't requested. whozart's \"listen\" spectrum reads it. */\n getAnalyser(): AnalyserNode | null;\n /** Current audio-clock time in seconds (Tone.now()). */\n audioNow(): number;\n /** releaseAll on the sampler; safe no-op on failure. */\n stop(): void;\n}\n\n/** Per-app tone shaping for the shared promo mastering bus. Ported from\n * whozart's audio-impl chain (the richest of the fleet); `reading` is a drier,\n * closer voicing so sight-reading notation stays legible note-to-note. Apps\n * pick one via `createPromoSampler({ voicing })`. */\nexport type PromoVoicing = 'concertHall' | 'reading' | 'dry';\n\ninterface VoicingPreset {\n release: number;\n attack: number;\n eq: { low: number; mid: number; high: number; lowFrequency: number; highFrequency: number };\n comp: { threshold: number; ratio: number; attack: number; release: number; knee: number };\n widen: number;\n reverb: { decay: number; preDelay: number; wet: number };\n}\n\nconst PROMO_VOICINGS: Record<PromoVoicing, VoicingPreset> = {\n // whozart's hall sound: long ringing release, lifted lows/air, gentle glue\n // compression, wide stereo, a real-hall reverb tail.\n concertHall: {\n release: 1.8, attack: 0.005,\n eq: { low: 1.5, mid: -1, high: 1.5, lowFrequency: 280, highFrequency: 4500 },\n comp: { threshold: -18, ratio: 1.6, attack: 0.012, release: 0.3, knee: 18 },\n widen: 0.30,\n reverb: { decay: 3.5, preDelay: 0.04, wet: 0.22 },\n },\n // Sight-reading: shorter release + much drier/shorter reverb so consecutive\n // notes don't smear into each other while the eye tracks the staff. Small\n // presence bump in the mids for note-attack clarity, narrower stereo.\n reading: {\n release: 1.1, attack: 0.004,\n eq: { low: 0.5, mid: 1, high: 1, lowFrequency: 280, highFrequency: 5000 },\n comp: { threshold: -16, ratio: 2.0, attack: 0.008, release: 0.25, knee: 12 },\n widen: 0.18,\n reverb: { decay: 1.4, preDelay: 0.02, wet: 0.10 },\n },\n // Bone-dry — no reverb at all (e.g. a debugging/reference voicing).\n dry: {\n release: 1.0, attack: 0.004,\n eq: { low: 0, mid: 0, high: 0, lowFrequency: 280, highFrequency: 4500 },\n comp: { threshold: -14, ratio: 2.0, attack: 0.008, release: 0.25, knee: 10 },\n widen: 0,\n reverb: { decay: 1.0, preDelay: 0, wet: 0 },\n },\n};\n\nexport async function createPromoSampler(opts?: {\n /** Feed a 0-value ConstantSource into the capture stream so the recorder's\n * audio track is live from t=0 (silent intro scenes aren't dropped).\n * Default false (RSR behavior). RMT passes true. */\n keepAlive?: boolean;\n /** Override the voicing's sampler release time (seconds). */\n release?: number;\n /** Sampler volume in dB. Default -2. */\n volumeDb?: number;\n /** Per-app tone shaping (see PromoVoicing). Default 'concertHall'. */\n voicing?: PromoVoicing;\n /** Use the full A0..C8 Salamander anchor set instead of the 8-anchor set —\n * fuller tone, more samples to fetch. Default false (8-anchor). */\n fullSamples?: boolean;\n /** Expose a master-bus analyser (for reactive visualizers). Default false. */\n analyser?: boolean;\n}): Promise<PromoSampler> {\n const voicing = PROMO_VOICINGS[opts?.voicing ?? 'concertHall'];\n const release = opts?.release ?? voicing.release;\n const volumeDb = opts?.volumeDb ?? -2;\n const keepAlive = opts?.keepAlive ?? false;\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const Tone = (await import('tone')) as any;\n await Tone.start();\n\n // Unrouted sampler — we build the mastering bus off it (don't send the dry\n // sampler to the speakers, which would bypass the chain).\n const sampler = createSalamanderSampler(Tone, {\n urls: opts?.fullSamples ? SALAMANDER_URLS_FULL : SALAMANDER_URLS_8,\n baseUrl: SALAMANDER_CDN_BASE,\n release,\n attack: voicing.attack,\n volumeDb,\n connectToDestination: false,\n });\n\n // Mastering bus (ported from whozart): EQ tilt → glue compressor → stereo\n // widener → reverb → brick-wall limiter. Reverb is generated with a timeout\n // fallback so a slow/again-failing IR can never hang the render; on failure\n // the chain simply omits reverb.\n const eq = new Tone.EQ3(voicing.eq);\n const compressor = new Tone.Compressor(voicing.comp);\n const widener = new Tone.StereoWidener(voicing.widen);\n const limiter = new Tone.Limiter(-1.5);\n\n const reverb =\n voicing.reverb.wet > 0\n ? await generateReverb(\n Tone,\n { decay: voicing.reverb.decay, wet: voicing.reverb.wet },\n 4000,\n )\n : null;\n if (reverb) reverb.preDelay = voicing.reverb.preDelay;\n\n // chain() wires node→node in order; tail node feeds the taps below.\n const chainNodes = reverb ? [eq, compressor, widener, reverb, limiter] : [eq, compressor, widener, limiter];\n sampler.chain(...chainNodes);\n\n const ctx = Tone.getContext().rawContext as AudioContext;\n const mediaDest = ctx.createMediaStreamDestination();\n limiter.connect(mediaDest);\n // Also monitor through the speakers so a non-headless preview is audible.\n limiter.connect(Tone.getDestination());\n\n // Optional master-bus analyser (sink-only — no onward connection so it can't\n // double the signal). Drives whozart's reactive \"listen\" visualizer.\n let analyserNode: AnalyserNode | null = null;\n if (opts?.analyser) {\n analyserNode = ctx.createAnalyser();\n analyserNode.fftSize = 2048;\n analyserNode.smoothingTimeConstant = 0.8;\n limiter.connect(analyserNode);\n }\n\n // Optional: keep the audio track alive from t=0 so the recorder doesn't drop\n // silent intro scenes. RMT uses this; RSR does not.\n if (keepAlive) {\n const source = ctx.createConstantSource();\n source.offset.value = 0;\n source.connect(mediaDest);\n source.start();\n }\n\n await Tone.loaded();\n\n return {\n Tone,\n sampler,\n getStream(): MediaStream {\n return mediaDest.stream;\n },\n getAnalyser(): AnalyserNode | null {\n return analyserNode;\n },\n audioNow(): number {\n return Tone.now();\n },\n stop(): void {\n try {\n (sampler as unknown as { releaseAll?: () => void }).releaseAll?.();\n } catch {\n /* no-op */\n }\n },\n };\n}\n"],"mappings":"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","names":["y","x"]}
@@ -662,6 +662,13 @@ interface NotationProps {
662
662
  xml?: string;
663
663
  /** Bar range [from,to] forwarded to renderNotation (RSR drawFrom/drawUpTo). */
664
664
  bars?: [number, number];
665
+ /**
666
+ * Engraving scroll mode (forwarded to renderNotation when engraving from
667
+ * `xml`). 'hstack' (default) = single horizontal staffline; 'vstack' = stacked
668
+ * systems. MUST match the scroll-cursor layer's `scrollMode`. Ignored when a
669
+ * pre-rendered `rendered` is supplied (engraving is already laid out).
670
+ */
671
+ scrollMode?: 'hstack' | 'vstack';
665
672
  /** Top y of the notation band (screen px). Default safeBox.top. */
666
673
  bandTop?: number;
667
674
  /** Height of the notation band (screen px). Default safeBox.bottom - bandTop. */
@@ -679,6 +686,18 @@ interface ScrollCursorProps {
679
686
  * for back-compat; prefer leaving this unset.
680
687
  */
681
688
  mode?: 'audio' | 'linear';
689
+ /**
690
+ * Notation scroll mode — MUST match the engraving's `scrollMode`.
691
+ * 'hstack' (default) — single horizontal staffline: the follow window is a
692
+ * FOLLOW_BARS-wide horizontal slice that pans left→right (followBoxAt /
693
+ * followWindowStart). No vertical movement.
694
+ * 'vstack' — stacked systems: the follow camera frames the ACTIVE system at a
695
+ * fixed band position and scrolls VERTICALLY to the next system as the
696
+ * playhead crosses systems (vstackFollowBox). The playhead pans L→R within
697
+ * the framed system; the only horizontal reset is per-system, never a
698
+ * vertical leap over staff lines.
699
+ */
700
+ scrollMode?: 'hstack' | 'vstack';
682
701
  /** Bars visible in the follow window. Default 2 (RSR FOLLOW_BARS). Informational
683
702
  * for v1 — the geometry uses the module constant unless overridden here. */
684
703
  followBars?: number;
@@ -735,6 +754,24 @@ declare function followBoxAt(rn: RenderedNotation, posMeasures: number): Box | n
735
754
  * last third of EVERY bar and so pushed the still-current bar off the top.)
736
755
  */
737
756
  declare function followWindowStart(nBars: number, camProgress01: number): number;
757
+ /** For each measure index, the system (row) it lives on — ordered by index.
758
+ * Empty when geometry is missing. */
759
+ declare function measureSystemMap(rn: RenderedNotation): number[];
760
+ /** Union (canvas coords) of every measure box on system `sys`. Falls back to the
761
+ * system's own box when no measures map to it. */
762
+ declare function systemBox(rn: RenderedNotation, sys: number, sysMap?: number[]): Box | null;
763
+ /**
764
+ * vstack follow window (canvas coords) for an audio-clock progress 0..1. Frames
765
+ * the ACTIVE system (the row the playhead's current bar is on) and eases
766
+ * VERTICALLY to the NEXT system over the tail of the active system's last bar, so
767
+ * the next row slides up just as the playhead crosses into it. Within a system
768
+ * the box is fixed (the playhead pans L→R inside it). No measure-window vertical
769
+ * jumps: the focusBox always spans an integer-or-tween of FULL systems.
770
+ *
771
+ * The box keeps a constant size (the larger of the two framed systems' extents)
772
+ * so the dest mapping doesn't breathe as it scrolls — the camera just translates.
773
+ */
774
+ declare function vstackFollowBox(rn: RenderedNotation, camProgress01: number): Box | null;
738
775
  /** The dest rect a notation frame is drawn into, on screen. */
739
776
  interface NotationRect {
740
777
  dx: number;
@@ -1582,4 +1619,4 @@ interface SectionMinimapProps {
1582
1619
  }
1583
1620
  declare const sectionMinimapFactory: LayerFactory<SectionMinimapProps>;
1584
1621
 
1585
- export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatTick, type BrandingProps, type BufferFactory, type BuildSceneOpts, type BuiltScene, type CameraState, type CaptionCue, type CaptionProps, type CaptionScript, type CaptionStyle, type ChordSpan, type CircleOfFifthsProps, type CirclePoint, type ClickTrackOpts, type ColorBy, type ContourPlot, type ContourPoint, type CountInOpts, type CountingTrackProps, type CtaProps, DEFAULT_FUNCTION_COLORS, type DegreeLabel, type DegreeLabelsProps, type DroneOpts, type DuckWindow, type Easing, type ExtendedDemoOpts, FIFTHS_MAJOR, FIFTHS_MINOR, FOLLOW_BARS, FOLLOW_PAD, type FallingKeyboardDemoOpts, type FallingNotesProps, type FunctionColors, type FunctionalHarmonyProps, type GateError, type GateInput, type HandColors, type HarmonicFunction, type HarmonyMode, type HighlightRegion, type HookProps, type KeyRect, type KeyboardLayout, type KeyboardLayoutOpts, type KeyboardProps, type LabelMode, type Layer, type LayerFactory, type McqCardProps, type NotationEngraving, type NotationLayout, type NotationLayoutOpts, type NotationProps, type NotationRect, type OutputProbe, PIANO_HIGH, PIANO_LOW, type PitchContourProps, type Placement, type PlayheadLine, type PortraitProps, type PromoCardsDemoOpts, type Quiz, type QuizOption, type QuizPhase, type RayEndpoints, type RecordSceneSpecOpts, type Rect, type RenderCtx, type ResolvedSegment, type RevealProps, type SafeGuidesProps, type SceneSpec, type ScheduleTarget, type Score, type ScoreFromMusicXMLOpts, type ScoreNote, type ScrollCursorProps, type Section, type SectionMinimapProps, type SegmentAudio, type SegmentAudioCtx, type SegmentTransition, type SpecLayer, type SpectrumInput, type SpectrumProps, type StaffKeyboardRayProps, type TempoMap, type TimeAnchor, type TimelineSegment, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, audioPlayheadLine, ballArc, ballX, beatGrid, beatPhase, blackKeys, bpmOf, brandingFactory, buildScene, cameraForFollow, cameraTransform, circleOfFifthsDemoSpec, circleOfFifthsFactory, clamp, clickTrackSchedule, contourMinimapDemoSpec, contourPoints, contourPolyline, countInLeadSec, countInSchedule, countdownRemaining, countdownSeconds, countingDegreeDemoSpec, countingTrackFactory, cropAroundBox, ctaFactory, cubicEaseInOut, cueOpacity, degreeLabel, degreeLabelsFactory, distinctOnsets, dotAt, drawCaption, drawHighlight, droneSchedule, duckGainAt, easeIn, easeInOut, easeOut, fallingKeyboardDemoScore, fallingKeyboardDemoSpec, fallingNotesFactory, firstMeasureBox, followBoxAt, followSrcBox, followWindowStart, fracSlotPoint, frameRect, functionColor, functionalHarmonyFactory, getKeyboardLayout, getLayerFactory, getNotationEngraving, harmonyDemoSpec, highlightIntensity, hookFactory, identityCamera, inRange, invLerp, isBlackKey, kenBurns, keyCenterX, keyColumnWidth, keyRect, keySlot, keyboardFactory, keyboardLayout, lerp, lerpBox, lerpCamera, linear, mapBoxThroughLayout, mcqCardFactory, mcqDemoSpec, measureColumnsFromLayout, measureCount, measureSpanBox, measureSpans, timeToX as minimapTimeToX, msPerBeat, notationFactory, notationLayout, noteColor, noteSetXRange, parseKey, parseTimeSig, pcToSlot, pitchAt, pitchContourFactory, pitchRange, playheadLine, portraitFactory, progress01, projectPoint, promoCardsDemoSpec, quizPhase, rayEndpoints, rayPointAt, recordSceneSpec, registerLayer, registeredKeys, resolveAnchor, resolveKeyboardLayout, resolveTimeline, revealFactory, revealProgress, runGate, safeGuidesFactory, scoreFromMusicXML, scorePitchSpan, scrollCursorFactory, sectionMinimapFactory, setFollowLayoutProvider, setKeyboardLayout, setNotationEngraving, slotAngle, slotPc, slotPoint, spectrumFactory, staffAnchor, staffKeyboardRayFactory, staffRayDemoSpec, transitionProgress, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, whiteKeys, worldToViewport };
1622
+ export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatTick, type BrandingProps, type BufferFactory, type BuildSceneOpts, type BuiltScene, type CameraState, type CaptionCue, type CaptionProps, type CaptionScript, type CaptionStyle, type ChordSpan, type CircleOfFifthsProps, type CirclePoint, type ClickTrackOpts, type ColorBy, type ContourPlot, type ContourPoint, type CountInOpts, type CountingTrackProps, type CtaProps, DEFAULT_FUNCTION_COLORS, type DegreeLabel, type DegreeLabelsProps, type DroneOpts, type DuckWindow, type Easing, type ExtendedDemoOpts, FIFTHS_MAJOR, FIFTHS_MINOR, FOLLOW_BARS, FOLLOW_PAD, type FallingKeyboardDemoOpts, type FallingNotesProps, type FunctionColors, type FunctionalHarmonyProps, type GateError, type GateInput, type HandColors, type HarmonicFunction, type HarmonyMode, type HighlightRegion, type HookProps, type KeyRect, type KeyboardLayout, type KeyboardLayoutOpts, type KeyboardProps, type LabelMode, type Layer, type LayerFactory, type McqCardProps, type NotationEngraving, type NotationLayout, type NotationLayoutOpts, type NotationProps, type NotationRect, type OutputProbe, PIANO_HIGH, PIANO_LOW, type PitchContourProps, type Placement, type PlayheadLine, type PortraitProps, type PromoCardsDemoOpts, type Quiz, type QuizOption, type QuizPhase, type RayEndpoints, type RecordSceneSpecOpts, type Rect, type RenderCtx, type ResolvedSegment, type RevealProps, type SafeGuidesProps, type SceneSpec, type ScheduleTarget, type Score, type ScoreFromMusicXMLOpts, type ScoreNote, type ScrollCursorProps, type Section, type SectionMinimapProps, type SegmentAudio, type SegmentAudioCtx, type SegmentTransition, type SpecLayer, type SpectrumInput, type SpectrumProps, type StaffKeyboardRayProps, type TempoMap, type TimeAnchor, type TimelineSegment, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, audioPlayheadLine, ballArc, ballX, beatGrid, beatPhase, blackKeys, bpmOf, brandingFactory, buildScene, cameraForFollow, cameraTransform, circleOfFifthsDemoSpec, circleOfFifthsFactory, clamp, clickTrackSchedule, contourMinimapDemoSpec, contourPoints, contourPolyline, countInLeadSec, countInSchedule, countdownRemaining, countdownSeconds, countingDegreeDemoSpec, countingTrackFactory, cropAroundBox, ctaFactory, cubicEaseInOut, cueOpacity, degreeLabel, degreeLabelsFactory, distinctOnsets, dotAt, drawCaption, drawHighlight, droneSchedule, duckGainAt, easeIn, easeInOut, easeOut, fallingKeyboardDemoScore, fallingKeyboardDemoSpec, fallingNotesFactory, firstMeasureBox, followBoxAt, followSrcBox, followWindowStart, fracSlotPoint, frameRect, functionColor, functionalHarmonyFactory, getKeyboardLayout, getLayerFactory, getNotationEngraving, harmonyDemoSpec, highlightIntensity, hookFactory, identityCamera, inRange, invLerp, isBlackKey, kenBurns, keyCenterX, keyColumnWidth, keyRect, keySlot, keyboardFactory, keyboardLayout, lerp, lerpBox, lerpCamera, linear, mapBoxThroughLayout, mcqCardFactory, mcqDemoSpec, measureColumnsFromLayout, measureCount, measureSpanBox, measureSpans, measureSystemMap, timeToX as minimapTimeToX, msPerBeat, notationFactory, notationLayout, noteColor, noteSetXRange, parseKey, parseTimeSig, pcToSlot, pitchAt, pitchContourFactory, pitchRange, playheadLine, portraitFactory, progress01, projectPoint, promoCardsDemoSpec, quizPhase, rayEndpoints, rayPointAt, recordSceneSpec, registerLayer, registeredKeys, resolveAnchor, resolveKeyboardLayout, resolveTimeline, revealFactory, revealProgress, runGate, safeGuidesFactory, scoreFromMusicXML, scorePitchSpan, scrollCursorFactory, sectionMinimapFactory, setFollowLayoutProvider, setKeyboardLayout, setNotationEngraving, slotAngle, slotPc, slotPoint, spectrumFactory, staffAnchor, staffKeyboardRayFactory, staffRayDemoSpec, systemBox, transitionProgress, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, vstackFollowBox, whiteKeys, worldToViewport };
@@ -386,6 +386,65 @@ function followWindowStart(nBars, camProgress01) {
386
386
  const maxStart = Math.max(0, nBars - FOLLOW_BARS);
387
387
  return Math.max(0, Math.min(maxStart, restStart + scrollFrac));
388
388
  }
389
+ function systemIndexOfBox(systems, box) {
390
+ if (!systems.length) return 0;
391
+ const cy = box.y + box.h / 2;
392
+ let best = 0;
393
+ let bestD = Infinity;
394
+ for (let i = 0; i < systems.length; i++) {
395
+ const s = systems[i];
396
+ if (cy >= s.y && cy <= s.y + s.h) return i;
397
+ const sc = s.y + s.h / 2;
398
+ const d = Math.abs(cy - sc);
399
+ if (d < bestD) {
400
+ bestD = d;
401
+ best = i;
402
+ }
403
+ }
404
+ return best;
405
+ }
406
+ function measureSystemMap(rn) {
407
+ const systems = rn.systems ?? [];
408
+ const n = measureCount(rn);
409
+ const map = new Array(n).fill(0);
410
+ for (let idx = 0; idx < n; idx++) {
411
+ const m = (rn.measures ?? []).find((mm) => mm.index === idx);
412
+ map[idx] = m ? systemIndexOfBox(systems, m.box) : 0;
413
+ }
414
+ return map;
415
+ }
416
+ function systemBox(rn, sys, sysMap) {
417
+ const map = sysMap ?? measureSystemMap(rn);
418
+ const boxes = (rn.measures ?? []).filter((m) => map[m.index] === sys).map((m) => m.box);
419
+ if (!boxes.length) return (rn.systems ?? [])[sys] ?? null;
420
+ const x0 = Math.min(...boxes.map((b) => b.x));
421
+ const y0 = Math.min(...boxes.map((b) => b.y));
422
+ const x1 = Math.max(...boxes.map((b) => b.x + b.w));
423
+ const y1 = Math.max(...boxes.map((b) => b.y + b.h));
424
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
425
+ }
426
+ function vstackFollowBox(rn, camProgress01) {
427
+ const sysMap = measureSystemMap(rn);
428
+ const nBars = measureCount(rn);
429
+ const nSys = (rn.systems ?? []).length || (sysMap.length ? Math.max(...sysMap) + 1 : 0);
430
+ if (nBars <= 0 || nSys <= 0) return null;
431
+ const posBars = Math.max(0, Math.min(1, camProgress01)) * nBars;
432
+ const curBar = Math.min(nBars - 1, Math.floor(posBars));
433
+ const barFrac = posBars - Math.floor(posBars);
434
+ const curSys = sysMap[curBar] ?? 0;
435
+ const isLastBarOfSys = curBar + 1 >= nBars || (sysMap[curBar + 1] ?? curSys) !== curSys;
436
+ const scrollFrac = isLastBarOfSys ? cubicEaseInOut(Math.min(1, Math.max(0, (barFrac - 0.66) / 0.34))) : 0;
437
+ const nextSys = Math.min(nSys - 1, curSys + 1);
438
+ const a = systemBox(rn, curSys, sysMap);
439
+ const b = systemBox(rn, nextSys, sysMap) ?? a;
440
+ if (!a) return b;
441
+ if (!b) return a;
442
+ const w = Math.max(a.w, b.w);
443
+ const h = Math.max(a.h, b.h);
444
+ const x = a.x + (b.x - a.x) * scrollFrac;
445
+ const y = a.y + (b.y - a.y) * scrollFrac;
446
+ return { x, y, w, h };
447
+ }
389
448
  function notationLayout(rn, W, H, boxTop, boxH, opts = {}) {
390
449
  const { zoom01 = 1, focusBox = null } = opts;
391
450
  const sb = safeBox(W, H);
@@ -599,7 +658,11 @@ function notationLayer() {
599
658
  rn = props.rendered;
600
659
  } else if (props.xml) {
601
660
  const { renderNotation } = await import("../promo.js");
602
- rn = await renderNotation(props.xml, { bars: props.bars, paper: ctx.theme.paper });
661
+ rn = await renderNotation(props.xml, {
662
+ bars: props.bars,
663
+ paper: ctx.theme.paper,
664
+ scrollMode: props.scrollMode ?? "hstack"
665
+ });
603
666
  } else {
604
667
  throw new Error("notation layer: provide `rendered` or `xml`");
605
668
  }
@@ -641,6 +704,8 @@ var notationFactory = {
641
704
  const p = props;
642
705
  if (p.system != null && p.system !== "grand" && p.system !== "single")
643
706
  errs.push('notation.system must be "grand" | "single"');
707
+ if (p.scrollMode != null && p.scrollMode !== "hstack" && p.scrollMode !== "vstack")
708
+ errs.push('notation.scrollMode must be "hstack" | "vstack"');
644
709
  if (p.scale != null && (typeof p.scale !== "number" || p.scale <= 0))
645
710
  errs.push("notation.scale must be a positive number");
646
711
  if (p.rendered == null && typeof p.xml !== "string")
@@ -652,17 +717,17 @@ var notationFactory = {
652
717
  };
653
718
 
654
719
  // src/scene/layers/scrollCursor.ts
655
- function followLayoutFor(ctx, camProgress01) {
720
+ function followLayoutFor(ctx, camProgress01, scrollMode) {
656
721
  const eng = getNotationEngraving(ctx);
657
722
  if (!eng) return null;
658
723
  const nBars = measureCount(eng.rendered);
659
724
  if (nBars <= 0) return eng.base;
660
- const windowStart = followWindowStart(nBars, camProgress01);
661
- const focusBox = followBoxAt(eng.rendered, windowStart);
725
+ const focusBox = scrollMode === "vstack" ? vstackFollowBox(eng.rendered, camProgress01) : followBoxAt(eng.rendered, followWindowStart(nBars, camProgress01));
662
726
  return notationLayout(eng.rendered, ctx.W, ctx.H, eng.bandTop, eng.bandHeight, { focusBox });
663
727
  }
664
728
  function scrollCursorLayer() {
665
729
  let mode = "audio";
730
+ let scrollMode = "hstack";
666
731
  let musicMs = 0;
667
732
  let openingZoomMs = 900;
668
733
  let color;
@@ -699,12 +764,13 @@ function scrollCursorLayer() {
699
764
  }
700
765
  function layoutAt(ctx, tMs) {
701
766
  const eng = getNotationEngraving(ctx);
702
- return followLayoutFor(ctx, followProgress(ctx, tMs)) ?? eng.base;
767
+ return followLayoutFor(ctx, followProgress(ctx, tMs), scrollMode) ?? eng.base;
703
768
  }
704
769
  return {
705
770
  key: "scroll-cursor",
706
771
  init(ctx, props) {
707
772
  mode = props.mode ?? "audio";
773
+ scrollMode = props.scrollMode ?? "hstack";
708
774
  musicMs = props.musicMs ?? 0;
709
775
  openingZoomMs = props.openingZoomMs ?? 900;
710
776
  color = props.color;
@@ -744,6 +810,8 @@ var scrollCursorFactory = {
744
810
  const p = props;
745
811
  if (p.mode != null && p.mode !== "audio" && p.mode !== "linear")
746
812
  errs.push('scroll-cursor.mode must be "audio" | "linear"');
813
+ if (p.scrollMode != null && p.scrollMode !== "hstack" && p.scrollMode !== "vstack")
814
+ errs.push('scroll-cursor.scrollMode must be "hstack" | "vstack"');
747
815
  if (p.mode === "linear" && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
748
816
  errs.push('scroll-cursor.musicMs must be a positive number for mode:"linear"');
749
817
  if (p.musicMs != null && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
@@ -3703,6 +3771,7 @@ export {
3703
3771
  measureCount,
3704
3772
  measureSpanBox,
3705
3773
  measureSpans,
3774
+ measureSystemMap,
3706
3775
  timeToX as minimapTimeToX,
3707
3776
  msPerBeat,
3708
3777
  notationFactory,
@@ -3747,11 +3816,13 @@ export {
3747
3816
  staffAnchor,
3748
3817
  staffKeyboardRayFactory,
3749
3818
  staffRayDemoSpec,
3819
+ systemBox,
3750
3820
  transitionProgress,
3751
3821
  validateChordTrack,
3752
3822
  validateQuiz,
3753
3823
  validateSections,
3754
3824
  visualTimelineMs,
3825
+ vstackFollowBox,
3755
3826
  whiteKeys,
3756
3827
  worldToViewport
3757
3828
  };