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/OpenAudio_r.js CHANGED
@@ -1,34 +1,32 @@
1
1
  /**
2
2
  * @file OpenAudio_r.js
3
3
  * @author Rexore
4
- * @version 2.4.0
5
- * @license GPL-3.0-or-later
4
+ * @version 2.6.0
5
+ * @license Apache-2.0
6
6
  *
7
- * audio_engine.js — A self-contained, randomised audio scheduling engine.
8
- * Copyright (C) 2025 Rexore
7
+ * OpenAudio_r.js — A self-contained, randomised audio scheduling engine.
8
+ * Copyright 2025 Rexore
9
9
  *
10
- * This program is free software: you can redistribute it and/or modify
11
- * it under the terms of the GNU General Public License as published by
12
- * the Free Software Foundation, either version 3 of the License, or
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
- * This program is distributed in the hope that it will be useful,
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
- * You should have received a copy of the GNU General Public License
21
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
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="audio_engine.js"></script>
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. Previously only isStarted was checked,
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. Use this to check browser
168
- * support for .ogg, .wav, or .flac sources before constructing an engine,
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: constructor and addClip() now verify that every clip
178
- * has a non-empty string src. Previously a clip with a missing or non-string
179
- * src property would silently map undefined into the engine, producing a
180
- * confusing audio error rather than a clear failure at the point of entry.
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 throughout.
200
- * External scripts can no longer accidentally read or mutate internal state,
201
- * and attempting to do so throws a SyntaxError at parse time.
202
- * - NotAllowedError handling: if the browser rejects play() due to an autoplay
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 now validates lowTime and maxTime: both must be non-negative
217
- * numbers and lowTime must not exceed maxTime. Inverted or negative values
218
- * previously caused silent setTimeout(0) looping with no warning.
219
- * - _scheduleNext() clears any existing _timer before setting a new one,
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 the constructor.
230
- * Satisfies strict mobile autoplay rules (iOS Safari) which require the
231
- * Audio object itself to be created inside the gesture activation context.
232
- * Previously a new Audio() was instantiated per clip inside _playNext(),
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
- * - Shuffle bag algorithm replacing naive Math.random() selection.
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 and remove all document-level event
301
- * listeners. Call this when tearing down the instance
302
- * in SPAs (React, Vue, etc.) to prevent listener
303
- * accumulation and memory leaks.
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 playback.
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 actively playing.
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 Error('AudioEngine: clips must be a non-empty array.');
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 Error(`AudioEngine: clips[${badClip}] is missing a valid src string.`);
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 Error('AudioEngine: lowTime must be a non-negative number.');
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 Error('AudioEngine: maxTime must be a non-negative number.');
383
+ throw new TypeError('AudioEngine: maxTime must be a non-negative number.');
395
384
  }
396
385
  if (this.#lowTime > this.#maxTime) {
397
- throw new Error(`AudioEngine: lowTime (${this.#lowTime}) must not exceed maxTime (${this.#maxTime}).`);
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.isStarted = false;
408
- this.isPlaying = false;
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.isStarted) return;
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 { if (this.#onEnd) this.#onEnd(this.#currentClip); } catch(e) { console.warn('AudioEngine: onEnd callback error:', e); }
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.isStarted) return;
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.isStarted || this.#isUnlocking) return;
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 (!this.isStarted) return;
473
- // Restore volume and schedule the first real clip with a random delay
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.isStarted = false;
505
- this.isPlaying = false;
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 Error('AudioEngine.addClip: clip must have a non-empty string src property.');
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
- * Calls stop() and removes the visibilitychange event listener from
552
- * document using the stored bound handler reference. Always call this
553
- * when tearing down an AudioEngine in a Single Page Application
554
- * (React, Vue, Angular, etc.) to prevent stale listeners accumulating
555
- * on the document object across component mounts and unmounts.
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
- * After destroy(), the instance should be discarded behaviour of any
558
- * further method calls is undefined.
574
+ * All subsequent method calls after destroy() are safe no-ops.
559
575
  */
560
576
  destroy() {
561
- this.stop();
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 { if (this.#onCycleReset) this.#onCycleReset(); } catch(e) { console.warn('AudioEngine: onCycleReset callback error:', e); }
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.isStarted || !this.#timer || this.#timerSetAt === null) return;
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-selecting and pre-fetching during the gap means the browser starts
645
- * buffering the next file immediately after the current one ends, rather
646
- * than waiting until the timer fires. This eliminates the network latency
647
- * that would otherwise appear between the timer firing and audio actually
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.isPlaying = false;
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.isStarted) return;
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
- this.#audio.play().then(() => {
736
- if (!this.isStarted) {
737
- this.#audio.pause();
738
- return;
739
- }
740
- this.isPlaying = true;
741
- try { if (this.#onPlay) this.#onPlay(clip); } catch(e) { console.warn('AudioEngine: onPlay callback error:', e); }
742
-
743
- }).catch(err => {
744
- if (err.name === 'AbortError' || !this.isStarted) return;
745
-
746
- if (err.name === 'NotAllowedError') {
747
- console.warn(
748
- `AudioEngine: play() blocked by autoplay policy for "${clip.label}". ` +
749
- `Halting engine. Call start() again inside a user gesture to resume.`
750
- );
751
- this.stop();
752
- } else {
753
- console.warn(`AudioEngine: play() failed for "${clip.label}".\nError: ${err}`);
754
- this.#scheduleNext();
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 visibilitychange listener on unmount
898
+ // return () => engine.destroy(); // removes listener, releases audio on unmount
852
899
  // }, []);
853
900
 
854
901
  // Vue example: