audio-channel-queue 1.5.0 → 1.6.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,328 @@
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
+ audioPauseCallbacks: new Set(),
130
+ audioResumeCallbacks: new Set(),
131
+ audioStartCallbacks: new Set(),
132
+ isPaused: false,
133
+ progressCallbacks: new Map(),
134
+ queue: [],
135
+ queueChangeCallbacks: new Set(),
136
+ volume: clampedVolume
137
+ };
138
+ return;
139
+ }
140
+
141
+ if (transitionDuration && transitionDuration > 0) {
142
+ // Smooth transition
143
+ await transitionVolume(channelNumber, clampedVolume, transitionDuration, easing);
144
+ } else {
145
+ // Instant change (backward compatibility)
146
+ audioChannels[channelNumber].volume = clampedVolume;
147
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
148
+ if (channel.queue.length > 0) {
149
+ const currentAudio: HTMLAudioElement = channel.queue[0];
150
+ currentAudio.volume = clampedVolume;
151
+ }
152
+ }
153
+ };
154
+
155
+ /**
156
+ * Gets the current volume for a specific channel
157
+ * @param channelNumber - The channel number to get volume for (defaults to 0)
158
+ * @returns Current volume level (0-1) or 1.0 if channel doesn't exist
159
+ * @example
160
+ * ```typescript
161
+ * const volume = getChannelVolume(0);
162
+ * const defaultChannelVolume = getChannelVolume(); // Gets channel 0
163
+ * console.log(`Channel 0 volume: ${volume * 100}%`);
164
+ * ```
165
+ */
166
+ export const getChannelVolume = (channelNumber: number = 0): number => {
167
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
168
+ return channel?.volume || 1.0;
169
+ };
170
+
171
+ /**
172
+ * Gets the volume levels for all channels
173
+ * @returns Array of volume levels (0-1) for each channel
174
+ * @example
175
+ * ```typescript
176
+ * const volumes = getAllChannelsVolume();
177
+ * volumes.forEach((volume, index) => {
178
+ * console.log(`Channel ${index}: ${volume * 100}%`);
179
+ * });
180
+ * ```
181
+ */
182
+ export const getAllChannelsVolume = (): number[] => {
183
+ return audioChannels.map((channel: ExtendedAudioQueueChannel) =>
184
+ channel?.volume || 1.0
185
+ );
186
+ };
187
+
188
+ /**
189
+ * Sets volume for all channels to the same level
190
+ * @param volume - Volume level (0-1) to apply to all channels
191
+ * @example
192
+ * ```typescript
193
+ * await setAllChannelsVolume(0.6); // Set all channels to 60% volume
194
+ * ```
195
+ */
196
+ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
197
+ const promises: Promise<void>[] = [];
198
+ audioChannels.forEach((_channel: ExtendedAudioQueueChannel, index: number) => {
199
+ promises.push(setChannelVolume(index, volume));
200
+ });
201
+ await Promise.all(promises);
202
+ };
203
+
204
+ /**
205
+ * Configures volume ducking for channels. When the priority channel plays audio,
206
+ * all other channels will be automatically reduced to the ducking volume level
207
+ * @param config - Volume ducking configuration
208
+ * @example
209
+ * ```typescript
210
+ * // When channel 1 plays, reduce all other channels to 20% volume
211
+ * setVolumeDucking({
212
+ * priorityChannel: 1,
213
+ * priorityVolume: 1.0,
214
+ * duckingVolume: 0.2
215
+ * });
216
+ * ```
217
+ */
218
+ export const setVolumeDucking = (config: VolumeConfig): void => {
219
+ // First, ensure we have enough channels for the priority channel
220
+ while (audioChannels.length <= config.priorityChannel) {
221
+ audioChannels.push({
222
+ audioCompleteCallbacks: new Set(),
223
+ audioPauseCallbacks: new Set(),
224
+ audioResumeCallbacks: new Set(),
225
+ audioStartCallbacks: new Set(),
226
+ isPaused: false,
227
+ progressCallbacks: new Map(),
228
+ queue: [],
229
+ queueChangeCallbacks: new Set(),
230
+ volume: 1.0
231
+ });
232
+ }
233
+
234
+ // Apply the config to all existing channels
235
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, index: number) => {
236
+ if (!audioChannels[index]) {
237
+ audioChannels[index] = {
238
+ audioCompleteCallbacks: new Set(),
239
+ audioPauseCallbacks: new Set(),
240
+ audioResumeCallbacks: new Set(),
241
+ audioStartCallbacks: new Set(),
242
+ isPaused: false,
243
+ progressCallbacks: new Map(),
244
+ queue: [],
245
+ queueChangeCallbacks: new Set(),
246
+ volume: 1.0
247
+ };
248
+ }
249
+ audioChannels[index].volumeConfig = config;
250
+ });
251
+ };
252
+
253
+ /**
254
+ * Removes volume ducking configuration from all channels
255
+ * @example
256
+ * ```typescript
257
+ * clearVolumeDucking(); // Remove all volume ducking effects
258
+ * ```
259
+ */
260
+ export const clearVolumeDucking = (): void => {
261
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel) => {
262
+ if (channel) {
263
+ delete channel.volumeConfig;
264
+ }
265
+ });
266
+ };
267
+
268
+ /**
269
+ * Applies volume ducking effects based on current playback state with smooth transitions
270
+ * @param activeChannelNumber - The channel that just started playing
271
+ * @internal
272
+ */
273
+ export const applyVolumeDucking = async (activeChannelNumber: number): Promise<void> => {
274
+ const transitionPromises: Promise<void>[] = [];
275
+
276
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
277
+ if (channel?.volumeConfig) {
278
+ const config: VolumeConfig = channel.volumeConfig;
279
+
280
+ if (activeChannelNumber === config.priorityChannel) {
281
+ const duration = config.duckTransitionDuration || 250;
282
+ const easing = config.transitionEasing || 'ease-out';
283
+
284
+ // Priority channel is active, duck other channels
285
+ if (channelNumber === config.priorityChannel) {
286
+ transitionPromises.push(
287
+ transitionVolume(channelNumber, config.priorityVolume, duration, easing)
288
+ );
289
+ } else {
290
+ transitionPromises.push(
291
+ transitionVolume(channelNumber, config.duckingVolume, duration, easing)
292
+ );
293
+ }
294
+ }
295
+ }
296
+ });
297
+
298
+ // Wait for all transitions to complete
299
+ await Promise.all(transitionPromises);
300
+ };
301
+
302
+ /**
303
+ * Restores normal volume levels when priority channel stops with smooth transitions
304
+ * @param stoppedChannelNumber - The channel that just stopped playing
305
+ * @internal
306
+ */
307
+ export const restoreVolumeLevels = async (stoppedChannelNumber: number): Promise<void> => {
308
+ const transitionPromises: Promise<void>[] = [];
309
+
310
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
311
+ if (channel?.volumeConfig) {
312
+ const config: VolumeConfig = channel.volumeConfig;
313
+
314
+ if (stoppedChannelNumber === config.priorityChannel) {
315
+ const duration = config.restoreTransitionDuration || 500;
316
+ const easing = config.transitionEasing || 'ease-out';
317
+
318
+ // Priority channel stopped, restore normal volumes
319
+ transitionPromises.push(
320
+ transitionVolume(channelNumber, channel.volume || 1.0, duration, easing)
321
+ );
322
+ }
323
+ }
324
+ });
325
+
326
+ // Wait for all transitions to complete
327
+ await Promise.all(transitionPromises);
328
+ };