@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/CHANGELOG.md CHANGED
@@ -1,5 +1,195 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ The music layer. Streaming tracks via `MediaElementAudioSourceNode` share the
6
+ same bus graph as SFX; the crossfade is the mixer, the buses are the routing,
7
+ the pool is untouched.
8
+
9
+ Not purely additive after all. Putting tracks on the buses turned three
10
+ existing behaviours into bugs — `stopAll()` that leaves the music playing,
11
+ `playUnique()` that hands back a live SFX handle to say "the track started",
12
+ `resumeTrack()` that plays silence — and surfaced one that predates the music
13
+ layer entirely: SFX handles did not carry their bus. See **Fixed**.
14
+
15
+ ### Added
16
+
17
+ - **`defineTracks(config)`** — fetch + attach `<audio>` elements + register
18
+ per-track signals (loadState, playing, position, duration). Same async
19
+ shape as `defineSounds`. Config accepts `loop`, `loopStart`, `loopEnd`
20
+ alongside `src`, `bus`, `volume`. Format fallback via `canPlayType` over
21
+ the src array, same probe as SFX.
22
+ - **`playTrack(name, opts?)`** — starts or resumes a track. Idempotent per
23
+ name; playing an already-playing track is a no-op unless `restart: true`
24
+ is set. `fadeIn` (ms) schedules an equal-power ramp; `position` (seconds)
25
+ seeks before play. Silent no-op if the context is still locked or the
26
+ track is not `ready`.
27
+ - **`stopTrack(name, { fade? })`** — schedules a fade-out on the track's
28
+ xfadeGain. The `playing` signal flips false immediately (HUDs update); the
29
+ `<audio>` element is paused after the fade so decoding stops. Default
30
+ fade 200 ms.
31
+ - **`pauseTrack(name)` / `resumeTrack(name)`** — pause without losing
32
+ position; resume picks up where paused. Distinct from stop/play in that
33
+ no fade is scheduled and the graph stays live.
34
+ - **`crossfade(from, to, durationMs)`** — equal-power ramp on both sides.
35
+ Either side may be `null` for fade-out-only or fade-in-only. Curves are
36
+ scaled from the outgoing/incoming track's current xfadeGain value, so a
37
+ mid-flight retarget starts from where the automation actually is and has
38
+ no discontinuity.
39
+ - **Interruption semantics (case c).** A track fading out from a previous
40
+ crossfade keeps its scheduled fade. Only tracks named in the new call
41
+ are touched. A track that was fading IN and is now the OUTGOING of the
42
+ new call reads its current gain, cancels its schedule, and starts an
43
+ equal-power fade-out from that value.
44
+ - **`playExclusive(name, opts?)`** — starts `name`, fades every other
45
+ playing track on `name`'s bus. Bus-scoped, not category-scoped: buses are
46
+ the physical version of the manager's categories, so an exclusive on the
47
+ music bus leaves SFX and voice untouched.
48
+ - **`playUnique(name, thresholdMs = 100)`** — manager parity. Timestamp
49
+ gate keyed by name via `Map<string, timestamp>`. Works on both registered
50
+ sounds (dispatches to `play`) and tracks (dispatches to `playTrack`).
51
+ Returns `-1` on threshold rejection or unknown name.
52
+ - **New reactive readouts.** `trackLoadState(name)`, `trackPlaying(name)`,
53
+ `trackPosition(name)`, `trackDuration(name)`.
54
+
55
+ ### Changed
56
+
57
+ - **Default bus set.** `LiteAudioOptions.buses` now defaults to
58
+ `['sfx', 'ui', 'voice', 'music']` so `defineTracks` works with its default
59
+ routing out of the box. Explicit `buses` config still overrides.
60
+
61
+ ### Fixed
62
+
63
+ - **SFX handles did not name a bus.** A pool handle is a full uint32
64
+ (`[gen:24][channel:8]`) with no spare bits, and every bus runs its own pool
65
+ counting channels and generations from zero. So the first play on *every* bus
66
+ returned the identical handle — `0x00000000` — and `stop()` broadcast that raw
67
+ value to every pool, where each generation check happily passed. Stopping an
68
+ `sfx` voice also killed whatever sat on channel 0 of `ui`, `voice`, and
69
+ `music`. The generation counter cannot prevent this: it is a recycle counter,
70
+ not a namespace. `play()` now returns `busIndex * 2^32 + poolHandle`; `stop()`
71
+ resolves the owning pool in O(1) and cannot cross a bus boundary. Handles stay
72
+ plain opaque numbers, exact well inside 2^53.
73
+ - **`stopAll()` and `stopBus()` ignored music.** Both stopped pool voices only,
74
+ so a scene change that called `stopAll()` left the old theme playing under the
75
+ new scene. Both now also fade out the tracks routed to the buses they name
76
+ (`opts.fade`, default 200 ms). "Stop every voice on every bus" has to mean the
77
+ bus, now that tracks live on it.
78
+ - **`playUnique()` returned `0` for a track.** `0` is not a null handle — it is
79
+ channel 0, generation 0, bus 0: a perfectly good voice. A caller doing
80
+ `const h = playUnique('theme'); ... stop(h)` killed an unrelated SFX voice.
81
+ Tracks are singletons addressed by name and have no handle, so a track start
82
+ now reports `-2`, which is inert to `stop()` and distinguishable from the `-1`
83
+ that already meant "skipped".
84
+ - **`resumeTrack()` after `stopTrack()` played silence.** `stopTrack()` fades
85
+ `xfadeGain` to zero; `resumeTrack()` restarted the element and set
86
+ `playing` to `true` without ever restoring the gain. The result was a track
87
+ that was decoding, reporting itself as playing, and completely inaudible — a
88
+ signal that lied. `resumeTrack()` now lifts the gain back to full over a 40 ms
89
+ equal-power ramp (a no-op when it is already there) and disarms any pause left
90
+ queued behind the earlier fade.
91
+ - **`destroy()` left the `<audio>` elements holding their streams.** Handlers
92
+ were removed and the element paused, but a paused element that still has a
93
+ `src` can keep buffering, and after `_tracks.clear()` nothing could reach it to
94
+ stop. Teardown now releases the source (`removeAttribute('src')` + `load()`).
95
+
96
+ ### Internal
97
+
98
+ - **Equal-power curves precomputed at module load.** Two 128-sample
99
+ `Float32Array`s (`EQ_POWER_IN`, `EQ_POWER_OUT`). Per-crossfade allocation
100
+ is one scaled `Float32Array` per side (512 bytes each), GC'd after the
101
+ curve executes. Off the hot path — scene transitions, not frame loops.
102
+ - **Position writes are throttled at the source.** A per-track
103
+ `lastPositionWrite` timestamp on the ctx clock; a `timeupdate` listener
104
+ writes to the position signal only if 100 ms of ctx time have passed
105
+ since the last write. No `setInterval`, no rAF loop, no allocation.
106
+ - **Delayed pause after stopTrack.** `<audio>.pause()` is deferred until
107
+ `fade + 30 ms` via injectable `setTimeout`, so the fade tail is audible.
108
+ Tests inject a manual scheduler and call `flush()` to make teardown
109
+ deterministic.
110
+
111
+ ### Mock harness (test/mock-ctx.js) extensions
112
+
113
+ - **`mockParam` params now follow their automation.** Every scheduling call
114
+ still records its shape into `.events`, and now also settles `.value` on the
115
+ value it is heading for (including a new `setValueCurveAtTime`). Recording
116
+ alone was not enough: it let a test prove a fade-out was *scheduled* while
117
+ saying nothing about where the gain ended up — and "scheduled a fade-out"
118
+ plus "still audible" is precisely the shape of the `resumeTrack` bug fixed
119
+ above. A harness that cannot express the bug cannot catch it. Tests that care
120
+ about the ramp read `.events`; tests that care about the outcome read `.value`.
121
+ - `mockMediaElementSource(element)` factory, and `ctx.createMediaElementSource(el)`
122
+ on the mock context. The node keeps a reference to the element it was built
123
+ from, so a test can assert the graph was wired to the right track.
124
+ - `mockAudioElement(src?)` factory: `play` / `pause` / `load` /
125
+ `removeAttribute`, settable `currentTime`, `duration`, `loop`, `paused`,
126
+ `playCalls` / `pauseCalls` / `loadCalls` / `srcReleased` counters, and
127
+ `_fire(type)` / `_listenerCount(type)` so tests can dispatch `timeupdate`,
128
+ `ended`, and `loadedmetadata` by hand.
129
+ - `mockDocument()` — hands out `<audio>` elements and records them in order, so
130
+ "one element per track" is an assertion rather than a hope.
131
+ - `mockScheduler()` — manual `setTimeout` / `clearTimeout` for
132
+ `opts.setTimeout` / `opts.clearTimeout`. `stopTrack` defers the element pause
133
+ until after the fade; with a real timer that is a race, and with this it is an
134
+ assertion.
135
+
136
+ Every v1.0.0 test consumes the harness unchanged.
137
+
138
+ ### Test coverage (v1.1.0)
139
+
140
+ **75 tests across 18 suites, all green.** 32 carried from v1.0.0 unchanged, plus:
141
+
142
+ `test/Tracks.test.js` — 37 tests:
143
+
144
+ - **defineTracks** (6): ready state, one `<audio>` element per track,
145
+ unknown-bus throw, idempotent re-definition, error when no source resolves,
146
+ duration from `loadedmetadata` and error from the element.
147
+ - **playTrack** (7): graph wired `source -> xfade -> volume -> bus` with the
148
+ track's volume on `volumeGain`, element starts and `playing` flips, no-op
149
+ while locked, no-op when never loaded, idempotent unless `restart`, seeks to
150
+ `position`, `fadeIn` schedules an equal-power curve of the right duration.
151
+ - **stopTrack** (3): `playing` flips at once while the element keeps decoding
152
+ through the fade tail, the pause lands only on scheduler flush, `fade: 0`
153
+ drops the gain in one step with no curve, stopping an idle track arms nothing.
154
+ - **pauseTrack / resumeTrack** (4): pause preserves position and does not touch
155
+ the gain; **resume after `stopTrack` restores the gain instead of playing
156
+ silence**; resume disarms the pause queued behind the fade; resume refuses
157
+ while locked or unloaded.
158
+ - **Looping and position** (4): native `element.loop` when there are no custom
159
+ points and disabled when there are, `timeupdate` seek back to `loopStart` on
160
+ crossing `loopEnd`, position signal throttled to 100 ms of context time,
161
+ `ended` flips `playing` without tearing the graph down.
162
+ - **Crossfade** (5): both sides scheduled from the same instant over the same
163
+ duration; **combined power holds at 1.0 across every sample** (a linear fade
164
+ would dip to 0.71); one-sided fades in each direction; a mid-flight retarget
165
+ starts from the gain's current value with no jump.
166
+ - **playExclusive / playUnique** (3): exclusive fades bus siblings and leaves
167
+ other buses alone; **`playUnique` returns `-2` for a track and cannot be
168
+ mistaken for handle `0`**; threshold gating and unknown names.
169
+ - **Bus-wide stops** (2): `stopAll()` reaches the music; `stopBus()` stays
170
+ scoped to the bus it names.
171
+ - **Teardown** (3): `destroy()` releases the elements (`removeAttribute('src')`
172
+ + `load()`), removes handlers, disconnects the track nodes, clears the pending
173
+ pause timer, and is idempotent and inert afterwards.
174
+
175
+ `test/BusHandles.test.js` — 6 tests: handles from different buses are never
176
+ equal while their raw pool handles are identical, `stop()` cannot cross a bus
177
+ boundary, `busOf()`, stolen-handle staleness, `activeCount()` per-bus and
178
+ engine-wide, and handles staying exact integers well inside 2^53.
179
+
180
+ Not covered, and honestly so: **format fallback**. `pickSupportedSrc()` probes
181
+ the real `document` / `Audio` globals rather than the injected ones, so under
182
+ `node:test` it always takes the "no `<audio>` available, first URL wins" branch.
183
+ Testing it would mean either injecting the probe or polluting globals; the
184
+ branch it actually runs is covered, the `canPlayType` path is not.
185
+
186
+ ### Deliberately deferred to A3 (v1.2.0)
187
+
188
+ - Ducking (sfx/voice activity dips the music bus via a follower ramp)
189
+ - Mix snapshots (`captureSnapshot()` / `applySnapshot(name, ms)`)
190
+ - Auto-suspend (D9)
191
+ - Per-bus `AnalyserNode` meter signals (opt-in)
192
+
3
193
  ## 1.0.0
