feat: add comparable localization algorithms
This commit is contained in:
@@ -11,6 +11,8 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
||||
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/localization.test.js");
|
||||
await import("/tests/unit/realtime-localizer.test.js");
|
||||
await import("/tests/unit/builder.test.js");
|
||||
await import("/tests/unit/evaluation.test.js");
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
prepareDelaySpectrum,
|
||||
} from "/packages/acoustics-core/fractional-delay.js";
|
||||
import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
|
||||
import {
|
||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||
LOCALIZATION_ALGORITHM_IDS,
|
||||
isDasAlgorithm,
|
||||
localizationAlgorithmDefinition,
|
||||
localizationAlgorithmLabel,
|
||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
||||
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
||||
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||
@@ -29,6 +36,115 @@ function assertNear(actual, expected, tolerance, label) {
|
||||
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected} ± ${tolerance}, received ${actual}`);
|
||||
}
|
||||
|
||||
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",
|
||||
"stable algorithm ids",
|
||||
);
|
||||
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS map scale");
|
||||
assertEqual(
|
||||
localizationAlgorithmDefinition("das-phat").spectralWeighting,
|
||||
"phat",
|
||||
"DAS-PHAT weighting",
|
||||
);
|
||||
assertEqual(
|
||||
localizationAlgorithmDefinition("gcc-phat").backends.join(","),
|
||||
"cpu",
|
||||
"GCC-PHAT backend capability",
|
||||
);
|
||||
assert(isDasAlgorithm("das") && isDasAlgorithm("das-phat"), "DAS engine algorithms");
|
||||
assert(!isDasAlgorithm("gcc-phat"), "GCC-PHAT uses a separate engine");
|
||||
assertEqual(localizationAlgorithmLabel("das-phat"), "DAS-PHAT", "algorithm label");
|
||||
assertEqual(localizationAlgorithmDefinition("unknown"), undefined, "unknown lookup");
|
||||
});
|
||||
|
||||
test("CPU DAS-PHAT is invariant to spectrum and calibration amplitude gains", async () => {
|
||||
const sampleRate = 8_000;
|
||||
const fftSize = 256;
|
||||
const bin = 32;
|
||||
const frequency = (bin * sampleRate) / fftSize;
|
||||
const speedOfSound = 343;
|
||||
const target = [0.2, 0, 1];
|
||||
const positions = [[-0.15, 0, 0], [-0.02, 0, 0], [0.12, 0, 0]];
|
||||
const phaseOffsets = [30, -45, 75];
|
||||
const responsePhases = [10, 20, -35];
|
||||
const createDefinition = (withAmplitudeMetadata) => ({
|
||||
microphones: positions.map((position, index) => ({
|
||||
id: `mic-${index}`,
|
||||
position,
|
||||
sensitivityDb: withAmplitudeMetadata ? [-30, 24, 60][index] : 0,
|
||||
phaseOffsetDeg: phaseOffsets[index],
|
||||
frequencyResponse: [
|
||||
{
|
||||
frequencyHz: 0,
|
||||
gainDb: withAmplitudeMetadata ? [-60, 40, 100][index] : 0,
|
||||
phaseDeg: responsePhases[index],
|
||||
},
|
||||
{
|
||||
frequencyHz: 4_000,
|
||||
gainDb: withAmplitudeMetadata ? [-60, 40, 100][index] : 0,
|
||||
phaseDeg: responsePhases[index],
|
||||
},
|
||||
],
|
||||
})),
|
||||
});
|
||||
const createSpectrum = (magnitudes) => {
|
||||
const real = new Float32Array(positions.length);
|
||||
const imaginary = new Float32Array(positions.length);
|
||||
positions.forEach((position, index) => {
|
||||
const propagationPhase =
|
||||
(2 * Math.PI * frequency * distance3(target, position)) / speedOfSound;
|
||||
const calibrationPhase =
|
||||
((phaseOffsets[index] + responsePhases[index]) * Math.PI) / 180;
|
||||
const observedPhase = -propagationPhase + calibrationPhase;
|
||||
real[index] = Math.cos(observedPhase) * magnitudes[index];
|
||||
imaginary[index] = Math.sin(observedPhase) * magnitudes[index];
|
||||
});
|
||||
return { real, imaginary };
|
||||
};
|
||||
const scan = {
|
||||
z: 1,
|
||||
xMin: -0.2,
|
||||
xMax: 0.2,
|
||||
yMin: 0,
|
||||
yMax: 0,
|
||||
columns: 2,
|
||||
rows: 1,
|
||||
};
|
||||
const baselineArray = createDefinition(false);
|
||||
const gainedArray = createDefinition(true);
|
||||
const common = {
|
||||
bin,
|
||||
frameCount: 1,
|
||||
pointCount: 2,
|
||||
sampleRate,
|
||||
fftSize,
|
||||
speedOfSound,
|
||||
algorithm: "das-phat",
|
||||
pointBatchSize: 64,
|
||||
};
|
||||
const baseline = await computeBinMap({
|
||||
...common,
|
||||
spectrum: createSpectrum([1, 1, 1]),
|
||||
arrayDefinition: baselineArray,
|
||||
distances: createDistanceMatrix(baselineArray, scan),
|
||||
});
|
||||
const gained = await computeBinMap({
|
||||
...common,
|
||||
spectrum: createSpectrum([0.125, 8, 32]),
|
||||
arrayDefinition: gainedArray,
|
||||
distances: createDistanceMatrix(gainedArray, scan),
|
||||
});
|
||||
|
||||
baseline.forEach((value, index) => {
|
||||
assertNear(gained[index], value, 2e-6, `gain-invariant map value ${index}`);
|
||||
});
|
||||
assertNear(baseline[1], 1, 2e-6, "phase-compensated target score");
|
||||
assert(baseline[1] > baseline[0] * 1.1, "phase compensation preserves target peak");
|
||||
});
|
||||
|
||||
function rms(values, start = 0, end = values.length) {
|
||||
let sum = 0;
|
||||
for (let index = start; index < end; index += 1) sum += values[index] ** 2;
|
||||
@@ -521,12 +637,31 @@ test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾
|
||||
onProgress: (progress) => progressEvents.push(progress),
|
||||
});
|
||||
const peakPosition = result.bands[0].peak.position;
|
||||
assertEqual(result.algorithm, "das", "default result algorithm");
|
||||
assertEqual(result.bands[0].rawScale, "power", "DAS raw map scale");
|
||||
assert(distance3(peakPosition, scene.sources[0].position) <= 0.071, `peak=${peakPosition.join(",")}`);
|
||||
assert(result.bands[0].rawMap !== result.bands[0].displayMap, "raw/display buffers are separate");
|
||||
assertNear(Math.max(...result.bands[0].rawMap), result.bands[0].peak.rawPower, 1e-4, "raw map peak");
|
||||
assertNear(Math.max(...result.bands[0].displayMap), 1, 1e-6, "normalized display peak");
|
||||
assert(progressEvents.some(({ stage }) => stage === "beamforming"), "beamforming progress");
|
||||
|
||||
const phaseOnly = await computeDasMap({
|
||||
channels: synthesis.channels,
|
||||
sampleRate: synthesis.sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment: {
|
||||
...experiment,
|
||||
beamforming: { ...experiment.beamforming, algorithm: "das-phat" },
|
||||
},
|
||||
});
|
||||
assertEqual(phaseOnly.algorithm, "das-phat", "phase-only result algorithm");
|
||||
assertEqual(phaseOnly.bands[0].rawScale, "score", "DAS-PHAT raw map scale");
|
||||
assert(
|
||||
distance3(phaseOnly.bands[0].peak.position, scene.sources[0].position) <= 0.071,
|
||||
`DAS-PHAT peak=${phaseOnly.bands[0].peak.position.join(",")}`,
|
||||
);
|
||||
|
||||
const uncompensated = await computeDasMap({
|
||||
channels: synthesis.channels,
|
||||
sampleRate: synthesis.sampleRate,
|
||||
|
||||
@@ -157,6 +157,51 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
|
||||
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
||||
});
|
||||
|
||||
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"]) {
|
||||
const experiment = structuredClone(baseline);
|
||||
experiment.beamforming.algorithm = algorithm;
|
||||
experiment.beamforming.backend = algorithm === "gcc-phat" ? "cpu" : "auto";
|
||||
assertEqual(
|
||||
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
|
||||
0,
|
||||
`${algorithm} experiment issues`,
|
||||
);
|
||||
}
|
||||
|
||||
const incompatible = structuredClone(baseline);
|
||||
incompatible.beamforming.algorithm = "gcc-phat";
|
||||
incompatible.beamforming.backend = "webgpu";
|
||||
let issues = await validateExperiment(incompatible, { arrayDefinition, scene });
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.beamforming.backend"),
|
||||
"GCC-PHAT rejects the WebGPU backend",
|
||||
);
|
||||
|
||||
incompatible.beamforming.backend = "cpu";
|
||||
issues = await validateExperiment(incompatible, {
|
||||
arrayDefinition: { ...arrayDefinition, microphones: arrayDefinition.microphones.slice(0, 1) },
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.beamforming.algorithm"),
|
||||
"GCC-PHAT requires two microphones",
|
||||
);
|
||||
|
||||
incompatible.beamforming.algorithm = "music";
|
||||
issues = await validateExperiment(incompatible, { arrayDefinition, scene });
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.beamforming.algorithm"),
|
||||
"unsupported algorithms are rejected",
|
||||
);
|
||||
});
|
||||
|
||||
test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => {
|
||||
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
|
||||
scene.acoustics = {
|
||||
@@ -357,6 +402,7 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
||||
sceneId: experiment.sceneId,
|
||||
createdAt: "2026-08-28T00:00:00.000Z",
|
||||
backend: "cpu",
|
||||
algorithm: "das",
|
||||
inputHash: "a".repeat(64),
|
||||
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
|
||||
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
|
||||
@@ -369,10 +415,25 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
||||
}],
|
||||
};
|
||||
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
|
||||
|
||||
experiment.beamforming.algorithm = "das-phat";
|
||||
result.algorithm = "das-phat";
|
||||
result.bands[0].rawMap.scale = "score";
|
||||
assertEqual(
|
||||
(await validateResult(result, { experiment, scene })).length,
|
||||
0,
|
||||
"phase-weighted score result issues",
|
||||
);
|
||||
|
||||
result.algorithm = "das";
|
||||
let issues = await validateResult(result, { experiment, scene });
|
||||
assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment");
|
||||
|
||||
experiment.beamforming.algorithm = "das";
|
||||
result.bands[0].rawMap.values.pop();
|
||||
result.bands[0].rawMap.scale = "normalized";
|
||||
result.bands[0].peaks[0].sourceId = "missing-source";
|
||||
const issues = await validateResult(result, { experiment, scene });
|
||||
issues = await validateResult(result, { experiment, scene });
|
||||
assert(issues.some(({ message }) => message.includes("rows × columns")));
|
||||
assert(issues.some(({ path }) => path.endsWith("rawMap.scale")));
|
||||
assert(issues.some(({ path }) => path.endsWith("sourceId")));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
RESULT_METRIC_KEYS,
|
||||
evaluateDasBand,
|
||||
evaluateLocalizationBand,
|
||||
exportBandEvaluation,
|
||||
findLocalPeaks,
|
||||
measureBeamSpread,
|
||||
@@ -213,6 +214,44 @@ test("beam spread가 회전된 비등방성 half-power lobe의 covariance ellips
|
||||
);
|
||||
});
|
||||
|
||||
test("generic localization 평가는 score map의 척도와 beam spread 방식을 보존한다", () => {
|
||||
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 3, rows: 3 };
|
||||
const rawMap = {
|
||||
rows: 3,
|
||||
columns: 3,
|
||||
scale: "score",
|
||||
values: Float32Array.of(
|
||||
0, 1, 0,
|
||||
1, 4, 1,
|
||||
0, 1, 0,
|
||||
),
|
||||
};
|
||||
const evaluation = evaluateLocalizationBand({
|
||||
rawMap,
|
||||
scan,
|
||||
sources: [{ id: "center", position: [0, 0, 2] }],
|
||||
matchToleranceM: 0,
|
||||
truthRadiusM: 0,
|
||||
minimumPeakDistanceM: 0,
|
||||
});
|
||||
|
||||
assertEqual(evaluation.diagnostics.mapScale, "score", "score scale diagnostic");
|
||||
assertEqual(
|
||||
evaluation.detections[0].beamSpread.method,
|
||||
"connected-half-maximum-score-weighted-covariance",
|
||||
"score beam-spread method",
|
||||
);
|
||||
assertEqual(evaluation.sourceMatches[0].localizationErrorM, 0, "score peak localization");
|
||||
|
||||
const legacy = evaluateDasBand({
|
||||
rawMap: rawMap.values,
|
||||
scan,
|
||||
sources: [],
|
||||
minimumPeakDistanceM: 0,
|
||||
});
|
||||
assertEqual(legacy.diagnostics.mapScale, "power", "legacy DAS defaults to power");
|
||||
});
|
||||
|
||||
test("beam spread가 1-row, 1-column, single-pixel lobe에서 유한하고 결정적이다", () => {
|
||||
const powers = Float32Array.of(5, 8, 10, 8, 5);
|
||||
const rowSpread = measureBeamSpread(
|
||||
@@ -337,6 +376,6 @@ test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서
|
||||
scan,
|
||||
sources: [],
|
||||
})) instanceof RangeError,
|
||||
"non-power map rejected",
|
||||
"display-only map scale rejected",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -12,6 +12,48 @@ import {
|
||||
} from "/packages/webgpu-kernels/das.js";
|
||||
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
|
||||
|
||||
test("WebGPU DAS-PHAT packing phase-normalizes above a relative floor", () => {
|
||||
const stft = {
|
||||
frameCount: 1,
|
||||
microphoneCount: 3,
|
||||
spectra: new Map([[
|
||||
8,
|
||||
{
|
||||
real: Float32Array.of(3, 0, 1e-8),
|
||||
imaginary: Float32Array.of(4, 2, 0),
|
||||
},
|
||||
]]),
|
||||
};
|
||||
const microphones = [
|
||||
{
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: 60, phaseDeg: 90 },
|
||||
{ frequencyHz: 4_000, gainDb: 60, phaseDeg: 90 },
|
||||
],
|
||||
},
|
||||
{
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: -60, phaseDeg: 0 },
|
||||
{ frequencyHz: 4_000, gainDb: -60, phaseDeg: 0 },
|
||||
],
|
||||
},
|
||||
{},
|
||||
];
|
||||
const packed = packStftBatch(stft, [8], {
|
||||
algorithm: "das-phat",
|
||||
microphones,
|
||||
sampleRate: 8_000,
|
||||
fftSize: 256,
|
||||
});
|
||||
|
||||
assertNear(packed[0], 0.8, 1e-6, "response-phase corrected real");
|
||||
assertNear(packed[1], -0.6, 1e-6, "response-phase corrected imaginary");
|
||||
assertNear(packed[2], 0, 1e-7, "second microphone real");
|
||||
assertNear(packed[3], 1, 1e-7, "response gain is ignored");
|
||||
assertEqual(packed[4], 0, "below-floor real is suppressed");
|
||||
assertEqual(packed[5], 0, "below-floor imaginary is suppressed");
|
||||
});
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
@@ -53,6 +95,17 @@ test("WebGPU DAS uniform과 microphone calibration을 WGSL layout으로 packing
|
||||
assertNear(calibration[3], Math.PI / 2, 1e-6, "+90 degree phase");
|
||||
assertNear(calibration[4], 2, 1e-6, "-6 dB inverse gain");
|
||||
assertNear(calibration[5], -Math.PI / 4, 1e-6, "-45 degree phase");
|
||||
|
||||
const phaseOnlyCalibration = packMicrophoneCalibration([
|
||||
{ sensitivityDb: 0, phaseOffsetDeg: 0 },
|
||||
{ sensitivityDb: 6.020599913, phaseOffsetDeg: 90 },
|
||||
{ sensitivityDb: -6.020599913, phaseOffsetDeg: -45 },
|
||||
], { includeAmplitude: false });
|
||||
assertNear(phaseOnlyCalibration[0], 1, 1e-7, "phase-only unity gain 0");
|
||||
assertNear(phaseOnlyCalibration[2], 1, 1e-7, "phase-only unity gain 1");
|
||||
assertNear(phaseOnlyCalibration[4], 1, 1e-7, "phase-only unity gain 2");
|
||||
assertNear(phaseOnlyCalibration[3], Math.PI / 2, 1e-6, "phase-only preserves +90 phase");
|
||||
assertNear(phaseOnlyCalibration[5], -Math.PI / 4, 1e-6, "phase-only preserves -45 phase");
|
||||
});
|
||||
|
||||
test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () => {
|
||||
|
||||
Reference in New Issue
Block a user