315 lines
10 KiB
JavaScript
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");
|
|
});
|