entangled-worlds-orbiters-shared 4.0.0 → 4.1.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.
@@ -71,9 +71,15 @@ export declare class Transport {
71
71
  private _nextDist;
72
72
  setProjection(projection: MusicalProjectionLike | null): void;
73
73
  musicalPosition(): unknown;
74
- applyAnchoredState(s: AnchoredState): void;
74
+ /**
75
+ * Apply a peer's anchored state. A malformed one (unknown playState, non-finite position, anchor or
76
+ * loop bounds, or a rate that is not a finite number > 0) is DROPPED whole and `false` is returned: the transport keeps its last
77
+ * good state until the next good one arrives. It never throws — this runs inside `tick()`.
78
+ */
79
+ applyAnchoredState(s: AnchoredState): boolean;
75
80
  /** Arm an anchored state (with optional control) to apply when audio time reaches `atAudioSec`. */
76
- scheduleStateChange(atAudioSec: number, state: AnchoredState, meta?: ScheduleStateMeta): void;
81
+ /** Returns `false` (and arms nothing) for a non-finite `atAudioSec` or a malformed state. */
82
+ scheduleStateChange(atAudioSec: number, state: AnchoredState, meta?: ScheduleStateMeta): boolean;
77
83
  /** Drop pending changes older than a freshly-applied (term,seq) — supersession (Codex fix #4). */
78
84
  dropPendingOlderThan(term: number, seq: number): void;
79
85
  private _drainPending;
@@ -11,6 +11,22 @@
11
11
  // - Looping is handled by computing each event's NEXT occurrence distance (wrapping across the loop
12
12
  // boundary), so events just after loopStart are armed during the pre-wrap look-ahead.
13
13
  const EPS = 1e-9;
14
+ function isValidRate(rate) {
15
+ return Number.isFinite(rate) && rate > 0;
16
+ }
17
+ function isValidAnchoredState(s) {
18
+ if (s.playState !== 'playing' && s.playState !== 'paused')
19
+ return false;
20
+ if (!Number.isFinite(s.positionSec))
21
+ return false;
22
+ if (s.anchorAudioSec != null && !Number.isFinite(s.anchorAudioSec))
23
+ return false;
24
+ if (s.rate != null && !isValidRate(s.rate))
25
+ return false;
26
+ if (s.loop && !(Number.isFinite(s.loop.startSec) && Number.isFinite(s.loop.endSec)))
27
+ return false;
28
+ return true;
29
+ }
14
30
  export class Transport {
15
31
  backend;
16
32
  lookaheadSec;
@@ -63,13 +79,15 @@ export class Transport {
63
79
  this._cancelArmed(); // armed future audio must not sound during pause; re-armed on resume
64
80
  }
65
81
  seekSec(sec) {
82
+ if (!Number.isFinite(sec))
83
+ return; // a NaN seek is ignored, never freezes the transport
66
84
  this._reanchor(Math.max(0, sec));
67
85
  this._cancelArmed();
68
86
  this._reopenEvents(); // a seek MOVES position -> fired events may replay
69
87
  }
70
88
  setRate(rate) {
71
- if (rate <= 0)
72
- throw new Error('rate must be > 0');
89
+ if (!isValidRate(rate))
90
+ throw new Error('rate must be a finite number > 0');
73
91
  this._reanchor();
74
92
  this._rate = rate;
75
93
  this._cancelArmed(); // window/audio-time changed -> re-arm pending events (NOT replay fired ones)
@@ -81,6 +99,8 @@ export class Transport {
81
99
  return this._playing;
82
100
  }
83
101
  setLoopSec(loop) {
102
+ if (loop && !(Number.isFinite(loop.startSec) && Number.isFinite(loop.endSec)))
103
+ return; // ignored
84
104
  this._loop = loop ? { startSec: loop.startSec, endSec: loop.endSec } : null;
85
105
  this._cancelArmed();
86
106
  this._reopenEvents(); // loop topology changed -> events may re-enter/leave the loop window
@@ -149,6 +169,11 @@ export class Transport {
149
169
  d += len; // wrapped into the next pass
150
170
  return d;
151
171
  }
172
+ // At or past the loop end: the transport wraps back to startSec before getting there, so the
173
+ // event never occurs while looping. Without this, its look-ahead distance came out positive just
174
+ // before the wrap and it armed (and sounded) at the wrap instant, at a position never reached.
175
+ if (len > 0 && ev.atSec >= endSec - EPS)
176
+ return null;
152
177
  }
153
178
  const d = ev.atSec - pos;
154
179
  return d >= -EPS ? d : null; // non-loop: a passed event won't fire again until a seek
@@ -161,7 +186,14 @@ export class Transport {
161
186
  return this._projection ? this._projection.musicalAt(this.positionSec()) : null;
162
187
  }
163
188
  // ---- sync apply (Inc 3) -------------------------------------------------------------
189
+ /**
190
+ * Apply a peer's anchored state. A malformed one (unknown playState, non-finite position, anchor or
191
+ * loop bounds, or a rate that is not a finite number > 0) is DROPPED whole and `false` is returned: the transport keeps its last
192
+ * good state until the next good one arrives. It never throws — this runs inside `tick()`.
193
+ */
164
194
  applyAnchoredState(s) {
195
+ if (!isValidAnchoredState(s))
196
+ return false;
165
197
  this._playing = s.playState === 'playing';
166
198
  if (s.rate != null)
167
199
  this._rate = s.rate;
@@ -171,9 +203,14 @@ export class Transport {
171
203
  this._loop = s.loop ? { ...s.loop } : null;
172
204
  this._cancelArmed();
173
205
  this._reopenEvents(); // anchored apply repositions the timeline -> events re-evaluate
206
+ return true;
174
207
  }
175
208
  /** Arm an anchored state (with optional control) to apply when audio time reaches `atAudioSec`. */
209
+ /** Returns `false` (and arms nothing) for a non-finite `atAudioSec` or a malformed state. */
176
210
  scheduleStateChange(atAudioSec, state, meta = {}) {
211
+ // checked at intake too: a NaN time would never drain and would block every later change
212
+ if (!Number.isFinite(atAudioSec) || !isValidAnchoredState(state))
213
+ return false;
177
214
  this._pending.push({
178
215
  atAudioSec,
179
216
  state,
@@ -182,6 +219,7 @@ export class Transport {
182
219
  onApply: meta.onApply ?? null,
183
220
  });
184
221
  this._pending.sort((a, b) => a.atAudioSec - b.atAudioSec);
222
+ return true;
185
223
  }
186
224
  /** Drop pending changes older than a freshly-applied (term,seq) — supersession (Codex fix #4). */
187
225
  dropPendingOlderThan(term, seq) {
@@ -191,9 +229,10 @@ export class Transport {
191
229
  const now = this.backend.nowSec();
192
230
  while (this._pending.length && this._pending[0].atAudioSec <= now + EPS) {
193
231
  const p = this._pending.shift();
194
- this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
195
- if (p.onApply)
196
- p.onApply(); // control rides WITH the state, applied atomically here (fix #5)
232
+ const applied = this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
233
+ // control rides WITH the state, applied atomically here (fix #5) — a dropped state drops its control too
234
+ if (applied && p.onApply)
235
+ p.onApply();
197
236
  }
198
237
  }
199
238
  // ---- the pump -----------------------------------------------------------------------
@@ -248,8 +287,8 @@ export class Transport {
248
287
  return;
249
288
  const { startSec, endSec } = this._loop;
250
289
  const len = endSec - startSec;
251
- if (len <= 0)
252
- return;
290
+ if (!(len > 0))
291
+ return; // also catches NaN
253
292
  const pos = this.positionSec();
254
293
  if (pos < endSec - EPS)
255
294
  return;
@@ -14,20 +14,25 @@ export class MusicalProjection {
14
14
  _sig;
15
15
  constructor(spec = {}) {
16
16
  this.ppq = spec.ppq ?? DEFAULT_PPQ;
17
- const tempo = (spec.tempoMap && spec.tempoMap.length ? spec.tempoMap : [{ startSec: 0, bpm: 120 }])
18
- .slice()
19
- .sort((a, b) => a.startSec - b.startSec);
17
+ // An entry with bpm <= 0, a NaN bpm or a non-finite start would make beat <-> seconds return
18
+ // Infinity/NaN — skip it. If nothing usable is left, fall back to the same 120 BPM default as an
19
+ // empty map.
20
+ const usable = (spec.tempoMap ?? []).filter((e) => Number.isFinite(e.startSec) && Number.isFinite(e.bpm) && e.bpm > 0);
21
+ const tempo = (usable.length ? usable : [{ startSec: 0, bpm: 120 }]).sort((a, b) => a.startSec - b.startSec);
20
22
  // precompute the cumulative beat position at each tempo segment boundary
21
23
  this._tempo = tempo.map((s) => ({ ...s, startBeat: 0 }));
22
24
  for (let i = 1; i < this._tempo.length; i++) {
23
25
  const prev = this._tempo[i - 1];
24
26
  this._tempo[i].startBeat = prev.startBeat + (this._tempo[i].startSec - prev.startSec) * (prev.bpm / 60);
25
27
  }
26
- const sig = (spec.signatureMap && spec.signatureMap.length
27
- ? spec.signatureMap
28
- : [{ startBar: 0, numerator: 4, denominator: 4 }])
29
- .slice()
30
- .sort((a, b) => a.startBar - b.startBar);
28
+ // Same rule for signatures: a zero/negative/NaN numerator or denominator, or a non-finite start
29
+ // bar, would give Infinity/NaN bars — skip it, and fall back to 4/4 if nothing usable is left.
30
+ const usableSig = (spec.signatureMap ?? []).filter((e) => Number.isFinite(e.startBar) &&
31
+ Number.isFinite(e.numerator) &&
32
+ e.numerator > 0 &&
33
+ Number.isFinite(e.denominator) &&
34
+ e.denominator > 0);
35
+ const sig = (usableSig.length ? usableSig : [{ startBar: 0, numerator: 4, denominator: 4 }]).sort((a, b) => a.startBar - b.startBar);
31
36
  // precompute the cumulative beat position at each signature segment's startBar
32
37
  this._sig = sig.map((s) => ({ ...s, beatsPerBar: (s.numerator * 4) / s.denominator, startBeat: 0 }));
33
38
  for (let i = 1; i < this._sig.length; i++) {
@@ -12,6 +12,13 @@
12
12
  // Reuses the session-time substrate offset estimation; the server `sessionEpoch` gives every peer a
13
13
  // shared `sessionMs`; the beat timeline is anchored to that.
14
14
  const mod = (n, m) => ((n % m) + m) % m;
15
+ /** A usable timeline: a finite tempo above zero and finite anchors. Anything else makes every beat
16
+ * <-> time conversion NaN or Infinity, for this peer and for every peer that adopts it. */
17
+ const isValidTimeline = (t) => !!t &&
18
+ Number.isFinite(t.tempoBpm) &&
19
+ t.tempoBpm > 0 &&
20
+ Number.isFinite(t.anchorBeat) &&
21
+ Number.isFinite(t.anchorSessionMs);
15
22
  const newerTs = (a, b) => a.time > b.time || (a.time === b.time && a.id > b.id);
16
23
  /** Cristian/SNTP offset estimate: median of (serverStamp + rtt/2 - t1). Asymmetry biases by (up-down)/2. */
17
24
  export function estimateOffset(node, relay, { samples = 7, upMs = relay.upMs, downMs = relay.downMs, jitterFn = () => 0 } = {}) {
@@ -132,6 +139,9 @@ export class BeatTimeline {
132
139
  }
133
140
  // ---- timeline mutations (all leaderless: broadcast + last-writer-wins) ---------------
134
141
  _commit(timeline) {
142
+ // Validated before it is broadcast: last-writer-wins would hand a broken timeline to every peer.
143
+ if (!isValidTimeline(timeline))
144
+ throw new Error('beat timeline needs a finite tempo > 0 and finite anchors');
135
145
  this.timeline = { ...timeline };
136
146
  this._ts = { time: this.sessionNowMs(), id: this.id };
137
147
  if (this.enabled)
@@ -177,7 +187,9 @@ export class BeatTimeline {
177
187
  // ---- start/stop intent (opt-in; never adopted on join) ------------------------------
178
188
  setLocalPlaying(playing) {
179
189
  this.localPlaying = playing;
180
- this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
190
+ // Solo (`enabled` false) broadcasts nothing, as with the timeline and hello messages.
191
+ if (this.enabled)
192
+ this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
181
193
  if (this.onChange)
182
194
  this.onChange();
183
195
  }
@@ -196,7 +208,8 @@ export class BeatTimeline {
196
208
  case 'beat:timeline':
197
209
  if (msg.id)
198
210
  this.peers.add(msg.id);
199
- if (newerTs(msg.ts, this._ts)) {
211
+ // A malformed timeline (tempo 0, NaN anchor, from a buggy or older peer) is never adopted.
212
+ if (isValidTimeline(msg.timeline) && msg.ts && newerTs(msg.ts, this._ts)) {
200
213
  this.timeline = { ...msg.timeline };
201
214
  this._ts = msg.ts;
202
215
  }
@@ -2,6 +2,7 @@ import * as THREE from 'three';
2
2
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
3
  import { ensureStandardMaterial, applyFlagsToMaterial } from '../utils/materials.js';
4
4
  import { getKTX2LoaderForRenderer } from './ktx2.js';
5
+ import { disposeObject3D } from '../utils/dispose.js';
5
6
  /**
6
7
  * `renderer` is REQUIRED at runtime (throws without one).
7
8
  * - `retries` (3): load attempts before giving up.
@@ -20,16 +21,28 @@ export async function loadGLB(url, { retries = 3, signal, onProgress, applyFlags
20
21
  throw new Error('[glb] loadGLB requires the owning renderer (KTX2 textures bind per WebGL context).');
21
22
  let attempt = 0;
22
23
  while (attempt < retries) {
24
+ // An abort during the pause between attempts must stop here: a listener added to an
25
+ // already-aborted signal never fires, so the next attempt would load and resolve anyway.
26
+ if (signal?.aborted)
27
+ throw new Error('aborted');
23
28
  try {
24
29
  const loader = new GLTFLoader()
25
30
  .setCrossOrigin(withCredentials ? 'use-credentials' : 'anonymous')
26
31
  .setWithCredentials(Boolean(withCredentials))
27
32
  .setKTX2Loader(getKTX2LoaderForRenderer(renderer));
28
33
  const root = await new Promise((resolve, reject) => {
29
- loader.load(url, (gltf) => resolve(gltf.scene), onProgress, reject);
30
- if (signal) {
31
- signal.addEventListener('abort', () => reject(new Error('aborted')));
32
- }
34
+ const onAbort = () => reject(new Error('aborted'));
35
+ signal?.addEventListener('abort', onAbort, { once: true });
36
+ loader.load(url, (gltf) => {
37
+ signal?.removeEventListener('abort', onAbort);
38
+ // Aborted while loading: the promise has already rejected and nobody will own this
39
+ // scene, so free its geometry, materials and textures now instead of leaking them.
40
+ if (signal?.aborted) {
41
+ disposeObject3D(gltf.scene);
42
+ return;
43
+ }
44
+ resolve(gltf.scene);
45
+ }, onProgress, (err) => { signal?.removeEventListener('abort', onAbort); reject(err); });
33
46
  });
34
47
  // Post-process materials
35
48
  root.traverse((object) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -77,6 +77,19 @@ export interface TransportOptions {
77
77
  lookaheadSec?: number;
78
78
  }
79
79
 
80
+ function isValidRate(rate: number): boolean {
81
+ return Number.isFinite(rate) && rate > 0;
82
+ }
83
+
84
+ function isValidAnchoredState(s: AnchoredState): boolean {
85
+ if (s.playState !== 'playing' && s.playState !== 'paused') return false;
86
+ if (!Number.isFinite(s.positionSec)) return false;
87
+ if (s.anchorAudioSec != null && !Number.isFinite(s.anchorAudioSec)) return false;
88
+ if (s.rate != null && !isValidRate(s.rate)) return false;
89
+ if (s.loop && !(Number.isFinite(s.loop.startSec) && Number.isFinite(s.loop.endSec))) return false;
90
+ return true;
91
+ }
92
+
80
93
  export class Transport {
81
94
  backend: ClockBackend;
82
95
  lookaheadSec: number;
@@ -132,12 +145,13 @@ export class Transport {
132
145
  this._cancelArmed(); // armed future audio must not sound during pause; re-armed on resume
133
146
  }
134
147
  seekSec(sec: number): void {
148
+ if (!Number.isFinite(sec)) return; // a NaN seek is ignored, never freezes the transport
135
149
  this._reanchor(Math.max(0, sec));
136
150
  this._cancelArmed();
137
151
  this._reopenEvents(); // a seek MOVES position -> fired events may replay
138
152
  }
139
153
  setRate(rate: number): void {
140
- if (rate <= 0) throw new Error('rate must be > 0');
154
+ if (!isValidRate(rate)) throw new Error('rate must be a finite number > 0');
141
155
  this._reanchor();
142
156
  this._rate = rate;
143
157
  this._cancelArmed(); // window/audio-time changed -> re-arm pending events (NOT replay fired ones)
@@ -149,6 +163,7 @@ export class Transport {
149
163
  return this._playing;
150
164
  }
151
165
  setLoopSec(loop: LoopSec | null): void {
166
+ if (loop && !(Number.isFinite(loop.startSec) && Number.isFinite(loop.endSec))) return; // ignored
152
167
  this._loop = loop ? { startSec: loop.startSec, endSec: loop.endSec } : null;
153
168
  this._cancelArmed();
154
169
  this._reopenEvents(); // loop topology changed -> events may re-enter/leave the loop window
@@ -218,6 +233,10 @@ export class Transport {
218
233
  if (d < -EPS) d += len; // wrapped into the next pass
219
234
  return d;
220
235
  }
236
+ // At or past the loop end: the transport wraps back to startSec before getting there, so the
237
+ // event never occurs while looping. Without this, its look-ahead distance came out positive just
238
+ // before the wrap and it armed (and sounded) at the wrap instant, at a position never reached.
239
+ if (len > 0 && ev.atSec >= endSec - EPS) return null;
221
240
  }
222
241
  const d = ev.atSec - pos;
223
242
  return d >= -EPS ? d : null; // non-loop: a passed event won't fire again until a seek
@@ -232,7 +251,13 @@ export class Transport {
232
251
  }
233
252
 
234
253
  // ---- sync apply (Inc 3) -------------------------------------------------------------
235
- applyAnchoredState(s: AnchoredState): void {
254
+ /**
255
+ * Apply a peer's anchored state. A malformed one (unknown playState, non-finite position, anchor or
256
+ * loop bounds, or a rate that is not a finite number > 0) is DROPPED whole and `false` is returned: the transport keeps its last
257
+ * good state until the next good one arrives. It never throws — this runs inside `tick()`.
258
+ */
259
+ applyAnchoredState(s: AnchoredState): boolean {
260
+ if (!isValidAnchoredState(s)) return false;
236
261
  this._playing = s.playState === 'playing';
237
262
  if (s.rate != null) this._rate = s.rate;
238
263
  this._anchorPos = s.positionSec;
@@ -240,9 +265,13 @@ export class Transport {
240
265
  if (s.loop !== undefined) this._loop = s.loop ? { ...s.loop } : null;
241
266
  this._cancelArmed();
242
267
  this._reopenEvents(); // anchored apply repositions the timeline -> events re-evaluate
268
+ return true;
243
269
  }
244
270
  /** Arm an anchored state (with optional control) to apply when audio time reaches `atAudioSec`. */
245
- scheduleStateChange(atAudioSec: number, state: AnchoredState, meta: ScheduleStateMeta = {}): void {
271
+ /** Returns `false` (and arms nothing) for a non-finite `atAudioSec` or a malformed state. */
272
+ scheduleStateChange(atAudioSec: number, state: AnchoredState, meta: ScheduleStateMeta = {}): boolean {
273
+ // checked at intake too: a NaN time would never drain and would block every later change
274
+ if (!Number.isFinite(atAudioSec) || !isValidAnchoredState(state)) return false;
246
275
  this._pending.push({
247
276
  atAudioSec,
248
277
  state,
@@ -251,6 +280,7 @@ export class Transport {
251
280
  onApply: meta.onApply ?? null,
252
281
  });
253
282
  this._pending.sort((a, b) => a.atAudioSec - b.atAudioSec);
283
+ return true;
254
284
  }
255
285
  /** Drop pending changes older than a freshly-applied (term,seq) — supersession (Codex fix #4). */
256
286
  dropPendingOlderThan(term: number, seq: number): void {
@@ -260,8 +290,9 @@ export class Transport {
260
290
  const now = this.backend.nowSec();
261
291
  while (this._pending.length && this._pending[0].atAudioSec <= now + EPS) {
262
292
  const p = this._pending.shift()!;
263
- this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
264
- if (p.onApply) p.onApply(); // control rides WITH the state, applied atomically here (fix #5)
293
+ const applied = this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
294
+ // control rides WITH the state, applied atomically here (fix #5) — a dropped state drops its control too
295
+ if (applied && p.onApply) p.onApply();
265
296
  }
266
297
  }
267
298
 
@@ -312,7 +343,7 @@ export class Transport {
312
343
  if (!this._loop) return;
313
344
  const { startSec, endSec } = this._loop;
314
345
  const len = endSec - startSec;
315
- if (len <= 0) return;
346
+ if (!(len > 0)) return; // also catches NaN
316
347
  const pos = this.positionSec();
317
348
  if (pos < endSec - EPS) return;
318
349
  const overshoot = (pos - startSec) % len;
@@ -58,9 +58,13 @@ export class MusicalProjection {
58
58
  constructor(spec: MusicalProjectionSpec = {}) {
59
59
  this.ppq = spec.ppq ?? DEFAULT_PPQ;
60
60
 
61
- const tempo = (spec.tempoMap && spec.tempoMap.length ? spec.tempoMap : [{ startSec: 0, bpm: 120 }])
62
- .slice()
63
- .sort((a, b) => a.startSec - b.startSec);
61
+ // An entry with bpm <= 0, a NaN bpm or a non-finite start would make beat <-> seconds return
62
+ // Infinity/NaN — skip it. If nothing usable is left, fall back to the same 120 BPM default as an
63
+ // empty map.
64
+ const usable = (spec.tempoMap ?? []).filter(
65
+ (e) => Number.isFinite(e.startSec) && Number.isFinite(e.bpm) && e.bpm > 0,
66
+ );
67
+ const tempo = (usable.length ? usable : [{ startSec: 0, bpm: 120 }]).sort((a, b) => a.startSec - b.startSec);
64
68
  // precompute the cumulative beat position at each tempo segment boundary
65
69
  this._tempo = tempo.map((s) => ({ ...s, startBeat: 0 }));
66
70
  for (let i = 1; i < this._tempo.length; i++) {
@@ -68,11 +72,17 @@ export class MusicalProjection {
68
72
  this._tempo[i].startBeat = prev.startBeat + (this._tempo[i].startSec - prev.startSec) * (prev.bpm / 60);
69
73
  }
70
74
 
71
- const sig = (spec.signatureMap && spec.signatureMap.length
72
- ? spec.signatureMap
73
- : [{ startBar: 0, numerator: 4, denominator: 4 }])
74
- .slice()
75
- .sort((a, b) => a.startBar - b.startBar);
75
+ // Same rule for signatures: a zero/negative/NaN numerator or denominator, or a non-finite start
76
+ // bar, would give Infinity/NaN bars — skip it, and fall back to 4/4 if nothing usable is left.
77
+ const usableSig = (spec.signatureMap ?? []).filter(
78
+ (e) =>
79
+ Number.isFinite(e.startBar) &&
80
+ Number.isFinite(e.numerator) &&
81
+ e.numerator > 0 &&
82
+ Number.isFinite(e.denominator) &&
83
+ e.denominator > 0,
84
+ );
85
+ const sig = (usableSig.length ? usableSig : [{ startBar: 0, numerator: 4, denominator: 4 }]).sort((a, b) => a.startBar - b.startBar);
76
86
  // precompute the cumulative beat position at each signature segment's startBar
77
87
  this._sig = sig.map((s) => ({ ...s, beatsPerBar: (s.numerator * 4) / s.denominator, startBeat: 0 }));
78
88
  for (let i = 1; i < this._sig.length; i++) {
package/src/clock/sync.ts CHANGED
@@ -72,6 +72,14 @@ export interface EstimateOffsetOpts {
72
72
  }
73
73
 
74
74
  const mod = (n: number, m: number): number => ((n % m) + m) % m;
75
+ /** A usable timeline: a finite tempo above zero and finite anchors. Anything else makes every beat
76
+ * <-> time conversion NaN or Infinity, for this peer and for every peer that adopts it. */
77
+ const isValidTimeline = (t: BeatTimelineState | null | undefined): t is BeatTimelineState =>
78
+ !!t &&
79
+ Number.isFinite(t.tempoBpm) &&
80
+ t.tempoBpm > 0 &&
81
+ Number.isFinite(t.anchorBeat) &&
82
+ Number.isFinite(t.anchorSessionMs);
75
83
  const newerTs = (a: TimelineStamp, b: TimelineStamp): boolean =>
76
84
  a.time > b.time || (a.time === b.time && a.id > b.id);
77
85
 
@@ -225,6 +233,8 @@ export class BeatTimeline implements RelayNode {
225
233
 
226
234
  // ---- timeline mutations (all leaderless: broadcast + last-writer-wins) ---------------
227
235
  private _commit(timeline: BeatTimelineState): void {
236
+ // Validated before it is broadcast: last-writer-wins would hand a broken timeline to every peer.
237
+ if (!isValidTimeline(timeline)) throw new Error('beat timeline needs a finite tempo > 0 and finite anchors');
228
238
  this.timeline = { ...timeline };
229
239
  this._ts = { time: this.sessionNowMs(), id: this.id };
230
240
  if (this.enabled)
@@ -269,7 +279,9 @@ export class BeatTimeline implements RelayNode {
269
279
  // ---- start/stop intent (opt-in; never adopted on join) ------------------------------
270
280
  setLocalPlaying(playing: boolean): void {
271
281
  this.localPlaying = playing;
272
- this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
282
+ // Solo (`enabled` false) broadcasts nothing, as with the timeline and hello messages.
283
+ if (this.enabled)
284
+ this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
273
285
  if (this.onChange) this.onChange();
274
286
  }
275
287
 
@@ -289,7 +301,8 @@ export class BeatTimeline implements RelayNode {
289
301
  break;
290
302
  case 'beat:timeline':
291
303
  if (msg.id) this.peers.add(msg.id);
292
- if (newerTs(msg.ts, this._ts)) {
304
+ // A malformed timeline (tempo 0, NaN anchor, from a buggy or older peer) is never adopted.
305
+ if (isValidTimeline(msg.timeline) && msg.ts && newerTs(msg.ts, this._ts)) {
293
306
  this.timeline = { ...msg.timeline };
294
307
  this._ts = msg.ts;
295
308
  }
@@ -2,6 +2,7 @@ import * as THREE from 'three';
2
2
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
3
  import { ensureStandardMaterial, applyFlagsToMaterial, type MaterialFlags } from '../utils/materials.js';
4
4
  import { getKTX2LoaderForRenderer } from './ktx2.js';
5
+ import { disposeObject3D } from '../utils/dispose.js';
5
6
 
6
7
  /**
7
8
  * `renderer` is REQUIRED at runtime (throws without one).
@@ -43,6 +44,9 @@ export async function loadGLB(
43
44
 
44
45
  let attempt = 0;
45
46
  while (attempt < retries) {
47
+ // An abort during the pause between attempts must stop here: a listener added to an
48
+ // already-aborted signal never fires, so the next attempt would load and resolve anyway.
49
+ if (signal?.aborted) throw new Error('aborted');
46
50
  try {
47
51
  const loader = new GLTFLoader()
48
52
  .setCrossOrigin(withCredentials ? 'use-credentials' : 'anonymous')
@@ -50,15 +54,20 @@ export async function loadGLB(
50
54
  .setKTX2Loader(getKTX2LoaderForRenderer(renderer));
51
55
 
52
56
  const root = await new Promise<THREE.Group>((resolve, reject) => {
57
+ const onAbort = () => reject(new Error('aborted'));
58
+ signal?.addEventListener('abort', onAbort, { once: true });
53
59
  loader.load(
54
60
  url,
55
- (gltf) => resolve(gltf.scene),
56
- onProgress,
57
- reject
61
+ (gltf) => {
62
+ signal?.removeEventListener('abort', onAbort);
63
+ // Aborted while loading: the promise has already rejected and nobody will own this
64
+ // scene, so free its geometry, materials and textures now instead of leaking them.
65
+ if (signal?.aborted) { disposeObject3D(gltf.scene); return; }
66
+ resolve(gltf.scene);
67
+ },
68
+ onProgress,
69
+ (err) => { signal?.removeEventListener('abort', onAbort); reject(err); }
58
70
  );
59
- if (signal) {
60
- signal.addEventListener('abort', () => reject(new Error('aborted')));
61
- }
62
71
  });
63
72
 
64
73
  // Post-process materials