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

291 lines
10 KiB
JavaScript

import {
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
selectAcousticPathSummary,
summarizeAcousticPaths,
} from "./environment.js";
import { ceilSampleIndex } from "./math.js";
import { effectiveSignalDurationSeconds } from "./signal.js";
function copyPosition(position) {
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
throw new TypeError("source position must be a finite [x, y, z] array.");
}
return [position[0], position[1], position[2]];
}
function requireFiniteTime(time, label = "time") {
if (!Number.isFinite(time)) throw new TypeError(label + " must be finite.");
}
/**
* Resolve a source position at an absolute time measured from the beginning of
* the scene. Valid trajectories are ordered by the scene contract. Values
* outside their covered interval hold the nearest endpoint position.
*/
export function sourcePositionAtTime(source, time) {
requireFiniteTime(time);
const trajectory = source?.trajectory;
if (!Array.isArray(trajectory) || trajectory.length === 0) {
return copyPosition(source?.position);
}
const first = trajectory[0];
const last = trajectory[trajectory.length - 1];
if (time <= first.time) return copyPosition(first.position);
if (time >= last.time) return copyPosition(last.position);
let low = 0;
let high = trajectory.length - 1;
while (high - low > 1) {
const middle = (low + high) >> 1;
if (trajectory[middle].time <= time) low = middle;
else high = middle;
}
const left = trajectory[low];
const right = trajectory[high];
const ratio = (time - left.time) / (right.time - left.time);
return left.position.map((coordinate, index) =>
coordinate + (right.position[index] - coordinate) * ratio,
);
}
/**
* Return a new shallow scene/source graph whose source positions are resolved
* at one instant. Trajectories stay attached so callers can sample the returned
* scene again; the input scene and source objects are never mutated.
*/
export function sceneAtTime(scene, time) {
requireFiniteTime(time);
if (!scene || !Array.isArray(scene.sources)) {
throw new TypeError("scene.sources must be an array.");
}
return {
...scene,
sources: scene.sources.map((source) => ({
...source,
position: sourcePositionAtTime(source, time),
})),
};
}
/**
* Approximate a moving scene as stationary during [startTime, endTime] by
* sampling every trajectory at the window midpoint.
*/
export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {}) {
requireFiniteTime(startTime, "startTime");
requireFiniteTime(endTime, "endTime");
if (startTime < 0 || endTime <= startTime) {
throw new RangeError("window must satisfy 0 <= startTime < endTime.");
}
if (Number.isFinite(scene?.duration) && endTime > scene.duration) {
throw new RangeError("window endTime must not exceed scene.duration.");
}
const sampleRate = scene?.sampleRate;
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
const sampleTime = startTime + (endTime - startTime) / 2;
return {
startTime,
endTime,
sampleTime,
duration: endTime - startTime,
scene: sceneAtTime(scene, sampleTime),
};
}
// Ceil selects the first sample whose timestamp is inside each half-open
// boundary. Adjacent windows therefore share an exact boundary without a
// duplicated or omitted sample, even when their requested times are not
// integer multiples of the sample period.
const startSample = ceilSampleIndex(startTime, sampleRate);
const endSample = ceilSampleIndex(endTime, sampleRate);
if (endSample <= startSample) {
throw new RangeError("window must contain at least one sample.");
}
const alignedStartTime = startSample / sampleRate;
const alignedEndTime = endSample / sampleRate;
const sampleTime = (startSample + endSample) / (2 * sampleRate);
const sampledScene = sceneAtTime(scene, sampleTime);
const pathSummary = options.arrayDefinition
? summarizeAcousticPaths({
scene: sampledScene,
arrayDefinition: options.arrayDefinition,
referenceDistance: options.referenceDistance ?? 1,
minimumDistance: options.minimumDistance ?? 0.01,
maximumPathEvaluations:
options.maximumPathEvaluations ??
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
})
: undefined;
let maximumPropagationDelaySeconds = options.maximumPropagationDelaySeconds;
if (maximumPropagationDelaySeconds === undefined) {
maximumPropagationDelaySeconds = pathSummary?.maximumDelaySeconds ?? 0;
}
if (
!Number.isFinite(maximumPropagationDelaySeconds) ||
maximumPropagationDelaySeconds < 0
) {
throw new RangeError("maximumPropagationDelaySeconds must be finite and non-negative.");
}
const requestedPreRollSamples = ceilSampleIndex(
maximumPropagationDelaySeconds,
sampleRate,
);
const requiredPreRollSamples = pathSummary
? ceilSampleIndex(pathSummary.maximumDelaySeconds, sampleRate)
: 0;
if (pathSummary && requestedPreRollSamples < requiredPreRollSamples) {
throw new RangeError(
"maximumPropagationDelaySeconds is shorter than an active acoustic path delay.",
);
}
const renderMaximumPropagationDelaySeconds = pathSummary
? pathSummary.sources.reduce(
(maximum, sourceSummary, sourceIndex) =>
sampledScene.sources[sourceIndex].signal.type === "tone"
? maximum
: Math.max(maximum, sourceSummary.maximumDelaySeconds),
0,
)
: maximumPropagationDelaySeconds;
const preRollSamples = ceilSampleIndex(
renderMaximumPropagationDelaySeconds,
sampleRate,
);
const renderStartSample = Math.max(0, startSample - preRollSamples);
const renderDuration = (endSample - renderStartSample) / sampleRate;
const sources = [];
const sourceOffsets = [];
const sourceIndexes = [];
for (let sourceIndex = 0; sourceIndex < scene.sources.length; sourceIndex += 1) {
const source = scene.sources[sourceIndex];
const signalStartTime = source.startTime ?? 0;
const signalDuration = effectiveSignalDurationSeconds(
source,
scene,
options.assetBuffers,
);
const signalStartSample = ceilSampleIndex(signalStartTime, sampleRate);
const signalEndSample = ceilSampleIndex(signalStartTime + signalDuration, sampleRate);
const activeStartSample = Math.max(renderStartSample, signalStartSample);
const activeEndSample = Math.min(endSample, signalEndSample);
const sourceMaximumDelaySeconds =
pathSummary?.sources[sourceIndex]?.maximumDelaySeconds ??
maximumPropagationDelaySeconds;
const latestPossibleArrivalEndSample = ceilSampleIndex(
signalStartTime + signalDuration + sourceMaximumDelaySeconds,
sampleRate,
);
const analyticTailMayOverlap =
source.signal.type === "tone" &&
signalStartSample < endSample &&
latestPossibleArrivalEndSample > startSample;
if (activeEndSample <= activeStartSample && !analyticTailMayOverlap) continue;
const hasEmissionInRender = activeEndSample > activeStartSample;
sources.push({
...source,
position: sampledScene.sources[sourceIndex].position,
startTime: hasEmissionInRender
? (activeStartSample - renderStartSample) / sampleRate
: 0,
duration: hasEmissionInRender
? (activeEndSample - activeStartSample) / sampleRate
: renderDuration,
});
sourceOffsets.push({
signalStartTime,
signalStartSample,
signalDuration,
});
sourceIndexes.push(sourceIndex);
}
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
const windowScene = {
...scene,
duration: renderDuration,
sources,
...(Array.isArray(scene.ambient) ? { ambient } : {}),
};
const selectedPathSummary = pathSummary
? selectAcousticPathSummary({
summary: pathSummary,
sourceIndexes,
sourceScene: sampledScene,
scene: windowScene,
arrayDefinition: options.arrayDefinition,
referenceDistance: options.referenceDistance ?? 1,
minimumDistance: options.minimumDistance ?? 0.01,
})
: undefined;
const signalOffsets = {
sceneSampleOffset: renderStartSample,
cropStartSample: startSample - renderStartSample,
cropSampleCount: endSample - startSample,
maximumPropagationDelaySeconds,
renderMaximumPropagationDelaySeconds,
sourcePositionSampleTime: sampleTime,
windowingModel: "quasi-static-midpoint-propagation-preroll",
...(selectedPathSummary
? { acousticPathSummary: selectedPathSummary }
: {}),
sources: sourceOffsets,
ambient: (scene.ambient ?? []).map(() => ({
signalStartTime: 0,
signalStartSample: 0,
signalDuration: scene.duration,
})),
};
const duration = (endSample - startSample) / sampleRate;
return {
startTime: alignedStartTime,
endTime: alignedEndTime,
sampleTime,
startSample,
endSample,
sampleCount: endSample - startSample,
duration,
renderStartSample,
renderDuration,
preRollSamples: startSample - renderStartSample,
maximumPropagationDelaySeconds,
renderMaximumPropagationDelaySeconds,
signalOffsets,
scene: windowScene,
};
}
/**
* Split a scene interval into quasi-static windows. The final window is
* shortened to the requested end time instead of being dropped.
*/
export function createQuasiStaticWindows(scene, options = {}) {
const startTime = options.startTime ?? 0;
const endTime = options.endTime ?? scene?.duration;
const windowDuration = options.windowDuration;
const hopDuration = options.hopDuration ?? windowDuration;
requireFiniteTime(startTime, "startTime");
requireFiniteTime(endTime, "endTime");
requireFiniteTime(windowDuration, "windowDuration");
requireFiniteTime(hopDuration, "hopDuration");
if (startTime < 0 || endTime <= startTime) {
throw new RangeError("interval must satisfy 0 <= startTime < endTime.");
}
if (windowDuration <= 0 || hopDuration <= 0) {
throw new RangeError("windowDuration and hopDuration must be positive.");
}
if (Number.isFinite(scene?.duration) && endTime > scene.duration) {
throw new RangeError("endTime must not exceed scene.duration.");
}
const windows = [];
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
const windowEnd = Math.min(windowStart + windowDuration, endTime);
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd, options));
}
return windows;
}