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,
+62 -1
View File
@@ -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")));
+40 -1
View File
@@ -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",
);
});
+314
View File
@@ -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");
});
+198
View File
@@ -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");
});
+53
View File
@@ -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한다", () => {