entangled-worlds-orbiters-shared 4.0.0 → 4.2.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.
Files changed (41) hide show
  1. package/README.md +8 -0
  2. package/dist/clock/Transport.d.ts +8 -2
  3. package/dist/clock/Transport.js +46 -7
  4. package/dist/clock/musical.js +13 -8
  5. package/dist/clock/sync.js +15 -2
  6. package/dist/loaders/glbLoader.js +17 -4
  7. package/dist/shortcuts/chord.d.ts +35 -0
  8. package/dist/shortcuts/chord.js +99 -0
  9. package/dist/shortcuts/entangledWorlds.d.ts +21 -0
  10. package/dist/shortcuts/entangledWorlds.js +40 -0
  11. package/dist/shortcuts/format.d.ts +44 -0
  12. package/dist/shortcuts/format.js +132 -0
  13. package/dist/shortcuts/index.d.ts +9 -0
  14. package/dist/shortcuts/index.js +13 -0
  15. package/dist/shortcuts/labels.d.ts +10 -0
  16. package/dist/shortcuts/labels.js +130 -0
  17. package/dist/shortcuts/match.d.ts +65 -0
  18. package/dist/shortcuts/match.js +145 -0
  19. package/dist/shortcuts/orbiters.d.ts +55 -0
  20. package/dist/shortcuts/orbiters.js +109 -0
  21. package/dist/shortcuts/overrides.d.ts +30 -0
  22. package/dist/shortcuts/overrides.js +81 -0
  23. package/dist/shortcuts/sections.d.ts +23 -0
  24. package/dist/shortcuts/sections.js +19 -0
  25. package/dist/shortcuts/site.d.ts +9 -0
  26. package/dist/shortcuts/site.js +25 -0
  27. package/package.json +5 -1
  28. package/src/clock/Transport.ts +37 -6
  29. package/src/clock/musical.ts +18 -8
  30. package/src/clock/sync.ts +15 -2
  31. package/src/loaders/glbLoader.ts +15 -6
  32. package/src/shortcuts/chord.ts +122 -0
  33. package/src/shortcuts/entangledWorlds.ts +51 -0
  34. package/src/shortcuts/format.ts +143 -0
  35. package/src/shortcuts/index.ts +69 -0
  36. package/src/shortcuts/labels.ts +139 -0
  37. package/src/shortcuts/match.ts +195 -0
  38. package/src/shortcuts/orbiters.ts +132 -0
  39. package/src/shortcuts/overrides.ts +110 -0
  40. package/src/shortcuts/sections.ts +36 -0
  41. package/src/shortcuts/site.ts +33 -0
