@woosh/meep-engine 2.154.0 → 2.156.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/README.md +1 -1
- package/build/bundle-worker-image-decoder.js +1 -1
- package/build/bundle-worker-terrain.js +1 -1
- package/editor/view/ecs/ComponentControlView.d.ts +0 -9
- package/editor/view/ecs/ComponentControlView.js +2 -98
- package/package.json +1 -1
- package/src/core/binary/32BitEncoder.js +1 -1
- package/src/core/binary/to_half_float_uint16.js +3 -3
- package/src/core/bvh2/bvh3/ebvh_build_hierarchy_radix.d.ts.map +1 -1
- package/src/core/bvh2/bvh3/ebvh_build_hierarchy_radix.js +275 -253
- package/src/core/cache/Cache.d.ts.map +1 -1
- package/src/core/cache/Cache.js +7 -0
- package/src/core/cache/FrequencySketch.d.ts.map +1 -1
- package/src/core/cache/FrequencySketch.js +8 -4
- package/src/core/clipboard/obtainClipBoard.d.ts +6 -0
- package/src/core/clipboard/obtainClipBoard.d.ts.map +1 -0
- package/src/core/clipboard/obtainClipBoard.js +29 -0
- package/src/core/clipboard/safeClipboardReadText.d.ts +6 -0
- package/src/core/clipboard/safeClipboardReadText.d.ts.map +1 -0
- package/src/core/clipboard/safeClipboardReadText.js +55 -0
- package/src/core/clipboard/safeClipboardWriteText.d.ts +8 -0
- package/src/core/clipboard/safeClipboardWriteText.d.ts.map +1 -0
- package/src/core/clipboard/safeClipboardWriteText.js +23 -0
- package/src/core/collection/array/array_quick_sort_by_lookup_map.js +1 -1
- package/src/core/collection/array/array_set_diff_sorting.d.ts.map +1 -1
- package/src/core/collection/array/array_set_diff_sorting.js +4 -1
- package/src/core/collection/array/array_shuffle.d.ts.map +1 -1
- package/src/core/collection/array/array_shuffle.js +30 -27
- package/src/core/collection/array/binarySearchLowIndex.d.ts.map +1 -1
- package/src/core/collection/array/binarySearchLowIndex.js +4 -3
- package/src/core/collection/array/typed/array_buffer_hash.js +1 -1
- package/src/core/collection/array/typed/is_typed_array_equals.d.ts.map +1 -1
- package/src/core/collection/array/typed/is_typed_array_equals.js +12 -2
- package/src/core/collection/heap/BinaryHeap.d.ts.map +1 -1
- package/src/core/collection/heap/BinaryHeap.js +12 -2
- package/src/core/collection/queue/Deque.d.ts.map +1 -1
- package/src/core/collection/queue/Deque.js +10 -8
- package/src/core/collection/table/RowFirstTable.d.ts.map +1 -1
- package/src/core/collection/table/RowFirstTable.js +4 -2
- package/src/core/collection/table/RowFirstTableSpec.js +2 -2
- package/src/core/color/operations/color_lerp.d.ts.map +1 -1
- package/src/core/color/operations/color_lerp.js +10 -3
- package/src/core/color/rgb2uint32.js +1 -1
- package/src/core/color/rgbe9995_to_rgb.js +1 -1
- package/src/core/function/objectsEqual.d.ts.map +1 -1
- package/src/core/function/objectsEqual.js +2 -1
- package/src/core/geom/2d/aabb/AABB2.d.ts.map +1 -1
- package/src/core/geom/2d/aabb/AABB2.js +12 -11
- package/src/core/geom/2d/convex-hull/convex_hull_jarvis_2d.d.ts.map +1 -1
- package/src/core/geom/2d/convex-hull/convex_hull_jarvis_2d.js +30 -4
- package/src/core/geom/2d/convex-hull/fixed_convex_hull_relaxation.d.ts.map +1 -1
- package/src/core/geom/2d/convex-hull/fixed_convex_hull_relaxation.js +6 -2
- package/src/core/geom/2d/hash-grid/SpatialHashGrid.d.ts.map +1 -1
- package/src/core/geom/2d/hash-grid/SpatialHashGrid.js +388 -386
- package/src/core/geom/2d/hash-grid/shg_query_elements_line.d.ts.map +1 -1
- package/src/core/geom/2d/hash-grid/shg_query_elements_line.js +8 -3
- package/src/core/geom/2d/quad-tree/QuadTreeDatum.d.ts.map +1 -1
- package/src/core/geom/2d/quad-tree/QuadTreeDatum.js +9 -1
- package/src/core/geom/2d/quad-tree/qt_query_data_nearest_to_point.d.ts +3 -1
- package/src/core/geom/2d/quad-tree/qt_query_data_nearest_to_point.d.ts.map +1 -1
- package/src/core/geom/2d/quad-tree/qt_query_data_nearest_to_point.js +3 -1
- package/src/core/geom/2d/quad-tree-binary/QuadTree.js +714 -714
- package/src/core/geom/2d/r-tree/StaticR2Tree.d.ts.map +1 -1
- package/src/core/geom/2d/r-tree/StaticR2Tree.js +5 -4
- package/src/core/geom/3d/aabb/aabb3_detailed_volume_intersection.d.ts.map +1 -1
- package/src/core/geom/3d/aabb/aabb3_detailed_volume_intersection.js +33 -29
- package/src/core/geom/3d/aabb/aabb3_near_distance_to_intersection_ray_segment.d.ts.map +1 -1
- package/src/core/geom/3d/aabb/aabb3_near_distance_to_intersection_ray_segment.js +3 -1
- package/src/core/geom/3d/aabb/aabb3_signed_distance_to_aabb3.d.ts.map +1 -1
- package/src/core/geom/3d/aabb/aabb3_signed_distance_to_aabb3.js +10 -7
- package/src/core/geom/3d/aabb/aabb3_transformed_compute_plane_side.d.ts.map +1 -1
- package/src/core/geom/3d/aabb/aabb3_transformed_compute_plane_side.js +30 -9
- package/src/core/geom/3d/aabb/compute_aabb_from_points.js +3 -3
- package/src/core/geom/3d/box/box3_raycast.d.ts +37 -0
- package/src/core/geom/3d/box/box3_raycast.d.ts.map +1 -0
- package/src/core/geom/3d/box/box3_raycast.js +81 -0
- package/src/core/geom/3d/capsule/capsule_raycast.d.ts +35 -0
- package/src/core/geom/3d/capsule/capsule_raycast.d.ts.map +1 -0
- package/src/core/geom/3d/capsule/capsule_raycast.js +93 -0
- package/src/core/geom/3d/cone/compute_bounding_cone_of_2_cones.d.ts.map +1 -1
- package/src/core/geom/3d/cone/compute_bounding_cone_of_2_cones.js +4 -0
- package/src/core/geom/3d/frustum/frustum3_computeNearestPointToPoint.js +1 -1
- package/src/core/geom/3d/line/line3_compute_segment_point_distance_eikonal.d.ts.map +1 -1
- package/src/core/geom/3d/line/line3_compute_segment_point_distance_eikonal.js +3 -2
- package/src/core/geom/3d/mat4/decompose_matrix_4_array.d.ts.map +1 -1
- package/src/core/geom/3d/mat4/decompose_matrix_4_array.js +12 -2
- package/src/core/geom/3d/mat4/eulerAnglesFromMatrix.js +2 -2
- package/src/core/geom/3d/mat4/m4_multiply_alphatensor.d.ts +1 -1
- package/src/core/geom/3d/mat4/m4_multiply_alphatensor.d.ts.map +1 -1
- package/src/core/geom/3d/mat4/m4_multiply_alphatensor.js +19 -13
- package/src/core/geom/3d/octahedra/octahedral_direction_to_uv.d.ts.map +1 -1
- package/src/core/geom/3d/octahedra/octahedral_direction_to_uv.js +3 -2
- package/src/core/geom/3d/plane/plane3_compute_plane_intersection.js +3 -2
- package/src/core/geom/3d/shape/MeshShape3D.d.ts.map +1 -1
- package/src/core/geom/3d/shape/MeshShape3D.js +7 -0
- package/src/core/geom/3d/shape/UnionShape3D.d.ts.map +1 -1
- package/src/core/geom/3d/shape/UnionShape3D.js +3 -2
- package/src/core/geom/3d/shape/util/shape3d_voxelize_to_grid.d.ts.map +1 -1
- package/src/core/geom/3d/shape/util/shape3d_voxelize_to_grid.js +153 -148
- package/src/core/geom/3d/sphere/harmonics/sh3_dering_optimize_positive.d.ts.map +1 -1
- package/src/core/geom/3d/sphere/harmonics/sh3_dering_optimize_positive.js +7 -0
- package/src/core/geom/3d/sphere/harmonics/sh3_sample_by_direction.d.ts.map +1 -1
- package/src/core/geom/3d/sphere/harmonics/sh3_sample_by_direction.js +13 -10
- package/src/core/geom/3d/sphere/sphere_projected_sphere_radius_sqr.d.ts +1 -1
- package/src/core/geom/3d/sphere/sphere_projected_sphere_radius_sqr.js +2 -2
- package/src/core/geom/3d/sphere/sphere_raycast.d.ts +33 -0
- package/src/core/geom/3d/sphere/sphere_raycast.d.ts.map +1 -0
- package/src/core/geom/3d/sphere/sphere_raycast.js +47 -0
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_tet_get_neighbours.d.ts +24 -0
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_tet_get_neighbours.d.ts.map +1 -0
- package/src/core/geom/3d/tetrahedra/tetrahedral_mesh_tet_get_neighbours.js +39 -0
- package/src/core/geom/3d/tetrahedra/triangle/trace_triangular_depth_map.d.ts.map +1 -1
- package/src/core/geom/3d/tetrahedra/triangle/trace_triangular_depth_map.js +4 -2
- package/src/core/geom/3d/topology/bounds/computeTriangleClusterNormalBoundingCone.js +3 -3
- package/src/core/geom/3d/topology/simplify/decimate_edge_collapse_snap.js +1 -1
- package/src/core/geom/3d/topology/tm_vertex_compute_normal.d.ts.map +1 -1
- package/src/core/geom/3d/topology/tm_vertex_compute_normal.js +4 -2
- package/src/core/geom/3d/util/make_justified_point_grid.d.ts.map +1 -1
- package/src/core/geom/3d/util/make_justified_point_grid.js +18 -10
- package/src/core/geom/ConicRay.d.ts.map +1 -1
- package/src/core/geom/ConicRay.js +11 -13
- package/src/core/geom/packing/max-rect/removeRedundantBoxes.d.ts.map +1 -1
- package/src/core/geom/packing/max-rect/removeRedundantBoxes.js +19 -4
- package/src/core/geom/vec3/v3_array_copy.d.ts +3 -3
- package/src/core/geom/vec3/v3_array_copy.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_array_copy.js +2 -2
- package/src/core/geom/vec3/v3_cross.d.ts +17 -0
- package/src/core/geom/vec3/v3_cross.d.ts.map +1 -0
- package/src/core/geom/vec3/v3_cross.js +20 -0
- package/src/core/geom/vec3/v3_orthonormal_matrix_from_normal.d.ts.map +1 -0
- package/src/{engine/graphics/sh3/path_tracer/sampling → core/geom/vec3}/v3_orthonormal_matrix_from_normal.js +1 -1
- package/src/core/geom/vec3/v3_subtract.d.ts +16 -0
- package/src/core/geom/vec3/v3_subtract.d.ts.map +1 -0
- package/src/core/geom/vec3/v3_subtract.js +19 -0
- package/src/core/graph/coloring/colorizeGraph.js +2 -2
- package/src/core/graph/csr/CSRGraph.d.ts.map +1 -1
- package/src/core/graph/csr/CSRGraph.js +325 -319
- package/src/core/graph/layout/CircleLayout.d.ts.map +1 -1
- package/src/core/graph/layout/CircleLayout.js +8 -6
- package/src/core/graph/metis/native/refine/compute_kway_params.d.ts.map +1 -1
- package/src/core/graph/metis/native/refine/compute_kway_params.js +139 -138
- package/src/core/graph/mn_graph_coarsen.d.ts.map +1 -1
- package/src/core/graph/mn_graph_coarsen.js +4 -2
- package/src/core/graph/v2/NodeContainer.js +7 -7
- package/src/core/localization/LocalizationEngine.js +1 -1
- package/src/core/math/bell_membership_function.d.ts.map +1 -1
- package/src/core/math/bell_membership_function.js +3 -1
- package/src/core/math/complex/complex_add.d.ts +4 -4
- package/src/core/math/complex/complex_add.d.ts.map +1 -1
- package/src/core/math/complex/complex_add.js +3 -3
- package/src/core/math/complex/complex_div.d.ts +4 -4
- package/src/core/math/complex/complex_div.d.ts.map +1 -1
- package/src/core/math/complex/complex_div.js +3 -3
- package/src/core/math/complex/complex_mul.d.ts +4 -4
- package/src/core/math/complex/complex_mul.d.ts.map +1 -1
- package/src/core/math/complex/complex_mul.js +3 -3
- package/src/core/math/complex/complex_sub.d.ts +4 -4
- package/src/core/math/complex/complex_sub.d.ts.map +1 -1
- package/src/core/math/complex/complex_sub.js +3 -3
- package/src/core/math/idct_1d.d.ts +4 -4
- package/src/core/math/idct_1d.d.ts.map +1 -1
- package/src/core/math/idct_1d.js +3 -3
- package/src/core/math/noise/create_simplex_noise_2d.d.ts.map +1 -1
- package/src/core/math/noise/create_simplex_noise_2d.js +4 -2
- package/src/core/math/noise/sdnoise.d.ts.map +1 -1
- package/src/core/math/noise/sdnoise.js +12 -9
- package/src/core/math/physics/mie/compute_bhmie_optical_properties.d.ts.map +1 -1
- package/src/core/math/physics/mie/compute_bhmie_optical_properties.js +94 -50
- package/src/core/math/physics/mie/lorenz_mie_coefs.d.ts +3 -6
- package/src/core/math/physics/mie/lorenz_mie_coefs.d.ts.map +1 -1
- package/src/core/math/physics/mie/lorenz_mie_coefs.js +180 -157
- package/src/core/math/physics/mie/mie_ab_to_optical_properties.d.ts +3 -4
- package/src/core/math/physics/mie/mie_ab_to_optical_properties.d.ts.map +1 -1
- package/src/core/math/physics/mie/mie_ab_to_optical_properties.js +47 -21
- package/src/core/math/random/randomIntegerBetween.d.ts.map +1 -1
- package/src/core/math/random/randomIntegerBetween.js +4 -1
- package/src/core/math/solveCubic.d.ts.map +1 -1
- package/src/core/math/solveCubic.js +95 -82
- package/src/core/math/spline/computeCatmullRomSplineUniformDistance.d.ts.map +1 -1
- package/src/core/math/spline/computeCatmullRomSplineUniformDistance.js +13 -0
- package/src/core/math/statistics/softmax.js +1 -1
- package/src/core/model/node-graph/visual/NodeGraphVisualData.d.ts +1 -0
- package/src/core/model/node-graph/visual/NodeGraphVisualData.d.ts.map +1 -1
- package/src/core/model/node-graph/visual/NodeGraphVisualData.js +2 -1
- package/src/core/model/node-graph/visual/NodeVisualData.js +1 -1
- package/src/core/model/object/ImmutableObjectPool.d.ts +7 -0
- package/src/core/model/object/ImmutableObjectPool.d.ts.map +1 -1
- package/src/core/model/object/ImmutableObjectPool.js +20 -10
- package/src/core/model/reactive/evaluation/MultiPredicateEvaluator.d.ts.map +1 -1
- package/src/core/model/reactive/evaluation/MultiPredicateEvaluator.js +39 -2
- package/src/core/model/reactive/model/terminal/ReactiveReference.d.ts.map +1 -1
- package/src/core/model/reactive/model/terminal/ReactiveReference.js +2 -0
- package/src/core/parser/simple/readHexToken.d.ts.map +1 -1
- package/src/core/parser/simple/readHexToken.js +6 -0
- package/src/core/primitives/numbers/number_pretty_print.d.ts.map +1 -1
- package/src/core/primitives/numbers/number_pretty_print.js +4 -1
- package/src/core/primitives/strings/string_jaro_winkler.js +1 -1
- package/src/core/process/CompositeProcess.js +1 -1
- package/src/core/process/action/AsynchronousDelayAction.d.ts.map +1 -1
- package/src/core/process/action/AsynchronousDelayAction.js +3 -0
- package/src/core/process/executor/ConcurrentExecutor.d.ts.map +1 -1
- package/src/core/process/executor/ConcurrentExecutor.js +3 -2
- package/src/core/process/task/util/randomCountTask.d.ts.map +1 -1
- package/src/core/process/task/util/randomCountTask.js +3 -1
- package/src/core/process/undo/ActionProcessor.d.ts.map +1 -1
- package/src/core/process/undo/ActionProcessor.js +5 -3
- package/src/core/process/worker/WorkerBuilder.js +3 -3
- package/src/engine/animation/curve/AnimationCurve.d.ts.map +1 -1
- package/src/engine/animation/curve/AnimationCurve.js +4 -2
- package/src/engine/control/first-person/DESIGN.md +1 -1
- package/src/engine/control/first-person/FirstPersonMotionPhase.d.ts +55 -0
- package/src/engine/control/first-person/FirstPersonMotionPhase.d.ts.map +1 -0
- package/src/engine/control/first-person/FirstPersonMotionPhase.js +134 -0
- package/src/engine/control/first-person/FirstPersonPlayerController.d.ts +23 -2
- package/src/engine/control/first-person/FirstPersonPlayerController.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerController.js +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts +168 -0
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js +115 -0
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts +71 -0
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.d.ts.map +1 -1
- package/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js +255 -55
- package/src/engine/control/first-person/abilities/LedgeGrab.d.ts +82 -43
- package/src/engine/control/first-person/abilities/LedgeGrab.d.ts.map +1 -1
- package/src/engine/control/first-person/abilities/LedgeGrab.js +405 -213
- package/src/engine/control/first-person/abilities/Mantle.d.ts +6 -0
- package/src/engine/control/first-person/abilities/Mantle.d.ts.map +1 -1
- package/src/engine/control/first-person/abilities/Mantle.js +104 -45
- package/src/engine/control/first-person/abilities/ScrambleUp.d.ts +61 -0
- package/src/engine/control/first-person/abilities/ScrambleUp.d.ts.map +1 -0
- package/src/engine/control/first-person/abilities/ScrambleUp.js +182 -0
- package/src/engine/control/first-person/math/jumpDynamics.d.ts +84 -0
- package/src/engine/control/first-person/math/jumpDynamics.d.ts.map +1 -0
- package/src/engine/control/first-person/math/jumpDynamics.js +108 -0
- package/src/engine/control/first-person/prototype_first_person_controller.js +45 -1
- package/src/engine/graphics/camera/testClippingPlaneComputation.js +1 -1
- package/src/engine/graphics/ecs/decal/v2/FPDecalSystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/decal/v2/FPDecalSystem.js +8 -0
- package/src/engine/graphics/ecs/path/tube/prototypeAnimatedPathMask.js +1 -1
- package/src/engine/graphics/particles/particular/engine/utils/volume/prototypeParticleVolume.js +1 -1
- package/src/engine/graphics/render/buffer/buffers/prototypeNormalFrameBuffer.js +1 -1
- package/src/engine/graphics/render/forward_plus/plugin/ptototypeFPPlugin.js +1 -1
- package/src/engine/graphics/render/visibility/hiz/prototypeHiZ.js +1 -1
- package/src/engine/graphics/sh3/path_tracer/texture/sample_material.js +1 -1
- package/src/engine/graphics/shadows/testShadowMapRendering.js +1 -1
- package/src/engine/physics/CONSTRAINT_SOLVER_BENCH_LOG.md +208 -0
- package/src/engine/physics/CONSTRAINT_SOLVER_IMPROVEMENTS_PLAN.md +364 -0
- package/src/engine/physics/PLAN.md +6 -5
- package/src/engine/physics/constraint/solve_constraints.d.ts +4 -1
- package/src/engine/physics/constraint/solve_constraints.d.ts.map +1 -1
- package/src/engine/physics/constraint/solve_constraints.js +147 -33
- package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
- package/src/engine/physics/ecs/PhysicsSystem.js +1750 -1747
- package/src/engine/physics/fluid/ecs/FluidSystem.d.ts +3 -3
- package/src/engine/physics/gjk/gjk_epa_penetration.d.ts +12 -8
- package/src/engine/physics/gjk/gjk_epa_penetration.d.ts.map +1 -1
- package/src/engine/physics/gjk/gjk_epa_penetration.js +447 -158
- package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/convex_convex_manifold.js +22 -25
- package/src/engine/physics/narrowphase/convex_decomposition.d.ts +32 -13
- package/src/engine/physics/narrowphase/convex_decomposition.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/convex_decomposition.js +61 -65
- package/src/engine/physics/narrowphase/mesh_convex_hull.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/mesh_convex_hull.js +13 -8
- package/src/engine/physics/narrowphase/refine_ray_concave.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/refine_ray_concave.js +5 -3
- package/src/engine/physics/narrowphase/refine_ray_hit.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/refine_ray_hit.js +81 -78
- package/src/engine/sound/SoundEngine.d.ts.map +1 -1
- package/src/engine/sound/SoundEngine.js +28 -0
- package/src/engine/sound/dB2Volume.d.ts +1 -1
- package/src/engine/sound/dB2Volume.d.ts.map +1 -1
- package/src/engine/sound/dB2Volume.js +1 -1
- package/src/engine/sound/ecs/SoundController.d.ts +4 -0
- package/src/engine/sound/ecs/SoundController.d.ts.map +1 -1
- package/src/engine/sound/ecs/SoundController.js +4 -0
- package/src/engine/sound/ecs/SoundControllerSystem.d.ts +5 -0
- package/src/engine/sound/ecs/SoundControllerSystem.d.ts.map +1 -1
- package/src/engine/sound/ecs/SoundControllerSystem.js +5 -0
- package/src/engine/sound/ecs/audio/AudioEmitter.d.ts +69 -0
- package/src/engine/sound/ecs/audio/AudioEmitter.d.ts.map +1 -0
- package/src/engine/sound/ecs/audio/AudioEmitter.js +83 -0
- package/src/engine/sound/ecs/audio/AudioEmitterSystem.d.ts +97 -0
- package/src/engine/sound/ecs/audio/AudioEmitterSystem.d.ts.map +1 -0
- package/src/engine/sound/ecs/audio/AudioEmitterSystem.js +238 -0
- package/src/engine/sound/ecs/audio/LiveEmitterSet.d.ts +90 -0
- package/src/engine/sound/ecs/audio/LiveEmitterSet.d.ts.map +1 -0
- package/src/engine/sound/ecs/audio/LiveEmitterSet.js +324 -0
- package/src/engine/sound/ecs/audio/SpatialAudioIndex.d.ts +59 -0
- package/src/engine/sound/ecs/audio/SpatialAudioIndex.d.ts.map +1 -0
- package/src/engine/sound/ecs/audio/SpatialAudioIndex.js +140 -0
- package/src/engine/sound/ecs/emitter/SoundEmitter.d.ts +16 -65
- package/src/engine/sound/ecs/emitter/SoundEmitter.d.ts.map +1 -1
- package/src/engine/sound/ecs/emitter/SoundEmitter.js +19 -224
- package/src/engine/sound/ecs/emitter/SoundEmitterComponentContext.d.ts +26 -29
- package/src/engine/sound/ecs/emitter/SoundEmitterComponentContext.d.ts.map +1 -1
- package/src/engine/sound/ecs/emitter/SoundEmitterComponentContext.js +168 -135
- package/src/engine/sound/ecs/emitter/SoundEmitterSystem.d.ts +36 -59
- package/src/engine/sound/ecs/emitter/SoundEmitterSystem.d.ts.map +1 -1
- package/src/engine/sound/ecs/emitter/SoundEmitterSystem.js +154 -390
- package/src/engine/sound/ecs/emitter/SoundTrack.d.ts +20 -23
- package/src/engine/sound/ecs/emitter/SoundTrack.d.ts.map +1 -1
- package/src/engine/sound/ecs/emitter/SoundTrack.js +34 -152
- package/src/engine/sound/sopra/IMPLEMENTATION_PLAN.md +993 -0
- package/src/engine/sound/sopra/README.md +643 -7
- package/src/engine/sound/sopra/SopraEngine.d.ts +229 -0
- package/src/engine/sound/sopra/SopraEngine.d.ts.map +1 -0
- package/src/engine/sound/sopra/SopraEngine.js +423 -0
- package/src/engine/sound/sopra/asset/AssetManagerBufferProvider.d.ts +26 -0
- package/src/engine/sound/sopra/asset/AssetManagerBufferProvider.d.ts.map +1 -0
- package/src/engine/sound/sopra/asset/AssetManagerBufferProvider.js +71 -0
- package/src/engine/sound/sopra/asset/BufferProvider.d.ts +24 -0
- package/src/engine/sound/sopra/asset/BufferProvider.d.ts.map +1 -0
- package/src/engine/sound/sopra/asset/BufferProvider.js +29 -0
- package/src/engine/sound/sopra/asset/StubBufferProvider.d.ts +31 -0
- package/src/engine/sound/sopra/asset/StubBufferProvider.d.ts.map +1 -0
- package/src/engine/sound/sopra/asset/StubBufferProvider.js +58 -0
- package/src/engine/sound/sopra/definition/BusDefinition.d.ts +83 -0
- package/src/engine/sound/sopra/definition/BusDefinition.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/BusDefinition.js +142 -0
- package/src/engine/sound/sopra/definition/BusDefinitionSerializationAdapter.d.ts +17 -0
- package/src/engine/sound/sopra/definition/BusDefinitionSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/BusDefinitionSerializationAdapter.js +54 -0
- package/src/engine/sound/sopra/definition/DuckingRule.d.ts +71 -0
- package/src/engine/sound/sopra/definition/DuckingRule.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/DuckingRule.js +106 -0
- package/src/engine/sound/sopra/definition/DuckingRuleSerializationAdapter.d.ts +18 -0
- package/src/engine/sound/sopra/definition/DuckingRuleSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/DuckingRuleSerializationAdapter.js +31 -0
- package/src/engine/sound/sopra/definition/EventDescription.d.ts +132 -0
- package/src/engine/sound/sopra/definition/EventDescription.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/EventDescription.js +259 -0
- package/src/engine/sound/sopra/definition/EventDescriptionSerializationAdapter.d.ts +17 -0
- package/src/engine/sound/sopra/definition/EventDescriptionSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/EventDescriptionSerializationAdapter.js +71 -0
- package/src/engine/sound/sopra/definition/MixerSnapshot.d.ts +51 -0
- package/src/engine/sound/sopra/definition/MixerSnapshot.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/MixerSnapshot.js +83 -0
- package/src/engine/sound/sopra/definition/MixerSnapshotSerializationAdapter.d.ts +18 -0
- package/src/engine/sound/sopra/definition/MixerSnapshotSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/MixerSnapshotSerializationAdapter.js +39 -0
- package/src/engine/sound/sopra/definition/ParameterDefinition.d.ts +72 -0
- package/src/engine/sound/sopra/definition/ParameterDefinition.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/ParameterDefinition.js +117 -0
- package/src/engine/sound/sopra/definition/ParameterDefinitionSerializationAdapter.d.ts +18 -0
- package/src/engine/sound/sopra/definition/ParameterDefinitionSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/ParameterDefinitionSerializationAdapter.js +31 -0
- package/src/engine/sound/sopra/definition/SopraPanningModel.d.ts +14 -0
- package/src/engine/sound/sopra/definition/SopraPanningModel.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/SopraPanningModel.js +20 -0
- package/src/engine/sound/sopra/definition/VoiceStealMode.d.ts +10 -0
- package/src/engine/sound/sopra/definition/VoiceStealMode.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/VoiceStealMode.js +18 -0
- package/src/engine/sound/sopra/definition/clip/AbstractAudioClip.d.ts +93 -0
- package/src/engine/sound/sopra/definition/clip/AbstractAudioClip.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/AbstractAudioClip.js +109 -0
- package/src/engine/sound/sopra/definition/clip/BlendContainerAudioClip.d.ts +80 -0
- package/src/engine/sound/sopra/definition/clip/BlendContainerAudioClip.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/BlendContainerAudioClip.js +181 -0
- package/src/engine/sound/sopra/definition/clip/BlendContainerAudioClipSerializationAdapter.d.ts +17 -0
- package/src/engine/sound/sopra/definition/clip/BlendContainerAudioClipSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/BlendContainerAudioClipSerializationAdapter.js +74 -0
- package/src/engine/sound/sopra/definition/clip/ContainerAudioClip.d.ts +34 -0
- package/src/engine/sound/sopra/definition/clip/ContainerAudioClip.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/ContainerAudioClip.js +100 -0
- package/src/engine/sound/sopra/definition/clip/RandomContainerAudioClip.d.ts +101 -0
- package/src/engine/sound/sopra/definition/clip/RandomContainerAudioClip.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/RandomContainerAudioClip.js +230 -0
- package/src/engine/sound/sopra/definition/clip/RandomContainerAudioClipSerializationAdapter.d.ts +17 -0
- package/src/engine/sound/sopra/definition/clip/RandomContainerAudioClipSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/RandomContainerAudioClipSerializationAdapter.js +54 -0
- package/src/engine/sound/sopra/definition/clip/SampleAudioClip.d.ts +103 -0
- package/src/engine/sound/sopra/definition/clip/SampleAudioClip.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/SampleAudioClip.js +191 -0
- package/src/engine/sound/sopra/definition/clip/SampleAudioClipSerializationAdapter.d.ts +18 -0
- package/src/engine/sound/sopra/definition/clip/SampleAudioClipSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/SampleAudioClipSerializationAdapter.js +39 -0
- package/src/engine/sound/sopra/definition/clip/SequenceContainerAudioClip.d.ts +40 -0
- package/src/engine/sound/sopra/definition/clip/SequenceContainerAudioClip.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/SequenceContainerAudioClip.js +91 -0
- package/src/engine/sound/sopra/definition/clip/SequenceContainerAudioClipSerializationAdapter.d.ts +17 -0
- package/src/engine/sound/sopra/definition/clip/SequenceContainerAudioClipSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/SequenceContainerAudioClipSerializationAdapter.js +42 -0
- package/src/engine/sound/sopra/definition/clip/SilenceAudioClip.d.ts +44 -0
- package/src/engine/sound/sopra/definition/clip/SilenceAudioClip.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/SilenceAudioClip.js +77 -0
- package/src/engine/sound/sopra/definition/clip/SilenceAudioClipSerializationAdapter.d.ts +18 -0
- package/src/engine/sound/sopra/definition/clip/SilenceAudioClipSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/SilenceAudioClipSerializationAdapter.js +27 -0
- package/src/engine/sound/sopra/definition/clip/SwitchContainerAudioClip.d.ts +65 -0
- package/src/engine/sound/sopra/definition/clip/SwitchContainerAudioClip.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/SwitchContainerAudioClip.js +131 -0
- package/src/engine/sound/sopra/definition/clip/SwitchContainerAudioClipSerializationAdapter.d.ts +17 -0
- package/src/engine/sound/sopra/definition/clip/SwitchContainerAudioClipSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/clip/SwitchContainerAudioClipSerializationAdapter.js +41 -0
- package/src/engine/sound/sopra/definition/effect/AbstractAudioEffect.d.ts +24 -0
- package/src/engine/sound/sopra/definition/effect/AbstractAudioEffect.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/effect/AbstractAudioEffect.js +24 -0
- package/src/engine/sound/sopra/definition/effect/CompressorEffect.d.ts +70 -0
- package/src/engine/sound/sopra/definition/effect/CompressorEffect.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/effect/CompressorEffect.js +120 -0
- package/src/engine/sound/sopra/definition/effect/CompressorEffectSerializationAdapter.d.ts +18 -0
- package/src/engine/sound/sopra/definition/effect/CompressorEffectSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/effect/CompressorEffectSerializationAdapter.js +31 -0
- package/src/engine/sound/sopra/definition/effect/EqEffect.d.ts +74 -0
- package/src/engine/sound/sopra/definition/effect/EqEffect.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/effect/EqEffect.js +128 -0
- package/src/engine/sound/sopra/definition/effect/EqEffectSerializationAdapter.d.ts +18 -0
- package/src/engine/sound/sopra/definition/effect/EqEffectSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/effect/EqEffectSerializationAdapter.js +29 -0
- package/src/engine/sound/sopra/definition/effect/ReverbEffect.d.ts +49 -0
- package/src/engine/sound/sopra/definition/effect/ReverbEffect.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/effect/ReverbEffect.js +101 -0
- package/src/engine/sound/sopra/definition/effect/ReverbEffectSerializationAdapter.d.ts +18 -0
- package/src/engine/sound/sopra/definition/effect/ReverbEffectSerializationAdapter.d.ts.map +1 -0
- package/src/engine/sound/sopra/definition/effect/ReverbEffectSerializationAdapter.js +25 -0
- package/src/engine/sound/sopra/legacy/soundEmitterToEventDescription.d.ts +31 -0
- package/src/engine/sound/sopra/legacy/soundEmitterToEventDescription.d.ts.map +1 -0
- package/src/engine/sound/sopra/legacy/soundEmitterToEventDescription.js +106 -0
- package/src/engine/sound/sopra/runtime/BusGraph.d.ts +79 -0
- package/src/engine/sound/sopra/runtime/BusGraph.d.ts.map +1 -0
- package/src/engine/sound/sopra/runtime/BusGraph.js +227 -0
- package/src/engine/sound/sopra/runtime/EventInstance.d.ts +144 -0
- package/src/engine/sound/sopra/runtime/EventInstance.d.ts.map +1 -0
- package/src/engine/sound/sopra/runtime/EventInstance.js +579 -0
- package/src/engine/sound/sopra/runtime/ParameterStore.d.ts +42 -0
- package/src/engine/sound/sopra/runtime/ParameterStore.d.ts.map +1 -0
- package/src/engine/sound/sopra/runtime/ParameterStore.js +98 -0
- package/src/engine/sound/sopra/runtime/SopraPlaybackContext.d.ts +42 -0
- package/src/engine/sound/sopra/runtime/SopraPlaybackContext.d.ts.map +1 -0
- package/src/engine/sound/sopra/runtime/SopraPlaybackContext.js +68 -0
- package/src/engine/sound/sopra/runtime/Voice.d.ts +67 -0
- package/src/engine/sound/sopra/runtime/Voice.d.ts.map +1 -0
- package/src/engine/sound/sopra/runtime/Voice.js +145 -0
- package/src/engine/sound/sopra/runtime/VoiceManager.d.ts +38 -0
- package/src/engine/sound/sopra/runtime/VoiceManager.d.ts.map +1 -0
- package/src/engine/sound/sopra/runtime/VoiceManager.js +136 -0
- package/src/engine/sound/sopra/runtime/VoicePool.d.ts +12 -0
- package/src/engine/sound/sopra/runtime/VoicePool.d.ts.map +1 -0
- package/src/engine/sound/sopra/runtime/VoicePool.js +17 -0
- package/src/engine/sound/sopra/serialization/populateSopraSerializationRegistry.d.ts +11 -0
- package/src/engine/sound/sopra/serialization/populateSopraSerializationRegistry.d.ts.map +1 -0
- package/src/engine/sound/sopra/serialization/populateSopraSerializationRegistry.js +42 -0
- package/src/engine/sound/sopra/serialization/sopraJSON.d.ts +33 -0
- package/src/engine/sound/sopra/serialization/sopraJSON.d.ts.map +1 -0
- package/src/engine/sound/sopra/serialization/sopraJSON.js +99 -0
- package/src/engine/sound/sopra/serialization/sopraSerializationHarness.d.ts +27 -0
- package/src/engine/sound/sopra/serialization/sopraSerializationHarness.d.ts.map +1 -0
- package/src/engine/sound/sopra/serialization/sopraSerializationHarness.js +49 -0
- package/src/engine/sound/sopra/util/MockAudioContext.d.ts +74 -0
- package/src/engine/sound/sopra/util/MockAudioContext.d.ts.map +1 -0
- package/src/engine/sound/sopra/util/MockAudioContext.js +215 -0
- package/src/engine/sound/sopra/util/buildAttenuationCurve.d.ts +15 -0
- package/src/engine/sound/sopra/util/buildAttenuationCurve.d.ts.map +1 -0
- package/src/engine/sound/sopra/util/buildAttenuationCurve.js +40 -0
- package/src/engine/sound/sopra/util/fadeOutAndStop.d.ts +34 -0
- package/src/engine/sound/sopra/util/fadeOutAndStop.d.ts.map +1 -0
- package/src/engine/sound/sopra/util/fadeOutAndStop.js +60 -0
- package/src/engine/sound/volume2dB.d.ts +1 -1
- package/src/engine/sound/volume2dB.d.ts.map +1 -1
- package/src/engine/sound/volume2dB.js +1 -1
- package/src/engine/graphics/sh3/path_tracer/sampling/v3_orthonormal_matrix_from_normal.d.ts.map +0 -1
- package/src/engine/physics/narrowphase/ray_shapes.d.ts +0 -66
- package/src/engine/physics/narrowphase/ray_shapes.d.ts.map +0 -1
- package/src/engine/physics/narrowphase/ray_shapes.js +0 -187
- package/src/engine/sound/ecs/emitter/SoundEmitterChannel.d.ts +0 -23
- package/src/engine/sound/ecs/emitter/SoundEmitterChannel.d.ts.map +0 -1
- package/src/engine/sound/ecs/emitter/SoundEmitterChannel.js +0 -32
- package/src/engine/sound/ecs/emitter/SoundTrackNodes.d.ts +0 -18
- package/src/engine/sound/ecs/emitter/SoundTrackNodes.d.ts.map +0 -1
- package/src/engine/sound/ecs/emitter/SoundTrackNodes.js +0 -18
- package/src/engine/sound/sopra/AbstractAudioClip.d.ts +0 -26
- package/src/engine/sound/sopra/AbstractAudioClip.d.ts.map +0 -1
- package/src/engine/sound/sopra/AbstractAudioClip.js +0 -29
- package/src/engine/sound/sopra/ContainerAudioClip.d.ts +0 -12
- package/src/engine/sound/sopra/ContainerAudioClip.d.ts.map +0 -1
- package/src/engine/sound/sopra/ContainerAudioClip.js +0 -13
- package/src/engine/sound/sopra/RandomContainerAudioClip.d.ts +0 -12
- package/src/engine/sound/sopra/RandomContainerAudioClip.d.ts.map +0 -1
- package/src/engine/sound/sopra/RandomContainerAudioClip.js +0 -15
- package/src/engine/sound/sopra/SequenceContainerAudioClip.d.ts +0 -7
- package/src/engine/sound/sopra/SequenceContainerAudioClip.d.ts.map +0 -1
- package/src/engine/sound/sopra/SequenceContainerAudioClip.js +0 -8
- package/src/engine/sound/sopra/SilenceAudioClip.d.ts +0 -13
- package/src/engine/sound/sopra/SilenceAudioClip.d.ts.map +0 -1
- package/src/engine/sound/sopra/SilenceAudioClip.js +0 -15
- /package/src/{engine/graphics/sh3/path_tracer/sampling → core/geom/vec3}/v3_orthonormal_matrix_from_normal.d.ts +0 -0
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
# Sopra — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> Object-oriented, FMOD/Wwise-style sound engine for **meep**, built on the WebAudio API.
|
|
4
|
+
> Status: implemented through **P5** (P0–P4 build-out + P5 spatial scaling to 100k emitters). Not yet
|
|
5
|
+
> wired into the production engine config — the legacy `SoundEmitter` path still ships; the native
|
|
6
|
+
> `AudioEmitter`/`AudioEmitterSystem` is fully built and tested but registered only in tests, awaiting
|
|
7
|
+
> the cut-over (register in `makeMirEngineConfig` + `AudioEventTrigger` + serialization wiring; see §9).
|
|
8
|
+
|
|
9
|
+
This plan was produced from a full read of the current sound engine + a deep survey of the
|
|
10
|
+
surrounding meep architecture (ECS core, asset system, serialization, precedents, game-side
|
|
11
|
+
coupling) and an FMOD/Wwise ⇄ WebAudio feasibility study. Every "verified" claim below was
|
|
12
|
+
checked against source.
|
|
13
|
+
|
|
14
|
+
**Source layout** (`engine/sound/sopra/`): `SopraEngine.js` (orchestrator) at the root;
|
|
15
|
+
`definition/` = authored, immutable data (`EventDescription`, `BusDefinition`, `ParameterDefinition`,
|
|
16
|
+
`SopraPanningModel`, `VoiceStealMode`, plus `clip/` and `effect/` families); `runtime/` = transient
|
|
17
|
+
runtime + services (`EventInstance`, `Voice`, `VoicePool`, `BusGraph`, `VoiceManager`,
|
|
18
|
+
`ParameterStore`, `SopraPlaybackContext`); `asset/` = buffer providers; `serialization/` = `sopraJSON`
|
|
19
|
+
+ registry (binary adapters stay co-located with their classes); `util/` = `buildAttenuationCurve`,
|
|
20
|
+
`fadeOutAndStop`, `MockAudioContext`; `legacy/` = the SoundEmitter→sopra translator. Specs sit next to
|
|
21
|
+
the code they cover.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 0. Status
|
|
26
|
+
|
|
27
|
+
- **P0 — Core skeleton + ser/de + bus inserts (no ECS): ✅ DONE** (26 tests green via
|
|
28
|
+
`npx jest --config jest.conf.json engine/sound/sopra`).
|
|
29
|
+
Delivered under `sopra/`: the finished clip hierarchy (`AbstractAudioClip`, `ContainerAudioClip`,
|
|
30
|
+
`SampleAudioClip`, `SilenceAudioClip`, `SequenceContainerAudioClip`, `RandomContainerAudioClip`),
|
|
31
|
+
`EventDescription`, `BusDefinition`, `ParameterDefinition` (3D attenuation reuses the existing
|
|
32
|
+
`AnimationCurve`), the effect hierarchy
|
|
33
|
+
(`AbstractAudioEffect`, `EqEffect`, `CompressorEffect`), runtime (`SopraEngine`, `BusGraph`,
|
|
34
|
+
`Voice`, `VoicePool`, `EventInstance`, `ParameterStore`), `BufferProvider` +
|
|
35
|
+
`AssetManagerBufferProvider`/`StubBufferProvider`, the click-safe `fadeOutAndStop` (with the
|
|
36
|
+
Firefox fallback, D1), polymorphic binary adapters (co-located) + JSON dispatchers (`sopraJSON`) +
|
|
37
|
+
`populateSopraSerializationRegistry`, and a `MockAudioContext` test double. `volume2dB`/`dB2Volume`
|
|
38
|
+
are now exported. Specs: `sopraSerialization.spec.js`, `SopraEngine.spec.js`,
|
|
39
|
+
`fadeOutAndStop.spec.js`.
|
|
40
|
+
Deferred to **P3** (integration, not P0): registering the adapters in
|
|
41
|
+
`GameBinarySerializationRegistry` and constructing `SopraEngine` in `makeMirEngineConfig`. P0 keeps
|
|
42
|
+
the legacy engine fully untouched.
|
|
43
|
+
- **P1 — Timeline playback + containers + parameters: ✅ DONE** (sopra suite now 35 tests green).
|
|
44
|
+
Delivered: a polymorphic `AbstractAudioClip.planTimeline(env, gainDb, pitchCents, offset)` on each
|
|
45
|
+
clip (deterministic flatten of the clip graph to timed leaf plays, gain/pitch inheritance resolved
|
|
46
|
+
once; no `instanceof` switching) + `collectSampleClips`, `SopraPlaybackContext` (seeded RNG + random-container
|
|
47
|
+
avoid-repeat history — network-deterministic per D5), Sequence/Random/Silence playback through
|
|
48
|
+
`EventInstance`, per-trigger pitch/gain randomization on `SampleAudioClip`, and parameter→bus-volume
|
|
49
|
+
automation via the reused `AnimationCurve`.
|
|
50
|
+
NOTE: the pumping **lookahead Scheduler** moved to **P2**, where virtualization actually needs it
|
|
51
|
+
(re-spawning virtual voices in-phase). P1 schedules a *finite* timeline eagerly with WebAudio's own
|
|
52
|
+
sample-accurate future `start(when)` — correct and simpler for bounded events.
|
|
53
|
+
- **P2 — Voice management + spatialization + cursor scheduling: ✅ DONE** (sopra suite now 43 tests
|
|
54
|
+
green). Cursor-based playback (each frame `EventInstance.update(now, listener)` evaluates the
|
|
55
|
+
timeline against the playhead, spawning leaves within a lookahead window only while audible);
|
|
56
|
+
**virtualization** — out-of-range instances stop their voices, keep the cursor, and revive at the
|
|
57
|
+
correct child + buffer-offset (D3); **3D spatialization** — per-instance `attenuationGain → panner →
|
|
58
|
+
bus` chain (D4), attenuation = `curve.evaluate(distance)`, with `buildAttenuationCurve` reproducing
|
|
59
|
+
the legacy falloff from `interpolate_irradiance_*`; HRTF/EqualPower panner choice; and a
|
|
60
|
+
**VoiceManager** (per-event `maxInstances` + steal oldest/quietest/none; defaults a no-op).
|
|
61
|
+
NOTES: the lookahead scheduler is realised as the per-instance cursor evaluation (a global priority
|
|
62
|
+
queue is a P4 optimization if profiling warrants); StereoPanner / "no-panner" modes deferred to P4.
|
|
63
|
+
- **P3 — ECS binding / strangler cut-over: P3.1–P3.6 DONE (P3.7 cleanup remains).** `SoundEngine` owns
|
|
64
|
+
the sopra engine via `createSopra(bufferProvider)`; `SoundEmitterSystem(assetManager, soundEngine)`
|
|
65
|
+
(required 2-arg ctor — no switch, no `sopra === null` branch) creates it through an
|
|
66
|
+
`AssetManagerBufferProvider`, reuses the single `SoundAssetLoader` (D7), and drives `sopra.setListener`
|
|
67
|
+
+ `sopra.update` from its tick. **sopra is now the single renderer:** every `SoundEmitter` routes
|
|
68
|
+
through a per-entity `SoundEmitterComponentContext` translation record (legacy tracks → sopra
|
|
69
|
+
`EventInstance`s; live `emitter.volume × track.volume` → instance gain; shared `transform.position`
|
|
70
|
+
Vector3; live `track.volume` sets also plumb to the instance gain via `__soundRuntime`). The locked
|
|
71
|
+
public API (`SoundEmitter`: tracks/channel/volume/flags/distanceMin/Max; `SoundTrack`:
|
|
72
|
+
on.ended/flags/volume/time/url — `duration` was dropped, never written) is preserved as
|
|
73
|
+
deprecate-and-plumb; the `nodes` getters + `setVolumeOverTime` throw, while
|
|
74
|
+
`distanceRolloff`/`buildNodes`/`endTrack` and the rest of the non-contract surface were removed
|
|
75
|
+
outright. Channel volume = sopra bus volume (`get/setChannelVolume` proxy; legacy mix intact).
|
|
76
|
+
`CombatEndMusicProcess.switchMusic` now crossfades via `fadeOutAllTracks(1)` + `tracks.add`. All 10
|
|
77
|
+
`new SoundEmitterSystem(...)` call sites updated; the dead `SoundEmitterChannel` (singular) +
|
|
78
|
+
`SoundTrackNodes` classes are deleted; full `engine/sound` suite green (62 tests).
|
|
79
|
+
STILL PENDING (P3.7): single listener-feeder convergence and the remaining `@deprecated` JSDoc sweep.
|
|
80
|
+
- **P4 — build-out: STARTED.** Switch + Blend parameter-driven containers DONE
|
|
81
|
+
(`SwitchContainerAudioClip` = discrete RTPC-keyed single-child pick; `BlendContainerAudioClip` =
|
|
82
|
+
per-child parameter→gain curves, all audible layers play simultaneously). Parameter access threaded
|
|
83
|
+
into the planTimeline `env` (`getParameter`, read once at trigger). Both fully serialized (JSON +
|
|
84
|
+
binary + registry) and folded the dormant `material/` concepts (surface-switch / composition) into
|
|
85
|
+
general primitives. v1 Blend is a **trigger-time snapshot**, not live re-blend as the parameter
|
|
86
|
+
sweeps (follow-up below). **Reverb sends DONE:** `BusDefinition.sends` is now wired at build time
|
|
87
|
+
(post-fader copy → send gain → target bus input), and `ReverbEffect` (`ConvolverNode` + a
|
|
88
|
+
procedurally-generated decaying-noise IR — no IR asset, synchronous build) is a new bus insert.
|
|
89
|
+
**Mixer snapshots DONE:** `MixerSnapshot` (named per-bus target gains) + `SopraEngine.applySnapshot`
|
|
90
|
+
(instant or click-safe ramped blend, via `BusGraph.rampVolume`) + `captureSnapshot`. **Emulated
|
|
91
|
+
ducking DONE:** `DuckingRule` + `SopraEngine.addDucker`/`clearDuckers` — play-state sidechain (duck a
|
|
92
|
+
target bus while any instance plays on a trigger bus; `setTargetAtTime` attack/release via
|
|
93
|
+
`BusGraph.approachVolume`). **Native migration STARTED (additive-first):** native `AudioEmitter`
|
|
94
|
+
component (holds a full `EventDescription` directly) + `AudioEmitterSystem` done at the engine level —
|
|
95
|
+
shares the one sopra (idempotent `createSopra`), autoplay/stop/volume/position/listener, fully tested;
|
|
96
|
+
coexists with the untouched legacy `SoundEmitter`. (The autoplay-every-emitter behavior here is
|
|
97
|
+
**superseded by P5** spatial management — see below.) Remaining: `AudioEventTrigger` + game wiring
|
|
98
|
+
(config/editor/serialization registries) + the `SoundEmitter`→`LegacySoundEmitter` rename & call-site
|
|
99
|
+
flip (see §9). Sound banks were evaluated and DROPPED — the ECS data model (components + dataset +
|
|
100
|
+
AssetManager) already covers every bank role.
|
|
101
|
+
Full `engine/sound` suite green.
|
|
102
|
+
- **P5 — spatial scaling to 100k emitters: ✅ DONE (P5.1–P5.6; see §15 for the full record).** A BVH
|
|
103
|
+
broadphase + live/dormant split so the engine carries far more 3D emitters than can sound at once. A
|
|
104
|
+
registered-but-dormant emitter costs only a BVH leaf (no instance, no nodes, no per-frame work); each
|
|
105
|
+
tick the nearest in-range emitters — up to a global voice **budget** (default 64) — are promoted to
|
|
106
|
+
live instances and the rest demoted. **P5.1** `SpatialAudioIndex` (BVH cull, reactive leaf refit via
|
|
107
|
+
`position.onChanged`, allocation-free `queryAudible`). **P5.2** `LiveEmitterSet` (promote/budget/
|
|
108
|
+
distance-priority stealing/rank-hysteresis). **P5.3** demotion policy (hard cut when culled out of
|
|
109
|
+
range, click-safe fade on contention). **P5.4** continuous-clock phase reconstruction (`EventInstance.startTime`
|
|
110
|
+
+ looping-voice buffer-offset wrap). **P5.5** a cull throttle was added then **reverted** — the cull
|
|
111
|
+
runs every tick (BVH is microseconds). **P5.6** `AudioEmitterSystem` wired to `LiveEmitterSet`:
|
|
112
|
+
looping-3D autoplay → spatially managed; 2D + finite-3D-one-shot → direct; non-autoplay → inert;
|
|
113
|
+
per-emitter `AudioEmitter.volume` carried through promote/demote. Two `.skip`-ped 100k stress tests
|
|
114
|
+
prove only `budget` instances exist regardless of N and the BVH prunes the far field (per-frame
|
|
115
|
+
`refresh + tick` ≈ 5 ms at 100k). Full `engine/sound` suite green (298, 2 skipped).
|
|
116
|
+
|
|
117
|
+
## 1. Recommendation in one paragraph
|
|
118
|
+
|
|
119
|
+
Build sopra as an **ECS-agnostic runtime audio engine** (`SopraEngine` + its services) that is
|
|
120
|
+
fully unit-testable with a mock `AudioContext`, then bind it to meep with a **thin ECS layer**, and
|
|
121
|
+
keep the legacy `SoundEmitter` / `createSound` / channel surface alive as a **translating facade**
|
|
122
|
+
during migration (the *strangler* pattern). There is always **exactly one renderer** (sopra);
|
|
123
|
+
legacy classes become translators, never a second engine. The architecture grafts the best of three
|
|
124
|
+
evaluated stances: the *ECS-agnostic, clock-injected core* (testability), the *clean component/system
|
|
125
|
+
topology* (meep-idiomatic), and the *zero-churn facade* (ships v1 with no call-site / save / settings
|
|
126
|
+
/ editor edits).
|
|
127
|
+
|
|
128
|
+
The **spine** is the thing meep lacks and FMOD/Wwise are built on: a **definition / instance split**.
|
|
129
|
+
Today `SoundTrack` conflates the spec (`url`/`time`/`volume`/flags) with the live, single-use
|
|
130
|
+
`AudioBufferSourceNode`. Sopra splits this into immutable **definitions** (the `*AudioClip` tree),
|
|
131
|
+
transient pooled **instances/voices**, and engine-owned **services** (mixer bus tree, scheduler,
|
|
132
|
+
parameter store, voice manager).
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 2. Engine philosophy this plan must respect (verified)
|
|
137
|
+
|
|
138
|
+
**meep ECS** (`engine/ecs/`)
|
|
139
|
+
- `System` subclasses declare `dependencies = [ClassA, ClassB]`; the engine auto-delivers
|
|
140
|
+
`link(...componentsInDependencyOrder, entity)` / `unlink(...)` (arity is validated as
|
|
141
|
+
`deps.length + 1`). `components_used = [ResourceAccessSpecification.from(Klass, access)]` is a
|
|
142
|
+
scheduling hint that also auto-registers component columns.
|
|
143
|
+
- `simulate(dt)` runs a **fixed-step lock-step pass** (`fixedUpdate(fixedStep)`) then **one
|
|
144
|
+
variable pass** (`update(dt)`); `getFixedStepAlpha()` gives the sub-step remainder for
|
|
145
|
+
interpolation. `fixedStepTick` only advances in the fixed pass.
|
|
146
|
+
- Components are **plain serializable object instances** stored column-wise
|
|
147
|
+
(`components[componentIndex][entityId]`). Runtime/WebAudio objects must NOT live on the
|
|
148
|
+
component — they live in a system-owned `this.data[entity]` context (the established
|
|
149
|
+
`SoundEmitterComponentContext` discipline).
|
|
150
|
+
- Singletons (the listener) are fetched via `ecd.getAnyComponent(Klass)`. Gameplay→sound uses the
|
|
151
|
+
per-entity event bus (`addEntityEventListener` / `sendEvent`), as `SoundControllerSystem` does.
|
|
152
|
+
|
|
153
|
+
**Conventions**: every persisted type has `toJSON`/`fromJSON` **and** a versioned
|
|
154
|
+
`BinaryClassSerializationAdapter` registered by `typeName`; polymorphic trees recurse through
|
|
155
|
+
`ObjectBasedClassSerializationAdapter` + the shared `objectAdapter` (the behavior-tree precedent);
|
|
156
|
+
`Signal` for events, `List` for observable collections, `Vector1` for change-notifying scalars,
|
|
157
|
+
flags as bitmask enums, `assert.*` (free in prod).
|
|
158
|
+
|
|
159
|
+
**User memories (hard constraints)**
|
|
160
|
+
- **Uniform control flow** — no `null = auto / value = override` sentinels.
|
|
161
|
+
⚠️ `AbstractAudioClip.parent` / `.channel` are exactly this antipattern; resolve inheritance
|
|
162
|
+
**once** at voice instantiation (flatten), so the runtime tick never sees a sentinel.
|
|
163
|
+
- **Reuse over micro-opt** — prefer tested primitives; lead with end-to-end measurement.
|
|
164
|
+
- **Correctness first, no bandaids**; **black-box tests only** (assert observable outcomes — which
|
|
165
|
+
buffer, which bus, what gain, what `when`, how many real voices — never call counts);
|
|
166
|
+
**no silent catch** (expected failure → sentinel/defer; unexpected → throw); asserts are free.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 3. The current engine and its gaps
|
|
171
|
+
|
|
172
|
+
| Layer | Current | FMOD/Wwise gap |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| Definition | `SoundEmitter` (List<SoundTrack>, channel, distance, attenuation enum, flags) | no event/instance split |
|
|
175
|
+
| Playback | `SoundTrack` **conflates** spec + live single-use source + state | — |
|
|
176
|
+
| Mixer | **flat** 3 channel `GainNode`s (effects 1.2 / music 0.1 / ambient) → master `Gain → DynamicsCompressor → destination` | no nestable buses, no inserts, no sends |
|
|
177
|
+
| Spatial | per-emitter `PannerNode` (rolloff=0) + custom attenuation `GainNode` via `interpolate_irradiance_*`; BVH hearing-range cull | attenuation is a fixed 3-value enum, not a curve |
|
|
178
|
+
| Voices | **plays every track of every in-range emitter**; only cull is BVH | no limits / priority / stealing / virtualization |
|
|
179
|
+
| Params | none | no RTPC / automation |
|
|
180
|
+
| Timing | `track.time += timeDelta` each frame; `Suspended` flag works around Chrome disconnected-source time-freeze | no lookahead scheduler; sequence/loop precision at mercy of frame rate |
|
|
181
|
+
| Containers | `sopra/` stubs exist with **no** selection logic, scheduler, instance, or serialization | random / sequence / blend / switch are empty classes |
|
|
182
|
+
|
|
183
|
+
Latent bug to *not* faithfully reproduce: `SoundEmitterSystem.update` line ~370 is
|
|
184
|
+
`if (soundTrack.setFlag(...) && ...)` — `setFlag` returns `undefined`, so the suspended-track-end
|
|
185
|
+
branch is dead code and it sets `Suspended|Playing` on every track every frame. Port the **intent**
|
|
186
|
+
(logical time advances while virtual; revive in phase), not the mechanism.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 4. The sopra model — three layers
|
|
191
|
+
|
|
192
|
+
### 4.1 Definition layer (immutable, serialized, `typeName` + adapter + `toJSON`/`fromJSON`)
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
AbstractAudioClip (finish the stub: typeName, isAudioClip tag, .from(), compare/equals/hash/clone)
|
|
196
|
+
├── SampleAudioClip NEW leaf — the only buffer-referencing clip: assetRef(path|alias),
|
|
197
|
+
│ gainDb, pitch(cents), loop + loopStart/loopEnd, pitchRandom, gainRandom
|
|
198
|
+
├── SilenceAudioClip (stub) duration → pure schedule-cursor offset, emits no source
|
|
199
|
+
├── ContainerAudioClip (stub) children[]
|
|
200
|
+
│ ├── SequenceContainerAudioClip plays children in order (scheduled, not onended-chained)
|
|
201
|
+
│ ├── RandomContainerAudioClip (stub) avoid_repeating_last via recent-history ring + seededRandom
|
|
202
|
+
│ ├── BlendContainerAudioClip [deferred] parameter-driven crossfade
|
|
203
|
+
│ └── SwitchContainerAudioClip [deferred] discrete parameter selects child (subsumes material/)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Plus three non-clip definition types:
|
|
207
|
+
- **`EventDescription`** — the triggerable unit: `label, rootClip, busId, maxInstances, priority,
|
|
208
|
+
stealMode, virtualThresholdDb, is3D, attenuation: AnimationCurve, distanceMin/Max`.
|
|
209
|
+
(FMOD `EventDescription` / Wwise Actor-Mixer object.)
|
|
210
|
+
- **`BusDefinition`** — `{ id, parentId, gainDb, effects[], sends[] }`. In v1, `effects[]` is **live**
|
|
211
|
+
(an ordered chain of `AbstractAudioEffect` — `EqEffect`/`CompressorEffect`, see D10); `sends[]` ships
|
|
212
|
+
as a **data shape only** (reverb sends are P4) so adding them later is non-breaking.
|
|
213
|
+
- **`AbstractAudioEffect`** (+ `EqEffect`, `CompressorEffect`) — serialized bus-insert effects with a
|
|
214
|
+
`build(ctx) → { input, output }` contract (see D10).
|
|
215
|
+
- **`AnimationCurve`** (reused from `engine/animation/curve/`, a cubic-Hermite keyframe curve) —
|
|
216
|
+
used for **both** parameter automation (RTPC) and 3D distance attenuation. `EventDescription.attenuation`
|
|
217
|
+
is an `AnimationCurve`; since it has no `typeName`/binary adapter of its own, the event adapter
|
|
218
|
+
serializes its keyframes inline. (Supersedes the originally-planned bespoke `AutomationCurve`.)
|
|
219
|
+
- **`ParameterDefinition`** — `{ name, range, default, scope }`.
|
|
220
|
+
|
|
221
|
+
> **Uniform-flow fix:** `parent`/`channel`/`gain`/`pitch` inheritance is resolved **once** when an
|
|
222
|
+
> `EventInstance` is created (top-down flatten). The runtime never branches on a `null`-means-inherit
|
|
223
|
+
> sentinel.
|
|
224
|
+
|
|
225
|
+
### 4.2 Runtime layer (transient, pooled, **never serialized**)
|
|
226
|
+
|
|
227
|
+
- **`EventInstance`** — one trigger: resolved position, parameter overrides, **schedule cursor**,
|
|
228
|
+
active `Voice`s, one-shot|persistent lifetime. Owns **its** `PannerNode` + attenuation `GainNode`
|
|
229
|
+
(see decision D4). Modeled on `ParticleEmitter.build()`, *not* the behavior-tree re-armed node
|
|
230
|
+
(which cannot produce concurrent voices).
|
|
231
|
+
- **`Voice`** — one throwaway `AudioBufferSourceNode` + a **pooled** trim `GainNode`. The source is
|
|
232
|
+
the only unavoidable per-play allocation (it is single-use).
|
|
233
|
+
|
|
234
|
+
### 4.3 Services (single instances hung off `SoundEngine`, no ECS imports)
|
|
235
|
+
|
|
236
|
+
- **`SopraEngine`** — ECS-agnostic public API: `playEvent / playOneShot / createInstance / stop`,
|
|
237
|
+
`bus(id)`, `setParameter / getParameter`, `setListener(pose)`, `update(now)`. Constructed with
|
|
238
|
+
`(audioContext, destinationNode, bufferProvider)`; **reuses the existing `AudioContext` + master
|
|
239
|
+
chain verbatim — never a second context.**
|
|
240
|
+
- **`BusGraph`** — instantiates the `BusDefinition` tree into chained `GainNode`s; root →
|
|
241
|
+
`SoundEngine.destination`; **seeds the default `effects`/`music`/`ambient` buses with the exact
|
|
242
|
+
legacy mix (Effects 1.2, Music 0.1)**; exposes `get/setChannelVolume` (linear, see D6).
|
|
243
|
+
- **`Scheduler`** — lookahead queue over `AudioContext.currentTime`; each `update(now)` drains events
|
|
244
|
+
due before `now + lookahead` and calls `start(when, offset, duration)`. Replaces `time += timeDelta`
|
|
245
|
+
and obsoletes the `Suspended` hack. (Reuse `BinaryHeap`.)
|
|
246
|
+
- **`ParameterStore`** — `Map<name, number>` + per-instance overrides + `onChanged` `Signal`s; samples
|
|
247
|
+
bound `AnimationCurve`s → `setTargetAtTime`.
|
|
248
|
+
- **`VoiceManager`** — active-voice registries keyed by `eventId` and `busId`; per-event
|
|
249
|
+
`maxInstances` + per-bus limits + priority + steal-oldest/quietest (by post-attenuation gain);
|
|
250
|
+
**virtualization** (stop+disconnect source, keep advancing the instance cursor, revive in phase).
|
|
251
|
+
- **`VoicePool`** — `ObjectPoolFactory` of pre-wired trim-gain chains.
|
|
252
|
+
- **`BufferProvider`** (interface) + **`AssetManagerBufferProvider`** — `get(ref) → Promise<AudioBuffer>`.
|
|
253
|
+
Prod wraps `AssetManager.promise(resolveAlias(ref) || ref, 'audio')` (shared immutable buffer);
|
|
254
|
+
a stub impl in tests is what makes the core ECS-free unit-testable.
|
|
255
|
+
- **`fadeOutAndStop`** — the ONE click-safe fade primitive (see D1). No call site hand-rolls fades.
|
|
256
|
+
|
|
257
|
+
### 4.4 ECS binding (thin)
|
|
258
|
+
|
|
259
|
+
- **`SopraEmitter`** (component) `+ Transform` — plain serializable
|
|
260
|
+
`{ eventId, paramOverrides, busOverride, volume: Vector1, flags }`. Runtime `EventInstance` lives
|
|
261
|
+
in `system.data[entity]`.
|
|
262
|
+
- **`SopraEmitterSystem`** `dependencies = [SopraEmitter, Transform]` — `link` → `createInstance`
|
|
263
|
+
+ `BVH.link`; `update(dt)` → BVH cull around the listener (reuse `BVHQueryIntersectsSphere` +
|
|
264
|
+
`bvh_query_user_data_generic` + `IncrementalDeltaSet`), push position/params, `engine.update(now)`;
|
|
265
|
+
`unlink` → stop + release. Hosts `playOneShot(eventId, position, overrides)`.
|
|
266
|
+
- **`SoundListener` / `SoundListenerSystem`** — **reused verbatim**; read via `getAnyComponent`,
|
|
267
|
+
forwarded through `SopraEngine.setListener`.
|
|
268
|
+
- BVH out-of-range = **virtual** (not disconnect); the `VoiceManager` decides actual realness under
|
|
269
|
+
budget. This turns the Chrome `Suspended` hack into standard virtual-voice behavior.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## 5. Key design decisions (incl. the 5 critique revisions)
|
|
274
|
+
|
|
275
|
+
- **D1 — `fadeOutAndStop` must NOT hard-depend on `cancelAndHoldAtTime`.** It is unimplemented in
|
|
276
|
+
Firefox (and used nowhere in meep today). Feature-detect it; otherwise use the portable idiom:
|
|
277
|
+
read `param.value`, `cancelScheduledValues(now)`, `setValueAtTime(currentValue, now)`, then ramp.
|
|
278
|
+
Always ramp to an **epsilon, never 0** (exponential-ramp-to-0 throws / clicks), and schedule
|
|
279
|
+
`stop()` just after the gain reaches ~epsilon. Test the no-`cancelAndHoldAtTime` path with a mock.
|
|
280
|
+
- **D2 — `playOneShot` owns a max-lifetime timeout (default 60s) AND releases on asset-load
|
|
281
|
+
failure.** `createSound` today is belt-and-suspenders (`on.ended` **and** `Sequence[Delay(60),Die]`).
|
|
282
|
+
A one-shot whose asset 404s must still release — the no-silent-catch sentinel path, not a hang.
|
|
283
|
+
- **D3 — Virtualization revive uses the instance logical cursor, not `t % dur`.** `t % dur` is only
|
|
284
|
+
correct for a single looping leaf. For a `Sequence`/`Random`/one-shot, re-derive the active leaf(s)
|
|
285
|
+
and their offsets from the cursor at `currentTime`, then `start(when, leafOffset)` each.
|
|
286
|
+
- **D4 — `PannerNode` + attenuation `GainNode` are per-`EventInstance` (shared by its leaf voices),
|
|
287
|
+
not per-`Voice`.** This matches today's per-emitter sharing (`buildNodes` builds one chain; all
|
|
288
|
+
tracks feed the single emitter volume node). Per-voice panners would multiply HRTF cost (the
|
|
289
|
+
dominant spatialization cost) on multi-track emitters — a regression for the dense scenes we care
|
|
290
|
+
about. Only the source node + trim gain are per-voice. Panner-type choice (HRTF / equalpower /
|
|
291
|
+
`StereoPanner` for 2D-UI / none) + distance/budget downgrade lives at the instance level.
|
|
292
|
+
- **D5 — Random/Switch selection is network-deterministic (decided).** Seed `seededRandom` from a
|
|
293
|
+
replicated source (`entity id + fixedStepTick`), not `Math.random`. Because the seed depends on
|
|
294
|
+
`fixedStepTick` (which only advances in the fixed pass), a selection decision must be made in
|
|
295
|
+
`fixedUpdate(fixedStep)` **or** stamped with the `fixedStepTick` read once per frame — the
|
|
296
|
+
selection must never depend on the variable `update(dt)` cadence. Keep `setRandomSeed` on the
|
|
297
|
+
container voice (the `WeightedRandomBehavior` precedent) so tests are reproducible.
|
|
298
|
+
- **D6 — Channel-volume facade stays linear end-to-end.** The settings sliders are `{min:0,max:1}`
|
|
299
|
+
linear and `Effects` defaults to **1.2** (above max). Store/return the raw linear gain the slider
|
|
300
|
+
expects; convert to dB only internally if a bus needs it. Preserve 1.2 / 0.1 exactly.
|
|
301
|
+
- **D7 — Single owner of the `SoundAssetLoader` registration.** `AssetManager.registerLoader` throws
|
|
302
|
+
on duplicate type. The rewired `SoundEmitterSystem` already registers it; `SopraEngine` reuses it,
|
|
303
|
+
does not re-register. Make construction order in `makeMirEngineConfig` explicit.
|
|
304
|
+
- **D8 — Clip-tree adapters model the behavior-tree spine, NOT the legacy `SoundEmitter` adapter.**
|
|
305
|
+
The legacy adapter hand-rolls track ser/de inline and is **not** polymorphic. New container
|
|
306
|
+
adapters extend `ObjectBasedClassSerializationAdapter` so children recurse by `typeName`.
|
|
307
|
+
- **D9 — Resolve the dormant overlaps.** `material/` (weighted `SoundMaterialComposition` + terrain
|
|
308
|
+
splat detector) and `asset/SoundAssetPlaybackSpec` are dormant (zero usages/tests). Fold them into
|
|
309
|
+
`Switch`/weighted containers in P4; do not maintain three parallel spec hierarchies.
|
|
310
|
+
- **D10 — Bus insert effects are live in v1 (per the scope decision).** Add an `AbstractAudioEffect`
|
|
311
|
+
base mirroring `AbstractAudioClip` style (`typeName`, `toJSON`/`fromJSON`, adapter, `compare`/
|
|
312
|
+
`equals`/`hash`/`clone`) with a `build(ctx) → { input, output }` contract, plus two concrete
|
|
313
|
+
effects for v1: `EqEffect` (`BiquadFilterNode`, automatable `frequency`/`Q`/`gain`) and
|
|
314
|
+
`CompressorEffect` (`DynamicsCompressorNode`). `BusGraph` wires each bus as
|
|
315
|
+
`input → effect[0] → … → effect[n] → output`; an empty `effects[]` is a direct `input === output`.
|
|
316
|
+
The existing **master** `DynamicsCompressor` (in `SoundEngine`) stays master-glue only — buses must
|
|
317
|
+
not double-compress by default. `WaveShaper`/occlusion-lowpass remain nice-to-have (P4+).
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 6. Reuse map (reuse-over-micro-opt)
|
|
322
|
+
|
|
323
|
+
| Existing meep code | Used for |
|
|
324
|
+
|---|---|
|
|
325
|
+
| `sound/SoundEngine.js` (AudioContext, master `Gain→Compressor→destination`, suspend/resume) | injected into `SopraEngine`; never a 2nd context |
|
|
326
|
+
| `sound/ecs/SoundListener*.js` | listener pose, reused **verbatim** |
|
|
327
|
+
| `core/bvh2/bvh3/*` + `BVHQueryIntersectsSphere` + `IncrementalDeltaSet` | hearing-range cull = virtualization trigger |
|
|
328
|
+
| `core/model/object/ObjectPoolFactory.js` | voice trim-gain pool; `ParticlePool`/`BitSet` if fixed-capacity budget wanted |
|
|
329
|
+
| `core/math/random/seededRandom.js` (Mulberry32) + `weightedRandomFromArray.js` | deterministic random/weighted selection; avoid-repeat ring |
|
|
330
|
+
| `Signal`, `Vector1` (onChanged), `List`, `combine_hash`/`computeHashFloat`/`computeStringHash` | params, gains, child collections, voice `onended`, clip hashing |
|
|
331
|
+
| `engine/asset/*` (`AssetManager`, `SoundAssetLoader`, `loadSoundTrackAsset` alias path, `AssetPreloader`) | `AssetManagerBufferProvider`; preload via existing asset manifests (no bank concept) |
|
|
332
|
+
| `engine/animation/curve/AnimationCurve.js` + `Keyframe.js` | the curve type for RTPC automation **and** 3D distance attenuation (`EventDescription.attenuation`) — reused instead of a bespoke curve |
|
|
333
|
+
| `core/math/physics/irradiance/interpolate_irradiance_*` | reference falloff shapes when authoring `AnimationCurve` attenuation presets (P2) |
|
|
334
|
+
| `sound/volume2dB.js` + `dB2Volume.js` (**export them**) | dB↔linear at facade edges, gain randomization, ducking math |
|
|
335
|
+
| `ecs/storage/binary/object/*` + `SequenceBehaviorSerializationAdapter` shape | polymorphic recursive clip-tree ser/de by `typeName` |
|
|
336
|
+
| `intelligence/behavior/*` (`Behavior`, `CompositeBehavior`, `BehaviorSystem`) | definition node contract + system lifecycle shape (but split def/instance) |
|
|
337
|
+
| `SoundEmitterComponentContext` discipline | runtime objects in `data[entity]`; `Suspended` plumbing → `VoiceManager` virtualization |
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## 7. Serialization plan
|
|
342
|
+
|
|
343
|
+
- Each definition type gets a `typeName`, a `BinaryClassSerializationAdapter` at **version 0**, and a
|
|
344
|
+
parallel `toJSON`/`fromJSON`. Container adapters extend `ObjectBasedClassSerializationAdapter`.
|
|
345
|
+
- Register all in `GameBinarySerializationRegistry.initializeGameBinarySerializationRegistry`
|
|
346
|
+
(`registerAdapters([...])`) **and** in `GameClassRegistry` — the single registration sites, called
|
|
347
|
+
before `config.apply` in `GameBootstrap`.
|
|
348
|
+
- Design byte layouts to be **upgrade-friendly from day one** (varint-counted, append-only fields),
|
|
349
|
+
per the `SoundEmitterSerializationUpgrader_1_2` lesson (which just appends a default `volume`).
|
|
350
|
+
- **The legacy `SoundEmitterSerializationAdapter` (v2) + upgraders `_0_1`/`_1_2` stay registered
|
|
351
|
+
verbatim** — old saves deserialize into a `SoundEmitter` exactly as today; the facade translates at
|
|
352
|
+
link time. No save-format migration in v1.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## 8. Migration / back-compat (the four surfaces)
|
|
357
|
+
|
|
358
|
+
Invariant: always exactly one renderer (sopra); legacy classes are translators.
|
|
359
|
+
|
|
360
|
+
1. **`createSound`** (#1 fire-and-forget, ~7 high-traffic sites). Keep the signature
|
|
361
|
+
`{position, url|track, positioned, channel, volume, timeout}` and self-destruct contract
|
|
362
|
+
byte-for-byte. Reimplement the body to build an ad-hoc `EventDescription` (root =
|
|
363
|
+
`SampleAudioClip`, `busId = channel`, `is3D = positioned`, `gainDb = volume2dB(volume)`) and call
|
|
364
|
+
`playOneShot(desc, {position})`. **Preserve the full safety net** (D2). `GameSounds.js` named
|
|
365
|
+
tracks become named `EventDescription`s keeping their export names.
|
|
366
|
+
2. **`SoundEmitter` + `fromJSON` + manual entity builds** (footsteps/voice/impacts, boss ambience,
|
|
367
|
+
music stems, title music, achievements, dialogue). v1: keep the exact component shape & `fromJSON`;
|
|
368
|
+
the rewired `SoundEmitterSystem.link` translates each `SoundTrack` → `SampleAudioClip` + a
|
|
369
|
+
persistent `EventInstance` on the channel-bus (attenuation enum → 3-point `AutomationCurve`).
|
|
370
|
+
`tracks.on.added/removed` spawn/stop instances so `emitter.tracks.add/addAll` keep working.
|
|
371
|
+
Direct AudioParam reaches get equivalents: `setVolumeOverTime`/`emitter.volume.onChanged` →
|
|
372
|
+
live voice gain; `resetSoundEmitterTracks` (`t.time=0`) → `instance.seek(0)` **and** write
|
|
373
|
+
`track.time` back from the live cursor so readers stay consistent; `hideEntityGracefully` 2.7s
|
|
374
|
+
fade / `SoundEmitterVolumeBehavior` → `instance.fadeOutAndStop`. `CombatEndMusicProcess` reads
|
|
375
|
+
`track.nodes.volume.gain` directly → expose a **temporary bridge GainNode** at
|
|
376
|
+
`SoundTrack.nodes.volume` during cut-over, then migrate it to a `bus.fadeTo`/voice-crossfade
|
|
377
|
+
primitive and drop the bridge in P4 (do not keep the bridge as steady state).
|
|
378
|
+
3. **`SoundController`** (UnitMaker only). Keep its v0 adapter frozen in v1. P4: provide
|
|
379
|
+
`SopraEventTrigger` preserving the `{tracks, startEvent, stopEvent, loop, volume, channel}` rule
|
|
380
|
+
shape; migrate UnitMaker last.
|
|
381
|
+
4. **Channels + settings UI** (must preserve). Seed default buses with the exact legacy mix; keep
|
|
382
|
+
`get/setChannelVolume` on the (rewired) `SoundEmitterSystem` proxying to bus gain **in the linear
|
|
383
|
+
domain** (D6). Sliders bind unchanged. **Editor** (`SoundEmitterController`, symbolic display,
|
|
384
|
+
reset-tracks) keeps working because the component shape is preserved.
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## 9. Phased plan
|
|
389
|
+
|
|
390
|
+
> Each phase ships behind black-box `*.spec.js` run from `H:/git/moh` root. P0–P2 need **no ECS**.
|
|
391
|
+
|
|
392
|
+
**P0 — Core skeleton + ser/de + bus inserts (no ECS).**
|
|
393
|
+
Finish clip classes; add `SampleAudioClip`, `EventDescription`, `BusDefinition`,
|
|
394
|
+
`ParameterDefinition` (3D attenuation reuses the existing `AnimationCurve`), and the effect hierarchy
|
|
395
|
+
`AbstractAudioEffect`/`EqEffect`/`CompressorEffect`
|
|
396
|
+
(D10); `BusGraph` (default buses at legacy mix, wiring each bus's `input→effects→output` insert
|
|
397
|
+
chain); `EventInstance` + `Voice` (pooled gain); `SopraEngine.playEvent/playOneShot/createInstance/
|
|
398
|
+
stop/bus`; `BufferProvider` + stub; **export `volume2dB`/`dB2Volume`**; `fadeOutAndStop` **with the
|
|
399
|
+
Firefox fallback (D1)**; one adapter per definition + effect type registered.
|
|
400
|
+
*Exit:* a `SampleAudioClip` event plays to the right bus at the right gain, **through a bus insert
|
|
401
|
+
chain (EQ→Compressor) wired in the correct order**, **and** every definition + effect round-trips
|
|
402
|
+
equal (binary + JSON) — in Jest with a mock `AudioContext`, no ECS. Firefox fade path tested. Legacy
|
|
403
|
+
engine untouched and still active. **✅ DONE — 26 tests green (see §0).**
|
|
404
|
+
|
|
405
|
+
**P1 — Timeline playback + containers + parameters. ✅ DONE — sopra suite 35 tests green.**
|
|
406
|
+
A polymorphic `AbstractAudioClip.planTimeline()` (overridden per clip) deterministically flattens the clip graph to timed leaf plays (gain/pitch
|
|
407
|
+
inheritance resolved once); `Random` (seeded + avoid-repeat ring via `SopraPlaybackContext`) and
|
|
408
|
+
`Sequence` (cumulative offsets; `Silence` = offset arithmetic); `ParameterStore` + `AnimationCurve`
|
|
409
|
+
bound to bus volume (same curve type used for 3D attenuation); per-trigger pitch/gain randomization.
|
|
410
|
+
D5 resolved (deterministic). The pumping lookahead `Scheduler` was MOVED TO P2 (it's only needed for
|
|
411
|
+
virtualization / indefinite content); P1 schedules finite timelines eagerly via WebAudio future
|
|
412
|
+
`start(when)`.
|
|
413
|
+
*Exit (met):* sequence schedules children back-to-back with no gap; random avoids last N for a fixed
|
|
414
|
+
seed; `setParameter` observably moves a live gain — black-box, no ECS.
|
|
415
|
+
|
|
416
|
+
**P2 — VoiceManager + spatialization + cursor scheduling. ✅ DONE — sopra suite 43 tests green.**
|
|
417
|
+
Cursor-based per-instance scheduling realises the lookahead model (each frame
|
|
418
|
+
`EventInstance.update(now, listener)` spawns leaves within a lookahead window) — a global priority
|
|
419
|
+
queue is deferred to P4 as an optimization. `VoiceManager` with per-event `maxInstances` + priority +
|
|
420
|
+
steal (oldest/quietest/none). **Cursor-based virtualization** (D3): out-of-range instances stop their
|
|
421
|
+
voices, advance the cursor, and revive at the correct child + buffer-offset. Per-`EventInstance`
|
|
422
|
+
`PannerNode` (rolloff=0) + attenuation `GainNode` driven by `attenuation.evaluate(distance)` (D4);
|
|
423
|
+
`buildAttenuationCurve` authors legacy-equivalent curves from `interpolate_irradiance_*`;
|
|
424
|
+
HRTF/EqualPower panner choice (StereoPanner / no-panner deferred to P4). Per-bus limits also deferred
|
|
425
|
+
to P4 (per-event covers the exit criteria).
|
|
426
|
+
*Exit (met):* over-limit steals the correct victim; a virtualized sequence revives at the correct
|
|
427
|
+
child+offset; attenuation matches the legacy curve at sample distances. Default limits are a no-op /
|
|
428
|
+
stealing is opt-in.
|
|
429
|
+
*Exit:* over-limit steals the correct victim; a virtualized **sequence** (not just a loop) revives at
|
|
430
|
+
the correct child+offset; attenuation matches the legacy curve at sample distances. **Default limits
|
|
431
|
+
are a no-op / stealing is opt-in** (so dense scenes can't regress before measurement).
|
|
432
|
+
|
|
433
|
+
**P3 — ECS binding + facade cut-over (zero call-site / save / settings / editor change).**
|
|
434
|
+
`SopraEmitterSystem` (+ `playOneShot`); reuse `SoundListener*`; construct `SopraEngine` in
|
|
435
|
+
`makeMirEngineConfig` from `sound.context/.destination`; **single loader owner (D7)**. Rewire
|
|
436
|
+
`SoundEmitterSystem` to translate tracks→clips→persistent instances (same constructor); `createSound`
|
|
437
|
+
→ `playOneShot` (D2); `GameSounds` as named events; `get/setChannelVolume` proxy (linear, D6);
|
|
438
|
+
temporary `track.nodes.volume` bridge + `track.time` write-back. Legacy v2 adapter/upgraders +
|
|
439
|
+
`SoundController`/`SoundListener` + editor untouched; remove old `SoundTrack` node-rendering once the
|
|
440
|
+
regression suite is green.
|
|
441
|
+
*Exit:* all ~25 call sites, saves, sliders, and the editor behave identically; sopra is the only
|
|
442
|
+
renderer.
|
|
443
|
+
|
|
444
|
+
**P4 — Native migration + FMOD/Wwise build-out (post-strangler).**
|
|
445
|
+
|
|
446
|
+
> **DONE — Switch/Blend containers.** `SwitchContainerAudioClip` (discrete RTPC-keyed: rounds+clamps a
|
|
447
|
+
> parameter to a child index) and `BlendContainerAudioClip` (per-child parameter→linear-gain curves;
|
|
448
|
+
> every child with gain > 0 plays simultaneously, scaled). Parameter access added to the planTimeline
|
|
449
|
+
> `env.getParameter` (read once at trigger). Full JSON+binary serialization + registry; supersedes the
|
|
450
|
+
> dormant `material/` subsystem's surface-switch / composition concepts. **Limitation:** Blend is a
|
|
451
|
+
> trigger-time snapshot — it does NOT re-blend live as the parameter later sweeps. **Live re-blend
|
|
452
|
+
> follow-up:** have plays carry an optional `{gainParam, gainCurve}`, bind the spawned voice's trim
|
|
453
|
+
> gain to the `ParameterStore` (needs an *unbind* on voice retire — ParameterStore.bind currently has
|
|
454
|
+
> no removal). Also still dormant: delete the `material/` files + `asset/SoundAssetPlaybackSpec` once
|
|
455
|
+
> confirmed unreferenced.
|
|
456
|
+
|
|
457
|
+
> **DONE — Reverb / aux sends.** `BusGraph.build` now wires `BusDefinition.sends` (previously a
|
|
458
|
+
> data-only shape): for each send it taps the source bus's output → a send-level `GainNode` →
|
|
459
|
+
> `targetBus.input` (post-fader copy; the dry path to the parent is unchanged). Throws on an unknown
|
|
460
|
+
> send target. `ReverbEffect` is a new `ConvolverNode` insert whose impulse response is generated
|
|
461
|
+
> procedurally at build (decaying noise; params `decaySeconds`/`decayPower`) — so build stays
|
|
462
|
+
> synchronous with no IR-asset dependency. A "reverb bus" = a bus with a `ReverbEffect` insert that
|
|
463
|
+
> other buses send to. **Follow-ups:** live send-level control (sends are static at build); authored-IR
|
|
464
|
+
> source from the BufferProvider (would make build async); pre-fader sends.
|
|
465
|
+
|
|
466
|
+
**Native migration** (user naming: `AudioEmitter` [rename `SoundEmitter`→`LegacySoundEmitter`],
|
|
467
|
+
`AudioEventTrigger` — keep "sopra" internal-only). Decided **additive-first**: `AudioEmitter` holds a
|
|
468
|
+
full `EventDescription`.
|
|
469
|
+
|
|
470
|
+
> **DONE — additive foundation (engine-level, not yet wired into the game).** `AudioEmitter`
|
|
471
|
+
> (`engine/sound/ecs/audio/`) holds a full `EventDescription` + a live `volume` (Vector1) + `autoplay`;
|
|
472
|
+
> serializable (JSON). `AudioEmitterSystem` shares the single sopra (`SoundEngine.createSopra` made
|
|
473
|
+
> idempotent) + self-registers the `SoundAssetLoader` if absent (standalone-capable, guarded), autoplays
|
|
474
|
+
> on link with `oneShot` derived from `event.rootClip.loops()` (finite events self-release at end,
|
|
475
|
+
> looping events persist), plumbs `volume`→instance gain + `transform.position`→instance, and ticks
|
|
476
|
+
> sopra + listener. Coexists with the untouched legacy `SoundEmitter`. Black-box tested.
|
|
477
|
+
> **RENAME DROPPED (owner change of plans):** no `SoundEmitter`→`LegacySoundEmitter` rename. Instead
|
|
478
|
+
> the legacy stack — `SoundEmitter`, `SoundEmitterSystem`, `SoundController`, `SoundControllerSystem` —
|
|
479
|
+
> is annotated `@deprecated` (pointing at `AudioEmitter`/`AudioEmitterSystem`/the forthcoming
|
|
480
|
+
> `AudioEventTrigger`). The classes keep their names; callers migrate organically.
|
|
481
|
+
> **NOT yet done (own pass):** `AudioEventTrigger` (replaces `SoundController`); wiring `AudioEmitter`
|
|
482
|
+
> into `GameClassRegistry` / `GameBinarySerializationRegistry` / the editor (`AudioEmitterController`,
|
|
483
|
+
> symbolic display) / `makeMirEngineConfig`; consolidating the sopra tick to one owner once both systems
|
|
484
|
+
> run together; migrating call sites to `AudioEmitter`.
|
|
485
|
+
>
|
|
486
|
+
> **Post-review fixes landed (P4 review):** D1 — `oneShot` now derived from `event.rootClip.loops()`
|
|
487
|
+
> (looping AudioEmitter events no longer die at the 60s lifetime backstop; added `loops()` to the clip
|
|
488
|
+
> hierarchy). D2 (keystone) — `BusGraph` now keeps an authoritative per-bus `nominal` gain; `getVolume`/
|
|
489
|
+
> `getVolumeDb` read it, `setVolume`/`rampVolume` write it, and `approachVolume` (ducking) deliberately
|
|
490
|
+
> does NOT — so snapshot `captureSnapshot`, duck nominal-capture/restore, and settings read-back are
|
|
491
|
+
> correct under automation (was: stale `gain.value`, which WebAudio never updates under ramps). IR2 —
|
|
492
|
+
> `AudioEmitterSystem` self-registers the `SoundAssetLoader` (guarded). **Deferred (do at wiring time,
|
|
493
|
+
> not band-aided):** IR1 single-owner sopra tick (the double-tick is idempotent-within-frame today and
|
|
494
|
+
> only matters once both systems are in one config); D3 multi-ducker aggregation; IR4 production
|
|
495
|
+
> serialization wiring; the remaining quick-fixes (D4–D9, C3).
|
|
496
|
+
|
|
497
|
+
Flip call sites to `AudioEmitter`; `AudioEventTrigger` replaces `SoundController`; migrate
|
|
498
|
+
`CombatEndMusicProcess` to bus/voice crossfade and drop the bridge; `AudioEmitterController` editor
|
|
499
|
+
panel; `AudioEmitter` v0 adapter + save-translation path. Then the deferred features behind their own
|
|
500
|
+
tests: **~~Snapshots/mixer-states~~ (DONE); ~~Blend/Switch containers~~ (DONE); additional bus insert
|
|
501
|
+
effects (occlusion lowpass / `WaveShaper`) beyond the v1 EQ/Compressor; ~~reverb sends~~ (DONE);
|
|
502
|
+
~~emulated ducking~~ (DONE); scatterer; adaptive-music timeline.**
|
|
503
|
+
|
|
504
|
+
> **DONE — Emulated ducking.** `DuckingRule` definition (trigger/target bus, `duckDb`, `attack`,
|
|
505
|
+
> `release`) + ser/de + registry. `SopraEngine.addDucker`/`clearDuckers`; evaluated each `update()`:
|
|
506
|
+
> edge-triggered — when the trigger bus has ≥1 live instance the target ducks (`setTargetAtTime` toward
|
|
507
|
+
> nominal+duckDb over attack; nominal captured at engage), restoring over release when the trigger goes
|
|
508
|
+
> quiet. **Play-state** sidechain (active-instance count, NOT signal level — WebAudio has no native
|
|
509
|
+
> sidechain) via the new `BusGraph.approachVolume`. Trigger match is by direct `instance.busId` (child
|
|
510
|
+
> buses not counted in v1). Follow-up: true signal-following duck via `AudioWorklet`.
|
|
511
|
+
|
|
512
|
+
> **DONE — Mixer snapshots.** `MixerSnapshot` definition (id + per-bus target `gainDb`) with full
|
|
513
|
+
> ser/de + registry. `SopraEngine.applySnapshot(snapshot, {duration})` snaps (duration 0 →
|
|
514
|
+
> `setVolume`) or click-safely ramps (`BusGraph.rampVolume` → shared `rampGain`) each listed bus to its
|
|
515
|
+
> target — the FMOD mix-state blend. `captureSnapshot(id, busIds)` reads the current mix (for
|
|
516
|
+
> save/restore). Throws on an unknown bus.
|
|
517
|
+
|
|
518
|
+
> **Sound banks — DROPPED (not a feature).** A "bank" is FMOD/Wwise's catch-all for serialization +
|
|
519
|
+
> load-unit + media package + by-id namespace, needed only because that middleware has no host data
|
|
520
|
+
> model. meep already has every piece: `EventDescription`s are serializable definitions that live as
|
|
521
|
+
> component / `EntityComponentDataset` data; load/unload is the scene/dataset boundary; media is the
|
|
522
|
+
> `AssetManager`'s concern (cache + preload manifests). The only residual — shared "play by id" events
|
|
523
|
+
> — is at most a small `AudioEventLibrary` *component* (id→`EventDescription` map), and even that is
|
|
524
|
+
> optional since `playEvent` already accepts a description object directly.
|
|
525
|
+
|
|
526
|
+
---
|
|
527
|
+
|
|
528
|
+
## 10. Testing strategy (black-box only)
|
|
529
|
+
|
|
530
|
+
- **Layer 1 — ECS-free core** (the payoff of the agnostic design): inject a mock `AudioContext`
|
|
531
|
+
(records `createGain`/`createBufferSource`/`connect`/`start(when,offset,duration)`/gain
|
|
532
|
+
automation as an observable graph + schedule), a stub `BufferProvider` (fixed-duration buffers),
|
|
533
|
+
a deterministic clock, and a seeded RNG. Cover: definition round-trip; routing to the right bus at
|
|
534
|
+
the right dB; sequence gapless scheduling (`when` = prev `when` + prev `duration`); random
|
|
535
|
+
avoid-last-N **for a fixed seed (deterministic, D5)**; parameter moves gain; over-limit steals
|
|
536
|
+
correct victim; **virtualized sequence revives at correct child+offset**; attenuation matches
|
|
537
|
+
`interpolate_irradiance_*`; **a bus insert chain wires EQ→Compressor in order (empty `effects[]` is
|
|
538
|
+
pass-through; the master compressor is not double-applied)**; `fadeOutAndStop` never ramps to 0,
|
|
539
|
+
stops after ~epsilon, **and the no-`cancelAndHoldAtTime` fallback path**.
|
|
540
|
+
- **Layer 2 — ECS binding**: an entity with `SopraEmitter+Transform` plays a 3D event, attenuates
|
|
541
|
+
with distance, goes virtual outside BVH range and revives on re-entry; sliders move bus gains.
|
|
542
|
+
- **Layer 3 — back-compat regression** (the strangler safety net): `createSound` self-destructs on
|
|
543
|
+
end **and** on the timeout/asset-failure path; `SoundEmitter.fromJSON` is audibly equivalent;
|
|
544
|
+
`emitter.tracks.add` spawns a voice; `resetSoundEmitterTracks` restarts audibly; channel volume
|
|
545
|
+
round-trips at **1.2 / 0.1** through the linear proxy; an old save deserializes via the frozen v2
|
|
546
|
+
adapter and plays; single `SoundAssetLoader` registration when both engines are constructed.
|
|
547
|
+
|
|
548
|
+
Determinism: seed `seededRandom` from the test (and, per D5, from a replicated source if
|
|
549
|
+
network-deterministic audio is chosen).
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## 11. Risks
|
|
554
|
+
|
|
555
|
+
| Risk | Sev | Mitigation |
|
|
556
|
+
|---|---|---|
|
|
557
|
+
| Game code pokes `track.nodes.volume.gain` directly (CombatEndMusicProcess) | high | temporary bridge GainNode in P3; migrate to bus/voice crossfade in P4; regression test |
|
|
558
|
+
| `cancelAndHoldAtTime` absent in Firefox | high | D1 feature-detect + portable fallback + test the fallback |
|
|
559
|
+
| One-shot leak on asset 404 | high | D2 max-lifetime timeout + release-on-failure sentinel |
|
|
560
|
+
| Per-voice HRTF cost regression on multi-track emitters | high | D4 panner per-instance, shared across leaf voices |
|
|
561
|
+
| Voice stealing silences sounds the old "play everything" engine played | med | default limits a no-op; stealing opt-in; measure densest scene first |
|
|
562
|
+
| Scheduler/virtualization regresses loop sync or clicks | med | one fade primitive; absolute `when` off `currentTime`; revive-phase tests |
|
|
563
|
+
| New ser/de drifts from conventions | med | D8 model on object adapter; version 0 + day-1 upgrader plan; P0 round-trip specs |
|
|
564
|
+
| Uniform-flow flatten changes resolved gain/bus vs legacy | med | resolve once at instance creation; assert resolved bus/gain/pitch in tests |
|
|
565
|
+
| Duplicate `audio` loader registration crash | low | D7 single owner |
|
|
566
|
+
| `track.time` becomes dead state, breaks readers | med | write `track.time` back from the live cursor each frame |
|
|
567
|
+
| Scope creep (reverb/ducking/snapshots in v1) | med | hard v1 line = spine + facades; ship effects[]/sends[] data shape only |
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## 12. Decisions
|
|
572
|
+
|
|
573
|
+
**Locked by the owner:**
|
|
574
|
+
1. **Migration aggressiveness — Facade-first strangler.** One renderer always; `SoundEmitter`/
|
|
575
|
+
`createSound`/channels become translators; sites migrate post-cut-over.
|
|
576
|
+
2. **v1 scope — Spine + bus inserts.** Spine (split + buses + scheduler + voice manager + params +
|
|
577
|
+
random/sequence + spatialization + facades) **plus live EQ/Compressor insert effects on buses**
|
|
578
|
+
(`BiquadFilterNode` / `DynamicsCompressorNode`). Reverb sends / ducking / snapshots remain
|
|
579
|
+
P4 (banks dropped — see §9). (See D10 for the effect hierarchy and where it lands.)
|
|
580
|
+
3. **Random/Switch selection — Network-deterministic.** Seed `seededRandom` from a replicated source
|
|
581
|
+
(entity id + `fixedStepTick`); selection is sampled where the fixed tick is stable (see D5).
|
|
582
|
+
|
|
583
|
+
**Still the owner's call (defaults assumed; revisit any time):**
|
|
584
|
+
4. **`SoundEmitter` endgame** — assume **keep as a frozen compat reader** through P3/P4; delete only
|
|
585
|
+
after full migration + a tested save-translation path. Removing a frozen ~80-line adapter is low value.
|
|
586
|
+
5. **Voice budget defaults** — assume **high/no-op defaults + opt-in stealing**, measured in the
|
|
587
|
+
densest scene before enabling.
|
|
588
|
+
6. **Bus tree source of truth** — assume **both**: code-seeded legacy default (1.2/0.1) overridable by
|
|
589
|
+
serialized scene data.
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## 13. FMOD/Wwise concept → sopra coverage
|
|
594
|
+
|
|
595
|
+
**v1 (core):** definition/instance split · nestable bus tree · **bus insert effects (EQ/Compressor)** ·
|
|
596
|
+
lookahead scheduler · parameters/RTPC + automation curves · voice manager
|
|
597
|
+
(limits/priority/stealing/virtualization) · random + sequence containers · custom 3D attenuation
|
|
598
|
+
curves · one-shot vs persistent · pitch/gain randomization · gapless loop regions
|
|
599
|
+
(`loopStart/loopEnd`).
|
|
600
|
+
|
|
601
|
+
**P4 / deferred (important):** ~~banks~~ (DROPPED — ECS data model covers it) ·
|
|
602
|
+
~~snapshots/mixer-states~~ **DONE** · ~~blend + switch containers (fold in `material/`)~~ **DONE** ·
|
|
603
|
+
~~reverb sends (`ConvolverNode`)~~ **DONE** · ~~emulated ducking~~ **DONE** · additional insert effects
|
|
604
|
+
(occlusion lowpass / `WaveShaper`).
|
|
605
|
+
|
|
606
|
+
**Nice-to-have:** scatterer/multi-spawn ambience · `AudioWorklet` (true sidechain + accurate meters)
|
|
607
|
+
· `AnalyserNode` dev meters · `OfflineAudioContext` baking · adaptive-music timeline
|
|
608
|
+
(transitions/stingers/sustain points).
|
|
609
|
+
|
|
610
|
+
---
|
|
611
|
+
|
|
612
|
+
## 14. P3 detailed integration sub-plan — route `SoundEmitter` through sopra (real, not a veneer)
|
|
613
|
+
|
|
614
|
+
Goal: the legacy `SoundEmitter` path genuinely renders through sopra. We do **not** keep 100%
|
|
615
|
+
back-compat; instead we **deprecate-and-plumb** — the old public API stays as `@deprecated` members
|
|
616
|
+
that translate into sopra (so call sites are unchanged), and is removed later. Where plumbing reaches
|
|
617
|
+
private state, we rewrite the call site.
|
|
618
|
+
|
|
619
|
+
### 14.1 Architecture — strangler at the existing spine
|
|
620
|
+
`SoundEmitterSystem` is already the seam: it owns `this.sopra` and ticks `sopra.setListener` +
|
|
621
|
+
`sopra.update` each frame (currently inert). Translation happens **there, at link time** — no new
|
|
622
|
+
system. Per-entity runtime state lives in the (gutted) `SoundEmitterComponentContext`, repurposed from
|
|
623
|
+
a WebAudio renderer into a **translation record**.
|
|
624
|
+
|
|
625
|
+
- **link(emitter,transform,entity)** (sopra branch): translate the emitter to sopra
|
|
626
|
+
`EventDescription`s and `playEvent` a persistent `EventInstance` per persistent/looping track; store
|
|
627
|
+
them in a per-entity `Map<SoundTrack, EventInstance>`.
|
|
628
|
+
- **tracks.on.added / on.removed** stay the hook: added → `playEvent` a new instance; removed →
|
|
629
|
+
`instance.stop()`. Preserves the live-mutation idiom (`SoundController`, `CombatEndMusicProcess`,
|
|
630
|
+
`injectMusic`, `StoryManager`).
|
|
631
|
+
- **emitter.volume.onChanged** → `instance.setGainDb(volume2dB(v))`; **setVolumeOverTime /
|
|
632
|
+
fadeOutAllTracks** → `instance.fadeToGainDb` / `instance.fadeOutAndStop`.
|
|
633
|
+
- **update()**: push `transform.position` into each owned instance (sopra already does distance
|
|
634
|
+
attenuation + panner + virtualization). The BVH stays as a cheap **far-cull pre-gate**.
|
|
635
|
+
- **unlink** → stop all the entity's instances.
|
|
636
|
+
- **createSound** (+ ~9 one-shot sites) → `sopra.playOneShot`, via a kept `@deprecated` facade that
|
|
637
|
+
still returns a destroyable `Entity` (so handle-dependent callers are unchanged) and wires
|
|
638
|
+
`instance.onEnded → builder.destroy`.
|
|
639
|
+
|
|
640
|
+
**Component model:** `SoundEmitter`/`SoundTrack` are **kept as `@deprecated` data components**,
|
|
641
|
+
translated at link time — lowest churn (editor, serialization adapters, `GameClassRegistry`, the 8
|
|
642
|
+
`fromJSON` sites all keep working). No `SopraEmitter` component in v1. **One persistent `SoundTrack` →
|
|
643
|
+
one `EventInstance`** (both already own an independent playhead/gain/loop/ended-signal); the emitter
|
|
644
|
+
maps to a *set* of instances reconciled via the existing `on.added/on.removed` signals.
|
|
645
|
+
|
|
646
|
+
### 14.2 Sopra gaps to fill first (engine-only, ECS-untouched)
|
|
647
|
+
The engine is fire-and-forget today (content frozen at `#resolve`, no live gain/fade/seek; 2D voices
|
|
648
|
+
route straight to the bus). Add the **live-control surface** without making content mutable:
|
|
649
|
+
1. **`EventInstance` `#instanceGain`** — one always-on trim gain that *all* voices route through
|
|
650
|
+
(uniform 2D/3D chain head: `instanceGain → [panner if 3D] → bus`). Parity with the legacy
|
|
651
|
+
`nodes.volume`, not new cost. *(do first)*
|
|
652
|
+
2. **`EventInstance.setGainDb(db)` / `get gainDb`** — composes with `#attenuationGain` (separate node)
|
|
653
|
+
so user volume × attenuation multiply.
|
|
654
|
+
3. **`EventInstance.fadeToGainDb(target,dur,startAfter)` + public `fadeOutAndStop(dur,startAfter)`** —
|
|
655
|
+
refactor `fadeOutAndStop.js` into a shared `rampGain` helper; stop scheduled via an audio-clock
|
|
656
|
+
deadline checked in `update()` (not `setTimeout`).
|
|
657
|
+
4. **`EventInstance.seek(seconds)` / `restart()`** — re-base `startedAt`, retire live voices so
|
|
658
|
+
`#evaluate` respawns at the new offset; **must not** reseed `playbackContext` (preserve avoid-repeat
|
|
659
|
+
determinism).
|
|
660
|
+
5. **`SopraEngine.crossfade(oldInstance, idOrDesc, opts)`** — start new at epsilon, ramp up while
|
|
661
|
+
`old.fadeOutAndStop`.
|
|
662
|
+
6. **Pure translators** in `sopra/legacy/`: `soundEmitterToEventDescriptions(emitter)` +
|
|
663
|
+
`attenuationFunctionToCurve(fn,min,max)` (dispatch `SoundAttenuationFunction` →
|
|
664
|
+
`interpolate_irradiance_*` → `buildAttenuationCurve`; `Attenuation` flag clear → flat-1 curve;
|
|
665
|
+
`is3D ← Spatialization`; `SoundPanningModelType → SopraPanningModel`).
|
|
666
|
+
|
|
667
|
+
**Gain composition rule (as built):** the translator bakes **NO** gain (`description.gainDb = 0`); the
|
|
668
|
+
per-instance `#instanceGain` carries the full live product `volume2dB(emitter.volume × track.volume)`,
|
|
669
|
+
re-applied on `emitter.volume.onChanged` and on a live `track.volume` set (via the track's
|
|
670
|
+
`__soundRuntime` back-ref); the **bus** carries the channel mix (the legacy 1.2/0.1 already lives on
|
|
671
|
+
sopra buses). Baking the product into the description *and* the instance gain would double-count — so
|
|
672
|
+
the description carries none.
|
|
673
|
+
|
|
674
|
+
### 14.3 Phased sequence (bottom-up; the 47 sopra tests + 2 legacy specs stay green throughout)
|
|
675
|
+
- **P3.2 — live-control primitives** *(risk: med)*: `#instanceGain`, `setGainDb`, `fadeToGainDb` +
|
|
676
|
+
public `fadeOutAndStop`, `seek`/`restart`, `SopraEngine.crossfade` + unit tests.
|
|
677
|
+
- **P3.3 — pure translators** *(low)*: `soundEmitterToEventDescriptions` + `attenuationFunctionToCurve`
|
|
678
|
+
+ unit tests (curves match `interpolate_irradiance_*` at sampled distances); not yet wired.
|
|
679
|
+
- **P3.4 — one-shots** *(med)*: `GameSounds` → registered `EventDescription`s; `createSound` facade →
|
|
680
|
+
`playOneShot` returning a destroyable Entity; legacy path kept when sopra unreachable.
|
|
681
|
+
- **P3.5 — persistent-emitter spine** *(high)*: rewire `SoundEmitterSystem.link` + gut
|
|
682
|
+
`SoundEmitterComponentContext` to a translation record; plumb volume/fade/stop; BVH far-cull kept;
|
|
683
|
+
`get/setChannelVolume` → bus proxies.
|
|
684
|
+
- **P3.6 — hard live-mutation sites** *(med)*: `CombatEndMusicProcess.switchMusic` → `sopra.crossfade`;
|
|
685
|
+
`resetSoundEmitterTracks` → `restart()`; verify `SoundController`/`injectMusic`/`StoryManager` via the
|
|
686
|
+
add/remove plumb.
|
|
687
|
+
- **P3.7 — listener convergence + cleanup** *(med)*: single sopra listener feeder; `@deprecated` JSDoc;
|
|
688
|
+
delete dead WebAudio in the sopra branch.
|
|
689
|
+
|
|
690
|
+
### 14.4 Deletions vs retained-`@deprecated`
|
|
691
|
+
- **Delete now (sopra branch):** `SoundEmitter.buildNodes`/`nodes`/`getTargetNode`/
|
|
692
|
+
`writeAttenuationVolume`/panner-half of `updatePosition`; `SoundEmitterComponentContext` WebAudio
|
|
693
|
+
body (connect/disconnect/suspend/resume); `SoundTrack.start`/`suspend`/`nodes`/`SoundTrackNodes`;
|
|
694
|
+
the per-track time-advance + `endTrack` loop; `CombatEndMusicProcess` hand-rolled crossfade;
|
|
695
|
+
`resetSoundEmitterTracks` `time=0` body.
|
|
696
|
+
- **Retain `@deprecated`:** `SoundEmitter` + serializable fields, `SoundTrack`, `GameSounds` constants,
|
|
697
|
+
`SoundEmitterChannel`/`channels` (editor dropdown), `get/setChannelVolume`, `fadeOutAllTracks`/
|
|
698
|
+
`stopAllTracks`, `createSound`, `SoundController`/`System`, **both serialization adapters**
|
|
699
|
+
(save-compat — never delete at cutover).
|
|
700
|
+
- **Later (dedicated change):** remove the `sopra === null` legacy WebAudio fallback once prototypes
|
|
701
|
+
are sound-free or sopra-capable; then retire `SoundEmitter` for a native sopra component.
|
|
702
|
+
|
|
703
|
+
### 14.5 Testing & risks
|
|
704
|
+
Black-box only; existing 47 sopra tests + `SoundEmitterSystem.spec` + `SoundEmitterSerializationAdapter.spec`
|
|
705
|
+
(both exercise the `sopra === null` path) stay green every phase. New coverage per phase via
|
|
706
|
+
`MockAudioContext` + `StubBufferProvider`. **Not unit-testable** (verify in-browser): AudioContext
|
|
707
|
+
bootstrap, autoplay-resume, audible result, dat.gui editor.
|
|
708
|
+
|
|
709
|
+
Top risks: **save-compat** (keep both adapters round-tripping — high); **gain composition** double-fold
|
|
710
|
+
(one rule, dB↔linear round-trip test — med); **createSound handle** (return a real Entity that stops
|
|
711
|
+
its instance on destroy — med); **`#instanceGain` topology change** perturbing the 47 tests (unity gain,
|
|
712
|
+
re-run suite immediately — med); **`seek` reseeding RNG** (must not — med).
|
|
713
|
+
|
|
714
|
+
### 14.6 Open decisions (recommendations in **bold**)
|
|
715
|
+
1. Music crossfade ownership → **keep `CombatEndMusicProcess` locating the music emitter + calling
|
|
716
|
+
`sopra.crossfade`** (a `MusicDirector` is later scope).
|
|
717
|
+
2. Prototypes → **keep the `sopra === null` branch** through P3 (strangler safety net); delete later.
|
|
718
|
+
3. `createSound` return → **real `Entity`** (callers unchanged); revisit only if the UI-click path shows cost.
|
|
719
|
+
4. Per-track volume fold → **`description.gainDb` per instance + `#instanceGain` for live volume**.
|
|
720
|
+
5. `resetSoundEmitterTracks` → **`restart()`** (not stop+replay — preserves RNG/loading).
|
|
721
|
+
6. `SoundController` → **keep the add/remove plumb** in v1 (single consumer: `UnitMaker`).
|
|
722
|
+
|
|
723
|
+
### 14.7 LOCKED API contract + no-switch refinement (owner-directed; supersedes the above where they differ)
|
|
724
|
+
|
|
725
|
+
**No switch.** There is no `sopra === null` fallback. `SoundEmitterSystem` ALWAYS routes through sopra.
|
|
726
|
+
Constructor becomes `SoundEmitterSystem(assetManager, soundEngine)` (context/destination derived from
|
|
727
|
+
it; it always `createSopra`s). All 11 construction sites pass the `SoundEngine` (the 10 prototypes/
|
|
728
|
+
`testEffect` pass `sound`/`engine.sound`; the unit spec passes a `MockAudioContext`-backed stub whose
|
|
729
|
+
`createSopra` builds a real `SopraEngine` on the mock + a `StubBufferProvider`). The legacy WebAudio
|
|
730
|
+
renderer is **deleted outright**, not guarded: per-track time-advance loop, `SoundTrackNodes`,
|
|
731
|
+
connect/disconnect virtualization, the channel `GainNode`s, `buildNodes`. (The two background-added
|
|
732
|
+
`SoundEmitterSystem.spec` tests cover that deleted loop and are replaced by sopra-routing tests.)
|
|
733
|
+
|
|
734
|
+
**Preserved public API — the whole contract. Everything else is removed or throws.**
|
|
735
|
+
|
|
736
|
+
`SoundEmitter`:
|
|
737
|
+
|
|
738
|
+
| member | plumbing into sopra |
|
|
739
|
+
|---|---|
|
|
740
|
+
| `tracks` (List) | add → `playEvent` + map; remove → `instance.stop()`; reconciled via `tracks.on.added/removed` |
|
|
741
|
+
| `channel` (string) | `EventDescription.busId` (read at translation; `""` → `effects`) |
|
|
742
|
+
| `volume` (Vector1) | `onChanged` → each owned `instance.setGainDb(volume2dB(emitter.volume × track.volume))` |
|
|
743
|
+
| `flags` | `Spatialization` → `is3D`; `Attenuation` → attenuation curve (read at translation) |
|
|
744
|
+
| `distanceMin` / `distanceMax` | `EventDescription.distanceMin/Max` |
|
|
745
|
+
|
|
746
|
+
`SoundTrack`:
|
|
747
|
+
|
|
748
|
+
| member | plumbing into sopra |
|
|
749
|
+
|---|---|
|
|
750
|
+
| `on.ended` (`{ended:Signal}`) | the track's `EventInstance.onEnded` → `on.ended.send1(track)` |
|
|
751
|
+
| `flags` | `Loop` → `clip.loop`; `UsingAliasURL` → `clip.usingAlias`; `StartWhenReady` → autoplay |
|
|
752
|
+
| `volume` (number) | NOT baked at translation; carried live in the owning instance gain (`emitter.volume × track.volume`), re-applied on a live set via `__soundRuntime` |
|
|
753
|
+
| `time` (number) | written back from the instance playhead each frame; rewind via `restart()` (used by `resetSoundEmitterTracks`) |
|
|
754
|
+
| `url` (string) | `SampleAudioClip.url` |
|
|
755
|
+
|
|
756
|
+
**Node access → fail loud, explicitly NOT public API** (as built). The property getters
|
|
757
|
+
`SoundEmitter.nodes` and `SoundTrack.nodes`, plus `SoundTrack.setVolumeOverTime`, are throwing stubs
|
|
758
|
+
(`get nodes() { throw new Error("... sopra owns the audio graph"); }`) — these are the members external
|
|
759
|
+
code could read. The internal builders that had **no external caller** (`buildNodes` / `getTargetNode` /
|
|
760
|
+
`writeAttenuationVolume` / `start` / `suspend` / `initializeNodes`) and the `SoundTrackNodes` class were
|
|
761
|
+
removed outright. Any straggler (e.g. `CombatEndMusicProcess` reaching `track.nodes.volume.gain`) breaks
|
|
762
|
+
loudly → rewritten (it now uses `fadeOutAllTracks(1)` + `tracks.add`).
|
|
763
|
+
|
|
764
|
+
**Kept as trivial `@deprecated` plumbing** (not in the contract, but behavioral with real callers, 1:1
|
|
765
|
+
to instance ops): `SoundEmitter.fadeOutAllTracks` → `instance.fadeOutAndStop` per owned instance;
|
|
766
|
+
`stopAllTracks` → owner `stopAll`. (Backs `hideEntityGracefully` / `AnimatedActions` unchanged.)
|
|
767
|
+
|
|
768
|
+
**Kept for save-compat (data only, unchanged):** both serialization adapters + the component's
|
|
769
|
+
serializable fields, including `isPositioned` (a thin `flags` wrapper retained for the editor +
|
|
770
|
+
serialization). **Removed entirely** (callers migrated): the `SoundEmitterComponentContext` WebAudio
|
|
771
|
+
body (gutted to a translation record), `endTrack`, `distanceRolloff`, the dead-but-unwritten
|
|
772
|
+
`SoundTrack.duration` (dropped from the locked surface), and the rest of the non-contract surface.
|
|
773
|
+
|
|
774
|
+
This also resolves §14.6 #2 (no `sopra === null` branch) and revises §14.4 (the node members move from
|
|
775
|
+
"retained @deprecated" to "throwing stubs"; there is no legacy fallback to retain).
|
|
776
|
+
|
|
777
|
+
## 15. P5 — spatial scaling to 100k emitters (BVH broadphase + live/dormant split)
|
|
778
|
+
|
|
779
|
+
**Goal.** Support ~100,000 registered 3D `AudioEmitter`s with per-frame work and WebAudio node count
|
|
780
|
+
bounded by a small audible budget `K`, **not** by the registered count. The fix is to decouple a
|
|
781
|
+
*registered emitter* (cheap data + one BVH leaf) from a *live `EventInstance`* (WebAudio nodes): only
|
|
782
|
+
emitters whose own audible sphere reaches the listener — capped to `K` — are ever live.
|
|
783
|
+
|
|
784
|
+
### 15.1 Principle — broadphase / narrowphase (mirrors `PhysicsSystem`)
|
|
785
|
+
- **Broadphase (new, cheap, ALL emitters):** a dynamic BVH of emitter audible-spheres; one point query
|
|
786
|
+
at the listener returns the emitters that *can possibly* be heard.
|
|
787
|
+
- **Narrowphase (existing, ≤K live):** the current `EventInstance` per-frame exact
|
|
788
|
+
distance/attenuation + virtualization (`virtualThresholdDb`) decides which live instances actually
|
|
789
|
+
hold a source `Voice`. Unchanged.
|
|
790
|
+
|
|
791
|
+
### 15.2 Three tiers
|
|
792
|
+
|
|
793
|
+
| Tier | Count (island) | Cost each | Where |
|
|
794
|
+
|---|---|---|---|
|
|
795
|
+
| **Registered / dormant** | ~100k | 1 record + 1 BVH leaf — **no `EventInstance`, no nodes, no per-frame update** | `SpatialAudioIndex` |
|
|
796
|
+
| **Live** | ≤ `K` (default **64**) | an `EventInstance` (panner chain), ticked each frame | sopra + the live set |
|
|
797
|
+
| **Audible voice** | ≤ K, usually fewer | a `Voice` (source node) | existing virtualization |
|
|
798
|
+
|
|
799
|
+
### 15.3 Reuse (this layer is thin — the BVH does the heavy lifting)
|
|
800
|
+
- `core/bvh2/bvh3/BVH.js` + **`BvhClient`** (`link(tree,data)`/`unlink()`/`resize(x0..z1)`/`write_bounds`)
|
|
801
|
+
— one leaf per emitter; the same per-entity handle `RenderSystem`/`PhysicsSystem` already use.
|
|
802
|
+
- `bvh_query_user_data_generic(out, 0, bvh, bvh.root, BVHQueryIntersectsSphere.from([lx,ly,lz, 0]))` —
|
|
803
|
+
the per-frame cull (point-in-leaf collect). `bvh_query_user_data_nearest_to_point` available if a
|
|
804
|
+
nearest-N selector is preferred over collect-then-select.
|
|
805
|
+
- Existing `EventInstance` virtualization / `seek` / `fadeOutAndStop`, the `VOICE_POOL`, and
|
|
806
|
+
`SoundListener`+`Transform` — all unchanged.
|
|
807
|
+
|
|
808
|
+
### 15.4 Key trick — leaf AABB = the emitter's OWN audible region (no global radius — owner-locked)
|
|
809
|
+
Each emitter's leaf is sized to its audible sphere: `AABB = position ± event.distanceMax`. The cull is
|
|
810
|
+
then a **point query at the listener**, returning exactly the emitters whose `distanceMax` reaches the
|
|
811
|
+
listener — honoring each emitter's own range, with no hardcoded global radius.
|
|
812
|
+
|
|
813
|
+
### 15.5 Reactive leaf maintenance (NO per-tick iteration — owner-locked)
|
|
814
|
+
Everything in meep is dynamic; there is **no static/dynamic flag and no per-tick refit loop**. On
|
|
815
|
+
`add`, the leaf subscribes to the emitter's `transform.position.onChanged` (the position channel of
|
|
816
|
+
`Transform.subscribe`); the handler does `bvhClient.resize(position ± distanceMax)`. Unsubscribe on
|
|
817
|
+
`remove`. Cost is proportional to *actual movement*, not emitter count: a forest of still trees refits
|
|
818
|
+
zero leaves/frame; 100k handlers that never fire are just memory. (Live instances separately read the
|
|
819
|
+
shared `position` Vector3 each frame for their panner — but only ≤K of those exist.)
|
|
820
|
+
|
|
821
|
+
### 15.6 `SpatialAudioIndex` + `LiveEmitterSet` (two thin pieces) [as-built — the sketch's single class split in two]
|
|
822
|
+
- **`SpatialAudioIndex`** (cull-only): owns the emitter `BVH` + records `{ entity, event, position,
|
|
823
|
+
bvhClient, onMove }`.
|
|
824
|
+
- `add(entity, event, position)` → record + `bvhClient.link(bvh, entity)` (leaf user_data = the entity
|
|
825
|
+
id, so queries return entity ids) + `resize(pos ± distanceMax)` + subscribe `position.onChanged`.
|
|
826
|
+
- `remove(entity)` → unsubscribe + `bvhClient.unlink()` + drop the record.
|
|
827
|
+
- `queryAudible(out, out_offset, listenerPosition)` → writes candidate entity ids into the caller's
|
|
828
|
+
reused buffer from `out_offset`, returns the match count (allocation-free; meep out-buffer convention).
|
|
829
|
+
- **`LiveEmitterSet`** (the live/dormant lifecycle): wraps the index; owns the live map
|
|
830
|
+
`{ entity → EventInstance }` + per-emitter records `{ event, position, gainDb, playingSince }`; config
|
|
831
|
+
`{ budget = 64, liveStickiness = 0.8, fadeOutSeconds = 0.15 }`.
|
|
832
|
+
- `add(entity, event, position, gainDb)` → register with the index + record (dormant).
|
|
833
|
+
- `remove(entity)` → demote (hard cut) if live, then `index.remove`.
|
|
834
|
+
- `setGainDb(entity, gainDb)` → update the record + the live instance if any (survives re-promotion).
|
|
835
|
+
- `refresh(listenerPosition)` → cull + budget + diff, **every tick** (§15.7).
|
|
836
|
+
|
|
837
|
+
Non-3D emitters (and finite-3D one-shots) skip the index entirely — the ECS layer plays them directly
|
|
838
|
+
(§15.13 P5.6); only persistent looping 3D emitters are spatially managed.
|
|
839
|
+
|
|
840
|
+
### 15.7 `LiveEmitterSet.refresh` — every tick [as-built]
|
|
841
|
+
1. **Cull:** `queryAudible` point query → candidate entity ids (reused buffer); an exact spherical
|
|
842
|
+
refine then drops the conservative-cube corners (`distance > distanceMax`).
|
|
843
|
+
2. **Budget select:** rank in-range candidates by **effective distance** — a live emitter competes as
|
|
844
|
+
`distance × liveStickiness` so it does not flicker promote/demote at the cutoff (rank hysteresis) —
|
|
845
|
+
and take the nearest `budget`. (The sketch's extra `event.priority` / attenuation-gain tie-breakers
|
|
846
|
+
were not needed; pure distance + stickiness.)
|
|
847
|
+
3. **Diff** vs the current live set (`#live`) → **demote** leavers, **promote** entrants; at budget a
|
|
848
|
+
closer entrant steals the farthest live slot. The diff is re-derived from `#live` each call (so a
|
|
849
|
+
denied or self-ended promotion is retried automatically), with demote-before-promote ordering.
|
|
850
|
+
4. `sopra.update(now)` — the caller's, right after `refresh` — ticks only the ≤budget live instances.
|
|
851
|
+
|
|
852
|
+
**No throttle (§15.10):** this runs on every tick. The BVH query is microseconds and the diff is
|
|
853
|
+
re-derived from `#live`, so there is nothing to gate; the scratch containers are reused so the per-tick
|
|
854
|
+
diff is allocation-light apart from the rank entries.
|
|
855
|
+
|
|
856
|
+
### 15.8 Promotion / demotion + voice slots (allocated at go-live — owner-confirmed)
|
|
857
|
+
`K` is the voice-slot pool, allocated at promotion (the only moment WebAudio cost is incurred).
|
|
858
|
+
- **promote:** `sopra.playEvent(event, { position, oneShot, startTime })` where `startTime = playingSince`
|
|
859
|
+
reconstructs the phase (§15.9 — via the `EventInstance.startTime` option, not a post-hoc `seek`), then
|
|
860
|
+
apply the emitter's `gainDb`, then store the instance.
|
|
861
|
+
- **demote — two cases (owner-locked):**
|
|
862
|
+
- **culled out of range** (the emitter left its own audible sphere → already ~0 gain / virtual):
|
|
863
|
+
**hard cut** (`instance.stop()`). A fade is pointless/wrong — it is already inaudible.
|
|
864
|
+
- **contention / stolen** (a closer emitter evicts a still-in-range, audible live one): **fade**
|
|
865
|
+
(`fadeOutAndStop`) to avoid a click.
|
|
866
|
+
- **stealing = distance priority:** at budget, a closer entrant demotes (steals the slot of) the
|
|
867
|
+
farthest live record.
|
|
868
|
+
|
|
869
|
+
This sits *on top of*, not replacing, sopra's per-event `maxInstances` (polyphony among the live) and
|
|
870
|
+
per-instance virtualization (source-voice gating). **Constraint (P5.2):** `budget` and `maxInstances`
|
|
871
|
+
are independent composed caps. For content-equal ambience you want up to N copies of audible at once,
|
|
872
|
+
that event's `maxInstances` must be ≥ N, else sopra gates the budget — `stealMode None` denies the
|
|
873
|
+
promote (emitter stays dormant, retried next refresh) and `stealMode Oldest` makes the two layers fight
|
|
874
|
+
(churn). Set managed ambience `maxInstances` high (P5.6 wiring).
|
|
875
|
+
|
|
876
|
+
### 15.9 Phase reconstruction (continuous world clock — owner-decided; supersedes the entity-id phaseOffset idea)
|
|
877
|
+
Dormant emitters don't simulate playback, but the loop conceptually keeps running on the world clock.
|
|
878
|
+
Record `playingSince` (audio clock) **once at registration**; on promotion the instance starts with
|
|
879
|
+
logical `startTime = playingSince`, so its playhead is `now − playingSince` and the looping voice's
|
|
880
|
+
buffer offset wraps into the loop region. A bird you approach is mid-song (continuity); a re-promotion
|
|
881
|
+
after a cull resumes at **total elapsed since link** (the loop kept running while you were away).
|
|
882
|
+
Deterministic from real time — **no engine-invented random phase**. Decorrelation/variety is the content
|
|
883
|
+
author's job (existing per-trigger pitch/gain randomization, RandomContainer), not an engine concern.
|
|
884
|
+
(Rejected the earlier auto entity-id phaseOffset: opinionated/magic, and for sparse chirps the artifact
|
|
885
|
+
is imperceptible; phase-lock matters mainly for tonal loops, which the author can vary.)
|
|
886
|
+
|
|
887
|
+
### 15.10 Churn control (promotion has real cost)
|
|
888
|
+
- **Hysteresis (as-built):** rank hysteresis via `liveStickiness` — a live emitter competes at
|
|
889
|
+
`distance × liveStickiness` (default 0.8), so a newcomer must be clearly closer to steal its slot; this
|
|
890
|
+
kills promote/demote flicker at the budget cutoff. (The sketch's leaf-AABB / `distanceMax + margin`
|
|
891
|
+
approach was not needed — range-edge churn is inaudible, gain ≈ 0 there; the audible flicker is at the
|
|
892
|
+
*budget* cutoff, which the rank discount handles.) Demote-by-cull is at exactly `distanceMax` (the
|
|
893
|
+
spherical refine).
|
|
894
|
+
- **Cull every tick — NO throttling (owner-decided, P5.5 reverted).** `refresh()` runs the full cull
|
|
895
|
+
on every `update` tick. The BVH point-query is O(log n + candidates) (microseconds; the 100k stress
|
|
896
|
+
shows the whole per-frame `refresh + tick` is ~5 ms), and the promote/demote diff is re-derived from
|
|
897
|
+
`#live` each call, so there is no reason to throttle. A throttle was briefly added and removed — it was
|
|
898
|
+
unnecessary complexity (and introduced a contention-fade gap edge). The diff is allocation-light: the
|
|
899
|
+
scratch containers (`#ranked`, `#inRange`, `#target`, `#demoteScratch`, `#candidates`) are reused
|
|
900
|
+
across calls. (`IncrementalDeltaSet` was considered for the diff but does not fit: `#live` changes
|
|
901
|
+
out-of-band — instances self-end on asset failure, and promotions can be denied by polyphony caps — so
|
|
902
|
+
the diff must be re-derived from `#live` each frame, which a maintained delta set would fight.)
|
|
903
|
+
- **Pool the per-instance output chain** (instanceGain → attenuationGain → panner) — **DEFERRED
|
|
904
|
+
(measure-first).** Original rationale was the "1000 live panners / panner not torn down on virtualize"
|
|
905
|
+
cost; the P5.1–P5.2 live/dormant model already bounds live panner chains to `budget` (≤64), and
|
|
906
|
+
hysteresis minimises promote/demote frequency, so this is now a constant-factor micro-opt on ≤64
|
|
907
|
+
chains churned only on genuine live-set changes. Per project guidance (reuse-over-micro-opt, measure
|
|
908
|
+
first, treat constant-factor diffs as noise) this is held until profiling shows promote/demote
|
|
909
|
+
node-churn is actually hot. AudioBufferSourceNodes are single-use and stay pooled via `VOICE_POOL`;
|
|
910
|
+
only Gain/Panner nodes would be poolable.
|
|
911
|
+
|
|
912
|
+
### 15.11 What changes where
|
|
913
|
+
- `SopraEngine` core: **unchanged**; its `update` is now O(K) (only live instances are active). No new
|
|
914
|
+
public surface (`playEvent`/`stop`/`seek` exist).
|
|
915
|
+
- **New `SpatialAudioIndex`** (BVH cull + records, thin over the BVH + queries) and **`LiveEmitterSet`**
|
|
916
|
+
(the live/dormant lifecycle: budget + promote/demote/steal + per-emitter gain) layered on it.
|
|
917
|
+
- `LiveEmitterSet.refresh` culls every tick (no throttling — §15.10), with reused scratch so the diff is
|
|
918
|
+
allocation-light. The per-instance output-chain pool is **deferred** (measure-first — §15.10): not
|
|
919
|
+
needed for the 100k target since live chains are already bounded to `budget`.
|
|
920
|
+
- `AudioEmitterSystem` (DONE, P5.6): `link/unlink` → `liveSet.add/remove` for managed (looping 3D
|
|
921
|
+
autoplay) emitters; `update` → `liveSet.refresh(listener)` + `sopra.update`. 2D + finite-3D-one-shot
|
|
922
|
+
autoplay take the direct play path; non-autoplay is inert. No more unconditional autoplay-per-emitter.
|
|
923
|
+
- `AudioEmitter`: **no** `dynamic` flag. `VoiceManager`: unchanged.
|
|
924
|
+
|
|
925
|
+
### 15.12 Scorecard (target, 100k emitters)
|
|
926
|
+
- Per frame: 1 BVH point query (O(log n + candidates)) + top-K select + O(K) live ticks. **Independent
|
|
927
|
+
of 100k.**
|
|
928
|
+
- WebAudio: ≤K panner chains + ≤K voices.
|
|
929
|
+
- Memory: 100k records + 100k BVH leaves (~a few MB, typed-array backed) + shared events/buffers. Leaf
|
|
930
|
+
refits ∝ movement, not count.
|
|
931
|
+
|
|
932
|
+
### 15.13 Rollout (incremental, test-first; MockAudioContext + synthetic emitter field + movable mock listener)
|
|
933
|
+
- **P5.1 — DONE** (meep `73f9becc1`). `SpatialAudioIndex` (`engine/sound/ecs/audio/SpatialAudioIndex.js`)
|
|
934
|
+
cull only: BVH add/remove + reactive `position.onChanged` refit + point query (`BVHQueryIntersectsSphere`
|
|
935
|
+
radius 0) → candidate entity ids; leaf user_data = entity id; leaf AABB = `position ± distanceMax`;
|
|
936
|
+
reused query object + result buffer. 12 black-box tests (in/out per-emitter range, reactive refit both
|
|
937
|
+
ways, removal+detach, boundary-inclusive on-axis, repeated-query buffer reuse, 1000-emitter cull). No
|
|
938
|
+
promotion yet.
|
|
939
|
+
- **P5.2 — DONE** (meep `a39ecbbe6`). `LiveEmitterSet` (`engine/sound/ecs/audio/LiveEmitterSet.js`)
|
|
940
|
+
composes the index: `refresh(listener)` promotes nearest-in-range up to `budget` (64), demotes the
|
|
941
|
+
rest; distance-priority stealing; rank hysteresis via live-stickiness; exact spherical refine over the
|
|
942
|
+
conservative cube; demote-frees-before-promote-fills; zombie-safe demote (delete-before-stop) + self-
|
|
943
|
+
end cleanup. Hard cut on demote (fade-on-contention = P5.3). Documented budget↔maxInstances
|
|
944
|
+
dependency + pinned by a test. 12 black-box tests.
|
|
945
|
+
- **P5.3 — DONE** (meep `1e0a0deee`). Demotion policy in `LiveEmitterSet`: refresh builds an `inRange`
|
|
946
|
+
set (exact distanceMax); a demoted live emitter still in range is faded (`fadeOutAndStop`,
|
|
947
|
+
`fadeOutSeconds` default 0.15) — audible, would click — while one that left range is hard-stopped
|
|
948
|
+
(already inaudible). `remove()` (unlink) hard-cuts. Slot frees immediately (delete-before-fade); the
|
|
949
|
+
onEnded `=== instance` guard makes re-promote-during-fade safe. Tests: cut-on-cull, fade-on-contention,
|
|
950
|
+
re-promote-during-fade no-clobber. Adversarial review → CLEAN.
|
|
951
|
+
- **P5.4 — DONE** (meep `ad96e3a00`). Continuous-clock phase reconstruction (see §15.9, owner-decided —
|
|
952
|
+
no random phaseOffset). `EventInstance` gained a logical `startTime` option (playhead = now − startTime)
|
|
953
|
+
+ a looping-voice buffer-offset wrap (also fixes long-dormant virtualization revival).
|
|
954
|
+
`LiveEmitterSet` records `playingSince` once at add and promotes with it, so a re-promotion after a
|
|
955
|
+
cull resumes at total elapsed since link. Zombie guard for a finite event whose play time already
|
|
956
|
+
elapsed (self-stops synchronously during start). Tests: resume-at-elapsed + offset-wrap,
|
|
957
|
+
continuity-across-cull, seek-wrap, finite-elapsed guard. Adversarial review → CLEAN.
|
|
958
|
+
- **P5.5 — cull throttling ADDED then REVERTED** (added meep `8264bc328`, reverted later). A throttle
|
|
959
|
+
(`cullIntervalSeconds`/`cullMoveThreshold`) was briefly added, then removed at the owner's direction:
|
|
960
|
+
the BVH point-query is microseconds and the diff is re-derived from `#live` each call, so there is no
|
|
961
|
+
reason not to cull on every `update` tick (100k stress: ~5 ms per `refresh + tick`). What remains from
|
|
962
|
+
P5.5: `refresh()` culls every tick with an **allocation-light diff** — the scratch containers
|
|
963
|
+
(`#ranked`, `#inRange`, `#target`, `#demoteScratch`, `#candidates`) are reused across calls; the
|
|
964
|
+
comparator is module-static. `IncrementalDeltaSet` was considered for the diff but does not fit (`#live`
|
|
965
|
+
changes out-of-band: self-end on asset failure, denial by polyphony caps — the diff must re-derive from
|
|
966
|
+
`#live`). The +9 throttle tests were removed. **Output-chain pool still DEFERRED (measure-first)** —
|
|
967
|
+
see §15.10 (live/dormant already bounds panners to budget; held until profiling shows churn is hot).
|
|
968
|
+
- **P5.6 — DONE** (meep `d4ba84cd4`). Wired `AudioEmitterSystem` to `LiveEmitterSet`. Routing fixed at
|
|
969
|
+
link: **managed** = `autoplay && is3D && rootClip.loops()` → `liveSet.add(entity, event, position,
|
|
970
|
+
volume2dB(volume))`, dormant until refresh; **direct** = any other autoplay (2D, or finite 3D one-shot)
|
|
971
|
+
→ immediate `#play`; **inert** = non-autoplay. (Finite 3D one-shots are deliberately direct, not
|
|
972
|
+
managed — a dead one-shot would otherwise re-promote+self-stop every cull and occupy a budget slot:
|
|
973
|
+
the P5.6 review's main RISK.) `update` resolves the `SoundListener`, then refreshes the live set BEFORE
|
|
974
|
+
`sopra.update` (fresh promote spatializes same frame); a listener entity missing its `Transform` is
|
|
975
|
+
tolerated as "no listener" (guard) rather than throwing every frame. Per-emitter gain: `AudioEmitter.volume`
|
|
976
|
+
carried by `LiveEmitterSet` (`add` 4th arg + `setGainDb`), replayed on every promotion (survives
|
|
977
|
+
demote→re-promote). `instanceFor(entity)` exposes the active instance for either path; ctor takes
|
|
978
|
+
`liveEmitterSetOptions`. **100k stress (both `.skip`):** `AudioEmitterSystem.spec` proves only `budget`
|
|
979
|
+
hold a live instance (node count O(budget), not O(N)) + listener-walks-away frees all; `SpatialAudioIndex.spec`
|
|
980
|
+
proves the cull returns only the in-range candidates (~200), not N → per-frame work O(log N + k). 298
|
|
981
|
+
engine/sound green (34 suites), 2 skipped. Adversarial workflow review (6 dims / 21 agents): no
|
|
982
|
+
BUG-level defects; acted on the confirmed RISKs + doc fixes.
|
|
983
|
+
- **Remaining (post-P5):** register `AudioEmitterSystem` in `makeMirEngineConfig` (production wiring; it
|
|
984
|
+
is currently constructed only in tests) and the standing background follow-ups (P5.5 output-chain pool
|
|
985
|
+
if profiling demands it; IR1/IR4; dormant-material cleanup).
|
|
986
|
+
|
|
987
|
+
### 15.14 Locked decisions (owner)
|
|
988
|
+
- Budget **K = 64** (live instances; virtualization trims actual voices further).
|
|
989
|
+
- **No global radius** — cull by per-emitter `event.distanceMax` (leaf = `pos ± distanceMax`).
|
|
990
|
+
- **No static/dynamic distinction** — everything is dynamic; leaves refit reactively via
|
|
991
|
+
`transform.position.onChanged`, never per-tick iteration.
|
|
992
|
+
- **Demotion:** hard **cut** when culled out of range (already inaudible); **fade** only on contention
|
|
993
|
+
(stealing a still-audible live instance).
|