@codexo/exojs 0.6.12 → 0.7.11

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 (186) hide show
  1. package/CHANGELOG.md +1316 -0
  2. package/dist/esm/audio/AbstractMedia.d.ts +18 -0
  3. package/dist/esm/audio/AbstractMedia.js +66 -0
  4. package/dist/esm/audio/AbstractMedia.js.map +1 -1
  5. package/dist/esm/audio/AudioAnalyser.d.ts +62 -23
  6. package/dist/esm/audio/AudioAnalyser.js +261 -57
  7. package/dist/esm/audio/AudioAnalyser.js.map +1 -1
  8. package/dist/esm/audio/AudioBus.d.ts +45 -0
  9. package/dist/esm/audio/AudioBus.js +219 -0
  10. package/dist/esm/audio/AudioBus.js.map +1 -0
  11. package/dist/esm/audio/AudioFilter.d.ts +9 -0
  12. package/dist/esm/audio/AudioFilter.js +7 -0
  13. package/dist/esm/audio/AudioFilter.js.map +1 -0
  14. package/dist/esm/audio/AudioListener.d.ts +20 -0
  15. package/dist/esm/audio/AudioListener.js +86 -0
  16. package/dist/esm/audio/AudioListener.js.map +1 -0
  17. package/dist/esm/audio/AudioManager.d.ts +31 -0
  18. package/dist/esm/audio/AudioManager.js +102 -0
  19. package/dist/esm/audio/AudioManager.js.map +1 -0
  20. package/dist/esm/audio/BeatDetector.d.ts +121 -0
  21. package/dist/esm/audio/BeatDetector.js +936 -0
  22. package/dist/esm/audio/BeatDetector.js.map +1 -0
  23. package/dist/esm/audio/Envelope.d.ts +44 -0
  24. package/dist/esm/audio/Envelope.js +60 -0
  25. package/dist/esm/audio/Envelope.js.map +1 -0
  26. package/dist/esm/audio/Music.d.ts +8 -0
  27. package/dist/esm/audio/Music.js +33 -4
  28. package/dist/esm/audio/Music.js.map +1 -1
  29. package/dist/esm/audio/OscillatorSound.d.ts +98 -0
  30. package/dist/esm/audio/OscillatorSound.js +342 -0
  31. package/dist/esm/audio/OscillatorSound.js.map +1 -0
  32. package/dist/esm/audio/Sound.d.ts +94 -9
  33. package/dist/esm/audio/Sound.js +283 -117
  34. package/dist/esm/audio/Sound.js.map +1 -1
  35. package/dist/esm/audio/crossFade.d.ts +19 -0
  36. package/dist/esm/audio/crossFade.js +26 -0
  37. package/dist/esm/audio/crossFade.js.map +1 -0
  38. package/dist/esm/audio/dsp/fft.d.ts +22 -0
  39. package/dist/esm/audio/dsp/mel.d.ts +43 -0
  40. package/dist/esm/audio/dsp/tempogram.d.ts +51 -0
  41. package/dist/esm/audio/filters/ChorusFilter.d.ts +47 -0
  42. package/dist/esm/audio/filters/ChorusFilter.js +139 -0
  43. package/dist/esm/audio/filters/ChorusFilter.js.map +1 -0
  44. package/dist/esm/audio/filters/CompressorFilter.d.ts +31 -0
  45. package/dist/esm/audio/filters/CompressorFilter.js +97 -0
  46. package/dist/esm/audio/filters/CompressorFilter.js.map +1 -0
  47. package/dist/esm/audio/filters/DelayFilter.d.ts +23 -0
  48. package/dist/esm/audio/filters/DelayFilter.js +100 -0
  49. package/dist/esm/audio/filters/DelayFilter.js.map +1 -0
  50. package/dist/esm/audio/filters/DuckingFilter.d.ts +31 -0
  51. package/dist/esm/audio/filters/DuckingFilter.js +152 -0
  52. package/dist/esm/audio/filters/DuckingFilter.js.map +1 -0
  53. package/dist/esm/audio/filters/EqualizerFilter.d.ts +29 -0
  54. package/dist/esm/audio/filters/EqualizerFilter.js +94 -0
  55. package/dist/esm/audio/filters/EqualizerFilter.js.map +1 -0
  56. package/dist/esm/audio/filters/GranularFilter.d.ts +56 -0
  57. package/dist/esm/audio/filters/GranularFilter.js +170 -0
  58. package/dist/esm/audio/filters/GranularFilter.js.map +1 -0
  59. package/dist/esm/audio/filters/HighpassFilter.d.ts +19 -0
  60. package/dist/esm/audio/filters/HighpassFilter.js +62 -0
  61. package/dist/esm/audio/filters/HighpassFilter.js.map +1 -0
  62. package/dist/esm/audio/filters/LowpassFilter.d.ts +19 -0
  63. package/dist/esm/audio/filters/LowpassFilter.js +62 -0
  64. package/dist/esm/audio/filters/LowpassFilter.js.map +1 -0
  65. package/dist/esm/audio/filters/PitchShiftFilter.d.ts +42 -0
  66. package/dist/esm/audio/filters/PitchShiftFilter.js +130 -0
  67. package/dist/esm/audio/filters/PitchShiftFilter.js.map +1 -0
  68. package/dist/esm/audio/filters/ReverbFilter.d.ts +24 -0
  69. package/dist/esm/audio/filters/ReverbFilter.js +107 -0
  70. package/dist/esm/audio/filters/ReverbFilter.js.map +1 -0
  71. package/dist/esm/audio/filters/VocoderFilter.d.ts +38 -0
  72. package/dist/esm/audio/filters/VocoderFilter.js +163 -0
  73. package/dist/esm/audio/filters/VocoderFilter.js.map +1 -0
  74. package/dist/esm/audio/filters/WorkletFilter.d.ts +46 -0
  75. package/dist/esm/audio/filters/WorkletFilter.js +101 -0
  76. package/dist/esm/audio/filters/WorkletFilter.js.map +1 -0
  77. package/dist/esm/audio/filters/index.d.ts +12 -0
  78. package/dist/esm/audio/index.d.ts +15 -1
  79. package/dist/esm/audio/worklet/registerWorklet.d.ts +10 -0
  80. package/dist/esm/audio/worklet/registerWorklet.js +44 -0
  81. package/dist/esm/audio/worklet/registerWorklet.js.map +1 -0
  82. package/dist/esm/core/Application.d.ts +19 -0
  83. package/dist/esm/core/Application.js +76 -2
  84. package/dist/esm/core/Application.js.map +1 -1
  85. package/dist/esm/core/SceneNode.d.ts +9 -1
  86. package/dist/esm/core/SceneNode.js +44 -6
  87. package/dist/esm/core/SceneNode.js.map +1 -1
  88. package/dist/esm/core/Time.js +1 -1
  89. package/dist/esm/core/index.d.ts +0 -1
  90. package/dist/esm/debug/BoundingBoxesLayer.d.ts +18 -0
  91. package/dist/esm/debug/BoundingBoxesLayer.js +128 -0
  92. package/dist/esm/debug/BoundingBoxesLayer.js.map +1 -0
  93. package/dist/esm/debug/DebugLayer.d.ts +29 -0
  94. package/dist/esm/debug/DebugLayer.js +26 -0
  95. package/dist/esm/debug/DebugLayer.js.map +1 -0
  96. package/dist/esm/debug/DebugOverlay.d.ts +48 -0
  97. package/dist/esm/debug/DebugOverlay.js +117 -0
  98. package/dist/esm/debug/DebugOverlay.js.map +1 -0
  99. package/dist/esm/debug/HitTestLayer.d.ts +23 -0
  100. package/dist/esm/debug/HitTestLayer.js +109 -0
  101. package/dist/esm/debug/HitTestLayer.js.map +1 -0
  102. package/dist/esm/debug/PerformanceLayer.d.ts +21 -0
  103. package/dist/esm/debug/PerformanceLayer.js +175 -0
  104. package/dist/esm/debug/PerformanceLayer.js.map +1 -0
  105. package/dist/esm/debug/PointerStackLayer.d.ts +23 -0
  106. package/dist/esm/debug/PointerStackLayer.js +152 -0
  107. package/dist/esm/debug/PointerStackLayer.js.map +1 -0
  108. package/dist/esm/debug/index.d.ts +6 -0
  109. package/dist/esm/debug/index.js +7 -0
  110. package/dist/esm/debug/index.js.map +1 -0
  111. package/dist/esm/index.js +28 -2
  112. package/dist/esm/index.js.map +1 -1
  113. package/dist/esm/input/InputManager.d.ts +10 -0
  114. package/dist/esm/input/InputManager.js +35 -5
  115. package/dist/esm/input/InputManager.js.map +1 -1
  116. package/dist/esm/input/InteractionEvent.d.ts +18 -0
  117. package/dist/esm/input/InteractionEvent.js +29 -0
  118. package/dist/esm/input/InteractionEvent.js.map +1 -0
  119. package/dist/esm/input/InteractionManager.d.ts +134 -0
  120. package/dist/esm/input/InteractionManager.js +546 -0
  121. package/dist/esm/input/InteractionManager.js.map +1 -0
  122. package/dist/esm/input/index.d.ts +2 -0
  123. package/dist/esm/input/interaction-hooks.d.ts +34 -0
  124. package/dist/esm/input/interaction-hooks.js +35 -0
  125. package/dist/esm/input/interaction-hooks.js.map +1 -0
  126. package/dist/esm/math/Circle.d.ts +12 -2
  127. package/dist/esm/math/Circle.js +82 -14
  128. package/dist/esm/math/Circle.js.map +1 -1
  129. package/dist/esm/math/Interval.js +1 -1
  130. package/dist/esm/math/ObservableVector.d.ts +2 -2
  131. package/dist/esm/math/ObservableVector.js +4 -2
  132. package/dist/esm/math/ObservableVector.js.map +1 -1
  133. package/dist/esm/math/Polygon.d.ts +15 -1
  134. package/dist/esm/math/Polygon.js +58 -6
  135. package/dist/esm/math/Polygon.js.map +1 -1
  136. package/dist/esm/math/Quadtree.d.ts +47 -0
  137. package/dist/esm/math/Quadtree.js +168 -0
  138. package/dist/esm/math/Quadtree.js.map +1 -0
  139. package/dist/esm/math/Random.js +1 -1
  140. package/dist/esm/math/Size.js +1 -1
  141. package/dist/esm/math/Vector.js +1 -1
  142. package/dist/esm/math/collision-detection.js +4 -1
  143. package/dist/esm/math/collision-detection.js.map +1 -1
  144. package/dist/esm/math/index.d.ts +1 -0
  145. package/dist/esm/particles/ParticleSystem.js +1 -0
  146. package/dist/esm/particles/ParticleSystem.js.map +1 -1
  147. package/dist/esm/particles/affectors/TorqueAffector.js +1 -1
  148. package/dist/esm/rendering/Container.d.ts +1 -0
  149. package/dist/esm/rendering/Container.js +19 -0
  150. package/dist/esm/rendering/Container.js.map +1 -1
  151. package/dist/esm/rendering/RenderNode.d.ts +27 -0
  152. package/dist/esm/rendering/RenderNode.js +44 -0
  153. package/dist/esm/rendering/RenderNode.js.map +1 -1
  154. package/dist/esm/rendering/View.d.ts +6 -4
  155. package/dist/esm/rendering/View.js +12 -2
  156. package/dist/esm/rendering/View.js.map +1 -1
  157. package/dist/esm/rendering/filters/WebGl2ShaderFilter.d.ts +109 -0
  158. package/dist/esm/rendering/filters/WebGl2ShaderFilter.js +268 -0
  159. package/dist/esm/rendering/filters/WebGl2ShaderFilter.js.map +1 -0
  160. package/dist/esm/rendering/filters/WebGpuShaderFilter.d.ts +111 -0
  161. package/dist/esm/rendering/filters/WebGpuShaderFilter.js +397 -0
  162. package/dist/esm/rendering/filters/WebGpuShaderFilter.js.map +1 -0
  163. package/dist/esm/rendering/index.d.ts +3 -0
  164. package/dist/esm/rendering/mesh/Mesh.js +1 -0
  165. package/dist/esm/rendering/mesh/Mesh.js.map +1 -1
  166. package/dist/esm/rendering/shader/upgradeFragmentShaderToGl300.d.ts +34 -0
  167. package/dist/esm/rendering/shader/upgradeFragmentShaderToGl300.js +60 -0
  168. package/dist/esm/rendering/shader/upgradeFragmentShaderToGl300.js.map +1 -0
  169. package/dist/esm/rendering/sprite/Sprite.d.ts +6 -1
  170. package/dist/esm/rendering/sprite/Sprite.js +41 -19
  171. package/dist/esm/rendering/sprite/Sprite.js.map +1 -1
  172. package/dist/esm/rendering/video/Video.d.ts +4 -0
  173. package/dist/esm/rendering/video/Video.js +32 -4
  174. package/dist/esm/rendering/video/Video.js.map +1 -1
  175. package/dist/esm/rendering/webgl2/WebGl2Backend.d.ts +4 -4
  176. package/dist/esm/rendering/webgl2/WebGl2Backend.js +7 -16
  177. package/dist/esm/rendering/webgl2/WebGl2Backend.js.map +1 -1
  178. package/dist/esm/rendering/webgpu/WebGpuBackend.d.ts +10 -8
  179. package/dist/esm/rendering/webgpu/WebGpuBackend.js +30 -40
  180. package/dist/esm/rendering/webgpu/WebGpuBackend.js.map +1 -1
  181. package/dist/exo.esm.js +7764 -2453
  182. package/dist/exo.esm.js.map +1 -1
  183. package/package.json +14 -2
  184. package/dist/esm/core/Quadtree.d.ts +0 -20
  185. package/dist/esm/core/Quadtree.js +0 -86
  186. package/dist/esm/core/Quadtree.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,1322 @@ All notable changes to ExoJS are documented in this file.
