hci-atrium 0.1.0 → 0.3.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.
Files changed (96) hide show
  1. package/README.md +266 -8
  2. package/dist/asset-meta.d.ts +31 -0
  3. package/dist/asset-meta.d.ts.map +1 -0
  4. package/dist/asset-meta.js +25 -0
  5. package/dist/asset-meta.js.map +1 -0
  6. package/dist/atrium.d.ts +220 -7
  7. package/dist/atrium.d.ts.map +1 -1
  8. package/dist/atrium.js +410 -39
  9. package/dist/atrium.js.map +1 -1
  10. package/dist/dash.d.ts +206 -0
  11. package/dist/dash.d.ts.map +1 -0
  12. package/dist/dash.js +302 -0
  13. package/dist/dash.js.map +1 -0
  14. package/dist/direct.d.ts +86 -0
  15. package/dist/direct.d.ts.map +1 -0
  16. package/dist/direct.js +121 -0
  17. package/dist/direct.js.map +1 -0
  18. package/dist/index.d.ts +8 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/models.d.ts +70 -0
  23. package/dist/models.d.ts.map +1 -1
  24. package/dist/models.js +56 -0
  25. package/dist/models.js.map +1 -1
  26. package/dist/player/audio-layers.d.ts +306 -0
  27. package/dist/player/audio-layers.d.ts.map +1 -0
  28. package/dist/player/audio-layers.js +1245 -0
  29. package/dist/player/audio-layers.js.map +1 -0
  30. package/dist/player/index.d.ts +13 -0
  31. package/dist/player/index.d.ts.map +1 -0
  32. package/dist/player/index.js +13 -0
  33. package/dist/player/index.js.map +1 -0
  34. package/dist/player/layer-signals.d.ts +80 -0
  35. package/dist/player/layer-signals.d.ts.map +1 -0
  36. package/dist/player/layer-signals.js +174 -0
  37. package/dist/player/layer-signals.js.map +1 -0
  38. package/dist/player/play-command.d.ts +55 -0
  39. package/dist/player/play-command.d.ts.map +1 -0
  40. package/dist/player/play-command.js +36 -0
  41. package/dist/player/play-command.js.map +1 -0
  42. package/dist/player/play-intent.d.ts +18 -0
  43. package/dist/player/play-intent.d.ts.map +1 -0
  44. package/dist/player/play-intent.js +26 -0
  45. package/dist/player/play-intent.js.map +1 -0
  46. package/dist/player/runtime.d.ts +105 -0
  47. package/dist/player/runtime.d.ts.map +1 -0
  48. package/dist/player/runtime.js +402 -0
  49. package/dist/player/runtime.js.map +1 -0
  50. package/dist/react/index.d.ts +1 -0
  51. package/dist/react/index.d.ts.map +1 -1
  52. package/dist/react/index.js +1 -0
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react/use-room-connection.d.ts +40 -0
  55. package/dist/react/use-room-connection.d.ts.map +1 -0
  56. package/dist/react/use-room-connection.js +85 -0
  57. package/dist/react/use-room-connection.js.map +1 -0
  58. package/dist/rooms/recorder.d.ts +4 -0
  59. package/dist/rooms/recorder.d.ts.map +1 -1
  60. package/dist/rooms/recorder.js +7 -0
  61. package/dist/rooms/recorder.js.map +1 -1
  62. package/dist/rooms/room.d.ts +78 -0
  63. package/dist/rooms/room.d.ts.map +1 -1
  64. package/dist/rooms/room.js +208 -3
  65. package/dist/rooms/room.js.map +1 -1
  66. package/dist/rooms/standing-source.d.ts +3 -0
  67. package/dist/rooms/standing-source.d.ts.map +1 -1
  68. package/dist/rooms/standing-source.js +6 -0
  69. package/dist/rooms/standing-source.js.map +1 -1
  70. package/dist/rooms/transcriber.d.ts +4 -0
  71. package/dist/rooms/transcriber.d.ts.map +1 -1
  72. package/dist/rooms/transcriber.js +1 -0
  73. package/dist/rooms/transcriber.js.map +1 -1
  74. package/dist/shaping/index.d.ts +1 -0
  75. package/dist/shaping/index.d.ts.map +1 -1
  76. package/dist/shaping/index.js +1 -0
  77. package/dist/shaping/index.js.map +1 -1
  78. package/dist/shaping/request.d.ts +1 -1
  79. package/dist/shaping/request.d.ts.map +1 -1
  80. package/dist/shaping/surveys.d.ts +69 -0
  81. package/dist/shaping/surveys.d.ts.map +1 -0
  82. package/dist/shaping/surveys.js +129 -0
  83. package/dist/shaping/surveys.js.map +1 -0
  84. package/dist/tools.d.ts +84 -0
  85. package/dist/tools.d.ts.map +1 -0
  86. package/dist/tools.js +105 -0
  87. package/dist/tools.js.map +1 -0
  88. package/dist/trace.d.ts +96 -0
  89. package/dist/trace.d.ts.map +1 -0
  90. package/dist/trace.js +251 -0
  91. package/dist/trace.js.map +1 -0
  92. package/dist/transcribe.d.ts +5 -0
  93. package/dist/transcribe.d.ts.map +1 -1
  94. package/dist/transcribe.js +1 -1
  95. package/dist/transcribe.js.map +1 -1
  96. package/package.json +5 -1
