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.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file OpenAudio.js
|
|
3
|
+
* @author Rexore
|
|
4
|
+
* @version 1.1.0
|
|
5
|
+
* @license GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* Plays one audio file once, triggered by any user event.
|
|
8
|
+
*
|
|
9
|
+
* Key behaviours:
|
|
10
|
+
* - Silent MP3 unlock: satisfies browser autoplay policy on mobile/desktop
|
|
11
|
+
* by playing a zero-length base64 MP3 synchronously inside the gesture.
|
|
12
|
+
* - #isUnlocking guard: ignores rapid repeated calls during the async unlock.
|
|
13
|
+
* - Background tab detection: listens to the Page Visibility API
|
|
14
|
+
* (document.visibilitychange). Optionally pauses on hide and resumes on
|
|
15
|
+
* show (pauseOnHidden). Always fires onHidden / onVisible callbacks.
|
|
16
|
+
* Listener stored as #boundVisibility so destroy() removes the exact
|
|
17
|
+
* reference — no stale listener accumulation in SPAs.
|
|
18
|
+
* - Callbacks: onPlay, onEnd, onHidden, onVisible — all wrapped in try/catch
|
|
19
|
+
* so a throwing handler can never stall playback.
|
|
20
|
+
* - destroy(): removes the visibilitychange listener and releases the Audio
|
|
21
|
+
* element. Safe for SPA component teardown.
|
|
22
|
+
* - canPlay() static: check browser format support before constructing.
|
|
23
|
+
*
|
|
24
|
+
* ============================================================================
|
|
25
|
+
* QUICK START
|
|
26
|
+
* ============================================================================
|
|
27
|
+
*
|
|
28
|
+
* const player = new OpenAudio('audio/sound.mp3', {
|
|
29
|
+
* volume: 0.9,
|
|
30
|
+
* label: 'My Sound',
|
|
31
|
+
* pauseOnHidden: true, // pause when tab loses focus
|
|
32
|
+
* onPlay: () => console.log('playing'),
|
|
33
|
+
* onEnd: () => console.log('done'),
|
|
34
|
+
* onHidden: () => console.log('tab hidden'),
|
|
35
|
+
* onVisible: () => console.log('tab visible'),
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* // Trigger on any user gesture:
|
|
39
|
+
* document.getElementById('btn').addEventListener('click', () => player.play());
|
|
40
|
+
* document.addEventListener('click', () => player.play(), { once: true });
|
|
41
|
+
* document.addEventListener('keydown', () => player.play(), { once: true });
|
|
42
|
+
* document.addEventListener('touchstart', () => player.play(), { once: true });
|
|
43
|
+
*
|
|
44
|
+
* ============================================================================
|
|
45
|
+
* BROWSER AUTOPLAY POLICY
|
|
46
|
+
* ============================================================================
|
|
47
|
+
*
|
|
48
|
+
* Call play() synchronously inside a user-initiated event handler.
|
|
49
|
+
* Scroll does NOT qualify as a gesture in Chrome or Firefox.
|
|
50
|
+
* play() internally fires a silent base64 MP3 to unlock the audio element
|
|
51
|
+
* before playing the real clip — required for iOS Safari compatibility.
|
|
52
|
+
*
|
|
53
|
+
* ============================================================================
|
|
54
|
+
* BACKGROUND TAB DETECTION
|
|
55
|
+
* ============================================================================
|
|
56
|
+
*
|
|
57
|
+
* This engine listens to the Page Visibility API (document.visibilitychange).
|
|
58
|
+
*
|
|
59
|
+
* Behaviour depends on the pauseOnHidden option (default: false):
|
|
60
|
+
*
|
|
61
|
+
* pauseOnHidden: false (default)
|
|
62
|
+
* Audio continues playing when the tab is hidden — browsers do not
|
|
63
|
+
* throttle active audio playback, only setTimeout timers. onHidden and
|
|
64
|
+
* onVisible still fire so you can update UI state if needed.
|
|
65
|
+
*
|
|
66
|
+
* pauseOnHidden: true
|
|
67
|
+
* The clip is paused when the tab hides and resumed from the same
|
|
68
|
+
* position when the tab returns to the foreground. Useful when audio
|
|
69
|
+
* should only play while the page is visible (e.g. in-app sounds, game
|
|
70
|
+
* audio). Note: after a resume, browsers may require the resume call
|
|
71
|
+
* to be inside a gesture on stricter autoplay policies — if the user
|
|
72
|
+
* has not interacted since hiding, resume may be silently blocked.
|
|
73
|
+
*
|
|
74
|
+
* The visibilitychange listener is stored as a bound reference (#boundVisibility)
|
|
75
|
+
* so that destroy() can pass the exact same function to removeEventListener.
|
|
76
|
+
* An inline arrow function would create a new reference each time and could
|
|
77
|
+
* not be removed, causing stale listeners to accumulate in SPAs.
|
|
78
|
+
*
|
|
79
|
+
* ============================================================================
|
|
80
|
+
* CHANGELOG
|
|
81
|
+
* ============================================================================
|
|
82
|
+
*
|
|
83
|
+
* 1.1.0
|
|
84
|
+
* - Background tab detection via Page Visibility API.
|
|
85
|
+
* - pauseOnHidden option: pause on hide, resume on show.
|
|
86
|
+
* - onHidden / onVisible callbacks, wrapped in try/catch.
|
|
87
|
+
* - #boundVisibility: stored bound reference for clean destroy() removal.
|
|
88
|
+
* - destroy() now removes the visibilitychange listener.
|
|
89
|
+
* - Class renamed from SingleAudio to OpenAudio to match filename.
|
|
90
|
+
*
|
|
91
|
+
* 1.0.0
|
|
92
|
+
* - Initial release. Single-clip, one-shot player.
|
|
93
|
+
* - Silent MP3 unlock, #isUnlocking guard, onPlay/onEnd callbacks,
|
|
94
|
+
* destroy(), canPlay() static.
|
|
95
|
+
*
|
|
96
|
+
* ============================================================================
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
class OpenAudio {
|
|
100
|
+
|
|
101
|
+
// ── Silent 1-second MP3 used only to unlock the audio element ──────────────
|
|
102
|
+
static #SILENT_MP3 =
|
|
103
|
+
'data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjI5LjEwMAAAAAAA' +
|
|
104
|
+
'AAAAAP/7kGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADhgCg' +
|
|
105
|
+
'oKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKD///////' +
|
|
106
|
+
'///////////////////////////////////////////////////////////AAAAAExhdmM1OC41' +
|
|
107
|
+
'NQAAAAAAAAAAAAAAA//uQZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWluZwAAAA8A' +
|
|
108
|
+
'AAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAv' +
|
|
109
|
+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
|
110
|
+
|
|
111
|
+
// ── Private fields ──────────────────────────────────────────────────────────
|
|
112
|
+
#src;
|
|
113
|
+
#label;
|
|
114
|
+
#volume;
|
|
115
|
+
#audio;
|
|
116
|
+
#onPlay;
|
|
117
|
+
#onEnd;
|
|
118
|
+
#onHidden;
|
|
119
|
+
#onVisible;
|
|
120
|
+
#pauseOnHidden;
|
|
121
|
+
#isUnlocking = false;
|
|
122
|
+
#pausedByVisibility = false;
|
|
123
|
+
#boundVisibility;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} src - Path or data URI for your audio file.
|
|
127
|
+
* @param {object} [options]
|
|
128
|
+
* @param {number} [options.volume=1.0] - Playback volume 0.0–1.0.
|
|
129
|
+
* @param {string} [options.label=''] - Name shown in console warnings.
|
|
130
|
+
* @param {boolean} [options.pauseOnHidden=false] - Pause when tab is hidden;
|
|
131
|
+
* resume when it returns.
|
|
132
|
+
* @param {Function} [options.onPlay] - Called when playback starts.
|
|
133
|
+
* @param {Function} [options.onEnd] - Called when playback ends naturally.
|
|
134
|
+
* @param {Function} [options.onHidden] - Called when the tab becomes hidden.
|
|
135
|
+
* @param {Function} [options.onVisible] - Called when the tab becomes visible.
|
|
136
|
+
*/
|
|
137
|
+
constructor(src, options = {}) {
|
|
138
|
+
if (!src || typeof src !== 'string') {
|
|
139
|
+
throw new TypeError('OpenAudio: src must be a non-empty string.');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const {
|
|
143
|
+
volume = 1.0,
|
|
144
|
+
label = '',
|
|
145
|
+
pauseOnHidden = false,
|
|
146
|
+
onPlay = null,
|
|
147
|
+
onEnd = null,
|
|
148
|
+
onHidden = null,
|
|
149
|
+
onVisible = null,
|
|
150
|
+
} = options;
|
|
151
|
+
|
|
152
|
+
this.#src = src;
|
|
153
|
+
this.#label = label || src;
|
|
154
|
+
this.#volume = Math.min(1, Math.max(0, volume));
|
|
155
|
+
this.#pauseOnHidden = pauseOnHidden;
|
|
156
|
+
this.#onPlay = onPlay;
|
|
157
|
+
this.#onEnd = onEnd;
|
|
158
|
+
this.#onHidden = onHidden;
|
|
159
|
+
this.#onVisible = onVisible;
|
|
160
|
+
|
|
161
|
+
// Single shared Audio element — created once, reused on replay.
|
|
162
|
+
this.#audio = new Audio();
|
|
163
|
+
this.#audio.volume = this.#volume;
|
|
164
|
+
this.#audio.preload = 'auto';
|
|
165
|
+
this.#audio.src = this.#src;
|
|
166
|
+
|
|
167
|
+
this.#audio.addEventListener('ended', () => {
|
|
168
|
+
this.isPlaying = false;
|
|
169
|
+
try { if (this.#onEnd) this.#onEnd(); } catch (e) {
|
|
170
|
+
console.warn(`OpenAudio: onEnd callback error (${this.#label}):`, e);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Store the bound reference so destroy() removes the exact same function.
|
|
175
|
+
// An inline arrow would create a new reference that removeEventListener
|
|
176
|
+
// could never match — causing stale listeners to accumulate in SPAs.
|
|
177
|
+
this.#boundVisibility = this.#onVisibilityChange.bind(this);
|
|
178
|
+
document.addEventListener('visibilitychange', this.#boundVisibility);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Public state ────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/** True while the clip is actively playing. */
|
|
184
|
+
isPlaying = false;
|
|
185
|
+
|
|
186
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Unlocks the audio element (if needed) then plays the clip.
|
|
190
|
+
* Must be called synchronously inside a user-gesture event handler on first use.
|
|
191
|
+
*
|
|
192
|
+
* Safe to call repeatedly — ignored while already playing or unlocking.
|
|
193
|
+
* Calling play() after the clip has ended rewinds and replays from the start.
|
|
194
|
+
*/
|
|
195
|
+
play() {
|
|
196
|
+
if (this.isPlaying || this.#isUnlocking) return;
|
|
197
|
+
|
|
198
|
+
this.#isUnlocking = true;
|
|
199
|
+
|
|
200
|
+
// Play the silent MP3 synchronously within the gesture context.
|
|
201
|
+
// This unlocks the audio element for subsequent .play() calls on iOS/Chrome.
|
|
202
|
+
const unlock = new Audio(OpenAudio.#SILENT_MP3);
|
|
203
|
+
unlock.play().then(() => {
|
|
204
|
+
this.#isUnlocking = false;
|
|
205
|
+
this.#playClip();
|
|
206
|
+
}).catch(() => {
|
|
207
|
+
// Unlock failed — still attempt playback (desktop may not need it).
|
|
208
|
+
this.#isUnlocking = false;
|
|
209
|
+
this.#playClip();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Stops playback and rewinds to the start.
|
|
215
|
+
*/
|
|
216
|
+
stop() {
|
|
217
|
+
this.#audio.pause();
|
|
218
|
+
this.#audio.currentTime = 0;
|
|
219
|
+
this.isPlaying = false;
|
|
220
|
+
this.#pausedByVisibility = false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Removes the visibilitychange listener and releases the Audio element.
|
|
225
|
+
* Call on SPA component unmount. Do not call any other methods after destroy().
|
|
226
|
+
*/
|
|
227
|
+
destroy() {
|
|
228
|
+
document.removeEventListener('visibilitychange', this.#boundVisibility);
|
|
229
|
+
this.stop();
|
|
230
|
+
this.#audio.src = '';
|
|
231
|
+
this.#audio = null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Returns true if the browser reports it can probably or maybe play the
|
|
236
|
+
* given MIME type. Use before constructing to avoid silent format failures.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} type e.g. 'audio/ogg', 'audio/wav', 'audio/mpeg'
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
static canPlay(type) {
|
|
242
|
+
const result = new Audio().canPlayType(type);
|
|
243
|
+
return result === 'probably' || result === 'maybe';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Private ─────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Handles visibilitychange events.
|
|
250
|
+
*
|
|
251
|
+
* On hide:
|
|
252
|
+
* - Fires onHidden callback.
|
|
253
|
+
* - If pauseOnHidden is true and the clip is playing, pauses it and sets
|
|
254
|
+
* #pausedByVisibility so the resume path knows to restore playback.
|
|
255
|
+
*
|
|
256
|
+
* On show:
|
|
257
|
+
* - Fires onVisible callback.
|
|
258
|
+
* - If pauseOnHidden is true and #pausedByVisibility is set, resumes
|
|
259
|
+
* playback from the same position.
|
|
260
|
+
*/
|
|
261
|
+
#onVisibilityChange() {
|
|
262
|
+
if (document.visibilityState === 'hidden') {
|
|
263
|
+
|
|
264
|
+
try { if (this.#onHidden) this.#onHidden(); } catch (e) {
|
|
265
|
+
console.warn(`OpenAudio: onHidden callback error (${this.#label}):`, e);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (this.#pauseOnHidden && this.isPlaying) {
|
|
269
|
+
this.#audio.pause();
|
|
270
|
+
this.#pausedByVisibility = true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
} else if (document.visibilityState === 'visible') {
|
|
274
|
+
|
|
275
|
+
try { if (this.#onVisible) this.#onVisible(); } catch (e) {
|
|
276
|
+
console.warn(`OpenAudio: onVisible callback error (${this.#label}):`, e);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (this.#pauseOnHidden && this.#pausedByVisibility) {
|
|
280
|
+
this.#pausedByVisibility = false;
|
|
281
|
+
this.#audio.play().catch(err => {
|
|
282
|
+
if (err.name === 'AbortError') return;
|
|
283
|
+
console.warn(
|
|
284
|
+
`OpenAudio: resume after visibility restore failed for "${this.#label}".\nError:`, err
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#playClip() {
|
|
292
|
+
// Rewind in case it was played before.
|
|
293
|
+
this.#audio.currentTime = 0;
|
|
294
|
+
this.#audio.volume = this.#volume;
|
|
295
|
+
this.#pausedByVisibility = false;
|
|
296
|
+
|
|
297
|
+
this.#audio.play().then(() => {
|
|
298
|
+
this.isPlaying = true;
|
|
299
|
+
try { if (this.#onPlay) this.#onPlay(); } catch (e) {
|
|
300
|
+
console.warn(`OpenAudio: onPlay callback error (${this.#label}):`, e);
|
|
301
|
+
}
|
|
302
|
+
}).catch(err => {
|
|
303
|
+
if (err.name === 'AbortError') return;
|
|
304
|
+
|
|
305
|
+
if (err.name === 'NotAllowedError') {
|
|
306
|
+
console.warn(
|
|
307
|
+
`OpenAudio: play() blocked by autoplay policy for "${this.#label}". ` +
|
|
308
|
+
`Call play() again inside a user gesture.`
|
|
309
|
+
);
|
|
310
|
+
} else {
|
|
311
|
+
console.warn(`OpenAudio: play() failed for "${this.#label}".\nError:`, err);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
// ── USAGE EXAMPLES ────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/*
|
|
321
|
+
|
|
322
|
+
// ── Minimal ───────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
const player = new OpenAudio('audio/chime.mp3');
|
|
325
|
+
document.getElementById('btn').addEventListener('click', () => player.play());
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
// ── With background tab options ───────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
const player = new OpenAudio('audio/chime.mp3', {
|
|
331
|
+
volume: 0.8,
|
|
332
|
+
label: 'Chime',
|
|
333
|
+
pauseOnHidden: true, // pause when tab loses focus
|
|
334
|
+
onPlay: () => console.log('playing'),
|
|
335
|
+
onEnd: () => console.log('done'),
|
|
336
|
+
onHidden: () => console.log('tab hidden — audio paused'),
|
|
337
|
+
onVisible: () => console.log('tab visible — audio resumed'),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
// ── Callbacks only, no auto-pause ─────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
// Audio keeps playing in background; only UI is updated.
|
|
344
|
+
const player = new OpenAudio('audio/ambient.mp3', {
|
|
345
|
+
onHidden: () => updateUI('background'),
|
|
346
|
+
onVisible: () => updateUI('foreground'),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
// ── One-shot on any gesture ───────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
document.addEventListener('click', () => player.play(), { once: true });
|
|
353
|
+
document.addEventListener('keydown', () => player.play(), { once: true });
|
|
354
|
+
document.addEventListener('touchstart', () => player.play(), { once: true });
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
// ── Replay ────────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
// play() rewinds and replays if called again after the clip has ended.
|
|
360
|
+
document.getElementById('replay-btn').addEventListener('click', () => player.play());
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
// ── Stop mid-playback ─────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
document.getElementById('stop-btn').addEventListener('click', () => player.stop());
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
// ── Check format support ──────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
if (!OpenAudio.canPlay('audio/ogg')) {
|
|
371
|
+
console.warn('OGG not supported — use an MP3 source instead.');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
// ── SPA teardown (React, Vue, etc.) ──────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
// React:
|
|
378
|
+
// useEffect(() => {
|
|
379
|
+
// const player = new OpenAudio('audio/chime.mp3', { pauseOnHidden: true });
|
|
380
|
+
// return () => player.destroy(); // removes visibilitychange listener on unmount
|
|
381
|
+
// }, []);
|
|
382
|
+
|
|
383
|
+
// Vue:
|
|
384
|
+
// onUnmounted(() => player.destroy());
|
|
385
|
+
|
|
386
|
+
*/
|