219 lines
8.0 KiB
JavaScript
219 lines
8.0 KiB
JavaScript
import { maximumAcousticDelaySeconds } from "./environment.js";
|
|
import { ceilSampleIndex } from "./math.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);
|
|
let maximumPropagationDelaySeconds = options.maximumPropagationDelaySeconds;
|
|
if (maximumPropagationDelaySeconds === undefined) {
|
|
maximumPropagationDelaySeconds = options.arrayDefinition
|
|
? maximumAcousticDelaySeconds({
|
|
scene: sampledScene,
|
|
arrayDefinition: options.arrayDefinition,
|
|
referenceDistance: options.referenceDistance ?? 1,
|
|
minimumDistance: options.minimumDistance ?? 0.01,
|
|
})
|
|
: 0;
|
|
}
|
|
if (
|
|
!Number.isFinite(maximumPropagationDelaySeconds) ||
|
|
maximumPropagationDelaySeconds < 0
|
|
) {
|
|
throw new RangeError("maximumPropagationDelaySeconds must be finite and non-negative.");
|
|
}
|
|
const preRollSamples = ceilSampleIndex(maximumPropagationDelaySeconds, sampleRate);
|
|
const renderStartSample = Math.max(0, startSample - preRollSamples);
|
|
const sources = [];
|
|
const sourceOffsets = [];
|
|
|
|
for (let sourceIndex = 0; sourceIndex < scene.sources.length; sourceIndex += 1) {
|
|
const source = scene.sources[sourceIndex];
|
|
const signalStartTime = source.startTime ?? 0;
|
|
const signalDuration = source.duration ?? scene.duration;
|
|
const signalStartSample = ceilSampleIndex(signalStartTime, sampleRate);
|
|
const signalEndSample = ceilSampleIndex(signalStartTime + signalDuration, sampleRate);
|
|
const activeStartSample = Math.max(renderStartSample, signalStartSample);
|
|
const activeEndSample = Math.min(endSample, signalEndSample);
|
|
if (activeEndSample <= activeStartSample) continue;
|
|
|
|
sources.push({
|
|
...source,
|
|
position: sampledScene.sources[sourceIndex].position,
|
|
startTime: (activeStartSample - renderStartSample) / sampleRate,
|
|
duration: (activeEndSample - activeStartSample) / sampleRate,
|
|
});
|
|
sourceOffsets.push({
|
|
signalStartTime,
|
|
signalStartSample,
|
|
signalDuration,
|
|
});
|
|
}
|
|
|
|
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
|
|
const signalOffsets = {
|
|
sceneSampleOffset: renderStartSample,
|
|
cropStartSample: startSample - renderStartSample,
|
|
cropSampleCount: endSample - startSample,
|
|
maximumPropagationDelaySeconds,
|
|
sourcePositionSampleTime: sampleTime,
|
|
windowingModel: "quasi-static-midpoint-propagation-preroll",
|
|
sources: sourceOffsets,
|
|
ambient: (scene.ambient ?? []).map(() => ({
|
|
signalStartTime: 0,
|
|
signalStartSample: 0,
|
|
signalDuration: scene.duration,
|
|
})),
|
|
};
|
|
const duration = (endSample - startSample) / sampleRate;
|
|
const renderDuration = (endSample - renderStartSample) / sampleRate;
|
|
return {
|
|
startTime: alignedStartTime,
|
|
endTime: alignedEndTime,
|
|
sampleTime,
|
|
startSample,
|
|
endSample,
|
|
sampleCount: endSample - startSample,
|
|
duration,
|
|
renderStartSample,
|
|
renderDuration,
|
|
preRollSamples: startSample - renderStartSample,
|
|
maximumPropagationDelaySeconds,
|
|
signalOffsets,
|
|
scene: {
|
|
...scene,
|
|
duration: renderDuration,
|
|
sources,
|
|
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|