Files
micarrsim/tests/integration/webgpu.test.js
T
2026-08-28 11:26:19 +09:00

177 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computeDasMap } from "/packages/acoustics-core/das.js";
import { distance3 } from "/packages/acoustics-core/math.js";
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
import { computeDasWebGpu } from "/packages/webgpu-kernels/das.js";
function createArray(columns, rows, width, height) {
const microphones = [];
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const index = row * columns + column;
microphones.push({
id: `gpu-mic-${row}-${column}`,
position: [
-width / 2 + (column * width) / (columns - 1),
-height / 2 + (row * height) / (rows - 1),
0,
],
sensitivityDb: (index % 3) * 2 - 2,
phaseOffsetDeg: (index % 5) * 8 - 16,
});
}
}
return { id: "gpu-test-array", aperture: { width, height }, microphones };
}
function createFixture(backend = "webgpu") {
const arrayDefinition = createArray(4, 4, 0.3, 0.3);
const scene = {
id: "gpu-das-scene",
sampleRate: 8_000,
duration: 0.064,
speedOfSound: 343,
seed: 11,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "source",
name: "source",
position: [0.1, 0.1, 1.2],
gainDb: -12,
startTime: 0,
duration: 0.064,
signal: { type: "tone", frequency: 1_000, phaseDeg: 0 },
}],
ambient: [],
};
const experiment = {
stft: { fftSize: 128, hopSize: 64, window: "hann" },
scan: { z: 1.2, xMin: -0.4, xMax: 0.4, yMin: -0.4, yMax: 0.4, columns: 9, rows: 9 },
frequencyBands: [{ id: "1k", minHz: 990, maxHz: 1_010, color: "#65f2bf" }],
beamforming: { backend, algorithm: "das", normalization: "peak" },
};
const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition });
return {
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment,
};
}
function normalizedMapError(reference, actual) {
const referenceScale = Math.max(...reference);
let squaredError = 0;
let maximumAbsoluteError = 0;
for (let index = 0; index < reference.length; index += 1) {
const error = Math.abs(actual[index] - reference[index]) / referenceScale;
squaredError += error * error;
maximumAbsoluteError = Math.max(maximumAbsoluteError, error);
}
return {
rms: Math.sqrt(squaredError / reference.length),
maximumAbsoluteError,
};
}
test("WebGPU DAS가 CPU 수치 기준과 일치하거나 auto CPU fallback한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
if (!adapter) {
const fixture = createFixture("auto");
const result = await computeDasWithBackend(fixture);
assertEqual(result.backend, "cpu", "auto fallback backend without adapter");
assert(result.bands[0].peak.rawPower > 0, "fallback produced a usable map");
document.documentElement.dataset.webgpuIntegration = "cpu-fallback";
return;
}
const fixture = createFixture("webgpu");
const cpuResult = await computeDasMap(fixture);
const gpuResult = await computeDasWebGpu(fixture);
const cpuBand = cpuResult.bands[0];
const gpuBand = gpuResult.bands[0];
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
assertEqual(gpuResult.backend, "webgpu", "actual WebGPU backend");
assertEqual(gpuBand.rawMap.length, cpuBand.rawMap.length, "map size");
assert(error.rms <= 2e-3, `CPU/GPU normalized RMS error=${error.rms}`);
assert(
error.maximumAbsoluteError <= 1e-2,
`CPU/GPU normalized maximum absolute error=${error.maximumAbsoluteError}`,
);
const gridDiagonal = Math.hypot(
(fixture.experiment.scan.xMax - fixture.experiment.scan.xMin) /
(fixture.experiment.scan.columns - 1),
(fixture.experiment.scan.yMax - fixture.experiment.scan.yMin) /
(fixture.experiment.scan.rows - 1),
);
assert(
distance3(gpuBand.peak.position, cpuBand.peak.position) <= gridDiagonal + 1e-9,
"CPU/GPU peak positions are within one grid diagonal",
);
assert(
distance3(gpuBand.peak.position, fixture.scene.sources[0].position) <= 0.071,
`GPU localization error=${distance3(gpuBand.peak.position, fixture.scene.sources[0].position)} m`,
);
document.documentElement.dataset.webgpuIntegration = "gpu";
document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3);
});
test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
if (!adapter) {
document.documentElement.dataset.webgpuBaseline = "unavailable";
return;
}
const baseline = globalThis.__micarrsimCpuBaseline;
assert(baseline, "CPU baseline fixture is available");
const experiment = {
...baseline.experiment,
beamforming: { ...baseline.experiment.beamforming, backend: "webgpu" },
};
const result = await computeDasWebGpu({ ...baseline, experiment });
const cpuBand = baseline.result.bands[0];
const gpuBand = result.bands[0];
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
const peakPowerRelativeError =
Math.abs(gpuBand.peak.rawPower - cpuBand.peak.rawPower) / cpuBand.peak.rawPower;
const gridDiagonal = Math.hypot(
(experiment.scan.xMax - experiment.scan.xMin) / (experiment.scan.columns - 1),
(experiment.scan.yMax - experiment.scan.yMin) / (experiment.scan.rows - 1),
);
assert(gpuBand.rawMap.every((value) => Number.isFinite(value) && value >= 0), "finite nonnegative GPU map");
assert(error.rms <= 0.005, `192ch CPU/GPU normalized RMS error=${error.rms}`);
assert(error.maximumAbsoluteError <= 0.02, `192ch CPU/GPU normalized max error=${error.maximumAbsoluteError}`);
assert(peakPowerRelativeError <= 0.02, `192ch CPU/GPU peak power relative error=${peakPowerRelativeError}`);
assert(
distance3(gpuBand.peak.position, cpuBand.peak.position) <= gridDiagonal + 1e-9,
"192ch CPU/GPU peaks are within one grid diagonal",
);
document.documentElement.dataset.webgpuBaseline = "gpu";
document.documentElement.dataset.webgpuBaselineMs = result.timingMs.total.toFixed(1);
document.documentElement.dataset.webgpuBaselineRmsError = error.rms.toExponential(3);
});
test("WebGPU bin·point 강제 batching 결과가 CPU map과 일치한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
if (!adapter) return;
const fixture = createFixture("webgpu");
fixture.experiment.frequencyBands = [
{ id: "wide-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" },
];
const cpuResult = await computeDasMap(fixture);
const gpuResult = await computeDasWebGpu({
...fixture,
maximumSpectrumBatchBins: 1,
maximumSteeringBufferBytes: 512,
});
const error = normalizedMapError(cpuResult.bands[0].rawMap, gpuResult.bands[0].rawMap);
assertEqual(gpuResult.gpu.batchPlan.binBatchSize, 1, "forced bin batch size");
assertEqual(gpuResult.gpu.batchPlan.pointBatchSize, 4, "forced point batch size");
assert(error.rms <= 0.005, `chunked CPU/GPU normalized RMS error=${error.rms}`);
assert(error.maximumAbsoluteError <= 0.02, `chunked CPU/GPU max error=${error.maximumAbsoluteError}`);
});