@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.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. Useful for HUDs and for asserting in
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
- * Voices currently sounding on a bus, or across every bus when called with no
520
- * argument. Allocation-free; safe to call every frame.
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
- /** Stop every voice on a named bus. */
539
- stopBus(busName) {
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?.pool) busRec.pool.stopAll();
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
- /** Stop every voice across every bus. */
546
- stopAll() {
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
  }