feat: add comparable localization algorithms
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
computeLocalizationMap,
|
||||
createLocalizationAnalysisPlan,
|
||||
estimateLocalizationWorkload,
|
||||
} from "/packages/acoustics-core/localization.js";
|
||||
import {
|
||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||
LOCALIZATION_ALGORITHM_IDS,
|
||||
localizationAlgorithmDefinition,
|
||||
localizationAlgorithmSupportsBackend,
|
||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||
import {
|
||||
StreamingDasLocalizer,
|
||||
StreamingLocalizer,
|
||||
createLocalizationPlan,
|
||||
detectBandPeaks,
|
||||
localizeMultichannel,
|
||||
} from "/packages/acoustics-core/realtime-localizer.js";
|
||||
import {
|
||||
computeDasWithBackend,
|
||||
computeLocalizationWithBackend,
|
||||
} from "/packages/webgpu-kernels/compute.js";
|
||||
|
||||
function createFixture(algorithm = "das") {
|
||||
const sampleRate = 4096;
|
||||
const arrayDefinition = {
|
||||
id: "algorithm-fixture",
|
||||
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 = 0x9e3779b9;
|
||||
const emitted = Float32Array.from({ length: 128 }, () => {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return ((state >>> 0) / 0xffff_ffff) * 2 - 1;
|
||||
});
|
||||
const channels = arrayDefinition.microphones.map((_, microphoneIndex) =>
|
||||
Float32Array.from(
|
||||
{ length: emitted.length },
|
||||
(_, sampleIndex) => emitted[Math.max(0, sampleIndex - microphoneIndex)],
|
||||
)
|
||||
);
|
||||
const scan = {
|
||||
z: 1,
|
||||
xMin: -0.2,
|
||||
xMax: 0.2,
|
||||
yMin: -0.2,
|
||||
yMax: 0.2,
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
};
|
||||
const frequencyBands = [
|
||||
{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" },
|
||||
];
|
||||
const experiment = {
|
||||
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
||||
scan,
|
||||
frequencyBands,
|
||||
beamforming: {
|
||||
algorithm,
|
||||
backend: "cpu",
|
||||
normalization: "peak",
|
||||
compensateFrequencyResponse: true,
|
||||
},
|
||||
};
|
||||
return {
|
||||
channels,
|
||||
sampleRate,
|
||||
arrayDefinition,
|
||||
scene: { speedOfSound: 343 },
|
||||
experiment,
|
||||
};
|
||||
}
|
||||
|
||||
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(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");
|
||||
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU support");
|
||||
assert(!localizationAlgorithmSupportsBackend("gcc-phat", "webgpu"), "GCC-PHAT CPU-only support");
|
||||
});
|
||||
|
||||
test("generic CPU dispatcher runs all algorithms with explicit raw-map scales", async () => {
|
||||
for (const algorithm of LOCALIZATION_ALGORITHM_IDS) {
|
||||
const fixture = createFixture(algorithm);
|
||||
const analysisPlan = createLocalizationAnalysisPlan(fixture);
|
||||
const workload = estimateLocalizationWorkload({ ...fixture, analysisPlan });
|
||||
const result = await computeLocalizationMap({
|
||||
...fixture,
|
||||
analysisPlan,
|
||||
pointBatchSize: 1024,
|
||||
stftTransformBatchSize: 1024,
|
||||
});
|
||||
const definition = localizationAlgorithmDefinition(algorithm);
|
||||
|
||||
assertEqual(result.algorithm, algorithm, `${algorithm} result id`);
|
||||
assertEqual(result.backend, "cpu", `${algorithm} backend`);
|
||||
assertEqual(result.bands[0].rawScale, definition.rawScale, `${algorithm} raw scale`);
|
||||
assertEqual(result.bands[0].rawMap.length, 9, `${algorithm} map shape`);
|
||||
assertEqual(result.bands[0].peak.rawScale, definition.rawScale, `${algorithm} peak scale`);
|
||||
assertEqual(
|
||||
"rawPower" in result.bands[0].peak,
|
||||
definition.rawScale === "power",
|
||||
`${algorithm} peak power field`,
|
||||
);
|
||||
assert(
|
||||
result.bands[0].rawMap.every((value) => Number.isFinite(value) && value >= 0),
|
||||
`${algorithm} finite nonnegative map`,
|
||||
);
|
||||
assert(workload.pointCount === 9 && workload.microphoneCount === 4, `${algorithm} workload`);
|
||||
}
|
||||
});
|
||||
|
||||
test("backend dispatcher keeps GCC-PHAT on CPU and rejects an explicit GPU request", async () => {
|
||||
const fixture = createFixture("gcc-phat");
|
||||
const calls = [];
|
||||
const result = await computeLocalizationWithBackend(fixture, {
|
||||
computeGpu: async () => {
|
||||
calls.push("gpu");
|
||||
throw new Error("GCC-PHAT must not call WebGPU");
|
||||
},
|
||||
computeGccCpu: async () => {
|
||||
calls.push("gcc-cpu");
|
||||
return { backend: "cpu", algorithm: "gcc-phat", bands: [] };
|
||||
},
|
||||
});
|
||||
assertEqual(calls.join(","), "gcc-cpu", "GCC auto routing");
|
||||
assertEqual(result.algorithm, "gcc-phat", "GCC result");
|
||||
|
||||
fixture.experiment.beamforming.backend = "webgpu";
|
||||
let backendError;
|
||||
try {
|
||||
await computeLocalizationWithBackend(fixture);
|
||||
} catch (error) {
|
||||
backendError = error;
|
||||
}
|
||||
assert(backendError instanceof TypeError, "explicit GCC WebGPU is rejected");
|
||||
|
||||
let legacyError;
|
||||
try {
|
||||
await computeDasWithBackend(fixture);
|
||||
} catch (error) {
|
||||
legacyError = error;
|
||||
}
|
||||
assert(legacyError instanceof RangeError, "legacy DAS entry rejects GCC");
|
||||
});
|
||||
|
||||
test("portable and streaming API accept an algorithm choice without Scene synthesis", async () => {
|
||||
const fixture = createFixture("gcc-phat");
|
||||
const config = {
|
||||
sampleRate: fixture.sampleRate,
|
||||
array: fixture.arrayDefinition,
|
||||
scan: fixture.experiment.scan,
|
||||
bands: fixture.experiment.frequencyBands,
|
||||
speedOfSound: fixture.scene.speedOfSound,
|
||||
stft: fixture.experiment.stft,
|
||||
algorithm: "gcc-phat",
|
||||
phatFloorRatio: 1e-5,
|
||||
normalization: "peak",
|
||||
computeOptions: { pointBatchSize: 1024, stftTransformBatchSize: 1024 },
|
||||
};
|
||||
const plan = createLocalizationPlan(config);
|
||||
const localized = await localizeMultichannel({ plan, channels: fixture.channels });
|
||||
assertEqual(plan.algorithm, "gcc-phat", "portable plan algorithm");
|
||||
assertEqual(plan.computeOptions.phatFloorRatio, 1e-5, "portable PHAT floor forwarding");
|
||||
assertEqual(plan.analysisPlan, plan.gccPhatPlan, "portable GCC plan alias");
|
||||
assertEqual(localized.algorithm, "gcc-phat", "portable result algorithm");
|
||||
assertEqual(localized.phatFloorRatio, 1e-5, "portable PHAT floor reaches GCC core");
|
||||
assertEqual(localized.bands[0].rawScale, "score", "portable score map");
|
||||
assertEqual(StreamingLocalizer, StreamingDasLocalizer, "streaming compatibility alias");
|
||||
|
||||
const stream = new StreamingLocalizer({
|
||||
...config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 128,
|
||||
maximumBufferedSamples: 256,
|
||||
});
|
||||
const streamed = await stream.push(fixture.channels);
|
||||
assertEqual(streamed.length, 1, "one GCC streaming result");
|
||||
assertEqual(streamed[0].algorithm, "gcc-phat", "streaming result algorithm");
|
||||
assertEqual(stream.getState().algorithm, "gcc-phat", "streaming state algorithm");
|
||||
assertEqual(stream.plan.gccPhatPlan.busy, false, "streaming GCC plan lock released");
|
||||
|
||||
const scorePeaks = detectBandPeaks(
|
||||
Float32Array.of(0, 2, 0, 1),
|
||||
{ z: 1, xMin: 0, xMax: 1, yMin: 0, yMax: 1, columns: 2, rows: 2 },
|
||||
{ rawScale: "score", minimumPeakDistanceM: 0 },
|
||||
);
|
||||
assertEqual(scorePeaks[0].rawScale, "score", "portable peak scale");
|
||||
assert(!("rawPower" in scorePeaks[0]), "score is not mislabeled as acoustic power");
|
||||
});
|
||||
Reference in New Issue
Block a user