@zakkster/lite-audio 1.0.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/Audio.d.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @zakkster/lite-audio - Zero-GC reactive Web Audio engine.
3
+ * Peers: @zakkster/lite-signal, @zakkster/lite-audio-pool.
4
+ */
5
+
6
+ /**
7
+ * lite-signal's callable signal shape. A signal is a getter function that
8
+ * doubles as an object with .peek() and (for writable signals) .set().
9
+ * lite-audio exposes read-only signals to consumers; internal writes go
10
+ * through explicit setter methods on the engine.
11
+ */
12
+ export interface ReadSignal<T> {
13
+ (): T;
14
+ peek(): T;
15
+ }
16
+ export interface WriteSignal<T> extends ReadSignal<T> {
17
+ set(value: T): void;
18
+ }
19
+
20
+ export type LoadState = 'idle' | 'loading' | 'ready' | 'error';
21
+ export type CtxState = 'suspended' | 'running' | 'interrupted' | 'closed';
22
+
23
+ export interface SoundConfig {
24
+ /** Source URL list, ordered by preference. First supported extension wins. */
25
+ src: string[];
26
+ /** Bus to route this sound to. Defaults to 'sfx'. Must exist in `opts.buses`. */
27
+ bus?: string;
28
+ /** Default volume for this sound (playOpts fallback). */
29
+ volume?: number;
30
+ /** Default pitch variation range for playOpts (e.g. 0.1 = plus/minus 10%). */
31
+ pitchVar?: number;
32
+ }
33
+
34
+ export interface LiteAudioOptions {
35
+ /**
36
+ * User-facing bus names. Do not include 'master' - it is always the top
37
+ * of the graph and is controlled via setMuted() / muted().
38
+ */
39
+ buses?: string[];
40
+
41
+ /** Voices per bus pool. Defaults to 32. */
42
+ poolCapacity?: number;
43
+
44
+ /**
45
+ * Maximum number of distinct sounds that can be queued for play before
46
+ * unlock. Same-sound repeats collapse to latest-per-sound; new distinct
47
+ * sounds past the limit are silently dropped.
48
+ */
49
+ queueLimit?: number;
50
+
51
+ /** localStorage key for master mute. Defaults to 'lite_audio_muted' (manager parity). */
52
+ mutedStorageKey?: string;
53
+
54
+ /** Injectable for tests. Defaults to globalThis.fetch. */
55
+ fetch?: typeof fetch;
56
+
57
+ /** Injectable for tests. Defaults to globalThis.window. */
58
+ window?: any;
59
+
60
+ /** Injectable for tests. Defaults to globalThis.document. */
61
+ document?: any;
62
+ }
63
+
64
+ export class LiteAudio {
65
+ constructor(opts?: LiteAudioOptions);
66
+
67
+ /** Wire to an AudioContext. Creates one if none passed. Idempotent per-instance. */
68
+ init(ctx?: AudioContext): Promise<void>;
69
+
70
+ /**
71
+ * Fetch, decode, and register sounds. Resolves after every sound has
72
+ * settled (loaded or errored). Pools are built once per touched bus at
73
+ * the end. Safe to call multiple times to add more sounds.
74
+ */
75
+ defineSounds(config: Record<string, SoundConfig>): Promise<void>;
76
+
77
+ /**
78
+ * Positional hot path: no options object. Returns a bus-tagged voice handle,
79
+ * or -1 on any skip (unknown sound, not-yet-loaded, or context locked).
80
+ * Locked-context plays are queued and flushed on the first user gesture (D3).
81
+ *
82
+ * The handle is `busIndex * 2^32 + poolHandle`: a plain number, exact well
83
+ * inside 2^53, opaque to callers. The bus tag is what makes a handle name one
84
+ * voice engine-wide - every bus's pool counts generations from zero on its own,
85
+ * so the raw pool handles collide across buses by construction.
86
+ */
87
+ play(soundId: string, volume?: number, pan?: number, pitch?: number): number;
88
+
89
+ /**
90
+ * Sugar layer over play(): options object with pitchVar resolution.
91
+ * Not the hot path - a frame loop should call play() positionally.
92
+ */
93
+ playOpts(soundId: string, opts?: {
94
+ volume?: number;
95
+ pan?: number;
96
+ pitch?: number;
97
+ pitchVar?: number;
98
+ }): number;
99
+
100
+ /** Stop a specific voice by handle. Stale handles are silent no-ops. */
101
+ stop(handle: number): void;
102
+
103
+ /** Is this exact voice still sounding? False if stolen, stopped, or played out. */
104
+ isPlaying(handle: number): boolean;
105
+
106
+ /** Name of the bus that issued this handle, or null. */
107
+ busOf(handle: number): string | null;
108
+
109
+ /** Voices sounding on a bus, or across every bus when called with no argument. */
110
+ activeCount(busName?: string): number;
111
+
112
+ /** Stop every voice on a named bus. */
113
+ stopBus(busName: string): void;
114
+
115
+ /** Stop every voice across every bus. */
116
+ stopAll(): void;
117
+
118
+ // ---------- Bus + master controls --------------------------------------
119
+ setBusVolume(busName: string, volume: number): void;
120
+ setBusMuted(busName: string, muted: boolean): void;
121
+ setMuted(state: boolean): void;
122
+
123
+ // ---------- Reactive readables -----------------------------------------
124
+ /** Current AudioContext state signal. */
125
+ ctxState(): ReadSignal<CtxState>;
126
+ /** Whether the context has been unlocked by a user gesture. */
127
+ unlocked(): ReadSignal<boolean>;
128
+ /** Master mute signal (persisted to localStorage). */
129
+ muted(): ReadSignal<boolean>;
130
+ /** Per-sound load state signal. Undefined if the sound is not registered. */
131
+ loadState(soundId: string): ReadSignal<LoadState> | undefined;
132
+ /** Per-bus volume signal. Undefined for unknown bus name. */
133
+ busVolume(busName: string): ReadSignal<number> | undefined;
134
+ /** Per-bus mute signal. Undefined for unknown bus name. */
135
+ busMuted(busName: string): ReadSignal<boolean> | undefined;
136
+ /** Direct access to a bus's GainNode for advanced effect insertion. */
137
+ busNode(busName: string): GainNode | undefined;
138
+ /** Direct access to the master GainNode. */
139
+ masterNode(): GainNode | null;
140
+
141
+ /**
142
+ * Stop every voice, tear down pools and buses, dispose signal effects,
143
+ * detach DOM listeners, release references. Idempotent. Does not close
144
+ * the AudioContext - the app owns that.
145
+ */
146
+ destroy(): void;
147
+ }
148
+
149
+ export default LiteAudio;
package/Audio.js ADDED
@@ -0,0 +1,671 @@
1
+ /** @zakkster/lite-audio - Zero-GC reactive Web Audio engine */
2
+
3
+ import { signal, effect, batch, dispose } from '@zakkster/lite-signal';
4
+ import { AudioPool } from '@zakkster/lite-audio-pool';
5
+
6
+ /**
7
+ * Persistence key: byte-identical to lite-audio-manager so a game migrating
8
+ * from the manager to lite-audio does not lose the player's saved mute
9
+ * preference. Do not rename without a migration path.
10
+ */
11
+ const MUTED_STORAGE_KEY = 'lite_audio_muted';
12
+
13
+ /**
14
+ * Time constants held under lite-audio's roof. FADE_SECONDS is our default
15
+ * for non-emergency stops - short enough to feel instant, long enough to
16
+ * avoid clicks. RAMP_TC is the setTargetAtTime time constant for bus writes
17
+ * (D1): 10ms means the mixer settles ~63% within 10ms and effectively fully
18
+ * within 30ms, imperceptible to a listener but click-free.
19
+ */
20
+ const FADE_SECONDS = 0.02;
21
+ const RAMP_TC = 0.01;
22
+
23
+ /**
24
+ * Handle namespace. A pool handle is a full uint32 - [gen:24][channel:8] - with no
25
+ * spare bits, and every bus runs its own pool counting generations from zero. So the
26
+ * first play on EVERY bus returns the same uint32 (channel 0, generation 0), and a
27
+ * handle alone cannot say which bus issued it. The engine therefore tags the bus
28
+ * above the pool's 32 bits: handle = busIndex * 2^32 + poolHandle.
29
+ *
30
+ * Handles stay plain numbers, exact to 2^53, which leaves room for 2^21 buses. The
31
+ * low half round-trips through ToUint32 for free (h >>> 0), so decoding costs one
32
+ * shift and one divide, and stop() becomes an O(1) lookup instead of a broadcast
33
+ * across every pool hoping the generation check rejects the wrong ones.
34
+ */
35
+ const BUS_STRIDE = 4294967296; // 2^32
36
+
37
+ /**
38
+ * The unlock queue is bounded to bound worst-case memory: if a spammy loop
39
+ * calls play() a thousand times before the first user gesture, we do not
40
+ * want that to become a thousand-voice burst on unlock. Latest-per-sound
41
+ * so the most recent intent wins.
42
+ */
43
+ const DEFAULT_QUEUE_LIMIT = 32;
44
+
45
+ /**
46
+ * DOM events wired at capture phase to intercept the very first user gesture
47
+ * on iOS/Safari, exact set the manager uses. Do not add 'click' - some pages
48
+ * dispatch synthetic clicks that would falsely resume the context.
49
+ */
50
+ const UNLOCK_EVENTS = ['touchstart', 'touchend', 'mousedown', 'keydown'];
51
+
52
+ /**
53
+ * Storage-safe read (SSR / Workers / sandboxed iframes / Safari private mode).
54
+ * @param {string} key
55
+ * @param {any} fallback
56
+ */
57
+ function readStorage(key, fallback) {
58
+ try {
59
+ if (typeof localStorage === 'undefined') return fallback;
60
+ const v = localStorage.getItem(key);
61
+ return v === null ? fallback : v;
62
+ } catch { return fallback; }
63
+ }
64
+
65
+ function writeStorage(key, value) {
66
+ try {
67
+ if (typeof localStorage === 'undefined') return;
68
+ localStorage.setItem(key, String(value));
69
+ } catch { /* Safari private mode / storage disabled */ }
70
+ }
71
+
72
+ // ---------- Loader ---------------------------------------------------------
73
+
74
+ /**
75
+ * Format-fallback probe. Given a list of URLs like ['a.webm', 'b.mp3'], picks
76
+ * the first one whose extension the runtime claims to support via a
77
+ * throwaway <audio>.canPlayType('audio/<ext>') check. In Node or environments
78
+ * without <audio>, all URLs are considered supported and the first wins.
79
+ */
80
+ function pickSupportedSrc(srcs) {
81
+ if (!Array.isArray(srcs) || srcs.length === 0) return null;
82
+ if (typeof document === 'undefined' || typeof Audio === 'undefined') return srcs[0];
83
+ let probe;
84
+ try { probe = new Audio(); } catch { return srcs[0]; }
85
+ for (const url of srcs) {
86
+ const ext = extensionOf(url);
87
+ const mime = ext ? MIME_BY_EXT[ext] : null;
88
+ if (!mime) continue;
89
+ const verdict = probe.canPlayType(mime);
90
+ if (verdict === 'probably' || verdict === 'maybe') return url;
91
+ }
92
+ return srcs[0]; // fall back to first if nothing matched, best-effort
93
+ }
94
+
95
+ const MIME_BY_EXT = {
96
+ mp3: 'audio/mpeg',
97
+ ogg: 'audio/ogg',
98
+ oga: 'audio/ogg',
99
+ opus: 'audio/ogg; codecs=opus',
100
+ wav: 'audio/wav',
101
+ m4a: 'audio/mp4',
102
+ aac: 'audio/aac',
103
+ webm: 'audio/webm',
104
+ flac: 'audio/flac',
105
+ };
106
+
107
+ function extensionOf(url) {
108
+ const m = /\.([a-z0-9]{2,5})(\?|#|$)/i.exec(url);
109
+ return m ? m[1].toLowerCase() : null;
110
+ }
111
+
112
+ // ---------- Engine ---------------------------------------------------------
113
+
114
+ /**
115
+ * lite-audio v1.0.0 engine. SFX layer over @zakkster/lite-audio-pool, one
116
+ * pool per bus. Signals drive continuous state (bus volumes, master mute,
117
+ * context state, load state); one-shots stay imperative through play().
118
+ *
119
+ * The typical lifecycle:
120
+ * const audio = new LiteAudio({ buses: ['sfx', 'ui'] });
121
+ * await audio.init(new AudioContext());
122
+ * await audio.defineSounds({ laser: { src: ['/laser.mp3'], bus: 'sfx' } });
123
+ * const h = audio.play('laser', 1, 0, 1);
124
+ * audio.stop(h);
125
+ * audio.destroy();
126
+ *
127
+ * Everything past init() is safe to call before unlock: play() enqueues,
128
+ * defineSounds() starts fetching, bus volumes accept writes. The unlock
129
+ * gesture flushes the queue and starts the audible clock.
130
+ */
131
+ export class LiteAudio {
132
+ /**
133
+ * @param {Object} [opts]
134
+ * @param {string[]} [opts.buses=['sfx','ui','voice']] - Bus names. 'master' is implicit.
135
+ * @param {number} [opts.poolCapacity=32] - Voices per bus pool.
136
+ * @param {number} [opts.queueLimit=32] - Bound on pre-unlock play queue.
137
+ * @param {string} [opts.mutedStorageKey='lite_audio_muted'] - localStorage key.
138
+ * @param {Function} [opts.fetch] - Injectable for tests. Defaults to globalThis.fetch.
139
+ * @param {Object} [opts.window] - Injectable for tests. Defaults to globalThis.window.
140
+ * @param {Object} [opts.document] - Injectable for tests. Defaults to globalThis.document.
141
+ */
142
+ constructor(opts = {}) {
143
+ this._busNames = opts.buses || ['sfx', 'ui', 'voice'];
144
+ this._poolCapacity = opts.poolCapacity ?? 32;
145
+ this._queueLimit = opts.queueLimit ?? DEFAULT_QUEUE_LIMIT;
146
+ this._mutedKey = opts.mutedStorageKey ?? MUTED_STORAGE_KEY;
147
+ this._fetch = opts.fetch || (typeof fetch !== 'undefined' ? fetch : null);
148
+ this._window = opts.window ?? (typeof window !== 'undefined' ? window : null);
149
+ this._document = opts.document ?? (typeof document !== 'undefined' ? document : null);
150
+
151
+ // Read persisted mute BEFORE constructing the master signal so the
152
+ // very first emitted value already carries the user's preference.
153
+ const initialMuted = readStorage(this._mutedKey, 'false') === 'true';
154
+
155
+ // Public signals (readable via getter methods). Callable getters +
156
+ // .set/.peek; effects can depend on these directly.
157
+ this._sMuted = signal(initialMuted);
158
+ this._sUnlocked = signal(false);
159
+ this._sCtxState = signal('suspended');
160
+
161
+ // Per-bus state, populated in init(). Each entry:
162
+ // { index, gain, volume:signal, muted:signal, pool, effectHandle }
163
+ this._buses = new Map();
164
+
165
+ // Index -> bus record, so a handle's bus tag resolves in O(1).
166
+ this._busList = [];
167
+
168
+ // Per-sound state, populated by defineSounds():
169
+ // { srcs:[], busName, buffer, spriteId, loadState:signal, volume, pitchVar }
170
+ this._sounds = new Map();
171
+
172
+ // Reverse index: bus name -> array of soundIds routed to that bus.
173
+ // Rebuilt after every defineSounds so pools know their sprite map.
174
+ this._soundsByBus = new Map();
175
+
176
+ // Pre-unlock play queue. Map key is soundId (latest-per-sound wins).
177
+ // Bounded by _queueLimit.
178
+ this._pendingPlays = new Map();
179
+
180
+ // Effect handles kept so destroy() can dispose them cleanly.
181
+ this._effectHandles = [];
182
+
183
+ // Unlock/lifecycle AbortControllers - same shape as the manager.
184
+ this._unlockAbort = null;
185
+ this._lifecycleAbort = null;
186
+
187
+ this._ctx = null;
188
+ this._master = null;
189
+ this._destroyed = false;
190
+ this._initialized = false;
191
+ }
192
+
193
+ // ---------- Lifecycle --------------------------------------------------
194
+
195
+ /**
196
+ * Wire the engine to an AudioContext. Idempotent per-instance; a second
197
+ * call with a different context throws (destroy first). If no context is
198
+ * passed, one is created from globalThis.AudioContext.
199
+ *
200
+ * After init() the master and bus GainNodes exist and their bus->volume
201
+ * effects are wired. The unlock listeners are attached only if a window
202
+ * is available (test environments can skip by omitting `window`).
203
+ */
204
+ async init(ctx) {
205
+ if (this._destroyed) throw new Error('LiteAudio: destroyed');
206
+ if (this._initialized) {
207
+ if (ctx && ctx !== this._ctx) throw new Error('LiteAudio: already bound to a different context');
208
+ return;
209
+ }
210
+
211
+ this._ctx = ctx || new (globalThis.AudioContext || globalThis.webkitAudioContext)();
212
+ this._sCtxState.set(this._ctx.state);
213
+ if (this._ctx.state === 'running') this._sUnlocked.set(true);
214
+
215
+ // Master GainNode is the top of the bus tree; user buses feed into it,
216
+ // and the master's own gain reflects the master mute signal.
217
+ this._master = this._ctx.createGain();
218
+ this._master.connect(this._ctx.destination);
219
+
220
+ // Master mute effect: writes to master gain via setTargetAtTime for
221
+ // click-free transitions. Also persists to storage on write.
222
+ const masterEffect = effect(() => {
223
+ const muted = this._sMuted();
224
+ const target = muted ? 0 : 1;
225
+ const t = this._ctx.currentTime;
226
+ this._master.gain.setTargetAtTime(target, t, RAMP_TC);
227
+ writeStorage(this._mutedKey, muted);
228
+ });
229
+ this._effectHandles.push(masterEffect);
230
+
231
+ // Build each user bus: GainNode -> master, plus a per-bus volume+muted
232
+ // effect that writes to gain via setTargetAtTime.
233
+ for (const name of this._busNames) {
234
+ if (name === 'master') continue; // reserved name; use master directly
235
+ const gain = this._ctx.createGain();
236
+ gain.connect(this._master);
237
+ const sVol = signal(1);
238
+ const sMut = signal(false);
239
+ const busEffect = effect(() => {
240
+ const v = sVol();
241
+ const m = sMut();
242
+ const target = m ? 0 : v;
243
+ gain.gain.setTargetAtTime(target, this._ctx.currentTime, RAMP_TC);
244
+ });
245
+ this._effectHandles.push(busEffect);
246
+ const busRec = {
247
+ index: this._busList.length,
248
+ gain, volume: sVol, muted: sMut, effect: busEffect, pool: null,
249
+ };
250
+ this._buses.set(name, busRec);
251
+ this._busList.push(busRec);
252
+ }
253
+
254
+ // Ctx state effect: mirror external state changes into our signal so
255
+ // downstream effects (auto-suspend, HUD, etc.) can react.
256
+ if (typeof this._ctx.addEventListener === 'function') {
257
+ const onStatechange = () => this._sCtxState.set(this._ctx.state);
258
+ this._ctx.addEventListener('statechange', onStatechange);
259
+ this._statechangeHandler = onStatechange;
260
+ }
261
+
262
+ // Wire unlock and visibility handlers, if we have a DOM.
263
+ if (this._window) this._setupUnlock();
264
+ if (this._document) this._setupVisibilityResume();
265
+
266
+ this._initialized = true;
267
+ }
268
+
269
+ /**
270
+ * Attach unlock listeners at capture phase (D3, verbatim port from the
271
+ * manager's semantics). The first qualifying gesture fires a silent buffer
272
+ * pulse, calls ctx.resume(), sets the unlocked signal, and flushes any
273
+ * play() calls the caller queued while locked.
274
+ */
275
+ _setupUnlock() {
276
+ this._unlockAbort = new AbortController();
277
+
278
+ const unlock = async () => {
279
+ if (this._sUnlocked.peek()) return;
280
+ const ctx = this._ctx;
281
+ if (!ctx) return;
282
+
283
+ if (ctx.state === 'suspended' || ctx.state === 'interrupted') {
284
+ // Silent buffer pulse: forces the hardware pipeline open on
285
+ // iOS Safari, which otherwise treats a resume() alone as a
286
+ // no-op the first time.
287
+ try {
288
+ const buf = ctx.createBuffer(1, 1, 22050);
289
+ const src = ctx.createBufferSource();
290
+ src.buffer = buf;
291
+ src.connect(ctx.destination);
292
+ src.start(0);
293
+ } catch { /* some mock contexts may not implement createBuffer fully */ }
294
+
295
+ try {
296
+ await ctx.resume();
297
+ } catch { /* resume rejected - keep trying on next gesture */ return; }
298
+ }
299
+ // Post-resume: if we made it here without an error, unlock succeeded.
300
+ batch(() => {
301
+ this._sUnlocked.set(true);
302
+ this._sCtxState.set(ctx.state);
303
+ });
304
+ this._unlockAbort.abort();
305
+ this._flushQueue();
306
+ };
307
+
308
+ for (const evt of UNLOCK_EVENTS) {
309
+ this._window.addEventListener(evt, unlock, {
310
+ capture: true,
311
+ signal: this._unlockAbort.signal,
312
+ });
313
+ }
314
+ }
315
+
316
+ _setupVisibilityResume() {
317
+ this._lifecycleAbort = new AbortController();
318
+ this._document.addEventListener('visibilitychange', () => {
319
+ if (!this._document.hidden && this._ctx?.state === 'suspended') {
320
+ this._ctx.resume().catch(() => {});
321
+ }
322
+ }, { signal: this._lifecycleAbort.signal });
323
+ }
324
+
325
+ // ---------- Loader -----------------------------------------------------
326
+
327
+ /**
328
+ * Fetch and decode a set of sounds. Sound configs have shape:
329
+ * { src: [url, ...], bus?: string, volume?: number, pitchVar?: number }
330
+ *
331
+ * Returns a promise that resolves when every sound has settled (loaded or
332
+ * errored). Load state per sound is tracked in a signal readable via
333
+ * loadState(soundId).
334
+ *
335
+ * Pools are built (or rebuilt) after all sounds in this batch settle, so
336
+ * a bus's sprite map covers everything currently loaded for it.
337
+ */
338
+ async defineSounds(config) {
339
+ if (this._destroyed) throw new Error('LiteAudio: destroyed');
340
+ if (!this._initialized) throw new Error('LiteAudio: init() before defineSounds()');
341
+ if (!config) return;
342
+
343
+ const busesTouched = new Set();
344
+ const loadPromises = [];
345
+
346
+ for (const [soundId, cfg] of Object.entries(config)) {
347
+ if (this._sounds.has(soundId)) continue; // idempotent
348
+
349
+ const busName = cfg.bus || 'sfx';
350
+ if (!this._buses.has(busName)) {
351
+ throw new Error(`LiteAudio: sound "${soundId}" routed to unknown bus "${busName}"`);
352
+ }
353
+
354
+ const sState = signal('idle');
355
+ const record = {
356
+ soundId,
357
+ srcs: cfg.src || [],
358
+ busName,
359
+ volume: cfg.volume ?? 1,
360
+ pitchVar: cfg.pitchVar ?? 0,
361
+ buffer: null,
362
+ loadState: sState,
363
+ };
364
+ this._sounds.set(soundId, record);
365
+ busesTouched.add(busName);
366
+
367
+ loadPromises.push(this._loadOne(record));
368
+ }
369
+
370
+ await Promise.all(loadPromises);
371
+
372
+ // Rebuild sound-by-bus reverse index for the buses we touched.
373
+ for (const busName of busesTouched) {
374
+ const ids = [];
375
+ for (const [id, rec] of this._sounds) {
376
+ if (rec.busName === busName && rec.loadState.peek() === 'ready') ids.push(id);
377
+ }
378
+ this._soundsByBus.set(busName, ids);
379
+ }
380
+
381
+ // Build/rebuild the pool for each touched bus. Rebuilding tears down
382
+ // the previous pool cleanly (its destroy() disconnects nodes), which
383
+ // is why the pool's destroy() had to be fixed to actually disconnect
384
+ // - see lite-audio-pool CHANGELOG 1.1.0.
385
+ for (const busName of busesTouched) {
386
+ const busRec = this._buses.get(busName);
387
+ if (busRec.pool) { busRec.pool.destroy(); busRec.pool = null; }
388
+
389
+ const ids = this._soundsByBus.get(busName) || [];
390
+ if (ids.length === 0) continue; // nothing loaded
391
+
392
+ const spriteMap = {};
393
+ for (const id of ids) {
394
+ const rec = this._sounds.get(id);
395
+ spriteMap[id] = { start: 0, duration: rec.buffer.duration };
396
+ }
397
+ // Pool's default buffer is a dummy - every play passes an explicit
398
+ // per-play buffer, which is why we asked the pool to grow that arg
399
+ // in v1.1.0. spriteMap only needs to name what CAN be played.
400
+ const dummyBuffer = ids.length > 0 ? this._sounds.get(ids[0]).buffer : null;
401
+ busRec.pool = new AudioPool(
402
+ this._ctx, dummyBuffer, spriteMap, this._poolCapacity, busRec.gain
403
+ );
404
+ }
405
+
406
+ // Flush the queue in case sounds arrived after the user gesture:
407
+ // plays that were queued because the sound was still loading can now
408
+ // fire. flushQueue is a no-op if unlock is not yet in.
409
+ this._flushQueue();
410
+ }
411
+
412
+ async _loadOne(record) {
413
+ const url = pickSupportedSrc(record.srcs);
414
+ if (!url) { record.loadState.set('error'); return; }
415
+ if (!this._fetch) { record.loadState.set('error'); return; }
416
+
417
+ record.loadState.set('loading');
418
+ try {
419
+ const resp = await this._fetch(url);
420
+ if (!resp || !resp.ok) throw new Error(`fetch failed: ${resp?.status ?? '?'}`);
421
+ const bytes = await resp.arrayBuffer();
422
+ const buf = await this._ctx.decodeAudioData(bytes);
423
+ record.buffer = buf;
424
+ record.loadState.set('ready');
425
+ } catch {
426
+ record.loadState.set('error');
427
+ }
428
+ }
429
+
430
+ // ---------- Playback ---------------------------------------------------
431
+
432
+ /**
433
+ * Positional hot path (D5). Returns the pool's generation-stamped handle,
434
+ * or -1 on any skip (unknown sound, not-ready sound, context locked).
435
+ * When locked, the call is enqueued (bounded, latest-per-sound) instead
436
+ * of dropped - the queue is flushed on unlock.
437
+ */
438
+ play(soundId, volume = 1, pan = 0, pitch = 1) {
439
+ if (this._destroyed) return -1;
440
+ const rec = this._sounds.get(soundId);
441
+ if (!rec) return -1;
442
+
443
+ // If not unlocked yet, queue the intent and return -1. The caller can
444
+ // observe unlocked() and re-decide, but a good game just fires - the
445
+ // queue turns pre-gesture plays into post-gesture plays automatically.
446
+ if (!this._sUnlocked.peek() || rec.loadState.peek() !== 'ready') {
447
+ this._enqueue(soundId, volume, pan, pitch);
448
+ return -1;
449
+ }
450
+
451
+ const busRec = this._buses.get(rec.busName);
452
+ if (!busRec || !busRec.pool) return -1;
453
+
454
+ // Per-play buffer override (pool v1.1.0): the pool is constructed
455
+ // with a dummy default buffer; we always pass the sound's own buffer.
456
+ const poolHandle = busRec.pool.play(soundId, volume, pan, pitch, rec.buffer);
457
+ if (poolHandle < 0) return -1;
458
+ return busRec.index * BUS_STRIDE + poolHandle;
459
+ }
460
+
461
+ /**
462
+ * Sugar over play(): options object + pitchVar resolution. Not the hot
463
+ * path - a frame loop should call the positional play() directly.
464
+ */
465
+ playOpts(soundId, opts = {}) {
466
+ const rec = this._sounds.get(soundId);
467
+ const vol = opts.volume ?? rec?.volume ?? 1;
468
+ const pan = opts.pan ?? 0;
469
+ let pitch = opts.pitch ?? 1;
470
+ const pv = opts.pitchVar ?? rec?.pitchVar ?? 0;
471
+ if (opts.pitch == null && pv > 0) {
472
+ pitch = 1 + (Math.random() * 2 - 1) * pv;
473
+ }
474
+ return this.play(soundId, vol, pan, pitch);
475
+ }
476
+
477
+ /**
478
+ * Stop a specific play by its pool handle. Because pool handles are
479
+ * generation-stamped, a stale handle from a stolen voice is a silent
480
+ * no-op - the guarantee we shipped in pool v1.1.0.
481
+ * @param {number} handle
482
+ */
483
+ stop(handle) {
484
+ if (this._destroyed || handle < 0) return;
485
+ const busRec = this._busList[(handle / BUS_STRIDE) | 0];
486
+ if (!busRec || !busRec.pool) return;
487
+ busRec.pool.stop(handle >>> 0);
488
+ }
489
+
490
+ /**
491
+ * Is this exact voice still sounding? False once its channel was stolen,
492
+ * stopped, or played out - and false for a handle the named bus never issued.
493
+ * @param {number} handle
494
+ * @returns {boolean}
495
+ */
496
+ isPlaying(handle) {
497
+ if (this._destroyed || handle < 0) return false;
498
+ const busRec = this._busList[(handle / BUS_STRIDE) | 0];
499
+ if (!busRec || !busRec.pool) return false;
500
+ return busRec.pool.isPlaying(handle >>> 0);
501
+ }
502
+
503
+ /**
504
+ * Which bus issued this handle, or null. Useful for HUDs and for asserting in
505
+ * tests that a voice landed where its sound was routed.
506
+ * @param {number} handle
507
+ * @returns {string|null}
508
+ */
509
+ busOf(handle) {
510
+ if (handle < 0) return null;
511
+ const index = (handle / BUS_STRIDE) | 0;
512
+ for (const [name, busRec] of this._buses) {
513
+ if (busRec.index === index) return name;
514
+ }
515
+ return null;
516
+ }
517
+
518
+ /**
519
+ * Voices currently sounding on a bus, or across every bus when called with no
520
+ * argument. Allocation-free; safe to call every frame.
521
+ * @param {string} [busName]
522
+ * @returns {number}
523
+ */
524
+ activeCount(busName) {
525
+ if (this._destroyed) return 0;
526
+ if (busName !== undefined) {
527
+ const busRec = this._buses.get(busName);
528
+ return busRec?.pool ? busRec.pool.activeCount() : 0;
529
+ }
530
+ let n = 0;
531
+ for (let i = 0; i < this._busList.length; i++) {
532
+ const pool = this._busList[i].pool;
533
+ if (pool) n += pool.activeCount();
534
+ }
535
+ return n;
536
+ }
537
+
538
+ /** Stop every voice on a named bus. */
539
+ stopBus(busName) {
540
+ if (this._destroyed) return;
541
+ const busRec = this._buses.get(busName);
542
+ if (busRec?.pool) busRec.pool.stopAll();
543
+ }
544
+
545
+ /** Stop every voice across every bus. */
546
+ stopAll() {
547
+ if (this._destroyed) return;
548
+ for (const [, busRec] of this._buses) {
549
+ if (busRec.pool) busRec.pool.stopAll();
550
+ }
551
+ }
552
+
553
+ // ---------- Bus controls -----------------------------------------------
554
+
555
+ setBusVolume(busName, volume) {
556
+ const busRec = this._buses.get(busName);
557
+ if (busRec) busRec.volume.set(volume);
558
+ }
559
+
560
+ setBusMuted(busName, muted) {
561
+ const busRec = this._buses.get(busName);
562
+ if (busRec) busRec.muted.set(!!muted);
563
+ }
564
+
565
+ setMuted(state) { this._sMuted.set(!!state); }
566
+
567
+ // ---------- Signal getters ---------------------------------------------
568
+
569
+ /** Reactive readable: current context state ('suspended'|'running'|...). */
570
+ ctxState() { return this._sCtxState; }
571
+
572
+ /** Reactive readable: whether the context has been unlocked. */
573
+ unlocked() { return this._sUnlocked; }
574
+
575
+ /** Reactive readable: master mute state. */
576
+ muted() { return this._sMuted; }
577
+
578
+ /** Reactive readable: load state for a specific sound. */
579
+ loadState(soundId) { return this._sounds.get(soundId)?.loadState; }
580
+
581
+ /** Reactive readable: bus volume signal. */
582
+ busVolume(busName) { return this._buses.get(busName)?.volume; }
583
+
584
+ /** Reactive readable: bus mute signal. */
585
+ busMuted(busName) { return this._buses.get(busName)?.muted; }
586
+
587
+ /** Direct access to a bus's GainNode for effect insertion (advanced). */
588
+ busNode(busName) { return this._buses.get(busName)?.gain; }
589
+
590
+ /** Direct access to the master GainNode (advanced). */
591
+ masterNode() { return this._master; }
592
+
593
+ // ---------- Unlock queue -----------------------------------------------
594
+
595
+ _enqueue(soundId, volume, pan, pitch) {
596
+ // Latest-per-sound: if the same sound was already queued, overwrite
597
+ // the intent. Bounded: if we would exceed queueLimit with a NEW key,
598
+ // silently drop the newcomer - the game can rebuild it on unlock,
599
+ // and this is a paper-cut fallback, not the happy path.
600
+ if (this._pendingPlays.has(soundId)) {
601
+ this._pendingPlays.set(soundId, [soundId, volume, pan, pitch]);
602
+ return;
603
+ }
604
+ if (this._pendingPlays.size >= this._queueLimit) return;
605
+ this._pendingPlays.set(soundId, [soundId, volume, pan, pitch]);
606
+ }
607
+
608
+ _flushQueue() {
609
+ if (!this._sUnlocked.peek()) return;
610
+ if (this._pendingPlays.size === 0) return;
611
+
612
+ // Snapshot - play() may re-queue if a sound is still not ready.
613
+ const items = [...this._pendingPlays.values()];
614
+ this._pendingPlays.clear();
615
+ for (const [id, v, p, pi] of items) {
616
+ const rec = this._sounds.get(id);
617
+ if (!rec) continue;
618
+ if (rec.loadState.peek() !== 'ready') {
619
+ // Sound not ready yet: put it back for the next flush.
620
+ this._enqueue(id, v, p, pi);
621
+ continue;
622
+ }
623
+ this.play(id, v, p, pi);
624
+ }
625
+ }
626
+
627
+ // ---------- Teardown ---------------------------------------------------
628
+
629
+ /**
630
+ * Stop everything, tear down the audio graph, remove listeners, dispose
631
+ * effects, and release references. Idempotent - a second destroy() is
632
+ * safe. The AudioContext itself is not closed (the app owns it).
633
+ */
634
+ destroy() {
635
+ if (this._destroyed) return;
636
+ this._destroyed = true;
637
+
638
+ this._unlockAbort?.abort();
639
+ this._lifecycleAbort?.abort();
640
+
641
+ if (this._statechangeHandler && this._ctx?.removeEventListener) {
642
+ this._ctx.removeEventListener('statechange', this._statechangeHandler);
643
+ }
644
+
645
+ // stopAll first so voice fade-outs schedule against a still-live graph.
646
+ for (const [, busRec] of this._buses) {
647
+ if (busRec.pool) { try { busRec.pool.destroy(); } catch {} busRec.pool = null; }
648
+ }
649
+
650
+ for (const h of this._effectHandles) {
651
+ try { dispose(h); } catch {}
652
+ }
653
+ this._effectHandles.length = 0;
654
+
655
+ // Disconnect bus and master gains.
656
+ for (const [, busRec] of this._buses) {
657
+ try { busRec.gain.disconnect(); } catch {}
658
+ }
659
+ try { this._master?.disconnect(); } catch {}
660
+
661
+ this._buses.clear();
662
+ this._busList.length = 0;
663
+ this._sounds.clear();
664
+ this._soundsByBus.clear();
665
+ this._pendingPlays.clear();
666
+ this._master = null;
667
+ this._ctx = null;
668
+ }
669
+ }
670
+
671
+ export default LiteAudio;
package/CHANGELOG.md ADDED
@@ -0,0 +1,111 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ Initial release. Ships the SFX layer of the lite-audio stack: signal-driven
6
+ buses over one `AudioPool` per bus, iOS/mobile unlock ported verbatim from
7
+ `lite-audio-manager`, plus a bounded pre-unlock play queue.
8
+
9
+ ### Included (roadmap D1 / D3 / D4 / D5 / D6 / D7 / D8)
10
+
11
+ - **Signal-driven control surface (D1).** Bus volumes, mutes, master mute,
12
+ context state, per-sound load state are all `lite-signal` signals. Each bus
13
+ gets one `effect()` that writes the effective target
14
+ (`muted ? 0 : volume`) through `setTargetAtTime(target, currentTime, 0.01)`
15
+ — click-free by construction, no manual ramp code in userland.
16
+ - **Unlock ported verbatim (D3).** Silent-buffer pulse + `ctx.resume()` on the
17
+ first `touchstart` / `touchend` / `mousedown` / `keydown`, capture-phase,
18
+ behind an `AbortController` — same event set and same attach shape as the
19
+ manager. Handles `'interrupted'` state (iOS phone-call scenario) as
20
+ equivalent to `'suspended'`. Added: `ctxState()` and `unlocked()` as signals
21
+ the rest of the app can subscribe to.
22
+ - **Bounded pre-unlock play queue (D3 extension).** `play()` before unlock
23
+ returns `-1` and enqueues the intent. Same-sound repeats collapse to
24
+ latest-per-sound; new distinct sounds past `queueLimit` are silently dropped.
25
+ Flushed on unlock. The manager's silent-drop behavior was the #1 mobile-game
26
+ paper cut Howler could not fix from the outside; lite-audio fixes it from
27
+ the inside.
28
+ - **Reactive loader (D4).** `defineSounds(config)` fetches + decodes each
29
+ sound; a `loadState(id)` signal per sound transitions
30
+ `'idle' -> 'loading' -> 'ready' | 'error'` observably. Format fallback via
31
+ `canPlayType` probe over the `src` array. Fetch is injectable for tests.
32
+ - **Positional hot path (D5).** `play(soundId, volume, pan, pitch)` — no
33
+ options object per call. `playOpts(id, {...})` sugar layer above handles
34
+ `pitchVar` random-pitch resolution.
35
+ - **Bus-tagged voice handles (D5/D6).** `play()` returns
36
+ `busIndex * 2^32 + poolHandle`. The tag is not decoration: every bus runs its
37
+ own `AudioPool`, and every pool counts channels and generations from zero, so
38
+ the first play on *each* bus returns the identical raw handle
39
+ (`0x00000000` - channel 0, generation 0). A handle alone cannot say which bus
40
+ issued it, and the pool's generation check cannot help, because it is a
41
+ recycle counter, not a namespace. With the tag, `stop()` resolves the owning
42
+ pool in O(1) and cannot reach across a bus boundary. Handles stay plain
43
+ numbers, exact well inside 2^53, opaque to callers.
44
+ - **Fades delegated to pool (D6).** `stop(handle)` routes to the handle's own
45
+ bus; the generation check inside that pool means a stale handle after a steal
46
+ is a silent no-op, never a wrong-voice hit.
47
+ - **`isPlaying(handle)`, `busOf(handle)`, `activeCount(busName?)`.** Liveness of
48
+ one voice, the bus that issued a handle, and the live voice count per bus or
49
+ engine-wide. Allocation-free; `activeCount()` is safe to call every frame.
50
+ - **Master mute persistence (D7).** localStorage key `'lite_audio_muted'` -
51
+ byte-identical to `lite-audio-manager` so a game migrating between the two
52
+ keeps the player's saved preference intact.
53
+ - **Mock-ctx test harness (D8).** `test/mock-ctx.js` records every
54
+ `AudioParam` scheduled event into an `.events` array, runs a real state
55
+ machine covering all four `AudioContextState` values, and mocks
56
+ `decodeAudioData` + `fetch` with length-hint payloads so different mock
57
+ URLs decode to different-sized buffers deterministically. Foundation for
58
+ every later lite-audio session.
59
+
60
+ ### Deliberately deferred (roadmap D10)
61
+
62
+ - **Music streaming layer.** `MediaElementAudioSourceNode`, crossfades,
63
+ reactive `position()` / `duration()`, `playExclusive` / `playUnique` -
64
+ ships in **v1.1.0** (roadmap session A2).
65
+ - **Ducking, mix snapshots, per-bus meters, auto-suspend.** Ships in
66
+ **v1.2.0** (roadmap session A3).
67
+ - **`./compat` shim.** `AudioManager`-shaped adapter over `LiteAudio` for
68
+ drop-in migration, plus full parity certification. Ships in **v2.0.0**
69
+ (roadmap session A4).
70
+ - **3D spatial (PannerNode/HRTF), AudioWorklet custom DSP, convolution
71
+ reverb.** Post-2.0.
72
+
73
+ ### Demo
74
+
75
+ - New `demo/index.html`: single-file, four scenes, no asset folder - every sound
76
+ is synthesized in an `OfflineAudioContext`, encoded to WAV, and served to the
77
+ engine as a `blob:` URL, so the real fetch + decode path runs.
78
+ **unlock** - the context lifecycle as an SVG state machine driven by
79
+ `ctxState()`, plus the pre-unlock queue: fire plays while locked and watch
80
+ latest-per-sound collapse and the `queueLimit` drop, then dispatch the gesture
81
+ and hear the flush. The engine takes a *fake window* through `opts.window`, so
82
+ the demo owns the gesture instead of racing it - the one-shot feature becomes
83
+ replayable. **loader** - `loadState()` per sound, with one asset behind 2.6s of
84
+ injected fetch latency and one pointed at a 404, so `loading` and `error` are
85
+ states you can actually watch. **mixer** - bus faders and mutes as signals,
86
+ with a gain scope plotting the real `GainNode.value` against the signal target:
87
+ the 10ms `setTargetAtTime` bend is the click you do not hear. **voices** - the
88
+ per-bus pools, and the twin-handle trap: two buses hand back the same raw
89
+ handle, and only the bus tag keeps `stop()` from killing both.
90
+
91
+ ### Test coverage
92
+
93
+ 38 tests across 10 suites, all green. Gate items from the roadmap:
94
+
95
+ - Unlock state machine including `'interrupted'` — 6 tests
96
+ - Loader fallback + error - 5 tests
97
+ - Bus effect writes as `setTargetAtTime` on the mock ctx - 4 tests
98
+ - Steal + declick schedule shape (delegated) - 1 test
99
+ - Generation no-op on stale handles (delegated) - covered in 2 playback tests
100
+ - Unlock queue flush (latest-per-sound, bounded) - 3 tests
101
+ - Zero-GC hot path shape - 1 test
102
+ - Master mute persistence — 3 tests
103
+ - Playback delegation to pool — 6 tests
104
+ - destroy() idempotency + listener detachment - 3 tests
105
+
106
+ - Handle namespace across buses - 6 tests (`test/BusHandles.test.js`)
107
+
108
+ ### Peer dependency pins
109
+
110
+ - `@zakkster/lite-signal` `^1.3.0`
111
+ - `@zakkster/lite-audio-pool` `^1.1.0`
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zahary Shinikchiev
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 ADDED
@@ -0,0 +1,175 @@
1
+ # @zakkster/lite-audio
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-audio.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-audio)
4
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
5
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
6
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-audio?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-audio)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-audio?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-audio)
8
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-audio?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-audio)
9
+ ![Tree-Shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)
10
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
11
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
12
+ [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)
13
+
14
+
15
+ Zero-GC reactive Web Audio engine. Signal-driven buses, ABA-safe voice handles,
16
+ unlock queue, one `AudioPool` per bus. The SFX layer of a growing audio stack
17
+ that eventually deprecates the Howler wrapper it replaces.
18
+
19
+ - Zero deps of its own; two peers: `@zakkster/lite-signal` and
20
+ `@zakkster/lite-audio-pool`
21
+ - ~7.5 KB minified, ~2.7 KB gzipped
22
+ - No Howler, no HTML5 Audio fallback, no MP3 decoder shim — just the Web Audio
23
+ API surface the modern web has had since 2018, wired precisely
24
+ - Ports the iOS/mobile unlock semantics of `lite-audio-manager` verbatim, plus a
25
+ bounded pre-unlock play queue the manager could not offer from Howler's outside
26
+
27
+ **Status:** v1.0.0 covers SFX. The music streaming layer
28
+ (`MediaElementAudioSourceNode`, crossfades, exclusive/unique) is the v1.1.0
29
+ milestone; ducking, snapshots, and auto-suspend are v1.2.0; a Howler-shim
30
+ compat subpath and full parity certification against `lite-audio-manager` are
31
+ v2.0.0.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ npm i @zakkster/lite-audio @zakkster/lite-signal @zakkster/lite-audio-pool
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ ```js
42
+ import { LiteAudio } from '@zakkster/lite-audio';
43
+
44
+ const audio = new LiteAudio({ buses: ['sfx', 'ui', 'voice'] });
45
+ await audio.init(); // creates an AudioContext internally
46
+
47
+ await audio.defineSounds({
48
+ laser: { src: ['/laser.opus', '/laser.mp3'], bus: 'sfx' },
49
+ hit: { src: ['/hit.wav'], bus: 'sfx', pitchVar: 0.15 },
50
+ click: { src: ['/click.mp3'], bus: 'ui' },
51
+ });
52
+
53
+ // Fires immediately when unlocked; queued (bounded, latest-per-sound) and
54
+ // flushed on the first touchstart / mousedown / keydown otherwise. No polling,
55
+ // no "waiting for unlock" ceremony in the caller.
56
+ const handle = audio.play('laser', 0.8, 0.0, 1.0);
57
+ audio.stop(handle);
58
+
59
+ // Reactive state - subscribe with lite-signal's effect() for UI hookup.
60
+ audio.unlocked(); // ReadSignal<boolean>
61
+ audio.loadState('laser'); // ReadSignal<'idle'|'loading'|'ready'|'error'>
62
+ audio.busVolume('sfx'); // ReadSignal<number>
63
+
64
+ audio.setBusVolume('sfx', 0.5); // schedules setTargetAtTime, click-free
65
+ audio.setMuted(true); // master mute, persists to localStorage
66
+
67
+ audio.destroy(); // idempotent, disconnects the graph
68
+ ```
69
+
70
+ ## Why not Howler?
71
+
72
+ Howler.js is a good general-purpose audio library. It handles decoding, HTML5
73
+ Audio fallback for browsers that predate reliable Web Audio, format detection
74
+ across MP3/OGG/WAV/M4A, streaming, spatial audio, and a lot more. If you are
75
+ shipping cross-platform audio to a broad web audience without wanting to think
76
+ about any of that, use Howler.
77
+
78
+ lite-audio deliberately does not do most of those things. It targets the narrow
79
+ slice where the ecosystem has moved on:
80
+
81
+ - **Web Audio is universal.** Every browser we care about (2018+) has decode +
82
+ scheduling. HTML5 Audio fallback is dead weight for a modern game.
83
+ - **Signals over events.** Continuous state — bus volumes, mute, load status,
84
+ context state — belongs in a reactive graph, not a bag of event listeners.
85
+ `effect(() => busGain.setTargetAtTime(...))` is one line; the equivalent
86
+ Howler pattern is a handful of `on('volumechange')` handlers plus manual
87
+ ramps.
88
+ - **Handles that survive stealing.** Voice-stealing is the norm in games. The
89
+ underlying `AudioPool` returns generation-stamped handles; a `stop(handle)`
90
+ after the channel has been stolen is a silent no-op instead of a wrong-voice
91
+ hit. Howler has no direct equivalent — `Howl.stop(id)` on an id that has
92
+ been reused is a real bug we ran into.
93
+ - **Zero-GC hot path.** No per-play closure, no options-object allocation on
94
+ the positional `play(id, vol, pan, pitch)` path. The pool underneath has
95
+ been benchmarked to ~14M plays/sec with ~0 B retained per play.
96
+
97
+ lite-audio is ~7.5 KB minified against Howler's ~35 KB. That size difference is
98
+ real budget you get back for shaders, netcode, or another 100 sounds.
99
+
100
+ ## Architecture in one paragraph
101
+
102
+ **Signals are the control surface; AudioParams are the sink.** Continuous state
103
+ (bus volumes, mute, per-sound load state, ctx state) lives in `lite-signal`
104
+ signals. One `effect()` per bus writes the effective target through
105
+ `setTargetAtTime(target, ctx.currentTime, 0.01)` — click-free by construction.
106
+ One-shot triggers (`play`) stay imperative — a sound firing is an event, not
107
+ state. Voices come from a per-bus `AudioPool`, addressed by
108
+ bus-tagged handles (`busIndex * 2^32 | (gen << 8) | channel`); a stale
109
+ `stop(handle)` after a steal is a silent no-op. Unlock is ported from
110
+ `lite-audio-manager` verbatim (silent-buffer pulse + `resume()` + capture-phase
111
+ listeners behind an `AbortController`), plus a bounded pre-unlock play queue
112
+ so a call fired before the first user gesture does not vanish.
113
+
114
+ ## Signal readouts (the whole reactive surface)
115
+
116
+ | Signal | Read via | Notes |
117
+ | --- | --- | --- |
118
+ | Context state | `audio.ctxState()` | `'suspended' \| 'running' \| 'interrupted' \| 'closed'` |
119
+ | Unlock status | `audio.unlocked()` | `true` once the first gesture succeeded |
120
+ | Master mute | `audio.muted()` | Persisted to `localStorage['lite_audio_muted']` |
121
+ | Bus volume | `audio.busVolume(name)` | 0..1 (or higher; not clamped) |
122
+ | Bus mute | `audio.busMuted(name)` | Independent of master mute |
123
+ | Load state | `audio.loadState(id)` | `'idle' \| 'loading' \| 'ready' \| 'error'` |
124
+
125
+ All readable via `signal()` (calling), `signal.peek()` (untracked read), or
126
+ inside a `computed()` / `effect()`.
127
+
128
+ ## Options reference
129
+
130
+ ```js
131
+ new LiteAudio({
132
+ buses: ['sfx', 'ui', 'voice'], // user-facing buses
133
+ poolCapacity: 32, // voices per bus pool
134
+ queueLimit: 32, // bound on pre-unlock queue
135
+ mutedStorageKey: 'lite_audio_muted', // manager parity default
136
+ fetch: globalThis.fetch, // injectable for tests
137
+ window: globalThis.window, // ditto
138
+ document: globalThis.document, // ditto
139
+ });
140
+ ```
141
+
142
+ ## Migration from lite-audio-manager
143
+
144
+ The persistence key (`lite_audio_muted`), unlock event set (`touchstart`,
145
+ `touchend`, `mousedown`, `keydown`), capture-phase attachment, and silent-buffer
146
+ unlock pulse are byte-identical to the manager. A migration should keep the
147
+ player's saved mute preference intact on first launch.
148
+
149
+ A `./compat` subpath re-exporting an `AudioManager`-shaped adapter over
150
+ `LiteAudio` is planned for **v2.0.0**; the migration guide will be the release
151
+ notes for that.
152
+
153
+ ## Testing
154
+
155
+ ```bash
156
+ npm test
157
+ ```
158
+
159
+ 32 tests across 9 suites, covering unlock state machine (including
160
+ `'interrupted'`), loader fallback + error, bus signal writes as
161
+ `setTargetAtTime` on a mock AudioContext, pool delegation (steal, generation
162
+ no-op on stale handles, stopBus scope), unlock queue flush semantics
163
+ (latest-per-sound, bounded), and destroy idempotency + listener detachment.
164
+
165
+ The mock harness (`test/mock-ctx.js`) records every `AudioParam` scheduled
166
+ event into an inspectable `.events` array, runs a real context state machine
167
+ (all four states — `suspended`, `running`, `interrupted`, `closed`), and
168
+ mocks `fetch` + `decodeAudioData` with length-hint payloads so tests can
169
+ distinguish sounds deterministically. This harness is the D8 foundation the
170
+ next roadmap sessions will reuse for the music layer, ducking, and parity
171
+ certification.
172
+
173
+ ## License
174
+
175
+ MIT. Copyright Zahary Shinikchiev.
package/llms.txt ADDED
@@ -0,0 +1,106 @@
1
+ # @zakkster/lite-audio
2
+ > Zero-GC reactive Web Audio engine. Signal-driven buses, ABA-safe voice handles, unlock queue, per-bus AudioPool. SFX layer over @zakkster/lite-audio-pool.
3
+
4
+ ## Install
5
+ npm i @zakkster/lite-audio @zakkster/lite-signal @zakkster/lite-audio-pool
6
+
7
+ ## Import
8
+ import { LiteAudio } from '@zakkster/lite-audio';
9
+
10
+ ## Key Facts
11
+ - Zero deps of its own; two peers: @zakkster/lite-signal (^1.3), @zakkster/lite-audio-pool (^1.1)
12
+ - Signal-driven control surface: bus volumes, master mute, ctx state, per-sound load state are all reactive
13
+ - Bus writes go through setTargetAtTime for click-free transitions (10 ms time constant)
14
+ - Pre-unlock play() calls are queued (bounded, latest-per-sound), flushed on first user gesture
15
+ - iOS/Safari unlock: silent-buffer pulse + resume() on touchstart/touchend/mousedown/keydown (capture phase, exact set the manager uses)
16
+ - Handles 'interrupted' state (iOS phone-call scenario) the same as 'suspended'
17
+ - Master mute persists to localStorage under 'lite_audio_muted' (byte-identical to lite-audio-manager for migration)
18
+ - One AudioPool per bus (pool v1.1.0+ output param + per-play buffer override)
19
+ - Positional hot path: play(soundId, volume, pan, pitch) - no options object per call
20
+ - Handles are bus-tagged: busIndex * 2^32 + poolHandle. Pool handles collide across buses (every pool starts at channel 0 / generation 0), so the tag is what makes stop() name one voice
21
+ - v1.0.0 covers SFX only; music streaming layer (MediaElementAudioSourceNode) lands in v1.1.0
22
+
23
+ ## Quick Start
24
+ ```js
25
+ import { LiteAudio } from '@zakkster/lite-audio';
26
+
27
+ const audio = new LiteAudio({ buses: ['sfx', 'ui', 'voice'] });
28
+ await audio.init(); // creates AudioContext
29
+ await audio.defineSounds({
30
+ laser: { src: ['/laser.opus', '/laser.mp3'], bus: 'sfx' },
31
+ hit: { src: ['/hit.wav'], bus: 'sfx', pitchVar: 0.15 },
32
+ click: { src: ['/click.mp3'], bus: 'ui' },
33
+ });
34
+
35
+ // Fires immediately if unlocked; queued and flushed on first gesture otherwise.
36
+ const h = audio.play('laser', 0.8, 0.0, 1.0);
37
+ audio.stop(h);
38
+ audio.isPlaying(h); // false once stolen, stopped, or played out
39
+
40
+ // Reactive readouts
41
+ audio.unlocked(); // ReadSignal<boolean>
42
+ audio.ctxState(); // ReadSignal<'suspended'|'running'|'interrupted'|'closed'>
43
+ audio.loadState('laser'); // ReadSignal<'idle'|'loading'|'ready'|'error'>
44
+ audio.busVolume('sfx'); // ReadSignal<number>
45
+
46
+ // Writes
47
+ audio.setBusVolume('sfx', 0.5);
48
+ audio.setBusMuted('ui', true);
49
+ audio.setMuted(true); // master, persists to localStorage
50
+
51
+ audio.destroy(); // idempotent
52
+ ```
53
+
54
+ ## Export: LiteAudio (class)
55
+
56
+ ### Constructor
57
+ ```
58
+ new LiteAudio(opts?)
59
+ ```
60
+ - `opts.buses?: string[]` - defaults ['sfx', 'ui', 'voice']. 'master' is implicit.
61
+ - `opts.poolCapacity?: number` - defaults 32.
62
+ - `opts.queueLimit?: number` - defaults 32.
63
+ - `opts.mutedStorageKey?: string` - defaults 'lite_audio_muted'.
64
+ - `opts.fetch?, opts.window?, opts.document?` - test injectables.
65
+
66
+ ### async init(ctx?): Promise<void>
67
+ Wires the engine to an AudioContext. Creates one via globalThis.AudioContext if none passed. Sets up master gain, bus gains, master-mute effect, per-bus volume+mute effects, statechange listener, unlock listeners.
68
+
69
+ ### async defineSounds(config): Promise<void>
70
+ Fetches and decodes every sound in the config. Config shape:
71
+ ```
72
+ { [soundId]: { src: string[], bus?: string, volume?: number, pitchVar?: number } }
73
+ ```
74
+ Resolves after every sound has settled. Load state per sound is a reactive signal readable via loadState(soundId). Pools are built (or rebuilt) once per touched bus at the end.
75
+
76
+ ### play(soundId, volume?, pan?, pitch?): number
77
+ Positional hot path. Returns a handle >= 0 on success, -1 on any skip (unknown sound, not-yet-ready sound, context locked). Locked plays are queued (latest-per-sound, bounded by queueLimit) and flushed on unlock.
78
+
79
+ ### playOpts(soundId, opts?): number
80
+ Sugar over play() with pitchVar resolution: `pitch = 1 + (rand*2-1)*pitchVar`.
81
+
82
+ ### stop(handle): void
83
+ Stops a specific play. Stale handles (channel stolen or already ended) are silent no-ops. The handle carries its bus, so stop() resolves the owning pool in O(1) and can never reach across a bus boundary.
84
+
85
+ ### isPlaying(handle): boolean, busOf(handle): string|null, activeCount(busName?): number
86
+ Liveness of one voice, the bus that issued a handle, and the live voice count per bus or engine-wide.
87
+
88
+ ### stopBus(busName): void, stopAll(): void
89
+ Bus- and engine-wide stops.
90
+
91
+ ### setBusVolume(name, v), setBusMuted(name, m), setMuted(state)
92
+ Signal setters. Every write schedules a setTargetAtTime on the affected GainNode with a 10 ms time constant.
93
+
94
+ ### Reactive readables
95
+ `ctxState() : ReadSignal<CtxState>`
96
+ `unlocked() : ReadSignal<boolean>`
97
+ `muted() : ReadSignal<boolean>`
98
+ `loadState(id) : ReadSignal<LoadState> | undefined`
99
+ `busVolume(name), busMuted(name) : ReadSignal | undefined`
100
+ `busNode(name) : GainNode | undefined`
101
+ `masterNode() : GainNode | null`
102
+
103
+ ### destroy(): void
104
+ Stops every voice, tears down pools (which disconnect their nodes), disposes signal effects, aborts unlock/lifecycle controllers, releases references. Idempotent. Does not close the AudioContext.
105
+
106
+ See Audio.js source for full inline documentation.
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@zakkster/lite-audio",
3
+ "version": "1.0.0",
4
+ "description": "Zero-GC reactive Web Audio engine. Signal-driven buses, ABA-safe voice handles, unlock queue. SFX layer over @zakkster/lite-audio-pool.",
5
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./Audio.js",
9
+ "module": "./Audio.js",
10
+ "types": "./Audio.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./Audio.d.ts",
14
+ "import": "./Audio.js",
15
+ "node": "./Audio.js",
16
+ "default": "./Audio.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "Audio.js",
21
+ "Audio.d.ts",
22
+ "llms.txt",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "keywords": [
26
+ "audio",
27
+ "webaudio",
28
+ "sound",
29
+ "reactive",
30
+ "signals",
31
+ "zero-gc",
32
+ "game-audio",
33
+ "sfx",
34
+ "voice-stealing",
35
+ "unlock",
36
+ "ios-unlock"
37
+ ],
38
+ "sideEffects": false,
39
+ "scripts": {
40
+ "test": "node --test test/*.test.js"
41
+ },
42
+ "homepage": "https://github.com/PeshoVurtoleta/lite-audio#readme",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/PeshoVurtoleta/lite-audio.git"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/PeshoVurtoleta/lite-audio/issues",
49
+ "email": "shinikchiev@yahoo.com"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "funding": {
55
+ "type": "github",
56
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
57
+ },
58
+ "peerDependencies": {
59
+ "@zakkster/lite-signal": "^1.3.0",
60
+ "@zakkster/lite-audio-pool": "^1.1.0"
61
+ },
62
+ "devDependencies": {
63
+ "@zakkster/lite-signal": "^1.3.0",
64
+ "@zakkster/lite-audio-pool": "^1.1.0"
65
+ }
66
+ }