@@ -0,0 +1,1245 @@
1
+ // LayerEngine — named, independently-mixed audio layers for the Player (and future embedders).
2
+ // Framework-agnostic: no React, no room/SSE knowledge. The page glue resolves room signals to
3
+ // resolved track URLs and calls this API; the engine owns the per-layer WebAudio graph.
4
+ //
5
+ // Design record: docs/plans/audio-layers.md. Graph per sound:
6
+ // source → [per-sound panner] → (per-sound effects: reserved) → layerEffectBus → layerGain → destination
7
+ // A layer is just a named, independently-mixed channel. Every layer can hold BOTH a single-track
8
+ // queue (music/ambient — one at a time, crossfade on change) AND overlapping one-shots (SFX) at the
9
+ // same time; which one a sound uses is the caller's choice (`enqueue` vs `playOneshots`), not a
10
+ // per-layer kind. Layers auto-vivify on first use.
11
+ const NO_QUEUE_MODE = { repeat: false, shuffle: false };
12
+ /** Procedural reverb preset names — impulse responses generated at runtime (no bundled binaries). */
13
+ export const REVERB_PRESETS = ["room", "hall", "cave", "outdoor"];
14
+ // ── Loudness normalization ──────────────────────────────────────────────────────────────────────
15
+ // Tracks carry a measured integrated loudness (LUFS) from the asset's facts; the engine applies a
16
+ // per-track gain toward a common target so a quiet bed and a loud song sit at a similar level. It's
17
+ // a plain multiply on the per-sound gain node (ReplayGain-style) — the measurement already happened
18
+ // on ingest, so no live analysis / latency. The master limiter (page side) catches any residual peak.
19
+ /** Target integrated loudness (LUFS). A moderate interactive/game level — quieter than streaming's
20
+ * −14, louder than broadcast's −23, leaving headroom for stacked one-shots under the limiter. */
21
+ export const NORMALIZE_TARGET_LUFS = -18;
22
+ /** Never boost a track by more than this — protects against near-silent or too-short clips whose
23
+ * measured loudness is unreliable (a huge boost would just amplify noise). */
24
+ const NORMALIZE_MAX_GAIN_DB = 12;
25
+ /** Keep the post-gain true peak this far below full scale (dBTP) when the peak is known. */
26
+ const NORMALIZE_TRUE_PEAK_CEILING_DB = -1;
27
+ /** Fade applied when the listener toggles normalization on/off mid-playback (ms). */
28
+ const NORMALIZE_TOGGLE_FADE_MS = 200;
29
+ const dbToGain = (db) => 10 ** (db / 20);
30
+ /** The normalization multiplier for a track: a gain toward the target loudness, capped by the boost
31
+ * limit and (when the true peak is known) clamped so it can't clip. 1 when the track has no measured
32
+ * loudness (nothing to normalize against). Exported for unit tests. */
33
+ export function normalizeGain(track, targetLufs) {
34
+ const lufs = track.loudness_lufs;
35
+ if (lufs == null || !Number.isFinite(lufs))
36
+ return 1;
37
+ let g = Math.min(dbToGain(targetLufs - lufs), dbToGain(NORMALIZE_MAX_GAIN_DB));
38
+ const peak = track.true_peak_db;
39
+ if (peak != null && Number.isFinite(peak)) {
40
+ g = Math.min(g, dbToGain(NORMALIZE_TRUE_PEAK_CEILING_DB - peak));
41
+ }
42
+ return g;
43
+ }
44
+ /** Cap on a layer's pending queue; oldest pending are trimmed first. */
45
+ export const MAX_PENDING_TRACKS = 6;
46
+ /** Default crossfade when neither layer spec nor track sets one (ms). */
47
+ export const DEFAULT_CROSSFADE_MS = 1500;
48
+ // Neutral filter targets — a lowpass at 20 kHz and a highpass at 10 Hz pass everything audible, so
49
+ // the effect bus is always inserted but click-free and inaudible until an effect ramps it in.
50
+ const LOWPASS_NEUTRAL_HZ = 20000;
51
+ const HIGHPASS_NEUTRAL_HZ = 10;
52
+ const NEUTRAL_Q = Math.SQRT1_2; // Butterworth (flat) response
53
+ const DEFAULT_EFFECT_FADE_MS = 50;
54
+ const DEFAULT_VOLUME_FADE_MS = 30;
55
+ // layer.position defaults to a short ramp (avoid zipper noise while keeping the move responsive).
56
+ const DEFAULT_POSITION_FADE_MS = 50;
57
+ const MONITOR_INTERVAL_MS = 300;
58
+ // ── Echo/reverb bounds ────────────────────────────────────────────────────────────────────────────
59
+ const MIN_ECHO_TIME_MS = 1;
60
+ const MAX_ECHO_TIME_MS = 2000;
61
+ const MAX_ECHO_FEEDBACK = 0.9; // safety — no runaway feedback
62
+ const DEFAULT_ECHO_MIX = 0.25;
63
+ const MAX_ECHO_DELAY_S = 2.5; // DelayNode maxDelayTime — must exceed the clamped time ceiling (2 s)
64
+ // Reverb convolution tail hold: how long a per-sound reverb send lingers after the source stops so
65
+ // the wet tail rings out before the send is disconnected (the shared bus itself persists).
66
+ const REVERB_TAIL_MS = 3500;
67
+ // Longest an echo tail is held after a source stops before its per-sound nodes are disconnected.
68
+ const MAX_ECHO_TAIL_MS = 8000;
69
+ const DISTANCE_MODELS = ["linear", "inverse", "exponential"];
70
+ /** Coerce an untrusted `ir` name to a known preset; anything else falls back to `"room"`. */
71
+ export function reverbPresetOf(ir) {
72
+ return REVERB_PRESETS.includes(ir ?? "") ? ir : "room";
73
+ }
74
+ function validDistanceModel(model) {
75
+ return DISTANCE_MODELS.includes(model) ? model : "inverse";
76
+ }
77
+ function finiteOr(v, fallback) {
78
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
79
+ }
80
+ function clampEchoTimeMs(ms) {
81
+ if (!Number.isFinite(ms))
82
+ return MIN_ECHO_TIME_MS;
83
+ return Math.max(MIN_ECHO_TIME_MS, Math.min(MAX_ECHO_TIME_MS, ms));
84
+ }
85
+ function clampFeedback(feedback) {
86
+ if (feedback == null || !Number.isFinite(feedback))
87
+ return 0;
88
+ return Math.max(0, Math.min(MAX_ECHO_FEEDBACK, feedback));
89
+ }
90
+ /** How long an echo tail stays audible: one delay period per repeat, until the feedback has decayed
91
+ * ~60 dB (capped). Feedback 0 → a single slap (one delay period). Exported for unit tests. */
92
+ export function echoTailMs(echo) {
93
+ const time = clampEchoTimeMs(echo.time_ms);
94
+ const fb = clampFeedback(echo.feedback);
95
+ if (fb <= 0)
96
+ return time + 50;
97
+ const perEchoDb = -20 * Math.log10(fb);
98
+ const echoes = perEchoDb > 0 ? 60 / perEchoDb : 30;
99
+ return Math.min(time * echoes, MAX_ECHO_TAIL_MS);
100
+ }
101
+ const IR_PARAMS = {
102
+ room: { decayS: 0.4, predelayMs: 0, damping: 0.5, sparse: false },
103
+ hall: { decayS: 1.8, predelayMs: 8, damping: 0.32, sparse: false },
104
+ cave: { decayS: 3.0, predelayMs: 20, damping: 0.14, sparse: false },
105
+ outdoor: { decayS: 0.15, predelayMs: 0, damping: 0.25, sparse: true },
106
+ };
107
+ /** Frame counts for a preset's impulse response at `sampleRate` (predelay + tail). Exported for tests. */
108
+ export function impulseResponseFrames(preset, sampleRate) {
109
+ const p = IR_PARAMS[preset];
110
+ return {
111
+ predelay: Math.round((sampleRate * p.predelayMs) / 1000),
112
+ tail: Math.max(1, Math.round(sampleRate * p.decayS)),
113
+ };
114
+ }
115
+ /** Fill the given channel arrays with a preset's impulse response (independent noise per channel for
116
+ * stereo decorrelation). Length must be `predelay + tail` frames (see `impulseResponseFrames`). `rng`
117
+ * is injectable for deterministic tests. Exported for unit tests. */
118
+ export function fillImpulseResponse(channels, sampleRate, preset, rng = Math.random) {
119
+ const p = IR_PARAMS[preset];
120
+ const predelay = Math.round((sampleRate * p.predelayMs) / 1000);
121
+ for (const data of channels) {
122
+ for (let i = 0; i < predelay && i < data.length; i++)
123
+ data[i] = 0;
124
+ const tail = data.length - predelay;
125
+ if (tail <= 0)
126
+ continue;
127
+ if (p.sparse) {
128
+ for (let i = predelay; i < data.length; i++)
129
+ data[i] = 0;
130
+ const reflections = 5;
131
+ for (let r = 0; r < reflections; r++) {
132
+ const pos = predelay + Math.floor((tail * (r + 1)) / (reflections + 1));
133
+ if (pos < data.length)
134
+ data[pos] = (data[pos] ?? 0) + Math.exp(-3 * (r / reflections)) * (rng() * 2 - 1);
135
+ }
136
+ continue;
137
+ }
138
+ let lp = 0;
139
+ for (let i = 0; i < tail; i++) {
140
+ const env = Math.exp(-4 * (i / tail)); // exponential decay to ~ −35 dB at the tail end
141
+ lp += p.damping * (rng() * 2 - 1 - lp); // one-pole lowpass for damping/coloration
142
+ data[predelay + i] = lp * env;
143
+ }
144
+ }
145
+ }
146
+ function clamp01(value) {
147
+ if (!Number.isFinite(value))
148
+ return 1;
149
+ return Math.max(0, Math.min(1, value));
150
+ }
151
+ /** A shuffled copy (Fisher–Yates) — used for `shuffle` queues; never mutates the input. */
152
+ function shuffled(items) {
153
+ const out = items.slice();
154
+ for (let i = out.length - 1; i > 0; i--) {
155
+ const j = Math.floor(Math.random() * (i + 1));
156
+ const a = out[i];
157
+ const b = out[j];
158
+ if (a !== undefined && b !== undefined) {
159
+ out[i] = b;
160
+ out[j] = a;
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+ /** Points in a precomputed fade curve — plenty for a smooth 0.5–3 s fade, cheap to build. */
166
+ const FADE_CURVE_SAMPLES = 64;
167
+ /** Curves need `cancelAndHoldAtTime` to truncate an in-flight curve before scheduling the next one
168
+ * (setValueCurveAtTime throws if a curve already overlaps its window). Where it is missing (old
169
+ * engines) callers fall back to a linear ramp rather than risk that throw. */
170
+ function canCurve(param) {
171
+ return (typeof param.setValueCurveAtTime === "function" &&
172
+ typeof param.cancelAndHoldAtTime === "function");
173
+ }
174
+ /** Build a fade curve from `from` to `to` following `shape`. Direction (rise vs fall) is inferred so
175
+ * an equal-power fade-in (√t) and fade-out (√(1−t)) stay complementary through a crossfade. */
176
+ function fadeCurve(from, to, shape) {
177
+ const curve = new Float32Array(FADE_CURVE_SAMPLES);
178
+ const rising = to >= from;
179
+ const last = FADE_CURVE_SAMPLES - 1;
180
+ for (let i = 0; i <= last; i++) {
181
+ const t = i / last;
182
+ let f;
183
+ if (shape === "equal_power") {
184
+ f = rising ? Math.sqrt(t) : 1 - Math.sqrt(1 - t);
185
+ }
186
+ else if (shape === "perceptual") {
187
+ f = rising ? t * t : 1 - (1 - t) * (1 - t);
188
+ }
189
+ else {
190
+ f = t;
191
+ }
192
+ curve[i] = from + (to - from) * f;
193
+ }
194
+ return curve;
195
+ }
196
+ /** Ramp an AudioParam to `target` over `ms` (0 = jump), following `shape`. Uses scheduled ramps, not
197
+ * rAF loops; a curved fade hits `target` exactly at `ms` (the monitor's crossfade-lead relies on
198
+ * that). `linear` is unchanged from before — effects/volume ramps keep it. */
199
+ function rampParam(param, target, ms, now, shape = "linear") {
200
+ if (ms <= 0) {
201
+ param.cancelScheduledValues(now);
202
+ param.setValueAtTime(target, now);
203
+ param.value = target;
204
+ return;
205
+ }
206
+ const from = param.value;
207
+ if (shape !== "linear" && from !== target && canCurve(param)) {
208
+ param.cancelAndHoldAtTime(now);
209
+ param.setValueCurveAtTime(fadeCurve(from, target, shape), now, ms / 1000);
210
+ return;
211
+ }
212
+ param.cancelScheduledValues(now);
213
+ param.setValueAtTime(from, now);
214
+ param.linearRampToValueAtTime(target, now + ms / 1000);
215
+ }
216
+ /** Schedule a fade on a fresh param over [start, start + durS] from `from` to `to` (one-shots fade at
217
+ * a future `when`, not `now`, and have no prior automation to cancel). Falls back to a linear ramp
218
+ * where curves are unsupported. */
219
+ function scheduleFade(param, from, to, start, durS, shape) {
220
+ if (durS <= 0) {
221
+ param.setValueAtTime(to, start);
222
+ return;
223
+ }
224
+ if (shape !== "linear" && from !== to && typeof param.setValueCurveAtTime === "function") {
225
+ param.setValueCurveAtTime(fadeCurve(from, to, shape), start, durS);
226
+ return;
227
+ }
228
+ param.setValueAtTime(from, start);
229
+ param.linearRampToValueAtTime(to, start + durS);
230
+ }
231
+ export class LayerEngine {
232
+ ctx;
233
+ destination;
234
+ loadBuffer;
235
+ onLog;
236
+ layers = new Map();
237
+ // Shared reverb buses, keyed by preset name or `asset:<id>`. Created on first use and kept for the
238
+ // engine's life so tails ring out; layers/sounds tap in via their own send gains.
239
+ reverbBuses = new Map();
240
+ listeners = new Set();
241
+ timers = new Set();
242
+ monitorId;
243
+ notifyScheduled = false;
244
+ disposed = false;
245
+ normalize;
246
+ onContextState;
247
+ constructor(options) {
248
+ this.ctx = options.context;
249
+ this.destination = options.destination;
250
+ this.loadBuffer = options.loadBuffer;
251
+ this.onLog = options.onLog;
252
+ this.normalize = options.normalize ?? true;
253
+ this.monitorId = setInterval(() => this.monitor(), MONITOR_INTERVAL_MS);
254
+ // An output-device change or OS audio interruption can suspend the context (and pause our queue
255
+ // elements) with no ended/error event, leaving playback stuck. Recover by resuming when the
256
+ // context reports it was suspended — see resume() / onTrackPause for the per-element path.
257
+ this.onContextState = () => {
258
+ if (!this.disposed && this.ctx.state === "suspended")
259
+ this.resume();
260
+ };
261
+ this.ctx.addEventListener?.("statechange", this.onContextState);
262
+ }
263
+ /** Recover playback after an output-device change / OS audio interruption: resume the context and
264
+ * re-play any queue track the browser paused. Idempotent and safe to call anytime — the Player
265
+ * also calls it on `devicechange`. */
266
+ resume() {
267
+ if (this.disposed)
268
+ return;
269
+ void this.ctx.resume?.();
270
+ for (const layer of this.layers.values()) {
271
+ const el = layer.current?.element;
272
+ if (el?.paused && !el.ended)
273
+ el.play().catch(() => { });
274
+ }
275
+ }
276
+ /** Declare/refresh the layer set (idempotent). Existing layers keep their current volume. */
277
+ configure(specs) {
278
+ for (const spec of specs) {
279
+ const existing = this.layers.get(spec.id);
280
+ if (existing) {
281
+ // Volume is deliberately NOT applied — the listener slider is the authority once a layer
282
+ // exists (see the plan). Everything else is declarative and re-appliable.
283
+ if (spec.label != null)
284
+ existing.label = spec.label;
285
+ if (spec.crossfade_ms != null)
286
+ existing.crossfadeMs = spec.crossfade_ms;
287
+ if (spec.effects) {
288
+ this.applyEffects(existing, spec.effects, DEFAULT_EFFECT_FADE_MS);
289
+ existing.effects = { ...spec.effects };
290
+ }
291
+ }
292
+ else {
293
+ const layer = this.createLayer(spec.id, {
294
+ label: spec.label ?? spec.id,
295
+ volume: spec.volume ?? 1,
296
+ crossfadeMs: spec.crossfade_ms ?? DEFAULT_CROSSFADE_MS,
297
+ });
298
+ if (spec.effects) {
299
+ this.applyEffects(layer, spec.effects, 0);
300
+ layer.effects = { ...spec.effects };
301
+ }
302
+ }
303
+ }
304
+ this.notify();
305
+ }
306
+ /** Auto-vivify the layer + drop unplayable tracks (spotify stubs, no url). Returns null when
307
+ * nothing survives — the caller must no-op then, since mutating queue state on an empty batch
308
+ * would silently wipe an existing pending list. Notifies (the auto-vivify is visible) either way. */
309
+ preparePlayable(layerId, tracks) {
310
+ const layer = this.ensureLayer(layerId);
311
+ const playable = this.filterTracks(tracks);
312
+ if (playable.length === 0) {
313
+ this.notify();
314
+ return null;
315
+ }
316
+ return { layer, playable };
317
+ }
318
+ /** Queue tracks on a layer's single-track queue (auto-vivifies). `transition` places them:
319
+ * `now` crossfades in immediately, `after_current` replaces the pending queue, `queued` appends.
320
+ * `mode` sets the queue's repeat/shuffle behaviour (the tracks handed here become the repeat
321
+ * rotation). */
322
+ enqueue(layerId, tracks, transition, mode = NO_QUEUE_MODE) {
323
+ if (this.disposed)
324
+ return; // parity with playOneshots' post-load guard
325
+ const prep = this.preparePlayable(layerId, tracks);
326
+ if (prep)
327
+ this.enqueueQueue(prep.layer, prep.playable, transition, mode);
328
+ }
329
+ /** Fire tracks as overlapping one-shots on a layer (auto-vivifies). They layer over each other and
330
+ * over the layer's queue; honours delay/max-duration/fades/gain/spatial/clip/loop. */
331
+ playOneshots(layerId, tracks) {
332
+ const prep = this.preparePlayable(layerId, tracks);
333
+ if (!prep)
334
+ return;
335
+ const { layer } = prep;
336
+ for (const track of prep.playable) {
337
+ const generation = layer.generation;
338
+ this.loadBuffer(track.url)
339
+ .then((buffer) => {
340
+ if (this.disposed)
341
+ return;
342
+ if (layer.generation !== generation)
343
+ return; // cleared while loading
344
+ this.startOneshot(layer, track, buffer);
345
+ })
346
+ .catch(() => this.onLog?.(`· failed to load: ${track.name ?? track.url}`));
347
+ }
348
+ }
349
+ /** Fade out and empty a layer — its queue (current + pending) and its one-shots. */
350
+ clear(layerId, fadeOutMs) {
351
+ const layer = this.layers.get(layerId);
352
+ if (!layer)
353
+ return;
354
+ const fade = fadeOutMs ?? layer.crossfadeMs;
355
+ // Bump first: invalidates the current track's end/monitor events and any in-flight oneshot load.
356
+ layer.generation++;
357
+ if (layer.current) {
358
+ this.retire(layer.current, fade, "perceptual");
359
+ layer.current = null;
360
+ }
361
+ layer.pending = [];
362
+ layer.repeat = false;
363
+ layer.shuffle = false;
364
+ layer.queueSource = [];
365
+ layer.transitionFired = false;
366
+ for (const inst of layer.oneshots) {
367
+ this.fadeOutThen(inst.gain, fade, () => {
368
+ try {
369
+ inst.src.stop();
370
+ }
371
+ catch {
372
+ // already stopped
373
+ }
374
+ this.teardownOneshot(inst);
375
+ }, "perceptual");
376
+ }
377
+ layer.oneshots.clear();
378
+ this.notify();
379
+ }
380
+ /** Fade out and empty every layer at once (queues + one-shots) — a "stop all audio". */
381
+ clearAll(fadeOutMs) {
382
+ for (const id of this.layers.keys())
383
+ this.clear(id, fadeOutMs);
384
+ }
385
+ /** Set a layer's volume 0..1 (the listener slider), optionally ramped. Auto-vivifies. */
386
+ setVolume(layerId, volume, fadeMs) {
387
+ const layer = this.ensureLayer(layerId);
388
+ const v = clamp01(volume);
389
+ layer.volume = v;
390
+ rampParam(layer.gain.gain, v, fadeMs ?? DEFAULT_VOLUME_FADE_MS, this.ctx.currentTime);
391
+ this.notify();
392
+ }
393
+ /** Set/ramp a layer's effect bus (lowpass/highpass filters + reverb/echo sends). Auto-vivifies. */
394
+ setEffects(layerId, effects, fadeMs) {
395
+ const layer = this.ensureLayer(layerId);
396
+ this.applyEffects(layer, effects, fadeMs ?? DEFAULT_EFFECT_FADE_MS);
397
+ layer.effects = { ...effects };
398
+ this.notify();
399
+ }
400
+ /** Apply the same effect bus to every EXISTING layer at once (the `layer.effects` `"*"` wildcard;
401
+ * mirrors `clearAll`). Does not auto-vivify — an empty engine is a no-op. */
402
+ setEffectsAll(effects, fadeMs) {
403
+ const fade = fadeMs ?? DEFAULT_EFFECT_FADE_MS;
404
+ for (const layer of this.layers.values()) {
405
+ this.applyEffects(layer, effects, fade);
406
+ layer.effects = { ...effects };
407
+ }
408
+ this.notify();
409
+ }
410
+ /** Ramp a layer's whole HRTF position (`layer.position`). Auto-vivifies; the positioning panner is
411
+ * created lazily on first use (an unpositioned layer keeps today's exact graph). `distance` opts a
412
+ * layer into distance attenuation and is STICKY across re-sends (position is a repeatable verb —
413
+ * a bare move must not reset the model); omitted → unchanged, initial default direction-only. */
414
+ setPosition(layerId, x, y, z, fadeMs, distance) {
415
+ const layer = this.ensureLayer(layerId);
416
+ const panner = this.ensurePosition(layer);
417
+ const now = this.ctx.currentTime;
418
+ const fade = fadeMs ?? DEFAULT_POSITION_FADE_MS;
419
+ rampParam(panner.positionX, x, fade, now);
420
+ rampParam(panner.positionY, y, fade, now);
421
+ rampParam(panner.positionZ, z, fade, now);
422
+ if (distance)
423
+ this.applyDistanceParams(panner, distance);
424
+ layer.positionValue = { x, y, z };
425
+ this.notify();
426
+ }
427
+ /** Insert the layer's positioning panner between its gain and the destination on first use. Sends
428
+ * stay tapped post-gain (before the panner), so positioning a layer leaves its reverb/echo unmoved. */
429
+ ensurePosition(layer) {
430
+ if (layer.position)
431
+ return layer.position;
432
+ const p = this.ctx.createPanner();
433
+ p.panningModel = "HRTF";
434
+ this.applyDistanceParams(p, undefined); // rolloff 0 until a distance config arrives
435
+ p.positionX.value = 0;
436
+ p.positionY.value = 0;
437
+ p.positionZ.value = 0;
438
+ try {
439
+ layer.gain.disconnect(this.destination);
440
+ }
441
+ catch {
442
+ // no direct edge (already routed through something else) — ignore
443
+ }
444
+ layer.gain.connect(p);
445
+ p.connect(this.destination);
446
+ layer.position = p;
447
+ return p;
448
+ }
449
+ /** Enable/disable loudness normalization. Re-ramps every currently-playing queue track to its new
450
+ * level (one-shots are fleeting — they pick up the change on the next play). */
451
+ setNormalize(enabled) {
452
+ if (this.normalize === enabled)
453
+ return;
454
+ this.normalize = enabled;
455
+ const now = this.ctx.currentTime;
456
+ for (const layer of this.layers.values()) {
457
+ if (layer.current) {
458
+ rampParam(layer.current.gain.gain, this.effectiveGain(layer.current.track), NORMALIZE_TOGGLE_FADE_MS, now);
459
+ }
460
+ }
461
+ }
462
+ /** The full-volume target for a sound's own gain node: its per-track trim, times the loudness
463
+ * normalization multiplier when normalization is on and the track carries a measured loudness. */
464
+ effectiveGain(track) {
465
+ const base = track.gain ?? 1;
466
+ return this.normalize ? base * normalizeGain(track, NORMALIZE_TARGET_LUFS) : base;
467
+ }
468
+ /** Current state of every known layer, keyed by id. */
469
+ snapshot() {
470
+ const out = {};
471
+ for (const layer of this.layers.values())
472
+ out[layer.id] = this.stateOf(layer);
473
+ return out;
474
+ }
475
+ /** Subscribe to state changes (track transitions, volume, configure). Returns unsubscribe. */
476
+ onChange(cb) {
477
+ this.listeners.add(cb);
478
+ return () => {
479
+ this.listeners.delete(cb);
480
+ };
481
+ }
482
+ /** Stop everything and release nodes/elements. */
483
+ dispose() {
484
+ this.disposed = true;
485
+ this.ctx.removeEventListener?.("statechange", this.onContextState);
486
+ clearInterval(this.monitorId);
487
+ for (const t of this.timers)
488
+ clearTimeout(t);
489
+ this.timers.clear();
490
+ for (const layer of this.layers.values()) {
491
+ if (layer.current)
492
+ this.teardownQueueInstance(layer.current);
493
+ layer.current = null;
494
+ for (const inst of layer.oneshots) {
495
+ try {
496
+ inst.src.stop();
497
+ }
498
+ catch {
499
+ // already stopped
500
+ }
501
+ this.teardownOneshot(inst);
502
+ }
503
+ layer.oneshots.clear();
504
+ this.disconnect(layer.input);
505
+ this.disconnect(layer.lowpass);
506
+ this.disconnect(layer.highpass);
507
+ this.disconnect(layer.gain);
508
+ for (const send of layer.reverbSends.values())
509
+ this.disconnect(send);
510
+ if (layer.echo) {
511
+ this.disconnect(layer.echo.send);
512
+ this.disconnect(layer.echo.delay);
513
+ this.disconnect(layer.echo.feedback);
514
+ }
515
+ if (layer.position)
516
+ this.disconnect(layer.position);
517
+ }
518
+ for (const bus of this.reverbBuses.values()) {
519
+ this.disconnect(bus.convolver);
520
+ this.disconnect(bus.output);
521
+ }
522
+ this.reverbBuses.clear();
523
+ this.layers.clear();
524
+ this.listeners.clear();
525
+ }
526
+ // ── Layer lifecycle ─────────────────────────────────────────────────────────
527
+ ensureLayer(id) {
528
+ const existing = this.layers.get(id);
529
+ if (existing)
530
+ return existing;
531
+ return this.createLayer(id, { label: id, volume: 1, crossfadeMs: DEFAULT_CROSSFADE_MS });
532
+ }
533
+ createLayer(id, opts) {
534
+ const input = this.ctx.createGain();
535
+ const lowpass = this.ctx.createBiquadFilter();
536
+ lowpass.type = "lowpass";
537
+ lowpass.frequency.value = LOWPASS_NEUTRAL_HZ;
538
+ lowpass.Q.value = NEUTRAL_Q;
539
+ const highpass = this.ctx.createBiquadFilter();
540
+ highpass.type = "highpass";
541
+ highpass.frequency.value = HIGHPASS_NEUTRAL_HZ;
542
+ highpass.Q.value = NEUTRAL_Q;
543
+ const gain = this.ctx.createGain();
544
+ gain.gain.value = clamp01(opts.volume);
545
+ input.connect(lowpass);
546
+ lowpass.connect(highpass);
547
+ highpass.connect(gain);
548
+ gain.connect(this.destination);
549
+ const layer = {
550
+ id,
551
+ label: opts.label,
552
+ volume: clamp01(opts.volume),
553
+ crossfadeMs: opts.crossfadeMs,
554
+ effects: {},
555
+ input,
556
+ lowpass,
557
+ highpass,
558
+ gain,
559
+ reverbSends: new Map(),
560
+ reverbKey: null,
561
+ echo: null,
562
+ position: null,
563
+ positionValue: null,
564
+ current: null,
565
+ pending: [],
566
+ repeat: false,
567
+ shuffle: false,
568
+ queueSource: [],
569
+ generation: 0,
570
+ transitionFired: false,
571
+ oneshots: new Set(),
572
+ };
573
+ this.layers.set(id, layer);
574
+ return layer;
575
+ }
576
+ applyEffects(layer, effects, fadeMs) {
577
+ const now = this.ctx.currentTime;
578
+ const lp = effects.lowpass;
579
+ rampParam(layer.lowpass.frequency, lp ? lp.hz : LOWPASS_NEUTRAL_HZ, fadeMs, now);
580
+ rampParam(layer.lowpass.Q, lp?.q ?? NEUTRAL_Q, fadeMs, now);
581
+ const hp = effects.highpass;
582
+ rampParam(layer.highpass.frequency, hp ? hp.hz : HIGHPASS_NEUTRAL_HZ, fadeMs, now);
583
+ rampParam(layer.highpass.Q, hp?.q ?? NEUTRAL_Q, fadeMs, now);
584
+ this.applyReverb(layer, effects.reverb, fadeMs);
585
+ this.applyEcho(layer, effects.echo, fadeMs);
586
+ }
587
+ // ── Reverb (shared convolution buses) ─────────────────────────────────────────
588
+ /** Ramp a layer's reverb send. Absent (or mix 0) → ramp any open send to 0 (bypass). Otherwise
589
+ * route to the configured IR's bus, ramping the previously-open send down over the same window. */
590
+ applyReverb(layer, reverb, fadeMs) {
591
+ if (!reverb || !(reverb.mix > 0)) {
592
+ const now = this.ctx.currentTime;
593
+ for (const send of layer.reverbSends.values())
594
+ rampParam(send.gain, 0, fadeMs, now);
595
+ layer.reverbKey = null;
596
+ return;
597
+ }
598
+ const mix = clamp01(reverb.mix);
599
+ const preset = reverbPresetOf(reverb.ir);
600
+ if (reverb.ir_asset) {
601
+ const key = `asset:${reverb.ir_asset}`;
602
+ this.ensureAssetReverbBus(reverb.ir_asset, () => {
603
+ // The custom IR failed to load — fall this layer back to the preset if it's still on it.
604
+ if (layer.reverbKey === key) {
605
+ this.ensurePresetReverbBus(preset);
606
+ this.routeLayerReverb(layer, preset, mix, DEFAULT_EFFECT_FADE_MS);
607
+ }
608
+ });
609
+ this.routeLayerReverb(layer, key, mix, fadeMs);
610
+ }
611
+ else {
612
+ this.ensurePresetReverbBus(preset);
613
+ this.routeLayerReverb(layer, preset, mix, fadeMs);
614
+ }
615
+ }
616
+ /** Open the send for `key` (creating it on first use), ramp it to `mix`, and ramp every other send
617
+ * on the layer down to 0 — so switching IR crossfades the wet path over `fadeMs`. */
618
+ routeLayerReverb(layer, key, mix, fadeMs) {
619
+ let send = layer.reverbSends.get(key);
620
+ if (!send) {
621
+ const bus = this.reverbBuses.get(key);
622
+ if (!bus)
623
+ return; // ensured by the caller; a missing bus means nothing to route to
624
+ send = this.ctx.createGain();
625
+ send.gain.value = 0;
626
+ layer.gain.connect(send); // post-gain tap: the slider scales the send
627
+ send.connect(bus.convolver);
628
+ layer.reverbSends.set(key, send);
629
+ }
630
+ const now = this.ctx.currentTime;
631
+ for (const [k, g] of layer.reverbSends)
632
+ rampParam(g.gain, k === key ? mix : 0, fadeMs, now);
633
+ layer.reverbKey = key;
634
+ }
635
+ createReverbBus() {
636
+ const convolver = this.ctx.createConvolver();
637
+ const output = this.ctx.createGain();
638
+ output.gain.value = 1;
639
+ convolver.connect(output);
640
+ output.connect(this.destination);
641
+ return { convolver, output };
642
+ }
643
+ ensurePresetReverbBus(preset) {
644
+ const existing = this.reverbBuses.get(preset);
645
+ if (existing)
646
+ return existing;
647
+ const bus = this.createReverbBus();
648
+ const { predelay, tail } = impulseResponseFrames(preset, this.ctx.sampleRate);
649
+ const buffer = this.ctx.createBuffer(2, predelay + tail, this.ctx.sampleRate);
650
+ fillImpulseResponse([buffer.getChannelData(0), buffer.getChannelData(1)], this.ctx.sampleRate, preset);
651
+ bus.convolver.buffer = buffer;
652
+ this.reverbBuses.set(preset, bus);
653
+ return bus;
654
+ }
655
+ /** Ensure a bus for a custom IR asset. Created synchronously (empty convolver, so a send can wire up
656
+ * immediately) then filled once the asset content decodes; `onFailure` runs on fetch/decode error. */
657
+ ensureAssetReverbBus(assetId, onFailure) {
658
+ const key = `asset:${assetId}`;
659
+ if (this.reverbBuses.has(key))
660
+ return;
661
+ const bus = this.createReverbBus();
662
+ this.reverbBuses.set(key, bus);
663
+ // The public content-by-id path (mirrors the play glue's asset_id resolution).
664
+ const url = `/v1/assets/${encodeURIComponent(assetId)}/content`;
665
+ this.loadBuffer(url)
666
+ .then((buffer) => {
667
+ if (this.disposed)
668
+ return;
669
+ bus.convolver.buffer = buffer;
670
+ })
671
+ .catch(() => {
672
+ if (this.disposed)
673
+ return;
674
+ this.onLog?.(`· reverb IR asset failed to load: ${assetId}`);
675
+ onFailure();
676
+ // Drop the failed bus and any layer sends already routed to it, so the *next* use of this
677
+ // asset re-fetches instead of routing to a permanently-silent convolver. (onFailure runs
678
+ // first — the layer fallback checks `reverbKey === key` before the sweep clears it.)
679
+ this.reverbBuses.delete(key);
680
+ for (const layer of this.layers.values()) {
681
+ const send = layer.reverbSends.get(key);
682
+ if (!send)
683
+ continue;
684
+ this.disconnect(send);
685
+ layer.reverbSends.delete(key);
686
+ if (layer.reverbKey === key)
687
+ layer.reverbKey = null;
688
+ }
689
+ });
690
+ }
691
+ // ── Echo (delay + feedback) ───────────────────────────────────────────────────
692
+ /** Ramp a layer's echo send. Absent → ramp the send to 0 (bypass, nodes kept); present → create the
693
+ * delay/feedback loop on first use and ramp time/feedback/mix. */
694
+ applyEcho(layer, echo, fadeMs) {
695
+ if (!echo) {
696
+ if (layer.echo)
697
+ rampParam(layer.echo.send.gain, 0, fadeMs, this.ctx.currentTime);
698
+ return;
699
+ }
700
+ if (!layer.echo) {
701
+ layer.echo = this.createEchoNodes();
702
+ layer.gain.connect(layer.echo.send); // post-gain tap: the slider scales the send
703
+ }
704
+ this.setEchoParams(layer.echo, echo, fadeMs);
705
+ }
706
+ createEchoNodes() {
707
+ const send = this.ctx.createGain();
708
+ send.gain.value = 0;
709
+ const delay = this.ctx.createDelay(MAX_ECHO_DELAY_S);
710
+ const feedback = this.ctx.createGain();
711
+ feedback.gain.value = 0;
712
+ send.connect(delay);
713
+ delay.connect(feedback);
714
+ feedback.connect(delay); // feedback loop
715
+ delay.connect(this.destination); // wet output (a send — dry path is untouched)
716
+ return { send, delay, feedback };
717
+ }
718
+ setEchoParams(echo, cfg, fadeMs) {
719
+ const now = this.ctx.currentTime;
720
+ rampParam(echo.delay.delayTime, clampEchoTimeMs(cfg.time_ms) / 1000, fadeMs, now);
721
+ rampParam(echo.feedback.gain, clampFeedback(cfg.feedback), fadeMs, now);
722
+ rampParam(echo.send.gain, clamp01(cfg.mix ?? DEFAULT_ECHO_MIX), fadeMs, now);
723
+ }
724
+ // ── Track filtering ─────────────────────────────────────────────────────────
725
+ filterTracks(tracks) {
726
+ const out = [];
727
+ for (const t of tracks) {
728
+ if (t.spotify_ref) {
729
+ this.onLog?.(`· spotify track in layer not yet supported: ${t.name ?? t.spotify_ref}`);
730
+ continue;
731
+ }
732
+ if (!t.url)
733
+ continue;
734
+ out.push(t);
735
+ }
736
+ return out;
737
+ }
738
+ // ── Queue playback ──────────────────────────────────────────────────────────
739
+ enqueueQueue(layer, tracks, transition, mode) {
740
+ // The tracks handed to this play set the queue's mode and (when repeating) its rotation. The
741
+ // rotation is stored unshuffled; each repeat cycle re-shuffles it.
742
+ layer.repeat = mode.repeat;
743
+ layer.shuffle = mode.shuffle;
744
+ layer.queueSource = mode.repeat ? tracks.slice() : [];
745
+ const ordered = mode.shuffle ? shuffled(tracks) : tracks;
746
+ if (transition === "now") {
747
+ const prev = layer.current;
748
+ layer.generation++;
749
+ layer.transitionFired = false;
750
+ const [first, ...rest] = ordered;
751
+ if (first === undefined)
752
+ return;
753
+ const inst = this.playTrack(layer, first, prev ? "equal_power" : "perceptual");
754
+ layer.current = inst;
755
+ layer.pending = this.capPending(rest);
756
+ // Genuine crossfade: the incoming element faded in above; the outgoing fades out over the
757
+ // same window (equal-power complement) and is torn down once the fade completes.
758
+ if (prev)
759
+ this.retire(prev, inst.crossfadeMs, "equal_power");
760
+ this.notify();
761
+ return;
762
+ }
763
+ if (transition === "after_current") {
764
+ // A looping bed never ends, so "after_current" would wait forever — the loop semantics
765
+ // (a bed sustains until replaced by now/after_current, or cleared) make it a replace-now.
766
+ if (layer.current?.track.loop) {
767
+ this.enqueueQueue(layer, tracks, "now", mode);
768
+ return;
769
+ }
770
+ layer.pending = this.capPending(ordered);
771
+ }
772
+ else {
773
+ layer.pending = this.capPending([...layer.pending, ...ordered]);
774
+ }
775
+ // Nothing playing → the queue starts the first pending track (lone fade-in, no crossfade).
776
+ if (!layer.current) {
777
+ const first = layer.pending.shift();
778
+ if (first) {
779
+ layer.generation++;
780
+ layer.transitionFired = false;
781
+ layer.current = this.playTrack(layer, first, "perceptual");
782
+ }
783
+ }
784
+ this.notify();
785
+ }
786
+ capPending(tracks) {
787
+ if (tracks.length <= MAX_PENDING_TRACKS)
788
+ return tracks;
789
+ return tracks.slice(tracks.length - MAX_PENDING_TRACKS);
790
+ }
791
+ /** Create an element + graph for a queue track and start it fading in. `incomingShape` is
792
+ * `equal_power` when it crossfades over an outgoing track (constant power with the retiring one)
793
+ * and `perceptual` for a lone fade-in from silence. The caller has already bumped
794
+ * `layer.generation`; this instance captures that value for its end/error guards. */
795
+ playTrack(layer, track, incomingShape) {
796
+ const generation = layer.generation;
797
+ const clipStart = Math.max(0, track.clip_start_seconds ?? 0);
798
+ const clipEnd = track.clip_end_seconds ?? null;
799
+ const element = new Audio();
800
+ element.crossOrigin = "anonymous";
801
+ element.preload = "auto";
802
+ // Pure loop only when there's no clip end; a clipped loop is seeked back by the monitor so it
803
+ // repeats the clip range rather than the whole file.
804
+ element.loop = !!track.loop && clipEnd == null;
805
+ element.src = track.url;
806
+ const source = this.ctx.createMediaElementSource(element);
807
+ const gain = this.ctx.createGain();
808
+ gain.gain.value = 0;
809
+ source.connect(gain);
810
+ const { out, fx } = this.buildSoundEffects(track, gain);
811
+ out.connect(layer.input);
812
+ const crossfadeMs = this.resolveCrossfade(layer, track);
813
+ const instance = {
814
+ track,
815
+ element,
816
+ source,
817
+ gain,
818
+ fx,
819
+ crossfadeMs,
820
+ clipStart,
821
+ clipEnd,
822
+ generation,
823
+ };
824
+ element.addEventListener("loadedmetadata", () => {
825
+ if (clipStart > 0)
826
+ element.currentTime = clipStart;
827
+ });
828
+ element.addEventListener("ended", () => this.onTrackEnded(layer, instance));
829
+ element.addEventListener("error", () => this.onTrackError(layer, instance));
830
+ element.addEventListener("pause", () => this.onTrackPause(layer, instance));
831
+ // A rejected play() (autoplay policy, or a pause() interrupting a still-pending play) routes
832
+ // through the error path; the generation/current guards neutralize it for retired instances.
833
+ element.play().catch(() => this.onTrackError(layer, instance));
834
+ rampParam(gain.gain, this.effectiveGain(track), crossfadeMs, this.ctx.currentTime, incomingShape);
835
+ this.onLog?.(`· playing: ${track.name ?? track.url} on ${layer.id}`);
836
+ return instance;
837
+ }
838
+ resolveCrossfade(layer, track) {
839
+ return track.crossfade_ms ?? layer.crossfadeMs ?? DEFAULT_CROSSFADE_MS;
840
+ }
841
+ onTrackEnded(layer, instance) {
842
+ if (instance.generation !== layer.generation)
843
+ return;
844
+ if (layer.current !== instance || layer.transitionFired)
845
+ return;
846
+ if (instance.track.loop)
847
+ return;
848
+ layer.transitionFired = true;
849
+ this.advance(layer);
850
+ }
851
+ onTrackPause(layer, instance) {
852
+ // A pause we didn't initiate — an output-device change / interruption stopped the live track.
853
+ // Retired/cleared instances are filtered by the generation/current guards (we paused those); a
854
+ // natural end is an `ended` event, not a pause, and `disposed` covers teardown. Resume it.
855
+ if (this.disposed)
856
+ return;
857
+ if (instance.generation !== layer.generation || layer.current !== instance)
858
+ return;
859
+ if (instance.element.ended)
860
+ return;
861
+ void this.ctx.resume?.();
862
+ instance.element.play().catch(() => { });
863
+ }
864
+ onTrackError(layer, instance) {
865
+ if (instance.generation !== layer.generation || layer.current !== instance)
866
+ return;
867
+ this.onLog?.(`· failed to load: ${instance.track.name ?? instance.track.url}`);
868
+ if (layer.transitionFired)
869
+ return;
870
+ layer.transitionFired = true;
871
+ this.advance(layer);
872
+ }
873
+ /** Crossfade into the next pending track, or fade out and go idle when the queue is empty. */
874
+ advance(layer) {
875
+ const prev = layer.current;
876
+ let next = layer.pending.shift();
877
+ if (!next && layer.repeat && layer.queueSource.length > 0) {
878
+ // Queue-level loop: the rotation is empty, so rebuild it (re-shuffled each cycle) and keep
879
+ // playing instead of going idle. A per-track `loop` bed never reaches here (it never ends).
880
+ layer.pending = this.capPending(layer.shuffle ? shuffled(layer.queueSource) : layer.queueSource.slice());
881
+ next = layer.pending.shift();
882
+ }
883
+ if (!next) {
884
+ // Queue drained → lone fade-out to silence (no incoming to stay complementary with).
885
+ if (prev)
886
+ this.retire(prev, prev.crossfadeMs, "perceptual");
887
+ layer.current = null;
888
+ layer.transitionFired = false;
889
+ this.notify();
890
+ return;
891
+ }
892
+ layer.generation++;
893
+ layer.transitionFired = false;
894
+ const inst = this.playTrack(layer, next, "equal_power");
895
+ layer.current = inst;
896
+ if (prev)
897
+ this.retire(prev, inst.crossfadeMs, "equal_power");
898
+ this.notify();
899
+ }
900
+ /** Fade an instance out over `fadeMs` (`equal_power` when crossfading over an incoming track,
901
+ * `perceptual` for a lone fade to silence) then tear its nodes/element down. */
902
+ retire(instance, fadeMs, shape) {
903
+ this.fadeOutThen(instance.gain, fadeMs, () => this.teardownQueueInstance(instance), shape);
904
+ }
905
+ /** Ramp a sound's fade gain to silence, then run its teardown once the fade completes. */
906
+ fadeOutThen(gain, fadeMs, onSettle, shape) {
907
+ rampParam(gain.gain, 0, fadeMs, this.ctx.currentTime, shape);
908
+ const t = setTimeout(() => {
909
+ this.timers.delete(t);
910
+ onSettle();
911
+ }, Math.max(0, fadeMs));
912
+ this.timers.add(t);
913
+ }
914
+ teardownQueueInstance(instance) {
915
+ try {
916
+ instance.element.pause();
917
+ }
918
+ catch {
919
+ // no-op
920
+ }
921
+ this.disconnect(instance.source);
922
+ this.disconnect(instance.gain);
923
+ this.teardownSoundEffects(instance.fx);
924
+ instance.element.src = "";
925
+ }
926
+ // ── Oneshot playback ────────────────────────────────────────────────────────
927
+ startOneshot(layer, track, buffer) {
928
+ const src = this.ctx.createBufferSource();
929
+ src.buffer = buffer;
930
+ const gain = this.ctx.createGain();
931
+ src.connect(gain);
932
+ const { out, fx } = this.buildSoundEffects(track, gain);
933
+ out.connect(layer.input);
934
+ const target = this.effectiveGain(track);
935
+ const fadeIn = Math.max(0, (track.fade_in_ms ?? 0) / 1000);
936
+ const fadeOut = Math.max(0, (track.fade_out_ms ?? 0) / 1000);
937
+ const clipStart = Math.max(0, track.clip_start_seconds ?? 0);
938
+ const clipEnd = track.clip_end_seconds ?? null;
939
+ const clipDur = clipEnd != null ? Math.max(0, clipEnd - clipStart) : null;
940
+ const maxDur = track.max_duration_seconds != null ? Math.max(0, track.max_duration_seconds) : null;
941
+ // Effective length of a non-looping shot: the tightest of clip / max-duration (else the whole
942
+ // buffer). Drives the auto-stop below.
943
+ const bounded = [clipDur, maxDur].filter((d) => d != null);
944
+ const effectiveDur = bounded.length > 0 ? Math.min(...bounded) : null;
945
+ const when = this.ctx.currentTime + Math.max(0, track.delay_seconds ?? 0);
946
+ // When the sound actually ends (for scheduling the fade-out): a non-looping shot ends at its
947
+ // effective length; a looping shot runs until clear() UNLESS a max-duration caps it — a clip
948
+ // range on a loop just sets the loop bounds, it isn't an end, so it must not schedule a fade.
949
+ const endAt = track.loop
950
+ ? maxDur != null
951
+ ? when + maxDur
952
+ : null
953
+ : effectiveDur != null
954
+ ? when + effectiveDur
955
+ : null;
956
+ // Fade in (or jump to target) at start, then fade out before a known end. A lone SFX has no
957
+ // crossfade partner, so both use the perceptual (convex) shape.
958
+ if (fadeIn > 0) {
959
+ scheduleFade(gain.gain, 0, target, when, fadeIn, "perceptual");
960
+ }
961
+ else {
962
+ gain.gain.setValueAtTime(target, when);
963
+ }
964
+ if (fadeOut > 0 && endAt != null && endAt - when > fadeOut) {
965
+ // Start no earlier than the fade-in's end so the two curve windows never overlap (which
966
+ // setValueCurveAtTime rejects); the value holds at `target` in between.
967
+ const fadeOutStart = Math.max(endAt - fadeOut, when + fadeIn);
968
+ if (endAt - fadeOutStart > 0) {
969
+ scheduleFade(gain.gain, target, 0, fadeOutStart, endAt - fadeOutStart, "perceptual");
970
+ }
971
+ }
972
+ const inst = { src, gain, fx };
973
+ if (track.loop) {
974
+ src.loop = true;
975
+ if (clipEnd != null) {
976
+ src.loopStart = clipStart;
977
+ src.loopEnd = clipEnd;
978
+ }
979
+ src.start(when, clipStart);
980
+ // A looping one-shot ends only via clear() — unless a max-duration caps it.
981
+ if (maxDur != null)
982
+ src.stop(when + maxDur);
983
+ }
984
+ else if (effectiveDur != null) {
985
+ src.start(when, clipStart, effectiveDur);
986
+ }
987
+ else if (clipStart > 0) {
988
+ src.start(when, clipStart);
989
+ }
990
+ else {
991
+ src.start(when);
992
+ }
993
+ src.onended = () => {
994
+ if (!layer.oneshots.has(inst))
995
+ return;
996
+ layer.oneshots.delete(inst);
997
+ this.teardownOneshot(inst);
998
+ this.notify();
999
+ };
1000
+ layer.oneshots.add(inst);
1001
+ this.onLog?.(`· playing: ${track.name ?? track.url} on ${layer.id}`);
1002
+ this.notify();
1003
+ }
1004
+ teardownOneshot(inst) {
1005
+ this.disconnect(inst.src);
1006
+ this.disconnect(inst.gain);
1007
+ this.teardownSoundEffects(inst.fx);
1008
+ }
1009
+ // ── Monitor ─────────────────────────────────────────────────────────────────
1010
+ /** Fires the advance transition a crossfade before each current queue track's end, so the incoming
1011
+ * track has already faded in when the outgoing one finishes. Also drives clip-range looping. */
1012
+ monitor() {
1013
+ for (const layer of this.layers.values()) {
1014
+ const cur = layer.current;
1015
+ if (!cur || layer.transitionFired)
1016
+ continue;
1017
+ const el = cur.element;
1018
+ const pos = el.currentTime;
1019
+ if (cur.track.loop) {
1020
+ if (cur.clipEnd != null && pos >= cur.clipEnd)
1021
+ el.currentTime = cur.clipStart;
1022
+ continue; // a loop never advances the queue
1023
+ }
1024
+ const dur = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null;
1025
+ const clipEnd = cur.clipEnd ?? dur;
1026
+ if (clipEnd == null)
1027
+ continue;
1028
+ // Clamp the lead to half the clip so a track shorter than its crossfade still plays —
1029
+ // unclamped, the threshold goes negative and the first tick would skip it entirely.
1030
+ const leadS = Math.min(cur.crossfadeMs / 1000, Math.max(0, (clipEnd - cur.clipStart) / 2));
1031
+ if (pos >= clipEnd - leadS) {
1032
+ layer.transitionFired = true;
1033
+ this.advance(layer);
1034
+ }
1035
+ }
1036
+ }
1037
+ // ── Snapshot ────────────────────────────────────────────────────────────────
1038
+ stateOf(layer) {
1039
+ const oneshotCount = layer.oneshots.size;
1040
+ const queuedNames = layer.pending.map((t) => t.name ?? "");
1041
+ const base = {
1042
+ id: layer.id,
1043
+ label: layer.label,
1044
+ volume: layer.volume,
1045
+ repeat: layer.repeat,
1046
+ shuffle: layer.shuffle,
1047
+ // `effects`/`position` are omitted entirely when neutral (never set), keeping a plain layer's
1048
+ // state shape unchanged; present as-stored once an effect or a position has been applied.
1049
+ ...(Object.keys(layer.effects).length > 0 ? { effects: layer.effects } : {}),
1050
+ ...(layer.positionValue ? { position: layer.positionValue } : {}),
1051
+ };
1052
+ const cur = layer.current;
1053
+ if (!cur) {
1054
+ return {
1055
+ ...base,
1056
+ playing: oneshotCount > 0,
1057
+ track_name: null,
1058
+ track_source: null,
1059
+ queued_count: layer.pending.length,
1060
+ queued_names: queuedNames,
1061
+ elapsed_ms: 0,
1062
+ remaining_ms: 0,
1063
+ duration_ms: null,
1064
+ oneshot_count: oneshotCount,
1065
+ };
1066
+ }
1067
+ const el = cur.element;
1068
+ const pos = el.currentTime;
1069
+ const dur = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null;
1070
+ const clipEnd = cur.clipEnd ?? dur;
1071
+ const durationMs = clipEnd != null
1072
+ ? Math.max(0, (clipEnd - cur.clipStart) * 1000)
1073
+ : dur != null
1074
+ ? dur * 1000
1075
+ : null;
1076
+ return {
1077
+ ...base,
1078
+ playing: true,
1079
+ track_name: cur.track.name ?? null,
1080
+ track_source: cur.track.url ?? null,
1081
+ queued_count: layer.pending.length,
1082
+ queued_names: queuedNames,
1083
+ elapsed_ms: Math.max(0, (pos - cur.clipStart) * 1000),
1084
+ remaining_ms: clipEnd != null ? Math.max(0, (clipEnd - pos) * 1000) : 0,
1085
+ duration_ms: durationMs,
1086
+ oneshot_count: oneshotCount,
1087
+ };
1088
+ }
1089
+ // ── Shared helpers ──────────────────────────────────────────────────────────
1090
+ makePanner(pos, distance) {
1091
+ const p = this.ctx.createPanner();
1092
+ p.panningModel = "HRTF";
1093
+ this.applyDistanceParams(p, distance);
1094
+ p.positionX.value = pos.x;
1095
+ p.positionY.value = pos.y;
1096
+ p.positionZ.value = pos.z ?? 0;
1097
+ return p;
1098
+ }
1099
+ /** Configure a panner's distance model. Absent `distance` → direction-only (rolloff 0), exactly
1100
+ * today's behaviour; present → apply the model with field defaults (inverse / ref 1 / max 10000 /
1101
+ * rolloff 1). */
1102
+ applyDistanceParams(p, distance) {
1103
+ if (!distance) {
1104
+ p.distanceModel = "inverse";
1105
+ p.refDistance = 1;
1106
+ p.maxDistance = 10000;
1107
+ p.rolloffFactor = 0;
1108
+ return;
1109
+ }
1110
+ // Clamp to what the PannerNode setters accept (negative ref/rolloff and non-positive max throw)
1111
+ // — wire values are untrusted and this path runs unguarded in the signal handler.
1112
+ p.distanceModel = validDistanceModel(distance.model);
1113
+ p.refDistance = Math.max(0, finiteOr(distance.ref, 1));
1114
+ p.maxDistance = Math.max(1e-4, finiteOr(distance.max, 10000));
1115
+ p.rolloffFactor = Math.max(0, finiteOr(distance.rolloff, 1));
1116
+ }
1117
+ /** Build a sound's per-sound effect insert onto its `gain` node:
1118
+ * `gain → [lowpass] → [highpass] → [panner] → out`, wiring reverb/echo sends off the final node.
1119
+ * Only requested nodes are created; returns the node to connect to the layer input plus the fx
1120
+ * record for teardown. Filters/panner are static for the sound's lifetime (no per-sound ramp). */
1121
+ buildSoundEffects(track, gain) {
1122
+ const fx = {
1123
+ lowpass: null,
1124
+ highpass: null,
1125
+ panner: null,
1126
+ reverbSend: null,
1127
+ echo: null,
1128
+ tailMs: 0,
1129
+ };
1130
+ const eff = track.effects;
1131
+ let node = gain;
1132
+ if (eff?.lowpass) {
1133
+ const lp = this.ctx.createBiquadFilter();
1134
+ lp.type = "lowpass";
1135
+ lp.frequency.value = eff.lowpass.hz;
1136
+ lp.Q.value = eff.lowpass.q ?? NEUTRAL_Q;
1137
+ node.connect(lp);
1138
+ node = lp;
1139
+ fx.lowpass = lp;
1140
+ }
1141
+ if (eff?.highpass) {
1142
+ const hp = this.ctx.createBiquadFilter();
1143
+ hp.type = "highpass";
1144
+ hp.frequency.value = eff.highpass.hz;
1145
+ hp.Q.value = eff.highpass.q ?? NEUTRAL_Q;
1146
+ node.connect(hp);
1147
+ node = hp;
1148
+ fx.highpass = hp;
1149
+ }
1150
+ if (track.spatial) {
1151
+ const panner = this.makePanner(track.spatial, track.spatial.distance);
1152
+ node.connect(panner);
1153
+ node = panner;
1154
+ fx.panner = panner;
1155
+ }
1156
+ // Sends tap the final per-sound node (post filters + panner) — they stack in front of the layer bus.
1157
+ if (eff?.reverb && eff.reverb.mix > 0) {
1158
+ fx.reverbSend = this.makeSoundReverbSend(node, eff.reverb);
1159
+ fx.tailMs = Math.max(fx.tailMs, REVERB_TAIL_MS);
1160
+ }
1161
+ if (eff?.echo) {
1162
+ const echo = this.createEchoNodes();
1163
+ node.connect(echo.send);
1164
+ this.setEchoParams(echo, eff.echo, 0);
1165
+ fx.echo = echo;
1166
+ fx.tailMs = Math.max(fx.tailMs, echoTailMs(eff.echo));
1167
+ }
1168
+ return { out: node, fx };
1169
+ }
1170
+ /** A per-sound reverb send off `node` into the configured IR's bus (a preset, or a custom asset IR
1171
+ * that falls back to the preset if it fails to load). */
1172
+ makeSoundReverbSend(node, reverb) {
1173
+ const preset = reverbPresetOf(reverb.ir);
1174
+ const send = this.ctx.createGain();
1175
+ send.gain.value = clamp01(reverb.mix);
1176
+ if (reverb.ir_asset) {
1177
+ const key = `asset:${reverb.ir_asset}`;
1178
+ this.ensureAssetReverbBus(reverb.ir_asset, () => {
1179
+ const fallback = this.ensurePresetReverbBus(preset);
1180
+ this.disconnect(send); // drop the (silent) failed-asset send edge, keep node → send
1181
+ send.connect(fallback.convolver);
1182
+ });
1183
+ node.connect(send);
1184
+ const bus = this.reverbBuses.get(key);
1185
+ if (bus)
1186
+ send.connect(bus.convolver);
1187
+ }
1188
+ else {
1189
+ const bus = this.ensurePresetReverbBus(preset);
1190
+ node.connect(send);
1191
+ send.connect(bus.convolver);
1192
+ }
1193
+ return send;
1194
+ }
1195
+ /** Tear down a sound's effect insert: dry-path nodes go now; the echo/reverb send nodes carry the
1196
+ * tail, so they linger `tailMs` past the source stop (the shared reverb bus itself persists). On
1197
+ * dispose they go immediately. */
1198
+ teardownSoundEffects(fx) {
1199
+ if (fx.lowpass)
1200
+ this.disconnect(fx.lowpass);
1201
+ if (fx.highpass)
1202
+ this.disconnect(fx.highpass);
1203
+ if (fx.panner)
1204
+ this.disconnect(fx.panner);
1205
+ if (!fx.reverbSend && !fx.echo)
1206
+ return;
1207
+ const tailNodes = [];
1208
+ if (fx.reverbSend)
1209
+ tailNodes.push(fx.reverbSend);
1210
+ if (fx.echo)
1211
+ tailNodes.push(fx.echo.send, fx.echo.delay, fx.echo.feedback);
1212
+ if (this.disposed) {
1213
+ for (const n of tailNodes)
1214
+ this.disconnect(n);
1215
+ return;
1216
+ }
1217
+ const t = setTimeout(() => {
1218
+ this.timers.delete(t);
1219
+ for (const n of tailNodes)
1220
+ this.disconnect(n);
1221
+ }, Math.max(0, fx.tailMs));
1222
+ this.timers.add(t);
1223
+ }
1224
+ disconnect(node) {
1225
+ try {
1226
+ node.disconnect();
1227
+ }
1228
+ catch {
1229
+ // already disconnected
1230
+ }
1231
+ }
1232
+ /** Coalesce a synchronous burst of changes into one listener notification (one enqueue of five
1233
+ * tracks fires once). */
1234
+ notify() {
1235
+ if (this.notifyScheduled)
1236
+ return;
1237
+ this.notifyScheduled = true;
1238
+ queueMicrotask(() => {
1239
+ this.notifyScheduled = false;
1240
+ for (const cb of this.listeners)
1241
+ cb();
1242
+ });
1243
+ }
1244
+ }
1245
+ //# sourceMappingURL=audio-layers.js.map