feat: add realistic portable localization core

This commit is contained in:
Codex
2026-08-28 18:19:31 +09:00
parent bc63ada1cd
commit d002a2bd70
20 changed files with 2345 additions and 70 deletions
+111 -2
View File
@@ -4,6 +4,8 @@ export const SCHEMA_VERSION = "1.0.0";
const MAX_MICROPHONES = 1024;
const MAX_SOURCES = 32;
const MAX_GRID_SIDE = 512;
const MAX_FREQUENCY_RESPONSE_POINTS = 64;
const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
/** @typedef {{ path: string, message: string }} ValidationIssue */
@@ -65,6 +67,28 @@ function requirePosition(value, path, issues) {
return true;
}
function requireBounds(value, path, issues) {
if (!requireRecord(value, path, issues)) return false;
const minimumValid = requirePosition(value.min, path + ".min", issues);
const maximumValid = requirePosition(value.max, path + ".max", issues);
if (!minimumValid || !maximumValid) return false;
let ordered = true;
for (let axis = 0; axis < 3; axis += 1) {
if (value.min[axis] >= value.max[axis]) {
addIssue(issues, path, "min 좌표는 모든 축에서 max 좌표보다 작아야 합니다.");
ordered = false;
break;
}
}
return ordered;
}
function boundsContain(bounds, position) {
return position.every(
(coordinate, axis) => coordinate >= bounds.min[axis] && coordinate <= bounds.max[axis],
);
}
function validateHeader(value, expectedKind, issues) {
if (!requireRecord(value, "$", issues)) return false;
if (value.schemaVersion !== SCHEMA_VERSION) {
@@ -131,6 +155,37 @@ function validateArrayRelationships(value) {
if (microphone.sensitivityDb !== undefined) {
requireFinite(microphone.sensitivityDb, `${path}.sensitivityDb`, issues, { min: -120, max: 120 });
}
if (microphone.frequencyResponse !== undefined) {
const responsePath = `${path}.frequencyResponse`;
if (!Array.isArray(microphone.frequencyResponse)) {
addIssue(issues, responsePath, "주파수 응답점 배열이어야 합니다.");
} else {
if (microphone.frequencyResponse.length < 2 || microphone.frequencyResponse.length > MAX_FREQUENCY_RESPONSE_POINTS) {
addIssue(issues, responsePath, `주파수 응답점은 2~${MAX_FREQUENCY_RESPONSE_POINTS}개여야 합니다.`);
}
let previousFrequency = -Infinity;
microphone.frequencyResponse.forEach((point, pointIndex) => {
const pointPath = `${responsePath}[${pointIndex}]`;
if (!requireRecord(point, pointPath, issues)) return;
const frequencyValid = requireFinite(point.frequencyHz, `${pointPath}.frequencyHz`, issues, {
min: 0,
max: MAX_FREQUENCY_RESPONSE_HZ,
});
requireFinite(point.gainDb, `${pointPath}.gainDb`, issues, { min: -120, max: 120 });
if (point.phaseDeg !== undefined) {
requireFinite(point.phaseDeg, `${pointPath}.phaseDeg`, issues, { min: -180, max: 180 });
}
if (frequencyValid && point.frequencyHz <= previousFrequency) {
addIssue(
issues,
`${pointPath}.frequencyHz`,
"주파수 응답점은 frequencyHz가 중복 없이 엄격히 증가하도록 정렬하세요.",
);
}
if (frequencyValid) previousFrequency = point.frequencyHz;
});
}
}
});
for (const duplicate of findDuplicates(ids)) {
addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`);
@@ -178,6 +233,44 @@ function validateSceneRelationships(value) {
requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 });
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
let acousticRoom = null;
if (value.acoustics !== undefined && requireRecord(value.acoustics, "$.acoustics", issues)) {
const acoustics = value.acoustics;
if (acoustics.model === "free-field") {
const unusedFields = ["room", "reflections", "diffuseReverb", "occluders"]
.filter((key) => acoustics[key] !== undefined);
if (unusedFields.length > 0) {
addIssue(
issues,
"$.acoustics",
"free-field 모델에서는 사용되지 않는 설정을 제거하세요: " + unusedFields.join(", "),
);
}
} else if (acoustics.model === "shoebox") {
if (acoustics.room === undefined) {
addIssue(issues, "$.acoustics.room", "shoebox 모델에는 room 경계가 필요합니다.");
} else if (requireBounds(acoustics.room, "$.acoustics.room", issues)) {
acousticRoom = acoustics.room;
}
}
if (Array.isArray(acoustics.occluders)) {
const occluderIds = [];
acoustics.occluders.forEach((occluder, index) => {
const path = "$.acoustics.occluders[" + index + "]";
if (!requireRecord(occluder, path, issues)) return;
if (requireString(occluder.id, path + ".id", issues)) occluderIds.push(occluder.id);
if (requireBounds(occluder, path, issues) && acousticRoom) {
if (!boundsContain(acousticRoom, occluder.min) || !boundsContain(acousticRoom, occluder.max)) {
addIssue(issues, path, "occluder는 room 경계 안에 있어야 합니다.");
}
}
});
for (const duplicate of findDuplicates(occluderIds)) {
addIssue(issues, "$.acoustics.occluders", "중복 occluder id: " + duplicate);
}
}
}
const assets = Array.isArray(value.assets) ? value.assets : [];
const assetsById = new Map();
const allIds = [];
@@ -231,7 +324,10 @@ function validateSceneRelationships(value) {
if (!requireRecord(source, path, issues)) return;
if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
requireString(source.name, `${path}.name`, issues);
requirePosition(source.position, `${path}.position`, issues);
const sourcePositionValid = requirePosition(source.position, `${path}.position`, issues);
if (sourcePositionValid && acousticRoom && !boundsContain(acousticRoom, source.position)) {
addIssue(issues, `${path}.position`, "shoebox room 경계 안에 있어야 합니다.");
}
if (Array.isArray(source.trajectory)) {
let previousTime = -Infinity;
source.trajectory.forEach((keyframe, keyframeIndex) => {
@@ -241,7 +337,10 @@ function validateSceneRelationships(value) {
min: 0,
max: sceneDurationValid ? value.duration : 300,
});
requirePosition(keyframe.position, `${keyframePath}.position`, issues);
const keyframePositionValid = requirePosition(keyframe.position, `${keyframePath}.position`, issues);
if (keyframePositionValid && acousticRoom && !boundsContain(acousticRoom, keyframe.position)) {
addIssue(issues, `${keyframePath}.position`, "shoebox room 경계 안에 있어야 합니다.");
}
if (timeValid && keyframe.time <= previousTime) {
addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다.");
}
@@ -346,6 +445,16 @@ function validateExperimentRelationships(value, context = {}) {
max: 10,
exclusiveMin: true,
});
if (
value.beamforming.compensateFrequencyResponse !== undefined &&
typeof value.beamforming.compensateFrequencyResponse !== "boolean"
) {
addIssue(
issues,
"$.beamforming.compensateFrequencyResponse",
"주파수 응답 보정 사용 여부는 true 또는 false여야 합니다.",
);
}
}
if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
const threshold = value.evaluation.detectionThresholdRatio;