feat: plan bounded live timelines
This commit is contained in:
@@ -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],
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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