Files
micarrsim/packages/acoustics-core/environment.js
T

245 lines
8.2 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;
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 maximumAcousticDelaySeconds(options) {
const { scene, arrayDefinition, referenceDistance, minimumDistance } = options;
let maximum = 0;
for (const source of scene.sources) {
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) maximum = Math.max(maximum, path.delaySeconds);
}
}
return maximum;
}