1014 lines
36 KiB
JavaScript
1014 lines
36 KiB
JavaScript
import {
|
|
createAnalysisFramePlan,
|
|
playbackFrameAtTime,
|
|
sceneHasTrajectories,
|
|
scenePlaybackState,
|
|
} from "/apps/acoustic-camera/timeline.js";
|
|
import {
|
|
applyAcousticProfile,
|
|
detectAcousticProfile,
|
|
inferShoeboxRoom,
|
|
maximumSparsePathsPerReceiver,
|
|
} from "/apps/acoustic-camera/acoustic-profiles.js";
|
|
|
|
import {
|
|
alignExperimentToScene,
|
|
canonicalStringify,
|
|
createComparisonSignature,
|
|
createHistorySummary,
|
|
createResultDocument,
|
|
estimateExperimentWorkload,
|
|
hashSimulationInputs,
|
|
shiftSceneDistance,
|
|
} from "/apps/acoustic-camera/model.js";
|
|
import { validateResult } from "/packages/contracts/validation.js";
|
|
import { computeLocalizationMap } from "/packages/acoustics-core/localization.js";
|
|
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
|
|
|
|
function assertNear(actual, expected, tolerance, label) {
|
|
assert(
|
|
Math.abs(actual - expected) <= tolerance,
|
|
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
|
);
|
|
}
|
|
|
|
function assertThrows(callback, expectedText, label) {
|
|
let error;
|
|
try {
|
|
callback();
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
assert(error instanceof Error, `${label}: expected an error`);
|
|
assert(error.message.includes(expectedText), `${label}: unexpected message ${error.message}`);
|
|
}
|
|
|
|
test("Camera detects moving source trajectories", () => {
|
|
assertEqual(sceneHasTrajectories(null), false, "missing scene");
|
|
assertEqual(sceneHasTrajectories({ sources: [{ id: "fixed", position: [0, 0, 2] }] }), false, "fixed scene");
|
|
assertEqual(sceneHasTrajectories({
|
|
sources: [{
|
|
id: "moving",
|
|
position: [0, 0, 2],
|
|
trajectory: [
|
|
{ time: 0, position: [0, 0, 2] },
|
|
{ time: 1, position: [1, 0, 2] },
|
|
],
|
|
}],
|
|
}), true, "moving scene");
|
|
});
|
|
|
|
test("Camera live truth uses synthesized path arrivals instead of emission midpoint", () => {
|
|
const scene = {
|
|
sampleRate: 8_000,
|
|
duration: 1,
|
|
sources: [
|
|
{
|
|
id: "ended-tone-tail",
|
|
startTime: 0,
|
|
duration: 0.1,
|
|
signal: { type: "tone", frequency: 1_000 },
|
|
},
|
|
{
|
|
id: "ended-noise-tail",
|
|
startTime: 0,
|
|
duration: 0.1,
|
|
signal: { type: "noise", color: "white" },
|
|
},
|
|
{
|
|
id: "wrong-band-tone",
|
|
startTime: 0,
|
|
duration: 0.1,
|
|
signal: { type: "tone", frequency: 2_000 },
|
|
},
|
|
],
|
|
};
|
|
const band = { minHz: 900, maxHz: 1_100 };
|
|
const fallback = sourcesForObservedBand(scene, band, 256, { time: 0.5 });
|
|
assertEqual(fallback.length, 0, "legacy emission-time fallback sees no active source");
|
|
assertEqual(
|
|
sourcesForObservedBand(scene, band, 256, { time: 0.1 }).length,
|
|
0,
|
|
"emission-time fallback treats the exact end boundary as half-open",
|
|
);
|
|
|
|
const arrived = sourcesForObservedBand(scene, band, 256, {
|
|
time: 0.5,
|
|
arrivingSourceIds: ["ended-tone-tail", "ended-noise-tail", "wrong-band-tone"],
|
|
});
|
|
assertEqual(
|
|
arrived.map(({ id }) => id).join(","),
|
|
"ended-tone-tail,ended-noise-tail",
|
|
"path-arrival ids override time while band selection remains independent",
|
|
);
|
|
assertEqual(
|
|
sourcesForObservedBand(scene, band, 256, { arrivingSourceIds: [] }).length,
|
|
0,
|
|
"an explicit empty arrival set does not fall back to emission activity",
|
|
);
|
|
});
|
|
|
|
test("Camera analysis plan covers a scene and respects its frame budget", () => {
|
|
const scene = { duration: 2, sampleRate: 1000, sources: [] };
|
|
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
|
|
const first = createAnalysisFramePlan(scene, options);
|
|
const second = createAnalysisFramePlan(scene, options);
|
|
|
|
assertEqual(first.frameCount, 3, "frame count is capped");
|
|
assertNear(first.minimumWindowDuration, 0.1, 1e-12, "minimum FFT window");
|
|
assertNear(first.windowDuration, 2 / 3, 1e-12, "windows grow to cover capped timeline");
|
|
assertEqual(first.frames[0].startTime, 0, "first frame begins at zero");
|
|
assertEqual(first.frames.at(-1).endTime, 2, "last frame reaches scene end");
|
|
first.frames.forEach((frame, index) => {
|
|
assertEqual(frame.index, index, `frame ${index} index`);
|
|
assertNear(frame.sampleTime, (frame.startTime + frame.endTime) / 2, 1e-12, `frame ${index} midpoint`);
|
|
if (index > 0) {
|
|
assert(frame.startTime <= first.frames[index - 1].endTime, `frame ${index} leaves no timeline gap`);
|
|
}
|
|
});
|
|
assertEqual(JSON.stringify(second), JSON.stringify(first), "frame planning is deterministic");
|
|
});
|
|
|
|
test("Camera analysis windows obey FFT and short-scene constraints", () => {
|
|
const fftLimited = createAnalysisFramePlan(
|
|
{ duration: 1, sampleRate: 1000, sources: [] },
|
|
{ fftSize: 400, targetMapHz: 10, maxFrames: 100 },
|
|
);
|
|
assertEqual(fftLimited.frameCount, 10, "requested timeline frames");
|
|
assertNear(fftLimited.windowDuration, 0.4, 1e-12, "FFT-sized window");
|
|
assertEqual(fftLimited.frames[0].startTime, 0, "overlapping first window");
|
|
assertEqual(fftLimited.frames.at(-1).endTime, 1, "overlapping last window");
|
|
assert(fftLimited.frames.every((frame) => frame.duration >= 0.4 - 1e-12), "no window is shorter than one FFT");
|
|
|
|
const shortScene = createAnalysisFramePlan(
|
|
{ duration: 0.05, sampleRate: 1000, sources: [] },
|
|
{ fftSize: 100, targetMapHz: 100, maxFrames: 50 },
|
|
);
|
|
assertEqual(shortScene.frameCount, 1, "short scene avoids duplicate frames");
|
|
assertEqual(shortScene.windowDuration, 0.05, "window is bounded by scene duration");
|
|
assertEqual(shortScene.zeroPaddingRequired, true, "short scene reports FFT padding requirement");
|
|
assertNear(shortScene.frames[0].sampleTime, 0.025, 1e-12, "short scene midpoint");
|
|
});
|
|
|
|
test("Camera playback selects and interpolates bounded analysis frames", () => {
|
|
const plan = createAnalysisFramePlan(
|
|
{ duration: 1, sampleRate: 1000, sources: [] },
|
|
{ fftSize: 100, targetMapHz: 4, maxFrames: 10 },
|
|
);
|
|
const middle = playbackFrameAtTime(plan, 0.5);
|
|
assertEqual(middle.leftFrameIndex, 1, "middle left frame");
|
|
assertEqual(middle.rightFrameIndex, 2, "middle right frame");
|
|
assertEqual(middle.frameIndex, 2, "nearest frame resolves midpoint ties forward");
|
|
assertNear(middle.progress, 0.5, 1e-12, "middle interpolation progress");
|
|
assertNear(middle.framePosition, 1.5, 1e-12, "fractional frame position");
|
|
|
|
const before = playbackFrameAtTime(plan, -10);
|
|
assertEqual(before.time, 0, "negative playback time is clamped");
|
|
assertEqual(before.frameIndex, 0, "negative playback selects first frame");
|
|
const after = playbackFrameAtTime(plan, 10);
|
|
assertEqual(after.time, 1, "late playback time is clamped");
|
|
assertEqual(after.frameIndex, 3, "late playback selects last frame");
|
|
assertEqual(after.sceneProgress, 1, "late playback progress");
|
|
});
|
|
|
|
test("Camera playback resolves moving positions without mutating the scene", () => {
|
|
const movingSource = {
|
|
id: "moving",
|
|
position: [99, 99, 99],
|
|
trajectory: [
|
|
{ time: 0, position: [0, 0, 2] },
|
|
{ time: 1, position: [4, 2, 2] },
|
|
],
|
|
};
|
|
const fixedSource = { id: "fixed", position: [-1, 1, 3] };
|
|
const scene = { duration: 1, sampleRate: 1000, sources: [movingSource, fixedSource] };
|
|
const plan = createAnalysisFramePlan(scene, { fftSize: 100, targetMapHz: 4 });
|
|
const state = scenePlaybackState(scene, plan, 0.25);
|
|
|
|
assertNear(state.scene.sources[0].position[0], 1, 1e-12, "moving x");
|
|
assertNear(state.scene.sources[0].position[1], 0.5, 1e-12, "moving y");
|
|
assertEqual(state.scene.sources[1].position.join(","), "-1,1,3", "fixed source");
|
|
assertEqual(state.sourcePositions[0].sourceId, "moving", "position metadata source id");
|
|
assertEqual(state.sourcePositions[0].position.join(","), "1,0.5,2", "position metadata value");
|
|
assertEqual(movingSource.position.join(","), "99,99,99", "input source remains unchanged");
|
|
assert(state.scene !== scene && state.scene.sources[0] !== movingSource, "sampled scene is immutable");
|
|
});
|
|
|
|
test("Camera timeline rejects non-finite and invalid planning inputs", () => {
|
|
const scene = { duration: 1, sampleRate: 1000, sources: [] };
|
|
assertThrows(
|
|
() => createAnalysisFramePlan({ ...scene, duration: Number.NaN }, { fftSize: 100 }),
|
|
"scene.duration",
|
|
"NaN duration",
|
|
);
|
|
assertThrows(
|
|
() => createAnalysisFramePlan(scene, { fftSize: Number.POSITIVE_INFINITY }),
|
|
"fftSize",
|
|
"infinite FFT size",
|
|
);
|
|
assertThrows(
|
|
() => createAnalysisFramePlan(scene, { fftSize: 100, targetMapHz: 0 }),
|
|
"targetMapHz",
|
|
"zero map rate",
|
|
);
|
|
assertThrows(
|
|
() => createAnalysisFramePlan(scene, { fftSize: 100, maxFrames: 1.5 }),
|
|
"maxFrames",
|
|
"fractional frame limit",
|
|
);
|
|
assertThrows(
|
|
() => createAnalysisFramePlan(scene, { fftSize: 100, windowDuration: Number.NaN }),
|
|
"windowDuration",
|
|
"NaN analysis window",
|
|
);
|
|
const plan = createAnalysisFramePlan(scene, { fftSize: 100 });
|
|
assertThrows(() => playbackFrameAtTime(plan, Number.NaN), "time", "NaN playback time");
|
|
assertThrows(
|
|
() => scenePlaybackState({ ...scene, duration: 2 }, plan, 0.5),
|
|
"scene.duration",
|
|
"scene and plan mismatch",
|
|
);
|
|
});
|
|
|
|
test("Camera input hash is canonical across object key order", async () => {
|
|
const first = {
|
|
arrayDefinition: {
|
|
id: "array",
|
|
microphones: [
|
|
{ id: "mic-1", position: [0, 0, 0] },
|
|
{ id: "mic-2", position: [0.1, 0, 0] },
|
|
],
|
|
},
|
|
scene: {
|
|
id: "scene",
|
|
sampleRate: 8000,
|
|
sources: [{ id: "source", position: [0, 0, 2], signal: { type: "tone", frequency: 1000 } }],
|
|
},
|
|
experiment: {
|
|
id: "experiment",
|
|
scan: { z: 2, columns: 8, rows: 8 },
|
|
frequencyBands: [{ id: "band", minHz: 900, maxHz: 1100 }],
|
|
},
|
|
};
|
|
const reordered = {
|
|
experiment: {
|
|
frequencyBands: [{ maxHz: 1100, minHz: 900, id: "band" }],
|
|
scan: { rows: 8, columns: 8, z: 2 },
|
|
id: "experiment",
|
|
},
|
|
scene: {
|
|
sources: [{ signal: { frequency: 1000, type: "tone" }, position: [0, 0, 2], id: "source" }],
|
|
sampleRate: 8000,
|
|
id: "scene",
|
|
},
|
|
arrayDefinition: {
|
|
microphones: [
|
|
{ position: [0, 0, 0], id: "mic-1" },
|
|
{ position: [0.1, 0, 0], id: "mic-2" },
|
|
],
|
|
id: "array",
|
|
},
|
|
};
|
|
|
|
assertEqual(canonicalStringify({ z: undefined, b: 2, a: { d: 4, c: 3 } }), '{"a":{"c":3,"d":4},"b":2}', "canonical JSON");
|
|
const firstHash = await hashSimulationInputs(first);
|
|
const reorderedHash = await hashSimulationInputs(reordered);
|
|
assertEqual(firstHash, reorderedHash, "key ordering does not alter hash");
|
|
assert(/^[a-f0-9]{64}$/.test(firstHash), "hash is lowercase SHA-256");
|
|
|
|
reordered.scene.sources[0].position[0] = 0.01;
|
|
const changedHash = await hashSimulationInputs(reordered);
|
|
assert(changedHash !== firstHash, "semantic input change alters hash");
|
|
});
|
|
|
|
test("Camera workload counts unique overlapping FFT bins", () => {
|
|
const arrayDefinition = {
|
|
microphones: Array.from({ length: 3 }, (_, index) => ({ id: `mic-${index}`, position: [index, 0, 0] })),
|
|
};
|
|
const scene = { sampleRate: 8000, duration: 1 };
|
|
const experiment = {
|
|
scan: { columns: 10, rows: 8 },
|
|
stft: { fftSize: 256, hopSize: 128 },
|
|
frequencyBands: [
|
|
{ id: "lower", minHz: 100, maxHz: 200 },
|
|
{ id: "upper", minHz: 187.5, maxHz: 250 },
|
|
],
|
|
};
|
|
|
|
const workload = estimateExperimentWorkload(arrayDefinition, scene, experiment);
|
|
assertEqual(workload.pointCount, 80, "scan point count");
|
|
assertEqual(workload.microphoneCount, 3, "microphone count");
|
|
assertEqual(workload.frameCount, 62, "STFT frame count");
|
|
assertEqual(JSON.stringify(workload.bandBins), JSON.stringify([[4, 5, 6], [6, 7, 8]]), "bins per band");
|
|
assertEqual(workload.selectedBinCount, 5, "overlapping bin is counted once");
|
|
assertEqual(workload.stftTransformCount, 186, "STFT transform count");
|
|
assertEqual(workload.scanOperationCount, 74_400, "DAS scan operation count");
|
|
assertEqual(workload.scanOperationKind, "complex MAC", "DAS operation kind");
|
|
assertEqual(workload.complexMacCount, 74_400, "estimated complex MAC count");
|
|
assertNear(workload.scalingVs50, 80 / 2500, 1e-15, "50x50 scaling");
|
|
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
|
|
|
|
const gccExperiment = structuredClone(experiment);
|
|
gccExperiment.beamforming = { algorithm: "gcc-phat" };
|
|
const gccWorkload = estimateExperimentWorkload(arrayDefinition, scene, gccExperiment);
|
|
assertEqual(gccWorkload.algorithmLabel, "GCC-PHAT", "GCC algorithm label");
|
|
assertEqual(gccWorkload.referencePairCount, 2, "GCC reference pairs");
|
|
assertEqual(gccWorkload.bandBinSelectionCount, 6, "GCC counts overlapping band bins per band");
|
|
assertEqual(gccWorkload.gccCrossSpectrumComplexOps, 1_488, "GCC two-pass cross-spectrum ops");
|
|
assertEqual(gccWorkload.inverseFftTransformCount, 4, "GCC inverse FFT count");
|
|
assertEqual(gccWorkload.minimumBandBinCount, 3, "GCC narrowest band bin count");
|
|
assertEqual(gccWorkload.scanOperationCount, 320, "GCC pair-band scan lookups");
|
|
assertEqual(gccWorkload.scanOperationKind, "reference-pair lookup", "GCC operation kind");
|
|
assertEqual(gccWorkload.complexMacCount, 0, "GCC is not mislabeled as DAS complex MAC");
|
|
|
|
const musicExperiment = structuredClone(experiment);
|
|
musicExperiment.frequencyBands = [
|
|
{ id: "single-bin", minHz: 120, maxHz: 130 },
|
|
];
|
|
musicExperiment.beamforming = {
|
|
algorithm: "music",
|
|
musicSourceCountMethod: "fixed",
|
|
musicSourceCount: 1,
|
|
};
|
|
const musicWorkload = estimateExperimentWorkload(
|
|
arrayDefinition,
|
|
scene,
|
|
musicExperiment,
|
|
);
|
|
assertEqual(musicWorkload.algorithmLabel, "MUSIC", "MUSIC algorithm label");
|
|
assertEqual(
|
|
musicWorkload.musicCovarianceComplexAccumulations,
|
|
372,
|
|
"MUSIC covariance accumulation count",
|
|
);
|
|
assertEqual(
|
|
musicWorkload.musicJacobiWorstCasePairUpdates,
|
|
384,
|
|
"MUSIC 32-sweep Jacobi upper bound",
|
|
);
|
|
assertEqual(
|
|
musicWorkload.musicProjectionComplexMacs,
|
|
480,
|
|
"MUSIC fixed-K projection count",
|
|
);
|
|
assertEqual(musicWorkload.scanOperationCount, 1236, "MUSIC disclosed total work");
|
|
assertEqual(
|
|
musicWorkload.scanOperationKind,
|
|
"covariance + Jacobi upper bound + projection",
|
|
"MUSIC work is not mislabeled as DAS",
|
|
);
|
|
assertNear(
|
|
musicWorkload.distanceMiB,
|
|
(80 * 3 * 8) / 1024 ** 2,
|
|
1e-15,
|
|
"MUSIC Float64 distance table size",
|
|
);
|
|
});
|
|
|
|
test("Camera comparison signature groups array, algorithm, and backend variants only", () => {
|
|
const scene = { id: "scene", seed: 7, sources: [] };
|
|
const baseline = {
|
|
id: "experiment",
|
|
arrayId: "array-a",
|
|
scan: { rows: 8, columns: 8 },
|
|
beamforming: { algorithm: "das", backend: "cpu", normalization: "peak" },
|
|
};
|
|
const variant = structuredClone(baseline);
|
|
variant.arrayId = "array-b";
|
|
variant.beamforming.algorithm = "das-phat";
|
|
variant.beamforming.backend = "webgpu";
|
|
assertEqual(
|
|
createComparisonSignature(scene, variant),
|
|
createComparisonSignature(scene, baseline),
|
|
"comparison axes share one history group",
|
|
);
|
|
variant.scan.columns = 9;
|
|
assert(
|
|
createComparisonSignature(scene, variant) !== createComparisonSignature(scene, baseline),
|
|
"scan changes remain separate",
|
|
);
|
|
});
|
|
|
|
test("Camera acoustic presets bound current geometry and expose predictable sparse cost", () => {
|
|
const scene = {
|
|
sources: [{
|
|
id: "moving",
|
|
position: [0, 0, 2],
|
|
trajectory: [
|
|
{ time: 0, position: [-0.5, 0, 2] },
|
|
{ time: 1, position: [0.75, 0.5, 3] },
|
|
],
|
|
}],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [
|
|
{ id: "left", position: [-0.2, 0, 0] },
|
|
{ id: "right", position: [0.2, 0, 0] },
|
|
],
|
|
};
|
|
const experiment = {
|
|
scan: { xMin: -1, xMax: 1, yMin: -0.75, yMax: 0.75, z: 2.5 },
|
|
};
|
|
const room = inferShoeboxRoom(scene, arrayDefinition, experiment);
|
|
assert(room.min[0] < -1 && room.max[0] > 1, "scan x is padded");
|
|
assert(room.min[2] < 0 && room.max[2] > 3, "array and trajectory depth are padded");
|
|
|
|
applyAcousticProfile(scene, arrayDefinition, experiment, "typical-room");
|
|
assertEqual(detectAcousticProfile(scene), "typical-room", "profile round trip");
|
|
assertEqual(maximumSparsePathsPerReceiver(scene), 19, "bounded typical-room path count");
|
|
assertEqual(scene.acoustics.room.min.join(","), room.min.join(","), "inferred room minimum");
|
|
assertEqual(scene.acoustics.room.max.join(","), room.max.join(","), "inferred room maximum");
|
|
|
|
applyAcousticProfile(scene, arrayDefinition, experiment, "free-field");
|
|
assertEqual(detectAcousticProfile(scene), "free-field", "free-field selection");
|
|
assertEqual(maximumSparsePathsPerReceiver(scene), 1, "free-field path count");
|
|
});
|
|
|
|
test("Camera distance shift keeps the scene and scan on one z plane", () => {
|
|
const scene = {
|
|
sources: [
|
|
{
|
|
id: "one",
|
|
position: [0.1, -0.2, 2],
|
|
trajectory: [
|
|
{ time: 0, position: [0.1, -0.2, 2] },
|
|
{ time: 1, position: [0.6, 0.3, 2.5] },
|
|
],
|
|
},
|
|
{ id: "two", position: [-0.3, 0.4, 2.5] },
|
|
],
|
|
image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } },
|
|
acoustics: {
|
|
model: "shoebox",
|
|
room: { min: [-2, -2, -0.5], max: [2, 2, 4] },
|
|
occluders: [{
|
|
id: "screen",
|
|
min: [-0.2, -0.2, 1],
|
|
max: [0.2, 0.2, 1.1],
|
|
transmissionDb: -20,
|
|
diffractionGainDb: -12,
|
|
}],
|
|
},
|
|
};
|
|
const experiment = { scan: { z: 2 } };
|
|
|
|
shiftSceneDistance(scene, experiment, 3.25);
|
|
|
|
assertEqual(experiment.scan.z, 3.25, "scan z");
|
|
assertEqual(scene.sources[0].position.join(","), "0.1,-0.2,3.25", "first source z shift");
|
|
assertEqual(
|
|
scene.sources[0].trajectory[0].position.join(","),
|
|
"0.1,-0.2,3.25",
|
|
"first trajectory keyframe z shift",
|
|
);
|
|
assertEqual(
|
|
scene.sources[0].trajectory[1].position.join(","),
|
|
"0.6,0.3,3.75",
|
|
"later trajectory keyframe retains relative depth",
|
|
);
|
|
assertEqual(scene.sources[1].position.join(","), "-0.3,0.4,3.75", "relative source depth retained");
|
|
assertEqual(scene.image.plane.z, 3.25, "background image plane z shift");
|
|
assertEqual(scene.acoustics.room.min[2], 0.75, "room minimum z shift");
|
|
assertEqual(scene.acoustics.room.max[2], 5.25, "room maximum z shift");
|
|
assertEqual(scene.acoustics.occluders[0].min[2], 2.25, "occluder minimum z shift");
|
|
assertEqual(scene.acoustics.occluders[0].max[2], 2.35, "occluder maximum z shift");
|
|
});
|
|
|
|
test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
|
|
const experiment = {
|
|
scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 },
|
|
frequencyBands: [
|
|
{ id: "low", minHz: 600, maxHz: 900, color: "#65f2bf" },
|
|
{ id: "high", minHz: 1100, maxHz: 1500, color: "#58c7ff" },
|
|
],
|
|
};
|
|
const originalBands = structuredClone(experiment.frequencyBands);
|
|
const imageScene = {
|
|
sources: [{ id: "one", position: [0.2, -0.1, 4.5] }],
|
|
image: { plane: { z: 4.5, xMin: -2, xMax: 3, yMin: -1.5, yMax: 2.5 } },
|
|
};
|
|
|
|
assertEqual(alignExperimentToScene(experiment, imageScene), "image-plane", "alignment source");
|
|
assertEqual(
|
|
JSON.stringify(experiment.scan),
|
|
JSON.stringify({
|
|
z: 4.5,
|
|
xMin: -2,
|
|
xMax: 3,
|
|
yMin: -1.5,
|
|
yMax: 2.5,
|
|
columns: 32,
|
|
rows: 24,
|
|
}),
|
|
"image plane becomes scan plane while rows and columns stay unchanged",
|
|
);
|
|
|
|
const sourceScene = {
|
|
sources: [
|
|
{ id: "one", position: [-0.5, 0.1, 3] },
|
|
{ id: "two", position: [0.5, 0.2, 5] },
|
|
],
|
|
};
|
|
assertEqual(alignExperimentToScene(experiment, sourceScene), "source-bounds", "source fallback");
|
|
assertEqual(experiment.scan.z, 4, "median source depth");
|
|
assertNear(experiment.scan.xMin, -0.75, 1e-12, "source x minimum with padding");
|
|
assertNear(experiment.scan.xMax, 0.75, 1e-12, "source x maximum with padding");
|
|
assertNear(experiment.scan.yMin, -0.15, 1e-12, "source y minimum with padding");
|
|
assertNear(experiment.scan.yMax, 0.45, 1e-12, "source y maximum with padding");
|
|
assertEqual(experiment.scan.columns, 32, "columns retained after source fallback");
|
|
assertEqual(experiment.scan.rows, 24, "rows retained after source fallback");
|
|
|
|
const movingScene = {
|
|
sources: [
|
|
{
|
|
id: "moving",
|
|
position: [-2, -3, 1],
|
|
trajectory: [
|
|
{ time: 0, position: [-2, -3, 1] },
|
|
{ time: 1, position: [4, 5, 9] },
|
|
],
|
|
},
|
|
{ id: "fixed", position: [0, 1, 5] },
|
|
],
|
|
};
|
|
assertEqual(alignExperimentToScene(experiment, movingScene), "source-bounds", "trajectory fallback");
|
|
assertEqual(experiment.scan.z, 5, "trajectory depths contribute to median scan depth");
|
|
assertEqual(
|
|
JSON.stringify(experiment.frequencyBands),
|
|
JSON.stringify(originalBands),
|
|
"scene alignment preserves every analysis band",
|
|
);
|
|
assertNear(experiment.scan.xMin, -2.9, 1e-12, "trajectory x minimum with padding");
|
|
assertNear(experiment.scan.xMax, 4.9, 1e-12, "trajectory x maximum with padding");
|
|
assertNear(experiment.scan.yMin, -4.2, 1e-12, "trajectory y minimum with padding");
|
|
assertNear(experiment.scan.yMax, 6.2, 1e-12, "trajectory y maximum with padding");
|
|
});
|
|
|
|
test("Camera result document is schema-safe and linked to its snapshot", async () => {
|
|
const arrayDefinition = {
|
|
id: "test-array",
|
|
name: "Test array",
|
|
microphones: [
|
|
{ id: "mic-1", position: [0, 0, 0] },
|
|
{ id: "mic-2", position: [0.1, 0, 0] },
|
|
],
|
|
};
|
|
const scene = {
|
|
id: "test-scene",
|
|
sampleRate: 8_000,
|
|
sources: [{
|
|
id: "source-1",
|
|
position: [0, 0, 2],
|
|
signal: { type: "tone", frequency: 5_000, phaseDeg: 0 },
|
|
}],
|
|
};
|
|
const experiment = {
|
|
id: "test-experiment",
|
|
arrayId: "declared-array-provenance",
|
|
sceneId: "declared-scene-provenance",
|
|
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
|
beamforming: { algorithm: "das-phat", backend: "auto" },
|
|
stft: { fftSize: 256, hopSize: 128, window: "hann" },
|
|
frequencyBands: [{
|
|
id: "band-1",
|
|
minHz: 4_900,
|
|
maxHz: 5_100,
|
|
color: "#65f2bf",
|
|
}, {
|
|
id: "band-2",
|
|
minHz: 2_900,
|
|
maxHz: 3_100,
|
|
color: "#58c7ff",
|
|
}],
|
|
};
|
|
const run = {
|
|
algorithm: "das-phat",
|
|
backend: "cpu",
|
|
referenceMic: "mic-1",
|
|
signalStats: {
|
|
preNormalizationPeak: 0.75,
|
|
appliedScale: 1,
|
|
clippedSampleCount: 0,
|
|
acousticModel: "shoebox",
|
|
acousticPathCount: 38,
|
|
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
|
|
sampling: {
|
|
model: "ideal-real-sampler-no-antialias-filter",
|
|
sampleRate: 8_000,
|
|
nyquistHz: 4_000,
|
|
toneObservations: [{
|
|
scope: "source",
|
|
id: "source-1",
|
|
analogFrequencyHz: 5_000,
|
|
adcFrequencyHz: 3_000,
|
|
signedAdcFrequencyHz: -3_000,
|
|
aliased: true,
|
|
spectralInversion: true,
|
|
dcEdge: false,
|
|
nyquistEdge: false,
|
|
phaseDegenerate: false,
|
|
}],
|
|
},
|
|
},
|
|
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
|
|
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
|
bands: [{
|
|
id: "band-1",
|
|
color: "#65f2bf",
|
|
rawScale: "score",
|
|
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
|
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
|
displayScale: "normalized",
|
|
}, {
|
|
id: "band-2",
|
|
color: "#58c7ff",
|
|
rawScale: "score",
|
|
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
|
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
|
displayScale: "normalized",
|
|
}],
|
|
};
|
|
const evaluations = new Map([["band-1", {
|
|
peaks: [{
|
|
index: 3,
|
|
sourceId: "source-1",
|
|
position: [1, -1, 2],
|
|
value: 1,
|
|
localizationErrorM: 0.05,
|
|
localizationErrorPixels: 0.1,
|
|
beamSpread: {
|
|
calibrated: false,
|
|
method: "connected-half-maximum-score-weighted-covariance",
|
|
halfPowerRatio: 0.5,
|
|
thresholdValue: 0.5,
|
|
pixelCount: 3,
|
|
centroid: [0.9, -0.9, 2],
|
|
covarianceM2: { xx: 0.04, xy: 0.01, yy: 0.02 },
|
|
ellipse: {
|
|
axisScale: "rms",
|
|
angleReference: "counterclockwise-from-positive-x",
|
|
majorRmsRadiusM: 0.21,
|
|
minorRmsRadiusM: 0.12,
|
|
angleDeg: 22.5,
|
|
},
|
|
},
|
|
}],
|
|
metrics: {
|
|
peakToSidelobeDb: 10,
|
|
energyInsideTruthRatio: 0.75,
|
|
precision: 1,
|
|
recall: 1,
|
|
mainLobeWidthM: 0.2,
|
|
ignoredNonFinite: Number.NaN,
|
|
},
|
|
evaluation: {
|
|
version: "1.0.0",
|
|
truthRadiusM: 0.1,
|
|
matchToleranceM: 0.2,
|
|
detectionThresholdRatio: 0.5,
|
|
detectionThresholdValue: 0.25,
|
|
minimumPeakDistanceM: 0.1,
|
|
sidelobeExclusionRadiusM: 0.1,
|
|
halfPowerRatio: 0.5,
|
|
dynamicRangeDb: 30,
|
|
maximumDetections: 128,
|
|
},
|
|
}]]);
|
|
evaluations.set("band-2", evaluations.get("band-1"));
|
|
|
|
const result = createResultDocument({
|
|
arrayDefinition,
|
|
scene,
|
|
experiment,
|
|
run,
|
|
evaluations,
|
|
inputHash: "a".repeat(64),
|
|
createdAt: "2026-08-28T00:00:00.000Z",
|
|
});
|
|
|
|
assertEqual(result.schemaVersion, "1.1.0", "sampling-aware Result contract version");
|
|
assertEqual(result.arrayId, arrayDefinition.id, "Result records the Array actually used");
|
|
assertEqual(result.sceneId, scene.id, "Result records the Scene actually used");
|
|
assertEqual(
|
|
experiment.arrayId,
|
|
"declared-array-provenance",
|
|
"Result creation preserves the Experiment Array provenance label",
|
|
);
|
|
assertEqual(
|
|
experiment.sceneId,
|
|
"declared-scene-provenance",
|
|
"Result creation preserves the Experiment Scene provenance label",
|
|
);
|
|
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
|
|
assertEqual(result.algorithm, "das-phat", "actual algorithm is exported");
|
|
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
|
|
assertEqual(result.bands[0].rawMap.scale, "score", "algorithm raw score scale is exported");
|
|
assertEqual(result.environment.referenceMic, "mic-1", "reference microphone metadata is exported");
|
|
assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted");
|
|
assertEqual(
|
|
Object.keys(result.bands[0].metrics).join(","),
|
|
"peakToSidelobeDb,energyInsideTruthRatio,precision,recall,mainLobeWidthM",
|
|
"only finite schema metrics are exported",
|
|
);
|
|
assertEqual(result.bands[0].peaks[0].localizationErrorPixels, 0.1, "pixel error is exported");
|
|
assertEqual(result.bands[0].peaks[0].beamSpread.pixelCount, 3, "beam spread is exported");
|
|
assertEqual(result.bands[0].peaks[0].beamSpread.calibrated, false, "beam spread is not probability");
|
|
assertEqual(result.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported");
|
|
assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
|
|
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
|
|
assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported");
|
|
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
|
|
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
|
|
assertEqual(
|
|
result.signalStats.sampling.toneObservations[0].adcFrequencyHz,
|
|
3_000,
|
|
"ADC alias is exported",
|
|
);
|
|
assertEqual(
|
|
result.bands[0].frequencyObservation.adcBins.join(","),
|
|
"93,94,95,96,97,98,99",
|
|
"requested analog band exports every folded ADC bin",
|
|
);
|
|
assertEqual(
|
|
result.bands[0].frequencyObservation.equivalentBandIds.join(","),
|
|
"band-2",
|
|
"alias-equivalent band is disclosed",
|
|
);
|
|
assertEqual(
|
|
result.bands[1].frequencyObservation.equivalentBandIds.join(","),
|
|
"band-1",
|
|
"equivalence is symmetric",
|
|
);
|
|
assertEqual(
|
|
result.bands[0].frequencyObservation.localizationModel,
|
|
"spherical-steering-at-positive-adc-bin-frequency",
|
|
"actual DAS-family localization model is explicit",
|
|
);
|
|
assertEqual(
|
|
result.bands[0].frequencyObservation.originalCarrierUsed,
|
|
false,
|
|
"result does not imply hidden original-carrier steering",
|
|
);
|
|
assertEqual(
|
|
result.bands[0].evaluation.truthSelection,
|
|
"tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources",
|
|
"truth-selection policy is explicit",
|
|
);
|
|
|
|
const history = createHistorySummary({
|
|
arrayDefinition,
|
|
scene,
|
|
experiment,
|
|
run,
|
|
evaluations,
|
|
inputHash: "a".repeat(64),
|
|
});
|
|
assertEqual(history.algorithm, "das-phat", "history records the actual algorithm");
|
|
assertEqual(history.localizationMs, 10, "history records localization time");
|
|
|
|
const issues = await validateResult(result, {
|
|
experiment,
|
|
scene,
|
|
arrayDefinition,
|
|
});
|
|
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
|
assertEqual(
|
|
(await validateResult(result)).length,
|
|
0,
|
|
"generated Result is self-consistent without external context",
|
|
);
|
|
|
|
const standaloneCorruption = structuredClone(result);
|
|
standaloneCorruption.signalStats.sampling.nyquistHz += 1;
|
|
standaloneCorruption.signalStats.sampling.toneObservations.push(
|
|
structuredClone(standaloneCorruption.signalStats.sampling.toneObservations[0]),
|
|
);
|
|
standaloneCorruption.bands[0].frequencyObservation.adcBins.push(
|
|
standaloneCorruption.bands[0].frequencyObservation.adcBins[0],
|
|
);
|
|
standaloneCorruption.bands[0].frequencyObservation.equivalentBandIds.push("band-2");
|
|
standaloneCorruption.bands[0].frequencyObservation.localizationModel =
|
|
"band-limited-gcc-phat-geometric-lag";
|
|
const standaloneIssues = await validateResult(standaloneCorruption);
|
|
assert(
|
|
standaloneIssues.some(({ path }) => path === "$.signalStats.sampling.nyquistHz"),
|
|
"standalone validator checks Nyquist self-consistency",
|
|
);
|
|
assert(
|
|
standaloneIssues.some(({ message }) => message.includes("duplicate tone observation")),
|
|
"standalone validator rejects duplicate tone provenance",
|
|
);
|
|
assert(
|
|
standaloneIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
|
|
"standalone validator recomputes unique ADC bins",
|
|
);
|
|
assert(
|
|
standaloneIssues.some(({ path }) =>
|
|
path.endsWith("frequencyObservation.equivalentBandIds")),
|
|
"standalone validator recomputes equivalent band IDs",
|
|
);
|
|
assert(
|
|
standaloneIssues.some(({ path }) =>
|
|
path.endsWith("frequencyObservation.localizationModel")),
|
|
"standalone validator links localization model to actual algorithm",
|
|
);
|
|
|
|
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz += 1;
|
|
let semanticIssues = await validateResult(result, { experiment, scene });
|
|
assert(
|
|
semanticIssues.some(({ path }) => path.endsWith("signedAdcFrequencyHz")),
|
|
"validator recomputes the temporal alias",
|
|
);
|
|
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz -= 1;
|
|
|
|
result.bands[0].frequencyObservation.adcBins[0] += 1;
|
|
semanticIssues = await validateResult(result, { experiment, scene });
|
|
assert(
|
|
semanticIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
|
|
"validator recomputes folded ADC bins",
|
|
);
|
|
result.bands[0].frequencyObservation.adcBins[0] -= 1;
|
|
|
|
const missingBandResult = structuredClone(result);
|
|
missingBandResult.bands.pop();
|
|
semanticIssues = await validateResult(missingBandResult, { experiment, scene });
|
|
assert(
|
|
semanticIssues.some(({ message }) => message.includes("missing Experiment band result")),
|
|
"validator rejects incomplete Result band sets",
|
|
);
|
|
|
|
const wrongArrayResult = structuredClone(result);
|
|
wrongArrayResult.arrayId = "another-array";
|
|
let linkageIssues = await validateResult(wrongArrayResult, {
|
|
experiment,
|
|
scene,
|
|
arrayDefinition,
|
|
});
|
|
assert(
|
|
linkageIssues.some(({ path }) => path === "$.arrayId"),
|
|
"validator catches an actual Array snapshot linkage mismatch",
|
|
);
|
|
|
|
const wrongSceneResult = structuredClone(result);
|
|
wrongSceneResult.sceneId = "another-scene";
|
|
linkageIssues = await validateResult(wrongSceneResult, {
|
|
experiment,
|
|
scene,
|
|
arrayDefinition,
|
|
});
|
|
assert(
|
|
linkageIssues.some(({ path }) => path === "$.sceneId"),
|
|
"validator catches an actual Scene snapshot linkage mismatch",
|
|
);
|
|
});
|
|
|
|
test("MUSIC Result preserves its physical model and numerical audit", async () => {
|
|
const sampleRate = 8192;
|
|
const fftSize = 128;
|
|
const sampleCount = 256;
|
|
const arrayDefinition = {
|
|
id: "music-result-array",
|
|
microphones: [
|
|
{ id: "m0", position: [-0.1, 0.1, 0] },
|
|
{ id: "m1", position: [0.1, 0.1, 0] },
|
|
{ id: "m2", position: [-0.1, -0.1, 0] },
|
|
{ id: "m3", position: [0.1, -0.1, 0] },
|
|
],
|
|
};
|
|
const channels = arrayDefinition.microphones.map((_, microphoneIndex) =>
|
|
Float64Array.from(
|
|
{ length: sampleCount },
|
|
(_, sampleIndex) =>
|
|
Math.cos(
|
|
2 * Math.PI * 16 * sampleIndex / fftSize +
|
|
microphoneIndex * 0.21,
|
|
),
|
|
));
|
|
const scene = {
|
|
id: "music-result-scene",
|
|
sampleRate,
|
|
duration: sampleCount / sampleRate,
|
|
speedOfSound: 343,
|
|
sources: [],
|
|
};
|
|
const experiment = {
|
|
id: "music-result-experiment",
|
|
arrayId: arrayDefinition.id,
|
|
sceneId: scene.id,
|
|
scan: {
|
|
rows: 2,
|
|
columns: 2,
|
|
xMin: -0.2,
|
|
xMax: 0.2,
|
|
yMin: -0.2,
|
|
yMax: 0.2,
|
|
z: 1,
|
|
},
|
|
frequencyBands: [
|
|
{ id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" },
|
|
],
|
|
stft: { fftSize, hopSize: 64, window: "hann" },
|
|
beamforming: {
|
|
algorithm: "music",
|
|
backend: "cpu",
|
|
minimumDistance: 0.01,
|
|
normalization: "none",
|
|
compensateFrequencyResponse: true,
|
|
musicSourceCountMethod: "fixed",
|
|
musicSourceCount: 1,
|
|
},
|
|
};
|
|
const localization = await computeLocalizationMap({
|
|
channels,
|
|
sampleRate,
|
|
arrayDefinition,
|
|
scene,
|
|
experiment,
|
|
});
|
|
const run = {
|
|
...localization,
|
|
signalStats: {
|
|
preNormalizationPeak: 1,
|
|
appliedScale: 1,
|
|
clippedSampleCount: 0,
|
|
sampling: {
|
|
model: "ideal-real-sampler-no-antialias-filter",
|
|
sampleRate,
|
|
nyquistHz: sampleRate / 2,
|
|
toneObservations: [],
|
|
},
|
|
},
|
|
timingMs: {
|
|
total: 1,
|
|
signalGeneration: 0,
|
|
localization: 1,
|
|
beamforming: 1,
|
|
},
|
|
};
|
|
const result = createResultDocument({
|
|
arrayDefinition,
|
|
scene,
|
|
experiment,
|
|
run,
|
|
evaluations: new Map([[
|
|
"single-bin",
|
|
{ peaks: [], metrics: { precision: 1, recall: 1 } },
|
|
]]),
|
|
inputHash: "b".repeat(64),
|
|
createdAt: "2026-08-29T00:00:00.000Z",
|
|
});
|
|
|
|
assertEqual(result.bands[0].rawMap.scale, "noise-projection", "raw q scale");
|
|
assertEqual(result.bands[0].rawMap.objectiveDirection, "minimum", "q is minimized");
|
|
assertEqual(result.musicModel.originalCarrierUsed, true, "configured carrier is used");
|
|
assertEqual(
|
|
result.musicModel.hypothesisSource,
|
|
"experiment-band-not-scene-truth",
|
|
"carrier comes from Experiment rather than Scene truth",
|
|
);
|
|
assertEqual(result.musicModel.diagonalLoading, "none", "no hidden diagonal loading");
|
|
assertEqual(result.musicModel.multibandFusion, "none-each-band-is-separate-single-analog-hypothesis", "no band fusion");
|
|
assertEqual(result.bands[0].musicDiagnostics.eigenvalues.length, 4, "all eigenvalues exported");
|
|
assert(result.bands[0].musicDiagnostics.eigensolver.numericallyVerified, "EVD audit exported");
|
|
assertEqual(result.bands[0].musicDiagnostics.sourceCount.value, 1, "fixed K exported");
|
|
const resultIssues = await validateResult(result, {
|
|
experiment,
|
|
scene,
|
|
arrayDefinition,
|
|
});
|
|
assertEqual(
|
|
resultIssues.length,
|
|
0,
|
|
`MUSIC Result passes schema and semantic validation: ${JSON.stringify(resultIssues)}`,
|
|
);
|
|
|
|
const corrupted = structuredClone(result);
|
|
corrupted.bands[0].musicDiagnostics.sourceCount.value = 4;
|
|
const issues = await validateResult(corrupted, {
|
|
experiment,
|
|
scene,
|
|
arrayDefinition,
|
|
});
|
|
assert(
|
|
issues.some(({ path }) =>
|
|
path.endsWith("musicDiagnostics.sourceCount.value")),
|
|
"source count cannot equal microphone count",
|
|
);
|
|
|
|
const corruptedHypothesis = structuredClone(result);
|
|
corruptedHypothesis.bands[0].musicDiagnostics.frequencyHypothesis
|
|
.analogFrequencyHz += sampleRate;
|
|
const hypothesisIssues = await validateResult(corruptedHypothesis, {
|
|
experiment,
|
|
scene,
|
|
arrayDefinition,
|
|
});
|
|
assert(
|
|
hypothesisIssues.some(({ path }) =>
|
|
path.endsWith(
|
|
"musicDiagnostics.frequencyHypothesis.analogFrequencyHz",
|
|
)),
|
|
"numerical diagnostics cannot claim a different analog carrier",
|
|
);
|
|
});
|