diff --git a/apps/acoustic-camera/timeline.js b/apps/acoustic-camera/timeline.js new file mode 100644 index 0000000..9adb1b3 --- /dev/null +++ b/apps/acoustic-camera/timeline.js @@ -0,0 +1,250 @@ +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], + })), + }; +} diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js index 8f9492b..0c08985 100644 --- a/tests/unit/camera.test.js +++ b/tests/unit/camera.test.js @@ -1,3 +1,10 @@ +import { + createAnalysisFramePlan, + playbackFrameAtTime, + sceneHasTrajectories, + scenePlaybackState, +} from "/apps/acoustic-camera/timeline.js"; + import { alignExperimentToScene, canonicalStringify, @@ -15,6 +22,154 @@ function assertNear(actual, expected, tolerance, label) { ); } +function assertThrows(callback, expectedText, label) { + let error; + try { + callback(); + } catch (caught) { + error = caught; + } + assert(error instanceof Error, `${label}: expected an error`); + assert(error.message.includes(expectedText), `${label}: unexpected message ${error.message}`); +} + +test("Camera detects moving source trajectories", () => { + assertEqual(sceneHasTrajectories(null), false, "missing scene"); + assertEqual(sceneHasTrajectories({ sources: [{ id: "fixed", position: [0, 0, 2] }] }), false, "fixed scene"); + assertEqual(sceneHasTrajectories({ + sources: [{ + id: "moving", + position: [0, 0, 2], + trajectory: [ + { time: 0, position: [0, 0, 2] }, + { time: 1, position: [1, 0, 2] }, + ], + }], + }), true, "moving scene"); +}); + +test("Camera analysis plan covers a scene and respects its frame budget", () => { + const scene = { duration: 2, sampleRate: 1000, sources: [] }; + const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 }; + const first = createAnalysisFramePlan(scene, options); + const second = createAnalysisFramePlan(scene, options); + + assertEqual(first.frameCount, 3, "frame count is capped"); + assertNear(first.minimumWindowDuration, 0.1, 1e-12, "minimum FFT window"); + assertNear(first.windowDuration, 2 / 3, 1e-12, "windows grow to cover capped timeline"); + assertEqual(first.frames[0].startTime, 0, "first frame begins at zero"); + assertEqual(first.frames.at(-1).endTime, 2, "last frame reaches scene end"); + first.frames.forEach((frame, index) => { + assertEqual(frame.index, index, `frame ${index} index`); + assertNear(frame.sampleTime, (frame.startTime + frame.endTime) / 2, 1e-12, `frame ${index} midpoint`); + if (index > 0) { + assert(frame.startTime <= first.frames[index - 1].endTime, `frame ${index} leaves no timeline gap`); + } + }); + assertEqual(JSON.stringify(second), JSON.stringify(first), "frame planning is deterministic"); +}); + +test("Camera analysis windows obey FFT and short-scene constraints", () => { + const fftLimited = createAnalysisFramePlan( + { duration: 1, sampleRate: 1000, sources: [] }, + { fftSize: 400, targetMapHz: 10, maxFrames: 100 }, + ); + assertEqual(fftLimited.frameCount, 10, "requested timeline frames"); + assertNear(fftLimited.windowDuration, 0.4, 1e-12, "FFT-sized window"); + assertEqual(fftLimited.frames[0].startTime, 0, "overlapping first window"); + assertEqual(fftLimited.frames.at(-1).endTime, 1, "overlapping last window"); + assert(fftLimited.frames.every((frame) => frame.duration >= 0.4 - 1e-12), "no window is shorter than one FFT"); + + const shortScene = createAnalysisFramePlan( + { duration: 0.05, sampleRate: 1000, sources: [] }, + { fftSize: 100, targetMapHz: 100, maxFrames: 50 }, + ); + assertEqual(shortScene.frameCount, 1, "short scene avoids duplicate frames"); + assertEqual(shortScene.windowDuration, 0.05, "window is bounded by scene duration"); + assertEqual(shortScene.zeroPaddingRequired, true, "short scene reports FFT padding requirement"); + assertNear(shortScene.frames[0].sampleTime, 0.025, 1e-12, "short scene midpoint"); +}); + +test("Camera playback selects and interpolates bounded analysis frames", () => { + const plan = createAnalysisFramePlan( + { duration: 1, sampleRate: 1000, sources: [] }, + { fftSize: 100, targetMapHz: 4, maxFrames: 10 }, + ); + const middle = playbackFrameAtTime(plan, 0.5); + assertEqual(middle.leftFrameIndex, 1, "middle left frame"); + assertEqual(middle.rightFrameIndex, 2, "middle right frame"); + assertEqual(middle.frameIndex, 2, "nearest frame resolves midpoint ties forward"); + assertNear(middle.progress, 0.5, 1e-12, "middle interpolation progress"); + assertNear(middle.framePosition, 1.5, 1e-12, "fractional frame position"); + + const before = playbackFrameAtTime(plan, -10); + assertEqual(before.time, 0, "negative playback time is clamped"); + assertEqual(before.frameIndex, 0, "negative playback selects first frame"); + const after = playbackFrameAtTime(plan, 10); + assertEqual(after.time, 1, "late playback time is clamped"); + assertEqual(after.frameIndex, 3, "late playback selects last frame"); + assertEqual(after.sceneProgress, 1, "late playback progress"); +}); + +test("Camera playback resolves moving positions without mutating the scene", () => { + const movingSource = { + id: "moving", + position: [99, 99, 99], + trajectory: [ + { time: 0, position: [0, 0, 2] }, + { time: 1, position: [4, 2, 2] }, + ], + }; + const fixedSource = { id: "fixed", position: [-1, 1, 3] }; + const scene = { duration: 1, sampleRate: 1000, sources: [movingSource, fixedSource] }; + const plan = createAnalysisFramePlan(scene, { fftSize: 100, targetMapHz: 4 }); + const state = scenePlaybackState(scene, plan, 0.25); + + assertNear(state.scene.sources[0].position[0], 1, 1e-12, "moving x"); + assertNear(state.scene.sources[0].position[1], 0.5, 1e-12, "moving y"); + assertEqual(state.scene.sources[1].position.join(","), "-1,1,3", "fixed source"); + assertEqual(state.sourcePositions[0].sourceId, "moving", "position metadata source id"); + assertEqual(state.sourcePositions[0].position.join(","), "1,0.5,2", "position metadata value"); + assertEqual(movingSource.position.join(","), "99,99,99", "input source remains unchanged"); + assert(state.scene !== scene && state.scene.sources[0] !== movingSource, "sampled scene is immutable"); +}); + +test("Camera timeline rejects non-finite and invalid planning inputs", () => { + const scene = { duration: 1, sampleRate: 1000, sources: [] }; + assertThrows( + () => createAnalysisFramePlan({ ...scene, duration: Number.NaN }, { fftSize: 100 }), + "scene.duration", + "NaN duration", + ); + assertThrows( + () => createAnalysisFramePlan(scene, { fftSize: Number.POSITIVE_INFINITY }), + "fftSize", + "infinite FFT size", + ); + assertThrows( + () => createAnalysisFramePlan(scene, { fftSize: 100, targetMapHz: 0 }), + "targetMapHz", + "zero map rate", + ); + assertThrows( + () => createAnalysisFramePlan(scene, { fftSize: 100, maxFrames: 1.5 }), + "maxFrames", + "fractional frame limit", + ); + assertThrows( + () => createAnalysisFramePlan(scene, { fftSize: 100, windowDuration: Number.NaN }), + "windowDuration", + "NaN analysis window", + ); + const plan = createAnalysisFramePlan(scene, { fftSize: 100 }); + assertThrows(() => playbackFrameAtTime(plan, Number.NaN), "time", "NaN playback time"); + assertThrows( + () => scenePlaybackState({ ...scene, duration: 2 }, plan, 0.5), + "scene.duration", + "scene and plan mismatch", + ); +}); + test("Camera input hash is canonical across object key order", async () => { const first = { arrayDefinition: {