@real-music-packages/web-core 0.14.0 → 0.16.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;
@@ -902,6 +930,10 @@ interface KeyboardProps {
902
930
  bottomY?: number;
903
931
  /** Right/left hand colours. Defaults: R = theme.accent, L = theme.gold. */
904
932
  handColors?: HandColors;
933
+ /** Realistic 3-D piano styling: white/black key gradients, rounded key fronts,
934
+ * a felt rail + cast shadow at the top, and a soft glow on lit keys. Default
935
+ * false (the flat two-tone look — unchanged for existing consumers). */
936
+ realistic?: boolean;
905
937
  }
906
938
  declare const keyboardFactory: LayerFactory<KeyboardProps>;
907
939
 
@@ -918,6 +950,10 @@ interface FallingNotesProps {
918
950
  speed?: number;
919
951
  /** Glow flash at the hit-line while a note sounds. Default true. */
920
952
  hitGlow?: boolean;
953
+ /** Glowy/translucent block styling: rounded corners, a vertical gradient that
954
+ * fades toward the top, a soft bloom (shadow) in the note colour, and a bright
955
+ * leading-edge cap. Default false (flat opaque rectangles — unchanged). */
956
+ glow?: boolean;
921
957
  /** Standalone-only: keyboard range when `keyboard:false`. Default "auto". */
922
958
  range?: '88' | 'auto';
923
959
  /** Standalone-only: hit-line (world y). Default safeBox.bottom - 220. */
@@ -1532,4 +1568,4 @@ interface SectionMinimapProps {
1532
1568
  }
1533
1569
  declare const sectionMinimapFactory: LayerFactory<SectionMinimapProps>;
1534
1570
 
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 };
1571
+ 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))
@@ -656,7 +761,7 @@ var BLACK_PC = /* @__PURE__ */ new Set([1, 3, 6, 8, 10]);
656
761
  function whiteIndexAtOrBelow(midi) {
657
762
  const oct = Math.floor(midi / 12);
658
763
  const pc = midi - oct * 12;
659
- const WHITES_BELOW = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6];
764
+ const WHITES_BELOW = [0, 0, 1, 1, 2, 3, 3, 4, 4, 5, 5, 6];
660
765
  return oct * 7 + WHITES_BELOW[pc];
661
766
  }