4
194
 
5
195
  Initial release. Ships the SFX layer of the lite-audio stack: signal-driven
@@ -15,95 +205,15 @@ buses over one `AudioPool` per bus, iOS/mobile unlock ported verbatim from
15
205
  — click-free by construction, no manual ramp code in userland.
16
206
  - **Unlock ported verbatim (D3).** Silent-buffer pulse + `ctx.resume()` on the
17
207
  first `touchstart` / `touchend` / `mousedown` / `keydown`, capture-phase,
18
- behind an `AbortController` same event set and same attach shape as the
19
- manager. Handles `'interrupted'` state (iOS phone-call scenario) as
20
- equivalent to `'suspended'`. Added: `ctxState()` and `unlocked()` as signals
21
- the rest of the app can subscribe to.
208
+ behind an `AbortController`. Handles `'interrupted'` state (iOS phone-call
209
+ scenario) as equivalent to `'suspended'`.
22
210
  - **Bounded pre-unlock play queue (D3 extension).** `play()` before unlock
23
- returns `-1` and enqueues the intent. Same-sound repeats collapse to
24
- latest-per-sound; new distinct sounds past `queueLimit` are silently dropped.
25
- Flushed on unlock. The manager's silent-drop behavior was the #1 mobile-game
26
- paper cut Howler could not fix from the outside; lite-audio fixes it from
27
- the inside.
28
- - **Reactive loader (D4).** `defineSounds(config)` fetches + decodes each
29
- sound; a `loadState(id)` signal per sound transitions
30
- `'idle' -> 'loading' -> 'ready' | 'error'` observably. Format fallback via
31
- `canPlayType` probe over the `src` array. Fetch is injectable for tests.
32
- - **Positional hot path (D5).** `play(soundId, volume, pan, pitch)` — no
33
- options object per call. `playOpts(id, {...})` sugar layer above handles
34
- `pitchVar` random-pitch resolution.
35
- - **Bus-tagged voice handles (D5/D6).** `play()` returns
36
- `busIndex * 2^32 + poolHandle`. The tag is not decoration: every bus runs its
37
- own `AudioPool`, and every pool counts channels and generations from zero, so
38
- the first play on *each* bus returns the identical raw handle
39
- (`0x00000000` - channel 0, generation 0). A handle alone cannot say which bus
40
- issued it, and the pool's generation check cannot help, because it is a
41
- recycle counter, not a namespace. With the tag, `stop()` resolves the owning
42
- pool in O(1) and cannot reach across a bus boundary. Handles stay plain
43
- numbers, exact well inside 2^53, opaque to callers.
44
- - **Fades delegated to pool (D6).** `stop(handle)` routes to the handle's own
45
- bus; the generation check inside that pool means a stale handle after a steal
46
- is a silent no-op, never a wrong-voice hit.
47
- - **`isPlaying(handle)`, `busOf(handle)`, `activeCount(busName?)`.** Liveness of
48
- one voice, the bus that issued a handle, and the live voice count per bus or
49
- engine-wide. Allocation-free; `activeCount()` is safe to call every frame.
50
- - **Master mute persistence (D7).** localStorage key `'lite_audio_muted'` -
51
- byte-identical to `lite-audio-manager` so a game migrating between the two
52
- keeps the player's saved preference intact.
53
- - **Mock-ctx test harness (D8).** `test/mock-ctx.js` records every
54
- `AudioParam` scheduled event into an `.events` array, runs a real state
55
- machine covering all four `AudioContextState` values, and mocks
56
- `decodeAudioData` + `fetch` with length-hint payloads so different mock
57
- URLs decode to different-sized buffers deterministically. Foundation for
58
- every later lite-audio session.
59
-
60
- ### Deliberately deferred (roadmap D10)
61
-
62
- - **Music streaming layer.** `MediaElementAudioSourceNode`, crossfades,
63
- reactive `position()` / `duration()`, `playExclusive` / `playUnique` -
64
- ships in **v1.1.0** (roadmap session A2).
65
- - **Ducking, mix snapshots, per-bus meters, auto-suspend.** Ships in
66
- **v1.2.0** (roadmap session A3).
67
- - **`./compat` shim.** `AudioManager`-shaped adapter over `LiteAudio` for
68
- drop-in migration, plus full parity certification. Ships in **v2.0.0**
69
- (roadmap session A4).
70
- - **3D spatial (PannerNode/HRTF), AudioWorklet custom DSP, convolution
71
- reverb.** Post-2.0.
72
-
73
- ### Demo
74
-
75
- - New `demo/index.html`: single-file, four scenes, no asset folder - every sound
76
- is synthesized in an `OfflineAudioContext`, encoded to WAV, and served to the
77
- engine as a `blob:` URL, so the real fetch + decode path runs.
78
- **unlock** - the context lifecycle as an SVG state machine driven by
79
- `ctxState()`, plus the pre-unlock queue: fire plays while locked and watch
80
- latest-per-sound collapse and the `queueLimit` drop, then dispatch the gesture
81
- and hear the flush. The engine takes a *fake window* through `opts.window`, so
82
- the demo owns the gesture instead of racing it - the one-shot feature becomes
83
- replayable. **loader** - `loadState()` per sound, with one asset behind 2.6s of
84
- injected fetch latency and one pointed at a 404, so `loading` and `error` are
85
- states you can actually watch. **mixer** - bus faders and mutes as signals,
86
- with a gain scope plotting the real `GainNode.value` against the signal target:
87
- the 10ms `setTargetAtTime` bend is the click you do not hear. **voices** - the
88
- per-bus pools, and the twin-handle trap: two buses hand back the same raw
89
- handle, and only the bus tag keeps `stop()` from killing both.
90
-
91
- ### Test coverage
92
-
93
- 38 tests across 10 suites, all green. Gate items from the roadmap:
94
-
95
- - Unlock state machine including `'interrupted'` — 6 tests
96
- - Loader fallback + error - 5 tests
97
- - Bus effect writes as `setTargetAtTime` on the mock ctx - 4 tests
98
- - Steal + declick schedule shape (delegated) - 1 test
99
- - Generation no-op on stale handles (delegated) - covered in 2 playback tests
100
- - Unlock queue flush (latest-per-sound, bounded) - 3 tests
101
- - Zero-GC hot path shape - 1 test
102
- - Master mute persistence — 3 tests
103
- - Playback delegation to pool — 6 tests
104
- - destroy() idempotency + listener detachment - 3 tests
105
-
106
- - Handle namespace across buses - 6 tests (`test/BusHandles.test.js`)
211
+ returns `-1` and enqueues the intent (latest-per-sound, bounded).
212
+ - **Reactive loader (D4).** `defineSounds(config)` with per-sound
213
+ `loadState()` signal transitions and format fallback.
214
+ - **Positional hot path (D5) and pool-delegated stops (D6).**
215
+ - **Master mute persistence (D7).** localStorage key `'lite_audio_muted'`.
216
+ - **Mock-ctx test harness (D8).**
107
217
 
