@real-music-packages/web-core 0.32.0 → 0.34.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/audio.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import {
2
2
  KEYS_PREFER_FLATS
3
3
  } from "./chunk-BYZBLH25.js";
4
+ import {
5
+ getMidiNote,
6
+ midiToNoteName
7
+ } from "./chunk-GORQ5YMR.js";
4
8
  import {
5
9
  SALAMANDER_CDN_BASE,
6
10
  SALAMANDER_URLS_8,
@@ -8,10 +12,6 @@ import {
8
12
  createSalamanderSampler,
9
13
  generateReverb
10
14
  } from "./chunk-JVGAABTK.js";
11
- import {
12
- getMidiNote,
13
- midiToNoteName
14
- } from "./chunk-GORQ5YMR.js";
15
15
 
16
16
  // src/engine.ts
17
17
  var browser = typeof window !== "undefined";
package/dist/index.js CHANGED
@@ -1,8 +1,3 @@
1
- import {
2
- KEYS_PREFER_FLATS,
3
- useFlatsForKeyFifths,
4
- useFlatsForKeyName
5
- } from "./chunk-BYZBLH25.js";
6
1
  import {
7
2
  INTERVALS,
8
3
  MAX_FRET,
@@ -17,6 +12,11 @@ import {
17
12
  midiToPositions,
18
13
  noteToFret
19
14
  } from "./chunk-ILIXNGPE.js";
15
+ import {
16
+ KEYS_PREFER_FLATS,
17
+ useFlatsForKeyFifths,
18
+ useFlatsForKeyName
19
+ } from "./chunk-BYZBLH25.js";
20
20
  import {
21
21
  ALL_KEYS,
22
22
  MAJOR_SCALE_INTERVALS,
@@ -0,0 +1,61 @@
1
+ import { S as Score } from './waveform-DdSMAbYQ.js';
2
+ import './video.js';
3
+
4
+ /** createScorePlayer — play a `Score` (from `scoreFromMusicXML`) through a sink.
5
+ *
6
+ * The standard Web Audio lookahead pattern: a coarse `setTimeout` tick fires
7
+ * every ~100ms and schedules every note whose onset falls inside the next
8
+ * ~300ms at its *exact* time on the sink's audio clock. Timer jitter moves the
9
+ * tick, never the note — the audio clock is the only clock the ear hears.
10
+ *
11
+ * Deliberately Tone-free, like the rest of this package's audio layer: the sink
12
+ * owns the instrument (a Tone.Sampler, an oscillator, a test fake) and this
13
+ * module owns nothing but time. Position is score-time in ms; wall time maps to
14
+ * it through `rate`, and every rate change or seek re-anchors the mapping so
15
+ * position is continuous.
16
+ *
17
+ * Seeking is onset-faithful, not state-faithful: a note whose onset is behind
18
+ * the new position does not sound, even if it would still be ringing there.
19
+ * Mid-note pickup needs per-note release tracking for a payoff nobody asked
20
+ * for yet; the seam is `scheduleWindow`.
21
+ */
22
+
23
+ interface PlaybackSink {
24
+ /** Sound `midi` for `durSec` starting at `atAudioTime` on the sink's clock. */
25
+ noteAt(midi: number, durSec: number, atAudioTime: number): void;
26
+ /** The sink's audio clock, in seconds (AudioContext.currentTime). */
27
+ now(): number;
28
+ /** Silence everything — pause, seek, teardown. */
29
+ releaseAll(): void;
30
+ }
31
+ interface ScorePlayerOpts {
32
+ /** Playback rate; 1 plays the score's own tempo. */
33
+ rate?: number;
34
+ /** Scheduler granularity — how often the tick fires. */
35
+ tickMs?: number;
36
+ /** How far ahead each tick schedules. Must exceed tickMs comfortably. */
37
+ lookaheadMs?: number;
38
+ }
39
+ interface ScorePlayer {
40
+ play(): void;
41
+ pause(): void;
42
+ /** Jump to a position in score-time. Keeps playing/paused state. */
43
+ seekMs(ms: number): void;
44
+ setRate(rate: number): void;
45
+ loop: boolean;
46
+ /** ~Every tick while playing: the current position in score-ms. */
47
+ onTick?: (positionMs: number) => void;
48
+ /** The piece ran out (never fires when looping). */
49
+ onEnd?: () => void;
50
+ readonly positionMs: number;
51
+ readonly playing: boolean;
52
+ readonly rate: number;
53
+ }
54
+ declare function createScorePlayer(score: Score, sink: PlaybackSink, opts?: ScorePlayerOpts): ScorePlayer;
55
+ /** Per printed bar: where it starts in score-ms. For bar-wise scrub and cursor. */
56
+ declare function measureStartsMs(score: Score): Array<{
57
+ measure: number;
58
+ atMs: number;
59
+ }>;
60
+
61
+ export { type PlaybackSink, type ScorePlayer, type ScorePlayerOpts, createScorePlayer, measureStartsMs };
@@ -0,0 +1,128 @@
1
+ // src/playback.ts
2
+ var DEFAULT_TICK_MS = 100;
3
+ var DEFAULT_LOOKAHEAD_MS = 300;
4
+ function createScorePlayer(score, sink, opts = {}) {
5
+ const tickMs = opts.tickMs ?? DEFAULT_TICK_MS;
6
+ const lookaheadMs = opts.lookaheadMs ?? DEFAULT_LOOKAHEAD_MS;
7
+ const notes = [...score.notes].sort((a, b) => a.onsetMs - b.onsetMs);
8
+ let rate = opts.rate ?? 1;
9
+ let playing = false;
10
+ let loop = false;
11
+ let anchorScoreMs = 0;
12
+ let anchorAudioSec = 0;
13
+ let restingMs = 0;
14
+ let nextIndex = 0;
15
+ let timer = null;
16
+ const positionNow = () => playing ? anchorScoreMs + (sink.now() - anchorAudioSec) * 1e3 * rate : restingMs;
17
+ function indexAt(scoreMs) {
18
+ let lo = 0, hi = notes.length;
19
+ while (lo < hi) {
20
+ const mid = lo + hi >> 1;
21
+ if (notes[mid].onsetMs < scoreMs) lo = mid + 1;
22
+ else hi = mid;
23
+ }
24
+ return lo;
25
+ }
26
+ function scheduleWindow() {
27
+ const pos = positionNow();
28
+ const horizon = pos + lookaheadMs * rate;
29
+ while (nextIndex < notes.length && notes[nextIndex].onsetMs < horizon) {
30
+ const n = notes[nextIndex++];
31
+ if (n.onsetMs < pos - 1) continue;
32
+ const at = anchorAudioSec + (n.onsetMs - anchorScoreMs) / 1e3 / rate;
33
+ sink.noteAt(n.pitchMidi, n.durMs / 1e3 / rate, at);
34
+ }
35
+ }
36
+ function tick() {
37
+ if (!playing) return;
38
+ scheduleWindow();
39
+ const pos = positionNow();
40
+ player.onTick?.(Math.min(pos, score.durationMs));
41
+ if (nextIndex >= notes.length && pos >= score.durationMs) {
42
+ if (loop) {
43
+ anchorScoreMs = 0;
44
+ anchorAudioSec = sink.now();
45
+ nextIndex = 0;
46
+ scheduleWindow();
47
+ } else {
48
+ stopClock(score.durationMs);
49
+ player.onEnd?.();
50
+ return;
51
+ }
52
+ }
53
+ timer = setTimeout(tick, tickMs);
54
+ }
55
+ function stopClock(atMs) {
56
+ restingMs = atMs;
57
+ playing = false;
58
+ if (timer) {
59
+ clearTimeout(timer);
60
+ timer = null;
61
+ }
62
+ }
63
+ const player = {
64
+ play() {
65
+ if (playing) return;
66
+ if (restingMs >= score.durationMs) restingMs = 0;
67
+ playing = true;
68
+ anchorScoreMs = restingMs;
69
+ anchorAudioSec = sink.now();
70
+ nextIndex = indexAt(restingMs);
71
+ tick();
72
+ },
73
+ pause() {
74
+ if (!playing) return;
75
+ stopClock(positionNow());
76
+ sink.releaseAll();
77
+ },
78
+ seekMs(ms) {
79
+ const target = Math.max(0, Math.min(score.durationMs, ms));
80
+ if (playing) {
81
+ sink.releaseAll();
82
+ anchorScoreMs = target;
83
+ anchorAudioSec = sink.now();
84
+ nextIndex = indexAt(target);
85
+ } else {
86
+ restingMs = target;
87
+ nextIndex = indexAt(target);
88
+ }
89
+ },
90
+ setRate(next) {
91
+ if (next <= 0 || next === rate) return;
92
+ if (playing) {
93
+ anchorScoreMs = positionNow();
94
+ anchorAudioSec = sink.now();
95
+ }
96
+ rate = next;
97
+ },
98
+ get loop() {
99
+ return loop;
100
+ },
101
+ set loop(v) {
102
+ loop = v;
103
+ },
104
+ get positionMs() {
105
+ return Math.min(positionNow(), score.durationMs);
106
+ },
107
+ get playing() {
108
+ return playing;
109
+ },
110
+ get rate() {
111
+ return rate;
112
+ }
113
+ };
114
+ return player;
115
+ }
116
+ function measureStartsMs(score) {
117
+ const starts = /* @__PURE__ */ new Map();
118
+ for (const n of score.notes) {
119
+ const seen = starts.get(n.measure);
120
+ if (seen === void 0 || n.onsetMs < seen) starts.set(n.measure, n.onsetMs);
121
+ }
122
+ return [...starts.entries()].map(([measure, atMs]) => ({ measure, atMs })).sort((a, b) => a.atMs - b.atMs);
123
+ }
124
+ export {
125
+ createScorePlayer,
126
+ measureStartsMs
127
+ };
128
+ //# sourceMappingURL=playback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/playback.ts"],"sourcesContent":["/** createScorePlayer — play a `Score` (from `scoreFromMusicXML`) through a sink.\n *\n * The standard Web Audio lookahead pattern: a coarse `setTimeout` tick fires\n * every ~100ms and schedules every note whose onset falls inside the next\n * ~300ms at its *exact* time on the sink's audio clock. Timer jitter moves the\n * tick, never the note — the audio clock is the only clock the ear hears.\n *\n * Deliberately Tone-free, like the rest of this package's audio layer: the sink\n * owns the instrument (a Tone.Sampler, an oscillator, a test fake) and this\n * module owns nothing but time. Position is score-time in ms; wall time maps to\n * it through `rate`, and every rate change or seek re-anchors the mapping so\n * position is continuous.\n *\n * Seeking is onset-faithful, not state-faithful: a note whose onset is behind\n * the new position does not sound, even if it would still be ringing there.\n * Mid-note pickup needs per-note release tracking for a payoff nobody asked\n * for yet; the seam is `scheduleWindow`.\n */\n\nimport type { Score } from './scene/index';\n\nexport interface PlaybackSink {\n /** Sound `midi` for `durSec` starting at `atAudioTime` on the sink's clock. */\n noteAt(midi: number, durSec: number, atAudioTime: number): void;\n /** The sink's audio clock, in seconds (AudioContext.currentTime). */\n now(): number;\n /** Silence everything — pause, seek, teardown. */\n releaseAll(): void;\n}\n\nexport interface ScorePlayerOpts {\n /** Playback rate; 1 plays the score's own tempo. */\n rate?: number;\n /** Scheduler granularity — how often the tick fires. */\n tickMs?: number;\n /** How far ahead each tick schedules. Must exceed tickMs comfortably. */\n lookaheadMs?: number;\n}\n\nexport interface ScorePlayer {\n play(): void;\n pause(): void;\n /** Jump to a position in score-time. Keeps playing/paused state. */\n seekMs(ms: number): void;\n setRate(rate: number): void;\n loop: boolean;\n /** ~Every tick while playing: the current position in score-ms. */\n onTick?: (positionMs: number) => void;\n /** The piece ran out (never fires when looping). */\n onEnd?: () => void;\n readonly positionMs: number;\n readonly playing: boolean;\n readonly rate: number;\n}\n\nconst DEFAULT_TICK_MS = 100;\nconst DEFAULT_LOOKAHEAD_MS = 300;\n\nexport function createScorePlayer(\n score: Score,\n sink: PlaybackSink,\n opts: ScorePlayerOpts = {},\n): ScorePlayer {\n const tickMs = opts.tickMs ?? DEFAULT_TICK_MS;\n const lookaheadMs = opts.lookaheadMs ?? DEFAULT_LOOKAHEAD_MS;\n // Sorted by onset once; the scheduler walks `nextIndex` forward and never\n // rescans, so a tick is O(notes due), not O(all notes).\n const notes = [...score.notes].sort((a, b) => a.onsetMs - b.onsetMs);\n\n let rate = opts.rate ?? 1;\n let playing = false;\n let loop = false;\n // The wall↔score mapping: score-ms = anchorScoreMs + (now - anchorAudioSec) * 1000 * rate.\n let anchorScoreMs = 0;\n let anchorAudioSec = 0;\n // Where the playhead rests while paused.\n let restingMs = 0;\n let nextIndex = 0;\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n const positionNow = (): number =>\n playing ? anchorScoreMs + (sink.now() - anchorAudioSec) * 1000 * rate : restingMs;\n\n function indexAt(scoreMs: number): number {\n let lo = 0, hi = notes.length;\n while (lo < hi) {\n const mid = (lo + hi) >> 1;\n if (notes[mid].onsetMs < scoreMs) lo = mid + 1;\n else hi = mid;\n }\n return lo;\n }\n\n /** Schedule everything with an onset inside [position, position + lookahead). */\n function scheduleWindow() {\n const pos = positionNow();\n const horizon = pos + lookaheadMs * rate;\n while (nextIndex < notes.length && notes[nextIndex].onsetMs < horizon) {\n const n = notes[nextIndex++];\n if (n.onsetMs < pos - 1) continue; // behind the playhead (post-seek edge)\n const at = anchorAudioSec + (n.onsetMs - anchorScoreMs) / 1000 / rate;\n sink.noteAt(n.pitchMidi, n.durMs / 1000 / rate, at);\n }\n }\n\n function tick() {\n if (!playing) return;\n scheduleWindow();\n const pos = positionNow();\n player.onTick?.(Math.min(pos, score.durationMs));\n\n if (nextIndex >= notes.length && pos >= score.durationMs) {\n if (loop) {\n anchorScoreMs = 0;\n anchorAudioSec = sink.now();\n nextIndex = 0;\n scheduleWindow();\n } else {\n stopClock(score.durationMs);\n player.onEnd?.();\n return;\n }\n }\n timer = setTimeout(tick, tickMs);\n }\n\n function stopClock(atMs: number) {\n restingMs = atMs;\n playing = false;\n if (timer) { clearTimeout(timer); timer = null; }\n }\n\n const player: ScorePlayer = {\n play() {\n if (playing) return;\n if (restingMs >= score.durationMs) restingMs = 0; // play again from the top\n playing = true;\n anchorScoreMs = restingMs;\n anchorAudioSec = sink.now();\n nextIndex = indexAt(restingMs);\n tick();\n },\n\n pause() {\n if (!playing) return;\n stopClock(positionNow());\n sink.releaseAll();\n },\n\n seekMs(ms: number) {\n const target = Math.max(0, Math.min(score.durationMs, ms));\n if (playing) {\n sink.releaseAll();\n anchorScoreMs = target;\n anchorAudioSec = sink.now();\n nextIndex = indexAt(target);\n } else {\n restingMs = target;\n nextIndex = indexAt(target);\n }\n },\n\n setRate(next: number) {\n if (next <= 0 || next === rate) return;\n // Re-anchor at the current position so the playhead does not jump.\n if (playing) {\n anchorScoreMs = positionNow();\n anchorAudioSec = sink.now();\n }\n rate = next;\n },\n\n get loop() { return loop; },\n set loop(v: boolean) { loop = v; },\n get positionMs() { return Math.min(positionNow(), score.durationMs); },\n get playing() { return playing; },\n get rate() { return rate; },\n };\n\n return player;\n}\n\n/** Per printed bar: where it starts in score-ms. For bar-wise scrub and cursor. */\nexport function measureStartsMs(score: Score): Array<{ measure: number; atMs: number }> {\n const starts = new Map<number, number>();\n for (const n of score.notes) {\n const seen = starts.get(n.measure);\n if (seen === undefined || n.onsetMs < seen) starts.set(n.measure, n.onsetMs);\n }\n return [...starts.entries()]\n .map(([measure, atMs]) => ({ measure, atMs }))\n .sort((a, b) => a.atMs - b.atMs);\n}\n"],"mappings":"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","names":[]}
@@ -1,145 +1,8 @@
1
- import { PromoTheme, SafeBox, RecordOpts, Scene } from '../video.js';
1
+ import { A as AudioClock, S as Score, a as LayerFactory, R as RenderCtx, b as ScoreFromMusicXMLOpts, T as TempoMap, c as ScoreNote } from '../waveform-DdSMAbYQ.js';
2
+ export { L as Layer, d as SpectrumInput, e as SpectrumProps, W as WaveformInput, f as WaveformProps, s as scoreFromMusicXML, g as spectrumFactory, w as waveformFactory } from '../waveform-DdSMAbYQ.js';
3
+ import { PromoTheme, RecordOpts, Scene, SafeBox } from '../video.js';
2
4
  import { RenderedNotation, Box, StaffMeasureBox, MeasureColumnBox } from '../promo.js';
3
5
 
4
- interface ScoreNote {
5
- /** MIDI note number (middle C = 60). */
6
- pitchMidi: number;
7
- /** Diatonic letter name: C D E F G A B. */
8
- step: string;
9
- /** Chromatic alteration in semitones: -1 flat, +1 sharp, 0 natural, ±2 double. */
10
- alter: number;
11
- /** Scientific octave (middle C = C4). */
12
- octave: number;
13
- /** Onset on the linear playback clock, in ms (repeats expanded). */
14
- onsetMs: number;
15
- /** Sounding duration in ms (tie chains merged). */
16
- durMs: number;
17
- /** Staff index within the whole sheet (0-based). */
18
- staff: number;
19
- /** Voice id within the part. */
20
- voice: number;
21
- /** Performing hand: grand-staff top -> "R", bottom -> "L" (see hand-inference stub). */
22
- hand: 'L' | 'R';
23
- /** Lyric syllable attached to this note, if any. */
24
- lyric?: string;
25
- /** Fingering digit attached to this note, if any. */
26
- fingering?: number;
27
- }
28
- /**
29
- * Tempo map. v1 is a single constant tempo (one segment at t=0). The piecewise
30
- * shape is the documented seam for rubato / multiple `<sound tempo>` — see the
31
- * tempo-map stub note in scoreFromMusicXML.
32
- */
33
- interface TempoMap {
34
- /** Where the tempo came from: the XML's notated tempo, the fallback, or an override. */
35
- source: 'xml' | 'fallback' | 'override';
36
- /** Piecewise-constant segments, ordered by onset. v1 always has exactly one (at 0). */
37
- segments: Array<{
38
- atMs: number;
39
- bpm: number;
40
- }>;
41
- }
42
- interface Score {
43
- notes: ScoreNote[];
44
- tempoMap: TempoMap;
45
- /** End of the last sounding note, in ms. */
46
- durationMs: number;
47
- key?: string;
48
- timeSig?: string;
49
- title?: string;
50
- composer?: string;
51
- }
52
- interface ScoreFromMusicXMLOpts {
53
- /** bpm to use when the XML has no notated tempo (DefaultStartTempoInBpm === 0). Default 100. */
54
- tempoFallback?: number;
55
- /** Force this bpm regardless of the XML's notated tempo (per-recipe override). */
56
- tempoOverride?: number;
57
- /**
58
- * Provide the OSMD instance. Defaults to a literal `import('opensheetmusicdisplay')`
59
- * + a detached div (works in a browser, or in Node after `setupHeadlessDom()`).
60
- * Inject for tests or non-DOM environments.
61
- */
62
- osmdFactory?: () => any;
63
- }
64
- /**
65
- * Parse MusicXML into a timed, typed Score via OSMD's source model.
66
- *
67
- * @param xml MusicXML document (uncompressed string; unzip .mxl first).
68
- * @param opts tempoFallback / tempoOverride / osmdFactory.
69
- */
70
- declare function scoreFromMusicXML(xml: string, opts?: ScoreFromMusicXMLOpts): Promise<Score>;
71
-
72
- /**
73
- * Audio clock the runner exposes to layers. `nowMs` is the current playback
74
- * position in milliseconds (audio-clock-driven, NOT wall-clock). During an
75
- * offline/deterministic render the runner supplies the frame time directly, so
76
- * `nowMs` and the `tMs` passed to `draw` agree.
77
- */
78
- interface AudioClock {
79
- /** Current playback position in ms. */
80
- nowMs(): number;
81
- }
82
- /**
83
- * Everything a layer needs to draw a frame. Constructed once per render and
84
- * passed to every `init`/`draw`. World coordinates: layers draw in the frame's
85
- * pixel space (0,0 top-left, W×H); the camera primitive (./camera) applies any
86
- * pan/zoom transform to `ctx2d` BEFORE the layer's `draw` runs, so a layer never
87
- * re-derives the viewport.
88
- */
89
- interface RenderCtx {
90
- /** The shared 2D context all layers draw onto. */
91
- ctx2d: CanvasRenderingContext2D;
92
- /** Frame width in px. */
93
- W: number;
94
- /** Frame height in px. */
95
- H: number;
96
- /** The parsed Score (timing/pitch/hands/lyrics). May be undefined for
97
- * non-musical scenes (a pure hook/CTA card). */
98
- score?: Score;
99
- /** Audio playback clock. */
100
- audioClock: AudioClock;
101
- /** Per-app theme tokens (colours/fonts/brand). */
102
- theme: PromoTheme;
103
- /** Phone-safe content rectangle (./video safeBox) — layers anchor to this
104
- * instead of re-deriving insets. */
105
- safeBox: SafeBox;
106
- /** Capture frame rate. */
107
- fps: number;
108
- }
109
- /**
110
- * A composable, time-synced render component.
111
- *
112
- * @typeParam P the layer's prop type (what a SceneSpec passes as `p`).
113
- */
114
- interface Layer<P = unknown> {
115
- /** Stable identity, e.g. "notation" | "falling-notes" | "caption". */
116
- readonly key: string;
117
- /**
118
- * One-time setup: load assets, lay out, rasterize an offscreen bitmap, etc.
119
- * Runs before the first captured frame. May be async (e.g. font/IR load).
120
- */
121
- init(ctx: RenderCtx, props: P): void | Promise<void>;
122
- /**
123
- * Per-frame draw. MUST be cheap and a pure function of `tMs` (no rAF / wall
124
- * clock). `tMs` is the absolute playback time in ms.
125
- */
126
- draw(ctx: RenderCtx, tMs: number): void;
127
- /** Optional teardown (free bitmaps / audio nodes). */
128
- dispose?(): void;
129
- }
130
- /**
131
- * A layer factory keyed by name, with a runtime prop validator so the runner /
132
- * pre-render gate can reject an unknown prop before capture (spec: "unknown
133
- * layer/prop → fail fast"). `validateProps` returns an array of human-readable
134
- * errors ([] = valid).
135
- */
136
- interface LayerFactory<P = unknown> {
137
- key: string;
138
- create(): Layer<P>;
139
- /** Validate a SceneSpec's `p` object. Return [] when valid. */
140
- validateProps(props: unknown): string[];
141
- }
142
-
143
6
  /** One scheduled audio event, in seconds RELATIVE to the schedule start. */
144
7
  interface AudioEvent {
145
8
  /** Offset from schedule start, seconds. */
@@ -1130,36 +993,6 @@ interface PortraitProps extends WindowProps {
1130
993
  }
1131
994
  declare const portraitFactory: LayerFactory<PortraitProps>;
1132
995
 
1133
- /** Host-provided level source attached to RenderCtx by the app/capture harness. */
1134
- interface SpectrumInput {
1135
- /** Normalized 0..1 magnitudes for `bands` bars at time tMs (preferred). */
1136
- levels?(tMs: number, bands: number): Float32Array | number[] | null | undefined;
1137
- /** Raw byte-FFT (0..255), log-binned by the layer (AnalyserNode shape). */
1138
- byteFreq?(tMs: number): Uint8Array | null | undefined;
1139
- }
1140
- /** Augment RenderCtx with the optional spectrum input (declaration merging). */
1141
- declare module '../layer' {
1142
- interface RenderCtx {
1143
- /** Optional audio-reactive level source for the spectrum layer (host-wired). */
1144
- spectrum?: SpectrumInput;
1145
- }
1146
- }
1147
- interface SpectrumProps {
1148
- /** Number of bars. Default 44 (whozart SPEC_BARS). */
1149
- bars?: number;
1150
- /** Vertical center as a fraction of H. Default 0.46 (whozart). */
1151
- centerFrac?: number;
1152
- /** Max half-height as a fraction of H. Default 0.135 (whozart). */
1153
- maxHeightFrac?: number;
1154
- /** Bar fill at low magnitude (wine). Default theme.accent. */
1155
- colorLow?: string;
1156
- /** Bar fill at high magnitude (gold). Default theme.gold. */
1157
- colorHigh?: string;
1158
- /** Per-frame level provider (overrides ctx.spectrum). Pure fn of t for tests. */
1159
- levelsFn?: (tMs: number, bands: number) => Float32Array | number[] | null;
1160
- }
1161
- declare const spectrumFactory: LayerFactory<SpectrumProps>;
1162
-
1163
996
  interface BrandingProps {
1164
997
  /** Wordmark to draw. Default theme.brand. */
1165
998
  logo?: string;
@@ -1837,33 +1670,6 @@ interface CountdownProps {
1837
1670
  }
1838
1671
  declare const countdownFactory: LayerFactory<CountdownProps>;
1839
1672
 
1840
- /** Host-provided time-domain sample source (AnalyserNode shape). */
1841
- interface WaveformInput {
1842
- /** 0..255 samples centered ~128 (AnalyserNode.getByteTimeDomainData). */
1843
- byteTime?(tMs: number): Uint8Array | null | undefined;
1844
- }
1845
- declare module '../layer' {
1846
- interface RenderCtx {
1847
- /** Optional time-domain source for the waveform layer (host-wired). */
1848
- waveform?: WaveformInput;
1849
- }
1850
- }
1851
- interface WaveformProps {
1852
- /** Number of samples plotted. Default 128. */
1853
- samples?: number;
1854
- /** Vertical center as a fraction of H. Default 0.46. */
1855
- centerFrac?: number;
1856
- /** Max deflection as a fraction of H. Default 0.10. */
1857
- amplitudeFrac?: number;
1858
- /** Line width px. Default 5. */
1859
- lineWidth?: number;
1860
- /** Line colour. Default theme.accent. */
1861
- color?: string;
1862
- /** Per-frame sample provider returning -1..1 (overrides ctx.waveform). */
1863
- samplesFn?: (tMs: number, n: number) => Float32Array | number[] | null;
1864
- }
1865
- declare const waveformFactory: LayerFactory<WaveformProps>;
1866
-
1867
1673
  /** Semitone interval sets (from the root) for the supported scales/modes. */
1868
1674
  declare const SCALE_INTERVALS: Record<string, number[]>;
1869
1675
  interface ScaleHighlightProps {
@@ -1990,6 +1796,14 @@ interface EndCardProps {
1990
1796
  declare const endCardFactory: LayerFactory<EndCardProps>;
1991
1797
 
1992
1798
  type ImageRevealMode = 'pixelate' | 'shuffle' | 'blur';
1799
+ /**
1800
+ * Reveal pacing curve applied to linear progress before it drives the effect.
1801
+ * - 'linear' : constant rate (default — unchanged behavior).
1802
+ * - 'easeOut' : fast at first, SLOWER near the end (quadratic).
1803
+ * - 'easeOutStrong' : like easeOut but a more DRAMATIC, drawn-out final reveal (cubic).
1804
+ * - 'easeInOut' : slow start (stays obscured), then a slow settle at the end.
1805
+ */
1806
+ type RevealEasing = 'linear' | 'easeOut' | 'easeOutStrong' | 'easeInOut';
1993
1807
  interface ImageRevealProps {
1994
1808
  /** The image to reveal. Any CanvasImageSource (HTMLImageElement, OffscreenCanvas, …). */
1995
1809
  image: CanvasImageSource;
@@ -2006,7 +1820,9 @@ interface ImageRevealProps {
2006
1820
  * - blur: controls maximum blur radius in px (0→mild, 1→extreme; default 0.5 ≈ 40px).
2007
1821
  */
2008
1822
  difficulty?: number;
1823
+ /** Pacing curve for the reveal. Default 'linear'. See RevealEasing. */
1824
+ easing?: RevealEasing;
2009
1825
  }
2010
1826
  declare const imageRevealFactory: LayerFactory<ImageRevealProps>;
2011
1827
 
2012
- export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatPulseProps, type BeatTick, type BrandingProps, type BufferFactory, type BuildSceneOpts, type BuiltScene, type CameraState, type CaptionCue, type CaptionProps, type CaptionScript, type CaptionStyle, type ChordRibbonProps, type ChordSpan, type CircleOfFifthsProps, type CirclePoint, type ClickTrackOpts, type ColorBy, type ContourPlot, type ContourPoint, type CountInOpts, type CountdownProps, type CountingTrackProps, type CtaProps, DEFAULT_FUNCTION_COLORS, type DegreeLabel, type DegreeLabelsProps, type DroneOpts, type DuckWindow, type Easing, type EndCardProps, type ExtendedDemoOpts, FIFTHS_MAJOR, FIFTHS_MINOR, FOLLOW_BARS, FOLLOW_PAD, type FallingKeyboardDemoOpts, type FallingNotesProps, type FretboardProps, type FunctionColors, type FunctionalHarmonyProps, type GateError, type GateInput, type HandColors, type HarmonicFunction, type HarmonyMode, type HighlightRegion, type HookProps, type ImageRevealMode, type ImageRevealProps, type IntervalArcsProps, type KaraokeCaptionProps, type KaraokeWord, type KeyRect, type KeyboardLayout, type KeyboardLayoutOpts, type KeyboardProps, type KineticMode, type KineticTextProps, 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 ParticleBurstProps, type PitchContourProps, type Placement, type PlayheadLine, type PortraitProps, type ProgressRingProps, type PromoCardsDemoOpts, type PulseStyle, type Quiz, type QuizOption, type QuizPhase, type RadialSpectrumProps, type RayEndpoints, type RecordSceneSpecOpts, type Rect, type RenderCtx, type ResolvedSegment, type RevealProps, SCALE_INTERVALS, type SafeGuidesProps, type ScaleHighlightProps, 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 StatCounterProps, type TempoMap, type TensionGraphProps, type TensionPoint, type TextureProps, type TimeAnchor, type TimelineSegment, type WaveformInput, type WaveformProps, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, audioPlayheadLine, ballArc, ballX, beatGrid, beatPhase, beatPulseFactory, blackKeys, bpmOf, brandingFactory, buildScene, cameraForFollow, cameraTransform, chordRibbonFactory, circleOfFifthsDemoSpec, circleOfFifthsFactory, clamp, clickTrackSchedule, contourMinimapDemoSpec, contourPoints, contourPolyline, countInLeadSec, countInSchedule, countdownFactory, countdownRemaining, countdownSeconds, countingDegreeDemoSpec, countingTrackFactory, cropAroundBox, ctaFactory, cubicEaseInOut, cueOpacity, degreeLabel, degreeLabelsFactory, distinctMeasureIndices, distinctOnsets, dotAt, drawCaption, drawHighlight, droneSchedule, duckGainAt, easeIn, easeInOut, easeOut, endCardFactory, fallingKeyboardDemoScore, fallingKeyboardDemoSpec, fallingNotesFactory, firstMeasureBox, followBoxAt, followSrcBox, followWindowStart, fracSlotPoint, frameRect, fretboardFactory, functionColor, functionalHarmonyFactory, getKeyboardLayout, getLayerFactory, getNotationEngraving, harmonyDemoSpec, highlightIntensity, hookFactory, identityCamera, imageRevealFactory, inRange, intervalArcsFactory, invLerp, isBlackKey, karaokeCaptionFactory, kenBurns, keyCenterX, keyColumnWidth, keyRect, keySlot, keyboardFactory, keyboardLayout, kineticTextFactory, lerp, lerpBox, lerpCamera, linear, mapBoxThroughLayout, mcqCardFactory, mcqDemoSpec, measureColumnsFromLayout, measureCount, measureSpanBox, measureSpans, measureSystemMap, timeToX as minimapTimeToX, msPerBeat, notationFactory, notationLayout, noteColor, noteSetXRange, parseKey, parseTimeSig, particleBurstFactory, pcToSlot, pitchAt, pitchContourFactory, pitchRange, playheadLine, portraitFactory, progress01, progressRingFactory, projectPoint, promoCardsDemoSpec, pulseAt, quizPhase, radialSpectrumFactory, rayEndpoints, rayPointAt, recordSceneSpec, registerLayer, registeredKeys, resolveAnchor, resolveKeyboardLayout, resolveTimeline, revealFactory, revealProgress, runGate, safeGuidesFactory, scaleHighlightFactory, scoreFromMusicXML, scorePitchSpan, scrollCursorFactory, sectionMinimapFactory, setFollowLayoutProvider, setKeyboardLayout, setNotationEngraving, slotAngle, slotPc, slotPoint, spectrumFactory, staffAnchor, staffKeyboardRayFactory, staffRayDemoSpec, statCounterFactory, systemBox, tensionGraphFactory, textureFactory, transitionProgress, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, vstackAudioPlayheadLine, vstackFollowBox, waveformFactory, whiteKeys, worldToViewport };
1828
+ export { type Affine, AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatPulseProps, type BeatTick, type BrandingProps, type BufferFactory, type BuildSceneOpts, type BuiltScene, type CameraState, type CaptionCue, type CaptionProps, type CaptionScript, type CaptionStyle, type ChordRibbonProps, type ChordSpan, type CircleOfFifthsProps, type CirclePoint, type ClickTrackOpts, type ColorBy, type ContourPlot, type ContourPoint, type CountInOpts, type CountdownProps, type CountingTrackProps, type CtaProps, DEFAULT_FUNCTION_COLORS, type DegreeLabel, type DegreeLabelsProps, type DroneOpts, type DuckWindow, type Easing, type EndCardProps, type ExtendedDemoOpts, FIFTHS_MAJOR, FIFTHS_MINOR, FOLLOW_BARS, FOLLOW_PAD, type FallingKeyboardDemoOpts, type FallingNotesProps, type FretboardProps, type FunctionColors, type FunctionalHarmonyProps, type GateError, type GateInput, type HandColors, type HarmonicFunction, type HarmonyMode, type HighlightRegion, type HookProps, type ImageRevealMode, type ImageRevealProps, type IntervalArcsProps, type KaraokeCaptionProps, type KaraokeWord, type KeyRect, type KeyboardLayout, type KeyboardLayoutOpts, type KeyboardProps, type KineticMode, type KineticTextProps, type LabelMode, LayerFactory, type McqCardProps, type NotationEngraving, type NotationLayout, type NotationLayoutOpts, type NotationProps, type NotationRect, type OutputProbe, PIANO_HIGH, PIANO_LOW, type ParticleBurstProps, type PitchContourProps, type Placement, type PlayheadLine, type PortraitProps, type ProgressRingProps, type PromoCardsDemoOpts, type PulseStyle, type Quiz, type QuizOption, type QuizPhase, type RadialSpectrumProps, type RayEndpoints, type RecordSceneSpecOpts, type Rect, RenderCtx, type ResolvedSegment, type RevealEasing, type RevealProps, SCALE_INTERVALS, type SafeGuidesProps, type ScaleHighlightProps, type SceneSpec, type ScheduleTarget, Score, ScoreFromMusicXMLOpts, ScoreNote, type ScrollCursorProps, type Section, type SectionMinimapProps, type SegmentAudio, type SegmentAudioCtx, type SegmentTransition, type SpecLayer, type StaffKeyboardRayProps, type StatCounterProps, TempoMap, type TensionGraphProps, type TensionPoint, type TextureProps, type TimeAnchor, type TimelineSegment, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, audioPlayheadLine, ballArc, ballX, beatGrid, beatPhase, beatPulseFactory, blackKeys, bpmOf, brandingFactory, buildScene, cameraForFollow, cameraTransform, chordRibbonFactory, circleOfFifthsDemoSpec, circleOfFifthsFactory, clamp, clickTrackSchedule, contourMinimapDemoSpec, contourPoints, contourPolyline, countInLeadSec, countInSchedule, countdownFactory, countdownRemaining, countdownSeconds, countingDegreeDemoSpec, countingTrackFactory, cropAroundBox, ctaFactory, cubicEaseInOut, cueOpacity, degreeLabel, degreeLabelsFactory, distinctMeasureIndices, distinctOnsets, dotAt, drawCaption, drawHighlight, droneSchedule, duckGainAt, easeIn, easeInOut, easeOut, endCardFactory, fallingKeyboardDemoScore, fallingKeyboardDemoSpec, fallingNotesFactory, firstMeasureBox, followBoxAt, followSrcBox, followWindowStart, fracSlotPoint, frameRect, fretboardFactory, functionColor, functionalHarmonyFactory, getKeyboardLayout, getLayerFactory, getNotationEngraving, harmonyDemoSpec, highlightIntensity, hookFactory, identityCamera, imageRevealFactory, inRange, intervalArcsFactory, invLerp, isBlackKey, karaokeCaptionFactory, kenBurns, keyCenterX, keyColumnWidth, keyRect, keySlot, keyboardFactory, keyboardLayout, kineticTextFactory, lerp, lerpBox, lerpCamera, linear, mapBoxThroughLayout, mcqCardFactory, mcqDemoSpec, measureColumnsFromLayout, measureCount, measureSpanBox, measureSpans, measureSystemMap, timeToX as minimapTimeToX, msPerBeat, notationFactory, notationLayout, noteColor, noteSetXRange, parseKey, parseTimeSig, particleBurstFactory, pcToSlot, pitchAt, pitchContourFactory, pitchRange, playheadLine, portraitFactory, progress01, progressRingFactory, projectPoint, promoCardsDemoSpec, pulseAt, quizPhase, radialSpectrumFactory, rayEndpoints, rayPointAt, recordSceneSpec, registerLayer, registeredKeys, resolveAnchor, resolveKeyboardLayout, resolveTimeline, revealFactory, revealProgress, runGate, safeGuidesFactory, scaleHighlightFactory, scorePitchSpan, scrollCursorFactory, sectionMinimapFactory, setFollowLayoutProvider, setKeyboardLayout, setNotationEngraving, slotAngle, slotPc, slotPoint, staffAnchor, staffKeyboardRayFactory, staffRayDemoSpec, statCounterFactory, systemBox, tensionGraphFactory, textureFactory, transitionProgress, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, vstackAudioPlayheadLine, vstackFollowBox, whiteKeys, worldToViewport };
@@ -44,6 +44,7 @@ async function scoreFromMusicXML(xml, opts = {}) {
44
44
  while (!it.EndReached && steps++ < MAX_ITERATOR_STEPS) {
45
45
  const enrolled = it.CurrentEnrolledTimestamp?.RealValue ?? 0;
46
46
  const onsetMs = wholeNoteToMs(enrolled);
47
+ const measure = it.CurrentMeasure?.MeasureNumber ?? (it.CurrentMeasureIndex ?? 0) + 1;
47
48
  const voiceEntries = it.CurrentAudibleVoiceEntries?.() ?? [];
48
49
  for (const ve of voiceEntries) {
49
50
  const voiceId = ve.ParentVoice?.VoiceId ?? 1;
@@ -69,7 +70,8 @@ async function scoreFromMusicXML(xml, opts = {}) {
69
70
  durMs: Math.round(wholeNoteToMs(wholeNoteLen)),
70
71
  staff: staffIdx,
71
72
  voice: voiceId,
72
- hand
73
+ hand,
74
+ measure
73
75
  };
74
76
  const lyric = extractLyric(ve);
75
77
  if (lyric != null) note.lyric = lyric;
@@ -4766,11 +4768,18 @@ var endCardFactory = {
4766
4768
  };
4767
4769
 
4768
4770
  // src/scene/layers/imageReveal.ts
4771
+ function applyEasing(p, easing) {
4772
+ if (easing === "easeOut") return 1 - (1 - p) * (1 - p);
4773
+ if (easing === "easeOutStrong") return 1 - (1 - p) * (1 - p) * (1 - p);
4774
+ if (easing === "easeInOut") return p * p * (3 - 2 * p);
4775
+ return p;
4776
+ }
4769
4777
  var DEFAULTS11 = {
4770
4778
  mode: "pixelate",
4771
4779
  startMs: 0,
4772
4780
  durationMs: 6e3,
4773
- difficulty: 0.5
4781
+ difficulty: 0.5,
4782
+ easing: "linear"
4774
4783
  };
4775
4784
  function clamp017(x) {
4776
4785
  return x < 0 ? 0 : x > 1 ? 1 : x;
@@ -4799,6 +4808,7 @@ function imageRevealLayer() {
4799
4808
  let startMs = DEFAULTS11.startMs;
4800
4809
  let durationMs = DEFAULTS11.durationMs;
4801
4810
  let difficulty = DEFAULTS11.difficulty;
4811
+ let easing = DEFAULTS11.easing;
4802
4812
  let image = null;
4803
4813
  let offscreen = null;
4804
4814
  let offCtx = null;
@@ -4832,6 +4842,7 @@ function imageRevealLayer() {
4832
4842
  startMs = props.startMs ?? DEFAULTS11.startMs;
4833
4843
  durationMs = props.durationMs ?? DEFAULTS11.durationMs;
4834
4844
  difficulty = props.difficulty ?? DEFAULTS11.difficulty;
4845
+ easing = props.easing ?? DEFAULTS11.easing;
4835
4846
  image = props.image;
4836
4847
  const src = props.image;
4837
4848
  imgW = src["naturalWidth"] ?? src["displayWidth"] ?? src["width"] ?? 0;
@@ -4839,7 +4850,8 @@ function imageRevealLayer() {
4839
4850
  },
4840
4851
  draw(ctx, tMs) {
4841
4852
  if (!image || imgW === 0 || imgH === 0) return;
4842
- const p = clamp017((tMs - startMs) / (durationMs > 0 ? durationMs : 1));
4853
+ const raw = clamp017((tMs - startMs) / (durationMs > 0 ? durationMs : 1));
4854
+ const p = applyEasing(raw, easing);
4843
4855
  const sb = ctx.safeBox;
4844
4856
  const fit = fitRect(imgW, imgH, sb.left, sb.top, sb.w, sb.h);
4845
4857
  const c = ctx.ctx2d;
@@ -4951,6 +4963,7 @@ function drawShuffle(c, image, fit, p, difficulty, imgW, imgH) {
4951
4963
  }
4952
4964
  }
4953
4965
  var VALID_MODES = ["pixelate", "shuffle", "blur"];
4966
+ var VALID_EASINGS = ["linear", "easeOut", "easeOutStrong", "easeInOut"];
4954
4967
  var imageRevealFactory = {
4955
4968
  key: "image-reveal",
4956
4969
  create: imageRevealLayer,
@@ -4967,6 +4980,8 @@ var imageRevealFactory = {
4967
4980
  errs.push("image-reveal.durationMs must be a positive number");
4968
4981
  if (p["difficulty"] != null && (typeof p["difficulty"] !== "number" || p["difficulty"] < 0 || p["difficulty"] > 1))
4969
4982
  errs.push("image-reveal.difficulty must be a number in [0, 1]");
4983
+ if (p["easing"] != null && !VALID_EASINGS.includes(p["easing"]))
4984
+ errs.push(`image-reveal.easing must be one of: ${VALID_EASINGS.join(", ")}`);
4970
4985
  return errs;
4971
4986
  }
4972
4987
  };