@@ -0,0 +1,109 @@
1
+ // The Orbiters catalogues: every key Orbiters answers, with the keys it has by default.
2
+ //
3
+ // `voice` acts on the focused deck(s), so in a shared session it only reaches a deck this person
4
+ // holds; `client` is about this machine and no deck (MIDI learn, performance mode, the Studio).
5
+ export const ORBITERS_PLAY_SECTION = 'orbiters-play';
6
+ export const ORBITERS_STUDIO_SECTION = 'orbiters-studio';
7
+ const play = (id, label, defaultKeys, { scope = 'voice', group, command } = {}) => Object.freeze({
8
+ id: `orbiters.play.${id}`, section: ORBITERS_PLAY_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope,
9
+ ...(group ? { group } : {}), ...(command === false ? { command } : {}),
10
+ });
11
+ const studio = (id, label, defaultKeys) => Object.freeze({ id: `orbiters.studio.${id}`, section: ORBITERS_STUDIO_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope: 'client', group: 'studio' });
12
+ /** How a command menu groups the actions, in its order. */
13
+ export const ORBITERS_COMMAND_GROUPS = Object.freeze([
14
+ { id: 'transport', label: 'Transport' },
15
+ { id: 'view', label: 'View' },
16
+ { id: 'snapshots', label: 'Snapshots' },
17
+ { id: 'panels', label: 'Panels' },
18
+ { id: 'orbiter', label: 'Orbiter' },
19
+ { id: 'studio', label: 'Studio' },
20
+ ]);
21
+ /** The snapshot slots, by the number each shows: the number row, 1 to 9, then 0. */
22
+ const SLOT_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
23
+ /** Undo and redo, the same keys wherever an edit can be taken back. */
24
+ const UNDO_KEYS = ['Mod+KeyZ'];
25
+ const REDO_KEYS = ['Mod+Shift+KeyZ', 'Ctrl+Shift?+KeyY'];
26
+ /**
27
+ * Playing an Orbiter. Shift is free on most keys (`Shift?`): it is held to select several decks,
28
+ * and a key pressed while selecting means what it means alone.
29
+ */
30
+ export const ORBITERS_PLAY_SHORTCUTS = Object.freeze([
31
+ // Space takes Ctrl, Option and ⌘ as it comes: only Shift changes what it does.
32
+ play('transport.toggle', 'Play or pause', ['Alt?+Ctrl?+Meta?+Space'], { group: 'transport' }),
33
+ play('transport.restart', 'Play from the beginning', ['Shift+Alt?+Ctrl?+Meta?+Space'], { group: 'transport' }),
34
+ play('rec.hold', 'Record while held', ['KeyR'], { group: 'transport', command: false }),
35
+ play('rec.toggle', 'Rec on or off', ['Shift+KeyR'], { group: 'transport' }),
36
+ play('history.undo', 'Undo', UNDO_KEYS, { group: 'transport' }),
37
+ play('history.redo', 'Redo', REDO_KEYS, { group: 'transport' }),
38
+ play('dimension.1', 'First dimension', ['Shift?+Digit1'], { group: 'view' }),
39
+ play('dimension.2', 'Second dimension', ['Shift?+Digit2'], { group: 'view' }),
40
+ play('dimension.3', 'Third dimension', ['Shift?+Digit3'], { group: 'view' }),
41
+ play('body.world', 'World', ['Shift?+KeyQ'], { group: 'view' }),
42
+ play('body.moon', 'Moon', ['Shift?+KeyW'], { group: 'view' }),
43
+ play('body.star', 'Star', ['Shift?+KeyE'], { group: 'view' }),
44
+ ...SLOT_NUMBERS.map((n) => play(`snapshot.recall.${n}`, `Recall snapshot ${n}`, [`Alt+Digit${n}`], { group: 'snapshots' })),
45
+ ...SLOT_NUMBERS.map((n) => play(`snapshot.store.${n}`, `Store snapshot ${n}`, [`Alt+Shift+Digit${n}`], { group: 'snapshots' })),
46
+ // The panels, from the bottom of the rail up.
47
+ play('panel.jamming', 'Jamming', ['KeyA'], { group: 'panels' }),
48
+ play('panel.playback', 'Playback', ['KeyS'], { group: 'panels' }),
49
+ play('panel.cosmicLfo', 'Cosmic LFO', ['KeyD'], { group: 'panels' }),
50
+ play('panel.sensors', 'Sensors', ['KeyF'], { group: 'panels' }),
51
+ // The More menu, and this machine's switches.
52
+ play('midiLearn', 'MIDI learn', ['Shift?+KeyM'], { scope: 'client', group: 'orbiter' }),
53
+ play('performanceMode', 'Performance mode', ['Shift?+KeyP'], { scope: 'client', group: 'orbiter' }),
54
+ play('screenCapture', 'Screen capture', [], { scope: 'client', group: 'orbiter' }),
55
+ play('share', 'Share', [], { scope: 'client', group: 'orbiter' }),
56
+ play('fullscreen', 'Fullscreen', [], { scope: 'client', group: 'orbiter' }),
57
+ // `?` on a US keyboard: the key's place, whatever this keyboard prints there.
58
+ play('help', 'Keyboard shortcuts', ['Shift?+Slash'], { scope: 'client', group: 'orbiter', command: false }),
59
+ play('commands', 'Commands', ['Mod+KeyK'], { scope: 'client', group: 'orbiter', command: false }),
60
+ ]);
61
+ /**
62
+ * The keys sheet (`?`): what to look up at a glance, in three columns. A row may stand for several
63
+ * actions whose keys differ only in the last key ("Dimension I · II · III — 1 2 3"); the keys
64
+ * shown are always this person's (`mergedKeyParts`).
65
+ */
66
+ export const ORBITERS_PLAY_SHEET = Object.freeze([
67
+ { id: 'transport', label: 'Transport', rows: [
68
+ { id: 'playPause', label: 'Play / pause', actions: ['transport.toggle'] },
69
+ { id: 'playFromStart', label: 'Play from start', actions: ['transport.restart'] },
70
+ { id: 'recHeld', label: 'REC while held', actions: ['rec.hold'] },
71
+ { id: 'recLatch', label: 'REC latch', actions: ['rec.toggle'] },
72
+ { id: 'undo', label: 'Undo', actions: ['history.undo'] },
73
+ { id: 'redo', label: 'Redo', actions: ['history.redo'] },
74
+ ] },
75
+ { id: 'view', label: 'View', rows: [
76
+ { id: 'dimensions', label: 'Dimension I · II · III', actions: ['dimension.1', 'dimension.2', 'dimension.3'] },
77
+ { id: 'bodies', label: 'World · Moon · Star', actions: ['body.world', 'body.moon', 'body.star'] },
78
+ { id: 'snapshotRecall', label: 'Snapshot recall', actions: SLOT_NUMBERS.map((n) => `snapshot.recall.${n}`) },
79
+ { id: 'snapshotStore', label: 'Snapshot store', actions: SLOT_NUMBERS.map((n) => `snapshot.store.${n}`) },
80
+ ] },
81
+ { id: 'panels', label: 'Panels', rows: [
82
+ { id: 'jamming', label: 'Jamming', actions: ['panel.jamming'] },
83
+ { id: 'playback', label: 'Playback', actions: ['panel.playback'] },
84
+ { id: 'cosmicLfo', label: 'Cosmic LFO', actions: ['panel.cosmicLfo'] },
85
+ { id: 'sensors', label: 'Sensors', actions: ['panel.sensors'] },
86
+ { id: 'midiLearn', label: 'MIDI learn', actions: ['midiLearn'] },
87
+ { id: 'performanceMode', label: 'Performance mode', actions: ['performanceMode'] },
88
+ ] },
89
+ ].map((column) => Object.freeze({
90
+ ...column,
91
+ rows: Object.freeze(column.rows.map((row) => Object.freeze({ ...row, actions: Object.freeze(row.actions.map((a) => `orbiters.play.${a}`)) }))),
92
+ })));
93
+ /**
94
+ * The Studio. It is open over play, so the play keys above still work there; where both name a key,
95
+ * the Studio's action is the one.
96
+ */
97
+ export const ORBITERS_STUDIO_SHORTCUTS = Object.freeze([
98
+ studio('undo', 'Undo', UNDO_KEYS),
99
+ studio('redo', 'Redo', REDO_KEYS),
100
+ studio('map', 'Map', ['Shift+KeyM']),
101
+ ]);
102
+ /**
103
+ * The letters the on-screen keyboard plays while it is open (`event.key`, lower-case): the piano row,
104
+ * then Z and X for the octave and C and V for velocity. While it is up they are its notes, and the
105
+ * shortcuts on them wait (`claimKeys`); a settings page says so beside those keys.
106
+ */
107
+ export const ORBITERS_KEYBOARD_SURFACE_KEYS = Object.freeze([
108
+ 'a', 'w', 's', 'e', 'd', 'f', 't', 'g', 'y', 'h', 'u', 'j', 'k', 'o', 'l', 'p', ';', "'", 'z', 'x', 'c', 'v',
109
+ ]);
@@ -0,0 +1,30 @@
1
+ import { type ShortcutAction, type ShortcutOverrides } from './match.js';
2
+ /** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
3
+ export declare function parseOverrides(value: unknown): ShortcutOverrides | null;
4
+ /**
5
+ * How the app reads the signed-in person's settings: `null` when no one is signed in (the
6
+ * default keys); `undefined` when the read failed (the overrides in force are kept: a blip must not
7
+ * swap someone's keys for the defaults).
8
+ */
9
+ export type ReadAccountSettings = (options: {
10
+ fresh: boolean;
11
+ }) => Promise<{
12
+ shortcuts?: unknown;
13
+ } | null | undefined>;
14
+ export interface ShortcutOverridesSource {
15
+ /** The overrides in force. */
16
+ get(): ShortcutOverrides;
17
+ /** A catalogue's actions with their keys now (kept until the overrides change). */
18
+ bindings(catalogue: readonly ShortcutAction[]): Map<string, readonly string[]>;
19
+ /** Called whenever the overrides change; returns the unsubscribe. */
20
+ subscribe(listener: () => void): () => void;
21
+ /** Read again (`fresh`: past the app's own cached read). The newest read wins, whatever order they end in. */
22
+ refresh(options?: {
23
+ fresh?: boolean;
24
+ }): Promise<void>;
25
+ /** Read now, and again whenever the page comes back to the front; once per source. */
26
+ bind(): void;
27
+ /** For tests: set the overrides directly. */
28
+ set(overrides: ShortcutOverrides): void;
29
+ }
30
+ export declare function createShortcutOverridesSource(readAccount: ReadAccountSettings): ShortcutOverridesSource;
@@ -0,0 +1,81 @@
1
+ // A person's shortcut overrides, read the same way by every app: from their account when signed in
2
+ // (someone signed out has the default keys), and again whenever the page comes back to the front (a key changed
3
+ // on the settings page applies on return, without a reload).
4
+ //
5
+ // Framework-free: the app hands in how it reads the account (its own API client) and gets the
6
+ // overrides and each catalogue's resolved keys back, kept until the overrides change.
7
+ import { resolveBindings } from './match.js';
8
+ /** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
9
+ export function parseOverrides(value) {
10
+ if (!Array.isArray(value))
11
+ return null;
12
+ return value.filter((entry) => !!entry && typeof entry.id === 'string' && Array.isArray(entry.keys) && entry.keys.every((k) => typeof k === 'string'));
13
+ }
14
+ /** Coming back to the front twice in a row (visible, then focus) asks the account once. */
15
+ const REFRESH_GAP_MS = 2000;
16
+ export function createShortcutOverridesSource(readAccount) {
17
+ let overrides = [];
18
+ let resolved = new WeakMap();
19
+ let latest = 0;
20
+ let applied = 0;
21
+ let lastRefreshAt = -Infinity;
22
+ let bound = false;
23
+ const listeners = new Set();
24
+ const apply = (next) => {
25
+ if (JSON.stringify(next) === JSON.stringify(overrides))
26
+ return;
27
+ overrides = next;
28
+ resolved = new WeakMap();
29
+ listeners.forEach((listener) => listener());
30
+ };
31
+ const refresh = async ({ fresh = false } = {}) => {
32
+ const ticket = ++latest;
33
+ lastRefreshAt = Date.now();
34
+ let settings;
35
+ try {
36
+ settings = await readAccount({ fresh });
37
+ }
38
+ catch {
39
+ settings = undefined;
40
+ }
41
+ // The newest read that answered wins: an older one ending after it says nothing new, and a newer
42
+ // one that failed does not throw away an older one's answer.
43
+ if (settings === undefined || ticket < applied)
44
+ return;
45
+ applied = ticket;
46
+ apply(settings === null ? [] : (parseOverrides(settings.shortcuts) ?? []));
47
+ };
48
+ return {
49
+ get: () => overrides,
50
+ bindings(catalogue) {
51
+ let map = resolved.get(catalogue);
52
+ if (!map) {
53
+ map = resolveBindings(catalogue, overrides);
54
+ resolved.set(catalogue, map);
55
+ }
56
+ return map;
57
+ },
58
+ subscribe(listener) {
59
+ listeners.add(listener);
60
+ return () => {
61
+ listeners.delete(listener);
62
+ };
63
+ },
64
+ refresh,
65
+ bind() {
66
+ const win = globalThis;
67
+ if (bound || typeof win.addEventListener !== 'function' || !win.document?.addEventListener)
68
+ return;
69
+ bound = true;
70
+ void refresh();
71
+ const onFront = () => {
72
+ if (win.document?.visibilityState !== 'visible' || Date.now() - lastRefreshAt < REFRESH_GAP_MS)
73
+ return;
74
+ void refresh({ fresh: true });
75
+ };
76
+ win.document.addEventListener('visibilitychange', onFront);
77
+ win.addEventListener('focus', onFront);
78
+ },
79
+ set: apply,
80
+ };
81
+ }
@@ -0,0 +1,23 @@
1
+ import type { ShortcutAction } from './match.js';
2
+ /** The apps a person tells apart, in the order a settings page shows them. */
3
+ export declare const SHORTCUT_APPS: readonly [Readonly<{
4
+ id: "plantasia-space";
5
+ label: "Plantasia Space";
6
+ }>, Readonly<{
7
+ id: "orbiters";
8
+ label: "Orbiters";
9
+ }>, Readonly<{
10
+ id: "entangled-worlds";
11
+ label: "Entangled Worlds";
12
+ }>];
13
+ export type ShortcutAppId = (typeof SHORTCUT_APPS)[number]['id'];
14
+ export interface ShortcutSection {
15
+ readonly id: string;
16
+ readonly label: string;
17
+ /** The app it belongs to. */
18
+ readonly app: ShortcutAppId;
19
+ /** Its name inside that app (`Play`, `Studio`); none when the app has only this section. */
20
+ readonly part?: string;
21
+ readonly actions: readonly ShortcutAction[];
22
+ }
23
+ export declare const SHORTCUT_SECTIONS: readonly ShortcutSection[];
@@ -0,0 +1,19 @@
1
+ // Every app's catalogue in one place, in the order a settings page shows them. A section is one
2
+ // surface's keys; two actions may share a key only across sections (the Orbiter Studio lies over
3
+ // play, and takes ⌘Z from it while it is open).
4
+ import { EW_PLAYER_SECTION, EW_PLAYER_SHORTCUTS, EW_STUDIO_SECTION, EW_STUDIO_SHORTCUTS } from './entangledWorlds.js';
5
+ import { ORBITERS_PLAY_SECTION, ORBITERS_PLAY_SHORTCUTS, ORBITERS_STUDIO_SECTION, ORBITERS_STUDIO_SHORTCUTS } from './orbiters.js';
6
+ import { SITE_SECTION, SITE_SHORTCUTS } from './site.js';
7
+ /** The apps a person tells apart, in the order a settings page shows them. */
8
+ export const SHORTCUT_APPS = Object.freeze([
9
+ Object.freeze({ id: 'plantasia-space', label: 'Plantasia Space' }),
10
+ Object.freeze({ id: 'orbiters', label: 'Orbiters' }),
11
+ Object.freeze({ id: 'entangled-worlds', label: 'Entangled Worlds' }),
12
+ ]);
13
+ export const SHORTCUT_SECTIONS = Object.freeze([
14
+ Object.freeze({ id: SITE_SECTION, label: 'Plantasia Space', app: 'plantasia-space', actions: SITE_SHORTCUTS }),
15
+ Object.freeze({ id: ORBITERS_PLAY_SECTION, label: 'Orbiters play', app: 'orbiters', part: 'Play', actions: ORBITERS_PLAY_SHORTCUTS }),
16
+ Object.freeze({ id: ORBITERS_STUDIO_SECTION, label: 'Orbiter Studio', app: 'orbiters', part: 'Studio', actions: ORBITERS_STUDIO_SHORTCUTS }),
17
+ Object.freeze({ id: EW_STUDIO_SECTION, label: 'Entangled Worlds studio', app: 'entangled-worlds', part: 'Studio', actions: EW_STUDIO_SHORTCUTS }),
18
+ Object.freeze({ id: EW_PLAYER_SECTION, label: 'Entangled Worlds player', app: 'entangled-worlds', part: 'Player', actions: EW_PLAYER_SHORTCUTS }),
19
+ ]);
@@ -0,0 +1,9 @@
1
+ import type { ShortcutAction } from './match.js';
2
+ export type SiteShortcut = ShortcutAction<'client'>;
3
+ export declare const SITE_SECTION = "site";
4
+ /**
5
+ * Two-key sequences move around the site (G, then H for Home; N, then O for a new Orbiter), as on
6
+ * many sites: a single letter is an Orbiter's on a page that plays one, and the browser holds most
7
+ * ⌘ and ⇧⌘ letters.
8
+ */
9
+ export declare const SITE_SHORTCUTS: readonly SiteShortcut[];
@@ -0,0 +1,25 @@
1
+ // The Plantasia Space site's own keys: the pages around the apps (home, explore, settings). They
2
+ // work on any page of the site, outside an Orbiter or a world.
3
+ export const SITE_SECTION = 'site';
4
+ const site = (id, label, keys) => Object.freeze({ id: `site.${id}`, section: SITE_SECTION, label, defaultKeys: Object.freeze([keys]), scope: 'client' });
5
+ /**
6
+ * Two-key sequences move around the site (G, then H for Home; N, then O for a new Orbiter), as on
7
+ * many sites: a single letter is an Orbiter's on a page that plays one, and the browser holds most
8
+ * ⌘ and ⇧⌘ letters.
9
+ */
10
+ export const SITE_SHORTCUTS = Object.freeze([
11
+ site('settings', 'Open settings', 'Mod+Shift+Comma'),
12
+ site('commands', 'Commands', 'Mod+KeyK'),
13
+ site('search', 'Search', 'Slash'),
14
+ site('go.home', 'Home', 'KeyG KeyH'),
15
+ site('go.playground', 'Playground', 'KeyG KeyP'),
16
+ site('go.notifications', 'Notifications', 'KeyG KeyN'),
17
+ site('go.audios', 'My audios', 'KeyG KeyA'),
18
+ site('go.orbiters', 'My Orbiters', 'KeyG KeyO'),
19
+ site('go.worlds', 'My Entangled Worlds', 'KeyG KeyW'),
20
+ site('go.collections', 'My collections', 'KeyG KeyC'),
21
+ site('go.stars', 'My stars', 'KeyG KeyS'),
22
+ site('new.orbiter', 'New Orbiter', 'KeyN KeyO'),
23
+ site('new.audio', 'New audio', 'KeyN KeyA'),
24
+ site('new.world', 'New Entangled World', 'KeyN KeyW'),
25
+ ]);
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.2.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",
@@ -86,6 +86,10 @@
86
86
  "./audio": {
87
87
  "types": "./dist/audio/index.d.ts",
88
88
  "import": "./dist/audio/index.js"
89
+ },
90
+ "./shortcuts": {
91
+ "types": "./dist/shortcuts/index.d.ts",
92
+ "import": "./dist/shortcuts/index.js"
89
93
  }
90
94
  },
91
95
  "files": [
@@ -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