@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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/AudioManager.js +1058 -0
- package/dist/AudioManager.js.map +1 -0
- package/dist/SoundBank.js +286 -0
- package/dist/SoundBank.js.map +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +620 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1 -0
- package/package.json +58 -0
|
@@ -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
|