@real-music-packages/web-core 0.14.0 → 0.15.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.
@@ -670,15 +670,25 @@ interface NotationProps {
670
670
  declare const notationFactory: LayerFactory<NotationProps>;
671
671
 
672
672
  interface ScrollCursorProps {
673
+ /**
674
+ * Cursor pacing mode. Default `'audio'` (foolproof): the cursor is driven
675
+ * note-by-note off the audio clock against each note's `onsetMs`, so it lands
676
+ * on every note as it sounds and can never race/desync the full score width —
677
+ * correct by construction regardless of timeline. `'linear'` is the legacy
678
+ * measure-linear sweep paced over `musicMs` (RSR's original behaviour) — kept
679
+ * for back-compat; prefer leaving this unset.
680
+ */
681
+ mode?: 'audio' | 'linear';
673
682
  /** Bars visible in the follow window. Default 2 (RSR FOLLOW_BARS). Informational
674
683
  * for v1 — the geometry uses the module constant unless overridden here. */
675
684
  followBars?: number;
676
685
  /** Opening-zoom duration in ms before the music/cursor start (RSR INTRO_MS=900).
677
- * During this lead-in camProgress is held at 0. Default 900. */
686
+ * During this lead-in the follow window is held at the start. Default 900. */
678
687
  openingZoomMs?: number;
679
- /** Total music length in ms (RSR musicMs). Required to pace camProgress + the
680
- * cursor against the audio clock. */
681
- musicMs: number;
688
+ /** Total music length in ms (RSR musicMs). Required by `'linear'` to pace
689
+ * camProgress; in `'audio'` mode it is optional (the onset clock drives pacing)
690
+ * and used only as a fallback when the score has no notes. */
691
+ musicMs?: number;
682
692
  /** Cursor stroke colour. Defaults to theme.accent. */
683
693
  color?: string;
684
694
  }
@@ -755,6 +765,24 @@ interface PlayheadLine {
755
765
  alpha: number;
756
766
  }
757
767
  declare function playheadLine(layout: NotationLayout, t01: number): PlayheadLine | null;
768
+ /** Distinct, sorted note onsets from a Score's notes. */
769
+ declare function distinctOnsets(notes: {
770
+ onsetMs: number;
771
+ }[]): number[];
772
+ /**
773
+ * The FOOLPROOF audio-driven playhead line for absolute audio time `tMs`.
774
+ *
775
+ * `tMs` is the audio clock; `onsetsMs` are the score's distinct note onsets
776
+ * (sorted). The cursor is the time-lerp of the two anchors bracketing `tMs`:
777
+ * before the first onset it holds on anchor 0; after the last it holds on the
778
+ * last; in a gap it eases between the bracketing onsets. Pure function of
779
+ * (layout, onsetsMs, tMs). Returns null only when there is no geometry to anchor
780
+ * to (falls back to the rect sweep in the layer, same as `playheadLine`).
781
+ *
782
+ * Fade in/out mirrors `playheadLine` but is keyed on position in the ONSET span
783
+ * (first→last onset), not on musicMs — so the fade tracks the notes too.
784
+ */
785
+ declare function audioPlayheadLine(layout: NotationLayout, onsetsMs: number[], tMs: number): PlayheadLine | null;
758
786
 
759
787
  /** Map a canvas-space box through a base notation layout into world/screen coords. */
760
788
  declare function mapBoxThroughLayout(base: NotationLayout, b: Box): Box;
@@ -1532,4 +1560,4 @@ interface SectionMinimapProps {
1532
1560
  }
1533
1561
  declare const sectionMinimapFactory: LayerFactory<SectionMinimapProps>;
1534
1562
 
1535
- 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, 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, 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 };
1563
+ 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 };
@@ -489,6 +489,74 @@ function playheadLine(layout, t01) {
489
489
  }
490
490
  return { x, y0, y1, alpha };
491
491
  }
