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_r.js
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file OpenAudio_r.js
|
|
3
|
+
* @author Rexore
|
|
4
|
+
* @version 2.4.0
|
|
5
|
+
* @license GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* audio_engine.js — A self-contained, randomised audio scheduling engine.
|
|
8
|
+
* Copyright (C) 2025 Rexore
|
|
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.
|
|
14
|
+
*
|
|
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.
|
|
19
|
+
*
|
|
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/>.
|
|
22
|
+
*
|
|
23
|
+
* Attach to any element or user action. No dependencies.
|
|
24
|
+
*
|
|
25
|
+
* ============================================================================
|
|
26
|
+
* QUICK START
|
|
27
|
+
* ============================================================================
|
|
28
|
+
*
|
|
29
|
+
* 1. Include the script:
|
|
30
|
+
*
|
|
31
|
+
* <script src="audio_engine.js"></script>
|
|
32
|
+
*
|
|
33
|
+
* 2. Create an engine instance with your clips:
|
|
34
|
+
*
|
|
35
|
+
* const engine = new AudioEngine([
|
|
36
|
+
* { src: 'audio/clip1.mp3', label: 'Clip One' },
|
|
37
|
+
* { src: 'audio/clip2.mp3', label: 'Clip Two' },
|
|
38
|
+
* { src: 'audio/clip3.mp3', label: 'Clip Three' }
|
|
39
|
+
* ]);
|
|
40
|
+
*
|
|
41
|
+
* 3. Attach to any element or action:
|
|
42
|
+
*
|
|
43
|
+
* // Button
|
|
44
|
+
* document.getElementById('my-btn').addEventListener('click', () => engine.start());
|
|
45
|
+
*
|
|
46
|
+
* // First click anywhere
|
|
47
|
+
* document.addEventListener('click', () => engine.start(), { once: true });
|
|
48
|
+
*
|
|
49
|
+
* // Keydown
|
|
50
|
+
* document.addEventListener('keydown', () => engine.start(), { once: true });
|
|
51
|
+
*
|
|
52
|
+
* // Custom app hook
|
|
53
|
+
* function onGameStart() { engine.start(); }
|
|
54
|
+
*
|
|
55
|
+
* ============================================================================
|
|
56
|
+
* BROWSER AUTOPLAY POLICY
|
|
57
|
+
* ============================================================================
|
|
58
|
+
*
|
|
59
|
+
* Browsers block audio.play() until the user performs a genuine gesture
|
|
60
|
+
* (click, keydown, touchstart). Scroll does NOT qualify in Chrome or Firefox.
|
|
61
|
+
*
|
|
62
|
+
* Rule: call engine.start() synchronously inside a user-initiated event
|
|
63
|
+
* handler on first call. After the first successful play(), all subsequent
|
|
64
|
+
* clips schedule themselves via setTimeout — no further interaction needed.
|
|
65
|
+
*
|
|
66
|
+
* start() unlocks the shared Audio element by playing a silent 1-second
|
|
67
|
+
* base64 MP3 synchronously within the gesture context. The first real clip
|
|
68
|
+
* then plays after a random delay in [lowTime, maxTime], just like all
|
|
69
|
+
* subsequent clips. This allows the engine to respect the initial delay
|
|
70
|
+
* setting without sacrificing mobile autoplay compatibility.
|
|
71
|
+
*
|
|
72
|
+
* ============================================================================
|
|
73
|
+
* BACKGROUND TAB THROTTLING
|
|
74
|
+
* ============================================================================
|
|
75
|
+
*
|
|
76
|
+
* Browsers aggressively throttle setTimeout when a tab loses focus. Chrome
|
|
77
|
+
* and Firefox typically cap background timers to fire no more than once per
|
|
78
|
+
* second; some power-saving modes on mobile may suspend them entirely.
|
|
79
|
+
*
|
|
80
|
+
* Impact on this engine: the inter-clip delays configured via lowTime and
|
|
81
|
+
* maxTime will stretch when the tab is hidden. Audio playback itself is not
|
|
82
|
+
* affected — a clip that has already started will continue at normal speed —
|
|
83
|
+
* but the gap before the *next* clip begins will be longer than configured.
|
|
84
|
+
*
|
|
85
|
+
* Mitigation: this engine listens to the Page Visibility API
|
|
86
|
+
* (document.visibilitychange). When the tab returns to the foreground and a
|
|
87
|
+
* timer is still pending, the remaining delay is recalculated against the
|
|
88
|
+
* wall-clock time elapsed since the timer was set. If the throttled timer has
|
|
89
|
+
* already overrun the intended delay, #playNext() fires immediately on
|
|
90
|
+
* visibility restore rather than waiting for the lagging setTimeout.
|
|
91
|
+
*
|
|
92
|
+
* This does not provide frame-perfect scheduling. For that, see the Web Audio
|
|
93
|
+
* API note below.
|
|
94
|
+
*
|
|
95
|
+
* ============================================================================
|
|
96
|
+
* WEB AUDIO API — WHEN TO GRADUATE FROM THIS ENGINE
|
|
97
|
+
* ============================================================================
|
|
98
|
+
*
|
|
99
|
+
* This engine is built on the HTML5 <audio> element, which is the right
|
|
100
|
+
* choice for the majority of ambient / randomised scheduling use cases:
|
|
101
|
+
* • No dependencies, minimal setup, broad compatibility.
|
|
102
|
+
* • Works on mobile without AudioContext unlock boilerplate.
|
|
103
|
+
* • Per-clip src swapping handles arbitrary file formats.
|
|
104
|
+
*
|
|
105
|
+
* However, the HTML5 <audio> element has hard ceilings that no wrapper code
|
|
106
|
+
* can fully overcome:
|
|
107
|
+
*
|
|
108
|
+
* 1. Scheduling is tied to setTimeout, which is subject to background
|
|
109
|
+
* throttling and the browser event loop — not a hardware clock.
|
|
110
|
+
* Sub-second precision is not reliable.
|
|
111
|
+
*
|
|
112
|
+
* 2. Crossfading between clips is not natively possible without a second
|
|
113
|
+
* Audio element, which reintroduces the iOS autoplay problem.
|
|
114
|
+
*
|
|
115
|
+
* 3. A tab suspended entirely by aggressive mobile power-saving will see
|
|
116
|
+
* gaps that the visibility recalculation above cannot recover from.
|
|
117
|
+
*
|
|
118
|
+
* Consider the Web Audio API (AudioContext) when:
|
|
119
|
+
* • You need crossfading or seamless looping between clips.
|
|
120
|
+
* • Timing requirements are sub-second and must be precise.
|
|
121
|
+
* • Background-tab immunity is critical (AudioContext.currentTime runs
|
|
122
|
+
* against the audio hardware clock, unaffected by tab visibility).
|
|
123
|
+
* • You need runtime DSP effects (reverb, EQ, compression).
|
|
124
|
+
*
|
|
125
|
+
* The Web Audio API requires AudioContext.resume() inside a user gesture and
|
|
126
|
+
* more complex buffer management. It is a significant architectural change,
|
|
127
|
+
* not a drop-in upgrade from this engine.
|
|
128
|
+
*
|
|
129
|
+
* ============================================================================
|
|
130
|
+
* SHUFFLE BAG ALGORITHM
|
|
131
|
+
* ============================================================================
|
|
132
|
+
*
|
|
133
|
+
* Clips are drawn from an unplayed pool rather than selected from the full
|
|
134
|
+
* array each time. This guarantees every clip plays exactly once per cycle
|
|
135
|
+
* before any clip repeats — solving the birthday problem that naive
|
|
136
|
+
* Math.random() selection causes with small collections.
|
|
137
|
+
*
|
|
138
|
+
* Each clip carries a `played` boolean. On each selection:
|
|
139
|
+
* 1. Filter clips to the unplayed pool.
|
|
140
|
+
* 2. If pool is empty, reset all played flags (lazy cycle reset).
|
|
141
|
+
* 3. Pick uniformly at random from the pool.
|
|
142
|
+
* 4. Mark the selected clip played = true immediately.
|
|
143
|
+
* 5. Play it. On 'ended', schedule the next clip after a random interval.
|
|
144
|
+
*
|
|
145
|
+
* The reset is lazy — it happens at selection time, not at the end of the
|
|
146
|
+
* last clip. This means there is no gap between cycles and the engine keeps
|
|
147
|
+
* running seamlessly.
|
|
148
|
+
*
|
|
149
|
+
* ============================================================================
|
|
150
|
+
* CHANGELOG
|
|
151
|
+
* ============================================================================
|
|
152
|
+
*
|
|
153
|
+
* 2.4.0
|
|
154
|
+
* - #isUnlocking flag: start() now sets a private boolean during the silent
|
|
155
|
+
* MP3 unlock phase. Rapid repeated calls to start() before the unlock
|
|
156
|
+
* 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.
|
|
160
|
+
* - destroy() method: removes the visibilitychange listener added in the
|
|
161
|
+
* 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
|
+
* - 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.
|
|
173
|
+
* - Documentation: added HTML5 AUDIO vs. WEB AUDIO API comparison table and
|
|
174
|
+
* CALLBACK RESILIENCE section.
|
|
175
|
+
*
|
|
176
|
+
* 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.
|
|
197
|
+
*
|
|
198
|
+
* 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.
|
|
214
|
+
*
|
|
215
|
+
* 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.
|
|
227
|
+
*
|
|
228
|
+
* 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.
|
|
242
|
+
*
|
|
243
|
+
* 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.
|
|
248
|
+
*
|
|
249
|
+
* ============================================================================
|
|
250
|
+
* HTML5 AUDIO vs. WEB AUDIO API — QUICK REFERENCE
|
|
251
|
+
* ============================================================================
|
|
252
|
+
*
|
|
253
|
+
* Feature HTML5 Audio (this engine) Web Audio API (AudioContext)
|
|
254
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
255
|
+
* Scheduling precision Event-loop (~10–50ms jitter) Hardware clock (sample-accurate)
|
|
256
|
+
* Crossfading Not possible (1 element) Native / straightforward
|
|
257
|
+
* Background tab setTimeout throttled Unaffected (hardware clock)
|
|
258
|
+
* Setup complexity Very low — drop-in High (buffer management)
|
|
259
|
+
* Mobile autoplay Solved via element blessing Requires AudioContext.resume()
|
|
260
|
+
* Runtime DSP effects None Reverb, EQ, compression, etc.
|
|
261
|
+
*
|
|
262
|
+
* See the WEB AUDIO API section above for migration guidance.
|
|
263
|
+
*
|
|
264
|
+
* ============================================================================
|
|
265
|
+
* CALLBACK RESILIENCE
|
|
266
|
+
* ============================================================================
|
|
267
|
+
*
|
|
268
|
+
* onPlay, onEnd, and onCycleReset are all wrapped in try/catch. A throwing
|
|
269
|
+
* user callback will log a console warning but will never stall the engine's
|
|
270
|
+
* internal scheduling loop. This means a bug in your UI update code (e.g. a
|
|
271
|
+
* reference error in onPlay) will not silence the engine — it will keep
|
|
272
|
+
* running and log a warning so the error is still visible and fixable.
|
|
273
|
+
*
|
|
274
|
+
* ============================================================================
|
|
275
|
+
* CONFIGURATION OPTIONS
|
|
276
|
+
* ============================================================================
|
|
277
|
+
*
|
|
278
|
+
* lowTime {number} Min seconds between clips default: 1
|
|
279
|
+
* maxTime {number} Max seconds between clips default: 10
|
|
280
|
+
* volume {number} Playback volume 0.0–1.0 default: 0.85
|
|
281
|
+
* onPlay {Function} Called when a clip starts default: null
|
|
282
|
+
* onEnd {Function} Called when a clip ends default: null
|
|
283
|
+
* onCycleReset {Function} Called when all clips have played and cycle resets
|
|
284
|
+
*
|
|
285
|
+
* ============================================================================
|
|
286
|
+
* PUBLIC API
|
|
287
|
+
* ============================================================================
|
|
288
|
+
*
|
|
289
|
+
* engine.start() Start the engine. Must be inside a gesture handler.
|
|
290
|
+
* Safe to call multiple times — ignored if running.
|
|
291
|
+
* Ignores rapid repeat calls during the unlock phase
|
|
292
|
+
* via an internal #isUnlocking flag.
|
|
293
|
+
*
|
|
294
|
+
* engine.stop() Stop engine. Cancels timer, pauses audio.
|
|
295
|
+
* Preserves cycle state — start() resumes it.
|
|
296
|
+
*
|
|
297
|
+
* engine.reset() Stop and clear all played flags.
|
|
298
|
+
* Next start() begins a fresh random cycle.
|
|
299
|
+
*
|
|
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.
|
|
304
|
+
*
|
|
305
|
+
* engine.addClip(clip) Add a clip at runtime. Enters the pool immediately.
|
|
306
|
+
* Throws if clip.src is missing or not a string.
|
|
307
|
+
*
|
|
308
|
+
* engine.setVolume(value) Set volume (0.0–1.0) immediately, affects live playback.
|
|
309
|
+
*
|
|
310
|
+
* engine.isStarted {boolean} True after first start() call.
|
|
311
|
+
* engine.isPlaying {boolean} True while a clip is actively playing.
|
|
312
|
+
*
|
|
313
|
+
* ── STATIC UTILITY ──────────────────────────────────────────────────────────
|
|
314
|
+
*
|
|
315
|
+
* AudioEngine.canPlay(type) Returns true if the browser can likely play the
|
|
316
|
+
* given MIME type (e.g. 'audio/ogg', 'audio/wav').
|
|
317
|
+
* Wraps HTMLAudioElement.canPlayType() — returns
|
|
318
|
+
* false for an empty string or 'no' response.
|
|
319
|
+
* Use this before constructing an engine with
|
|
320
|
+
* non-MP3 sources to check format support.
|
|
321
|
+
*
|
|
322
|
+
* ============================================================================
|
|
323
|
+
*/
|
|
324
|
+
|
|
325
|
+
// Silent 1-second MP3 encoded as base64.
|
|
326
|
+
// Used by start() to unlock the shared Audio element within the gesture
|
|
327
|
+
// context before the first real clip is scheduled via setTimeout.
|
|
328
|
+
const SILENT_MP3 =
|
|
329
|
+
'data:audio/mpeg;base64,SUQzBAAAAAABEVRYWFgAAAAtAAADY29tbWVudABCaWdTb3VuZEJhbmsgU291bmQgRWZmZWN0cyBMaWJyYXJ5Ly8v' +
|
|
330
|
+
'VFNTTQAAAAALAAADAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAA' +
|
|
331
|
+
'AP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP//' +
|
|
332
|
+
'//8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8AAAAAAAAAAP////8A' +
|
|
333
|
+
'AAAAAAAAAP////8=';
|
|
334
|
+
|
|
335
|
+
class AudioEngine {
|
|
336
|
+
|
|
337
|
+
// ── True private fields ────────────────────────────────────────────────────
|
|
338
|
+
// Declared here; JavaScript enforces access at the class boundary.
|
|
339
|
+
// Attempting to read or write these from outside throws a SyntaxError.
|
|
340
|
+
#clips;
|
|
341
|
+
#lowTime;
|
|
342
|
+
#maxTime;
|
|
343
|
+
#volume;
|
|
344
|
+
#onPlay;
|
|
345
|
+
#onEnd;
|
|
346
|
+
#onCycleReset;
|
|
347
|
+
#timer;
|
|
348
|
+
#timerSetAt; // wall-clock ms at which the current inter-clip delay was set
|
|
349
|
+
#timerDelay; // intended delay duration in ms (for visibility recalculation)
|
|
350
|
+
#audio;
|
|
351
|
+
#currentClip;
|
|
352
|
+
#nextClip; // pre-selected clip loaded into #audio during the inter-clip gap
|
|
353
|
+
#isUnlocking; // true while the silent-MP3 unlock play() promise is pending
|
|
354
|
+
#boundVisibility; // bound handler reference stored for removeEventListener in destroy()
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* @param {Array<{ src: string, label?: string }>} clips
|
|
358
|
+
* Array of clip objects. `src` is required. `label` is optional and used
|
|
359
|
+
* in callbacks and console warnings.
|
|
360
|
+
*
|
|
361
|
+
* @param {Object} [options={}]
|
|
362
|
+
* @param {number} [options.lowTime=1]
|
|
363
|
+
* @param {number} [options.maxTime=10]
|
|
364
|
+
* @param {number} [options.volume=0.85]
|
|
365
|
+
* @param {Function} [options.onPlay]
|
|
366
|
+
* @param {Function} [options.onEnd]
|
|
367
|
+
* @param {Function} [options.onCycleReset]
|
|
368
|
+
*/
|
|
369
|
+
constructor(clips = [], options = {}) {
|
|
370
|
+
if (!Array.isArray(clips) || clips.length === 0) {
|
|
371
|
+
throw new Error('AudioEngine: clips must be a non-empty array.');
|
|
372
|
+
}
|
|
373
|
+
// Validate every clip has a non-empty string src before doing anything else
|
|
374
|
+
const badClip = clips.findIndex(c => !c || typeof c.src !== 'string' || c.src.trim() === '');
|
|
375
|
+
if (badClip !== -1) {
|
|
376
|
+
throw new Error(`AudioEngine: clips[${badClip}] is missing a valid src string.`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Defensive copy — never mutate the caller's original array
|
|
380
|
+
this.#clips = clips.map(c => ({
|
|
381
|
+
src: c.src,
|
|
382
|
+
label: c.label || c.src,
|
|
383
|
+
played: false
|
|
384
|
+
}));
|
|
385
|
+
|
|
386
|
+
this.#lowTime = options.lowTime ?? 1;
|
|
387
|
+
this.#maxTime = options.maxTime ?? 10;
|
|
388
|
+
this.#volume = options.volume ?? 0.85;
|
|
389
|
+
|
|
390
|
+
if (typeof this.#lowTime !== 'number' || this.#lowTime < 0) {
|
|
391
|
+
throw new Error('AudioEngine: lowTime must be a non-negative number.');
|
|
392
|
+
}
|
|
393
|
+
if (typeof this.#maxTime !== 'number' || this.#maxTime < 0) {
|
|
394
|
+
throw new Error('AudioEngine: maxTime must be a non-negative number.');
|
|
395
|
+
}
|
|
396
|
+
if (this.#lowTime > this.#maxTime) {
|
|
397
|
+
throw new Error(`AudioEngine: lowTime (${this.#lowTime}) must not exceed maxTime (${this.#maxTime}).`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
this.#onPlay = options.onPlay || null;
|
|
401
|
+
this.#onEnd = options.onEnd || null;
|
|
402
|
+
this.#onCycleReset = options.onCycleReset || null;
|
|
403
|
+
|
|
404
|
+
this.#timer = null;
|
|
405
|
+
this.#timerSetAt = null;
|
|
406
|
+
this.#timerDelay = null;
|
|
407
|
+
this.isStarted = false;
|
|
408
|
+
this.isPlaying = false;
|
|
409
|
+
this.#isUnlocking = false;
|
|
410
|
+
|
|
411
|
+
// Single reusable Audio element — created once here so that mobile browsers
|
|
412
|
+
// (iOS Safari) keep it within the gesture activation context for all
|
|
413
|
+
// subsequent play() calls, satisfying strict autoplay policies.
|
|
414
|
+
this.#audio = new Audio();
|
|
415
|
+
this.#currentClip = null;
|
|
416
|
+
this.#nextClip = null;
|
|
417
|
+
|
|
418
|
+
// Listeners attached once to avoid duplicates and memory leaks
|
|
419
|
+
this.#audio.onended = () => {
|
|
420
|
+
if (!this.isStarted) return;
|
|
421
|
+
// Schedule next BEFORE firing callback — a throwing onEnd can never stall the engine
|
|
422
|
+
this.#scheduleNext();
|
|
423
|
+
try { if (this.#onEnd) this.#onEnd(this.#currentClip); } catch(e) { console.warn('AudioEngine: onEnd callback error:', e); }
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
this.#audio.onerror = (e) => {
|
|
427
|
+
console.warn(`AudioEngine: audio error on "${this.#currentClip?.label}":`, e);
|
|
428
|
+
if (!this.isStarted) return;
|
|
429
|
+
this.#scheduleNext();
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// Store the bound handler so destroy() can pass the exact same reference
|
|
433
|
+
// to removeEventListener — an inline arrow would not match on removal.
|
|
434
|
+
this.#boundVisibility = this.#onVisibilityChange.bind(this);
|
|
435
|
+
|
|
436
|
+
// Background tab throttling mitigation — see #onVisibilityChange below.
|
|
437
|
+
document.addEventListener('visibilitychange', this.#boundVisibility);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── PUBLIC API ─────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Start the engine.
|
|
444
|
+
*
|
|
445
|
+
* Call this synchronously inside a user gesture event handler on first use.
|
|
446
|
+
* This satisfies the browser autoplay policy.
|
|
447
|
+
*
|
|
448
|
+
* A silent MP3 is played immediately to unlock the Audio element within the
|
|
449
|
+
* gesture context. The first real clip is then scheduled via #scheduleNext(),
|
|
450
|
+
* respecting the configured lowTime/maxTime delay — exactly like all
|
|
451
|
+
* subsequent clips.
|
|
452
|
+
*
|
|
453
|
+
* Safe to call multiple times — silently ignored if already started or if
|
|
454
|
+
* the unlock phase is still in progress (#isUnlocking), preventing a rapid
|
|
455
|
+
* sequence of start() calls from triggering duplicate #scheduleNext() calls
|
|
456
|
+
* before the first unlock promise resolves.
|
|
457
|
+
*/
|
|
458
|
+
start() {
|
|
459
|
+
if (this.isStarted || this.#isUnlocking) return;
|
|
460
|
+
this.#isUnlocking = true;
|
|
461
|
+
this.isStarted = true;
|
|
462
|
+
|
|
463
|
+
// Play the silent clip synchronously within the gesture context.
|
|
464
|
+
// This "blesses" the Audio element on strict autoplay browsers (iOS Safari)
|
|
465
|
+
// without making the user hear anything. After this resolves, all
|
|
466
|
+
// subsequent play() calls on the same element are permitted.
|
|
467
|
+
this.#audio.src = SILENT_MP3;
|
|
468
|
+
this.#audio.volume = 0;
|
|
469
|
+
this.#audio.play()
|
|
470
|
+
.then(() => {
|
|
471
|
+
this.#isUnlocking = false;
|
|
472
|
+
if (!this.isStarted) return;
|
|
473
|
+
// Restore volume and schedule the first real clip with a random delay
|
|
474
|
+
this.#audio.volume = Math.min(1, Math.max(0, this.#volume));
|
|
475
|
+
this.#scheduleNext();
|
|
476
|
+
})
|
|
477
|
+
.catch(err => {
|
|
478
|
+
this.#isUnlocking = false;
|
|
479
|
+
if (err.name === 'NotAllowedError') {
|
|
480
|
+
// Autoplay is blocked even for the silent unlock clip — the gesture
|
|
481
|
+
// context was not valid. Halt cleanly rather than looping silently.
|
|
482
|
+
console.warn(
|
|
483
|
+
'AudioEngine: autoplay blocked during unlock. ' +
|
|
484
|
+
'start() must be called synchronously inside a user gesture handler ' +
|
|
485
|
+
'(click / keydown / touchstart).'
|
|
486
|
+
);
|
|
487
|
+
this.stop();
|
|
488
|
+
}
|
|
489
|
+
// AbortError or other — safe to ignore; stop() cleans up if needed
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Stop the engine.
|
|
495
|
+
* Cancels any pending timer and stops the current audio.
|
|
496
|
+
* Played flags are preserved — call start() again to begin the next
|
|
497
|
+
* clip in the cycle (the interrupted clip is not replayed).
|
|
498
|
+
*/
|
|
499
|
+
stop() {
|
|
500
|
+
if (this.#timer) {
|
|
501
|
+
clearTimeout(this.#timer);
|
|
502
|
+
this.#timer = null;
|
|
503
|
+
}
|
|
504
|
+
this.isStarted = false;
|
|
505
|
+
this.isPlaying = false;
|
|
506
|
+
this.#audio.pause();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Stop the engine and reset all cycle state.
|
|
511
|
+
* All played flags cleared — next start() begins a completely fresh cycle.
|
|
512
|
+
*/
|
|
513
|
+
reset() {
|
|
514
|
+
this.stop();
|
|
515
|
+
this.#clips.forEach(c => c.played = false);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Add a clip to the engine at runtime.
|
|
520
|
+
* The clip is inserted into the pool immediately with played = false,
|
|
521
|
+
* making it available for selection in the current or next cycle.
|
|
522
|
+
*
|
|
523
|
+
* @param {{ src: string, label?: string }} clip
|
|
524
|
+
*/
|
|
525
|
+
addClip(clip) {
|
|
526
|
+
if (!clip || typeof clip.src !== 'string' || clip.src.trim() === '') {
|
|
527
|
+
throw new Error('AudioEngine.addClip: clip must have a non-empty string src property.');
|
|
528
|
+
}
|
|
529
|
+
this.#clips.push({
|
|
530
|
+
src: clip.src,
|
|
531
|
+
label: clip.label || clip.src,
|
|
532
|
+
played: false
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Set the playback volume immediately.
|
|
538
|
+
* Applies to the currently playing clip as well as all future clips.
|
|
539
|
+
*
|
|
540
|
+
* @param {number} value Volume level from 0.0 (silent) to 1.0 (full).
|
|
541
|
+
* Values outside this range are clamped.
|
|
542
|
+
*/
|
|
543
|
+
setVolume(value) {
|
|
544
|
+
this.#volume = Math.min(1, Math.max(0, value));
|
|
545
|
+
this.#audio.volume = this.#volume;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Destroy the engine instance.
|
|
550
|
+
*
|
|
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.
|
|
556
|
+
*
|
|
557
|
+
* After destroy(), the instance should be discarded — behaviour of any
|
|
558
|
+
* further method calls is undefined.
|
|
559
|
+
*/
|
|
560
|
+
destroy() {
|
|
561
|
+
this.stop();
|
|
562
|
+
document.removeEventListener('visibilitychange', this.#boundVisibility);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Check whether the browser can likely play a given audio MIME type.
|
|
567
|
+
* Wraps HTMLAudioElement.canPlayType() with a clean boolean return.
|
|
568
|
+
*
|
|
569
|
+
* canPlayType() returns 'probably', 'maybe', or '' (unsupported).
|
|
570
|
+
* This method returns true for 'probably' or 'maybe', and false for ''.
|
|
571
|
+
* Note: 'maybe' means the browser recognises the format but cannot
|
|
572
|
+
* guarantee support without attempting to load a real file.
|
|
573
|
+
*
|
|
574
|
+
* Use this before constructing an engine with non-MP3 sources:
|
|
575
|
+
*
|
|
576
|
+
* if (!AudioEngine.canPlay('audio/ogg')) {
|
|
577
|
+
* console.warn('OGG not supported — falling back to MP3 sources');
|
|
578
|
+
* }
|
|
579
|
+
*
|
|
580
|
+
* @param {string} type MIME type string, e.g. 'audio/ogg', 'audio/wav'
|
|
581
|
+
* @returns {boolean}
|
|
582
|
+
*/
|
|
583
|
+
static canPlay(type) {
|
|
584
|
+
if (typeof type !== 'string' || type.trim() === '') return false;
|
|
585
|
+
const probe = new Audio();
|
|
586
|
+
return probe.canPlayType(type) !== '';
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── PRIVATE ────────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Returns all clips not yet played in the current cycle.
|
|
593
|
+
* @returns {Array}
|
|
594
|
+
*/
|
|
595
|
+
#getUnplayed() {
|
|
596
|
+
return this.#clips.filter(c => !c.played);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Resets all clips to unplayed, beginning a new cycle.
|
|
601
|
+
* Called lazily at selection time when the unplayed pool is empty.
|
|
602
|
+
*/
|
|
603
|
+
#resetCycle() {
|
|
604
|
+
this.#clips.forEach(c => c.played = false);
|
|
605
|
+
try { if (this.#onCycleReset) this.#onCycleReset(); } catch(e) { console.warn('AudioEngine: onCycleReset callback error:', e); }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Handles visibilitychange events to mitigate background tab throttling.
|
|
610
|
+
*
|
|
611
|
+
* When the tab returns to the foreground, checks whether the pending inter-
|
|
612
|
+
* clip timer has already overrun its intended delay (common when browsers
|
|
613
|
+
* cap background setTimeout to ~1Hz). If the delay has been met or exceeded,
|
|
614
|
+
* #playNext() fires immediately. Otherwise, a precise reschedule is set for
|
|
615
|
+
* the remaining duration.
|
|
616
|
+
*
|
|
617
|
+
* Stored as a bound reference in #boundVisibility so that destroy() can
|
|
618
|
+
* remove this exact listener from document.
|
|
619
|
+
*/
|
|
620
|
+
#onVisibilityChange() {
|
|
621
|
+
if (document.visibilityState !== 'visible') return;
|
|
622
|
+
if (!this.isStarted || !this.#timer || this.#timerSetAt === null) return;
|
|
623
|
+
|
|
624
|
+
const elapsed = Date.now() - this.#timerSetAt;
|
|
625
|
+
if (elapsed >= this.#timerDelay) {
|
|
626
|
+
// Timer should already have fired — play immediately
|
|
627
|
+
clearTimeout(this.#timer);
|
|
628
|
+
this.#timer = null;
|
|
629
|
+
this.#timerSetAt = null;
|
|
630
|
+
this.#timerDelay = null;
|
|
631
|
+
this.#playNext();
|
|
632
|
+
} else {
|
|
633
|
+
// Reschedule precisely for the remaining duration
|
|
634
|
+
const remaining = this.#timerDelay - elapsed;
|
|
635
|
+
clearTimeout(this.#timer);
|
|
636
|
+
this.#timerSetAt = Date.now();
|
|
637
|
+
this.#timerDelay = remaining;
|
|
638
|
+
this.#timer = setTimeout(() => this.#playNext(), remaining);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
*
|
|
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.
|
|
650
|
+
*
|
|
651
|
+
* #timerSetAt and #timerDelay are recorded so the visibilitychange listener
|
|
652
|
+
* can recalculate remaining time accurately after background tab throttling.
|
|
653
|
+
*/
|
|
654
|
+
#scheduleNext() {
|
|
655
|
+
if (this.#timer) {
|
|
656
|
+
clearTimeout(this.#timer);
|
|
657
|
+
this.#timer = null;
|
|
658
|
+
}
|
|
659
|
+
this.isPlaying = false;
|
|
660
|
+
|
|
661
|
+
// Pre-select the next clip now, during the gap, so shuffle-bag state
|
|
662
|
+
// stays consistent regardless of how long the delay runs.
|
|
663
|
+
this.#nextClip = this.#selectNext();
|
|
664
|
+
|
|
665
|
+
// Begin buffering the next clip immediately while we wait.
|
|
666
|
+
// Volume is set to 0 so any brief decode artefact is inaudible.
|
|
667
|
+
if (this.#nextClip) {
|
|
668
|
+
this.#audio.src = this.#nextClip.src;
|
|
669
|
+
this.#audio.volume = 0;
|
|
670
|
+
this.#audio.load();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const delay = (this.#lowTime + Math.random() * (this.#maxTime - this.#lowTime)) * 1000;
|
|
674
|
+
this.#timerSetAt = Date.now();
|
|
675
|
+
this.#timerDelay = delay;
|
|
676
|
+
this.#timer = setTimeout(() => {
|
|
677
|
+
this.#timerSetAt = null;
|
|
678
|
+
this.#timerDelay = null;
|
|
679
|
+
this.#playNext();
|
|
680
|
+
}, delay);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Selects and marks the next clip from the shuffle bag, handling lazy cycle
|
|
685
|
+
* reset and cycle-boundary repeat prevention.
|
|
686
|
+
*
|
|
687
|
+
* Extracted from #playNext() so that #scheduleNext() can pre-select during
|
|
688
|
+
* the inter-clip gap for prefetching purposes.
|
|
689
|
+
*
|
|
690
|
+
* @returns {{ src: string, label: string, played: boolean }}
|
|
691
|
+
*/
|
|
692
|
+
#selectNext() {
|
|
693
|
+
let pool = this.#getUnplayed();
|
|
694
|
+
|
|
695
|
+
if (pool.length === 0) {
|
|
696
|
+
this.#resetCycle();
|
|
697
|
+
pool = this.#clips.length > 1 && this.#currentClip
|
|
698
|
+
? this.#clips.filter(c => c !== this.#currentClip)
|
|
699
|
+
: this.#clips.slice();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const clip = pool[Math.floor(Math.random() * pool.length)];
|
|
703
|
+
clip.played = true;
|
|
704
|
+
return clip;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Core engine step: play the pre-selected and pre-fetched clip, then
|
|
709
|
+
* schedule the next one.
|
|
710
|
+
*
|
|
711
|
+
* #nextClip is set by #scheduleNext() during the inter-clip gap. By the
|
|
712
|
+
* time this method fires, the browser has had the full delay period to
|
|
713
|
+
* buffer the file, eliminating the fetch latency that would occur if src
|
|
714
|
+
* were assigned here instead.
|
|
715
|
+
*/
|
|
716
|
+
#playNext() {
|
|
717
|
+
if (!this.isStarted) return;
|
|
718
|
+
|
|
719
|
+
// Consume the pre-selected clip. If somehow null (e.g. called directly
|
|
720
|
+
// before #scheduleNext has run), fall back to selecting now.
|
|
721
|
+
const clip = this.#nextClip ?? this.#selectNext();
|
|
722
|
+
this.#nextClip = null;
|
|
723
|
+
this.#currentClip = clip;
|
|
724
|
+
|
|
725
|
+
// Restore volume — was zeroed during prefetch to suppress decode artefacts
|
|
726
|
+
this.#audio.volume = Math.min(1, Math.max(0, this.#volume));
|
|
727
|
+
this.#audio.loop = false;
|
|
728
|
+
|
|
729
|
+
// src is already set and buffering from #scheduleNext; just play.
|
|
730
|
+
// If #nextClip was null and we selected now, set src before playing.
|
|
731
|
+
if (this.#audio.src !== clip.src) {
|
|
732
|
+
this.#audio.src = clip.src;
|
|
733
|
+
}
|
|
734
|
+
|
|
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
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
// ── USAGE EXAMPLES ────────────────────────────────────────────────────────────
|
|
762
|
+
|
|
763
|
+
/*
|
|
764
|
+
|
|
765
|
+
// ── Minimal setup ─────────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
const engine = new AudioEngine([
|
|
768
|
+
{ src: 'audio/clip1.mp3', label: 'Clip One' },
|
|
769
|
+
{ src: 'audio/clip2.mp3', label: 'Clip Two' },
|
|
770
|
+
{ src: 'audio/clip3.mp3', label: 'Clip Three' }
|
|
771
|
+
]);
|
|
772
|
+
|
|
773
|
+
document.getElementById('start-btn').addEventListener('click', () => engine.start());
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
// ── With full options ─────────────────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
const engine = new AudioEngine(clips, {
|
|
779
|
+
lowTime: 1,
|
|
780
|
+
maxTime: 10,
|
|
781
|
+
volume: 0.85,
|
|
782
|
+
onPlay: clip => console.log('Now playing:', clip.label),
|
|
783
|
+
onEnd: clip => console.log('Finished:', clip.label),
|
|
784
|
+
onCycleReset: () => console.log('Cycle reset — all clips played'),
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
// ── Attach to any element ─────────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
// Any button
|
|
791
|
+
document.getElementById('my-btn').addEventListener('click', () => engine.start());
|
|
792
|
+
|
|
793
|
+
// First click anywhere on the page
|
|
794
|
+
document.addEventListener('click', () => engine.start(), { once: true });
|
|
795
|
+
|
|
796
|
+
// First keydown
|
|
797
|
+
document.addEventListener('keydown', () => engine.start(), { once: true });
|
|
798
|
+
|
|
799
|
+
// First touchstart (mobile)
|
|
800
|
+
document.addEventListener('touchstart', () => engine.start(), { once: true });
|
|
801
|
+
|
|
802
|
+
// Custom app event
|
|
803
|
+
function onGameStart() { engine.start(); }
|
|
804
|
+
function onLevelLoad() { engine.start(); }
|
|
805
|
+
function onModalOpen() { engine.start(); }
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
// ── Stop and resume ───────────────────────────────────────────────────────────
|
|
809
|
+
|
|
810
|
+
engine.stop(); // pauses, preserves cycle position
|
|
811
|
+
engine.start(); // resumes — must still be inside a gesture handler
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
// ── Hard reset ────────────────────────────────────────────────────────────────
|
|
815
|
+
|
|
816
|
+
engine.reset(); // stops and clears all played flags
|
|
817
|
+
engine.start(); // starts a completely fresh random cycle
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
// ── Volume control ────────────────────────────────────────────────────────────
|
|
821
|
+
|
|
822
|
+
engine.setVolume(0.5); // immediately updates live playback and future clips
|
|
823
|
+
engine.setVolume(0.0); // effectively mutes without stopping the engine
|
|
824
|
+
engine.setVolume(1.0); // full volume
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
// ── Add clips at runtime ──────────────────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
engine.addClip({ src: 'audio/bonus.mp3', label: 'Bonus Clip' });
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
// ── Embedded base64 (no external files needed) ───────────────────────────────
|
|
833
|
+
|
|
834
|
+
const engine = new AudioEngine([
|
|
835
|
+
{ src: 'data:audio/mp3;base64,SUQzBA...', label: 'Embedded Clip' }
|
|
836
|
+
]);
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
// ── Check format support before constructing ──────────────────────────────────
|
|
840
|
+
|
|
841
|
+
if (!AudioEngine.canPlay('audio/ogg')) {
|
|
842
|
+
console.warn('OGG not supported — use MP3 sources instead');
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
// ── SPA teardown (React, Vue, etc.) ──────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
// React example:
|
|
849
|
+
// useEffect(() => {
|
|
850
|
+
// const engine = new AudioEngine(clips);
|
|
851
|
+
// return () => engine.destroy(); // removes visibilitychange listener on unmount
|
|
852
|
+
// }, []);
|
|
853
|
+
|
|
854
|
+
// Vue example:
|
|
855
|
+
// onUnmounted(() => engine.destroy());
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
// ── Check engine state ────────────────────────────────────────────────────────
|
|
859
|
+
|
|
860
|
+
console.log(engine.isStarted); // true after first start() call
|
|
861
|
+
console.log(engine.isPlaying); // true while a clip is actively playing
|
|
862
|
+
|
|
863
|
+
*/
|