feat: define moving source trajectories

This commit is contained in:
Codex
2026-08-28 15:48:16 +09:00
parent b678d1f9c7
commit a7befa0270
6 changed files with 253 additions and 10 deletions
+1
View File
@@ -8,4 +8,5 @@ export * from "./noise.js";
export * from "./propagation.js";
export * from "./signal.js";
export * from "./stft.js";
export * from "./trajectory.js";
export * from "./wav.js";
+116
View File
@@ -0,0 +1,116 @@
function copyPosition(position) {
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
throw new TypeError("source position must be a finite [x, y, z] array.");
}
return [position[0], position[1], position[2]];
}
function requireFiniteTime(time, label = "time") {
if (!Number.isFinite(time)) throw new TypeError(label + " must be finite.");
}
/**
* Resolve a source position at an absolute time measured from the beginning of
* the scene. Valid trajectories are ordered by the scene contract. Values
* outside their covered interval hold the nearest endpoint position.
*/
export function sourcePositionAtTime(source, time) {
requireFiniteTime(time);
const trajectory = source?.trajectory;
if (!Array.isArray(trajectory) || trajectory.length === 0) {
return copyPosition(source?.position);
}
const first = trajectory[0];
const last = trajectory[trajectory.length - 1];
if (time <= first.time) return copyPosition(first.position);
if (time >= last.time) return copyPosition(last.position);
let low = 0;
let high = trajectory.length - 1;
while (high - low > 1) {
const middle = (low + high) >> 1;
if (trajectory[middle].time <= time) low = middle;
else high = middle;
}
const left = trajectory[low];
const right = trajectory[high];
const ratio = (time - left.time) / (right.time - left.time);
return left.position.map((coordinate, index) =>
coordinate + (right.position[index] - coordinate) * ratio,
);
}
/**
* Return a new shallow scene/source graph whose source positions are resolved
* at one instant. Trajectories stay attached so callers can sample the returned
* scene again; the input scene and source objects are never mutated.
*/
export function sceneAtTime(scene, time) {
requireFiniteTime(time);
if (!scene || !Array.isArray(scene.sources)) {
throw new TypeError("scene.sources must be an array.");
}
return {
...scene,
sources: scene.sources.map((source) => ({
...source,
position: sourcePositionAtTime(source, time),
})),
};
}
/**
* Approximate a moving scene as stationary during [startTime, endTime] by
* sampling every trajectory at the window midpoint.
*/
export function sampleQuasiStaticWindow(scene, startTime, endTime) {
requireFiniteTime(startTime, "startTime");
requireFiniteTime(endTime, "endTime");
if (startTime < 0 || endTime <= startTime) {
throw new RangeError("window must satisfy 0 <= startTime < endTime.");
}
if (Number.isFinite(scene?.duration) && endTime > scene.duration) {
throw new RangeError("window endTime must not exceed scene.duration.");
}
const sampleTime = startTime + (endTime - startTime) / 2;
return {
startTime,
endTime,
sampleTime,
duration: endTime - startTime,
scene: sceneAtTime(scene, sampleTime),
};
}
/**
* Split a scene interval into quasi-static windows. The final window is
* shortened to the requested end time instead of being dropped.
*/
export function createQuasiStaticWindows(scene, options = {}) {
const startTime = options.startTime ?? 0;
const endTime = options.endTime ?? scene?.duration;
const windowDuration = options.windowDuration;
const hopDuration = options.hopDuration ?? windowDuration;
requireFiniteTime(startTime, "startTime");
requireFiniteTime(endTime, "endTime");
requireFiniteTime(windowDuration, "windowDuration");
requireFiniteTime(hopDuration, "hopDuration");
if (startTime < 0 || endTime <= startTime) {
throw new RangeError("interval must satisfy 0 <= startTime < endTime.");
}
if (windowDuration <= 0 || hopDuration <= 0) {
throw new RangeError("windowDuration and hopDuration must be positive.");
}
if (Number.isFinite(scene?.duration) && endTime > scene.duration) {
throw new RangeError("endTime must not exceed scene.duration.");
}
const windows = [];
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
const windowEnd = Math.min(windowStart + windowDuration, endTime);
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd));
}
return windows;
}
+31 -10
View File
@@ -232,6 +232,22 @@ function validateSceneRelationships(value) {
if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
requireString(source.name, `${path}.name`, issues);
requirePosition(source.position, `${path}.position`, issues);
if (Array.isArray(source.trajectory)) {
let previousTime = -Infinity;
source.trajectory.forEach((keyframe, keyframeIndex) => {
const keyframePath = `${path}.trajectory[${keyframeIndex}]`;
if (!requireRecord(keyframe, keyframePath, issues)) return;
const timeValid = requireFinite(keyframe.time, `${keyframePath}.time`, issues, {
min: 0,
max: sceneDurationValid ? value.duration : 300,
});
requirePosition(keyframe.position, `${keyframePath}.position`, issues);
if (timeValid && keyframe.time <= previousTime) {
addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다.");
}
if (timeValid) previousTime = keyframe.time;
});
}
requireFinite(source.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
const startValid = requireFinite(source.startTime, `${path}.startTime`, issues, { min: 0 });
const durationValid = requireFinite(source.duration, `${path}.duration`, issues, {
@@ -336,16 +352,21 @@ function validateExperimentRelationships(value, context = {}) {
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
let maximumDelaySamples = 0;
for (const source of context.scene.sources) {
for (const microphone of context.arrayDefinition.microphones) {
const distance = Math.hypot(
source.position[0] - microphone.position[0],
source.position[1] - microphone.position[1],
source.position[2] - microphone.position[2],
);
maximumDelaySamples = Math.max(
maximumDelaySamples,
(distance / context.scene.speedOfSound) * context.scene.sampleRate,
);
const sourcePositions = Array.isArray(source.trajectory)
? source.trajectory.map((keyframe) => keyframe.position)
: [source.position];
for (const sourcePosition of sourcePositions) {
for (const microphone of context.arrayDefinition.microphones) {
const distance = Math.hypot(
sourcePosition[0] - microphone.position[0],
sourcePosition[1] - microphone.position[1],
sourcePosition[2] - microphone.position[2],
);
maximumDelaySamples = Math.max(
maximumDelaySamples,
(distance / context.scene.speedOfSound) * context.scene.sampleRate,
);
}
}
}
let delayFftSize = 1;