audio-channel-queue 1.5.0 → 1.7.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/src/utils.ts CHANGED
@@ -1,109 +1,134 @@
1
- /**
2
- * @fileoverview Utility functions for the audio-channel-queue package
3
- */
4
-
5
- import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from './types';
6
-
7
- /**
8
- * Extracts the filename from a URL string
9
- * @param url - The URL to extract the filename from
10
- * @returns The extracted filename or 'unknown' if extraction fails
11
- * @example
12
- * ```typescript
13
- * extractFileName('https://example.com/audio/song.mp3') // Returns: 'song.mp3'
14
- * extractFileName('/path/to/audio.wav') // Returns: 'audio.wav'
15
- * ```
16
- */
17
- export const extractFileName = (url: string): string => {
18
- try {
19
- const urlObj: URL = new URL(url);
20
- const pathname: string = urlObj.pathname;
21
- const segments: string[] = pathname.split('/');
22
- const fileName: string = segments[segments.length - 1];
23
- return fileName || 'unknown';
24
- } catch {
25
- // If URL parsing fails, try simple string manipulation
26
- const segments: string[] = url.split('/');
27
- const fileName: string = segments[segments.length - 1];
28
- return fileName || 'unknown';
29
- }
30
- };
31
-
32
- /**
33
- * Extracts comprehensive audio information from an HTMLAudioElement
34
- * @param audio - The HTML audio element to extract information from
35
- * @returns AudioInfo object with current playback state or null if audio is invalid
36
- * @example
37
- * ```typescript
38
- * const audioElement = new Audio('song.mp3');
39
- * const info = getAudioInfoFromElement(audioElement);
40
- * console.log(info?.progress); // Current progress as decimal (0-1)
41
- * ```
42
- */
43
- export const getAudioInfoFromElement = (audio: HTMLAudioElement): AudioInfo | null => {
44
- if (!audio) return null;
45
-
46
- const duration: number = isNaN(audio.duration) ? 0 : audio.duration * 1000; // Convert to milliseconds
47
- const currentTime: number = isNaN(audio.currentTime) ? 0 : audio.currentTime * 1000; // Convert to milliseconds
48
- const progress: number = duration > 0 ? Math.min(currentTime / duration, 1) : 0;
49
- const isPlaying: boolean = !audio.paused && !audio.ended && audio.readyState > 2;
50
-
51
- return {
52
- currentTime,
53
- duration,
54
- fileName: extractFileName(audio.src),
55
- isPlaying,
56
- progress,
57
- src: audio.src
58
- };
59
- };
60
-
61
- /**
62
- * Creates a complete snapshot of a queue's current state
63
- * @param channelNumber - The channel number to create a snapshot for
64
- * @param audioChannels - Array of audio channels
65
- * @returns QueueSnapshot object or null if channel doesn't exist
66
- * @example
67
- * ```typescript
68
- * const snapshot = createQueueSnapshot(0, audioChannels);
69
- * console.log(`Queue has ${snapshot?.totalItems} items`);
70
- * ```
71
- */
72
- export const createQueueSnapshot = (
73
- channelNumber: number,
74
- audioChannels: ExtendedAudioQueueChannel[]
75
- ): QueueSnapshot | null => {
76
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
77
- if (!channel) return null;
78
-
79
- const items: QueueItem[] = channel.queue.map((audio: HTMLAudioElement, index: number) => ({
80
- duration: isNaN(audio.duration) ? 0 : audio.duration * 1000,
81
- fileName: extractFileName(audio.src),
82
- isCurrentlyPlaying: index === 0 && !audio.paused && !audio.ended,
83
- src: audio.src
84
- }));
85
-
86
- return {
87
- channelNumber,
88
- currentIndex: 0, // Current playing is always index 0 in our queue structure
89
- items,
90
- totalItems: channel.queue.length
91
- };
92
- };
93
-
94
- /**
95
- * Removes webpack hash patterns from filenames to get clean, readable names
96
- * @param fileName - The filename that may contain webpack hashes
97
- * @returns The cleaned filename with webpack hashes removed
98
- * @example
99
- * ```typescript
100
- * cleanWebpackFilename('song.a1b2c3d4.mp3') // Returns: 'song.mp3'
101
- * cleanWebpackFilename('notification.1a2b3c4d5e6f7890.wav') // Returns: 'notification.wav'
102
- * cleanWebpackFilename('music.12345678.ogg') // Returns: 'music.ogg'
103
- * cleanWebpackFilename('clean-file.mp3') // Returns: 'clean-file.mp3' (unchanged)
104
- * ```
105
- */
106
- export const cleanWebpackFilename = (fileName: string): string => {
107
- // Remove webpack hash pattern: filename.hash.ext → filename.ext
108
- return fileName.replace(/\.[a-f0-9]{8,}\./i, '.');
1
+ /**
2
+ * @fileoverview Utility functions for the audio-channel-queue package
3
+ */
4
+
5
+ import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from './types';
6
+
7
+ /**
8
+ * Extracts the filename from a URL string
9
+ * @param url - The URL to extract the filename from
10
+ * @returns The extracted filename or 'unknown' if extraction fails
11
+ * @example
12
+ * ```typescript
13
+ * extractFileName('https://example.com/audio/song.mp3') // Returns: 'song.mp3'
14
+ * extractFileName('/path/to/audio.wav') // Returns: 'audio.wav'
15
+ * ```
16
+ */
17
+ export const extractFileName = (url: string): string => {
18
+ try {
19
+ const urlObj: URL = new URL(url);
20
+ const pathname: string = urlObj.pathname;
21
+ const segments: string[] = pathname.split('/');
22
+ const fileName: string = segments[segments.length - 1];
23
+ return fileName || 'unknown';
24
+ } catch {
25
+ // If URL parsing fails, try simple string manipulation
26
+ const segments: string[] = url.split('/');
27
+ const fileName: string = segments[segments.length - 1];
28
+ return fileName || 'unknown';
29
+ }
30
+ };
31
+
32
+ /**
33
+ * Extracts comprehensive audio information from an HTMLAudioElement
34
+ * @param audio - The HTML audio element to extract information from
35
+ * @param channelNumber - Optional channel number to include remaining queue info
36
+ * @param audioChannels - Optional audio channels array to calculate remainingInQueue
37
+ * @returns AudioInfo object with current playback state or null if audio is invalid
38
+ * @example
39
+ * ```typescript
40
+ * const audioElement = new Audio('song.mp3');
41
+ * const info = getAudioInfoFromElement(audioElement);
42
+ * console.log(info?.progress); // Current progress as decimal (0-1)
43
+ *
44
+ * // With channel context for remainingInQueue
45
+ * const infoWithQueue = getAudioInfoFromElement(audioElement, 0, audioChannels);
46
+ * console.log(infoWithQueue?.remainingInQueue); // Number of items left in queue
47
+ * ```
48
+ */
49
+ export const getAudioInfoFromElement = (
50
+ audio: HTMLAudioElement,
51
+ channelNumber?: number,
52
+ audioChannels?: ExtendedAudioQueueChannel[]
53
+ ): AudioInfo | null => {
54
+ if (!audio) return null;
55
+
56
+ const duration: number = isNaN(audio.duration) ? 0 : audio.duration * 1000; // Convert to milliseconds
57
+ const currentTime: number = isNaN(audio.currentTime) ? 0 : audio.currentTime * 1000; // Convert to milliseconds
58
+ const progress: number = duration > 0 ? Math.min(currentTime / duration, 1) : 0;
59
+ const isPlaying: boolean = !audio.paused && !audio.ended && audio.readyState > 2;
60
+
61
+ // Calculate remainingInQueue if channel context is provided
62
+ let remainingInQueue: number = 0;
63
+ if (channelNumber !== undefined && audioChannels && audioChannels[channelNumber]) {
64
+ const channel = audioChannels[channelNumber];
65
+ remainingInQueue = Math.max(0, channel.queue.length - 1); // Exclude current playing audio
66
+ }
67
+
68
+ return {
69
+ currentTime,
70
+ duration,
71
+ fileName: extractFileName(audio.src),
72
+ isLooping: audio.loop,
73
+ isPaused: audio.paused && !audio.ended,
74
+ isPlaying,
75
+ progress,
76
+ remainingInQueue,
77
+ src: audio.src,
78
+ volume: audio.volume
79
+ };
80
+ };
81
+
82
+ /**
83
+ * Creates a complete snapshot of a queue's current state
84
+ * @param channelNumber - The channel number to create a snapshot for
85
+ * @param audioChannels - Array of audio channels
86
+ * @returns QueueSnapshot object or null if channel doesn't exist
87
+ * @example
88
+ * ```typescript
89
+ * const snapshot = createQueueSnapshot(0, audioChannels);
90
+ * console.log(`Queue has ${snapshot?.totalItems} items`);
91
+ * ```
92
+ */
93
+ export const createQueueSnapshot = (
94
+ channelNumber: number,
95
+ audioChannels: ExtendedAudioQueueChannel[]
96
+ ): QueueSnapshot | null => {
97
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
98
+ if (!channel) return null;
99
+
100
+ const items: QueueItem[] = channel.queue.map((audio: HTMLAudioElement, index: number) => ({
101
+ duration: isNaN(audio.duration) ? 0 : audio.duration * 1000,
102
+ fileName: extractFileName(audio.src),
103
+ isCurrentlyPlaying: index === 0 && !audio.paused && !audio.ended,
104
+ isLooping: audio.loop,
105
+ src: audio.src,
106
+ volume: audio.volume
107
+ }));
108
+
109
+ return {
110
+ channelNumber,
111
+ currentIndex: 0, // Current playing is always index 0 in our queue structure
112
+ isPaused: channel.isPaused || false,
113
+ items,
114
+ totalItems: channel.queue.length,
115
+ volume: channel.volume || 1.0
116
+ };
117
+ };
118
+
119
+ /**
120
+ * Removes webpack hash patterns from filenames to get clean, readable names
121
+ * @param fileName - The filename that may contain webpack hashes
122
+ * @returns The cleaned filename with webpack hashes removed
123
+ * @example
124
+ * ```typescript
125
+ * cleanWebpackFilename('song.a1b2c3d4.mp3') // Returns: 'song.mp3'
126
+ * cleanWebpackFilename('notification.1a2b3c4d5e6f7890.wav') // Returns: 'notification.wav'
127
+ * cleanWebpackFilename('music.12345678.ogg') // Returns: 'music.ogg'
128
+ * cleanWebpackFilename('clean-file.mp3') // Returns: 'clean-file.mp3' (unchanged)
129
+ * ```
130
+ */
131
+ export const cleanWebpackFilename = (fileName: string): string => {
132
+ // Remove webpack hash pattern: filename.hash.ext → filename.ext
133
+ return fileName.replace(/\.[a-f0-9]{8,}\./i, '.');
109
134
  };
package/src/volume.ts ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * @fileoverview Volume management functions for the audio-channel-queue package
3
+ */
4
+
5
+ import { ExtendedAudioQueueChannel, VolumeConfig } from './types';
6
+ import { audioChannels } from './info';
7
+
8
+ // Store active volume transitions to handle interruptions
9
+ const activeTransitions = new Map<number, number>();
10
+
11
+ /**
12
+ * Easing functions for smooth volume transitions
13
+ */
14
+ const easingFunctions = {
15
+ linear: (t: number): number => t,
16
+ 'ease-in': (t: number): number => t * t,
17
+ 'ease-out': (t: number): number => t * (2 - t),
18
+ 'ease-in-out': (t: number): number => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
19
+ };
20
+
21
+ /**
22
+ * Smoothly transitions volume for a specific channel over time
23
+ * @param channelNumber - The channel number to transition
24
+ * @param targetVolume - Target volume level (0-1)
25
+ * @param duration - Transition duration in milliseconds
26
+ * @param easing - Easing function type
27
+ * @returns Promise that resolves when transition completes
28
+ * @example
29
+ * ```typescript
30
+ * await transitionVolume(0, 0.2, 500, 'ease-out'); // Duck to 20% over 500ms
31
+ * ```
32
+ */
33
+ export const transitionVolume = async (
34
+ channelNumber: number,
35
+ targetVolume: number,
36
+ duration: number = 250,
37
+ easing: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' = 'ease-out'
38
+ ): Promise<void> => {
39
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
40
+ if (!channel || channel.queue.length === 0) return;
41
+
42
+ const currentAudio: HTMLAudioElement = channel.queue[0];
43
+ const startVolume: number = currentAudio.volume;
44
+ const volumeDelta: number = targetVolume - startVolume;
45
+
46
+ // Cancel any existing transition for this channel
47
+ if (activeTransitions.has(channelNumber)) {
48
+ clearTimeout(activeTransitions.get(channelNumber));
49
+ activeTransitions.delete(channelNumber);
50
+ }
51
+
52
+ // If no change needed, resolve immediately
53
+ if (Math.abs(volumeDelta) < 0.001) {
54
+ channel.volume = targetVolume;
55
+ return Promise.resolve();
56
+ }
57
+
58
+ // Handle zero duration - instant change
59
+ if (duration === 0) {
60
+ channel.volume = targetVolume;
61
+ if (channel.queue.length > 0) {
62
+ channel.queue[0].volume = targetVolume;
63
+ }
64
+ return Promise.resolve();
65
+ }
66
+
67
+ const startTime: number = performance.now();
68
+ const easingFn = easingFunctions[easing];
69
+
70
+ return new Promise<void>((resolve) => {
71
+ const updateVolume = (): void => {
72
+ const elapsed: number = performance.now() - startTime;
73
+ const progress: number = Math.min(elapsed / duration, 1);
74
+ const easedProgress: number = easingFn(progress);
75
+
76
+ const currentVolume: number = startVolume + (volumeDelta * easedProgress);
77
+ const clampedVolume: number = Math.max(0, Math.min(1, currentVolume));
78
+
79
+ // Apply volume to both channel config and current audio
80
+ channel.volume = clampedVolume;
81
+ if (channel.queue.length > 0) {
82
+ channel.queue[0].volume = clampedVolume;
83
+ }
84
+
85
+ if (progress >= 1) {
86
+ // Transition complete
87
+ activeTransitions.delete(channelNumber);
88
+ resolve();
89
+ } else {
90
+ // Use requestAnimationFrame in browser, setTimeout in tests
91
+ if (typeof requestAnimationFrame !== 'undefined') {
92
+ const rafId = requestAnimationFrame(updateVolume);
93
+ activeTransitions.set(channelNumber, rafId as unknown as number);
94
+ } else {
95
+ // In test environment, use shorter intervals
96
+ const timeoutId = setTimeout(updateVolume, 1);
97
+ activeTransitions.set(channelNumber, timeoutId as unknown as number);
98
+ }
99
+ }
100
+ };
101
+
102
+ updateVolume();
103
+ });
104
+ };
105
+
106
+ /**
107
+ * Sets the volume for a specific channel with optional smooth transition
108
+ * @param channelNumber - The channel number to set volume for
109
+ * @param volume - Volume level (0-1)
110
+ * @param transitionDuration - Optional transition duration in milliseconds
111
+ * @param easing - Optional easing function
112
+ * @example
113
+ * ```typescript
114
+ * setChannelVolume(0, 0.5); // Set channel 0 to 50%
115
+ * setChannelVolume(0, 0.5, 300, 'ease-out'); // Smooth transition over 300ms
116
+ * ```
117
+ */
118
+ export const setChannelVolume = async (
119
+ channelNumber: number,
120
+ volume: number,
121
+ transitionDuration?: number,
122
+ easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'
123
+ ): Promise<void> => {
124
+ const clampedVolume: number = Math.max(0, Math.min(1, volume));
125
+
126
+ if (!audioChannels[channelNumber]) {
127
+ audioChannels[channelNumber] = {
128
+ audioCompleteCallbacks: new Set(),
129
+ audioErrorCallbacks: new Set(),
130
+ audioPauseCallbacks: new Set(),
131
+ audioResumeCallbacks: new Set(),
132
+ audioStartCallbacks: new Set(),
133
+ isPaused: false,
134
+ progressCallbacks: new Map(),
135
+ queue: [],
136
+ queueChangeCallbacks: new Set(),
137
+ volume: clampedVolume
138
+ };
139
+ return;
140
+ }
141
+
142
+ if (transitionDuration && transitionDuration > 0) {
143
+ // Smooth transition
144
+ await transitionVolume(channelNumber, clampedVolume, transitionDuration, easing);
145
+ } else {
146
+ // Instant change (backward compatibility)
147
+ audioChannels[channelNumber].volume = clampedVolume;
148
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
149
+ if (channel.queue.length > 0) {
150
+ const currentAudio: HTMLAudioElement = channel.queue[0];
151
+ currentAudio.volume = clampedVolume;
152
+ }
153
+ }
154
+ };
155
+
156
+ /**
157
+ * Gets the current volume for a specific channel
158
+ * @param channelNumber - The channel number to get volume for (defaults to 0)
159
+ * @returns Current volume level (0-1) or 1.0 if channel doesn't exist
160
+ * @example
161
+ * ```typescript
162
+ * const volume = getChannelVolume(0);
163
+ * const defaultChannelVolume = getChannelVolume(); // Gets channel 0
164
+ * console.log(`Channel 0 volume: ${volume * 100}%`);
165
+ * ```
166
+ */
167
+ export const getChannelVolume = (channelNumber: number = 0): number => {
168
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
169
+ return channel?.volume || 1.0;
170
+ };
171
+
172
+ /**
173
+ * Gets the volume levels for all channels
174
+ * @returns Array of volume levels (0-1) for each channel
175
+ * @example
176
+ * ```typescript
177
+ * const volumes = getAllChannelsVolume();
178
+ * volumes.forEach((volume, index) => {
179
+ * console.log(`Channel ${index}: ${volume * 100}%`);
180
+ * });
181
+ * ```
182
+ */
183
+ export const getAllChannelsVolume = (): number[] => {
184
+ return audioChannels.map((channel: ExtendedAudioQueueChannel) =>
185
+ channel?.volume || 1.0
186
+ );
187
+ };
188
+
189
+ /**
190
+ * Sets volume for all channels to the same level
191
+ * @param volume - Volume level (0-1) to apply to all channels
192
+ * @example
193
+ * ```typescript
194
+ * await setAllChannelsVolume(0.6); // Set all channels to 60% volume
195
+ * ```
196
+ */
197
+ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
198
+ const promises: Promise<void>[] = [];
199
+ audioChannels.forEach((_channel: ExtendedAudioQueueChannel, index: number) => {
200
+ promises.push(setChannelVolume(index, volume));
201
+ });
202
+ await Promise.all(promises);
203
+ };
204
+
205
+ /**
206
+ * Configures volume ducking for channels. When the priority channel plays audio,
207
+ * all other channels will be automatically reduced to the ducking volume level
208
+ * @param config - Volume ducking configuration
209
+ * @example
210
+ * ```typescript
211
+ * // When channel 1 plays, reduce all other channels to 20% volume
212
+ * setVolumeDucking({
213
+ * priorityChannel: 1,
214
+ * priorityVolume: 1.0,
215
+ * duckingVolume: 0.2
216
+ * });
217
+ * ```
218
+ */
219
+ export const setVolumeDucking = (config: VolumeConfig): void => {
220
+ // First, ensure we have enough channels for the priority channel
221
+ while (audioChannels.length <= config.priorityChannel) {
222
+ audioChannels.push({
223
+ audioCompleteCallbacks: new Set(),
224
+ audioErrorCallbacks: new Set(),
225
+ audioPauseCallbacks: new Set(),
226
+ audioResumeCallbacks: new Set(),
227
+ audioStartCallbacks: new Set(),
228
+ isPaused: false,
229
+ progressCallbacks: new Map(),
230
+ queue: [],
231
+ queueChangeCallbacks: new Set(),
232
+ volume: 1.0
233
+ });
234
+ }
235
+
236
+ // Apply the config to all existing channels
237
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, index: number) => {
238
+ if (!audioChannels[index]) {
239
+ audioChannels[index] = {
240
+ audioCompleteCallbacks: new Set(),
241
+ audioErrorCallbacks: new Set(),
242
+ audioPauseCallbacks: new Set(),
243
+ audioResumeCallbacks: new Set(),
244
+ audioStartCallbacks: new Set(),
245
+ isPaused: false,
246
+ progressCallbacks: new Map(),
247
+ queue: [],
248
+ queueChangeCallbacks: new Set(),
249
+ volume: 1.0
250
+ };
251
+ }
252
+ audioChannels[index].volumeConfig = config;
253
+ });
254
+ };
255
+
256
+ /**
257
+ * Removes volume ducking configuration from all channels
258
+ * @example
259
+ * ```typescript
260
+ * clearVolumeDucking(); // Remove all volume ducking effects
261
+ * ```
262
+ */
263
+ export const clearVolumeDucking = (): void => {
264
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel) => {
265
+ if (channel) {
266
+ delete channel.volumeConfig;
267
+ }
268
+ });
269
+ };
270
+
271
+ /**
272
+ * Applies volume ducking effects based on current playback state with smooth transitions
273
+ * @param activeChannelNumber - The channel that just started playing
274
+ * @internal
275
+ */
276
+ export const applyVolumeDucking = async (activeChannelNumber: number): Promise<void> => {
277
+ const transitionPromises: Promise<void>[] = [];
278
+
279
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
280
+ if (channel?.volumeConfig) {
281
+ const config: VolumeConfig = channel.volumeConfig;
282
+
283
+ if (activeChannelNumber === config.priorityChannel) {
284
+ const duration = config.duckTransitionDuration || 250;
285
+ const easing = config.transitionEasing || 'ease-out';
286
+
287
+ // Priority channel is active, duck other channels
288
+ if (channelNumber === config.priorityChannel) {
289
+ transitionPromises.push(
290
+ transitionVolume(channelNumber, config.priorityVolume, duration, easing)
291
+ );
292
+ } else {
293
+ transitionPromises.push(
294
+ transitionVolume(channelNumber, config.duckingVolume, duration, easing)
295
+ );
296
+ }
297
+ }
298
+ }
299
+ });
300
+
301
+ // Wait for all transitions to complete
302
+ await Promise.all(transitionPromises);
303
+ };
304
+
305
+ /**
306
+ * Restores normal volume levels when priority channel stops with smooth transitions
307
+ * @param stoppedChannelNumber - The channel that just stopped playing
308
+ * @internal
309
+ */
310
+ export const restoreVolumeLevels = async (stoppedChannelNumber: number): Promise<void> => {
311
+ const transitionPromises: Promise<void>[] = [];
312
+
313
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
314
+ if (channel?.volumeConfig) {
315
+ const config: VolumeConfig = channel.volumeConfig;
316
+
317
+ if (stoppedChannelNumber === config.priorityChannel) {
318
+ const duration = config.restoreTransitionDuration || 500;
319
+ const easing = config.transitionEasing || 'ease-out';
320
+
321
+ // Priority channel stopped, restore normal volumes
322
+ transitionPromises.push(
323
+ transitionVolume(channelNumber, channel.volume || 1.0, duration, easing)
324
+ );
325
+ }
326
+ }
327
+ });
328
+
329
+ // Wait for all transitions to complete
330
+ await Promise.all(transitionPromises);
331
+ };