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.
- package/dist/clock/Transport.d.ts +8 -2
- package/dist/clock/Transport.js +46 -7
- package/dist/clock/musical.js +13 -8
- package/dist/clock/sync.js +15 -2
- package/dist/loaders/glbLoader.js +17 -4
- package/package.json +1 -1
- package/src/clock/Transport.ts +37 -6
- package/src/clock/musical.ts +18 -8
- package/src/clock/sync.ts +15 -2
- package/src/loaders/glbLoader.ts +15 -6
|
@@ -71,9 +71,15 @@ export declare class Transport {
|
|
|
71
71
|
private _nextDist;
|
|
72
72
|
setProjection(projection: MusicalProjectionLike | null): void;
|
|
73
73
|
musicalPosition(): unknown;
|
|
74
|
-
|
|
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
|
-
|
|
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;
|
package/dist/clock/Transport.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
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;
|
package/dist/clock/musical.js
CHANGED
|
@@ -14,20 +14,25 @@ export class MusicalProjection {
|
|
|
14
14
|
_sig;
|
|
15
15
|
constructor(spec = {}) {
|
|
16
16
|
this.ppq = spec.ppq ?? DEFAULT_PPQ;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
.
|
|
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++) {
|
package/dist/clock/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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",
|
package/src/clock/Transport.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/clock/musical.ts
CHANGED
|
@@ -58,9 +58,13 @@ export class MusicalProjection {
|
|
|
58
58
|
constructor(spec: MusicalProjectionSpec = {}) {
|
|
59
59
|
this.ppq = spec.ppq ?? DEFAULT_PPQ;
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/loaders/glbLoader.ts
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|