@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zubin Khavarian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # @zkmake/sound-scape
2
+
3
+ Soundscapes for web games: a looping **bed** (wind, room tone, traffic) with **emitters** above
4
+ it, short samples that fire on random timers (birds, cups, a distant siren). A game without one
5
+ goes silent between sound effects. This is the layer that fills that silence, and it stays out of
6
+ the way of your music and effects.
7
+
8
+ ```sh
9
+ bun add @zkmake/sound-scape # or npm i / pnpm add
10
+ ```
11
+
12
+ Live demo: [zkmake.github.io/sound-kit](https://zkmake.github.io/sound-kit/). Background:
13
+ [Soundscapes for Web Games, part 1](https://zubin.dev/blog/web-game-soundscapes-1-concepts/) and
14
+ [part 2](https://zubin.dev/blog/web-game-soundscapes-2-howler/).
15
+
16
+ - **Never the same sample twice in a row.** Each emitter draws from a pool of 3–6 samples. Of
17
+ everything here, this does the most to stop a soundscape sounding like a tape loop.
18
+ - **Tight random ranges.** Every fire gets its own volume, rate and pan. The defaults are
19
+ `[0.7, 1]`, `[0.95, 1.05]` and `[-0.4, 0.4]`; past about ±5% rate, a sample turns chipmunk.
20
+ - **A voice cap.** A fire past `maxConcurrent` is dropped, not queued, so a cluster of fires never
21
+ muddies the mix or clips the master.
22
+ - **Ducking that reaches every voice.** `bus.duck()` dips the bed and every emitter, including
23
+ ones already playing, by −12 dB over 120 ms, and brings them back over 450 ms. It lowers gain
24
+ instead of pausing, so loops keep their place.
25
+ - **Fades everywhere.** Beds fade in and out. A voice is stopped when its fade lands, so no silent
26
+ voices are left alive.
27
+ - **Swaps and tab changes.** `SoundscapePlayer` crossfades between scenes. It ignores a def with the
28
+ same content, and fades out while the tab is hidden.
29
+ - **Safe teardown.** `dispose()` is safe to call twice and under React StrictMode. A timer queued
30
+ before a teardown does nothing, so no ghost bird sings in the next level.
31
+ - **Two backends, one engine.** `./howler` sits on Howler; `./web-audio` has no dependencies. The
32
+ engine talks to either through four functions, so you can write your own.
33
+
34
+ ## Entry points
35
+
36
+ | Import | What | Needs |
37
+ | ------------------------------- | -------------------------------------------------------- | ------------ |
38
+ | `@zkmake/sound-scape` | `Soundscape`, `SoundscapePlayer`, types. No DOM, no deps | nothing |
39
+ | `@zkmake/sound-scape/web-audio` | `createWebAudioBus`: the adapter on the Web Audio API | nothing |
40
+ | `@zkmake/sound-scape/howler` | `createHowlerBus`: the adapter on Howler | `howler` 2.2 |
41
+ | `@zkmake/sound-scape/react` | `useSoundscape`, `<SoundscapeRunner>` | `react` 18+ |
42
+
43
+ Every entry imports under SSR. Nothing touches `window` or creates an `AudioContext` until it
44
+ plays.
45
+
46
+ ## Vanilla
47
+
48
+ ```ts
49
+ import { SoundscapePlayer, type SoundscapeDef } from "@zkmake/sound-scape";
50
+ import { createWebAudioBus } from "@zkmake/sound-scape/web-audio";
51
+
52
+ // name → sources, in order of preference: the first one the browser plays wins.
53
+ const bus = createWebAudioBus({
54
+ room: ["audio/room.ogg", "audio/room.m4a"],
55
+ "cup-1": ["audio/cup-1.ogg", "audio/cup-1.m4a"],
56
+ "cup-2": ["audio/cup-2.ogg", "audio/cup-2.m4a"],
57
+ "cup-3": ["audio/cup-3.ogg", "audio/cup-3.m4a"],
58
+ });
59
+
60
+ const CAFE: SoundscapeDef = {
61
+ bed: { sample: "room", volume: 0.5 },
62
+ emitters: [{ name: "cups", samples: ["cup-1", "cup-2", "cup-3"], intervalMs: [1500, 5000] }],
63
+ maxConcurrent: 4,
64
+ };
65
+
66
+ await bus.load({ onProgress: (settled, total) => bar(settled / total) });
67
+
68
+ const player = new SoundscapePlayer(bus, { crossfadeMs: 1500 });
69
+
70
+ player.play(CAFE); // from a click or key press: browsers start audio suspended
71
+ player.play(STREET); // crossfades
72
+ player.play(null); // fades out
73
+
74
+ const release = bus.duck(); // a voice line starts
75
+ release(); // …and ends
76
+ ```
77
+
78
+ For full control, use `Soundscape` directly: `new Soundscape(def, bus)`, then `start()`,
79
+ `stop()` and `dispose()`. Both expose `fire(name)`, `suppressEmitters(on)` and `setBedGain(gain)`
80
+ (the player through `player.current`).
81
+
82
+ ## React and React Three Fiber
83
+
84
+ ```tsx
85
+ import { useSoundscape } from "@zkmake/sound-scape/react";
86
+
87
+ function Ambience({ scene, paused }: { scene: SceneId; paused: boolean }) {
88
+ useSoundscape(bus, SCENES[scene], { active: !paused, crossfadeMs: 1500 });
89
+
90
+ return null;
91
+ }
92
+ ```
93
+
94
+ - **Placement:** keep the hook outside `<Canvas>`. Audio is a side effect of the scene, not part
95
+ of the render loop.
96
+ - **Inline defs:** a def is compared by content, so an inline object literal doesn't restart the
97
+ bed on every render.
98
+ - **Unmounting** fades the soundscape out.
99
+ - **The bus:** make it once, at module scope or in a ref. A new bus starts a new player.
100
+ - **The return value** is the player, for `player.current?.fire("cups")`.
101
+ - **JSX alternative:** `<SoundscapeRunner bus={bus} def={def} active={!paused} />` does the same.
102
+
103
+ ## Definitions
104
+
105
+ `SoundscapeDef` is plain data, and it is all you tune: you can adjust a scene's feel without
106
+ touching engine code.
107
+
108
+ | Field | Default | Meaning |
109
+ | ----------------------- | -------------- | ---------------------------------------------------------------- |
110
+ | `bed.sample` | | The looping layer. Non-positional; set `bed` to `null` for none. |
111
+ | `bed.volume` | | Before the bus level and duck. |
112
+ | `bed.fadeInMs` | 500 | A bed that starts at full volume is heard as a seam. |
113
+ | `bed.fadeOutMs` | 700 | |
114
+ | `emitters[].samples` | | The pool. 3–6 is plenty. |
115
+ | `emitters[].intervalMs` | | `[min, max]` wait between fires. The first fire comes sooner. |
116
+ | `emitters[].volume` | `[0.7, 1]` | |
117
+ | `emitters[].rate` | `[0.95, 1.05]` | Playback rate, which also shifts pitch. |
118
+ | `emitters[].pan` | `[-0.4, 0.4]` | Stereo, −1..1. |
119
+ | `emitters[].name` | its index | For `fire(name)` and `onEmitter`. |
120
+ | `maxConcurrent` | 4 | Emitter voices at once, across the soundscape. |
121
+
122
+ Options for `Soundscape` and `SoundscapePlayer`:
123
+
124
+ - `random`: pass `seededRandom(n)` to make a soundscape play the same way each time.
125
+ - `timers`: inject these to drive time in tests.
126
+ - `onEmitter({ emitter, sample, phase })`: for captions, debug panels or tests.
127
+ - Player only:
128
+ - `crossfadeMs`: default 1000.
129
+ - `whenHidden`: `"stop"` (the default) or `"play"`.
130
+
131
+ Calls:
132
+
133
+ - `suppressEmitters(true)` fades out the emitters that are playing and holds back new fires while
134
+ dialog is on screen, because one-shots compete with screen readers even when ducked. The timers
135
+ keep running, and `suppressEmitters(false)` lets fires through again.
136
+ - `fire(name)` fires one emitter now, still within the cap, for debug buttons.
137
+
138
+ ## The adapters
139
+
140
+ Both return a `SoundscapeAdapter`. It has the engine's four functions plus `load`, `duck`,
141
+ `ducked`, `setLevel`, `setMuted`, `stopAll`, `voices` and `dispose`. Use one bus as your ambience
142
+ bus, shared by every soundscape, so its duck and level carry across a scene swap.
143
+
144
+ | Option | Web Audio | Howler | Meaning |
145
+ | --------------- | --------------------- | --------- | ------------------------------------------------------------------------------------------- |
146
+ | `level` | 1 (above 1 allowed) | 1 (max 1) | Bus level, for a settings slider. `setLevel(v, fadeMs)` moves it live. |
147
+ | `muted` | false | false | Mutes in place: beds keep their phase, and emitters don't fire. |
148
+ | `duck` | −12 dB, 120 / 450 ms | same | Defaults for `duck()`. Ducks count, and the deepest held one wins. |
149
+ | `context` | made on first use | | Share your game's `AudioContext`. One the adapter made is closed on `dispose`. |
150
+ | `destination` | `context.destination` | | Route the bus into your own mixer node. |
151
+ | `autoUnlock` | true | | Resumes the context on the first pointer or key press, and again after an iOS interruption. |
152
+ | `html5` | | false | Streams through `<audio>`, with no per-voice pan or rate. |
153
+ | `loadTimeoutMs` | | 10000 | A sample still loading after this long counts as settled. |
154
+
155
+ Behaviour when a sample is missing or still loading:
156
+
157
+ - `load()` never rejects. A sample that fails logs one warning and stays silent; its fires are
158
+ skipped.
159
+ - A bed may start before its sample loads, and fades in when it arrives.
160
+ - An emitter whose sample isn't loaded yet is skipped, because a late bird is worse than none.
161
+
162
+ ### Writing your own
163
+
164
+ The engine only ever calls these four functions:
165
+
166
+ ```ts
167
+ type SoundscapeBus<Id> = {
168
+ playBed(sample, { volume, fadeInMs }): Id | null;
169
+ playEmitter(sample, { volume, rate, pan, onEnd }): Id | null;
170
+ stop(id, fadeOutMs): void; // forget the id synchronously, then fade and stop
171
+ setVolume(id, volume, fadeMs): void;
172
+ };
173
+ ```
174
+
175
+ The contract:
176
+
177
+ - `stop` must forget the id at once, so a fade in flight survives a dispose that follows.
178
+ - `onEnd` is called once, when a voice ends by itself. It needn't be called for a voice the engine
179
+ stopped.
180
+ - Return `null` when a voice can't play. The engine skips that fire and counts nothing.
181
+
182
+ ## Formats and iOS
183
+
184
+ Give each sample two sources: `[ogg, m4a]`. Opus in Ogg is small and decodes in Chrome, Firefox
185
+ and recent Safari. AAC in M4A is the fallback that every Safari version decodes. The Web Audio
186
+ adapter skips any source that `canPlayType` rejects, and tries the next source if decoding fails.
187
+ Howler does the same with its `src` list.
188
+
189
+ For beds, prefer Ogg. AAC encoders add a few milliseconds of padding at the start, which can
190
+ leave a gap at a loop seam. If a bed has to be AAC, listen to it on an iPhone.
191
+
192
+ On iOS, audio starts on a gesture: call `play` from a tap, or rely on `autoUnlock`. After a phone
193
+ call or Siri, Safari reports the context as `"interrupted"`, and the next tap resumes it. Targets:
194
+ iOS 17 and later, and current desktop browsers.
195
+
196
+ ## Shipping it
197
+
198
+ - **Settings:** connect your sliders to `setLevel` and your mute switch to `setMuted`. Both apply
199
+ live to voices already playing.
200
+ - **Narration:** duck while a voice line plays, and call `suppressEmitters(true)` while dialog is
201
+ on screen.
202
+ - **Size:** the core is about 3.5 kB gzipped. The Web Audio adapter adds 2.7 kB and the Howler
203
+ adapter 1.7 kB (Howler itself is about 7 kB more).
204
+
205
+ ## Origin
206
+
207
+ The engine is extracted from four copies of the same idea:
208
+
209
+ - keyboard-express
210
+ - platform-typing
211
+ - finlit-careers
212
+ - virtual-room-three
213
+
214
+ Their tests came with it.
215
+
216
+ ## License
217
+
218
+ MIT
@@ -0,0 +1,43 @@
1
+ import { a as SoundscapeBus } from "./types-BML2s2Se.js";
2
+ //#region src/core/adapter.d.ts
3
+ /** The source of a sample: a URL, or URLs in order of preference (`[opus, m4a]`). The first one the browser plays wins. */
4
+ type SampleSource = string | readonly string[];
5
+ type DuckOptions = {
6
+ /** How far the bus dips, in dB. Default −12. */
7
+ db?: number;
8
+ /** Default 120. */
9
+ attackMs?: number;
10
+ /** Default 450. */
11
+ releaseMs?: number;
12
+ };
13
+ type LoadOptions = {
14
+ /** Called as each sample settles, loaded or not, so a loading bar never stalls on a bad file. */
15
+ onProgress?: (settled: number, total: number) => void;
16
+ };
17
+ /**
18
+ * What both shipped adapters give the game on top of the engine's four functions: loading,
19
+ * ducking, a level, mute and teardown. The bus is the ambience bus; share one between soundscapes
20
+ * and its duck and level carry across a swap.
21
+ */
22
+ type SoundscapeAdapter<Id = unknown> = SoundscapeBus<Id> & {
23
+ /** Fetch and decode every sample. Never rejects: a sample that fails logs once and stays silent. */
24
+ load(opts?: LoadOptions): Promise<void>;
25
+ /**
26
+ * Dip every voice on the bus, ones already playing included, and return the release. Ducks
27
+ * count: the bus comes back up when the last one is released.
28
+ */
29
+ duck(opts?: DuckOptions): () => void;
30
+ readonly ducked: boolean;
31
+ /** The bus level, 0..1 (the Web Audio adapter allows more), for a settings slider. */
32
+ setLevel(level: number, fadeMs?: number): void;
33
+ /** Mute in place: beds keep their phase and come back without a click; emitters don't fire while muted. */
34
+ setMuted(muted: boolean): void;
35
+ /** Stop every voice on the bus. */
36
+ stopAll(fadeOutMs?: number): void;
37
+ /** Voices playing now: beds and emitters. */
38
+ readonly voices: number;
39
+ dispose(): void;
40
+ };
41
+ //#endregion
42
+ export { SoundscapeAdapter as i, LoadOptions as n, SampleSource as r, DuckOptions as t };
43
+ //# sourceMappingURL=adapter-D8cYn0vs.d.ts.map
@@ -0,0 +1,58 @@
1
+ //#region src/core/adapter.ts
2
+ const DEFAULT_DUCK = {
3
+ db: -12,
4
+ attackMs: 120,
5
+ releaseMs: 450
6
+ };
7
+ const dbToGain = (db) => 10 ** (db / 20);
8
+ const sources = (source) => typeof source === "string" ? [source] : source;
9
+ /**
10
+ * Ref-counted ducking: `duck()` returns a release that works once. The deepest duck held wins;
11
+ * releasing it eases back to the next deepest, or to full. `apply(gain, ms)` moves the adapter's
12
+ * duck multiplier.
13
+ */
14
+ const createDucker = (defaults, apply) => {
15
+ const held = /* @__PURE__ */ new Map();
16
+ let current = 1;
17
+ const update = (fadeMs) => {
18
+ const target = Math.min(1, ...held.values());
19
+ if (target !== current) {
20
+ current = target;
21
+ apply(target, fadeMs);
22
+ }
23
+ };
24
+ return {
25
+ duck(opts = {}) {
26
+ const db = opts.db ?? defaults?.db ?? DEFAULT_DUCK.db;
27
+ const attackMs = opts.attackMs ?? defaults?.attackMs ?? DEFAULT_DUCK.attackMs;
28
+ const releaseMs = opts.releaseMs ?? defaults?.releaseMs ?? DEFAULT_DUCK.releaseMs;
29
+ const token = Symbol("duck");
30
+ held.set(token, dbToGain(db));
31
+ update(attackMs);
32
+ return () => {
33
+ if (held.delete(token)) update(releaseMs);
34
+ };
35
+ },
36
+ get ducked() {
37
+ return held.size > 0;
38
+ },
39
+ clear() {
40
+ held.clear();
41
+ current = 1;
42
+ }
43
+ };
44
+ };
45
+ /** Warn once per sample: a missing file shouldn't flood the console every fire. */
46
+ const createWarnOnce = (label) => {
47
+ const warned = /* @__PURE__ */ new Set();
48
+ return (sample, message) => {
49
+ if (!warned.has(sample)) {
50
+ warned.add(sample);
51
+ console.warn(`[${label}] ${sample}: ${message}`);
52
+ }
53
+ };
54
+ };
55
+ //#endregion
56
+ export { createWarnOnce as n, sources as r, createDucker as t };
57
+
58
+ //# sourceMappingURL=adapter-DHuOy8V8.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-DHuOy8V8.js","names":[],"sources":["../src/core/adapter.ts"],"sourcesContent":["import { type SoundscapeBus } from \"./types.ts\";\n\n/** The source of a sample: a URL, or URLs in order of preference (`[opus, m4a]`). The first one the browser plays wins. */\ntype SampleSource = string | readonly string[];\n\ntype DuckOptions = {\n /** How far the bus dips, in dB. Default −12. */\n db?: number;\n /** Default 120. */\n attackMs?: number;\n /** Default 450. */\n releaseMs?: number;\n};\n\ntype LoadOptions = {\n /** Called as each sample settles, loaded or not, so a loading bar never stalls on a bad file. */\n onProgress?: (settled: number, total: number) => void;\n};\n\n/**\n * What both shipped adapters give the game on top of the engine's four functions: loading,\n * ducking, a level, mute and teardown. The bus is the ambience bus; share one between soundscapes\n * and its duck and level carry across a swap.\n */\ntype SoundscapeAdapter<Id = unknown> = SoundscapeBus<Id> & {\n /** Fetch and decode every sample. Never rejects: a sample that fails logs once and stays silent. */\n load(opts?: LoadOptions): Promise<void>;\n /**\n * Dip every voice on the bus, ones already playing included, and return the release. Ducks\n * count: the bus comes back up when the last one is released.\n */\n duck(opts?: DuckOptions): () => void;\n readonly ducked: boolean;\n /** The bus level, 0..1 (the Web Audio adapter allows more), for a settings slider. */\n setLevel(level: number, fadeMs?: number): void;\n /** Mute in place: beds keep their phase and come back without a click; emitters don't fire while muted. */\n setMuted(muted: boolean): void;\n /** Stop every voice on the bus. */\n stopAll(fadeOutMs?: number): void;\n /** Voices playing now: beds and emitters. */\n readonly voices: number;\n dispose(): void;\n};\n\nconst DEFAULT_DUCK = { db: -12, attackMs: 120, releaseMs: 450 } as const;\n\nconst dbToGain = (db: number) => 10 ** (db / 20);\n\nconst sources = (source: SampleSource): readonly string[] =>\n typeof source === \"string\" ? [source] : source;\n\n/**\n * Ref-counted ducking: `duck()` returns a release that works once. The deepest duck held wins;\n * releasing it eases back to the next deepest, or to full. `apply(gain, ms)` moves the adapter's\n * duck multiplier.\n */\nconst createDucker = (\n defaults: DuckOptions | undefined,\n apply: (gain: number, fadeMs: number) => void,\n) => {\n const held = new Map<symbol, number>();\n let current = 1;\n\n const update = (fadeMs: number) => {\n const target = Math.min(1, ...held.values());\n\n if (target !== current) {\n current = target;\n apply(target, fadeMs);\n }\n };\n\n return {\n duck(opts: DuckOptions = {}) {\n const db = opts.db ?? defaults?.db ?? DEFAULT_DUCK.db;\n const attackMs = opts.attackMs ?? defaults?.attackMs ?? DEFAULT_DUCK.attackMs;\n const releaseMs = opts.releaseMs ?? defaults?.releaseMs ?? DEFAULT_DUCK.releaseMs;\n const token = Symbol(\"duck\");\n\n held.set(token, dbToGain(db));\n update(attackMs);\n\n return () => {\n if (held.delete(token)) {\n update(releaseMs);\n }\n };\n },\n get ducked() {\n return held.size > 0;\n },\n clear() {\n held.clear();\n current = 1;\n },\n };\n};\n\n/** Warn once per sample: a missing file shouldn't flood the console every fire. */\nconst createWarnOnce = (label: string) => {\n const warned = new Set<string>();\n\n return (sample: string, message: string) => {\n if (!warned.has(sample)) {\n warned.add(sample);\n // oxlint-disable-next-line no-console\n console.warn(`[${label}] ${sample}: ${message}`);\n }\n };\n};\n\nexport { createDucker, createWarnOnce, dbToGain, DEFAULT_DUCK, sources };\nexport type { DuckOptions, LoadOptions, SampleSource, SoundscapeAdapter };\n"],"mappings":";AA4CA,MAAM,eAAe;CAAE,IAAI;CAAK,UAAU;CAAK,WAAW;AAAI;AAE9D,MAAM,YAAY,OAAe,OAAO,KAAK;AAE7C,MAAM,WAAW,WACf,OAAO,WAAW,WAAW,CAAC,MAAM,IAAI;;;;;;AAO1C,MAAM,gBACJ,UACA,UACG;CACH,MAAM,uBAAO,IAAI,IAAoB;CACrC,IAAI,UAAU;CAEd,MAAM,UAAU,WAAmB;EACjC,MAAM,SAAS,KAAK,IAAI,GAAG,GAAG,KAAK,OAAO,CAAC;EAE3C,IAAI,WAAW,SAAS;GACtB,UAAU;GACV,MAAM,QAAQ,MAAM;EACtB;CACF;CAEA,OAAO;EACL,KAAK,OAAoB,CAAC,GAAG;GAC3B,MAAM,KAAK,KAAK,MAAM,UAAU,MAAM,aAAa;GACnD,MAAM,WAAW,KAAK,YAAY,UAAU,YAAY,aAAa;GACrE,MAAM,YAAY,KAAK,aAAa,UAAU,aAAa,aAAa;GACxE,MAAM,QAAQ,OAAO,MAAM;GAE3B,KAAK,IAAI,OAAO,SAAS,EAAE,CAAC;GAC5B,OAAO,QAAQ;GAEf,aAAa;IACX,IAAI,KAAK,OAAO,KAAK,GACnB,OAAO,SAAS;GAEpB;EACF;EACA,IAAI,SAAS;GACX,OAAO,KAAK,OAAO;EACrB;EACA,QAAQ;GACN,KAAK,MAAM;GACX,UAAU;EACZ;CACF;AACF;;AAGA,MAAM,kBAAkB,UAAkB;CACxC,MAAM,yBAAS,IAAI,IAAY;CAE/B,QAAQ,QAAgB,YAAoB;EAC1C,IAAI,CAAC,OAAO,IAAI,MAAM,GAAG;GACvB,OAAO,IAAI,MAAM;GAEjB,QAAQ,KAAK,IAAI,MAAM,IAAI,OAAO,IAAI,SAAS;EACjD;CACF;AACF"}
@@ -0,0 +1,17 @@
1
+ import { i as SoundscapeAdapter, n as LoadOptions, r as SampleSource, t as DuckOptions } from "./adapter-D8cYn0vs.js";
2
+ //#region src/howler/index.d.ts
3
+ type HowlerBusOptions = {
4
+ /** Bus level, 0..1. Default 1. */
5
+ level?: number;
6
+ muted?: boolean;
7
+ /** Defaults for `duck()`. */
8
+ duck?: DuckOptions;
9
+ /** Stream through `<audio>` instead of decoding. No pan or rate per voice then. Default false. */
10
+ html5?: boolean;
11
+ /** A sample still loading after this long counts as settled for `load`. Default 10000. */
12
+ loadTimeoutMs?: number;
13
+ };
14
+ declare const createHowlerBus: <Sample extends string>(samples: Record<Sample, SampleSource>, opts?: HowlerBusOptions) => SoundscapeAdapter<number>;
15
+ //#endregion
16
+ export { type DuckOptions, type HowlerBusOptions, type LoadOptions, type SampleSource, type SoundscapeAdapter, createHowlerBus };
17
+ //# sourceMappingURL=howler.d.ts.map
package/dist/howler.js ADDED
@@ -0,0 +1,167 @@
1
+ import { n as createWarnOnce, r as sources, t as createDucker } from "./adapter-DHuOy8V8.js";
2
+ import { Howl } from "howler";
3
+ //#region src/howler/index.ts
4
+ /**
5
+ * @zkmake/sound-scape/howler: a `SoundscapeAdapter` over Howler 2 (peer dependency). One Howl per
6
+ * sample, made once and replayed; every voice keeps its own base volume so a duck or a level change
7
+ * reaches beds and emitters already playing. Howler clamps volume to 0..1, so loud beds stay at 1.
8
+ */
9
+ const createHowlerBus = (samples, opts = {}) => {
10
+ const howls = /* @__PURE__ */ new Map();
11
+ const voices = /* @__PURE__ */ new Map();
12
+ let nextId = 1;
13
+ const warn = createWarnOnce("sound-scape/howler");
14
+ let level = opts.level ?? 1;
15
+ let muted = opts.muted ?? false;
16
+ let duckGain = 1;
17
+ const howlFor = (sample) => {
18
+ const known = howls.get(sample);
19
+ if (known) return known;
20
+ const source = samples[sample];
21
+ if (source === void 0) {
22
+ warn(sample, "not in the sample map; the fire is skipped");
23
+ return null;
24
+ }
25
+ const howl = new Howl({
26
+ src: [...sources(source)],
27
+ html5: opts.html5 ?? false
28
+ });
29
+ howl.once("loaderror", (_id, error) => {
30
+ warn(sample, `failed to load (${String(error)}); it stays silent`);
31
+ });
32
+ howls.set(sample, howl);
33
+ return howl;
34
+ };
35
+ const effective = (base) => Math.min(1, Math.max(0, base * level * duckGain));
36
+ const fadeTo = ({ howl, sound, base }, fadeMs) => {
37
+ const target = effective(base);
38
+ if (fadeMs <= 0) howl.volume(target, sound);
39
+ else howl.fade(howl.volume(sound), target, fadeMs, sound);
40
+ };
41
+ const rescale = (fadeMs) => {
42
+ for (const voice of voices.values()) fadeTo(voice, fadeMs);
43
+ };
44
+ const ducker = createDucker(opts.duck, (gain, fadeMs) => {
45
+ duckGain = gain;
46
+ rescale(fadeMs);
47
+ });
48
+ const stop = (id, fadeOutMs) => {
49
+ const voice = voices.get(id);
50
+ if (!voice) return;
51
+ voices.delete(id);
52
+ const { howl, sound } = voice;
53
+ howl.off("end", void 0, sound);
54
+ if (fadeOutMs <= 0) {
55
+ howl.stop(sound);
56
+ return;
57
+ }
58
+ howl.once("fade", () => howl.stop(sound), sound);
59
+ howl.fade(howl.volume(sound), 0, fadeOutMs, sound);
60
+ };
61
+ return {
62
+ playBed(sample, { volume, fadeInMs }) {
63
+ const howl = howlFor(sample);
64
+ if (!howl) return null;
65
+ const sound = howl.play();
66
+ const id = nextId++;
67
+ const voice = {
68
+ howl,
69
+ sound,
70
+ base: volume
71
+ };
72
+ howl.loop(true, sound);
73
+ howl.mute(muted, sound);
74
+ howl.volume(0, sound);
75
+ voices.set(id, voice);
76
+ fadeTo(voice, fadeInMs);
77
+ return id;
78
+ },
79
+ playEmitter(sample, { volume, rate, pan, onEnd }) {
80
+ if (muted) return null;
81
+ const howl = howlFor(sample);
82
+ if (!howl || howl.state() === "unloaded") return null;
83
+ const sound = howl.play();
84
+ const id = nextId++;
85
+ const voice = {
86
+ howl,
87
+ sound,
88
+ base: volume
89
+ };
90
+ howl.loop(false, sound);
91
+ howl.volume(effective(volume), sound);
92
+ howl.rate(rate, sound);
93
+ if (!opts.html5) howl.stereo(pan, sound);
94
+ voices.set(id, voice);
95
+ howl.once("end", () => {
96
+ if (voices.get(id) === voice) {
97
+ voices.delete(id);
98
+ onEnd();
99
+ }
100
+ }, sound);
101
+ return id;
102
+ },
103
+ stop,
104
+ setVolume(id, volume, fadeMs) {
105
+ const voice = voices.get(id);
106
+ if (voice) {
107
+ voice.base = volume;
108
+ fadeTo(voice, fadeMs);
109
+ }
110
+ },
111
+ load(loadOpts = {}) {
112
+ const names = Object.keys(samples);
113
+ let settled = 0;
114
+ return Promise.all(names.map((name) => new Promise((resolve) => {
115
+ const howl = howlFor(name);
116
+ let done = false;
117
+ const finish = () => {
118
+ if (!done) {
119
+ done = true;
120
+ settled += 1;
121
+ loadOpts.onProgress?.(settled, names.length);
122
+ resolve();
123
+ }
124
+ };
125
+ if (!howl || howl.state() === "loaded") {
126
+ finish();
127
+ return;
128
+ }
129
+ howl.once("load", finish);
130
+ howl.once("loaderror", finish);
131
+ globalThis.setTimeout(finish, opts.loadTimeoutMs ?? 1e4);
132
+ }))).then(() => void 0);
133
+ },
134
+ duck: ducker.duck,
135
+ get ducked() {
136
+ return ducker.ducked;
137
+ },
138
+ setLevel(next, fadeMs = 0) {
139
+ level = next;
140
+ rescale(fadeMs);
141
+ },
142
+ setMuted(next) {
143
+ muted = next;
144
+ for (const { howl, sound } of voices.values()) howl.mute(muted, sound);
145
+ },
146
+ stopAll(fadeOutMs = 0) {
147
+ for (const id of Array.from(voices.keys())) stop(id, fadeOutMs);
148
+ },
149
+ get voices() {
150
+ return voices.size;
151
+ },
152
+ dispose() {
153
+ for (const { howl, sound } of voices.values()) {
154
+ howl.off("end", void 0, sound);
155
+ howl.stop(sound);
156
+ }
157
+ voices.clear();
158
+ ducker.clear();
159
+ for (const howl of howls.values()) howl.unload();
160
+ howls.clear();
161
+ }
162
+ };
163
+ };
164
+ //#endregion
165
+ export { createHowlerBus };
166
+
167
+ //# sourceMappingURL=howler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"howler.js","names":[],"sources":["../src/howler/index.ts"],"sourcesContent":["/**\n * @zkmake/sound-scape/howler: a `SoundscapeAdapter` over Howler 2 (peer dependency). One Howl per\n * sample, made once and replayed; every voice keeps its own base volume so a duck or a level change\n * reaches beds and emitters already playing. Howler clamps volume to 0..1, so loud beds stay at 1.\n */\nimport { Howl } from \"howler\";\n\nimport {\n createDucker,\n createWarnOnce,\n type DuckOptions,\n type LoadOptions,\n type SampleSource,\n sources,\n type SoundscapeAdapter,\n} from \"../core/adapter.ts\";\n\ntype HowlerBusOptions = {\n /** Bus level, 0..1. Default 1. */\n level?: number;\n muted?: boolean;\n /** Defaults for `duck()`. */\n duck?: DuckOptions;\n /** Stream through `<audio>` instead of decoding. No pan or rate per voice then. Default false. */\n html5?: boolean;\n /** A sample still loading after this long counts as settled for `load`. Default 10000. */\n loadTimeoutMs?: number;\n};\n\n/** A voice: its Howl and Howler's id for it. The bus hands out its own ids, unique across Howls. */\ntype Voice = { howl: Howl; sound: number; base: number };\n\nconst createHowlerBus = <Sample extends string>(\n samples: Record<Sample, SampleSource>,\n opts: HowlerBusOptions = {},\n): SoundscapeAdapter<number> => {\n const howls = new Map<string, Howl>();\n const voices = new Map<number, Voice>();\n let nextId = 1;\n const warn = createWarnOnce(\"sound-scape/howler\");\n let level = opts.level ?? 1;\n let muted = opts.muted ?? false;\n let duckGain = 1;\n\n const howlFor = (sample: string) => {\n const known = howls.get(sample);\n\n if (known) {\n return known;\n }\n\n const source = (samples as Record<string, SampleSource | undefined>)[sample];\n\n if (source === undefined) {\n warn(sample, \"not in the sample map; the fire is skipped\");\n\n return null;\n }\n\n const howl = new Howl({ src: [...sources(source)], html5: opts.html5 ?? false });\n\n howl.once(\"loaderror\", (_id, error) => {\n warn(sample, `failed to load (${String(error)}); it stays silent`);\n });\n howls.set(sample, howl);\n\n return howl;\n };\n\n const effective = (base: number) => Math.min(1, Math.max(0, base * level * duckGain));\n\n const fadeTo = ({ howl, sound, base }: Voice, fadeMs: number) => {\n const target = effective(base);\n\n if (fadeMs <= 0) {\n howl.volume(target, sound);\n } else {\n howl.fade(howl.volume(sound) as number, target, fadeMs, sound);\n }\n };\n\n const rescale = (fadeMs: number) => {\n for (const voice of voices.values()) {\n fadeTo(voice, fadeMs);\n }\n };\n\n const ducker = createDucker(opts.duck, (gain, fadeMs) => {\n duckGain = gain;\n rescale(fadeMs);\n });\n\n const stop = (id: number, fadeOutMs: number) => {\n const voice = voices.get(id);\n\n if (!voice) {\n return;\n }\n\n // Forget it now: a dispose straight after must not cut this fade short.\n voices.delete(id);\n\n const { howl, sound } = voice;\n\n howl.off(\"end\", undefined, sound);\n\n if (fadeOutMs <= 0) {\n howl.stop(sound);\n\n return;\n }\n\n // Stop when the fade lands, or Howler keeps a silent voice alive.\n howl.once(\"fade\", () => howl.stop(sound), sound);\n howl.fade(howl.volume(sound) as number, 0, fadeOutMs, sound);\n };\n\n return {\n playBed(sample, { volume, fadeInMs }) {\n const howl = howlFor(sample);\n\n if (!howl) {\n return null;\n }\n\n const sound = howl.play();\n const id = nextId++;\n const voice: Voice = { howl, sound, base: volume };\n\n howl.loop(true, sound);\n howl.mute(muted, sound);\n howl.volume(0, sound);\n voices.set(id, voice);\n fadeTo(voice, fadeInMs);\n\n return id;\n },\n\n playEmitter(sample, { volume, rate, pan, onEnd }) {\n if (muted) {\n return null;\n }\n\n const howl = howlFor(sample);\n\n if (!howl || howl.state() === \"unloaded\") {\n return null;\n }\n\n const sound = howl.play();\n const id = nextId++;\n const voice: Voice = { howl, sound, base: volume };\n\n howl.loop(false, sound);\n howl.volume(effective(volume), sound);\n howl.rate(rate, sound);\n\n if (!opts.html5) {\n howl.stereo(pan, sound);\n }\n\n voices.set(id, voice);\n howl.once(\n \"end\",\n () => {\n if (voices.get(id) === voice) {\n voices.delete(id);\n onEnd();\n }\n },\n sound,\n );\n\n return id;\n },\n\n stop,\n\n setVolume(id, volume, fadeMs) {\n const voice = voices.get(id);\n\n if (voice) {\n voice.base = volume;\n fadeTo(voice, fadeMs);\n }\n },\n\n load(loadOpts: LoadOptions = {}) {\n const names = Object.keys(samples);\n let settled = 0;\n\n return Promise.all(\n names.map(\n (name) =>\n new Promise<void>((resolve) => {\n const howl = howlFor(name);\n let done = false;\n const finish = () => {\n if (!done) {\n done = true;\n settled += 1;\n loadOpts.onProgress?.(settled, names.length);\n resolve();\n }\n };\n\n if (!howl || howl.state() === \"loaded\") {\n finish();\n\n return;\n }\n\n howl.once(\"load\", finish);\n howl.once(\"loaderror\", finish);\n globalThis.setTimeout(finish, opts.loadTimeoutMs ?? 10_000);\n }),\n ),\n ).then(() => undefined);\n },\n\n duck: ducker.duck,\n\n get ducked() {\n return ducker.ducked;\n },\n\n setLevel(next, fadeMs = 0) {\n level = next;\n rescale(fadeMs);\n },\n\n setMuted(next) {\n muted = next;\n\n for (const { howl, sound } of voices.values()) {\n howl.mute(muted, sound);\n }\n },\n\n stopAll(fadeOutMs = 0) {\n for (const id of Array.from(voices.keys())) {\n stop(id, fadeOutMs);\n }\n },\n\n get voices() {\n return voices.size;\n },\n\n dispose() {\n for (const { howl, sound } of voices.values()) {\n howl.off(\"end\", undefined, sound);\n howl.stop(sound);\n }\n\n voices.clear();\n ducker.clear();\n\n for (const howl of howls.values()) {\n howl.unload();\n }\n\n howls.clear();\n },\n };\n};\n\nexport { createHowlerBus };\nexport type { HowlerBusOptions };\nexport type { DuckOptions, LoadOptions, SampleSource, SoundscapeAdapter } from \"../core/adapter.ts\";\n"],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import { a as SoundscapeBus, c as Timers, i as Range, n as EmitterDef, o as SoundscapeDef, r as EmitterPhase, s as SoundscapeOptions, t as BedDef } from "./types-BML2s2Se.js";
2
+ import { n as SoundscapePlayerOptions, r as Soundscape, t as SoundscapePlayer } from "./player-CepoDB_J.js";
3
+ //#region src/core/random.d.ts
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
+ declare const pickNotLast: <T>(pool: readonly T[], last: T | null, random: () => number) => T | undefined;
9
+ /**
10
+ * A small seeded generator (mulberry32) in `Math.random`'s shape: the same seed plays the same
11
+ * soundscape, for tests and for demos you want to reproduce.
12
+ */
13
+ declare const seededRandom: (seed: number) => () => number;
14
+ //#endregion
15
+ export { type BedDef, type EmitterDef, type EmitterPhase, type Range, Soundscape, type SoundscapeBus, type SoundscapeDef, type SoundscapeOptions, SoundscapePlayer, type SoundscapePlayerOptions, type Timers, pickNotLast, seededRandom };
16
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { i as seededRandom, n as Soundscape, r as pickNotLast, t as SoundscapePlayer } from "./player-BlQ9UyoK.js";
2
+ export { Soundscape, SoundscapePlayer, pickNotLast, seededRandom };