import { sceneAtTime } from "/packages/acoustics-core/trajectory.js"; export const DEFAULT_TARGET_MAP_HZ = 8; export const DEFAULT_MAX_ANALYSIS_FRAMES = 120; function requirePositiveFinite(value, label) { if (!Number.isFinite(value) || value <= 0) { throw new RangeError(`${label} must be a positive finite number.`); } return value; } function requirePositiveInteger(value, label) { if (!Number.isInteger(value) || value <= 0) { throw new RangeError(`${label} must be a positive integer.`); } return value; } function requireScene(scene) { if (!scene || typeof scene !== "object") { throw new TypeError("scene must be an object."); } requirePositiveFinite(scene.duration, "scene.duration"); requirePositiveFinite(scene.sampleRate, "scene.sampleRate"); if (!Array.isArray(scene.sources)) { throw new TypeError("scene.sources must be an array."); } return scene; } function requireFramePlan(plan) { if (!plan || typeof plan !== "object" || !Array.isArray(plan.frames) || plan.frames.length === 0) { throw new TypeError("plan.frames must be a non-empty array."); } requirePositiveFinite(plan.duration, "plan.duration"); let previousSampleTime = -Infinity; plan.frames.forEach((frame, index) => { if ( !frame || !Number.isFinite(frame.startTime) || !Number.isFinite(frame.endTime) || !Number.isFinite(frame.sampleTime) || frame.startTime < 0 || frame.endTime <= frame.startTime || frame.endTime > plan.duration || frame.sampleTime < frame.startTime || frame.sampleTime > frame.endTime || frame.sampleTime < previousSampleTime ) { throw new RangeError(`plan.frames[${index}] contains an invalid time range.`); } previousSampleTime = frame.sampleTime; }); return plan; } function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); } /** * Return whether at least one source has a usable trajectory. This is a * feature check only; scene contract validation remains the caller's job. */ export function sceneHasTrajectories(scene) { return Array.isArray(scene?.sources) && scene.sources.some( (source) => Array.isArray(source?.trajectory) && source.trajectory.length >= 2, ); } /** * Build a serializable, deterministic set of analysis windows for a scene. * * Frames are distributed from the beginning to the end of the scene. The * windows are wide enough to avoid gaps, never shorter than one FFT when the * scene itself permits it, and capped so precomputation cannot grow without * bound. A shorter-than-FFT scene produces one full-scene frame which the * signal/STFT layer can zero-pad. */ export function createAnalysisFramePlan(scene, options = {}) { requireScene(scene); const fftSize = requirePositiveInteger(options.fftSize, "fftSize"); const targetMapHz = requirePositiveFinite( options.targetMapHz ?? DEFAULT_TARGET_MAP_HZ, "targetMapHz", ); const maxFrames = requirePositiveInteger( options.maxFrames ?? DEFAULT_MAX_ANALYSIS_FRAMES, "maxFrames", ); const requestedWindowDuration = options.windowDuration === undefined ? 1 / targetMapHz : requirePositiveFinite(options.windowDuration, "windowDuration"); const duration = scene.duration; const minimumWindowDuration = fftSize / scene.sampleRate; const desiredFrameCount = Math.max(1, Math.ceil(duration * targetMapHz)); let frameCount = Math.min(desiredFrameCount, maxFrames); let windowDuration = Math.min( duration, Math.max(minimumWindowDuration, requestedWindowDuration, duration / frameCount), ); // More than one full-scene window would produce duplicate analysis frames. if (windowDuration >= duration) { frameCount = 1; windowDuration = duration; } const frames = []; if (frameCount === 1) { frames.push({ index: 0, startTime: 0, endTime: duration, sampleTime: duration / 2, duration, }); } else { const firstSampleTime = windowDuration / 2; const lastSampleTime = duration - windowDuration / 2; const sampleInterval = (lastSampleTime - firstSampleTime) / (frameCount - 1); for (let index = 0; index < frameCount; index += 1) { const sampleTime = index === frameCount - 1 ? lastSampleTime : firstSampleTime + sampleInterval * index; let startTime = index === 0 ? 0 : sampleTime - windowDuration / 2; if (index > 0 && startTime > frames[index - 1].endTime) { startTime = frames[index - 1].endTime; } const endTime = index === frameCount - 1 ? duration : startTime + windowDuration; frames.push({ index, startTime, endTime, sampleTime: startTime + (endTime - startTime) / 2, duration: endTime - startTime, }); } } const frameInterval = frameCount > 1 ? (frames[frameCount - 1].sampleTime - frames[0].sampleTime) / (frameCount - 1) : duration; return { duration, sampleRate: scene.sampleRate, fftSize, targetMapHz, maxFrames, minimumWindowDuration, windowDuration, frameInterval, effectiveMapHz: frameInterval > 0 ? 1 / frameInterval : 0, frameCount, zeroPaddingRequired: duration < minimumWindowDuration, frames, }; } /** * Select the precomputed frames surrounding an arbitrary playback time. * `progress` is the blend amount from the left frame to the right frame, while * `frameIndex` identifies the nearest single frame for renderers that do not * blend heatmaps. */ export function playbackFrameAtTime(plan, time) { requireFramePlan(plan); if (!Number.isFinite(time)) throw new TypeError("time must be finite."); const clampedTime = clamp(time, 0, plan.duration); const { frames } = plan; const lastIndex = frames.length - 1; if (frames.length === 1 || clampedTime <= frames[0].sampleTime) { return { time: clampedTime, sceneProgress: clampedTime / plan.duration, leftFrameIndex: 0, rightFrameIndex: 0, frameIndex: 0, framePosition: 0, progress: 0, }; } if (clampedTime >= frames[lastIndex].sampleTime) { return { time: clampedTime, sceneProgress: clampedTime / plan.duration, leftFrameIndex: lastIndex, rightFrameIndex: lastIndex, frameIndex: lastIndex, framePosition: lastIndex, progress: 0, }; } let low = 0; let high = lastIndex; while (high - low > 1) { const middle = (low + high) >> 1; if (frames[middle].sampleTime <= clampedTime) low = middle; else high = middle; } if (clampedTime === frames[low].sampleTime) { return { time: clampedTime, sceneProgress: clampedTime / plan.duration, leftFrameIndex: low, rightFrameIndex: low, frameIndex: low, framePosition: low, progress: 0, }; } const span = frames[high].sampleTime - frames[low].sampleTime; const progress = (clampedTime - frames[low].sampleTime) / span; return { time: clampedTime, sceneProgress: clampedTime / plan.duration, leftFrameIndex: low, rightFrameIndex: high, frameIndex: progress < 0.5 ? low : high, framePosition: low + progress, progress, }; } /** * Resolve moving sources at playback time while also returning the map-frame * blend metadata needed by the renderer. Inputs are not mutated. */ export function scenePlaybackState(scene, plan, time) { requireScene(scene); const playback = playbackFrameAtTime(plan, time); if (Math.abs(scene.duration - plan.duration) > Number.EPSILON * Math.max(1, scene.duration)) { throw new RangeError("scene.duration must match plan.duration."); } const sampledScene = sceneAtTime(scene, playback.time); return { ...playback, scene: sampledScene, sourcePositions: sampledScene.sources.map((source) => ({ sourceId: source.id, position: [...source.position], })), }; }