@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/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
|
|
19
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
- **
|
|
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'],
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
(
|
|
164
|
-
|
|
165
|
-
The mock harness (`test/mock-ctx.js`) records every `AudioParam` scheduled
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
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(
|
|
28
|
-
await audio.init();
|
|
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
|
-
|
|
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.
|
|
43
|
-
audio.
|
|
44
|
-
audio.
|
|
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
|
-
###
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
###
|
|
89
|
-
|
|
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
|
-
###
|
|
92
|
-
|
|
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<
|
|
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 (
|
|
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.
|
|
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-
|
|
44
|
+
"url": "git+https://github.com/PeshoVurtoleta/lite-scheduler.git"
|
|
46
45
|
},
|
|
47
46
|
"bugs": {
|
|
48
|
-
"url": "https://github.com/PeshoVurtoleta/lite-
|
|
47
|
+
"url": "https://github.com/PeshoVurtoleta/lite-signal/issues",
|
|
49
48
|
"email": "shinikchiev@yahoo.com"
|
|
50
49
|
},
|
|
51
50
|
"engines": {
|