108
218
  ### Peer dependency pins
109
219
 
package/README.md CHANGED
@@ -54,7 +54,18 @@ await audio.defineSounds({
54
54
  // flushed on the first touchstart / mousedown / keydown otherwise. No polling,
55
55
  // no "waiting for unlock" ceremony in the caller.
56
56
  const handle = audio.play('laser', 0.8, 0.0, 1.0);
57
- audio.stop(handle);
57
+ audio.stop(handle); // stale handles are silent no-ops
58
+ audio.isPlaying(handle); // false once stolen, stopped, or played out
59
+
60
+ // Music: streamed, not decoded. Singletons, addressed by name.
61
+ await audio.defineTracks({
62
+ menu: { src: ['/menu.mp3'], bus: 'music', loop: true },
63
+ boss: { src: ['/boss.mp3'], bus: 'music', loop: true, loopStart: 8, loopEnd: 96 },
64
+ });
65
+
66
+ audio.playTrack('menu', { fadeIn: 600 });
67
+ audio.crossfade('menu', 'boss', 800); // equal-power, both sides, no power dip
68
+ audio.trackPosition('boss'); // ReadSignal<number>, throttled to 10 Hz
58
69
 
59
70
  // Reactive state - subscribe with lite-signal's effect() for UI hookup.
60
71
  audio.unlocked(); // ReadSignal<boolean>
@@ -64,9 +75,28 @@ audio.busVolume('sfx'); // ReadSignal<number>
64
75
  audio.setBusVolume('sfx', 0.5); // schedules setTargetAtTime, click-free
65
76
  audio.setMuted(true); // master mute, persists to localStorage
66
77
 
78
+ audio.stopAll(); // every voice AND every track
67
79
  audio.destroy(); // idempotent, disconnects the graph
68
80
  ```
69
81
 
82
+ ## Handles
83
+
84
+ `play()` returns a **bus-tagged** handle: `busIndex * 2^32 + poolHandle`. Treat it
85
+ as opaque — get it from `play()`, pass it to `stop()` / `isPlaying()`.
86
+
87
+ The tag is not decoration. A pool handle is a full `uint32` — `[gen:24][channel:8]`,
88
+ no spare bits — and every bus runs its own `AudioPool` counting channels and
89
+ generations from zero. So the first play on *every* bus hands back the identical
90
+ raw handle, `0x00000000`. Without the tag, `stop()` on an `sfx` handle also killed
91
+ whatever sat on channel 0 of `ui`, `voice`, and `music`. The generation counter
92
+ cannot prevent that: it is a recycle counter, not a namespace. With the tag,
93
+ `stop()` resolves the owning pool in O(1) and cannot cross a bus boundary.
94
+
95
+ Handles stay plain numbers, exact well inside `2^53`, so `-1` still means "no voice"
96
+ (unknown sound, not loaded, or context locked). `playUnique()` on a *track* returns
97
+ `-2`: tracks are name-addressed singletons with no handle, and `0` was never
98
+ available as a "nothing" value — it is a real voice.
99
+
70
100
  ## Why not Howler?
71
101
 
72
102
  Howler.js is a good general-purpose audio library. It handles decoding, HTML5
@@ -111,6 +141,39 @@ bus-tagged handles (`busIndex * 2^32 | (gen << 8) | channel`); a stale
111
141
  listeners behind an `AbortController`), plus a bounded pre-unlock play queue
112
142
  so a call fired before the first user gesture does not vanish.
113
143
 
144
+ ## Music layer
145
+
146
+ SFX are decoded into memory and fired through a pool; a five-minute track would be
147
+ ~50 MB of PCM for that privilege. Tracks are streamed instead —
148
+ `MediaElementAudioSourceNode` over an `<audio>` element — and routed into the *same*
149
+ bus graph, so one master mute and one set of faders govern both.
150
+
151
+ Each track is a singleton addressed by name, with its own two-gain chain:
152
+
153
+ ```
154
+ <audio> -> MediaElementSource -> xfadeGain -> volumeGain -> bus.gain -> master
155
+ ```
156
+
157
+ `xfadeGain` carries crossfade curves (0..1); `volumeGain` carries the track's
158
+ baseline volume. They are separate on purpose: a crossfade must not clobber the
159
+ mix, and changing the mix mid-crossfade must not fight the curve.
160
+
161
+ | Call | Does |
162
+ | --- | --- |
163
+ | `playTrack(name, { fadeIn?, position?, restart? })` | Start or restart. Idempotent. No-op while locked — music is scene-scale, so it is *not* queued the way SFX are. |
164
+ | `stopTrack(name, { fade? })` | Equal-power fade out, then pause the element so the browser stops decoding. `playing` flips to `false` at once; the tail is an audio detail, not a state a HUD should show. |
165
+ | `pauseTrack(name)` / `resumeTrack(name)` | Pause without losing position. `resumeTrack()` also restores a gain that an earlier `stopTrack()` faded away — otherwise it would decode, report itself playing, and be inaudible. |
166
+ | `crossfade(from, to, ms)` | Equal-power both sides. Either side may be `null`. A retarget mid-fade starts from where the gain actually is, so there is no discontinuity. |
167
+ | `playExclusive(name, { fade? })` | Start `name`, fade every other playing track **on the same bus**. Other buses are untouched. |
168
+ | `stopBus(name, { fade? })` / `stopAll({ fade? })` | Stop the pool voices *and* fade the tracks routed there. Once music lives on a bus, "stop the bus" has to mean the bus. |
169
+
170
+ Loop points: `loop: true` alone uses the native `element.loop`. Add `loopEnd` and the
171
+ engine takes over — `timeupdate` seeks back to `loopStart` on crossing it, and the
172
+ native loop is switched off (it would jump to `0`, not to `loopStart`). Note that
173
+ `timeupdate` fires roughly four times a second, so a custom loop can overshoot by up
174
+ to ~250 ms. For a tight musical loop, prefer an asset whose file boundaries *are* the
175
+ loop.
176
+
114
177
  ## Signal readouts (the whole reactive surface)
115
178
 
116
179
  | Signal | Read via | Notes |
@@ -121,6 +184,10 @@ so a call fired before the first user gesture does not vanish.
121
184
  | Bus volume | `audio.busVolume(name)` | 0..1 (or higher; not clamped) |
122
185
  | Bus mute | `audio.busMuted(name)` | Independent of master mute |
123
186
  | Load state | `audio.loadState(id)` | `'idle' \| 'loading' \| 'ready' \| 'error'` |
187
+ | Track load state | `audio.trackLoadState(name)` | Same four states |
188
+ | Track playing | `audio.trackPlaying(name)` | Flips `false` the instant a stop is *asked for*, not when the fade ends |
189
+ | Track position | `audio.trackPosition(name)` | Seconds, written at most every 100 ms |
190
+ | Track duration | `audio.trackDuration(name)` | `0` until `loadedmetadata` lands |
124
191
 
125
192
  All readable via `signal()` (calling), `signal.peek()` (untracked read), or
126
193
  inside a `computed()` / `effect()`.
@@ -129,13 +196,15 @@ inside a `computed()` / `effect()`.
129
196
 
130
197
  ```js
131
198
  new LiteAudio({
132
- buses: ['sfx', 'ui', 'voice'], // user-facing buses
199
+ buses: ['sfx', 'ui', 'voice', 'music'], // user-facing buses
133
200
  poolCapacity: 32, // voices per bus pool
134
201
  queueLimit: 32, // bound on pre-unlock queue
135
202
  mutedStorageKey: 'lite_audio_muted', // manager parity default
136
203
  fetch: globalThis.fetch, // injectable for tests
137
204
  window: globalThis.window, // ditto
138
205
  document: globalThis.document, // ditto
206
+ setTimeout: globalThis.setTimeout, // ditto (deferred track pause)
207
+ clearTimeout: globalThis.clearTimeout, // ditto
139
208
  });
140
209
  ```
141
210
 
@@ -156,19 +225,26 @@ notes for that.
156
225
  npm test
157
226
  ```
158
227
 
159
- 32 tests across 9 suites, covering unlock state machine (including
160
- `'interrupted'`), loader fallback + error, bus signal writes as
161
- `setTargetAtTime` on a mock AudioContext, pool delegation (steal, generation
162
- no-op on stale handles, stopBus scope), unlock queue flush semantics
163
- (latest-per-sound, bounded), and destroy idempotency + listener detachment.
164
-
165
- The mock harness (`test/mock-ctx.js`) records every `AudioParam` scheduled
166
- event into an inspectable `.events` array, runs a real context state machine
167
- (all four states `suspended`, `running`, `interrupted`, `closed`), and
168
- mocks `fetch` + `decodeAudioData` with length-hint payloads so tests can
169
- distinguish sounds deterministically. This harness is the D8 foundation the
170
- next roadmap sessions will reuse for the music layer, ducking, and parity
171
- certification.
228
+ 75 tests across 18 suites. The unlock state machine (including `'interrupted'`),
229
+ loader fallback + error, bus writes as `setTargetAtTime`, pool delegation (steal,
230
+ generation no-op on stale handles, bus scope), unlock queue semantics
231
+ (latest-per-sound, bounded), destroy idempotency plus the whole music layer
232
+ (`test/Tracks.test.js`) and the bus-handle namespace (`test/BusHandles.test.js`).
233
+
234
+ The mock harness (`test/mock-ctx.js`) records every `AudioParam` scheduled event
235
+ into an inspectable `.events` array **and settles `.value` on whatever the
236
+ automation is heading for**. That second half matters more than it sounds: a
237
+ harness that only records schedules can prove a fade-out was *scheduled* while
238
+ saying nothing about whether the gain ended up silent — and "scheduled a fade-out"
239
+ plus "still audible" is exactly the shape of a real bug this suite now catches. It
240
+ also runs a real context state machine (all four states), mocks `fetch` +
241
+ `decodeAudioData` with length-hint payloads, and hands out `<audio>` elements and
242
+ a manual timer scheduler so the deferred pause behind a track fade is an assertion
243
+ rather than a race.
244
+
245
+ Not covered, and honestly so: `pickSupportedSrc()` probes the real `document` /
246
+ `Audio` globals rather than the injected ones, so under `node:test` it always takes
247
+ the "first URL wins" branch. The `canPlayType` path is unexercised.
172
248
 
173
249
  ## License
174
250
 
package/llms.txt CHANGED
@@ -1,5 +1,5 @@
1
1
  # @zakkster/lite-audio
2
- > Zero-GC reactive Web Audio engine. Signal-driven buses, ABA-safe voice handles, unlock queue, per-bus AudioPool. SFX layer over @zakkster/lite-audio-pool.
2
+ > Zero-GC reactive Web Audio engine. Signal-driven buses, ABA-safe voice handles, unlock queue, per-bus AudioPool, streaming music tracks with equal-power crossfade.
3
3
 
4
4
  ## Install
5
5
  npm i @zakkster/lite-audio @zakkster/lite-signal @zakkster/lite-audio-pool
@@ -9,44 +9,49 @@ import { LiteAudio } from '@zakkster/lite-audio';
9
9
 
10
10
  ## Key Facts
11
11
  - Zero deps of its own; two peers: @zakkster/lite-signal (^1.3), @zakkster/lite-audio-pool (^1.1)
12
- - Signal-driven control surface: bus volumes, master mute, ctx state, per-sound load state are all reactive
12
+ - Signal-driven control surface: bus volumes, master mute, ctx state, per-sound and per-track load state are all reactive
13
13
  - Bus writes go through setTargetAtTime for click-free transitions (10 ms time constant)
14
14
  - Pre-unlock play() calls are queued (bounded, latest-per-sound), flushed on first user gesture
15
15
  - iOS/Safari unlock: silent-buffer pulse + resume() on touchstart/touchend/mousedown/keydown (capture phase, exact set the manager uses)
16
16
  - Handles 'interrupted' state (iOS phone-call scenario) the same as 'suspended'
17
17
  - Master mute persists to localStorage under 'lite_audio_muted' (byte-identical to lite-audio-manager for migration)
18
- - One AudioPool per bus (pool v1.1.0+ output param + per-play buffer override)
19
- - Positional hot path: play(soundId, volume, pan, pitch) - no options object per call
20
- - Handles are bus-tagged: busIndex * 2^32 + poolHandle. Pool handles collide across buses (every pool starts at channel 0 / generation 0), so the tag is what makes stop() name one voice
21
- - v1.0.0 covers SFX only; music streaming layer (MediaElementAudioSourceNode) lands in v1.1.0
18
+ - One AudioPool per bus for SFX (pool v1.1.0+ output param + per-play buffer override)
19
+ - Music tracks stream via MediaElementAudioSourceNode routed through the same bus graph
20
+ - Positional hot path for SFX: play(soundId, volume, pan, pitch) - no options object per call
21
+ - SFX handles are bus-tagged: busIndex * 2^32 + poolHandle. Raw pool handles collide across buses (every pool starts at channel 0 / generation 0), so the tag is what makes stop() name one voice
22
+ - Music tracks are singletons addressed by name, not handle - stopTrack('menu_bg') not stop(handle)
23
+ - v1.1.0 ships SFX + music (crossfade, exclusive, unique, loop points). Ducking / snapshots / auto-suspend are v1.2.0; Howler-shim compat subpath is v2.0.0.
22
24
 
23
25
  ## Quick Start
24
26
  ```js
25
27
  import { LiteAudio } from '@zakkster/lite-audio';
26
28
 
27
- const audio = new LiteAudio({ buses: ['sfx', 'ui', 'voice'] });
28
- await audio.init(); // creates AudioContext
29
+ const audio = new LiteAudio(); // buses default to sfx/ui/voice/music
30
+ await audio.init();
31
+
32
+ // SFX
29
33
  await audio.defineSounds({
30
34
  laser: { src: ['/laser.opus', '/laser.mp3'], bus: 'sfx' },
31
- hit: { src: ['/hit.wav'], bus: 'sfx', pitchVar: 0.15 },
32
35
  click: { src: ['/click.mp3'], bus: 'ui' },
33
36
  });
34
-
35
- // Fires immediately if unlocked; queued and flushed on first gesture otherwise.
36
37
  const h = audio.play('laser', 0.8, 0.0, 1.0);
37
38
  audio.stop(h);
38
- audio.isPlaying(h); // false once stolen, stopped, or played out
39
+
40
+ // Music
41
+ await audio.defineTracks({
42
+ menu: { src: ['/menu.mp3'], bus: 'music' },
43
+ gameplay: { src: ['/gameplay.mp3'], bus: 'music',
44
+ loop: true, loopStart: 4.5, loopEnd: 92 },
45
+ });
46
+ audio.playTrack('menu', { fadeIn: 400 });
47
+ audio.crossfade('menu', 'gameplay', 500);
48
+ audio.stopTrack('gameplay', { fade: 300 });
39
49
 
40
50
  // Reactive readouts
41
51
  audio.unlocked(); // ReadSignal<boolean>
42
- audio.ctxState(); // ReadSignal<'suspended'|'running'|'interrupted'|'closed'>
43
- audio.loadState('laser'); // ReadSignal<'idle'|'loading'|'ready'|'error'>
44
- audio.busVolume('sfx'); // ReadSignal<number>
45
-
46
- // Writes
47
- audio.setBusVolume('sfx', 0.5);
48
- audio.setBusMuted('ui', true);
49
- audio.setMuted(true); // master, persists to localStorage
52
+ audio.trackPlaying('gameplay'); // ReadSignal<boolean>
53
+ audio.trackPosition('gameplay'); // ReadSignal<number>, throttled ~10 Hz
54
+ audio.setMuted(true); // master mute, persists to localStorage
50
55
 
51
56
  audio.destroy(); // idempotent
52
57
  ```
@@ -57,50 +62,56 @@ audio.destroy(); // idempotent
57
62
  ```
58
63
  new LiteAudio(opts?)
59
64
  ```
60
- - `opts.buses?: string[]` - defaults ['sfx', 'ui', 'voice']. 'master' is implicit.
61
- - `opts.poolCapacity?: number` - defaults 32.
62
- - `opts.queueLimit?: number` - defaults 32.
65
+ - `opts.buses?: string[]` - defaults ['sfx', 'ui', 'voice', 'music']. 'master' is implicit.
66
+ - `opts.poolCapacity?: number` - defaults 32 (SFX per bus).
67
+ - `opts.queueLimit?: number` - defaults 32 (pre-unlock queue).
63
68
  - `opts.mutedStorageKey?: string` - defaults 'lite_audio_muted'.
64
69
  - `opts.fetch?, opts.window?, opts.document?` - test injectables.
70
+ - `opts.setTimeout?, opts.clearTimeout?` - test injectables for stopTrack teardown.
65
71
 
66
72
  ### async init(ctx?): Promise<void>
67
73
  Wires the engine to an AudioContext. Creates one via globalThis.AudioContext if none passed. Sets up master gain, bus gains, master-mute effect, per-bus volume+mute effects, statechange listener, unlock listeners.
68
74
 
69
- ### async defineSounds(config): Promise<void>
70
- Fetches and decodes every sound in the config. Config shape:
71
- ```
72
- { [soundId]: { src: string[], bus?: string, volume?: number, pitchVar?: number } }
73
- ```
74
- Resolves after every sound has settled. Load state per sound is a reactive signal readable via loadState(soundId). Pools are built (or rebuilt) once per touched bus at the end.
75
-
76
- ### play(soundId, volume?, pan?, pitch?): number
77
- Positional hot path. Returns a handle >= 0 on success, -1 on any skip (unknown sound, not-yet-ready sound, context locked). Locked plays are queued (latest-per-sound, bounded by queueLimit) and flushed on unlock.
75
+ ### SFX (v1.0.0)
78
76
 
79
- ### playOpts(soundId, opts?): number
80
- Sugar over play() with pitchVar resolution: `pitch = 1 + (rand*2-1)*pitchVar`.
77
+ async defineSounds(config): fetch + decodeAudioData every sound. Config `{ [id]: { src: string[], bus?, volume?, pitchVar? } }`.
78
+ play(soundId, volume?, pan?, pitch?): positional hot path, returns handle >= 0 or -1.
79
+ playOpts(soundId, opts?): sugar with pitchVar resolution.
80
+ stop(handle): stops a specific play. The handle carries its bus, so stop() resolves the owning pool in O(1) and can never reach across a bus boundary.
81
+ isPlaying(handle): boolean. busOf(handle): string|null. activeCount(busName?): live SFX voices per bus or engine-wide.
82
+ stopBus(name, opts?), stopAll(opts?): bus- and engine-wide stops. Both stop SFX voices AND fade out any music track routed to those buses (opts.fade, default 200ms).
83
+ loadState(id): ReadSignal<'idle'|'loading'|'ready'|'error'>.
81
84
 
82
- ### stop(handle): void
83
- Stops a specific play. Stale handles (channel stolen or already ended) are silent no-ops. The handle carries its bus, so stop() resolves the owning pool in O(1) and can never reach across a bus boundary.
85
+ ### Music (v1.1.0)
84
86
 
85
- ### isPlaying(handle): boolean, busOf(handle): string|null, activeCount(busName?): number
86
- Liveness of one voice, the bus that issued a handle, and the live voice count per bus or engine-wide.
87
+ async defineTracks(config): fetch + attach <audio> elements for streaming. Config `{ [name]: { src: string[], bus?, volume?, loop?, loopStart?, loopEnd? } }`.
88
+ playTrack(name, opts?): starts or resumes. Opts `{ fadeIn?, position?, restart? }`. Idempotent unless restart.
89
+ stopTrack(name, { fade? }): fades out, pauses element after fade. Playing signal flips false immediately.
90
+ pauseTrack(name) / resumeTrack(name): position preserved.
91
+ crossfade(from, to, durationMs): equal-power both sides. Either side can be null.
92
+ playExclusive(name, opts?): bus-scoped - fades other playing tracks on same bus.
93
+ playUnique(name, thresholdMs = 100): timestamp gate, works on sounds AND tracks. Returns a handle for a sound, -2 for a track (singleton, name-addressed), -1 on rejection or unknown. Never 0 for a track - 0 is a real handle.
94
+ trackLoadState(name), trackPlaying(name), trackPosition(name), trackDuration(name): per-track signals.
87
95
 
88
- ### stopBus(busName): void, stopAll(): void
89
- Bus- and engine-wide stops.
96
+ ### Crossfade semantics
97
+ - Equal-power curves precomputed at module load (128 samples each: EQ_POWER_IN, EQ_POWER_OUT). Per-call scaled Float32Array per side is the only allocation, off the hot path (scene transitions).
98
+ - Interruption (case c): only tracks named in the new call are touched. A track fading out from a previous crossfade keeps its schedule. A track being retargeted reads its current xfadeGain value and starts an equal-power ramp scaled from that value - no discontinuities.
99
+ - Graph: <audio> -> MediaElementSource -> xfadeGain (0..1 crossfade knob) -> volumeGain (baseline volume) -> bus.gain
90
100
 
91
- ### setBusVolume(name, v), setBusMuted(name, m), setMuted(state)
92
- Signal setters. Every write schedules a setTargetAtTime on the affected GainNode with a 10 ms time constant.
101
+ ### Bus + master
102
+ setBusVolume(name, v), setBusMuted(name, m), setMuted(state): signal setters. Every write schedules setTargetAtTime with a 10 ms time constant.
93
103
 
94
104
  ### Reactive readables
95
- `ctxState() : ReadSignal<CtxState>`
105
+ `ctxState() : ReadSignal<'suspended'|'running'|'interrupted'|'closed'>`
96
106
  `unlocked() : ReadSignal<boolean>`
97
107
  `muted() : ReadSignal<boolean>`
98
108
  `loadState(id) : ReadSignal<LoadState> | undefined`
109
+ `trackLoadState(name), trackPlaying(name), trackPosition(name), trackDuration(name) : ReadSignal | undefined`
99
110
  `busVolume(name), busMuted(name) : ReadSignal | undefined`
100
111
  `busNode(name) : GainNode | undefined`
101
112
  `masterNode() : GainNode | null`
102
113
 
103
114
  ### destroy(): void
104
- Stops every voice, tears down pools (which disconnect their nodes), disposes signal effects, aborts unlock/lifecycle controllers, releases references. Idempotent. Does not close the AudioContext.
115
+ Stops every voice, tears down SFX pools, tears down every track (pauses element, disconnects source + gains, removes timeupdate/ended listeners, cancels pending pause timers), disposes signal effects, aborts unlock/lifecycle controllers, releases references. Idempotent. Does not close the AudioContext.
105
116
 
106
117
  See Audio.js source for full inline documentation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-audio",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Zero-GC reactive Web Audio engine. Signal-driven buses, ABA-safe voice handles, unlock queue. SFX layer over @zakkster/lite-audio-pool.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",
@@ -39,13 +39,12 @@
39
39
  "scripts": {
40
40
  "test": "node --test test/*.test.js"
41
41
  },
42
- "homepage": "https://github.com/PeshoVurtoleta/lite-audio#readme",
43
42
  "repository": {
44
43
  "type": "git",
45
- "url": "git+https://github.com/PeshoVurtoleta/lite-audio.git"
44
+ "url": "git+https://github.com/PeshoVurtoleta/lite-scheduler.git"
46
45
  },
47
46
  "bugs": {
48
- "url": "https://github.com/PeshoVurtoleta/lite-audio/issues",
47
+ "url": "https://github.com/PeshoVurtoleta/lite-signal/issues",
49
48
  "email": "shinikchiev@yahoo.com"
50
49
  },
51
50
  "engines": {