@aethervtt/shard-platform 0.1.0 → 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.
@@ -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.
@@ -34,6 +37,37 @@ export interface HostPerformance {
34
37
  onLongTask?(listener: (startMs: number, durationMs: number) => void): () => void;
35
38
  /** Bytes fetched so far (scripts, WASM, assets), over the wire and decoded. Absent without resource timing. */
36
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;
37
71
  }
38
72
  export interface HostDownloads {
39
73
  /** Bytes over the wire, headers included. Cached responses add nothing. */
@@ -163,10 +197,18 @@ export interface FileChangeEvent {
163
197
  readonly path: string;
164
198
  }
165
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;
166
202
  /** False on hosts that can only read (for example a static web build). */
167
203
  readonly writable: boolean;
168
204
  readText(path: string): Promise<string>;
169
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>;
170
212
  writeText(path: string, data: string): Promise<void>;
171
213
  writeBytes(path: string, data: Uint8Array): Promise<void>;
172
214
  exists(path: string): Promise<boolean>;
@@ -221,6 +263,10 @@ export interface AudioClipSource {
221
263
  readonly id: string;
222
264
  /** The file as imported (WAV, Ogg, MP3, FLAC). A new array means new contents. */
223
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;
224
270
  /** `wav`, `vorbis`, `opus`, `mp3`, or `flac`. */
225
271
  readonly codec: string;
226
272
  /** Seconds. */
@@ -255,6 +301,11 @@ export interface AudioVoiceParams {
255
301
  z: number;
256
302
  }
257
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
+ }[];
258
309
  readonly clip: AudioClipSource;
259
310
  /** Bus name; the backend mixes it at the gain last given to `setBus`. */
260
311
  readonly bus: string;
@@ -270,6 +321,10 @@ export type AudioContextState = 'running' | 'suspended' | 'closed' | 'headless';
270
321
  * voices; a backend only makes the sound (or, headless, records it).
271
322
  */
272
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;
273
328
  /** `web`, `headless`, ... */
274
329
  readonly kind: string;
275
330
  /** Browsers keep the context suspended until a user gesture; voices wait until then. */
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateAudioAutomation } from "./audio-runtime.js";
1
2
  export * from "./workers.js";
2
3
  const MODIFIERS = { shift: 1, ctrl: 2, alt: 4, meta: 8 };
3
4
  function createMemoryStorage() {
@@ -14,5 +15,6 @@ function createMemoryStorage() {
14
15
  }
15
16
  export {
16
17
  MODIFIERS,
17
- createMemoryStorage
18
+ createMemoryStorage,
19
+ validateAudioAutomation
18
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
- run: async (module, fn, args) => {
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
- return await f(...args);
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
- post({ id, ok: true, result }, transfers(result))
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.1.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.1.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": "8e14c9896f82a3554b156f1ffdd68f2cc3e7d78a"
31
+ "buildSha": "32c22a3944ed23f5d1baa90bf7cc9fae724f5fd8"
32
32
  }
33
33
  }