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
+65
View File
@@ -61,6 +61,43 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
});
test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.acoustics = {
model: "shoebox",
room: { min: [-2, -2, -1], max: [2, 2, 3] },
reflections: { order: 1, wallGainDb: -8, scattering: 0.2 },
diffuseReverb: { enabled: true, rt60Seconds: 0.35, gainDb: -20, tapCount: 12 },
occluders: [{
id: "screen",
min: [-0.2, -0.2, 0.8],
max: [0.2, 0.2, 1],
transmissionDb: -18,
diffractionGainDb: -12,
}],
};
assertEqual((await validateScene(scene)).length, 0, "valid shoebox scene");
scene.acoustics.room.max[0] = scene.acoustics.room.min[0];
let issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.acoustics.room"), "unordered room bounds");
scene.acoustics.room.max[0] = 2;
scene.acoustics.occluders[0].max[2] = 4;
issues = await validateScene(scene);
assert(
issues.some(({ path, message }) => path.includes("occluders[0]") && message.includes("room")),
"occluder outside room",
);
scene.acoustics = {
model: "free-field",
room: { min: [-2, -2, -1], max: [2, 2, 3] },
};
issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.acoustics"), "unused free-field settings");
});
test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
@@ -125,6 +162,32 @@ test("중복 마이크 id를 거부한다", async () => {
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
});
test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름차순이어야 한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
arrayDefinition.microphones[0].frequencyResponse = [
{ frequencyHz: 20, gainDb: -3 },
{ frequencyHz: 1000, gainDb: 1.5, phaseDeg: 12 },
{ frequencyHz: 20_000, gainDb: -2 },
];
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "valid response curve");
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1000;
let issues = await validateArrayDefinition(arrayDefinition);
assert(
issues.some(({ path, message }) =>
path === "$.microphones[0].frequencyResponse[2].frequencyHz" &&
message.includes("엄격히 증가")),
"duplicate frequency is rejected with sorting guidance",
);
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1_000_001;
issues = await validateArrayDefinition(arrayDefinition);
assert(
issues.some(({ path }) => path === "$.microphones[0].frequencyResponse[2].frequencyHz"),
"globally bounded frequency is enforced without a Scene Nyquist dependency",
);
});
test("Nyquist를 넘는 tone을 거부한다", async () => {
const scene = {
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
@@ -156,11 +219,13 @@ test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한
scene.unexpected = true;
delete experiment.beamforming.normalization;
experiment.beamforming.dynamicRangeDb = 4;
experiment.beamforming.compensateFrequencyResponse = "false";
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse")));
});
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {