@aethervtt/shard-audio-preview 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Dias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @aethervtt/shard-audio-preview
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Part of [Shard](https://github.com/aethervtt/shard), a WebGPU-first 2D/3D game engine in TypeScript. See the repository for documentation.
@@ -0,0 +1,62 @@
1
+ import { type JsonObject } from '@aethervtt/shard-core';
2
+ export interface AudioRenderBundle {
3
+ /** Serialized AudioPreviewPlan; runtime handles must pass through its serializer first. */
4
+ plan: JsonObject;
5
+ assets: {
6
+ guid: string;
7
+ path: string;
8
+ type: string;
9
+ value: unknown;
10
+ }[];
11
+ }
12
+ export declare function renderAudioBundle(bundle: AudioRenderBundle, options?: {
13
+ signal?: AbortSignal;
14
+ progress?: (value: Record<string, unknown>) => void;
15
+ }): Promise<{
16
+ wav: Uint8Array<ArrayBufferLike>;
17
+ measured: {
18
+ peak: number;
19
+ rms: number;
20
+ clipped: number;
21
+ nonfinite: number;
22
+ sampleRate: number;
23
+ channels: number;
24
+ frames: number;
25
+ duration: number;
26
+ };
27
+ trace: {
28
+ zones: {
29
+ at: number;
30
+ buses: string[];
31
+ }[];
32
+ events: {
33
+ entries: import("@aethervtt/shard-audio").AudioEventTraceEntry[];
34
+ oldestCursor: number;
35
+ nextCursor: number;
36
+ truncated: boolean;
37
+ };
38
+ music: {
39
+ entries: import("@aethervtt/shard-audio").AudioEventTraceEntry[];
40
+ oldestCursor: number;
41
+ nextCursor: number;
42
+ truncated: boolean;
43
+ };
44
+ controls: {
45
+ index: number;
46
+ requestedAt: number;
47
+ appliedAt: number;
48
+ }[];
49
+ sources: {
50
+ id: number;
51
+ presentation: Readonly<import("@aethervtt/shard-platform").AudioPresentation> | undefined;
52
+ }[];
53
+ controlRate: number;
54
+ quantumFrames: number;
55
+ };
56
+ capabilities: Readonly<import("@aethervtt/shard-platform").AudioCapabilities>;
57
+ backend: {
58
+ kind: string;
59
+ userAgent: string;
60
+ sampleRate: number;
61
+ };
62
+ }>;
package/dist/index.js ADDED
@@ -0,0 +1,317 @@
1
+ import { assetServer } from "@aethervtt/shard-assets";
2
+ import {
3
+ AudioAcoustics,
4
+ AudioClips,
5
+ AudioListener,
6
+ AudioPreviewPlan,
7
+ AudioTransport,
8
+ acquireAudioSnapshot,
9
+ audioAcousticsPlugin,
10
+ audioEvents,
11
+ audioEventsPlugin,
12
+ audioMixerPlugin,
13
+ audioMusicPlugin,
14
+ audioPlugin,
15
+ audioState,
16
+ configureAudioMixer,
17
+ describeAudioEvents,
18
+ describeMusic,
19
+ pauseMusic,
20
+ playAudioEvent,
21
+ playMusicStinger,
22
+ prepareAudio,
23
+ prepareMusic,
24
+ prepareMusicSection,
25
+ ReverbZone,
26
+ releaseAudioSnapshot,
27
+ requestMusicSection,
28
+ resumeMusic,
29
+ seekMusic,
30
+ setAudioParameter,
31
+ setAudioSnapshotWeight,
32
+ setBus,
33
+ setMusicParameter,
34
+ startMusic,
35
+ stopAudioEvent,
36
+ stopMusic,
37
+ updateMusic,
38
+ validateAudioPreview
39
+ } from "@aethervtt/shard-audio";
40
+ import { ShardError } from "@aethervtt/shard-core";
41
+ import {
42
+ audioBufferWav,
43
+ createWebAudioBackend,
44
+ measureAudioBuffer
45
+ } from "@aethervtt/shard-platform-web";
46
+ import { App } from "@aethervtt/shard-runtime";
47
+ import { Transform, TransformPlugin } from "@aethervtt/shard-transform";
48
+ async function renderAudioBundle(bundle, options = {}) {
49
+ if (typeof OfflineAudioContext === "undefined" || typeof OfflineAudioContext.prototype.suspend !== "function" || typeof OfflineAudioContext.prototype.resume !== "function")
50
+ throw new ShardError(
51
+ "audio-preview/unsupported-capability",
52
+ "This browser lacks suspended offline rendering",
53
+ {
54
+ hint: "Use a browser with OfflineAudioContext suspend/resume, or provide a host export adapter."
55
+ }
56
+ );
57
+ const plan = AudioPreviewPlan.deserialize(bundle.plan);
58
+ validateAudioPreview(plan);
59
+ const frames = Math.ceil((plan.duration + plan.tailSeconds) * plan.sampleRate);
60
+ if (frames * plan.channels * 6 + 44 > 64 * 1024 * 1024)
61
+ throw new ShardError("audio/preview-unavailable", "Preview output exceeds 64 MiB");
62
+ const context = new OfflineAudioContext(plan.channels, frames, plan.sampleRate);
63
+ const backend = createWebAudioBackend({
64
+ context,
65
+ fixedListener: true,
66
+ maxVoices: 4096,
67
+ maxHostVoices: 4096
68
+ });
69
+ const app = new App({ seed: plan.seed }).addPlugin(
70
+ TransformPlugin,
71
+ audioPlugin({ backend, maxVoices: 4096 }),
72
+ audioEventsPlugin(),
73
+ audioMixerPlugin(),
74
+ ...plan.zones.length ? [audioAcousticsPlugin()] : [],
75
+ audioMusicPlugin()
76
+ );
77
+ const world = app.world;
78
+ try {
79
+ await app.init();
80
+ const assets = assetServer(world), runtime = audioState(world).backend.runtime;
81
+ for (const item of bundle.assets)
82
+ assets.virtual(item.guid, item.path, item.type, () => item.value, { lazy: true });
83
+ for (const item of bundle.assets) await assets.load(item.guid);
84
+ const decoded = [];
85
+ for (const clip of world.resource(AudioClips).values())
86
+ decoded.push(await runtime.prepare(clip));
87
+ if (plan.mixer) await configureAudioMixer(world, plan.mixer);
88
+ const eventLeases = [];
89
+ for (const event of plan.events) eventLeases.push(await prepareAudio(world, event.asset));
90
+ const programs = /* @__PURE__ */ new Map();
91
+ for (const entry of plan.music) {
92
+ const program = await prepareMusic(world, entry.asset);
93
+ for (const section of program.definition.sections)
94
+ await prepareMusicSection(world, program, section.id);
95
+ programs.set(entry.id, program);
96
+ }
97
+ const eventIds = /* @__PURE__ */ new Map(), musicIds = /* @__PURE__ */ new Map(), emitters = /* @__PURE__ */ new Map(), snapshots = /* @__PURE__ */ new Map();
98
+ for (const entry of plan.music)
99
+ musicIds.set(
100
+ entry.id,
101
+ startMusic(world, programs.get(entry.id), {
102
+ section: entry.section || void 0,
103
+ at: entry.at,
104
+ parameters: entry.parameters
105
+ })
106
+ );
107
+ for (const entry of plan.zones)
108
+ world.spawn([Transform, { translation: entry.position }], [ReverbZone, entry.zone]);
109
+ const zoneTrace = [];
110
+ let lastZones = "";
111
+ const listener = world.spawn(Transform, AudioListener);
112
+ const voices = plan.voices.map(
113
+ (voice) => runtime.play({
114
+ clip: world.resource(AudioClips).require(plan.clips.find((c) => c.id === voice.clip).asset),
115
+ bus: voice.bus,
116
+ when: voice.at,
117
+ duration: voice.duration || (voice.loop ? plan.duration - voice.at : void 0),
118
+ offset: voice.offset,
119
+ loop: voice.loop,
120
+ gain: 10 ** (voice.gainDb / 20),
121
+ pitch: voice.pitch,
122
+ x: voice.position[0],
123
+ y: voice.position[1],
124
+ z: voice.position[2],
125
+ spatial: voice.spatial ? {
126
+ panning: "equal-power",
127
+ distanceModel: "inverse",
128
+ refDistance: voice.minDistance,
129
+ maxDistance: voice.maxDistance,
130
+ rolloffFactor: 1
131
+ } : null
132
+ })
133
+ );
134
+ for (const command of plan.automation)
135
+ runtime.automate(voices[plan.voices.findIndex((v) => v.id === command.voice)], command);
136
+ const controls = plan.controls.map((control, index) => ({ control, index })).sort((a, b) => a.control.at - b.control.at || a.index - b.index);
137
+ const applied = [];
138
+ let nextControl = 0, last = 0, stopped = false;
139
+ const target = (map, name) => {
140
+ const id = map.get(name);
141
+ if (id === void 0)
142
+ throw new ShardError("audio/preview-unavailable", `Preview target ${name} has not started`);
143
+ return id;
144
+ };
145
+ async function tick() {
146
+ const now = context.currentTime;
147
+ if (options.signal?.aborted)
148
+ throw new ShardError("audio-preview/disposed", "Offline preview cancelled");
149
+ options.progress?.({ stage: "tick", time: now, nextControl });
150
+ for (const entry of plan.events)
151
+ if (!eventIds.has(entry.id) && now >= entry.at) {
152
+ const entity = world.spawn(Transform);
153
+ world.set(entity, Transform, { translation: entry.position });
154
+ emitters.set(entry.id, entity);
155
+ eventIds.set(
156
+ entry.id,
157
+ playAudioEvent(world, entry.asset, {
158
+ entity,
159
+ parameters: entry.parameters,
160
+ clock: entry.clock,
161
+ pitchLinked: entry.pitchLinked
162
+ })
163
+ );
164
+ }
165
+ while (nextControl < controls.length && controls[nextControl].control.at <= now) {
166
+ const { control: c, index } = controls[nextControl++], opts = c.options;
167
+ switch (c.kind) {
168
+ case "event.parameter":
169
+ setAudioParameter(world, target(eventIds, c.target), c.name, c.value);
170
+ break;
171
+ case "global.parameter":
172
+ setAudioParameter(world, null, c.name, c.value);
173
+ break;
174
+ case "event.stop":
175
+ stopAudioEvent(world, target(eventIds, c.target), opts);
176
+ break;
177
+ case "event.position": {
178
+ const entity = emitters.get(c.target);
179
+ if (entity === void 0)
180
+ throw new ShardError("audio/preview-unavailable", "Emitter has not started");
181
+ world.set(entity, Transform, { translation: c.position });
182
+ break;
183
+ }
184
+ case "listener.position":
185
+ world.set(listener, Transform, { translation: c.position });
186
+ break;
187
+ case "music.parameter":
188
+ setMusicParameter(world, target(musicIds, c.target), c.name, c.value);
189
+ break;
190
+ case "music.section":
191
+ requestMusicSection(world, target(musicIds, c.target), c.name, opts);
192
+ break;
193
+ case "music.stinger":
194
+ await playMusicStinger(world, target(musicIds, c.target), c.name, opts);
195
+ break;
196
+ case "music.pause":
197
+ pauseMusic(world, target(musicIds, c.target));
198
+ break;
199
+ case "music.resume":
200
+ resumeMusic(world, target(musicIds, c.target));
201
+ break;
202
+ case "music.seek":
203
+ seekMusic(world, target(musicIds, c.target), Number(c.value));
204
+ break;
205
+ case "snapshot.acquire":
206
+ if (snapshots.has(c.target)) releaseAudioSnapshot(world, snapshots.get(c.target));
207
+ snapshots.set(
208
+ c.target,
209
+ await acquireAudioSnapshot(world, c.asset, c.value == null ? 1 : Number(c.value))
210
+ );
211
+ break;
212
+ case "snapshot.set":
213
+ setAudioSnapshotWeight(world, target(snapshots, c.target), Number(c.value));
214
+ break;
215
+ case "snapshot.release":
216
+ releaseAudioSnapshot(world, target(snapshots, c.target));
217
+ break;
218
+ case "bus.volume":
219
+ setBus(world, c.target, { volume: Number(c.value) });
220
+ break;
221
+ case "transport.pause":
222
+ world.resource(AudioTransport).paused = Boolean(c.value);
223
+ break;
224
+ }
225
+ applied.push({ index, requestedAt: c.at, appliedAt: now });
226
+ }
227
+ if (!stopped && now >= plan.duration) {
228
+ stopped = true;
229
+ for (const instance of audioEvents(world).instances)
230
+ if (instance) stopAudioEvent(world, instance.id, { release: 0 });
231
+ for (const id of musicIds.values()) stopMusic(world, id);
232
+ for (const id of voices) runtime.cancel(id);
233
+ }
234
+ app.update(now - last);
235
+ const acoustics = world.tryResource(AudioAcoustics);
236
+ if (acoustics) {
237
+ const buses = acoustics.slots.filter((slot) => slot.weight > 0).map((slot) => slot.bus);
238
+ const key = buses.join(",");
239
+ if (key !== lastZones) {
240
+ zoneTrace.push({ at: now, buses });
241
+ lastZones = key;
242
+ }
243
+ }
244
+ updateMusic(world);
245
+ last = now;
246
+ await runtime.flush();
247
+ }
248
+ await tick();
249
+ const total = frames / plan.sampleRate;
250
+ let rendering;
251
+ let step = 1;
252
+ for (; ; ) {
253
+ const wanted = Math.min(
254
+ step / 60,
255
+ nextControl < controls.length ? controls[nextControl].control.at : Infinity,
256
+ ...plan.events.filter((e) => !eventIds.has(e.id)).map((e) => e.at),
257
+ ...plan.music.filter((e) => !musicIds.has(e.id)).map((e) => e.at),
258
+ stopped ? Infinity : plan.duration
259
+ );
260
+ const at = Math.max(
261
+ Math.round(last * plan.sampleRate / 128) + 1,
262
+ Math.ceil(wanted * plan.sampleRate / 128)
263
+ ) * 128 / plan.sampleRate;
264
+ if (at >= total) break;
265
+ options.progress?.({ stage: "suspend", time: context.currentTime, target: at });
266
+ const suspended = context.suspend(at + 0.5 / plan.sampleRate);
267
+ if (!rendering) rendering = context.startRendering();
268
+ else await context.resume();
269
+ await suspended;
270
+ await tick();
271
+ step = Math.floor(context.currentTime * 60) + 1;
272
+ }
273
+ if (!rendering) rendering = context.startRendering();
274
+ else await context.resume();
275
+ const buffer = await rendering, measured = measureAudioBuffer(buffer);
276
+ if (measured.nonfinite)
277
+ throw new ShardError("audio/preview-unavailable", "Rendered nonfinite samples");
278
+ const trace = {
279
+ zones: zoneTrace,
280
+ events: describeAudioEvents(world).trace,
281
+ music: describeMusic(world).trace,
282
+ controls: applied,
283
+ sources: voices.map((id) => ({ id, presentation: runtime.presentation(id) })),
284
+ controlRate: 60,
285
+ quantumFrames: 128
286
+ };
287
+ for (const lease of decoded) lease.release();
288
+ for (const lease of eventLeases) lease.release();
289
+ for (const program of programs.values()) program.release();
290
+ return {
291
+ wav: audioBufferWav(buffer),
292
+ measured,
293
+ trace,
294
+ capabilities: runtime.capabilities,
295
+ backend: {
296
+ kind: "web-audio-offline",
297
+ userAgent: navigator.userAgent,
298
+ sampleRate: context.sampleRate
299
+ }
300
+ };
301
+ } finally {
302
+ try {
303
+ await app.dispose();
304
+ } finally {
305
+ backend.dispose?.();
306
+ if (options.signal?.aborted && context.state === "suspended") {
307
+ try {
308
+ await context.resume();
309
+ } catch {
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+ export {
316
+ renderAudioBundle
317
+ };
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-audio-preview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "LICENSE",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "@aethervtt/shard-assets": "0.2.0",
20
+ "@aethervtt/shard-audio": "0.2.0",
21
+ "@aethervtt/shard-core": "0.2.0",
22
+ "@aethervtt/shard-platform-web": "0.2.0",
23
+ "@aethervtt/shard-runtime": "0.2.0",
24
+ "@aethervtt/shard-transform": "0.2.0"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/aethervtt/shard.git",
29
+ "directory": "packages/audio-preview"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "provenance": true
34
+ },
35
+ "shard": {
36
+ "buildSha": "32c22a3944ed23f5d1baa90bf7cc9fae724f5fd8"
37
+ }
38
+ }