audio-channel-queue 1.4.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/README.md +483 -20
- package/dist/core.d.ts +71 -0
- package/dist/core.js +273 -0
- package/dist/events.d.ts +81 -0
- package/dist/events.js +210 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +77 -0
- package/dist/info.d.ts +166 -0
- package/dist/info.js +352 -0
- package/dist/pause.d.ts +87 -0
- package/dist/pause.js +186 -0
- package/dist/types.d.ts +183 -0
- package/dist/types.js +5 -0
- package/dist/utils.d.ts +58 -0
- package/dist/utils.js +126 -0
- package/dist/volume.d.ts +98 -0
- package/dist/volume.js +302 -0
- package/package.json +10 -4
- package/src/core.ts +306 -0
- package/src/events.ts +243 -0
- package/src/index.ts +104 -0
- package/src/info.ts +380 -0
- package/src/pause.ts +190 -0
- package/src/types.ts +199 -0
- package/src/utils.ts +134 -0
- package/src/volume.ts +328 -0
- package/dist/audio.d.ts +0 -10
- package/dist/audio.js +0 -66
package/dist/volume.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Volume management functions for the audio-channel-queue package
|
|
4
|
+
*/
|
|
5
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
6
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
7
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
8
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
9
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
10
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
11
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.restoreVolumeLevels = exports.applyVolumeDucking = exports.clearVolumeDucking = exports.setVolumeDucking = exports.setAllChannelsVolume = exports.getAllChannelsVolume = exports.getChannelVolume = exports.setChannelVolume = exports.transitionVolume = void 0;
|
|
16
|
+
const info_1 = require("./info");
|
|
17
|
+
// Store active volume transitions to handle interruptions
|
|
18
|
+
const activeTransitions = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* Easing functions for smooth volume transitions
|
|
21
|
+
*/
|
|
22
|
+
const easingFunctions = {
|
|
23
|
+
linear: (t) => t,
|
|
24
|
+
'ease-in': (t) => t * t,
|
|
25
|
+
'ease-out': (t) => t * (2 - t),
|
|
26
|
+
'ease-in-out': (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Smoothly transitions volume for a specific channel over time
|
|
30
|
+
* @param channelNumber - The channel number to transition
|
|
31
|
+
* @param targetVolume - Target volume level (0-1)
|
|
32
|
+
* @param duration - Transition duration in milliseconds
|
|
33
|
+
* @param easing - Easing function type
|
|
34
|
+
* @returns Promise that resolves when transition completes
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* await transitionVolume(0, 0.2, 500, 'ease-out'); // Duck to 20% over 500ms
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
const transitionVolume = (channelNumber_1, targetVolume_1, ...args_1) => __awaiter(void 0, [channelNumber_1, targetVolume_1, ...args_1], void 0, function* (channelNumber, targetVolume, duration = 250, easing = 'ease-out') {
|
|
41
|
+
const channel = info_1.audioChannels[channelNumber];
|
|
42
|
+
if (!channel || channel.queue.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
const currentAudio = channel.queue[0];
|
|
45
|
+
const startVolume = currentAudio.volume;
|
|
46
|
+
const volumeDelta = targetVolume - startVolume;
|
|
47
|
+
// Cancel any existing transition for this channel
|
|
48
|
+
if (activeTransitions.has(channelNumber)) {
|
|
49
|
+
clearTimeout(activeTransitions.get(channelNumber));
|
|
50
|
+
activeTransitions.delete(channelNumber);
|
|
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
|
+
// Handle zero duration - instant change
|
|
58
|
+
if (duration <= 0) {
|
|
59
|
+
channel.volume = targetVolume;
|
|
60
|
+
if (channel.queue.length > 0) {
|
|
61
|
+
channel.queue[0].volume = targetVolume;
|
|
62
|
+
}
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
}
|
|
65
|
+
const startTime = performance.now();
|
|
66
|
+
const easingFn = easingFunctions[easing];
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const updateVolume = () => {
|
|
69
|
+
const elapsed = performance.now() - startTime;
|
|
70
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
71
|
+
const easedProgress = easingFn(progress);
|
|
72
|
+
const currentVolume = startVolume + (volumeDelta * easedProgress);
|
|
73
|
+
const clampedVolume = Math.max(0, Math.min(1, currentVolume));
|
|
74
|
+
// Apply volume to both channel config and current audio
|
|
75
|
+
channel.volume = clampedVolume;
|
|
76
|
+
if (channel.queue.length > 0) {
|
|
77
|
+
channel.queue[0].volume = clampedVolume;
|
|
78
|
+
}
|
|
79
|
+
if (progress >= 1) {
|
|
80
|
+
// Transition complete
|
|
81
|
+
activeTransitions.delete(channelNumber);
|
|
82
|
+
resolve();
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Use requestAnimationFrame in browser, setTimeout in tests
|
|
86
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
87
|
+
const rafId = requestAnimationFrame(updateVolume);
|
|
88
|
+
activeTransitions.set(channelNumber, rafId);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// In test environment, use shorter intervals
|
|
92
|
+
const timeoutId = setTimeout(updateVolume, 1);
|
|
93
|
+
activeTransitions.set(channelNumber, timeoutId);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
updateVolume();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
exports.transitionVolume = transitionVolume;
|
|
101
|
+
/**
|
|
102
|
+
* Sets the volume for a specific channel with optional smooth transition
|
|
103
|
+
* @param channelNumber - The channel number to set volume for
|
|
104
|
+
* @param volume - Volume level (0-1)
|
|
105
|
+
* @param transitionDuration - Optional transition duration in milliseconds
|
|
106
|
+
* @param easing - Optional easing function
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* setChannelVolume(0, 0.5); // Set channel 0 to 50%
|
|
110
|
+
* setChannelVolume(0, 0.5, 300, 'ease-out'); // Smooth transition over 300ms
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
const setChannelVolume = (channelNumber, volume, transitionDuration, easing) => __awaiter(void 0, void 0, void 0, function* () {
|
|
114
|
+
const clampedVolume = Math.max(0, Math.min(1, volume));
|
|
115
|
+
if (!info_1.audioChannels[channelNumber]) {
|
|
116
|
+
info_1.audioChannels[channelNumber] = {
|
|
117
|
+
audioCompleteCallbacks: new Set(),
|
|
118
|
+
audioPauseCallbacks: new Set(),
|
|
119
|
+
audioResumeCallbacks: new Set(),
|
|
120
|
+
audioStartCallbacks: new Set(),
|
|
121
|
+
isPaused: false,
|
|
122
|
+
progressCallbacks: new Map(),
|
|
123
|
+
queue: [],
|
|
124
|
+
queueChangeCallbacks: new Set(),
|
|
125
|
+
volume: clampedVolume
|
|
126
|
+
};
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (transitionDuration && transitionDuration > 0) {
|
|
130
|
+
// Smooth transition
|
|
131
|
+
yield (0, exports.transitionVolume)(channelNumber, clampedVolume, transitionDuration, easing);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Instant change (backward compatibility)
|
|
135
|
+
info_1.audioChannels[channelNumber].volume = clampedVolume;
|
|
136
|
+
const channel = info_1.audioChannels[channelNumber];
|
|
137
|
+
if (channel.queue.length > 0) {
|
|
138
|
+
const currentAudio = channel.queue[0];
|
|
139
|
+
currentAudio.volume = clampedVolume;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
exports.setChannelVolume = setChannelVolume;
|
|
144
|
+
/**
|
|
145
|
+
* Gets the current volume for a specific channel
|
|
146
|
+
* @param channelNumber - The channel number to get volume for (defaults to 0)
|
|
147
|
+
* @returns Current volume level (0-1) or 1.0 if channel doesn't exist
|
|
148
|
+
* @example
|
|
149
|
+
* ```typescript
|
|
150
|
+
* const volume = getChannelVolume(0);
|
|
151
|
+
* const defaultChannelVolume = getChannelVolume(); // Gets channel 0
|
|
152
|
+
* console.log(`Channel 0 volume: ${volume * 100}%`);
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
const getChannelVolume = (channelNumber = 0) => {
|
|
156
|
+
const channel = info_1.audioChannels[channelNumber];
|
|
157
|
+
return (channel === null || channel === void 0 ? void 0 : channel.volume) || 1.0;
|
|
158
|
+
};
|
|
159
|
+
exports.getChannelVolume = getChannelVolume;
|
|
160
|
+
/**
|
|
161
|
+
* Gets the volume levels for all channels
|
|
162
|
+
* @returns Array of volume levels (0-1) for each channel
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* const volumes = getAllChannelsVolume();
|
|
166
|
+
* volumes.forEach((volume, index) => {
|
|
167
|
+
* console.log(`Channel ${index}: ${volume * 100}%`);
|
|
168
|
+
* });
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
const getAllChannelsVolume = () => {
|
|
172
|
+
return info_1.audioChannels.map((channel) => (channel === null || channel === void 0 ? void 0 : channel.volume) || 1.0);
|
|
173
|
+
};
|
|
174
|
+
exports.getAllChannelsVolume = getAllChannelsVolume;
|
|
175
|
+
/**
|
|
176
|
+
* Sets volume for all channels to the same level
|
|
177
|
+
* @param volume - Volume level (0-1) to apply to all channels
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* await setAllChannelsVolume(0.6); // Set all channels to 60% volume
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
const setAllChannelsVolume = (volume) => __awaiter(void 0, void 0, void 0, function* () {
|
|
184
|
+
const promises = [];
|
|
185
|
+
info_1.audioChannels.forEach((_channel, index) => {
|
|
186
|
+
promises.push((0, exports.setChannelVolume)(index, volume));
|
|
187
|
+
});
|
|
188
|
+
yield Promise.all(promises);
|
|
189
|
+
});
|
|
190
|
+
exports.setAllChannelsVolume = setAllChannelsVolume;
|
|
191
|
+
/**
|
|
192
|
+
* Configures volume ducking for channels. When the priority channel plays audio,
|
|
193
|
+
* all other channels will be automatically reduced to the ducking volume level
|
|
194
|
+
* @param config - Volume ducking configuration
|
|
195
|
+
* @example
|
|
196
|
+
* ```typescript
|
|
197
|
+
* // When channel 1 plays, reduce all other channels to 20% volume
|
|
198
|
+
* setVolumeDucking({
|
|
199
|
+
* priorityChannel: 1,
|
|
200
|
+
* priorityVolume: 1.0,
|
|
201
|
+
* duckingVolume: 0.2
|
|
202
|
+
* });
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
const setVolumeDucking = (config) => {
|
|
206
|
+
// First, ensure we have enough channels for the priority channel
|
|
207
|
+
while (info_1.audioChannels.length <= config.priorityChannel) {
|
|
208
|
+
info_1.audioChannels.push({
|
|
209
|
+
audioCompleteCallbacks: new Set(),
|
|
210
|
+
audioPauseCallbacks: new Set(),
|
|
211
|
+
audioResumeCallbacks: new Set(),
|
|
212
|
+
audioStartCallbacks: new Set(),
|
|
213
|
+
isPaused: false,
|
|
214
|
+
progressCallbacks: new Map(),
|
|
215
|
+
queue: [],
|
|
216
|
+
queueChangeCallbacks: new Set(),
|
|
217
|
+
volume: 1.0
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// Apply the config to all existing channels
|
|
221
|
+
info_1.audioChannels.forEach((channel, index) => {
|
|
222
|
+
if (!info_1.audioChannels[index]) {
|
|
223
|
+
info_1.audioChannels[index] = {
|
|
224
|
+
audioCompleteCallbacks: 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
|
+
info_1.audioChannels[index].volumeConfig = config;
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
exports.setVolumeDucking = setVolumeDucking;
|
|
239
|
+
/**
|
|
240
|
+
* Removes volume ducking configuration from all channels
|
|
241
|
+
* @example
|
|
242
|
+
* ```typescript
|
|
243
|
+
* clearVolumeDucking(); // Remove all volume ducking effects
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
const clearVolumeDucking = () => {
|
|
247
|
+
info_1.audioChannels.forEach((channel) => {
|
|
248
|
+
if (channel) {
|
|
249
|
+
delete channel.volumeConfig;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
exports.clearVolumeDucking = clearVolumeDucking;
|
|
254
|
+
/**
|
|
255
|
+
* Applies volume ducking effects based on current playback state with smooth transitions
|
|
256
|
+
* @param activeChannelNumber - The channel that just started playing
|
|
257
|
+
* @internal
|
|
258
|
+
*/
|
|
259
|
+
const applyVolumeDucking = (activeChannelNumber) => __awaiter(void 0, void 0, void 0, function* () {
|
|
260
|
+
const transitionPromises = [];
|
|
261
|
+
info_1.audioChannels.forEach((channel, channelNumber) => {
|
|
262
|
+
if (channel === null || channel === void 0 ? void 0 : channel.volumeConfig) {
|
|
263
|
+
const config = channel.volumeConfig;
|
|
264
|
+
if (activeChannelNumber === config.priorityChannel) {
|
|
265
|
+
const duration = config.duckTransitionDuration || 250;
|
|
266
|
+
const easing = config.transitionEasing || 'ease-out';
|
|
267
|
+
// Priority channel is active, duck other channels
|
|
268
|
+
if (channelNumber === config.priorityChannel) {
|
|
269
|
+
transitionPromises.push((0, exports.transitionVolume)(channelNumber, config.priorityVolume, duration, easing));
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
transitionPromises.push((0, exports.transitionVolume)(channelNumber, config.duckingVolume, duration, easing));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
// Wait for all transitions to complete
|
|
278
|
+
yield Promise.all(transitionPromises);
|
|
279
|
+
});
|
|
280
|
+
exports.applyVolumeDucking = applyVolumeDucking;
|
|
281
|
+
/**
|
|
282
|
+
* Restores normal volume levels when priority channel stops with smooth transitions
|
|
283
|
+
* @param stoppedChannelNumber - The channel that just stopped playing
|
|
284
|
+
* @internal
|
|
285
|
+
*/
|
|
286
|
+
const restoreVolumeLevels = (stoppedChannelNumber) => __awaiter(void 0, void 0, void 0, function* () {
|
|
287
|
+
const transitionPromises = [];
|
|
288
|
+
info_1.audioChannels.forEach((channel, channelNumber) => {
|
|
289
|
+
if (channel === null || channel === void 0 ? void 0 : channel.volumeConfig) {
|
|
290
|
+
const config = channel.volumeConfig;
|
|
291
|
+
if (stoppedChannelNumber === config.priorityChannel) {
|
|
292
|
+
const duration = config.restoreTransitionDuration || 500;
|
|
293
|
+
const easing = config.transitionEasing || 'ease-out';
|
|
294
|
+
// Priority channel stopped, restore normal volumes
|
|
295
|
+
transitionPromises.push((0, exports.transitionVolume)(channelNumber, channel.volume || 1.0, duration, easing));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
// Wait for all transitions to complete
|
|
300
|
+
yield Promise.all(transitionPromises);
|
|
301
|
+
});
|
|
302
|
+
exports.restoreVolumeLevels = restoreVolumeLevels;
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "audio-channel-queue",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Allows you to queue audio files to different playback channels.",
|
|
5
|
-
"main": "dist/
|
|
6
|
-
"types": "dist/
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist",
|
|
9
9
|
"src"
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"prepare": "npm run build",
|
|
14
|
-
"test": "jest"
|
|
14
|
+
"test": "jest",
|
|
15
|
+
"test:watch": "jest --watch",
|
|
16
|
+
"test:coverage": "jest --coverage"
|
|
15
17
|
},
|
|
16
18
|
"repository": {
|
|
17
19
|
"type": "git",
|
|
@@ -29,9 +31,13 @@
|
|
|
29
31
|
"url": "https://github.com/tonycarpenter21/audio-queue-package/issues"
|
|
30
32
|
},
|
|
31
33
|
"homepage": "https://github.com/tonycarpenter21/audio-queue-package#readme",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=14.0.0"
|
|
36
|
+
},
|
|
32
37
|
"devDependencies": {
|
|
33
38
|
"@types/jest": "^29.5.13",
|
|
34
39
|
"jest": "^29.7.0",
|
|
40
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
35
41
|
"ts-jest": "^29.2.5",
|
|
36
42
|
"typescript": "^5.6.2"
|
|
37
43
|
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Core queue management functions for the audio-channel-queue package
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ExtendedAudioQueueChannel, AudioQueueOptions } from './types';
|
|
6
|
+
import { audioChannels } from './info';
|
|
7
|
+
import { extractFileName } from './utils';
|
|
8
|
+
import {
|
|
9
|
+
emitQueueChange,
|
|
10
|
+
emitAudioStart,
|
|
11
|
+
emitAudioComplete,
|
|
12
|
+
setupProgressTracking,
|
|
13
|
+
cleanupProgressTracking
|
|
14
|
+
} from './events';
|
|
15
|
+
import { applyVolumeDucking, restoreVolumeLevels } from './volume';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Queues an audio file to a specific channel and starts playing if it's the first in queue
|
|
19
|
+
* @param audioUrl - The URL of the audio file to queue
|
|
20
|
+
* @param channelNumber - The channel number to queue the audio to (defaults to 0)
|
|
21
|
+
* @param options - Optional configuration for the audio file
|
|
22
|
+
* @returns Promise that resolves when the audio is queued and starts playing (if first in queue)
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* await queueAudio('https://example.com/song.mp3', 0);
|
|
26
|
+
* await queueAudio('./sounds/notification.wav'); // Uses default channel 0
|
|
27
|
+
* await queueAudio('./music/loop.mp3', 1, { loop: true }); // Loop the audio
|
|
28
|
+
* await queueAudio('./urgent.wav', 0, { addToFront: true }); // Add to front of queue
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export const queueAudio = async (
|
|
32
|
+
audioUrl: string,
|
|
33
|
+
channelNumber: number = 0,
|
|
34
|
+
options?: AudioQueueOptions
|
|
35
|
+
): Promise<void> => {
|
|
36
|
+
if (!audioChannels[channelNumber]) {
|
|
37
|
+
audioChannels[channelNumber] = {
|
|
38
|
+
audioCompleteCallbacks: new Set(),
|
|
39
|
+
audioPauseCallbacks: new Set(),
|
|
40
|
+
audioResumeCallbacks: new Set(),
|
|
41
|
+
audioStartCallbacks: new Set(),
|
|
42
|
+
isPaused: false,
|
|
43
|
+
progressCallbacks: new Map(),
|
|
44
|
+
queue: [],
|
|
45
|
+
queueChangeCallbacks: new Set(),
|
|
46
|
+
volume: 1.0
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const audio: HTMLAudioElement = new Audio(audioUrl);
|
|
51
|
+
|
|
52
|
+
// Apply audio configuration from options
|
|
53
|
+
if (options?.loop) {
|
|
54
|
+
audio.loop = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (options?.volume !== undefined) {
|
|
58
|
+
const clampedVolume: number = Math.max(0, Math.min(1, options.volume));
|
|
59
|
+
// Handle NaN case - default to channel volume or 1.0
|
|
60
|
+
const safeVolume: number = isNaN(clampedVolume) ? (audioChannels[channelNumber].volume || 1.0) : clampedVolume;
|
|
61
|
+
audio.volume = safeVolume;
|
|
62
|
+
// Also update the channel volume
|
|
63
|
+
audioChannels[channelNumber].volume = safeVolume;
|
|
64
|
+
} else {
|
|
65
|
+
// Use channel volume if no specific volume is set
|
|
66
|
+
const channelVolume: number = audioChannels[channelNumber].volume || 1.0;
|
|
67
|
+
audio.volume = channelVolume;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Add to front or back of queue based on options
|
|
71
|
+
if ((options?.addToFront || options?.priority) && audioChannels[channelNumber].queue.length > 0) {
|
|
72
|
+
// Insert after the currently playing audio (index 1)
|
|
73
|
+
audioChannels[channelNumber].queue.splice(1, 0, audio);
|
|
74
|
+
} else if ((options?.addToFront || options?.priority) && audioChannels[channelNumber].queue.length === 0) {
|
|
75
|
+
// If queue is empty, just add normally
|
|
76
|
+
audioChannels[channelNumber].queue.push(audio);
|
|
77
|
+
} else {
|
|
78
|
+
// Default behavior - add to back of queue
|
|
79
|
+
audioChannels[channelNumber].queue.push(audio);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
emitQueueChange(channelNumber, audioChannels);
|
|
83
|
+
|
|
84
|
+
if (audioChannels[channelNumber].queue.length === 1) {
|
|
85
|
+
// Don't await - let playback happen asynchronously
|
|
86
|
+
playAudioQueue(channelNumber).catch(console.error);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Adds an audio file to the front of the queue in a specific channel
|
|
92
|
+
* This is a convenience function that places the audio right after the currently playing track
|
|
93
|
+
* @param audioUrl - The URL of the audio file to queue
|
|
94
|
+
* @param channelNumber - The channel number to queue the audio to (defaults to 0)
|
|
95
|
+
* @param options - Optional configuration for the audio file
|
|
96
|
+
* @returns Promise that resolves when the audio is queued
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* await queueAudioPriority('./urgent-announcement.wav', 0);
|
|
100
|
+
* await queueAudioPriority('./priority-sound.mp3', 1, { loop: true });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export const queueAudioPriority = async (
|
|
104
|
+
audioUrl: string,
|
|
105
|
+
channelNumber: number = 0,
|
|
106
|
+
options?: AudioQueueOptions
|
|
107
|
+
): Promise<void> => {
|
|
108
|
+
const priorityOptions: AudioQueueOptions = { ...options, addToFront: true };
|
|
109
|
+
return queueAudio(audioUrl, channelNumber, priorityOptions);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Plays the audio queue for a specific channel
|
|
114
|
+
* @param channelNumber - The channel number to play
|
|
115
|
+
* @returns Promise that resolves when the current audio finishes playing
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* await playAudioQueue(0); // Play queue for channel 0
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export const playAudioQueue = async (channelNumber: number): Promise<void> => {
|
|
122
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
123
|
+
|
|
124
|
+
if (!channel || channel.queue.length === 0) return;
|
|
125
|
+
|
|
126
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
127
|
+
|
|
128
|
+
// Apply channel volume if not already set
|
|
129
|
+
if (currentAudio.volume === 1.0 && channel.volume !== undefined) {
|
|
130
|
+
currentAudio.volume = channel.volume;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setupProgressTracking(currentAudio, channelNumber, audioChannels);
|
|
134
|
+
|
|
135
|
+
// Apply volume ducking when audio starts
|
|
136
|
+
await applyVolumeDucking(channelNumber);
|
|
137
|
+
|
|
138
|
+
return new Promise<void>((resolve) => {
|
|
139
|
+
let hasStarted: boolean = false;
|
|
140
|
+
let metadataLoaded: boolean = false;
|
|
141
|
+
let playStarted: boolean = false;
|
|
142
|
+
|
|
143
|
+
// Check if we should fire onAudioStart (both conditions met)
|
|
144
|
+
const tryFireAudioStart = (): void => {
|
|
145
|
+
if (!hasStarted && metadataLoaded && playStarted) {
|
|
146
|
+
hasStarted = true;
|
|
147
|
+
emitAudioStart(channelNumber, {
|
|
148
|
+
channelNumber,
|
|
149
|
+
duration: currentAudio.duration * 1000, // Now guaranteed to have valid duration
|
|
150
|
+
fileName: extractFileName(currentAudio.src),
|
|
151
|
+
src: currentAudio.src
|
|
152
|
+
}, audioChannels);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Event handler for when metadata loads (duration becomes available)
|
|
157
|
+
const handleLoadedMetadata = (): void => {
|
|
158
|
+
metadataLoaded = true;
|
|
159
|
+
tryFireAudioStart();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Event handler for when audio actually starts playing
|
|
163
|
+
const handlePlay = (): void => {
|
|
164
|
+
playStarted = true;
|
|
165
|
+
tryFireAudioStart();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Event handler for when audio ends
|
|
169
|
+
const handleEnded = async (): Promise<void> => {
|
|
170
|
+
emitAudioComplete(channelNumber, {
|
|
171
|
+
channelNumber,
|
|
172
|
+
fileName: extractFileName(currentAudio.src),
|
|
173
|
+
remainingInQueue: channel.queue.length - 1,
|
|
174
|
+
src: currentAudio.src
|
|
175
|
+
}, audioChannels);
|
|
176
|
+
|
|
177
|
+
// Restore volume levels when priority channel stops
|
|
178
|
+
await restoreVolumeLevels(channelNumber);
|
|
179
|
+
|
|
180
|
+
// Clean up event listeners
|
|
181
|
+
currentAudio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
182
|
+
currentAudio.removeEventListener('play', handlePlay);
|
|
183
|
+
currentAudio.removeEventListener('ended', handleEnded);
|
|
184
|
+
|
|
185
|
+
cleanupProgressTracking(currentAudio, channelNumber, audioChannels);
|
|
186
|
+
|
|
187
|
+
// Handle looping vs non-looping audio
|
|
188
|
+
if (currentAudio.loop) {
|
|
189
|
+
// For looping audio, reset current time and continue playing
|
|
190
|
+
currentAudio.currentTime = 0;
|
|
191
|
+
await currentAudio.play();
|
|
192
|
+
// Don't remove from queue, but resolve the promise so tests don't hang
|
|
193
|
+
resolve();
|
|
194
|
+
} else {
|
|
195
|
+
// For non-looping audio, remove from queue and play next
|
|
196
|
+
channel.queue.shift();
|
|
197
|
+
|
|
198
|
+
// Emit queue change after completion
|
|
199
|
+
setTimeout(() => emitQueueChange(channelNumber, audioChannels), 10);
|
|
200
|
+
|
|
201
|
+
await playAudioQueue(channelNumber);
|
|
202
|
+
resolve();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Add event listeners
|
|
207
|
+
currentAudio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
208
|
+
currentAudio.addEventListener('play', handlePlay);
|
|
209
|
+
currentAudio.addEventListener('ended', handleEnded);
|
|
210
|
+
|
|
211
|
+
// Check if metadata is already loaded (in case it loads before we add the listener)
|
|
212
|
+
if (currentAudio.readyState >= 1) { // HAVE_METADATA or higher
|
|
213
|
+
metadataLoaded = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
currentAudio.play();
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Stops the currently playing audio in a specific channel and plays the next audio in queue
|
|
222
|
+
* @param channelNumber - The channel number (defaults to 0)
|
|
223
|
+
* @example
|
|
224
|
+
* ```typescript
|
|
225
|
+
* await stopCurrentAudioInChannel(); // Stop current audio in default channel (0)
|
|
226
|
+
* await stopCurrentAudioInChannel(1); // Stop current audio in channel 1
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
export const stopCurrentAudioInChannel = async (channelNumber: number = 0): Promise<void> => {
|
|
230
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
231
|
+
if (channel && channel.queue.length > 0) {
|
|
232
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
233
|
+
|
|
234
|
+
emitAudioComplete(channelNumber, {
|
|
235
|
+
channelNumber,
|
|
236
|
+
fileName: extractFileName(currentAudio.src),
|
|
237
|
+
remainingInQueue: channel.queue.length - 1,
|
|
238
|
+
src: currentAudio.src
|
|
239
|
+
}, audioChannels);
|
|
240
|
+
|
|
241
|
+
// Restore volume levels when stopping
|
|
242
|
+
await restoreVolumeLevels(channelNumber);
|
|
243
|
+
|
|
244
|
+
currentAudio.pause();
|
|
245
|
+
cleanupProgressTracking(currentAudio, channelNumber, audioChannels);
|
|
246
|
+
channel.queue.shift();
|
|
247
|
+
channel.isPaused = false; // Reset pause state
|
|
248
|
+
|
|
249
|
+
emitQueueChange(channelNumber, audioChannels);
|
|
250
|
+
|
|
251
|
+
// Start next audio without waiting for it to complete
|
|
252
|
+
playAudioQueue(channelNumber).catch(console.error);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Stops all audio in a specific channel and clears the entire queue
|
|
258
|
+
* @param channelNumber - The channel number (defaults to 0)
|
|
259
|
+
* @example
|
|
260
|
+
* ```typescript
|
|
261
|
+
* await stopAllAudioInChannel(); // Clear all audio in default channel (0)
|
|
262
|
+
* await stopAllAudioInChannel(1); // Clear all audio in channel 1
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
export const stopAllAudioInChannel = async (channelNumber: number = 0): Promise<void> => {
|
|
266
|
+
const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
|
|
267
|
+
if (channel) {
|
|
268
|
+
if (channel.queue.length > 0) {
|
|
269
|
+
const currentAudio: HTMLAudioElement = channel.queue[0];
|
|
270
|
+
|
|
271
|
+
emitAudioComplete(channelNumber, {
|
|
272
|
+
channelNumber,
|
|
273
|
+
fileName: extractFileName(currentAudio.src),
|
|
274
|
+
remainingInQueue: 0, // Will be 0 since we're clearing the queue
|
|
275
|
+
src: currentAudio.src
|
|
276
|
+
}, audioChannels);
|
|
277
|
+
|
|
278
|
+
// Restore volume levels when stopping
|
|
279
|
+
await restoreVolumeLevels(channelNumber);
|
|
280
|
+
|
|
281
|
+
currentAudio.pause();
|
|
282
|
+
cleanupProgressTracking(currentAudio, channelNumber, audioChannels);
|
|
283
|
+
}
|
|
284
|
+
// Clean up all progress tracking for this channel
|
|
285
|
+
channel.queue.forEach(audio => cleanupProgressTracking(audio, channelNumber, audioChannels));
|
|
286
|
+
channel.queue = [];
|
|
287
|
+
channel.isPaused = false; // Reset pause state
|
|
288
|
+
|
|
289
|
+
emitQueueChange(channelNumber, audioChannels);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Stops all audio across all channels and clears all queues
|
|
295
|
+
* @example
|
|
296
|
+
* ```typescript
|
|
297
|
+
* await stopAllAudio(); // Emergency stop - clears everything
|
|
298
|
+
* ```
|
|
299
|
+
*/
|
|
300
|
+
export const stopAllAudio = async (): Promise<void> => {
|
|
301
|
+
const stopPromises: Promise<void>[] = [];
|
|
302
|
+
audioChannels.forEach((_channel: ExtendedAudioQueueChannel, index: number) => {
|
|
303
|
+
stopPromises.push(stopAllAudioInChannel(index));
|
|
304
|
+
});
|
|
305
|
+
await Promise.all(stopPromises);
|
|
306
|
+
};
|