import { dbToLinear, distance3 } from "./math.js"; import { hashString, whiteNoiseAt } from "./noise.js"; const WALLS = [ { id: "xMin", axis: 0, boundary: "min" }, { id: "xMax", axis: 0, boundary: "max" }, { id: "yMin", axis: 1, boundary: "min" }, { id: "yMax", axis: 1, boundary: "max" }, { id: "zMin", axis: 2, boundary: "min" }, { id: "zMax", axis: 2, boundary: "max" }, ]; const GEOMETRY_EPSILON = 1e-9; export const DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS = 2_000_000; const trustedAcousticPathSummaries = new WeakSet(); const acousticPathSummaryCache = new WeakMap(); const acousticPathSummaryLru = new Map(); const MAXIMUM_ACOUSTIC_PATH_SUMMARY_CACHE_ENTRIES = 8; function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) { return position.every( (coordinate, axis) => coordinate >= bounds.min[axis] - epsilon && coordinate <= bounds.max[axis] + epsilon, ); } function segmentIntersectsBox(start, end, box) { let entry = 0; let exit = 1; for (let axis = 0; axis < 3; axis += 1) { const delta = end[axis] - start[axis]; if (Math.abs(delta) <= GEOMETRY_EPSILON) { if (start[axis] < box.min[axis] || start[axis] > box.max[axis]) return false; continue; } const first = (box.min[axis] - start[axis]) / delta; const second = (box.max[axis] - start[axis]) / delta; entry = Math.max(entry, Math.min(first, second)); exit = Math.min(exit, Math.max(first, second)); if (entry > exit) return false; } return exit > GEOMETRY_EPSILON && entry < 1 - GEOMETRY_EPSILON; } function shortestCornerDetour(start, end, box) { let shortestDistance = Infinity; for (let mask = 0; mask < 8; mask += 1) { const corner = [0, 1, 2].map((axis) => mask & (1 << axis) ? box.max[axis] : box.min[axis], ); shortestDistance = Math.min( shortestDistance, distance3(start, corner) + distance3(corner, end), ); } return shortestDistance; } function imageSourceDistance(source, receiver, room, wall) { if (!insideBounds(source, room) || !insideBounds(receiver, room)) return null; const plane = room[wall.boundary][wall.axis]; const image = [...source]; image[wall.axis] = 2 * plane - source[wall.axis]; const denominator = receiver[wall.axis] - image[wall.axis]; if (Math.abs(denominator) <= GEOMETRY_EPSILON) return null; const amount = (plane - image[wall.axis]) / denominator; if (amount <= GEOMETRY_EPSILON || amount >= 1 - GEOMETRY_EPSILON) return null; const reflectionPoint = image.map( (coordinate, axis) => coordinate + (receiver[axis] - coordinate) * amount, ); for (let axis = 0; axis < 3; axis += 1) { if (axis === wall.axis) continue; if ( reflectionPoint[axis] < room.min[axis] - GEOMETRY_EPSILON || reflectionPoint[axis] > room.max[axis] + GEOMETRY_EPSILON ) { return null; } } return distance3(image, receiver); } function pathGain(distance, coefficient, referenceDistance, minimumDistance) { return coefficient * referenceDistance / Math.max(distance, minimumDistance); } function deterministicUnit(seed, index) { return (whiteNoiseAt(seed, index) + 1) / 2; } function stableSeed(scene, sourceId, receiverId) { return ( (scene.seed ?? 0) ^ hashString(String(sourceId ?? "source")) ^ Math.imul(hashString(String(receiverId ?? "receiver")), 0x45d9f3b) ) >>> 0; } /** * Build a sparse, deterministic set of propagation paths for one source/receiver pair. * * The optional shoebox model is intentionally bounded: direct sound, six first-order * image sources, at most one edge-detour per occluder, and a capped diffuse tail. * It is useful for array/algorithm comparisons; it is not a room-acoustics solver. */ export function buildAcousticPaths(options) { const { scene, sourcePosition, receiverPosition, sourceId, receiverId, referenceDistance = 1, minimumDistance = 0.01, } = options; const speedOfSound = scene.speedOfSound; const directDistance = distance3(sourcePosition, receiverPosition); const acoustics = scene.acoustics; if (!acoustics || acoustics.model === "free-field") { return [{ kind: "direct", distance: directDistance, delaySeconds: directDistance / speedOfSound, gain: pathGain(directDistance, 1, referenceDistance, minimumDistance), }]; } const paths = []; const blockers = (acoustics.occluders ?? []).filter((occluder) => segmentIntersectsBox(sourcePosition, receiverPosition, occluder), ); const transmission = blockers.reduce( (coefficient, occluder) => coefficient * dbToLinear(occluder.transmissionDb), 1, ); paths.push({ kind: "direct", distance: directDistance, delaySeconds: directDistance / speedOfSound, gain: pathGain(directDistance, transmission, referenceDistance, minimumDistance), blockedBy: blockers.map(({ id }) => id), }); for (const occluder of blockers) { const distance = shortestCornerDetour(sourcePosition, receiverPosition, occluder); paths.push({ kind: "diffraction", occluderId: occluder.id, distance, delaySeconds: distance / speedOfSound, gain: pathGain( distance, dbToLinear(occluder.diffractionGainDb), referenceDistance, minimumDistance, ), }); } const reflections = acoustics.reflections; const seed = stableSeed(scene, sourceId, receiverId); if (reflections?.order === 1 && acoustics.room) { const scattering = reflections.scattering ?? 0; for (let wallIndex = 0; wallIndex < WALLS.length; wallIndex += 1) { const wall = WALLS[wallIndex]; const distance = imageSourceDistance( sourcePosition, receiverPosition, acoustics.room, wall, ); if (distance === null) continue; const wallDb = reflections.perWallDb?.[wall.id] ?? reflections.wallGainDb; const reflectionCoefficient = dbToLinear(wallDb); const specularCoefficient = reflectionCoefficient * Math.sqrt(1 - scattering); if (specularCoefficient > 0) { paths.push({ kind: "reflection", surface: wall.id, distance, delaySeconds: distance / speedOfSound, gain: pathGain(distance, specularCoefficient, referenceDistance, minimumDistance), }); } if (scattering > 0) { const jitterSeconds = 0.0002 + deterministicUnit(seed, wallIndex) * 0.0018; const sign = whiteNoiseAt(seed ^ 0x9e3779b9, wallIndex) < 0 ? -1 : 1; paths.push({ kind: "scattering", surface: wall.id, distance: distance + jitterSeconds * speedOfSound, delaySeconds: distance / speedOfSound + jitterSeconds, gain: pathGain( distance, reflectionCoefficient * Math.sqrt(scattering) * sign, referenceDistance, minimumDistance, ), }); } } } const diffuse = acoustics.diffuseReverb; if (diffuse?.enabled) { const tapGain = dbToLinear(diffuse.gainDb) / Math.sqrt(diffuse.tapCount); for (let tapIndex = 0; tapIndex < diffuse.tapCount; tapIndex += 1) { const progress = (tapIndex + 1) / (diffuse.tapCount + 1); const tailTime = progress * diffuse.rt60Seconds; const jitterLimit = Math.min(0.001, diffuse.rt60Seconds / (diffuse.tapCount * 4)); const jitter = deterministicUnit(seed ^ 0xa511e9b3, tapIndex) * jitterLimit; const decay = 10 ** ((-60 * tailTime / diffuse.rt60Seconds) / 20); const sign = whiteNoiseAt(seed ^ 0x63d83595, tapIndex) < 0 ? -1 : 1; paths.push({ kind: "diffuse", distance: directDistance + (tailTime + jitter) * speedOfSound, delaySeconds: directDistance / speedOfSound + tailTime + jitter, gain: pathGain( directDistance, tapGain * decay * sign, referenceDistance, minimumDistance, ), }); } } return paths; } export function maximumAcousticPathsPerReceiver(scene) { const acoustics = scene?.acoustics; if (!acoustics || acoustics.model === "free-field") return 1; let count = 1 + (Array.isArray(acoustics.occluders) ? acoustics.occluders.length : 0); if ( acoustics.reflections?.order === 1 && acoustics.room ) { count += 6; if (acoustics.reflections.scattering > 0) count += 6; } if (acoustics.diffuseReverb?.enabled) { count += acoustics.diffuseReverb.tapCount; } return count; } function acousticGeometrySignature(options) { const { scene, arrayDefinition, referenceDistance = 1, minimumDistance = 0.01, } = options; return JSON.stringify({ version: 1, speedOfSound: scene.speedOfSound, seed: scene.seed ?? 0, acoustics: scene.acoustics ?? null, referenceDistance, minimumDistance, sources: scene.sources.map((source) => ({ id: source.id, position: source.position, })), microphones: arrayDefinition.microphones.map((microphone) => ({ id: microphone.id, position: microphone.position, })), }); } function freezePathCounts(counts) { return Object.freeze({ ...counts }); } function createTrustedAcousticPathSummary(options) { const { signature, sources, microphoneCount, } = options; const pathCounts = {}; let pathCount = 0; let maximumDelaySeconds = 0; for (const source of sources) { pathCount += source.pathCount; maximumDelaySeconds = Math.max(maximumDelaySeconds, source.maximumDelaySeconds); for (const [kind, count] of Object.entries(source.pathCounts)) { pathCounts[kind] = (pathCounts[kind] ?? 0) + count; } } const frozenSources = Object.freeze(sources.map((source) => Object.freeze({ sourceId: source.sourceId, pathCount: source.pathCount, pathCounts: freezePathCounts(source.pathCounts), maximumDelaySeconds: source.maximumDelaySeconds, }))); const summary = Object.freeze({ kind: "micarrsim.acoustic-path-summary/1", signature, sourceCount: frozenSources.length, microphoneCount, sourceReceiverPairCount: frozenSources.length * microphoneCount, pathCount, pathCounts: freezePathCounts(pathCounts), maximumDelaySeconds, sources: frozenSources, }); trustedAcousticPathSummaries.add(summary); return summary; } /** * Enumerate sparse paths one source/receiver pair at a time and retain only * numeric count/delay metadata. The returned immutable summary is internally * branded so callers cannot forge a stale geometry cache. */ export function summarizeAcousticPaths(options) { const { scene, arrayDefinition, referenceDistance = 1, minimumDistance = 0.01, maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS, } = options; if ( !Number.isSafeInteger(maximumPathEvaluations) || maximumPathEvaluations <= 0 ) { throw new RangeError( "maximumPathEvaluations must be a positive safe integer.", ); } const maximumPerReceiver = maximumAcousticPathsPerReceiver(scene); const theoreticalPathEvaluations = scene.sources.length * arrayDefinition.microphones.length * maximumPerReceiver; if ( !Number.isSafeInteger(theoreticalPathEvaluations) || theoreticalPathEvaluations > maximumPathEvaluations ) { throw new RangeError( `acoustic path evaluation upper bound ${theoreticalPathEvaluations} exceeds ` + `maximumPathEvaluations ${maximumPathEvaluations}.`, ); } const signature = acousticGeometrySignature({ scene, arrayDefinition, referenceDistance, minimumDistance, }); const lruSummary = acousticPathSummaryLru.get(signature); if (lruSummary) { acousticPathSummaryLru.delete(signature); acousticPathSummaryLru.set(signature, lruSummary); return lruSummary; } let arrayCache = acousticPathSummaryCache.get(scene); if (!arrayCache) { arrayCache = new WeakMap(); acousticPathSummaryCache.set(scene, arrayCache); } let parameterCache = arrayCache.get(arrayDefinition); if (!parameterCache) { parameterCache = new Map(); arrayCache.set(arrayDefinition, parameterCache); } const parameterKey = `${referenceDistance}\u0000${minimumDistance}`; const cachedSummary = parameterCache.get(parameterKey); if (cachedSummary?.signature === signature) return cachedSummary; const sources = scene.sources.map((source) => { const pathCounts = {}; let pathCount = 0; let maximumDelaySeconds = 0; for (const microphone of arrayDefinition.microphones) { const paths = buildAcousticPaths({ scene, sourcePosition: source.position, receiverPosition: microphone.position, sourceId: source.id, receiverId: microphone.id, referenceDistance, minimumDistance, }); for (const path of paths) { if (!Number.isFinite(path.delaySeconds) || path.delaySeconds < 0) { throw new RangeError("acoustic path delay must be finite and non-negative."); } pathCount += 1; pathCounts[path.kind] = (pathCounts[path.kind] ?? 0) + 1; maximumDelaySeconds = Math.max(maximumDelaySeconds, path.delaySeconds); } } return { sourceId: source.id, pathCount, pathCounts, maximumDelaySeconds, }; }); const summary = createTrustedAcousticPathSummary({ signature, sources, microphoneCount: arrayDefinition.microphones.length, }); parameterCache.set(parameterKey, summary); acousticPathSummaryLru.set(signature, summary); while ( acousticPathSummaryLru.size > MAXIMUM_ACOUSTIC_PATH_SUMMARY_CACHE_ENTRIES ) { acousticPathSummaryLru.delete(acousticPathSummaryLru.keys().next().value); } return summary; } export function isAcousticPathSummaryCompatible(summary, options) { return ( trustedAcousticPathSummaries.has(summary) && summary.signature === acousticGeometrySignature(options) && summary.sourceCount === options.scene.sources.length && summary.microphoneCount === options.arrayDefinition.microphones.length ); } /** * Retarget an authenticated full-scene summary to the source subset retained * by a quasi-static render window. Only immutable numeric metadata is shared. */ export function selectAcousticPathSummary(options) { const { summary, sourceIndexes, sourceScene, scene, arrayDefinition, referenceDistance = 1, minimumDistance = 0.01, } = options; if (!trustedAcousticPathSummaries.has(summary)) { throw new TypeError("summary must be created by summarizeAcousticPaths."); } if ( !sourceScene || !isAcousticPathSummaryCompatible(summary, { scene: sourceScene, arrayDefinition, referenceDistance, minimumDistance, }) ) { throw new RangeError( "source acoustic path summary does not match its original geometry.", ); } if ( !Array.isArray(sourceIndexes) || sourceIndexes.length !== scene.sources.length ) { throw new RangeError("sourceIndexes must correspond exactly to the selected scene sources."); } const sources = sourceIndexes.map((sourceIndex, selectedIndex) => { const sourceSummary = summary.sources[sourceIndex]; if ( !sourceSummary || sourceSummary.sourceId !== scene.sources[selectedIndex].id ) { throw new RangeError("selected acoustic path summary does not match scene source order."); } return sourceSummary; }); const expectedSelectedSignature = acousticGeometrySignature({ scene: { speedOfSound: sourceScene.speedOfSound, seed: sourceScene.seed, acoustics: sourceScene.acoustics, sources: sourceIndexes.map((sourceIndex) => sourceScene.sources[sourceIndex]), }, arrayDefinition, referenceDistance, minimumDistance, }); const selectedSignature = acousticGeometrySignature({ scene, arrayDefinition, referenceDistance, minimumDistance, }); if (selectedSignature !== expectedSelectedSignature) { throw new RangeError( "selected scene path geometry does not match the summarized source geometry.", ); } return createTrustedAcousticPathSummary({ signature: selectedSignature, sources, microphoneCount: arrayDefinition.microphones.length, }); } export function maximumAcousticDelaySeconds(options) { return summarizeAcousticPaths(options).maximumDelaySeconds; }