feat: plan bounded live timelines

This commit is contained in:
Codex
2026-08-28 16:02:03 +09:00
parent a7befa0270
commit acb36bf15d
2 changed files with 405 additions and 0 deletions
+250
View File
@@ -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],
})),
};
}
+155
View File
@@ -1,3 +1,10 @@
import {
createAnalysisFramePlan,
playbackFrameAtTime,
sceneHasTrajectories,
scenePlaybackState,
} from "/apps/acoustic-camera/timeline.js";
import { import {
alignExperimentToScene, alignExperimentToScene,
canonicalStringify, 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 () => { test("Camera input hash is canonical across object key order", async () => {
const first = { const first = {
arrayDefinition: { arrayDefinition: {