@call-me-sensei/toonlab 0.2.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 (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,806 @@
1
+ import {
2
+ cloneSerializable, createSeededRandom, deepMerge, hashSeed, stableStringify,
3
+ } from '../core/generation.js';
4
+ import { resolveSoundscapeGeneratorRecipe } from './soundscapeGenerator.js';
5
+ import { createSoundscapeSettings, sanitizeSoundscapeSettings } from './soundscapeSettings.js';
6
+
7
+ const LAYER_TYPES = new Map();
8
+
9
+ function clamp(value, min, max, fallback = min) {
10
+ const number = Number(value);
11
+ return Number.isFinite(number) ? Math.min(Math.max(number, min), max) : fallback;
12
+ }
13
+
14
+ function isObject(value) {
15
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+
18
+ function audioTime(context) {
19
+ return Number.isFinite(context?.currentTime) ? context.currentTime : 0;
20
+ }
21
+
22
+ function setParam(parameter, value, context, ramp = 0.025) {
23
+ if (!parameter) return;
24
+ const next = Number(value);
25
+ if (!Number.isFinite(next)) return;
26
+ const now = audioTime(context);
27
+ try {
28
+ parameter.cancelScheduledValues?.(now);
29
+ if (ramp > 0 && parameter.setTargetAtTime) parameter.setTargetAtTime(next, now, ramp);
30
+ else if (parameter.setValueAtTime) parameter.setValueAtTime(next, now);
31
+ else parameter.value = next;
32
+ } catch {
33
+ try { parameter.value = next; } catch { /* readonly AudioParam shim */ }
34
+ }
35
+ }
36
+
37
+ function safeConnect(source, destination) {
38
+ try { source?.connect?.(destination); } catch { /* optional node in reduced contexts */ }
39
+ }
40
+
41
+ function safeDisconnect(node) {
42
+ try { node?.disconnect?.(); } catch { /* already disconnected */ }
43
+ }
44
+
45
+ function safeStop(node, when) {
46
+ try { node?.stop?.(when); } catch { /* already stopped */ }
47
+ }
48
+
49
+ function getPath(source, path) {
50
+ return String(path).split('.').reduce((value, key) => value?.[key], source);
51
+ }
52
+
53
+ function setPath(source, path, value) {
54
+ const keys = String(path).split('.');
55
+ let target = source;
56
+ for (const key of keys.slice(0, -1)) {
57
+ if (!isObject(target[key])) target[key] = {};
58
+ target = target[key];
59
+ }
60
+ target[keys.at(-1)] = value;
61
+ }
62
+
63
+ function interpolateSettings(from, to, amount) {
64
+ if (typeof from === 'number' && typeof to === 'number') return from + (to - from) * amount;
65
+ if (Array.isArray(from) || Array.isArray(to)) return cloneSerializable(amount < 1 ? from ?? to : to ?? from);
66
+ if (!isObject(from) && !isObject(to)) return cloneSerializable(amount < 1 ? from ?? to : to ?? from);
67
+ const output = {};
68
+ for (const key of new Set([...Object.keys(from ?? {}), ...Object.keys(to ?? {})])) {
69
+ output[key] = interpolateSettings(from?.[key], to?.[key], amount);
70
+ }
71
+ return output;
72
+ }
73
+
74
+ function structureSignature(settings) {
75
+ return JSON.stringify({
76
+ budget: settings.budget,
77
+ buses: Object.keys(settings.buses ?? {}),
78
+ layers: Object.entries(settings.layers ?? {}).map(([id, layer]) => {
79
+ const definition = LAYER_TYPES.get(layer.type);
80
+ let construction = null;
81
+ try { construction = definition?.constructionKey?.(layer) ?? null; } catch { construction = null; }
82
+ return [id, layer.type, layer.enabled, layer.bus, stableStringify(construction)];
83
+ }),
84
+ limiter: settings.master?.limiter,
85
+ });
86
+ }
87
+
88
+ function createBudgetController(limits) {
89
+ let nodes = 0;
90
+ let voices = 0;
91
+ const layerVoices = new Map();
92
+ let droppedNodes = 0;
93
+ let droppedVoices = 0;
94
+
95
+ return {
96
+ node(layerId) {
97
+ if (nodes >= limits.maxNodes) {
98
+ droppedNodes += 1;
99
+ return null;
100
+ }
101
+ nodes += 1;
102
+ let released = false;
103
+ return () => {
104
+ if (released) return;
105
+ released = true;
106
+ nodes = Math.max(0, nodes - 1);
107
+ };
108
+ },
109
+ stats() {
110
+ return { droppedNodes, droppedVoices, nodes, voices };
111
+ },
112
+ voice(layerId, layerLimit = limits.maxVoices) {
113
+ const current = layerVoices.get(layerId) ?? 0;
114
+ if (voices >= limits.maxVoices || current >= Math.max(1, Number(layerLimit) || 1)) {
115
+ droppedVoices += 1;
116
+ return null;
117
+ }
118
+ voices += 1;
119
+ layerVoices.set(layerId, current + 1);
120
+ let released = false;
121
+ return () => {
122
+ if (released) return;
123
+ released = true;
124
+ voices = Math.max(0, voices - 1);
125
+ layerVoices.set(layerId, Math.max(0, (layerVoices.get(layerId) ?? 1) - 1));
126
+ };
127
+ },
128
+ };
129
+ }
130
+
131
+ function createGraphTools(controller, layerId) {
132
+ const nodes = new Map();
133
+ const voices = new Set();
134
+ return {
135
+ dispose() {
136
+ for (const release of voices) release();
137
+ voices.clear();
138
+ for (const [node, release] of nodes) {
139
+ safeDisconnect(node);
140
+ release();
141
+ }
142
+ nodes.clear();
143
+ },
144
+ node(factory) {
145
+ const release = controller.node(layerId);
146
+ if (!release) return null;
147
+ try {
148
+ const node = factory();
149
+ if (!node) throw new Error('Audio node factory returned no node.');
150
+ nodes.set(node, release);
151
+ return node;
152
+ } catch {
153
+ release();
154
+ return null;
155
+ }
156
+ },
157
+ releaseNode(node) {
158
+ const release = nodes.get(node);
159
+ if (!release) return;
160
+ nodes.delete(node);
161
+ safeDisconnect(node);
162
+ release();
163
+ },
164
+ releaseVoice(release) {
165
+ if (!release || !voices.has(release)) return;
166
+ voices.delete(release);
167
+ release();
168
+ },
169
+ voice(limit) {
170
+ const release = controller.voice(layerId, limit);
171
+ if (release) voices.add(release);
172
+ return release;
173
+ },
174
+ };
175
+ }
176
+
177
+ export function registerSoundscapeLayerType(type, definition, { overwrite = false } = {}) {
178
+ const id = String(type ?? '').trim();
179
+ if (!id) throw new Error('Soundscape layer type id is required.');
180
+ if (!overwrite && LAYER_TYPES.has(id)) throw new Error(`Soundscape layer type "${id}" already exists.`);
181
+ const normalized = typeof definition === 'function' ? { create: definition } : definition;
182
+ if (!normalized || typeof normalized.create !== 'function') throw new Error('A soundscape layer type requires a create() factory.');
183
+ LAYER_TYPES.set(id, {
184
+ constructionKey: typeof normalized.constructionKey === 'function' ? normalized.constructionKey : null,
185
+ create: normalized.create,
186
+ description: String(normalized.description ?? ''),
187
+ label: String(normalized.label ?? id),
188
+ });
189
+ return id;
190
+ }
191
+
192
+ export function unregisterSoundscapeLayerType(type) {
193
+ return LAYER_TYPES.delete(String(type));
194
+ }
195
+
196
+ export function getSoundscapeLayerTypeOptions() {
197
+ return [...LAYER_TYPES.entries()].map(([id, definition]) => ({
198
+ description: definition.description, id, label: definition.label,
199
+ }));
200
+ }
201
+
202
+ function createNoiseBuffer(context, color, seed) {
203
+ if (!context?.createBuffer) return null;
204
+ const length = Math.max(128, Math.floor((context.sampleRate || 44100) * 2));
205
+ const buffer = context.createBuffer(1, length, context.sampleRate || 44100);
206
+ const channel = buffer?.getChannelData?.(0);
207
+ if (!channel) return buffer;
208
+ const random = createSeededRandom(seed, 'noise-buffer');
209
+ let brown = 0;
210
+ let pink0 = 0;
211
+ let pink1 = 0;
212
+ for (let index = 0; index < channel.length; index += 1) {
213
+ const white = random.float(-1, 1);
214
+ if (color === 'brown') {
215
+ brown = (brown + 0.02 * white) / 1.02;
216
+ channel[index] = brown * 3.1;
217
+ } else if (color === 'pink') {
218
+ pink0 = 0.99765 * pink0 + white * 0.099046;
219
+ pink1 = 0.963 * pink1 + white * 0.2965164;
220
+ channel[index] = (pink0 + pink1 + white * 0.1848) * 0.18;
221
+ } else channel[index] = white * 0.62;
222
+ }
223
+ return buffer;
224
+ }
225
+
226
+ async function createNoiseLayer({ context, destination, id, settings, tools }) {
227
+ const gain = tools.node(() => context.createGain());
228
+ const pan = tools.node(() => context.createStereoPanner());
229
+ const highpass = tools.node(() => context.createBiquadFilter());
230
+ const lowpass = tools.node(() => context.createBiquadFilter());
231
+ if (!gain) throw new Error('Node budget exhausted before noise layer gain.');
232
+ if (highpass) {
233
+ highpass.type = 'highpass';
234
+ safeConnect(highpass, lowpass ?? pan ?? gain);
235
+ }
236
+ if (lowpass) {
237
+ lowpass.type = 'lowpass';
238
+ safeConnect(lowpass, pan ?? gain);
239
+ }
240
+ if (pan) safeConnect(pan, gain);
241
+ safeConnect(gain, destination);
242
+ let source = null;
243
+ let releaseVoice = null;
244
+ let phase = 0;
245
+
246
+ function apply(next, delta = 0) {
247
+ phase += delta * clamp(next.params?.movement, 0, 8, 0);
248
+ const movement = 0.9 + Math.sin(phase * Math.PI * 2) * 0.1;
249
+ setParam(gain.gain, next.gain * movement, context);
250
+ setParam(pan?.pan, next.pan, context);
251
+ setParam(highpass?.frequency, clamp(next.params?.highpass, 10, 22000, 60), context);
252
+ setParam(lowpass?.frequency, clamp(next.params?.lowpass, 10, 22000, 3200), context);
253
+ }
254
+
255
+ return {
256
+ start() {
257
+ if (source || !context.createBufferSource) return false;
258
+ releaseVoice = tools.voice(settings.voiceLimit);
259
+ if (!releaseVoice) return false;
260
+ source = tools.node(() => context.createBufferSource());
261
+ if (!source) {
262
+ tools.releaseVoice(releaseVoice);
263
+ releaseVoice = null;
264
+ return false;
265
+ }
266
+ source.buffer = createNoiseBuffer(context, settings.params?.color, hashSeed(`${id}:${settings.params?.color ?? 'white'}`));
267
+ source.loop = true;
268
+ safeConnect(source, highpass ?? lowpass ?? pan ?? gain);
269
+ try { source.start(); } catch { /* reduced context */ }
270
+ apply(settings);
271
+ return true;
272
+ },
273
+ stop() {
274
+ if (!source) return;
275
+ safeStop(source);
276
+ tools.releaseNode(source);
277
+ source = null;
278
+ tools.releaseVoice(releaseVoice);
279
+ releaseVoice = null;
280
+ },
281
+ update: apply,
282
+ trigger() { return false; },
283
+ };
284
+ }
285
+
286
+ async function createOscillatorLayer({ context, destination, settings, tools }) {
287
+ const gain = tools.node(() => context.createGain());
288
+ const pan = tools.node(() => context.createStereoPanner());
289
+ const filter = tools.node(() => context.createBiquadFilter());
290
+ if (!gain) throw new Error('Node budget exhausted before oscillator layer gain.');
291
+ if (filter) {
292
+ filter.type = 'lowpass';
293
+ safeConnect(filter, pan ?? gain);
294
+ }
295
+ if (pan) safeConnect(pan, gain);
296
+ safeConnect(gain, destination);
297
+ let oscillator = null;
298
+ let releaseVoice = null;
299
+ let phase = 0;
300
+
301
+ function apply(next, delta = 0) {
302
+ phase += delta * clamp(next.params?.movement, 0, 8, 0);
303
+ const drift = Math.sin(phase * Math.PI * 2) * clamp(next.params?.detune, -1200, 1200, 0);
304
+ setParam(gain.gain, next.gain, context);
305
+ setParam(pan?.pan, next.pan, context);
306
+ setParam(oscillator?.frequency, clamp(next.params?.frequency, 8, 22000, 110), context, 0.08);
307
+ setParam(oscillator?.detune, drift, context, 0.08);
308
+ setParam(filter?.frequency, clamp(next.params?.lowpass, 80, 22000, 2200), context);
309
+ }
310
+
311
+ return {
312
+ start() {
313
+ if (oscillator || !context.createOscillator) return false;
314
+ releaseVoice = tools.voice(settings.voiceLimit);
315
+ if (!releaseVoice) return false;
316
+ oscillator = tools.node(() => context.createOscillator());
317
+ if (!oscillator) {
318
+ tools.releaseVoice(releaseVoice);
319
+ releaseVoice = null;
320
+ return false;
321
+ }
322
+ oscillator.type = settings.params?.waveform ?? 'sine';
323
+ safeConnect(oscillator, filter ?? pan ?? gain);
324
+ apply(settings);
325
+ try { oscillator.start(); } catch { /* reduced context */ }
326
+ return true;
327
+ },
328
+ stop() {
329
+ if (!oscillator) return;
330
+ safeStop(oscillator);
331
+ tools.releaseNode(oscillator);
332
+ oscillator = null;
333
+ tools.releaseVoice(releaseVoice);
334
+ releaseVoice = null;
335
+ },
336
+ update: apply,
337
+ trigger() { return false; },
338
+ };
339
+ }
340
+
341
+ async function createProceduralEventLayer({ context, destination, id, settings, tools }) {
342
+ const output = tools.node(() => context.createGain());
343
+ const pan = tools.node(() => context.createStereoPanner());
344
+ if (!output) throw new Error('Node budget exhausted before procedural layer output.');
345
+ if (pan) safeConnect(pan, output);
346
+ safeConnect(output, destination);
347
+ const random = createSeededRandom(settings.seed ?? hashSeed(id), 'procedural-events');
348
+ let current = settings;
349
+ let elapsed = 0;
350
+ let nextEvent = 0.2 + random.float(0, 1);
351
+
352
+ function trigger(payload = {}) {
353
+ if (!context.createOscillator || !context.createGain) return false;
354
+ const releaseVoice = tools.voice(current.voiceLimit);
355
+ if (!releaseVoice) return false;
356
+ const oscillator = tools.node(() => context.createOscillator());
357
+ const envelope = tools.node(() => context.createGain());
358
+ if (!oscillator || !envelope) {
359
+ if (oscillator) tools.releaseNode(oscillator);
360
+ if (envelope) tools.releaseNode(envelope);
361
+ tools.releaseVoice(releaseVoice);
362
+ return false;
363
+ }
364
+ const now = audioTime(context);
365
+ const duration = clamp(payload.duration ?? current.params?.duration, 0.025, 4, 0.18);
366
+ const spread = clamp(current.params?.pitchSpread, 0, 2, 0.3);
367
+ const pitch = clamp(
368
+ payload.frequency ?? current.params?.frequency * (1 + random.float(-spread, spread)),
369
+ 20,
370
+ 22000,
371
+ 1200,
372
+ );
373
+ oscillator.type = payload.waveform ?? current.params?.waveform ?? 'sine';
374
+ setParam(oscillator.frequency, pitch, context, 0);
375
+ try {
376
+ envelope.gain.setValueAtTime?.(0.0001, now);
377
+ envelope.gain.exponentialRampToValueAtTime?.(Math.max(0.0001, current.gain), now + Math.min(0.025, duration * 0.25));
378
+ envelope.gain.exponentialRampToValueAtTime?.(0.0001, now + duration);
379
+ } catch { setParam(envelope.gain, current.gain, context, 0); }
380
+ safeConnect(oscillator, envelope);
381
+ safeConnect(envelope, pan ?? output);
382
+ let released = false;
383
+ const release = () => {
384
+ if (released) return;
385
+ released = true;
386
+ tools.releaseNode(oscillator);
387
+ tools.releaseNode(envelope);
388
+ tools.releaseVoice(releaseVoice);
389
+ };
390
+ oscillator.onended = release;
391
+ try {
392
+ oscillator.start(now);
393
+ oscillator.stop(now + duration + 0.02);
394
+ } catch { release(); }
395
+ return true;
396
+ }
397
+
398
+ return {
399
+ start() { return true; },
400
+ stop() {},
401
+ trigger,
402
+ update(next, delta = 0) {
403
+ current = next;
404
+ setParam(output.gain, 1, context);
405
+ setParam(pan?.pan, current.pan, context);
406
+ elapsed += Math.max(0, delta);
407
+ if (elapsed >= nextEvent) {
408
+ trigger();
409
+ elapsed = 0;
410
+ const density = clamp(current.params?.density, 0.001, 32, 0.2);
411
+ nextEvent = -Math.log(Math.max(0.0001, 1 - random.next())) / density;
412
+ }
413
+ },
414
+ };
415
+ }
416
+
417
+ async function defaultAssetResolver(asset, context, assets) {
418
+ const source = assets?.[asset] ?? asset;
419
+ if (source && typeof source !== 'string') return source;
420
+ if (!source || typeof fetch !== 'function' || !context?.decodeAudioData) return null;
421
+ const response = await fetch(source);
422
+ if (!response.ok) throw new Error(`Unable to load soundscape asset ${source}: ${response.status}.`);
423
+ return context.decodeAudioData(await response.arrayBuffer());
424
+ }
425
+
426
+ async function createAssetLayer({ assets, context, destination, resolveAsset, settings, tools }) {
427
+ const output = tools.node(() => context.createGain());
428
+ const pan = tools.node(() => context.createStereoPanner());
429
+ if (!output) throw new Error('Node budget exhausted before asset layer output.');
430
+ if (pan) safeConnect(pan, output);
431
+ safeConnect(output, destination);
432
+ let current = settings;
433
+ let buffer = null;
434
+ try {
435
+ buffer = await resolveAsset(settings.asset ?? settings.params?.asset, context, assets);
436
+ } catch { buffer = null; }
437
+
438
+ function trigger(payload = {}) {
439
+ if (!buffer || !context.createBufferSource) return false;
440
+ const releaseVoice = tools.voice(current.voiceLimit);
441
+ if (!releaseVoice) return false;
442
+ const source = tools.node(() => context.createBufferSource());
443
+ if (!source) {
444
+ tools.releaseVoice(releaseVoice);
445
+ return false;
446
+ }
447
+ source.buffer = buffer;
448
+ source.loop = Boolean(payload.loop);
449
+ source.playbackRate && setParam(source.playbackRate, payload.playbackRate ?? current.playbackRate, context, 0);
450
+ safeConnect(source, pan ?? output);
451
+ let released = false;
452
+ const release = () => {
453
+ if (released) return;
454
+ released = true;
455
+ tools.releaseNode(source);
456
+ tools.releaseVoice(releaseVoice);
457
+ };
458
+ source.onended = release;
459
+ try { source.start(0, Math.max(0, payload.offset ?? 0)); } catch { release(); return false; }
460
+ return true;
461
+ }
462
+
463
+ return {
464
+ start() { return current.autostart ? trigger({ loop: current.loop !== false }) : Boolean(buffer); },
465
+ stop() {},
466
+ trigger,
467
+ update(next) {
468
+ current = next;
469
+ setParam(output.gain, current.gain, context);
470
+ setParam(pan?.pan, current.pan, context);
471
+ },
472
+ };
473
+ }
474
+
475
+ registerSoundscapeLayerType('noise', {
476
+ constructionKey: (settings) => ({ color: settings.params?.color ?? 'white' }),
477
+ create: createNoiseLayer,
478
+ description: 'Seeded continuous white, pink or brown noise with procedural movement.',
479
+ label: 'Procedural Noise',
480
+ });
481
+ registerSoundscapeLayerType('oscillator', {
482
+ constructionKey: (settings) => ({ waveform: settings.params?.waveform ?? 'sine' }),
483
+ create: createOscillatorLayer,
484
+ description: 'Continuous tonal bed generated entirely with Web Audio oscillators.',
485
+ label: 'Oscillator Bed',
486
+ });
487
+ registerSoundscapeLayerType('procedural-events', {
488
+ constructionKey: (settings) => ({ seed: settings.seed ?? null }),
489
+ create: createProceduralEventLayer,
490
+ description: 'Deterministic Poisson-timed synthetic details and gameplay one-shots.',
491
+ label: 'Procedural Events',
492
+ });
493
+ registerSoundscapeLayerType('asset', {
494
+ constructionKey: (settings) => ({
495
+ asset: settings.asset ?? settings.params?.asset ?? null,
496
+ autostart: Boolean(settings.autostart),
497
+ loop: settings.loop !== false,
498
+ }),
499
+ create: createAssetLayer,
500
+ description: 'Decoded AudioBuffer or URL-backed layer for custom recordings and music.',
501
+ label: 'Audio Asset',
502
+ });
503
+
504
+ function normalizeRuntimeInput(input, quality, budget) {
505
+ if (input?.type === 'toonlab/soundscape-generator') return resolveSoundscapeGeneratorRecipe(input, { budget, quality });
506
+ if (input?.type === 'toonlab/soundscape-preset') return sanitizeSoundscapeSettings(input.settings, { budget, quality });
507
+ return createSoundscapeSettings(input ?? {}, { budget, quality });
508
+ }
509
+
510
+ function applyAdaptiveMappings(settings, adaptiveValues) {
511
+ const output = cloneSerializable(settings);
512
+ for (const [parameter, mappings] of Object.entries(settings.adaptive?.mappings ?? {})) {
513
+ const amount = clamp(adaptiveValues[parameter], 0, 1, 0);
514
+ for (const mapping of mappings) {
515
+ const curve = clamp(mapping.curve, 0.05, 8, 1);
516
+ const mapped = Number(mapping.from) + (Number(mapping.to) - Number(mapping.from)) * amount ** curve;
517
+ const current = Number(getPath(output, mapping.path));
518
+ const next = mapping.mode === 'multiply' ? current * mapped : mapping.mode === 'add' ? current + mapped : mapped;
519
+ if (Number.isFinite(next)) setPath(output, mapping.path, next);
520
+ }
521
+ }
522
+ return output;
523
+ }
524
+
525
+ /**
526
+ * Creates a package-grade Web Audio soundscape. The AudioContext is created
527
+ * lazily by start(), keeping construction safe before a user gesture and in
528
+ * SSR/Node environments.
529
+ */
530
+ export function createSoundscapeRuntime(options = {}) {
531
+ const quality = options.quality ?? 'balanced';
532
+ const runtimeBudget = options.budget ?? {};
533
+ let baseSettings = normalizeRuntimeInput(options.recipe ?? options.preset ?? options.settings, quality, runtimeBudget);
534
+ let appliedSettings = cloneSerializable(baseSettings);
535
+ let context = options.audioContext ?? null;
536
+ let ownsContext = false;
537
+ let status = 'idle';
538
+ let disposed = false;
539
+ let controller = createBudgetController(baseSettings.budget);
540
+ let mixerTools = null;
541
+ let masterNode = null;
542
+ let limiterNode = null;
543
+ let buses = new Map();
544
+ let layers = new Map();
545
+ let skippedLayers = [];
546
+ let transitionState = null;
547
+ let adaptiveTargets = { ...baseSettings.adaptive.values };
548
+ let adaptiveValues = { ...adaptiveTargets };
549
+ let buildPromise = null;
550
+ let transitionCount = 0;
551
+
552
+ const resolveAsset = options.assetResolver ?? ((asset, audioContext, assets) => defaultAssetResolver(asset, audioContext, assets));
553
+
554
+ function ensureContext() {
555
+ if (context) return context;
556
+ const Constructor = globalThis.AudioContext ?? globalThis.webkitAudioContext;
557
+ if (!Constructor) return null;
558
+ try {
559
+ context = new Constructor({ latencyHint: options.latencyHint ?? 'interactive' });
560
+ ownsContext = true;
561
+ return context;
562
+ } catch { return null; }
563
+ }
564
+
565
+ function teardownGraph() {
566
+ for (const entry of layers.values()) {
567
+ try { entry.handle.stop?.(); } catch { /* layer already stopped */ }
568
+ try { entry.handle.dispose?.(); } catch { /* optional extension cleanup */ }
569
+ entry.tools.dispose();
570
+ }
571
+ layers.clear();
572
+ buses.clear();
573
+ mixerTools?.dispose();
574
+ mixerTools = null;
575
+ masterNode = null;
576
+ limiterNode = null;
577
+ }
578
+
579
+ async function buildGraph() {
580
+ if (buildPromise) return buildPromise;
581
+ buildPromise = (async () => {
582
+ const activeContext = ensureContext();
583
+ if (!activeContext || disposed) return false;
584
+ teardownGraph();
585
+ controller = createBudgetController(baseSettings.budget);
586
+ skippedLayers = [];
587
+ mixerTools = createGraphTools(controller, '$mixer');
588
+ masterNode = mixerTools.node(() => activeContext.createGain());
589
+ if (!masterNode) {
590
+ status = 'budget-exhausted';
591
+ return false;
592
+ }
593
+ setParam(masterNode.gain, baseSettings.master.gain, activeContext, 0);
594
+ if (baseSettings.master.limiter && activeContext.createDynamicsCompressor) {
595
+ limiterNode = mixerTools.node(() => activeContext.createDynamicsCompressor());
596
+ }
597
+ if (limiterNode) {
598
+ setParam(limiterNode.threshold, -3, activeContext, 0);
599
+ setParam(limiterNode.knee, 5, activeContext, 0);
600
+ setParam(limiterNode.ratio, 12, activeContext, 0);
601
+ safeConnect(limiterNode, masterNode);
602
+ }
603
+ safeConnect(masterNode, activeContext.destination);
604
+ for (const [id, settings] of Object.entries(baseSettings.buses)) {
605
+ const node = mixerTools.node(() => activeContext.createGain());
606
+ if (!node) break;
607
+ setParam(node.gain, settings.mute ? 0 : settings.gain, activeContext, 0);
608
+ safeConnect(node, limiterNode ?? masterNode);
609
+ buses.set(id, node);
610
+ }
611
+ for (const [id, settings] of Object.entries(baseSettings.layers)) {
612
+ if (!settings.enabled) continue;
613
+ const definition = LAYER_TYPES.get(settings.type);
614
+ if (!definition) {
615
+ skippedLayers.push({ id, reason: `Unknown layer type ${settings.type}` });
616
+ continue;
617
+ }
618
+ const tools = createGraphTools(controller, id);
619
+ try {
620
+ const handle = await definition.create({
621
+ assets: options.assets ?? {},
622
+ context: activeContext,
623
+ destination: buses.get(settings.bus) ?? masterNode,
624
+ id,
625
+ resolveAsset,
626
+ settings,
627
+ tools,
628
+ });
629
+ if (!handle) throw new Error('Layer factory returned no handle.');
630
+ const started = await handle.start?.();
631
+ if (started === false) {
632
+ try { handle.stop?.(); } catch { /* incomplete source cleanup */ }
633
+ try { handle.dispose?.(); } catch { /* optional extension cleanup */ }
634
+ tools.dispose();
635
+ skippedLayers.push({ id, reason: 'Layer produced no playable source.' });
636
+ continue;
637
+ }
638
+ layers.set(id, { handle, settings, tools, type: settings.type });
639
+ } catch (error) {
640
+ tools.dispose();
641
+ skippedLayers.push({ id, reason: error.message });
642
+ }
643
+ }
644
+ appliedSettings = cloneSerializable(baseSettings);
645
+ applyLiveSettings(0);
646
+ return true;
647
+ })().finally(() => { buildPromise = null; });
648
+ return buildPromise;
649
+ }
650
+
651
+ function applyLiveSettings(delta) {
652
+ if (!context) return;
653
+ const settings = applyAdaptiveMappings(appliedSettings, adaptiveValues);
654
+ setParam(masterNode?.gain, settings.master.gain, context);
655
+ for (const [id, node] of buses) {
656
+ const bus = settings.buses[id];
657
+ if (bus) setParam(node.gain, bus.mute ? 0 : bus.gain, context);
658
+ }
659
+ for (const [id, entry] of layers) {
660
+ const layer = settings.layers[id];
661
+ if (!layer) continue;
662
+ entry.settings = layer;
663
+ try { entry.handle.update?.(layer, delta, adaptiveValues); } catch { /* isolate extension failures */ }
664
+ }
665
+ }
666
+
667
+ function setAdaptiveParameters(values = {}) {
668
+ for (const [key, value] of Object.entries(values)) adaptiveTargets[key] = clamp(value, 0, 1, adaptiveTargets[key] ?? 0);
669
+ return { ...adaptiveTargets };
670
+ }
671
+
672
+ function transition(input, { duration = baseSettings.master.transitionSeconds } = {}) {
673
+ if (disposed) return false;
674
+ const target = normalizeRuntimeInput(input, quality, runtimeBudget);
675
+ transitionState = {
676
+ duration: Math.max(0, Number(duration) || 0),
677
+ elapsed: 0,
678
+ from: cloneSerializable(appliedSettings),
679
+ previousStructure: structureSignature(baseSettings),
680
+ target,
681
+ };
682
+ transitionCount += 1;
683
+ if (transitionState.duration === 0) update(0);
684
+ return true;
685
+ }
686
+
687
+ function applySnapshot(snapshot, options = {}) {
688
+ const patch = typeof snapshot === 'string' ? baseSettings.snapshots?.[snapshot] : snapshot;
689
+ if (!isObject(patch)) return false;
690
+ return transition(deepMerge(baseSettings, patch), options);
691
+ }
692
+
693
+ function update(deltaSeconds = 0, adaptivePatch) {
694
+ if (disposed) return;
695
+ const delta = Math.max(0, Math.min(Number(deltaSeconds) || 0, 1));
696
+ if (adaptivePatch) setAdaptiveParameters(adaptivePatch);
697
+ const smoothing = Math.max(0, baseSettings.adaptive.smoothing);
698
+ const blend = smoothing <= 0 ? 1 : 1 - Math.exp(-delta / Math.max(0.0001, smoothing));
699
+ for (const [key, target] of Object.entries(adaptiveTargets)) {
700
+ adaptiveValues[key] = (adaptiveValues[key] ?? target) + (target - (adaptiveValues[key] ?? target)) * blend;
701
+ }
702
+ if (transitionState) {
703
+ transitionState.elapsed += delta;
704
+ const amount = transitionState.duration <= 0 ? 1 : Math.min(1, transitionState.elapsed / transitionState.duration);
705
+ appliedSettings = sanitizeSoundscapeSettings(interpolateSettings(transitionState.from, transitionState.target, amount), {
706
+ budget: runtimeBudget,
707
+ quality,
708
+ });
709
+ if (amount >= 1) {
710
+ const previousStructure = transitionState.previousStructure;
711
+ baseSettings = transitionState.target;
712
+ appliedSettings = cloneSerializable(baseSettings);
713
+ adaptiveTargets = { ...baseSettings.adaptive.values, ...adaptiveTargets };
714
+ transitionState = null;
715
+ if (structureSignature(baseSettings) !== previousStructure && context) void buildGraph();
716
+ }
717
+ }
718
+ applyLiveSettings(delta);
719
+ }
720
+
721
+ function trigger(event, payload = {}) {
722
+ if (disposed) return 0;
723
+ let targets = [];
724
+ const route = baseSettings.events?.[event];
725
+ if (typeof route === 'string') targets = [route];
726
+ else if (Array.isArray(route)) targets = route;
727
+ else if (Array.isArray(route?.layers)) targets = route.layers;
728
+ else if (layers.has(event)) targets = [event];
729
+ else if (payload.layer && layers.has(payload.layer)) targets = [payload.layer];
730
+ else if (event === 'all') targets = [...layers.keys()];
731
+ else targets = [...layers].filter(([, entry]) => entry.settings.triggers?.includes?.(event)).map(([id]) => id);
732
+ let accepted = 0;
733
+ for (const id of targets) {
734
+ try { if (layers.get(id)?.handle.trigger?.(payload) !== false) accepted += 1; } catch { /* isolate layer failure */ }
735
+ }
736
+ return accepted;
737
+ }
738
+
739
+ async function start() {
740
+ if (disposed) return { ok: false, reason: 'disposed' };
741
+ const activeContext = ensureContext();
742
+ if (!activeContext) {
743
+ status = 'unavailable';
744
+ return { ok: false, reason: 'audio-context-unavailable' };
745
+ }
746
+ try { await activeContext.resume?.(); } catch { /* context may already be running */ }
747
+ const graphReady = layers.size > 0 || await buildGraph();
748
+ const ok = Boolean(graphReady && layers.size > 0);
749
+ status = ok ? 'running' : status === 'budget-exhausted' ? status : 'silent';
750
+ return { ok: Boolean(ok), reason: ok ? null : status };
751
+ }
752
+
753
+ async function suspend() {
754
+ if (disposed) return false;
755
+ try { await context?.suspend?.(); } catch { /* externally managed context */ }
756
+ status = 'suspended';
757
+ return true;
758
+ }
759
+
760
+ function stats() {
761
+ const budgetStats = controller.stats();
762
+ return {
763
+ activeLayers: layers.size,
764
+ adaptive: { ...adaptiveValues },
765
+ budget: { ...baseSettings.budget },
766
+ contextState: context?.state ?? 'unavailable',
767
+ disposed,
768
+ nodes: budgetStats.nodes,
769
+ quality,
770
+ skippedLayers: cloneSerializable(skippedLayers),
771
+ status,
772
+ transitionActive: Boolean(transitionState),
773
+ transitionCount,
774
+ voices: budgetStats.voices,
775
+ voicesDropped: budgetStats.droppedVoices,
776
+ nodesDropped: budgetStats.droppedNodes,
777
+ };
778
+ }
779
+
780
+ async function dispose() {
781
+ if (disposed) return;
782
+ disposed = true;
783
+ status = 'disposed';
784
+ teardownGraph();
785
+ if (ownsContext && options.closeContextOnDispose !== false) {
786
+ try { await context?.close?.(); } catch { /* context already closed */ }
787
+ }
788
+ context = null;
789
+ }
790
+
791
+ return {
792
+ applySnapshot,
793
+ dispose,
794
+ get audioContext() { return context; },
795
+ get settings() { return cloneSerializable(baseSettings); },
796
+ setAdaptiveParameters,
797
+ start,
798
+ stats,
799
+ suspend,
800
+ transition,
801
+ trigger,
802
+ update,
803
+ };
804
+ }
805
+
806
+ export const createSoundscape = createSoundscapeRuntime;