492
+ var clamp01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
493
+ function anchorAtColumnPos(cols, pos, padV) {
494
+ const i = Math.min(cols.length - 1, Math.max(0, Math.floor(pos)));
495
+ const m = cols[i];
496
+ const startX = Math.min(m.noteStartX, m.x + m.w);
497
+ const frac = Math.min(1, Math.max(0, pos - i));
498
+ return {
499
+ onsetMs: 0,
500
+ x: startX + frac * (m.x + m.w - startX),
501
+ y0: m.y - padV,
502
+ y1: m.y + m.h + padV
503
+ };
504
+ }
505
+ function noteAnchors(layout, onsetsMs, padV) {
506
+ const cols = measureColumnsFromLayout(layout.measures);
507
+ if (!cols.length || !onsetsMs.length) return [];
508
+ const n = onsetsMs.length;
509
+ return onsetsMs.map((onsetMs, k) => {
510
+ const frac = n === 1 ? 0 : k / (n - 1);
511
+ const a = anchorAtColumnPos(cols, frac * cols.length, padV);
512
+ return { ...a, onsetMs };
513
+ });
514
+ }
515
+ function distinctOnsets(notes) {
516
+ const set = /* @__PURE__ */ new Set();
517
+ for (const n of notes) set.add(n.onsetMs);
518
+ return [...set].sort((a, b) => a - b);
519
+ }
520
+ function audioPlayheadLine(layout, onsetsMs, tMs) {
521
+ const padV = 10;
522
+ const anchors = noteAnchors(layout, onsetsMs, padV);
523
+ if (!anchors.length) return null;
524
+ const first = anchors[0].onsetMs;
525
+ const last = anchors[anchors.length - 1].onsetMs;
526
+ const span = last - first;
527
+ let a, b, frac;
528
+ if (tMs <= first || anchors.length === 1) {
529
+ a = b = anchors[0];
530
+ frac = 0;
531
+ } else if (tMs >= last) {
532
+ a = b = anchors[anchors.length - 1];
533
+ frac = 0;
534
+ } else {
535
+ let lo = 0;
536
+ let hi = anchors.length - 1;
537
+ while (lo < hi) {
538
+ const mid = lo + hi + 1 >> 1;
539
+ if (anchors[mid].onsetMs <= tMs) lo = mid;
540
+ else hi = mid - 1;
541
+ }
542
+ a = anchors[lo];
543
+ b = anchors[lo + 1];
544
+ const dt = b.onsetMs - a.onsetMs;
545
+ frac = dt > 0 ? cubicEaseInOut((tMs - a.onsetMs) / dt) : 0;
546
+ }
547
+ const x = a.x + (b.x - a.x) * frac;
548
+ const y0 = a.y0 + (b.y0 - a.y0) * frac;
549
+ const y1 = a.y1 + (b.y1 - a.y1) * frac;
550
+ let alpha = 0.85;
551
+ if (span > 0) {
552
+ const fadeIn = Math.min(250, span * 0.5);
553
+ const fadeOut = Math.min(400, span * 0.5);
554
+ if (tMs < first + fadeIn) alpha *= clamp01((tMs - (first - fadeIn)) / (2 * fadeIn));
555
+ if (tMs > last - fadeOut) alpha *= clamp01((last + fadeOut - tMs) / (2 * fadeOut));
556
+ }
557
+ if (alpha <= 0.02) return null;
558
+ return { x, y0, y1, alpha };
559
+ }
492
560
 
493
561
  // src/scene/engravingStore.ts
494
562
  var STORE = /* @__PURE__ */ new WeakMap();
@@ -593,6 +661,7 @@ function followLayoutFor(ctx, camProgress01) {
593
661
  return notationLayout(eng.rendered, ctx.W, ctx.H, eng.bandTop, eng.bandHeight, { focusBox });
594
662
  }