4
4
 
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.7.11] - 2026-05-07
8
+
9
+ Performance pass — adds a multi-domain benchmark suite, an auto-profiler
10
+ that finds Top-3-Wins from baseline data, and three measured optimizations
11
+ those benchmarks identified. Includes a breaking change to
12
+ `InteractionManager` (the `useSpatialIndex` flag is removed; spatial
13
+ indexing is now automatic and persistent).
14
+
15
+ ### Added — Performance infrastructure
16
+
17
+ - **`test/perf/` benchmark suite** covering five domains: rendering,
18
+ audio, collision, scene-graph, interaction. Each domain has its own
19
+ script (`npm run perf:bench:rendering`, `:audio`, `:collision`,
20
+ `:scene-graph`, `:interaction`) plus `:all` aggregator. Output: JSON
21
+ + Markdown to `test/perf/results/`.
22
+ - **Baseline snapshot** committed as `test/perf/results/baseline.md` —
23
+ reference numbers at 0.7.10 for future regression detection.
24
+ - **Auto-profiler** (`npm run perf:profile`, `:gc` variant with
25
+ `--expose-gc`) that re-runs the hottest scenarios with granular
26
+ sub-timings, heap-delta tracking, and call counters. Writes
27
+ `test/perf/results/findings.md` with auto-derived Top-3 Wins
28
+ recommendations.
29
+ - Profile helpers (`SubTimingTracker`, `CallCounter`, `MemoryTracker`)
30
+ in `test/perf/profile-runner.ts` for future ad-hoc profiling.
31
+
32
+ ### Performance — Win 1: `Polygon.getNormals()` cached
33
+
34
+ Mirrors the 0.6.19 dirty-flag pattern from `Sprite.getNormals()` and the
35
+ 0.7.8 work on `Circle.getNormals()`. `Polygon.getNormals()` now caches
36
+ the result and recomputes only when shape mutates. Returns the same
37
+ array reference on subsequent calls. Eliminates per-call allocation of
38
+ N `Vector` instances during SAT collision — significant for collision-
39
+ heavy scenes. Cache invalidated on `setPoints`, `setPosition`, `set`,
40
+ `copy`, and the `x` / `y` / `position` setters.
41
+
42
+ The legacy `normals` getter is now `@deprecated` — call `getNormals()`
43
+ directly. Behavior is identical; the getter just delegates.
44
+
45
+ ### Performance — Win 2: `Quadtree.queryPoint()` documented buffer reuse
46
+
47
+ The `results?: Array<QuadtreeItem<T>>` parameter has been there since
48
+ 0.6.16 but was undocumented. JSDoc now explicitly documents the
49
+ buffer-reuse pattern for zero-allocation hot-path queries. Added a
50
+ `Quadtree.remove(item)` method (needed internally by Win 3); also
51
+ publicly available for users who want to maintain quadtrees externally.
52
+
53
+ ### Performance — Win 3: Persistent Spatial-Index (BREAKING)
54
+
55
+ `InteractionManager`'s spatial index now lives across frames and is
56
+ incrementally maintained — replaces the per-frame full rebuild. This
57
+ also makes the `useSpatialIndex` opt-in flag unnecessary and **the
58
+ flag has been removed entirely**.
59
+
60
+ **How it works now:**
61
+ - A persistent quadtree is created lazily when the first interactive
62
+ node enters the scene.
63
+ - `Container.addChild` / `removeChild` walk subtrees and add/remove
64
+ interactive descendants from the index.
65
+ - `RenderNode.interactive = true/false` toggles registration.
66
+ - Transform mutations on interactive nodes (position / rotation / scale)
67
+ mark the node as "stale" via `_invalidateBoundsCascade`.
68
+ - Stale entries are lazy-updated at the start of `InteractionManager.update()`
69
+ on the next frame, before queries are dispatched.
70
+ - When the last interactive node is removed, the quadtree is disposed
71
+ and lifecycle returns to zero overhead.
72
+
73
+ **Practical effect:** scenes with many interactive nodes get the same
74
+ ~5× faster hit-testing the old `useSpatialIndex = true` provided, but
75
+ without the per-frame rebuild cost. Mostly-static scenes (the common
76
+ case) see particularly large wins — incremental updates only fire on
77
+ actually-moved nodes.
78
+
79
+ ### Changed (BREAKING)
80
+
81
+ - **`InteractionManager.useSpatialIndex` removed.** Spatial indexing is
82
+ now automatic. Code that explicitly set the flag (`= true` or
83
+ `= false`) gets a TypeScript error; the value should simply be
84
+ removed. Old `useSpatialIndex = true` users get the same speedup
85
+ automatically. Old `useSpatialIndex = false` users get a faster hit
86
+ path with negligible mutation overhead.
87
+ - **`RenderNode.interactive` is now a getter/setter** (was a public
88
+ field). External behavior is identical for normal usage
89
+ (`node.interactive = true`). Any code that relied on the field's
90
+ shape (descriptor inspection, etc.) needs to adapt. Reading the value
91
+ is a getter call — same observable behavior.
92
+ - The `HitTestLayer` debug overlay no longer requires
93
+ `useSpatialIndex = true` to draw quadtree quadrants; it draws them
94
+ whenever the persistent quadtree is non-null (i.e., whenever any
95
+ interactive node exists in the active scene).
96
+
97
+ ### Migration
98
+
99
+ ```ts
100
+ // Before:
101
+ app.interaction.useSpatialIndex = true; // flag opt-in
102
+
103
+ // After:
104
+ // Nothing — index is automatic. Just have at least one interactive
105
+ // node in the scene and queries use the persistent quadtree.
106
+ ```
107
+
108
+ ### Notes
109
+
110
+ - This release adds 30 net new tests (Polygon-cache + persistent-index
111
+ lifecycle), removes a few `useSpatialIndex`-flag assertion tests, and
112
+ modifies `interaction.test.ts` `TestSprite` to expose `getBounds()`
113
+ for the persistent index. Test count: 1196 → 1212.
114
+ - The benchmark suite and auto-profiler are dev infrastructure — they
115
+ live in `test/perf/` and are not shipped via npm (the `files` array
116
+ in package.json controls what's packed).
117
+ - The findings.md committed alongside baseline.md is a snapshot of
118
+ performance characteristics at 0.7.11 baseline — re-running profiles
119
+ will overwrite locally but the committed reference remains for
120
+ diff comparisons.
121
+ - Future perf passes can use the same auto-profiler tooling to identify
122
+ the next round of Wins. CI-integrated regression detection is a
123
+ future Phase 4 if there's demand.
124
+
125
+ ## [0.7.10] - 2026-05-07
126
+
127
+ Closes the audio chapter. Adds the long-deferred fade transition helper,
128
+ a procedural tone generator, and four custom-DSP filter classes that
129
+ demonstrate the WorkletFilter foundation from 0.7.1. After this release,
130
+ ExoJS audio is feature-complete for the originally-planned scope.
131
+
132
+ ### Added
133
+
134
+ - **`crossFade(from, to, durationMs, options?): Promise<void>`** — top-
135
+ level helper that calls `from.fadeOut()` and `to.fadeIn()` in parallel,
136
+ optionally auto-playing `to` if paused. Resolves after `durationMs`
137
+ elapses. Replaces the manual `await` + dual-fade pattern documented in
138
+ 0.6.20.
139
+ - **`Envelope`** — ADSR (Attack-Decay-Sustain-Release) generator usable
140
+ on any `AudioParam`. Schedules a gain curve via `trigger()` (attack →
141
+ decay → sustain) and `release()` (sustain → 0). Independent of any
142
+ specific media class — apply to oscillators, filters, or custom
143
+ AudioParam targets.
144
+ - **`OscillatorSound`** — procedural tone generator. No AudioBuffer
145
+ needed — each `play()` synthesizes via WebAudio's `OscillatorNode`.
146
+ Configurable `frequency`, `type` (`sine` | `square` | `sawtooth` |
147
+ `triangle`), `detune` (cents), optional `Envelope`. Pool semantics
148
+ match `Sound` (default `poolSize: 8`, `SoundPoolStrategy.FirstInFirstOut`).
149
+ Static helper `OscillatorSound.midiToFrequency(midiNote)` and
150
+ `setNote(midiNote)` for music apps. Default-routes to `mixer.sound`.
151
+ - **`ChorusFilter`** — modulated-delay chorus / vibrato effect. Native
152
+ WebAudio nodes only (DelayNode + Oscillator LFO + GainNodes), no
153
+ worklet. Configurable `delayMs`, `depthMs`, `rateHz`, `wet`. Use as
154
+ an Audio bus filter:
155
+ ```ts
156
+ bus.addFilter(new ChorusFilter({ rateHz: 1.5, depthMs: 5 }));
157
+ ```
158
+ - **`PitchShiftFilter`** — granular real-time pitch shifter (WorkletFilter).
159
+ Configurable `pitch` (0.25× to 4×), `wet`, internal `grainSize`. V1
160
+ quality is good for ±1 octave; beyond that, audible granular artifacts.
161
+ Higher-quality phase-vocoder pitch shifting is V2.
162
+ - **`VocoderFilter`** — classic 16-band vocoder (WorkletFilter, 2-input).
163
+ Takes a `modulator: AudioBus` whose spectral envelope shapes the
164
+ carrier signal (the bus the filter is attached to). Configurable
165
+ `numBands`, `minHz`, `maxHz`, `bandQ`, `wet`, `envelopeSmoothing`.
166
+ Per-band biquad bandpass filters + envelope follower entirely in the
167
+ worklet for sample-accurate processing.
168
+ - **`GranularFilter`** — granular synthesis effect. Slices recent input
169
+ audio into Hann-windowed grains and replays them with randomized
170
+ offset and pitch. Configurable `grainSize`, `density`, `spread`,
171
+ `pitchMin`, `pitchMax`, `wet`. Suitable for ambient textures, glitch
172
+ effects, time-stretching, pitch clouds.
173
+
174
+ ### Notes
175
+
176
+ - `OscillatorSound` does NOT support spatial audio in V1 (no
177
+ `position` / `velocity` properties). For spatial procedural audio,
178
+ attach the OscillatorSound to a spatial `Sound` bus or wait for a
179
+ future enhancement. `Sound`'s spatial path covers AudioBuffer-based
180
+ sources.
181
+ - All four custom-DSP filters extend the `WorkletFilter` base from
182
+ 0.7.1, except `ChorusFilter` which uses native nodes (sufficient for
183
+ modulated-delay topology).
184
+ - The audio chapter as originally scoped is now closed:
185
+ - 0.7.0 — AudioMixer + Buses + Filters + Spatial + Pool
186
+ - 0.7.1 — AudioWorklet foundation + DuckingFilter migration
187
+ - 0.7.2 — BeatDetector (Stage 1+2) + AudioAnalyser rewrite
188
+ - 0.7.7 — 3/4 time-signature detection + AudioListener bugfix
189
+ - 0.7.10 — crossFade + OscillatorSound + Envelope + 4 custom-DSP
190
+ filters (Chorus, PitchShift, Vocoder, Granular)
191
+ - Items deferred indefinitely: HRTF binaural panning, ambisonic /
192
+ surround output, MIDI playback, voice chat, ASR/TTS, format
193
+ conversion, audio editor / waveform UI, custom-loudness
194
+ normalization. These remain out-of-scope per the original audio
195
+ modernization roadmap.
196
+
197
+ ## [0.7.9] - 2026-05-07
198
+
199
+ Fixes a GLSL compile-error in the 0.7.8 shader auto-upgrade path.
200
+
201
+ ### Fixed
202
+
203
+ - **`upgradeFragmentShaderToGl300()` now always prepends `precision highp
204
+ float;`** before the `out vec4 fragColor;` declaration. Previously, if
205
+ the user's source already contained a precision declaration anywhere
206
+ (e.g., `precision lowp float;` mid-source), the upgrader skipped its
207
+ own injection — but the user's declaration came AFTER the
208
+ `out vec4 fragColor;` line, which itself uses a float-typed variable.
209
+ GLSL ES 3.00 requires precision to be declared before any float-typed
210
+ declaration, so the compiler rejected the output with
211
+ `0:2: '' : No precision specified for (float).`
212
+
213
+ Multiple precision declarations are legal in GLSL ES 3.00 with
214
+ last-precision-wins semantics. The fix always injects `precision highp
215
+ float;` at line 2 (before `out vec4 fragColor;`); the user's own
216
+ precision declaration further down still applies to their code via
217
+ the standard last-precision-wins rule. No semantic change for
218
+ user-provided shader logic; previously-broken shaders with custom
219
+ precision declarations now compile correctly.
220
+
221
+ ## [0.7.8] - 2026-05-04
222
+
223
+ GLSL 1.00 → 3.00 auto-upgrade for `WebGl2ShaderFilter` (Shadertoy/ISF
224
+ shaders work out of the box) plus a code-hygiene pass — `Circle.getNormals()`
225
+ now caches via dirty-flag (matching 0.6.19's Sprite pattern), Rectangle-vs-
226
+ Rectangle collision response now reports correct `overlap` value, and the
227
+ `destroy()` audit cleans up TODO comments across 8 value classes (with
228
+ real cleanup logic added to `ObservableVector` and `Circle` where needed).
229
+
230
+ ### Added
231
+
232
+ - **`upgradeFragmentShaderToGl300(source)`** — exported utility function.
233
+ Upgrades GLSL ES 1.00 fragment shader source to 3.00 with documented
234
+ transformations (adds `#version 300 es`, `precision highp float`,
235
+ `out vec4 fragColor`, replaces `gl_FragColor` / `texture2D(` /
236
+ `textureCube(` / `texture2DProj(` / `varying`). Idempotent: 3.00
237
+ source returns unchanged. Edge cases not handled (`gl_FragData[N]`,
238
+ `textureLod` variants, etc.) produce GLSL compile errors that the
239
+ user must port manually.
240
+ - **`WebGl2ShaderFilterOptions.autoUpgrade: boolean`** (default `true`)
241
+ — when enabled, the constructor passes the user's `fragmentSource`
242
+ through `upgradeFragmentShaderToGl300()` before storing. Set to
243
+ `false` for strict 3.00 input (legacy code becomes a compile error
244
+ — useful for CI / linting setups that want to catch legacy shaders
245
+ as bugs). Vertex shader source is never auto-upgraded; legacy
246
+ vertex sources must be ported manually.
247
+
248
+ ### Performance
249
+
250
+ - **`Circle.getNormals()` cached via dirty flag** (matching the 0.6.19
251
+ pattern for `Sprite.getNormals()`). Returns a stable array of `Vector`
252
+ references on subsequent calls; recomputes only when radius / position
253
+ / x / y change. Reduces GC pressure in collision-detection hot paths
254
+ (especially SAT polygon-vs-circle).
255
+ - **`Circle.getCollisionVertices()` invalidation bug fixed.** The
256
+ cache existed since the initial commit but was never invalidated on
257
+ position / radius changes — moving a Circle after first collision
258
+ check returned stale vertex positions. Now invalidates correctly via
259
+ `_verticesDirty` flag.
260
+
261
+ ### Fixed
262
+
263
+ - **`getCollisionRectangleRectangle.overlap`** now returns the correct
264
+ minimum axis overlap (`min(overlapX, overlapY)`) instead of hardcoded
265
+ `0`. Required for any collision-response logic that pushes shapes
266
+ apart by their overlap distance. Other collision shapes (Circle-vs-
267
+ Circle, Circle-vs-Rectangle, polygon-via-SAT) already computed this
268
+ correctly.
269
+
270
+ ### Changed
271
+
272
+ - **`destroy()` audit complete** across 8 value classes:
273
+ - `Vector`, `Size`, `Interval`, `Random`, `Time`, `TorqueAffector`
274
+ — kept as no-op; `// todo` comments replaced with explanatory
275
+ "no-op — pure value class, kept for `Destroyable` interface
276
+ conformance" comments.
277
+ - `ObservableVector` — `destroy()` now nulls the change callback
278
+ to prevent leaks if the instance is held in external scope.
279
+ Field type widened to `(() => void) | null`; all internal call
280
+ sites already used optional-chaining, so no functional change for
281
+ live instances.
282
+ - `Circle` — `destroy()` now destroys all cached `Vector` instances
283
+ in `_collisionVertices` and `_normals` arrays (added in this
284
+ release).
285
+
286
+ ### Notes
287
+
288
+ - The autoUpgrade default is `true` so Shadertoy/ISF/legacy shaders
289
+ work without any flag. Strict-3.00 codebases can opt out per filter.
290
+ - Removed the private `Circle.getCollisionVertex` helper — its logic
291
+ was inlined into `getCollisionVertices` for the cache-reuse pattern.
292
+ Internal change, no external impact.
293
+
294
+ ## [0.7.7] - 2026-05-04
295
+
296
+ Critical bugfix in `AudioListener` and adds 3/4 time-signature detection
297
+ to `BeatDetector`.
298
+
299
+ ### Fixed
300
+
301
+ - **`AudioListener._tick()` no longer crashes in real browsers.** The
302
+ WebAudio `AudioListener` interface does not expose a `.context`
303
+ property — that's an undocumented quirk that does not exist in any
304
+ spec-compliant browser. The previous `_tick()` implementation read
305
+ `_audioListener.context.currentTime`, which crashed
306
+ deterministically on the first frame after audio-context unlock.
307
+ Tests passed because the jsdom mock incorrectly defined a `.context`
308
+ property; that has been removed from the mock as well.
309
+
310
+ **Severity**: production-critical. The bug fired in every ExoJS app
311
+ that triggered `getAudioContext()` (i.e. any app using `Sound`,
312
+ `Music`, `BeatDetector`, `AudioAnalyser`, or `Video` audio), because
313
+ `AudioMixer.update()` ticks the listener every frame regardless of
314
+ whether the user explicitly set `listener.target`.
315
+
316
+ **Fix**: `AudioListener` now stores its `AudioContext` reference in
317
+ a private `_ctx` field at setup time and reads `_ctx.currentTime`
318
+ instead. Mirrors the pattern used elsewhere in the audio stack.
319
+
320
+ ### Added
321
+
322
+ - **3/4 time-signature detection in `BeatDetector`** — the worklet
323
+ now tracks parallel posteriors over 4-beat and 3-beat bar
324
+ structures. Active time signature is selected via hysteresis:
325
+ - **EMA confidences** (smoothing α=0.1) for each candidate
326
+ - **Sustain-margin guard**: switching requires the alternate TS's
327
+ confidence to exceed the active by 1.4× for ~12-16 consecutive
328
+ beats. Bridges and breakdowns don't trigger spurious switches.
329
+ - **Settling**: first 8 beats stay 4/4 regardless of evidence
330
+ - **`BeatDetectorOptions.enableTimeSignatureDetection: boolean`**
331
+ (default `true`) — set to `false` to lock detection to 4/4.
332
+ - **`BeatDetector.timeSignature`** stops being hardcoded to
333
+ `{numerator: 4, denominator: 4}` — now reflects the active
334
+ detected TS. Public API unchanged.
335
+ - **`BeatDetector.barLength` and `barPosition`** dynamically reflect
336
+ the active TS (3 vs 4 positions). The `lookahead` array marks
337
+ downbeats based on the active bar length.
338
+
339
+ ### Notes
340
+
341
+ - 6/8, 5/4, 7/8 and other odd time signatures are not detected.
342
+ Default-fallback is 4/4 in all ambiguous cases.
343
+ - 3/4 detection works best on stable, percussive 3/4 material
344
+ (waltz-feel music). Performance on Jazz / Rubato / Free-form
345
+ remains weak — consistent with Stage 1+2 limitations from 0.7.2.
346
+ - The mock-cleanup means existing test fixtures that relied on
347
+ `audioContext.listener.context` had to be updated; the production
348
+ path no longer reads that property at all.
349
+
350
+ ## [0.7.6] - 2026-05-04
351
+
352
+ Closes the remaining WebGPU / WebGL2 backend parity gaps and cleans up
353
+ vestigial backend API. Adds device-loss / context-loss recovery signals
354
+ on both backends, unifies them under `Application.onBackendLost`, moves
355
+ `setCursor` to Application, and removes dead-code throws from WebGPU.
356
+
357
+ ### Added
358
+
359
+ - **`Application.onBackendLost: Signal<[]>`** — unified signal that
360
+ fires when either backend's GPU context is lost (WebGl2 context-lost
361
+ event or WebGpu device-lost promise). User code listens once and
362
+ doesn't care which backend they're on. Useful for showing a "GPU
363
+ driver issue, please reload" dialog.
364
+ - **`WebGl2Backend.onContextLost: Signal<[]>`** — backend-specific
365
+ signal mirroring the existing `webglcontextlost` handler.
366
+ - **`WebGl2Backend.onContextRestored: Signal<[]>`** — backend-specific
367
+ signal mirroring the existing `webglcontextrestored` handler.
368
+ - **`WebGpuBackend.onDeviceLost: Signal<[GPUDeviceLostInfo]>`** —
369
+ WebGPU's `device.lost` promise is now subscribed at initialization;
370
+ resolution dispatches this signal with the loss info. Note: WebGPU
371
+ device loss is irrecoverable on the same device — user code must
372
+ reload, retry, or recreate the application to recover. V1 only
373
+ signals; user decides response strategy.
374
+ - **`WebGpuBackend.deviceLost: boolean`** — getter for current
375
+ device-loss state.
376
+ - **`WebGpuBackend.clearColor: Color`** + **`setClearColor(color)`** —
377
+ persistent clear color, matching WebGl2's API. `clear()` without
378
+ arguments uses the persistent color.
379
+ - **`Application.setCursor(cursor)`** + **`cursor` property** — moved
380
+ here from `WebGl2Backend`. Accepts CSS cursor strings or a
381
+ `Texture` / `HTMLImageElement` / `HTMLCanvasElement` (converted to
382
+ a `url(...)` cursor). Sets `canvas.style.cursor` directly.
383
+
384
+ ### Changed (BREAKING)
385
+
386
+ - **`WebGl2Backend.setCursor()` and `cursor` getter removed.** Use
387
+ `app.setCursor(...)` or `app.cursor = ...` instead. Cursor is a DOM
388
+ concern, not a backend concern; this corrects the misplacement.
389
+ - **`WebGpuBackend.setShader()` removed.** Was a vestigial throw with
390
+ no callers. WebGPU's pipeline-based architecture doesn't fit the
391
+ imperative `setShader` pattern. Custom shaders go through
392
+ `WebGpuShaderFilter` (since 0.7.4).
393
+ - **`WebGpuBackend.setVao()` removed.** VAOs are a WebGL concept;
394
+ WebGPU uses bind groups + pipelines. Method had no callers.
395
+ - **`WebGpuBackend.setTexture()` and `setRenderTarget()` no-longer-
396
+ throwing on RenderTarget subclass guards.** Throws were unreachable
397
+ because `RenderTexture` is the only `RenderTarget` subclass. The
398
+ guards are gone; the type system already prevents misuse.
399
+ - **`WebGpuBackend.setBlendMode()` no-longer-throwing**. Internal
400
+ renderers call this during their pipeline setup; the previous throw
401
+ for unrecognized modes was unreachable (covered all 5 valid blend
402
+ modes). Method now silently returns; the actual blend logic lives in
403
+ the pipeline-creation paths inside `WebGpuBlendState` and the
404
+ individual renderers.
405
+
406
+ ### Migration
407
+
408
+ ```ts
409
+ // Before:
410
+ app.backend.setCursor('pointer');
411
+ const cursor = app.backend.cursor;
412
+
413
+ // After:
414
+ app.setCursor('pointer'); // or
415
+ app.cursor = 'pointer';
416
+ const cursor = app.cursor;
417
+ ```
418
+
419
+ ```ts
420
+ // New: react to backend loss
421
+ app.onBackendLost.add(() => {
422
+ showReloadDialog();
423
+ });
424
+
425
+ // Or backend-specific:
426
+ if (app.backend.backendType === RenderBackendType.WebGpu) {
427
+ (app.backend as WebGpuBackend).onDeviceLost.add((info) => {
428
+ console.error('GPU device lost:', info.message, info.reason);
429
+ });
430
+ }
431
+ ```
432
+
433
+ ### Notes
434
+
435
+ - Device-loss is irrecoverable on WebGPU (the lost device cannot be
436
+ reused; recovery requires creating a fresh device, which means
437
+ re-initializing the application). V1 dispatches the signal and stops;
438
+ the user's app code decides whether to reload, retry, or fall back.
439
+ - `setBlendMode` could be removed entirely in a future cleanup if the
440
+ pipeline-creation path is the only place blend state is set, but it
441
+ remains as a no-op for now to preserve internal call sites.
442
+
443
+ ## [0.7.5] - 2026-05-04
444
+
445
+ Expands the debug overlay with three new layers: `BoundingBoxesLayer`,
446
+ `HitTestLayer`, and `PointerStackLayer`. Adds a master visibility switch
447
+ on `DebugOverlay`. Layers can now opt into world-space rendering for
448
+ overlays that need to align with scene content. F2 / F3 / F4 keys are
449
+ hardcoded to toggle the new layers (matching the existing F1 for
450
+ Performance).
451
+
452
+ ### Added
453
+
454
+ - **`BoundingBoxesLayer`** — renders AABB outlines for every visible
455
+ RenderNode in the active scene. Color cycles through HSL hue based
456
+ on `zIndex` (`hue = (zIndex * 30) % 360`), so layered nodes are
457
+ visually distinct. Toggle via F2 or
458
+ `debug.layers.boundingBoxes.visible = true`.
459
+ - **`HitTestLayer`** — outlines for `interactive` nodes only, with
460
+ state-based colors:
461
+ - **Magenta** (idle interactive)
462
+ - **Yellow** (currently hovered, via `app.interaction.getHoveredNode()`)
463
+ - **Cyan** (captured by an active drag, via the new
464
+ `getCapturedNodes()` accessor)
465
+ - When `useSpatialIndex` is enabled on InteractionManager,
466
+ additionally draws faint quadtree quadrant outlines.
467
+ - Toggle via F3.
468
+ - **`PointerStackLayer`** — fixed top-right text panel listing all
469
+ RenderNodes in the active scene whose `contains(worldX, worldY)`
470
+ matches the primary pointer position. Sorted by `zIndex`
471
+ descending (top of stack first). Limited to 10 entries to avoid
472
+ overflow. Useful for debugging "why isn't this clickable" — see
473
+ exactly what's stacked under the cursor. Toggle via F4.
474
+ - **`DebugOverlay.visible: boolean`** (default `true`) — master gate
475
+ that suppresses all layer rendering when `false` while preserving
476
+ individual layer states. Restoring `debug.visible = true` brings
477
+ layers back without rewiring.
478
+ - **`DebugLayer.viewMode: 'screen' | 'world'`** — abstract getter
479
+ (default `'screen'`); subclasses override. The DebugOverlay groups
480
+ layers by viewMode and swaps `backend.view` accordingly: world-mode
481
+ layers render in the active scene's view (matching scene
482
+ coordinates), screen-mode layers render in canvas-pixel space.
483
+ - **`InteractionManager.getCapturedNodes(): ReadonlyArray<RenderNode>`** —
484
+ returns the nodes currently captured by active drags. Used by
485
+ HitTestLayer; also generally useful.
486
+ - **`InputManager.getPrimaryPointerPosition()`** — returns the canvas-
487
+ pixel position of the primary pointer (or null if none active).
488
+
489
+ ### Notes
490
+
491
+ - F2 / F3 / F4 are hardcoded for V1 (matching F1 from 0.6.17). A
492
+ `keybindings: false` opt-out comes when there's concrete demand.
493
+ - BoundingBoxes color cycle is intentionally simple (`hue = z * 30 % 360`).
494
+ Adapts to any z range without per-frame normalization. If two nodes
495
+ share zIndex, they share color — that's fine, the layer's purpose is
496
+ visualizing depth differences.
497
+ - World-mode layers (BoundingBoxes, HitTest) render BEFORE screen-mode
498
+ layers (Performance, PointerStack) in each frame, so text panels
499
+ appear on top of outlines.
500
+
501
+ ## [0.7.4] - 2026-05-04
502
+
503
+ Renames `ShaderFilter` → `WebGl2ShaderFilter` and adds `WebGpuShaderFilter`
504
+ — full backend-specific custom shader support. Custom post-process
505
+ shaders now work on both WebGL2 (GLSL) and WebGPU (WGSL) backends with
506
+ explicit, type-safe class names matching the rest of the codebase
507
+ (WebGl2Backend / WebGpuBackend, WebGl2SpriteRenderer / WebGpuSpriteRenderer,
508
+ etc.).
509
+
510
+ ### Added
511
+
512
+ - **`WebGpuShaderFilter`** — full WGSL fragment shader support on the
513
+ WebGPU backend. API mirrors `WebGl2ShaderFilter` — accepts WGSL source,
514
+ exposes a mutable `uniforms` map, applies as a post-process Filter via
515
+ `node.filters = [filter]`. Internally creates GPUShaderModules,
516
+ bind-group layouts, render pipeline, and fullscreen-quad vertex buffer
517
+ using the same patterns as `WebGpuMaskCompositor`.
518
+ - **WGSL auto-bindings** in `@group(0)`:
519
+ - `@binding(0) var<uniform> uResolution: vec2<f32>` — output dimensions
520
+ - `@binding(1) var uTexture: texture_2d<f32>` — input texture
521
+ - `@binding(2) var uSampler: sampler` — linear sampler
522
+ - **User uniforms** in `@group(1)` — packed into a uniform buffer with
523
+ 16-byte alignment per slot (per WGSL alignment rules; vec3 is 16-byte
524
+ aligned, not 12). Texture uniforms get separate bind group entries.
525
+ - **WGSL default vertex shader** when omitted — fullscreen pass-through
526
+ with a `vUv: vec2<f32>` varying.
527
+
528
+ ### Changed (BREAKING)
529
+
530
+ - **`ShaderFilter` → `WebGl2ShaderFilter`** — the class was always
531
+ WebGL2-only; the name now reflects that. Same API otherwise.
532
+ - **`ShaderFilterOptions` → `WebGl2ShaderFilterOptions`**.
533
+ - **`wgsl` option removed from `WebGl2ShaderFilterOptions`** — was
534
+ reserved API surface for future WGSL support, now superseded by the
535
+ separate `WebGpuShaderFilter`.
536
+ - **Backend guard messages updated**:
537
+ - `WebGl2ShaderFilter` on WebGPU: `'WebGl2ShaderFilter requires the
538
+ WebGL2 backend. Use WebGpuShaderFilter on WebGPU.'`
539
+ - `WebGpuShaderFilter` on WebGL2: `'WebGpuShaderFilter requires the
540
+ WebGPU backend. Use WebGl2ShaderFilter on WebGL2.'`
541
+
542
+ `ShaderFilterUniformValue` (the polymorphic uniform value type) is
543
+ **unchanged** and shared between both backends — same value shapes
544
+ (number / tuples / TypedArrays / Texture).
545
+
546
+ ### Migration
547
+
548
+ ```ts
549
+ // Before (0.7.3):
550
+ import { ShaderFilter } from '@codexo/exojs';
551
+ const filter = new ShaderFilter({ fragmentSource: glsl, uniforms: { ... } });
552
+
553
+ // After (0.7.4):
554
+ import { WebGl2ShaderFilter } from '@codexo/exojs';
555
+ const filter = new WebGl2ShaderFilter({ fragmentSource: glsl, uniforms: { ... } });
556
+
557
+ // New on WebGPU:
558
+ import { WebGpuShaderFilter } from '@codexo/exojs';
559
+ const filter = new WebGpuShaderFilter({ fragmentSource: wgsl, uniforms: { ... } });
560
+ ```
561
+
562
+ ### Notes
563
+
564
+ - Two separate classes (rather than one polymorphic class with both
565
+ shader sources) reflects the reality that GLSL and WGSL are entirely
566
+ different languages with different binding models. Users writing a
567
+ custom shader inherently know their backend; the explicit class name
568
+ matches that mental model.
569
+ - 0.7.3 is effectively replaced — it shipped with the wrong name and a
570
+ WebGPU stub. Window of exposure was minutes; this is corrective.
571
+ - WGSL alignment rules differ from GLSL std140: vec3 occupies 16 bytes
572
+ (not 12). The user's WGSL struct must declare members accordingly.
573
+ - Performance for fullscreen pixel-shader rendering is equivalent on
574
+ both backends — choose based on browser support, ecosystem
575
+ familiarity (GLSL has more tutorials / Shadertoy), or future-proofing
576
+ preference (WebGPU is the long-term direction).
577
+
578
+ ## [0.7.3] - 2026-05-04
579
+
580
+ Adds `ShaderFilter` — a high-level Filter subclass that renders the input
581
+ through a user-provided GLSL fragment shader. Unlocks custom post-process
582
+ effects: visualizers, demoscene shaders, glitch/scanline/dithering passes,
583
+ LUT color grading, chromatic aberration, etc.
584
+
585
+ ### Added
586
+
587
+ - **`ShaderFilter`** — accepts a fragment shader source string + uniforms,
588
+ applies it as a post-process filter on any `RenderNode` via
589
+ `node.filters = [shaderFilter]`. Internally lazy-compiles the shader on
590
+ first apply, allocates a per-instance fullscreen-quad vertex buffer,
591
+ and uses the existing `RenderTargetPass` orchestration shared with
592
+ built-in filters like `BlurFilter`.
593
+ - **Auto-bound uniforms** for the user shader:
594
+ - `uniform sampler2D uTexture` — the filter's input
595
+ - `uniform vec2 uResolution` — output dimensions
596
+ - `in vec2 vUv` (varying) — 0..1 UVs across the quad
597
+ - **`ShaderFilter.uniforms`** — mutable map for user uniforms. Set values
598
+ via property assignment; flushed before each apply():
599
+ ```ts
600
+ filter.uniforms.uTime = performance.now() / 1000;
601
+ filter.uniforms.uColor = [1, 0.5, 0, 1]; // vec4
602
+ ```
603
+ - **Polymorphic uniform values**: scalar `number`, tuple `[a, b]` /
604
+ `[a, b, c]` / `[a, b, c, d]`, `Float32Array` / `Int32Array`, or
605
+ `Texture` / `RenderTexture` (auto-bound to a sampler slot).
606
+ - **Default vertex shader** when `vertexSource` is omitted — pass-through
607
+ fullscreen quad. User can supply a custom vertex shader for warps /
608
+ vertex displacement effects.
609
+ - **`wgsl` option** in `ShaderFilterOptions` — reserved API surface for
610
+ WebGPU support landing in a future release.
611
+
612
+ ### Notes
613
+
614
+ - **WebGL2-only in V1.** Constructor accepts `wgsl` source, but `apply()`
615
+ on the WebGPU backend throws `'ShaderFilter does not yet support the
616
+ WebGPU backend. WGSL support is planned for a future release. Use the
617
+ WebGL2 backend for now.'` Document this limitation; reasoning: WebGPU
618
+ requires a separate WGSL pipeline implementation that's substantial
619
+ on its own. Coming when there's concrete user demand.
620
+ - `fragmentSource` is required at construction. Constructor throws if
621
+ missing.
622
+ - Internally reuses the existing `Shader` + `WebGl2ShaderProgram`
623
+ infrastructure — no new public Backend methods added.
624
+ - Vertex buffer is per-instance (4 vertices × 16 bytes = 64 bytes per
625
+ filter). Pooling across instances was considered but rejected for V1
626
+ to avoid cross-instance lifecycle coupling.
627
+
628
+ ### Usage
629
+
630
+ ```ts
631
+ import { ShaderFilter } from '@codexo/exojs';
632
+
633
+ const filter = new ShaderFilter({
634
+ fragmentSource: `#version 300 es
635
+ precision highp float;
636
+ in vec2 vUv;
637
+ uniform sampler2D uTexture;
638
+ uniform vec2 uResolution;
639
+ uniform float uTime;
640
+ out vec4 outColor;
641
+ void main() {
642
+ vec2 uv = vUv;
643
+ uv.x += sin(uv.y * 10.0 + uTime) * 0.01; // wavy distort
644
+ outColor = texture(uTexture, uv);
645
+ }
646
+ `,
647
+ uniforms: {
648
+ uTime: 0,
649
+ },
650
+ });
651
+
652
+ sprite.filters = [filter];
653
+
654
+ app.onFrame.add((delta) => {
655
+ filter.uniforms.uTime = performance.now() / 1000;
656
+ });
657
+ ```
658
+
659
+ ## [0.7.2] - 2026-05-04
660
+
661
+ Adds `BeatDetector` (Stage 1+2: causal DSP hybrid tracker with bar-aware
662
+ state model) and rewrites `AudioAnalyser` with a polymorphic source
663
+ setter and convenience helpers. **Breaking change** to AudioAnalyser
664
+ API — see migration below. Pure-additive on BeatDetector.
665
+
666
+ ### Added
667
+
668
+ - **`BeatDetector`** — Stage 1+2 beat tracker via AudioWorkletNode.
669
+ Causal DSP pipeline: log-mel spectral flux → 6-second sliding
670
+ tempogram → top-K tempo candidates with octave-error hysteresis →
671
+ phase tracker with novelty-snap correction → HMM-lite bar-position
672
+ posterior. ~500 LOC of inlined worklet source, all in plain JS, no
673
+ dependencies. Polymorphic `source` setter accepts `AudioBus`,
674
+ `Sound`, `Music`, `MediaStream`, raw `AudioNode`, or `null`.
675
+ - **BeatDetector live state**:
676
+ - Stage 1: `tempo`, `beatPhase`, `nextBeatTime`, `confidence`,
677
+ `gridStability`, `tempoCandidates`, `rms`, `onsetStrength`,
678
+ `bandEnergy`
679
+ - Stage 2: `barPosition` (1..N within bar), `barLength`,
680
+ `timeSignature` (currently always 4/4 in V1), `nextDownbeatTime`,
681
+ `lookahead` (next 8 beats projected with audio-time precision)
682
+ - **BeatDetector signals**:
683
+ - Stage 1: `onBeat`, `onTempoChange`
684
+ - Stage 2: `onDownbeat` (the "1" of each bar), `onBarStart`,
685
+ `onBeatPredicted` (when lookahead updates)
686
+ - **`BeatDetectorOptions`** — `minBpm` (default 50), `maxBpm` (default
687
+ 250), `fftSize` (default 2048), `hopSize` (default 512),
688
+ `tempoWindowSec` (default 6), `settlingMs` (default 1500), `melBands`
689
+ (default 24).
690
+ - **Settling period** — first `settlingMs` ms after worklet starts,
691
+ beats are suppressed and `confidence` is `0`. Prevents spurious early
692
+ beat firings before the tempogram has stabilized.
693
+ - **Anti-half/double-tempo hysteresis** — top-K candidates retain
694
+ octave-related tempos; switch only with 1.5× score margin to resist
695
+ the classic 60↔120↔240 BPM flipping.
696
+ - **DSP utilities** in `@/audio/dsp` — pure-function exports for
697
+ `fft`, `mel`, `tempogram`. Used internally by the worklet (inlined
698
+ as JS strings) but also testable in isolation. Also usable directly
699
+ by advanced users for custom analysis.
700
+ - **`AudioAnalyser` rewrite** — polymorphic `source` setter (same 5
701
+ source types as BeatDetector). Lazy-init pattern (works before
702
+ AudioContext is unlocked).
703
+ - **`AudioAnalyser` data getters**: `getSpectrum(into?)`,
704
+ `getSpectrumFloat(into?)`, `getWaveform(into?)`,
705
+ `getWaveformFloat(into?)` — all support a user-provided buffer for
706
+ zero-allocation reads.
707
+ - **`AudioAnalyser` convenience**: `getBandEnergy(fromHz, toHz)`,
708
+ `getLowMidHigh()`, `getRms()` — high-level helpers for visualizers
709
+ and reactive UI.
710
+
711
+ ### Changed (BREAKING)
712
+
713
+ - **`AudioAnalyser` constructor signature changed.** Old:
714
+ `new AudioAnalyser(media, options)`. New:
715
+ `new AudioAnalyser(options?); analyser.source = media`.
716
+ - **`AudioAnalyser` data properties replaced with methods.** Old
717
+ getters `timeDomainData`, `frequencyData`, `preciseTimeDomainData`,
718
+ `preciseFrequencyData` are removed. Use `getWaveform()`,
719
+ `getSpectrum()`, `getWaveformFloat()`, `getSpectrumFloat()`
720
+ respectively. The new methods accept an optional `into` buffer
721
+ argument for zero-allocation reuse.
722
+ - **`AudioAnalyser.connect()` removed.** Connection is now automatic
723
+ on `source` assignment.
724
+
725
+ ### Migration
726
+
727
+ ```ts
728
+ // Before:
729
+ const analyser = new AudioAnalyser(music, { fftSize: 1024 });
730
+ analyser.connect();
731
+ const spectrum = analyser.frequencyData;
732
+ const waveform = analyser.timeDomainData;
733
+
734
+ // After:
735
+ const analyser = new AudioAnalyser({ fftSize: 1024 });
736
+ analyser.source = music;
737
+ const spectrum = analyser.getSpectrum();
738
+ const waveform = analyser.getWaveform();
739
+
740
+ // Now also possible:
741
+ analyser.source = mediaStream; // Mic input
742
+ analyser.source = app.audio.master; // Whole mix
743
+ analyser.getBandEnergy(20, 200); // Bass energy 0..1
744
+ analyser.getLowMidHigh(); // {low, mid, high}
745
+ ```
746
+
747
+ ```ts
748
+ // New: BeatDetector
749
+ const detector = new BeatDetector();
750
+ detector.source = music;
751
+ await detector.ready;
752
+
753
+ detector.onBeat.add(({ audioTime, tempo, isDownbeat, energy }) => {
754
+ sprite.scale.set(1.5);
755
+ new Tween().target(sprite.scale).to({x: 1, y: 1}).duration(200).start();
756
+ });
757
+
758
+ detector.onDownbeat.add(() => {
759
+ boss.attack(); // syncs exactly to "the 1" of each bar
760
+ });
761
+ ```
762
+
763
+ ### Notes
764
+
765
+ - BeatDetector is calibrated for percussive, metrically stable music
766
+ (Pop, EDM, Dance, Hip-Hop). Expect ~85-92% beat F1 in that range.
767
+ Performance on Jazz, Classical, and Ambient is weaker (50-65%) —
768
+ Stage 3 (CRNN-based activations) would address that and is deferred.
769
+ - Time-signature detection is hardcoded to 4/4 in V1. Bar-position
770
+ tracking still works (HMM-lite over 4 beats); 3/4 detection comes
771
+ later if needed.
772
+ - Lookahead returns 8 beats projected at current tempo. Game-event
773
+ scheduling can use `audioContext.currentTime` differences for
774
+ sample-accurate alignment.
775
+ - The DSP runs entirely in the audio thread via AudioWorklet — no
776
+ main-thread CPU pressure, no jitter from GC or task scheduling. The
777
+ worklet source is embedded as a JS string in BeatDetector.ts (no
778
+ separate asset shipped).
779
+
780
+ ## [0.7.1] - 2026-05-04
781
+
782
+ Adds an AudioWorklet foundation and migrates `DuckingFilter` from
783
+ CPU-thread `setInterval(60Hz)` polling to sample-accurate audio-thread
784
+ DSP. Establishes the architecture for future custom-DSP filters
785
+ (Chorus, Pitch-Shift, Vocoder, etc.) without shipping any new effect
786
+ filters in this release.
787
+
788
+ ### Added
789
+
790
+ - **`registerWorkletProcessor(audioContext, name, source)`** — Blob-URL
791
+ based helper for registering AudioWorkletProcessors at runtime from a
792
+ source string. No build-tooling changes required: worklet code lives
793
+ as a JavaScript string inside the TypeScript file, gets converted to
794
+ a Blob URL on first registration, and is cached per-AudioContext.
795
+ Concurrent registrations are deduplicated via shared in-flight
796
+ Promises.
797
+ - **`WorkletFilter`** — abstract base class extending `AudioFilter` for
798
+ filters implemented as AudioWorklet processors. Subclasses declare
799
+ `_workletName`, `_workletSource`, and (optionally) `_workletOptions`
800
+ / `_onWorkletReady`. The base handles:
801
+ - Async worklet loading lifecycle
802
+ - Stable `inputNode` / `outputNode` GainNodes that exist immediately
803
+ (audio passes through directly while the worklet loads, then
804
+ re-routes through the worklet once ready — no destruction or
805
+ re-wiring on the bus side)
806
+ - `_setAudioParam(name, value)` helper for smooth parameter updates
807
+ - Safe destruction during async load
808
+ - **`AudioFilter.ready: Promise<void>`** — resolves when the filter is
809
+ fully initialized. Sync filters (BiquadFilter-backed, etc.) return
810
+ an already-resolved Promise. Async filters (WorkletFilter
811
+ subclasses) return a Promise that resolves once the worklet has
812
+ loaded. Useful when user code wants to `await` a parameter setup
813
+ that depends on the underlying node existing.
814
+
815
+ ### Changed
816
+
817
+ - **`DuckingFilter` is now AudioWorklet-backed.** The setInterval-based
818
+ envelope follower has been replaced with a sample-accurate worklet
819
+ processor. Public API is unchanged: same constructor options
820
+ (`sidechain`, `threshold`, `ratio`, `attackMs`, `releaseMs`), same
821
+ property setters. Behaviorally:
822
+ - Detection runs at full sample-rate (typically 48 kHz) instead of
823
+ 60 Hz polling
824
+ - Audio-thread isolated — no jitter from main-thread garbage
825
+ collection or task pressure
826
+ - Functions correctly when the page tab is inactive (audio thread
827
+ keeps running while CPU thread is throttled)
828
+ - Initial use has a one-time ~10–50 ms async load cost as the
829
+ worklet code registers; during that window the filter passes
830
+ audio through unmodified
831
+
832
+ ### Notes
833
+
834
+ - AudioWorklet is supported in all browsers since 2020 (Chrome 66+,
835
+ Firefox 76+, Safari 14.1+). No fallback to the old setInterval
836
+ approach — environments without worklet support will throw on
837
+ DuckingFilter construction.
838
+ - The shared infrastructure (`registerWorkletProcessor` +
839
+ `WorkletFilter`) is the foundation for future custom-DSP filters.
840
+ Concrete filter additions (Chorus, Pitch-Shift, Vocoder, Granular,
841
+ etc.) come in subsequent releases.
842
+ - BeatDetector / AudioAnalyser hook revamp is deferred — that's the
843
+ next focused topic.
844
+
845
+ ## [0.7.0] - 2026-05-04
846
+
847
+ Audio modernization. Introduces a routing manager with hierarchical buses,
848
+ a filter API consistent with the rendering side, 2D spatial audio, and
849
+ unifies `Sound.play()` into a multi-instance default. Pure-additive on
850
+ the bus / filter / spatial side; the `Sound.play()` semantics are a
851
+ breaking change.
852
+
853
+ ### Added
854
+
855
+ - **`AudioManager`** — routing mixer accessible via `app.audio` (lazy
856
+ module-level singleton, also reachable via `getAudioManager()`).
857
+ Built-in buses `master`, `music`, `sound` with hierarchy
858
+ (`music` and `sound` are children of `master`).
859
+ - **`AudioBus`** — class with `name` (positional constructor arg),
860
+ `parent`, `volume`, `muted`, `pan`, `addFilter`, `removeFilter`,
861
+ `fadeIn`, `fadeOut`, `destroy`. Internal node chain is
862
+ `inputNode → [filters...] → panNode → outputNode → parent.input`.
863
+ - **Mixer API**: `app.audio.registerBus(bus)`, `getBus(name)`,
864
+ `hasBus(name)`, `unregisterBus(bus)`. Built-ins cannot be
865
+ unregistered.
866
+ - **Default routing**: `Sound` → `app.audio.sound`, `Music` →
867
+ `app.audio.music`, `Video` → `app.audio.master`. Override by
868
+ setting `media.bus = customBus`.
869
+ - **`AudioManager.muteOnHidden: boolean`** — when true, master is
870
+ muted while `document.visibilityState !== 'visible'`. Wired
871
+ through the `app.onVisibilityChange` signal added in 0.6.20.
872
+ - **`AudioFilter`** — abstract base with `inputNode`, `outputNode`,
873
+ `destroy()`. Buses chain filter `inputNode → outputNode` in the
874
+ order they were added.
875
+ - **Filter implementations**: `LowpassFilter`, `HighpassFilter`,
876
+ `CompressorFilter`, `DelayFilter`, `ReverbFilter` (algorithmic
877
+ impulse-response, no IR assets shipped), `EqualizerFilter`
878
+ (3-band low-shelf / peaking / high-shelf), `DuckingFilter`
879
+ (sidechain-driven gain reduction via `AnalyserNode` polled at
880
+ ~60 Hz; takes a `sidechain: AudioBus` option).
881
+ - **`AudioListener`** — accessible at `app.audio.listener`. Has
882
+ `position: Vector`, `velocity: Vector`, and a polymorphic
883
+ `target: SceneNode | View | { x, y } | null` that auto-feeds
884
+ the WebAudio listener position each frame.
885
+ - **`Sound.position: Vector | null`** — when non-null, the sound
886
+ becomes spatial: routes through a `PannerNode`
887
+ (`panningModel: 'equalpower'`, `distanceModel: 'linear'`) and
888
+ ticks per-frame from `AudioManager.update()`. Setting back to null
889
+ tears down the panner and restores non-spatial routing.
890
+ - **`Sound.velocity: Vector | null`** — tracked for future Doppler
891
+ use (modern WebAudio infers Doppler implicitly from positional
892
+ change between frames; we don't pipe velocity to the panner
893
+ directly).
894
+ - **`SoundPoolStrategy` enum** — `FirstInFirstOut`,
895
+ `LeastRecentlyUsed`, `LowestPriority`. Selects the eviction
896
+ policy when pool capacity is reached.
897
+ - **`Sound.priority: number`** — used by the `LowestPriority`
898
+ strategy. Default 0.
899
+ - **`AudioManager.update()`** — public per-frame tick called from
900
+ `Application.update()` between `interaction.update()` and
901
+ `tweens.update()`. Updates listener position from target,
902
+ ticks each registered spatial sound's panner.
903
+
904
+ ### Changed (BREAKING)
905
+
906
+ - **`Sound.play()` is now multi-instance by default.** Each call
907
+ creates a new pooled instance up to `poolSize`. The previous
908
+ singleton-replace behavior is opt-in via
909
+ `play({ replace: true })`.
910
+ - **`Sound.playPooled()` removed.** Use `play()` (which is now the
911
+ pooled multi-instance path).
912
+ - **`Sound.poolSize` default raised from 1 to 8.** Closer to typical
913
+ SFX needs without manual configuration.
914
+ - **`Sound._sourceNode` (the previous primary singleton source) is
915
+ removed.** With pooled play unified, all sources go through
916
+ `_pooledSources`. As a consequence, `Sound.getTime()` and
917
+ `Sound.setTime()` no longer track per-source playback position
918
+ — they're effectively no-ops on Sound now. For precise timing
919
+ use `Music` (HTMLMediaElement-backed singleton).
920
+ - **`AbstractMedia.bus` property added.** Subclasses (Sound, Music)
921
+ override `_defaultBus()`, `_connectToBus()`, `_disconnectFromBus()`
922
+ to integrate with the mixer.
923
+
924
+ ### Migration
925
+
926
+ ```ts
927
+ // Before:
928
+ sound.play(); // singleton — second call replaces first
929
+ sound.playPooled(); // multi-instance — concurrent plays
930
+
931
+ // After:
932
+ sound.play(); // multi-instance — concurrent plays (default!)
933
+ sound.play({ replace: true }); // singleton — equivalent of old play()
934
+ ```
935
+
936
+ ```ts
937
+ // Before — direct destination routing was implicit:
938
+ const sound = new Sound(buffer);
939
+ sound.play(); // → audioContext.destination
940
+
941
+ // After — routes through the soundBus by default:
942
+ const sound = new Sound(buffer);
943
+ sound.play(); // → app.audio.sound → app.audio.master → destination
944
+
945
+ // Override to a custom bus:
946
+ const dialogueBus = new AudioBus('dialogue', { parent: app.audio.master });
947
+ app.audio.registerBus(dialogueBus);
948
+ sound.bus = dialogueBus;
949
+ ```
950
+
951
+ ```ts
952
+ // Spatial audio:
953
+ const explosion = new Sound(buffer);
954
+ explosion.position = { x: 200, y: 100 }; // becomes spatial
955
+ app.audio.listener.target = playerSprite; // ears follow player
956
+
957
+ explosion.play();
958
+ // → routes through equalpower panner with distance falloff
959
+ ```
960
+
961
+ ### Notes
962
+
963
+ - `DuckingFilter` uses its own internal `setInterval(60Hz)` for
964
+ per-frame envelope-following rather than hooking into
965
+ `AudioManager.update()`. This keeps audio-side filters
966
+ self-contained and avoids cross-cutting changes to the mixer
967
+ contract. May be revisited.
968
+ - `LowestPriority` pool strategy degenerates to FIFO within a
969
+ single Sound instance because all pooled sources share the same
970
+ `priority` value. The strategy becomes meaningful when the
971
+ engine later adds cross-Sound voice management.
972
+ - Spatial sounds share a single `PannerNode` per Sound instance —
973
+ all simultaneous pooled plays of one sound emit from the same
974
+ world-space point. Per-instance positions would require an
975
+ API extension and are deferred.
976
+ - BeatDetector / `AudioAnalyser.onBeat` hooks are deferred to
977
+ 0.7.1 — this release focuses on the mixer / filter / spatial
978
+ foundation.
979
+
980
+ ## [0.6.20] - 2026-05-02
981
+
982
+ Adds `view.follow(SceneNode)`, audio fade helpers, and focus / visibility
983
+ infrastructure. Pure additive — no behavior changes for existing code.
984
+
985
+ ### Added
986
+
987
+ - **`view.follow()` accepts `SceneNode`** in addition to `{x, y}`
988
+ targets. When the target is a SceneNode, the follow tracks its
989
+ **world-space position** via `getGlobalTransform()`, so following a
990
+ Sprite nested under a translated/rotated Container works correctly.
991
+ New exported type `ViewFollowTarget = SceneNode | { x: number; y:
992
+ number } | null`.
993
+ - **Audio fade helpers on `AbstractMedia`** — both `Sound` and `Music`
994
+ inherit:
995
+ - `fadeIn(durationMs): this` — ramps gain from 0 to current volume.
996
+ Auto-plays if paused. Cancels any in-flight fade.
997
+ - `fadeOut(durationMs, options?: { stopAfter?: boolean }): this` —
998
+ ramps gain to 0. By default calls `pause()` after the fade
999
+ completes; pass `{ stopAfter: false }` to keep playing at zero
1000
+ volume.
1001
+ - Both return `this` for chaining and use Web Audio's
1002
+ `linearRampToValueAtTime` for sample-accurate fades.
1003
+ - **`Application.canvasFocused: boolean`** — passthrough getter for the
1004
+ InputManager's existing canvas focus state.
1005
+ - **`Application.documentVisible: boolean`** — tracks
1006
+ `document.visibilityState`, updated on `visibilitychange`.
1007
+ - **`Application.onCanvasFocusChange: Signal<[focused: boolean]>`** —
1008
+ fires when the canvas gains or loses focus (canvas blur,
1009
+ click-outside, alt-tab from canvas-focused state).
1010
+ - **`Application.onVisibilityChange: Signal<[visible: boolean]>`** —
1011
+ fires when the page tab becomes hidden or visible (minimize, switch
1012
+ tab, etc.).
1013
+ - **`Application.pauseOnHidden: boolean`** (default `false`) — when
1014
+ `true`, `app.update()` skips the entire frame body while
1015
+ `documentVisible` is `false`. `requestAnimationFrame` keeps
1016
+ ticking (already throttled by the browser when hidden) so the loop
1017
+ resumes seamlessly when the page becomes visible again.
1018
+ - **`InputManager.onCanvasFocusChange`** — same signal also exposed
1019
+ here for users who only need input-side focus tracking without
1020
+ reaching for the Application.
1021
+
1022
+ ### Notes
1023
+
1024
+ - Window-level `blur` / `focus` events are intentionally not exposed as
1025
+ separate signals — `document.visibilitychange` is the better-defined
1026
+ API and covers the common cases.
1027
+ - `crossFade()` as a top-level helper was deferred — compose
1028
+ `a.fadeOut(ms)` + `b.fadeIn(ms)` manually until the AudioManager lands.
1029
+ - `view.follow()` continues to use lerp-based smoothing for continuous
1030
+ tracking. Scripted one-shot camera moves (zoom-to-room,
1031
+ pan-to-cutscene) should use the existing Tween system on
1032
+ `view.center` for full easing-curve support.
1033
+
1034
+ ## [0.6.19] - 2026-05-02
1035
+
1036
+ Caches global transforms, world-space bounds, sprite vertices, and
1037
+ sprite normals via dirty flags. Closes four hot-path recomputation
1038
+ gaps that the audit identified — `getGlobalTransform()` and
1039
+ `getBounds()` were O(depth) per call, called many times per frame
1040
+ from sprite rendering, hit-testing, frustum culling, and collision
1041
+ detection. Pure performance change — no public API surface changes.
1042
+
1043
+ ### Performance
1044
+
1045
+ - **`SceneNode.getGlobalTransform()`** is now cache-hit-O(1) instead
1046
+ of O(depth). The cached `_globalTransform` is invalidated on
1047
+ position / rotation / scale / origin change, on parent change
1048
+ (add/remove from a Container), and propagated to all descendants
1049
+ on parent transform changes.
1050
+ - **`SceneNode.getBounds()`** is now cache-hit-O(1). Invalidated
1051
+ alongside global transform, plus on local-bounds mutations
1052
+ (`Sprite.setTextureFrame`, `Mesh.recomputeLocalBounds`,
1053
+ `ParticleSystem.setTextureFrame`). Local-bounds changes also
1054
+ cascade up to ancestor Containers' bounds.
1055
+ - **`Sprite.vertices`** getter caches the eight world-space vertex
1056
+ components. Recomputes only when the sprite's transform or local
1057
+ bounds change. Previously had a `// todo cache this` comment.
1058
+ - **`Sprite.getNormals()`** returns a stable `[Vector, Vector,
1059
+ Vector, Vector]` array. The four `Vector` instances are reused
1060
+ across calls; previously each call allocated four new `Vector`s.
1061
+ Recomputes only when vertices change. Reduces GC pressure in
1062
+ collision-detection hot paths.
1063
+
1064
+ ### Notes
1065
+
1066
+ - `Sprite.getNormals()` now returns the **same array reference** on
1067
+ every call. Callers that previously stored the result and expected
1068
+ it to remain stable across mutations must re-read after any
1069
+ transform change. This is a behavior refinement; no caller in the
1070
+ codebase relied on the prior allocation pattern.
1071
+ - Invalidation propagation walks the scene subtree on position /
1072
+ rotation / scale / origin changes. For very large UI trees
1073
+ (thousands of nested children), this is O(descendants) per setter
1074
+ call. Setters are typically called on a small number of nodes per
1075
+ frame, so the cumulative cost is dominated by the savings on
1076
+ the read path. Generation-counter invalidation is a possible
1077
+ future optimization if profiling shows the walk dominates.
1078
+ - New flag bits: `SceneNodeTransformFlags.GlobalTransform` (1<<8),
1079
+ `SceneNodeTransformFlags.BoundsRect` (1<<9),
1080
+ `SpriteFlags.Vertices` (0x400), `SpriteFlags.Normals` (0x800).
1081
+ Non-overlapping with existing flags so they share the same
1082
+ `Flags<T>` instance.
1083
+
1084
+ ## [0.6.18] - 2026-05-02
1085
+
1086
+ Fixes a long-standing audio volume-ramp bug.
1087
+
1088
+ ### Fixed
1089
+
1090
+ - **Audio volume / mute changes are now near-instant**. The third
1091
+ argument to `GainNode.setTargetAtTime` is a time constant in
1092
+ **seconds** — `Sound`, `Music`, and the `Video` audio path were
1093
+ passing `10`, which made every volume update take ~30 seconds to
1094
+ reach 95% of its target value. Calling `sound.setVolume(0.5)` would
1095
+ fade over half a minute instead of taking effect immediately.
1096
+ Replaced with `0.01` (10 ms) — fast enough to feel instant, slow
1097
+ enough to avoid the audible click of a snapped value. Standard
1098
+ practice in `pixi-sound`, Howler, and other Web Audio libraries.
1099
+ Affects: `Sound.setVolume`, `Sound.setMuted`, `Sound` audio-context
1100
+ setup, and the equivalent paths on `Music` and `Video`. Bug was
1101
+ present since the initial commit; not caught by tests because the
1102
+ jsdom mock stubs `setTargetAtTime` as a no-op.
1103
+
1104
+ ## [0.6.17] - 2026-05-02
1105
+
1106
+ Rewrites the debug overlay as a canvas-native, tree-shake-able module.
1107
+ Replaces the DOM-based 0.6.15 implementation. Also adds a generic
1108
+ per-frame application hook.
1109
+
1110
+ ### Added
1111
+
1112
+ - **`Application.onFrame: Signal<[Time]>`** — generic per-frame hook
1113
+ fired between `sceneManager.update()` and `backend.flush()`. Useful
1114
+ for any external tool that wants per-frame ticks without writing a
1115
+ Scene (debug overlays, profilers, custom HUDs).
1116
+ - **`@codexo/exojs/debug` subpath export** — DebugOverlay and friends
1117
+ now live behind a separate import path. Apps that don't import it
1118
+ pay zero bundle cost. The root `@codexo/exojs` no longer references
1119
+ any debug code.
1120
+ - **Canvas-native `DebugOverlay`** — instantiate manually:
1121
+ ```ts
1122
+ import { DebugOverlay } from '@codexo/exojs/debug';
1123
+ const debug = new DebugOverlay(app);
1124
+ debug.layers.performance.visible = true; // or press F1
1125
+ ```
1126
+ Subscribes to `app.onFrame` for ticking, `inputManager.onKeyDown`
1127
+ for F1 binding, and `app.onResize` for screen-space view sync.
1128
+ Renders into its own screen-space view between scene render and
1129
+ backend flush.
1130
+ - **`PerformanceLayer`** (V1's only layer) — FPS, frame-time
1131
+ sparkline, draw calls, node count, culled nodes. Top-left fixed
1132
+ position. Toggle via `F1` or `debug.layers.performance.visible`.
1133
+ - **`DebugLayer` abstract base** — exported so future layer types
1134
+ (BoundingBoxes, HitTest, PointerStack) plug in cleanly. V1 ships
1135
+ only PerformanceLayer; more arrive in subsequent patches.
1136
+
1137
+ ### Changed
1138
+
1139
+ - **`Application.debug` removed** — was added in 0.6.15. Apps that
1140
+ used `app.debug.show()` must migrate to `import { DebugOverlay }
1141
+ from '@codexo/exojs/debug'` and instantiate manually. **Breaking
1142
+ change**, but the affected window is one day (0.6.15 → 0.6.17).
1143
+
1144
+ ### Notes
1145
+
1146
+ - The new architecture decouples DebugOverlay from Application so
1147
+ the root bundle tree-shakes the debug code away when unused. This
1148
+ is the same pattern projects use for optional dev-tools modules.
1149
+ - F1 binding is hardcoded for V1. Opt-out (`{ keybindings: false }`
1150
+ constructor option) and additional keybindings come with the
1151
+ next layers.
1152
+ - F-keys only fire while the canvas has focus — engine convention,
1153
+ not a debug-specific quirk.
1154
+
1155
+ ## [0.6.16] - 2026-05-02
1156
+
1157
+ Adds an opt-in spatial index for hit-testing and replaces the dead
1158
+ `core/Quadtree` class with a generic `math/Quadtree<T>`.
1159
+
1160
+ ### Added
1161
+
1162
+ - **`Quadtree<T>`** in `@/math/Quadtree` — generic spatial index with
1163
+ `insert(item)`, `queryPoint(x, y, results?)`, `queryRect(rect, results?)`,
1164
+ `clear()`, and `destroy()`. Items carry their `bounds: Rectangle` and
1165
+ arbitrary `payload: T` separately, so a single tree can index any
1166
+ spatial domain. The `results` array is reused across queries for
1167
+ zero-allocation hot paths.
1168
+ - **`InteractionManager.useSpatialIndex: boolean`** (default `false`) —
1169
+ opt-in flag. When enabled, the manager rebuilds a quadtree of all
1170
+ visible interactive nodes once per `update()` tick and uses it for
1171
+ hit-testing instead of the recursive scene-tree walk. Z-order is
1172
+ preserved via insertion-order tags. Captured pointers (active drags)
1173
+ bypass the index — same as the recursive fallback.
1174
+
1175
+ ### Changed
1176
+
1177
+ - **`core/Quadtree`** removed — was dead code, exposed publicly via the
1178
+ `core` barrel but never imported anywhere internally. The new
1179
+ `math/Quadtree<T>` covers the same conceptual ground with a cleaner
1180
+ API and broader applicability. **This is a breaking change for any
1181
+ external code that imported `Quadtree` from `@codexo/exojs`** and
1182
+ relied on the SceneNode-specialized `addSceneNode` /
1183
+ `getRelatedChildren` methods. Replacement: use `Quadtree<RenderNode>`
1184
+ from `@/math/Quadtree` with `insert({ bounds, payload })` and
1185
+ `queryPoint` / `queryRect`.
1186
+
1187
+ ### Notes
1188
+
1189
+ - Default behavior is unchanged: `useSpatialIndex` is off, so the
1190
+ recursive walk remains the hit-test path. Turn it on for scenes
1191
+ with many interactive nodes — the per-frame rebuild + log-time
1192
+ query pays off when the linear walk becomes a bottleneck.
1193
+ - Per-frame rebuild is intentional in v1. Smarter invalidation
1194
+ (rebuild only when the scene tree mutates) is a follow-up.
1195
+ - The new tree does not redistribute items already-stored in a parent
1196
+ when subdivision happens — fine for the rebuild-each-frame model
1197
+ since items don't accumulate across frames. If item-stable trees
1198
+ become a use case later, redistribution is ~20 LOC to add.
1199
+
1200
+ ## [0.6.15] - 2026-05-02
1201
+
1202
+ Adds a built-in debug HUD for runtime stats. Opt-in HTML overlay that
1203
+ shows FPS, frame time, draw calls, node count, active pointers, and
1204
+ the currently hovered interactive node — handy during development,
1205
+ zero cost when not shown.
1206
+
1207
+ ### Added
1208
+
1209
+ - **`Application.debug`** — auto-instantiated `DebugOverlay` instance.
1210
+ DOM is created lazily on first `show()`, so the panel costs nothing
1211
+ until opt-in. Position-fixed over the canvas, recomputed each frame
1212
+ from `canvas.getBoundingClientRect()` so it tracks if the canvas
1213
+ moves.
1214
+ - **`DebugOverlay.show() / hide() / toggle()`** — visibility control.
1215
+ `show()` returns `this` for chaining. Bind to a key in your code if
1216
+ you want a hotkey toggle.
1217
+ - **Stats displayed**: FPS (60-sample rolling average), frame time
1218
+ (ms), draw calls, culled nodes, total scene-tree node count, active
1219
+ pointers, hovered node class + cursor coords.
1220
+ - **`InteractionManager.getHoveredNode(pointerId?)`** — returns the
1221
+ RenderNode currently hovered by the given pointer (or the first one
1222
+ in iteration order when omitted). Used by the debug panel; also
1223
+ useful for custom HUDs.
1224
+
1225
+ ### Notes
1226
+
1227
+ - The overlay is a styled `<div>` appended to `document.body`. It uses
1228
+ `pointer-events: none` so clicks pass through to the canvas.
1229
+ - No keyboard shortcut is wired up — bind `app.debug.toggle()` to
1230
+ whatever key you want.
1231
+ - Hit-test box visualization is not in this release — coming when
1232
+ the spatial-index work lands.
1233
+
1234
+ ## [0.6.14] - 2026-05-02
1235
+
1236
+ Reshapes the interaction system around a per-frame tick and adds an
1237
+ opt-in drag-and-drop helper. The public per-node signal API from 0.6.13
1238
+ is unchanged; only event *cadence* and a new `draggable` flag.
1239
+
1240
+ ### Added
1241
+
1242
+ - **`RenderNode.draggable: boolean`** (default `false`) — when set on
1243
+ an interactive node, a `pointerdown` over the node starts a drag:
1244
+ the framework auto-positions the node by tracking pointer movement
1245
+ while preserving the grab offset, and routes all subsequent pointer
1246
+ events for that pointer ID to the dragged node regardless of where
1247
+ the pointer is. Drag bypasses hit-testing until release.
1248
+ - **Three drag signals on `RenderNode`**: `onDragStart`, `onDrag`,
1249
+ `onDragEnd` — all `Signal<[InteractionEvent]>`. Drag events use new
1250
+ event types `'dragstart' | 'drag' | 'dragend'` and dispatch directly
1251
+ on the node (no bubble — parent containers don't receive child drag
1252
+ events).
1253
+ - **`InteractionManager.update()`** — public per-frame tick called
1254
+ automatically from `Application.update()` between `inputManager.update()`
1255
+ and `tweens.update()`. Drains a per-pointer queue filled by signal
1256
+ handlers; no-op when nothing happened that frame.
1257
+
1258
+ ### Changed
1259
+
1260
+ - **InteractionManager moved from event-driven to tick-driven.**
1261
+ Signal handlers now only enqueue flags into a per-pointer bitfield
1262
+ and set a dirty flag; the actual hit-test + dispatch happens once
1263
+ per frame in `update()`. Same observable behavior, but decoupled
1264
+ from `InputManager` signal cadence — paves the way for spatial-index
1265
+ integration.
1266
+
1267
+ ### Notes
1268
+
1269
+ - **Drag uses native `setPointerCapture`** so movement keeps tracking
1270
+ even when the pointer leaves canvas bounds. `pointercancel` /
1271
+ `pointerleave` during a drag fires `onDragEnd` (no separate
1272
+ cancellation flag in v1; check the event type if needed).
1273
+ - **Drag offset is in canvas-space.** Nodes whose parent containers
1274
+ have non-identity transforms may feel off — v1 assumes top-level
1275
+ draggable elements (UI panels, inventory items). True
1276
+ parent-aware drag is a follow-up.
1277
+ - **`pointerover` / `pointerout` are suppressed during a drag** —
1278
+ the dragged node stays "hovered" by definition.
1279
+
1280
+ ## [0.6.13] - 2026-05-02
1281
+
1282
+ Adds object-level pointer events. Scene-graph nodes are now first-class
1283
+ event targets — opt in with `node.interactive = true` and listen on
1284
+ per-node signals. Pure addition; existing global pointer signals on
1285
+ `InputManager` are unchanged.
1286
+
1287
+ ### Added
1288
+
1289
+ - **`RenderNode.interactive: boolean`** (default `false`) — opt-in flag
1290
+ enabling hit-testing for the node. Hit-test reuses the existing
1291
+ `RenderNode.contains(x, y)` (AABB in world space).
1292
+ - **`RenderNode.cursor: string | null`** (default `null`) — CSS cursor
1293
+ string applied to `canvas.style.cursor` while the pointer is over the
1294
+ node. Walks up the ancestor chain; first non-null wins.
1295
+ - **Six per-node signals**: `onPointerDown`, `onPointerUp`,
1296
+ `onPointerMove`, `onPointerOver`, `onPointerOut`, `onPointerTap` —
1297
+ all `Signal<[InteractionEvent]>`.
1298
+ - **`InteractionEvent`** — `type`, `target` (the originally-hit node,
1299
+ stable across bubble), `currentTarget` (changes per bubble step),
1300
+ `pointer`, `worldX`, `worldY`, `stopPropagation()`,
1301
+ `propagationStopped`.
1302
+ - **`InteractionManager`** — wired automatically as
1303
+ `Application.interaction`. Subscribes to existing `InputManager`
1304
+ signals (no extra DOM listeners), hit-tests the active scene's root
1305
+ in reverse z-order, dispatches with bubble propagation, and updates
1306
+ the canvas cursor.
1307
+
1308
+ ### Notes
1309
+
1310
+ - **Bubble-only, no capture phase.** Bubble walks `parentNode` and
1311
+ stops at the first non-interactive ancestor — parents must opt in
1312
+ to receive bubbled events. `event.stopPropagation()` halts the walk.
1313
+ - **Touch has no hover phase.** `pointerover` / `pointerout` for touch
1314
+ fire only at down/up boundaries (a finger doesn't exist on the
1315
+ surface between presses). Don't rely on hover effects for touch UX.
1316
+ - **AABB hit-test only in v1.** Precise (polygon / alpha) hit-testing
1317
+ is deferred. Override `contains(x, y)` for custom shapes.
1318
+ - **Cursor is CSS-only.** For animated or texture-based custom cursors,
1319
+ set `canvas.style.cursor = 'none'` and render a sprite that follows
1320
+ pointer position. CSS gives OS-level latency and survives game-loop
1321
+ stutter; engine-rendered cursors don't.
1322
+
7
1323
  ## [0.6.12] - 2026-05-02
8
1324
 
9
1325
  Adds swept (continuous) collision detection. Pure-math addition —