662
767
  function isBlackKey(midi) {
@@ -779,15 +884,34 @@ function getKeyboardLayout(ctx) {
779
884
  }
780
885
 
781
886
  // src/scene/layers/keyboard.ts
887
+ function roundedPath(c, x, y, w, h, radii) {
888
+ c.beginPath();
889
+ const rr = c.roundRect;
890
+ if (typeof rr === "function") {
891
+ rr.call(c, x, y, w, h, radii);
892
+ return;
893
+ }
894
+ c.rect(x, y, w, h);
895
+ }
896
+ function shade(hex, f) {
897
+ const n = parseInt(hex.slice(1), 16);
898
+ const t = f < 0 ? 0 : 255, p = Math.abs(f);
899
+ const r = Math.round((n >> 16 & 255) + (t - (n >> 16 & 255)) * p);
900
+ const g = Math.round((n >> 8 & 255) + (t - (n >> 8 & 255)) * p);
901
+ const b = Math.round((n & 255) + (t - (n & 255)) * p);
902
+ return `rgb(${r},${g},${b})`;
903
+ }
782
904
  function keyboardLayer() {
783
905
  let layout = null;
784
906
  let colorBy = "hand";
785
907
  let hands = { R: "#7b2436", L: "#c8a55b" };
908
+ let realistic = false;
786
909
  return {
787
910
  key: "keyboard",
788
911
  init(ctx, props) {
789
912
  colorBy = props.colorBy ?? "hand";
790
913
  hands = props.handColors ?? { R: ctx.theme.accent, L: ctx.theme.gold };
914
+ realistic = props.realistic ?? false;
791
915
  const sb = ctx.safeBox;
792
916
  const height = props.height ?? 220;
793
917
  const bottomY = props.bottomY ?? sb.bottom;
@@ -810,6 +934,10 @@ function keyboardLayer() {
810
934
  for (const n of ctx.score?.notes ?? []) {
811
935
  if (tMs >= n.onsetMs && tMs < n.onsetMs + n.durMs) lit.set(n.pitchMidi, n.hand);
812
936
  }
937
+ if (realistic) {
938
+ drawRealisticKeyboard(c, L, lit, colorBy, hands);
939
+ return;
940
+ }
813
941
  c.save();
814
942
  for (const m of whiteKeys(L)) {
815
943
  const r = keyRect(L, m);
@@ -833,6 +961,78 @@ function keyboardLayer() {
833
961
  }
834
962
  };
835
963
  }
964
+ function drawRealisticKeyboard(c, L, lit, colorBy, hands) {
965
+ {
966
+ const wr = keyRect(L, whiteKeys(L)[0] ?? 0);
967
+ const wRad = Math.max(3, Math.min(8, wr.w * 0.18));
968
+ c.save();
969
+ for (const m of whiteKeys(L)) {
970
+ const r = keyRect(L, m);
971
+ const onHand = lit.get(m);
972
+ const x = r.x + 0.75, w = r.w - 1.5;
973
+ roundedPath(c, x, r.y, w, r.h, [2, 2, wRad, wRad]);
974
+ if (onHand) {
975
+ const base = noteColor(m, onHand, colorBy, hands);
976
+ const g = c.createLinearGradient(0, r.y, 0, r.y + r.h);
977
+ g.addColorStop(0, shade(base, 0.18));
978
+ g.addColorStop(1, base);
979
+ c.fillStyle = g;
980
+ } else {
981
+ const g = c.createLinearGradient(0, r.y, 0, r.y + r.h);
982
+ g.addColorStop(0, "#e9e4da");
983
+ g.addColorStop(0.12, "#fbfaf7");
984
+ g.addColorStop(1, "#ffffff");
985
+ c.fillStyle = g;
986
+ }
987
+ c.fill();
988
+ c.strokeStyle = "#cdc6ba";
989
+ c.lineWidth = 1;
990
+ c.stroke();
991
+ }
992
+ const railH = Math.max(4, L.height * 0.03);
993
+ c.fillStyle = "#7b2436";
994
+ c.fillRect(L.x, L.top - railH, L.w, railH);
995
+ const sh = c.createLinearGradient(0, L.top, 0, L.top + L.height * 0.14);
996
+ sh.addColorStop(0, "rgba(0,0,0,0.22)");
997
+ sh.addColorStop(1, "rgba(0,0,0,0)");
998
+ c.fillStyle = sh;
999
+ c.fillRect(L.x, L.top, L.w, L.height * 0.14);
1000
+ for (const m of blackKeys(L)) {
1001
+ const r = keyRect(L, m);
1002
+ const bRad = Math.max(2, r.w * 0.22);
1003
+ const onHand = lit.get(m);
1004
+ roundedPath(c, r.x, r.y, r.w, r.h, [1, 1, bRad, bRad]);
1005
+ if (onHand) {
1006
+ const base = noteColor(m, onHand, colorBy, hands);
1007
+ const g = c.createLinearGradient(0, r.y, 0, r.y + r.h);
1008
+ g.addColorStop(0, shade(base, -0.25));
1009
+ g.addColorStop(1, base);
1010
+ c.fillStyle = g;
1011
+ } else {
1012
+ const g = c.createLinearGradient(0, r.y, 0, r.y + r.h);
1013
+ g.addColorStop(0, "#15110e");
1014
+ g.addColorStop(0.82, "#2c2622");
1015
+ g.addColorStop(1, "#453d36");
1016
+ c.fillStyle = g;
1017
+ }
1018
+ c.fill();
1019
+ c.fillStyle = onHand ? "rgba(255,255,255,0.28)" : "rgba(255,255,255,0.10)";
1020
+ roundedPath(c, r.x + r.w * 0.18, r.y + r.h - Math.max(2, r.h * 0.06), r.w * 0.64, Math.max(1.5, r.h * 0.035), [1, 1, 1, 1]);
1021
+ c.fill();
1022
+ }
1023
+ for (const [m, hand] of lit) {
1024
+ const r = keyRect(L, m);
1025
+ c.save();
1026
+ c.shadowColor = noteColor(m, hand, colorBy, hands);
1027
+ c.shadowBlur = 22;
1028
+ c.globalAlpha = 0.55;
1029
+ c.fillStyle = noteColor(m, hand, colorBy, hands);
1030
+ c.fillRect(r.x + r.w * 0.2, r.y + r.h * 0.5, r.w * 0.6, r.h * 0.3);
1031
+ c.restore();
1032
+ }
1033
+ c.restore();
1034
+ }
1035
+ }
836
1036
  var keyboardFactory = {
837
1037
  key: "keyboard",
838
1038
  create: keyboardLayer,
@@ -848,17 +1048,68 @@ var keyboardFactory = {
848
1048
  errs.push("keyboard.height must be a positive number");
849
1049
  if (p.bottomY != null && typeof p.bottomY !== "number")
850
1050
  errs.push("keyboard.bottomY must be a number");
1051
+ if (p.realistic != null && typeof p.realistic !== "boolean")
1052
+ errs.push("keyboard.realistic must be a boolean");
851
1053
  return errs;
852
1054
  }
853
1055
  };
854
1056
 
855
1057
  // src/scene/layers/fallingNotes.ts
856
1058
  var DEFAULT_LEAD_MS = 2e3;
1059
+ function rgba(color, a) {
1060
+ if (/^#[0-9a-fA-F]{6}$/.test(color)) {
1061
+ const n = parseInt(color.slice(1), 16);
1062
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
1063
+ }
1064
+ return color;
1065
+ }
1066
+ function roundedRect(c, x, y, w, h, r) {
1067
+ c.beginPath();
1068
+ const rr = c.roundRect;
1069
+ if (typeof rr === "function") {
1070
+ rr.call(c, x, y, w, h, r);
1071
+ return;
1072
+ }
1073
+ c.rect(x, y, w, h);
1074
+ }
1075
+ function drawGlowBlock(c, x, y, w, hRaw, color) {
1076
+ const h = Math.max(2, hRaw);
1077
+ const r = Math.min(w * 0.42, 8);
1078
+ const bx = x + 1, bw = Math.max(1, w - 2);
1079
+ c.save();
1080
+ c.shadowColor = rgba(color, 1);
1081
+ c.shadowBlur = 22;
1082
+ c.fillStyle = rgba(color, 0.5);
1083
+ roundedRect(c, bx, y, bw, h, r);
1084
+ c.fill();
1085
+ c.fill();
1086
+ c.shadowBlur = 0;
1087
+ const g = c.createLinearGradient(0, y, 0, y + h);
1088
+ g.addColorStop(0, rgba(color, 0.06));
1089
+ g.addColorStop(0.5, rgba(color, 0.38));
1090
+ g.addColorStop(1, rgba(color, 0.78));
1091
+ c.fillStyle = g;
1092
+ roundedRect(c, bx, y, bw, h, r);
1093
+ c.fill();
1094
+ const hl = c.createLinearGradient(bx, 0, bx + bw, 0);
1095
+ hl.addColorStop(0, "rgba(255,255,255,0)");
1096
+ hl.addColorStop(0.5, "rgba(255,255,255,0.22)");
1097
+ hl.addColorStop(1, "rgba(255,255,255,0)");
1098
+ c.fillStyle = hl;
1099
+ roundedRect(c, bx + bw * 0.18, y, bw * 0.46, h, Math.min(r, bw * 0.2));
1100
+ c.fill();
1101
+ const capH = Math.min(7, h);
1102
+ c.fillStyle = rgba(color, 0.98);
1103
+ roundedRect(c, bx, y + h - capH, bw, capH, Math.min(r, capH / 2));
1104
+ c.fill();
1105
+ c.restore();
1106
+ }
857
1107
  function fallingNotesLayer() {
858
1108
  let paired = true;
859
1109
  let colorBy = "hand";
860
1110
  let hands = { R: "#7b2436", L: "#c8a55b" };
861
1111
  let hitGlow = true;
1112
+ let glow = false;
862
1113
  let ownLayout = null;
863
1114
  let topY = 0;
864
1115
  let leadMsProp;
@@ -882,6 +1133,7 @@ function fallingNotesLayer() {
882
1133
  colorBy = props.colorBy ?? "hand";
883
1134
  hands = props.handColors ?? { R: ctx.theme.accent, L: ctx.theme.gold };
884
1135
  hitGlow = props.hitGlow ?? true;
1136
+ glow = props.glow ?? false;
885
1137
  leadMsProp = props.leadMs;
886
1138
  speedProp = props.speed;
887
1139
  const sb = ctx.safeBox;
@@ -919,11 +1171,15 @@ function fallingNotesLayer() {
919
1171
  const drawTop = Math.max(topY, topEdge);
920
1172
  const drawBottom = Math.min(hit, bottomY);
921
1173
  const fill = noteColor(n.pitchMidi, n.hand, colorBy, hands);
922
- c.save();
923
- c.fillStyle = fill;
924
- c.globalAlpha = 0.92;
925
- c.fillRect(cx - w / 2, drawTop, w, Math.max(1, drawBottom - drawTop));
926
- c.restore();
1174
+ if (glow) {
1175
+ drawGlowBlock(c, cx - w / 2, drawTop, w, drawBottom - drawTop, fill);
1176
+ } else {
1177
+ c.save();
1178
+ c.fillStyle = fill;
1179
+ c.globalAlpha = 0.92;
1180
+ c.fillRect(cx - w / 2, drawTop, w, Math.max(1, drawBottom - drawTop));
1181
+ c.restore();
1182
+ }
927
1183
  }
928
1184
  if (hitGlow) {
929
1185
  for (const n of ctx.score?.notes ?? []) {
@@ -961,6 +1217,8 @@ var fallingNotesFactory = {
961
1217
  errs.push("falling-notes.speed must be a positive number");
962
1218
  if (p.hitGlow != null && typeof p.hitGlow !== "boolean")
963
1219
  errs.push("falling-notes.hitGlow must be a boolean");
1220
+ if (p.glow != null && typeof p.glow !== "boolean")
1221
+ errs.push("falling-notes.glow must be a boolean");
964
1222
  if (p.range != null && p.range !== "88" && p.range !== "auto")
965
1223
  errs.push('falling-notes.range must be "88" | "auto"');
966
1224
  return errs;
@@ -1173,11 +1431,11 @@ function spectrumLayer() {
1173
1431
  const src = fromProp ?? resolveFromCtx(ctx, tMs);
1174
1432
  if (src && src.length) {
1175
1433
  const out = new Array(n);
1176
- for (let b = 0; b < n; b++) out[b] = clamp01(src[Math.min(src.length - 1, b)] ?? 0);
1434
+ for (let b = 0; b < n; b++) out[b] = clamp012(src[Math.min(src.length - 1, b)] ?? 0);
1177
1435
  return out;
1178
1436
  }
1179
1437
  const tSec = tMs / 1e3;
1180
- return Array.from({ length: n }, (_, b) => clamp01(syntheticMagnitude(tSec, b, n)));
1438
+ return Array.from({ length: n }, (_, b) => clamp012(syntheticMagnitude(tSec, b, n)));
1181
1439
  }
1182
1440
  function resolveFromCtx(ctx, tMs) {
1183
1441
  const sp = ctx.spectrum;
@@ -1239,7 +1497,7 @@ function spectrumLayer() {
1239
1497
  }
1240
1498
  };
1241
1499
  }
1242
- function clamp01(x) {
1500
+ function clamp012(x) {
1243
1501
  return x < 0 ? 0 : x > 1 ? 1 : x;
1244
1502
  }
1245
1503
  var spectrumFactory = {
@@ -3366,6 +3624,7 @@ export {
3366
3624
  applySchedule,
3367
3625
  applyToContext,
3368
3626
  assertGate,
3627
+ audioPlayheadLine,
3369
3628
  ballArc,
3370
3629
  ballX,
3371
3630
  beatGrid,
@@ -3395,6 +3654,7 @@ export {
3395
3654
  cueOpacity,
3396
3655
  degreeLabel,
3397
3656
  degreeLabelsFactory,
3657
+ distinctOnsets,
3398
3658
  dotAt,
3399
3659
  drawCaption,
3400
3660
  drawHighlight,