595
663
  function scrollCursorLayer() {
664
+ let mode = "audio";
596
665
  let musicMs = 0;
597
666
  let openingZoomMs = 900;
598
667
  let color;
@@ -601,14 +670,41 @@ function scrollCursorLayer() {
601
670
  if (phMs < 0 || musicMs <= 0) return 0;
602
671
  return Math.min(1, phMs / musicMs);
603
672
  }
673
+ function onsetsFor(ctx) {
674
+ const notes = ctx.score?.notes;
675
+ return notes && notes.length ? distinctOnsets(notes) : [];
676
+ }
677
+ function notesProgress(onsetsMs, tMs) {
678
+ if (tMs < openingZoomMs) return 0;
679
+ const n = onsetsMs.length;
680
+ if (n <= 1) return 0;
681
+ const first = onsetsMs[0];
682
+ const last = onsetsMs[n - 1];
683
+ if (tMs <= first) return 0;
684
+ if (tMs >= last || last <= first) return 1;
685
+ let lo = 0, hi = n - 1;
686
+ while (lo < hi) {
687
+ const mid = lo + hi + 1 >> 1;
688
+ if (onsetsMs[mid] <= tMs) lo = mid;
689
+ else hi = mid - 1;
690
+ }
691
+ const segFrac = (tMs - onsetsMs[lo]) / (onsetsMs[lo + 1] - onsetsMs[lo]);
692
+ return (lo + segFrac) / (n - 1);
693
+ }
694
+ function followProgress(ctx, tMs) {
695
+ if (mode === "linear") return camProgress(tMs);
696
+ const onsets = onsetsFor(ctx);
697
+ return onsets.length ? notesProgress(onsets, tMs) : camProgress(tMs);
698
+ }
604
699
  function layoutAt(ctx, tMs) {
605
700
  const eng = getNotationEngraving(ctx);
606
- return followLayoutFor(ctx, camProgress(tMs)) ?? eng.base;
701
+ return followLayoutFor(ctx, followProgress(ctx, tMs)) ?? eng.base;
607
702
  }
608
703
  return {
609
704
  key: "scroll-cursor",
610
705
  init(ctx, props) {
611
- musicMs = props.musicMs;
706
+ mode = props.mode ?? "audio";
707
+ musicMs = props.musicMs ?? 0;
612
708
  openingZoomMs = props.openingZoomMs ?? 900;
613
709
  color = props.color;
614
710
  setFollowLayoutProvider(ctx, layoutAt);
@@ -616,9 +712,14 @@ function scrollCursorLayer() {
616
712
  draw(ctx, tMs) {
617
713
  const eng = getNotationEngraving(ctx);
618
714
  if (!eng) return;
619
- const p = camProgress(tMs);
620
715
  const layout = layoutAt(ctx, tMs);
621
- const line = playheadLine(layout, p);
716
+ let line;
717
+ const onsets = mode === "audio" ? onsetsFor(ctx) : [];
718
+ if (mode === "audio" && onsets.length) {
719
+ line = audioPlayheadLine(layout, onsets, tMs);
720
+ } else {
721
+ line = playheadLine(layout, camProgress(tMs));
722
+ }
622
723
  if (!line) return;
623
724
  const c = ctx.ctx2d;
624
725
  c.save();
@@ -640,8 +741,12 @@ var scrollCursorFactory = {
640
741
  const errs = [];
641
742
  if (props == null || typeof props !== "object") return ["scroll-cursor: props must be an object"];
642
743
  const p = props;
643
- if (typeof p.musicMs !== "number" || !(p.musicMs > 0))
644
- errs.push("scroll-cursor.musicMs must be a positive number (total music length ms)");
744
+ if (p.mode != null && p.mode !== "audio" && p.mode !== "linear")
745
+ errs.push('scroll-cursor.mode must be "audio" | "linear"');
746
+ if (p.mode === "linear" && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
747
+ errs.push('scroll-cursor.musicMs must be a positive number for mode:"linear"');
748
+ if (p.musicMs != null && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
749
+ errs.push("scroll-cursor.musicMs must be a positive number");
645
750
  if (p.followBars != null && (typeof p.followBars !== "number" || p.followBars < 1))
646
751
  errs.push("scroll-cursor.followBars must be a number >= 1");
647
752
  if (p.openingZoomMs != null && (typeof p.openingZoomMs !== "number" || p.openingZoomMs < 0))
@@ -1173,11 +1278,11 @@ function spectrumLayer() {
1173
1278
  const src = fromProp ?? resolveFromCtx(ctx, tMs);
1174
1279
  if (src && src.length) {
1175
1280
  const out = new Array(n);
1176
- for (let b = 0; b < n; b++) out[b] = clamp01(src[Math.min(src.length - 1, b)] ?? 0);
1281
+ for (let b = 0; b < n; b++) out[b] = clamp012(src[Math.min(src.length - 1, b)] ?? 0);
1177
1282
  return out;
1178
1283
  }
1179
1284
  const tSec = tMs / 1e3;
1180
- return Array.from({ length: n }, (_, b) => clamp01(syntheticMagnitude(tSec, b, n)));
1285
+ return Array.from({ length: n }, (_, b) => clamp012(syntheticMagnitude(tSec, b, n)));
1181
1286
  }
1182
1287
  function resolveFromCtx(ctx, tMs) {
1183
1288
  const sp = ctx.spectrum;
@@ -1239,7 +1344,7 @@ function spectrumLayer() {
1239
1344
  }
1240
1345
  };
1241
1346
  }
1242
- function clamp01(x) {
1347
+ function clamp012(x) {
1243
1348
  return x < 0 ? 0 : x > 1 ? 1 : x;
1244
1349
  }
1245
1350
  var spectrumFactory = {
@@ -3366,6 +3471,7 @@ export {
3366
3471
  applySchedule,
3367
3472
  applyToContext,
3368
3473
  assertGate,
3474
+ audioPlayheadLine,
3369
3475
  ballArc,
3370
3476
  ballX,
3371
3477
  beatGrid,
@@ -3395,6 +3501,7 @@ export {
3395
3501
  cueOpacity,
3396
3502
  degreeLabel,
3397
3503
  degreeLabelsFactory,
3504
+ distinctOnsets,
3398
3505
  dotAt,
3399
3506
  drawCaption,
3400
3507
  drawHighlight,