audio-channel-queue 1.9.0 → 1.10.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 +197 -313
- package/dist/core.d.ts +59 -1
- package/dist/core.js +331 -40
- package/dist/errors.d.ts +1 -0
- package/dist/errors.js +10 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +21 -2
- package/dist/info.d.ts +17 -6
- package/dist/info.js +81 -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 +389 -50
- package/src/errors.ts +14 -2
- package/src/index.ts +26 -5
- package/src/info.ts +107 -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/types.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* @fileoverview Type definitions for the audio-channel-queue package
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Maximum number of audio channels allowed to prevent memory exhaustion
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_CHANNELS: number = 64;
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* Symbol used as a key for global (channel-wide) progress callbacks
|
|
7
12
|
* This avoids the need for `null as any` type assertions
|
|
@@ -39,19 +44,33 @@ export interface VolumeConfig {
|
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
|
-
*
|
|
47
|
+
* Configuration options for queuing audio
|
|
43
48
|
*/
|
|
44
49
|
export interface AudioQueueOptions {
|
|
45
|
-
/** Whether to add
|
|
50
|
+
/** Whether to add this audio to the front of the queue (after currently playing) */
|
|
46
51
|
addToFront?: boolean;
|
|
47
52
|
/** Whether the audio should loop when it finishes */
|
|
48
53
|
loop?: boolean;
|
|
49
|
-
/**
|
|
54
|
+
/** Maximum number of items allowed in the queue (defaults to unlimited) */
|
|
55
|
+
maxQueueSize?: number;
|
|
56
|
+
/** @deprecated Use addToFront instead. Legacy support for priority queuing */
|
|
50
57
|
priority?: boolean;
|
|
51
|
-
/** Volume level for this specific audio
|
|
58
|
+
/** Volume level for this specific audio (0-1) */
|
|
52
59
|
volume?: number;
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Global queue configuration options
|
|
64
|
+
*/
|
|
65
|
+
export interface QueueConfig {
|
|
66
|
+
/** Default maximum queue size across all channels (defaults to unlimited) */
|
|
67
|
+
defaultMaxQueueSize?: number;
|
|
68
|
+
/** Whether to drop oldest items when queue is full (defaults to false - reject new items) */
|
|
69
|
+
dropOldestWhenFull?: boolean;
|
|
70
|
+
/** Whether to show warnings when queue limits are reached (defaults to true) */
|
|
71
|
+
showQueueWarnings?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
/**
|
|
56
75
|
* Comprehensive audio information interface providing metadata about currently playing audio
|
|
57
76
|
*/
|
|
@@ -142,6 +161,18 @@ export interface QueueSnapshot {
|
|
|
142
161
|
volume: number;
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Information about a queue manipulation operation result
|
|
166
|
+
*/
|
|
167
|
+
export interface QueueManipulationResult {
|
|
168
|
+
/** Error message if operation failed */
|
|
169
|
+
error?: string;
|
|
170
|
+
/** Whether the operation was successful */
|
|
171
|
+
success: boolean;
|
|
172
|
+
/** The queue snapshot after the operation (if successful) */
|
|
173
|
+
updatedQueue?: QueueSnapshot;
|
|
174
|
+
}
|
|
175
|
+
|
|
145
176
|
/**
|
|
146
177
|
* Callback function type for audio progress updates
|
|
147
178
|
* @param info Current audio information
|
|
@@ -224,7 +255,7 @@ export interface ErrorRecoveryOptions {
|
|
|
224
255
|
export type AudioErrorCallback = (errorInfo: AudioErrorInfo) => void;
|
|
225
256
|
|
|
226
257
|
/**
|
|
227
|
-
* Extended audio
|
|
258
|
+
* Extended audio channel with queue management and callback support
|
|
228
259
|
*/
|
|
229
260
|
export interface ExtendedAudioQueueChannel {
|
|
230
261
|
audioCompleteCallbacks: Set<AudioCompleteCallback>;
|
|
@@ -233,13 +264,16 @@ export interface ExtendedAudioQueueChannel {
|
|
|
233
264
|
audioResumeCallbacks: Set<AudioResumeCallback>;
|
|
234
265
|
audioStartCallbacks: Set<AudioStartCallback>;
|
|
235
266
|
fadeState?: ChannelFadeState;
|
|
236
|
-
isPaused
|
|
267
|
+
isPaused: boolean;
|
|
268
|
+
/** Active operation lock to prevent race conditions */
|
|
269
|
+
isLocked?: boolean;
|
|
270
|
+
/** Maximum allowed queue size for this channel */
|
|
271
|
+
maxQueueSize?: number;
|
|
237
272
|
progressCallbacks: Map<HTMLAudioElement | typeof GLOBAL_PROGRESS_KEY, Set<ProgressCallback>>;
|
|
238
273
|
queue: HTMLAudioElement[];
|
|
239
274
|
queueChangeCallbacks: Set<QueueChangeCallback>;
|
|
240
275
|
retryConfig?: RetryConfig;
|
|
241
|
-
volume
|
|
242
|
-
volumeConfig?: VolumeConfig;
|
|
276
|
+
volume: number;
|
|
243
277
|
}
|
|
244
278
|
|
|
245
279
|
/**
|
|
@@ -261,6 +295,14 @@ export enum FadeType {
|
|
|
261
295
|
Dramatic = 'dramatic'
|
|
262
296
|
}
|
|
263
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Timer types for volume transitions to ensure proper cleanup
|
|
300
|
+
*/
|
|
301
|
+
export enum TimerType {
|
|
302
|
+
RequestAnimationFrame = 'raf',
|
|
303
|
+
Timeout = 'timeout'
|
|
304
|
+
}
|
|
305
|
+
|
|
264
306
|
/**
|
|
265
307
|
* Configuration for fade transitions
|
|
266
308
|
*/
|
|
@@ -287,4 +329,4 @@ export interface ChannelFadeState {
|
|
|
287
329
|
customDuration?: number;
|
|
288
330
|
/** Whether the channel is currently transitioning (during any fade operation) to prevent capturing intermediate volumes during rapid pause/resume toggles */
|
|
289
331
|
isTransitioning?: boolean;
|
|
290
|
-
}
|
|
332
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -4,6 +4,101 @@
|
|
|
4
4
|
|
|
5
5
|
import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from './types';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Validates an audio URL for security and correctness
|
|
9
|
+
* @param url - The URL to validate
|
|
10
|
+
* @returns The validated URL
|
|
11
|
+
* @throws Error if the URL is invalid or potentially malicious
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* validateAudioUrl('https://example.com/audio.mp3'); // Valid
|
|
15
|
+
* validateAudioUrl('./sounds/local.wav'); // Valid relative path
|
|
16
|
+
* validateAudioUrl('javascript:alert("XSS")'); // Throws error
|
|
17
|
+
* validateAudioUrl('data:text/html,<script>alert("XSS")</script>'); // Throws error
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export const validateAudioUrl = (url: string): string => {
|
|
21
|
+
if (!url || typeof url !== 'string') {
|
|
22
|
+
throw new Error('Audio URL must be a non-empty string');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Trim whitespace
|
|
26
|
+
const trimmedUrl: string = url.trim();
|
|
27
|
+
|
|
28
|
+
// Check for dangerous protocols
|
|
29
|
+
const dangerousProtocols: string[] = [
|
|
30
|
+
'javascript:',
|
|
31
|
+
'data:',
|
|
32
|
+
'vbscript:',
|
|
33
|
+
'file:',
|
|
34
|
+
'about:',
|
|
35
|
+
'chrome:',
|
|
36
|
+
'chrome-extension:'
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const lowerUrl: string = trimmedUrl.toLowerCase();
|
|
40
|
+
for (const protocol of dangerousProtocols) {
|
|
41
|
+
if (lowerUrl.startsWith(protocol)) {
|
|
42
|
+
throw new Error(`Invalid audio URL: dangerous protocol "${protocol}" is not allowed`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check for path traversal attempts
|
|
47
|
+
if (trimmedUrl.includes('../') || trimmedUrl.includes('..\\')) {
|
|
48
|
+
throw new Error('Invalid audio URL: path traversal attempts are not allowed');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// For relative URLs, ensure they don't start with dangerous characters
|
|
52
|
+
if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) {
|
|
53
|
+
// Check for protocol-less URLs that might be interpreted as protocols
|
|
54
|
+
if (trimmedUrl.includes(':') && !trimmedUrl.startsWith('//')) {
|
|
55
|
+
const colonIndex: number = trimmedUrl.indexOf(':');
|
|
56
|
+
const beforeColon: string = trimmedUrl.substring(0, colonIndex);
|
|
57
|
+
// Allow only if it looks like a Windows drive letter (e.g., C:)
|
|
58
|
+
if (!/^[a-zA-Z]$/.test(beforeColon)) {
|
|
59
|
+
throw new Error('Invalid audio URL: suspicious protocol-like pattern detected');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Validate common audio file extensions (warning, not error)
|
|
65
|
+
const hasAudioExtension: boolean = /\.(mp3|wav|ogg|m4a|webm|aac|flac|opus|weba|mp4)$/i.test(
|
|
66
|
+
trimmedUrl
|
|
67
|
+
);
|
|
68
|
+
if (!hasAudioExtension && !trimmedUrl.includes('?')) {
|
|
69
|
+
// Log warning but don't throw - some valid URLs might not have extensions
|
|
70
|
+
// eslint-disable-next-line no-console
|
|
71
|
+
console.warn(`Audio URL "${trimmedUrl}" does not have a recognized audio file extension`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return trimmedUrl;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Sanitizes a string for safe display in HTML contexts
|
|
79
|
+
* @param text - The text to sanitize
|
|
80
|
+
* @returns The sanitized text safe for display
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* sanitizeForDisplay('<script>alert("XSS")</script>'); // Returns: '<script>alert("XSS")</script>'
|
|
84
|
+
* sanitizeForDisplay('normal-file.mp3'); // Returns: 'normal-file.mp3'
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export const sanitizeForDisplay = (text: string): string => {
|
|
88
|
+
if (!text || typeof text !== 'string') {
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Replace HTML special characters
|
|
93
|
+
return text
|
|
94
|
+
.replace(/&/g, '&')
|
|
95
|
+
.replace(/</g, '<')
|
|
96
|
+
.replace(/>/g, '>')
|
|
97
|
+
.replace(/"/g, '"')
|
|
98
|
+
.replace(/'/g, ''')
|
|
99
|
+
.replace(/\//g, '/');
|
|
100
|
+
};
|
|
101
|
+
|
|
7
102
|
/**
|
|
8
103
|
* Extracts the filename from a URL string
|
|
9
104
|
* @param url - The URL to extract the filename from
|
|
@@ -15,17 +110,23 @@ import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from '
|
|
|
15
110
|
* ```
|
|
16
111
|
*/
|
|
17
112
|
export const extractFileName = (url: string): string => {
|
|
113
|
+
if (!url || typeof url !== 'string') {
|
|
114
|
+
return sanitizeForDisplay('unknown');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Always use simple string manipulation for consistency
|
|
118
|
+
const segments: string[] = url.split('/');
|
|
119
|
+
const lastSegment: string = segments[segments.length - 1] || '';
|
|
120
|
+
|
|
121
|
+
// Remove query parameters and hash
|
|
122
|
+
const fileName: string = lastSegment.split('?')[0].split('#')[0];
|
|
123
|
+
|
|
124
|
+
// Decode URI components and sanitize
|
|
18
125
|
try {
|
|
19
|
-
|
|
20
|
-
const pathname: string = urlObj.pathname;
|
|
21
|
-
const segments: string[] = pathname.split('/');
|
|
22
|
-
const fileName: string = segments[segments.length - 1];
|
|
23
|
-
return fileName || 'unknown';
|
|
126
|
+
return sanitizeForDisplay(decodeURIComponent(fileName || 'unknown'));
|
|
24
127
|
} catch {
|
|
25
|
-
// If
|
|
26
|
-
|
|
27
|
-
const fileName: string = segments[segments.length - 1];
|
|
28
|
-
return fileName || 'unknown';
|
|
128
|
+
// If decoding fails, return the sanitized raw filename
|
|
129
|
+
return sanitizeForDisplay(fileName || 'unknown');
|
|
29
130
|
}
|
|
30
131
|
};
|
|
31
132
|
|
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
|
+
};
|