782 lines
31 KiB
JavaScript
782 lines
31 KiB
JavaScript
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")));
|
||
});
|