251 lines
7.8 KiB
JavaScript
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],
|
|
})),
|
|
};
|
|
}
|