@kieransimkin/dancemoves 3.2.0 → 3.3.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/lib/index.cjs CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  createPlaybackPublisher: () => createPlaybackPublisher,
28
28
  createPlaybackReceiver: () => createPlaybackReceiver,
29
29
  fromWordPressMeta: () => fromWordPressMeta,
30
+ validateBeatAlignment: () => validateBeatAlignment,
30
31
  validatePageConfig: () => validatePageConfig
31
32
  });
32
33
  module.exports = __toCommonJS(index_exports);
@@ -135,6 +136,38 @@ function install(__dmScope) {
135
136
  return ticks;
136
137
  }
137
138
  var bpm = effectiveBpm(rawConfig.bpm);
139
+ var bpmSource = ["explicit", "analysis"].indexOf(rawConfig.bpmSource) >= 0 ? rawConfig.bpmSource : "fallback";
140
+ function beatAlignmentState(audio) {
141
+ var reading = rawConfig.beatAlignment;
142
+ if (!reading)
143
+ return { status: rawConfig.beatAlignmentStatus === "invalid" ? "invalid" : "unconfigured", applied: false };
144
+ var source = playbackAudio(audio);
145
+ var identity2 = source && (source.masterSourceUrl || source.masterId || source.currentSrc || source.src);
146
+ try {
147
+ if (!identity2 || new URL(String(identity2), document2.baseURI).href !== new URL(reading.source_url).href) {
148
+ return { status: "source-mismatch", applied: false };
149
+ }
150
+ } catch (error) {
151
+ return { status: "source-mismatch", applied: false };
152
+ }
153
+ return {
154
+ status: reading.phase_status,
155
+ applied: true,
156
+ firstBeatSeconds: reading.first_beat_seconds,
157
+ offsetPips: reading.offset_pips,
158
+ sourceSha256: reading.source_sha256,
159
+ bpmStatus: reading.bpm_status
160
+ };
161
+ }
162
+ function rhythmicTime(audio) {
163
+ var source = playbackAudio(audio);
164
+ var seconds = source && Number.isFinite(Number(source.currentTime)) ? Number(source.currentTime) : 0;
165
+ var state = beatAlignmentState(source);
166
+ return seconds - (state.applied ? state.firstBeatSeconds : 0);
167
+ }
168
+ function sourceTick(audio, bpmOverride) {
169
+ return rhythmicTime(audio) * effectiveBpm(bpmOverride || bpm) * TICKS_PER_BEAT / 60;
170
+ }
138
171
  function durationMilliseconds(ticks, bpmOverride) {
139
172
  return 3750 / effectiveBpm(bpmOverride || bpm) * quantizeTicks(ticks);
140
173
  }
@@ -142,7 +175,7 @@ function install(__dmScope) {
142
175
  var settings = options || {};
143
176
  var audio = playbackAudio(settings.audio) || (settings.clock === "audio" ? activeAudio : null);
144
177
  if (audio && !audio.paused && !audio.ended && Number.isFinite(Number(audio.currentTime))) {
145
- return Number(audio.currentTime) * effectiveBpm(settings.bpm || bpm) * TICKS_PER_BEAT / 60;
178
+ return sourceTick(audio, settings.bpm);
146
179
  }
147
180
  var now = window2.performance && typeof window2.performance.now === "function" ? window2.performance.now() : Date.now();
148
181
  return Math.max(0, now - pageClockStarted) * effectiveBpm(settings.bpm || bpm) * TICKS_PER_BEAT / 6e4;
@@ -441,7 +474,7 @@ function install(__dmScope) {
441
474
  __dmScope.dataset(document2.documentElement, "danceMovesSharedControls", null);
442
475
  }
443
476
  __dmScope.dataset(document2.documentElement, "danceMovesBpm", String(bpm));
444
- __dmScope.dataset(document2.documentElement, "danceMovesBpmSource", rawConfig.bpmSource === "explicit" ? "explicit" : "fallback");
477
+ __dmScope.dataset(document2.documentElement, "danceMovesBpmSource", bpmSource);
445
478
  __dmScope.dataset(document2.documentElement, "danceMovesVersion", String(rawConfig.version || ""));
446
479
  }
