506 lines
16 KiB
JavaScript
506 lines
16 KiB
JavaScript
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;
|
|
}
|