@utsp/audio 0.4.0-nightly.20251203172602.bbfc50f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1058 @@
1
+ /**
2
+ * AudioManager - Web Audio API management for UTSP applications
3
+ *
4
+ * Provides a centralized audio context and utilities for playing sounds.
5
+ * Handles browser autoplay restrictions by requiring user interaction to initialize.
6
+ *
7
+ * Implements IAudioProcessor interface from @utsp/types for unified audio handling
8
+ * across standalone and connected modes.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * // Create audio manager
13
+ * const audio = new AudioManager({ debug: true });
14
+ *
15
+ * // Initialize on user interaction (required by browsers)
16
+ * button.addEventListener('click', () => {
17
+ * audio.initialize();
18
+ * audio.playStartSound(); // Confirmation sound
19
+ * });
20
+ *
21
+ * // Play loaded sounds (from File or FileExternal)
22
+ * audio.play('coin');
23
+ * audio.play('explosion', { volume: 0.5, pitch: 1.2 });
24
+ *
25
+ * // Play custom tones
26
+ * audio.playTone(440, 0.5); // A4 note for 0.5 seconds
27
+ * ```
28
+ */
29
+ import { SoundBank } from './SoundBank';
30
+ /**
31
+ * AudioManager - Main audio management class
32
+ *
33
+ * Implements IAudioProcessor for unified audio handling in standalone and connected modes.
34
+ */
35
+ export class AudioManager {
36
+ audioContext = null;
37
+ masterGainNode = null;
38
+ soundBank = null;
39
+ options;
40
+ initialized = false;
41
+ // Instance tracking system
42
+ nextInstanceId = 1;
43
+ instances = new Map();
44
+ instancesBySound = new Map();
45
+ // Legacy: for backward compatibility
46
+ activeSounds = new Set();
47
+ // Spatial sounds that need real-time updates when listener moves
48
+ spatialSounds = new Map();
49
+ // Spatial audio configuration
50
+ spatialConfig = {
51
+ listenerX: 32768, // Center of 16-bit range
52
+ listenerY: 32768,
53
+ maxDistance: 16384, // 1/4 of 16-bit range
54
+ referenceDistance: 1024,
55
+ rolloffFactor: 1.0,
56
+ panSpread: 0.8,
57
+ };
58
+ constructor(options = {}) {
59
+ this.options = {
60
+ debug: options.debug ?? false,
61
+ masterVolume: options.masterVolume ?? 1.0,
62
+ };
63
+ this.log('AudioManager created');
64
+ }
65
+ /**
66
+ * Initialize the Web AudioContext
67
+ * Must be called from a user interaction event (click, touch, keypress)
68
+ *
69
+ * @returns true if initialization was successful, false otherwise
70
+ */
71
+ initialize() {
72
+ if (this.initialized && this.audioContext) {
73
+ this.log('AudioContext already initialized');
74
+ return true;
75
+ }
76
+ try {
77
+ // Create AudioContext (with fallback for older browsers)
78
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
79
+ if (!AudioContextClass) {
80
+ console.warn('[AudioManager] Web Audio API not supported');
81
+ return false;
82
+ }
83
+ this.audioContext = new AudioContextClass();
84
+ this.log(`AudioContext initialized (state: ${this.audioContext.state}, sampleRate: ${this.audioContext.sampleRate}Hz)`);
85
+ // Create master gain node for volume control
86
+ this.masterGainNode = this.audioContext.createGain();
87
+ this.masterGainNode.gain.value = this.options.masterVolume;
88
+ this.masterGainNode.connect(this.audioContext.destination);
89
+ // Resume if suspended (some browsers start in suspended state)
90
+ if (this.audioContext.state === 'suspended') {
91
+ this.audioContext.resume().then(() => {
92
+ this.log('AudioContext resumed');
93
+ });
94
+ }
95
+ // Create SoundBank for storing loaded sounds
96
+ this.soundBank = new SoundBank(this.audioContext, this.options.debug);
97
+ this.initialized = true;
98
+ return true;
99
+ }
100
+ catch (error) {
101
+ console.error('[AudioManager] Failed to initialize AudioContext:', error);
102
+ return false;
103
+ }
104
+ }
105
+ /**
106
+ * Check if the AudioManager is initialized and ready to play sounds
107
+ */
108
+ isInitialized() {
109
+ return this.initialized && this.audioContext !== null;
110
+ }
111
+ /**
112
+ * Get the underlying AudioContext
113
+ * Returns null if not initialized
114
+ */
115
+ getContext() {
116
+ return this.audioContext;
117
+ }
118
+ /**
119
+ * Get the master gain node for connecting custom audio nodes
120
+ * Returns null if not initialized
121
+ */
122
+ getMasterGain() {
123
+ return this.masterGainNode;
124
+ }
125
+ /**
126
+ * Set master volume
127
+ * @param volume Volume level (0.0 to 1.0)
128
+ */
129
+ setMasterVolume(volume) {
130
+ this.options.masterVolume = Math.max(0, Math.min(1, volume));
131
+ if (this.masterGainNode) {
132
+ this.masterGainNode.gain.value = this.options.masterVolume;
133
+ }
134
+ this.log(`Master volume set to ${this.options.masterVolume}`);
135
+ }
136
+ /**
137
+ * Get current master volume
138
+ */
139
+ getMasterVolume() {
140
+ return this.options.masterVolume;
141
+ }
142
+ /**
143
+ * Play a retro arcade-style start sound
144
+ * Creates a quick ascending arpeggio with a square wave for that classic 8-bit feel
145
+ */
146
+ playStartSound() {
147
+ if (!this.audioContext || !this.masterGainNode) {
148
+ this.log('Cannot play sound: AudioContext not initialized');
149
+ return;
150
+ }
151
+ try {
152
+ const ctx = this.audioContext;
153
+ const now = ctx.currentTime;
154
+ // Ascending arpeggio notes (C major chord going up)
155
+ const notes = [262, 330, 392, 523]; // C4, E4, G4, C5
156
+ const noteDuration = 0.06;
157
+ const noteGap = 0.07;
158
+ notes.forEach((freq, index) => {
159
+ const startTime = now + index * noteGap;
160
+ // Create oscillator with square wave for retro sound
161
+ const oscillator = ctx.createOscillator();
162
+ oscillator.type = 'square';
163
+ oscillator.frequency.setValueAtTime(freq, startTime);
164
+ // Create gain node for each note
165
+ const gainNode = ctx.createGain();
166
+ gainNode.gain.setValueAtTime(0, startTime);
167
+ gainNode.gain.linearRampToValueAtTime(0.15, startTime + 0.005); // Quick attack
168
+ gainNode.gain.setValueAtTime(0.15, startTime + noteDuration - 0.01);
169
+ gainNode.gain.linearRampToValueAtTime(0, startTime + noteDuration);
170
+ // Connect nodes
171
+ oscillator.connect(gainNode);
172
+ gainNode.connect(this.masterGainNode);
173
+ // Play
174
+ oscillator.start(startTime);
175
+ oscillator.stop(startTime + noteDuration);
176
+ });
177
+ this.log('Start sound played (retro arpeggio)');
178
+ }
179
+ catch (error) {
180
+ console.error('[AudioManager] Failed to play start sound:', error);
181
+ }
182
+ }
183
+ /**
184
+ * Play a tone at a specific frequency
185
+ *
186
+ * @param frequency Frequency in Hz (e.g., 440 for A4)
187
+ * @param duration Duration in seconds
188
+ * @param options Tone options (type, volume, attack, release)
189
+ */
190
+ playTone(frequency, duration, options = {}) {
191
+ if (!this.audioContext || !this.masterGainNode) {
192
+ this.log('Cannot play tone: AudioContext not initialized');
193
+ return;
194
+ }
195
+ const { type = 'sine', volume = 0.3, attack = 0.01, release = 0.1 } = options;
196
+ try {
197
+ const ctx = this.audioContext;
198
+ const now = ctx.currentTime;
199
+ // Create oscillator
200
+ const oscillator = ctx.createOscillator();
201
+ oscillator.type = type;
202
+ oscillator.frequency.setValueAtTime(frequency, now);
203
+ // Create gain node for envelope
204
+ const gainNode = ctx.createGain();
205
+ gainNode.gain.setValueAtTime(0, now);
206
+ gainNode.gain.linearRampToValueAtTime(volume, now + attack);
207
+ gainNode.gain.setValueAtTime(volume, now + duration - release);
208
+ gainNode.gain.linearRampToValueAtTime(0, now + duration);
209
+ // Connect nodes
210
+ oscillator.connect(gainNode);
211
+ gainNode.connect(this.masterGainNode);
212
+ // Play
213
+ oscillator.start(now);
214
+ oscillator.stop(now + duration);
215
+ this.log(`Tone played: ${frequency}Hz for ${duration}s`);
216
+ }
217
+ catch (error) {
218
+ console.error('[AudioManager] Failed to play tone:', error);
219
+ }
220
+ }
221
+ /**
222
+ * Play a note by name (e.g., 'C4', 'A#5', 'Bb3')
223
+ *
224
+ * @param note Note name with octave (e.g., 'A4', 'C#5', 'Bb3')
225
+ * @param duration Duration in seconds
226
+ * @param options Tone options
227
+ */
228
+ playNote(note, duration, options = {}) {
229
+ const frequency = this.noteToFrequency(note);
230
+ if (frequency === null) {
231
+ console.warn(`[AudioManager] Invalid note: ${note}`);
232
+ return;
233
+ }
234
+ this.playTone(frequency, duration, options);
235
+ }
236
+ /**
237
+ * Convert a note name to frequency
238
+ *
239
+ * @param note Note name (e.g., 'A4', 'C#5', 'Bb3')
240
+ * @returns Frequency in Hz, or null if invalid
241
+ */
242
+ noteToFrequency(note) {
243
+ const notePattern = /^([A-Ga-g])([#b]?)(\d)$/;
244
+ const match = note.match(notePattern);
245
+ if (!match)
246
+ return null;
247
+ const [, noteLetter, accidental, octaveStr] = match;
248
+ const octave = parseInt(octaveStr, 10);
249
+ // Note to semitone offset from C
250
+ const noteOffsets = {
251
+ C: 0,
252
+ D: 2,
253
+ E: 4,
254
+ F: 5,
255
+ G: 7,
256
+ A: 9,
257
+ B: 11,
258
+ };
259
+ let semitone = noteOffsets[noteLetter.toUpperCase()];
260
+ if (semitone === undefined)
261
+ return null;
262
+ if (accidental === '#')
263
+ semitone += 1;
264
+ else if (accidental === 'b')
265
+ semitone -= 1;
266
+ // Calculate frequency (A4 = 440Hz)
267
+ // A4 is the 49th key on piano (0-indexed: 48)
268
+ // Formula: f = 440 * 2^((n-49)/12) where n is key number (1-indexed)
269
+ const keyNumber = semitone + (octave + 1) * 12;
270
+ const frequency = 440 * Math.pow(2, (keyNumber - 49) / 12);
271
+ return frequency;
272
+ }
273
+ /**
274
+ * Resume the AudioContext if it was suspended
275
+ * Useful when tab becomes visible again
276
+ */
277
+ async resumeContext() {
278
+ if (this.audioContext && this.audioContext.state === 'suspended') {
279
+ await this.audioContext.resume();
280
+ this.log('AudioContext resumed');
281
+ }
282
+ }
283
+ /**
284
+ * Suspend the AudioContext to save resources
285
+ * Useful when tab becomes hidden
286
+ */
287
+ async suspendContext() {
288
+ if (this.audioContext && this.audioContext.state === 'running') {
289
+ await this.audioContext.suspend();
290
+ this.log('AudioContext suspended');
291
+ }
292
+ }
293
+ /**
294
+ * Get the current state of the AudioContext
295
+ */
296
+ getState() {
297
+ return this.audioContext?.state ?? null;
298
+ }
299
+ // ═══════════════════════════════════════════════════════════════════════════
300
+ // SOUND BANK & PLAYBACK
301
+ // ═══════════════════════════════════════════════════════════════════════════
302
+ /**
303
+ * Get the sound bank for loading and managing sounds
304
+ * Returns null if not initialized
305
+ */
306
+ getSoundBank() {
307
+ return this.soundBank;
308
+ }
309
+ /**
310
+ * Play a loaded sound by name or ID
311
+ *
312
+ * @param nameOrId - Sound name (string) or ID (number)
313
+ * @param options - Playback options (volume, pitch, loop, position)
314
+ * @returns Handle to control the playing sound, or null if sound not found
315
+ *
316
+ * @example
317
+ * ```typescript
318
+ * // Simple playback (global, centered)
319
+ * const instance = audio.play('coin');
320
+ * console.log(instance.instanceId); // e.g., 1
321
+ *
322
+ * // With options
323
+ * audio.play('explosion', { volume: 0.5, pitch: 0.8 });
324
+ *
325
+ * // Positional audio (2D spatial)
326
+ * audio.play('footstep', { position: { x: 10000, y: 32768 } });
327
+ *
328
+ * // Looping music - can stop later by instanceId
329
+ * const music = audio.play('background', { loop: true });
330
+ * // Later: audio.stop(music.instanceId);
331
+ * ```
332
+ */
333
+ play(nameOrId, options = {}) {
334
+ if (!this.audioContext || !this.masterGainNode || !this.soundBank) {
335
+ this.log('Cannot play: not initialized');
336
+ return null;
337
+ }
338
+ const sound = this.soundBank.get(nameOrId);
339
+ if (!sound) {
340
+ console.warn(`[AudioManager] Sound not found: ${nameOrId}`);
341
+ return null;
342
+ }
343
+ try {
344
+ const ctx = this.audioContext;
345
+ // Use provided instanceId (from server) or generate one (standalone)
346
+ const instanceId = options.instanceId ?? this.nextInstanceId++;
347
+ // Ensure nextInstanceId stays ahead of any provided IDs to avoid conflicts
348
+ if (options.instanceId !== undefined && options.instanceId >= this.nextInstanceId) {
349
+ this.nextInstanceId = options.instanceId + 1;
350
+ }
351
+ // Create buffer source
352
+ const source = ctx.createBufferSource();
353
+ source.buffer = sound.buffer;
354
+ source.loop = options.loop ?? false;
355
+ source.playbackRate.value = options.pitch ?? 1.0;
356
+ // Create gain node for volume control
357
+ const gainNode = ctx.createGain();
358
+ const baseVolume = options.volume ?? 1.0;
359
+ const fadeInDuration = options.fadeIn ?? 0;
360
+ // Instance data
361
+ const instance = {
362
+ source,
363
+ gainNode,
364
+ soundName: sound.name,
365
+ stopped: false,
366
+ baseVolume,
367
+ targetVolume: baseVolume,
368
+ fadingOut: false,
369
+ paused: false,
370
+ pausedAt: 0,
371
+ startedAt: ctx.currentTime,
372
+ loop: options.loop ?? false,
373
+ playbackRate: options.pitch ?? 1.0,
374
+ buffer: sound.buffer,
375
+ };
376
+ // Calculate initial and target volumes
377
+ let initialVolume = baseVolume;
378
+ let targetVolume = baseVolume;
379
+ // Handle spatial audio if position is provided
380
+ if (options.position) {
381
+ const { pan, distanceVolume } = this.calculateSpatialAudio(options.position.x, options.position.y);
382
+ // Create stereo panner for left/right positioning
383
+ const pannerNode = ctx.createStereoPanner();
384
+ pannerNode.pan.value = pan;
385
+ // Calculate volumes with distance attenuation
386
+ initialVolume = fadeInDuration > 0 ? 0 : baseVolume * distanceVolume;
387
+ targetVolume = baseVolume * distanceVolume;
388
+ // Set initial volume
389
+ gainNode.gain.setValueAtTime(initialVolume, ctx.currentTime);
390
+ // Apply fade in if specified
391
+ if (fadeInDuration > 0) {
392
+ gainNode.gain.linearRampToValueAtTime(targetVolume, ctx.currentTime + fadeInDuration);
393
+ this.log(`Fade in: ${sound.name} [#${instanceId}] over ${fadeInDuration}s`);
394
+ }
395
+ // Connect: source -> gain -> panner -> master
396
+ source.connect(gainNode);
397
+ gainNode.connect(pannerNode);
398
+ pannerNode.connect(this.masterGainNode);
399
+ // Add spatial data to instance
400
+ instance.pannerNode = pannerNode;
401
+ instance.position = { x: options.position.x, y: options.position.y };
402
+ instance.targetVolume = targetVolume;
403
+ // Register for real-time spatial updates
404
+ this.spatialSounds.set(source, {
405
+ gainNode,
406
+ pannerNode,
407
+ position: { x: options.position.x, y: options.position.y },
408
+ baseVolume,
409
+ });
410
+ this.log(`Playing spatial: ${sound.name} [#${instanceId}] at (${options.position.x}, ${options.position.y}) pan=${pan.toFixed(2)} vol=${distanceVolume.toFixed(2)}`);
411
+ }
412
+ else {
413
+ // Non-spatial: source -> gain -> master
414
+ initialVolume = fadeInDuration > 0 ? 0 : baseVolume;
415
+ targetVolume = baseVolume;
416
+ // Set initial volume
417
+ gainNode.gain.setValueAtTime(initialVolume, ctx.currentTime);
418
+ // Apply fade in if specified
419
+ if (fadeInDuration > 0) {
420
+ gainNode.gain.linearRampToValueAtTime(targetVolume, ctx.currentTime + fadeInDuration);
421
+ this.log(`Fade in: ${sound.name} [#${instanceId}] over ${fadeInDuration}s`);
422
+ }
423
+ source.connect(gainNode);
424
+ gainNode.connect(this.masterGainNode);
425
+ }
426
+ // Register instance
427
+ this.instances.set(instanceId, instance);
428
+ // Track by sound name for stopping all instances of a sound
429
+ if (!this.instancesBySound.has(sound.name)) {
430
+ this.instancesBySound.set(sound.name, new Set());
431
+ }
432
+ this.instancesBySound.get(sound.name).add(instanceId);
433
+ // Track active sounds (legacy)
434
+ this.activeSounds.add(source);
435
+ // Cleanup on end (but not if paused - we'll recreate on resume)
436
+ source.onended = () => {
437
+ if (!instance.paused) {
438
+ this.cleanupInstance(instanceId);
439
+ }
440
+ };
441
+ // Start playback
442
+ source.start(0, options.offset ?? 0);
443
+ if (!options.position) {
444
+ this.log(`Playing: ${sound.name} [#${instanceId}]`);
445
+ }
446
+ // Return handle - capture references for closure
447
+ const stopFn = this.stop.bind(this);
448
+ const instancesRef = this.instances;
449
+ return {
450
+ instanceId,
451
+ stop: () => {
452
+ stopFn(instanceId);
453
+ },
454
+ get playing() {
455
+ const inst = instancesRef.get(instanceId);
456
+ return inst !== undefined && !inst.stopped;
457
+ },
458
+ sound,
459
+ };
460
+ }
461
+ catch (error) {
462
+ console.error(`[AudioManager] Failed to play sound "${nameOrId}":`, error);
463
+ return null;
464
+ }
465
+ }
466
+ /**
467
+ * Stop a sound by instance ID, sound name, or all sounds
468
+ *
469
+ * @param target - Instance ID (number), sound name (string), or 'all'
470
+ * @returns Number of instances stopped
471
+ *
472
+ * @example
473
+ * ```typescript
474
+ * // Stop specific instance
475
+ * const music = audio.play('background', { loop: true });
476
+ * audio.stop(music.instanceId);
477
+ *
478
+ * // Stop all instances of a sound
479
+ * audio.stop('explosion');
480
+ *
481
+ * // Stop everything
482
+ * audio.stop('all');
483
+ * ```
484
+ */
485
+ stop(target) {
486
+ if (target === 'all') {
487
+ return this.stopAll();
488
+ }
489
+ if (typeof target === 'number') {
490
+ // Stop specific instance by ID
491
+ return this.stopInstance(target) ? 1 : 0;
492
+ }
493
+ // Stop all instances of a sound by name
494
+ return this.stopByName(target);
495
+ }
496
+ /**
497
+ * Fade out and stop a sound
498
+ *
499
+ * @param target - Instance ID (number), sound name (string), or 'all'
500
+ * @param duration - Fade duration in seconds
501
+ * @returns Number of instances being faded
502
+ *
503
+ * @example
504
+ * ```typescript
505
+ * // Fade out specific instance over 2 seconds
506
+ * const music = audio.play('background', { loop: true });
507
+ * audio.fadeOut(music.instanceId, 2);
508
+ *
509
+ * // Fade out all instances of a sound
510
+ * audio.fadeOut('ambience', 1.5);
511
+ *
512
+ * // Fade out everything
513
+ * audio.fadeOut('all', 1);
514
+ * ```
515
+ */
516
+ fadeOut(target, duration) {
517
+ if (!this.audioContext) {
518
+ return 0;
519
+ }
520
+ // Ensure minimum duration
521
+ const fadeDuration = Math.max(0.01, duration);
522
+ if (target === 'all') {
523
+ return this.fadeOutAll(fadeDuration);
524
+ }
525
+ if (typeof target === 'number') {
526
+ return this.fadeOutInstance(target, fadeDuration) ? 1 : 0;
527
+ }
528
+ return this.fadeOutByName(target, fadeDuration);
529
+ }
530
+ /**
531
+ * Fade out a specific instance
532
+ * @private
533
+ */
534
+ fadeOutInstance(instanceId, duration) {
535
+ const instance = this.instances.get(instanceId);
536
+ if (!instance || instance.stopped || instance.fadingOut) {
537
+ return false;
538
+ }
539
+ if (!this.audioContext) {
540
+ return false;
541
+ }
542
+ const ctx = this.audioContext;
543
+ instance.fadingOut = true;
544
+ // Cancel any ongoing ramps and set current value
545
+ const currentValue = instance.gainNode.gain.value;
546
+ instance.gainNode.gain.cancelScheduledValues(ctx.currentTime);
547
+ instance.gainNode.gain.setValueAtTime(currentValue, ctx.currentTime);
548
+ // Ramp to 0
549
+ instance.gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + duration);
550
+ // Schedule stop after fade completes
551
+ setTimeout(() => {
552
+ this.stopInstance(instanceId);
553
+ }, duration * 1000);
554
+ this.log(`Fading out instance #${instanceId} (${instance.soundName}) over ${duration}s`);
555
+ return true;
556
+ }
557
+ /**
558
+ * Fade out all instances of a sound by name
559
+ * @private
560
+ */
561
+ fadeOutByName(soundName, duration) {
562
+ const instanceIds = this.instancesBySound.get(soundName);
563
+ if (!instanceIds || instanceIds.size === 0) {
564
+ return 0;
565
+ }
566
+ let count = 0;
567
+ for (const instanceId of Array.from(instanceIds)) {
568
+ if (this.fadeOutInstance(instanceId, duration)) {
569
+ count++;
570
+ }
571
+ }
572
+ this.log(`Fading out ${count} instances of "${soundName}" over ${duration}s`);
573
+ return count;
574
+ }
575
+ /**
576
+ * Fade out all playing sounds
577
+ * @private
578
+ */
579
+ fadeOutAll(duration) {
580
+ let count = 0;
581
+ for (const instanceId of Array.from(this.instances.keys())) {
582
+ if (this.fadeOutInstance(instanceId, duration)) {
583
+ count++;
584
+ }
585
+ }
586
+ this.log(`Fading out all sounds (${count} instances) over ${duration}s`);
587
+ return count;
588
+ }
589
+ // ═══════════════════════════════════════════════════════════════════════════
590
+ // PAUSE / RESUME
591
+ // ═══════════════════════════════════════════════════════════════════════════
592
+ /**
593
+ * Pause a sound by instance ID, sound name, or all sounds
594
+ *
595
+ * @param target - Instance ID (number), sound name (string), or 'all'
596
+ * @returns Number of instances paused
597
+ *
598
+ * @example
599
+ * ```typescript
600
+ * // Pause specific instance
601
+ * const music = audio.play('background', { loop: true });
602
+ * audio.pause(music.instanceId);
603
+ *
604
+ * // Pause all instances of a sound
605
+ * audio.pause('ambience');
606
+ *
607
+ * // Pause everything
608
+ * audio.pause('all');
609
+ * ```
610
+ */
611
+ pause(target) {
612
+ if (target === 'all') {
613
+ return this.pauseAll();
614
+ }
615
+ if (typeof target === 'number') {
616
+ return this.pauseInstance(target) ? 1 : 0;
617
+ }
618
+ return this.pauseByName(target);
619
+ }
620
+ /**
621
+ * Pause a specific instance
622
+ * @private
623
+ */
624
+ pauseInstance(instanceId) {
625
+ const instance = this.instances.get(instanceId);
626
+ if (!instance || instance.stopped || instance.paused || instance.fadingOut) {
627
+ return false;
628
+ }
629
+ if (!this.audioContext) {
630
+ return false;
631
+ }
632
+ const ctx = this.audioContext;
633
+ // Calculate current playback position
634
+ const elapsed = (ctx.currentTime - instance.startedAt) * instance.playbackRate;
635
+ if (instance.loop) {
636
+ // For looping sounds, wrap around the buffer duration
637
+ instance.pausedAt = elapsed % instance.buffer.duration;
638
+ }
639
+ else {
640
+ instance.pausedAt = elapsed;
641
+ // If sound has finished naturally, don't pause
642
+ if (instance.pausedAt >= instance.buffer.duration) {
643
+ return false;
644
+ }
645
+ }
646
+ // Stop the source (Web Audio API doesn't support pause, must recreate)
647
+ try {
648
+ instance.source.stop();
649
+ }
650
+ catch {
651
+ // Already stopped
652
+ }
653
+ instance.paused = true;
654
+ this.log(`Paused instance #${instanceId} (${instance.soundName}) at ${instance.pausedAt.toFixed(2)}s`);
655
+ return true;
656
+ }
657
+ /**
658
+ * Pause all instances of a sound by name
659
+ * @private
660
+ */
661
+ pauseByName(soundName) {
662
+ const instanceIds = this.instancesBySound.get(soundName);
663
+ if (!instanceIds || instanceIds.size === 0) {
664
+ return 0;
665
+ }
666
+ let count = 0;
667
+ for (const instanceId of Array.from(instanceIds)) {
668
+ if (this.pauseInstance(instanceId)) {
669
+ count++;
670
+ }
671
+ }
672
+ this.log(`Paused ${count} instances of "${soundName}"`);
673
+ return count;
674
+ }
675
+ /**
676
+ * Pause all playing sounds
677
+ * @private
678
+ */
679
+ pauseAll() {
680
+ let count = 0;
681
+ for (const instanceId of Array.from(this.instances.keys())) {
682
+ if (this.pauseInstance(instanceId)) {
683
+ count++;
684
+ }
685
+ }
686
+ this.log(`Paused all sounds (${count} instances)`);
687
+ return count;
688
+ }
689
+ /**
690
+ * Resume a paused sound by instance ID, sound name, or all sounds
691
+ *
692
+ * @param target - Instance ID (number), sound name (string), or 'all'
693
+ * @returns Number of instances resumed
694
+ *
695
+ * @example
696
+ * ```typescript
697
+ * // Resume specific instance
698
+ * audio.resume(musicInstanceId);
699
+ *
700
+ * // Resume all instances of a sound
701
+ * audio.resume('ambience');
702
+ *
703
+ * // Resume everything
704
+ * audio.resume('all');
705
+ * ```
706
+ */
707
+ resume(target) {
708
+ if (target === 'all') {
709
+ return this.resumeAll();
710
+ }
711
+ if (typeof target === 'number') {
712
+ return this.resumeInstance(target) ? 1 : 0;
713
+ }
714
+ return this.resumeByName(target);
715
+ }
716
+ /**
717
+ * Resume a specific paused instance
718
+ * @private
719
+ */
720
+ resumeInstance(instanceId) {
721
+ const instance = this.instances.get(instanceId);
722
+ if (!instance || instance.stopped || !instance.paused) {
723
+ return false;
724
+ }
725
+ if (!this.audioContext || !this.masterGainNode) {
726
+ return false;
727
+ }
728
+ const ctx = this.audioContext;
729
+ // Create a new source node (AudioBufferSourceNode can only be started once)
730
+ const newSource = ctx.createBufferSource();
731
+ newSource.buffer = instance.buffer;
732
+ newSource.loop = instance.loop;
733
+ newSource.playbackRate.value = instance.playbackRate;
734
+ // Connect through existing gain node
735
+ newSource.connect(instance.gainNode);
736
+ // Update instance with new source
737
+ instance.source = newSource;
738
+ instance.paused = false;
739
+ instance.startedAt = ctx.currentTime - instance.pausedAt / instance.playbackRate;
740
+ // Cleanup on end (but not if paused again - we'll recreate on resume)
741
+ newSource.onended = () => {
742
+ if (!instance.paused) {
743
+ this.cleanupInstance(instanceId);
744
+ }
745
+ };
746
+ // Start from where we paused
747
+ newSource.start(0, instance.pausedAt);
748
+ this.log(`Resumed instance #${instanceId} (${instance.soundName}) from ${instance.pausedAt.toFixed(2)}s`);
749
+ return true;
750
+ }
751
+ /**
752
+ * Resume all instances of a sound by name
753
+ * @private
754
+ */
755
+ resumeByName(soundName) {
756
+ const instanceIds = this.instancesBySound.get(soundName);
757
+ if (!instanceIds || instanceIds.size === 0) {
758
+ return 0;
759
+ }
760
+ let count = 0;
761
+ for (const instanceId of Array.from(instanceIds)) {
762
+ if (this.resumeInstance(instanceId)) {
763
+ count++;
764
+ }
765
+ }
766
+ this.log(`Resumed ${count} instances of "${soundName}"`);
767
+ return count;
768
+ }
769
+ /**
770
+ * Resume all paused sounds
771
+ * @private
772
+ */
773
+ resumeAll() {
774
+ let count = 0;
775
+ for (const instanceId of Array.from(this.instances.keys())) {
776
+ if (this.resumeInstance(instanceId)) {
777
+ count++;
778
+ }
779
+ }
780
+ this.log(`Resumed all sounds (${count} instances)`);
781
+ return count;
782
+ }
783
+ /**
784
+ * Stop a specific sound instance by ID
785
+ * @private
786
+ */
787
+ stopInstance(instanceId) {
788
+ const instance = this.instances.get(instanceId);
789
+ if (!instance || instance.stopped) {
790
+ return false;
791
+ }
792
+ try {
793
+ instance.source.stop();
794
+ instance.stopped = true;
795
+ this.log(`Stopped instance #${instanceId} (${instance.soundName})`);
796
+ }
797
+ catch {
798
+ // Already stopped
799
+ }
800
+ this.cleanupInstance(instanceId);
801
+ return true;
802
+ }
803
+ /**
804
+ * Stop all instances of a sound by name
805
+ * @private
806
+ */
807
+ stopByName(soundName) {
808
+ const instanceIds = this.instancesBySound.get(soundName);
809
+ if (!instanceIds || instanceIds.size === 0) {
810
+ return 0;
811
+ }
812
+ let count = 0;
813
+ // Copy to array to avoid mutation during iteration
814
+ for (const instanceId of Array.from(instanceIds)) {
815
+ if (this.stopInstance(instanceId)) {
816
+ count++;
817
+ }
818
+ }
819
+ this.log(`Stopped ${count} instances of "${soundName}"`);
820
+ return count;
821
+ }
822
+ /**
823
+ * Cleanup instance data after it ends or is stopped
824
+ * @private
825
+ */
826
+ cleanupInstance(instanceId) {
827
+ const instance = this.instances.get(instanceId);
828
+ if (!instance)
829
+ return;
830
+ // Remove from instances map
831
+ this.instances.delete(instanceId);
832
+ // Remove from sound name tracking
833
+ const byName = this.instancesBySound.get(instance.soundName);
834
+ if (byName) {
835
+ byName.delete(instanceId);
836
+ if (byName.size === 0) {
837
+ this.instancesBySound.delete(instance.soundName);
838
+ }
839
+ }
840
+ // Legacy cleanup
841
+ this.activeSounds.delete(instance.source);
842
+ this.spatialSounds.delete(instance.source);
843
+ }
844
+ // ═══════════════════════════════════════════════════════════════════════════
845
+ // SPATIAL AUDIO
846
+ // ═══════════════════════════════════════════════════════════════════════════
847
+ /**
848
+ * Set the listener position for spatial audio
849
+ * Updates all currently playing spatial sounds in real-time
850
+ *
851
+ * @param x - X position
852
+ * @param y - Y position
853
+ *
854
+ * @example
855
+ * ```typescript
856
+ * // Update listener position every frame (typically the player's position)
857
+ * audio.setListenerPosition(playerX, playerY);
858
+ * ```
859
+ */
860
+ setListenerPosition(x, y) {
861
+ // Skip if position hasn't changed
862
+ if (x === this.spatialConfig.listenerX && y === this.spatialConfig.listenerY) {
863
+ return;
864
+ }
865
+ this.spatialConfig.listenerX = x;
866
+ this.spatialConfig.listenerY = y;
867
+ // Update all active spatial sounds (only if we have any)
868
+ if (this.spatialSounds.size > 0) {
869
+ this.updateAllSpatialSounds();
870
+ }
871
+ }
872
+ /**
873
+ * Update all active spatial sounds based on current listener position
874
+ * Called automatically when listener position changes
875
+ * @private
876
+ */
877
+ updateAllSpatialSounds() {
878
+ for (const [source, spatialData] of this.spatialSounds) {
879
+ // Skip if sound has ended
880
+ if (!this.activeSounds.has(source)) {
881
+ this.spatialSounds.delete(source);
882
+ continue;
883
+ }
884
+ const { pan, distanceVolume } = this.calculateSpatialAudio(spatialData.position.x, spatialData.position.y);
885
+ // Update panner and gain in real-time
886
+ spatialData.pannerNode.pan.value = pan;
887
+ spatialData.gainNode.gain.value = spatialData.baseVolume * distanceVolume;
888
+ }
889
+ }
890
+ /**
891
+ * Get the current listener position
892
+ */
893
+ getListenerPosition() {
894
+ return {
895
+ x: this.spatialConfig.listenerX,
896
+ y: this.spatialConfig.listenerY,
897
+ };
898
+ }
899
+ /**
900
+ * Configure spatial audio parameters
901
+ *
902
+ * @param config - Partial spatial configuration
903
+ *
904
+ * @example
905
+ * ```typescript
906
+ * // Configure for a smaller game world
907
+ * audio.configureSpatial({
908
+ * maxDistance: 8192, // Sounds fade out at 8192 units
909
+ * referenceDistance: 512, // Full volume within 512 units
910
+ * rolloffFactor: 1.5, // Faster falloff
911
+ * panSpread: 1.0, // Full stereo pan
912
+ * });
913
+ * ```
914
+ */
915
+ configureSpatial(config) {
916
+ if (config.listenerX !== undefined) {
917
+ this.spatialConfig.listenerX = Math.max(0, Math.min(65535, config.listenerX));
918
+ }
919
+ if (config.listenerY !== undefined) {
920
+ this.spatialConfig.listenerY = Math.max(0, Math.min(65535, config.listenerY));
921
+ }
922
+ if (config.maxDistance !== undefined) {
923
+ this.spatialConfig.maxDistance = Math.max(1, config.maxDistance);
924
+ }
925
+ if (config.referenceDistance !== undefined) {
926
+ this.spatialConfig.referenceDistance = Math.max(1, config.referenceDistance);
927
+ }
928
+ if (config.rolloffFactor !== undefined) {
929
+ this.spatialConfig.rolloffFactor = Math.max(0, config.rolloffFactor);
930
+ }
931
+ if (config.panSpread !== undefined) {
932
+ this.spatialConfig.panSpread = Math.max(0, Math.min(1, config.panSpread));
933
+ }
934
+ }
935
+ /**
936
+ * Get current spatial audio configuration
937
+ */
938
+ getSpatialConfig() {
939
+ return { ...this.spatialConfig };
940
+ }
941
+ /**
942
+ * Calculate stereo pan and volume based on sound position relative to listener
943
+ *
944
+ * Uses a linear attenuation model:
945
+ * - Volume = 1.0 when distance <= referenceDistance
946
+ * - Volume decreases linearly from 1.0 to 0.0 between referenceDistance and maxDistance
947
+ * - Volume = 0.0 when distance >= maxDistance
948
+ *
949
+ * @param soundX - Sound X position
950
+ * @param soundY - Sound Y position
951
+ * @returns Object with pan (-1 to 1) and distanceVolume (0 to 1)
952
+ * @private
953
+ */
954
+ calculateSpatialAudio(soundX, soundY) {
955
+ const { listenerX, listenerY, maxDistance, referenceDistance, panSpread } = this.spatialConfig;
956
+ // Calculate distance
957
+ const dx = soundX - listenerX;
958
+ const dy = soundY - listenerY;
959
+ const distance = Math.sqrt(dx * dx + dy * dy);
960
+ // Calculate volume based on distance (linear attenuation model)
961
+ let distanceVolume;
962
+ if (distance <= referenceDistance) {
963
+ // Full volume within reference distance
964
+ distanceVolume = 1.0;
965
+ }
966
+ else if (distance >= maxDistance) {
967
+ // Silent beyond max distance
968
+ distanceVolume = 0.0;
969
+ }
970
+ else {
971
+ // Linear falloff between reference and max distance
972
+ distanceVolume = 1.0 - (distance - referenceDistance) / (maxDistance - referenceDistance);
973
+ }
974
+ // Calculate stereo pan based on X offset
975
+ // Pan ranges from -1 (left) to 1 (right)
976
+ let pan = 0;
977
+ if (maxDistance > 0 && panSpread > 0) {
978
+ // Normalize X difference to -1..1 range based on max distance
979
+ pan = (dx / maxDistance) * panSpread;
980
+ // Clamp to valid range
981
+ pan = Math.max(-1, Math.min(1, pan));
982
+ }
983
+ this.log(`Spatial: listener(${listenerX.toFixed(1)}, ${listenerY.toFixed(1)}) ` +
984
+ `sound(${soundX.toFixed(1)}, ${soundY.toFixed(1)}) ` +
985
+ `dist=${distance.toFixed(1)} vol=${distanceVolume.toFixed(2)} pan=${pan.toFixed(2)}`);
986
+ return { pan, distanceVolume };
987
+ }
988
+ /**
989
+ * Stop all currently playing sounds
990
+ * @returns Number of instances stopped
991
+ */
992
+ stopAll() {
993
+ const count = this.instances.size;
994
+ // Stop all tracked instances
995
+ for (const [, instance] of this.instances) {
996
+ if (!instance.stopped) {
997
+ try {
998
+ instance.source.stop();
999
+ instance.stopped = true;
1000
+ }
1001
+ catch {
1002
+ // Already stopped
1003
+ }
1004
+ }
1005
+ }
1006
+ // Clear all tracking
1007
+ this.instances.clear();
1008
+ this.instancesBySound.clear();
1009
+ this.activeSounds.clear();
1010
+ this.spatialSounds.clear();
1011
+ this.log(`Stopped all sounds (${count} instances)`);
1012
+ return count;
1013
+ }
1014
+ /**
1015
+ * Get the number of currently playing sounds
1016
+ */
1017
+ getPlayingCount() {
1018
+ return this.instances.size;
1019
+ }
1020
+ /**
1021
+ * Get all active instance IDs
1022
+ */
1023
+ getActiveInstances() {
1024
+ return Array.from(this.instances.keys());
1025
+ }
1026
+ /**
1027
+ * Check if a specific instance is still playing
1028
+ */
1029
+ isPlaying(instanceId) {
1030
+ const instance = this.instances.get(instanceId);
1031
+ return instance !== undefined && !instance.stopped;
1032
+ }
1033
+ /**
1034
+ * Destroy the AudioManager and release resources
1035
+ */
1036
+ destroy() {
1037
+ // Stop all sounds
1038
+ this.stopAll();
1039
+ // Clear sound bank
1040
+ if (this.soundBank) {
1041
+ this.soundBank.clear();
1042
+ this.soundBank = null;
1043
+ }
1044
+ if (this.audioContext) {
1045
+ this.audioContext.close();
1046
+ this.audioContext = null;
1047
+ }
1048
+ this.masterGainNode = null;
1049
+ this.initialized = false;
1050
+ this.log('AudioManager destroyed');
1051
+ }
1052
+ log(message) {
1053
+ if (this.options.debug) {
1054
+ console.warn(`[AudioManager] ${message}`);
1055
+ }
1056
+ }
1057
+ }
1058
+ //# sourceMappingURL=AudioManager.js.map