@vib3code/sdk 2.0.1

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 (258) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DOCS/BLUEPRINT_EXECUTION_PLAN_2026-01-07.md +34 -0
  3. package/DOCS/CI_TESTING.md +38 -0
  4. package/DOCS/CLI_ONBOARDING.md +75 -0
  5. package/DOCS/CONTROL_REFERENCE.md +64 -0
  6. package/DOCS/DEV_TRACK_ANALYSIS.md +77 -0
  7. package/DOCS/DEV_TRACK_PLAN_2026-01-07.md +42 -0
  8. package/DOCS/DEV_TRACK_SESSION_2026-01-31.md +220 -0
  9. package/DOCS/ENV_SETUP.md +189 -0
  10. package/DOCS/EXPORT_FORMATS.md +417 -0
  11. package/DOCS/GPU_DISPOSAL_GUIDE.md +21 -0
  12. package/DOCS/LICENSING_TIERS.md +275 -0
  13. package/DOCS/MASTER_PLAN_2026-01-31.md +570 -0
  14. package/DOCS/OBS_SETUP_GUIDE.md +98 -0
  15. package/DOCS/PROJECT_SETUP.md +66 -0
  16. package/DOCS/RENDERER_LIFECYCLE.md +40 -0
  17. package/DOCS/REPO_MANIFEST.md +121 -0
  18. package/DOCS/SESSION_014_PLAN.md +195 -0
  19. package/DOCS/SESSION_LOG_2026-01-07.md +56 -0
  20. package/DOCS/STRATEGIC_BLUEPRINT_2026-01-07.md +72 -0
  21. package/DOCS/SYSTEM_AUDIT_2026-01-30.md +738 -0
  22. package/DOCS/SYSTEM_INVENTORY.md +520 -0
  23. package/DOCS/TELEMETRY_EXPORTS.md +34 -0
  24. package/DOCS/WEBGPU_STATUS.md +38 -0
  25. package/DOCS/XR_BENCHMARKS.md +608 -0
  26. package/LICENSE +21 -0
  27. package/README.md +426 -0
  28. package/docs/.nojekyll +0 -0
  29. package/docs/01-dissolution_of_euclidean_hegemony.html +346 -0
  30. package/docs/02-hyperspatial_ego_death.html +346 -0
  31. package/docs/03-post_cartesian_sublime.html +346 -0
  32. package/docs/04-crystalline_void_meditation.html +346 -0
  33. package/docs/05-quantum_decoherence_ballet.html +346 -0
  34. package/docs/06-dissolution_of_euclidean_hegemony.html +346 -0
  35. package/docs/07-hyperspatial_ego_death.html +346 -0
  36. package/docs/08-post_cartesian_sublime.html +346 -0
  37. package/docs/09-crystalline_void_meditation.html +346 -0
  38. package/docs/10-quantum_decoherence_ballet.html +346 -0
  39. package/docs/11-dissolution_of_euclidean_hegemony.html +346 -0
  40. package/docs/12-hyperspatial_ego_death.html +346 -0
  41. package/docs/13-post_cartesian_sublime.html +346 -0
  42. package/docs/index.html +794 -0
  43. package/docs/test-hub.html +441 -0
  44. package/docs/url-state.js +102 -0
  45. package/docs/vib3-exports/01-quantum-quantum-tetrahedron-lattice.html +489 -0
  46. package/docs/vib3-exports/02-quantum-quantum-hypersphere-matrix.html +489 -0
  47. package/docs/vib3-exports/03-quantum-quantum-hypertetra-fractal.html +489 -0
  48. package/docs/vib3-exports/04-faceted-faceted-crystal-structure.html +407 -0
  49. package/docs/vib3-exports/05-faceted-faceted-klein-bottle.html +407 -0
  50. package/docs/vib3-exports/06-faceted-faceted-hypertetra-torus.html +407 -0
  51. package/docs/vib3-exports/07-holographic-holographic-wave-field.html +457 -0
  52. package/docs/vib3-exports/08-holographic-holographic-hypersphere-sphere.html +457 -0
  53. package/docs/vib3-exports/09-holographic-holographic-hypertetra-crystal.html +457 -0
  54. package/docs/vib3-exports/index.html +238 -0
  55. package/docs/webgpu-live.html +702 -0
  56. package/package.json +367 -0
  57. package/src/advanced/AIPresetGenerator.js +777 -0
  58. package/src/advanced/MIDIController.js +703 -0
  59. package/src/advanced/OffscreenWorker.js +1051 -0
  60. package/src/advanced/WebGPUCompute.js +1051 -0
  61. package/src/advanced/WebXRRenderer.js +680 -0
  62. package/src/agent/cli/AgentCLI.js +615 -0
  63. package/src/agent/cli/index.js +14 -0
  64. package/src/agent/index.js +73 -0
  65. package/src/agent/mcp/MCPServer.js +950 -0
  66. package/src/agent/mcp/index.js +9 -0
  67. package/src/agent/mcp/tools.js +548 -0
  68. package/src/agent/telemetry/EventStream.js +669 -0
  69. package/src/agent/telemetry/Instrumentation.js +618 -0
  70. package/src/agent/telemetry/TelemetryExporters.js +427 -0
  71. package/src/agent/telemetry/TelemetryService.js +464 -0
  72. package/src/agent/telemetry/index.js +52 -0
  73. package/src/benchmarks/BenchmarkRunner.js +381 -0
  74. package/src/benchmarks/MetricsCollector.js +299 -0
  75. package/src/benchmarks/index.js +9 -0
  76. package/src/benchmarks/scenes.js +259 -0
  77. package/src/cli/index.js +675 -0
  78. package/src/config/ApiConfig.js +88 -0
  79. package/src/core/CanvasManager.js +217 -0
  80. package/src/core/ErrorReporter.js +117 -0
  81. package/src/core/ParameterMapper.js +333 -0
  82. package/src/core/Parameters.js +396 -0
  83. package/src/core/RendererContracts.js +200 -0
  84. package/src/core/UnifiedResourceManager.js +370 -0
  85. package/src/core/VIB3Engine.js +636 -0
  86. package/src/core/renderers/FacetedRendererAdapter.js +32 -0
  87. package/src/core/renderers/HolographicRendererAdapter.js +29 -0
  88. package/src/core/renderers/QuantumRendererAdapter.js +29 -0
  89. package/src/core/renderers/RendererLifecycleManager.js +63 -0
  90. package/src/creative/ColorPresetsSystem.js +980 -0
  91. package/src/creative/ParameterTimeline.js +1061 -0
  92. package/src/creative/PostProcessingPipeline.js +1113 -0
  93. package/src/creative/TransitionAnimator.js +683 -0
  94. package/src/export/CSSExporter.js +226 -0
  95. package/src/export/CardGeneratorBase.js +279 -0
  96. package/src/export/ExportManager.js +580 -0
  97. package/src/export/FacetedCardGenerator.js +279 -0
  98. package/src/export/HolographicCardGenerator.js +543 -0
  99. package/src/export/LottieExporter.js +552 -0
  100. package/src/export/QuantumCardGenerator.js +315 -0
  101. package/src/export/SVGExporter.js +519 -0
  102. package/src/export/ShaderExporter.js +903 -0
  103. package/src/export/TradingCardGenerator.js +3055 -0
  104. package/src/export/TradingCardManager.js +181 -0
  105. package/src/export/VIB3PackageExporter.js +559 -0
  106. package/src/export/index.js +14 -0
  107. package/src/export/systems/TradingCardSystemFaceted.js +494 -0
  108. package/src/export/systems/TradingCardSystemHolographic.js +452 -0
  109. package/src/export/systems/TradingCardSystemQuantum.js +411 -0
  110. package/src/faceted/FacetedSystem.js +963 -0
  111. package/src/features/CollectionManager.js +433 -0
  112. package/src/gallery/CollectionManager.js +240 -0
  113. package/src/gallery/GallerySystem.js +485 -0
  114. package/src/geometry/GeometryFactory.js +314 -0
  115. package/src/geometry/GeometryLibrary.js +72 -0
  116. package/src/geometry/buffers/BufferBuilder.js +338 -0
  117. package/src/geometry/buffers/index.js +18 -0
  118. package/src/geometry/generators/Crystal.js +420 -0
  119. package/src/geometry/generators/Fractal.js +298 -0
  120. package/src/geometry/generators/KleinBottle.js +197 -0
  121. package/src/geometry/generators/Sphere.js +192 -0
  122. package/src/geometry/generators/Tesseract.js +160 -0
  123. package/src/geometry/generators/Tetrahedron.js +225 -0
  124. package/src/geometry/generators/Torus.js +304 -0
  125. package/src/geometry/generators/Wave.js +341 -0
  126. package/src/geometry/index.js +142 -0
  127. package/src/geometry/warp/HypersphereCore.js +211 -0
  128. package/src/geometry/warp/HypertetraCore.js +386 -0
  129. package/src/geometry/warp/index.js +57 -0
  130. package/src/holograms/HolographicVisualizer.js +1073 -0
  131. package/src/holograms/RealHolographicSystem.js +966 -0
  132. package/src/holograms/variantRegistry.js +69 -0
  133. package/src/integrations/FigmaPlugin.js +854 -0
  134. package/src/integrations/OBSMode.js +754 -0
  135. package/src/integrations/ThreeJsPackage.js +660 -0
  136. package/src/integrations/TouchDesignerExport.js +552 -0
  137. package/src/integrations/frameworks/Vib3React.js +591 -0
  138. package/src/integrations/frameworks/Vib3Svelte.js +654 -0
  139. package/src/integrations/frameworks/Vib3Vue.js +628 -0
  140. package/src/llm/LLMParameterInterface.js +240 -0
  141. package/src/llm/LLMParameterUI.js +577 -0
  142. package/src/math/Mat4x4.js +708 -0
  143. package/src/math/Projection.js +341 -0
  144. package/src/math/Rotor4D.js +637 -0
  145. package/src/math/Vec4.js +476 -0
  146. package/src/math/constants.js +164 -0
  147. package/src/math/index.js +68 -0
  148. package/src/math/projections.js +54 -0
  149. package/src/math/rotations.js +196 -0
  150. package/src/quantum/QuantumEngine.js +906 -0
  151. package/src/quantum/QuantumVisualizer.js +1103 -0
  152. package/src/reactivity/ReactivityConfig.js +499 -0
  153. package/src/reactivity/ReactivityManager.js +586 -0
  154. package/src/reactivity/SpatialInputSystem.js +1783 -0
  155. package/src/reactivity/index.js +93 -0
  156. package/src/render/CommandBuffer.js +465 -0
  157. package/src/render/MultiCanvasBridge.js +340 -0
  158. package/src/render/RenderCommand.js +514 -0
  159. package/src/render/RenderResourceRegistry.js +523 -0
  160. package/src/render/RenderState.js +552 -0
  161. package/src/render/RenderTarget.js +512 -0
  162. package/src/render/ShaderLoader.js +253 -0
  163. package/src/render/ShaderProgram.js +599 -0
  164. package/src/render/UnifiedRenderBridge.js +496 -0
  165. package/src/render/backends/WebGLBackend.js +1108 -0
  166. package/src/render/backends/WebGPUBackend.js +1409 -0
  167. package/src/render/commands/CommandBufferExecutor.js +607 -0
  168. package/src/render/commands/RenderCommandBuffer.js +661 -0
  169. package/src/render/commands/index.js +17 -0
  170. package/src/render/index.js +367 -0
  171. package/src/scene/Disposable.js +498 -0
  172. package/src/scene/MemoryPool.js +618 -0
  173. package/src/scene/Node4D.js +697 -0
  174. package/src/scene/ResourceManager.js +599 -0
  175. package/src/scene/Scene4D.js +540 -0
  176. package/src/scene/index.js +98 -0
  177. package/src/schemas/error.schema.json +84 -0
  178. package/src/schemas/extension.schema.json +88 -0
  179. package/src/schemas/index.js +214 -0
  180. package/src/schemas/parameters.schema.json +142 -0
  181. package/src/schemas/tool-pack.schema.json +44 -0
  182. package/src/schemas/tool-response.schema.json +127 -0
  183. package/src/shaders/common/fullscreen.vert.glsl +5 -0
  184. package/src/shaders/common/fullscreen.vert.wgsl +17 -0
  185. package/src/shaders/common/geometry24.glsl +65 -0
  186. package/src/shaders/common/geometry24.wgsl +54 -0
  187. package/src/shaders/common/rotation4d.glsl +85 -0
  188. package/src/shaders/common/rotation4d.wgsl +86 -0
  189. package/src/shaders/common/uniforms.glsl +44 -0
  190. package/src/shaders/common/uniforms.wgsl +48 -0
  191. package/src/shaders/faceted/faceted.frag.glsl +129 -0
  192. package/src/shaders/faceted/faceted.frag.wgsl +164 -0
  193. package/src/shaders/holographic/holographic.frag.glsl +406 -0
  194. package/src/shaders/holographic/holographic.frag.wgsl +185 -0
  195. package/src/shaders/quantum/quantum.frag.glsl +513 -0
  196. package/src/shaders/quantum/quantum.frag.wgsl +361 -0
  197. package/src/testing/ParallelTestFramework.js +519 -0
  198. package/src/testing/__snapshots__/exportFormats.test.js.snap +24 -0
  199. package/src/testing/exportFormats.test.js +8 -0
  200. package/src/testing/projections.test.js +14 -0
  201. package/src/testing/rotations.test.js +37 -0
  202. package/src/ui/InteractivityMenu.js +516 -0
  203. package/src/ui/StatusManager.js +96 -0
  204. package/src/ui/adaptive/renderers/webgpu/BufferLayout.ts +252 -0
  205. package/src/ui/adaptive/renderers/webgpu/PolytopeInstanceBuffer.ts +144 -0
  206. package/src/ui/adaptive/renderers/webgpu/TripleBufferedUniform.ts +170 -0
  207. package/src/ui/adaptive/renderers/webgpu/WebGPURenderer.ts +735 -0
  208. package/src/ui/adaptive/renderers/webgpu/index.ts +112 -0
  209. package/src/variations/VariationManager.js +431 -0
  210. package/src/viewer/AudioReactivity.js +505 -0
  211. package/src/viewer/CardBending.js +481 -0
  212. package/src/viewer/GalleryUI.js +832 -0
  213. package/src/viewer/ReactivityManager.js +590 -0
  214. package/src/viewer/TradingCardExporter.js +600 -0
  215. package/src/viewer/ViewerPortal.js +374 -0
  216. package/src/viewer/index.js +12 -0
  217. package/src/wasm/WasmLoader.js +296 -0
  218. package/src/wasm/index.js +132 -0
  219. package/tools/agentic/mcpTools.js +88 -0
  220. package/tools/cli/agent-cli.js +92 -0
  221. package/tools/export/formats.js +24 -0
  222. package/tools/math/rotation-baseline.mjs +64 -0
  223. package/tools/shader-sync-verify.js +937 -0
  224. package/tools/telemetry/manifestPipeline.js +141 -0
  225. package/tools/telemetry/telemetryEvents.js +35 -0
  226. package/types/adaptive-sdk.d.ts +185 -0
  227. package/types/advanced/AIPresetGenerator.d.ts +81 -0
  228. package/types/advanced/MIDIController.d.ts +100 -0
  229. package/types/advanced/OffscreenWorker.d.ts +82 -0
  230. package/types/advanced/WebGPUCompute.d.ts +52 -0
  231. package/types/advanced/WebXRRenderer.d.ts +77 -0
  232. package/types/advanced/index.d.ts +46 -0
  233. package/types/core/ErrorReporter.d.ts +50 -0
  234. package/types/core/VIB3Engine.d.ts +204 -0
  235. package/types/creative/ColorPresetsSystem.d.ts +91 -0
  236. package/types/creative/ParameterTimeline.d.ts +74 -0
  237. package/types/creative/PostProcessingPipeline.d.ts +109 -0
  238. package/types/creative/TransitionAnimator.d.ts +71 -0
  239. package/types/creative/index.d.ts +35 -0
  240. package/types/integrations/FigmaPlugin.d.ts +46 -0
  241. package/types/integrations/OBSMode.d.ts +74 -0
  242. package/types/integrations/ThreeJsPackage.d.ts +62 -0
  243. package/types/integrations/TouchDesignerExport.d.ts +36 -0
  244. package/types/integrations/Vib3React.d.ts +74 -0
  245. package/types/integrations/Vib3Svelte.d.ts +63 -0
  246. package/types/integrations/Vib3Vue.d.ts +55 -0
  247. package/types/integrations/index.d.ts +52 -0
  248. package/types/reactivity/SpatialInputSystem.d.ts +173 -0
  249. package/types/reactivity/index.d.ts +394 -0
  250. package/types/render/CommandBuffer.d.ts +169 -0
  251. package/types/render/RenderCommand.d.ts +312 -0
  252. package/types/render/RenderState.d.ts +279 -0
  253. package/types/render/RenderTarget.d.ts +254 -0
  254. package/types/render/ShaderProgram.d.ts +277 -0
  255. package/types/render/UnifiedRenderBridge.d.ts +143 -0
  256. package/types/render/WebGLBackend.d.ts +168 -0
  257. package/types/render/WebGPUBackend.d.ts +186 -0
  258. package/types/render/index.d.ts +141 -0
