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 "./propagation.js";
export * from "./signal.js"; export * from "./signal.js";
export * from "./stft.js"; export * from "./stft.js";
export * from "./trajectory.js";
export * from "./wav.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); if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
requireString(source.name, `${path}.name`, issues); requireString(source.name, `${path}.name`, issues);
requirePosition(source.position, `${path}.position`, 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 }); requireFinite(source.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
const startValid = requireFinite(source.startTime, `${path}.startTime`, issues, { min: 0 }); const startValid = requireFinite(source.startTime, `${path}.startTime`, issues, { min: 0 });
const durationValid = requireFinite(source.duration, `${path}.duration`, issues, { 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); const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
let maximumDelaySamples = 0; let maximumDelaySamples = 0;
for (const source of context.scene.sources) { for (const source of context.scene.sources) {
for (const microphone of context.arrayDefinition.microphones) { const sourcePositions = Array.isArray(source.trajectory)
const distance = Math.hypot( ? source.trajectory.map((keyframe) => keyframe.position)
source.position[0] - microphone.position[0], : [source.position];
source.position[1] - microphone.position[1], for (const sourcePosition of sourcePositions) {
source.position[2] - microphone.position[2], for (const microphone of context.arrayDefinition.microphones) {
); const distance = Math.hypot(
maximumDelaySamples = Math.max( sourcePosition[0] - microphone.position[0],
maximumDelaySamples, sourcePosition[1] - microphone.position[1],
(distance / context.scene.speedOfSound) * context.scene.sampleRate, sourcePosition[2] - microphone.position[2],
); );
maximumDelaySamples = Math.max(
maximumDelaySamples,
(distance / context.scene.speedOfSound) * context.scene.sampleRate,
);
}
} }
} }
let delayFftSize = 1; let delayFftSize = 1;
+11
View File
@@ -31,6 +31,13 @@
"type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }], "type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"items": false, "minItems": 3, "maxItems": 3 "items": false, "minItems": 3, "maxItems": 3
}, },
"trajectoryKeyframe": {
"type": "object", "additionalProperties": false, "required": ["time", "position"],
"properties": {
"time": { "type": "number", "minimum": 0 },
"position": { "$ref": "#/$defs/position" }
}
},
"image": { "image": {
"type": "object", "additionalProperties": false, "type": "object", "additionalProperties": false,
"required": ["assetId", "widthPixels", "heightPixels", "plane"], "required": ["assetId", "widthPixels", "heightPixels", "plane"],
@@ -80,6 +87,10 @@
"properties": { "properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 }, "position": { "$ref": "#/$defs/position" }, "name": { "type": "string", "minLength": 1, "maxLength": 120 }, "position": { "$ref": "#/$defs/position" },
"trajectory": {
"type": "array", "minItems": 2, "maxItems": 4096,
"items": { "$ref": "#/$defs/trajectoryKeyframe" }
},
"gainDb": { "type": "number", "minimum": -120, "maximum": 24 }, "startTime": { "type": "number", "minimum": 0 }, "gainDb": { "type": "number", "minimum": -120, "maximum": 24 }, "startTime": { "type": "number", "minimum": 0 },
"duration": { "type": "number", "exclusiveMinimum": 0 }, "signal": { "$ref": "#/$defs/signal" } "duration": { "type": "number", "exclusiveMinimum": 0 }, "signal": { "$ref": "#/$defs/signal" }
} }
+72
View File
@@ -10,6 +10,11 @@ import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js"; import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { getFrameCount } from "/packages/acoustics-core/stft.js"; import { getFrameCount } from "/packages/acoustics-core/stft.js";
import {
createQuasiStaticWindows,
sceneAtTime,
sourcePositionAtTime,
} from "/packages/acoustics-core/trajectory.js";
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js"; import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
function assertNear(actual, expected, tolerance, label) { function assertNear(actual, expected, tolerance, label) {
@@ -39,6 +44,73 @@ function createArray(columns, rows, width, height) {
return { id: "test-array", aperture: { width, height }, microphones }; return { id: "test-array", aperture: { width, height }, microphones };
} }
test("trajectory 위치를 선형 보간하고 시간창 중앙에서 준정적으로 샘플링한다", () => {
const source = {
position: [9, 8, 7],
trajectory: [
{ time: 0.2, position: [-1, 0, 2] },
{ time: 0.6, position: [1, 2, 4] },
{ time: 1, position: [3, 0, 2] },
],
};
assertEqual(sourcePositionAtTime({ position: [4, 5, 6] }, 0.5).join(","), "4,5,6", "fixed fallback");
assertEqual(sourcePositionAtTime(source, 0).join(","), "-1,0,2", "before first keyframe");
assertEqual(sourcePositionAtTime(source, 1.5).join(","), "3,0,2", "after last keyframe");
const interpolated = sourcePositionAtTime(source, 0.4);
assertNear(interpolated[0], 0, 1e-12, "interpolated x");
assertNear(interpolated[1], 1, 1e-12, "interpolated y");
assertNear(interpolated[2], 3, 1e-12, "interpolated z");
const scene = { duration: 1, sources: [source] };
const sampled = sceneAtTime(scene, 0.4);
assert(sampled !== scene && sampled.sources[0] !== source, "sceneAtTime is immutable");
assertEqual(source.position.join(","), "9,8,7", "original fallback position is unchanged");
assertEqual(sampled.sources[0].trajectory, source.trajectory, "trajectory is preserved");
assertNear(sampled.sources[0].position[0], 0, 1e-12, "sampled scene x");
assertNear(sampled.sources[0].position[1], 1, 1e-12, "sampled scene y");
assertNear(sampled.sources[0].position[2], 3, 1e-12, "sampled scene z");
const windows = createQuasiStaticWindows(scene, { windowDuration: 0.4, hopDuration: 0.4 });
assertEqual(windows.length, 3, "partial final window");
assertNear(windows[0].sampleTime, 0.2, 1e-12, "first midpoint");
assertNear(windows[1].sampleTime, 0.6, 1e-12, "second midpoint");
assertNear(windows[2].sampleTime, 0.9, 1e-12, "partial midpoint");
assertNear(windows[2].duration, 0.2, 1e-12, "partial duration");
assertNear(windows[1].scene.sources[0].position[0], 1, 1e-12, "window x");
assertNear(windows[1].scene.sources[0].position[1], 2, 1e-12, "window y");
assertNear(windows[1].scene.sources[0].position[2], 4, 1e-12, "window z");
});
test("trajectory가 없는 sceneAtTime snapshot은 기존 합성 결과와 sample 단위로 동일하다", () => {
const scene = {
sampleRate: 8000,
duration: 0.01,
speedOfSound: 343,
seed: 17,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "fixed", name: "fixed", position: [0.1, -0.2, 1.2], gainDb: -12, startTime: 0, duration: 0.01,
signal: { type: "tone", frequency: 750, phaseDeg: 35 },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [
{ id: "left", position: [-0.1, 0, 0] },
{ id: "right", position: [0.1, 0, 0], sensitivityDb: -1, phaseOffsetDeg: 3 },
],
};
const baseline = synthesizeMicrophoneSignals({ scene, arrayDefinition });
const sampled = synthesizeMicrophoneSignals({ scene: sceneAtTime(scene, 0.007), arrayDefinition });
assertEqual(sampled.appliedScale, baseline.appliedScale, "applied scale");
assertEqual(sampled.preNormalizationPeak, baseline.preNormalizationPeak, "pre-normalization peak");
baseline.channels.forEach((channel, channelIndex) => {
channel.forEach((value, sampleIndex) => {
assertEqual(sampled.channels[channelIndex][sampleIndex], value, `channel ${channelIndex} sample ${sampleIndex}`);
});
});
});
test("거리, dB와 구면 감쇠 기본식을 계산한다", () => { test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
assertNear(distance3([0, 0, 0], [1, 2, 2]), 3, 1e-12, "distance"); assertNear(distance3([0, 0, 0], [1, 2, 2]), 3, 1e-12, "distance");
assertNear(dbToLinear(-6.020599913), 0.5, 1e-9, "dB amplitude"); assertNear(dbToLinear(-6.020599913), 0.5, 1e-9, "dB amplitude");
+22
View File
@@ -43,6 +43,28 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues"); assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
}); });
test("이동 음원 trajectory의 keyframe 순서와 장면 시간 범위를 검증한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.sources[0].trajectory = [
{ time: 0, position: [-0.2, 0, 2] },
{ time: 0.125, position: [0, 0.1, 2] },
{ time: 0.25, position: [0.2, 0, 2] },
];
assertEqual((await validateScene(scene)).length, 0, "valid trajectory issues");
scene.sources[0].trajectory[2].time = 0.125;
let issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory[2].time"), "strictly ordered times");
scene.sources[0].trajectory[2].time = 0.3;
issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory[2].time"), "scene duration bound");
scene.sources[0].trajectory = [{ time: 0, position: [0, 0, 2] }];
issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory"), "two-keyframe minimum");
});
test("중복 마이크 id를 거부한다", async () => { test("중복 마이크 id를 거부한다", async () => {
const arrayDefinition = { const arrayDefinition = {
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m", schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m",