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) { 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 = Math.ceil(startTime * sampleRate); const endSample = Math.ceil(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 sources = []; const sourceOffsets = []; for (const source of scene.sources) { const signalStartTime = source.startTime ?? 0; const signalDuration = source.duration ?? scene.duration; const signalStartSample = Math.ceil(signalStartTime * sampleRate); const signalEndSample = Math.ceil((signalStartTime + signalDuration) * sampleRate); const activeStartSample = Math.max(startSample, signalStartSample); const activeEndSample = Math.min(endSample, signalEndSample); if (activeEndSample <= activeStartSample) continue; sources.push({ ...source, position: sourcePositionAtTime(source, sampleTime), startTime: (activeStartSample - startSample) / sampleRate, duration: (activeEndSample - activeStartSample) / sampleRate, }); sourceOffsets.push({ signalStartTime, signalStartSample }); } const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient; const signalOffsets = { sceneSampleOffset: startSample, sources: sourceOffsets, ambient: (scene.ambient ?? []).map(() => ({ signalStartTime: 0, signalStartSample: 0 })), }; const duration = (endSample - startSample) / sampleRate; return { startTime: alignedStartTime, endTime: alignedEndTime, sampleTime, startSample, endSample, sampleCount: endSample - startSample, duration, signalOffsets, scene: { ...scene, duration, 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)); } return windows; }