audioq 2.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/LICENSE +21 -0
- package/README.md +486 -0
- package/dist/core.d.ts +129 -0
- package/dist/core.js +591 -0
- package/dist/errors.d.ts +138 -0
- package/dist/errors.js +441 -0
- package/dist/events.d.ts +81 -0
- package/dist/events.js +217 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +119 -0
- package/dist/info.d.ts +224 -0
- package/dist/info.js +529 -0
- package/dist/pause.d.ts +170 -0
- package/dist/pause.js +467 -0
- package/dist/queue-manipulation.d.ts +104 -0
- package/dist/queue-manipulation.js +319 -0
- package/dist/types.d.ts +382 -0
- package/dist/types.js +55 -0
- package/dist/utils.d.ts +83 -0
- package/dist/utils.js +215 -0
- package/dist/volume.d.ts +162 -0
- package/dist/volume.js +644 -0
- package/dist/web-audio.d.ts +156 -0
- package/dist/web-audio.js +327 -0
- package/package.json +63 -0
- package/src/core.ts +698 -0
- package/src/errors.ts +467 -0
- package/src/events.ts +252 -0
- package/src/index.ts +162 -0
- package/src/info.ts +590 -0
- package/src/pause.ts +523 -0
- package/src/queue-manipulation.ts +378 -0
- package/src/types.ts +415 -0
- package/src/utils.ts +235 -0
- package/src/volume.ts +735 -0
- package/src/web-audio.ts +331 -0
package/src/volume.ts
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Volume management functions for the audioq package
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ExtendedAudioQueueChannel,
|
|
7
|
+
VolumeConfig,
|
|
8
|
+
FadeType,
|
|
9
|
+
FadeConfig,
|
|
10
|
+
EasingType,
|
|
11
|
+
TimerType,
|
|
12
|
+
MAX_CHANNELS
|
|
13
|
+
} from './types';
|
|
14
|
+
import { audioChannels } from './info';
|
|
15
|
+
import {
|
|
16
|
+
shouldUseWebAudio,
|
|
17
|
+
getAudioContext,
|
|
18
|
+
createWebAudioNodes,
|
|
19
|
+
setWebAudioVolume,
|
|
20
|
+
resumeAudioContext,
|
|
21
|
+
cleanupWebAudioNodes
|
|
22
|
+
} from './web-audio';
|
|
23
|
+
|
|
24
|
+
// Store active volume transitions to handle interruptions
|
|
25
|
+
const activeTransitions: Map<number, number> = new Map();
|
|
26
|
+
// Track which timer type was used for each channel
|
|
27
|
+
const timerTypes: Map<number, TimerType> = new Map();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Global volume multiplier that affects all channels
|
|
31
|
+
* Acts as a global volume control (0-1)
|
|
32
|
+
*/
|
|
33
|
+
let globalVolume: number = 1.0;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Global volume ducking configuration
|
|
37
|
+
* Stores the volume ducking settings that apply to all channels
|
|
38
|
+
*/
|
|
39
|
+
let globalVolumeConfig: VolumeConfig | null = null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Predefined fade configurations for different transition types
|
|
43
|
+
*/
|
|
44
|
+
const fadeConfigs: Record<FadeType, FadeConfig> = {
|
|
45
|
+
[FadeType.Dramatic]: {
|
|
46
|
+
duration: 800,
|
|
47
|
+
pauseCurve: EasingType.EaseIn,
|
|
48
|
+
resumeCurve: EasingType.EaseOut
|
|
49
|
+
},
|
|
50
|
+
[FadeType.Gentle]: {
|
|
51
|
+
duration: 800,
|
|
52
|
+
pauseCurve: EasingType.EaseOut,
|
|
53
|
+
resumeCurve: EasingType.EaseIn
|
|
54
|
+
},
|
|
55
|
+
[FadeType.Linear]: {
|
|
56
|
+
duration: 800,
|
|
57
|
+
pauseCurve: EasingType.Linear,
|
|
58
|
+
resumeCurve: EasingType.Linear
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets the fade configuration for a specific fade type
|
|
64
|
+
* @param fadeType - The fade type to get configuration for
|
|
65
|
+
* @returns Fade configuration object
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const config = getFadeConfig('gentle');
|
|
69
|
+
* console.log(`Gentle fade duration: ${config.duration}ms`);
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export const getFadeConfig = (fadeType: FadeType): FadeConfig => {
|
|
73
|
+
return { ...fadeConfigs[fadeType] };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Easing functions for smooth volume transitions
|
|
78
|
+
*/
|
|
79
|
+
const easingFunctions: Record<EasingType, (t: number) => number> = {
|
|
80
|
+
[EasingType.Linear]: (t: number): number => t,
|
|
81
|
+
[EasingType.EaseIn]: (t: number): number => t * t,
|
|
82
|
+
[EasingType.EaseOut]: (t: number): number => t * (2 - t),
|
|
83
|
+
[EasingType.EaseInOut]: (t: number): number => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Smoothly transitions volume for a specific channel over time
|
|
88
|
+
* @param channelNumber - The channel number to transition
|
|
89
|
+
* @param targetVolume - Target volume level (0-1)
|
|
90
|
+
* @param duration - Transition duration in milliseconds
|
|
91
|
+
* @param easing - Easing function type
|
|
92
|
+
* @returns Promise that resolves when transition completes
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* await transitionVolume(0, 0.2, 500, 'ease-out'); // Duck to 20% over 500ms
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export const transitionVolume = async (
|
|
99
|
+
channelNumber: number,
|
|
100
|
+
targetVolume: number,
|
|
101
|
+
duration: number = 250,
|
|
102
|
+
easing: EasingType = EasingType.EaseOut
|
|
103
|
+
): Promise<void> => {
|
|
104
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
105
|
+
|
|
106
|
+
if (!channel || channel.queue.length === 0) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
111
|
+
|
|
112
|
+
// When Web Audio is active, read the actual start volume from the gain node
|
|
113
|
+
// This is critical for iOS where audio.volume is ignored when Web Audio is active
|
|
114
|
+
let startVolume: number = currentAudio.volume;
|
|
115
|
+
if (channel.webAudioNodes) {
|
|
116
|
+
const nodes = channel.webAudioNodes.get(currentAudio);
|
|
117
|
+
if (nodes) {
|
|
118
|
+
startVolume = nodes.gainNode.gain.value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const volumeDelta: number = targetVolume - startVolume;
|
|
123
|
+
|
|
124
|
+
// Cancel any existing transition for this channel
|
|
125
|
+
if (activeTransitions.has(channelNumber)) {
|
|
126
|
+
const transitionId = activeTransitions.get(channelNumber);
|
|
127
|
+
const timerType = timerTypes.get(channelNumber);
|
|
128
|
+
if (transitionId) {
|
|
129
|
+
// Cancel based on the timer type that was actually used
|
|
130
|
+
if (
|
|
131
|
+
timerType === TimerType.RequestAnimationFrame &&
|
|
132
|
+
typeof cancelAnimationFrame !== 'undefined'
|
|
133
|
+
) {
|
|
134
|
+
cancelAnimationFrame(transitionId);
|
|
135
|
+
} else if (timerType === TimerType.Timeout) {
|
|
136
|
+
clearTimeout(transitionId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
activeTransitions.delete(channelNumber);
|
|
140
|
+
timerTypes.delete(channelNumber);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// If no change needed, resolve immediately
|
|
144
|
+
if (Math.abs(volumeDelta) < 0.001) {
|
|
145
|
+
channel.volume = targetVolume;
|
|
146
|
+
return Promise.resolve();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Handle zero or negative duration - instant change
|
|
150
|
+
if (duration <= 0) {
|
|
151
|
+
channel.volume = targetVolume;
|
|
152
|
+
if (channel.queue.length > 0) {
|
|
153
|
+
channel.queue[0].volume = targetVolume;
|
|
154
|
+
}
|
|
155
|
+
return Promise.resolve();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const startTime: number = performance.now();
|
|
159
|
+
const easingFn = easingFunctions[easing];
|
|
160
|
+
return new Promise<void>((resolve) => {
|
|
161
|
+
const updateVolume = async (): Promise<void> => {
|
|
162
|
+
const elapsed: number = performance.now() - startTime;
|
|
163
|
+
const progress: number = Math.min(elapsed / duration, 1);
|
|
164
|
+
const easedProgress: number = easingFn(progress);
|
|
165
|
+
|
|
166
|
+
const currentVolume: number = startVolume + volumeDelta * easedProgress;
|
|
167
|
+
const clampedVolume: number = Math.max(0, Math.min(1, currentVolume));
|
|
168
|
+
|
|
169
|
+
// Apply volume to both channel config and current audio
|
|
170
|
+
channel.volume = clampedVolume;
|
|
171
|
+
if (channel.queue.length > 0) {
|
|
172
|
+
await setVolumeForAudio(channel.queue[0], clampedVolume, channelNumber);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (progress >= 1) {
|
|
176
|
+
// Transition complete
|
|
177
|
+
activeTransitions.delete(channelNumber);
|
|
178
|
+
timerTypes.delete(channelNumber);
|
|
179
|
+
resolve();
|
|
180
|
+
} else {
|
|
181
|
+
// Use requestAnimationFrame in browser, setTimeout in tests
|
|
182
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
183
|
+
const rafId = requestAnimationFrame(() => updateVolume());
|
|
184
|
+
activeTransitions.set(channelNumber, rafId as unknown as number);
|
|
185
|
+
timerTypes.set(channelNumber, TimerType.RequestAnimationFrame);
|
|
186
|
+
} else {
|
|
187
|
+
// In test environment, use shorter intervals
|
|
188
|
+
const timeoutId = setTimeout(() => updateVolume(), 1);
|
|
189
|
+
activeTransitions.set(channelNumber, timeoutId as unknown as number);
|
|
190
|
+
timerTypes.set(channelNumber, TimerType.Timeout);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
updateVolume();
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Sets the volume for a specific channel with optional smooth transition
|
|
201
|
+
* Automatically uses Web Audio API on iOS devices for enhanced volume control
|
|
202
|
+
* @param channelNumber - The channel number to set volume for
|
|
203
|
+
* @param volume - Volume level (0-1)
|
|
204
|
+
* @param transitionDuration - Optional transition duration in milliseconds
|
|
205
|
+
* @param easing - Optional easing function
|
|
206
|
+
* @throws Error if the channel number exceeds the maximum allowed channels
|
|
207
|
+
* @example
|
|
208
|
+
* ```typescript
|
|
209
|
+
* setChannelVolume(0, 0.5); // Set channel 0 to 50%
|
|
210
|
+
* setChannelVolume(0, 0.5, 300, 'ease-out'); // Smooth transition over 300ms
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export const setChannelVolume = async (
|
|
214
|
+
channelNumber: number,
|
|
215
|
+
volume: number,
|
|
216
|
+
transitionDuration?: number,
|
|
217
|
+
easing?: EasingType
|
|
218
|
+
): Promise<void> => {
|
|
219
|
+
const clampedVolume: number = Math.max(0, Math.min(1, volume));
|
|
220
|
+
|
|
221
|
+
// Check channel number limits
|
|
222
|
+
if (channelNumber < 0) {
|
|
223
|
+
throw new Error('Channel number must be non-negative');
|
|
224
|
+
}
|
|
225
|
+
if (channelNumber >= MAX_CHANNELS) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Channel number ${channelNumber} exceeds maximum allowed channels (${MAX_CHANNELS})`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!audioChannels[channelNumber]) {
|
|
232
|
+
audioChannels[channelNumber] = {
|
|
233
|
+
audioCompleteCallbacks: new Set(),
|
|
234
|
+
audioErrorCallbacks: new Set(),
|
|
235
|
+
audioPauseCallbacks: new Set(),
|
|
236
|
+
audioResumeCallbacks: new Set(),
|
|
237
|
+
audioStartCallbacks: new Set(),
|
|
238
|
+
isPaused: false,
|
|
239
|
+
progressCallbacks: new Map(),
|
|
240
|
+
queue: [],
|
|
241
|
+
queueChangeCallbacks: new Set(),
|
|
242
|
+
volume: clampedVolume
|
|
243
|
+
};
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
248
|
+
|
|
249
|
+
// Initialize Web Audio API if needed and supported
|
|
250
|
+
if (shouldUseWebAudio() && !channel.webAudioContext) {
|
|
251
|
+
await initializeWebAudioForChannel(channelNumber);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (transitionDuration && transitionDuration > 0) {
|
|
255
|
+
// Smooth transition
|
|
256
|
+
await transitionVolume(channelNumber, clampedVolume, transitionDuration, easing);
|
|
257
|
+
} else {
|
|
258
|
+
// Instant change (backward compatibility)
|
|
259
|
+
channel.volume = clampedVolume;
|
|
260
|
+
if (channel.queue.length > 0) {
|
|
261
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
262
|
+
await setVolumeForAudio(currentAudio, clampedVolume, channelNumber);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Gets the current volume for a specific channel
|
|
269
|
+
* @param channelNumber - The channel number to get volume for (defaults to 0)
|
|
270
|
+
* @returns Current volume level (0-1) or 1.0 if channel doesn't exist
|
|
271
|
+
* @example
|
|
272
|
+
* ```typescript
|
|
273
|
+
* const volume = getChannelVolume(0);
|
|
274
|
+
* const defaultChannelVolume = getChannelVolume(); // Gets channel 0
|
|
275
|
+
* console.log(`Channel 0 volume: ${volume * 100}%`);
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
export const getChannelVolume = (channelNumber: number = 0): number => {
|
|
279
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
280
|
+
return channel?.volume ?? 1.0;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Gets the volume levels for all channels
|
|
285
|
+
* @returns Array of volume levels (0-1) for each channel
|
|
286
|
+
* @example
|
|
287
|
+
* ```typescript
|
|
288
|
+
* const volumes = getAllChannelsVolume();
|
|
289
|
+
* volumes.forEach((volume, index) => {
|
|
290
|
+
* console.log(`Channel ${index}: ${volume * 100}%`);
|
|
291
|
+
* });
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
export const getAllChannelsVolume = (): number[] => {
|
|
295
|
+
return audioChannels.map((channel: ExtendedAudioQueueChannel) => channel?.volume ?? 1.0);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Sets volume for all channels to the same level
|
|
300
|
+
* @param volume - Volume level (0-1) to apply to all channels
|
|
301
|
+
* @example
|
|
302
|
+
* ```typescript
|
|
303
|
+
* await setAllChannelsVolume(0.6); // Set all channels to 60% volume
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
export const setAllChannelsVolume = async (volume: number): Promise<void> => {
|
|
307
|
+
const promises: Promise<void>[] = [];
|
|
308
|
+
audioChannels.forEach((_channel: ExtendedAudioQueueChannel, index: number) => {
|
|
309
|
+
promises.push(setChannelVolume(index, volume));
|
|
310
|
+
});
|
|
311
|
+
await Promise.all(promises);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sets the global volume multiplier that affects all channels
|
|
316
|
+
* This acts as a global volume control - individual channel volumes are multiplied by this value
|
|
317
|
+
* @param volume - Global volume level (0-1, will be clamped to this range)
|
|
318
|
+
* @example
|
|
319
|
+
* ```typescript
|
|
320
|
+
* // Set channel-specific volumes
|
|
321
|
+
* await setChannelVolume(0, 0.8); // SFX at 80%
|
|
322
|
+
* await setChannelVolume(1, 0.6); // Music at 60%
|
|
323
|
+
*
|
|
324
|
+
* // Apply global volume of 50% - all channels play at half their set volume
|
|
325
|
+
* await setGlobalVolume(0.5); // SFX now plays at 40%, music at 30%
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
export const setGlobalVolume = async (volume: number): Promise<void> => {
|
|
329
|
+
// Clamp to valid range
|
|
330
|
+
globalVolume = Math.max(0, Math.min(1, volume));
|
|
331
|
+
|
|
332
|
+
// Update all currently playing audio to reflect the new global volume
|
|
333
|
+
// Note: setVolumeForAudio internally multiplies channel.volume by globalVolume
|
|
334
|
+
const updatePromises: Promise<void>[] = [];
|
|
335
|
+
audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
|
|
336
|
+
if (channel && channel.queue.length > 0) {
|
|
337
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
338
|
+
updatePromises.push(setVolumeForAudio(currentAudio, channel.volume, channelNumber));
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await Promise.all(updatePromises);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Gets the current global volume multiplier
|
|
347
|
+
* @returns Current global volume level (0-1), defaults to 1.0
|
|
348
|
+
* @example
|
|
349
|
+
* ```typescript
|
|
350
|
+
* const globalVol = getGlobalVolume();
|
|
351
|
+
* console.log(`Global volume is ${globalVol * 100}%`);
|
|
352
|
+
* ```
|
|
353
|
+
*/
|
|
354
|
+
export const getGlobalVolume = (): number => {
|
|
355
|
+
return globalVolume;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Configures volume ducking for channels. When the priority channel plays audio,
|
|
360
|
+
* all other channels will be automatically reduced to the ducking volume level
|
|
361
|
+
* @param config - Volume ducking configuration
|
|
362
|
+
* @throws Error if the priority channel number exceeds the maximum allowed channels
|
|
363
|
+
* @example
|
|
364
|
+
* ```typescript
|
|
365
|
+
* // When channel 1 plays, reduce all other channels to 20% volume
|
|
366
|
+
* setVolumeDucking({
|
|
367
|
+
* priorityChannel: 1,
|
|
368
|
+
* priorityVolume: 1.0,
|
|
369
|
+
* duckingVolume: 0.2
|
|
370
|
+
* });
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
export const setVolumeDucking = (config: VolumeConfig): void => {
|
|
374
|
+
const { priorityChannel } = config;
|
|
375
|
+
|
|
376
|
+
// Check priority channel limits
|
|
377
|
+
if (priorityChannel < 0) {
|
|
378
|
+
throw new Error('Priority channel number must be non-negative');
|
|
379
|
+
}
|
|
380
|
+
if (priorityChannel >= MAX_CHANNELS) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`Priority channel ${priorityChannel} exceeds maximum allowed channels (${MAX_CHANNELS})`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Store the configuration globally
|
|
387
|
+
globalVolumeConfig = config;
|
|
388
|
+
|
|
389
|
+
// Ensure we have enough channels for the priority channel
|
|
390
|
+
while (audioChannels.length <= priorityChannel) {
|
|
391
|
+
audioChannels.push({
|
|
392
|
+
audioCompleteCallbacks: new Set(),
|
|
393
|
+
audioErrorCallbacks: new Set(),
|
|
394
|
+
audioPauseCallbacks: new Set(),
|
|
395
|
+
audioResumeCallbacks: new Set(),
|
|
396
|
+
audioStartCallbacks: new Set(),
|
|
397
|
+
isPaused: false,
|
|
398
|
+
progressCallbacks: new Map(),
|
|
399
|
+
queue: [],
|
|
400
|
+
queueChangeCallbacks: new Set(),
|
|
401
|
+
volume: 1.0
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Removes volume ducking configuration from all channels
|
|
408
|
+
* @example
|
|
409
|
+
* ```typescript
|
|
410
|
+
* clearVolumeDucking(); // Remove all volume ducking effects
|
|
411
|
+
* ```
|
|
412
|
+
*/
|
|
413
|
+
export const clearVolumeDucking = (): void => {
|
|
414
|
+
globalVolumeConfig = null;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Applies volume ducking effects based on current playback state with smooth transitions
|
|
419
|
+
* @param activeChannelNumber - The channel that just started playing
|
|
420
|
+
* @internal
|
|
421
|
+
*/
|
|
422
|
+
export const applyVolumeDucking = async (activeChannelNumber: number): Promise<void> => {
|
|
423
|
+
// Check if ducking is configured and this channel is the priority channel
|
|
424
|
+
if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== activeChannelNumber) {
|
|
425
|
+
return; // No ducking configured for this channel
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const config = globalVolumeConfig;
|
|
429
|
+
const transitionPromises: Promise<void>[] = [];
|
|
430
|
+
const duration = config.duckTransitionDuration ?? 250;
|
|
431
|
+
const easing = config.transitionEasing ?? EasingType.EaseOut;
|
|
432
|
+
|
|
433
|
+
// Duck all channels except the priority channel
|
|
434
|
+
audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
|
|
435
|
+
if (!channel || channel.queue.length === 0) {
|
|
436
|
+
return; // Skip channels without audio
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (channelNumber === activeChannelNumber) {
|
|
440
|
+
// This is the priority channel - set to priority volume
|
|
441
|
+
// Only change audio volume, preserve channel.volume as desired volume
|
|
442
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
443
|
+
transitionPromises.push(
|
|
444
|
+
transitionAudioVolume(currentAudio, config.priorityVolume, duration, easing, channelNumber)
|
|
445
|
+
);
|
|
446
|
+
} else {
|
|
447
|
+
// This is a background channel - duck it
|
|
448
|
+
// Only change audio volume, preserve channel.volume as desired volume
|
|
449
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
450
|
+
transitionPromises.push(
|
|
451
|
+
transitionAudioVolume(currentAudio, config.duckingVolume, duration, easing, channelNumber)
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Wait for all transitions to complete
|
|
457
|
+
await Promise.all(transitionPromises);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Restores normal volume levels when priority channel queue becomes empty
|
|
462
|
+
* @param stoppedChannelNumber - The channel that just stopped playing
|
|
463
|
+
* @internal
|
|
464
|
+
*/
|
|
465
|
+
export const restoreVolumeLevels = async (stoppedChannelNumber: number): Promise<void> => {
|
|
466
|
+
// Check if ducking is configured and this channel is the priority channel
|
|
467
|
+
if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== stoppedChannelNumber) {
|
|
468
|
+
return; // No ducking configured for this channel
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Check if the priority channel queue is now empty
|
|
472
|
+
const priorityChannel = audioChannels[stoppedChannelNumber];
|
|
473
|
+
if (priorityChannel && priorityChannel.queue.length > 0) {
|
|
474
|
+
return; // Priority channel still has audio queued, don't restore yet
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const config = globalVolumeConfig;
|
|
478
|
+
const transitionPromises: Promise<void>[] = [];
|
|
479
|
+
|
|
480
|
+
// Restore volume for all channels EXCEPT the priority channel
|
|
481
|
+
audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
|
|
482
|
+
// Skip the priority channel itself and channels without audio
|
|
483
|
+
if (channelNumber === stoppedChannelNumber || !channel || channel.queue.length === 0) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Restore this channel to its desired volume
|
|
488
|
+
const duration = config.restoreTransitionDuration ?? 250;
|
|
489
|
+
const easing = config.transitionEasing ?? EasingType.EaseOut;
|
|
490
|
+
const targetVolume = channel.volume ?? 1.0;
|
|
491
|
+
|
|
492
|
+
// Only transition the audio element volume, keep channel.volume as the desired volume
|
|
493
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
494
|
+
transitionPromises.push(
|
|
495
|
+
transitionAudioVolume(currentAudio, targetVolume, duration, easing, channelNumber)
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Wait for all transitions to complete
|
|
500
|
+
await Promise.all(transitionPromises);
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Transitions only the audio element volume without affecting channel.volume
|
|
505
|
+
* This is used for ducking/restoration where channel.volume represents desired volume
|
|
506
|
+
* Uses Web Audio API when available for enhanced volume control
|
|
507
|
+
* @param audio - The audio element to transition
|
|
508
|
+
* @param targetVolume - Target volume level (0-1)
|
|
509
|
+
* @param duration - Transition duration in milliseconds
|
|
510
|
+
* @param easing - Easing function type
|
|
511
|
+
* @param channelNumber - The channel number this audio belongs to (for Web Audio API)
|
|
512
|
+
* @returns Promise that resolves when transition completes
|
|
513
|
+
* @internal
|
|
514
|
+
*/
|
|
515
|
+
const transitionAudioVolume = async (
|
|
516
|
+
audio: HTMLAudioElement,
|
|
517
|
+
targetVolume: number,
|
|
518
|
+
duration: number = 250,
|
|
519
|
+
easing: EasingType = EasingType.EaseOut,
|
|
520
|
+
channelNumber?: number
|
|
521
|
+
): Promise<void> => {
|
|
522
|
+
// Apply global volume multiplier
|
|
523
|
+
const actualTargetVolume: number = targetVolume * globalVolume;
|
|
524
|
+
|
|
525
|
+
// Try to use Web Audio API if available and channel number is provided
|
|
526
|
+
if (channelNumber !== undefined) {
|
|
527
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
528
|
+
if (channel?.webAudioContext && channel.webAudioNodes) {
|
|
529
|
+
const nodes = channel.webAudioNodes.get(audio);
|
|
530
|
+
if (nodes) {
|
|
531
|
+
// Use Web Audio API for smooth transitions
|
|
532
|
+
setWebAudioVolume(nodes.gainNode, actualTargetVolume, duration);
|
|
533
|
+
// Also update the audio element's volume property for consistency
|
|
534
|
+
audio.volume = actualTargetVolume;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Fallback to standard HTMLAudioElement volume control with manual transition
|
|
541
|
+
const startVolume: number = audio.volume;
|
|
542
|
+
const volumeDelta: number = actualTargetVolume - startVolume;
|
|
543
|
+
|
|
544
|
+
// If no change needed, resolve immediately
|
|
545
|
+
if (Math.abs(volumeDelta) < 0.001) {
|
|
546
|
+
return Promise.resolve();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Handle zero or negative duration - instant change
|
|
550
|
+
if (duration <= 0) {
|
|
551
|
+
audio.volume = actualTargetVolume;
|
|
552
|
+
return Promise.resolve();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const startTime: number = performance.now();
|
|
556
|
+
const easingFn = easingFunctions[easing];
|
|
557
|
+
|
|
558
|
+
return new Promise<void>((resolve) => {
|
|
559
|
+
const updateVolume = (): void => {
|
|
560
|
+
const elapsed: number = performance.now() - startTime;
|
|
561
|
+
const progress: number = Math.min(elapsed / duration, 1);
|
|
562
|
+
const easedProgress: number = easingFn(progress);
|
|
563
|
+
|
|
564
|
+
const currentVolume: number = startVolume + volumeDelta * easedProgress;
|
|
565
|
+
const clampedVolume: number = Math.max(0, Math.min(1, currentVolume));
|
|
566
|
+
|
|
567
|
+
// Only apply volume to audio element, not channel.volume
|
|
568
|
+
audio.volume = clampedVolume;
|
|
569
|
+
|
|
570
|
+
if (progress >= 1) {
|
|
571
|
+
resolve();
|
|
572
|
+
} else {
|
|
573
|
+
// Use requestAnimationFrame in browser, setTimeout in tests
|
|
574
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
575
|
+
requestAnimationFrame(updateVolume);
|
|
576
|
+
} else {
|
|
577
|
+
// In test environment, use longer intervals to prevent stack overflow
|
|
578
|
+
setTimeout(updateVolume, 16);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
updateVolume();
|
|
584
|
+
});
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Cancels any active volume transition for a specific channel
|
|
589
|
+
* @param channelNumber - The channel number to cancel transitions for
|
|
590
|
+
* @internal
|
|
591
|
+
*/
|
|
592
|
+
export const cancelVolumeTransition = (channelNumber: number): void => {
|
|
593
|
+
if (activeTransitions.has(channelNumber)) {
|
|
594
|
+
const transitionId = activeTransitions.get(channelNumber);
|
|
595
|
+
const timerType = timerTypes.get(channelNumber);
|
|
596
|
+
|
|
597
|
+
if (transitionId) {
|
|
598
|
+
// Cancel based on the timer type that was actually used
|
|
599
|
+
if (
|
|
600
|
+
timerType === TimerType.RequestAnimationFrame &&
|
|
601
|
+
typeof cancelAnimationFrame !== 'undefined'
|
|
602
|
+
) {
|
|
603
|
+
cancelAnimationFrame(transitionId);
|
|
604
|
+
} else if (timerType === TimerType.Timeout) {
|
|
605
|
+
clearTimeout(transitionId);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
activeTransitions.delete(channelNumber);
|
|
610
|
+
timerTypes.delete(channelNumber);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Cancels all active volume transitions across all channels
|
|
616
|
+
* @internal
|
|
617
|
+
*/
|
|
618
|
+
export const cancelAllVolumeTransitions = (): void => {
|
|
619
|
+
// Get all active channel numbers to avoid modifying Map while iterating
|
|
620
|
+
const activeChannels = Array.from(activeTransitions.keys());
|
|
621
|
+
|
|
622
|
+
activeChannels.forEach((channelNumber) => {
|
|
623
|
+
cancelVolumeTransition(channelNumber);
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Initializes Web Audio API for a specific channel
|
|
629
|
+
* @param channelNumber - The channel number to initialize Web Audio for
|
|
630
|
+
* @internal
|
|
631
|
+
*/
|
|
632
|
+
const initializeWebAudioForChannel = async (channelNumber: number): Promise<void> => {
|
|
633
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
634
|
+
if (!channel || channel.webAudioContext) return;
|
|
635
|
+
|
|
636
|
+
const audioContext = getAudioContext();
|
|
637
|
+
if (!audioContext) {
|
|
638
|
+
throw new Error('AudioContext creation failed');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Resume audio context if needed (for autoplay policy)
|
|
642
|
+
await resumeAudioContext(audioContext);
|
|
643
|
+
|
|
644
|
+
channel.webAudioContext = audioContext;
|
|
645
|
+
channel.webAudioNodes = new Map();
|
|
646
|
+
|
|
647
|
+
// Initialize Web Audio nodes for existing audio elements
|
|
648
|
+
for (const audio of channel.queue) {
|
|
649
|
+
const nodes = createWebAudioNodes(audio, audioContext);
|
|
650
|
+
if (!nodes) {
|
|
651
|
+
throw new Error('Node creation failed');
|
|
652
|
+
}
|
|
653
|
+
channel.webAudioNodes.set(audio, nodes);
|
|
654
|
+
// Set initial volume to match channel volume
|
|
655
|
+
nodes.gainNode.gain.value = channel.volume;
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Sets volume for an audio element using the appropriate method (Web Audio API or standard)
|
|
661
|
+
* @param audio - The audio element to set volume for
|
|
662
|
+
* @param volume - Channel volume level (0-1) - will be multiplied by global volume
|
|
663
|
+
* @param channelNumber - The channel number this audio belongs to
|
|
664
|
+
* @param transitionDuration - Optional transition duration in milliseconds
|
|
665
|
+
* @internal
|
|
666
|
+
*/
|
|
667
|
+
const setVolumeForAudio = async (
|
|
668
|
+
audio: HTMLAudioElement,
|
|
669
|
+
volume: number,
|
|
670
|
+
channelNumber: number,
|
|
671
|
+
transitionDuration?: number
|
|
672
|
+
): Promise<void> => {
|
|
673
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
674
|
+
|
|
675
|
+
// Apply global volume multiplier to the channel volume
|
|
676
|
+
const actualVolume: number = volume * globalVolume;
|
|
677
|
+
|
|
678
|
+
// Use Web Audio API if available and initialized
|
|
679
|
+
if (channel?.webAudioContext && channel.webAudioNodes) {
|
|
680
|
+
const nodes = channel.webAudioNodes.get(audio);
|
|
681
|
+
if (nodes) {
|
|
682
|
+
setWebAudioVolume(nodes.gainNode, actualVolume, transitionDuration);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Fallback to standard HTMLAudioElement volume control
|
|
688
|
+
audio.volume = actualVolume;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Initializes Web Audio API nodes for a new audio element
|
|
693
|
+
* @param audio - The audio element to initialize nodes for
|
|
694
|
+
* @param channelNumber - The channel number this audio belongs to
|
|
695
|
+
* @internal
|
|
696
|
+
*/
|
|
697
|
+
export const initializeWebAudioForAudio = async (
|
|
698
|
+
audio: HTMLAudioElement,
|
|
699
|
+
channelNumber: number
|
|
700
|
+
): Promise<void> => {
|
|
701
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
702
|
+
if (!channel) return;
|
|
703
|
+
|
|
704
|
+
// Initialize Web Audio API for the channel if needed
|
|
705
|
+
if (shouldUseWebAudio() && !channel.webAudioContext) {
|
|
706
|
+
await initializeWebAudioForChannel(channelNumber);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Create nodes for this specific audio element
|
|
710
|
+
if (channel.webAudioContext && channel.webAudioNodes && !channel.webAudioNodes.has(audio)) {
|
|
711
|
+
const nodes = createWebAudioNodes(audio, channel.webAudioContext);
|
|
712
|
+
if (nodes) {
|
|
713
|
+
channel.webAudioNodes.set(audio, nodes);
|
|
714
|
+
// Set initial volume to match channel volume with global volume multiplier
|
|
715
|
+
nodes.gainNode.gain.value = channel.volume * globalVolume;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Cleans up Web Audio API nodes for an audio element
|
|
722
|
+
* @param audio - The audio element to clean up nodes for
|
|
723
|
+
* @param channelNumber - The channel number this audio belongs to
|
|
724
|
+
* @internal
|
|
725
|
+
*/
|
|
726
|
+
export const cleanupWebAudioForAudio = (audio: HTMLAudioElement, channelNumber: number): void => {
|
|
727
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
728
|
+
if (!channel?.webAudioNodes) return;
|
|
729
|
+
|
|
730
|
+
const nodes = channel.webAudioNodes.get(audio);
|
|
731
|
+
if (nodes) {
|
|
732
|
+
cleanupWebAudioNodes(nodes);
|
|
733
|
+
channel.webAudioNodes.delete(audio);
|
|
734
|
+
}
|
|
735
|
+
};
|