Files
micarrsim/tests/unit/gcc-phat.test.js
T

315 lines
10 KiB
JavaScript

import {
computeGccPhatMap,
createGccPhatAnalysisPlan,
estimateGccPhatWorkload,
selectGccPhatReference,
} from "/packages/acoustics-core/gcc-phat.js";
import {
addFrequencyDomainDelay,
createDelayWorkspace,
prepareDelaySpectrum,
} from "/packages/acoustics-core/fractional-delay.js";
import { distance3 } from "/packages/acoustics-core/math.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
label + ": expected " + expected + " ± " + tolerance + ", received " + actual,
);
}
function deterministicBroadbandSamples(length) {
let state = 0x6d2b79f5;
return Float32Array.from({ length }, (_, index) => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
const noise = ((state >>> 0) / 0xffff_ffff) * 2 - 1;
const taper = Math.min(1, index / 64, (length - 1 - index) / 64);
return noise * Math.max(0, taper);
});
}
function createBroadbandFixture() {
const sampleRate = 4096;
const speedOfSound = 343;
const target = [0.3, 0.2, 0.6];
const arrayDefinition = {
id: "gcc-array",
microphones: [
{
id: "a-ref",
position: [-0.45, -0.35, 0],
sensitivityDb: 9,
phaseOffsetDeg: 31,
frequencyResponse: [
{ frequencyHz: 0, gainDb: -8, phaseDeg: 19 },
{ frequencyHz: sampleRate / 2, gainDb: -8, phaseDeg: 19 },
],
},
{
id: "b-ne",
position: [0.45, 0.35, 0],
sensitivityDb: -7,
phaseOffsetDeg: -47,
frequencyResponse: [
{ frequencyHz: 0, gainDb: 6, phaseDeg: -23 },
{ frequencyHz: sampleRate / 2, gainDb: 6, phaseDeg: -23 },
],
},
{
id: "c-nw",
position: [-0.45, 0.35, 0],
sensitivityDb: 4,
phaseOffsetDeg: 73,
},
{
id: "d-se",
position: [0.45, -0.35, 0],
sensitivityDb: -3,
phaseOffsetDeg: -11,
},
],
};
const scene = { speedOfSound };
const experiment = {
stft: { fftSize: 256, hopSize: 128, window: "hann" },
scan: {
z: target[2],
xMin: -0.4,
xMax: 0.4,
yMin: -0.4,
yMax: 0.4,
columns: 9,
rows: 9,
},
frequencyBands: [
{ id: "broadband", minHz: 128, maxHz: 1800, color: "#65f2bf" },
],
beamforming: {
algorithm: "gcc-phat",
backend: "cpu",
normalization: "peak",
compensateFrequencyResponse: true,
maxCompensationDb: 24,
},
};
const emitted = deterministicBroadbandSamples(2048);
const distances = arrayDefinition.microphones.map((microphone) =>
distance3(target, microphone.position)
);
const maximumDelaySamples = Math.max(...distances) * sampleRate / speedOfSound;
const spectrum = prepareDelaySpectrum(emitted, maximumDelaySamples);
const workspace = createDelayWorkspace(spectrum.fftSize);
const channels = arrayDefinition.microphones.map((microphone, index) => {
const channel = new Float32Array(emitted.length);
addFrequencyDomainDelay({
output: channel,
spectrum,
delaySamples: distances[index] * sampleRate / speedOfSound,
gain: 1 / distances[index],
phaseOffsetDeg: microphone.phaseOffsetDeg,
frequencyResponse: microphone.frequencyResponse,
sampleRate,
workspace,
});
return channel;
});
return {
sampleRate,
speedOfSound,
target,
arrayDefinition,
scene,
experiment,
channels,
};
}
function scanIndexFor(scan, x, y) {
const column = Math.round((x - scan.xMin) / (scan.xMax - scan.xMin) * (scan.columns - 1));
const row = Math.round((scan.yMax - y) / (scan.yMax - scan.yMin) * (scan.rows - 1));
return row * scan.columns + column;
}
function assertFloat32Exact(actual, expected, label) {
assertEqual(actual.length, expected.length, label + " length");
for (let index = 0; index < actual.length; index += 1) {
assertEqual(actual[index], expected[index], label + "[" + index + "]");
}
}
test("GCC-PHAT reference selection uses the farthest centroid radius and id tie-break", () => {
const arrayDefinition = {
microphones: [
{ id: "z-left", position: [-1, 0, 0] },
{ id: "middle", position: [0, 0, 0] },
{ id: "a-right", position: [1, 0, 0] },
],
};
const selected = selectGccPhatReference(arrayDefinition);
assertEqual(selected.id, "a-right", "lexicographically first id wins a radius tie");
assertEqual(selected.index, 2, "selected microphone index");
assertEqual(selected.microphone, arrayDefinition.microphones[2], "selected microphone identity");
});
test("GCC-PHAT workload reports reference-pair scan reduction and rejects aliased lag", () => {
const fixture = createBroadbandFixture();
const workload = estimateGccPhatWorkload(fixture);
assertEqual(workload.pairCount, fixture.arrayDefinition.microphones.length - 1, "reference pair count");
assertEqual(
workload.scanLookupCount,
workload.pointCount * workload.pairCount * workload.bandCount,
"one correlation lookup per point, pair, and band",
);
assert(
workload.scanLookupCount < workload.dasEquivalentComplexMacCount,
"reference-pair lookup count is below equivalent DAS complex MAC count",
);
assert(
workload.maximumLagSamples > 0 &&
workload.maximumLagSamples <= fixture.experiment.stft.fftSize / 2,
"maximum geometric lag is exposed and unaliased",
);
let memoryError;
try {
createGccPhatAnalysisPlan({ ...fixture, maximumPlanBytes: 1 });
} catch (error) {
memoryError = error;
}
assert(
memoryError instanceof RangeError && memoryError.message.includes("maximumPlanBytes"),
"plan hard memory bound",
);
const aliasedArray = {
microphones: [
{ id: "far", position: [-1, 0, 0] },
{ id: "near", position: [1, 0, 0] },
],
};
let lagError;
try {
createGccPhatAnalysisPlan({
sampleRate: 48000,
arrayDefinition: aliasedArray,
scene: { speedOfSound: 343 },
experiment: {
stft: { fftSize: 128, hopSize: 64, window: "hann" },
scan: {
z: 0.1,
xMin: 0.9,
xMax: 0.9,
yMin: 0,
yMax: 0,
columns: 1,
rows: 1,
},
frequencyBands: [{ id: "wide", minHz: 500, maxHz: 5000, color: "#65f2bf" }],
beamforming: { algorithm: "gcc-phat", backend: "cpu", normalization: "peak" },
},
});
} catch (error) {
lagError = error;
}
assert(
lagError instanceof RangeError &&
lagError.message.includes("maximum absolute geometric lag") &&
lagError.message.includes("fftSize/2"),
"circular-lag alias guard",
);
});
test("GCC-PHAT corrected lag sign finds a deterministic broadband source and emits score maps", async () => {
const fixture = createBroadbandFixture();
const result = await computeGccPhatMap({
...fixture,
phatFloorRatio: 1e-5,
pointBatchSize: 1024,
stftTransformBatchSize: 1024,
});
const band = result.bands[0];
const peakPosition = band.peak.position;
assertEqual(result.backend, "cpu", "CPU backend");
assertEqual(result.algorithm, "gcc-phat", "algorithm id");
assertEqual(result.referenceMicrophoneId, "a-ref", "deterministic reference metadata");
assertEqual(result.pairCount, 3, "pair metadata");
assertEqual(result.phatFloorRatio, 1e-5, "PHAT floor metadata");
assertNear(result.maximumLagSamples, result.workload.maximumLagSamples, 0, "lag metadata");
assertEqual(band.rawScale, "score", "raw map scale");
assert(
band.rawMap.every((value) => Number.isFinite(value) && value >= 0),
"shifted and squared score map is finite and nonnegative",
);
assert(
distance3(peakPosition, fixture.target) <= 0.15,
"broadband GCC-PHAT peak is within one grid diagonal: " + peakPosition.join(","),
);
const targetIndex = scanIndexFor(
fixture.experiment.scan,
fixture.target[0],
fixture.target[1],
);
const wrongSignIndex = scanIndexFor(
fixture.experiment.scan,
-fixture.target[0],
fixture.target[1],
);
assert(
band.rawMap[targetIndex] > band.rawMap[wrongSignIndex],
"X_m * conj(X_ref) correlation uses the physical arrival-lag sign",
);
assertNear(Math.max(...band.displayMap), 1, 1e-6, "normalized score display");
});
test("GCC-PHAT analysis plan reuses geometry and FFT workspaces and releases its busy lock", async () => {
const fixture = createBroadbandFixture();
const plan = createGccPhatAnalysisPlan(fixture);
const retainedDistances = plan.distances;
const retainedStftWorkspace = plan.stftWorkspace;
const retainedCorrelationReal = plan.correlationReal;
const computeOptions = {
...fixture,
analysisPlan: plan,
pointBatchSize: 1024,
stftTransformBatchSize: 1024,
};
const first = await computeGccPhatMap(computeOptions);
const second = await computeGccPhatMap(computeOptions);
assertEqual(plan.distances, retainedDistances, "distance matrix identity is retained");
assertEqual(plan.stftWorkspace, retainedStftWorkspace, "STFT workspace identity is retained");
assertEqual(plan.correlationReal, retainedCorrelationReal, "correlation workspace identity is retained");
assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "reused plan raw map");
assertEqual(second.analysisCache.analysisAttempts, 2, "analysis attempts");
assertEqual(second.analysisCache.completedAnalyses, 2, "completed analyses");
assertEqual(second.analysisCache.distanceMatrixBuilds, 1, "distance matrix built once");
assertEqual(
second.analysisCache.avoidedDistanceEvaluations,
plan.pointCount * plan.microphoneCount,
"second geometry pass avoided",
);
assertEqual(second.analysisCache.stftWorkspaceUses, 2, "STFT workspace reused");
assertEqual(second.analysisCache.correlationWorkspaceUses, 2, "correlation workspace reused");
assertEqual(
second.analysisCache.inverseFftTransforms,
plan.pairCount * plan.bandBins.length * 2,
"one inverse FFT per pair and band per analysis",
);
const controller = new AbortController();
controller.abort();
let abortError;
try {
await computeGccPhatMap({ ...computeOptions, signal: controller.signal });
} catch (error) {
abortError = error;
}
assertEqual(abortError?.name, "AbortError", "AbortError propagated");
assertEqual(plan.busy, false, "plan busy lock released after cancellation");
assertEqual(plan.statistics.completedAnalyses, 2, "canceled analysis is not completed");
});