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; }