feat: plan bounded live timelines
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user