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("WebGPU DAS-PHAT가 CPU score map과 일치하거나 auto CPU fallback한다", async () => { const adapter = await globalThis.navigator?.gpu?.requestAdapter(); const fixture = createFixture(adapter ? "webgpu" : "auto"); fixture.experiment.beamforming.algorithm = "das-phat"; fixture.experiment.frequencyBands = [ { id: "phase-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" }, ]; if (!adapter) { const result = await computeDasWithBackend(fixture); assertEqual(result.backend, "cpu", "DAS-PHAT auto fallback backend"); assertEqual(result.algorithm, "das-phat", "DAS-PHAT fallback algorithm"); assertEqual(result.bands[0].rawScale, "score", "DAS-PHAT fallback scale"); return; } 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.algorithm, "das-phat", "actual WebGPU DAS-PHAT algorithm"); assertEqual(gpuBand.rawScale, "score", "actual WebGPU DAS-PHAT scale"); assert(!("rawPower" in gpuBand.peak), "DAS-PHAT GPU peak is not mislabeled as power"); assert(error.rms <= 0.005, `DAS-PHAT CPU/GPU normalized RMS error=${error.rms}`); assert( error.maximumAbsoluteError <= 0.02, `DAS-PHAT CPU/GPU normalized max error=${error.maximumAbsoluteError}`, ); }); 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}`); });