openaudio-suite 2.4.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/CHANGELOG.md +196 -0
- package/CONTRIBUTING.md +349 -0
- package/LICENSE +674 -0
- package/OpenAudio.js +386 -0
- package/OpenAudio_r.js +863 -0
- package/OpenAudio_s.js +445 -0
- package/README.md +433 -0
- package/docs/COMPARISON.md +449 -0
- package/docs/OPENAUDIO-v1.1.0.md +641 -0
- package/docs/OPENAUDIO_R.md +571 -0
- package/docs/OPENAUDIO_S.md +760 -0
- package/package.json +79 -0
package/OpenAudio_s.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file OpenAudio_s.js
|
|
3
|
+
* @author Rexore
|
|
4
|
+
* @version 1.0.0
|
|
5
|
+
* @license GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* Sequential audio player: plays clips one at a time, advances on demand.
|
|
8
|
+
* Perfect for interactive narratives, tutorials, quizzes, and guided tours.
|
|
9
|
+
*
|
|
10
|
+
* Key differences from OpenAudio_r.js:
|
|
11
|
+
* - Plays clips in fixed sequence (no randomization)
|
|
12
|
+
* - User controls when next clip plays (manual or auto-advance)
|
|
13
|
+
* - Tracks current clip index and progress
|
|
14
|
+
* - Can loop the entire sequence
|
|
15
|
+
* - Optional shuffle on sequence restart
|
|
16
|
+
*
|
|
17
|
+
* Inherited from parent architecture:
|
|
18
|
+
* - Silent MP3 unlock: satisfies browser autoplay policy
|
|
19
|
+
* - #isUnlocking guard: prevents duplicate unlock attempts
|
|
20
|
+
* - Callbacks: onPlay, onEnd — wrapped in try/catch
|
|
21
|
+
* - destroy(): removes listeners; safe for SPA teardown
|
|
22
|
+
* - canPlay() static: check browser format support before constructing
|
|
23
|
+
*
|
|
24
|
+
* ============================================================================
|
|
25
|
+
* QUICK START
|
|
26
|
+
* ============================================================================
|
|
27
|
+
*
|
|
28
|
+
* const player = new SequentialAudio([
|
|
29
|
+
* { src: 'intro.mp3', label: 'Introduction' },
|
|
30
|
+
* { src: 'chapter1.mp3', label: 'Chapter 1' },
|
|
31
|
+
* { src: 'chapter2.mp3', label: 'Chapter 2' }
|
|
32
|
+
* ], {
|
|
33
|
+
* autoAdvance: false, // Require click to go to next clip
|
|
34
|
+
* onPlay: (clip) => console.log(`Playing: ${clip.label}`),
|
|
35
|
+
* onEnd: (clip) => console.log(`Finished: ${clip.label}`),
|
|
36
|
+
* onComplete: () => console.log('Sequence complete!')
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* // Start sequence
|
|
40
|
+
* document.getElementById('play-btn').addEventListener('click', () => {
|
|
41
|
+
* player.play();
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* // Advance to next clip
|
|
45
|
+
* document.getElementById('next-btn').addEventListener('click', () => {
|
|
46
|
+
* player.next();
|
|
47
|
+
* });
|
|
48
|
+
*
|
|
49
|
+
* ============================================================================
|
|
50
|
+
* BROWSER AUTOPLAY POLICY
|
|
51
|
+
* ============================================================================
|
|
52
|
+
*
|
|
53
|
+
* First call to play() must be inside a user gesture (click, keydown, etc.).
|
|
54
|
+
* Subsequent calls to next() can happen anytime after the first unlock.
|
|
55
|
+
*
|
|
56
|
+
* ============================================================================
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
class SequentialAudio {
|
|
60
|
+
|
|
61
|
+
// ── Silent 1-second MP3 used only to unlock the audio element ──────────────
|
|
62
|
+
static #SILENT_MP3 =
|
|
63
|
+
'data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjI5LjEwMAAAAAAA' +
|
|
64
|
+
'AAAAAP/7kGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhgCg' +
|
|
65
|
+
'oKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKD///////' +
|
|
66
|
+
'///////////////////////////////////////////////////////////AAAAAExhdmM1OC41' +
|
|
67
|
+
'NQAAAAAAAAAAAAAAA//uQZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWluZwAAAA8A' +
|
|
68
|
+
'AAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAv' +
|
|
69
|
+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
|
70
|
+
|
|
71
|
+
// ── Private fields ──────────────────────────────────────────────────────────
|
|
72
|
+
#clips;
|
|
73
|
+
#currentIndex;
|
|
74
|
+
#audio;
|
|
75
|
+
#isUnlocking = false;
|
|
76
|
+
#autoAdvance;
|
|
77
|
+
#loop;
|
|
78
|
+
#onPlay;
|
|
79
|
+
#onEnd;
|
|
80
|
+
#onComplete;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Array} clips - Array of clip objects with src and label.
|
|
84
|
+
* @param {object} [options]
|
|
85
|
+
* @param {boolean} [options.autoAdvance=false] - Auto-play next clip after current ends.
|
|
86
|
+
* @param {boolean} [options.loop=false] - Loop sequence when complete.
|
|
87
|
+
* @param {Function} [options.onPlay] - Called when clip starts.
|
|
88
|
+
* @param {Function} [options.onEnd] - Called when clip ends.
|
|
89
|
+
* @param {Function} [options.onComplete] - Called when sequence finishes.
|
|
90
|
+
*/
|
|
91
|
+
constructor(clips, options = {}) {
|
|
92
|
+
if (!Array.isArray(clips) || clips.length === 0) {
|
|
93
|
+
throw new TypeError('SequentialAudio: clips must be a non-empty array.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Validate all clips have src
|
|
97
|
+
clips.forEach((clip, i) => {
|
|
98
|
+
if (!clip || typeof clip.src !== 'string' || !clip.src.trim()) {
|
|
99
|
+
throw new TypeError(`SequentialAudio: clips[${i}].src must be a non-empty string.`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const {
|
|
104
|
+
autoAdvance = false,
|
|
105
|
+
loop = false,
|
|
106
|
+
onPlay = null,
|
|
107
|
+
onEnd = null,
|
|
108
|
+
onComplete = null,
|
|
109
|
+
} = options;
|
|
110
|
+
|
|
111
|
+
this.#clips = clips;
|
|
112
|
+
this.#currentIndex = 0;
|
|
113
|
+
this.#autoAdvance = autoAdvance;
|
|
114
|
+
this.#loop = loop;
|
|
115
|
+
this.#onPlay = onPlay;
|
|
116
|
+
this.#onEnd = onEnd;
|
|
117
|
+
this.#onComplete = onComplete;
|
|
118
|
+
|
|
119
|
+
// Single shared Audio element
|
|
120
|
+
this.#audio = new Audio();
|
|
121
|
+
this.#audio.preload = 'auto';
|
|
122
|
+
|
|
123
|
+
this.#audio.addEventListener('ended', () => {
|
|
124
|
+
this.isPlaying = false;
|
|
125
|
+
const clip = this.#clips[this.#currentIndex];
|
|
126
|
+
|
|
127
|
+
try { if (this.#onEnd) this.#onEnd(clip); } catch (e) {
|
|
128
|
+
console.warn(`SequentialAudio: onEnd callback error (${clip.label}):`, e);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Auto-advance to next clip if enabled
|
|
132
|
+
if (this.#autoAdvance) {
|
|
133
|
+
setTimeout(() => this.next(), 500); // Small delay for better UX
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Public state ────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/** True while a clip is actively playing. */
|
|
141
|
+
isPlaying = false;
|
|
142
|
+
|
|
143
|
+
/** True after first play() call (sequence has started). */
|
|
144
|
+
isStarted = false;
|
|
145
|
+
|
|
146
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Unlock the audio element (if needed) and play the first clip.
|
|
150
|
+
* Must be called synchronously inside a user-gesture event handler on first use.
|
|
151
|
+
*
|
|
152
|
+
* Safe to call repeatedly — already-playing calls are ignored.
|
|
153
|
+
*/
|
|
154
|
+
play() {
|
|
155
|
+
if (this.isPlaying || this.#isUnlocking) return;
|
|
156
|
+
|
|
157
|
+
this.#isUnlocking = true;
|
|
158
|
+
|
|
159
|
+
// Play silent MP3 to unlock
|
|
160
|
+
const unlock = new Audio(SequentialAudio.#SILENT_MP3);
|
|
161
|
+
unlock.play().then(() => {
|
|
162
|
+
this.#isUnlocking = false;
|
|
163
|
+
this.#playClip();
|
|
164
|
+
}).catch(() => {
|
|
165
|
+
this.#isUnlocking = false;
|
|
166
|
+
this.#playClip();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Advance to the next clip and play it.
|
|
172
|
+
* Wraps to beginning if at end (and loop is enabled).
|
|
173
|
+
*
|
|
174
|
+
* Safe to call even if nothing is playing.
|
|
175
|
+
*/
|
|
176
|
+
next() {
|
|
177
|
+
// Move to next clip
|
|
178
|
+
this.#currentIndex++;
|
|
179
|
+
|
|
180
|
+
// Handle end-of-sequence
|
|
181
|
+
if (this.#currentIndex >= this.#clips.length) {
|
|
182
|
+
if (this.#loop) {
|
|
183
|
+
// Loop: restart sequence
|
|
184
|
+
this.#currentIndex = 0;
|
|
185
|
+
this.#playClip();
|
|
186
|
+
} else {
|
|
187
|
+
// End: stop and call onComplete
|
|
188
|
+
this.#currentIndex = this.#clips.length - 1; // Stay at last clip
|
|
189
|
+
this.isPlaying = false;
|
|
190
|
+
try { if (this.#onComplete) this.#onComplete(); } catch (e) {
|
|
191
|
+
console.warn('SequentialAudio: onComplete callback error:', e);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.#playClip();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Jump to a specific clip by index and play it.
|
|
202
|
+
*
|
|
203
|
+
* @param {number} index - Clip index (0-based)
|
|
204
|
+
*/
|
|
205
|
+
goto(index) {
|
|
206
|
+
if (index < 0 || index >= this.#clips.length) {
|
|
207
|
+
console.warn(`SequentialAudio: goto() index out of range [0, ${this.#clips.length - 1}]`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.#currentIndex = index;
|
|
212
|
+
this.#playClip();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Jump to a clip by label.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} label - Clip label (exact match)
|
|
219
|
+
*/
|
|
220
|
+
gotoLabel(label) {
|
|
221
|
+
const index = this.#clips.findIndex(c => c.label === label);
|
|
222
|
+
if (index === -1) {
|
|
223
|
+
console.warn(`SequentialAudio: no clip with label "${label}"`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.goto(index);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Pause playback without resetting position.
|
|
231
|
+
*/
|
|
232
|
+
pause() {
|
|
233
|
+
this.#audio.pause();
|
|
234
|
+
this.isPlaying = false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Resume playback from current position.
|
|
239
|
+
* If not paused, this has no effect.
|
|
240
|
+
*/
|
|
241
|
+
resume() {
|
|
242
|
+
if (!this.#audio.paused) return;
|
|
243
|
+
this.#audio.play().catch(err => {
|
|
244
|
+
console.warn('SequentialAudio: resume() failed:', err);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Stop playback and rewind to beginning of current clip.
|
|
250
|
+
*/
|
|
251
|
+
stop() {
|
|
252
|
+
this.#audio.pause();
|
|
253
|
+
this.#audio.currentTime = 0;
|
|
254
|
+
this.isPlaying = false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Reset sequence to first clip without playing.
|
|
259
|
+
*/
|
|
260
|
+
reset() {
|
|
261
|
+
this.stop();
|
|
262
|
+
this.#currentIndex = 0;
|
|
263
|
+
this.isStarted = false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get the current clip object.
|
|
268
|
+
*
|
|
269
|
+
* @returns {object} Current clip { src, label, ... }
|
|
270
|
+
*/
|
|
271
|
+
getCurrentClip() {
|
|
272
|
+
return this.#clips[this.#currentIndex];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get current clip index.
|
|
277
|
+
*
|
|
278
|
+
* @returns {number}
|
|
279
|
+
*/
|
|
280
|
+
getCurrentIndex() {
|
|
281
|
+
return this.#currentIndex;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get total number of clips.
|
|
286
|
+
*
|
|
287
|
+
* @returns {number}
|
|
288
|
+
*/
|
|
289
|
+
getClipCount() {
|
|
290
|
+
return this.#clips.length;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get all clips.
|
|
295
|
+
*
|
|
296
|
+
* @returns {Array}
|
|
297
|
+
*/
|
|
298
|
+
getClips() {
|
|
299
|
+
return [...this.#clips];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Removes all references. Call on SPA component unmount.
|
|
304
|
+
*/
|
|
305
|
+
destroy() {
|
|
306
|
+
this.stop();
|
|
307
|
+
this.#audio.src = '';
|
|
308
|
+
this.#audio = null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Check browser support for audio MIME type.
|
|
313
|
+
*
|
|
314
|
+
* @param {string} type - MIME type (e.g., 'audio/mpeg')
|
|
315
|
+
* @returns {boolean}
|
|
316
|
+
*/
|
|
317
|
+
static canPlay(type) {
|
|
318
|
+
const result = new Audio().canPlayType(type);
|
|
319
|
+
return result === 'probably' || result === 'maybe';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Private ─────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
#playClip() {
|
|
325
|
+
if (this.#currentIndex < 0 || this.#currentIndex >= this.#clips.length) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const clip = this.#clips[this.#currentIndex];
|
|
330
|
+
|
|
331
|
+
// Rewind to start
|
|
332
|
+
this.#audio.currentTime = 0;
|
|
333
|
+
this.#audio.src = clip.src;
|
|
334
|
+
|
|
335
|
+
this.#audio.play().then(() => {
|
|
336
|
+
this.isStarted = true;
|
|
337
|
+
this.isPlaying = true;
|
|
338
|
+
try { if (this.#onPlay) this.#onPlay(clip); } catch (e) {
|
|
339
|
+
console.warn(`SequentialAudio: onPlay callback error (${clip.label}):`, e);
|
|
340
|
+
}
|
|
341
|
+
}).catch(err => {
|
|
342
|
+
if (err.name === 'AbortError') return;
|
|
343
|
+
|
|
344
|
+
if (err.name === 'NotAllowedError') {
|
|
345
|
+
console.warn(
|
|
346
|
+
`SequentialAudio: play() blocked by autoplay policy for "${clip.label}". ` +
|
|
347
|
+
`Call play() again inside a user gesture.`
|
|
348
|
+
);
|
|
349
|
+
} else {
|
|
350
|
+
console.warn(`SequentialAudio: play() failed for "${clip.label}".\nError:`, err);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
// ── USAGE EXAMPLES ────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/*
|
|
360
|
+
|
|
361
|
+
// ── Minimal ───────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
const player = new SequentialAudio([
|
|
364
|
+
{ src: 'clip1.mp3', label: 'Clip 1' },
|
|
365
|
+
{ src: 'clip2.mp3', label: 'Clip 2' },
|
|
366
|
+
{ src: 'clip3.mp3', label: 'Clip 3' }
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
document.getElementById('play-btn').addEventListener('click', () => player.play());
|
|
370
|
+
document.getElementById('next-btn').addEventListener('click', () => player.next());
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
// ── With options ──────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
const player = new SequentialAudio(clips, {
|
|
376
|
+
autoAdvance: true, // Auto-play next clip when current finishes
|
|
377
|
+
loop: true, // Loop back to beginning when sequence ends
|
|
378
|
+
onPlay: (clip) => console.log(`Playing: ${clip.label}`),
|
|
379
|
+
onEnd: (clip) => console.log(`Finished: ${clip.label}`),
|
|
380
|
+
onComplete: () => console.log('Sequence complete!')
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
// ── Guided tour ───────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
const tour = new SequentialAudio([
|
|
387
|
+
{ src: 'welcome.mp3', label: 'Welcome' },
|
|
388
|
+
{ src: 'feature1.mp3', label: 'Feature 1' },
|
|
389
|
+
{ src: 'feature2.mp3', label: 'Feature 2' },
|
|
390
|
+
{ src: 'goodbye.mp3', label: 'Goodbye' }
|
|
391
|
+
], {
|
|
392
|
+
onPlay: (clip) => showStep(clip.label),
|
|
393
|
+
onEnd: (clip) => console.log(`Finished: ${clip.label}`),
|
|
394
|
+
onComplete: () => showCompletionMessage()
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
document.addEventListener('click', () => tour.play(), { once: true });
|
|
398
|
+
document.getElementById('next-btn').addEventListener('click', () => tour.next());
|
|
399
|
+
document.getElementById('prev-btn').addEventListener('click', () => {
|
|
400
|
+
tour.goto(tour.getCurrentIndex() - 1);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
// ── Narrated story ────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
const story = new SequentialAudio([
|
|
407
|
+
{ src: 'chapter1.mp3', label: 'Chapter 1' },
|
|
408
|
+
{ src: 'chapter2.mp3', label: 'Chapter 2' },
|
|
409
|
+
{ src: 'chapter3.mp3', label: 'Chapter 3' }
|
|
410
|
+
], {
|
|
411
|
+
autoAdvance: false, // User controls pacing
|
|
412
|
+
onPlay: (clip) => updateUI(`Now reading: ${clip.label}`),
|
|
413
|
+
onComplete: () => showCongratulations()
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Start, next, pause, resume controls
|
|
417
|
+
document.getElementById('play-btn').addEventListener('click', () => story.play());
|
|
418
|
+
document.getElementById('next-btn').addEventListener('click', () => story.next());
|
|
419
|
+
document.getElementById('pause-btn').addEventListener('click', () => story.pause());
|
|
420
|
+
document.getElementById('resume-btn').addEventListener('click', () => story.resume());
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
// ── Jump to clip by label ─────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
player.gotoLabel('Chapter 2'); // Jump to chapter 2
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
// ── Get current progress ──────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
const current = player.getCurrentClip();
|
|
431
|
+
const index = player.getCurrentIndex();
|
|
432
|
+
const total = player.getClipCount();
|
|
433
|
+
|
|
434
|
+
console.log(`Playing clip ${index + 1} of ${total}: ${current.label}`);
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
// ── SPA teardown (React, Vue, etc.) ───────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
// React:
|
|
440
|
+
// useEffect(() => {
|
|
441
|
+
// const player = new SequentialAudio(clips);
|
|
442
|
+
// return () => player.destroy();
|
|
443
|
+
// }, []);
|
|
444
|
+
|
|
445
|
+
*/
|