@zkmake/sound-scape 0.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.
@@ -0,0 +1,281 @@
1
+ //#region src/core/random.ts
2
+ /** A value drawn uniformly from `[low, high]`. */
3
+ const between = (random, [low, high]) => low + random() * (high - low);
4
+ /**
5
+ * One of `pool` at random, never `last` when there is a choice. With one sample it repeats; with
6
+ * two it alternates; with three or more the ear stops hearing a pattern.
7
+ */
8
+ const pickNotLast = (pool, last, random) => {
9
+ const choices = pool.length > 1 ? pool.filter((item) => item !== last) : pool;
10
+ return choices[Math.min(choices.length - 1, Math.floor(random() * choices.length))] ?? pool[0];
11
+ };
12
+ /**
13
+ * A small seeded generator (mulberry32) in `Math.random`'s shape: the same seed plays the same
14
+ * soundscape, for tests and for demos you want to reproduce.
15
+ */
16
+ const seededRandom = (seed) => {
17
+ let state = seed >>> 0;
18
+ return () => {
19
+ state = state + 1831565813 >>> 0;
20
+ let t = state;
21
+ t = Math.imul(t ^ t >>> 15, t | 1);
22
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
23
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
24
+ };
25
+ };
26
+ //#endregion
27
+ //#region src/core/soundscape.ts
28
+ const DEFAULT_VOLUME = [.7, 1];
29
+ const DEFAULT_RATE = [.95, 1.05];
30
+ const DEFAULT_PAN = [-.4, .4];
31
+ const DEFAULT_BED_FADE_IN_MS = 500;
32
+ const DEFAULT_BED_FADE_OUT_MS = 700;
33
+ const DEFAULT_MAX_CONCURRENT = 4;
34
+ /** Emitters cut by `stop` or suppression fade this fast at most, so a cut bird doesn't click. */
35
+ const EMITTER_CUT_MS = 250;
36
+ const defaultTimers = {
37
+ set: (callback, ms) => globalThis.setTimeout(callback, ms),
38
+ clear: (handle) => globalThis.clearTimeout(handle)
39
+ };
40
+ /**
41
+ * A soundscape: one looping bed under emitters that fire on their own random timers, each fire
42
+ * with a fresh sample (never the last one), volume, rate and pan. A concurrency cap drops fires
43
+ * that would muddy the mix. Every audio call goes through a `SoundscapeBus`, so the engine runs on
44
+ * Howler, raw Web Audio or a fake in a test.
45
+ *
46
+ * `start` and `stop` can alternate any number of times; `dispose` is final and safe to call twice.
47
+ * Every timer checks a disposed flag before it fires or re-arms, so a timer queued before a
48
+ * teardown (React StrictMode, a scene swap in the same tick) does nothing.
49
+ */
50
+ var Soundscape = class {
51
+ def;
52
+ bus;
53
+ random;
54
+ timers;
55
+ onEmitter;
56
+ emitters;
57
+ /** Live emitter voices, ours only: a bus shared with another soundscape keeps its voices. */
58
+ live = /* @__PURE__ */ new Map();
59
+ bedId = null;
60
+ bedGain = 1;
61
+ running = false;
62
+ disposed = false;
63
+ suppressed = false;
64
+ constructor(def, bus, opts = {}) {
65
+ this.def = def;
66
+ this.bus = bus;
67
+ this.random = opts.random ?? Math.random;
68
+ this.timers = opts.timers ?? defaultTimers;
69
+ this.onEmitter = opts.onEmitter;
70
+ this.emitters = (def.emitters ?? []).map((emitter, index) => ({
71
+ name: emitter.name ?? String(index),
72
+ def: emitter,
73
+ timer: null,
74
+ armed: false,
75
+ last: null
76
+ }));
77
+ }
78
+ /** Fade the bed in and arm the emitters. Does nothing while running or once disposed. */
79
+ start(opts = {}) {
80
+ if (this.disposed || this.running) return;
81
+ this.running = true;
82
+ const bed = this.def.bed;
83
+ if (bed) this.bedId = this.bus.playBed(bed.sample, {
84
+ volume: bed.volume * this.bedGain,
85
+ fadeInMs: opts.fadeInMs ?? bed.fadeInMs ?? DEFAULT_BED_FADE_IN_MS
86
+ });
87
+ for (const emitter of this.emitters) this.arm(emitter, .25 + this.random() * .5);
88
+ }
89
+ /** Fade the bed out and cut the emitters. `start` brings it all back. */
90
+ stop(opts = {}) {
91
+ if (!this.running) return;
92
+ this.running = false;
93
+ for (const emitter of this.emitters) this.disarm(emitter);
94
+ const fadeOutMs = opts.fadeOutMs ?? this.def.bed?.fadeOutMs ?? DEFAULT_BED_FADE_OUT_MS;
95
+ if (this.bedId !== null) {
96
+ this.bus.stop(this.bedId, fadeOutMs);
97
+ this.bedId = null;
98
+ }
99
+ this.cutEmitters(Math.min(fadeOutMs, EMITTER_CUT_MS));
100
+ }
101
+ /** Stop for good. Safe to call twice; `start` does nothing afterwards. */
102
+ dispose(opts = {}) {
103
+ if (this.disposed) return;
104
+ this.stop(opts);
105
+ this.disposed = true;
106
+ }
107
+ /**
108
+ * Hold the emitters back, for dialog or narration: one-shots compete with a voice line and with
109
+ * screen readers even when ducked. Voices already playing fade out; the timers keep ticking and
110
+ * fires resume when lifted. The bed plays on (duck it through the adapter if it should dip).
111
+ */
112
+ suppressEmitters(suppressed, opts = {}) {
113
+ if (this.suppressed === suppressed) return;
114
+ this.suppressed = suppressed;
115
+ if (suppressed) this.cutEmitters(opts.fadeOutMs ?? 300);
116
+ }
117
+ /**
118
+ * Fire one emitter now, by name or index, whether or not the soundscape is running. Still
119
+ * respects the cap. Returns whether a voice started. For debug panels and tests.
120
+ */
121
+ fire(emitter) {
122
+ const state = typeof emitter === "number" ? this.emitters[emitter] : this.emitters.find((candidate) => candidate.name === emitter);
123
+ if (!state || this.disposed) return false;
124
+ return this.play(state);
125
+ }
126
+ /** Scale the bed's volume, for a scene that wants it lower or a debug slider. Kept across restarts. */
127
+ setBedGain(gain, opts = {}) {
128
+ this.bedGain = gain;
129
+ if (this.bedId !== null && this.def.bed) this.bus.setVolume(this.bedId, this.def.bed.volume * gain, opts.fadeMs ?? 0);
130
+ }
131
+ get isRunning() {
132
+ return this.running;
133
+ }
134
+ get isDisposed() {
135
+ return this.disposed;
136
+ }
137
+ get emittersSuppressed() {
138
+ return this.suppressed;
139
+ }
140
+ /** Emitter voices playing right now. */
141
+ get liveEmitters() {
142
+ return this.live.size;
143
+ }
144
+ get emitterNames() {
145
+ return this.emitters.map((emitter) => emitter.name);
146
+ }
147
+ /** Schedule the next fire; `share` scales the interval, for the first one. */
148
+ arm(emitter, share = 1) {
149
+ if (this.disposed || !this.running) return;
150
+ const wait = between(this.random, emitter.def.intervalMs) * share;
151
+ emitter.armed = true;
152
+ emitter.timer = this.timers.set(() => {
153
+ emitter.armed = false;
154
+ emitter.timer = null;
155
+ if (this.disposed || !this.running) return;
156
+ if (!this.suppressed) this.play(emitter);
157
+ this.arm(emitter);
158
+ }, wait);
159
+ }
160
+ disarm(emitter) {
161
+ if (emitter.armed) {
162
+ this.timers.clear(emitter.timer);
163
+ emitter.armed = false;
164
+ emitter.timer = null;
165
+ }
166
+ }
167
+ play(emitter) {
168
+ if (this.live.size >= (this.def.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)) return false;
169
+ const sample = pickNotLast(emitter.def.samples, emitter.last, this.random);
170
+ if (sample === void 0) return false;
171
+ let ended = false;
172
+ let id = null;
173
+ id = this.bus.playEmitter(sample, {
174
+ volume: between(this.random, emitter.def.volume ?? DEFAULT_VOLUME),
175
+ rate: between(this.random, emitter.def.rate ?? DEFAULT_RATE),
176
+ pan: between(this.random, emitter.def.pan ?? DEFAULT_PAN),
177
+ onEnd: () => {
178
+ ended = true;
179
+ if (id !== null) this.release(id);
180
+ }
181
+ });
182
+ if (id === null) return false;
183
+ emitter.last = sample;
184
+ this.onEmitter?.({
185
+ emitter: emitter.name,
186
+ sample,
187
+ phase: "start"
188
+ });
189
+ if (ended) this.onEmitter?.({
190
+ emitter: emitter.name,
191
+ sample,
192
+ phase: "end"
193
+ });
194
+ else this.live.set(id, {
195
+ emitter: emitter.name,
196
+ sample
197
+ });
198
+ return true;
199
+ }
200
+ release(id) {
201
+ const voice = this.live.get(id);
202
+ if (voice) {
203
+ this.live.delete(id);
204
+ this.onEmitter?.({
205
+ ...voice,
206
+ phase: "end"
207
+ });
208
+ }
209
+ }
210
+ cutEmitters(fadeOutMs) {
211
+ for (const id of Array.from(this.live.keys())) {
212
+ this.bus.stop(id, fadeOutMs);
213
+ this.release(id);
214
+ }
215
+ }
216
+ };
217
+ //#endregion
218
+ //#region src/core/player.ts
219
+ const DEFAULT_CROSSFADE_MS = 1e3;
220
+ /** Two defs with the same content are the same soundscape, so an inline def doesn't restart the bed. */
221
+ const signature = (def) => JSON.stringify(def);
222
+ /**
223
+ * Plays one soundscape at a time over a bus and swaps between them: `play(def)` starts it, a
224
+ * different def crossfades to it, the same def again (by content, not identity) does nothing, and
225
+ * `play(null)` fades out. It also follows tab visibility. This is what a scene manager or a React
226
+ * effect wants; use `Soundscape` directly for full control.
227
+ */
228
+ var SoundscapePlayer = class {
229
+ bus;
230
+ opts;
231
+ scape = null;
232
+ key = null;
233
+ hidden = false;
234
+ disposed = false;
235
+ onVisibility = () => {
236
+ this.hidden = document.visibilityState === "hidden";
237
+ if (this.hidden) this.scape?.stop();
238
+ else this.scape?.start();
239
+ };
240
+ constructor(bus, opts = {}) {
241
+ this.bus = bus;
242
+ this.opts = opts;
243
+ if ((opts.whenHidden ?? "stop") === "stop" && typeof document !== "undefined") {
244
+ this.hidden = document.visibilityState === "hidden";
245
+ document.addEventListener("visibilitychange", this.onVisibility);
246
+ }
247
+ }
248
+ /** Play `def`, crossfading from whatever plays now. `null` fades out. */
249
+ play(def, opts = {}) {
250
+ if (this.disposed) return;
251
+ const key = def === null ? null : signature(def);
252
+ if (key === this.key) return;
253
+ const crossfadeMs = opts.crossfadeMs ?? this.opts.crossfadeMs ?? DEFAULT_CROSSFADE_MS;
254
+ const previous = this.scape;
255
+ const crossfading = previous?.isRunning ?? false;
256
+ previous?.dispose({ fadeOutMs: crossfadeMs });
257
+ this.key = key;
258
+ this.scape = def === null ? null : new Soundscape(def, this.bus, this.opts);
259
+ if (this.scape && !this.hidden) this.scape.start(crossfading ? { fadeInMs: crossfadeMs } : {});
260
+ }
261
+ /** Fade out and forget the current soundscape. */
262
+ stop(opts = {}) {
263
+ this.scape?.dispose(opts);
264
+ this.scape = null;
265
+ this.key = null;
266
+ }
267
+ /** The soundscape playing now, for `fire`, `suppressEmitters` or `setBedGain`. */
268
+ get current() {
269
+ return this.scape;
270
+ }
271
+ dispose(opts = {}) {
272
+ if (this.disposed) return;
273
+ this.stop(opts);
274
+ this.disposed = true;
275
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", this.onVisibility);
276
+ }
277
+ };
278
+ //#endregion
279
+ export { seededRandom as i, Soundscape as n, pickNotLast as r, SoundscapePlayer as t };
280
+
281
+ //# sourceMappingURL=player-BlQ9UyoK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"player-BlQ9UyoK.js","names":[],"sources":["../src/core/random.ts","../src/core/soundscape.ts","../src/core/player.ts"],"sourcesContent":["import { type Range } from \"./types.ts\";\n\n/** A value drawn uniformly from `[low, high]`. */\nconst between = (random: () => number, [low, high]: Range) => low + random() * (high - low);\n\n/**\n * One of `pool` at random, never `last` when there is a choice. With one sample it repeats; with\n * two it alternates; with three or more the ear stops hearing a pattern.\n */\nconst pickNotLast = <T>(\n pool: readonly T[],\n last: T | null,\n random: () => number,\n): T | undefined => {\n const choices = pool.length > 1 ? pool.filter((item) => item !== last) : pool;\n const index = Math.min(choices.length - 1, Math.floor(random() * choices.length));\n\n return choices[index] ?? pool[0];\n};\n\n/**\n * A small seeded generator (mulberry32) in `Math.random`'s shape: the same seed plays the same\n * soundscape, for tests and for demos you want to reproduce.\n */\nconst seededRandom = (seed: number) => {\n let state = seed >>> 0;\n\n return () => {\n state = (state + 0x6d2b79f5) >>> 0;\n\n let t = state;\n\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n};\n\nexport { between, pickNotLast, seededRandom };\n","import { between, pickNotLast } from \"./random.ts\";\nimport {\n type EmitterDef,\n type Range,\n type SoundscapeBus,\n type SoundscapeDef,\n type SoundscapeOptions,\n type Timers,\n} from \"./types.ts\";\n\nconst DEFAULT_VOLUME: Range = [0.7, 1];\nconst DEFAULT_RATE: Range = [0.95, 1.05];\nconst DEFAULT_PAN: Range = [-0.4, 0.4];\nconst DEFAULT_BED_FADE_IN_MS = 500;\nconst DEFAULT_BED_FADE_OUT_MS = 700;\nconst DEFAULT_MAX_CONCURRENT = 4;\n/** Emitters cut by `stop` or suppression fade this fast at most, so a cut bird doesn't click. */\nconst EMITTER_CUT_MS = 250;\n\ntype EmitterState = {\n name: string;\n def: EmitterDef;\n timer: unknown;\n armed: boolean;\n last: string | null;\n};\n\nconst defaultTimers: Timers = {\n set: (callback, ms) => globalThis.setTimeout(callback, ms),\n clear: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),\n};\n\n/**\n * A soundscape: one looping bed under emitters that fire on their own random timers, each fire\n * with a fresh sample (never the last one), volume, rate and pan. A concurrency cap drops fires\n * that would muddy the mix. Every audio call goes through a `SoundscapeBus`, so the engine runs on\n * Howler, raw Web Audio or a fake in a test.\n *\n * `start` and `stop` can alternate any number of times; `dispose` is final and safe to call twice.\n * Every timer checks a disposed flag before it fires or re-arms, so a timer queued before a\n * teardown (React StrictMode, a scene swap in the same tick) does nothing.\n */\nclass Soundscape<Id = unknown> {\n readonly def: SoundscapeDef;\n private readonly bus: SoundscapeBus<Id>;\n private readonly random: () => number;\n private readonly timers: Timers;\n private readonly onEmitter: SoundscapeOptions[\"onEmitter\"];\n private readonly emitters: EmitterState[];\n /** Live emitter voices, ours only: a bus shared with another soundscape keeps its voices. */\n private readonly live = new Map<Id, { emitter: string; sample: string }>();\n private bedId: Id | null = null;\n private bedGain = 1;\n private running = false;\n private disposed = false;\n private suppressed = false;\n\n constructor(def: SoundscapeDef, bus: SoundscapeBus<Id>, opts: SoundscapeOptions = {}) {\n this.def = def;\n this.bus = bus;\n this.random = opts.random ?? Math.random;\n this.timers = opts.timers ?? defaultTimers;\n this.onEmitter = opts.onEmitter;\n this.emitters = (def.emitters ?? []).map((emitter, index) => ({\n name: emitter.name ?? String(index),\n def: emitter,\n timer: null,\n armed: false,\n last: null,\n }));\n }\n\n /** Fade the bed in and arm the emitters. Does nothing while running or once disposed. */\n start(opts: { fadeInMs?: number } = {}) {\n if (this.disposed || this.running) {\n return;\n }\n\n this.running = true;\n\n const bed = this.def.bed;\n\n if (bed) {\n this.bedId = this.bus.playBed(bed.sample, {\n volume: bed.volume * this.bedGain,\n fadeInMs: opts.fadeInMs ?? bed.fadeInMs ?? DEFAULT_BED_FADE_IN_MS,\n });\n }\n\n for (const emitter of this.emitters) {\n // The first fire comes sooner than a full interval, so the scene isn't mute for a while.\n this.arm(emitter, 0.25 + this.random() * 0.5);\n }\n }\n\n /** Fade the bed out and cut the emitters. `start` brings it all back. */\n stop(opts: { fadeOutMs?: number } = {}) {\n if (!this.running) {\n return;\n }\n\n this.running = false;\n\n for (const emitter of this.emitters) {\n this.disarm(emitter);\n }\n\n const fadeOutMs = opts.fadeOutMs ?? this.def.bed?.fadeOutMs ?? DEFAULT_BED_FADE_OUT_MS;\n\n if (this.bedId !== null) {\n this.bus.stop(this.bedId, fadeOutMs);\n this.bedId = null;\n }\n\n this.cutEmitters(Math.min(fadeOutMs, EMITTER_CUT_MS));\n }\n\n /** Stop for good. Safe to call twice; `start` does nothing afterwards. */\n dispose(opts: { fadeOutMs?: number } = {}) {\n if (this.disposed) {\n return;\n }\n\n this.stop(opts);\n this.disposed = true;\n }\n\n /**\n * Hold the emitters back, for dialog or narration: one-shots compete with a voice line and with\n * screen readers even when ducked. Voices already playing fade out; the timers keep ticking and\n * fires resume when lifted. The bed plays on (duck it through the adapter if it should dip).\n */\n suppressEmitters(suppressed: boolean, opts: { fadeOutMs?: number } = {}) {\n if (this.suppressed === suppressed) {\n return;\n }\n\n this.suppressed = suppressed;\n\n if (suppressed) {\n this.cutEmitters(opts.fadeOutMs ?? 300);\n }\n }\n\n /**\n * Fire one emitter now, by name or index, whether or not the soundscape is running. Still\n * respects the cap. Returns whether a voice started. For debug panels and tests.\n */\n fire(emitter: string | number) {\n const state =\n typeof emitter === \"number\"\n ? this.emitters[emitter]\n : this.emitters.find((candidate) => candidate.name === emitter);\n\n if (!state || this.disposed) {\n return false;\n }\n\n return this.play(state);\n }\n\n /** Scale the bed's volume, for a scene that wants it lower or a debug slider. Kept across restarts. */\n setBedGain(gain: number, opts: { fadeMs?: number } = {}) {\n this.bedGain = gain;\n\n if (this.bedId !== null && this.def.bed) {\n this.bus.setVolume(this.bedId, this.def.bed.volume * gain, opts.fadeMs ?? 0);\n }\n }\n\n get isRunning() {\n return this.running;\n }\n\n get isDisposed() {\n return this.disposed;\n }\n\n get emittersSuppressed() {\n return this.suppressed;\n }\n\n /** Emitter voices playing right now. */\n get liveEmitters() {\n return this.live.size;\n }\n\n get emitterNames(): readonly string[] {\n return this.emitters.map((emitter) => emitter.name);\n }\n\n /** Schedule the next fire; `share` scales the interval, for the first one. */\n private arm(emitter: EmitterState, share = 1) {\n if (this.disposed || !this.running) {\n return;\n }\n\n const wait = between(this.random, emitter.def.intervalMs) * share;\n\n emitter.armed = true;\n emitter.timer = this.timers.set(() => {\n emitter.armed = false;\n emitter.timer = null;\n\n if (this.disposed || !this.running) {\n return;\n }\n\n if (!this.suppressed) {\n this.play(emitter);\n }\n\n this.arm(emitter);\n }, wait);\n }\n\n private disarm(emitter: EmitterState) {\n if (emitter.armed) {\n this.timers.clear(emitter.timer);\n emitter.armed = false;\n emitter.timer = null;\n }\n }\n\n private play(emitter: EmitterState) {\n if (this.live.size >= (this.def.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)) {\n return false;\n }\n\n const sample = pickNotLast(emitter.def.samples, emitter.last, this.random);\n\n if (sample === undefined) {\n return false;\n }\n\n let ended = false;\n let id: Id | null = null;\n\n id = this.bus.playEmitter(sample, {\n volume: between(this.random, emitter.def.volume ?? DEFAULT_VOLUME),\n rate: between(this.random, emitter.def.rate ?? DEFAULT_RATE),\n pan: between(this.random, emitter.def.pan ?? DEFAULT_PAN),\n onEnd: () => {\n // An adapter may end a voice before `playEmitter` returns (a zero-length sample).\n ended = true;\n\n if (id !== null) {\n this.release(id);\n }\n },\n });\n\n if (id === null) {\n return false;\n }\n\n emitter.last = sample;\n this.onEmitter?.({ emitter: emitter.name, sample, phase: \"start\" });\n\n if (ended) {\n this.onEmitter?.({ emitter: emitter.name, sample, phase: \"end\" });\n } else {\n this.live.set(id, { emitter: emitter.name, sample });\n }\n\n return true;\n }\n\n private release(id: Id) {\n const voice = this.live.get(id);\n\n if (voice) {\n this.live.delete(id);\n this.onEmitter?.({ ...voice, phase: \"end\" });\n }\n }\n\n private cutEmitters(fadeOutMs: number) {\n for (const id of Array.from(this.live.keys())) {\n this.bus.stop(id, fadeOutMs);\n this.release(id);\n }\n }\n}\n\nexport { Soundscape };\n","import { Soundscape } from \"./soundscape.ts\";\nimport { type SoundscapeBus, type SoundscapeDef, type SoundscapeOptions } from \"./types.ts\";\n\ntype SoundscapePlayerOptions = SoundscapeOptions & {\n /** Swap time between two soundscapes: the old bed fades out while the new one fades in. Default 1000. */\n crossfadeMs?: number;\n /**\n * `\"stop\"` (default) fades the soundscape out while the tab is hidden and back in when it\n * returns, so a background tab doesn't play to itself. `\"play\"` leaves it alone.\n */\n whenHidden?: \"stop\" | \"play\";\n};\n\nconst DEFAULT_CROSSFADE_MS = 1000;\n\n/** Two defs with the same content are the same soundscape, so an inline def doesn't restart the bed. */\nconst signature = (def: SoundscapeDef) => JSON.stringify(def);\n\n/**\n * Plays one soundscape at a time over a bus and swaps between them: `play(def)` starts it, a\n * different def crossfades to it, the same def again (by content, not identity) does nothing, and\n * `play(null)` fades out. It also follows tab visibility. This is what a scene manager or a React\n * effect wants; use `Soundscape` directly for full control.\n */\nclass SoundscapePlayer<Id = unknown> {\n private readonly bus: SoundscapeBus<Id>;\n private readonly opts: SoundscapePlayerOptions;\n private scape: Soundscape<Id> | null = null;\n private key: string | null = null;\n private hidden = false;\n private disposed = false;\n private readonly onVisibility = () => {\n this.hidden = document.visibilityState === \"hidden\";\n\n if (this.hidden) {\n this.scape?.stop();\n } else {\n this.scape?.start();\n }\n };\n\n constructor(bus: SoundscapeBus<Id>, opts: SoundscapePlayerOptions = {}) {\n this.bus = bus;\n this.opts = opts;\n\n if ((opts.whenHidden ?? \"stop\") === \"stop\" && typeof document !== \"undefined\") {\n this.hidden = document.visibilityState === \"hidden\";\n document.addEventListener(\"visibilitychange\", this.onVisibility);\n }\n }\n\n /** Play `def`, crossfading from whatever plays now. `null` fades out. */\n play(def: SoundscapeDef | null, opts: { crossfadeMs?: number } = {}) {\n if (this.disposed) {\n return;\n }\n\n const key = def === null ? null : signature(def);\n\n if (key === this.key) {\n return;\n }\n\n const crossfadeMs = opts.crossfadeMs ?? this.opts.crossfadeMs ?? DEFAULT_CROSSFADE_MS;\n const previous = this.scape;\n // Only a soundscape that is actually playing is crossfaded from.\n const crossfading = previous?.isRunning ?? false;\n\n previous?.dispose({ fadeOutMs: crossfadeMs });\n this.key = key;\n this.scape = def === null ? null : new Soundscape(def, this.bus, this.opts);\n\n if (this.scape && !this.hidden) {\n this.scape.start(crossfading ? { fadeInMs: crossfadeMs } : {});\n }\n }\n\n /** Fade out and forget the current soundscape. */\n stop(opts: { fadeOutMs?: number } = {}) {\n this.scape?.dispose(opts);\n this.scape = null;\n this.key = null;\n }\n\n /** The soundscape playing now, for `fire`, `suppressEmitters` or `setBedGain`. */\n get current() {\n return this.scape;\n }\n\n dispose(opts: { fadeOutMs?: number } = {}) {\n if (this.disposed) {\n return;\n }\n\n this.stop(opts);\n this.disposed = true;\n\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", this.onVisibility);\n }\n }\n}\n\nexport { SoundscapePlayer };\nexport type { SoundscapePlayerOptions };\n"],"mappings":"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"}
@@ -0,0 +1,112 @@
1
+ import { a as SoundscapeBus, o as SoundscapeDef, s as SoundscapeOptions } from "./types-BML2s2Se.js";
2
+ //#region src/core/soundscape.d.ts
3
+ /**
4
+ * A soundscape: one looping bed under emitters that fire on their own random timers, each fire
5
+ * with a fresh sample (never the last one), volume, rate and pan. A concurrency cap drops fires
6
+ * that would muddy the mix. Every audio call goes through a `SoundscapeBus`, so the engine runs on
7
+ * Howler, raw Web Audio or a fake in a test.
8
+ *
9
+ * `start` and `stop` can alternate any number of times; `dispose` is final and safe to call twice.
10
+ * Every timer checks a disposed flag before it fires or re-arms, so a timer queued before a
11
+ * teardown (React StrictMode, a scene swap in the same tick) does nothing.
12
+ */
13
+ declare class Soundscape<Id = unknown> {
14
+ readonly def: SoundscapeDef;
15
+ private readonly bus;
16
+ private readonly random;
17
+ private readonly timers;
18
+ private readonly onEmitter;
19
+ private readonly emitters;
20
+ /** Live emitter voices, ours only: a bus shared with another soundscape keeps its voices. */
21
+ private readonly live;
22
+ private bedId;
23
+ private bedGain;
24
+ private running;
25
+ private disposed;
26
+ private suppressed;
27
+ constructor(def: SoundscapeDef, bus: SoundscapeBus<Id>, opts?: SoundscapeOptions);
28
+ /** Fade the bed in and arm the emitters. Does nothing while running or once disposed. */
29
+ start(opts?: {
30
+ fadeInMs?: number;
31
+ }): void;
32
+ /** Fade the bed out and cut the emitters. `start` brings it all back. */
33
+ stop(opts?: {
34
+ fadeOutMs?: number;
35
+ }): void;
36
+ /** Stop for good. Safe to call twice; `start` does nothing afterwards. */
37
+ dispose(opts?: {
38
+ fadeOutMs?: number;
39
+ }): void;
40
+ /**
41
+ * Hold the emitters back, for dialog or narration: one-shots compete with a voice line and with
42
+ * screen readers even when ducked. Voices already playing fade out; the timers keep ticking and
43
+ * fires resume when lifted. The bed plays on (duck it through the adapter if it should dip).
44
+ */
45
+ suppressEmitters(suppressed: boolean, opts?: {
46
+ fadeOutMs?: number;
47
+ }): void;
48
+ /**
49
+ * Fire one emitter now, by name or index, whether or not the soundscape is running. Still
50
+ * respects the cap. Returns whether a voice started. For debug panels and tests.
51
+ */
52
+ fire(emitter: string | number): boolean;
53
+ /** Scale the bed's volume, for a scene that wants it lower or a debug slider. Kept across restarts. */
54
+ setBedGain(gain: number, opts?: {
55
+ fadeMs?: number;
56
+ }): void;
57
+ get isRunning(): boolean;
58
+ get isDisposed(): boolean;
59
+ get emittersSuppressed(): boolean;
60
+ /** Emitter voices playing right now. */
61
+ get liveEmitters(): number;
62
+ get emitterNames(): readonly string[];
63
+ /** Schedule the next fire; `share` scales the interval, for the first one. */
64
+ private arm;
65
+ private disarm;
66
+ private play;
67
+ private release;
68
+ private cutEmitters;
69
+ }
70
+ //#endregion
71
+ //#region src/core/player.d.ts
72
+ type SoundscapePlayerOptions = SoundscapeOptions & {
73
+ /** Swap time between two soundscapes: the old bed fades out while the new one fades in. Default 1000. */
74
+ crossfadeMs?: number;
75
+ /**
76
+ * `"stop"` (default) fades the soundscape out while the tab is hidden and back in when it
77
+ * returns, so a background tab doesn't play to itself. `"play"` leaves it alone.
78
+ */
79
+ whenHidden?: "stop" | "play";
80
+ };
81
+ /**
82
+ * Plays one soundscape at a time over a bus and swaps between them: `play(def)` starts it, a
83
+ * different def crossfades to it, the same def again (by content, not identity) does nothing, and
84
+ * `play(null)` fades out. It also follows tab visibility. This is what a scene manager or a React
85
+ * effect wants; use `Soundscape` directly for full control.
86
+ */
87
+ declare class SoundscapePlayer<Id = unknown> {
88
+ private readonly bus;
89
+ private readonly opts;
90
+ private scape;
91
+ private key;
92
+ private hidden;
93
+ private disposed;
94
+ private readonly onVisibility;
95
+ constructor(bus: SoundscapeBus<Id>, opts?: SoundscapePlayerOptions);
96
+ /** Play `def`, crossfading from whatever plays now. `null` fades out. */
97
+ play(def: SoundscapeDef | null, opts?: {
98
+ crossfadeMs?: number;
99
+ }): void;
100
+ /** Fade out and forget the current soundscape. */
101
+ stop(opts?: {
102
+ fadeOutMs?: number;
103
+ }): void;
104
+ /** The soundscape playing now, for `fire`, `suppressEmitters` or `setBedGain`. */
105
+ get current(): Soundscape<Id> | null;
106
+ dispose(opts?: {
107
+ fadeOutMs?: number;
108
+ }): void;
109
+ }
110
+ //#endregion
111
+ export { SoundscapePlayerOptions as n, Soundscape as r, SoundscapePlayer as t };
112
+ //# sourceMappingURL=player-CepoDB_J.d.ts.map
@@ -0,0 +1,23 @@
1
+ import { a as SoundscapeBus, o as SoundscapeDef } from "./types-BML2s2Se.js";
2
+ import { n as SoundscapePlayerOptions, t as SoundscapePlayer } from "./player-CepoDB_J.js";
3
+ //#region src/react/index.d.ts
4
+ type UseSoundscapeOptions = SoundscapePlayerOptions & {
5
+ /** `false` fades the soundscape out, `true` brings it back. Default true. */
6
+ active?: boolean;
7
+ };
8
+ /**
9
+ * Play `def` on `bus` while mounted and `active`. A new def crossfades; a def with the same
10
+ * content is the same soundscape, so an inline object literal doesn't restart the bed every
11
+ * render. StrictMode-safe. Returns the player once mounted, for `current.fire()` and friends.
12
+ * `random`, `timers` and `whenHidden` are read when the bus changes.
13
+ */
14
+ declare const useSoundscape: <Id>(bus: SoundscapeBus<Id>, def: SoundscapeDef | null, opts?: UseSoundscapeOptions) => SoundscapePlayer<Id> | null;
15
+ type SoundscapeRunnerProps<Id> = UseSoundscapeOptions & {
16
+ bus: SoundscapeBus<Id>;
17
+ def: SoundscapeDef | null;
18
+ };
19
+ /** `useSoundscape` as a component, for JSX that reads as the scene. Renders nothing. */
20
+ declare const SoundscapeRunner: <Id>({ bus, def, ...opts }: SoundscapeRunnerProps<Id>) => null;
21
+ //#endregion
22
+ export { SoundscapeRunner, type SoundscapeRunnerProps, type UseSoundscapeOptions, useSoundscape };
23
+ //# sourceMappingURL=react.d.ts.map
package/dist/react.js ADDED
@@ -0,0 +1,52 @@
1
+ import { t as SoundscapePlayer } from "./player-BlQ9UyoK.js";
2
+ import { useEffect, useRef, useState } from "react";
3
+ //#region src/react/index.tsx
4
+ /**
5
+ * @zkmake/sound-scape/react: a hook and a component that render nothing. Audio is a side effect of
6
+ * the scene, never part of the render tree; with React Three Fiber, put them outside `<Canvas>`.
7
+ */
8
+ /**
9
+ * Play `def` on `bus` while mounted and `active`. A new def crossfades; a def with the same
10
+ * content is the same soundscape, so an inline object literal doesn't restart the bed every
11
+ * render. StrictMode-safe. Returns the player once mounted, for `current.fire()` and friends.
12
+ * `random`, `timers` and `whenHidden` are read when the bus changes.
13
+ */
14
+ const useSoundscape = (bus, def, opts = {}) => {
15
+ const [player, setPlayer] = useState(null);
16
+ const live = useRef(null);
17
+ const latest = useRef(opts);
18
+ useEffect(() => {
19
+ latest.current = opts;
20
+ });
21
+ useEffect(() => {
22
+ const { random, timers, whenHidden, crossfadeMs } = latest.current;
23
+ const created = new SoundscapePlayer(bus, {
24
+ random,
25
+ timers,
26
+ whenHidden,
27
+ crossfadeMs,
28
+ onEmitter: (event) => latest.current.onEmitter?.(event)
29
+ });
30
+ live.current = created;
31
+ setPlayer(created);
32
+ return () => {
33
+ created.dispose();
34
+ live.current = null;
35
+ };
36
+ }, [bus]);
37
+ const active = opts.active ?? true;
38
+ const crossfadeMs = opts.crossfadeMs;
39
+ useEffect(() => {
40
+ live.current?.play(active ? def : null, { crossfadeMs });
41
+ });
42
+ return player;
43
+ };
44
+ /** `useSoundscape` as a component, for JSX that reads as the scene. Renders nothing. */
45
+ const SoundscapeRunner = ({ bus, def, ...opts }) => {
46
+ useSoundscape(bus, def, opts);
47
+ return null;
48
+ };
49
+ //#endregion
50
+ export { SoundscapeRunner, useSoundscape };
51
+
52
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","names":[],"sources":["../src/react/index.tsx"],"sourcesContent":["/**\n * @zkmake/sound-scape/react: a hook and a component that render nothing. Audio is a side effect of\n * the scene, never part of the render tree; with React Three Fiber, put them outside `<Canvas>`.\n */\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { SoundscapePlayer, type SoundscapePlayerOptions } from \"../core/player.ts\";\nimport { type SoundscapeBus, type SoundscapeDef } from \"../core/types.ts\";\n\ntype UseSoundscapeOptions = SoundscapePlayerOptions & {\n /** `false` fades the soundscape out, `true` brings it back. Default true. */\n active?: boolean;\n};\n\n/**\n * Play `def` on `bus` while mounted and `active`. A new def crossfades; a def with the same\n * content is the same soundscape, so an inline object literal doesn't restart the bed every\n * render. StrictMode-safe. Returns the player once mounted, for `current.fire()` and friends.\n * `random`, `timers` and `whenHidden` are read when the bus changes.\n */\nconst useSoundscape = <Id,>(\n bus: SoundscapeBus<Id>,\n def: SoundscapeDef | null,\n opts: UseSoundscapeOptions = {},\n) => {\n const [player, setPlayer] = useState<SoundscapePlayer<Id> | null>(null);\n const live = useRef<SoundscapePlayer<Id> | null>(null);\n const latest = useRef(opts);\n\n // First, so the effects below see this render's options.\n useEffect(() => {\n latest.current = opts;\n });\n\n useEffect(() => {\n const { random, timers, whenHidden, crossfadeMs } = latest.current;\n const created = new SoundscapePlayer(bus, {\n random,\n timers,\n whenHidden,\n crossfadeMs,\n onEmitter: (event) => latest.current.onEmitter?.(event),\n });\n\n live.current = created;\n setPlayer(created);\n\n return () => {\n created.dispose();\n live.current = null;\n };\n }, [bus]);\n\n const active = opts.active ?? true;\n const crossfadeMs = opts.crossfadeMs;\n\n // Every render: `play` compares by content and does nothing when it hasn't changed.\n useEffect(() => {\n live.current?.play(active ? def : null, { crossfadeMs });\n });\n\n return player;\n};\n\ntype SoundscapeRunnerProps<Id> = UseSoundscapeOptions & {\n bus: SoundscapeBus<Id>;\n def: SoundscapeDef | null;\n};\n\n/** `useSoundscape` as a component, for JSX that reads as the scene. Renders nothing. */\nconst SoundscapeRunner = <Id,>({ bus, def, ...opts }: SoundscapeRunnerProps<Id>) => {\n useSoundscape(bus, def, opts);\n\n return null;\n};\n\nexport { SoundscapeRunner, useSoundscape };\nexport type { SoundscapeRunnerProps, UseSoundscapeOptions };\n"],"mappings":";;;;;;;;;;;;;AAoBA,MAAM,iBACJ,KACA,KACA,OAA6B,CAAC,MAC3B;CACH,MAAM,CAAC,QAAQ,aAAa,SAAsC,IAAI;CACtE,MAAM,OAAO,OAAoC,IAAI;CACrD,MAAM,SAAS,OAAO,IAAI;CAG1B,gBAAgB;EACd,OAAO,UAAU;CACnB,CAAC;CAED,gBAAgB;EACd,MAAM,EAAE,QAAQ,QAAQ,YAAY,gBAAgB,OAAO;EAC3D,MAAM,UAAU,IAAI,iBAAiB,KAAK;GACxC;GACA;GACA;GACA;GACA,YAAY,UAAU,OAAO,QAAQ,YAAY,KAAK;EACxD,CAAC;EAED,KAAK,UAAU;EACf,UAAU,OAAO;EAEjB,aAAa;GACX,QAAQ,QAAQ;GAChB,KAAK,UAAU;EACjB;CACF,GAAG,CAAC,GAAG,CAAC;CAER,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,cAAc,KAAK;CAGzB,gBAAgB;EACd,KAAK,SAAS,KAAK,SAAS,MAAM,MAAM,EAAE,YAAY,CAAC;CACzD,CAAC;CAED,OAAO;AACT;;AAQA,MAAM,oBAAyB,EAAE,KAAK,KAAK,GAAG,WAAsC;CAClF,cAAc,KAAK,KAAK,IAAI;CAE5B,OAAO;AACT"}
@@ -0,0 +1,90 @@
1
+ //#region src/core/types.d.ts
2
+ /** `[low, high]`: each fire draws a value uniformly between them. */
3
+ type Range = readonly [number, number];
4
+ /**
5
+ * The looping layer under the emitters: room tone, wind, traffic. It is never positional (room
6
+ * tone comes from everywhere) and never silent while the soundscape runs.
7
+ */
8
+ type BedDef<Sample extends string = string> = {
9
+ sample: Sample;
10
+ volume: number;
11
+ /** Default 500. A bed that starts at full level is heard as a seam. */
12
+ fadeInMs?: number;
13
+ /** Default 700. */
14
+ fadeOutMs?: number;
15
+ };
16
+ /**
17
+ * Short, non-musical samples fired on a random timer, each fire with its own volume, rate and pan.
18
+ * Every emitter is its own metronome: it waits a random interval, fires, and re-arms.
19
+ */
20
+ type EmitterDef<Sample extends string = string> = {
21
+ /** Used by `fire(name)` and `onEmitter`. Defaults to the emitter's index. */
22
+ name?: string;
23
+ /** The pool. A fire picks one at random, never the one this emitter played last. 3–6 is plenty. */
24
+ samples: readonly Sample[];
25
+ /** Wait between fires, ms. */
26
+ intervalMs: Range;
27
+ /** Default `[0.7, 1]`. */
28
+ volume?: Range;
29
+ /** Playback rate. Default `[0.95, 1.05]`: past about ±5% a sample turns chipmunk or sluggish. */
30
+ rate?: Range;
31
+ /** Stereo pan, −1..1. Default `[-0.4, 0.4]`. */
32
+ pan?: Range;
33
+ };
34
+ type SoundscapeDef<Sample extends string = string> = {
35
+ bed?: BedDef<Sample> | null;
36
+ emitters?: readonly EmitterDef<Sample>[];
37
+ /** Emitter voices allowed at once across this soundscape. A fire past the cap is dropped, not queued. Default 4. */
38
+ maxConcurrent?: number;
39
+ };
40
+ /**
41
+ * Everything the engine asks of the audio backend: four functions. An adapter (`./howler`,
42
+ * `./web-audio`, or your own) implements them over its own voices and buses; ducking, muting and
43
+ * levels are the adapter's business, not the engine's.
44
+ *
45
+ * Ids are the adapter's own; the engine only hands them back.
46
+ */
47
+ type SoundscapeBus<Id = unknown> = {
48
+ /** Start a looping bed, fading in from silence. `null` when the sample is missing or can't play. */
49
+ playBed(sample: string, opts: {
50
+ volume: number;
51
+ fadeInMs: number;
52
+ }): Id | null;
53
+ /**
54
+ * Start a one-shot. Call `onEnd` once when it finishes by itself; a voice stopped through
55
+ * `stop` need not call it. `null` when it can't play: the fire is skipped.
56
+ */
57
+ playEmitter(sample: string, opts: {
58
+ volume: number;
59
+ rate: number;
60
+ pan: number;
61
+ onEnd: () => void;
62
+ }): Id | null;
63
+ /**
64
+ * Fade a voice to silence over `fadeOutMs` (0: at once), then stop it. Must forget the id
65
+ * synchronously, so a fade in flight survives a dispose that follows.
66
+ */
67
+ stop(id: Id, fadeOutMs: number): void;
68
+ /** Move a live voice's volume (before any bus level or duck), over `fadeMs`. */
69
+ setVolume(id: Id, volume: number, fadeMs: number): void;
70
+ };
71
+ /** `setTimeout`'s shape, so a test can drive time by hand. */
72
+ type Timers = {
73
+ set(callback: () => void, ms: number): unknown;
74
+ clear(handle: unknown): void;
75
+ };
76
+ type EmitterPhase = "start" | "end";
77
+ type SoundscapeOptions = {
78
+ /** `Math.random`'s shape. Pass `seededRandom(n)` for a soundscape that plays the same way each time. */
79
+ random?: () => number;
80
+ timers?: Timers;
81
+ /** Called when an emitter voice starts and when it ends. For captions, debug panels or tests. */
82
+ onEmitter?: (event: {
83
+ emitter: string;
84
+ sample: string;
85
+ phase: EmitterPhase;
86
+ }) => void;
87
+ };
88
+ //#endregion
89
+ export { SoundscapeBus as a, Timers as c, Range as i, EmitterDef as n, SoundscapeDef as o, EmitterPhase as r, SoundscapeOptions as s, BedDef as t };
90
+ //# sourceMappingURL=types-BML2s2Se.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { i as SoundscapeAdapter, n as LoadOptions, r as SampleSource, t as DuckOptions } from "./adapter-D8cYn0vs.js";
2
+ //#region src/web-audio/index.d.ts
3
+ type WebAudioBusOptions = {
4
+ /** Share the game's context. Default: one made on first use, closed on `dispose`. */
5
+ context?: AudioContext;
6
+ /** Where the bus ends up. Default: the context's destination. */
7
+ destination?: AudioNode;
8
+ /** Bus level. Default 1; above 1 is allowed. */
9
+ level?: number;
10
+ muted?: boolean;
11
+ duck?: DuckOptions;
12
+ /** Resume the context on the first pointer or key press, and again after an interruption. Default true. */
13
+ autoUnlock?: boolean;
14
+ };
15
+ declare const createWebAudioBus: <Sample extends string>(samples: Record<Sample, SampleSource>, opts?: WebAudioBusOptions) => SoundscapeAdapter<number> & {
16
+ /** The context, made on first access. */
17
+ readonly context: AudioContext;
18
+ };
19
+ //#endregion
20
+ export { type DuckOptions, type LoadOptions, type SampleSource, type SoundscapeAdapter, type WebAudioBusOptions, createWebAudioBus };
21
+ //# sourceMappingURL=web-audio.d.ts.map