@aethervtt/shard-platform 0.0.1 → 0.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.
- package/dist/audio-runtime.d.ts +142 -0
- package/dist/audio-runtime.js +16 -0
- package/dist/index.d.ts +144 -2
- package/dist/index.js +5 -1
- package/dist/workers.d.ts +18 -0
- package/dist/workers.js +41 -3
- package/package.json +3 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { AudioClipSource, AudioVoiceDesc, AudioVoiceParams } from './index.js';
|
|
2
|
+
/** Optional, host-neutral extensions. Legacy AudioBackend implementations remain valid. */
|
|
3
|
+
export interface AudioCapabilities {
|
|
4
|
+
scheduled: boolean;
|
|
5
|
+
automation: boolean;
|
|
6
|
+
mixer: boolean;
|
|
7
|
+
media: boolean;
|
|
8
|
+
meters: boolean;
|
|
9
|
+
offline: boolean;
|
|
10
|
+
sessions: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface AudioClock {
|
|
13
|
+
/** Seconds on the rendering clock, frozen while the context is suspended. */
|
|
14
|
+
now(): number;
|
|
15
|
+
/** Monotonic expiry clock, including suspension. Falls back to frame time in legacy hosts. */
|
|
16
|
+
wallNow?(): number;
|
|
17
|
+
/** Independent of rendering frames. Returns an idempotent unsubscribe. */
|
|
18
|
+
wake(callback: () => void, intervalSeconds: number): () => void;
|
|
19
|
+
}
|
|
20
|
+
export interface AudioPreparation {
|
|
21
|
+
readonly clip: AudioClipSource;
|
|
22
|
+
readonly decodedBytes: number;
|
|
23
|
+
readonly encodedBytes: number;
|
|
24
|
+
readonly sampleRate: number;
|
|
25
|
+
readonly samples: number;
|
|
26
|
+
readonly channels: number;
|
|
27
|
+
readonly released: boolean;
|
|
28
|
+
release(): void;
|
|
29
|
+
}
|
|
30
|
+
export interface AudioMemory {
|
|
31
|
+
/** Additional bounded finite-effect kernels, separate from decoded asset PCM. */
|
|
32
|
+
effectBytes: number;
|
|
33
|
+
encodedBytes: number;
|
|
34
|
+
decodedBytes: number;
|
|
35
|
+
budgetBytes: number;
|
|
36
|
+
loading: number;
|
|
37
|
+
pinned: number;
|
|
38
|
+
evictable: number;
|
|
39
|
+
voices: number;
|
|
40
|
+
nodes: number;
|
|
41
|
+
urls: number;
|
|
42
|
+
}
|
|
43
|
+
export type AudioPresentationState = 'preparing' | 'blocked' | 'scheduled' | 'rendering' | 'stopping' | 'ended' | 'failed';
|
|
44
|
+
export interface AudioPresentation {
|
|
45
|
+
state: AudioPresentationState;
|
|
46
|
+
scheduledAt: number;
|
|
47
|
+
endedAt: number;
|
|
48
|
+
error: string | null;
|
|
49
|
+
}
|
|
50
|
+
export interface AudioScheduledVoice extends AudioVoiceDesc {
|
|
51
|
+
/** Absolute rendering-clock time. Omitted means immediate, preserving the old API. */
|
|
52
|
+
when?: number;
|
|
53
|
+
/** Duration in output seconds, not clip seconds. */
|
|
54
|
+
duration?: number;
|
|
55
|
+
lowpass?: number;
|
|
56
|
+
highpass?: number;
|
|
57
|
+
}
|
|
58
|
+
export type AudioAutomationProperty = 'gain' | 'envelope' | 'pitch' | 'lowpass' | 'highpass';
|
|
59
|
+
export interface AudioAutomation {
|
|
60
|
+
property: AudioAutomationProperty;
|
|
61
|
+
at: number;
|
|
62
|
+
value: number;
|
|
63
|
+
duration: number;
|
|
64
|
+
curve?: 'linear' | 'exponential' | 'equal-power-in' | 'equal-power-out';
|
|
65
|
+
}
|
|
66
|
+
export interface AudioSessionOptions {
|
|
67
|
+
maxVoices?: number;
|
|
68
|
+
decodedBudgetBytes?: number;
|
|
69
|
+
maxDecodes?: number;
|
|
70
|
+
priority?: number;
|
|
71
|
+
}
|
|
72
|
+
export interface AudioEffectDesc {
|
|
73
|
+
id: string;
|
|
74
|
+
kind: 'lowpass' | 'highpass' | 'peaking' | 'compressor' | 'delay' | 'reverb';
|
|
75
|
+
frequency: number;
|
|
76
|
+
q: number;
|
|
77
|
+
gainDb: number;
|
|
78
|
+
threshold: number;
|
|
79
|
+
ratio: number;
|
|
80
|
+
attack: number;
|
|
81
|
+
release: number;
|
|
82
|
+
seconds: number;
|
|
83
|
+
feedback: number;
|
|
84
|
+
tailSeconds: number;
|
|
85
|
+
ir?: AudioClipSource;
|
|
86
|
+
}
|
|
87
|
+
export interface AudioSendDesc {
|
|
88
|
+
target: string;
|
|
89
|
+
gain: number;
|
|
90
|
+
pre: boolean;
|
|
91
|
+
}
|
|
92
|
+
export interface AudioBusDesc {
|
|
93
|
+
id: string;
|
|
94
|
+
parent: string;
|
|
95
|
+
gain: number;
|
|
96
|
+
userGain: number;
|
|
97
|
+
effects: readonly AudioEffectDesc[];
|
|
98
|
+
sends: readonly AudioSendDesc[];
|
|
99
|
+
return: boolean;
|
|
100
|
+
}
|
|
101
|
+
export interface AudioMixerGraph {
|
|
102
|
+
buses: readonly AudioBusDesc[];
|
|
103
|
+
maxNodes: number;
|
|
104
|
+
}
|
|
105
|
+
export interface AudioMeter {
|
|
106
|
+
available: boolean;
|
|
107
|
+
peak: number;
|
|
108
|
+
rms: number;
|
|
109
|
+
samples: number;
|
|
110
|
+
sampleRate: number;
|
|
111
|
+
at: number;
|
|
112
|
+
}
|
|
113
|
+
export interface AudioRuntimeExtension {
|
|
114
|
+
readonly capabilities: Readonly<AudioCapabilities>;
|
|
115
|
+
readonly clock: AudioClock;
|
|
116
|
+
readonly activeVoices: number;
|
|
117
|
+
setDecodedBudget(bytes: number): void;
|
|
118
|
+
/** Reserve a synchronous batch before starting any stem; release unused slots in finally. */
|
|
119
|
+
reserveVoices(count: number): () => void;
|
|
120
|
+
prepare(clip: AudioClipSource): Promise<AudioPreparation>;
|
|
121
|
+
/** Reclaims unpinned cache entries. Active voices retain their own pins. */
|
|
122
|
+
collect(): void;
|
|
123
|
+
memory(): AudioMemory;
|
|
124
|
+
play(desc: AudioScheduledVoice): number;
|
|
125
|
+
/** Waits until all requested source starts have been prepared and submitted, or rejects on failure. */
|
|
126
|
+
flush(): Promise<void>;
|
|
127
|
+
presentation(id: number): Readonly<AudioPresentation> | undefined;
|
|
128
|
+
/** In-place route/loop/spatial edits; false asks the caller to recreate at the current phase. */
|
|
129
|
+
reconfigure(id: number, desc: AudioVoiceDesc): boolean;
|
|
130
|
+
automate(id: number, command: AudioAutomation): void;
|
|
131
|
+
setVoiceSend(id: number, target: string, gain: number, rampSeconds: number): void;
|
|
132
|
+
/** Cancels a scheduled start; stops an already audible voice, with a truthful result. */
|
|
133
|
+
cancel(id: number): 'cancelled' | 'already-started' | 'ended';
|
|
134
|
+
configureMixer(graph: AudioMixerGraph): Promise<void>;
|
|
135
|
+
setMixerValue(bus: string, field: string, value: number, rampSeconds: number): void;
|
|
136
|
+
meter(bus: string): AudioMeter;
|
|
137
|
+
/** Headless clocks are explicitly advanced; live clocks ignore this. */
|
|
138
|
+
advance?(seconds: number): void;
|
|
139
|
+
update(id: number, params: AudioVoiceParams): void;
|
|
140
|
+
}
|
|
141
|
+
/** Shared host validation, also used by the headless scheduling model. */
|
|
142
|
+
export declare function validateAudioAutomation(command: AudioAutomation): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
function validateAudioAutomation(command) {
|
|
3
|
+
if (!Number.isFinite(command.at) || command.at < 0 || !Number.isFinite(command.duration) || command.duration < 0 || !Number.isFinite(command.value) || command.value < 0 || (command.property === "pitch" || command.property === "lowpass") && command.value === 0)
|
|
4
|
+
throw new ShardError(
|
|
5
|
+
"audio/invalid-range",
|
|
6
|
+
"Automation requires finite nonnegative times and values, and positive pitch/cutoff",
|
|
7
|
+
{ path: "/automation" }
|
|
8
|
+
);
|
|
9
|
+
if (command.property !== "gain" && command.property !== "envelope" && command.property !== "pitch" && command.property !== "lowpass" && command.property !== "highpass")
|
|
10
|
+
throw new ShardError("audio/invalid-range", "Unknown automation property", {
|
|
11
|
+
path: "/automation/property"
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
validateAudioAutomation
|
|
16
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { ShardError } from '@aethervtt/shard-core';
|
|
1
|
+
import type { HeapStats, HotFunction, ShardError } from '@aethervtt/shard-core';
|
|
2
|
+
import type { AudioRuntimeExtension, AudioSessionOptions } from './audio-runtime.js';
|
|
2
3
|
import type { Workers } from './workers.js';
|
|
4
|
+
export type * from './audio-runtime.js';
|
|
5
|
+
export { validateAudioAutomation } from './audio-runtime.js';
|
|
3
6
|
/**
|
|
4
7
|
* Everything the engine needs from its host. The engine only talks to this interface;
|
|
5
8
|
* hosts (browser, Tauri, headless) provide an implementation at startup.
|
|
@@ -17,6 +20,60 @@ export interface Platform {
|
|
|
17
20
|
* first use. Absent on hosts without threads; `workersOf` falls back to running jobs inline.
|
|
18
21
|
*/
|
|
19
22
|
readonly workers?: Workers;
|
|
23
|
+
/** The host's own performance instruments (long tasks, downloads), for 0062's metrics. */
|
|
24
|
+
readonly performance?: HostPerformance;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* What the host itself can measure about a page or process (0062). Browsers report long tasks and
|
|
28
|
+
* resource timing; Node and Tauri report what they can, and leave out what they can't.
|
|
29
|
+
*/
|
|
30
|
+
export interface HostPerformance {
|
|
31
|
+
/** The user agent, or the runtime's name and version. */
|
|
32
|
+
readonly userAgent: string;
|
|
33
|
+
/**
|
|
34
|
+
* Main-thread tasks of 50 ms or more, as the host reports them: start (on the `clock`'s timeline)
|
|
35
|
+
* and duration, in ms. Returns an unsubscribe function. Absent where the host can't see them.
|
|
36
|
+
*/
|
|
37
|
+
onLongTask?(listener: (startMs: number, durationMs: number) => void): () => void;
|
|
38
|
+
/** Bytes fetched so far (scripts, WASM, assets), over the wire and decoded. Absent without resource timing. */
|
|
39
|
+
downloads?(): HostDownloads;
|
|
40
|
+
/**
|
|
41
|
+
* The JS heap (0074): `v8.getHeapStatistics()` in Node; in a page,
|
|
42
|
+
* `performance.measureUserAgentSpecificMemory()` when isolated (at most every 5 s, so the last
|
|
43
|
+
* result), else `performance.memory` where it exists. Absent or undefined elsewhere.
|
|
44
|
+
*/
|
|
45
|
+
heap?(): HeapStats | undefined;
|
|
46
|
+
/** Garbage collections (Node): start on `performance.now()`'s timeline, pause, and kind. */
|
|
47
|
+
onGc?(listener: (start: number, ms: number, kind: string) => void): () => void;
|
|
48
|
+
/**
|
|
49
|
+
* Starts sampling JavaScript (0074): `node:inspector`'s profiler in Node, the JS Self-Profiling
|
|
50
|
+
* API in pages served with `Document-Policy: js-profiling`. Undefined where it can't.
|
|
51
|
+
*/
|
|
52
|
+
startSampler?(): Promise<HostSampler | undefined>;
|
|
53
|
+
/** Shows a span in the browser's own profiler (`performance.measure` with DevTools track data). */
|
|
54
|
+
measure?(name: string, track: string, start: number, ms: number): void;
|
|
55
|
+
}
|
|
56
|
+
/** A sampling profiler running for a capture's window. */
|
|
57
|
+
export interface HostSampler {
|
|
58
|
+
stop(): Promise<HostSamples>;
|
|
59
|
+
}
|
|
60
|
+
export interface HostSamples {
|
|
61
|
+
/** The functions with the most self time. */
|
|
62
|
+
hottest: HotFunction[];
|
|
63
|
+
/** Stretches of the same top function on `performance.now()`'s timeline (browsers). */
|
|
64
|
+
samples?: {
|
|
65
|
+
name: string;
|
|
66
|
+
start: number;
|
|
67
|
+
ms: number;
|
|
68
|
+
}[];
|
|
69
|
+
/** The V8 `.cpuprofile` (Node). */
|
|
70
|
+
cpuprofile?: unknown;
|
|
71
|
+
}
|
|
72
|
+
export interface HostDownloads {
|
|
73
|
+
/** Bytes over the wire, headers included. Cached responses add nothing. */
|
|
74
|
+
transferred: number;
|
|
75
|
+
/** Bytes after decoding (decompression). */
|
|
76
|
+
decoded: number;
|
|
20
77
|
}
|
|
21
78
|
export * from './workers.js';
|
|
22
79
|
export type MouseButton = 'left' | 'middle' | 'right' | 'back' | 'forward';
|
|
@@ -39,10 +96,35 @@ export type RawInputEvent = {
|
|
|
39
96
|
y: number;
|
|
40
97
|
dx: number;
|
|
41
98
|
dy: number;
|
|
42
|
-
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Wheel movement in pixels (line and page deltas already converted). `x`/`y` (CSS pixels, from
|
|
102
|
+
* the element's top left) and `modifiers` come from sources that know them; a ctrl wheel is a
|
|
103
|
+
* trackpad pinch.
|
|
104
|
+
*/
|
|
105
|
+
| {
|
|
43
106
|
type: 'wheel';
|
|
44
107
|
dx: number;
|
|
45
108
|
dy: number;
|
|
109
|
+
x?: number;
|
|
110
|
+
y?: number;
|
|
111
|
+
modifiers?: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* One pointer (0060): mouse, pen or touch, in CSS pixels from the element's top left. Gestures
|
|
115
|
+
* read these; `mouse-*` and `touch` events still feed the device resources and action maps.
|
|
116
|
+
*/
|
|
117
|
+
| {
|
|
118
|
+
type: 'pointer';
|
|
119
|
+
id: number;
|
|
120
|
+
phase: PointerPhase;
|
|
121
|
+
pointer: PointerKind;
|
|
122
|
+
/** The button that went down (`down`/`up`), or the one held since (`move`). */
|
|
123
|
+
button: MouseButton;
|
|
124
|
+
x: number;
|
|
125
|
+
y: number;
|
|
126
|
+
/** `MODIFIERS` bits held: shift, ctrl, alt, meta. */
|
|
127
|
+
modifiers: number;
|
|
46
128
|
} | {
|
|
47
129
|
type: 'touch';
|
|
48
130
|
id: number;
|
|
@@ -69,10 +151,44 @@ export type RawInputEvent = {
|
|
|
69
151
|
type: 'text';
|
|
70
152
|
text: string;
|
|
71
153
|
};
|
|
154
|
+
export type PointerPhase = 'down' | 'move' | 'up' | 'cancel';
|
|
155
|
+
export type PointerKind = 'mouse' | 'pen' | 'touch';
|
|
156
|
+
/** Bits of a pointer or wheel event's `modifiers`. */
|
|
157
|
+
export declare const MODIFIERS: {
|
|
158
|
+
readonly shift: 1;
|
|
159
|
+
readonly ctrl: 2;
|
|
160
|
+
readonly alt: 4;
|
|
161
|
+
readonly meta: 8;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* What the scene takes from the pointer while a control is enabled (0060). A source stops the
|
|
165
|
+
* host's default handling (`preventDefault`) only for what's listed here and for claimed pointers;
|
|
166
|
+
* everything else passes through to the page.
|
|
167
|
+
*/
|
|
168
|
+
export interface PointerPolicy {
|
|
169
|
+
/** Mouse buttons enabled controls drag with: presses with them, and the context menu for 'right'. */
|
|
170
|
+
buttons: readonly MouseButton[];
|
|
171
|
+
/** Wheel events zoom a control. */
|
|
172
|
+
wheel: boolean;
|
|
173
|
+
/** Touches drive a control: the element gets `touch-action: none`. */
|
|
174
|
+
touch: boolean;
|
|
175
|
+
}
|
|
72
176
|
/** Where raw input comes from: DOM listeners in a browser or webview, nothing when headless. */
|
|
73
177
|
export interface InputSource {
|
|
74
178
|
/** Appends events since the last drain (and polls devices like gamepads). */
|
|
75
179
|
drain(out: RawInputEvent[]): void;
|
|
180
|
+
/**
|
|
181
|
+
* Calls `listener` whenever an event arrives, so an on-demand app wakes to drain it (0052).
|
|
182
|
+
* Returns an unsubscribe function. Polled devices (gamepads) don't call it.
|
|
183
|
+
*/
|
|
184
|
+
onInput?(listener: () => void): () => void;
|
|
185
|
+
/**
|
|
186
|
+
* A consumer took this pointer's gesture (0060): its later events are the scene's
|
|
187
|
+
* (`preventDefault`), and it keeps reporting outside the element (pointer capture) until it ends.
|
|
188
|
+
*/
|
|
189
|
+
claimPointer?(id: number): void;
|
|
190
|
+
/** What enabled controls take; see `PointerPolicy`. Called when it changes. */
|
|
191
|
+
setPointerPolicy?(policy: PointerPolicy): void;
|
|
76
192
|
dispose(): void;
|
|
77
193
|
}
|
|
78
194
|
export type FileChangeKind = 'create' | 'modify' | 'remove';
|
|
@@ -81,10 +197,18 @@ export interface FileChangeEvent {
|
|
|
81
197
|
readonly path: string;
|
|
82
198
|
}
|
|
83
199
|
export interface PlatformFileSystem {
|
|
200
|
+
/** A media/fetch URL for an existing host file; absent when the host cannot expose one. */
|
|
201
|
+
resolveUrl?(path: string): string | undefined;
|
|
84
202
|
/** False on hosts that can only read (for example a static web build). */
|
|
85
203
|
readonly writable: boolean;
|
|
86
204
|
readText(path: string): Promise<string>;
|
|
87
205
|
readBytes(path: string): Promise<Uint8Array>;
|
|
206
|
+
/**
|
|
207
|
+
* `length` bytes from `offset` (fewer at the end of the file): one ranged read, so a terrain
|
|
208
|
+
* page comes out of a pack of hundreds without reading the rest (0071). Absent on hosts that
|
|
209
|
+
* can't; callers read the whole file then.
|
|
210
|
+
*/
|
|
211
|
+
readRange?(path: string, offset: number, length: number): Promise<Uint8Array>;
|
|
88
212
|
writeText(path: string, data: string): Promise<void>;
|
|
89
213
|
writeBytes(path: string, data: Uint8Array): Promise<void>;
|
|
90
214
|
exists(path: string): Promise<boolean>;
|
|
@@ -139,6 +263,10 @@ export interface AudioClipSource {
|
|
|
139
263
|
readonly id: string;
|
|
140
264
|
/** The file as imported (WAV, Ogg, MP3, FLAC). A new array means new contents. */
|
|
141
265
|
readonly bytes: Uint8Array;
|
|
266
|
+
/** Resolved immutable imported-artifact URL for lazy media playback. Bytes may be empty. */
|
|
267
|
+
readonly url?: string;
|
|
268
|
+
readonly channels?: number;
|
|
269
|
+
readonly sampleRate?: number;
|
|
142
270
|
/** `wav`, `vorbis`, `opus`, `mp3`, or `flac`. */
|
|
143
271
|
readonly codec: string;
|
|
144
272
|
/** Seconds. */
|
|
@@ -173,6 +301,11 @@ export interface AudioVoiceParams {
|
|
|
173
301
|
z: number;
|
|
174
302
|
}
|
|
175
303
|
export interface AudioVoiceDesc extends AudioVoiceParams {
|
|
304
|
+
/** Additive post-spatial sends; routes must be declared by the source bus mixer sends. */
|
|
305
|
+
readonly sends?: readonly {
|
|
306
|
+
target: string;
|
|
307
|
+
gain: number;
|
|
308
|
+
}[];
|
|
176
309
|
readonly clip: AudioClipSource;
|
|
177
310
|
/** Bus name; the backend mixes it at the gain last given to `setBus`. */
|
|
178
311
|
readonly bus: string;
|
|
@@ -188,12 +321,21 @@ export type AudioContextState = 'running' | 'suspended' | 'closed' | 'headless';
|
|
|
188
321
|
* voices; a backend only makes the sound (or, headless, records it).
|
|
189
322
|
*/
|
|
190
323
|
export interface AudioBackend {
|
|
324
|
+
/** Advanced features are opt-in; unsupported operations must not silently degrade. */
|
|
325
|
+
readonly runtime?: AudioRuntimeExtension;
|
|
326
|
+
/** An independent session under a shared host context/cache. */
|
|
327
|
+
createSession?(options?: AudioSessionOptions): AudioBackend;
|
|
191
328
|
/** `web`, `headless`, ... */
|
|
192
329
|
readonly kind: string;
|
|
193
330
|
/** Browsers keep the context suspended until a user gesture; voices wait until then. */
|
|
194
331
|
readonly state: AudioContextState;
|
|
195
332
|
/** Returns an id for `update` and `stop`. */
|
|
196
333
|
play(voice: AudioVoiceDesc): number;
|
|
334
|
+
/**
|
|
335
|
+
* Gets a clip ready to play (a browser decodes it), so its first play starts on time instead of
|
|
336
|
+
* starting late into the clip. Optional: backends with nothing to prepare leave it out.
|
|
337
|
+
*/
|
|
338
|
+
preload?(clip: AudioClipSource): void;
|
|
197
339
|
update(voice: number, params: AudioVoiceParams): void;
|
|
198
340
|
/** Stops a voice, fading out over `fade` seconds (default: at once). */
|
|
199
341
|
stop(voice: number, fade?: number): void;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { validateAudioAutomation } from "./audio-runtime.js";
|
|
1
2
|
export * from "./workers.js";
|
|
3
|
+
const MODIFIERS = { shift: 1, ctrl: 2, alt: 4, meta: 8 };
|
|
2
4
|
function createMemoryStorage() {
|
|
3
5
|
const data = /* @__PURE__ */ new Map();
|
|
4
6
|
return {
|
|
@@ -12,5 +14,7 @@ function createMemoryStorage() {
|
|
|
12
14
|
};
|
|
13
15
|
}
|
|
14
16
|
export {
|
|
15
|
-
|
|
17
|
+
MODIFIERS,
|
|
18
|
+
createMemoryStorage,
|
|
19
|
+
validateAudioAutomation
|
|
16
20
|
};
|
package/dist/workers.d.ts
CHANGED
|
@@ -4,6 +4,19 @@ export interface WorkerRunOptions {
|
|
|
4
4
|
readonly transfer?: readonly ArrayBuffer[];
|
|
5
5
|
/** `high` jobs start before every queued `normal` one. Default `normal`. */
|
|
6
6
|
readonly priority?: 'high' | 'normal';
|
|
7
|
+
/** What the profiler calls the job: its span is `worker/<kind>` (0074). Default: `fn`. */
|
|
8
|
+
readonly kind?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* One finished job as the worker timed it (0074), on the calling thread's `performance.now()`
|
|
12
|
+
* timeline: the two contexts' `timeOrigin`s are subtracted, so it lines up with main-thread spans.
|
|
13
|
+
*/
|
|
14
|
+
export interface WorkerJobTiming {
|
|
15
|
+
kind: string;
|
|
16
|
+
/** The pool's thread that ran it, from 0. */
|
|
17
|
+
worker: number;
|
|
18
|
+
start: number;
|
|
19
|
+
ms: number;
|
|
7
20
|
}
|
|
8
21
|
/**
|
|
9
22
|
* A pool of background threads that run functions exported by ES modules. Results never depend on
|
|
@@ -23,6 +36,11 @@ export interface Workers {
|
|
|
23
36
|
run<T>(module: string, fn: string, args: readonly unknown[], options?: WorkerRunOptions): Promise<T>;
|
|
24
37
|
/** Stops every worker. Jobs still running or queued reject. */
|
|
25
38
|
dispose(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Calls `listener` with every finished job's timing, measured inside the worker and carried back
|
|
41
|
+
* with its result, so it costs no extra messages. Returns an unsubscribe function.
|
|
42
|
+
*/
|
|
43
|
+
observe?(listener: (timing: WorkerJobTiming) => void): () => void;
|
|
26
44
|
}
|
|
27
45
|
/** What a host gives the pool for one worker thread. */
|
|
28
46
|
export interface WorkerThread {
|
package/dist/workers.js
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
const clock = globalThis;
|
|
3
|
+
function timeOrigin() {
|
|
4
|
+
return clock.performance?.timeOrigin ?? 0;
|
|
5
|
+
}
|
|
6
|
+
function now() {
|
|
7
|
+
return clock.performance ? clock.performance.now() : Date.now();
|
|
8
|
+
}
|
|
2
9
|
function createWorkerPool(options) {
|
|
3
10
|
const slots = [];
|
|
4
11
|
const high = [];
|
|
5
12
|
const normal = [];
|
|
13
|
+
const observers = /* @__PURE__ */ new Set();
|
|
6
14
|
let nextId = 1;
|
|
7
15
|
let disposed = false;
|
|
8
16
|
const start = (slot) => {
|
|
17
|
+
const index = slots.indexOf(slot);
|
|
9
18
|
const events = {
|
|
10
19
|
message: (message) => {
|
|
11
20
|
const reply = message;
|
|
12
21
|
const job = slot.job;
|
|
13
22
|
if (!job || job.id !== reply.id) return;
|
|
14
23
|
slot.job = void 0;
|
|
24
|
+
const time = reply.time;
|
|
25
|
+
if (time && observers.size > 0) {
|
|
26
|
+
const timing = {
|
|
27
|
+
kind: job.kind,
|
|
28
|
+
worker: index,
|
|
29
|
+
start: time[0] - timeOrigin(),
|
|
30
|
+
ms: time[1]
|
|
31
|
+
};
|
|
32
|
+
for (const listener of observers) listener(timing);
|
|
33
|
+
}
|
|
15
34
|
if (reply.ok) job.resolve(reply.result);
|
|
16
35
|
else job.reject(toError(reply.error));
|
|
17
36
|
pump();
|
|
@@ -70,6 +89,7 @@ function createWorkerPool(options) {
|
|
|
70
89
|
id: nextId++,
|
|
71
90
|
module,
|
|
72
91
|
fn,
|
|
92
|
+
kind: opts.kind ?? fn,
|
|
73
93
|
args,
|
|
74
94
|
transfer: opts.transfer ?? [],
|
|
75
95
|
resolve,
|
|
@@ -78,8 +98,13 @@ function createWorkerPool(options) {
|
|
|
78
98
|
(opts.priority === "high" ? high : normal).push(job);
|
|
79
99
|
pump();
|
|
80
100
|
}),
|
|
101
|
+
observe: (listener) => {
|
|
102
|
+
observers.add(listener);
|
|
103
|
+
return () => observers.delete(listener);
|
|
104
|
+
},
|
|
81
105
|
dispose: () => {
|
|
82
106
|
disposed = true;
|
|
107
|
+
observers.clear();
|
|
83
108
|
const stopped = new ShardError("platform/workers-disposed", "The worker pool was disposed");
|
|
84
109
|
for (const job of [...high.splice(0), ...normal.splice(0)]) job.reject(stopped);
|
|
85
110
|
for (const slot of slots) {
|
|
@@ -99,10 +124,15 @@ function toError(e) {
|
|
|
99
124
|
}
|
|
100
125
|
function createInlineWorkers() {
|
|
101
126
|
const modules = /* @__PURE__ */ new Map();
|
|
127
|
+
const observers = /* @__PURE__ */ new Set();
|
|
102
128
|
let disposed = false;
|
|
103
129
|
return {
|
|
104
130
|
size: 0,
|
|
105
|
-
|
|
131
|
+
observe: (listener) => {
|
|
132
|
+
observers.add(listener);
|
|
133
|
+
return () => observers.delete(listener);
|
|
134
|
+
},
|
|
135
|
+
run: async (module, fn, args, options = {}) => {
|
|
106
136
|
if (disposed)
|
|
107
137
|
throw new ShardError("platform/workers-disposed", "The worker pool was disposed");
|
|
108
138
|
let mod = modules.get(module);
|
|
@@ -119,7 +149,13 @@ function createInlineWorkers() {
|
|
|
119
149
|
hint: "Worker functions are named exports of the module passed to run()."
|
|
120
150
|
});
|
|
121
151
|
}
|
|
122
|
-
|
|
152
|
+
const start = now();
|
|
153
|
+
const result = await f(...args);
|
|
154
|
+
if (observers.size > 0) {
|
|
155
|
+
const timing = { kind: options.kind ?? fn, worker: 0, start, ms: now() - start };
|
|
156
|
+
for (const listener of observers) listener(timing);
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
123
159
|
},
|
|
124
160
|
dispose: () => {
|
|
125
161
|
disposed = true;
|
|
@@ -160,8 +196,10 @@ const handle = async (msg, post) => {
|
|
|
160
196
|
if (typeof f !== 'function') {
|
|
161
197
|
throw Object.assign(new Error('"' + module + '" has no export "' + fn + '"'), { code: 'platform/worker-no-export' })
|
|
162
198
|
}
|
|
199
|
+
const t0 = performance.now()
|
|
163
200
|
const result = await f(...args)
|
|
164
|
-
|
|
201
|
+
const time = [performance.timeOrigin + t0, performance.now() - t0]
|
|
202
|
+
post({ id, ok: true, result, time }, transfers(result))
|
|
165
203
|
} catch (e) {
|
|
166
204
|
post({ id, ok: false, error: { code: e && e.code, message: String((e && e.message) || e), hint: e && e.hint, path: e && e.path } }, [])
|
|
167
205
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aethervtt/shard-platform",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@aethervtt/shard-core": "0.0
|
|
19
|
+
"@aethervtt/shard-core": "0.2.0"
|
|
20
20
|
},
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"provenance": true
|
|
29
29
|
},
|
|
30
30
|
"shard": {
|
|
31
|
-
"buildSha": "
|
|
31
|
+
"buildSha": "32c22a3944ed23f5d1baa90bf7cc9fae724f5fd8"
|
|
32
32
|
}
|
|
33
33
|
}
|