Files
micarrsim/apps/acoustic-camera/timeline.js
T
2026-08-28 16:02:03 +09:00

251 lines
7.8 KiB
JavaScript

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],
})),
};
}