feat: add physically explicit narrowband MUSIC

This commit is contained in:
2026-08-29 12:40:48 +09:00
parent 044be3cf92
commit 11fee76280
40 changed files with 6473 additions and 275 deletions
+7 -1
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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"),
+100
View File
@@ -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");
});
+364 -5
View File
@@ -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 = {
+507
View File
@@ -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",
);
});
+92
View File
@@ -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);