@@ -0,0 +1,1783 @@
1
+ /**
2
+ * VIB3+ SpatialInputSystem
3
+ *
4
+ * Evolved spatial orientation input abstraction that maps ANY input source
5
+ * to visualization parameters. Provides universal "card tilting" behavior
6
+ * decoupled from physical device tilt -- mouse, gamepad, gyroscope, audio,
7
+ * MIDI, AR perspective, and programmatic API all feed the same spatial state.
8
+ *
9
+ * Architecture:
10
+ * Input Sources --> Spatial State (pitch/yaw/roll/x/y/z) --> Smoothing --> Mapping --> Parameter Updates
11
+ *
12
+ * @module reactivity/SpatialInputSystem
13
+ * @version 1.0.0
14
+ */
15
+
16
+ import { TARGETABLE_PARAMETERS, BLEND_MODES } from './ReactivityConfig.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Recognised input source types.
24
+ * @readonly
25
+ * @enum {string}
26
+ */
27
+ export const SOURCE_TYPES = Object.freeze({
28
+ DEVICE_TILT: 'deviceTilt',
29
+ MOUSE_POSITION: 'mousePosition',
30
+ GYROSCOPE: 'gyroscope',
31
+ GAMEPAD: 'gamepad',
32
+ PERSPECTIVE: 'perspective',
33
+ PROGRAMMATIC: 'programmatic',
34
+ AUDIO: 'audio',
35
+ MIDI: 'midi'
36
+ });
37
+
38
+ /**
39
+ * Named spatial axes that form the normalised intermediate state.
40
+ * All values are kept in the range -1 .. 1 (translations) or -1 .. 1
41
+ * (rotational axes). `intensity` and `velocity` are 0 .. 1.
42
+ * @readonly
43
+ * @enum {string}
44
+ */
45
+ export const SPATIAL_AXES = Object.freeze([
46
+ 'pitch', 'yaw', 'roll',
47
+ 'x', 'y', 'z',
48
+ 'intensity', 'velocity'
49
+ ]);
50
+
51
+ /**
52
+ * Dramatic mode amplification factor (matches legacy DeviceTiltHandler).
53
+ * @constant {number}
54
+ */
55
+ const DRAMATIC_MULTIPLIER = 8;
56
+
57
+ /**
58
+ * Maximum number of concurrently registered input sources.
59
+ * @constant {number}
60
+ */
61
+ const MAX_SOURCES = 32;
62
+
63
+ /**
64
+ * Maximum number of active mappings.
65
+ * @constant {number}
66
+ */
67
+ const MAX_MAPPINGS = 256;
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Helpers
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Clamp a number between min and max.
75
+ * @param {number} v
76
+ * @param {number} lo
77
+ * @param {number} hi
78
+ * @returns {number}
79
+ */
80
+ function clamp(v, lo, hi) {
81
+ return v < lo ? lo : v > hi ? hi : v;
82
+ }
83
+
84
+ /**
85
+ * Linear interpolation.
86
+ * @param {number} a - Start value
87
+ * @param {number} b - End value
88
+ * @param {number} t - Interpolant (0..1)
89
+ * @returns {number}
90
+ */
91
+ function lerp(a, b, t) {
92
+ return a + (b - a) * t;
93
+ }
94
+
95
+ /**
96
+ * Create a fresh zero-valued spatial state object.
97
+ * @returns {SpatialState}
98
+ */
99
+ function createSpatialState() {
100
+ return {
101
+ pitch: 0,
102
+ yaw: 0,
103
+ roll: 0,
104
+ x: 0,
105
+ y: 0,
106
+ z: 0,
107
+ intensity: 0,
108
+ velocity: 0
109
+ };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Type definitions (JSDoc only -- no TypeScript)
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * @typedef {Object} SpatialState
118
+ * @property {number} pitch - Forward/back tilt (-1..1)
119
+ * @property {number} yaw - Left/right rotation (-1..1)
120
+ * @property {number} roll - Left/right tilt (-1..1)
121
+ * @property {number} x - Translation X (-1..1)
122
+ * @property {number} y - Translation Y (-1..1)
123
+ * @property {number} z - Translation Z / depth (-1..1)
124
+ * @property {number} intensity - Overall spatial intensity (0..1)
125
+ * @property {number} velocity - Rate of change (0..1)
126
+ */
127
+
128
+ /**
129
+ * @typedef {Object} MappingEntry
130
+ * @property {string} axis - Source spatial axis name
131
+ * @property {string} target - Target VIB3+ parameter name
132
+ * @property {number} scale - Multiplier applied to axis value
133
+ * @property {number[]} [clamp] - Optional [min, max] to clamp mapped output
134
+ * @property {string} [blendMode] - One of BLEND_MODES ('add'|'replace'|'multiply'|'max'|'min')
135
+ */
136
+
137
+ /**
138
+ * @typedef {Object} ProfileDef
139
+ * @property {string} name - Human-readable name
140
+ * @property {string} description - What this profile does
141
+ * @property {MappingEntry[]} mappings - Array of axis-to-parameter mappings
142
+ * @property {string[]} sources - Recommended input source types
143
+ * @property {number} smoothing - Suggested smoothing factor (0..1)
144
+ */
145
+
146
+ /**
147
+ * @typedef {Object} SourceEntry
148
+ * @property {string} name - Unique source name
149
+ * @property {string} type - One of SOURCE_TYPES
150
+ * @property {Object} config - Source-specific configuration
151
+ * @property {boolean} active - Whether this source is currently feeding data
152
+ * @property {Function|null} _cleanup - Cleanup function for event listeners
153
+ */
154
+
155
+ /**
156
+ * @typedef {Object} SpatialInputOptions
157
+ * @property {number} [sensitivity=1.0] - Global sensitivity multiplier
158
+ * @property {number} [smoothing=0.15] - Default smoothing factor (0..1)
159
+ * @property {Function} [onParameterUpdate] - Callback invoked as fn(paramName, value)
160
+ * @property {boolean} [autoRegisterGlobals=true] - Expose window helpers
161
+ */
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // SpatialInputSystem
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /**
168
+ * SpatialInputSystem -- universal spatial orientation input for VIB3+.
169
+ *
170
+ * Abstracts spatial orientation from any combination of input sources
171
+ * (device tilt, mouse, gamepad, gyroscope, audio, MIDI, AR perspective,
172
+ * programmatic API) and maps the resulting spatial state to any VIB3+
173
+ * visualisation parameter.
174
+ *
175
+ * @example
176
+ * const spatial = new SpatialInputSystem({
177
+ * onParameterUpdate: (name, value) => engine.setParameter(name, value),
178
+ * smoothing: 0.12
179
+ * });
180
+ * spatial.addSource('tilt', 'deviceTilt');
181
+ * spatial.addSource('mouse', 'mousePosition');
182
+ * spatial.loadProfile('cardTilt');
183
+ * spatial.enable();
184
+ */
185
+ export class SpatialInputSystem {
186
+
187
+ // ------------------------------------------------------------------
188
+ // Construction
189
+ // ------------------------------------------------------------------
190
+
191
+ /**
192
+ * Create a new SpatialInputSystem.
193
+ * @param {SpatialInputOptions} [options={}]
194
+ */
195
+ constructor(options = {}) {
196
+ /** @type {Map<string, SourceEntry>} Active input sources keyed by name */
197
+ this.sources = new Map();
198
+
199
+ /** @type {Map<string, MappingEntry>} Active mappings keyed by "axis->target" */
200
+ this.mappings = new Map();
201
+
202
+ /** @type {Map<string, ProfileDef>} Named mapping profiles */
203
+ this.profiles = new Map();
204
+
205
+ /** @type {Map<string, number>} Per-axis smoothing state */
206
+ this.smoothing = new Map();
207
+
208
+ /** @type {boolean} Whether the frame loop is running */
209
+ this.enabled = false;
210
+
211
+ /** @type {number} Global sensitivity multiplier (0.1 .. 10) */
212
+ this.sensitivity = clamp(Number(options.sensitivity) || 1.0, 0.1, 10);
213
+
214
+ /** @type {number} Default smoothing factor (0 .. 1) */
215
+ this.smoothingFactor = clamp(Number(options.smoothing) || 0.15, 0, 1);
216
+
217
+ /** @type {boolean} Whether dramatic mode (8x amplification) is active */
218
+ this.dramaticMode = false;
219
+
220
+ /**
221
+ * Callback invoked when a mapped parameter should be updated.
222
+ * Signature: `(parameterName: string, value: number) => void`
223
+ * @type {Function|null}
224
+ */
225
+ this.parameterUpdateFn = typeof options.onParameterUpdate === 'function'
226
+ ? options.onParameterUpdate
227
+ : null;
228
+
229
+ /** @type {Set<string>} Custom user-defined target names beyond TARGETABLE_PARAMETERS */
230
+ this._customTargets = new Set();
231
+
232
+ // -- Spatial state (raw, pre-smoothing) --
233
+ /** @type {SpatialState} */
234
+ this.spatialState = createSpatialState();
235
+
236
+ // -- Smoothed output state --
237
+ /** @type {SpatialState} */
238
+ this.smoothedState = createSpatialState();
239
+
240
+ // -- Previous smoothed state (for velocity calculation) --
241
+ /** @private @type {SpatialState} */
242
+ this._prevSmoothedState = createSpatialState();
243
+
244
+ // -- Frame loop bookkeeping --
245
+ /** @private */
246
+ this._frameId = null;
247
+ /** @private */
248
+ this._lastFrameTime = 0;
249
+
250
+ // -- Event listeners --
251
+ /** @private @type {Map<string, Function[]>} */
252
+ this._listeners = new Map();
253
+
254
+ // -- Bound handlers stored for cleanup --
255
+ /** @private */
256
+ this._boundDeviceOrientation = null;
257
+ /** @private */
258
+ this._boundMouseMove = null;
259
+ /** @private */
260
+ this._boundGamepadPoll = null;
261
+ /** @private */
262
+ this._gyroscopeSensor = null;
263
+
264
+ // Initialise built-in profiles
265
+ this._initBuiltInProfiles();
266
+
267
+ /** @type {string|null} Name of the currently active profile */
268
+ this.activeProfile = null;
269
+
270
+ // Optionally register global helpers
271
+ if (options.autoRegisterGlobals !== false && typeof window !== 'undefined') {
272
+ this._registerGlobals();
273
+ }
274
+
275
+ console.log('SpatialInputSystem: Initialised');
276
+ }
277
+
278
+ // ------------------------------------------------------------------
279
+ // Global browser helpers
280
+ // ------------------------------------------------------------------
281
+
282
+ /**
283
+ * Register convenience functions on the window object.
284
+ * @private
285
+ */
286
+ _registerGlobals() {
287
+ if (typeof window === 'undefined') return;
288
+
289
+ window.spatialInputSystem = this;
290
+
291
+ /**
292
+ * Enable spatial input with an optional profile.
293
+ * @param {string} [profile]
294
+ */
295
+ window.enableSpatialInput = (profile) => {
296
+ if (profile) this.loadProfile(profile);
297
+ this.enable();
298
+ };
299
+
300
+ /**
301
+ * Disable spatial input processing.
302
+ */
303
+ window.disableSpatialInput = () => {
304
+ this.disable();
305
+ };
306
+
307
+ /**
308
+ * Switch the active spatial profile.
309
+ * @param {string} name
310
+ */
311
+ window.setSpatialProfile = (name) => {
312
+ this.loadProfile(name);
313
+ };
314
+
315
+ /**
316
+ * Feed manual spatial data (programmatic source).
317
+ * @param {Partial<SpatialState>} data
318
+ */
319
+ window.feedSpatialInput = (data) => {
320
+ this.feedInput(SOURCE_TYPES.PROGRAMMATIC, data);
321
+ };
322
+
323
+ /**
324
+ * Get the current smoothed spatial state.
325
+ * @returns {SpatialState}
326
+ */
327
+ window.getSpatialState = () => {
328
+ return this.getState();
329
+ };
330
+ }
331
+
332
+ // ------------------------------------------------------------------
333
+ // Built-in profiles
334
+ // ------------------------------------------------------------------
335
+
336
+ /**
337
+ * Register all built-in mapping profiles.
338
+ * @private
339
+ */
340
+ _initBuiltInProfiles() {
341
+
342
+ // ---- Card Tilt ----
343
+ this.profiles.set('cardTilt', {
344
+ name: 'Card Tilt',
345
+ description: 'Physical tilt controls 4D hyperspace rotation',
346
+ mappings: [
347
+ { axis: 'pitch', target: 'rot4dXW', scale: 0.02, clamp: [-1.5, 1.5], blendMode: 'replace' },
348
+ { axis: 'roll', target: 'rot4dYW', scale: 0.025, clamp: [-1.5, 1.5], blendMode: 'replace' },
349
+ { axis: 'yaw', target: 'rot4dZW', scale: 0.015, clamp: [-2.0, 2.0], blendMode: 'replace' },
350
+ { axis: 'intensity', target: 'chaos', scale: 0.3, clamp: [0, 1], blendMode: 'replace' }
351
+ ],
352
+ sources: [SOURCE_TYPES.DEVICE_TILT, SOURCE_TYPES.MOUSE_POSITION],
353
+ smoothing: 0.12
354
+ });
355
+
356
+ // ---- Wearable Perspective ----
357
+ this.profiles.set('wearablePerspective', {
358
+ name: 'Wearable Perspective',
359
+ description: 'Viewing angle affects visuals without physical device tilt; designed for AR glasses and headsets',
360
+ mappings: [
361
+ { axis: 'pitch', target: 'rot4dXW', scale: 0.015, clamp: [-1.0, 1.0], blendMode: 'replace' },
362
+ { axis: 'yaw', target: 'rot4dXY', scale: 0.02, clamp: [-3.14, 3.14], blendMode: 'replace' },
363
+ { axis: 'roll', target: 'rot4dYW', scale: 0.01, clamp: [-0.8, 0.8], blendMode: 'replace' },
364
+ { axis: 'z', target: 'dimension', scale: 0.5, clamp: [3.0, 4.5], blendMode: 'replace' },
365
+ { axis: 'intensity', target: 'intensity', scale: 0.4, clamp: [0.2, 1.0], blendMode: 'replace' }
366
+ ],
367
+ sources: [SOURCE_TYPES.PERSPECTIVE, SOURCE_TYPES.DEVICE_TILT],
368
+ smoothing: 0.2
369
+ });
370
+
371
+ // ---- Game Asset ----
372
+ this.profiles.set('gameAsset', {
373
+ name: 'Game Asset',
374
+ description: 'Game object orientation in world space maps to all 6 rotation planes',
375
+ mappings: [
376
+ { axis: 'pitch', target: 'rot4dYZ', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
377
+ { axis: 'yaw', target: 'rot4dXZ', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
378
+ { axis: 'roll', target: 'rot4dXY', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
379
+ { axis: 'x', target: 'rot4dXW', scale: 0.5, clamp: [-2.0, 2.0], blendMode: 'replace' },
380
+ { axis: 'y', target: 'rot4dYW', scale: 0.5, clamp: [-2.0, 2.0], blendMode: 'replace' },
381
+ { axis: 'z', target: 'rot4dZW', scale: 0.5, clamp: [-2.0, 2.0], blendMode: 'replace' }
382
+ ],
383
+ sources: [SOURCE_TYPES.PROGRAMMATIC, SOURCE_TYPES.GAMEPAD],
384
+ smoothing: 0.08
385
+ });
386
+
387
+ // ---- VJ Audio Spatial ----
388
+ this.profiles.set('vjAudioSpatial', {
389
+ name: 'VJ Audio Spatial',
390
+ description: 'Audio energy creates spatial movement for live VJ performance',
391
+ mappings: [
392
+ { axis: 'pitch', target: 'rot4dXW', scale: 0.8, clamp: [-1.5, 1.5], blendMode: 'add' },
393
+ { axis: 'yaw', target: 'rot4dYW', scale: 0.6, clamp: [-1.5, 1.5], blendMode: 'add' },
394
+ { axis: 'roll', target: 'rot4dZW', scale: 0.5, clamp: [-1.0, 1.0], blendMode: 'add' },
395
+ { axis: 'intensity', target: 'morphFactor', scale: 1.5, clamp: [0, 2], blendMode: 'replace' },
396
+ { axis: 'velocity', target: 'chaos', scale: 0.8, clamp: [0, 1], blendMode: 'replace' },
397
+ { axis: 'x', target: 'hue', scale: 180, clamp: [0, 360], blendMode: 'add' },
398
+ { axis: 'y', target: 'speed', scale: 2.0, clamp: [0.1, 3.0], blendMode: 'replace' }
399
+ ],
400
+ sources: [SOURCE_TYPES.AUDIO, SOURCE_TYPES.MIDI],
401
+ smoothing: 0.08
402
+ });
403
+
404
+ // ---- UI Element ----
405
+ this.profiles.set('uiElement', {
406
+ name: 'UI Element',
407
+ description: 'Mouse and scroll position changes visuals without physical movement',
408
+ mappings: [
409
+ { axis: 'pitch', target: 'rot4dXW', scale: 0.8, clamp: [-1.0, 1.0], blendMode: 'replace' },
410
+ { axis: 'roll', target: 'rot4dYW', scale: 0.8, clamp: [-1.0, 1.0], blendMode: 'replace' },
411
+ { axis: 'yaw', target: 'rot4dXY', scale: 0.5, clamp: [-1.5, 1.5], blendMode: 'replace' },
412
+ { axis: 'z', target: 'dimension', scale: 0.3, clamp: [3.0, 4.5], blendMode: 'add' },
413
+ { axis: 'intensity', target: 'intensity', scale: 0.5, clamp: [0.1, 1.0], blendMode: 'replace' }
414
+ ],
415
+ sources: [SOURCE_TYPES.MOUSE_POSITION],
416
+ smoothing: 0.18
417
+ });
418
+
419
+ // ---- Immersive XR ----
420
+ this.profiles.set('immersiveXR', {
421
+ name: 'Immersive XR',
422
+ description: 'Full 6DOF mapping for WebXR headsets and controllers',
423
+ mappings: [
424
+ // Rotational axes map to all 6 rotation planes
425
+ { axis: 'pitch', target: 'rot4dYZ', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
426
+ { axis: 'yaw', target: 'rot4dXZ', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
427
+ { axis: 'roll', target: 'rot4dXY', scale: 1.0, clamp: [-6.28, 6.28], blendMode: 'replace' },
428
+ // Translational axes map to 4D hyperspace rotations
429
+ { axis: 'x', target: 'rot4dXW', scale: 1.0, clamp: [-2.0, 2.0], blendMode: 'replace' },
430
+ { axis: 'y', target: 'rot4dYW', scale: 1.0, clamp: [-2.0, 2.0], blendMode: 'replace' },
431
+ { axis: 'z', target: 'rot4dZW', scale: 1.0, clamp: [-2.0, 2.0], blendMode: 'replace' },
432
+ // Movement intensity drives visual intensity
433
+ { axis: 'intensity', target: 'intensity', scale: 0.6, clamp: [0.2, 1.0], blendMode: 'replace' },
434
+ { axis: 'velocity', target: 'speed', scale: 2.0, clamp: [0.1, 3.0], blendMode: 'replace' }
435
+ ],
436
+ sources: [SOURCE_TYPES.PERSPECTIVE, SOURCE_TYPES.GYROSCOPE, SOURCE_TYPES.GAMEPAD],
437
+ smoothing: 0.06
438
+ });
439
+ }
440
+
441
+ // ------------------------------------------------------------------
442
+ // Source management
443
+ // ------------------------------------------------------------------
444
+
445
+ /**
446
+ * Register an input source.
447
+ *
448
+ * @param {string} name - Unique identifier for this source instance
449
+ * @param {string} type - One of SOURCE_TYPES values
450
+ * @param {Object} [config={}] - Source-specific configuration
451
+ * @returns {boolean} True if the source was added successfully
452
+ *
453
+ * @example
454
+ * spatial.addSource('tilt', 'deviceTilt');
455
+ * spatial.addSource('mouse', 'mousePosition', { element: myCanvas });
456
+ * spatial.addSource('pad', 'gamepad', { index: 0, deadzone: 0.1 });
457
+ */
458
+ addSource(name, type, config = {}) {
459
+ if (!name || typeof name !== 'string') {
460
+ console.warn('SpatialInputSystem.addSource: name must be a non-empty string');
461
+ return false;
462
+ }
463
+ if (!Object.values(SOURCE_TYPES).includes(type)) {
464
+ console.warn(`SpatialInputSystem.addSource: unknown source type "${type}"`);
465
+ return false;
466
+ }
467
+ if (this.sources.size >= MAX_SOURCES) {
468
+ console.warn('SpatialInputSystem.addSource: maximum source count reached');
469
+ return false;
470
+ }
471
+
472
+ // Remove existing source with same name first
473
+ if (this.sources.has(name)) {
474
+ this.removeSource(name);
475
+ }
476
+
477
+ /** @type {SourceEntry} */
478
+ const entry = {
479
+ name,
480
+ type,
481
+ config: { ...config },
482
+ active: true,
483
+ _cleanup: null
484
+ };
485
+
486
+ this.sources.set(name, entry);
487
+
488
+ // If already enabled, start listening immediately
489
+ if (this.enabled) {
490
+ this._attachSourceListeners(entry);
491
+ }
492
+
493
+ this._emit('sourceAdded', { name, type });
494
+ console.log(`SpatialInputSystem: Source added "${name}" (${type})`);
495
+ return true;
496
+ }
497
+
498
+ /**
499
+ * Remove an input source and clean up its listeners.
500
+ * @param {string} name
501
+ * @returns {boolean} True if a source was removed
502
+ */
503
+ removeSource(name) {
504
+ const entry = this.sources.get(name);
505
+ if (!entry) return false;
506
+
507
+ this._detachSourceListeners(entry);
508
+ this.sources.delete(name);
509
+
510
+ this._emit('sourceRemoved', { name, type: entry.type });
511
+ console.log(`SpatialInputSystem: Source removed "${name}"`);
512
+ return true;
513
+ }
514
+
515
+ /**
516
+ * Check whether a source with the given name is registered.
517
+ * @param {string} name
518
+ * @returns {boolean}
519
+ */
520
+ hasSource(name) {
521
+ return this.sources.has(name);
522
+ }
523
+
524
+ /**
525
+ * Get a list of all registered source names and types.
526
+ * @returns {{ name: string, type: string, active: boolean }[]}
527
+ */
528
+ listSources() {
529
+ const list = [];
530
+ for (const [name, entry] of this.sources) {
531
+ list.push({ name, type: entry.type, active: entry.active });
532
+ }
533
+ return list;
534
+ }
535
+
536
+ // ------------------------------------------------------------------
537
+ // Mapping management
538
+ // ------------------------------------------------------------------
539
+
540
+ /**
541
+ * Map a spatial axis to a VIB3+ parameter (or custom target).
542
+ *
543
+ * @param {string} sourceAxis - Spatial axis name (e.g. 'pitch', 'yaw')
544
+ * @param {string} targetParam - VIB3+ parameter name (e.g. 'rot4dXW')
545
+ * @param {number} [scale=1.0] - Scaling multiplier
546
+ * @param {number[]} [clampRange=null] - Optional [min, max]
547
+ * @param {string} [blendMode='replace'] - One of BLEND_MODES
548
+ * @returns {boolean} True if the mapping was added
549
+ */
550
+ setMapping(sourceAxis, targetParam, scale = 1.0, clampRange = null, blendMode = 'replace') {
551
+ if (!SPATIAL_AXES.includes(sourceAxis)) {
552
+ console.warn(`SpatialInputSystem.setMapping: unknown axis "${sourceAxis}"`);
553
+ return false;
554
+ }
555
+ if (!this._isValidTarget(targetParam)) {
556
+ console.warn(`SpatialInputSystem.setMapping: unknown target "${targetParam}"`);
557
+ return false;
558
+ }
559
+ if (!BLEND_MODES.includes(blendMode)) {
560
+ console.warn(`SpatialInputSystem.setMapping: unknown blend mode "${blendMode}", defaulting to "replace"`);
561
+ blendMode = 'replace';
562
+ }
563
+ if (this.mappings.size >= MAX_MAPPINGS) {
564
+ console.warn('SpatialInputSystem.setMapping: maximum mapping count reached');
565
+ return false;
566
+ }
567
+
568
+ const key = `${sourceAxis}->${targetParam}`;
569
+
570
+ /** @type {MappingEntry} */
571
+ const entry = {
572
+ axis: sourceAxis,
573
+ target: targetParam,
574
+ scale: Number.isFinite(scale) ? scale : 1.0,
575
+ clamp: Array.isArray(clampRange) && clampRange.length === 2 ? clampRange : null,
576
+ blendMode
577
+ };
578
+
579
+ this.mappings.set(key, entry);
580
+ this._emit('mappingChanged', entry);
581
+ return true;
582
+ }
583
+
584
+ /**
585
+ * Remove a specific axis-to-parameter mapping.
586
+ * @param {string} sourceAxis
587
+ * @param {string} targetParam
588
+ * @returns {boolean}
589
+ */
590
+ removeMapping(sourceAxis, targetParam) {
591
+ const key = `${sourceAxis}->${targetParam}`;
592
+ const removed = this.mappings.delete(key);
593
+ if (removed) {
594
+ this._emit('mappingRemoved', { axis: sourceAxis, target: targetParam });
595
+ }
596
+ return removed;
597
+ }
598
+
599
+ /**
600
+ * Clear all active mappings.
601
+ */
602
+ clearMappings() {
603
+ this.mappings.clear();
604
+ this._emit('mappingsCleared');
605
+ }
606
+
607
+ /**
608
+ * Get all current mappings as an array.
609
+ * @returns {MappingEntry[]}
610
+ */
611
+ listMappings() {
612
+ return Array.from(this.mappings.values());
613
+ }
614
+
615
+ // ------------------------------------------------------------------
616
+ // Profiles
617
+ // ------------------------------------------------------------------
618
+
619
+ /**
620
+ * Load a mapping profile by name, replacing all current mappings.
621
+ *
622
+ * @param {string} profileName
623
+ * @returns {boolean} True if the profile was loaded
624
+ *
625
+ * @example
626
+ * spatial.loadProfile('cardTilt');
627
+ */
628
+ loadProfile(profileName) {
629
+ const profile = this.profiles.get(profileName);
630
+ if (!profile) {
631
+ console.warn(`SpatialInputSystem.loadProfile: unknown profile "${profileName}"`);
632
+ return false;
633
+ }
634
+
635
+ // Clear existing mappings
636
+ this.clearMappings();
637
+
638
+ // Apply profile smoothing
639
+ if (typeof profile.smoothing === 'number') {
640
+ this.smoothingFactor = clamp(profile.smoothing, 0, 1);
641
+ }
642
+
643
+ // Install all mappings from the profile
644
+ for (const m of profile.mappings) {
645
+ this.setMapping(m.axis, m.target, m.scale, m.clamp || null, m.blendMode || 'replace');
646
+ }
647
+
648
+ this.activeProfile = profileName;
649
+ this._emit('profileLoaded', { name: profileName, profile });
650
+ console.log(`SpatialInputSystem: Profile loaded "${profileName}"`);
651
+ return true;
652
+ }
653
+
654
+ /**
655
+ * Create (or overwrite) a custom mapping profile.
656
+ *
657
+ * @param {string} name - Profile identifier
658
+ * @param {MappingEntry[]} mappings - Array of mapping definitions
659
+ * @param {Object} [meta={}] - Optional metadata
660
+ * @param {string} [meta.description]
661
+ * @param {string[]} [meta.sources]
662
+ * @param {number} [meta.smoothing]
663
+ * @returns {boolean}
664
+ */
665
+ createProfile(name, mappings, meta = {}) {
666
+ if (!name || typeof name !== 'string') {
667
+ console.warn('SpatialInputSystem.createProfile: name must be a non-empty string');
668
+ return false;
669
+ }
670
+ if (!Array.isArray(mappings) || mappings.length === 0) {
671
+ console.warn('SpatialInputSystem.createProfile: mappings must be a non-empty array');
672
+ return false;
673
+ }
674
+
675
+ /** @type {ProfileDef} */
676
+ const profile = {
677
+ name: meta.name || name,
678
+ description: meta.description || `Custom profile: ${name}`,
679
+ mappings: mappings.map(m => ({
680
+ axis: m.axis,
681
+ target: m.target,
682
+ scale: Number.isFinite(m.scale) ? m.scale : 1.0,
683
+ clamp: Array.isArray(m.clamp) ? m.clamp : null,
684
+ blendMode: m.blendMode || 'replace'
685
+ })),
686
+ sources: Array.isArray(meta.sources) ? meta.sources : [],
687
+ smoothing: typeof meta.smoothing === 'number' ? clamp(meta.smoothing, 0, 1) : this.smoothingFactor
688
+ };
689
+
690
+ this.profiles.set(name, profile);
691
+ this._emit('profileCreated', { name, profile });
692
+ console.log(`SpatialInputSystem: Profile created "${name}"`);
693
+ return true;
694
+ }
695
+
696
+ /**
697
+ * Remove a custom profile (built-in profiles cannot be removed).
698
+ * @param {string} name
699
+ * @returns {boolean}
700
+ */
701
+ removeProfile(name) {
702
+ const builtIn = ['cardTilt', 'wearablePerspective', 'gameAsset', 'vjAudioSpatial', 'uiElement', 'immersiveXR'];
703
+ if (builtIn.includes(name)) {
704
+ console.warn(`SpatialInputSystem.removeProfile: cannot remove built-in profile "${name}"`);
705
+ return false;
706
+ }
707
+ return this.profiles.delete(name);
708
+ }
709
+
710
+ /**
711
+ * Get a list of all available profile names.
712
+ * @returns {string[]}
713
+ */
714
+ listProfiles() {
715
+ return Array.from(this.profiles.keys());
716
+ }
717
+
718
+ /**
719
+ * Get a profile definition by name.
720
+ * @param {string} name
721
+ * @returns {ProfileDef|null}
722
+ */
723
+ getProfile(name) {
724
+ const p = this.profiles.get(name);
725
+ return p ? { ...p, mappings: p.mappings.map(m => ({ ...m })) } : null;
726
+ }
727
+
728
+ // ------------------------------------------------------------------
729
+ // Custom targets
730
+ // ------------------------------------------------------------------
731
+
732
+ /**
733
+ * Register a custom target parameter name (beyond TARGETABLE_PARAMETERS).
734
+ * @param {string} name
735
+ */
736
+ addCustomTarget(name) {
737
+ if (typeof name === 'string' && name.length > 0) {
738
+ this._customTargets.add(name);
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Remove a custom target parameter name.
744
+ * @param {string} name
745
+ */
746
+ removeCustomTarget(name) {
747
+ this._customTargets.delete(name);
748
+ }
749
+
750
+ /**
751
+ * Check whether a target name is valid (built-in or custom).
752
+ * @private
753
+ * @param {string} name
754
+ * @returns {boolean}
755
+ */
756
+ _isValidTarget(name) {
757
+ return TARGETABLE_PARAMETERS.includes(name) || this._customTargets.has(name);
758
+ }
759
+
760
+ // ------------------------------------------------------------------
761
+ // Enable / Disable
762
+ // ------------------------------------------------------------------
763
+
764
+ /**
765
+ * Start processing input and updating parameters each frame.
766
+ */
767
+ enable() {
768
+ if (this.enabled) return;
769
+
770
+ this.enabled = true;
771
+ this._lastFrameTime = performance.now();
772
+
773
+ // Attach listeners for all registered sources
774
+ for (const entry of this.sources.values()) {
775
+ this._attachSourceListeners(entry);
776
+ }
777
+
778
+ // Start frame loop
779
+ this._scheduleFrame();
780
+
781
+ this._emit('enabled');
782
+ console.log('SpatialInputSystem: Enabled');
783
+ }
784
+
785
+ /**
786
+ * Stop processing and detach all listeners.
787
+ */
788
+ disable() {
789
+ if (!this.enabled) return;
790
+
791
+ this.enabled = false;
792
+
793
+ // Cancel frame loop
794
+ if (this._frameId !== null) {
795
+ cancelAnimationFrame(this._frameId);
796
+ this._frameId = null;
797
+ }
798
+
799
+ // Detach listeners for all registered sources
800
+ for (const entry of this.sources.values()) {
801
+ this._detachSourceListeners(entry);
802
+ }
803
+
804
+ this._emit('disabled');
805
+ console.log('SpatialInputSystem: Disabled');
806
+ }
807
+
808
+ /**
809
+ * Whether the system is currently active.
810
+ * @returns {boolean}
811
+ */
812
+ isEnabled() {
813
+ return this.enabled;
814
+ }
815
+
816
+ // ------------------------------------------------------------------
817
+ // Configuration setters
818
+ // ------------------------------------------------------------------
819
+
820
+ /**
821
+ * Set the global sensitivity multiplier.
822
+ * @param {number} value - 0.1 .. 10
823
+ */
824
+ setSensitivity(value) {
825
+ this.sensitivity = clamp(Number(value) || 1.0, 0.1, 10);
826
+ }
827
+
828
+ /**
829
+ * Set the default smoothing factor.
830
+ * @param {number} value - 0 .. 1 (0 = no smoothing, 1 = maximum smoothing)
831
+ */
832
+ setSmoothing(value) {
833
+ this.smoothingFactor = clamp(Number(value) || 0.15, 0, 1);
834
+ }
835
+
836
+ /**
837
+ * Set per-axis smoothing override.
838
+ * @param {string} axis - Spatial axis name
839
+ * @param {number} value - 0 .. 1
840
+ */
841
+ setAxisSmoothing(axis, value) {
842
+ if (SPATIAL_AXES.includes(axis)) {
843
+ this.smoothing.set(axis, clamp(Number(value) || 0, 0, 1));
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Enable or disable dramatic mode (8x amplification).
849
+ * @param {boolean} enabled
850
+ */
851
+ setDramaticMode(enabled) {
852
+ this.dramaticMode = !!enabled;
853
+ this._emit('dramaticModeChanged', this.dramaticMode);
854
+ console.log(`SpatialInputSystem: Dramatic mode ${this.dramaticMode ? 'ON' : 'OFF'}`);
855
+ }
856
+
857
+ /**
858
+ * Set the parameter update callback.
859
+ * @param {Function} fn - (paramName: string, value: number) => void
860
+ */
861
+ setParameterUpdateFn(fn) {
862
+ this.parameterUpdateFn = typeof fn === 'function' ? fn : null;
863
+ }
864
+
865
+ // ------------------------------------------------------------------
866
+ // Manual data injection
867
+ // ------------------------------------------------------------------
868
+
869
+ /**
870
+ * Feed spatial data from an external/programmatic source.
871
+ *
872
+ * @param {string} sourceType - One of SOURCE_TYPES
873
+ * @param {Object} data - Partial spatial state or source-specific data
874
+ *
875
+ * @example
876
+ * // Programmatic: set pitch and roll directly
877
+ * spatial.feedInput('programmatic', { pitch: 0.5, roll: -0.3 });
878
+ *
879
+ * // Audio: provide frequency band levels
880
+ * spatial.feedInput('audio', { bass: 0.8, mid: 0.4, high: 0.2 });
881
+ *
882
+ * // MIDI: provide channel, CC number, and value
883
+ * spatial.feedInput('midi', { channel: 0, cc: 1, value: 64 });
884
+ */
885
+ feedInput(sourceType, data) {
886
+ if (!data || typeof data !== 'object') return;
887
+
888
+ switch (sourceType) {
889
+ case SOURCE_TYPES.PROGRAMMATIC:
890
+ this._processProgrammatic(data);
891
+ break;
892
+ case SOURCE_TYPES.DEVICE_TILT:
893
+ this._processDeviceTilt(data);
894
+ break;
895
+ case SOURCE_TYPES.MOUSE_POSITION:
896
+ this._processMousePosition(
897
+ Number(data.x) || 0,
898
+ Number(data.y) || 0,
899
+ data.width || (typeof window !== 'undefined' ? window.innerWidth : 1920),
900
+ data.height || (typeof window !== 'undefined' ? window.innerHeight : 1080)
901
+ );
902
+ break;
903
+ case SOURCE_TYPES.GYROSCOPE:
904
+ this._processGyroscope(data);
905
+ break;
906
+ case SOURCE_TYPES.GAMEPAD:
907
+ this._processGamepad(data);
908
+ break;
909
+ case SOURCE_TYPES.PERSPECTIVE:
910
+ this._processPerspective(data);
911
+ break;
912
+ case SOURCE_TYPES.AUDIO:
913
+ this._processAudioSpatial(
914
+ Number(data.bass) || 0,
915
+ Number(data.mid) || 0,
916
+ Number(data.high) || 0
917
+ );
918
+ break;
919
+ case SOURCE_TYPES.MIDI:
920
+ this._processMIDI(
921
+ Number(data.channel) || 0,
922
+ Number(data.cc) || 0,
923
+ Number(data.value) || 0
924
+ );
925
+ break;
926
+ default:
927
+ console.warn(`SpatialInputSystem.feedInput: unknown source type "${sourceType}"`);
928
+ }
929
+ }
930
+
931
+ // ------------------------------------------------------------------
932
+ // State retrieval
933
+ // ------------------------------------------------------------------
934
+
935
+ /**
936
+ * Get the current smoothed spatial state.
937
+ * @returns {SpatialState}
938
+ */
939
+ getState() {
940
+ return { ...this.smoothedState };
941
+ }
942
+
943
+ /**
944
+ * Get the raw (pre-smoothing) spatial state.
945
+ * @returns {SpatialState}
946
+ */
947
+ getRawState() {
948
+ return { ...this.spatialState };
949
+ }
950
+
951
+ // ------------------------------------------------------------------
952
+ // Serialisation
953
+ // ------------------------------------------------------------------
954
+
955
+ /**
956
+ * Export the full configuration (sources, mappings, profile, settings).
957
+ * @returns {Object}
958
+ */
959
+ exportConfig() {
960
+ return {
961
+ version: '1.0',
962
+ sensitivity: this.sensitivity,
963
+ smoothingFactor: this.smoothingFactor,
964
+ dramaticMode: this.dramaticMode,
965
+ activeProfile: this.activeProfile,
966
+ sources: this.listSources(),
967
+ mappings: this.listMappings(),
968
+ customTargets: Array.from(this._customTargets),
969
+ axisSmoothing: Object.fromEntries(this.smoothing)
970
+ };
971
+ }
972
+
973
+ /**
974
+ * Import a previously exported configuration.
975
+ *
976
+ * @param {Object} config - Configuration object from exportConfig()
977
+ * @returns {boolean} True if import succeeded
978
+ */
979
+ importConfig(config) {
980
+ if (!config || typeof config !== 'object') {
981
+ console.warn('SpatialInputSystem.importConfig: invalid config');
982
+ return false;
983
+ }
984
+
985
+ try {
986
+ // Settings
987
+ if (Number.isFinite(config.sensitivity)) {
988
+ this.setSensitivity(config.sensitivity);
989
+ }
990
+ if (Number.isFinite(config.smoothingFactor)) {
991
+ this.setSmoothing(config.smoothingFactor);
992
+ }
993
+ if (typeof config.dramaticMode === 'boolean') {
994
+ this.setDramaticMode(config.dramaticMode);
995
+ }
996
+
997
+ // Custom targets
998
+ if (Array.isArray(config.customTargets)) {
999
+ this._customTargets.clear();
1000
+ for (const t of config.customTargets) {
1001
+ this.addCustomTarget(t);
1002
+ }
1003
+ }
1004
+
1005
+ // Axis smoothing overrides
1006
+ if (config.axisSmoothing && typeof config.axisSmoothing === 'object') {
1007
+ this.smoothing.clear();
1008
+ for (const [axis, val] of Object.entries(config.axisSmoothing)) {
1009
+ this.setAxisSmoothing(axis, val);
1010
+ }
1011
+ }
1012
+
1013
+ // Sources
1014
+ if (Array.isArray(config.sources)) {
1015
+ // Remove all existing sources first
1016
+ for (const name of Array.from(this.sources.keys())) {
1017
+ this.removeSource(name);
1018
+ }
1019
+ for (const s of config.sources) {
1020
+ if (s.name && s.type) {
1021
+ this.addSource(s.name, s.type, s.config || {});
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ // Mappings
1027
+ if (Array.isArray(config.mappings)) {
1028
+ this.clearMappings();
1029
+ for (const m of config.mappings) {
1030
+ this.setMapping(m.axis, m.target, m.scale, m.clamp, m.blendMode);
1031
+ }
1032
+ }
1033
+
1034
+ // Profile (load after mappings -- if a profile is specified, it
1035
+ // replaces the explicit mappings above)
1036
+ if (config.activeProfile && this.profiles.has(config.activeProfile)) {
1037
+ this.loadProfile(config.activeProfile);
1038
+ }
1039
+
1040
+ this._emit('configImported', config);
1041
+ console.log('SpatialInputSystem: Config imported');
1042
+ return true;
1043
+ } catch (err) {
1044
+ console.error('SpatialInputSystem.importConfig: error during import', err);
1045
+ return false;
1046
+ }
1047
+ }
1048
+
1049
+ // ------------------------------------------------------------------
1050
+ // Frame processing
1051
+ // ------------------------------------------------------------------
1052
+
1053
+ /**
1054
+ * Schedule the next animation frame.
1055
+ * @private
1056
+ */
1057
+ _scheduleFrame() {
1058
+ if (!this.enabled) return;
1059
+ this._frameId = requestAnimationFrame((ts) => this._onFrame(ts));
1060
+ }
1061
+
1062
+ /**
1063
+ * Animation frame callback.
1064
+ * @private
1065
+ * @param {number} timestamp - High-resolution timestamp from rAF
1066
+ */
1067
+ _onFrame(timestamp) {
1068
+ if (!this.enabled) return;
1069
+
1070
+ const deltaTime = Math.min((timestamp - this._lastFrameTime) / 1000, 0.1); // cap at 100ms
1071
+ this._lastFrameTime = timestamp;
1072
+
1073
+ // Poll sources that need polling (e.g. gamepad)
1074
+ this._pollSources();
1075
+
1076
+ // Run the main processing pipeline
1077
+ this.processFrame(deltaTime);
1078
+
1079
+ // Schedule next
1080
+ this._scheduleFrame();
1081
+ }
1082
+
1083
+ /**
1084
+ * Main per-frame processing pipeline.
1085
+ *
1086
+ * 1. Compute velocity from state delta
1087
+ * 2. Compute intensity from overall displacement
1088
+ * 3. Apply smoothing
1089
+ * 4. Apply dramatic mode amplification
1090
+ * 5. Map smoothed state to parameters via active mappings
1091
+ * 6. Invoke parameter update callbacks
1092
+ *
1093
+ * @param {number} deltaTime - Seconds since last frame
1094
+ */
1095
+ processFrame(deltaTime) {
1096
+ const dt = Number.isFinite(deltaTime) && deltaTime > 0 ? deltaTime : 1 / 60;
1097
+
1098
+ // -- Compute derived values --
1099
+
1100
+ // Intensity: Euclidean distance of orientation + translation from origin
1101
+ const rawIntensity = Math.sqrt(
1102
+ this.spatialState.pitch * this.spatialState.pitch +
1103
+ this.spatialState.yaw * this.spatialState.yaw +
1104
+ this.spatialState.roll * this.spatialState.roll +
1105
+ this.spatialState.x * this.spatialState.x +
1106
+ this.spatialState.y * this.spatialState.y +
1107
+ this.spatialState.z * this.spatialState.z
1108
+ );
1109
+ this.spatialState.intensity = clamp(rawIntensity / Math.SQRT2, 0, 1);
1110
+
1111
+ // -- Smooth all axes --
1112
+ for (const axis of SPATIAL_AXES) {
1113
+ const factor = this.smoothing.has(axis)
1114
+ ? this.smoothing.get(axis)
1115
+ : this.smoothingFactor;
1116
+
1117
+ // Lerp factor: lower smoothingFactor = more responsive
1118
+ const lerpFactor = 1.0 - factor;
1119
+
1120
+ this.smoothedState[axis] = lerp(
1121
+ this.smoothedState[axis],
1122
+ this.spatialState[axis],
1123
+ clamp(lerpFactor, 0, 1)
1124
+ );
1125
+ }
1126
+
1127
+ // Velocity: rate of change of the smoothed state
1128
+ let velocitySum = 0;
1129
+ for (const axis of ['pitch', 'yaw', 'roll', 'x', 'y', 'z']) {
1130
+ const delta = this.smoothedState[axis] - this._prevSmoothedState[axis];
1131
+ velocitySum += delta * delta;
1132
+ }
1133
+ this.smoothedState.velocity = clamp(Math.sqrt(velocitySum) / (dt * 10), 0, 1);
1134
+
1135
+ // Store previous state
1136
+ Object.assign(this._prevSmoothedState, this.smoothedState);
1137
+
1138
+ // -- Apply mappings --
1139
+ this._applyMappings();
1140
+ }
1141
+
1142
+ /**
1143
+ * Apply all active mappings, reading from smoothedState and invoking
1144
+ * the parameter update callback.
1145
+ * @private
1146
+ */
1147
+ _applyMappings() {
1148
+ if (!this.parameterUpdateFn) return;
1149
+
1150
+ // Accumulate per-target to handle multiple mappings to the same target
1151
+ /** @type {Map<string, { value: number, mode: string }>} */
1152
+ const accumulator = new Map();
1153
+
1154
+ for (const mapping of this.mappings.values()) {
1155
+ const rawValue = this.smoothedState[mapping.axis] || 0;
1156
+
1157
+ // Apply sensitivity and scale
1158
+ let value = rawValue * mapping.scale * this.sensitivity;
1159
+
1160
+ // Dramatic mode amplification
1161
+ if (this.dramaticMode) {
1162
+ value *= DRAMATIC_MULTIPLIER;
1163
+ }
1164
+
1165
+ // Clamp if configured
1166
+ if (mapping.clamp) {
1167
+ value = clamp(value, mapping.clamp[0], mapping.clamp[1]);
1168
+ }
1169
+
1170
+ // Guard against NaN
1171
+ if (!Number.isFinite(value)) continue;
1172
+
1173
+ const mode = mapping.blendMode || 'replace';
1174
+ const existing = accumulator.get(mapping.target);
1175
+
1176
+ if (!existing) {
1177
+ accumulator.set(mapping.target, { value, mode });
1178
+ } else {
1179
+ // Combine multiple mappings targeting the same parameter
1180
+ switch (mode) {
1181
+ case 'add':
1182
+ existing.value += value;
1183
+ break;
1184
+ case 'multiply':
1185
+ existing.value *= value;
1186
+ break;
1187
+ case 'max':
1188
+ existing.value = Math.max(existing.value, value);
1189
+ break;
1190
+ case 'min':
1191
+ existing.value = Math.min(existing.value, value);
1192
+ break;
1193
+ case 'replace':
1194
+ default:
1195
+ existing.value = value;
1196
+ break;
1197
+ }
1198
+ }
1199
+ }
1200
+
1201
+ // Invoke callback for each accumulated target
1202
+ for (const [param, { value }] of accumulator) {
1203
+ try {
1204
+ this.parameterUpdateFn(param, value);
1205
+ } catch (err) {
1206
+ console.error(`SpatialInputSystem: Error updating parameter "${param}"`, err);
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ // ------------------------------------------------------------------
1212
+ // Input source listeners
1213
+ // ------------------------------------------------------------------
1214
+
1215
+ /**
1216
+ * Attach native event listeners for a source.
1217
+ * @private
1218
+ * @param {SourceEntry} entry
1219
+ */
1220
+ _attachSourceListeners(entry) {
1221
+ if (typeof window === 'undefined') return;
1222
+
1223
+ switch (entry.type) {
1224
+ case SOURCE_TYPES.DEVICE_TILT:
1225
+ this._attachDeviceTilt(entry);
1226
+ break;
1227
+ case SOURCE_TYPES.MOUSE_POSITION:
1228
+ this._attachMousePosition(entry);
1229
+ break;
1230
+ case SOURCE_TYPES.GYROSCOPE:
1231
+ this._attachGyroscope(entry);
1232
+ break;
1233
+ case SOURCE_TYPES.GAMEPAD:
1234
+ // Gamepad is polled, not event-driven; mark for polling
1235
+ entry._cleanup = null;
1236
+ break;
1237
+ case SOURCE_TYPES.PERSPECTIVE:
1238
+ case SOURCE_TYPES.PROGRAMMATIC:
1239
+ case SOURCE_TYPES.AUDIO:
1240
+ case SOURCE_TYPES.MIDI:
1241
+ // These are feed-based; no automatic listeners
1242
+ entry._cleanup = null;
1243
+ break;
1244
+ }
1245
+ }
1246
+
1247
+ /**
1248
+ * Detach native event listeners for a source.
1249
+ * @private
1250
+ * @param {SourceEntry} entry
1251
+ */
1252
+ _detachSourceListeners(entry) {
1253
+ if (typeof entry._cleanup === 'function') {
1254
+ entry._cleanup();
1255
+ entry._cleanup = null;
1256
+ }
1257
+ }
1258
+
1259
+ /**
1260
+ * Attach DeviceOrientationEvent listener.
1261
+ * @private
1262
+ * @param {SourceEntry} entry
1263
+ */
1264
+ _attachDeviceTilt(entry) {
1265
+ if (typeof window === 'undefined') return;
1266
+
1267
+ const handler = (event) => {
1268
+ if (!entry.active) return;
1269
+ this._processDeviceTilt(event);
1270
+ };
1271
+
1272
+ // Request permission on iOS 13+
1273
+ if (typeof DeviceOrientationEvent !== 'undefined' &&
1274
+ typeof DeviceOrientationEvent.requestPermission === 'function') {
1275
+ DeviceOrientationEvent.requestPermission()
1276
+ .then((permission) => {
1277
+ if (permission === 'granted') {
1278
+ window.addEventListener('deviceorientation', handler);
1279
+ } else {
1280
+ console.warn('SpatialInputSystem: Device orientation permission denied');
1281
+ }
1282
+ })
1283
+ .catch((err) => {
1284
+ console.warn('SpatialInputSystem: Device orientation permission error', err);
1285
+ });
1286
+ } else {
1287
+ window.addEventListener('deviceorientation', handler);
1288
+ }
1289
+
1290
+ entry._cleanup = () => {
1291
+ window.removeEventListener('deviceorientation', handler);
1292
+ };
1293
+ }
1294
+
1295
+ /**
1296
+ * Attach mousemove listener.
1297
+ * @private
1298
+ * @param {SourceEntry} entry
1299
+ */
1300
+ _attachMousePosition(entry) {
1301
+ if (typeof window === 'undefined') return;
1302
+
1303
+ const element = entry.config.element || window;
1304
+ const handler = (event) => {
1305
+ if (!entry.active) return;
1306
+ this._processMousePosition(
1307
+ event.clientX,
1308
+ event.clientY,
1309
+ window.innerWidth,
1310
+ window.innerHeight
1311
+ );
1312
+ };
1313
+
1314
+ element.addEventListener('mousemove', handler);
1315
+ entry._cleanup = () => {
1316
+ element.removeEventListener('mousemove', handler);
1317
+ };
1318
+ }
1319
+
1320
+ /**
1321
+ * Attach GyroscopeSensor API (Generic Sensor API).
1322
+ * @private
1323
+ * @param {SourceEntry} entry
1324
+ */
1325
+ _attachGyroscope(entry) {
1326
+ if (typeof window === 'undefined' || typeof window.Gyroscope === 'undefined') {
1327
+ console.warn('SpatialInputSystem: Gyroscope API not available');
1328
+ return;
1329
+ }
1330
+
1331
+ try {
1332
+ const frequency = entry.config.frequency || 60;
1333
+ const sensor = new window.Gyroscope({ frequency });
1334
+
1335
+ sensor.addEventListener('reading', () => {
1336
+ if (!entry.active) return;
1337
+ this._processGyroscope({
1338
+ x: sensor.x,
1339
+ y: sensor.y,
1340
+ z: sensor.z
1341
+ });
1342
+ });
1343
+
1344
+ sensor.addEventListener('error', (event) => {
1345
+ console.warn('SpatialInputSystem: Gyroscope error', event.error);
1346
+ });
1347
+
1348
+ sensor.start();
1349
+ this._gyroscopeSensor = sensor;
1350
+
1351
+ entry._cleanup = () => {
1352
+ sensor.stop();
1353
+ this._gyroscopeSensor = null;
1354
+ };
1355
+ } catch (err) {
1356
+ console.warn('SpatialInputSystem: Failed to initialise Gyroscope', err);
1357
+ }
1358
+ }
1359
+
1360
+ /**
1361
+ * Poll-based sources (called every frame).
1362
+ * @private
1363
+ */
1364
+ _pollSources() {
1365
+ for (const entry of this.sources.values()) {
1366
+ if (!entry.active) continue;
1367
+
1368
+ switch (entry.type) {
1369
+ case SOURCE_TYPES.GAMEPAD:
1370
+ this._pollGamepad(entry);
1371
+ break;
1372
+ // Other poll-based sources can be added here
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ /**
1378
+ * Poll the Gamepad API for analog stick data.
1379
+ * @private
1380
+ * @param {SourceEntry} entry
1381
+ */
1382
+ _pollGamepad(entry) {
1383
+ if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function') return;
1384
+
1385
+ const gamepads = navigator.getGamepads();
1386
+ const index = entry.config.index || 0;
1387
+ const gamepad = gamepads[index];
1388
+
1389
+ if (!gamepad || !gamepad.connected) return;
1390
+
1391
+ this._processGamepad(gamepad, entry.config);
1392
+ }
1393
+
1394
+ // ------------------------------------------------------------------
1395
+ // Input processors
1396
+ // ------------------------------------------------------------------
1397
+
1398
+ /**
1399
+ * Process DeviceOrientationEvent data.
1400
+ *
1401
+ * Maps:
1402
+ * beta (-180..180) --> pitch (-1..1)
1403
+ * gamma (-90..90) --> roll (-1..1)
1404
+ * alpha (0..360) --> yaw (-1..1)
1405
+ *
1406
+ * @private
1407
+ * @param {DeviceOrientationEvent|Object} event
1408
+ */
1409
+ _processDeviceTilt(event) {
1410
+ const alpha = Number(event.alpha) || 0; // 0..360
1411
+ const beta = Number(event.beta) || 0; // -180..180
1412
+ const gamma = Number(event.gamma) || 0; // -90..90
1413
+
1414
+ // Normalise to -1..1
1415
+ this.spatialState.pitch = clamp(beta / 90, -1, 1);
1416
+ this.spatialState.roll = clamp(gamma / 45, -1, 1);
1417
+ this.spatialState.yaw = clamp((alpha - 180) / 180, -1, 1);
1418
+ }
1419
+
1420
+ /**
1421
+ * Process mouse position as spatial orientation.
1422
+ *
1423
+ * Maps screen-relative position to pitch (vertical) and roll (horizontal).
1424
+ * Centre of screen = neutral (0, 0).
1425
+ *
1426
+ * @private
1427
+ * @param {number} x - Mouse X pixel coordinate
1428
+ * @param {number} y - Mouse Y pixel coordinate
1429
+ * @param {number} [width] - Viewport width
1430
+ * @param {number} [height] - Viewport height
1431
+ */
1432
+ _processMousePosition(x, y, width, height) {
1433
+ const w = Number(width) || (typeof window !== 'undefined' ? window.innerWidth : 1920);
1434
+ const h = Number(height) || (typeof window !== 'undefined' ? window.innerHeight : 1080);
1435
+
1436
+ if (w === 0 || h === 0) return;
1437
+
1438
+ // Normalise to -1..1 (centre = 0)
1439
+ const normX = clamp(((x / w) - 0.5) * 2, -1, 1);
1440
+ const normY = clamp(((y / h) - 0.5) * 2, -1, 1);
1441
+
1442
+ this.spatialState.roll = normX;
1443
+ this.spatialState.pitch = -normY; // Inverted: mouse up = positive pitch
1444
+ // Yaw stays unchanged by mouse (no horizontal rotation equivalent)
1445
+ }
1446
+
1447
+ /**
1448
+ * Process GyroscopeSensor reading.
1449
+ *
1450
+ * Maps angular velocity (rad/s) to spatial axes.
1451
+ * Integrates velocity over time to produce position-like values
1452
+ * with natural decay.
1453
+ *
1454
+ * @private
1455
+ * @param {Object} event
1456
+ * @param {number} event.x - Angular velocity around X axis (rad/s)
1457
+ * @param {number} event.y - Angular velocity around Y axis (rad/s)
1458
+ * @param {number} event.z - Angular velocity around Z axis (rad/s)
1459
+ */
1460
+ _processGyroscope(event) {
1461
+ const gx = Number(event.x) || 0;
1462
+ const gy = Number(event.y) || 0;
1463
+ const gz = Number(event.z) || 0;
1464
+
1465
+ // Scale factor to normalise typical angular velocity range (-10..10 rad/s) to -1..1
1466
+ const scale = 0.1;
1467
+ // Decay factor for integration (prevents drift)
1468
+ const decay = 0.95;
1469
+
1470
+ this.spatialState.pitch = clamp(this.spatialState.pitch * decay + gx * scale, -1, 1);
1471
+ this.spatialState.roll = clamp(this.spatialState.roll * decay + gy * scale, -1, 1);
1472
+ this.spatialState.yaw = clamp(this.spatialState.yaw * decay + gz * scale, -1, 1);
1473
+ }
1474
+
1475
+ /**
1476
+ * Process Gamepad API data.
1477
+ *
1478
+ * Standard mapping:
1479
+ * Left stick X --> roll
1480
+ * Left stick Y --> pitch
1481
+ * Right stick X --> yaw
1482
+ * Right stick Y --> z (depth)
1483
+ * Left trigger --> negative x
1484
+ * Right trigger --> positive x
1485
+ *
1486
+ * @private
1487
+ * @param {Gamepad|Object} gamepad
1488
+ * @param {Object} [config={}]
1489
+ * @param {number} [config.deadzone=0.1] - Analog stick deadzone
1490
+ */
1491
+ _processGamepad(gamepad, config = {}) {
1492
+ const deadzone = Number(config.deadzone) || 0.1;
1493
+ const axes = gamepad.axes || [];
1494
+ const buttons = gamepad.buttons || [];
1495
+
1496
+ /**
1497
+ * Apply deadzone to a value.
1498
+ * @param {number} v
1499
+ * @returns {number}
1500
+ */
1501
+ const applyDeadzone = (v) => {
1502
+ return Math.abs(v) < deadzone ? 0 : v;
1503
+ };
1504
+
1505
+ // Left stick
1506
+ if (axes.length >= 2) {
1507
+ this.spatialState.roll = clamp(applyDeadzone(axes[0]), -1, 1);
1508
+ this.spatialState.pitch = clamp(applyDeadzone(-axes[1]), -1, 1); // Invert Y
1509
+ }
1510
+
1511
+ // Right stick
1512
+ if (axes.length >= 4) {
1513
+ this.spatialState.yaw = clamp(applyDeadzone(axes[2]), -1, 1);
1514
+ this.spatialState.z = clamp(applyDeadzone(-axes[3]), -1, 1);
1515
+ }
1516
+
1517
+ // Triggers (L2 = button 6, R2 = button 7 in standard mapping)
1518
+ if (buttons.length >= 8) {
1519
+ const lt = typeof buttons[6] === 'object' ? buttons[6].value : Number(buttons[6]) || 0;
1520
+ const rt = typeof buttons[7] === 'object' ? buttons[7].value : Number(buttons[7]) || 0;
1521
+ this.spatialState.x = clamp(rt - lt, -1, 1);
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * Process AR/wearable perspective data.
1527
+ *
1528
+ * Expects pre-normalised orientation and position data from a
1529
+ * perspective tracking system (WebXR, custom AR SDK, etc.).
1530
+ *
1531
+ * @private
1532
+ * @param {Object} data
1533
+ * @param {number} [data.pitch] - Viewing pitch (-1..1)
1534
+ * @param {number} [data.yaw] - Viewing yaw (-1..1)
1535
+ * @param {number} [data.roll] - Viewing roll (-1..1)
1536
+ * @param {number} [data.x] - Position X (-1..1)
1537
+ * @param {number} [data.y] - Position Y (-1..1)
1538
+ * @param {number} [data.z] - Position Z (-1..1)
1539
+ */
1540
+ _processPerspective(data) {
1541
+ if (Number.isFinite(data.pitch)) {
1542
+ this.spatialState.pitch = clamp(data.pitch, -1, 1);
1543
+ }
1544
+ if (Number.isFinite(data.yaw)) {
1545
+ this.spatialState.yaw = clamp(data.yaw, -1, 1);
1546
+ }
1547
+ if (Number.isFinite(data.roll)) {
1548
+ this.spatialState.roll = clamp(data.roll, -1, 1);
1549
+ }
1550
+ if (Number.isFinite(data.x)) {
1551
+ this.spatialState.x = clamp(data.x, -1, 1);
1552
+ }
1553
+ if (Number.isFinite(data.y)) {
1554
+ this.spatialState.y = clamp(data.y, -1, 1);
1555
+ }
1556
+ if (Number.isFinite(data.z)) {
1557
+ this.spatialState.z = clamp(data.z, -1, 1);
1558
+ }
1559
+ }
1560
+
1561
+ /**
1562
+ * Process audio frequency band levels as spatial movement.
1563
+ *
1564
+ * Maps:
1565
+ * bass --> pitch (deep frequencies drive forward tilt)
1566
+ * mid --> yaw (mid-range drives lateral movement)
1567
+ * high --> roll (high frequencies drive shimmer/roll)
1568
+ * energy --> x (overall volume drives translation)
1569
+ *
1570
+ * Values are normalised 0..1 and mapped to -1..1 spatial range
1571
+ * using oscillation modulated by the input level.
1572
+ *
1573
+ * @private
1574
+ * @param {number} bass - Bass band level (0..1)
1575
+ * @param {number} mid - Mid band level (0..1)
1576
+ * @param {number} high - High band level (0..1)
1577
+ */
1578
+ _processAudioSpatial(bass, mid, high) {
1579
+ const b = clamp(bass, 0, 1);
1580
+ const m = clamp(mid, 0, 1);
1581
+ const h = clamp(high, 0, 1);
1582
+ const energy = (b + m + h) / 3;
1583
+
1584
+ // Use time-based oscillation modulated by audio levels.
1585
+ // This creates organic movement rather than static positioning.
1586
+ const t = typeof performance !== 'undefined' ? performance.now() * 0.001 : Date.now() * 0.001;
1587
+
1588
+ this.spatialState.pitch = clamp(Math.sin(t * 1.3) * b, -1, 1);
1589
+ this.spatialState.yaw = clamp(Math.sin(t * 0.7) * m, -1, 1);
1590
+ this.spatialState.roll = clamp(Math.sin(t * 2.1) * h, -1, 1);
1591
+ this.spatialState.x = clamp(Math.cos(t * 0.5) * energy, -1, 1);
1592
+ this.spatialState.y = clamp(energy * 2 - 1, -1, 1);
1593
+ }
1594
+
1595
+ /**
1596
+ * Process MIDI Control Change data.
1597
+ *
1598
+ * Default mapping (configurable by adding sources with config):
1599
+ * CC 1 (Mod Wheel) --> pitch
1600
+ * CC 2 (Breath) --> yaw
1601
+ * CC 11 (Expression)--> roll
1602
+ * CC 74 (Brightness)--> x
1603
+ * CC 71 (Resonance) --> y
1604
+ * CC 7 (Volume) --> z
1605
+ *
1606
+ * MIDI CC values are 0..127, normalised to -1..1.
1607
+ *
1608
+ * @private
1609
+ * @param {number} channel - MIDI channel (0..15)
1610
+ * @param {number} cc - Control Change number (0..127)
1611
+ * @param {number} value - CC value (0..127)
1612
+ */
1613
+ _processMIDI(channel, cc, value) {
1614
+ // Normalise 0..127 to -1..1
1615
+ const norm = clamp((value / 63.5) - 1.0, -1, 1);
1616
+ // Normalise 0..127 to 0..1 (for unipolar axes)
1617
+ const normUni = clamp(value / 127, 0, 1);
1618
+
1619
+ switch (cc) {
1620
+ case 1: // Mod Wheel
1621
+ this.spatialState.pitch = norm;
1622
+ break;
1623
+ case 2: // Breath Controller
1624
+ this.spatialState.yaw = norm;
1625
+ break;
1626
+ case 11: // Expression Controller
1627
+ this.spatialState.roll = norm;
1628
+ break;
1629
+ case 74: // Brightness (MPE slide)
1630
+ this.spatialState.x = norm;
1631
+ break;
1632
+ case 71: // Resonance / Timbre
1633
+ this.spatialState.y = norm;
1634
+ break;
1635
+ case 7: // Channel Volume
1636
+ this.spatialState.z = norm;
1637
+ break;
1638
+ default: {
1639
+ // For unmapped CCs, distribute across axes by CC group
1640
+ // This allows any CC to have some spatial effect
1641
+ const axisIndex = cc % 6;
1642
+ const axes = ['pitch', 'yaw', 'roll', 'x', 'y', 'z'];
1643
+ this.spatialState[axes[axisIndex]] = norm;
1644
+ break;
1645
+ }
1646
+ }
1647
+
1648
+ this._emit('midiInput', { channel, cc, value, normalized: norm });
1649
+ }
1650
+
1651
+ /**
1652
+ * Process programmatic / direct API input.
1653
+ * Accepts a partial SpatialState and merges it.
1654
+ *
1655
+ * @private
1656
+ * @param {Partial<SpatialState>} data
1657
+ */
1658
+ _processProgrammatic(data) {
1659
+ for (const axis of SPATIAL_AXES) {
1660
+ if (Number.isFinite(data[axis])) {
1661
+ if (axis === 'intensity' || axis === 'velocity') {
1662
+ this.spatialState[axis] = clamp(data[axis], 0, 1);
1663
+ } else {
1664
+ this.spatialState[axis] = clamp(data[axis], -1, 1);
1665
+ }
1666
+ }
1667
+ }
1668
+ }
1669
+
1670
+ // ------------------------------------------------------------------
1671
+ // Event system
1672
+ // ------------------------------------------------------------------
1673
+
1674
+ /**
1675
+ * Subscribe to an event.
1676
+ * @param {string} event - Event name
1677
+ * @param {Function} callback - Handler function
1678
+ */
1679
+ on(event, callback) {
1680
+ if (typeof callback !== 'function') return;
1681
+ if (!this._listeners.has(event)) {
1682
+ this._listeners.set(event, []);
1683
+ }
1684
+ this._listeners.get(event).push(callback);
1685
+ }
1686
+
1687
+ /**
1688
+ * Unsubscribe from an event.
1689
+ * @param {string} event - Event name
1690
+ * @param {Function} callback - Handler to remove
1691
+ */
1692
+ off(event, callback) {
1693
+ if (!this._listeners.has(event)) return;
1694
+ const arr = this._listeners.get(event);
1695
+ const idx = arr.indexOf(callback);
1696
+ if (idx !== -1) {
1697
+ arr.splice(idx, 1);
1698
+ }
1699
+ }
1700
+
1701
+ /**
1702
+ * Emit an event to all registered listeners.
1703
+ * @private
1704
+ * @param {string} event
1705
+ * @param {*} [data]
1706
+ */
1707
+ _emit(event, data) {
1708
+ if (!this._listeners.has(event)) return;
1709
+ for (const fn of this._listeners.get(event)) {
1710
+ try {
1711
+ fn(data);
1712
+ } catch (err) {
1713
+ console.error(`SpatialInputSystem: Event handler error for "${event}"`, err);
1714
+ }
1715
+ }
1716
+ }
1717
+
1718
+ // ------------------------------------------------------------------
1719
+ // Cleanup
1720
+ // ------------------------------------------------------------------
1721
+
1722
+ /**
1723
+ * Completely destroy the system, removing all listeners, sources,
1724
+ * and global references.
1725
+ */
1726
+ destroy() {
1727
+ this.disable();
1728
+
1729
+ // Remove all sources (which cleans up their listeners)
1730
+ for (const name of Array.from(this.sources.keys())) {
1731
+ this.removeSource(name);
1732
+ }
1733
+
1734
+ this.mappings.clear();
1735
+ this._listeners.clear();
1736
+ this._customTargets.clear();
1737
+ this.smoothing.clear();
1738
+
1739
+ // Clean up global references
1740
+ if (typeof window !== 'undefined') {
1741
+ if (window.spatialInputSystem === this) {
1742
+ delete window.spatialInputSystem;
1743
+ }
1744
+ delete window.enableSpatialInput;
1745
+ delete window.disableSpatialInput;
1746
+ delete window.setSpatialProfile;
1747
+ delete window.feedSpatialInput;
1748
+ delete window.getSpatialState;
1749
+ }
1750
+
1751
+ console.log('SpatialInputSystem: Destroyed');
1752
+ }
1753
+ }
1754
+
1755
+ // ---------------------------------------------------------------------------
1756
+ // Default instance setup
1757
+ // ---------------------------------------------------------------------------
1758
+
1759
+ /**
1760
+ * Create and return a default SpatialInputSystem instance.
1761
+ *
1762
+ * The instance is created with default settings and registers global helpers
1763
+ * on the window object. The caller should then add sources, load a profile,
1764
+ * and call `enable()`.
1765
+ *
1766
+ * @param {SpatialInputOptions} [options={}]
1767
+ * @returns {SpatialInputSystem}
1768
+ *
1769
+ * @example
1770
+ * import { createSpatialInputSystem } from './SpatialInputSystem.js';
1771
+ *
1772
+ * const spatial = createSpatialInputSystem({
1773
+ * onParameterUpdate: (name, value) => engine.setParameter(name, value)
1774
+ * });
1775
+ * spatial.addSource('mouse', 'mousePosition');
1776
+ * spatial.loadProfile('uiElement');
1777
+ * spatial.enable();
1778
+ */
1779
+ export function createSpatialInputSystem(options = {}) {
1780
+ return new SpatialInputSystem(options);
1781
+ }
1782
+
1783
+ export default SpatialInputSystem;