447
480
  function registerAnimationScope(root, selectors) {
@@ -977,7 +1010,7 @@ function install(__dmScope) {
977
1010
  var root = document2.querySelector(".ks-epk");
978
1011
  if (root) {
979
1012
  __dmScope.dataset(root, "danceMovesBpm", String(bpm));
980
- __dmScope.dataset(root, "danceMovesBpmSource", rawConfig.bpmSource === "explicit" ? "explicit" : "fallback");
1013
+ __dmScope.dataset(root, "danceMovesBpmSource", bpmSource);
981
1014
  if (rawConfig.lyricTimingUrl)
982
1015
  __dmScope.dataset(root, "danceMovesLyricTiming", String(rawConfig.lyricTimingUrl));
983
1016
  if (rawConfig.cueTimingUrl)
@@ -1006,11 +1039,14 @@ function install(__dmScope) {
1006
1039
  var api = {
1007
1040
  version: String(rawConfig.version || ""),
1008
1041
  bpm,
1009
- bpmSource: rawConfig.bpmSource === "explicit" ? "explicit" : "fallback",
1042
+ bpmSource,
1010
1043
  ticksPerBeat: TICKS_PER_BEAT,
1011
1044
  quantizeTicks,
1012
1045
  durationMilliseconds,
1013
1046
  currentTick,
1047
+ sourceTick,
1048
+ rhythmicTime,
1049
+ beatAlignmentState,
1014
1050
  nextIntervalTick,
1015
1051
  scheduleAtInterval,
1016
1052
  deferStart,
@@ -1217,7 +1253,8 @@ function install2(__dmScope) {
1217
1253
  function sync() {
1218
1254
  var rate = Math.max(0.01, finite3(audio.playbackRate, 1));
1219
1255
  var duration = motion.durationMilliseconds(ticks) / 1e3 / rate;
1220
- var phase = -(finite3(audio.currentTime, 0) / rate % duration);
1256
+ var rhythmicSeconds = motion.rhythmicTime ? motion.rhythmicTime(audio) : finite3(audio.currentTime, 0);
1257
+ var phase = -((rhythmicSeconds / rate % duration + duration) % duration);
1221
1258
  __dmScope.setStyle(state.root.style, prefix + "-duration", duration.toFixed(6) + "s");
1222
1259
  __dmScope.setStyle(state.root.style, prefix + "-delay", phase.toFixed(6) + "s");
1223
1260
  __dmScope.dataset(state.root, "danceMovesPulse", state.reduced.matches || state.forced.matches ? "inactive" : audio.paused ? "paused" : "playing");
@@ -1509,7 +1546,8 @@ function install2(__dmScope) {
1509
1546
  function calculate(reason, staticFallback) {
1510
1547
  var cycleSeconds = Math.max(1e-3, finite3(motion.durationMilliseconds(cycleTicks), 1) / 1e3);
1511
1548
  var time = Math.max(0, finite3(audio.currentTime, 0));
1512
- var phase = ((time - phaseOffsetSeconds) % cycleSeconds + cycleSeconds) % cycleSeconds / cycleSeconds;
1549
+ var rhythmicSeconds = motion.rhythmicTime ? motion.rhythmicTime(audio) : time;
1550
+ var phase = ((rhythmicSeconds - phaseOffsetSeconds) % cycleSeconds + cycleSeconds) % cycleSeconds / cycleSeconds;
1513
1551
  var localFrame = staticFallback ? staticFrame : Math.min(frameCount - 1, Math.floor(phase * frameCount + 1e-9));
1514
1552
  var mapped = mappedFrame(localFrame);
1515
1553
  return {
@@ -4928,8 +4966,9 @@ function install9(__dmScope) {
4928
4966
  anchor = 0;
4929
4967
  lastClock = mode;
4930
4968
  const tempo = bpm();
4931
- const beats = useAudio ? Math.max(0, Number.isFinite(audio.currentTime) ? audio.currentTime : 0) * tempo / 60 : motion.currentTick({ clock: "page" }) / 16;
4932
- return { beats, mode, tempo };
4969
+ const sourceBeats = useAudio ? Math.max(0, Number.isFinite(audio.currentTime) ? audio.currentTime : 0) * tempo / 60 : motion.currentTick({ clock: "page" }) / 16;
4970
+ const beats = useAudio && motion.sourceTick ? motion.sourceTick(audio) / 16 : sourceBeats;
4971
+ return { beats, sourceBeats, mode, tempo };
4933
4972
  }
4934
4973
  function blocked() {
4935
4974
  if (!enabled)
@@ -4967,7 +5006,8 @@ function install9(__dmScope) {
4967
5006
  audio: s.mode === "audio" ? audio : null,
4968
5007
  rudiment: row.name,
4969
5008
  clock: s.mode,
4970
- sourceBeats: s.beats,
5009
+ sourceBeats: s.sourceBeats,
5010
+ rhythmicBeats: s.beats,
4971
5011
  bpm: s.tempo,
4972
5012
  rate,
4973
5013
  pip: neutral ? null : pip,
@@ -5513,6 +5553,35 @@ function createScope(root, config, { legacyGlobals = false, nonce, orientation =
5513
5553
  return api;
5514
5554
  }
5515
5555
 
5556
+ // .build/modules/beat-alignment.mjs
5557
+ var hash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
5558
+ function rational(value, label) {
5559
+ if (typeof value !== "string" || !/^-?\d{1,80}(?:\/[1-9]\d{0,79})?$/.test(value)) throw new TypeError(`Invalid exact ${label}`);
5560
+ const [n, d = "1"] = value.split("/");
5561
+ return [BigInt(n), BigInt(d)];
5562
+ }
5563
+ function numeric(value, label, min, max) {
5564
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw new RangeError(`Invalid ${label}`);
5565
+ return value;
5566
+ }
5567
+ function validateBeatAlignment(input) {
5568
+ if (input === void 0 || input === null || input === "") return null;
5569
+ if (!input || typeof input !== "object" || Array.isArray(input) || input.schema !== "stemlab.beat-alignment.v1" || input.pips_per_beat !== 64 || input.canonical_first_beat_pip !== 0 || !hash(input.source_sha256) || !hash(input.evidence_sha256) || !hash(input.report_sha256)) throw new TypeError("Invalid source-bound StemLab beat alignment");
5570
+ const bpm = numeric(input.bpm, "alignment BPM", 20, 400);
5571
+ const first = numeric(input.first_beat_seconds, "first beat", 0, 86400);
5572
+ const offset = numeric(input.offset_pips, "pip offset", -36864e3, 0);
5573
+ const [bn, bd] = rational(input.exact_bpm, "BPM"), [fn, fd] = rational(input.first_beat_exact_seconds, "first beat"), [pn, pd] = rational(input.exact_offset_pips, "pip offset");
5574
+ if (fn * bn * 64n * pd + pn * fd * bd * 60n !== 0n || bn <= 0n || fn < 0n || Math.abs(Number(bn) / Number(bd) - bpm) > 1e-9 || Math.abs(Number(fn) / Number(fd) - first) > 1e-9 || Math.abs(Number(pn) / Number(pd) - offset) > 1e-7) throw new RangeError("Exact beat alignment and numeric clock disagree");
5575
+ if (!["canonical", "estimated"].includes(input.bpm_status) || !["estimated", "confirmed", "synthetic"].includes(input.phase_status)) throw new TypeError("Alignment authority is required");
5576
+ for (const key of ["analysis_id", "bpm_basis"]) if (typeof input[key] !== "string" || !/^[a-zA-Z0-9_.-]{1,128}$/.test(input[key])) throw new TypeError(`Invalid ${key}`);
5577
+ if (input.source_audio_verified !== true || !input.source_audio || !Number.isSafeInteger(input.source_audio.num_frames) || input.source_audio.num_frames < 1 || !Number.isSafeInteger(input.source_audio.sample_rate) || input.source_audio.sample_rate < 1 || first >= input.source_audio.num_frames / input.source_audio.sample_rate) throw new TypeError("Verified source audio frame bounds are required");
5578
+ const sourceUrl = input.source_url;
5579
+ if (typeof sourceUrl !== "string" || sourceUrl.length > 4096 || /[\u0000-\u001f]/.test(sourceUrl)) throw new TypeError("An exact verified master URL is required");
5580
+ const parsed = new URL(sourceUrl);
5581
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) throw new TypeError("Invalid verified master URL");
5582
+ return Object.freeze({ ...input, bpm, first_beat_seconds: first, offset_pips: offset, source_audio: Object.freeze({ ...input.source_audio }) });
5583
+ }
5584
+
5516
5585
  // .build/modules/config.mjs
5517
5586
  var PAGE_META_KEYS = Object.freeze({
5518
5587
  bpm: "_dance_moves_bpm",
@@ -5520,7 +5589,9 @@ var PAGE_META_KEYS = Object.freeze({
5520
5589
  cue: "_dance_moves_cue_timing_id",
5521
5590
  popups: "_dance_moves_lyric_popups_enabled",
5522
5591
  duration: "_dance_moves_master_duration_ms",
5523
- effect: "_dance_moves_effect"
5592
+ effect: "_dance_moves_effect",
5593
+ alignment: "_dance_moves_beat_alignment",
5594
+ alignmentAudio: "_dance_moves_beat_alignment_audio_id"
5524
5595
  });
5525
5596
  var object = (value) => value && typeof value === "object" && !Array.isArray(value);
5526
5597
  function finite(value, name, min, max) {
@@ -5539,7 +5610,11 @@ function url(value, name) {
5539
5610
  function validatePageConfig(input = {}) {
5540
5611
  if (!object(input)) throw new TypeError("Page configuration must be an object");
5541
5612
  const explicit = input.bpm !== void 0 && input.bpm !== null && input.bpm !== "";
5542
- const bpm = explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
5613
+ const beatAlignment = validateBeatAlignment(input.beatAlignment);
5614
+ const canonicalExplicit = explicit && !["fallback", "analysis"].includes(input.bpmSource);
5615
+ if (canonicalExplicit && beatAlignment && Math.abs(input.bpm - beatAlignment.bpm) > 500001e-9) throw new RangeError("Stored BPM and selected beat alignment disagree; select a matching BPM basis");
5616
+ const bpm = beatAlignment ? beatAlignment.bpm : explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
5617
+ if (explicit) finite(input.bpm, "bpm", 20, 400);
5543
5618
  const effect = input.effect ?? "";
5544
5619
  if (!["", "paper-planes"].includes(effect)) throw new RangeError("effect must be empty or paper-planes");
5545
5620
  if (input.lyricPopupsEnabled !== void 0 && typeof input.lyricPopupsEnabled !== "boolean") throw new TypeError("lyricPopupsEnabled must be boolean");
@@ -5551,8 +5626,9 @@ function validatePageConfig(input = {}) {
5551
5626
  return Object.freeze({
5552
5627
  pageId,
5553
5628
  bpm,
5554
- bpmSource: explicit && input.bpmSource !== "fallback" ? "explicit" : "fallback",
5629
+ bpmSource: canonicalExplicit ? "explicit" : beatAlignment ? "analysis" : "fallback",
5555
5630
  fallbackBpm: 120,
5631
+ beatAlignment,
5556
5632
  ticksPerBeat: 16,
5557
5633
  tickDefinition: "sixteenth-of-beat",
5558
5634
  longDurationQuantumTicks: 16,
@@ -5568,9 +5644,9 @@ function validatePageConfig(input = {}) {
5568
5644
  }
5569
5645
  async function fromWordPressMeta(meta, resolveAttachment) {
5570
5646
  if (!object(meta)) throw new TypeError("meta must be an object");
5571
- const numeric = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
5647
+ const numeric2 = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
5572
5648
  const b = meta[PAGE_META_KEYS.bpm];
5573
- const bpm = numeric(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
5649
+ const bpm = numeric2(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
5574
5650
  const resolve = async (key) => {
5575
5651
  const value = Number(meta[key] || 0);
5576
5652
  if (!Number.isSafeInteger(value)) throw new RangeError("Attachment ID must be a safe integer");
@@ -5580,8 +5656,15 @@ async function fromWordPressMeta(meta, resolveAttachment) {
5580
5656
  return await resolveAttachment(id) || "";
5581
5657
  };
5582
5658
  const duration = Number(meta[PAGE_META_KEYS.duration]);
5659
+ let beatAlignment = meta[PAGE_META_KEYS.alignment] || null;
5660
+ if (typeof beatAlignment === "string") {
5661
+ if (beatAlignment.length > 16384) throw new RangeError("Beat alignment exceeds 16 KiB");
5662
+ beatAlignment = JSON.parse(beatAlignment);
5663
+ }
5664
+ if (beatAlignment) beatAlignment = { ...beatAlignment, source_url: await resolve(PAGE_META_KEYS.alignmentAudio) };
5583
5665
  return validatePageConfig({
5584
5666
  bpm,
5667
+ beatAlignment,
5585
5668
  lyricTimingUrl: await resolve(PAGE_META_KEYS.lyric),
5586
5669
  cueTimingUrl: await resolve(PAGE_META_KEYS.cue),
5587
5670
  lyricPopupsEnabled: meta[PAGE_META_KEYS.popups] === true || String(meta[PAGE_META_KEYS.popups]) === "1",
@@ -6122,7 +6205,8 @@ function installPlayback(scope, api, host = scope.document.defaultView) {
6122
6205
  error: { get: () => read().error || null },
6123
6206
  element: { value: isNative ? master : null },
6124
6207
  activePlayer: { get: () => delegate ? delegate.activePlayer : active.element },
6125
- masterId: { get: () => delegate ? delegate.masterId : master.currentSrc || master.src || master.id }
6208
+ masterId: { get: () => delegate ? delegate.masterId : master.currentSrc || master.src || master.id },
6209
+ masterSourceUrl: { get: () => delegate ? delegate.masterSourceUrl : reference?.sourceUrl || master.currentSrc || master.src || "" }
6126
6210
  });
6127
6211
  transport.matches = (selector) => target.matches(selector);
6128
6212
  transport.getAttribute = (name) => target.getAttribute(name);
@@ -7038,7 +7122,7 @@ function installChildPlayback(scope, api, host) {
7038
7122
  }
7039
7123
 
7040
7124
  // .build/modules/version.mjs
7041
- var VERSION = "3.2.0";
7125
+ var VERSION = "3.3.0";
7042
7126
 
7043
7127
  // .build/modules/index.mjs
7044
7128
  var mounted = /* @__PURE__ */ new WeakMap();
package/lib/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export * from './rudiments';
2
2
  export type Remove = () => void;
3
3
  export type PlaybackMedia = HTMLMediaElement | PlaybackSource;
4
4
  export interface PlaybackMapping {offset?:number;duration?:number;playerStart?:number}
5
- export type MasterReference = HTMLMediaElement | Readonly<{id:string;duration:number;element?:HTMLMediaElement}>;
5
+ export type MasterReference = HTMLMediaElement | Readonly<{id:string;duration:number;element?:HTMLMediaElement;sourceUrl?:string}>;
6
6
  export interface PlaybackSource extends EventTarget {
7
7
  readonly nodeType:1;readonly tagName:string;readonly element:HTMLMediaElement|null;readonly activePlayer:HTMLMediaElement|HTMLIFrameElement|null;
8
8
  currentTime:number;readonly duration:number;readonly paused:boolean;readonly ended:boolean;readonly seeking:boolean;readonly playbackRate:number;
@@ -26,8 +26,15 @@ export function createPlaybackPublisher(options:ChildPlaybackPublisherOptions):C
26
26
  export function createPlaybackReceiver(options:ChildPlaybackReceiverOptions):ChildPlaybackReceiver;
27
27
  export type Offset = Readonly<{x:number;y:number;z?:number}>;
28
28
  export type Metadata = string | {id?:string;handlerId?:string};
29
+ export interface BeatAlignment {
30
+ schema:'stemlab.beat-alignment.v1';source_sha256:string;source_url:string;source_audio_verified:true;
31
+ source_audio:Readonly<{sample_rate:number;num_frames:number;num_channels?:number;duration_seconds?:number}>;
32
+ bpm:number;exact_bpm:string;bpm_basis:string;bpm_status:'canonical'|'estimated';phase_status:'estimated'|'confirmed'|'synthetic';
33
+ analysis_id:string;evidence_sha256:string;report_sha256:string;first_beat_seconds:number;first_beat_exact_seconds:string;
34
+ offset_pips:number;exact_offset_pips:string;pips_per_beat:64;canonical_first_beat_pip:0;
35
+ }
29
36
  export interface PageConfig {
30
- pageId?:number;bpm?:number|null;bpmSource?:'explicit'|'fallback';lyricTimingUrl?:string;cueTimingUrl?:string;
37
+ pageId?:number;bpm?:number|null;bpmSource?:'explicit'|'fallback'|'analysis';beatAlignment?:BeatAlignment|null;lyricTimingUrl?:string;cueTimingUrl?:string;
31
38
  lyricPopupsEnabled?:boolean;masterDurationMilliseconds?:number;sharedControlTicks?:number;lyricDisclosureTicks?:number;
32
39
  effect?:''|'paper-planes';diagnostics?:boolean;
33
40
  }
@@ -77,6 +84,8 @@ export interface DanceMoves {
77
84
  readonly version:string;readonly bpm:number;readonly bpmSource:string;readonly ticksPerBeat:16;readonly root:HTMLElement;readonly config:Readonly<PageConfig>;
78
85
  readonly destroyed:boolean;ready:Promise<DanceMoves>;
79
86
  quantizeTicks(value:number):number;durationMilliseconds(ticks:number,bpmOverride?:number):number;currentTick(options?:Clock):number;
87
+ sourceTick(audio:PlaybackMedia,bpmOverride?:number):number;rhythmicTime(audio:PlaybackMedia):number;
88
+ beatAlignmentState(audio:PlaybackMedia):Readonly<{status:string;applied:boolean;firstBeatSeconds?:number;offsetPips?:number;sourceSha256?:string;bpmStatus?:string}>;
80
89
  nextIntervalTick(interval:number,fromTick:number,strictlyFuture?:boolean):number;
81
90
  scheduleAtInterval(interval:number,callback:(detail:IntervalDetail)=>void,options?:Clock):Remove;
82
91
  deferStart(target:HTMLElement,interval:number,options?:Clock & {start?:(detail:IntervalDetail)=>void}):Remove;
@@ -104,7 +113,8 @@ export interface DanceMoves {
104
113
  }
105
114
  export const VERSION:string;
106
115
  export const ORIENTATION_ADAPTERS:readonly OrientationAdapter[];
107
- export const PAGE_META_KEYS:Readonly<{bpm:string;lyric:string;cue:string;popups:string;duration:string;effect:string}>;
116
+ export const PAGE_META_KEYS:Readonly<{bpm:string;lyric:string;cue:string;popups:string;duration:string;effect:string;alignment:string;alignmentAudio:string}>;
117
+ export function validateBeatAlignment(input?:BeatAlignment|null):Readonly<BeatAlignment>|null;
108
118
  export function createDanceMoves(options:Options):DanceMoves;
109
119
  export function validatePageConfig(input?:PageConfig):Readonly<PageConfig>;
110
120
  export function fromWordPressMeta(meta:Record<string,unknown>,resolveAttachment?:(id:number)=>string|Promise<string>):Promise<Readonly<PageConfig>>;
package/lib/index.mjs CHANGED
@@ -102,6 +102,38 @@ function install(__dmScope) {
102
102
  return ticks;
103
103
  }
104
104
  var bpm = effectiveBpm(rawConfig.bpm);
105
+ var bpmSource = ["explicit", "analysis"].indexOf(rawConfig.bpmSource) >= 0 ? rawConfig.bpmSource : "fallback";
106
+ function beatAlignmentState(audio) {
107
+ var reading = rawConfig.beatAlignment;
108
+ if (!reading)
109
+ return { status: rawConfig.beatAlignmentStatus === "invalid" ? "invalid" : "unconfigured", applied: false };
110
+ var source = playbackAudio(audio);
111
+ var identity2 = source && (source.masterSourceUrl || source.masterId || source.currentSrc || source.src);
112
+ try {
113
+ if (!identity2 || new URL(String(identity2), document2.baseURI).href !== new URL(reading.source_url).href) {
114
+ return { status: "source-mismatch", applied: false };
115
+ }
116
+ } catch (error) {
117
+ return { status: "source-mismatch", applied: false };
118
+ }
119
+ return {
120
+ status: reading.phase_status,
121
+ applied: true,
122
+ firstBeatSeconds: reading.first_beat_seconds,
123
+ offsetPips: reading.offset_pips,
124
+ sourceSha256: reading.source_sha256,
125
+ bpmStatus: reading.bpm_status
126
+ };
127
+ }
128
+ function rhythmicTime(audio) {
129
+ var source = playbackAudio(audio);
130
+ var seconds = source && Number.isFinite(Number(source.currentTime)) ? Number(source.currentTime) : 0;
131
+ var state = beatAlignmentState(source);
132
+ return seconds - (state.applied ? state.firstBeatSeconds : 0);
133
+ }
134
+ function sourceTick(audio, bpmOverride) {
135
+ return rhythmicTime(audio) * effectiveBpm(bpmOverride || bpm) * TICKS_PER_BEAT / 60;
136
+ }
105
137
  function durationMilliseconds(ticks, bpmOverride) {
106
138
  return 3750 / effectiveBpm(bpmOverride || bpm) * quantizeTicks(ticks);
107
139
  }
@@ -109,7 +141,7 @@ function install(__dmScope) {
109
141
  var settings = options || {};
110
142
  var audio = playbackAudio(settings.audio) || (settings.clock === "audio" ? activeAudio : null);
111
143
  if (audio && !audio.paused && !audio.ended && Number.isFinite(Number(audio.currentTime))) {
112
- return Number(audio.currentTime) * effectiveBpm(settings.bpm || bpm) * TICKS_PER_BEAT / 60;
144
+ return sourceTick(audio, settings.bpm);
113
145
  }
114
146
  var now = window2.performance && typeof window2.performance.now === "function" ? window2.performance.now() : Date.now();
115
147
  return Math.max(0, now - pageClockStarted) * effectiveBpm(settings.bpm || bpm) * TICKS_PER_BEAT / 6e4;
@@ -408,7 +440,7 @@ function install(__dmScope) {
408
440
  __dmScope.dataset(document2.documentElement, "danceMovesSharedControls", null);
409
441
  }
410
442
  __dmScope.dataset(document2.documentElement, "danceMovesBpm", String(bpm));
411
- __dmScope.dataset(document2.documentElement, "danceMovesBpmSource", rawConfig.bpmSource === "explicit" ? "explicit" : "fallback");
443
+ __dmScope.dataset(document2.documentElement, "danceMovesBpmSource", bpmSource);
412
444
  __dmScope.dataset(document2.documentElement, "danceMovesVersion", String(rawConfig.version || ""));
413
445
  }
414
446
  function registerAnimationScope(root, selectors) {
@@ -944,7 +976,7 @@ function install(__dmScope) {
944
976
  var root = document2.querySelector(".ks-epk");
945
977
  if (root) {
946
978
  __dmScope.dataset(root, "danceMovesBpm", String(bpm));
947
- __dmScope.dataset(root, "danceMovesBpmSource", rawConfig.bpmSource === "explicit" ? "explicit" : "fallback");
979
+ __dmScope.dataset(root, "danceMovesBpmSource", bpmSource);
948
980
  if (rawConfig.lyricTimingUrl)
949
981
  __dmScope.dataset(root, "danceMovesLyricTiming", String(rawConfig.lyricTimingUrl));
950
982
  if (rawConfig.cueTimingUrl)
@@ -973,11 +1005,14 @@ function install(__dmScope) {
973
1005
  var api = {
974
1006
  version: String(rawConfig.version || ""),
975
1007
  bpm,
976
- bpmSource: rawConfig.bpmSource === "explicit" ? "explicit" : "fallback",
1008
+ bpmSource,
977
1009
  ticksPerBeat: TICKS_PER_BEAT,
978
1010
  quantizeTicks,
979
1011
  durationMilliseconds,
980
1012
  currentTick,
1013
+ sourceTick,
1014
+ rhythmicTime,
1015
+ beatAlignmentState,
981
1016
  nextIntervalTick,
982
1017
  scheduleAtInterval,
983
1018
  deferStart,
@@ -1184,7 +1219,8 @@ function install2(__dmScope) {
1184
1219
  function sync() {
1185
1220
  var rate = Math.max(0.01, finite3(audio.playbackRate, 1));
1186
1221
  var duration = motion.durationMilliseconds(ticks) / 1e3 / rate;
1187
- var phase = -(finite3(audio.currentTime, 0) / rate % duration);
1222
+ var rhythmicSeconds = motion.rhythmicTime ? motion.rhythmicTime(audio) : finite3(audio.currentTime, 0);
1223
+ var phase = -((rhythmicSeconds / rate % duration + duration) % duration);
1188
1224
  __dmScope.setStyle(state.root.style, prefix + "-duration", duration.toFixed(6) + "s");
1189
1225
  __dmScope.setStyle(state.root.style, prefix + "-delay", phase.toFixed(6) + "s");
1190
1226
  __dmScope.dataset(state.root, "danceMovesPulse", state.reduced.matches || state.forced.matches ? "inactive" : audio.paused ? "paused" : "playing");
@@ -1476,7 +1512,8 @@ function install2(__dmScope) {
1476
1512
  function calculate(reason, staticFallback) {
1477
1513
  var cycleSeconds = Math.max(1e-3, finite3(motion.durationMilliseconds(cycleTicks), 1) / 1e3);
1478
1514
  var time = Math.max(0, finite3(audio.currentTime, 0));
1479
- var phase = ((time - phaseOffsetSeconds) % cycleSeconds + cycleSeconds) % cycleSeconds / cycleSeconds;
1515
+ var rhythmicSeconds = motion.rhythmicTime ? motion.rhythmicTime(audio) : time;
1516
+ var phase = ((rhythmicSeconds - phaseOffsetSeconds) % cycleSeconds + cycleSeconds) % cycleSeconds / cycleSeconds;
1480
1517
  var localFrame = staticFallback ? staticFrame : Math.min(frameCount - 1, Math.floor(phase * frameCount + 1e-9));
1481
1518
  var mapped = mappedFrame(localFrame);
1482
1519
  return {
@@ -4895,8 +4932,9 @@ function install9(__dmScope) {
4895
4932
  anchor = 0;
4896
4933
  lastClock = mode;
4897
4934
  const tempo = bpm();
4898
- const beats = useAudio ? Math.max(0, Number.isFinite(audio.currentTime) ? audio.currentTime : 0) * tempo / 60 : motion.currentTick({ clock: "page" }) / 16;
4899
- return { beats, mode, tempo };
4935
+ const sourceBeats = useAudio ? Math.max(0, Number.isFinite(audio.currentTime) ? audio.currentTime : 0) * tempo / 60 : motion.currentTick({ clock: "page" }) / 16;
4936
+ const beats = useAudio && motion.sourceTick ? motion.sourceTick(audio) / 16 : sourceBeats;
4937
+ return { beats, sourceBeats, mode, tempo };
4900
4938
  }
4901
4939
  function blocked() {
4902
4940
  if (!enabled)
@@ -4934,7 +4972,8 @@ function install9(__dmScope) {
4934
4972
  audio: s.mode === "audio" ? audio : null,
4935
4973
  rudiment: row.name,
4936
4974
  clock: s.mode,
4937
- sourceBeats: s.beats,
4975
+ sourceBeats: s.sourceBeats,
4976
+ rhythmicBeats: s.beats,
4938
4977
  bpm: s.tempo,
4939
4978
  rate,
4940
4979
  pip: neutral ? null : pip,
@@ -5480,6 +5519,35 @@ function createScope(root, config, { legacyGlobals = false, nonce, orientation =
5480
5519
  return api;
5481
5520
  }
5482
5521
 
5522
+ // .build/modules/beat-alignment.mjs
5523
+ var hash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
5524
+ function rational(value, label) {
5525
+ if (typeof value !== "string" || !/^-?\d{1,80}(?:\/[1-9]\d{0,79})?$/.test(value)) throw new TypeError(`Invalid exact ${label}`);
5526
+ const [n, d = "1"] = value.split("/");
5527
+ return [BigInt(n), BigInt(d)];
5528
+ }
5529
+ function numeric(value, label, min, max) {
5530
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) throw new RangeError(`Invalid ${label}`);
5531
+ return value;
5532
+ }
5533
+ function validateBeatAlignment(input) {
5534
+ if (input === void 0 || input === null || input === "") return null;
5535
+ if (!input || typeof input !== "object" || Array.isArray(input) || input.schema !== "stemlab.beat-alignment.v1" || input.pips_per_beat !== 64 || input.canonical_first_beat_pip !== 0 || !hash(input.source_sha256) || !hash(input.evidence_sha256) || !hash(input.report_sha256)) throw new TypeError("Invalid source-bound StemLab beat alignment");
5536
+ const bpm = numeric(input.bpm, "alignment BPM", 20, 400);
5537
+ const first = numeric(input.first_beat_seconds, "first beat", 0, 86400);
5538
+ const offset = numeric(input.offset_pips, "pip offset", -36864e3, 0);
5539
+ const [bn, bd] = rational(input.exact_bpm, "BPM"), [fn, fd] = rational(input.first_beat_exact_seconds, "first beat"), [pn, pd] = rational(input.exact_offset_pips, "pip offset");
5540
+ if (fn * bn * 64n * pd + pn * fd * bd * 60n !== 0n || bn <= 0n || fn < 0n || Math.abs(Number(bn) / Number(bd) - bpm) > 1e-9 || Math.abs(Number(fn) / Number(fd) - first) > 1e-9 || Math.abs(Number(pn) / Number(pd) - offset) > 1e-7) throw new RangeError("Exact beat alignment and numeric clock disagree");
5541
+ if (!["canonical", "estimated"].includes(input.bpm_status) || !["estimated", "confirmed", "synthetic"].includes(input.phase_status)) throw new TypeError("Alignment authority is required");
5542
+ for (const key of ["analysis_id", "bpm_basis"]) if (typeof input[key] !== "string" || !/^[a-zA-Z0-9_.-]{1,128}$/.test(input[key])) throw new TypeError(`Invalid ${key}`);
5543
+ if (input.source_audio_verified !== true || !input.source_audio || !Number.isSafeInteger(input.source_audio.num_frames) || input.source_audio.num_frames < 1 || !Number.isSafeInteger(input.source_audio.sample_rate) || input.source_audio.sample_rate < 1 || first >= input.source_audio.num_frames / input.source_audio.sample_rate) throw new TypeError("Verified source audio frame bounds are required");
5544
+ const sourceUrl = input.source_url;
5545
+ if (typeof sourceUrl !== "string" || sourceUrl.length > 4096 || /[\u0000-\u001f]/.test(sourceUrl)) throw new TypeError("An exact verified master URL is required");
5546
+ const parsed = new URL(sourceUrl);
5547
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) throw new TypeError("Invalid verified master URL");
5548
+ return Object.freeze({ ...input, bpm, first_beat_seconds: first, offset_pips: offset, source_audio: Object.freeze({ ...input.source_audio }) });
5549
+ }
5550
+
5483
5551
  // .build/modules/config.mjs
5484
5552
  var PAGE_META_KEYS = Object.freeze({
5485
5553
  bpm: "_dance_moves_bpm",
@@ -5487,7 +5555,9 @@ var PAGE_META_KEYS = Object.freeze({
5487
5555
  cue: "_dance_moves_cue_timing_id",
5488
5556
  popups: "_dance_moves_lyric_popups_enabled",
5489
5557
  duration: "_dance_moves_master_duration_ms",
5490
- effect: "_dance_moves_effect"
5558
+ effect: "_dance_moves_effect",
5559
+ alignment: "_dance_moves_beat_alignment",
5560
+ alignmentAudio: "_dance_moves_beat_alignment_audio_id"
5491
5561
  });
5492
5562
  var object = (value) => value && typeof value === "object" && !Array.isArray(value);
5493
5563
  function finite(value, name, min, max) {
@@ -5506,7 +5576,11 @@ function url(value, name) {
5506
5576
  function validatePageConfig(input = {}) {
5507
5577
  if (!object(input)) throw new TypeError("Page configuration must be an object");
5508
5578
  const explicit = input.bpm !== void 0 && input.bpm !== null && input.bpm !== "";
5509
- const bpm = explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
5579
+ const beatAlignment = validateBeatAlignment(input.beatAlignment);
5580
+ const canonicalExplicit = explicit && !["fallback", "analysis"].includes(input.bpmSource);
5581
+ if (canonicalExplicit && beatAlignment && Math.abs(input.bpm - beatAlignment.bpm) > 500001e-9) throw new RangeError("Stored BPM and selected beat alignment disagree; select a matching BPM basis");
5582
+ const bpm = beatAlignment ? beatAlignment.bpm : explicit ? Math.round(finite(input.bpm, "bpm", 20, 400) * 1e3) / 1e3 : 120;
5583
+ if (explicit) finite(input.bpm, "bpm", 20, 400);
5510
5584
  const effect = input.effect ?? "";
5511
5585
  if (!["", "paper-planes"].includes(effect)) throw new RangeError("effect must be empty or paper-planes");
5512
5586
  if (input.lyricPopupsEnabled !== void 0 && typeof input.lyricPopupsEnabled !== "boolean") throw new TypeError("lyricPopupsEnabled must be boolean");
@@ -5518,8 +5592,9 @@ function validatePageConfig(input = {}) {
5518
5592
  return Object.freeze({
5519
5593
  pageId,
5520
5594
  bpm,
5521
- bpmSource: explicit && input.bpmSource !== "fallback" ? "explicit" : "fallback",
5595
+ bpmSource: canonicalExplicit ? "explicit" : beatAlignment ? "analysis" : "fallback",
5522
5596
  fallbackBpm: 120,
5597
+ beatAlignment,
5523
5598
  ticksPerBeat: 16,
5524
5599
  tickDefinition: "sixteenth-of-beat",
5525
5600
  longDurationQuantumTicks: 16,
@@ -5535,9 +5610,9 @@ function validatePageConfig(input = {}) {
5535
5610
  }
5536
5611
  async function fromWordPressMeta(meta, resolveAttachment) {
5537
5612
  if (!object(meta)) throw new TypeError("meta must be an object");
5538
- const numeric = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
5613
+ const numeric2 = (value) => value !== "" && value !== null && value !== void 0 && Number.isFinite(Number(value));
5539
5614
  const b = meta[PAGE_META_KEYS.bpm];
5540
- const bpm = numeric(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
5615
+ const bpm = numeric2(b) && Number(b) >= 20 && Number(b) <= 400 ? Number(b) : void 0;
5541
5616
  const resolve = async (key) => {
5542
5617
  const value = Number(meta[key] || 0);
5543
5618
  if (!Number.isSafeInteger(value)) throw new RangeError("Attachment ID must be a safe integer");
@@ -5547,8 +5622,15 @@ async function fromWordPressMeta(meta, resolveAttachment) {
5547
5622
  return await resolveAttachment(id) || "";
5548
5623
  };
5549
5624
  const duration = Number(meta[PAGE_META_KEYS.duration]);
5625
+ let beatAlignment = meta[PAGE_META_KEYS.alignment] || null;
5626
+ if (typeof beatAlignment === "string") {
5627
+ if (beatAlignment.length > 16384) throw new RangeError("Beat alignment exceeds 16 KiB");
5628
+ beatAlignment = JSON.parse(beatAlignment);
5629
+ }
5630
+ if (beatAlignment) beatAlignment = { ...beatAlignment, source_url: await resolve(PAGE_META_KEYS.alignmentAudio) };
5550
5631
  return validatePageConfig({
5551
5632
  bpm,
5633
+ beatAlignment,
5552
5634
  lyricTimingUrl: await resolve(PAGE_META_KEYS.lyric),
5553
5635
  cueTimingUrl: await resolve(PAGE_META_KEYS.cue),
5554
5636
  lyricPopupsEnabled: meta[PAGE_META_KEYS.popups] === true || String(meta[PAGE_META_KEYS.popups]) === "1",
@@ -6089,7 +6171,8 @@ function installPlayback(scope, api, host = scope.document.defaultView) {
6089
6171
  error: { get: () => read().error || null },
6090
6172
  element: { value: isNative ? master : null },
6091
6173
  activePlayer: { get: () => delegate ? delegate.activePlayer : active.element },
6092
- masterId: { get: () => delegate ? delegate.masterId : master.currentSrc || master.src || master.id }
6174
+ masterId: { get: () => delegate ? delegate.masterId : master.currentSrc || master.src || master.id },
6175
+ masterSourceUrl: { get: () => delegate ? delegate.masterSourceUrl : reference?.sourceUrl || master.currentSrc || master.src || "" }
6093
6176
  });
6094
6177
  transport.matches = (selector) => target.matches(selector);
6095
6178
  transport.getAttribute = (name) => target.getAttribute(name);
@@ -7005,7 +7088,7 @@ function installChildPlayback(scope, api, host) {
7005
7088
  }
7006
7089
 
7007
7090
  // .build/modules/version.mjs
7008
- var VERSION = "3.2.0";
7091
+ var VERSION = "3.3.0";
7009
7092
 
7010
7093
  // .build/modules/index.mjs
7011
7094
  var mounted = /* @__PURE__ */ new WeakMap();
@@ -7100,5 +7183,6 @@ export {
7100
7183
  createPlaybackPublisher,
7101
7184
  createPlaybackReceiver,
7102
7185
  fromWordPressMeta,
7186
+ validateBeatAlignment,
7103
7187
  validatePageConfig
7104
7188
  };
package/lib/react.cjs CHANGED
@@ -115,7 +115,11 @@ function Rudiment({ runtime, options, children, ...attributes }) {
115
115
  }
116
116
  function PageMetadataEditor({ value = EMPTY, onSave }) {
117
117
  const [draft, setDraft] = (0, import_react.useState)(() => ({ ...value })), [message, setMessage] = (0, import_react.useState)(""), [busy, setBusy] = (0, import_react.useState)(false);
118
- (0, import_react.useEffect)(() => setDraft({ ...value }), [value]);
118
+ const [alignmentText, setAlignmentText] = (0, import_react.useState)(() => value.beatAlignment ? JSON.stringify(value.beatAlignment, null, 2) : "");
119
+ (0, import_react.useEffect)(() => {
120
+ setDraft({ ...value });
121
+ setAlignmentText(value.beatAlignment ? JSON.stringify(value.beatAlignment, null, 2) : "");
122
+ }, [value]);
119
123
  const input = (key, label, type = "text") => (0, import_react.createElement)("label", { key }, label, (0, import_react.createElement)("input", {
120
124
  type,
121
125
  value: draft[key] ?? "",
@@ -129,7 +133,7 @@ function PageMetadataEditor({ value = EMPTY, onSave }) {
129
133
  setBusy(true);
130
134
  setMessage("");
131
135
  try {
132
- await onSave((0, import_index.validatePageConfig)(draft));
136
+ await onSave((0, import_index.validatePageConfig)({ ...draft, beatAlignment: alignmentText.trim() ? JSON.parse(alignmentText) : null }));
133
137
  setMessage("Saved");
134
138
  } catch (error) {
135
139
  setMessage(error.message);
@@ -140,6 +144,7 @@ function PageMetadataEditor({ value = EMPTY, onSave }) {
140
144
  input("bpm", "BPM", "number"),
141
145
  input("lyricTimingUrl", "Lyric timing URL"),
142
146
  input("cueTimingUrl", "Cue timing URL"),
147
+ (0, import_react.createElement)("label", null, "Beat alignment reading (JSON)", (0, import_react.createElement)("textarea", { rows: 7, value: alignmentText, onChange: (e) => setAlignmentText(e.target.value) })),
143
148
  input("masterDurationMilliseconds", "Master duration (milliseconds)", "number"),
144
149
  (0, import_react.createElement)("label", null, (0, import_react.createElement)("input", { type: "checkbox", checked: !!draft.lyricPopupsEnabled, onChange: (e) => setDraft({ ...draft, lyricPopupsEnabled: e.target.checked }) }), "Timed lyric pop-ups"),
145
150
  (0, import_react.createElement)("label", null, "Ambient effect", (0, import_react.createElement)(
package/lib/react.mjs CHANGED
@@ -86,7 +86,11 @@ function Rudiment({ runtime, options, children, ...attributes }) {
86
86
  }
87
87
  function PageMetadataEditor({ value = EMPTY, onSave }) {
88
88
  const [draft, setDraft] = useState(() => ({ ...value })), [message, setMessage] = useState(""), [busy, setBusy] = useState(false);
89
- useEffect(() => setDraft({ ...value }), [value]);
89
+ const [alignmentText, setAlignmentText] = useState(() => value.beatAlignment ? JSON.stringify(value.beatAlignment, null, 2) : "");
90
+ useEffect(() => {
91
+ setDraft({ ...value });
92
+ setAlignmentText(value.beatAlignment ? JSON.stringify(value.beatAlignment, null, 2) : "");
93
+ }, [value]);
90
94
  const input = (key, label, type = "text") => h("label", { key }, label, h("input", {
91
95
  type,
92
96
  value: draft[key] ?? "",
@@ -100,7 +104,7 @@ function PageMetadataEditor({ value = EMPTY, onSave }) {
100
104
  setBusy(true);
101
105
  setMessage("");
102
106
  try {
103
- await onSave(validatePageConfig(draft));
107
+ await onSave(validatePageConfig({ ...draft, beatAlignment: alignmentText.trim() ? JSON.parse(alignmentText) : null }));
104
108
  setMessage("Saved");
105
109
  } catch (error) {
106
110
  setMessage(error.message);
@@ -111,6 +115,7 @@ function PageMetadataEditor({ value = EMPTY, onSave }) {
111
115
  input("bpm", "BPM", "number"),
112
116
  input("lyricTimingUrl", "Lyric timing URL"),
113
117
  input("cueTimingUrl", "Cue timing URL"),
118
+ h("label", null, "Beat alignment reading (JSON)", h("textarea", { rows: 7, value: alignmentText, onChange: (e) => setAlignmentText(e.target.value) })),
114
119
  input("masterDurationMilliseconds", "Master duration (milliseconds)", "number"),
115
120
  h("label", null, h("input", { type: "checkbox", checked: !!draft.lyricPopupsEnabled, onChange: (e) => setDraft({ ...draft, lyricPopupsEnabled: e.target.checked }) }), "Timed lyric pop-ups"),
116
121
  h("label", null, "Ambient effect", h(