Files
micarrsim/tests/unit/contracts.test.js
T

782 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
validateArrayDefinition,
validateExperiment,
validateResult,
validateScene,
} from "/packages/contracts/validation.js";
import {
describeFrequencyAliasing,
observeFrequencyBand,
} from "/packages/acoustics-core/sampling.js";
import {
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
estimateSynthesisWorkingSet,
} from "/packages/acoustics-core/propagation.js";
async function loadJson(path) {
const response = await fetch(path);
assert(response.ok, `${path} load failed`);
return response.json();
}
function resultFrequencyObservation(scene, experiment, band, equivalentBandIds = []) {
const observation = observeFrequencyBand(
band,
scene.sampleRate,
experiment.stft.fftSize,
);
return {
model: observation.model,
sampleRate: observation.sampleRate,
nyquistHz: observation.nyquistHz,
fftSize: observation.fftSize,
binWidthHz: observation.binWidthHz,
requestedMinHz: observation.requestedMinHz,
requestedMaxHz: observation.requestedMaxHz,
aliased: observation.aliased,
crossesFirstNyquist: observation.crossesFirstNyquist,
localizationInputModel: observation.localizationInputModel,
selectionModel: observation.selectionModel,
localizationModel:
experiment.beamforming?.algorithm === "gcc-phat"
? "band-limited-gcc-phat-geometric-lag"
: "spherical-steering-at-positive-adc-bin-frequency",
originalCarrierUsed: observation.originalCarrierUsed,
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
foldBoundaryCount: observation.foldBoundaryCount,
continuousManyToOne: observation.continuousManyToOne,
gridManyToOne: observation.gridManyToOne,
foldSegments: observation.foldSegments.map((segment) => ({ ...segment })),
adcBins: [...observation.bins],
adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })),
adcBinSetKey: observation.adcBinSetKey,
equivalentBandIds,
};
}
function resultSampling(scene) {
const toneObservations = [];
for (const [scope, entries] of [
["source", scene.sources ?? []],
["ambient", scene.ambient ?? []],
]) {
for (const entry of entries) {
if (entry.signal?.type !== "tone") continue;
const observation = describeFrequencyAliasing(
entry.signal.frequency,
scene.sampleRate,
);
toneObservations.push({
scope,
id: entry.id,
analogFrequencyHz: observation.analogFrequencyHz,
adcFrequencyHz: observation.adcFrequencyHz,
signedAdcFrequencyHz: observation.signedAdcFrequencyHz,
aliased: observation.aliased,
spectralInversion: observation.spectralInversion,
dcEdge: observation.dcEdge,
nyquistEdge: observation.nyquistEdge,
phaseDegenerate: observation.phaseDegenerate,
});
}
}
return {
model: "ideal-real-sampler-no-antialias-filter",
sampleRate: scene.sampleRate,
nyquistHz: scene.sampleRate / 2,
toneObservations,
};
}
const RECTANGULAR_ARRAY_EXAMPLES = [
{ id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 },
{ id: "rectangular-4x3-450mm", microphoneCount: 12, columns: 4, rows: 3, width: 0.45, height: 0.45 },
{ id: "rectangular-8x6-450mm", microphoneCount: 48, columns: 8, rows: 6, width: 0.45, height: 0.45 },
{ id: "rectangular-16x12", microphoneCount: 192, columns: 16, rows: 12, width: 0.45, height: 0.45 },
{ id: "rectangular-24x16-450mm", microphoneCount: 384, columns: 24, rows: 16, width: 0.45, height: 0.45 },
{ id: "rectangular-16x12-compact", microphoneCount: 192, columns: 16, rows: 12, width: 0.12, height: 0.12 },
{ id: "rectangular-16x12-large", microphoneCount: 192, columns: 16, rows: 12, width: 1.2, height: 1.2 },
];
test("moving-tone sample Scene and Experiment satisfy their linked contracts", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/moving-tone.scene.json"),
loadJson("/assets/sample-scenes/moving-tone.experiment.json"),
]);
const sceneIssues = await validateScene(scene);
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
assertEqual(sceneIssues.length, 0, `moving Scene issues: ${JSON.stringify(sceneIssues)}`);
assertEqual(experimentIssues.length, 0, `moving Experiment issues: ${JSON.stringify(experimentIssues)}`);
assertEqual(experiment.sceneId, scene.id, "moving example linkage");
assert(
scene.sources.some((source) => Array.isArray(source.trajectory) && source.trajectory.length >= 2),
"moving example contains a usable trajectory",
);
});
test("192채널 기준 배열이 계약을 통과한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
assertEqual(arrayDefinition.name, "16x12 rectangular baseline", "fixture encoding");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues");
});
test("rectangular array examples cover low-to-high channel counts and compact-to-wide apertures", async () => {
const arrays = await Promise.all(RECTANGULAR_ARRAY_EXAMPLES.map(({ id }) =>
loadJson(`/assets/array-geometries/${id}.array.json`)));
for (let index = 0; index < arrays.length; index += 1) {
const arrayDefinition = arrays[index];
const expected = RECTANGULAR_ARRAY_EXAMPLES[index];
assertEqual(arrayDefinition.id, expected.id, `${expected.id} fixture id`);
assertEqual(arrayDefinition.microphones.length, expected.microphoneCount, `${expected.id} channel count`);
assertEqual(arrayDefinition.aperture.width, expected.width, `${expected.id} aperture width`);
assertEqual(arrayDefinition.aperture.height, expected.height, `${expected.id} aperture height`);
assertEqual(
(await validateArrayDefinition(arrayDefinition)).length,
0,
`${expected.id} contract issues`,
);
assertEqual(
new Set(arrayDefinition.microphones.map(({ id }) => id)).size,
expected.microphoneCount,
`${expected.id} microphone ids are unique`,
);
assertEqual(
new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size,
expected.microphoneCount,
`${expected.id} positions are unique`,
);
const xCoordinates = arrayDefinition.microphones.map(({ position }) => position[0]);
const yCoordinates = arrayDefinition.microphones.map(({ position }) => position[1]);
assert(
arrayDefinition.microphones.every(({ position }) => position[2] === 0),
`${expected.id} microphones stay on the z=0 plane`,
);
assertEqual(new Set(xCoordinates).size, expected.columns, `${expected.id} column count`);
assertEqual(new Set(yCoordinates).size, expected.rows, `${expected.id} row count`);
const uniqueX = [...new Set(xCoordinates)].sort((left, right) => left - right);
const uniqueY = [...new Set(yCoordinates)].sort((left, right) => left - right);
const expectedXPitch = expected.width / (expected.columns - 1);
const expectedYPitch = expected.height / (expected.rows - 1);
assert(
uniqueX.slice(1).every((value, position) =>
Math.abs(value - uniqueX[position] - expectedXPitch) < 1.1e-6),
`${expected.id} uniform x pitch`,
);
assert(
uniqueY.slice(1).every((value, position) =>
Math.abs(value - uniqueY[position] - expectedYPitch) < 1.1e-6),
`${expected.id} uniform y pitch`,
);
assert(Math.abs(Math.min(...xCoordinates) + expected.width / 2) < 1e-6, `${expected.id} x minimum`);
assert(Math.abs(Math.max(...xCoordinates) - expected.width / 2) < 1e-6, `${expected.id} x maximum`);
assert(Math.abs(Math.min(...yCoordinates) + expected.height / 2) < 1e-6, `${expected.id} y minimum`);
assert(Math.abs(Math.max(...yCoordinates) - expected.height / 2) < 1e-6, `${expected.id} y maximum`);
}
const channelSeries = arrays.slice(0, 5);
assert(
channelSeries.every(({ aperture }) => aperture.width === 0.45 && aperture.height === 0.45),
"channel-count series keeps the 0.45 m aperture fixed",
);
assertEqual(
channelSeries.map(({ microphones }) => microphones.length).join(","),
"4,12,48,192,384",
"channel-count series",
);
const apertureSeries = [
arrays.find(({ id }) => id === "rectangular-16x12-compact"),
arrays.find(({ id }) => id === "rectangular-16x12"),
arrays.find(({ id }) => id === "rectangular-16x12-large"),
];
assert(
apertureSeries.every(({ microphones }) => microphones.length === 192),
"aperture series keeps 192 channels fixed",
);
assertEqual(
apertureSeries.map(({ aperture }) => aperture.width).join(","),
"0.12,0.45,1.2",
"compact-to-large aperture series",
);
assertEqual(
Math.max(...arrays.map(({ microphones }) => microphones.length)),
384,
"high-count example",
);
});
test("192채널 Fermat spiral 비교 배열이 계약과 원형 aperture를 만족한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/fermat-spiral-192.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "spiral microphone count");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "spiral array issues");
const maximumRadius = Math.max(
...arrayDefinition.microphones.map(({ position }) => Math.hypot(position[0], position[1])),
);
assert(maximumRadius <= 0.225001, `spiral aperture radius: ${maximumRadius}`);
assertEqual(
new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size,
192,
"spiral positions are unique",
);
});
test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
assertEqual((await validateScene(scene)).length, 0, "scene issues");
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
});
test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => {
const [arrayDefinition, scene, baseline] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
for (const algorithm of ["das", "das-phat", "gcc-phat"]) {
const experiment = structuredClone(baseline);
experiment.beamforming.algorithm = algorithm;
experiment.beamforming.backend = algorithm === "gcc-phat" ? "cpu" : "auto";
assertEqual(
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
0,
`${algorithm} experiment issues`,
);
}
const incompatible = structuredClone(baseline);
incompatible.beamforming.algorithm = "gcc-phat";
incompatible.beamforming.backend = "webgpu";
let issues = await validateExperiment(incompatible, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.beamforming.backend"),
"GCC-PHAT rejects the WebGPU backend",
);
incompatible.beamforming.backend = "cpu";
issues = await validateExperiment(incompatible, {
arrayDefinition: { ...arrayDefinition, microphones: arrayDefinition.microphones.slice(0, 1) },
scene,
});
assert(
issues.some(({ path }) => path === "$.beamforming.algorithm"),
"GCC-PHAT requires two microphones",
);
incompatible.beamforming.algorithm = "music";
issues = await validateExperiment(incompatible, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.beamforming.algorithm"),
"unsupported algorithms are rejected",
);
});
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"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
assertEqual(experiment.evaluation.detectionThresholdRatio, 0.25, "sample threshold ratio");
assertEqual(
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
0,
"0.25 threshold issues",
);
experiment.evaluation.detectionThresholdRatio = 0;
let issues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(
issues.some(({ path, message }) =>
path === "$.evaluation.detectionThresholdRatio" && message.includes("0보다 크고 1 이하")),
"zero threshold is rejected with actionable guidance",
);
experiment.evaluation.detectionThresholdRatio = 1.01;
issues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.evaluation.detectionThresholdRatio"),
"threshold above one is rejected",
);
});
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",
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
aperture: { width: 1, height: 1 },
microphones: [
{ id: "mic-1", position: [0, 0, 0] },
{ id: "mic-1", position: [0.1, 0, 0] },
],
};
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을 ideal ADC alias 실험으로 허용한다", async () => {
const scene = {
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "source", name: "source", position: [0, 0, 1], gainDb: 0, startTime: 0, duration: 1,
signal: { type: "tone", frequency: 5000 },
}],
};
assertEqual((await validateScene(scene)).length, 0, "above-Nyquist tone is structurally valid");
scene.sources[0].signal.frequency = 1_000_001;
assert(
(await validateScene(scene)).some(({ path }) =>
path === "$.sources[0].signal.frequency"),
"tone above the explicit numerical model range is rejected, not silently rounded",
);
});
test("Experiment band는 Nyquist 위에서도 허용하되 FFT bin center가 하나는 있어야 한다", async () => {
const [scene, experiment] = await Promise.all([
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
experiment.frequencyBands[0].minHz = 24_900;
experiment.frequencyBands[0].maxHz = 25_100;
assertEqual(
(await validateExperiment(experiment, { scene })).length,
0,
"above-Nyquist band with folded bin centers is valid",
);
experiment.frequencyBands[0].minHz = 1_000.1;
experiment.frequencyBands[0].maxHz = 1_000.2;
let issues = await validateExperiment(experiment, { scene });
assert(
issues.some(({ path, message }) =>
path === "$.frequencyBands[0]" && message.includes("bin center")),
"a sub-bin band fails validation before runtime",
);
experiment.frequencyBands[0].minHz = 999_900;
experiment.frequencyBands[0].maxHz = 1_000_001;
issues = await validateExperiment(experiment, { scene });
assert(
issues.some(({ path }) => path === "$.frequencyBands[0].maxHz"),
"band above the explicit numerical model range is rejected",
);
experiment.frequencyBands = {};
issues = await validateExperiment(experiment, { scene });
assert(
issues.some(({ path }) => path === "$.frequencyBands"),
"malformed frequencyBands returns validation issues instead of throwing",
);
});
test("analytic tone-only Scene은 존재하지 않는 delay FFT 메모리로 거부하지 않는다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 1);
scene.sampleRate = 192_000;
scene.duration = 30;
scene.sources = Array.from({ length: 32 }, (_, index) => ({
...structuredClone(scene.sources[0]),
id: `tone-${index + 1}`,
name: `Tone ${index + 1}`,
duration: 30,
}));
const issues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(
!issues.some(({ message }) => message.includes("512 MiB")),
`analytic tones must not allocate sampled source/FFT buffers: ${JSON.stringify(issues)}`,
);
});
test("Experiment preflight는 runtime과 같은 source-streaming shoebox delay working set을 쓴다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 16);
scene.sampleRate = 192_000;
scene.duration = 4_000_000 / scene.sampleRate;
scene.sources = [{
...structuredClone(scene.sources[0]),
id: "long-sampled-source",
name: "Long sampled source",
position: [0, 0, 1],
startTime: 0,
duration: scene.duration,
signal: { type: "noise", color: "white", seedOffset: 1 },
}];
scene.ambient = [];
scene.acoustics = {
model: "shoebox",
room: { min: [-10, -10, -10], max: [10, 10, 10] },
reflections: { order: 0, wallGainDb: -12, scattering: 0 },
diffuseReverb: {
enabled: true,
rt60Seconds: 4,
gainDb: -18,
tapCount: 1,
},
occluders: [],
};
experiment.beamforming.minimumDistance = 0.01;
const workingSet = estimateSynthesisWorkingSet({
scene,
arrayDefinition,
minimumDistance: experiment.beamforming.minimumDistance,
});
assertEqual(workingSet.sampleCount, 4_000_000, "fixture sample count");
assertEqual(
workingSet.maximumTransient.fftSize,
8_388_608,
"diffuse delay crosses the FFT power-of-two boundary",
);
assert(
workingSet.estimatedBufferBytes > DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
"shared runtime estimate exceeds 512 MiB",
);
const issues = await validateExperiment(experiment, {
arrayDefinition,
scene,
});
assert(
issues.some(({ message }) =>
message.includes("source-streaming") && message.includes("512 MiB")),
`validator must reject the same shared estimate: ${JSON.stringify(issues)}`,
);
scene.sources[0].signal = { type: "tone", frequency: 1_000, phaseDeg: 0 };
const toneWorkingSet = estimateSynthesisWorkingSet({
scene,
arrayDefinition,
minimumDistance: experiment.beamforming.minimumDistance,
});
assertEqual(
toneWorkingSet.estimatedBufferBytes,
toneWorkingSet.channelBytes,
"analytic tone has no sampled FFT transient",
);
const toneIssues = await validateExperiment(experiment, {
arrayDefinition,
scene,
});
assert(
!toneIssues.some(({ message }) => message.includes("512 MiB")),
"validator does not charge analytic tone for a nonexistent delay FFT",
);
});
test("NaN 좌표를 거부한다", async () => {
const arrayDefinition = {
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m",
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
aperture: { width: 1, height: 1 }, microphones: [{ id: "mic-1", position: [Number.NaN, 0, 0] }],
};
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("[0]")));
});
test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
arrayDefinition.microphones[0].phaseOffsetDeg = 999;
scene.unexpected = true;
delete experiment.beamforming.normalization;
experiment.beamforming.dynamicRangeDb = 4;
experiment.beamforming.compensateFrequencyResponse = "false";
experiment.beamforming.maxCompensationDb = 121;
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")));
assert(experimentIssues.some(({ path }) => path.endsWith("maxCompensationDb")));
});
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.assets = [
{ id: "audio", kind: "audio", fileName: "../outside.wav", mimeType: "audio/wav" },
{ id: "image", kind: "image", fileName: "machine.png", mimeType: "image/png" },
];
scene.image = {
assetId: "audio",
widthPixels: 600,
heightPixels: 400,
plane: { z: 2, xMin: -0.6, xMax: 0.6, yMin: -0.4, yMax: 0.4 },
};
scene.sources[0].signal = { type: "asset", assetId: "image" };
const issues = await validateScene(scene);
assert(issues.some(({ path }) => path.endsWith("fileName")));
assertEqual(issues.filter(({ message }) => message.includes("asset을 참조")).length, 2);
});
test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", async () => {
const [scene, experiment] = await Promise.all([
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
const result = {
schemaVersion: "1.1.0",
kind: "micarrsim.result",
experimentId: experiment.id,
arrayId: experiment.arrayId,
sceneId: experiment.sceneId,
createdAt: "2026-08-28T00:00:00.000Z",
backend: "cpu",
algorithm: "das",
inputHash: "a".repeat(64),
signalStats: {
preNormalizationPeak: 0.5,
appliedScale: 1,
clippedSampleCount: 0,
sampling: resultSampling(scene),
},
timingMs: {
total: 10,
signalGeneration: 3,
localization: 7,
beamforming: 7,
},
bands: [{
id: "tone-1k",
color: "#65f2bf",
frequencyObservation: resultFrequencyObservation(
scene,
experiment,
experiment.frequencyBands[0],
),
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
peaks: [{
sourceId: "source-1",
position: [0.08, 0.04, 2],
value: 1,
localizationErrorM: 0,
beamSpread: {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
halfPowerRatio: 0.5,
thresholdValue: 0.5,
pixelCount: 1,
centroid: [0.08, 0.04, 2],
covarianceM2: { xx: 0, xy: 0, yy: 0 },
ellipse: {
axisScale: "rms",
angleReference: "counterclockwise-from-positive-x",
majorRmsRadiusM: 0,
minorRmsRadiusM: 0,
angleDeg: 0,
},
},
}],
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
}],
};
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
const mismatchedTimingResult = structuredClone(result);
mismatchedTimingResult.timingMs.localization = 8;
const mismatchedTimingIssues = await validateResult(
mismatchedTimingResult,
{ experiment, scene },
);
assert(
mismatchedTimingIssues.some(
({ path }) => path === "$.timingMs.localization",
),
"localization timing and its compatibility alias must agree",
);
const missingLocalizationTiming = structuredClone(result);
delete missingLocalizationTiming.timingMs.localization;
const missingLocalizationIssues = await validateResult(
missingLocalizationTiming,
{ experiment, scene },
);
assert(
missingLocalizationIssues.some(
({ path }) => path === "$.timingMs.localization",
),
"Result 1.1 requires generic localization timing",
);
const legacyResult = structuredClone(result);
legacyResult.schemaVersion = "1.0.0";
const legacyIssues = await validateResult(legacyResult, {
experiment,
scene,
});
assert(
legacyIssues.some(({ path }) => path === "$.schemaVersion"),
"sampling-aware Result validation must reject legacy 1.0 implicitly",
);
const impossibleBackendResult = structuredClone(result);
impossibleBackendResult.algorithm = "gcc-phat";
impossibleBackendResult.backend = "webgpu";
const impossibleBackendIssues = await validateResult(
impossibleBackendResult,
);
assert(
impossibleBackendIssues.some(({ path }) => path === "$.backend"),
"standalone Result rejects the unsupported GCC-PHAT WebGPU combination",
);
const malformedResult = structuredClone(result);
delete malformedResult.bands[0].id;
malformedResult.bands[0].processedMaps = {};
const malformedIssues = await validateResult(malformedResult, {
experiment,
scene,
});
assert(
malformedIssues.length > 0,
"malformed Result fields return validation issues instead of throwing",
);
experiment.beamforming.algorithm = "das-phat";
result.algorithm = "das-phat";
result.bands[0].rawMap.scale = "score";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-maximum-score-weighted-covariance";
assertEqual(
(await validateResult(result, { experiment, scene })).length,
0,
"phase-weighted score result issues",
);
result.bands[0].rawMap.scale = "power";
let issues = await validateResult(result, { experiment, scene });
assert(
issues.some(({ path }) => path.endsWith("rawMap.scale")),
"algorithm and raw map scale must agree",
);
result.bands[0].rawMap.scale = "score";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-power-power-weighted-covariance";
issues = await validateResult(result, { experiment, scene });
assert(
issues.some(({ path }) => path.endsWith("beamSpread.method")),
"score map and beam spread method must agree",
);
result.bands[0].peaks[0].beamSpread.method =
"connected-half-maximum-score-weighted-covariance";
result.algorithm = "das";
issues = await validateResult(result, { experiment, scene });
assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment");
experiment.beamforming.algorithm = "das";
result.bands[0].rawMap.scale = "power";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-power-power-weighted-covariance";
result.bands[0].rawMap.values.pop();
result.bands[0].rawMap.scale = "normalized";
result.bands[0].peaks[0].sourceId = "missing-source";
issues = await validateResult(result, { experiment, scene });
assert(issues.some(({ message }) => message.includes("rows × columns")));
assert(issues.some(({ path }) => path.endsWith("rawMap.scale")));
assert(issues.some(({ path }) => path.endsWith("sourceId")));
});