audio-channel-queue 1.9.0 → 1.11.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/README.md +207 -311
- package/dist/core.d.ts +61 -3
- package/dist/core.js +344 -50
- package/dist/errors.d.ts +1 -0
- package/dist/errors.js +10 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.js +23 -2
- package/dist/info.d.ts +58 -6
- package/dist/info.js +172 -10
- package/dist/pause.js +1 -1
- package/dist/queue-manipulation.d.ts +104 -0
- package/dist/queue-manipulation.js +319 -0
- package/dist/types.d.ts +46 -8
- package/dist/types.js +13 -1
- package/dist/utils.d.ts +25 -0
- package/dist/utils.js +98 -10
- package/dist/volume.d.ts +14 -1
- package/dist/volume.js +173 -54
- package/package.json +12 -2
- package/src/core.ts +403 -60
- package/src/errors.ts +14 -2
- package/src/index.ts +28 -5
- package/src/info.ts +204 -9
- package/src/pause.ts +4 -6
- package/src/queue-manipulation.ts +378 -0
- package/src/types.ts +51 -9
- package/src/utils.ts +110 -9
- package/src/volume.ts +214 -62
package/src/volume.ts
CHANGED
|
@@ -2,11 +2,27 @@
|
|
|
2
2
|
* @fileoverview Volume management functions for the audio-channel-queue package
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ExtendedAudioQueueChannel,
|
|
7
|
+
VolumeConfig,
|
|
8
|
+
FadeType,
|
|
9
|
+
FadeConfig,
|
|
10
|
+
EasingType,
|
|
11
|
+
TimerType,
|
|
12
|
+
MAX_CHANNELS
|
|
13
|
+
} from './types';
|
|
6
14
|
import { audioChannels } from './info';
|
|
7
15
|
|
|
8
16
|
// Store active volume transitions to handle interruptions
|
|
9
17
|
const activeTransitions: Map<number, number> = new Map();
|
|
18
|
+
// Track which timer type was used for each channel
|
|
19
|
+
const timerTypes: Map<number, TimerType> = new Map();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Global volume ducking configuration
|
|
23
|
+
* Stores the volume ducking settings that apply to all channels
|
|
24
|
+
*/
|
|
25
|
+
let globalVolumeConfig: VolumeConfig | null = null;
|
|
10
26
|
|
|
11
27
|
/**
|
|
12
28
|
* Predefined fade configurations for different transition types
|
|
@@ -81,14 +97,20 @@ export const transitionVolume = async (
|
|
|
81
97
|
// Cancel any existing transition for this channel
|
|
82
98
|
if (activeTransitions.has(channelNumber)) {
|
|
83
99
|
const transitionId = activeTransitions.get(channelNumber);
|
|
100
|
+
const timerType = timerTypes.get(channelNumber);
|
|
84
101
|
if (transitionId) {
|
|
85
|
-
//
|
|
86
|
-
if (
|
|
102
|
+
// Cancel based on the timer type that was actually used
|
|
103
|
+
if (
|
|
104
|
+
timerType === TimerType.RequestAnimationFrame &&
|
|
105
|
+
typeof cancelAnimationFrame !== 'undefined'
|
|
106
|
+
) {
|
|
87
107
|
cancelAnimationFrame(transitionId);
|
|
108
|
+
} else if (timerType === TimerType.Timeout) {
|
|
109
|
+
clearTimeout(transitionId);
|
|
88
110
|
}
|
|
89
|
-
clearTimeout(transitionId);
|
|
90
111
|
}
|
|
91
112
|
activeTransitions.delete(channelNumber);
|
|
113
|
+
timerTypes.delete(channelNumber);
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
// If no change needed, resolve immediately
|
|
@@ -97,8 +119,8 @@ export const transitionVolume = async (
|
|
|
97
119
|
return Promise.resolve();
|
|
98
120
|
}
|
|
99
121
|
|
|
100
|
-
// Handle zero duration - instant change
|
|
101
|
-
if (duration
|
|
122
|
+
// Handle zero or negative duration - instant change
|
|
123
|
+
if (duration <= 0) {
|
|
102
124
|
channel.volume = targetVolume;
|
|
103
125
|
if (channel.queue.length > 0) {
|
|
104
126
|
channel.queue[0].volume = targetVolume;
|
|
@@ -127,16 +149,19 @@ export const transitionVolume = async (
|
|
|
127
149
|
if (progress >= 1) {
|
|
128
150
|
// Transition complete
|
|
129
151
|
activeTransitions.delete(channelNumber);
|
|
152
|
+
timerTypes.delete(channelNumber);
|
|
130
153
|
resolve();
|
|
131
154
|
} else {
|
|
132
155
|
// Use requestAnimationFrame in browser, setTimeout in tests
|
|
133
156
|
if (typeof requestAnimationFrame !== 'undefined') {
|
|
134
157
|
const rafId = requestAnimationFrame(updateVolume);
|
|
135
158
|
activeTransitions.set(channelNumber, rafId as unknown as number);
|
|
159
|
+
timerTypes.set(channelNumber, TimerType.RequestAnimationFrame);
|
|
136
160
|
} else {
|
|
137
161
|
// In test environment, use shorter intervals
|
|
138
162
|
const timeoutId = setTimeout(updateVolume, 1);
|
|
139
163
|
activeTransitions.set(channelNumber, timeoutId as unknown as number);
|
|
164
|
+
timerTypes.set(channelNumber, TimerType.Timeout);
|
|
140
165
|
}
|
|
141
166
|
}
|
|
142
167
|
};
|
|
@@ -151,6 +176,7 @@ export const transitionVolume = async (
|
|
|
151
176
|
* @param volume - Volume level (0-1)
|
|
152
177
|
* @param transitionDuration - Optional transition duration in milliseconds
|
|
153
178
|
* @param easing - Optional easing function
|
|
179
|
+
* @throws Error if the channel number exceeds the maximum allowed channels
|
|
154
180
|
* @example
|
|
155
181
|
* ```typescript
|
|
156
182
|
* setChannelVolume(0, 0.5); // Set channel 0 to 50%
|
|
@@ -165,6 +191,16 @@ export const setChannelVolume = async (
|
|
|
165
191
|
): Promise<void> => {
|
|
166
192
|
const clampedVolume: number = Math.max(0, Math.min(1, volume));
|
|
167
193
|
|
|
194
|
+
// Check channel number limits
|
|
195
|
+
if (channelNumber < 0) {
|
|
196
|
+
throw new Error('Channel number must be non-negative');
|
|
197
|
+
}
|
|
198
|
+
if (channelNumber >= MAX_CHANNELS) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Channel number ${channelNumber} exceeds maximum allowed channels (${MAX_CHANNELS})`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
168
204
|
if (!audioChannels[channelNumber]) {
|
|
169
205
|
audioChannels[channelNumber] = {
|
|
170
206
|
audioCompleteCallbacks: new Set(),
|
|
@@ -246,6 +282,7 @@ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
|
|
|
246
282
|
* Configures volume ducking for channels. When the priority channel plays audio,
|
|
247
283
|
* all other channels will be automatically reduced to the ducking volume level
|
|
248
284
|
* @param config - Volume ducking configuration
|
|
285
|
+
* @throws Error if the priority channel number exceeds the maximum allowed channels
|
|
249
286
|
* @example
|
|
250
287
|
* ```typescript
|
|
251
288
|
* // When channel 1 plays, reduce all other channels to 20% volume
|
|
@@ -257,8 +294,23 @@ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
|
|
|
257
294
|
* ```
|
|
258
295
|
*/
|
|
259
296
|
export const setVolumeDucking = (config: VolumeConfig): void => {
|
|
260
|
-
|
|
261
|
-
|
|
297
|
+
const { priorityChannel } = config;
|
|
298
|
+
|
|
299
|
+
// Check priority channel limits
|
|
300
|
+
if (priorityChannel < 0) {
|
|
301
|
+
throw new Error('Priority channel number must be non-negative');
|
|
302
|
+
}
|
|
303
|
+
if (priorityChannel >= MAX_CHANNELS) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Priority channel ${priorityChannel} exceeds maximum allowed channels (${MAX_CHANNELS})`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Store the configuration globally
|
|
310
|
+
globalVolumeConfig = config;
|
|
311
|
+
|
|
312
|
+
// Ensure we have enough channels for the priority channel
|
|
313
|
+
while (audioChannels.length <= priorityChannel) {
|
|
262
314
|
audioChannels.push({
|
|
263
315
|
audioCompleteCallbacks: new Set(),
|
|
264
316
|
audioErrorCallbacks: new Set(),
|
|
@@ -272,25 +324,6 @@ export const setVolumeDucking = (config: VolumeConfig): void => {
|
|
|
272
324
|
volume: 1.0
|
|
273
325
|
});
|
|
274
326
|
}
|
|
275
|
-
|
|
276
|
-
// Apply the config to all existing channels
|
|
277
|
-
audioChannels.forEach((channel: ExtendedAudioQueueChannel, index: number) => {
|
|
278
|
-
if (!audioChannels[index]) {
|
|
279
|
-
audioChannels[index] = {
|
|
280
|
-
audioCompleteCallbacks: new Set(),
|
|
281
|
-
audioErrorCallbacks: new Set(),
|
|
282
|
-
audioPauseCallbacks: new Set(),
|
|
283
|
-
audioResumeCallbacks: new Set(),
|
|
284
|
-
audioStartCallbacks: new Set(),
|
|
285
|
-
isPaused: false,
|
|
286
|
-
progressCallbacks: new Map(),
|
|
287
|
-
queue: [],
|
|
288
|
-
queueChangeCallbacks: new Set(),
|
|
289
|
-
volume: 1.0
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
audioChannels[index].volumeConfig = config;
|
|
293
|
-
});
|
|
294
327
|
};
|
|
295
328
|
|
|
296
329
|
/**
|
|
@@ -301,11 +334,7 @@ export const setVolumeDucking = (config: VolumeConfig): void => {
|
|
|
301
334
|
* ```
|
|
302
335
|
*/
|
|
303
336
|
export const clearVolumeDucking = (): void => {
|
|
304
|
-
|
|
305
|
-
if (channel) {
|
|
306
|
-
delete channel.volumeConfig;
|
|
307
|
-
}
|
|
308
|
-
});
|
|
337
|
+
globalVolumeConfig = null;
|
|
309
338
|
};
|
|
310
339
|
|
|
311
340
|
/**
|
|
@@ -314,27 +343,36 @@ export const clearVolumeDucking = (): void => {
|
|
|
314
343
|
* @internal
|
|
315
344
|
*/
|
|
316
345
|
export const applyVolumeDucking = async (activeChannelNumber: number): Promise<void> => {
|
|
346
|
+
// Check if ducking is configured and this channel is the priority channel
|
|
347
|
+
if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== activeChannelNumber) {
|
|
348
|
+
return; // No ducking configured for this channel
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const config = globalVolumeConfig;
|
|
317
352
|
const transitionPromises: Promise<void>[] = [];
|
|
353
|
+
const duration = config.duckTransitionDuration ?? 250;
|
|
354
|
+
const easing = config.transitionEasing ?? EasingType.EaseOut;
|
|
318
355
|
|
|
356
|
+
// Duck all channels except the priority channel
|
|
319
357
|
audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
|
|
320
|
-
if (channel
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
358
|
+
if (!channel || channel.queue.length === 0) {
|
|
359
|
+
return; // Skip channels without audio
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (channelNumber === activeChannelNumber) {
|
|
363
|
+
// This is the priority channel - set to priority volume
|
|
364
|
+
// Only change audio volume, preserve channel.volume as desired volume
|
|
365
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
366
|
+
transitionPromises.push(
|
|
367
|
+
transitionAudioVolume(currentAudio, config.priorityVolume, duration, easing)
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
// This is a background channel - duck it
|
|
371
|
+
// Only change audio volume, preserve channel.volume as desired volume
|
|
372
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
373
|
+
transitionPromises.push(
|
|
374
|
+
transitionAudioVolume(currentAudio, config.duckingVolume, duration, easing)
|
|
375
|
+
);
|
|
338
376
|
}
|
|
339
377
|
});
|
|
340
378
|
|
|
@@ -365,29 +403,143 @@ export const fadeVolume = async (
|
|
|
365
403
|
};
|
|
366
404
|
|
|
367
405
|
/**
|
|
368
|
-
* Restores normal volume levels when priority channel
|
|
406
|
+
* Restores normal volume levels when priority channel queue becomes empty
|
|
369
407
|
* @param stoppedChannelNumber - The channel that just stopped playing
|
|
370
408
|
* @internal
|
|
371
409
|
*/
|
|
372
410
|
export const restoreVolumeLevels = async (stoppedChannelNumber: number): Promise<void> => {
|
|
411
|
+
// Check if ducking is configured and this channel is the priority channel
|
|
412
|
+
if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== stoppedChannelNumber) {
|
|
413
|
+
return; // No ducking configured for this channel
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Check if the priority channel queue is now empty
|
|
417
|
+
const priorityChannel = audioChannels[stoppedChannelNumber];
|
|
418
|
+
if (priorityChannel && priorityChannel.queue.length > 0) {
|
|
419
|
+
return; // Priority channel still has audio queued, don't restore yet
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const config = globalVolumeConfig;
|
|
373
423
|
const transitionPromises: Promise<void>[] = [];
|
|
374
424
|
|
|
425
|
+
// Restore volume for all channels EXCEPT the priority channel
|
|
375
426
|
audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
|
|
376
|
-
|
|
377
|
-
|
|
427
|
+
// Skip the priority channel itself and channels without audio
|
|
428
|
+
if (channelNumber === stoppedChannelNumber || !channel || channel.queue.length === 0) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
378
431
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
432
|
+
// Restore this channel to its desired volume
|
|
433
|
+
const duration = config.restoreTransitionDuration ?? 500;
|
|
434
|
+
const easing = config.transitionEasing ?? EasingType.EaseOut;
|
|
435
|
+
const targetVolume = channel.volume ?? 1.0;
|
|
382
436
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
437
|
+
// Only transition the audio element volume, keep channel.volume as the desired volume
|
|
438
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
439
|
+
transitionPromises.push(transitionAudioVolume(currentAudio, targetVolume, duration, easing));
|
|
389
440
|
});
|
|
390
441
|
|
|
391
442
|
// Wait for all transitions to complete
|
|
392
443
|
await Promise.all(transitionPromises);
|
|
393
444
|
};
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Transitions only the audio element volume without affecting channel.volume
|
|
448
|
+
* This is used for ducking/restoration where channel.volume represents desired volume
|
|
449
|
+
* @param audio - The audio element to transition
|
|
450
|
+
* @param targetVolume - Target volume level (0-1)
|
|
451
|
+
* @param duration - Transition duration in milliseconds
|
|
452
|
+
* @param easing - Easing function type
|
|
453
|
+
* @returns Promise that resolves when transition completes
|
|
454
|
+
* @internal
|
|
455
|
+
*/
|
|
456
|
+
const transitionAudioVolume = async (
|
|
457
|
+
audio: HTMLAudioElement,
|
|
458
|
+
targetVolume: number,
|
|
459
|
+
duration: number = 250,
|
|
460
|
+
easing: EasingType = EasingType.EaseOut
|
|
461
|
+
): Promise<void> => {
|
|
462
|
+
const startVolume: number = audio.volume;
|
|
463
|
+
const volumeDelta: number = targetVolume - startVolume;
|
|
464
|
+
|
|
465
|
+
// If no change needed, resolve immediately
|
|
466
|
+
if (Math.abs(volumeDelta) < 0.001) {
|
|
467
|
+
return Promise.resolve();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Handle zero or negative duration - instant change
|
|
471
|
+
if (duration <= 0) {
|
|
472
|
+
audio.volume = Math.max(0, Math.min(1, targetVolume));
|
|
473
|
+
return Promise.resolve();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const startTime: number = performance.now();
|
|
477
|
+
const easingFn = easingFunctions[easing];
|
|
478
|
+
|
|
479
|
+
return new Promise<void>((resolve) => {
|
|
480
|
+
const updateVolume = (): void => {
|
|
481
|
+
const elapsed: number = performance.now() - startTime;
|
|
482
|
+
const progress: number = Math.min(elapsed / duration, 1);
|
|
483
|
+
const easedProgress: number = easingFn(progress);
|
|
484
|
+
|
|
485
|
+
const currentVolume: number = startVolume + volumeDelta * easedProgress;
|
|
486
|
+
const clampedVolume: number = Math.max(0, Math.min(1, currentVolume));
|
|
487
|
+
|
|
488
|
+
// Only apply volume to audio element, not channel.volume
|
|
489
|
+
audio.volume = clampedVolume;
|
|
490
|
+
|
|
491
|
+
if (progress >= 1) {
|
|
492
|
+
resolve();
|
|
493
|
+
} else {
|
|
494
|
+
// Use requestAnimationFrame in browser, setTimeout in tests
|
|
495
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
496
|
+
requestAnimationFrame(updateVolume);
|
|
497
|
+
} else {
|
|
498
|
+
setTimeout(updateVolume, 1);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
updateVolume();
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Cancels any active volume transition for a specific channel
|
|
509
|
+
* @param channelNumber - The channel number to cancel transitions for
|
|
510
|
+
* @internal
|
|
511
|
+
*/
|
|
512
|
+
export const cancelVolumeTransition = (channelNumber: number): void => {
|
|
513
|
+
if (activeTransitions.has(channelNumber)) {
|
|
514
|
+
const transitionId = activeTransitions.get(channelNumber);
|
|
515
|
+
const timerType = timerTypes.get(channelNumber);
|
|
516
|
+
|
|
517
|
+
if (transitionId) {
|
|
518
|
+
// Cancel based on the timer type that was actually used
|
|
519
|
+
if (
|
|
520
|
+
timerType === TimerType.RequestAnimationFrame &&
|
|
521
|
+
typeof cancelAnimationFrame !== 'undefined'
|
|
522
|
+
) {
|
|
523
|
+
cancelAnimationFrame(transitionId);
|
|
524
|
+
} else if (timerType === TimerType.Timeout) {
|
|
525
|
+
clearTimeout(transitionId);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
activeTransitions.delete(channelNumber);
|
|
530
|
+
timerTypes.delete(channelNumber);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Cancels all active volume transitions across all channels
|
|
536
|
+
* @internal
|
|
537
|
+
*/
|
|
538
|
+
export const cancelAllVolumeTransitions = (): void => {
|
|
539
|
+
// Get all active channel numbers to avoid modifying Map while iterating
|
|
540
|
+
const activeChannels = Array.from(activeTransitions.keys());
|
|
541
|
+
|
|
542
|
+
activeChannels.forEach((channelNumber) => {
|
|
543
|
+
cancelVolumeTransition(channelNumber);
|
|
544
|
+
});
|
|
545
|
+
};
|