@zakkster/lite-audio 1.0.0 → 1.1.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/Audio.d.ts +107 -9
- package/Audio.js +551 -10
- package/CHANGELOG.md +198 -88
- package/README.md +91 -15
- package/llms.txt +55 -44
- package/package.json +3 -4
package/Audio.js
CHANGED
|
@@ -34,6 +34,15 @@ const RAMP_TC = 0.01;
|
|
|
34
34
|
*/
|
|
35
35
|
const BUS_STRIDE = 4294967296; // 2^32
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* playUnique() returns a handle for a sound. Tracks have no handle - they are
|
|
39
|
+
* singletons addressed by name - so it needs a way to say "the track started"
|
|
40
|
+
* that is not a number stop() would act on. It cannot be 0: that is a perfectly
|
|
41
|
+
* good handle (bus 0, channel 0, generation 0). Negative values are inert to
|
|
42
|
+
* stop(), and -1 already means "skipped", so a track start reports -2.
|
|
43
|
+
*/
|
|
44
|
+
const TRACK_STARTED = -2;
|
|
45
|
+
|
|
37
46
|
/**
|
|
38
47
|
* The unlock queue is bounded to bound worst-case memory: if a spammy loop
|
|
39
48
|
* calls play() a thousand times before the first user gesture, we do not
|
|
@@ -69,6 +78,45 @@ function writeStorage(key, value) {
|
|
|
69
78
|
} catch { /* Safari private mode / storage disabled */ }
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Equal-power crossfade curves, precomputed once at module load. Both curves
|
|
83
|
+
* are 128 samples over t in [0, 1]. Scaled per crossfade to whatever the
|
|
84
|
+
* outgoing gain's current value is (so a mid-crossfade retarget starts from
|
|
85
|
+
* where the track actually is, not from a hardcoded 1.0). Allocations here
|
|
86
|
+
* are one Float32Array per crossfade side - a scene transition, not a hot
|
|
87
|
+
* path. The pool underneath still does the zero-GC work.
|
|
88
|
+
*/
|
|
89
|
+
const CROSSFADE_SAMPLES = 128;
|
|
90
|
+
const EQ_POWER_IN = new Float32Array(CROSSFADE_SAMPLES);
|
|
91
|
+
const EQ_POWER_OUT = new Float32Array(CROSSFADE_SAMPLES);
|
|
92
|
+
for (let i = 0; i < CROSSFADE_SAMPLES; i++) {
|
|
93
|
+
const t = i / (CROSSFADE_SAMPLES - 1);
|
|
94
|
+
EQ_POWER_IN[i] = Math.sin(t * Math.PI / 2);
|
|
95
|
+
EQ_POWER_OUT[i] = Math.cos(t * Math.PI / 2);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Scale an equal-power curve to a desired range [start, start+delta]. Used
|
|
100
|
+
* for both fade-in (start=current, delta=target-current) and fade-out
|
|
101
|
+
* (start=0, delta=current), and correctly handles mid-crossfade retargets
|
|
102
|
+
* where the current xfadeGain value is somewhere between 0 and 1.
|
|
103
|
+
*/
|
|
104
|
+
function scaleCurve(base, delta, start) {
|
|
105
|
+
const out = new Float32Array(base.length);
|
|
106
|
+
for (let i = 0; i < base.length; i++) out[i] = start + base[i] * delta;
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Position signal write throttle: >= 100 ms of ctx time between writes. */
|
|
111
|
+
const POSITION_WRITE_INTERVAL = 0.1;
|
|
112
|
+
|
|
113
|
+
/** Default fade durations in seconds (converted to ms at the API boundary). */
|
|
114
|
+
const DEFAULT_TRACK_FADE_MS = 200;
|
|
115
|
+
|
|
116
|
+
/** Gain restore ramp used by resumeTrack(). Long enough not to click, short
|
|
117
|
+
* enough that "resume" still feels immediate. */
|
|
118
|
+
const RESUME_FADE_MS = 40;
|
|
119
|
+
|
|
72
120
|
// ---------- Loader ---------------------------------------------------------
|
|
73
121
|
|
|
74
122
|
/**
|
|
@@ -140,13 +188,17 @@ export class LiteAudio {
|
|
|
140
188
|
* @param {Object} [opts.document] - Injectable for tests. Defaults to globalThis.document.
|
|
141
189
|
*/
|
|
142
190
|
constructor(opts = {}) {
|
|
143
|
-
this._busNames = opts.buses || ['sfx', 'ui', 'voice'];
|
|
191
|
+
this._busNames = opts.buses || ['sfx', 'ui', 'voice', 'music'];
|
|
144
192
|
this._poolCapacity = opts.poolCapacity ?? 32;
|
|
145
193
|
this._queueLimit = opts.queueLimit ?? DEFAULT_QUEUE_LIMIT;
|
|
146
194
|
this._mutedKey = opts.mutedStorageKey ?? MUTED_STORAGE_KEY;
|
|
147
195
|
this._fetch = opts.fetch || (typeof fetch !== 'undefined' ? fetch : null);
|
|
148
196
|
this._window = opts.window ?? (typeof window !== 'undefined' ? window : null);
|
|
149
197
|
this._document = opts.document ?? (typeof document !== 'undefined' ? document : null);
|
|
198
|
+
// Test-injectable timers. Real setTimeout/clearTimeout in production;
|
|
199
|
+
// a manual scheduler in tests so pause-after-fade is deterministic.
|
|
200
|
+
this._setTimeout = opts.setTimeout || ((cb, ms) => setTimeout(cb, ms));
|
|
201
|
+
this._clearTimeout = opts.clearTimeout || ((id) => clearTimeout(id));
|
|
150
202
|
|
|
151
203
|
// Read persisted mute BEFORE constructing the master signal so the
|
|
152
204
|
// very first emitted value already carries the user's preference.
|
|
@@ -169,6 +221,14 @@ export class LiteAudio {
|
|
|
169
221
|
// { srcs:[], busName, buffer, spriteId, loadState:signal, volume, pitchVar }
|
|
170
222
|
this._sounds = new Map();
|
|
171
223
|
|
|
224
|
+
// Per-track state, populated by defineTracks(). Music tracks are
|
|
225
|
+
// singletons - one instance per registered name. See _makeTrackRecord.
|
|
226
|
+
this._tracks = new Map();
|
|
227
|
+
|
|
228
|
+
// Timestamp map for playUnique(). Manager parity: keyed by name, holds
|
|
229
|
+
// performance.now() (or ctx-clock fallback). Applies to sounds AND tracks.
|
|
230
|
+
this._lastPlayed = new Map();
|
|
231
|
+
|
|
172
232
|
// Reverse index: bus name -> array of soundIds routed to that bus.
|
|
173
233
|
// Rebuilt after every defineSounds so pools know their sprite map.
|
|
174
234
|
this._soundsByBus = new Map();
|
|
@@ -501,8 +561,7 @@ export class LiteAudio {
|
|
|
501
561
|
}
|
|
502
562
|
|
|
503
563
|
/**
|
|
504
|
-
* Which bus issued this handle, or null.
|
|
505
|
-
* tests that a voice landed where its sound was routed.
|
|
564
|
+
* Which bus issued this handle, or null.
|
|
506
565
|
* @param {number} handle
|
|
507
566
|
* @returns {string|null}
|
|
508
567
|
*/
|
|
@@ -516,8 +575,9 @@ export class LiteAudio {
|
|
|
516
575
|
}
|
|
517
576
|
|
|
518
577
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
578
|
+
* SFX voices currently sounding on a bus, or across every bus with no argument.
|
|
579
|
+
* Tracks are not voices and are not counted - ask trackPlaying(name).
|
|
580
|
+
* Allocation-free; safe to call every frame.
|
|
521
581
|
* @param {string} [busName]
|
|
522
582
|
* @returns {number}
|
|
523
583
|
*/
|
|
@@ -535,21 +595,476 @@ export class LiteAudio {
|
|
|
535
595
|
return n;
|
|
536
596
|
}
|
|
537
597
|
|
|
538
|
-
/**
|
|
539
|
-
|
|
598
|
+
/**
|
|
599
|
+
* Stop every voice on a named bus - SFX voices AND any music track routed there.
|
|
600
|
+
* Before v1.1.0 a bus held nothing but pool voices; now that tracks share the bus
|
|
601
|
+
* graph, "stop the bus" has to mean the bus, or a scene change leaves the theme
|
|
602
|
+
* playing under the next scene.
|
|
603
|
+
* @param {string} busName
|
|
604
|
+
* @param {Object} [opts]
|
|
605
|
+
* @param {number} [opts.fade=200] - fade for tracks on this bus, ms
|
|
606
|
+
*/
|
|
607
|
+
stopBus(busName, opts = {}) {
|
|
540
608
|
if (this._destroyed) return;
|
|
541
609
|
const busRec = this._buses.get(busName);
|
|
542
|
-
if (busRec
|
|
610
|
+
if (!busRec) return;
|
|
611
|
+
if (busRec.pool) busRec.pool.stopAll();
|
|
612
|
+
|
|
613
|
+
const fade = opts.fade ?? DEFAULT_TRACK_FADE_MS;
|
|
614
|
+
for (const [name, rec] of this._tracks) {
|
|
615
|
+
if (rec.busName === busName && rec.playing.peek()) this.stopTrack(name, { fade });
|
|
616
|
+
}
|
|
543
617
|
}
|
|
544
618
|
|
|
545
|
-
/**
|
|
546
|
-
|
|
619
|
+
/**
|
|
620
|
+
* Stop every voice on every bus, and fade out every playing track.
|
|
621
|
+
* @param {Object} [opts]
|
|
622
|
+
* @param {number} [opts.fade=200] - fade for tracks, ms
|
|
623
|
+
*/
|
|
624
|
+
stopAll(opts = {}) {
|
|
547
625
|
if (this._destroyed) return;
|
|
548
626
|
for (const [, busRec] of this._buses) {
|
|
549
627
|
if (busRec.pool) busRec.pool.stopAll();
|
|
550
628
|
}
|
|
629
|
+
const fade = opts.fade ?? DEFAULT_TRACK_FADE_MS;
|
|
630
|
+
for (const [name, rec] of this._tracks) {
|
|
631
|
+
if (rec.playing.peek()) this.stopTrack(name, { fade });
|
|
632
|
+
}
|
|
551
633
|
}
|
|
552
634
|
|
|
635
|
+
// =========================================================================
|
|
636
|
+
// Music layer (v1.1.0). MediaElementSource-based streaming tracks routed
|
|
637
|
+
// through the same bus graph as SFX. Tracks are singletons - one instance
|
|
638
|
+
// per registered name - and addressed by name for stop/crossfade/position.
|
|
639
|
+
// =========================================================================
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Register a set of music tracks. Same shape as defineSounds, plus:
|
|
643
|
+
* { loop?, loopStart?, loopEnd? }.
|
|
644
|
+
* Tracks are streamed (MediaElementAudioSourceNode) rather than decoded
|
|
645
|
+
* into memory - a 5-minute track becomes ~800 KB of HTTP stream, not
|
|
646
|
+
* 50 MB of PCM.
|
|
647
|
+
*
|
|
648
|
+
* Resolves once every track has settled (ready or error). Load state per
|
|
649
|
+
* track is a signal readable via trackLoadState(name).
|
|
650
|
+
*/
|
|
651
|
+
async defineTracks(config) {
|
|
652
|
+
if (this._destroyed) throw new Error('LiteAudio: destroyed');
|
|
653
|
+
if (!this._initialized) throw new Error('LiteAudio: init() before defineTracks()');
|
|
654
|
+
if (!config) return;
|
|
655
|
+
|
|
656
|
+
const loadPromises = [];
|
|
657
|
+
for (const [name, cfg] of Object.entries(config)) {
|
|
658
|
+
if (this._tracks.has(name)) continue;
|
|
659
|
+
|
|
660
|
+
const busName = cfg.bus || 'music';
|
|
661
|
+
if (!this._buses.has(busName)) {
|
|
662
|
+
throw new Error(`LiteAudio: track "${name}" routed to unknown bus "${busName}"`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const rec = this._makeTrackRecord(name, cfg, busName);
|
|
666
|
+
this._tracks.set(name, rec);
|
|
667
|
+
loadPromises.push(this._loadTrack(rec));
|
|
668
|
+
}
|
|
669
|
+
await Promise.all(loadPromises);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Track record shape - kept private so callers cannot reach into the
|
|
674
|
+
* graph directly. Everything a UI needs is exposed via signal accessors.
|
|
675
|
+
*/
|
|
676
|
+
_makeTrackRecord(name, cfg, busName) {
|
|
677
|
+
return {
|
|
678
|
+
name,
|
|
679
|
+
srcs: cfg.src || [],
|
|
680
|
+
busName,
|
|
681
|
+
volume: cfg.volume ?? 1,
|
|
682
|
+
loop: !!cfg.loop,
|
|
683
|
+
loopStart: cfg.loopStart ?? null,
|
|
684
|
+
loopEnd: cfg.loopEnd ?? null,
|
|
685
|
+
|
|
686
|
+
// Signals (external readouts)
|
|
687
|
+
loadState: signal('idle'),
|
|
688
|
+
playing: signal(false),
|
|
689
|
+
position: signal(0),
|
|
690
|
+
duration: signal(0),
|
|
691
|
+
|
|
692
|
+
// Graph nodes, wired on first play so a defined-but-never-played
|
|
693
|
+
// track costs one <audio> element (holding onto the URL) and no
|
|
694
|
+
// audio graph footprint.
|
|
695
|
+
element: null,
|
|
696
|
+
source: null, // MediaElementAudioSourceNode
|
|
697
|
+
xfadeGain: null, // GainNode: 0..1 crossfade knob
|
|
698
|
+
volumeGain: null, // GainNode: track's baseline volume
|
|
699
|
+
|
|
700
|
+
// Throttling + handlers, so destroy() can remove them cleanly
|
|
701
|
+
lastPositionWrite: -Infinity,
|
|
702
|
+
timeupdateHandler: null,
|
|
703
|
+
endedHandler: null,
|
|
704
|
+
|
|
705
|
+
// Delayed-pause timer id (real setTimeout in production; teardown
|
|
706
|
+
// pauses the <audio> element after the fade completes so the
|
|
707
|
+
// browser stops decoding a track no one hears).
|
|
708
|
+
pauseTimer: null,
|
|
709
|
+
|
|
710
|
+
resolvedSrc: null, // the URL picked by the format probe
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async _loadTrack(rec) {
|
|
715
|
+
const url = pickSupportedSrc(rec.srcs);
|
|
716
|
+
if (!url) { rec.loadState.set('error'); return; }
|
|
717
|
+
rec.loadState.set('loading');
|
|
718
|
+
try {
|
|
719
|
+
rec.resolvedSrc = url;
|
|
720
|
+
// Create the <audio> element eagerly so metadata (duration) can
|
|
721
|
+
// populate before any play(). We do NOT wire the graph yet - that
|
|
722
|
+
// happens on first playTrack, since MediaElementSource is expensive.
|
|
723
|
+
const el = this._createAudioElement(url);
|
|
724
|
+
rec.element = el;
|
|
725
|
+
|
|
726
|
+
// 'loadedmetadata' pushes duration into the signal. Some browsers
|
|
727
|
+
// fire this eagerly for cached/short files, others take a beat.
|
|
728
|
+
const onMeta = () => {
|
|
729
|
+
rec.duration.set(Number.isFinite(el.duration) ? el.duration : 0);
|
|
730
|
+
};
|
|
731
|
+
el.addEventListener('loadedmetadata', onMeta);
|
|
732
|
+
|
|
733
|
+
// 'error' -> loadState = 'error'. We do not distinguish decode vs
|
|
734
|
+
// network errors here; the granularity is not useful to a game.
|
|
735
|
+
const onError = () => { rec.loadState.set('error'); };
|
|
736
|
+
el.addEventListener('error', onError);
|
|
737
|
+
|
|
738
|
+
rec.loadState.set('ready');
|
|
739
|
+
} catch {
|
|
740
|
+
rec.loadState.set('error');
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
_createAudioElement(src) {
|
|
745
|
+
if (this._document?.createElement) {
|
|
746
|
+
const el = this._document.createElement('audio');
|
|
747
|
+
el.src = src;
|
|
748
|
+
el.preload = 'auto';
|
|
749
|
+
el.crossOrigin = 'anonymous';
|
|
750
|
+
return el;
|
|
751
|
+
}
|
|
752
|
+
if (typeof Audio !== 'undefined') {
|
|
753
|
+
const el = new Audio(src);
|
|
754
|
+
el.preload = 'auto';
|
|
755
|
+
return el;
|
|
756
|
+
}
|
|
757
|
+
throw new Error('LiteAudio: no <audio> element factory available');
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Wire a track's graph on first play. Idempotent - subsequent calls are
|
|
762
|
+
* no-ops. The graph is:
|
|
763
|
+
* <audio> -> MediaElementSource -> xfadeGain -> volumeGain -> bus.gain
|
|
764
|
+
* xfadeGain carries crossfade curves (0..1). volumeGain carries the
|
|
765
|
+
* track's baseline volume (independent of crossfade).
|
|
766
|
+
*/
|
|
767
|
+
_wireTrackGraph(rec) {
|
|
768
|
+
if (rec.source) return;
|
|
769
|
+
const busRec = this._buses.get(rec.busName);
|
|
770
|
+
if (!busRec) return;
|
|
771
|
+
|
|
772
|
+
rec.source = this._ctx.createMediaElementSource(rec.element);
|
|
773
|
+
rec.xfadeGain = this._ctx.createGain();
|
|
774
|
+
rec.xfadeGain.gain.value = 0; // start silent, fade in
|
|
775
|
+
rec.volumeGain = this._ctx.createGain();
|
|
776
|
+
rec.volumeGain.gain.value = rec.volume;
|
|
777
|
+
|
|
778
|
+
rec.source.connect(rec.xfadeGain);
|
|
779
|
+
rec.xfadeGain.connect(rec.volumeGain);
|
|
780
|
+
rec.volumeGain.connect(busRec.gain);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Start (or resume) a track. Idempotent - playing a track already playing
|
|
785
|
+
* is a no-op unless `restart: true` is set, in which case the element is
|
|
786
|
+
* seeked to 0 and continues from there.
|
|
787
|
+
*
|
|
788
|
+
* `fadeIn` (ms) drives an equal-power ramp on xfadeGain, from its current
|
|
789
|
+
* value (0 for fresh play, whatever for a retarget) to the track's target
|
|
790
|
+
* xfade level (1.0). Default is 0 - snap to full immediately.
|
|
791
|
+
*
|
|
792
|
+
* Setting `position` (seconds) seeks the element before playback starts.
|
|
793
|
+
*/
|
|
794
|
+
playTrack(name, opts = {}) {
|
|
795
|
+
if (this._destroyed) return;
|
|
796
|
+
const rec = this._tracks.get(name);
|
|
797
|
+
if (!rec) return;
|
|
798
|
+
if (rec.loadState.peek() !== 'ready') return;
|
|
799
|
+
|
|
800
|
+
// Locked context: playing a track before unlock is not queued the way
|
|
801
|
+
// SFX are. Music is a scene-scale operation - the caller should either
|
|
802
|
+
// wait for the unlocked() signal or set up the track after unlock.
|
|
803
|
+
if (!this._sUnlocked.peek()) return;
|
|
804
|
+
|
|
805
|
+
// Idempotency: already playing, and no restart flag -> no-op.
|
|
806
|
+
if (rec.playing.peek() && !opts.restart) return;
|
|
807
|
+
|
|
808
|
+
this._wireTrackGraph(rec);
|
|
809
|
+
|
|
810
|
+
// Cancel any pending pause from a previous stopTrack - we're back.
|
|
811
|
+
if (rec.pauseTimer != null) {
|
|
812
|
+
this._clearTimeout(rec.pauseTimer);
|
|
813
|
+
rec.pauseTimer = null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (opts.position != null && Number.isFinite(opts.position)) {
|
|
817
|
+
rec.element.currentTime = opts.position;
|
|
818
|
+
} else if (opts.restart) {
|
|
819
|
+
rec.element.currentTime = 0;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Attach handlers lazily and only once. Removed on destroy or when
|
|
823
|
+
// the track record is torn down. Loop handling is done inside the
|
|
824
|
+
// handler so custom loopStart/loopEnd work even when element.loop
|
|
825
|
+
// is false (the native property would loop end -> 0, we may want
|
|
826
|
+
// end -> loopStart instead).
|
|
827
|
+
if (!rec.timeupdateHandler) {
|
|
828
|
+
const el = rec.element;
|
|
829
|
+
rec.timeupdateHandler = () => {
|
|
830
|
+
const now = this._ctx.currentTime;
|
|
831
|
+
|
|
832
|
+
// Custom loop points: seek back when currentTime crosses loopEnd.
|
|
833
|
+
if (rec.loop && rec.loopEnd != null && el.currentTime >= rec.loopEnd) {
|
|
834
|
+
el.currentTime = rec.loopStart ?? 0;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Position signal, throttled to POSITION_WRITE_INTERVAL of ctx time.
|
|
838
|
+
if (now - rec.lastPositionWrite >= POSITION_WRITE_INTERVAL) {
|
|
839
|
+
rec.position.set(el.currentTime);
|
|
840
|
+
rec.lastPositionWrite = now;
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
el.addEventListener('timeupdate', rec.timeupdateHandler);
|
|
844
|
+
}
|
|
845
|
+
if (!rec.endedHandler) {
|
|
846
|
+
rec.endedHandler = () => {
|
|
847
|
+
rec.playing.set(false);
|
|
848
|
+
// Do not tear down the graph - the caller may replay this track.
|
|
849
|
+
};
|
|
850
|
+
rec.element.addEventListener('ended', rec.endedHandler);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Set native loop when we do NOT have custom loop points. Custom
|
|
854
|
+
// loop points require the timeupdate seek path; leaving element.loop
|
|
855
|
+
// true alongside would double-loop.
|
|
856
|
+
rec.element.loop = rec.loop && rec.loopEnd == null;
|
|
857
|
+
|
|
858
|
+
// Kick playback and schedule the fade-in curve. play() returns a
|
|
859
|
+
// promise on modern browsers; we ignore it - the graph is already
|
|
860
|
+
// wired, and any autoplay rejection is caught by ctx-locked check.
|
|
861
|
+
rec.element.play();
|
|
862
|
+
rec.playing.set(true);
|
|
863
|
+
|
|
864
|
+
const fadeInMs = opts.fadeIn ?? 0;
|
|
865
|
+
const targetGain = 1; // xfadeGain lives 0..1
|
|
866
|
+
const now = this._ctx.currentTime;
|
|
867
|
+
if (fadeInMs > 0) {
|
|
868
|
+
const currentValue = rec.xfadeGain.gain.value;
|
|
869
|
+
const curve = scaleCurve(EQ_POWER_IN, targetGain - currentValue, currentValue);
|
|
870
|
+
rec.xfadeGain.gain.cancelScheduledValues(now);
|
|
871
|
+
rec.xfadeGain.gain.setValueAtTime(currentValue, now);
|
|
872
|
+
rec.xfadeGain.gain.setValueCurveAtTime(curve, now, fadeInMs / 1000);
|
|
873
|
+
} else {
|
|
874
|
+
rec.xfadeGain.gain.cancelScheduledValues(now);
|
|
875
|
+
rec.xfadeGain.gain.setValueAtTime(targetGain, now);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Fade a track out over `fade` ms and pause its element once the fade
|
|
881
|
+
* completes. The playing signal flips to false immediately - the fade
|
|
882
|
+
* tail is a graceful audio detail, not a "still playing" state a HUD
|
|
883
|
+
* should show.
|
|
884
|
+
*/
|
|
885
|
+
stopTrack(name, opts = {}) {
|
|
886
|
+
if (this._destroyed) return;
|
|
887
|
+
const rec = this._tracks.get(name);
|
|
888
|
+
if (!rec || !rec.playing.peek()) return;
|
|
889
|
+
|
|
890
|
+
const fadeMs = opts.fade ?? DEFAULT_TRACK_FADE_MS;
|
|
891
|
+
rec.playing.set(false);
|
|
892
|
+
|
|
893
|
+
// Immediately schedule fade-out. Cancel any in-flight fade first so a
|
|
894
|
+
// retarget starts from wherever the automation is right now.
|
|
895
|
+
if (rec.xfadeGain) {
|
|
896
|
+
const now = this._ctx.currentTime;
|
|
897
|
+
const currentValue = rec.xfadeGain.gain.value;
|
|
898
|
+
if (fadeMs > 0) {
|
|
899
|
+
const curve = scaleCurve(EQ_POWER_OUT, currentValue, 0);
|
|
900
|
+
rec.xfadeGain.gain.cancelScheduledValues(now);
|
|
901
|
+
rec.xfadeGain.gain.setValueAtTime(currentValue, now);
|
|
902
|
+
rec.xfadeGain.gain.setValueCurveAtTime(curve, now, fadeMs / 1000);
|
|
903
|
+
} else {
|
|
904
|
+
rec.xfadeGain.gain.cancelScheduledValues(now);
|
|
905
|
+
rec.xfadeGain.gain.setValueAtTime(0, now);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Pause the <audio> element after the fade so decoding stops. Use the
|
|
910
|
+
// injected setTimeout so tests can flush deterministically.
|
|
911
|
+
if (rec.pauseTimer != null) this._clearTimeout(rec.pauseTimer);
|
|
912
|
+
const slackMs = fadeMs + 30;
|
|
913
|
+
rec.pauseTimer = this._setTimeout(() => {
|
|
914
|
+
rec.pauseTimer = null;
|
|
915
|
+
if (!rec.playing.peek() && rec.element && !rec.element.paused) {
|
|
916
|
+
rec.element.pause();
|
|
917
|
+
}
|
|
918
|
+
}, slackMs);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Pause without losing position. */
|
|
922
|
+
pauseTrack(name) {
|
|
923
|
+
if (this._destroyed) return;
|
|
924
|
+
const rec = this._tracks.get(name);
|
|
925
|
+
if (!rec || !rec.playing.peek()) return;
|
|
926
|
+
rec.element?.pause();
|
|
927
|
+
rec.playing.set(false);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Resume from paused state. Preserves position (element.currentTime).
|
|
932
|
+
*
|
|
933
|
+
* pauseTrack() leaves xfadeGain alone, but stopTrack() fades it to zero - and
|
|
934
|
+
* nothing stops a caller from pairing stopTrack with resumeTrack. Without
|
|
935
|
+
* restoring the gain, that pair produced a track that was decoding, reporting
|
|
936
|
+
* playing() === true, and completely inaudible. So resume lifts the gain back
|
|
937
|
+
* to full over a short ramp (click-free, and a no-op when it is already there).
|
|
938
|
+
*/
|
|
939
|
+
resumeTrack(name) {
|
|
940
|
+
if (this._destroyed) return;
|
|
941
|
+
const rec = this._tracks.get(name);
|
|
942
|
+
if (!rec) return;
|
|
943
|
+
if (rec.loadState.peek() !== 'ready') return;
|
|
944
|
+
if (rec.playing.peek()) return;
|
|
945
|
+
if (!this._sUnlocked.peek()) return;
|
|
946
|
+
|
|
947
|
+
// A pause scheduled by an earlier stopTrack must not land on top of us.
|
|
948
|
+
if (rec.pauseTimer != null) {
|
|
949
|
+
this._clearTimeout(rec.pauseTimer);
|
|
950
|
+
rec.pauseTimer = null;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (rec.xfadeGain) {
|
|
954
|
+
const now = this._ctx.currentTime;
|
|
955
|
+
const currentValue = rec.xfadeGain.gain.value;
|
|
956
|
+
if (currentValue < 1) {
|
|
957
|
+
const curve = scaleCurve(EQ_POWER_IN, 1 - currentValue, currentValue);
|
|
958
|
+
rec.xfadeGain.gain.cancelScheduledValues(now);
|
|
959
|
+
rec.xfadeGain.gain.setValueAtTime(currentValue, now);
|
|
960
|
+
rec.xfadeGain.gain.setValueCurveAtTime(curve, now, RESUME_FADE_MS / 1000);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
rec.element?.play();
|
|
965
|
+
rec.playing.set(true);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Equal-power crossfade between two tracks. Either side may be null:
|
|
970
|
+
* crossfade('a', 'b', 400) - a fades out while b fades in
|
|
971
|
+
* crossfade('a', null, 400) - fade a out
|
|
972
|
+
* crossfade(null, 'b', 400) - fade b in
|
|
973
|
+
*
|
|
974
|
+
* Case (c) interruption semantics: only tracks named in this call are
|
|
975
|
+
* touched. A track fading out from a previous crossfade keeps its
|
|
976
|
+
* schedule; a track fading in from a previous crossfade will be
|
|
977
|
+
* retargeted only if it appears in this new call. Every side reads its
|
|
978
|
+
* xfadeGain's current value and schedules the equal-power curve scaled
|
|
979
|
+
* to that start point, so there are no discontinuities.
|
|
980
|
+
*/
|
|
981
|
+
crossfade(fromName, toName, durationMs) {
|
|
982
|
+
if (this._destroyed) return;
|
|
983
|
+
const dur = (durationMs ?? DEFAULT_TRACK_FADE_MS) / 1000;
|
|
984
|
+
const now = this._ctx.currentTime;
|
|
985
|
+
|
|
986
|
+
if (fromName) {
|
|
987
|
+
const rec = this._tracks.get(fromName);
|
|
988
|
+
if (rec && rec.playing.peek()) {
|
|
989
|
+
// Fade out. Same path as stopTrack but scoped to the schedule.
|
|
990
|
+
this.stopTrack(fromName, { fade: durationMs ?? DEFAULT_TRACK_FADE_MS });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (toName) {
|
|
995
|
+
const rec = this._tracks.get(toName);
|
|
996
|
+
if (rec && rec.loadState.peek() === 'ready') {
|
|
997
|
+
if (!rec.playing.peek()) {
|
|
998
|
+
// Start from silence, fade in over dur.
|
|
999
|
+
this._wireTrackGraph(rec);
|
|
1000
|
+
if (rec.xfadeGain) rec.xfadeGain.gain.value = 0;
|
|
1001
|
+
this.playTrack(toName, { fadeIn: durationMs ?? DEFAULT_TRACK_FADE_MS });
|
|
1002
|
+
} else if (rec.xfadeGain) {
|
|
1003
|
+
// Already playing (probably being retargeted mid-fade).
|
|
1004
|
+
// Curve from wherever we are to 1.0.
|
|
1005
|
+
const currentValue = rec.xfadeGain.gain.value;
|
|
1006
|
+
const curve = scaleCurve(EQ_POWER_IN, 1 - currentValue, currentValue);
|
|
1007
|
+
rec.xfadeGain.gain.cancelScheduledValues(now);
|
|
1008
|
+
rec.xfadeGain.gain.setValueAtTime(currentValue, now);
|
|
1009
|
+
rec.xfadeGain.gain.setValueCurveAtTime(curve, now, dur);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Start `name` and fade every OTHER playing track on the same bus. The
|
|
1017
|
+
* roadmap's D1 sees buses as the physical version of manager categories:
|
|
1018
|
+
* a bus-scoped exclusive on the music bus fades all other music tracks
|
|
1019
|
+
* while leaving SFX (or a separate voice bus) untouched.
|
|
1020
|
+
*/
|
|
1021
|
+
playExclusive(name, opts = {}) {
|
|
1022
|
+
if (this._destroyed) return;
|
|
1023
|
+
const rec = this._tracks.get(name);
|
|
1024
|
+
if (!rec) return;
|
|
1025
|
+
const bus = rec.busName;
|
|
1026
|
+
const fadeMs = opts.fade ?? DEFAULT_TRACK_FADE_MS;
|
|
1027
|
+
for (const [otherName, otherRec] of this._tracks) {
|
|
1028
|
+
if (otherName === name) continue;
|
|
1029
|
+
if (otherRec.busName !== bus) continue;
|
|
1030
|
+
if (!otherRec.playing.peek()) continue;
|
|
1031
|
+
this.stopTrack(otherName, { fade: fadeMs });
|
|
1032
|
+
}
|
|
1033
|
+
this.playTrack(name, opts);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Play `name` (sound OR track) only if the last play attempt for the
|
|
1038
|
+
* same name was more than `thresholdMs` ago. Ported verbatim from the
|
|
1039
|
+
* manager's timestamp map. Uses performance.now() if available,
|
|
1040
|
+
* ctx.currentTime * 1000 as fallback so the threshold still means ms.
|
|
1041
|
+
*
|
|
1042
|
+
* Returns a voice handle when `name` is a sound, TRACK_STARTED (-2) when it is
|
|
1043
|
+
* a track, and -1 when the call was throttled or the name is unknown. It cannot
|
|
1044
|
+
* return 0 for a track: 0 is a real handle (bus 0, channel 0, generation 0), and
|
|
1045
|
+
* a caller passing it to stop() would kill an unrelated SFX voice.
|
|
1046
|
+
* @returns {number} handle >= 0, -2 (track started), or -1 (skipped)
|
|
1047
|
+
*/
|
|
1048
|
+
playUnique(name, thresholdMs = 100) {
|
|
1049
|
+
if (this._destroyed) return -1;
|
|
1050
|
+
const now = (typeof performance !== 'undefined' && performance.now)
|
|
1051
|
+
? performance.now()
|
|
1052
|
+
: this._ctx.currentTime * 1000;
|
|
1053
|
+
const last = this._lastPlayed.get(name) ?? -Infinity;
|
|
1054
|
+
if (now - last <= thresholdMs) return -1;
|
|
1055
|
+
this._lastPlayed.set(name, now);
|
|
1056
|
+
if (this._sounds.has(name)) return this.play(name);
|
|
1057
|
+
if (this._tracks.has(name)) { this.playTrack(name); return TRACK_STARTED; }
|
|
1058
|
+
return -1;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ---------- Track signal getters ---------------------------------------
|
|
1062
|
+
|
|
1063
|
+
trackLoadState(name) { return this._tracks.get(name)?.loadState; }
|
|
1064
|
+
trackPlaying(name) { return this._tracks.get(name)?.playing; }
|
|
1065
|
+
trackPosition(name) { return this._tracks.get(name)?.position; }
|
|
1066
|
+
trackDuration(name) { return this._tracks.get(name)?.duration; }
|
|
1067
|
+
|
|
553
1068
|
// ---------- Bus controls -----------------------------------------------
|
|
554
1069
|
|
|
555
1070
|
setBusVolume(busName, volume) {
|
|
@@ -647,6 +1162,30 @@ export class LiteAudio {
|
|
|
647
1162
|
if (busRec.pool) { try { busRec.pool.destroy(); } catch {} busRec.pool = null; }
|
|
648
1163
|
}
|
|
649
1164
|
|
|
1165
|
+
// Tear down every track: cancel pending pause, remove element handlers,
|
|
1166
|
+
// pause the <audio>, disconnect the MediaElementSource + gains.
|
|
1167
|
+
for (const [, rec] of this._tracks) {
|
|
1168
|
+
if (rec.pauseTimer != null) { this._clearTimeout(rec.pauseTimer); rec.pauseTimer = null; }
|
|
1169
|
+
if (rec.element) {
|
|
1170
|
+
if (rec.timeupdateHandler) {
|
|
1171
|
+
try { rec.element.removeEventListener('timeupdate', rec.timeupdateHandler); } catch {}
|
|
1172
|
+
}
|
|
1173
|
+
if (rec.endedHandler) {
|
|
1174
|
+
try { rec.element.removeEventListener('ended', rec.endedHandler); } catch {}
|
|
1175
|
+
}
|
|
1176
|
+
try { rec.element.pause(); } catch {}
|
|
1177
|
+
// Drop the stream. A paused <audio> that still has a src can keep
|
|
1178
|
+
// buffering, and the element is unreachable after _tracks.clear().
|
|
1179
|
+
try {
|
|
1180
|
+
rec.element.removeAttribute('src');
|
|
1181
|
+
rec.element.load();
|
|
1182
|
+
} catch {}
|
|
1183
|
+
}
|
|
1184
|
+
try { rec.source?.disconnect(); } catch {}
|
|
1185
|
+
try { rec.xfadeGain?.disconnect(); } catch {}
|
|
1186
|
+
try { rec.volumeGain?.disconnect(); } catch {}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
650
1189
|
for (const h of this._effectHandles) {
|
|
651
1190
|
try { dispose(h); } catch {}
|
|
652
1191
|
}
|
|
@@ -663,6 +1202,8 @@ export class LiteAudio {
|
|
|
663
1202
|
this._sounds.clear();
|
|
664
1203
|
this._soundsByBus.clear();
|
|
665
1204
|
this._pendingPlays.clear();
|
|
1205
|
+
this._tracks.clear();
|
|
1206
|
+
this._lastPlayed.clear();
|
|
666
1207
|
this._master = null;
|
|
667
1208
|
this._ctx = null;
|
|
668
1209
|
}
|