@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 +149 -0
- package/Audio.js +671 -0
- package/CHANGELOG.md +111 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/llms.txt +106 -0
- package/package.json +66 -0
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
|
+
[](https://www.npmjs.com/package/@zakkster/lite-audio)
|
|
4
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
5
|
+

|
|
6
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-audio)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-audio)
|
|
8
|
+
[](https://www.npmjs.com/package/@zakkster/lite-audio)
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
[](./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
|
+
}
|