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/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
+ */