@thunderclap-engine/engine 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +390 -0
- package/LICENSE +21 -0
- package/README.md +333 -2
- package/dist/analytics/analytics.d.ts +80 -0
- package/dist/analytics/analytics.js +129 -0
- package/dist/analytics/index.d.ts +1 -0
- package/dist/analytics/index.js +1 -0
- package/dist/anim/aim.d.ts +43 -0
- package/dist/anim/aim.js +157 -0
- package/dist/anim/clip.d.ts +98 -0
- package/dist/anim/clip.js +282 -0
- package/dist/anim/compress.d.ts +65 -0
- package/dist/anim/compress.js +339 -0
- package/dist/anim/foot-ik.d.ts +74 -0
- package/dist/anim/foot-ik.js +241 -0
- package/dist/anim/graph.d.ts +153 -0
- package/dist/anim/graph.js +532 -0
- package/dist/anim/ik.d.ts +105 -0
- package/dist/anim/ik.js +435 -0
- package/dist/anim/index.d.ts +36 -0
- package/dist/anim/index.js +21 -0
- package/dist/anim/match.d.ts +66 -0
- package/dist/anim/match.js +153 -0
- package/dist/anim/mixer.d.ts +113 -0
- package/dist/anim/mixer.js +314 -0
- package/dist/anim/morph.d.ts +41 -0
- package/dist/anim/morph.js +94 -0
- package/dist/anim/pose.d.ts +74 -0
- package/dist/anim/pose.js +254 -0
- package/dist/anim/quat.d.ts +44 -0
- package/dist/anim/quat.js +187 -0
- package/dist/anim/retarget.d.ts +97 -0
- package/dist/anim/retarget.js +366 -0
- package/dist/anim/root-motion.d.ts +44 -0
- package/dist/anim/root-motion.js +224 -0
- package/dist/anim/skin-pose.d.ts +39 -0
- package/dist/anim/skin-pose.js +53 -0
- package/dist/anim/socket.d.ts +47 -0
- package/dist/anim/socket.js +84 -0
- package/dist/anim/spring-chain.d.ts +73 -0
- package/dist/anim/spring-chain.js +294 -0
- package/dist/assets/atlas.d.ts +90 -0
- package/dist/assets/atlas.js +212 -0
- package/dist/assets/baked.d.ts +33 -0
- package/dist/assets/baked.js +38 -0
- package/dist/assets/bundle.d.ts +88 -0
- package/dist/assets/bundle.js +464 -0
- package/dist/assets/cache.d.ts +56 -0
- package/dist/assets/cache.js +123 -0
- package/dist/assets/gltf-load.d.ts +252 -0
- package/dist/assets/gltf-load.js +661 -0
- package/dist/assets/gltf-skin.d.ts +36 -0
- package/dist/assets/gltf-skin.js +109 -0
- package/dist/assets/gltf.d.ts +706 -0
- package/dist/assets/gltf.js +1376 -0
- package/dist/assets/index.d.ts +20 -0
- package/dist/assets/index.js +10 -0
- package/dist/assets/ktx2.d.ts +71 -0
- package/dist/assets/ktx2.js +213 -0
- package/dist/assets/manifest.d.ts +79 -0
- package/dist/assets/manifest.js +7 -0
- package/dist/assets/meshopt.d.ts +23 -0
- package/dist/assets/meshopt.js +403 -0
- package/dist/audio/audio.d.ts +733 -0
- package/dist/audio/audio.js +2013 -0
- package/dist/audio/dsp.d.ts +61 -0
- package/dist/audio/dsp.js +159 -0
- package/dist/audio/duck.d.ts +44 -0
- package/dist/audio/duck.js +101 -0
- package/dist/audio/envelope.d.ts +53 -0
- package/dist/audio/envelope.js +106 -0
- package/dist/audio/index.d.ts +13 -0
- package/dist/audio/index.js +14 -0
- package/dist/audio/music.d.ts +113 -0
- package/dist/audio/music.js +548 -0
- package/dist/audio/occlusion.d.ts +55 -0
- package/dist/audio/occlusion.js +61 -0
- package/dist/audio/persist.d.ts +23 -0
- package/dist/audio/persist.js +27 -0
- package/dist/audio/recipes.d.ts +55 -0
- package/dist/audio/recipes.js +227 -0
- package/dist/audio/send.d.ts +41 -0
- package/dist/audio/send.js +118 -0
- package/dist/audio/sprites.d.ts +59 -0
- package/dist/audio/sprites.js +43 -0
- package/dist/audio/synth.d.ts +103 -0
- package/dist/audio/synth.js +197 -0
- package/dist/audio/variations.d.ts +25 -0
- package/dist/audio/variations.js +55 -0
- package/dist/audio/zones.d.ts +100 -0
- package/dist/audio/zones.js +167 -0
- package/dist/core/detached-surface.d.ts +44 -0
- package/dist/core/detached-surface.js +104 -0
- package/dist/core/events.d.ts +24 -0
- package/dist/core/events.js +43 -0
- package/dist/core/game.d.ts +409 -0
- package/dist/core/game.js +530 -0
- package/dist/core/hash.d.ts +24 -0
- package/dist/core/hash.js +49 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.js +9 -0
- package/dist/core/loop.d.ts +113 -0
- package/dist/core/loop.js +185 -0
- package/dist/core/math.d.ts +58 -0
- package/dist/core/math.js +210 -0
- package/dist/core/noise.d.ts +40 -0
- package/dist/core/noise.js +247 -0
- package/dist/core/pool.d.ts +42 -0
- package/dist/core/pool.js +85 -0
- package/dist/core/random.d.ts +61 -0
- package/dist/core/random.js +199 -0
- package/dist/core/scene.d.ts +79 -0
- package/dist/core/scene.js +195 -0
- package/dist/core/timer.d.ts +66 -0
- package/dist/core/timer.js +286 -0
- package/dist/fx/billboard-wgsl.d.ts +29 -0
- package/dist/fx/billboard-wgsl.js +158 -0
- package/dist/fx/billboard.d.ts +249 -0
- package/dist/fx/billboard.js +702 -0
- package/dist/fx/debug-draw.d.ts +127 -0
- package/dist/fx/debug-draw.js +523 -0
- package/dist/fx/debug-wgsl.d.ts +7 -0
- package/dist/fx/debug-wgsl.js +32 -0
- package/dist/fx/decals-wgsl.d.ts +15 -0
- package/dist/fx/decals-wgsl.js +173 -0
- package/dist/fx/decals.d.ts +216 -0
- package/dist/fx/decals.js +607 -0
- package/dist/fx/effect.d.ts +129 -0
- package/dist/fx/effect.js +349 -0
- package/dist/fx/effects/hit-spark.json +48 -0
- package/dist/fx/effects/index.d.ts +8 -0
- package/dist/fx/effects/index.js +9 -0
- package/dist/fx/effects/magic-aura.json +47 -0
- package/dist/fx/effects/smoke-puff.json +34 -0
- package/dist/fx/emit-surface.d.ts +40 -0
- package/dist/fx/emit-surface.js +124 -0
- package/dist/fx/flicker.d.ts +30 -0
- package/dist/fx/flicker.js +42 -0
- package/dist/fx/index.d.ts +31 -0
- package/dist/fx/index.js +20 -0
- package/dist/fx/particles-wgsl.d.ts +57 -0
- package/dist/fx/particles-wgsl.js +828 -0
- package/dist/fx/particles.d.ts +566 -0
- package/dist/fx/particles.js +1533 -0
- package/dist/fx/random.d.ts +15 -0
- package/dist/fx/random.js +20 -0
- package/dist/fx/sky-wgsl.d.ts +23 -0
- package/dist/fx/sky-wgsl.js +127 -0
- package/dist/fx/sky.d.ts +96 -0
- package/dist/fx/sky.js +175 -0
- package/dist/fx/text-atlas.d.ts +143 -0
- package/dist/fx/text-atlas.js +327 -0
- package/dist/fx/trail-wgsl.d.ts +11 -0
- package/dist/fx/trail-wgsl.js +42 -0
- package/dist/fx/trail.d.ts +194 -0
- package/dist/fx/trail.js +555 -0
- package/dist/fx/water-wgsl.d.ts +34 -0
- package/dist/fx/water-wgsl.js +197 -0
- package/dist/fx/water.d.ts +176 -0
- package/dist/fx/water.js +371 -0
- package/dist/geom/collide/bvh.d.ts +151 -0
- package/dist/geom/collide/bvh.js +493 -0
- package/dist/geom/collide/debug.d.ts +14 -0
- package/dist/geom/collide/debug.js +74 -0
- package/dist/geom/collide/grid.d.ts +26 -0
- package/dist/geom/collide/grid.js +91 -0
- package/dist/geom/collide/index.d.ts +8 -0
- package/dist/geom/collide/index.js +8 -0
- package/dist/geom/collide/queries.d.ts +70 -0
- package/dist/geom/collide/queries.js +583 -0
- package/dist/geom/collide/serialize.d.ts +14 -0
- package/dist/geom/collide/serialize.js +145 -0
- package/dist/geom/collide/shapes.d.ts +161 -0
- package/dist/geom/collide/shapes.js +686 -0
- package/dist/geom/collide/slide.d.ts +66 -0
- package/dist/geom/collide/slide.js +375 -0
- package/dist/geom/collide/tile-slide.d.ts +88 -0
- package/dist/geom/collide/tile-slide.js +255 -0
- package/dist/geom/geometry.d.ts +95 -0
- package/dist/geom/geometry.js +322 -0
- package/dist/geom/heightfield.d.ts +72 -0
- package/dist/geom/heightfield.js +295 -0
- package/dist/geom/hull.d.ts +27 -0
- package/dist/geom/hull.js +279 -0
- package/dist/geom/index.d.ts +35 -0
- package/dist/geom/index.js +23 -0
- package/dist/geom/iso.d.ts +74 -0
- package/dist/geom/iso.js +89 -0
- package/dist/geom/lod.d.ts +29 -0
- package/dist/geom/lod.js +27 -0
- package/dist/geom/ops.d.ts +47 -0
- package/dist/geom/ops.js +285 -0
- package/dist/geom/primitives.d.ts +97 -0
- package/dist/geom/primitives.js +359 -0
- package/dist/geom/shapes.d.ts +43 -0
- package/dist/geom/shapes.js +88 -0
- package/dist/geom/simplify.d.ts +38 -0
- package/dist/geom/simplify.js +519 -0
- package/dist/geom/split.d.ts +37 -0
- package/dist/geom/split.js +413 -0
- package/dist/geom/sweep.d.ts +83 -0
- package/dist/geom/sweep.js +534 -0
- package/dist/geom/tangents.d.ts +16 -0
- package/dist/geom/tangents.js +124 -0
- package/dist/geom/triangulate.d.ts +19 -0
- package/dist/geom/triangulate.js +147 -0
- package/dist/host/default.d.ts +10 -0
- package/dist/host/default.js +10 -0
- package/dist/host/index.d.ts +4 -0
- package/dist/host/index.js +3 -0
- package/dist/host/types.d.ts +144 -0
- package/dist/host/types.js +1 -0
- package/dist/host/web-links.d.ts +38 -0
- package/dist/host/web-links.js +110 -0
- package/dist/host/web.d.ts +30 -0
- package/dist/host/web.js +216 -0
- package/dist/host/xr.d.ts +24 -0
- package/dist/host/xr.js +59 -0
- package/dist/i18n/i18n.d.ts +59 -0
- package/dist/i18n/i18n.js +241 -0
- package/dist/i18n/index.d.ts +3 -0
- package/dist/i18n/index.js +3 -0
- package/dist/i18n/message.d.ts +72 -0
- package/dist/i18n/message.js +369 -0
- package/dist/i18n/negotiate.d.ts +17 -0
- package/dist/i18n/negotiate.js +58 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1 -0
- package/dist/input/bindings.d.ts +31 -0
- package/dist/input/bindings.js +141 -0
- package/dist/input/clock.d.ts +44 -0
- package/dist/input/clock.js +49 -0
- package/dist/input/follow.d.ts +133 -0
- package/dist/input/follow.js +218 -0
- package/dist/input/gestures.d.ts +281 -0
- package/dist/input/gestures.js +662 -0
- package/dist/input/glyphs.d.ts +35 -0
- package/dist/input/glyphs.js +216 -0
- package/dist/input/gyro.d.ts +85 -0
- package/dist/input/gyro.js +140 -0
- package/dist/input/index.d.ts +19 -0
- package/dist/input/index.js +16 -0
- package/dist/input/input.d.ts +543 -0
- package/dist/input/input.js +1492 -0
- package/dist/input/look.d.ts +327 -0
- package/dist/input/look.js +681 -0
- package/dist/input/magnify.d.ts +177 -0
- package/dist/input/magnify.js +131 -0
- package/dist/input/pointer-dispatch.d.ts +138 -0
- package/dist/input/pointer-dispatch.js +180 -0
- package/dist/input/pointer-router.d.ts +204 -0
- package/dist/input/pointer-router.js +533 -0
- package/dist/input/pointer-sources.d.ts +164 -0
- package/dist/input/pointer-sources.js +306 -0
- package/dist/input/rays.d.ts +138 -0
- package/dist/input/rays.js +615 -0
- package/dist/input/record.d.ts +209 -0
- package/dist/input/record.js +601 -0
- package/dist/input/touch-zones.d.ts +147 -0
- package/dist/input/touch-zones.js +300 -0
- package/dist/input/types.d.ts +158 -0
- package/dist/input/types.js +1 -0
- package/dist/input/xr-source.d.ts +53 -0
- package/dist/input/xr-source.js +164 -0
- package/dist/nav/astar.d.ts +45 -0
- package/dist/nav/astar.js +182 -0
- package/dist/nav/corridor.d.ts +40 -0
- package/dist/nav/corridor.js +95 -0
- package/dist/nav/crowd.d.ts +94 -0
- package/dist/nav/crowd.js +624 -0
- package/dist/nav/flow.d.ts +61 -0
- package/dist/nav/flow.js +193 -0
- package/dist/nav/grid.d.ts +108 -0
- package/dist/nav/grid.js +210 -0
- package/dist/nav/heap.d.ts +22 -0
- package/dist/nav/heap.js +92 -0
- package/dist/nav/index.d.ts +37 -0
- package/dist/nav/index.js +19 -0
- package/dist/nav/mesh/areas.d.ts +44 -0
- package/dist/nav/mesh/areas.js +98 -0
- package/dist/nav/mesh/bake-options.d.ts +33 -0
- package/dist/nav/mesh/bake-options.js +76 -0
- package/dist/nav/mesh/contour.d.ts +22 -0
- package/dist/nav/mesh/contour.js +323 -0
- package/dist/nav/mesh/flow3.d.ts +35 -0
- package/dist/nav/mesh/flow3.js +229 -0
- package/dist/nav/mesh/links.d.ts +38 -0
- package/dist/nav/mesh/links.js +72 -0
- package/dist/nav/mesh/mesh-path.d.ts +152 -0
- package/dist/nav/mesh/mesh-path.js +643 -0
- package/dist/nav/mesh/mesh-reach.d.ts +78 -0
- package/dist/nav/mesh/mesh-reach.js +450 -0
- package/dist/nav/mesh/navmesh.d.ts +117 -0
- package/dist/nav/mesh/navmesh.js +647 -0
- package/dist/nav/mesh/polymesh.d.ts +24 -0
- package/dist/nav/mesh/polymesh.js +384 -0
- package/dist/nav/mesh/regions.d.ts +12 -0
- package/dist/nav/mesh/regions.js +367 -0
- package/dist/nav/mesh/serialize.d.ts +13 -0
- package/dist/nav/mesh/serialize.js +197 -0
- package/dist/nav/mesh/voxelize.d.ts +111 -0
- package/dist/nav/mesh/voxelize.js +552 -0
- package/dist/nav/obstacles.d.ts +75 -0
- package/dist/nav/obstacles.js +294 -0
- package/dist/nav/path.d.ts +59 -0
- package/dist/nav/path.js +177 -0
- package/dist/nav/query-queue.d.ts +46 -0
- package/dist/nav/query-queue.js +308 -0
- package/dist/nav/reach.d.ts +53 -0
- package/dist/nav/reach.js +148 -0
- package/dist/nav/steer.d.ts +36 -0
- package/dist/nav/steer.js +78 -0
- package/dist/platform/boot.d.ts +28 -0
- package/dist/platform/boot.js +43 -0
- package/dist/platform/device-mode.d.ts +40 -0
- package/dist/platform/device-mode.js +104 -0
- package/dist/platform/device.d.ts +31 -0
- package/dist/platform/device.js +91 -0
- package/dist/platform/environment.d.ts +42 -0
- package/dist/platform/environment.js +82 -0
- package/dist/platform/gpu.d.ts +40 -0
- package/dist/platform/gpu.js +57 -0
- package/dist/platform/index.d.ts +18 -0
- package/dist/platform/index.js +10 -0
- package/dist/platform/letterbox.d.ts +43 -0
- package/dist/platform/letterbox.js +57 -0
- package/dist/platform/links.d.ts +20 -0
- package/dist/platform/links.js +43 -0
- package/dist/platform/screens.d.ts +36 -0
- package/dist/platform/screens.js +138 -0
- package/dist/platform/surface.d.ts +72 -0
- package/dist/platform/surface.js +157 -0
- package/dist/platform/xr-fake.d.ts +168 -0
- package/dist/platform/xr-fake.js +434 -0
- package/dist/platform/xr-surface.d.ts +120 -0
- package/dist/platform/xr-surface.js +214 -0
- package/dist/platform/xr-types.d.ts +262 -0
- package/dist/post/ao.d.ts +12 -0
- package/dist/post/ao.js +136 -0
- package/dist/post/blend.d.ts +27 -0
- package/dist/post/blend.js +129 -0
- package/dist/post/depth-pass.d.ts +43 -0
- package/dist/post/depth-pass.js +118 -0
- package/dist/post/dof.d.ts +1 -0
- package/dist/post/dof.js +66 -0
- package/dist/post/exposure.d.ts +80 -0
- package/dist/post/exposure.js +322 -0
- package/dist/post/index.d.ts +12 -0
- package/dist/post/index.js +11 -0
- package/dist/post/lens-dirt.d.ts +13 -0
- package/dist/post/lens-dirt.js +91 -0
- package/dist/post/lut.d.ts +19 -0
- package/dist/post/lut.js +50 -0
- package/dist/post/motion-blur.d.ts +9 -0
- package/dist/post/motion-blur.js +78 -0
- package/dist/post/post-stack.d.ts +302 -0
- package/dist/post/post-stack.js +1137 -0
- package/dist/post/settings.d.ts +485 -0
- package/dist/post/settings.js +416 -0
- package/dist/post/shaders.d.ts +25 -0
- package/dist/post/shaders.js +410 -0
- package/dist/post/ssr.d.ts +16 -0
- package/dist/post/ssr.js +155 -0
- package/dist/post/taa.d.ts +74 -0
- package/dist/post/taa.js +235 -0
- package/dist/post/white-balance.d.ts +25 -0
- package/dist/post/white-balance.js +98 -0
- package/dist/render/bindings.d.ts +98 -0
- package/dist/render/bindings.js +281 -0
- package/dist/render/blend.d.ts +13 -0
- package/dist/render/blend.js +26 -0
- package/dist/render/camera-2d.d.ts +72 -0
- package/dist/render/camera-2d.js +92 -0
- package/dist/render/camera.d.ts +302 -0
- package/dist/render/camera.js +671 -0
- package/dist/render/color.d.ts +16 -0
- package/dist/render/color.js +41 -0
- package/dist/render/compressed.d.ts +104 -0
- package/dist/render/compressed.js +248 -0
- package/dist/render/diagnostics.d.ts +15 -0
- package/dist/render/diagnostics.js +43 -0
- package/dist/render/dynamic-resolution.d.ts +70 -0
- package/dist/render/dynamic-resolution.js +126 -0
- package/dist/render/fake-gpu.d.ts +204 -0
- package/dist/render/fake-gpu.js +426 -0
- package/dist/render/flipbook.d.ts +56 -0
- package/dist/render/flipbook.js +87 -0
- package/dist/render/frame.d.ts +278 -0
- package/dist/render/frame.js +174 -0
- package/dist/render/fullscreen.d.ts +54 -0
- package/dist/render/fullscreen.js +91 -0
- package/dist/render/gpu-timer.d.ts +73 -0
- package/dist/render/gpu-timer.js +186 -0
- package/dist/render/index.d.ts +53 -0
- package/dist/render/index.js +29 -0
- package/dist/render/instance-list.d.ts +185 -0
- package/dist/render/instance-list.js +547 -0
- package/dist/render/lights.d.ts +181 -0
- package/dist/render/lights.js +233 -0
- package/dist/render/lod.d.ts +54 -0
- package/dist/render/lod.js +52 -0
- package/dist/render/material.d.ts +165 -0
- package/dist/render/material.js +292 -0
- package/dist/render/mesh.d.ts +284 -0
- package/dist/render/mesh.js +472 -0
- package/dist/render/mipmap.d.ts +16 -0
- package/dist/render/mipmap.js +121 -0
- package/dist/render/parallax.d.ts +50 -0
- package/dist/render/parallax.js +47 -0
- package/dist/render/pbr-wgsl.d.ts +31 -0
- package/dist/render/pbr-wgsl.js +119 -0
- package/dist/render/ping-pong.d.ts +42 -0
- package/dist/render/ping-pong.js +74 -0
- package/dist/render/renderer.d.ts +741 -0
- package/dist/render/renderer.js +2486 -0
- package/dist/render/scene-pipelines.d.ts +44 -0
- package/dist/render/scene-pipelines.js +92 -0
- package/dist/render/scene-target.d.ts +170 -0
- package/dist/render/scene-target.js +450 -0
- package/dist/render/shadow.d.ts +74 -0
- package/dist/render/shadow.js +208 -0
- package/dist/render/skin.d.ts +112 -0
- package/dist/render/skin.js +240 -0
- package/dist/render/snapshot.d.ts +36 -0
- package/dist/render/snapshot.js +34 -0
- package/dist/render/standard.d.ts +162 -0
- package/dist/render/standard.js +263 -0
- package/dist/render/texture.d.ts +142 -0
- package/dist/render/texture.js +228 -0
- package/dist/render/tile-layer.d.ts +110 -0
- package/dist/render/tile-layer.js +304 -0
- package/dist/render/transform.d.ts +115 -0
- package/dist/render/transform.js +318 -0
- package/dist/render/wgsl.d.ts +131 -0
- package/dist/render/wgsl.js +528 -0
- package/dist/save/codec.d.ts +49 -0
- package/dist/save/codec.js +169 -0
- package/dist/save/index.d.ts +8 -0
- package/dist/save/index.js +8 -0
- package/dist/save/journal.d.ts +24 -0
- package/dist/save/journal.js +102 -0
- package/dist/save/migrate.d.ts +15 -0
- package/dist/save/migrate.js +37 -0
- package/dist/save/schema.d.ts +72 -0
- package/dist/save/schema.js +154 -0
- package/dist/save/settings.d.ts +28 -0
- package/dist/save/settings.js +59 -0
- package/dist/save/slots.d.ts +56 -0
- package/dist/save/slots.js +200 -0
- package/dist/save/storage.d.ts +35 -0
- package/dist/save/storage.js +62 -0
- package/dist/save/store.d.ts +114 -0
- package/dist/save/store.js +269 -0
- package/dist/testing/fake-host.d.ts +103 -0
- package/dist/testing/fake-host.js +163 -0
- package/dist/testing/fixed-clock.d.ts +25 -0
- package/dist/testing/fixed-clock.js +30 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/test-game.d.ts +76 -0
- package/dist/testing/test-game.js +92 -0
- package/dist/tween/ease.d.ts +77 -0
- package/dist/tween/ease.js +192 -0
- package/dist/tween/index.d.ts +8 -0
- package/dist/tween/index.js +7 -0
- package/dist/tween/timeline.d.ts +78 -0
- package/dist/tween/timeline.js +269 -0
- package/dist/tween/tween.d.ts +168 -0
- package/dist/tween/tween.js +320 -0
- package/dist/ui/anchor.d.ts +206 -0
- package/dist/ui/anchor.js +307 -0
- package/dist/ui/caption.d.ts +122 -0
- package/dist/ui/caption.js +286 -0
- package/dist/ui/cursor.d.ts +105 -0
- package/dist/ui/cursor.js +261 -0
- package/dist/ui/dom.d.ts +30 -0
- package/dist/ui/dom.js +92 -0
- package/dist/ui/floats.d.ts +86 -0
- package/dist/ui/floats.js +260 -0
- package/dist/ui/hud.d.ts +198 -0
- package/dist/ui/hud.js +431 -0
- package/dist/ui/index.d.ts +37 -0
- package/dist/ui/index.js +34 -0
- package/dist/ui/inventory-card.d.ts +52 -0
- package/dist/ui/inventory-card.js +173 -0
- package/dist/ui/inventory-context.d.ts +29 -0
- package/dist/ui/inventory-context.js +125 -0
- package/dist/ui/inventory-drag.d.ts +63 -0
- package/dist/ui/inventory-drag.js +394 -0
- package/dist/ui/inventory-grid.d.ts +66 -0
- package/dist/ui/inventory-grid.js +625 -0
- package/dist/ui/inventory-radial.d.ts +39 -0
- package/dist/ui/inventory-radial.js +168 -0
- package/dist/ui/inventory.d.ts +128 -0
- package/dist/ui/inventory.js +154 -0
- package/dist/ui/layout.d.ts +356 -0
- package/dist/ui/layout.js +856 -0
- package/dist/ui/link.d.ts +64 -0
- package/dist/ui/link.js +155 -0
- package/dist/ui/loading.d.ts +40 -0
- package/dist/ui/loading.js +157 -0
- package/dist/ui/loupe.d.ts +74 -0
- package/dist/ui/loupe.js +164 -0
- package/dist/ui/menu-nav.d.ts +33 -0
- package/dist/ui/menu-nav.js +28 -0
- package/dist/ui/minimap-canvas.d.ts +145 -0
- package/dist/ui/minimap-canvas.js +460 -0
- package/dist/ui/notify.d.ts +40 -0
- package/dist/ui/notify.js +141 -0
- package/dist/ui/orb.d.ts +53 -0
- package/dist/ui/orb.js +304 -0
- package/dist/ui/overlay.d.ts +213 -0
- package/dist/ui/overlay.js +394 -0
- package/dist/ui/padnav.d.ts +53 -0
- package/dist/ui/padnav.js +146 -0
- package/dist/ui/perf.d.ts +130 -0
- package/dist/ui/perf.js +329 -0
- package/dist/ui/rotate-card.d.ts +54 -0
- package/dist/ui/rotate-card.js +97 -0
- package/dist/ui/stack.d.ts +128 -0
- package/dist/ui/stack.js +312 -0
- package/dist/ui/styles.d.ts +13 -0
- package/dist/ui/styles.js +200 -0
- package/dist/ui/text-scale.d.ts +67 -0
- package/dist/ui/text-scale.js +102 -0
- package/dist/ui/theme.d.ts +70 -0
- package/dist/ui/theme.js +98 -0
- package/dist/ui/toast.d.ts +23 -0
- package/dist/ui/toast.js +46 -0
- package/dist/ui/tooltip.d.ts +89 -0
- package/dist/ui/tooltip.js +333 -0
- package/dist/ui/widgets-2.d.ts +134 -0
- package/dist/ui/widgets-2.js +399 -0
- package/dist/ui/widgets-3.d.ts +91 -0
- package/dist/ui/widgets-3.js +212 -0
- package/dist/ui/widgets.d.ts +34 -0
- package/dist/ui/widgets.js +50 -0
- package/dist/ui/world-panel/index.d.ts +5 -0
- package/dist/ui/world-panel/index.js +5 -0
- package/dist/ui/world-panel/layout.d.ts +38 -0
- package/dist/ui/world-panel/layout.js +68 -0
- package/dist/ui/world-panel/paint.d.ts +96 -0
- package/dist/ui/world-panel/paint.js +136 -0
- package/dist/ui/world-panel/panel.d.ts +235 -0
- package/dist/ui/world-panel/panel.js +762 -0
- package/dist/ui/world-panel/wgsl.d.ts +10 -0
- package/dist/ui/world-panel/wgsl.js +52 -0
- package/dist/ui/world-panel/widgets.d.ts +207 -0
- package/dist/ui/world-panel/widgets.js +569 -0
- package/dist/voxel/chunks.d.ts +77 -0
- package/dist/voxel/chunks.js +149 -0
- package/dist/voxel/grid.d.ts +84 -0
- package/dist/voxel/grid.js +223 -0
- package/dist/voxel/index.d.ts +22 -0
- package/dist/voxel/index.js +7 -0
- package/dist/voxel/mesher-worker.d.ts +14 -0
- package/dist/voxel/mesher-worker.js +47 -0
- package/dist/voxel/mesher.d.ts +73 -0
- package/dist/voxel/mesher.js +306 -0
- package/dist/voxel/ray.d.ts +32 -0
- package/dist/voxel/ray.js +73 -0
- package/dist/voxel/wgsl.d.ts +16 -0
- package/dist/voxel/wgsl.js +28 -0
- package/dist/voxel/worker-client.d.ts +79 -0
- package/dist/voxel/worker-client.js +122 -0
- package/package.json +177 -3
|
@@ -0,0 +1,2013 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime audio: an `AudioContext` created and resumed on the first user gesture, four buses
|
|
3
|
+
* (master ← music, sfx, ui), named sounds rendered from specs, one-shots and loops.
|
|
4
|
+
*/
|
|
5
|
+
import { reverbImpulse, volumeToGain } from './dsp.js';
|
|
6
|
+
import { DEFAULT_OCCLUSION, OcclusionScheduler, } from './occlusion.js';
|
|
7
|
+
import { parseSpriteRef, spriteSchedule } from './sprites.js';
|
|
8
|
+
import { renderSound } from './synth.js';
|
|
9
|
+
import { AudioZones } from './zones.js';
|
|
10
|
+
export const BUS_NAMES = ['master', 'music', 'sfx', 'ui'];
|
|
11
|
+
export const DEFAULT_MIXER = {
|
|
12
|
+
master: { volume: 0.8, muted: false },
|
|
13
|
+
music: { volume: 0.7, muted: false },
|
|
14
|
+
sfx: { volume: 0.9, muted: false },
|
|
15
|
+
ui: { volume: 0.8, muted: false },
|
|
16
|
+
};
|
|
17
|
+
/** Clamp and fill gaps so any (possibly hand-edited) object becomes valid settings. */
|
|
18
|
+
export function sanitizeMixer(input) {
|
|
19
|
+
const src = (typeof input === 'object' && input !== null ? input : {});
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const bus of BUS_NAMES) {
|
|
22
|
+
const b = (typeof src[bus] === 'object' && src[bus] !== null ? src[bus] : {});
|
|
23
|
+
const vol = typeof b.volume === 'number' && Number.isFinite(b.volume)
|
|
24
|
+
? b.volume
|
|
25
|
+
: DEFAULT_MIXER[bus].volume;
|
|
26
|
+
out[bus] = {
|
|
27
|
+
volume: Math.min(1, Math.max(0, vol)),
|
|
28
|
+
muted: typeof b.muted === 'boolean' ? b.muted : DEFAULT_MIXER[bus].muted,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Effective linear gain for a bus. */
|
|
34
|
+
export function busGain(s) {
|
|
35
|
+
return s.muted ? 0 : volumeToGain(s.volume);
|
|
36
|
+
}
|
|
37
|
+
/** m/s, the Doppler reference (`SpatialOptions.doppler`). */
|
|
38
|
+
export const SPEED_OF_SOUND = 343;
|
|
39
|
+
/** m/s: a move faster than this between two `update`s is a teleport, not a velocity. */
|
|
40
|
+
export const DEFAULT_MAX_DOPPLER_SPEED = 100;
|
|
41
|
+
/** Sends one voice can hold at once (`Voice.sendTo`). */
|
|
42
|
+
export const MAX_VOICE_SENDS = 4;
|
|
43
|
+
/** Seconds `pause` / `unpause` ramp bus gains and voice rates over. */
|
|
44
|
+
const PAUSE_RAMP = 0.05;
|
|
45
|
+
/**
|
|
46
|
+
* Internal: write `value` to an AudioParam as a linear ramp over `ramp` seconds from `t`,
|
|
47
|
+
* cancelling what was scheduled (a step when `ramp` is 0).
|
|
48
|
+
*/
|
|
49
|
+
export function rampParamTo(param, value, t, ramp) {
|
|
50
|
+
param.cancelScheduledValues(t);
|
|
51
|
+
param.setValueAtTime(param.value, t);
|
|
52
|
+
if (ramp > 0)
|
|
53
|
+
param.linearRampToValueAtTime(value, t + ramp);
|
|
54
|
+
else
|
|
55
|
+
param.setValueAtTime(value, t);
|
|
56
|
+
}
|
|
57
|
+
function sendInputOf(target) {
|
|
58
|
+
return typeof target.connect === 'function'
|
|
59
|
+
? target
|
|
60
|
+
: target.input;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Internal: the parallel sends behind `Voice.sendTo`, one per voice, shared by the mixer's own
|
|
64
|
+
* voices and music stems. `attach` names the node to tap once it exists; sends set before that
|
|
65
|
+
* are built then. Each live send is one `GainNode`: tap → gain → input.
|
|
66
|
+
*/
|
|
67
|
+
export class VoiceSends {
|
|
68
|
+
taps = [];
|
|
69
|
+
from = null;
|
|
70
|
+
ctx = null;
|
|
71
|
+
/** True once `attach` ran (the sends are in the graph). */
|
|
72
|
+
get attached() {
|
|
73
|
+
return this.from !== null;
|
|
74
|
+
}
|
|
75
|
+
/** Live sends. */
|
|
76
|
+
get count() {
|
|
77
|
+
return this.taps.length;
|
|
78
|
+
}
|
|
79
|
+
/** The voice's output node is known: build the sends set so far. */
|
|
80
|
+
attach(ctx, from) {
|
|
81
|
+
this.ctx = ctx;
|
|
82
|
+
this.from = from;
|
|
83
|
+
for (const tap of this.taps)
|
|
84
|
+
if (!tap.gain)
|
|
85
|
+
this.build(tap, 0);
|
|
86
|
+
}
|
|
87
|
+
/** The voice's output node changed (a filter went in or out): move the taps to `from`. */
|
|
88
|
+
retarget(from) {
|
|
89
|
+
const old = this.from;
|
|
90
|
+
if (old === from)
|
|
91
|
+
return;
|
|
92
|
+
this.from = from;
|
|
93
|
+
for (const tap of this.taps) {
|
|
94
|
+
if (!tap.gain)
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
old?.disconnect(tap.gain);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Not connected.
|
|
101
|
+
}
|
|
102
|
+
from.connect(tap.gain);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
set(target, amount, ramp) {
|
|
106
|
+
const i = this.taps.findIndex((t) => t.target === target);
|
|
107
|
+
if (!(amount > 0)) {
|
|
108
|
+
if (i >= 0)
|
|
109
|
+
this.remove(i);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (i < 0) {
|
|
113
|
+
if (this.taps.length >= MAX_VOICE_SENDS) {
|
|
114
|
+
throw new RangeError(`a voice has at most ${MAX_VOICE_SENDS} sends`);
|
|
115
|
+
}
|
|
116
|
+
const tap = { target, gain: null, amount };
|
|
117
|
+
this.taps.push(tap);
|
|
118
|
+
if (this.from)
|
|
119
|
+
this.build(tap, ramp);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const tap = this.taps[i];
|
|
123
|
+
tap.amount = amount;
|
|
124
|
+
if (tap.gain && this.ctx)
|
|
125
|
+
rampParamTo(tap.gain.gain, amount, this.ctx.currentTime, ramp);
|
|
126
|
+
}
|
|
127
|
+
build(tap, ramp) {
|
|
128
|
+
const ctx = this.ctx;
|
|
129
|
+
const from = this.from;
|
|
130
|
+
if (!ctx || !from)
|
|
131
|
+
return;
|
|
132
|
+
const input = sendInputOf(tap.target);
|
|
133
|
+
// A `Send` without a node (context lacks the effect) stays pending and silent.
|
|
134
|
+
if (!input)
|
|
135
|
+
return;
|
|
136
|
+
const g = ctx.createGain();
|
|
137
|
+
if (ramp > 0) {
|
|
138
|
+
g.gain.value = 0;
|
|
139
|
+
rampParamTo(g.gain, tap.amount, ctx.currentTime, ramp);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
g.gain.value = tap.amount;
|
|
143
|
+
}
|
|
144
|
+
from.connect(g);
|
|
145
|
+
g.connect(input);
|
|
146
|
+
tap.gain = g;
|
|
147
|
+
}
|
|
148
|
+
remove(i) {
|
|
149
|
+
const tap = this.taps[i];
|
|
150
|
+
this.taps.splice(i, 1);
|
|
151
|
+
if (!tap.gain)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
this.from?.disconnect(tap.gain);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Not connected.
|
|
158
|
+
}
|
|
159
|
+
tap.gain.disconnect();
|
|
160
|
+
}
|
|
161
|
+
/** Disconnect and drop every send (the voice ended). */
|
|
162
|
+
dispose() {
|
|
163
|
+
for (let i = this.taps.length - 1; i >= 0; i--)
|
|
164
|
+
this.remove(i);
|
|
165
|
+
this.from = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Where the ears are: one per `AudioEngine` (`audio.listener`), written to the context's
|
|
170
|
+
* `AudioListener`. Safe before unlock: the pose is kept and applied when the context exists.
|
|
171
|
+
* Every method writes in place and allocates nothing, so call `fromCamera` every frame.
|
|
172
|
+
*/
|
|
173
|
+
export class SpatialListener {
|
|
174
|
+
engine;
|
|
175
|
+
/** px, py, pz, fx, fy, fz, ux, uy, uz. */
|
|
176
|
+
pose = new Float64Array([0, 0, 0, 0, 0, -1, 0, 1, 0]);
|
|
177
|
+
/** Current pose, read-only (change it with `set` or `fromCamera`). */
|
|
178
|
+
position = this.pose.subarray(0, 3);
|
|
179
|
+
forward = this.pose.subarray(3, 6);
|
|
180
|
+
up = this.pose.subarray(6, 9);
|
|
181
|
+
/** Position at the previous `audio.update`, and the velocity derived from it (m/s). */
|
|
182
|
+
prev = new Float64Array(3);
|
|
183
|
+
hasPrev = false;
|
|
184
|
+
vel = new Float64Array(3);
|
|
185
|
+
/** Velocity over the last `audio.update(dt)` (ws/audio-4), read-only; zeros after a teleport. */
|
|
186
|
+
velocity = this.vel;
|
|
187
|
+
/** m/s above which a move between updates resets the velocity (a teleport). Default 100. */
|
|
188
|
+
maxSpeed = DEFAULT_MAX_DOPPLER_SPEED;
|
|
189
|
+
/** Built by `AudioEngine`; reach it as `audio.listener`. */
|
|
190
|
+
constructor(engine) {
|
|
191
|
+
this.engine = engine;
|
|
192
|
+
}
|
|
193
|
+
/** Place the listener at `position`, facing `forward`, with `up` (default `[0, 1, 0]`). */
|
|
194
|
+
set(position, forward, up) {
|
|
195
|
+
const p = this.pose;
|
|
196
|
+
p[0] = position[0] ?? 0;
|
|
197
|
+
p[1] = position[1] ?? 0;
|
|
198
|
+
p[2] = position[2] ?? 0;
|
|
199
|
+
p[3] = forward[0] ?? 0;
|
|
200
|
+
p[4] = forward[1] ?? 0;
|
|
201
|
+
p[5] = forward[2] ?? -1;
|
|
202
|
+
p[6] = up ? (up[0] ?? 0) : 0;
|
|
203
|
+
p[7] = up ? (up[1] ?? 1) : 1;
|
|
204
|
+
p[8] = up ? (up[2] ?? 0) : 0;
|
|
205
|
+
this.apply();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Follow a camera: position from `camera.position`, forward and up from the view matrix
|
|
209
|
+
* (forward is minus its third row, up its second; the view looks down -z).
|
|
210
|
+
*/
|
|
211
|
+
fromCamera(camera) {
|
|
212
|
+
const v = camera.view;
|
|
213
|
+
const pos = camera.position;
|
|
214
|
+
const p = this.pose;
|
|
215
|
+
p[0] = pos[0];
|
|
216
|
+
p[1] = pos[1];
|
|
217
|
+
p[2] = pos[2];
|
|
218
|
+
p[3] = -v[2];
|
|
219
|
+
p[4] = -v[6];
|
|
220
|
+
p[5] = -v[10];
|
|
221
|
+
p[6] = v[1];
|
|
222
|
+
p[7] = v[5];
|
|
223
|
+
p[8] = v[9];
|
|
224
|
+
this.apply();
|
|
225
|
+
}
|
|
226
|
+
/** Internal (`audio.update`): derive the velocity from the move since the last call. */
|
|
227
|
+
tick(dt) {
|
|
228
|
+
const p = this.pose;
|
|
229
|
+
const q = this.prev;
|
|
230
|
+
const v = this.vel;
|
|
231
|
+
if (this.hasPrev && dt > 0) {
|
|
232
|
+
const vx = (p[0] - q[0]) / dt;
|
|
233
|
+
const vy = (p[1] - q[1]) / dt;
|
|
234
|
+
const vz = (p[2] - q[2]) / dt;
|
|
235
|
+
const teleport = vx * vx + vy * vy + vz * vz > this.maxSpeed * this.maxSpeed;
|
|
236
|
+
v[0] = teleport ? 0 : vx;
|
|
237
|
+
v[1] = teleport ? 0 : vy;
|
|
238
|
+
v[2] = teleport ? 0 : vz;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
v[0] = v[1] = v[2] = 0;
|
|
242
|
+
}
|
|
243
|
+
q[0] = p[0];
|
|
244
|
+
q[1] = p[1];
|
|
245
|
+
q[2] = p[2];
|
|
246
|
+
this.hasPrev = true;
|
|
247
|
+
}
|
|
248
|
+
/** Write the pose to the context's listener (on every change, and when the context is built). */
|
|
249
|
+
apply() {
|
|
250
|
+
const l = this.engine.context?.listener;
|
|
251
|
+
if (!l)
|
|
252
|
+
return;
|
|
253
|
+
const p = this.pose;
|
|
254
|
+
if (l.positionX) {
|
|
255
|
+
l.positionX.value = p[0];
|
|
256
|
+
l.positionY.value = p[1];
|
|
257
|
+
l.positionZ.value = p[2];
|
|
258
|
+
l.forwardX.value = p[3];
|
|
259
|
+
l.forwardY.value = p[4];
|
|
260
|
+
l.forwardZ.value = p[5];
|
|
261
|
+
l.upX.value = p[6];
|
|
262
|
+
l.upY.value = p[7];
|
|
263
|
+
l.upZ.value = p[8];
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// Older Safari: the deprecated setters.
|
|
267
|
+
const legacy = l;
|
|
268
|
+
legacy.setPosition?.(p[0], p[1], p[2]);
|
|
269
|
+
legacy.setOrientation?.(p[3], p[4], p[5], p[6], p[7], p[8]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function writePannerPosition(p, x, y, z) {
|
|
274
|
+
if (p.positionX) {
|
|
275
|
+
p.positionX.value = x;
|
|
276
|
+
p.positionY.value = y;
|
|
277
|
+
p.positionZ.value = z;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
p.setPosition?.(x, y, z);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function writePannerDirection(p, x, y, z) {
|
|
284
|
+
if (p.orientationX) {
|
|
285
|
+
p.orientationX.value = x;
|
|
286
|
+
p.orientationY.value = y;
|
|
287
|
+
p.orientationZ.value = z;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
p.setOrientation?.(x, y, z);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const NULL_VOICE = {
|
|
294
|
+
stop() { },
|
|
295
|
+
setGain() { },
|
|
296
|
+
setRate() { },
|
|
297
|
+
setPosition() { },
|
|
298
|
+
setDirection() { },
|
|
299
|
+
follow() { },
|
|
300
|
+
sendTo() { },
|
|
301
|
+
filter() { },
|
|
302
|
+
position: new Float64Array(3),
|
|
303
|
+
playing: false,
|
|
304
|
+
sendCount: 0,
|
|
305
|
+
occlusion: 0,
|
|
306
|
+
doppler: 1,
|
|
307
|
+
};
|
|
308
|
+
/** Internal: set a pooled biquad's type, cutoff and Q (a step, or a ramp from the current values). */
|
|
309
|
+
function tuneBiquad(f, type, cutoff, q, t, ramp) {
|
|
310
|
+
f.type = type;
|
|
311
|
+
rampParamTo(f.frequency, cutoff, t, ramp);
|
|
312
|
+
rampParamTo(f.Q, q, t, ramp);
|
|
313
|
+
}
|
|
314
|
+
/** One-shots requested while their buffer is still rendering are dropped after this long. */
|
|
315
|
+
const MAX_LATE_START = 0.15;
|
|
316
|
+
/** `prepare` yields once the synchronous render starts in one turn take this long (ms). */
|
|
317
|
+
const PREPARE_SLICE_MS = 10;
|
|
318
|
+
/** Resolve on a fresh task (not a microtask), so frames and input can run in between. */
|
|
319
|
+
function yieldToEventLoop() {
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
if (typeof MessageChannel === 'undefined') {
|
|
322
|
+
setTimeout(resolve, 0);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const ch = new MessageChannel();
|
|
326
|
+
ch.port1.onmessage = () => {
|
|
327
|
+
ch.port1.close();
|
|
328
|
+
resolve();
|
|
329
|
+
};
|
|
330
|
+
ch.port2.postMessage(0);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
class VoiceImpl {
|
|
334
|
+
engine;
|
|
335
|
+
loop;
|
|
336
|
+
opts;
|
|
337
|
+
onEnd;
|
|
338
|
+
schedule;
|
|
339
|
+
src = null;
|
|
340
|
+
gainNode = null;
|
|
341
|
+
stopped = false;
|
|
342
|
+
targetGain;
|
|
343
|
+
rate;
|
|
344
|
+
ended = false;
|
|
345
|
+
panner = null;
|
|
346
|
+
/** The node after the filters (panner or stereo panner), or null when the chain ends at the bus. */
|
|
347
|
+
out = null;
|
|
348
|
+
/** `out` is connected to the bus (done once; `relink` never breaks that link). */
|
|
349
|
+
outWired = false;
|
|
350
|
+
bus_ = null;
|
|
351
|
+
/** The last node before the bus (gain, filter, panner or stereo panner): what the sends tap. */
|
|
352
|
+
tail = null;
|
|
353
|
+
sends = null;
|
|
354
|
+
/** `audio.pause` set the playback rate to 0; `rate` keeps the value to restore. */
|
|
355
|
+
frozen = false;
|
|
356
|
+
/** Emitter position and cone direction (kept for a voice that has not started yet). */
|
|
357
|
+
spatial;
|
|
358
|
+
pos = new Float64Array(3);
|
|
359
|
+
target = null;
|
|
360
|
+
/** `target.changed` at the last `syncFollow`; a target without one is read every update. */
|
|
361
|
+
followSeen = -1;
|
|
362
|
+
dx = 1;
|
|
363
|
+
dy = 0;
|
|
364
|
+
dz = 0;
|
|
365
|
+
/** `Voice.filter`: the pooled biquad and what it was last asked for (applied at start). */
|
|
366
|
+
userFilter = null;
|
|
367
|
+
userKind = null;
|
|
368
|
+
userCutoff = 1000;
|
|
369
|
+
userQ = 1;
|
|
370
|
+
/** Occlusion: the pooled lowpass and the last probe (ws/audio-4). */
|
|
371
|
+
occFilter = null;
|
|
372
|
+
occAmount = 0;
|
|
373
|
+
occGain = 1;
|
|
374
|
+
occApplied = false;
|
|
375
|
+
occlusionProbedAt = Number.NEGATIVE_INFINITY;
|
|
376
|
+
/** Doppler: the previous position per update, and the multiplier on `rate`. */
|
|
377
|
+
prevPos = new Float64Array(3);
|
|
378
|
+
hasPrev = false;
|
|
379
|
+
dopplerRate = 1;
|
|
380
|
+
constructor(engine, loop, opts, onEnd,
|
|
381
|
+
/** A sprite clip's range, or null for the whole buffer. */
|
|
382
|
+
schedule = null) {
|
|
383
|
+
this.engine = engine;
|
|
384
|
+
this.loop = loop;
|
|
385
|
+
this.opts = opts;
|
|
386
|
+
this.onEnd = onEnd;
|
|
387
|
+
this.schedule = schedule;
|
|
388
|
+
this.targetGain = opts.gain ?? 1;
|
|
389
|
+
this.rate = opts.rate ?? 1;
|
|
390
|
+
const at = opts.at;
|
|
391
|
+
this.spatial = at !== undefined;
|
|
392
|
+
if (at) {
|
|
393
|
+
this.pos[0] = at[0] ?? 0;
|
|
394
|
+
this.pos[1] = at[1] ?? 0;
|
|
395
|
+
this.pos[2] = at[2] ?? 0;
|
|
396
|
+
const d = opts.spatial?.direction;
|
|
397
|
+
if (d) {
|
|
398
|
+
this.dx = d[0] ?? 0;
|
|
399
|
+
this.dy = d[1] ?? 0;
|
|
400
|
+
this.dz = d[2] ?? 0;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
get playing() {
|
|
405
|
+
return !this.stopped && !this.ended;
|
|
406
|
+
}
|
|
407
|
+
get position() {
|
|
408
|
+
return this.pos;
|
|
409
|
+
}
|
|
410
|
+
get started() {
|
|
411
|
+
return this.src !== null;
|
|
412
|
+
}
|
|
413
|
+
get bus() {
|
|
414
|
+
return this.opts.bus ?? (this.loop ? 'music' : 'sfx');
|
|
415
|
+
}
|
|
416
|
+
get occlusion() {
|
|
417
|
+
return this.occAmount;
|
|
418
|
+
}
|
|
419
|
+
/** Internal: a spatial voice `audio.occlusion` may probe (`spatial.occlude` not false). */
|
|
420
|
+
get occludable() {
|
|
421
|
+
return this.spatial && this.panner !== null && this.opts.spatial?.occlude !== false;
|
|
422
|
+
}
|
|
423
|
+
/** The Doppler multiplier on `rate` as of the last `audio.update` (1 without `spatial.doppler`; diagnostics). */
|
|
424
|
+
get doppler() {
|
|
425
|
+
return this.dopplerRate;
|
|
426
|
+
}
|
|
427
|
+
/** Internal: `spatial.doppler` above 0. */
|
|
428
|
+
get dopplerFactor() {
|
|
429
|
+
return this.spatial ? Math.max(0, this.opts.spatial?.doppler ?? 0) : 0;
|
|
430
|
+
}
|
|
431
|
+
/** Create nodes and start. Returns false if it can't (no context). */
|
|
432
|
+
start(buffer) {
|
|
433
|
+
const ctx = this.engine.context;
|
|
434
|
+
if (!ctx || this.stopped)
|
|
435
|
+
return false;
|
|
436
|
+
const bus = this.engine.busNode(this.bus);
|
|
437
|
+
if (!bus)
|
|
438
|
+
return false;
|
|
439
|
+
const src = ctx.createBufferSource();
|
|
440
|
+
src.buffer = buffer;
|
|
441
|
+
src.loop = this.loop;
|
|
442
|
+
// Starting on a paused bus whose voices are frozen: start frozen too.
|
|
443
|
+
if (this.engine.isBusPaused(this.bus) && this.engine.pausedRate)
|
|
444
|
+
this.frozen = true;
|
|
445
|
+
src.playbackRate.value = this.frozen ? 0 : this.rate * this.dopplerRate;
|
|
446
|
+
if (this.opts.detune)
|
|
447
|
+
src.detune.value = this.opts.detune;
|
|
448
|
+
const sched = this.schedule;
|
|
449
|
+
if (sched && this.loop) {
|
|
450
|
+
src.loopStart = sched.loopStart;
|
|
451
|
+
src.loopEnd = sched.loopEnd;
|
|
452
|
+
}
|
|
453
|
+
const g = ctx.createGain();
|
|
454
|
+
const t = ctx.currentTime + (this.opts.delay ?? 0);
|
|
455
|
+
const fadeIn = this.opts.fadeIn ?? (this.loop ? 0.5 : 0);
|
|
456
|
+
if (fadeIn > 0) {
|
|
457
|
+
g.gain.setValueAtTime(0, t);
|
|
458
|
+
g.gain.linearRampToValueAtTime(this.targetGain, t + fadeIn);
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
g.gain.value = this.targetGain;
|
|
462
|
+
}
|
|
463
|
+
const panner = this.spatial ? this.engine.acquirePanner(this.opts.spatial) : null;
|
|
464
|
+
if (panner) {
|
|
465
|
+
writePannerPosition(panner, this.pos[0], this.pos[1], this.pos[2]);
|
|
466
|
+
writePannerDirection(panner, this.dx, this.dy, this.dz);
|
|
467
|
+
this.out = panner;
|
|
468
|
+
this.panner = panner;
|
|
469
|
+
}
|
|
470
|
+
else if (this.opts.pan && !this.spatial) {
|
|
471
|
+
const p = ctx.createStereoPanner();
|
|
472
|
+
p.pan.value = this.opts.pan;
|
|
473
|
+
this.out = p;
|
|
474
|
+
}
|
|
475
|
+
src.connect(g);
|
|
476
|
+
this.gainNode = g;
|
|
477
|
+
this.bus_ = bus;
|
|
478
|
+
// A filter asked for before start.
|
|
479
|
+
if (this.userKind && !this.userFilter) {
|
|
480
|
+
const f = this.engine.acquireBiquad();
|
|
481
|
+
if (f) {
|
|
482
|
+
tuneBiquad(f, this.userKind, this.userCutoff, this.userQ, t, 0);
|
|
483
|
+
this.userFilter = f;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
this.relink();
|
|
487
|
+
src.onended = () => {
|
|
488
|
+
this.ended = true;
|
|
489
|
+
this.sends?.dispose();
|
|
490
|
+
this.sends = null;
|
|
491
|
+
g.disconnect();
|
|
492
|
+
this.out?.disconnect();
|
|
493
|
+
this.out = null;
|
|
494
|
+
this.outWired = false;
|
|
495
|
+
this.tail = null;
|
|
496
|
+
this.bus_ = null;
|
|
497
|
+
if (this.userFilter) {
|
|
498
|
+
this.userFilter.disconnect();
|
|
499
|
+
this.engine.releaseBiquad(this.userFilter);
|
|
500
|
+
this.userFilter = null;
|
|
501
|
+
}
|
|
502
|
+
if (this.occFilter) {
|
|
503
|
+
this.occFilter.disconnect();
|
|
504
|
+
this.engine.releaseBiquad(this.occFilter);
|
|
505
|
+
this.occFilter = null;
|
|
506
|
+
}
|
|
507
|
+
if (this.panner) {
|
|
508
|
+
this.engine.releasePanner(this.panner);
|
|
509
|
+
this.panner = null;
|
|
510
|
+
}
|
|
511
|
+
this.onEnd(this);
|
|
512
|
+
};
|
|
513
|
+
if (sched) {
|
|
514
|
+
if (this.loop)
|
|
515
|
+
src.start(t, sched.offset);
|
|
516
|
+
else
|
|
517
|
+
src.start(t, sched.offset, sched.duration);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
src.start(t);
|
|
521
|
+
}
|
|
522
|
+
this.src = src;
|
|
523
|
+
this.engine.trackSpatial(this);
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* (Re)wire gain → [user filter] → [occlusion lowpass] → [panner | stereo panner] → bus and
|
|
528
|
+
* point the sends at the new tail. Only the links that can change are touched.
|
|
529
|
+
*/
|
|
530
|
+
relink() {
|
|
531
|
+
const g = this.gainNode;
|
|
532
|
+
const bus = this.bus_;
|
|
533
|
+
const ctx = this.engine.context;
|
|
534
|
+
if (!g || !bus || !ctx)
|
|
535
|
+
return;
|
|
536
|
+
// The panner (or stereo panner) stays last, so its own links (bus, send taps) are kept;
|
|
537
|
+
// only the nodes before it are rewired.
|
|
538
|
+
g.disconnect();
|
|
539
|
+
this.userFilter?.disconnect();
|
|
540
|
+
this.occFilter?.disconnect();
|
|
541
|
+
let node = g;
|
|
542
|
+
if (this.userFilter) {
|
|
543
|
+
node.connect(this.userFilter);
|
|
544
|
+
node = this.userFilter;
|
|
545
|
+
}
|
|
546
|
+
if (this.occFilter) {
|
|
547
|
+
node.connect(this.occFilter);
|
|
548
|
+
node = this.occFilter;
|
|
549
|
+
}
|
|
550
|
+
if (this.out) {
|
|
551
|
+
node.connect(this.out);
|
|
552
|
+
node = this.out;
|
|
553
|
+
if (!this.outWired) {
|
|
554
|
+
this.out.connect(bus);
|
|
555
|
+
this.outWired = true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
node.connect(bus);
|
|
560
|
+
}
|
|
561
|
+
this.tail = node;
|
|
562
|
+
if (this.sends) {
|
|
563
|
+
if (this.sends.attached)
|
|
564
|
+
this.sends.retarget(node);
|
|
565
|
+
else
|
|
566
|
+
this.sends.attach(ctx, node);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
filter(kind, o = {}) {
|
|
570
|
+
if (this.ended || this.stopped)
|
|
571
|
+
return;
|
|
572
|
+
const ctx = this.engine.context;
|
|
573
|
+
const ramp = o.ramp ?? 0.05;
|
|
574
|
+
if (kind === null) {
|
|
575
|
+
this.userKind = null;
|
|
576
|
+
const f = this.userFilter;
|
|
577
|
+
if (!f)
|
|
578
|
+
return;
|
|
579
|
+
this.userFilter = null;
|
|
580
|
+
this.relink();
|
|
581
|
+
this.engine.releaseBiquad(f);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.userKind = kind;
|
|
585
|
+
this.userCutoff = o.cutoff ?? this.userCutoff;
|
|
586
|
+
this.userQ = o.q ?? this.userQ;
|
|
587
|
+
if (!this.src || !ctx)
|
|
588
|
+
return;
|
|
589
|
+
if (this.userFilter) {
|
|
590
|
+
tuneBiquad(this.userFilter, kind, this.userCutoff, this.userQ, ctx.currentTime, ramp);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const f = this.engine.acquireBiquad();
|
|
594
|
+
if (!f)
|
|
595
|
+
return;
|
|
596
|
+
tuneBiquad(f, kind, this.userCutoff, this.userQ, ctx.currentTime, 0);
|
|
597
|
+
this.userFilter = f;
|
|
598
|
+
this.relink();
|
|
599
|
+
}
|
|
600
|
+
/** Internal (`OcclusionScheduler`): ramp the lowpass and the voice gain to the probe's answer. */
|
|
601
|
+
applyOcclusion(amount, cutoff, gain, ramp) {
|
|
602
|
+
const ctx = this.engine.context;
|
|
603
|
+
if (!ctx || !this.gainNode || this.ended)
|
|
604
|
+
return;
|
|
605
|
+
const t = ctx.currentTime;
|
|
606
|
+
if (!this.occFilter) {
|
|
607
|
+
const f = this.engine.acquireBiquad();
|
|
608
|
+
if (!f)
|
|
609
|
+
return;
|
|
610
|
+
f.type = 'lowpass';
|
|
611
|
+
f.frequency.value = this.engine.occlusion?.openCutoff ?? DEFAULT_OCCLUSION.openCutoff;
|
|
612
|
+
f.Q.value = 0.7;
|
|
613
|
+
this.occFilter = f;
|
|
614
|
+
this.relink();
|
|
615
|
+
}
|
|
616
|
+
if (this.occApplied && Math.abs(amount - this.occAmount) <= 1e-3)
|
|
617
|
+
return;
|
|
618
|
+
this.occApplied = true;
|
|
619
|
+
this.occAmount = amount;
|
|
620
|
+
this.occGain = gain;
|
|
621
|
+
rampParamTo(this.occFilter.frequency, cutoff, t, ramp);
|
|
622
|
+
this.gainNode.gain.setTargetAtTime(this.targetGain * gain, t, Math.max(0.001, ramp / 3));
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Internal (`audio.update`): the emitter's velocity from its move since the last update, and
|
|
626
|
+
* the Doppler ratio against the listener's, written to `playbackRate` when it changed.
|
|
627
|
+
*/
|
|
628
|
+
syncDoppler(dt, listener) {
|
|
629
|
+
const f = this.dopplerFactor;
|
|
630
|
+
const p = this.pos;
|
|
631
|
+
const q = this.prevPos;
|
|
632
|
+
let ratio = 1;
|
|
633
|
+
if (this.hasPrev && dt > 0 && f > 0) {
|
|
634
|
+
const max = this.opts.spatial?.maxDopplerSpeed ?? DEFAULT_MAX_DOPPLER_SPEED;
|
|
635
|
+
let vx = (p[0] - q[0]) / dt;
|
|
636
|
+
let vy = (p[1] - q[1]) / dt;
|
|
637
|
+
let vz = (p[2] - q[2]) / dt;
|
|
638
|
+
if (vx * vx + vy * vy + vz * vz > max * max)
|
|
639
|
+
vx = vy = vz = 0;
|
|
640
|
+
const lp = listener.position;
|
|
641
|
+
const lv = listener.velocity;
|
|
642
|
+
// n: unit vector from the emitter to the listener.
|
|
643
|
+
let nx = lp[0] - p[0];
|
|
644
|
+
let ny = lp[1] - p[1];
|
|
645
|
+
let nz = lp[2] - p[2];
|
|
646
|
+
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
|
647
|
+
if (len > 1e-6) {
|
|
648
|
+
nx /= len;
|
|
649
|
+
ny /= len;
|
|
650
|
+
nz /= len;
|
|
651
|
+
const c = SPEED_OF_SOUND;
|
|
652
|
+
const vl = f * (lv[0] * nx + lv[1] * ny + lv[2] * nz);
|
|
653
|
+
const ve = f * (vx * nx + vy * ny + vz * nz);
|
|
654
|
+
const lim = c * 0.9;
|
|
655
|
+
ratio = (c - Math.min(lim, Math.max(-lim, vl))) / (c - Math.min(lim, Math.max(-lim, ve)));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
q[0] = p[0];
|
|
659
|
+
q[1] = p[1];
|
|
660
|
+
q[2] = p[2];
|
|
661
|
+
this.hasPrev = true;
|
|
662
|
+
if (Math.abs(ratio - this.dopplerRate) <= 1e-4)
|
|
663
|
+
return;
|
|
664
|
+
this.dopplerRate = ratio;
|
|
665
|
+
const ctx = this.engine.context;
|
|
666
|
+
if (!this.src || !ctx || this.frozen)
|
|
667
|
+
return;
|
|
668
|
+
this.src.playbackRate.setTargetAtTime(this.rate * ratio, ctx.currentTime, 0.01);
|
|
669
|
+
}
|
|
670
|
+
stop(fade = 0) {
|
|
671
|
+
if (this.stopped)
|
|
672
|
+
return;
|
|
673
|
+
this.stopped = true;
|
|
674
|
+
const ctx = this.engine.context;
|
|
675
|
+
if (!this.src || !this.gainNode || !ctx) {
|
|
676
|
+
this.onEnd(this);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const t = ctx.currentTime;
|
|
680
|
+
try {
|
|
681
|
+
if (fade > 0) {
|
|
682
|
+
const g = this.gainNode.gain;
|
|
683
|
+
g.cancelScheduledValues(t);
|
|
684
|
+
g.setValueAtTime(g.value, t);
|
|
685
|
+
g.linearRampToValueAtTime(0, t + fade);
|
|
686
|
+
this.src.stop(t + fade);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
this.src.stop(t);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// Already stopped.
|
|
694
|
+
}
|
|
695
|
+
// Out of the mixer's books now (not when `onended` fires); the nodes are torn down there.
|
|
696
|
+
this.onEnd(this);
|
|
697
|
+
}
|
|
698
|
+
sendTo(input, amount, ramp = 0.05) {
|
|
699
|
+
if (this.ended)
|
|
700
|
+
return;
|
|
701
|
+
this.sends ??= new VoiceSends();
|
|
702
|
+
const sends = this.sends;
|
|
703
|
+
const ctx = this.engine.context;
|
|
704
|
+
if (this.tail && ctx && !sends.attached)
|
|
705
|
+
sends.attach(ctx, this.tail);
|
|
706
|
+
sends.set(input, amount, ramp);
|
|
707
|
+
}
|
|
708
|
+
/** Live sends (tests, diagnostics). */
|
|
709
|
+
get sendCount() {
|
|
710
|
+
return this.sends?.count ?? 0;
|
|
711
|
+
}
|
|
712
|
+
/** Internal: whether this voice's `playbackRate` accepts 0 (null before it has a source). */
|
|
713
|
+
rateZeroAllowed() {
|
|
714
|
+
if (!this.src)
|
|
715
|
+
return null;
|
|
716
|
+
const min = this.src.playbackRate.minValue;
|
|
717
|
+
return !(typeof min === 'number' && min > 0);
|
|
718
|
+
}
|
|
719
|
+
/** Internal (`audio.pause`): hold the source at rate 0, keeping `rate` for `thawRate`. */
|
|
720
|
+
freezeRate(ramp) {
|
|
721
|
+
if (this.frozen)
|
|
722
|
+
return;
|
|
723
|
+
this.frozen = true;
|
|
724
|
+
const ctx = this.engine.context;
|
|
725
|
+
if (this.src && ctx)
|
|
726
|
+
rampParamTo(this.src.playbackRate, 0, ctx.currentTime, ramp);
|
|
727
|
+
}
|
|
728
|
+
/** Internal (`audio.unpause`): back to the rate the game set. */
|
|
729
|
+
thawRate(ramp) {
|
|
730
|
+
if (!this.frozen)
|
|
731
|
+
return;
|
|
732
|
+
this.frozen = false;
|
|
733
|
+
const ctx = this.engine.context;
|
|
734
|
+
if (this.src && ctx) {
|
|
735
|
+
rampParamTo(this.src.playbackRate, this.rate * this.dopplerRate, ctx.currentTime, ramp);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
setGain(gain, ramp = 0.05) {
|
|
739
|
+
this.targetGain = gain;
|
|
740
|
+
const ctx = this.engine.context;
|
|
741
|
+
if (!this.gainNode || !ctx)
|
|
742
|
+
return;
|
|
743
|
+
this.gainNode.gain.setTargetAtTime(gain * this.occGain, ctx.currentTime, Math.max(0.001, ramp / 3));
|
|
744
|
+
}
|
|
745
|
+
setRate(rate, ramp = 0.05) {
|
|
746
|
+
this.rate = rate;
|
|
747
|
+
const ctx = this.engine.context;
|
|
748
|
+
// While frozen by `pause` the new rate waits for `unpause`.
|
|
749
|
+
if (!this.src || !ctx || this.frozen)
|
|
750
|
+
return;
|
|
751
|
+
this.src.playbackRate.setTargetAtTime(rate * this.dopplerRate, ctx.currentTime, Math.max(0.001, ramp / 3));
|
|
752
|
+
}
|
|
753
|
+
setPosition(x, y, z) {
|
|
754
|
+
if (!this.spatial)
|
|
755
|
+
return;
|
|
756
|
+
this.pos[0] = x;
|
|
757
|
+
this.pos[1] = y;
|
|
758
|
+
this.pos[2] = z;
|
|
759
|
+
if (this.panner)
|
|
760
|
+
writePannerPosition(this.panner, x, y, z);
|
|
761
|
+
}
|
|
762
|
+
follow(target) {
|
|
763
|
+
if (!this.spatial)
|
|
764
|
+
return;
|
|
765
|
+
this.target = target;
|
|
766
|
+
this.followSeen = -1;
|
|
767
|
+
this.engine.trackFollower(this, target !== null && this.playing);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Internal: copy the followed matrix's translation into the panner. A target with a
|
|
771
|
+
* `changed` counter (a `Transform`) is read only when it moved since the last sync.
|
|
772
|
+
*/
|
|
773
|
+
syncFollow() {
|
|
774
|
+
const t = this.target;
|
|
775
|
+
if (!t)
|
|
776
|
+
return;
|
|
777
|
+
const c = t.changed;
|
|
778
|
+
if (c !== undefined) {
|
|
779
|
+
if (c === this.followSeen)
|
|
780
|
+
return;
|
|
781
|
+
this.followSeen = c;
|
|
782
|
+
}
|
|
783
|
+
const m = t.world;
|
|
784
|
+
this.setPosition(m[3], m[7], m[11]);
|
|
785
|
+
}
|
|
786
|
+
setDirection(x, y, z) {
|
|
787
|
+
if (!this.spatial)
|
|
788
|
+
return;
|
|
789
|
+
this.dx = x;
|
|
790
|
+
this.dy = y;
|
|
791
|
+
this.dz = z;
|
|
792
|
+
if (this.panner)
|
|
793
|
+
writePannerDirection(this.panner, x, y, z);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
export const DEFAULT_LIMITER = {
|
|
797
|
+
threshold: -1,
|
|
798
|
+
knee: 0,
|
|
799
|
+
ratio: 20,
|
|
800
|
+
attack: 0.003,
|
|
801
|
+
release: 0.25,
|
|
802
|
+
};
|
|
803
|
+
export const DEFAULT_REVERB_SENDS = {
|
|
804
|
+
music: 0.2,
|
|
805
|
+
sfx: 0.25,
|
|
806
|
+
ui: 0,
|
|
807
|
+
};
|
|
808
|
+
const GESTURES = ['pointerdown', 'pointerup', 'keydown', 'touchend', 'mousedown', 'click'];
|
|
809
|
+
export class AudioEngine {
|
|
810
|
+
ctx = null;
|
|
811
|
+
_state = 'locked';
|
|
812
|
+
buses = {};
|
|
813
|
+
_settings;
|
|
814
|
+
sounds = new Map();
|
|
815
|
+
deferred = new Set();
|
|
816
|
+
active = new Set();
|
|
817
|
+
stateListeners = new Set();
|
|
818
|
+
settingsListeners = new Set();
|
|
819
|
+
createContext;
|
|
820
|
+
createOffline;
|
|
821
|
+
detachFns = [];
|
|
822
|
+
/** Gesture listeners: armed while audio should run but the browser won't let it. */
|
|
823
|
+
gestureTarget = null;
|
|
824
|
+
gesturesArmed = false;
|
|
825
|
+
userSuspended = false;
|
|
826
|
+
hidden = false;
|
|
827
|
+
_halted = false;
|
|
828
|
+
limiterOpts;
|
|
829
|
+
limiterNode = null;
|
|
830
|
+
reverbOpts;
|
|
831
|
+
/** The base sends (what the game set); the zones add `zoneSends` on top (`max`). */
|
|
832
|
+
reverbSends;
|
|
833
|
+
zoneSends = { music: 0, sfx: 0, ui: 0 };
|
|
834
|
+
effectiveSends = { music: 0, sfx: 0, ui: 0 };
|
|
835
|
+
appliedSends = { music: 0, sfx: 0, ui: 0 };
|
|
836
|
+
reverbNodes = null;
|
|
837
|
+
_zones = null;
|
|
838
|
+
/** Spatial voices following a matrix (`Voice.follow`), synced in `update`. */
|
|
839
|
+
followers = new Set();
|
|
840
|
+
/** Panners of ended spatial voices, reused by the next ones (cleared with the context). */
|
|
841
|
+
pannerPool = [];
|
|
842
|
+
/** Biquads of ended voices' filters and occlusion lowpasses, reused the same way. */
|
|
843
|
+
biquadPool = [];
|
|
844
|
+
/**
|
|
845
|
+
* The game's occlusion probe (ws/audio-4), or null for none. Set it once; every spatial voice
|
|
846
|
+
* (unless `spatial.occlude` is false) is then probed round-robin from `update`.
|
|
847
|
+
*/
|
|
848
|
+
occlusion = null;
|
|
849
|
+
occluder = new OcclusionScheduler();
|
|
850
|
+
/** Started spatial voices the probe may ask about, in start order. */
|
|
851
|
+
occludable = [];
|
|
852
|
+
/** Started voices with a Doppler factor. */
|
|
853
|
+
dopplers = new Set();
|
|
854
|
+
/** Sprite sheets by name (`defineSprites`); the buffer itself is a sound of the same name. */
|
|
855
|
+
spriteSheets = new Map();
|
|
856
|
+
scratchSchedule = {
|
|
857
|
+
offset: 0,
|
|
858
|
+
duration: 0,
|
|
859
|
+
loopStart: 0,
|
|
860
|
+
loopEnd: 0,
|
|
861
|
+
};
|
|
862
|
+
_listener = null;
|
|
863
|
+
/** Externally owned voices (`register`): music stems, a game's own players. */
|
|
864
|
+
registered = new Map();
|
|
865
|
+
_paused = false;
|
|
866
|
+
pausedList = [];
|
|
867
|
+
_pausedRate = false;
|
|
868
|
+
/** Whether this context's `playbackRate` accepts 0, once a voice told us. */
|
|
869
|
+
rateZeroOk = null;
|
|
870
|
+
/** Registered voices frozen by `pause`, with the rate to restore. */
|
|
871
|
+
frozenExternal = new Map();
|
|
872
|
+
/**
|
|
873
|
+
* Per-bus keyed multipliers on the settings gain (`setBusScale`: the duckers). Parallel
|
|
874
|
+
* key/value arrays, searched linearly (a bus has a handful of keys), so a per-frame
|
|
875
|
+
* `setBusScale` on an existing key allocates nothing. `scales` caches each bus's product.
|
|
876
|
+
*/
|
|
877
|
+
scaleKeys = {
|
|
878
|
+
master: [],
|
|
879
|
+
music: [],
|
|
880
|
+
sfx: [],
|
|
881
|
+
ui: [],
|
|
882
|
+
};
|
|
883
|
+
scaleValues = {
|
|
884
|
+
master: [],
|
|
885
|
+
music: [],
|
|
886
|
+
sfx: [],
|
|
887
|
+
ui: [],
|
|
888
|
+
};
|
|
889
|
+
scales = { master: 1, music: 1, sfx: 1, ui: 1 };
|
|
890
|
+
constructor(opts = {}) {
|
|
891
|
+
this._settings = sanitizeMixer(opts.settings);
|
|
892
|
+
const lim = opts.limiter ?? true;
|
|
893
|
+
this.limiterOpts = lim === false ? null : { ...DEFAULT_LIMITER, ...(lim === true ? {} : lim) };
|
|
894
|
+
this.reverbOpts = opts.reverb ?? null;
|
|
895
|
+
this.reverbSends = { ...DEFAULT_REVERB_SENDS, ...opts.reverb?.sends };
|
|
896
|
+
for (const b of BUS_NAMES) {
|
|
897
|
+
if (b !== 'master')
|
|
898
|
+
this.effectiveSends[b] = this.appliedSends[b] = this.reverbSends[b];
|
|
899
|
+
}
|
|
900
|
+
this.createOffline = opts.createOffline;
|
|
901
|
+
this.createContext =
|
|
902
|
+
opts.createContext ??
|
|
903
|
+
(() => {
|
|
904
|
+
const Ctor = globalThis.AudioContext ??
|
|
905
|
+
globalThis
|
|
906
|
+
.webkitAudioContext;
|
|
907
|
+
if (!Ctor)
|
|
908
|
+
throw new Error('WebAudio unavailable');
|
|
909
|
+
return new Ctor({ latencyHint: 'interactive' });
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
get state() {
|
|
913
|
+
return this._state;
|
|
914
|
+
}
|
|
915
|
+
/** The realtime context, once created (after the first gesture). */
|
|
916
|
+
get context() {
|
|
917
|
+
return this.ctx;
|
|
918
|
+
}
|
|
919
|
+
get settings() {
|
|
920
|
+
return this._settings;
|
|
921
|
+
}
|
|
922
|
+
/** Where spatial voices are heard from. A 3D game calls `audio.listener.fromCamera(camera)` each frame. */
|
|
923
|
+
get listener() {
|
|
924
|
+
this._listener ??= new SpatialListener(this);
|
|
925
|
+
return this._listener;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Regions that drive ambient loops and reverb sends by listener position. Built on first use;
|
|
929
|
+
* advanced by `update(dt)`.
|
|
930
|
+
*/
|
|
931
|
+
get zones() {
|
|
932
|
+
this._zones ??= new AudioZones(this);
|
|
933
|
+
return this._zones;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Per-frame bookkeeping: the zones (weights, ambient gains, reverb sends) and the followers
|
|
937
|
+
* (`Voice.follow`). `createGame`'s loop calls it after input, before the scenes. Safe before
|
|
938
|
+
* unlock and when audio is unavailable: the numbers still move, nothing plays.
|
|
939
|
+
*/
|
|
940
|
+
update(dt) {
|
|
941
|
+
this._zones?.update(dt);
|
|
942
|
+
for (const v of this.followers)
|
|
943
|
+
v.syncFollow();
|
|
944
|
+
if (this.dopplers.size > 0) {
|
|
945
|
+
const l = this.listener;
|
|
946
|
+
l.tick(dt);
|
|
947
|
+
for (const v of this.dopplers)
|
|
948
|
+
v.syncDoppler(dt, l);
|
|
949
|
+
}
|
|
950
|
+
const occ = this.occlusion;
|
|
951
|
+
if (occ && this.ctx && this.occludable.length > 0 && this._state === 'running') {
|
|
952
|
+
this.occluder.run(occ, this.listener.position, this.occludable, this.ctx.currentTime);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
/** Internal: a voice starts or stops following a matrix. */
|
|
956
|
+
trackFollower(v, on) {
|
|
957
|
+
if (on)
|
|
958
|
+
this.followers.add(v);
|
|
959
|
+
else
|
|
960
|
+
this.followers.delete(v);
|
|
961
|
+
}
|
|
962
|
+
/** Internal: a spatial voice started; it joins the occlusion and Doppler rounds it asks for. */
|
|
963
|
+
trackSpatial(v) {
|
|
964
|
+
if (v.occludable)
|
|
965
|
+
this.occludable.push(v);
|
|
966
|
+
if (v.dopplerFactor > 0)
|
|
967
|
+
this.dopplers.add(v);
|
|
968
|
+
}
|
|
969
|
+
untrackSpatial(v) {
|
|
970
|
+
this.dopplers.delete(v);
|
|
971
|
+
const i = this.occludable.indexOf(v);
|
|
972
|
+
if (i >= 0)
|
|
973
|
+
this.occludable.splice(i, 1);
|
|
974
|
+
}
|
|
975
|
+
/** Idle panners waiting for the next spatial voice (tests, diagnostics). */
|
|
976
|
+
get pooledPanners() {
|
|
977
|
+
return this.pannerPool.length;
|
|
978
|
+
}
|
|
979
|
+
/** Idle biquads waiting for the next `Voice.filter` or occluded voice (tests, diagnostics). */
|
|
980
|
+
get pooledFilters() {
|
|
981
|
+
return this.biquadPool.length;
|
|
982
|
+
}
|
|
983
|
+
/** Voices `occlusion.probe` may be asked about (tests, diagnostics). */
|
|
984
|
+
get occludedVoiceCount() {
|
|
985
|
+
return this.occludable.length;
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* The page lifecycle's flag (ws/audio-4): `true` suspends the context while the app is hidden
|
|
989
|
+
* or backgrounded, `false` brings back whatever the user state is (`suspend()` still holds it
|
|
990
|
+
* suspended; otherwise it resumes). `attach({ pauseWhenHidden })` and `createGame`'s host
|
|
991
|
+
* lifecycle call this; a game with its own lifecycle source can too. Independent of `pause()`.
|
|
992
|
+
*/
|
|
993
|
+
background(hidden) {
|
|
994
|
+
if (this.hidden === hidden)
|
|
995
|
+
return;
|
|
996
|
+
this.hidden = hidden;
|
|
997
|
+
// Bus writes held while hidden (`applyBus`) land now, before the context resumes.
|
|
998
|
+
if (!hidden)
|
|
999
|
+
for (const b of BUS_NAMES)
|
|
1000
|
+
this.applyBus(b);
|
|
1001
|
+
void this.syncSuspension();
|
|
1002
|
+
}
|
|
1003
|
+
/** True while `background(true)` holds (the document hidden or the app in the background). */
|
|
1004
|
+
get inBackground() {
|
|
1005
|
+
return this.hidden;
|
|
1006
|
+
}
|
|
1007
|
+
/** True after `halt()`: silent for good. */
|
|
1008
|
+
get halted() {
|
|
1009
|
+
return this._halted;
|
|
1010
|
+
}
|
|
1011
|
+
onStateChange(fn) {
|
|
1012
|
+
this.stateListeners.add(fn);
|
|
1013
|
+
return () => this.stateListeners.delete(fn);
|
|
1014
|
+
}
|
|
1015
|
+
onSettingsChange(fn) {
|
|
1016
|
+
this.settingsListeners.add(fn);
|
|
1017
|
+
return () => this.settingsListeners.delete(fn);
|
|
1018
|
+
}
|
|
1019
|
+
setState(s) {
|
|
1020
|
+
if (s === this._state)
|
|
1021
|
+
return;
|
|
1022
|
+
this._state = s;
|
|
1023
|
+
for (const fn of this.stateListeners)
|
|
1024
|
+
fn(s);
|
|
1025
|
+
}
|
|
1026
|
+
// ------------------------------------------------------------ lifecycle
|
|
1027
|
+
/**
|
|
1028
|
+
* Listen for the first user gesture to unlock audio, and pause while hidden. Returns detach.
|
|
1029
|
+
*
|
|
1030
|
+
* Phones take audio away again after unlock: iOS interrupts the context for a call, Siri or
|
|
1031
|
+
* an alarm (state `interrupted`) and may leave it suspended after an app switch; Android
|
|
1032
|
+
* suspends it in the background. Whenever the context is not running but should be, the
|
|
1033
|
+
* gesture listeners are re-armed so the next tap brings sound back, and `pageshow` / `focus`
|
|
1034
|
+
* try a resume without one.
|
|
1035
|
+
*/
|
|
1036
|
+
attach(opts = {}) {
|
|
1037
|
+
const target = opts.target ?? (typeof window !== 'undefined' ? window : undefined);
|
|
1038
|
+
if (target) {
|
|
1039
|
+
this.gestureTarget = target;
|
|
1040
|
+
this.armGestures();
|
|
1041
|
+
this.detachFns.push(() => {
|
|
1042
|
+
this.disarmGestures();
|
|
1043
|
+
this.gestureTarget = null;
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
if (opts.pauseWhenHidden !== false && typeof document !== 'undefined') {
|
|
1047
|
+
const visible = () => document.visibilityState !== 'hidden';
|
|
1048
|
+
const onVis = () => this.background(!visible());
|
|
1049
|
+
// pagehide (iOS app switch, bfcache) and freeze can arrive while still "visible".
|
|
1050
|
+
const onAway = () => this.background(true);
|
|
1051
|
+
const onBack = () => this.background(!visible());
|
|
1052
|
+
document.addEventListener('visibilitychange', onVis);
|
|
1053
|
+
window.addEventListener('pagehide', onAway);
|
|
1054
|
+
window.addEventListener('pageshow', onBack);
|
|
1055
|
+
window.addEventListener('focus', onBack);
|
|
1056
|
+
document.addEventListener('freeze', onAway);
|
|
1057
|
+
document.addEventListener('resume', onBack);
|
|
1058
|
+
this.detachFns.push(() => {
|
|
1059
|
+
document.removeEventListener('visibilitychange', onVis);
|
|
1060
|
+
window.removeEventListener('pagehide', onAway);
|
|
1061
|
+
window.removeEventListener('pageshow', onBack);
|
|
1062
|
+
window.removeEventListener('focus', onBack);
|
|
1063
|
+
document.removeEventListener('freeze', onAway);
|
|
1064
|
+
document.removeEventListener('resume', onBack);
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
return () => this.detach();
|
|
1068
|
+
}
|
|
1069
|
+
detach() {
|
|
1070
|
+
for (const fn of this.detachFns)
|
|
1071
|
+
fn();
|
|
1072
|
+
this.detachFns = [];
|
|
1073
|
+
}
|
|
1074
|
+
onGesture = () => {
|
|
1075
|
+
void this.unlock().then((ok) => {
|
|
1076
|
+
if (ok)
|
|
1077
|
+
this.disarmGestures();
|
|
1078
|
+
});
|
|
1079
|
+
};
|
|
1080
|
+
armGestures() {
|
|
1081
|
+
const target = this.gestureTarget;
|
|
1082
|
+
if (!target || this.gesturesArmed || this._halted)
|
|
1083
|
+
return;
|
|
1084
|
+
this.gesturesArmed = true;
|
|
1085
|
+
for (const g of GESTURES)
|
|
1086
|
+
target.addEventListener(g, this.onGesture, true);
|
|
1087
|
+
}
|
|
1088
|
+
disarmGestures() {
|
|
1089
|
+
const target = this.gestureTarget;
|
|
1090
|
+
if (!target || !this.gesturesArmed)
|
|
1091
|
+
return;
|
|
1092
|
+
this.gesturesArmed = false;
|
|
1093
|
+
for (const g of GESTURES)
|
|
1094
|
+
target.removeEventListener(g, this.onGesture, true);
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Create/resume the context. Must run inside a user-gesture handler the first time
|
|
1098
|
+
* (`attach` does this for you). Resolves true once audio is running.
|
|
1099
|
+
*/
|
|
1100
|
+
async unlock() {
|
|
1101
|
+
if (this._state === 'unavailable' || this._halted)
|
|
1102
|
+
return false;
|
|
1103
|
+
if (!this.ctx) {
|
|
1104
|
+
try {
|
|
1105
|
+
this.ctx = this.createContext();
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
this.setState('unavailable');
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
this.buildBuses();
|
|
1112
|
+
this.ctx.onstatechange = () => this.onContextState();
|
|
1113
|
+
}
|
|
1114
|
+
// iOS Safari: a silent buffer started inside the gesture fully unlocks output.
|
|
1115
|
+
try {
|
|
1116
|
+
const silent = this.ctx.createBuffer(1, 1, this.ctx.sampleRate);
|
|
1117
|
+
const s = this.ctx.createBufferSource();
|
|
1118
|
+
s.buffer = silent;
|
|
1119
|
+
s.connect(this.ctx.destination);
|
|
1120
|
+
s.start();
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
// ignore
|
|
1124
|
+
}
|
|
1125
|
+
if (this.ctx.state !== 'running' && !this.hidden && !this.userSuspended) {
|
|
1126
|
+
try {
|
|
1127
|
+
await this.ctx.resume();
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
// Not allowed yet; a later gesture will retry.
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
this.onContextState();
|
|
1134
|
+
return this.ctx.state === 'running' || (this._state === 'suspended' && this.isPaused());
|
|
1135
|
+
}
|
|
1136
|
+
isPaused() {
|
|
1137
|
+
return this.hidden || this.userSuspended || this._halted;
|
|
1138
|
+
}
|
|
1139
|
+
onContextState() {
|
|
1140
|
+
const ctx = this.ctx;
|
|
1141
|
+
if (!ctx)
|
|
1142
|
+
return;
|
|
1143
|
+
// `interrupted` (iOS: call, Siri, alarm, another app's audio) is a pause the browser chose;
|
|
1144
|
+
// only `closed` is final.
|
|
1145
|
+
const state = ctx.state;
|
|
1146
|
+
if (state === 'running') {
|
|
1147
|
+
this.setState('running');
|
|
1148
|
+
this.startDeferred();
|
|
1149
|
+
}
|
|
1150
|
+
else if (state === 'closed') {
|
|
1151
|
+
this.setState('unavailable');
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
// Suspended before ever running means the browser still wants a gesture.
|
|
1155
|
+
this.setState(this._state === 'locked' && !this.isPaused() ? 'locked' : 'suspended');
|
|
1156
|
+
// Should be playing but isn't: the next tap or key brings it back.
|
|
1157
|
+
if (!this.isPaused())
|
|
1158
|
+
this.armGestures();
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/** Pause all audio (e.g. game paused). Pair with `resume()`. */
|
|
1162
|
+
async suspend() {
|
|
1163
|
+
this.userSuspended = true;
|
|
1164
|
+
await this.syncSuspension();
|
|
1165
|
+
}
|
|
1166
|
+
async resume() {
|
|
1167
|
+
this.userSuspended = false;
|
|
1168
|
+
await this.syncSuspension();
|
|
1169
|
+
}
|
|
1170
|
+
async syncSuspension() {
|
|
1171
|
+
const ctx = this.ctx;
|
|
1172
|
+
if (!ctx || this._state === 'unavailable' || this._state === 'locked')
|
|
1173
|
+
return;
|
|
1174
|
+
try {
|
|
1175
|
+
if (this.isPaused() && ctx.state === 'running') {
|
|
1176
|
+
await ctx.suspend();
|
|
1177
|
+
this.setState('suspended');
|
|
1178
|
+
}
|
|
1179
|
+
else if (!this.isPaused() && ctx.state !== 'running') {
|
|
1180
|
+
await ctx.resume();
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
catch {
|
|
1184
|
+
// ignore
|
|
1185
|
+
}
|
|
1186
|
+
this.onContextState();
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Fall silent for good, e.g. when the page can no longer run (GPU device lost) and only a
|
|
1190
|
+
* reload helps. Stops every voice, drops loops waiting for unlock, removes the gesture and
|
|
1191
|
+
* visibility listeners and suspends the context. After this, `unlock`, `resume`, `play`,
|
|
1192
|
+
* `loop` and `prepare` do nothing, so no timer or tab switch can bring sound back.
|
|
1193
|
+
*/
|
|
1194
|
+
async halt() {
|
|
1195
|
+
if (this._halted)
|
|
1196
|
+
return;
|
|
1197
|
+
this._halted = true;
|
|
1198
|
+
this.detach();
|
|
1199
|
+
this.stopAll();
|
|
1200
|
+
this.deferred.clear();
|
|
1201
|
+
await this.syncSuspension();
|
|
1202
|
+
}
|
|
1203
|
+
/** Close the context and drop everything. */
|
|
1204
|
+
async dispose() {
|
|
1205
|
+
this.detach();
|
|
1206
|
+
this.stopAll();
|
|
1207
|
+
this.deferred.clear();
|
|
1208
|
+
const ctx = this.ctx;
|
|
1209
|
+
this.ctx = null;
|
|
1210
|
+
this.buses = {};
|
|
1211
|
+
this.limiterNode = null;
|
|
1212
|
+
this.reverbNodes = null;
|
|
1213
|
+
this.pannerPool.length = 0;
|
|
1214
|
+
this.biquadPool.length = 0;
|
|
1215
|
+
this.occludable.length = 0;
|
|
1216
|
+
this.dopplers.clear();
|
|
1217
|
+
this.registered.clear();
|
|
1218
|
+
this.frozenExternal.clear();
|
|
1219
|
+
this.pausedList.length = 0;
|
|
1220
|
+
this._paused = false;
|
|
1221
|
+
this._pausedRate = false;
|
|
1222
|
+
if (ctx)
|
|
1223
|
+
await ctx.close().catch(() => { });
|
|
1224
|
+
this.setState('locked');
|
|
1225
|
+
}
|
|
1226
|
+
// ------------------------------------------------------------ mixer
|
|
1227
|
+
buildBuses() {
|
|
1228
|
+
const ctx = this.ctx;
|
|
1229
|
+
const master = ctx.createGain();
|
|
1230
|
+
this.limiterNode = this.limiterOpts ? createLimiter(ctx, this.limiterOpts) : null;
|
|
1231
|
+
if (this.limiterNode) {
|
|
1232
|
+
master.connect(this.limiterNode);
|
|
1233
|
+
this.limiterNode.connect(ctx.destination);
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
master.connect(ctx.destination);
|
|
1237
|
+
}
|
|
1238
|
+
this.buses.master = master;
|
|
1239
|
+
for (const name of BUS_NAMES) {
|
|
1240
|
+
if (name === 'master')
|
|
1241
|
+
continue;
|
|
1242
|
+
const g = ctx.createGain();
|
|
1243
|
+
g.connect(master);
|
|
1244
|
+
this.buses[name] = g;
|
|
1245
|
+
}
|
|
1246
|
+
for (const name of BUS_NAMES)
|
|
1247
|
+
this.buses[name].gain.value = this.busTarget(name);
|
|
1248
|
+
// Paused before the context existed: the paused buses are born silent.
|
|
1249
|
+
for (const name of this.pausedList)
|
|
1250
|
+
this.buses[name].gain.value = 0;
|
|
1251
|
+
this.buildReverb();
|
|
1252
|
+
this._listener?.apply();
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Internal: a `PannerNode` set up from `opts`, from the pool when one is idle. Null where the
|
|
1256
|
+
* context has no panner (the voice then plays unspatialised, straight into its bus).
|
|
1257
|
+
*/
|
|
1258
|
+
acquirePanner(opts) {
|
|
1259
|
+
const ctx = this.ctx;
|
|
1260
|
+
if (!ctx || typeof ctx.createPanner !== 'function')
|
|
1261
|
+
return null;
|
|
1262
|
+
const p = this.pannerPool.pop() ?? ctx.createPanner();
|
|
1263
|
+
p.panningModel = opts?.panningModel ?? 'equalpower';
|
|
1264
|
+
p.distanceModel = opts?.rolloff ?? 'inverse';
|
|
1265
|
+
p.refDistance = opts?.refDistance ?? 1;
|
|
1266
|
+
p.maxDistance = opts?.maxDistance ?? 10000;
|
|
1267
|
+
p.rolloffFactor = opts?.rolloffFactor ?? 1;
|
|
1268
|
+
p.coneInnerAngle = opts?.cone?.inner ?? 360;
|
|
1269
|
+
p.coneOuterAngle = opts?.cone?.outer ?? 360;
|
|
1270
|
+
p.coneOuterGain = opts?.cone?.outerGain ?? 0;
|
|
1271
|
+
return p;
|
|
1272
|
+
}
|
|
1273
|
+
/** Internal: take back an ended voice's (disconnected) panner. Dropped if its context is gone. */
|
|
1274
|
+
releasePanner(p) {
|
|
1275
|
+
if (this.ctx && p.context === this.ctx)
|
|
1276
|
+
this.pannerPool.push(p);
|
|
1277
|
+
}
|
|
1278
|
+
/** Internal: a `BiquadFilterNode` from the pool, or a new one. Null where the context has none. */
|
|
1279
|
+
acquireBiquad() {
|
|
1280
|
+
const ctx = this.ctx;
|
|
1281
|
+
if (!ctx || typeof ctx.createBiquadFilter !== 'function')
|
|
1282
|
+
return null;
|
|
1283
|
+
return this.biquadPool.pop() ?? ctx.createBiquadFilter();
|
|
1284
|
+
}
|
|
1285
|
+
/** Internal: take back a (disconnected) biquad. Dropped if its context is gone. */
|
|
1286
|
+
releaseBiquad(f) {
|
|
1287
|
+
if (this.ctx && f.context === this.ctx)
|
|
1288
|
+
this.biquadPool.push(f);
|
|
1289
|
+
}
|
|
1290
|
+
/** The master limiter node, once the context exists (null when disabled or unsupported). */
|
|
1291
|
+
get limiter() {
|
|
1292
|
+
return this.limiterNode;
|
|
1293
|
+
}
|
|
1294
|
+
/** Current reverb settings (`null` = off). */
|
|
1295
|
+
get reverb() {
|
|
1296
|
+
return this.reverbOpts;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Turn the shared reverb on (or rebuild it with a new room), or off with `null`. Safe before
|
|
1300
|
+
* unlock: it is built with the buses. Rebuilding renders a new impulse, so do it rarely
|
|
1301
|
+
* (scene changes), not per frame. Sends given here replace the matching current sends.
|
|
1302
|
+
*/
|
|
1303
|
+
setReverb(opts) {
|
|
1304
|
+
this.reverbOpts = opts;
|
|
1305
|
+
if (opts?.sends) {
|
|
1306
|
+
this.reverbSends = { ...this.reverbSends, ...opts.sends };
|
|
1307
|
+
for (const b of BUS_NAMES)
|
|
1308
|
+
if (b !== 'master')
|
|
1309
|
+
this.applySend(b, false);
|
|
1310
|
+
}
|
|
1311
|
+
this.teardownReverb();
|
|
1312
|
+
this.buildReverb();
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Change one bus's base reverb send (linear, ramped). Remembered while reverb is off. The
|
|
1316
|
+
* zones (`audio.zones`) raise the effective send above it, never below.
|
|
1317
|
+
*/
|
|
1318
|
+
setReverbSend(bus, level) {
|
|
1319
|
+
const v = Math.max(0, Number.isFinite(level) ? level : 0);
|
|
1320
|
+
this.reverbSends = { ...this.reverbSends, [bus]: v };
|
|
1321
|
+
this.applySend(bus, true);
|
|
1322
|
+
}
|
|
1323
|
+
/** Internal: the zones' contribution to one bus's send; `max(base, zone)` reaches the graph. */
|
|
1324
|
+
setZoneReverbSend(bus, level) {
|
|
1325
|
+
this.zoneSends[bus] = Math.max(0, Number.isFinite(level) ? level : 0);
|
|
1326
|
+
this.applySend(bus, false);
|
|
1327
|
+
}
|
|
1328
|
+
/** Write `max(base, zone)` to the send node when it moved by more than 1e-3 (or `force`). */
|
|
1329
|
+
applySend(bus, force) {
|
|
1330
|
+
const v = Math.max(this.reverbSends[bus], this.zoneSends[bus]);
|
|
1331
|
+
this.effectiveSends[bus] = v;
|
|
1332
|
+
if (!force && Math.abs(v - this.appliedSends[bus]) <= 1e-3)
|
|
1333
|
+
return;
|
|
1334
|
+
this.appliedSends[bus] = v;
|
|
1335
|
+
const node = this.reverbNodes?.sends[bus];
|
|
1336
|
+
if (node && this.ctx)
|
|
1337
|
+
node.gain.setTargetAtTime(v, this.ctx.currentTime, 0.015);
|
|
1338
|
+
}
|
|
1339
|
+
/** Base send levels per bus (what `setReverbSend` and the reverb options set). */
|
|
1340
|
+
get reverbSendLevels() {
|
|
1341
|
+
return this.reverbSends;
|
|
1342
|
+
}
|
|
1343
|
+
/** What reaches the reverb per bus: `max(base, zones)`. The same object every call. */
|
|
1344
|
+
get effectiveReverbSendLevels() {
|
|
1345
|
+
return this.effectiveSends;
|
|
1346
|
+
}
|
|
1347
|
+
buildReverb() {
|
|
1348
|
+
const ctx = this.ctx;
|
|
1349
|
+
const opts = this.reverbOpts;
|
|
1350
|
+
const master = this.buses.master;
|
|
1351
|
+
if (!ctx || !opts || !master || typeof ctx.createConvolver !== 'function')
|
|
1352
|
+
return;
|
|
1353
|
+
const ir = reverbImpulse(ctx.sampleRate, opts.seconds ?? 2, opts.decay ?? 3, opts.seed ?? 1);
|
|
1354
|
+
const buffer = ctx.createBuffer(ir.length, ir[0].length, ctx.sampleRate);
|
|
1355
|
+
ir.forEach((ch, i) => {
|
|
1356
|
+
buffer.copyToChannel(ch, i);
|
|
1357
|
+
});
|
|
1358
|
+
const convolver = ctx.createConvolver();
|
|
1359
|
+
convolver.normalize = true;
|
|
1360
|
+
convolver.buffer = buffer;
|
|
1361
|
+
const wet = ctx.createGain();
|
|
1362
|
+
wet.gain.value = opts.wet ?? 1;
|
|
1363
|
+
convolver.connect(wet);
|
|
1364
|
+
wet.connect(master);
|
|
1365
|
+
const sends = {};
|
|
1366
|
+
for (const name of BUS_NAMES) {
|
|
1367
|
+
if (name === 'master')
|
|
1368
|
+
continue;
|
|
1369
|
+
const g = ctx.createGain();
|
|
1370
|
+
const level = Math.max(this.reverbSends[name], this.zoneSends[name]);
|
|
1371
|
+
this.effectiveSends[name] = level;
|
|
1372
|
+
this.appliedSends[name] = level;
|
|
1373
|
+
g.gain.value = level;
|
|
1374
|
+
this.buses[name].connect(g);
|
|
1375
|
+
g.connect(convolver);
|
|
1376
|
+
sends[name] = g;
|
|
1377
|
+
}
|
|
1378
|
+
this.reverbNodes = { convolver, wet, sends };
|
|
1379
|
+
}
|
|
1380
|
+
teardownReverb() {
|
|
1381
|
+
const r = this.reverbNodes;
|
|
1382
|
+
if (!r)
|
|
1383
|
+
return;
|
|
1384
|
+
this.reverbNodes = null;
|
|
1385
|
+
for (const name of BUS_NAMES) {
|
|
1386
|
+
if (name === 'master')
|
|
1387
|
+
continue;
|
|
1388
|
+
const bus = this.buses[name];
|
|
1389
|
+
try {
|
|
1390
|
+
bus?.disconnect(r.sends[name]);
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
// Not connected.
|
|
1394
|
+
}
|
|
1395
|
+
r.sends[name].disconnect();
|
|
1396
|
+
}
|
|
1397
|
+
r.convolver.disconnect();
|
|
1398
|
+
r.wet.disconnect();
|
|
1399
|
+
}
|
|
1400
|
+
/** Internal: the GainNode for a bus, once the context exists. */
|
|
1401
|
+
busNode(name) {
|
|
1402
|
+
return this.buses[name] ?? null;
|
|
1403
|
+
}
|
|
1404
|
+
/** What a bus gain should be when nothing holds it: the settings gain times its scale. */
|
|
1405
|
+
busTarget(name) {
|
|
1406
|
+
return busGain(this._settings[name]) * this.scales[name];
|
|
1407
|
+
}
|
|
1408
|
+
applyBus(name, timeConstant = 0.015) {
|
|
1409
|
+
const node = this.buses[name];
|
|
1410
|
+
if (!node || !this.ctx)
|
|
1411
|
+
return;
|
|
1412
|
+
// A paused bus stays silent; the new value is applied by `unpause`. In the background the
|
|
1413
|
+
// write waits for `background(false)`, so nothing is queued on a suspended clock.
|
|
1414
|
+
if (this.isBusPaused(name) || this.hidden)
|
|
1415
|
+
return;
|
|
1416
|
+
// Short time-constant ramp avoids zipper clicks.
|
|
1417
|
+
node.gain.setTargetAtTime(this.busTarget(name), this.ctx.currentTime, timeConstant);
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Multiply a bus's settings gain by `scale` (0..1) under `key` (default `'default'`),
|
|
1421
|
+
* approached with time constant `timeConstant` seconds (default 0.015). A bus keeps one scale
|
|
1422
|
+
* per key and its effective scale is the product of them all, so two duckers on one bus
|
|
1423
|
+
* combine instead of fighting. The bus gain is always `busGain(settings[bus]) × product`, so
|
|
1424
|
+
* `setVolume` and `setMuted` keep composing with it. The engine owns the write: while `pause`
|
|
1425
|
+
* holds the bus or `inBackground` is true the scale is only stored, and `unpause` /
|
|
1426
|
+
* `background(false)` ramp to the scaled gain; set before the context exists, the bus is
|
|
1427
|
+
* built at it. `createDucker` drives this with a key of its own.
|
|
1428
|
+
*/
|
|
1429
|
+
setBusScale(bus, scale, timeConstant, key = 'default') {
|
|
1430
|
+
const s = Number.isFinite(scale) ? Math.min(1, Math.max(0, scale)) : 1;
|
|
1431
|
+
const keys = this.scaleKeys[bus];
|
|
1432
|
+
const values = this.scaleValues[bus];
|
|
1433
|
+
const i = keys.indexOf(key);
|
|
1434
|
+
if (i >= 0) {
|
|
1435
|
+
if (values[i] === s)
|
|
1436
|
+
return;
|
|
1437
|
+
values[i] = s;
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
keys.push(key);
|
|
1441
|
+
values.push(s);
|
|
1442
|
+
}
|
|
1443
|
+
this.updateBusScale(bus, timeConstant);
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Remove `key`'s scale from `bus` (a no-op for a key never set), ramping to the product of
|
|
1447
|
+
* the keys left with time constant `timeConstant` (default 0.015). `Ducker.dispose` calls it.
|
|
1448
|
+
*/
|
|
1449
|
+
clearBusScale(bus, key, timeConstant) {
|
|
1450
|
+
const keys = this.scaleKeys[bus];
|
|
1451
|
+
const values = this.scaleValues[bus];
|
|
1452
|
+
const i = keys.indexOf(key);
|
|
1453
|
+
if (i < 0)
|
|
1454
|
+
return;
|
|
1455
|
+
keys.splice(i, 1);
|
|
1456
|
+
values.splice(i, 1);
|
|
1457
|
+
this.updateBusScale(bus, timeConstant);
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* `key`'s `setBusScale` multiplier on `bus` (1 for a key never set or cleared), or, with
|
|
1461
|
+
* `key` omitted, the bus's effective scale: the product of all its keys (1 by default).
|
|
1462
|
+
*/
|
|
1463
|
+
busScale(bus, key) {
|
|
1464
|
+
if (key === undefined)
|
|
1465
|
+
return this.scales[bus];
|
|
1466
|
+
const i = this.scaleKeys[bus].indexOf(key);
|
|
1467
|
+
return i < 0 ? 1 : this.scaleValues[bus][i];
|
|
1468
|
+
}
|
|
1469
|
+
/** Recompute a bus's product of keyed scales and write the bus if it changed. */
|
|
1470
|
+
updateBusScale(bus, timeConstant) {
|
|
1471
|
+
const values = this.scaleValues[bus];
|
|
1472
|
+
let p = 1;
|
|
1473
|
+
for (let j = 0; j < values.length; j++)
|
|
1474
|
+
p *= values[j];
|
|
1475
|
+
if (p === this.scales[bus])
|
|
1476
|
+
return;
|
|
1477
|
+
this.scales[bus] = p;
|
|
1478
|
+
this.applyBus(bus, timeConstant);
|
|
1479
|
+
}
|
|
1480
|
+
// ------------------------------------------------------------ pause
|
|
1481
|
+
/**
|
|
1482
|
+
* The pause-menu recipe: ramp every output bus except `keep` (default `['ui']`, so menu
|
|
1483
|
+
* clicks stay audible) to silence over 50 ms and hold the voices on those buses still by
|
|
1484
|
+
* setting their playback rate to 0, where the context's `playbackRate` allows it (every
|
|
1485
|
+
* current browser; elsewhere the voices keep running under the muted bus). `unpause` ramps
|
|
1486
|
+
* the bus gains back to the mixer settings and the rates back to what the game set. Music
|
|
1487
|
+
* stems pause the same way through their registered voices. The context keeps running
|
|
1488
|
+
* (`suspend()` is the other tool); `master` never ramps. Idempotent.
|
|
1489
|
+
*/
|
|
1490
|
+
pause(opts = {}) {
|
|
1491
|
+
if (this._paused)
|
|
1492
|
+
return;
|
|
1493
|
+
this._paused = true;
|
|
1494
|
+
const keep = opts.keep ?? ['ui'];
|
|
1495
|
+
this.pausedList.length = 0;
|
|
1496
|
+
for (const b of BUS_NAMES)
|
|
1497
|
+
if (b !== 'master' && !keep.includes(b))
|
|
1498
|
+
this.pausedList.push(b);
|
|
1499
|
+
const ctx = this.ctx;
|
|
1500
|
+
const t = ctx?.currentTime ?? 0;
|
|
1501
|
+
if (ctx) {
|
|
1502
|
+
for (const b of this.pausedList) {
|
|
1503
|
+
const node = this.buses[b];
|
|
1504
|
+
if (node)
|
|
1505
|
+
rampParamTo(node.gain, 0, t, PAUSE_RAMP);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (this.rateZeroOk === null) {
|
|
1509
|
+
for (const v of this.active) {
|
|
1510
|
+
const ok = v.rateZeroAllowed();
|
|
1511
|
+
if (ok !== null) {
|
|
1512
|
+
this.rateZeroOk = ok;
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
this._pausedRate = this.rateZeroOk !== false;
|
|
1518
|
+
if (!this._pausedRate)
|
|
1519
|
+
return;
|
|
1520
|
+
for (const v of this.active)
|
|
1521
|
+
if (this.isBusPaused(v.bus))
|
|
1522
|
+
v.freezeRate(PAUSE_RAMP);
|
|
1523
|
+
for (const [v, b] of this.registered) {
|
|
1524
|
+
const rate = v.rate;
|
|
1525
|
+
if (!this.isBusPaused(b) || typeof rate !== 'number')
|
|
1526
|
+
continue;
|
|
1527
|
+
this.frozenExternal.set(v, rate);
|
|
1528
|
+
v.setRate(0, PAUSE_RAMP);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
/** Undo `pause`: bus gains back to the settings (times any `setBusScale`), voice rates back to the game's. */
|
|
1532
|
+
unpause() {
|
|
1533
|
+
if (!this._paused)
|
|
1534
|
+
return;
|
|
1535
|
+
this._paused = false;
|
|
1536
|
+
const ctx = this.ctx;
|
|
1537
|
+
if (ctx) {
|
|
1538
|
+
for (const b of this.pausedList) {
|
|
1539
|
+
const node = this.buses[b];
|
|
1540
|
+
// Back to the scaled gain (a duck in progress), not the raw setting. Held in the
|
|
1541
|
+
// background, `background(false)` writes it.
|
|
1542
|
+
if (node && !this.hidden)
|
|
1543
|
+
rampParamTo(node.gain, this.busTarget(b), ctx.currentTime, PAUSE_RAMP);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
this.pausedList.length = 0;
|
|
1547
|
+
for (const v of this.active)
|
|
1548
|
+
v.thawRate(PAUSE_RAMP);
|
|
1549
|
+
for (const [v, rate] of this.frozenExternal)
|
|
1550
|
+
v.setRate(rate, PAUSE_RAMP);
|
|
1551
|
+
this.frozenExternal.clear();
|
|
1552
|
+
this._pausedRate = false;
|
|
1553
|
+
}
|
|
1554
|
+
/** True between `pause` and `unpause`. */
|
|
1555
|
+
get paused() {
|
|
1556
|
+
return this._paused;
|
|
1557
|
+
}
|
|
1558
|
+
/** The buses `pause` silenced (empty while not paused). The same array every call. */
|
|
1559
|
+
get pausedBuses() {
|
|
1560
|
+
return this.pausedList;
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Which pause path ran: true when the paused voices' playback rates were set to 0 as well
|
|
1564
|
+
* (they resume where they stopped), false when the context refuses rate 0 and the buses were
|
|
1565
|
+
* only muted (the voices keep running). False while not paused.
|
|
1566
|
+
*/
|
|
1567
|
+
get pausedRate() {
|
|
1568
|
+
return this._pausedRate;
|
|
1569
|
+
}
|
|
1570
|
+
/** Internal: whether `pause` silenced this bus. */
|
|
1571
|
+
isBusPaused(bus) {
|
|
1572
|
+
return this._paused && this.pausedList.includes(bus);
|
|
1573
|
+
}
|
|
1574
|
+
emitSettings() {
|
|
1575
|
+
for (const fn of this.settingsListeners)
|
|
1576
|
+
fn(this._settings);
|
|
1577
|
+
}
|
|
1578
|
+
setVolume(bus, volume) {
|
|
1579
|
+
this._settings = {
|
|
1580
|
+
...this._settings,
|
|
1581
|
+
[bus]: { ...this._settings[bus], volume: Math.min(1, Math.max(0, volume)) },
|
|
1582
|
+
};
|
|
1583
|
+
this.applyBus(bus);
|
|
1584
|
+
this.emitSettings();
|
|
1585
|
+
}
|
|
1586
|
+
setMuted(bus, muted) {
|
|
1587
|
+
this._settings = { ...this._settings, [bus]: { ...this._settings[bus], muted } };
|
|
1588
|
+
this.applyBus(bus);
|
|
1589
|
+
this.emitSettings();
|
|
1590
|
+
}
|
|
1591
|
+
toggleMuted(bus) {
|
|
1592
|
+
this.setMuted(bus, !this._settings[bus].muted);
|
|
1593
|
+
return this._settings[bus].muted;
|
|
1594
|
+
}
|
|
1595
|
+
/** Replace all settings (sanitized), e.g. after loading a save. */
|
|
1596
|
+
applySettings(settings) {
|
|
1597
|
+
this._settings = sanitizeMixer(settings);
|
|
1598
|
+
for (const b of BUS_NAMES)
|
|
1599
|
+
this.applyBus(b);
|
|
1600
|
+
this.emitSettings();
|
|
1601
|
+
}
|
|
1602
|
+
// ------------------------------------------------------------ sounds
|
|
1603
|
+
/**
|
|
1604
|
+
* Register a named sound from a spec (rendered lazily or via `prepare`) or an async buffer
|
|
1605
|
+
* factory. `maxVoices` caps simultaneous plays of this sound (oldest is stolen). Default 8.
|
|
1606
|
+
*/
|
|
1607
|
+
define(name, source, opts = {}) {
|
|
1608
|
+
const prev = this.sounds.get(name);
|
|
1609
|
+
for (const v of prev?.voices ?? [])
|
|
1610
|
+
v.stop();
|
|
1611
|
+
this.spriteSheets.delete(name);
|
|
1612
|
+
this.sounds.set(name, {
|
|
1613
|
+
source,
|
|
1614
|
+
buffer: null,
|
|
1615
|
+
pending: null,
|
|
1616
|
+
voices: [],
|
|
1617
|
+
maxVoices: opts.maxVoices ?? 8,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
has(name) {
|
|
1621
|
+
return this.sounds.has(name);
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Register a sprite sheet (ws/audio-4): one buffer (or a factory for it) and named
|
|
1625
|
+
* `[startMs, lengthMs]` ranges. `play('name#clip')` and `loop('name#clip')` then play one range
|
|
1626
|
+
* (a loop repeats between the clip's `loopPoints`, or its whole range); `play('name')` still
|
|
1627
|
+
* plays the whole buffer. `maxVoices` caps the sheet's simultaneous plays, every clip counted.
|
|
1628
|
+
*/
|
|
1629
|
+
defineSprites(name, sheet, opts = {}) {
|
|
1630
|
+
const source = sheet.buffer
|
|
1631
|
+
? (() => Promise.resolve(sheet.buffer))
|
|
1632
|
+
: (sheet.source ?? (() => Promise.reject(new Error(`sprite sheet "${name}" has no buffer`))));
|
|
1633
|
+
this.define(name, source, opts);
|
|
1634
|
+
const e = this.sounds.get(name);
|
|
1635
|
+
if (sheet.buffer)
|
|
1636
|
+
e.buffer = sheet.buffer;
|
|
1637
|
+
this.spriteSheets.set(name, sheet);
|
|
1638
|
+
}
|
|
1639
|
+
/** The sprite sheet registered under `name`, if any. */
|
|
1640
|
+
spriteSheet(name) {
|
|
1641
|
+
return this.spriteSheets.get(name) ?? null;
|
|
1642
|
+
}
|
|
1643
|
+
/** Render (or fetch) the buffer for a sound. Needs no gesture. Cached. */
|
|
1644
|
+
load(name) {
|
|
1645
|
+
const e = this.sounds.get(name);
|
|
1646
|
+
if (!e)
|
|
1647
|
+
return Promise.reject(new Error(`unknown sound "${name}"`));
|
|
1648
|
+
if (e.buffer)
|
|
1649
|
+
return Promise.resolve(e.buffer);
|
|
1650
|
+
if (!e.pending) {
|
|
1651
|
+
const src = e.source;
|
|
1652
|
+
e.pending = (typeof src === 'function' ? src() : renderSound(src, this.createOffline)).then((buf) => {
|
|
1653
|
+
e.buffer = buf;
|
|
1654
|
+
e.pending = null;
|
|
1655
|
+
return buf;
|
|
1656
|
+
}, (err) => {
|
|
1657
|
+
e.pending = null;
|
|
1658
|
+
throw err;
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
return e.pending;
|
|
1662
|
+
}
|
|
1663
|
+
/** Render several (default: all) defined sounds up front. Call at boot or behind a loading screen. */
|
|
1664
|
+
async prepare(names = [...this.sounds.keys()]) {
|
|
1665
|
+
if (this._halted)
|
|
1666
|
+
return;
|
|
1667
|
+
// Starting a spec render builds its graph (and fills any noise) synchronously, a few ms
|
|
1668
|
+
// each. Started all at once, a batch of them is one long main-thread task (50+ ms), so
|
|
1669
|
+
// yield to the event loop whenever the builds in this turn pass `PREPARE_SLICE_MS`.
|
|
1670
|
+
const pending = [];
|
|
1671
|
+
let t0 = performance.now();
|
|
1672
|
+
for (const n of names) {
|
|
1673
|
+
if (this._halted)
|
|
1674
|
+
return;
|
|
1675
|
+
const p = this.load(n);
|
|
1676
|
+
// Awaited below; the no-op catch keeps a rejection during a yield from being "unhandled".
|
|
1677
|
+
p.catch(() => { });
|
|
1678
|
+
pending.push(p);
|
|
1679
|
+
if (performance.now() - t0 >= PREPARE_SLICE_MS) {
|
|
1680
|
+
await yieldToEventLoop();
|
|
1681
|
+
t0 = performance.now();
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
await Promise.all(pending);
|
|
1685
|
+
}
|
|
1686
|
+
/** Rendered buffer if ready. */
|
|
1687
|
+
buffer(name) {
|
|
1688
|
+
return this.sounds.get(name)?.buffer ?? null;
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Fire a one-shot. Dropped (returns an inert voice) while audio is locked, suspended or
|
|
1692
|
+
* unavailable: a click sound arriving seconds late after unlock would be wrong.
|
|
1693
|
+
*/
|
|
1694
|
+
play(sound, opts = {}) {
|
|
1695
|
+
if (this._state !== 'running' || !this.ctx || this._halted)
|
|
1696
|
+
return NULL_VOICE;
|
|
1697
|
+
return this.startVoice(sound, false, opts, false);
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Start a loop (music, ambience). If audio is still locked, it starts as soon as it unlocks.
|
|
1701
|
+
* Stop it with `voice.stop(fade)`.
|
|
1702
|
+
*/
|
|
1703
|
+
loop(sound, opts = {}) {
|
|
1704
|
+
if (this._state === 'unavailable' || this._halted)
|
|
1705
|
+
return NULL_VOICE;
|
|
1706
|
+
return this.startVoice(sound, true, opts, this._state !== 'running');
|
|
1707
|
+
}
|
|
1708
|
+
startVoice(sound, loop, opts, defer) {
|
|
1709
|
+
let schedule = null;
|
|
1710
|
+
if (typeof sound === 'string') {
|
|
1711
|
+
const ref = parseSpriteRef(sound);
|
|
1712
|
+
if (ref) {
|
|
1713
|
+
const sheet = this.spriteSheets.get(ref.sheet);
|
|
1714
|
+
if (!sheet)
|
|
1715
|
+
throw new Error(`[audio] unknown sprite sheet "${ref.sheet}"`);
|
|
1716
|
+
schedule = { ...spriteSchedule(sheet, ref.sheet, ref.clip, this.scratchSchedule) };
|
|
1717
|
+
sound = ref.sheet;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
const entry = typeof sound === 'string' ? this.sounds.get(sound) : undefined;
|
|
1721
|
+
if (typeof sound === 'string' && !entry) {
|
|
1722
|
+
console.warn(`[audio] unknown sound "${sound}"`);
|
|
1723
|
+
return NULL_VOICE;
|
|
1724
|
+
}
|
|
1725
|
+
const voice = new VoiceImpl(this, loop, opts, (v) => {
|
|
1726
|
+
this.active.delete(v);
|
|
1727
|
+
this.followers.delete(v);
|
|
1728
|
+
this.untrackSpatial(v);
|
|
1729
|
+
if (entry) {
|
|
1730
|
+
const i = entry.voices.indexOf(v);
|
|
1731
|
+
if (i >= 0)
|
|
1732
|
+
entry.voices.splice(i, 1);
|
|
1733
|
+
}
|
|
1734
|
+
for (const d of this.deferred)
|
|
1735
|
+
if (d.voice === v)
|
|
1736
|
+
this.deferred.delete(d);
|
|
1737
|
+
}, schedule);
|
|
1738
|
+
this.active.add(voice);
|
|
1739
|
+
if (entry) {
|
|
1740
|
+
entry.voices.push(voice);
|
|
1741
|
+
while (entry.voices.length > entry.maxVoices)
|
|
1742
|
+
entry.voices.shift().stop(0.02);
|
|
1743
|
+
}
|
|
1744
|
+
if (defer) {
|
|
1745
|
+
this.deferred.add({ voice, sound });
|
|
1746
|
+
if (typeof sound === 'string')
|
|
1747
|
+
void this.load(sound).catch(() => { });
|
|
1748
|
+
return voice;
|
|
1749
|
+
}
|
|
1750
|
+
const buf = typeof sound === 'string' ? entry.buffer : sound;
|
|
1751
|
+
if (buf) {
|
|
1752
|
+
voice.start(buf);
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
const requested = this.ctx.currentTime;
|
|
1756
|
+
void this.load(sound).then((b) => {
|
|
1757
|
+
const late = (this.ctx?.currentTime ?? Infinity) - requested;
|
|
1758
|
+
if (loop || late <= MAX_LATE_START)
|
|
1759
|
+
voice.start(b);
|
|
1760
|
+
else
|
|
1761
|
+
voice.stop();
|
|
1762
|
+
}, (err) => {
|
|
1763
|
+
console.warn(`[audio] failed to render "${String(sound)}":`, err);
|
|
1764
|
+
voice.stop();
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
return voice;
|
|
1768
|
+
}
|
|
1769
|
+
startDeferred() {
|
|
1770
|
+
for (const d of [...this.deferred]) {
|
|
1771
|
+
this.deferred.delete(d);
|
|
1772
|
+
if (!d.voice.playing)
|
|
1773
|
+
continue;
|
|
1774
|
+
if (typeof d.sound !== 'string') {
|
|
1775
|
+
d.voice.start(d.sound);
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
void this.load(d.sound).then((b) => {
|
|
1779
|
+
if (d.voice.playing && !d.voice.started)
|
|
1780
|
+
d.voice.start(b);
|
|
1781
|
+
}, () => d.voice.stop());
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* Stop every voice the mixer knows (its own and the registered ones, so music stems too),
|
|
1786
|
+
* with an optional fade, or only those on `bus`. The options form adds `except`:
|
|
1787
|
+
* `stopAll({ except: ['music'] })` clears the effects and keeps the score.
|
|
1788
|
+
*/
|
|
1789
|
+
stopAll(fade = 0, bus) {
|
|
1790
|
+
const o = typeof fade === 'object' ? fade : { fade, bus };
|
|
1791
|
+
const f = o.fade ?? 0;
|
|
1792
|
+
const hit = (b) => (!o.bus || b === o.bus) && !o.except?.includes(b);
|
|
1793
|
+
for (const v of [...this.active])
|
|
1794
|
+
if (hit(v.bus))
|
|
1795
|
+
v.stop(f);
|
|
1796
|
+
for (const [v, b] of [...this.registered])
|
|
1797
|
+
if (hit(b))
|
|
1798
|
+
v.stop(f);
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* The live voices (playing or waiting to start, not yet stopped or ended) on `bus`, or on
|
|
1802
|
+
* every bus when `bus` is omitted or `master`. A voice leaves the list the moment `stop` is
|
|
1803
|
+
* called or its source ends. Allocates one array per call; `forEachVoice` allocates nothing.
|
|
1804
|
+
*/
|
|
1805
|
+
voices(bus) {
|
|
1806
|
+
const out = [];
|
|
1807
|
+
this.forEachVoice(bus, (v) => {
|
|
1808
|
+
out.push(v);
|
|
1809
|
+
});
|
|
1810
|
+
return out;
|
|
1811
|
+
}
|
|
1812
|
+
/** `voices(bus)` without the array: `fn(voice, bus)` for each live voice. */
|
|
1813
|
+
forEachVoice(bus, fn) {
|
|
1814
|
+
const all = bus === undefined || bus === 'master';
|
|
1815
|
+
for (const v of this.active)
|
|
1816
|
+
if (all || v.bus === bus)
|
|
1817
|
+
fn(v, v.bus);
|
|
1818
|
+
for (const [v, b] of this.registered)
|
|
1819
|
+
if ((all || b === bus) && v.playing)
|
|
1820
|
+
fn(v, b);
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Put an externally owned voice (anything implementing `Voice`) on the mixer's books, so
|
|
1824
|
+
* `voices`, `voiceCount`, `stopAll` and `pause` see it; `createMusic` does this for each
|
|
1825
|
+
* playing stem. A registered voice with a numeric `rate` property has its rate frozen by
|
|
1826
|
+
* `pause` and restored by `unpause`. Returns the unregister; a voice whose `playing` is false
|
|
1827
|
+
* is skipped meanwhile.
|
|
1828
|
+
*/
|
|
1829
|
+
register(voice, bus) {
|
|
1830
|
+
this.registered.set(voice, bus);
|
|
1831
|
+
return () => {
|
|
1832
|
+
this.registered.delete(voice);
|
|
1833
|
+
this.frozenExternal.delete(voice);
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
/** Number of live voices: `voices().length` without the array. */
|
|
1837
|
+
get voiceCount() {
|
|
1838
|
+
let n = this.active.size;
|
|
1839
|
+
for (const [v] of this.registered)
|
|
1840
|
+
if (v.playing)
|
|
1841
|
+
n++;
|
|
1842
|
+
return n;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* A `Voice` over a raw `AudioNode` another owner runs (ws/audio-4): source → gain → bus, with
|
|
1847
|
+
* `setGain`, `stop(fade)` (ramps the gain, stops a scheduled source at the fade's end, else
|
|
1848
|
+
* disconnects it then), `setRate` (writes the source's `playbackRate` when it has one, so
|
|
1849
|
+
* `audio.pause` freezes it), `sendTo` and `filter` on the same pool as the mixer's voices. Not
|
|
1850
|
+
* spatial: `setPosition`, `follow` and `occlusion` are inert. Registered with the mixer unless
|
|
1851
|
+
* `register` is false, so `voices`, `stopAll` and `pause` see it. Null context: an inert voice.
|
|
1852
|
+
*/
|
|
1853
|
+
export function createVoiceAdapter(audio, opts) {
|
|
1854
|
+
const ctx = audio.context;
|
|
1855
|
+
const bus = opts.bus ?? 'sfx';
|
|
1856
|
+
const busNode = audio.busNode(bus);
|
|
1857
|
+
const inert = {
|
|
1858
|
+
...NULL_VOICE,
|
|
1859
|
+
rate: 1,
|
|
1860
|
+
gainNode: null,
|
|
1861
|
+
end() { },
|
|
1862
|
+
};
|
|
1863
|
+
if (!ctx || !busNode)
|
|
1864
|
+
return inert;
|
|
1865
|
+
const g = ctx.createGain();
|
|
1866
|
+
g.gain.value = opts.gain ?? 1;
|
|
1867
|
+
const source = opts.source;
|
|
1868
|
+
let filterNode = null;
|
|
1869
|
+
let sends = null;
|
|
1870
|
+
let playing = true;
|
|
1871
|
+
let unregister = null;
|
|
1872
|
+
const rateParam = source.playbackRate;
|
|
1873
|
+
const relink = () => {
|
|
1874
|
+
g.disconnect();
|
|
1875
|
+
filterNode?.disconnect();
|
|
1876
|
+
const tail = filterNode ? g.connect(filterNode) : g;
|
|
1877
|
+
tail.connect(busNode);
|
|
1878
|
+
if (sends) {
|
|
1879
|
+
if (sends.attached)
|
|
1880
|
+
sends.retarget(tail);
|
|
1881
|
+
else
|
|
1882
|
+
sends.attach(ctx, tail);
|
|
1883
|
+
}
|
|
1884
|
+
};
|
|
1885
|
+
const teardown = () => {
|
|
1886
|
+
if (!playing)
|
|
1887
|
+
return;
|
|
1888
|
+
playing = false;
|
|
1889
|
+
unregister?.();
|
|
1890
|
+
unregister = null;
|
|
1891
|
+
sends?.dispose();
|
|
1892
|
+
sends = null;
|
|
1893
|
+
try {
|
|
1894
|
+
source.disconnect(g);
|
|
1895
|
+
}
|
|
1896
|
+
catch {
|
|
1897
|
+
// Not connected.
|
|
1898
|
+
}
|
|
1899
|
+
g.disconnect();
|
|
1900
|
+
if (filterNode) {
|
|
1901
|
+
filterNode.disconnect();
|
|
1902
|
+
audio.releaseBiquad(filterNode);
|
|
1903
|
+
filterNode = null;
|
|
1904
|
+
}
|
|
1905
|
+
opts.onEnd?.();
|
|
1906
|
+
};
|
|
1907
|
+
const voice = {
|
|
1908
|
+
rate: 1,
|
|
1909
|
+
get gainNode() {
|
|
1910
|
+
return playing ? g : null;
|
|
1911
|
+
},
|
|
1912
|
+
stop(fade = 0) {
|
|
1913
|
+
if (!playing)
|
|
1914
|
+
return;
|
|
1915
|
+
const t = ctx.currentTime;
|
|
1916
|
+
rampParamTo(g.gain, 0, t, fade);
|
|
1917
|
+
const scheduled = source;
|
|
1918
|
+
if (typeof scheduled.stop === 'function') {
|
|
1919
|
+
const prev = scheduled.onended;
|
|
1920
|
+
scheduled.onended = (ev) => {
|
|
1921
|
+
if (typeof prev === 'function')
|
|
1922
|
+
prev.call(source, ev);
|
|
1923
|
+
teardown();
|
|
1924
|
+
};
|
|
1925
|
+
try {
|
|
1926
|
+
scheduled.stop(t + fade);
|
|
1927
|
+
}
|
|
1928
|
+
catch {
|
|
1929
|
+
teardown();
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
else if (fade > 0) {
|
|
1933
|
+
setTimeout(teardown, fade * 1000);
|
|
1934
|
+
}
|
|
1935
|
+
else {
|
|
1936
|
+
teardown();
|
|
1937
|
+
}
|
|
1938
|
+
unregister?.();
|
|
1939
|
+
unregister = null;
|
|
1940
|
+
},
|
|
1941
|
+
end: teardown,
|
|
1942
|
+
setGain(gain, ramp = 0.05) {
|
|
1943
|
+
if (!playing)
|
|
1944
|
+
return;
|
|
1945
|
+
g.gain.setTargetAtTime(gain, ctx.currentTime, Math.max(0.001, ramp / 3));
|
|
1946
|
+
},
|
|
1947
|
+
setRate(rate, ramp = 0.05) {
|
|
1948
|
+
voice.rate = rate;
|
|
1949
|
+
if (rateParam && playing)
|
|
1950
|
+
rampParamTo(rateParam, rate, ctx.currentTime, ramp);
|
|
1951
|
+
},
|
|
1952
|
+
setPosition() { },
|
|
1953
|
+
setDirection() { },
|
|
1954
|
+
follow() { },
|
|
1955
|
+
sendTo(input, amount, ramp = 0.05) {
|
|
1956
|
+
if (!playing)
|
|
1957
|
+
return;
|
|
1958
|
+
sends ??= new VoiceSends();
|
|
1959
|
+
if (!sends.attached)
|
|
1960
|
+
sends.attach(ctx, filterNode ?? g);
|
|
1961
|
+
sends.set(input, amount, ramp);
|
|
1962
|
+
},
|
|
1963
|
+
filter(kind, o = {}) {
|
|
1964
|
+
if (!playing)
|
|
1965
|
+
return;
|
|
1966
|
+
if (kind === null) {
|
|
1967
|
+
if (!filterNode)
|
|
1968
|
+
return;
|
|
1969
|
+
const f = filterNode;
|
|
1970
|
+
filterNode = null;
|
|
1971
|
+
relink();
|
|
1972
|
+
audio.releaseBiquad(f);
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
const ramp = o.ramp ?? 0.05;
|
|
1976
|
+
if (filterNode) {
|
|
1977
|
+
tuneBiquad(filterNode, kind, o.cutoff ?? 1000, o.q ?? 1, ctx.currentTime, ramp);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
const f = audio.acquireBiquad();
|
|
1981
|
+
if (!f)
|
|
1982
|
+
return;
|
|
1983
|
+
tuneBiquad(f, kind, o.cutoff ?? 1000, o.q ?? 1, ctx.currentTime, 0);
|
|
1984
|
+
filterNode = f;
|
|
1985
|
+
relink();
|
|
1986
|
+
},
|
|
1987
|
+
position: new Float64Array(3),
|
|
1988
|
+
get playing() {
|
|
1989
|
+
return playing;
|
|
1990
|
+
},
|
|
1991
|
+
get sendCount() {
|
|
1992
|
+
return sends?.count ?? 0;
|
|
1993
|
+
},
|
|
1994
|
+
occlusion: 0,
|
|
1995
|
+
doppler: 1,
|
|
1996
|
+
};
|
|
1997
|
+
source.connect(g);
|
|
1998
|
+
g.connect(busNode);
|
|
1999
|
+
if (opts.register !== false)
|
|
2000
|
+
unregister = audio.register(voice, bus);
|
|
2001
|
+
return voice;
|
|
2002
|
+
}
|
|
2003
|
+
function createLimiter(ctx, o) {
|
|
2004
|
+
if (typeof ctx.createDynamicsCompressor !== 'function')
|
|
2005
|
+
return null;
|
|
2006
|
+
const c = ctx.createDynamicsCompressor();
|
|
2007
|
+
c.threshold.value = o.threshold;
|
|
2008
|
+
c.knee.value = o.knee;
|
|
2009
|
+
c.ratio.value = o.ratio;
|
|
2010
|
+
c.attack.value = o.attack;
|
|
2011
|
+
c.release.value = o.release;
|
|
2012
|
+
return c;
|
|
2013
|
+
}
|