feat: add physically explicit narrowband MUSIC
This commit is contained in:
@@ -613,6 +613,9 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
() => cameraDocument.documentElement.dataset.appReady === "true",
|
||||
"Camera handoff initialization",
|
||||
);
|
||||
const defaultCameraExperiment = await cameraWindow
|
||||
.fetch("/assets/sample-scenes/single-tone.experiment.json")
|
||||
.then((response) => response.json());
|
||||
const handoffFile = new cameraWindow.File(
|
||||
[JSON.stringify(editedExport)],
|
||||
"builder-handoff.array.json",
|
||||
@@ -642,8 +645,12 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
);
|
||||
assertEqual(
|
||||
cameraExperiment.arrayId,
|
||||
editedExport.id,
|
||||
"Camera rebinds Experiment to the Builder Array ID",
|
||||
defaultCameraExperiment.arrayId,
|
||||
"Camera preserves the Experiment Array provenance label",
|
||||
);
|
||||
assert(
|
||||
cameraExperiment.arrayId !== editedExport.id,
|
||||
"opening a Builder Array does not rewrite Experiment provenance",
|
||||
);
|
||||
} finally {
|
||||
cameraWindow.URL.createObjectURL = originalCameraCreate;
|
||||
|
||||
@@ -84,7 +84,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
|
||||
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
|
||||
assertEqual(documentObject.querySelector("#algorithm").value, "das", "default DAS algorithm");
|
||||
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 3, "three runnable algorithms");
|
||||
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 4, "four runnable algorithms");
|
||||
assert(!documentObject.querySelector("#postprocess"), "disabled postprocess advertisement is absent");
|
||||
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
|
||||
assertEqual(
|
||||
@@ -325,6 +325,63 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
);
|
||||
assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved");
|
||||
|
||||
documentObject.querySelector("#load-music-example-button").click();
|
||||
await waitFor(
|
||||
() =>
|
||||
documentObject.querySelector("#algorithm").value === "music" &&
|
||||
documentObject.querySelector("#microphone-count").textContent === "12 ch" &&
|
||||
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"quick MUSIC example load",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#workload-summary").textContent.includes(
|
||||
"1 analog hypotheses / 1 ADC bins",
|
||||
) &&
|
||||
documentObject.querySelector("#workload-summary").textContent.includes(
|
||||
"cap",
|
||||
),
|
||||
"MUSIC workload exposes hypotheses and hard cap",
|
||||
);
|
||||
documentObject.querySelector("#run-button").click();
|
||||
await waitFor(
|
||||
() =>
|
||||
documentObject.documentElement.dataset.runState === "done" &&
|
||||
documentObject.documentElement.dataset.runAlgorithm === "music",
|
||||
"quick MUSIC example run",
|
||||
30000,
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.documentElement.dataset.resultBandCount,
|
||||
"1",
|
||||
"quick MUSIC example returns one map",
|
||||
);
|
||||
const downloadCountBeforeMusic =
|
||||
downloads.length;
|
||||
documentObject.querySelector("#export-result-button").click();
|
||||
const musicDownload = await waitFor(
|
||||
() =>
|
||||
downloads.length > downloadCountBeforeMusic
|
||||
? downloads.at(-1)
|
||||
: null,
|
||||
"MUSIC Result JSON download",
|
||||
);
|
||||
const musicResult = await fetch(musicDownload.url).then(
|
||||
(response) => response.json(),
|
||||
);
|
||||
assertEqual(musicResult.algorithm, "music", "MUSIC example export");
|
||||
assertEqual(
|
||||
musicResult.musicModel.hypothesisSource,
|
||||
"experiment-band-not-scene-truth",
|
||||
"MUSIC export never claims Scene-truth carrier selection",
|
||||
);
|
||||
assertEqual(
|
||||
musicResult.bands[0].frequencyObservation
|
||||
.configuredAnalogFrequencyHz,
|
||||
1125,
|
||||
"configured analog carrier is exported",
|
||||
);
|
||||
|
||||
frame.style.width = "390px";
|
||||
await nextPaint(windowObject);
|
||||
assert(
|
||||
@@ -448,7 +505,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"Experiment import and automatic reference binding",
|
||||
"Experiment import with independent provenance labels",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#experiment-id").value,
|
||||
@@ -465,7 +522,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
!documentObject.querySelector("#validation-message").textContent.includes("sceneId") &&
|
||||
!documentObject.querySelector("#validation-message").textContent.includes("arrayId") &&
|
||||
!documentObject.querySelector("#run-status").textContent.includes("연결 대기"),
|
||||
"automatic binding does not leave a reference mismatch warning",
|
||||
"provenance labels do not create a reference mismatch warning",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") &&
|
||||
@@ -474,29 +531,33 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
);
|
||||
|
||||
documentObject.querySelector("#export-experiment-button").click();
|
||||
const reboundDownload = await waitFor(
|
||||
const preservedDownload = await waitFor(
|
||||
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
|
||||
"automatically rebound Experiment export",
|
||||
"provenance-preserving Experiment export",
|
||||
);
|
||||
const reboundExperiment = await fetch(reboundDownload.url).then((response) => response.json());
|
||||
assertEqual(reboundExperiment.sceneId, openScene.id, "import binds the open Scene ID");
|
||||
const preservedExperiment = await fetch(preservedDownload.url).then((response) => response.json());
|
||||
assertEqual(
|
||||
reboundExperiment.arrayId,
|
||||
openArrayDefinition.id,
|
||||
"import binds the open Array ID",
|
||||
preservedExperiment.sceneId,
|
||||
experiment.sceneId,
|
||||
"import preserves the Experiment Scene provenance label",
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(reboundExperiment.frequencyBands),
|
||||
preservedExperiment.arrayId,
|
||||
experiment.arrayId,
|
||||
"import preserves the Experiment Array provenance label",
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(preservedExperiment.frequencyBands),
|
||||
JSON.stringify(expectedBands),
|
||||
"automatic reference binding preserves every imported band",
|
||||
"provenance preservation keeps every imported band",
|
||||
);
|
||||
assertEqual(
|
||||
(await validateExperiment(reboundExperiment, {
|
||||
(await validateExperiment(preservedExperiment, {
|
||||
scene: openScene,
|
||||
arrayDefinition: openArrayDefinition,
|
||||
})).length,
|
||||
0,
|
||||
"rebound Experiment passes relationship validation",
|
||||
"provenance labels may differ from the open Scene and Array",
|
||||
);
|
||||
downloads.length = 0;
|
||||
|
||||
@@ -678,8 +739,16 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
JSON.stringify(expectedBands),
|
||||
"Experiment export preserves every band after Scene reimport",
|
||||
);
|
||||
assertEqual(exportedExperiment.sceneId, scene.id, "Experiment rebinds only the Scene ID");
|
||||
assertEqual(exportedExperiment.arrayId, arrayDefinition.id, "Experiment rebinds the Array ID");
|
||||
assertEqual(
|
||||
exportedExperiment.sceneId,
|
||||
experiment.sceneId,
|
||||
"Experiment keeps its Scene provenance label after Scene replacement",
|
||||
);
|
||||
assertEqual(
|
||||
exportedExperiment.arrayId,
|
||||
experiment.arrayId,
|
||||
"Experiment keeps its Array provenance label after Array replacement",
|
||||
);
|
||||
} finally {
|
||||
windowObject.confirm = originalConfirm;
|
||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
|
||||
@@ -12,6 +12,7 @@ try {
|
||||
await import("/tests/unit/contracts.test.js");
|
||||
await import("/tests/unit/acoustics.test.js");
|
||||
await import("/tests/unit/gcc-phat.test.js");
|
||||
await import("/tests/unit/music-numerics.test.js");
|
||||
await import("/tests/unit/localization.test.js");
|
||||
await import("/tests/unit/realtime-localizer.test.js");
|
||||
await import("/tests/unit/builder.test.js");
|
||||
|
||||
@@ -56,7 +56,7 @@ test("localization algorithm registry exposes stable ids and capabilities", () =
|
||||
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
||||
assertEqual(
|
||||
LOCALIZATION_ALGORITHM_IDS.join(","),
|
||||
"das,das-phat,gcc-phat",
|
||||
"das,das-phat,gcc-phat,music",
|
||||
"stable algorithm ids",
|
||||
);
|
||||
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS map scale");
|
||||
@@ -70,8 +70,14 @@ test("localization algorithm registry exposes stable ids and capabilities", () =
|
||||
"cpu",
|
||||
"GCC-PHAT backend capability",
|
||||
);
|
||||
assertEqual(
|
||||
localizationAlgorithmDefinition("music").rawScale,
|
||||
"noise-projection",
|
||||
"MUSIC map scale",
|
||||
);
|
||||
assert(isDasAlgorithm("das") && isDasAlgorithm("das-phat"), "DAS engine algorithms");
|
||||
assert(!isDasAlgorithm("gcc-phat"), "GCC-PHAT uses a separate engine");
|
||||
assert(!isDasAlgorithm("music"), "MUSIC uses a separate engine");
|
||||
assertEqual(localizationAlgorithmLabel("das-phat"), "DAS-PHAT", "algorithm label");
|
||||
assertEqual(localizationAlgorithmDefinition("unknown"), undefined, "unknown lookup");
|
||||
});
|
||||
|
||||
+236
-6
@@ -22,6 +22,7 @@ import {
|
||||
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) {
|
||||
@@ -319,6 +320,49 @@ test("Camera workload counts unique overlapping FFT bins", () => {
|
||||
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", () => {
|
||||
@@ -520,8 +564,8 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
};
|
||||
const experiment = {
|
||||
id: "test-experiment",
|
||||
arrayId: arrayDefinition.id,
|
||||
sceneId: scene.id,
|
||||
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" },
|
||||
@@ -643,6 +687,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -710,7 +766,11 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
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 });
|
||||
const issues = await validateResult(result, {
|
||||
experiment,
|
||||
scene,
|
||||
arrayDefinition,
|
||||
});
|
||||
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
||||
assertEqual(
|
||||
(await validateResult(result)).length,
|
||||
@@ -777,7 +837,177 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
"validator rejects incomplete Result band sets",
|
||||
);
|
||||
|
||||
const wrongExperiment = { ...experiment, arrayId: "another-array" };
|
||||
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
|
||||
assert(linkageIssues.some(({ path }) => path === "$.arrayId"), "validator catches snapshot linkage mismatch");
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,6 +40,8 @@ function resultFrequencyObservation(scene, experiment, band, equivalentBandIds =
|
||||
localizationModel:
|
||||
experiment.beamforming?.algorithm === "gcc-phat"
|
||||
? "band-limited-gcc-phat-geometric-lag"
|
||||
: experiment.beamforming?.algorithm === "music"
|
||||
? "near-field-music-at-positive-adc-bin-frequency"
|
||||
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||
originalCarrierUsed: observation.originalCarrierUsed,
|
||||
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
|
||||
@@ -234,17 +236,26 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
|
||||
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
||||
});
|
||||
|
||||
test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => {
|
||||
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"]) {
|
||||
for (const algorithm of ["das", "das-phat", "gcc-phat", "music"]) {
|
||||
const experiment = structuredClone(baseline);
|
||||
experiment.beamforming.algorithm = algorithm;
|
||||
experiment.beamforming.backend = algorithm === "gcc-phat" ? "cpu" : "auto";
|
||||
experiment.beamforming.backend =
|
||||
algorithm === "gcc-phat" || algorithm === "music" ? "cpu" : "auto";
|
||||
if (algorithm === "music") {
|
||||
experiment.frequencyBands = [
|
||||
{ id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" },
|
||||
];
|
||||
experiment.beamforming.normalization = "none";
|
||||
experiment.beamforming.musicSourceCountMethod = "fixed";
|
||||
experiment.beamforming.musicSourceCount = 1;
|
||||
}
|
||||
assertEqual(
|
||||
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
|
||||
0,
|
||||
@@ -252,6 +263,31 @@ test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다
|
||||
);
|
||||
}
|
||||
|
||||
const autoWithStaleK = structuredClone(baseline);
|
||||
autoWithStaleK.frequencyBands = [
|
||||
{ id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" },
|
||||
];
|
||||
autoWithStaleK.stft.hopSize = autoWithStaleK.stft.fftSize;
|
||||
Object.assign(autoWithStaleK.beamforming, {
|
||||
algorithm: "music",
|
||||
backend: "cpu",
|
||||
normalization: "none",
|
||||
musicSourceCountMethod: "mdl",
|
||||
musicSourceCount: 8,
|
||||
});
|
||||
const smallArray = {
|
||||
...arrayDefinition,
|
||||
microphones: arrayDefinition.microphones.slice(0, 4),
|
||||
};
|
||||
assertEqual(
|
||||
(await validateExperiment(autoWithStaleK, {
|
||||
arrayDefinition: smallArray,
|
||||
scene,
|
||||
})).length,
|
||||
0,
|
||||
"MDL ignores disabled stale fixed K after an array-size change",
|
||||
);
|
||||
|
||||
const incompatible = structuredClone(baseline);
|
||||
incompatible.beamforming.algorithm = "gcc-phat";
|
||||
incompatible.beamforming.backend = "webgpu";
|
||||
@@ -271,7 +307,7 @@ test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다
|
||||
"GCC-PHAT requires two microphones",
|
||||
);
|
||||
|
||||
incompatible.beamforming.algorithm = "music";
|
||||
incompatible.beamforming.algorithm = "invented-localizer";
|
||||
issues = await validateExperiment(incompatible, { arrayDefinition, scene });
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.beamforming.algorithm"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findLocalPeaks,
|
||||
measureBeamSpread,
|
||||
measureMainLobe,
|
||||
normalizedMusicPseudospectrum,
|
||||
selectResultMetrics,
|
||||
} from "/packages/acoustics-core/evaluation.js";
|
||||
|
||||
@@ -426,3 +427,102 @@ test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서
|
||||
"display-only map scale rejected",
|
||||
);
|
||||
});
|
||||
|
||||
test("MUSIC noise projection is minimized without rewriting raw q values", () => {
|
||||
const scan = {
|
||||
xMin: 0,
|
||||
xMax: 4,
|
||||
yMin: 0,
|
||||
yMax: 4,
|
||||
z: 1,
|
||||
columns: 5,
|
||||
rows: 5,
|
||||
};
|
||||
const values = new Float64Array(25);
|
||||
values.fill(1);
|
||||
values[6] = 0.1;
|
||||
values[18] = 0.2;
|
||||
values[3] = 0.6;
|
||||
const evaluation = evaluateLocalizationBand({
|
||||
rawMap: {
|
||||
rows: 5,
|
||||
columns: 5,
|
||||
scale: "noise-projection",
|
||||
objectiveDirection: "minimum",
|
||||
values,
|
||||
},
|
||||
scan,
|
||||
sources: [
|
||||
{ id: "left", position: [1, 3, 1] },
|
||||
{ id: "right", position: [3, 1, 1] },
|
||||
],
|
||||
detectionThresholdRatio: 0.5,
|
||||
minimumPeakDistanceM: 0,
|
||||
matchToleranceM: 0,
|
||||
});
|
||||
|
||||
assertEqual(evaluation.globalPeak.index, 6, "smallest q is the global optimum");
|
||||
assertNear(evaluation.globalPeak.value, 0.1, 1e-12, "global peak retains raw q");
|
||||
assertEqual(evaluation.detections.length, 2, "two separated minima are detected");
|
||||
assertNear(evaluation.detections[1].value, 0.2, 1e-12, "second detection retains raw q");
|
||||
assert(!("beamSpread" in evaluation.detections[0]), "power beam spread is omitted");
|
||||
assertEqual(
|
||||
Object.keys(evaluation.metrics).join(","),
|
||||
"precision,recall",
|
||||
"only objective-independent metrics are reported",
|
||||
);
|
||||
assertEqual(
|
||||
evaluation.diagnostics.detectionTransform,
|
||||
"normalized-reciprocal-noise-projection",
|
||||
"the standard normalized MUSIC pseudospectrum is disclosed",
|
||||
);
|
||||
const exported = exportBandEvaluation(evaluation);
|
||||
assertEqual(exported.evaluation.objectiveDirection, "minimum", "minimum objective is exported");
|
||||
assertEqual(
|
||||
exported.evaluation.detectionThresholdDomain,
|
||||
"normalized-music-pseudospectrum",
|
||||
"threshold domain is explicit",
|
||||
);
|
||||
assertNear(exported.peaks[0].value, 0.1, 1e-12, "exported peak remains raw q");
|
||||
|
||||
const withBadOutlier = Float64Array.from(values);
|
||||
withBadOutlier[0] = 100;
|
||||
const outlierEvaluation = evaluateLocalizationBand({
|
||||
rawMap: {
|
||||
rows: 5,
|
||||
columns: 5,
|
||||
scale: "noise-projection",
|
||||
objectiveDirection: "minimum",
|
||||
values: withBadOutlier,
|
||||
},
|
||||
scan,
|
||||
sources: [],
|
||||
detectionThresholdRatio: 0.5,
|
||||
minimumPeakDistanceM: 0,
|
||||
});
|
||||
assertEqual(
|
||||
outlierEvaluation.detections.map(({ index }) => index).join(","),
|
||||
evaluation.detections.map(({ index }) => index).join(","),
|
||||
"a worse scan outlier does not move the reciprocal threshold",
|
||||
);
|
||||
|
||||
const flat = evaluateLocalizationBand({
|
||||
rawMap: {
|
||||
rows: 5,
|
||||
columns: 5,
|
||||
scale: "noise-projection",
|
||||
objectiveDirection: "minimum",
|
||||
values: new Float64Array(25).fill(0.5),
|
||||
},
|
||||
scan,
|
||||
sources: [],
|
||||
});
|
||||
const flatDisplay = normalizedMusicPseudospectrum(
|
||||
new Float64Array(25).fill(0.5),
|
||||
);
|
||||
assert(
|
||||
flatDisplay.values.every((value) => value === 1),
|
||||
"flat q preserves the standard normalized reciprocal value",
|
||||
);
|
||||
assertEqual(flat.detections.length, 0, "flat q does not invent a location");
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
createLocalizationAnalysisPlan,
|
||||
estimateLocalizationWorkload,
|
||||
} from "/packages/acoustics-core/localization.js";
|
||||
import {
|
||||
createMusicBinCalibration,
|
||||
isMusicUnavailableError,
|
||||
} from "/packages/acoustics-core/music.js";
|
||||
import {
|
||||
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
||||
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||
@@ -58,9 +62,9 @@ function createFixture(algorithm = "das") {
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
};
|
||||
const frequencyBands = [
|
||||
{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" },
|
||||
];
|
||||
const frequencyBands = algorithm === "music"
|
||||
? [{ id: "single-bin", minHz: 500, maxHz: 530, color: "#65f2bf" }]
|
||||
: [{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" }];
|
||||
const experiment = {
|
||||
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
||||
scan,
|
||||
@@ -68,8 +72,11 @@ function createFixture(algorithm = "das") {
|
||||
beamforming: {
|
||||
algorithm,
|
||||
backend: "cpu",
|
||||
normalization: "peak",
|
||||
normalization: algorithm === "music" ? "none" : "peak",
|
||||
compensateFrequencyResponse: true,
|
||||
...(algorithm === "music"
|
||||
? { musicSourceCountMethod: "fixed", musicSourceCount: 1 }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
return {
|
||||
@@ -83,12 +90,22 @@ function createFixture(algorithm = "das") {
|
||||
|
||||
test("localization algorithm registry exposes explicit scale and backend semantics", () => {
|
||||
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
||||
assertEqual(LOCALIZATION_ALGORITHM_IDS.join(","), "das,das-phat,gcc-phat", "registered ids");
|
||||
assertEqual(
|
||||
LOCALIZATION_ALGORITHM_IDS.join(","),
|
||||
"das,das-phat,gcc-phat,music",
|
||||
"registered ids",
|
||||
);
|
||||
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS power scale");
|
||||
assertEqual(localizationAlgorithmDefinition("das-phat").rawScale, "score", "DAS-PHAT score scale");
|
||||
assertEqual(localizationAlgorithmDefinition("gcc-phat").rawScale, "score", "GCC-PHAT score scale");
|
||||
assertEqual(
|
||||
localizationAlgorithmDefinition("music").rawScale,
|
||||
"noise-projection",
|
||||
"MUSIC raw noise-projection scale",
|
||||
);
|
||||
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU support");
|
||||
assert(!localizationAlgorithmSupportsBackend("gcc-phat", "webgpu"), "GCC-PHAT CPU-only support");
|
||||
assert(!localizationAlgorithmSupportsBackend("music", "webgpu"), "MUSIC CPU-only support");
|
||||
});
|
||||
|
||||
test("generic CPU dispatcher runs all algorithms with explicit raw-map scales", async () => {
|
||||
@@ -156,6 +173,348 @@ test("backend dispatcher keeps GCC-PHAT on CPU and rejects an explicit GPU reque
|
||||
assert(legacyError instanceof RangeError, "legacy DAS entry rejects GCC");
|
||||
});
|
||||
|
||||
test("backend dispatcher keeps MUSIC on CPU and rejects an explicit GPU request", async () => {
|
||||
const fixture = createFixture("music");
|
||||
fixture.experiment.beamforming.backend = "auto";
|
||||
const calls = [];
|
||||
const result = await computeLocalizationWithBackend(fixture, {
|
||||
computeGpu: async () => {
|
||||
calls.push("gpu");
|
||||
throw new Error("MUSIC must not call WebGPU");
|
||||
},
|
||||
computeMusicCpu: async () => {
|
||||
calls.push("music-cpu");
|
||||
return { backend: "cpu", algorithm: "music", bands: [] };
|
||||
},
|
||||
});
|
||||
assertEqual(calls.join(","), "music-cpu", "MUSIC auto routing");
|
||||
assertEqual(result.algorithm, "music", "MUSIC result");
|
||||
|
||||
fixture.experiment.beamforming.backend = "webgpu";
|
||||
let backendError;
|
||||
try {
|
||||
await computeLocalizationWithBackend(fixture);
|
||||
} catch (error) {
|
||||
backendError = error;
|
||||
}
|
||||
assert(backendError instanceof TypeError, "explicit MUSIC WebGPU is rejected");
|
||||
});
|
||||
|
||||
test("fixed-K MUSIC reports silent rank loss instead of choosing a grid cell", async () => {
|
||||
const fixture = createFixture("music");
|
||||
fixture.channels = fixture.channels.map(
|
||||
(channel) => new Float32Array(channel.length),
|
||||
);
|
||||
let unavailable;
|
||||
try {
|
||||
await computeLocalizationMap(fixture);
|
||||
} catch (error) {
|
||||
unavailable = error;
|
||||
}
|
||||
assert(
|
||||
isMusicUnavailableError(unavailable),
|
||||
"silence is a typed MUSIC unavailable condition",
|
||||
);
|
||||
assertEqual(
|
||||
unavailable.reason,
|
||||
"signal-noise-partition-unidentifiable",
|
||||
"silence has an explicit identifiability reason",
|
||||
);
|
||||
});
|
||||
|
||||
test("MUSIC uses an explicit above-Nyquist carrier hypothesis without Scene truth", async () => {
|
||||
const baseband = createFixture("music");
|
||||
const aliased = createFixture("music");
|
||||
aliased.channels = baseband.channels;
|
||||
aliased.experiment.frequencyBands = [
|
||||
{ id: "aliased-single-bin", minHz: 4590, maxHz: 4620, color: "#65f2bf" },
|
||||
];
|
||||
|
||||
const basebandResult = await computeLocalizationMap(baseband);
|
||||
const aliasedResult = await computeLocalizationMap(aliased);
|
||||
assertEqual(aliasedResult.bands[0].bins.join(","), "8", "aliased hypothesis maps to bin 8");
|
||||
assertEqual(
|
||||
aliasedResult.bands[0].observedFrequencyHz,
|
||||
512,
|
||||
"covariance uses the observed ADC bin center",
|
||||
);
|
||||
assertEqual(
|
||||
aliasedResult.bands[0].analogFrequencyHz,
|
||||
4608,
|
||||
"band config supplies the analog FFT-grid carrier",
|
||||
);
|
||||
assertEqual(
|
||||
aliasedResult.bands[0].signedAdcFrequencyHz,
|
||||
512,
|
||||
"signed ADC frequency is retained",
|
||||
);
|
||||
assertEqual(
|
||||
aliasedResult.model.originalCarrierUsed,
|
||||
true,
|
||||
"configured analog carrier is used",
|
||||
);
|
||||
assertEqual(
|
||||
aliasedResult.model.hypothesisSource,
|
||||
"experiment-band-not-scene-truth",
|
||||
"Scene truth is never used to choose the carrier",
|
||||
);
|
||||
assertEqual(
|
||||
aliasedResult.model.multibandFusion,
|
||||
"none-each-band-is-separate-single-analog-hypothesis",
|
||||
"no cross-band fusion is invented",
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(Array.from(aliasedResult.bands[0].rawMap)) !==
|
||||
JSON.stringify(Array.from(basebandResult.bands[0].rawMap)),
|
||||
"same ADC data is tested against a distinct configured spatial wavelength",
|
||||
);
|
||||
});
|
||||
|
||||
test("near-field MUSIC applies the configured analog carrier and inversion without hiding grating lobes", async () => {
|
||||
const sampleRate = 8192;
|
||||
const fftSize = 128;
|
||||
const speedOfSound = 343;
|
||||
const basebandFrequency = 1024;
|
||||
const invertedAnalogFrequency = sampleRate - basebandFrequency;
|
||||
const source = [0.1, -0.1, 1];
|
||||
const arrayDefinition = {
|
||||
id: "physical-music-array",
|
||||
microphones: [
|
||||
{ id: "nw", position: [-0.12, 0.12, 0] },
|
||||
{ id: "ne", position: [0.12, 0.12, 0] },
|
||||
{ id: "sw", position: [-0.12, -0.12, 0] },
|
||||
{ id: "se", position: [0.12, -0.12, 0] },
|
||||
],
|
||||
};
|
||||
const channelsForAnalogFrequency = (frequency) =>
|
||||
arrayDefinition.microphones.map((microphone) => {
|
||||
const distance = Math.hypot(
|
||||
source[0] - microphone.position[0],
|
||||
source[1] - microphone.position[1],
|
||||
source[2] - microphone.position[2],
|
||||
);
|
||||
return Float64Array.from(
|
||||
{ length: 384 },
|
||||
(_, sampleIndex) =>
|
||||
Math.cos(
|
||||
2 * Math.PI * frequency *
|
||||
(sampleIndex / sampleRate - distance / speedOfSound),
|
||||
) / distance,
|
||||
);
|
||||
});
|
||||
const experimentForBand = (band) => ({
|
||||
stft: { fftSize, hopSize: 64, window: "hann" },
|
||||
scan: {
|
||||
z: 1,
|
||||
xMin: -0.2,
|
||||
xMax: 0.2,
|
||||
yMin: -0.2,
|
||||
yMax: 0.2,
|
||||
columns: 5,
|
||||
rows: 5,
|
||||
},
|
||||
frequencyBands: [{ id: "tone", color: "#65f2bf", ...band }],
|
||||
beamforming: {
|
||||
algorithm: "music",
|
||||
backend: "cpu",
|
||||
minimumDistance: 0.01,
|
||||
normalization: "none",
|
||||
compensateFrequencyResponse: true,
|
||||
musicSourceCountMethod: "fixed",
|
||||
musicSourceCount: 1,
|
||||
},
|
||||
});
|
||||
const common = {
|
||||
sampleRate,
|
||||
arrayDefinition,
|
||||
scene: { speedOfSound },
|
||||
};
|
||||
const baseband = await computeLocalizationMap({
|
||||
...common,
|
||||
channels: channelsForAnalogFrequency(basebandFrequency),
|
||||
experiment: experimentForBand({ minHz: 1000, maxHz: 1050 }),
|
||||
});
|
||||
const truthIndex = 18;
|
||||
assertEqual(baseband.bands[0].peak.index, truthIndex, "baseband physical truth minimizes q");
|
||||
const truthQ = baseband.bands[0].rawMap[truthIndex];
|
||||
assert(
|
||||
baseband.bands[0].rawMap.every((value) => value >= truthQ),
|
||||
"truth q is the global minimum",
|
||||
);
|
||||
|
||||
const aliased = await computeLocalizationMap({
|
||||
...common,
|
||||
channels: channelsForAnalogFrequency(invertedAnalogFrequency),
|
||||
experiment: experimentForBand({ minHz: 7140, maxHz: 7190 }),
|
||||
});
|
||||
assertEqual(aliased.bands[0].bins.join(","), "16", "analog carrier folds to observed bin 16");
|
||||
assertEqual(
|
||||
aliased.bands[0].observedFrequencyHz,
|
||||
basebandFrequency,
|
||||
"covariance still uses the observed ADC frequency",
|
||||
);
|
||||
assertEqual(
|
||||
aliased.bands[0].analogFrequencyHz,
|
||||
invertedAnalogFrequency,
|
||||
"manifold uses the configured analog carrier",
|
||||
);
|
||||
assertEqual(
|
||||
aliased.bands[0].signedAdcFrequencyHz,
|
||||
-basebandFrequency,
|
||||
"inverted signed ADC frequency is explicit",
|
||||
);
|
||||
assert(aliased.bands[0].spectralInversion, "full received manifold is conjugated");
|
||||
const aliasedTruthQ = aliased.bands[0].rawMap[truthIndex];
|
||||
const aliasedMinimumQ = Math.min(...aliased.bands[0].rawMap);
|
||||
assert(
|
||||
Math.abs(aliasedTruthQ - aliasedMinimumQ) <= 1e-10,
|
||||
"physical truth is a q minimum while grating-lobe ties remain allowed",
|
||||
);
|
||||
assertEqual(aliased.model.originalCarrierUsed, true, "configured carrier is used");
|
||||
assertEqual(
|
||||
aliased.model.aliasOrderResolution,
|
||||
"configured-single-hypothesis-no-selection",
|
||||
"no data-driven alias-order choice is claimed",
|
||||
);
|
||||
});
|
||||
|
||||
test("MUSIC conjugates analog microphone phase and preserves capped residual gain in inverted zones", () => {
|
||||
const arrayDefinition = {
|
||||
microphones: [{
|
||||
id: "calibrated",
|
||||
position: [0, 0, 0],
|
||||
phaseOffsetDeg: 20,
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 3_000, gainDb: 0, phaseDeg: 0 },
|
||||
{ frequencyHz: 5_000, gainDb: -30, phaseDeg: 40 },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const corrected = createMusicBinCalibration({
|
||||
arrayDefinition,
|
||||
bin: 48,
|
||||
sampleRate: 8_000,
|
||||
fftSize: 128,
|
||||
analogFrequencyHz: 5_000,
|
||||
signedAdcFrequencyHz: -3_000,
|
||||
spectralInversion: true,
|
||||
compensateFrequencyResponse: true,
|
||||
maxCompensationDb: 24,
|
||||
});
|
||||
assert(
|
||||
Math.abs(corrected.dataPhases[0] - Math.PI / 3) <= 1e-12,
|
||||
"inverted positive-bin calibration conjugates 20° + 40°",
|
||||
);
|
||||
assert(
|
||||
Math.abs(
|
||||
20 * Math.log10(corrected.manifoldGains[0]) + 6
|
||||
) <= 1e-9,
|
||||
"-30 dB response with 24 dB inverse cap leaves -6 dB",
|
||||
);
|
||||
assertEqual(corrected.manifoldPhases[0], 0, "compensated phase has no residual");
|
||||
|
||||
const raw = createMusicBinCalibration({
|
||||
arrayDefinition,
|
||||
bin: 48,
|
||||
sampleRate: 8_000,
|
||||
fftSize: 128,
|
||||
analogFrequencyHz: 5_000,
|
||||
signedAdcFrequencyHz: -3_000,
|
||||
spectralInversion: true,
|
||||
compensateFrequencyResponse: false,
|
||||
});
|
||||
assert(
|
||||
Math.abs(raw.dataPhases[0] - 20 * Math.PI / 180) <= 1e-12,
|
||||
"fixed phase offset correction follows inversion sign",
|
||||
);
|
||||
assert(
|
||||
Math.abs(raw.manifoldPhases[0] + 40 * Math.PI / 180) <= 1e-12,
|
||||
"uncompensated analog response phase remains conjugated in the manifold",
|
||||
);
|
||||
});
|
||||
|
||||
test("portable MUSIC plan retains its physical and source-count configuration", () => {
|
||||
const fixture = createFixture("music");
|
||||
const plan = createLocalizationPlan({
|
||||
sampleRate: fixture.sampleRate,
|
||||
array: fixture.arrayDefinition,
|
||||
scan: fixture.experiment.scan,
|
||||
bands: fixture.experiment.frequencyBands,
|
||||
speedOfSound: fixture.scene.speedOfSound,
|
||||
stft: fixture.experiment.stft,
|
||||
algorithm: "music",
|
||||
minimumDistance: 0.025,
|
||||
musicSourceCountMethod: "fixed",
|
||||
musicSourceCount: 1,
|
||||
});
|
||||
assertEqual(plan.normalization, "none", "portable MUSIC defaults to no map normalization");
|
||||
assertEqual(plan.minimumDistance, 0.025, "minimum distance is retained");
|
||||
assertEqual(plan.experiment.beamforming.musicSourceCount, 1, "fixed K reaches core experiment");
|
||||
assertEqual(plan.musicPlan, plan.analysisPlan, "portable MUSIC plan has a dedicated alias");
|
||||
assert(!("gccPhatPlan" in plan), "MUSIC is not mislabeled as a GCC plan");
|
||||
assert(!("crossSpectrumBatchSize" in plan.computeOptions), "GCC-only batching is omitted");
|
||||
});
|
||||
|
||||
test("MUSIC rejects peak normalization at both direct and portable API boundaries", () => {
|
||||
const direct = createFixture("music");
|
||||
direct.experiment.beamforming.normalization = "peak";
|
||||
let directError;
|
||||
try {
|
||||
createLocalizationAnalysisPlan(direct);
|
||||
} catch (error) {
|
||||
directError = error;
|
||||
}
|
||||
assert(
|
||||
directError instanceof RangeError &&
|
||||
directError.message.includes("normalization"),
|
||||
"direct core rejects peak normalization",
|
||||
);
|
||||
|
||||
let portableError;
|
||||
try {
|
||||
createLocalizationPlan({
|
||||
sampleRate: direct.sampleRate,
|
||||
array: direct.arrayDefinition,
|
||||
scan: direct.experiment.scan,
|
||||
bands: direct.experiment.frequencyBands,
|
||||
speedOfSound: direct.scene.speedOfSound,
|
||||
stft: direct.experiment.stft,
|
||||
algorithm: "music",
|
||||
normalization: "peak",
|
||||
musicSourceCount: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
portableError = error;
|
||||
}
|
||||
assert(
|
||||
portableError instanceof RangeError &&
|
||||
portableError.message.includes("normalization"),
|
||||
"portable API rejects peak normalization",
|
||||
);
|
||||
});
|
||||
|
||||
test("MUSIC MDL/AIC workload excludes a zero-padded partial snapshot", () => {
|
||||
const fixture = createFixture("music");
|
||||
const completeSampleCount = 6 * fixture.experiment.stft.fftSize;
|
||||
const partialSampleCount = completeSampleCount + 10;
|
||||
fixture.channels = fixture.channels.map((channel) =>
|
||||
Float64Array.from(
|
||||
{ length: partialSampleCount },
|
||||
(_, index) => channel[index % channel.length],
|
||||
));
|
||||
fixture.experiment.stft.hopSize = fixture.experiment.stft.fftSize;
|
||||
fixture.experiment.beamforming.musicSourceCountMethod = "mdl";
|
||||
delete fixture.experiment.beamforming.musicSourceCount;
|
||||
|
||||
const workload = estimateLocalizationWorkload(fixture);
|
||||
assertEqual(workload.frameCount, 6, "only six complete non-overlapping frames");
|
||||
assertEqual(
|
||||
workload.sampleCount,
|
||||
partialSampleCount,
|
||||
"input length remains disclosed without treating its tail as a snapshot",
|
||||
);
|
||||
});
|
||||
|
||||
test("portable and streaming API accept an algorithm choice without Scene synthesis", async () => {
|
||||
const fixture = createFixture("gcc-phat");
|
||||
const config = {
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
import {
|
||||
auditCovarianceEigenvalues,
|
||||
computeComplexSampleCovariance,
|
||||
selectWaxKailathSourceCount,
|
||||
} from "/packages/acoustics-core/covariance.js";
|
||||
import {
|
||||
decomposeHermitian,
|
||||
estimateHermitianEigenWorkload,
|
||||
} from "/packages/acoustics-core/hermitian-eigen.js";
|
||||
import {
|
||||
computeMusicMap,
|
||||
estimateMusicWorkload,
|
||||
} from "/packages/acoustics-core/music.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertArrayNear(actual, expected, tolerance, label) {
|
||||
assertEqual(actual.length, expected.length, `${label} length`);
|
||||
for (let index = 0; index < actual.length; index += 1) {
|
||||
assertNear(actual[index], expected[index], tolerance, `${label}[${index}]`);
|
||||
}
|
||||
}
|
||||
|
||||
function dftUnitary(order) {
|
||||
const real = new Float64Array(order * order);
|
||||
const imaginary = new Float64Array(order * order);
|
||||
const scale = 1 / Math.sqrt(order);
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
for (let column = 0; column < order; column += 1) {
|
||||
const phase = (2 * Math.PI * row * column) / order;
|
||||
const index = row * order + column;
|
||||
real[index] = Math.cos(phase) * scale;
|
||||
imaginary[index] = Math.sin(phase) * scale;
|
||||
}
|
||||
}
|
||||
return { real, imaginary };
|
||||
}
|
||||
|
||||
function hermitianFromEigenpairs(vectors, eigenvalues) {
|
||||
const order = eigenvalues.length;
|
||||
const real = new Float64Array(order * order);
|
||||
const imaginary = new Float64Array(order * order);
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
for (let column = row; column < order; column += 1) {
|
||||
let valueReal = 0;
|
||||
let valueImaginary = 0;
|
||||
for (let eigenvector = 0; eigenvector < order; eigenvector += 1) {
|
||||
const rowIndex = row * order + eigenvector;
|
||||
const columnIndex = column * order + eigenvector;
|
||||
const rowReal = vectors.real[rowIndex];
|
||||
const rowImaginary = vectors.imaginary[rowIndex];
|
||||
const columnReal = vectors.real[columnIndex];
|
||||
const columnImaginary = vectors.imaginary[columnIndex];
|
||||
valueReal += eigenvalues[eigenvector] *
|
||||
(rowReal * columnReal + rowImaginary * columnImaginary);
|
||||
valueImaginary += eigenvalues[eigenvector] *
|
||||
(rowImaginary * columnReal - rowReal * columnImaginary);
|
||||
}
|
||||
const upper = row * order + column;
|
||||
const lower = column * order + row;
|
||||
real[upper] = valueReal;
|
||||
imaginary[upper] = row === column ? 0 : valueImaginary;
|
||||
real[lower] = valueReal;
|
||||
imaginary[lower] = row === column ? 0 : -valueImaginary;
|
||||
}
|
||||
}
|
||||
return { real, imaginary };
|
||||
}
|
||||
|
||||
function projector(vectors, order, firstColumn, columnCount) {
|
||||
const real = new Float64Array(order * order);
|
||||
const imaginary = new Float64Array(order * order);
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
for (let column = 0; column < order; column += 1) {
|
||||
let valueReal = 0;
|
||||
let valueImaginary = 0;
|
||||
for (let vector = firstColumn; vector < firstColumn + columnCount; vector += 1) {
|
||||
const rowIndex = row * order + vector;
|
||||
const columnIndex = column * order + vector;
|
||||
valueReal +=
|
||||
vectors.real[rowIndex] * vectors.real[columnIndex] +
|
||||
vectors.imaginary[rowIndex] * vectors.imaginary[columnIndex];
|
||||
valueImaginary +=
|
||||
vectors.imaginary[rowIndex] * vectors.real[columnIndex] -
|
||||
vectors.real[rowIndex] * vectors.imaginary[columnIndex];
|
||||
}
|
||||
const index = row * order + column;
|
||||
real[index] = valueReal;
|
||||
imaginary[index] = valueImaginary;
|
||||
}
|
||||
}
|
||||
return { real, imaginary };
|
||||
}
|
||||
|
||||
function createAutomaticMusicFixture() {
|
||||
const sampleRate = 3_200;
|
||||
const fftSize = 32;
|
||||
const snapshotCount = 64;
|
||||
const frequencyHz = 500;
|
||||
const speedOfSound = 343;
|
||||
const source = [0.08, -0.04, 1];
|
||||
const arrayDefinition = {
|
||||
id: "automatic-music-audit-array",
|
||||
microphones: [
|
||||
{ id: "nw", position: [-0.1, 0.1, 0] },
|
||||
{ id: "ne", position: [0.1, 0.1, 0] },
|
||||
{ id: "sw", position: [-0.1, -0.1, 0] },
|
||||
{ id: "se", position: [0.1, -0.1, 0] },
|
||||
],
|
||||
};
|
||||
let state = 0x51f15e5d;
|
||||
const random = () => {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return ((state >>> 0) / 0xffff_ffff) * 2 - 1;
|
||||
};
|
||||
const sourceRealBySnapshot = Float64Array.from(
|
||||
{ length: snapshotCount },
|
||||
random,
|
||||
);
|
||||
const sourceImaginaryBySnapshot = Float64Array.from(
|
||||
{ length: snapshotCount },
|
||||
random,
|
||||
);
|
||||
const channels = arrayDefinition.microphones.map((microphone) => {
|
||||
const distance = Math.hypot(
|
||||
source[0] - microphone.position[0],
|
||||
source[1] - microphone.position[1],
|
||||
source[2] - microphone.position[2],
|
||||
);
|
||||
const propagationPhase = -(2 * Math.PI * frequencyHz * distance) / speedOfSound;
|
||||
const channel = new Float64Array(snapshotCount * fftSize);
|
||||
for (let snapshot = 0; snapshot < snapshotCount; snapshot += 1) {
|
||||
const sourceReal = sourceRealBySnapshot[snapshot];
|
||||
const sourceImaginary = sourceImaginaryBySnapshot[snapshot];
|
||||
for (let offset = 0; offset < fftSize; offset += 1) {
|
||||
const phase = (2 * Math.PI * frequencyHz * offset) / sampleRate + propagationPhase;
|
||||
channel[snapshot * fftSize + offset] =
|
||||
4 * (sourceReal * Math.cos(phase) - sourceImaginary * Math.sin(phase)) /
|
||||
distance +
|
||||
0.08 * random();
|
||||
}
|
||||
}
|
||||
return channel;
|
||||
});
|
||||
const experiment = {
|
||||
stft: { fftSize, hopSize: fftSize, window: "hann" },
|
||||
scan: {
|
||||
z: source[2],
|
||||
xMin: source[0],
|
||||
xMax: source[0],
|
||||
yMin: source[1],
|
||||
yMax: source[1],
|
||||
columns: 1,
|
||||
rows: 1,
|
||||
},
|
||||
frequencyBands: [
|
||||
{ id: "tone", minHz: 499, maxHz: 501, color: "#65f2bf" },
|
||||
],
|
||||
beamforming: {
|
||||
algorithm: "music",
|
||||
backend: "cpu",
|
||||
normalization: "none",
|
||||
compensateFrequencyResponse: true,
|
||||
musicSourceCountMethod: "mdl",
|
||||
},
|
||||
};
|
||||
return {
|
||||
channels,
|
||||
sampleRate,
|
||||
arrayDefinition,
|
||||
scene: { speedOfSound },
|
||||
experiment,
|
||||
};
|
||||
}
|
||||
|
||||
test("complex Hermitian Jacobi resolves the [4, 1] two-by-two reference", () => {
|
||||
const result = decomposeHermitian({
|
||||
real: Float64Array.of(2, 1, 1, 3),
|
||||
imaginary: Float64Array.of(0, 1, -1, 0),
|
||||
});
|
||||
assert(result.converged, "2x2 Jacobi convergence");
|
||||
assertArrayNear(result.eigenvalues, [4, 1], 2e-14, "2x2 eigenvalues");
|
||||
assertEqual(result.eigenvectors.layout, "row-major-columns", "vector layout");
|
||||
assert(result.relativeResidual < 1e-14, "2x2 relative residual");
|
||||
assert(result.orthogonalityError < 1e-14, "2x2 orthogonality");
|
||||
assert(result.numericallyVerified, "2x2 numerical verification");
|
||||
});
|
||||
|
||||
test("Hermitian Jacobi diagonalizes a constructed DFT4 matrix", () => {
|
||||
const vectors = dftUnitary(4);
|
||||
const matrix = hermitianFromEigenpairs(vectors, [9, 4, 2, 1]);
|
||||
const result = decomposeHermitian(matrix);
|
||||
assert(result.converged, "DFT4 convergence");
|
||||
assertArrayNear(result.eigenvalues, [9, 4, 2, 1], 2e-12, "DFT4 eigenvalues");
|
||||
assert(result.relativeResidual < 2e-13, "DFT4 relative residual");
|
||||
assert(result.relativeOffDiagonal < result.relativeTolerance, "DFT4 off-diagonal bound");
|
||||
});
|
||||
|
||||
test("repeated Hermitian eigenspaces preserve their invariant projector", () => {
|
||||
const expectedVectors = dftUnitary(4);
|
||||
const matrix = hermitianFromEigenpairs(expectedVectors, [5, 5, 1, 1]);
|
||||
const result = decomposeHermitian(matrix);
|
||||
assert(result.converged, "repeated-eigenspace convergence");
|
||||
assertArrayNear(result.eigenvalues, [5, 5, 1, 1], 2e-12, "repeated eigenvalues");
|
||||
const expectedProjector = projector(expectedVectors, 4, 0, 2);
|
||||
const actualProjector = projector(result.eigenvectors, 4, 0, 2);
|
||||
assertArrayNear(actualProjector.real, expectedProjector.real, 2e-12, "projector real");
|
||||
assertArrayNear(
|
||||
actualProjector.imaginary,
|
||||
expectedProjector.imaginary,
|
||||
2e-12,
|
||||
"projector imaginary",
|
||||
);
|
||||
});
|
||||
|
||||
test("Hermitian eigenpairs are deterministic and phase-canonicalized", () => {
|
||||
const matrix = hermitianFromEigenpairs(dftUnitary(4), [12, 7, 3, 0.5]);
|
||||
const first = decomposeHermitian(matrix);
|
||||
const second = decomposeHermitian(matrix);
|
||||
assertArrayNear(second.eigenvalues, first.eigenvalues, 0, "deterministic eigenvalues");
|
||||
assertArrayNear(second.eigenvectors.real, first.eigenvectors.real, 0, "deterministic real vectors");
|
||||
assertArrayNear(
|
||||
second.eigenvectors.imaginary,
|
||||
first.eigenvectors.imaginary,
|
||||
0,
|
||||
"deterministic imaginary vectors",
|
||||
);
|
||||
for (let column = 0; column < first.order; column += 1) {
|
||||
let maximumMagnitude = 0;
|
||||
for (let row = 0; row < first.order; row += 1) {
|
||||
const index = row * first.order + column;
|
||||
const magnitude = Math.hypot(
|
||||
first.eigenvectors.real[index],
|
||||
first.eigenvectors.imaginary[index],
|
||||
);
|
||||
maximumMagnitude = Math.max(maximumMagnitude, magnitude);
|
||||
}
|
||||
let canonicalPivotFound = false;
|
||||
for (let row = 0; row < first.order; row += 1) {
|
||||
const index = row * first.order + column;
|
||||
const magnitude = Math.hypot(
|
||||
first.eigenvectors.real[index],
|
||||
first.eigenvectors.imaginary[index],
|
||||
);
|
||||
if (
|
||||
magnitude >= maximumMagnitude * (1 - 16 * Number.EPSILON) &&
|
||||
first.eigenvectors.real[index] > 0 &&
|
||||
first.eigenvectors.imaginary[index] === 0
|
||||
) {
|
||||
canonicalPivotFound = true;
|
||||
}
|
||||
}
|
||||
assert(canonicalPivotFound, `column ${column} has a positive-real maximal pivot`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Hermitian Jacobi reports non-convergence without returning it as success", () => {
|
||||
const result = decomposeHermitian({
|
||||
real: Float64Array.of(
|
||||
4, 1, 0.5,
|
||||
1, 3, 1.5,
|
||||
0.5, 1.5, 2,
|
||||
),
|
||||
imaginary: Float64Array.of(
|
||||
0, 0.5, -0.3,
|
||||
-0.5, 0, 0.7,
|
||||
0.3, -0.7, 0,
|
||||
),
|
||||
maximumSweeps: 1,
|
||||
});
|
||||
assert(!result.converged, "one-sweep result is explicitly unconverged");
|
||||
assertEqual(result.sweeps, 1, "sweep limit is exact");
|
||||
assert(!result.numericallyVerified, "partial eigenpairs are not verified");
|
||||
assert(result.relativeResidual > result.relativeTolerance, "unconverged residual remains visible");
|
||||
});
|
||||
|
||||
test("Hermitian workload rejects M=1024 before reading or allocating matrix data", () => {
|
||||
const order = 1024;
|
||||
const workload = estimateHermitianEigenWorkload({ order });
|
||||
assert(workload.allocationBytes > 16 * 1024 * 1024, "M=1024 exceeds default bytes");
|
||||
let numericRead = false;
|
||||
const fakeMatrix = new Proxy(
|
||||
{ length: order * order },
|
||||
{
|
||||
get(target, property) {
|
||||
if (typeof property === "string" && /^\d+$/.test(property)) numericRead = true;
|
||||
return target[property];
|
||||
},
|
||||
},
|
||||
);
|
||||
let error;
|
||||
try {
|
||||
decomposeHermitian({ real: fakeMatrix, imaginary: fakeMatrix, order });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
assert(error instanceof RangeError, "M=1024 is rejected by the workload cap");
|
||||
assert(!numericRead, "workload cap fires before matrix values are read");
|
||||
});
|
||||
|
||||
test("sample covariance uses compensated upper-triangle accumulation and exact conjugates", () => {
|
||||
const cancellation = computeComplexSampleCovariance({
|
||||
sensorCount: 2,
|
||||
snapshotCount: 3,
|
||||
layout: "snapshot-major",
|
||||
real: Float64Array.of(1, 1e16, 1, 1, 1, -1e16),
|
||||
imaginary: new Float64Array(6),
|
||||
});
|
||||
assertNear(cancellation.matrix.real[1], 1 / 3, 1e-16, "compensated cross covariance");
|
||||
|
||||
const complex = computeComplexSampleCovariance({
|
||||
sensorCount: 2,
|
||||
snapshotCount: 1,
|
||||
real: Float64Array.of(1, 2),
|
||||
imaginary: Float64Array.of(1, 3),
|
||||
});
|
||||
assertNear(complex.matrix.real[1], 5, 0, "upper real");
|
||||
assertNear(complex.matrix.imaginary[1], -1, 0, "upper imaginary");
|
||||
assertNear(complex.matrix.real[2], 5, 0, "lower real");
|
||||
assertNear(complex.matrix.imaginary[2], 1, 0, "exact conjugate lower imaginary");
|
||||
assertEqual(complex.matrix.imaginary[0], 0, "real diagonal 0");
|
||||
assertEqual(complex.normalization, "1/T", "covariance normalization disclosed");
|
||||
assertEqual(complex.centered, false, "covariance is not silently centered");
|
||||
});
|
||||
|
||||
test("T<M rank-two covariance remains rank-deficient and source count is unavailable", () => {
|
||||
const sensorCount = 4;
|
||||
const snapshotCount = 2;
|
||||
const covariance = computeComplexSampleCovariance({
|
||||
sensorCount,
|
||||
snapshotCount,
|
||||
real: Float64Array.of(1, 2, 3, 4, 0, 1, 2, -1),
|
||||
imaginary: Float64Array.of(0, 0, 0, 0, 1, -1, 0.5, 2),
|
||||
});
|
||||
const decomposition = decomposeHermitian({
|
||||
...covariance.matrix,
|
||||
order: sensorCount,
|
||||
});
|
||||
assert(decomposition.converged, "rank-two covariance decomposition");
|
||||
const audit = auditCovarianceEigenvalues({
|
||||
eigenvalues: decomposition.eigenvalues,
|
||||
sensorCount,
|
||||
matrixScale: covariance.matrixScale,
|
||||
covarianceRoundoffBound: covariance.covarianceRoundoffBound,
|
||||
eigensolverResidualFrobeniusNorm: decomposition.residualFrobeniusNorm,
|
||||
});
|
||||
assertEqual(
|
||||
audit.roundoffDerivation,
|
||||
"covariance-accumulation-plus-eigensolver-residual",
|
||||
"PSD roundoff derivation",
|
||||
);
|
||||
assertNear(audit.roundoffClampedEigenvalues[2], 0, audit.roundoffTolerance, "rank eigenvalue 2");
|
||||
assertNear(audit.roundoffClampedEigenvalues[3], 0, audit.roundoffTolerance, "rank eigenvalue 3");
|
||||
const selected = selectWaxKailathSourceCount({
|
||||
eigenvalues: audit.roundoffClampedEigenvalues,
|
||||
snapshotCount,
|
||||
independentSnapshots: true,
|
||||
criterion: "mdl",
|
||||
roundoffTolerance: audit.roundoffTolerance,
|
||||
});
|
||||
assert(!selected.available, "T<M MDL unavailable");
|
||||
assertEqual(
|
||||
selected.reason,
|
||||
"snapshot-count-not-greater-than-sensors",
|
||||
"T<M reason",
|
||||
);
|
||||
});
|
||||
|
||||
test("Wax-Kailath explicitly declines noiseless rank-deficient spectra", () => {
|
||||
const result = selectWaxKailathSourceCount({
|
||||
eigenvalues: Float64Array.of(5, 2, 0, 0),
|
||||
snapshotCount: 100,
|
||||
independentSnapshots: true,
|
||||
criterion: "mdl",
|
||||
roundoffTolerance: 1e-12,
|
||||
});
|
||||
assert(!result.available, "rank-deficient MDL unavailable");
|
||||
assertEqual(
|
||||
result.reason,
|
||||
"noise-eigenvalue-not-strictly-above-roundoff",
|
||||
"rank-deficient reason",
|
||||
);
|
||||
});
|
||||
|
||||
test("Wax-Kailath AIC and MDL select q=1 for [10,1,1,1]", () => {
|
||||
for (const criterion of ["aic", "mdl"]) {
|
||||
const result = selectWaxKailathSourceCount({
|
||||
eigenvalues: Float64Array.of(10, 1, 1, 1),
|
||||
snapshotCount: 100,
|
||||
independentSnapshots: true,
|
||||
criterion,
|
||||
roundoffTolerance: 1e-14,
|
||||
});
|
||||
assert(result.available, `${criterion} available`);
|
||||
assertEqual(result.sourceCount, 1, `${criterion} source count`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Wax-Kailath AIC and MDL select q=0 for an identity covariance", () => {
|
||||
for (const criterion of ["aic", "mdl"]) {
|
||||
const result = selectWaxKailathSourceCount({
|
||||
eigenvalues: Float64Array.of(1, 1, 1, 1),
|
||||
snapshotCount: 100,
|
||||
independentSnapshots: true,
|
||||
criterion,
|
||||
roundoffTolerance: 1e-14,
|
||||
});
|
||||
assert(result.available, `${criterion} available`);
|
||||
assertEqual(result.sourceCount, 0, `${criterion} identity source count`);
|
||||
}
|
||||
});
|
||||
|
||||
test("PSD audit preserves tiny positives and rejects genuine negative eigenvalues", () => {
|
||||
const tinyPositive = 1e-20;
|
||||
const audit = auditCovarianceEigenvalues({
|
||||
eigenvalues: Float64Array.of(10, 1, tinyPositive, -1e-14),
|
||||
sensorCount: 4,
|
||||
matrixScale: 10,
|
||||
roundoffTolerance: 1e-12,
|
||||
});
|
||||
assertEqual(audit.rawEigenvalues[2], tinyPositive, "raw tiny positive");
|
||||
assertEqual(
|
||||
audit.roundoffClampedEigenvalues[2],
|
||||
tinyPositive,
|
||||
"tiny positive is not floored",
|
||||
);
|
||||
assertEqual(audit.roundoffClampedEigenvalues[3], 0, "roundoff negative clamps to zero");
|
||||
let error;
|
||||
try {
|
||||
auditCovarianceEigenvalues({
|
||||
eigenvalues: Float64Array.of(10, 1, 0.1, -1e-3),
|
||||
sensorCount: 4,
|
||||
roundoffTolerance: 1e-12,
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
assert(error instanceof RangeError, "genuine negative eigenvalue is rejected");
|
||||
});
|
||||
|
||||
test("MUSIC workload includes PSD-audit and automatic source-count typed arrays", () => {
|
||||
const automatic = createAutomaticMusicFixture();
|
||||
const automaticWorkload = estimateMusicWorkload(automatic);
|
||||
const microphoneCount = automatic.arrayDefinition.microphones.length;
|
||||
const fixed = {
|
||||
...automatic,
|
||||
experiment: structuredClone(automatic.experiment),
|
||||
};
|
||||
fixed.experiment.beamforming.musicSourceCountMethod = "fixed";
|
||||
fixed.experiment.beamforming.musicSourceCount = 1;
|
||||
const fixedWorkload = estimateMusicWorkload(fixed);
|
||||
|
||||
assertEqual(
|
||||
automaticWorkload.covarianceAuditBytes,
|
||||
2 * microphoneCount * Float64Array.BYTES_PER_ELEMENT,
|
||||
"raw and roundoff-clamped PSD audit arrays",
|
||||
);
|
||||
assertEqual(fixedWorkload.sourceCountSelectionBytes, 0, "fixed K has no score array");
|
||||
assertEqual(
|
||||
automaticWorkload.sourceCountSelectionBytes,
|
||||
microphoneCount * Float64Array.BYTES_PER_ELEMENT,
|
||||
"automatic K criterion score array",
|
||||
);
|
||||
assertEqual(
|
||||
automaticWorkload.perBinNumericalBytes - fixedWorkload.perBinNumericalBytes,
|
||||
automaticWorkload.sourceCountSelectionBytes,
|
||||
"automatic score bytes reach per-bin numerical peak",
|
||||
);
|
||||
assertEqual(
|
||||
automaticWorkload.baseWorkingBytes - fixedWorkload.baseWorkingBytes,
|
||||
automaticWorkload.sourceCountSelectionBytes,
|
||||
"automatic score bytes reach total working peak",
|
||||
);
|
||||
});
|
||||
|
||||
test("MUSIC reports PSD derivation and labels MDL independence as an unverified model assumption", async () => {
|
||||
const result = await computeMusicMap(createAutomaticMusicFixture());
|
||||
const diagnostics = result.bands[0].diagnostics;
|
||||
assertEqual(diagnostics.sourceCount.method, "mdl", "MDL selected model order");
|
||||
assertEqual(diagnostics.sourceCount.value, 1, "one dominant source selected");
|
||||
assertEqual(
|
||||
diagnostics.covarianceAudit.roundoffDerivation,
|
||||
"covariance-accumulation-plus-eigensolver-residual",
|
||||
"PSD roundoff derivation is serialized from the audit API",
|
||||
);
|
||||
const independence =
|
||||
diagnostics.sourceCount.assumptions.statisticalSnapshotIndependence;
|
||||
assertEqual(independence.assumed, true, "statistical independence is an assumption");
|
||||
assertEqual(
|
||||
independence.evidence,
|
||||
"non-overlapping-frames-only-not-statistical-proof",
|
||||
"non-overlap is not mislabeled as statistical proof",
|
||||
);
|
||||
assertEqual(independence.modelSelectedByUser, true, "MDL model was selected by the user");
|
||||
assertEqual(
|
||||
diagnostics.sourceCount.assumptions.statisticalDataModel.verified,
|
||||
false,
|
||||
"Wax-Kailath data model is not claimed as verified",
|
||||
);
|
||||
});
|
||||
@@ -165,6 +165,98 @@ test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강
|
||||
);
|
||||
});
|
||||
|
||||
test("portable peak detection treats MUSIC noise projection as a minimum", () => {
|
||||
const scan = {
|
||||
z: 1,
|
||||
xMin: 0,
|
||||
xMax: 4,
|
||||
yMin: 0,
|
||||
yMax: 4,
|
||||
columns: 5,
|
||||
rows: 5,
|
||||
};
|
||||
const q = new Float64Array(25);
|
||||
q.fill(1);
|
||||
q[6] = 0.1;
|
||||
q[18] = 0.2;
|
||||
q[3] = 0.6;
|
||||
|
||||
const peaks = detectBandPeaks(q, scan, {
|
||||
rawScale: "noise-projection",
|
||||
objectiveDirection: "minimum",
|
||||
});
|
||||
assertEqual(peaks.length, 2, "two q minima exceed the merit threshold");
|
||||
assertEqual(peaks[0].index, 6, "smallest q ranks first");
|
||||
assert(
|
||||
Math.abs(peaks[0].noiseProjection - 0.1) <= 1e-12,
|
||||
"raw q is retained",
|
||||
);
|
||||
assertEqual(peaks[0].objectiveDirection, "minimum", "minimum objective is explicit");
|
||||
assertEqual(
|
||||
detectBandPeaks(
|
||||
new Float64Array(25).fill(0.5),
|
||||
scan,
|
||||
{
|
||||
rawScale: "noise-projection",
|
||||
objectiveDirection: "minimum",
|
||||
},
|
||||
).length,
|
||||
0,
|
||||
"flat q does not choose the first grid cell",
|
||||
);
|
||||
});
|
||||
|
||||
test("portable and streaming MUSIC consume unavailable auto-K windows without stalling", async () => {
|
||||
const fixture = createFixture(256);
|
||||
const musicConfig = {
|
||||
...fixture.config,
|
||||
algorithm: "music",
|
||||
normalization: "none",
|
||||
stft: { fftSize: 64, hopSize: 64 },
|
||||
musicSourceCountMethod: "mdl",
|
||||
musicSourceCount: 1,
|
||||
};
|
||||
const plan = createLocalizationPlan(musicConfig);
|
||||
const block = await localizeMultichannel({
|
||||
plan,
|
||||
channels: sliceChannels(fixture.channels, 0, 128),
|
||||
});
|
||||
assertEqual(block.status, "unavailable", "short auto-K block is typed unavailable");
|
||||
assertEqual(
|
||||
block.unavailable.reason,
|
||||
"insufficient-snapshots-for-source-count-selection",
|
||||
"unavailable reason is explicit",
|
||||
);
|
||||
assertEqual(block.frameCount, 2, "unavailable result preserves analyzed frame count");
|
||||
assertEqual(block.bands.length, 0, "no arbitrary MUSIC map");
|
||||
assertEqual(block.locations.length, 0, "no arbitrary location");
|
||||
|
||||
const stream = createStreamingLocalizer({
|
||||
...musicConfig,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 128,
|
||||
maximumBufferedSamples: 256,
|
||||
maximumResultsPerCall: 2,
|
||||
});
|
||||
stream.append(fixture.channels);
|
||||
const results = await stream.analyzeAvailable({
|
||||
maximumResults: 2,
|
||||
});
|
||||
assertEqual(results.length, 2, "both unavailable windows are emitted");
|
||||
assert(
|
||||
results.every(
|
||||
(result) =>
|
||||
result.status === "unavailable" &&
|
||||
result.locations.length === 0,
|
||||
),
|
||||
"each window remains explicitly location-free",
|
||||
);
|
||||
const streamState = stream.getState();
|
||||
assertEqual(streamState.completedAnalyses, 2, "unavailable windows commit");
|
||||
assertEqual(streamState.failedAnalyses, 0, "unavailable is not a runtime failure");
|
||||
assertEqual(streamState.bufferedSamples, 0, "stream advances past both windows");
|
||||
});
|
||||
|
||||
test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 실제로 제거한다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const plan = createLocalizationPlan(fixture.config);
|
||||
|
||||
Reference in New Issue
Block a user