diff --git a/packages/acoustics-core/index.js b/packages/acoustics-core/index.js index 1f0a9c1..5085ad4 100644 --- a/packages/acoustics-core/index.js +++ b/packages/acoustics-core/index.js @@ -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"; diff --git a/packages/acoustics-core/trajectory.js b/packages/acoustics-core/trajectory.js new file mode 100644 index 0000000..0e8481e --- /dev/null +++ b/packages/acoustics-core/trajectory.js @@ -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; +} diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js index 6e28724..a69bf2c 100644 --- a/packages/contracts/validation.js +++ b/packages/contracts/validation.js @@ -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; diff --git a/schemas/scene.schema.json b/schemas/scene.schema.json index 5a8c9b2..2cf008a 100644 --- a/schemas/scene.schema.json +++ b/schemas/scene.schema.json @@ -31,6 +31,13 @@ "type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }], "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": { "type": "object", "additionalProperties": false, "required": ["assetId", "widthPixels", "heightPixels", "plane"], @@ -80,6 +87,10 @@ "properties": { "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, "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 }, "duration": { "type": "number", "exclusiveMinimum": 0 }, "signal": { "$ref": "#/$defs/signal" } } diff --git a/tests/unit/acoustics.test.js b/tests/unit/acoustics.test.js index ce2a88f..7881958 100644 --- a/tests/unit/acoustics.test.js +++ b/tests/unit/acoustics.test.js @@ -10,6 +10,11 @@ import { renderSceneMix } from "/packages/acoustics-core/mixdown.js"; import { whiteNoiseAt } from "/packages/acoustics-core/noise.js"; import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.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"; function assertNear(actual, expected, tolerance, label) { @@ -39,6 +44,73 @@ function createArray(columns, rows, width, height) { 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와 구면 감쇠 기본식을 계산한다", () => { assertNear(distance3([0, 0, 0], [1, 2, 2]), 3, 1e-12, "distance"); assertNear(dbToLinear(-6.020599913), 0.5, 1e-9, "dB amplitude"); diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js index 1005f02..de94e64 100644 --- a/tests/unit/contracts.test.js +++ b/tests/unit/contracts.test.js @@ -43,6 +43,28 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () = 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 () => { const arrayDefinition = { schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m",