feat: add comparable localization algorithms

This commit is contained in:
2026-08-28 20:20:57 +09:00
parent 7fe846fbf9
commit d58f673402
20 changed files with 2104 additions and 68 deletions
+135
View File
@@ -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,