177 lines
7.2 KiB
JavaScript
177 lines
7.2 KiB
JavaScript
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}`);
|
||
});
|