openaudio-suite 2.5.5 → 2.6.1
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 +163 -109
- package/CONTRIBUTING.md +39 -324
- package/OpenAudio.js +314 -159
- package/OpenAudio_r.js +211 -164
- package/OpenAudio_s.js +420 -204
- package/README.md +142 -122
- package/docs/OPENAUDIO_R.md +135 -143
- package/docs/OPENAUDIO_S.md +162 -377
- package/package.json +2 -2
package/OpenAudio_r.js
CHANGED
|
@@ -1,34 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file OpenAudio_r.js
|
|
3
3
|
* @author Rexore
|
|
4
|
-
* @version 2.
|
|
5
|
-
* @license
|
|
4
|
+
* @version 2.6.0
|
|
5
|
+
* @license Apache-2.0
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* Copyright
|
|
7
|
+
* OpenAudio_r.js — A self-contained, randomised audio scheduling engine.
|
|
8
|
+
* Copyright 2025 Rexore
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (at your option) any later version.
|
|
10
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
11
|
+
* you may not use this file except in compliance with the License.
|
|
12
|
+
* You may obtain a copy of the License at
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
17
|
-
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
18
|
-
* GNU General Public License for more details.
|
|
14
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
19
15
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
17
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
18
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
19
|
+
* See the License for the specific language governing permissions and
|
|
20
|
+
* limitations under the License.
|
|
22
21
|
*
|
|
23
22
|
* Attach to any element or user action. No dependencies.
|
|
24
|
-
*
|
|
25
23
|
* ============================================================================
|
|
26
24
|
* QUICK START
|
|
27
25
|
* ============================================================================
|
|
28
26
|
*
|
|
29
27
|
* 1. Include the script:
|
|
30
28
|
*
|
|
31
|
-
* <script src="
|
|
29
|
+
* <script src="OpenAudio_r.js"></script>
|
|
32
30
|
*
|
|
33
31
|
* 2. Create an engine instance with your clips:
|
|
34
32
|
*
|
|
@@ -150,101 +148,82 @@
|
|
|
150
148
|
* CHANGELOG
|
|
151
149
|
* ============================================================================
|
|
152
150
|
*
|
|
151
|
+
* 2.6.0
|
|
152
|
+
* - All constructor and addClip() validation throws changed from Error to
|
|
153
|
+
* TypeError. This matches the ECMAScript convention (and the behaviour of
|
|
154
|
+
* OpenAudio.js and OpenAudio_s.js) that type-mismatch errors should be
|
|
155
|
+
* TypeError so callers can distinguish them via instanceof.
|
|
156
|
+
* - #isStarted is now set true inside the unlock .then() (after the Audio
|
|
157
|
+
* element is confirmed usable), not at the top of start() before the
|
|
158
|
+
* unlock. The duplicate-start guard during the unlock window is handled
|
|
159
|
+
* by #isUnlocking, which is set immediately. This aligns the state machine
|
|
160
|
+
* semantics with OpenAudio_s.js (D3).
|
|
161
|
+
* - Expanded three compact single-line try/catch blocks in the onended
|
|
162
|
+
* handler, #resetCycle(), and #playNext() .then() to multi-line style,
|
|
163
|
+
* matching the formatting convention used in OpenAudio.js and
|
|
164
|
+
* OpenAudio_s.js (S2).
|
|
165
|
+
*
|
|
166
|
+
* 2.5.0
|
|
167
|
+
* - isStarted and isPlaying are now private fields (#isStarted, #isPlaying)
|
|
168
|
+
* exposed via read-only getters. Previously they were plain public
|
|
169
|
+
* properties, allowing callers to silently corrupt the state machine.
|
|
170
|
+
* All internal guards updated to use #isStarted / #isPlaying.
|
|
171
|
+
* - #isDestroyed flag: all public methods (start, stop, reset, setVolume,
|
|
172
|
+
* addClip, destroy) now return immediately after destroy() has been
|
|
173
|
+
* called, making post-destroy calls safe no-ops. Previously destroy()
|
|
174
|
+
* documented post-destroy behaviour as "undefined"; in practice, calling
|
|
175
|
+
* start() after destroy() would silently re-run the engine without a
|
|
176
|
+
* visibility listener, causing partially torn-down state.
|
|
177
|
+
* - destroy() now releases the Audio element via removeAttribute('src') +
|
|
178
|
+
* load() per the WHATWG HTMLMediaElement resource-release spec, then nulls
|
|
179
|
+
* #audio. Previously the element was kept live with its src intact.
|
|
180
|
+
* - #isPlaying is now set true synchronously before #audio.play() in
|
|
181
|
+
* #playNext(), closing the race window where isPlaying reported false
|
|
182
|
+
* while audio was starting. Reverted to false in .catch() on failure.
|
|
183
|
+
*
|
|
184
|
+
* 2.4.1
|
|
185
|
+
* - Licence changed from GPL-3.0-or-later to Apache-2.0 across the suite.
|
|
186
|
+
*
|
|
153
187
|
* 2.4.0
|
|
154
188
|
* - #isUnlocking flag: start() now sets a private boolean during the silent
|
|
155
189
|
* MP3 unlock phase. Rapid repeated calls to start() before the unlock
|
|
156
190
|
* promise resolves are ignored, preventing multiple overlapping unlock
|
|
157
|
-
* attempts from racing each other.
|
|
158
|
-
* which was set synchronously — but the unlock play() is async, leaving a
|
|
159
|
-
* window where a spam-click could trigger duplicate #scheduleNext() calls.
|
|
191
|
+
* attempts from racing each other.
|
|
160
192
|
* - destroy() method: removes the visibilitychange listener added in the
|
|
161
193
|
* constructor using a stored bound handler reference (#boundVisibility).
|
|
162
|
-
* Calling destroy() on teardown is essential in SPAs (React, Vue, etc.)
|
|
163
|
-
* where multiple engine instances may be created and destroyed over the
|
|
164
|
-
* component lifecycle. Without it, stale listeners accumulate on document
|
|
165
|
-
* and defunct engine instances wake up on every tab-focus event.
|
|
166
194
|
* - AudioEngine.canPlay(type) static method: wraps HTMLAudioElement
|
|
167
|
-
* .canPlayType() with a clean boolean return.
|
|
168
|
-
*
|
|
169
|
-
* rather than discovering a silent failure at play() time.
|
|
170
|
-
* - onCycleReset callback wrapped in try/catch, matching the existing
|
|
171
|
-
* resilience applied to onPlay and onEnd. A throwing onCycleReset can no
|
|
172
|
-
* longer stall the engine loop.
|
|
195
|
+
* .canPlayType() with a clean boolean return.
|
|
196
|
+
* - onCycleReset callback wrapped in try/catch.
|
|
173
197
|
* - Documentation: added HTML5 AUDIO vs. WEB AUDIO API comparison table and
|
|
174
198
|
* CALLBACK RESILIENCE section.
|
|
175
199
|
*
|
|
176
200
|
* 2.3.0
|
|
177
|
-
* - Clip src validation
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* - Next-clip prefetch: #scheduleNext() now sets #audio.src to the next
|
|
182
|
-
* selected clip immediately after the current clip ends, before the inter-
|
|
183
|
-
* clip delay fires. The browser begins buffering the file during the gap,
|
|
184
|
-
* eliminating the network fetch delay that previously occurred at #playNext()
|
|
185
|
-
* time. The clip is still marked played and selected at schedule time, not
|
|
186
|
-
* at play time, preserving shuffle-bag correctness.
|
|
187
|
-
* - Background tab throttling mitigation: #scheduleNext() records the wall-
|
|
188
|
-
* clock time at which the delay was set (#timerSetAt) and the intended
|
|
189
|
-
* duration (#timerDelay). A visibilitychange listener fires when the tab
|
|
190
|
-
* returns to the foreground: if the elapsed time has already met or exceeded
|
|
191
|
-
* the intended delay, the pending timer is cancelled and #playNext() is
|
|
192
|
-
* called immediately. If not, the remaining time is rescheduled precisely.
|
|
193
|
-
* This recovers pacing after background throttling without over-firing.
|
|
194
|
-
* - Documentation: added BACKGROUND TAB THROTTLING and WEB AUDIO API sections
|
|
195
|
-
* explaining the architectural limits of the HTML5 <audio> approach and
|
|
196
|
-
* guidance on when to consider migrating to the Web Audio API.
|
|
201
|
+
* - Clip src validation in constructor and addClip().
|
|
202
|
+
* - Next-clip prefetch: #scheduleNext() sets #audio.src to the next selected
|
|
203
|
+
* clip during the inter-clip gap, eliminating network fetch delay.
|
|
204
|
+
* - Background tab throttling mitigation via wall-clock correction.
|
|
197
205
|
*
|
|
198
206
|
* 2.2.0
|
|
199
|
-
* - True private fields (#) replace pseudo-private (_) properties
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* -
|
|
203
|
-
* policy violation, the engine now calls stop() and halts rather than
|
|
204
|
-
* rescheduling. Previously, rescheduling on an autoplay block produced a
|
|
205
|
-
* silent infinite loop of blocked timeouts and console warnings.
|
|
206
|
-
* - Silent base64 unlock in start(): a 1-second silent MP3 is played
|
|
207
|
-
* synchronously within the gesture context to unlock the Audio element.
|
|
208
|
-
* _scheduleNext() is then called immediately so the first real clip
|
|
209
|
-
* respects the configured lowTime/maxTime delay. Previously the first clip
|
|
210
|
-
* always played with zero delay.
|
|
211
|
-
* - setVolume(value) public method: updates #volume and applies the new value
|
|
212
|
-
* to the live Audio element immediately, enabling real-time fading and
|
|
213
|
-
* volume sliders without waiting for the next clip.
|
|
207
|
+
* - True private fields (#) replace pseudo-private (_) properties.
|
|
208
|
+
* - NotAllowedError handling: engine halts rather than silent-loops.
|
|
209
|
+
* - Silent base64 unlock in start().
|
|
210
|
+
* - setVolume(value) public method.
|
|
214
211
|
*
|
|
215
212
|
* 2.1.1
|
|
216
|
-
* - Constructor
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* -
|
|
220
|
-
* preventing a double-schedule race if onerror fires unexpectedly.
|
|
221
|
-
* - Cycle-boundary repeat fix: after a lazy reset with 2+ clips, _currentClip
|
|
222
|
-
* is excluded from the freshly-reset pool so the last clip of one cycle
|
|
223
|
-
* cannot immediately be the first clip of the next.
|
|
224
|
-
* - stop() no longer resets currentTime to 0. The interrupted clip's position
|
|
225
|
-
* is discarded (audio is paused); the next start() picks the next clip in
|
|
226
|
-
* the cycle. Resume-mid-clip is not a supported feature.
|
|
213
|
+
* - Constructor validates lowTime and maxTime.
|
|
214
|
+
* - #scheduleNext() clears existing timer before setting new one.
|
|
215
|
+
* - Cycle-boundary repeat fix.
|
|
216
|
+
* - stop() no longer resets currentTime.
|
|
227
217
|
*
|
|
228
218
|
* 2.1.0
|
|
229
|
-
* - Single reusable Audio element created once in
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* which broke mobile autoplay after the first clip.
|
|
234
|
-
* - _scheduleNext() extracted as a named method (previously an inline closure).
|
|
235
|
-
* - stop() no longer needs to null-check a _currentAudio ref — it just
|
|
236
|
-
* pauses the single shared element directly.
|
|
237
|
-
* - _playNext() guards against stop() racing with a resolving play() promise
|
|
238
|
-
* (AbortError catch path and isStarted check after .then() resolves).
|
|
239
|
-
* - onEnd fires AFTER _scheduleNext() — a throwing callback can never
|
|
240
|
-
* prevent the next clip from scheduling.
|
|
241
|
-
* - onerror listener attached once in constructor rather than per-play.
|
|
219
|
+
* - Single reusable Audio element created once in constructor.
|
|
220
|
+
* - #scheduleNext() extracted as a named method.
|
|
221
|
+
* - AbortError handling in #playNext().
|
|
222
|
+
* - onerror listener attached once in constructor.
|
|
242
223
|
*
|
|
243
224
|
* 2.0.0
|
|
244
|
-
* - Initial public release.
|
|
245
|
-
*
|
|
246
|
-
* - onPlay / onEnd / onCycleReset callbacks.
|
|
247
|
-
* - addClip() runtime insertion.
|
|
225
|
+
* - Initial public release. Shuffle bag algorithm. onPlay / onEnd /
|
|
226
|
+
* onCycleReset callbacks. addClip() runtime insertion.
|
|
248
227
|
*
|
|
249
228
|
* ============================================================================
|
|
250
229
|
* HTML5 AUDIO vs. WEB AUDIO API — QUICK REFERENCE
|
|
@@ -290,25 +269,30 @@
|
|
|
290
269
|
* Safe to call multiple times — ignored if running.
|
|
291
270
|
* Ignores rapid repeat calls during the unlock phase
|
|
292
271
|
* via an internal #isUnlocking flag.
|
|
272
|
+
* No-op after destroy().
|
|
293
273
|
*
|
|
294
274
|
* engine.stop() Stop engine. Cancels timer, pauses audio.
|
|
295
275
|
* Preserves cycle state — start() resumes it.
|
|
276
|
+
* No-op after destroy().
|
|
296
277
|
*
|
|
297
278
|
* engine.reset() Stop and clear all played flags.
|
|
298
279
|
* Next start() begins a fresh random cycle.
|
|
280
|
+
* No-op after destroy().
|
|
299
281
|
*
|
|
300
|
-
* engine.destroy() Stop the engine
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
282
|
+
* engine.destroy() Stop the engine, release the Audio element, and
|
|
283
|
+
* remove all document-level event listeners. Call
|
|
284
|
+
* when tearing down in SPAs. All subsequent method
|
|
285
|
+
* calls are safe no-ops.
|
|
304
286
|
*
|
|
305
287
|
* engine.addClip(clip) Add a clip at runtime. Enters the pool immediately.
|
|
306
288
|
* Throws if clip.src is missing or not a string.
|
|
289
|
+
* No-op after destroy().
|
|
307
290
|
*
|
|
308
|
-
* engine.setVolume(value) Set volume (0.0–1.0) immediately, affects live
|
|
291
|
+
* engine.setVolume(value) Set volume (0.0–1.0) immediately, affects live
|
|
292
|
+
* playback. No-op after destroy().
|
|
309
293
|
*
|
|
310
|
-
* engine.isStarted {boolean} True after first start() call.
|
|
311
|
-
* engine.isPlaying {boolean} True while a clip is
|
|
294
|
+
* engine.isStarted {boolean} Read-only. True after first start() call.
|
|
295
|
+
* engine.isPlaying {boolean} Read-only. True while a clip is playing.
|
|
312
296
|
*
|
|
313
297
|
* ── STATIC UTILITY ──────────────────────────────────────────────────────────
|
|
314
298
|
*
|
|
@@ -351,7 +335,11 @@ class AudioEngine {
|
|
|
351
335
|
#currentClip;
|
|
352
336
|
#nextClip; // pre-selected clip loaded into #audio during the inter-clip gap
|
|
353
337
|
#isUnlocking; // true while the silent-MP3 unlock play() promise is pending
|
|
338
|
+
#isDestroyed; // true after destroy(); all public methods become safe no-ops
|
|
354
339
|
#boundVisibility; // bound handler reference stored for removeEventListener in destroy()
|
|
340
|
+
// Backing fields for the read-only isStarted / isPlaying getters.
|
|
341
|
+
#isStarted;
|
|
342
|
+
#isPlaying;
|
|
355
343
|
|
|
356
344
|
/**
|
|
357
345
|
* @param {Array<{ src: string, label?: string }>} clips
|
|
@@ -368,12 +356,13 @@ class AudioEngine {
|
|
|
368
356
|
*/
|
|
369
357
|
constructor(clips = [], options = {}) {
|
|
370
358
|
if (!Array.isArray(clips) || clips.length === 0) {
|
|
371
|
-
throw new
|
|
359
|
+
throw new TypeError('AudioEngine: clips must be a non-empty array.');
|
|
372
360
|
}
|
|
361
|
+
|
|
373
362
|
// Validate every clip has a non-empty string src before doing anything else
|
|
374
363
|
const badClip = clips.findIndex(c => !c || typeof c.src !== 'string' || c.src.trim() === '');
|
|
375
364
|
if (badClip !== -1) {
|
|
376
|
-
throw new
|
|
365
|
+
throw new TypeError(`AudioEngine: clips[${badClip}] is missing a valid src string.`);
|
|
377
366
|
}
|
|
378
367
|
|
|
379
368
|
// Defensive copy — never mutate the caller's original array
|
|
@@ -388,13 +377,13 @@ class AudioEngine {
|
|
|
388
377
|
this.#volume = options.volume ?? 0.85;
|
|
389
378
|
|
|
390
379
|
if (typeof this.#lowTime !== 'number' || this.#lowTime < 0) {
|
|
391
|
-
throw new
|
|
380
|
+
throw new TypeError('AudioEngine: lowTime must be a non-negative number.');
|
|
392
381
|
}
|
|
393
382
|
if (typeof this.#maxTime !== 'number' || this.#maxTime < 0) {
|
|
394
|
-
throw new
|
|
383
|
+
throw new TypeError('AudioEngine: maxTime must be a non-negative number.');
|
|
395
384
|
}
|
|
396
385
|
if (this.#lowTime > this.#maxTime) {
|
|
397
|
-
throw new
|
|
386
|
+
throw new TypeError(`AudioEngine: lowTime (${this.#lowTime}) must not exceed maxTime (${this.#maxTime}).`);
|
|
398
387
|
}
|
|
399
388
|
|
|
400
389
|
this.#onPlay = options.onPlay || null;
|
|
@@ -404,9 +393,10 @@ class AudioEngine {
|
|
|
404
393
|
this.#timer = null;
|
|
405
394
|
this.#timerSetAt = null;
|
|
406
395
|
this.#timerDelay = null;
|
|
407
|
-
this
|
|
408
|
-
this
|
|
396
|
+
this.#isStarted = false;
|
|
397
|
+
this.#isPlaying = false;
|
|
409
398
|
this.#isUnlocking = false;
|
|
399
|
+
this.#isDestroyed = false;
|
|
410
400
|
|
|
411
401
|
// Single reusable Audio element — created once here so that mobile browsers
|
|
412
402
|
// (iOS Safari) keep it within the gesture activation context for all
|
|
@@ -415,17 +405,21 @@ class AudioEngine {
|
|
|
415
405
|
this.#currentClip = null;
|
|
416
406
|
this.#nextClip = null;
|
|
417
407
|
|
|
418
|
-
// Listeners attached once to avoid duplicates and memory leaks
|
|
408
|
+
// Listeners attached once to avoid duplicates and memory leaks.
|
|
419
409
|
this.#audio.onended = () => {
|
|
420
|
-
if (!this
|
|
421
|
-
// Schedule next BEFORE firing callback — a throwing onEnd can never stall the engine
|
|
410
|
+
if (!this.#isStarted) return;
|
|
411
|
+
// Schedule next BEFORE firing callback — a throwing onEnd can never stall the engine.
|
|
422
412
|
this.#scheduleNext();
|
|
423
|
-
try {
|
|
413
|
+
try {
|
|
414
|
+
if (this.#onEnd) this.#onEnd(this.#currentClip);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
console.warn('AudioEngine: onEnd callback error:', e);
|
|
417
|
+
}
|
|
424
418
|
};
|
|
425
419
|
|
|
426
420
|
this.#audio.onerror = (e) => {
|
|
427
421
|
console.warn(`AudioEngine: audio error on "${this.#currentClip?.label}":`, e);
|
|
428
|
-
if (!this
|
|
422
|
+
if (!this.#isStarted) return;
|
|
429
423
|
this.#scheduleNext();
|
|
430
424
|
};
|
|
431
425
|
|
|
@@ -439,6 +433,12 @@ class AudioEngine {
|
|
|
439
433
|
|
|
440
434
|
// ── PUBLIC API ─────────────────────────────────────────────────────────────
|
|
441
435
|
|
|
436
|
+
/** @returns {boolean} True after first start() call. Read-only. */
|
|
437
|
+
get isStarted() { return this.#isStarted; }
|
|
438
|
+
|
|
439
|
+
/** @returns {boolean} True while a clip is actively playing. Read-only. */
|
|
440
|
+
get isPlaying() { return this.#isPlaying; }
|
|
441
|
+
|
|
442
442
|
/**
|
|
443
443
|
* Start the engine.
|
|
444
444
|
*
|
|
@@ -453,12 +453,17 @@ class AudioEngine {
|
|
|
453
453
|
* Safe to call multiple times — silently ignored if already started or if
|
|
454
454
|
* the unlock phase is still in progress (#isUnlocking), preventing a rapid
|
|
455
455
|
* sequence of start() calls from triggering duplicate #scheduleNext() calls
|
|
456
|
-
* before the first unlock promise resolves.
|
|
456
|
+
* before the first unlock promise resolves. No-op after destroy().
|
|
457
|
+
*
|
|
458
|
+
* Design note (D3): #isStarted is set true inside the unlock .then(), after
|
|
459
|
+
* the Audio element is confirmed usable. This aligns with OpenAudio_s.js,
|
|
460
|
+
* which also sets #isStarted after the unlock resolves (inside #playClip()).
|
|
461
|
+
* The guard against duplicate start() calls during the unlock window is
|
|
462
|
+
* handled by #isUnlocking, not #isStarted.
|
|
457
463
|
*/
|
|
458
464
|
start() {
|
|
459
|
-
if (this
|
|
465
|
+
if (this.#isDestroyed || this.#isStarted || this.#isUnlocking) return;
|
|
460
466
|
this.#isUnlocking = true;
|
|
461
|
-
this.isStarted = true;
|
|
462
467
|
|
|
463
468
|
// Play the silent clip synchronously within the gesture context.
|
|
464
469
|
// This "blesses" the Audio element on strict autoplay browsers (iOS Safari)
|
|
@@ -469,8 +474,10 @@ class AudioEngine {
|
|
|
469
474
|
this.#audio.play()
|
|
470
475
|
.then(() => {
|
|
471
476
|
this.#isUnlocking = false;
|
|
472
|
-
if (
|
|
473
|
-
//
|
|
477
|
+
if (this.#isDestroyed) return;
|
|
478
|
+
// Mark started only after the element is confirmed usable.
|
|
479
|
+
this.#isStarted = true;
|
|
480
|
+
// Restore volume and schedule the first real clip with a random delay.
|
|
474
481
|
this.#audio.volume = Math.min(1, Math.max(0, this.#volume));
|
|
475
482
|
this.#scheduleNext();
|
|
476
483
|
})
|
|
@@ -486,7 +493,7 @@ class AudioEngine {
|
|
|
486
493
|
);
|
|
487
494
|
this.stop();
|
|
488
495
|
}
|
|
489
|
-
// AbortError or other — safe to ignore; stop() cleans up if needed
|
|
496
|
+
// AbortError or other — safe to ignore; stop() cleans up if needed.
|
|
490
497
|
});
|
|
491
498
|
}
|
|
492
499
|
|
|
@@ -495,22 +502,26 @@ class AudioEngine {
|
|
|
495
502
|
* Cancels any pending timer and stops the current audio.
|
|
496
503
|
* Played flags are preserved — call start() again to begin the next
|
|
497
504
|
* clip in the cycle (the interrupted clip is not replayed).
|
|
505
|
+
* No-op after destroy().
|
|
498
506
|
*/
|
|
499
507
|
stop() {
|
|
508
|
+
if (this.#isDestroyed) return;
|
|
500
509
|
if (this.#timer) {
|
|
501
510
|
clearTimeout(this.#timer);
|
|
502
511
|
this.#timer = null;
|
|
503
512
|
}
|
|
504
|
-
this
|
|
505
|
-
this
|
|
513
|
+
this.#isStarted = false;
|
|
514
|
+
this.#isPlaying = false;
|
|
506
515
|
this.#audio.pause();
|
|
507
516
|
}
|
|
508
517
|
|
|
509
518
|
/**
|
|
510
519
|
* Stop the engine and reset all cycle state.
|
|
511
520
|
* All played flags cleared — next start() begins a completely fresh cycle.
|
|
521
|
+
* No-op after destroy().
|
|
512
522
|
*/
|
|
513
523
|
reset() {
|
|
524
|
+
if (this.#isDestroyed) return;
|
|
514
525
|
this.stop();
|
|
515
526
|
this.#clips.forEach(c => c.played = false);
|
|
516
527
|
}
|
|
@@ -519,12 +530,14 @@ class AudioEngine {
|
|
|
519
530
|
* Add a clip to the engine at runtime.
|
|
520
531
|
* The clip is inserted into the pool immediately with played = false,
|
|
521
532
|
* making it available for selection in the current or next cycle.
|
|
533
|
+
* No-op after destroy().
|
|
522
534
|
*
|
|
523
535
|
* @param {{ src: string, label?: string }} clip
|
|
524
536
|
*/
|
|
525
537
|
addClip(clip) {
|
|
538
|
+
if (this.#isDestroyed) return;
|
|
526
539
|
if (!clip || typeof clip.src !== 'string' || clip.src.trim() === '') {
|
|
527
|
-
throw new
|
|
540
|
+
throw new TypeError('AudioEngine.addClip: clip must have a non-empty string src property.');
|
|
528
541
|
}
|
|
529
542
|
this.#clips.push({
|
|
530
543
|
src: clip.src,
|
|
@@ -536,11 +549,13 @@ class AudioEngine {
|
|
|
536
549
|
/**
|
|
537
550
|
* Set the playback volume immediately.
|
|
538
551
|
* Applies to the currently playing clip as well as all future clips.
|
|
552
|
+
* No-op after destroy().
|
|
539
553
|
*
|
|
540
554
|
* @param {number} value Volume level from 0.0 (silent) to 1.0 (full).
|
|
541
555
|
* Values outside this range are clamped.
|
|
542
556
|
*/
|
|
543
557
|
setVolume(value) {
|
|
558
|
+
if (this.#isDestroyed) return;
|
|
544
559
|
this.#volume = Math.min(1, Math.max(0, value));
|
|
545
560
|
this.#audio.volume = this.#volume;
|
|
546
561
|
}
|
|
@@ -548,18 +563,34 @@ class AudioEngine {
|
|
|
548
563
|
/**
|
|
549
564
|
* Destroy the engine instance.
|
|
550
565
|
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
566
|
+
* Stops the engine, releases the Audio element per the WHATWG
|
|
567
|
+
* HTMLMediaElement resource-release spec (removeAttribute('src') + load()),
|
|
568
|
+
* and removes the visibilitychange event listener from document using the
|
|
569
|
+
* stored bound handler reference. Always call this when tearing down an
|
|
570
|
+
* AudioEngine in a Single Page Application (React, Vue, Angular, etc.) to
|
|
571
|
+
* prevent stale listeners accumulating on the document object across
|
|
572
|
+
* component mounts and unmounts.
|
|
556
573
|
*
|
|
557
|
-
*
|
|
558
|
-
* further method calls is undefined.
|
|
574
|
+
* All subsequent method calls after destroy() are safe no-ops.
|
|
559
575
|
*/
|
|
560
576
|
destroy() {
|
|
561
|
-
this
|
|
577
|
+
if (this.#isDestroyed) return;
|
|
578
|
+
this.#isDestroyed = true;
|
|
579
|
+
if (this.#timer) {
|
|
580
|
+
clearTimeout(this.#timer);
|
|
581
|
+
this.#timer = null;
|
|
582
|
+
}
|
|
583
|
+
this.#isStarted = false;
|
|
584
|
+
this.#isPlaying = false;
|
|
585
|
+
this.#audio.pause();
|
|
562
586
|
document.removeEventListener('visibilitychange', this.#boundVisibility);
|
|
587
|
+
// WHATWG-specified resource-release sequence: removeAttribute('src') +
|
|
588
|
+
// load() aborts any in-progress network fetch and resets the media element
|
|
589
|
+
// state machine cleanly, avoiding the spurious 'error' events that
|
|
590
|
+
// src = '' can fire on some browsers.
|
|
591
|
+
this.#audio.removeAttribute('src');
|
|
592
|
+
this.#audio.load();
|
|
593
|
+
this.#audio = null;
|
|
563
594
|
}
|
|
564
595
|
|
|
565
596
|
/**
|
|
@@ -602,7 +633,11 @@ class AudioEngine {
|
|
|
602
633
|
*/
|
|
603
634
|
#resetCycle() {
|
|
604
635
|
this.#clips.forEach(c => c.played = false);
|
|
605
|
-
try {
|
|
636
|
+
try {
|
|
637
|
+
if (this.#onCycleReset) this.#onCycleReset();
|
|
638
|
+
} catch (e) {
|
|
639
|
+
console.warn('AudioEngine: onCycleReset callback error:', e);
|
|
640
|
+
}
|
|
606
641
|
}
|
|
607
642
|
|
|
608
643
|
/**
|
|
@@ -619,18 +654,18 @@ class AudioEngine {
|
|
|
619
654
|
*/
|
|
620
655
|
#onVisibilityChange() {
|
|
621
656
|
if (document.visibilityState !== 'visible') return;
|
|
622
|
-
if (!this
|
|
657
|
+
if (!this.#isStarted || !this.#timer || this.#timerSetAt === null) return;
|
|
623
658
|
|
|
624
659
|
const elapsed = Date.now() - this.#timerSetAt;
|
|
625
660
|
if (elapsed >= this.#timerDelay) {
|
|
626
|
-
// Timer should already have fired — play immediately
|
|
661
|
+
// Timer should already have fired — play immediately.
|
|
627
662
|
clearTimeout(this.#timer);
|
|
628
663
|
this.#timer = null;
|
|
629
664
|
this.#timerSetAt = null;
|
|
630
665
|
this.#timerDelay = null;
|
|
631
666
|
this.#playNext();
|
|
632
667
|
} else {
|
|
633
|
-
// Reschedule precisely for the remaining duration
|
|
668
|
+
// Reschedule precisely for the remaining duration.
|
|
634
669
|
const remaining = this.#timerDelay - elapsed;
|
|
635
670
|
clearTimeout(this.#timer);
|
|
636
671
|
this.#timerSetAt = Date.now();
|
|
@@ -640,13 +675,12 @@ class AudioEngine {
|
|
|
640
675
|
}
|
|
641
676
|
|
|
642
677
|
/**
|
|
678
|
+
* Schedules the next clip after a random inter-clip delay in [lowTime, maxTime].
|
|
643
679
|
*
|
|
644
|
-
* Pre-
|
|
645
|
-
* buffering the
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
* beginning to play, particularly noticeable on slow connections or with
|
|
649
|
-
* large files.
|
|
680
|
+
* Pre-selects and pre-fetches the clip during the gap so the browser starts
|
|
681
|
+
* buffering the file immediately after the current clip ends, rather than
|
|
682
|
+
* waiting for the timer to fire. This eliminates network fetch latency that
|
|
683
|
+
* would otherwise be noticeable on slow connections or large files.
|
|
650
684
|
*
|
|
651
685
|
* #timerSetAt and #timerDelay are recorded so the visibilitychange listener
|
|
652
686
|
* can recalculate remaining time accurately after background tab throttling.
|
|
@@ -656,7 +690,7 @@ class AudioEngine {
|
|
|
656
690
|
clearTimeout(this.#timer);
|
|
657
691
|
this.#timer = null;
|
|
658
692
|
}
|
|
659
|
-
this
|
|
693
|
+
this.#isPlaying = false;
|
|
660
694
|
|
|
661
695
|
// Pre-select the next clip now, during the gap, so shuffle-bag state
|
|
662
696
|
// stays consistent regardless of how long the delay runs.
|
|
@@ -712,9 +746,13 @@ class AudioEngine {
|
|
|
712
746
|
* time this method fires, the browser has had the full delay period to
|
|
713
747
|
* buffer the file, eliminating the fetch latency that would occur if src
|
|
714
748
|
* were assigned here instead.
|
|
749
|
+
*
|
|
750
|
+
* #isPlaying is set true synchronously before #audio.play() to close the
|
|
751
|
+
* race window where isPlaying reported false while audio was starting.
|
|
752
|
+
* Reverted in .catch() on real failure.
|
|
715
753
|
*/
|
|
716
754
|
#playNext() {
|
|
717
|
-
if (!this
|
|
755
|
+
if (!this.#isStarted) return;
|
|
718
756
|
|
|
719
757
|
// Consume the pre-selected clip. If somehow null (e.g. called directly
|
|
720
758
|
// before #scheduleNext has run), fall back to selecting now.
|
|
@@ -722,7 +760,7 @@ class AudioEngine {
|
|
|
722
760
|
this.#nextClip = null;
|
|
723
761
|
this.#currentClip = clip;
|
|
724
762
|
|
|
725
|
-
// Restore volume — was zeroed during prefetch to suppress decode artefacts
|
|
763
|
+
// Restore volume — was zeroed during prefetch to suppress decode artefacts.
|
|
726
764
|
this.#audio.volume = Math.min(1, Math.max(0, this.#volume));
|
|
727
765
|
this.#audio.loop = false;
|
|
728
766
|
|
|
@@ -732,28 +770,37 @@ class AudioEngine {
|
|
|
732
770
|
this.#audio.src = clip.src;
|
|
733
771
|
}
|
|
734
772
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
773
|
+
// Set before the async boundary to close the isPlaying race window.
|
|
774
|
+
this.#isPlaying = true;
|
|
775
|
+
|
|
776
|
+
this.#audio.play()
|
|
777
|
+
.then(() => {
|
|
778
|
+
if (!this.#isStarted || this.#isDestroyed) {
|
|
779
|
+
this.#audio?.pause();
|
|
780
|
+
this.#isPlaying = false;
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
if (this.#onPlay) this.#onPlay(clip);
|
|
785
|
+
} catch (e) {
|
|
786
|
+
console.warn('AudioEngine: onPlay callback error:', e);
|
|
787
|
+
}
|
|
788
|
+
})
|
|
789
|
+
.catch(err => {
|
|
790
|
+
this.#isPlaying = false; // revert the optimistic flag on failure
|
|
791
|
+
if (err.name === 'AbortError' || !this.#isStarted) return;
|
|
792
|
+
|
|
793
|
+
if (err.name === 'NotAllowedError') {
|
|
794
|
+
console.warn(
|
|
795
|
+
`AudioEngine: play() blocked by autoplay policy for "${clip.label}". ` +
|
|
796
|
+
`Halting engine. Call start() again inside a user gesture to resume.`
|
|
797
|
+
);
|
|
798
|
+
this.stop();
|
|
799
|
+
} else {
|
|
800
|
+
console.warn(`AudioEngine: play() failed for "${clip.label}".\nError: ${err}`);
|
|
801
|
+
this.#scheduleNext();
|
|
802
|
+
}
|
|
803
|
+
});
|
|
757
804
|
}
|
|
758
805
|
}
|
|
759
806
|
|
|
@@ -848,7 +895,7 @@ if (!AudioEngine.canPlay('audio/ogg')) {
|
|
|
848
895
|
// React example:
|
|
849
896
|
// useEffect(() => {
|
|
850
897
|
// const engine = new AudioEngine(clips);
|
|
851
|
-
// return () => engine.destroy(); // removes
|
|
898
|
+
// return () => engine.destroy(); // removes listener, releases audio on unmount
|
|
852
899
|
// }, []);
|
|
853
900
|
|
|
854
901
|
// Vue example:
|