fix: expose physical aliasing and delayed arrivals
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user