fix: expose physical aliasing and delayed arrivals

This commit is contained in:
2026-08-29 10:21:33 +09:00
parent e048ccbadc
commit de0053eae3
38 changed files with 3086 additions and 214 deletions
+220 -5
View File
@@ -4,6 +4,10 @@ import {
validateResult,
validateScene,
} from "/packages/contracts/validation.js";
import {
describeFrequencyAliasing,
observeFrequencyBand,
} from "/packages/acoustics-core/sampling.js";
async function loadJson(path) {
const response = await fetch(path);
@@ -11,6 +15,75 @@ async function loadJson(path) {
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 },
@@ -329,7 +402,7 @@ test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름
);
});
test("Nyquist를 넘는 tone을 거부한다", async () => {
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 },
@@ -338,7 +411,74 @@ test("Nyquist를 넘는 tone을 거부한다", async () => {
signal: { type: "tone", frequency: 5000 },
}],
};
assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency")));
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("NaN 좌표를 거부한다", async () => {
@@ -395,7 +535,7 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
const result = {
schemaVersion: "1.0.0",
schemaVersion: "1.1.0",
kind: "micarrsim.result",
experimentId: experiment.id,
arrayId: experiment.arrayId,
@@ -404,11 +544,26 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
backend: "cpu",
algorithm: "das",
inputHash: "a".repeat(64),
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
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",
@@ -437,6 +592,66 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
};
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";