361 lines
12 KiB
JavaScript
361 lines
12 KiB
JavaScript
import {
|
|
DAS_PARAMS_BYTE_LENGTH,
|
|
packDasParams,
|
|
packMicrophoneCalibration,
|
|
} from "/packages/webgpu-kernels/das.wgsl.js";
|
|
import {
|
|
WebGpuLimitError,
|
|
WebGpuUnavailableError,
|
|
estimateWebGpuBatchPlan,
|
|
estimateWebGpuBatchSize,
|
|
packStftBatch,
|
|
} 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,
|
|
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
|
);
|
|
}
|
|
|
|
test("WebGPU DAS uniform과 microphone calibration을 WGSL layout으로 packing한다", () => {
|
|
const params = packDasParams({
|
|
pointCount: 81,
|
|
microphoneCount: 16,
|
|
frameCount: 7,
|
|
binCount: 3,
|
|
sampleRate: 8_000,
|
|
fftSize: 256,
|
|
speedOfSound: 343.25,
|
|
});
|
|
const view = new DataView(params);
|
|
|
|
assertEqual(params.byteLength, DAS_PARAMS_BYTE_LENGTH, "uniform byte length");
|
|
assertEqual(view.getUint32(0, true), 81, "point count offset");
|
|
assertEqual(view.getUint32(4, true), 16, "microphone count offset");
|
|
assertEqual(view.getUint32(8, true), 7, "frame count offset");
|
|
assertEqual(view.getUint32(12, true), 3, "bin count offset");
|
|
assertEqual(view.getFloat32(16, true), Math.fround(8_000), "sample rate offset");
|
|
assertEqual(view.getUint32(20, true), 256, "FFT size offset");
|
|
assertEqual(view.getFloat32(24, true), Math.fround(343.25), "speed of sound offset");
|
|
assertEqual(view.getUint32(28, true), 0, "reserved word");
|
|
|
|
const calibration = packMicrophoneCalibration([
|
|
{ sensitivityDb: 0, phaseOffsetDeg: 0 },
|
|
{ sensitivityDb: 6.020599913, phaseOffsetDeg: 90 },
|
|
{ sensitivityDb: -6.020599913, phaseOffsetDeg: -45 },
|
|
]);
|
|
assertEqual(calibration.length, 6, "two f32 values per microphone");
|
|
assertNear(calibration[0], 1, 1e-7, "unity inverse gain");
|
|
assertNear(calibration[1], 0, 1e-7, "zero phase");
|
|
assertNear(calibration[2], 0.5, 1e-6, "+6 dB inverse gain");
|
|
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한다", () => {
|
|
const stft = {
|
|
frameCount: 2,
|
|
microphoneCount: 2,
|
|
spectra: new Map([
|
|
[3, {
|
|
real: Float32Array.of(1, 2, 3, 4),
|
|
imaginary: Float32Array.of(11, 12, 13, 14),
|
|
}],
|
|
[7, {
|
|
real: Float32Array.of(5, 6, 7, 8),
|
|
imaginary: Float32Array.of(15, 16, 17, 18),
|
|
}],
|
|
]),
|
|
};
|
|
|
|
const packed = packStftBatch(stft, [7, 3]);
|
|
assert(packed instanceof Float32Array, "packed STFT type");
|
|
const expected = [
|
|
5, 15, 6, 16, 7, 17, 8, 18,
|
|
1, 11, 2, 12, 3, 13, 4, 14,
|
|
];
|
|
assertEqual(packed.length, expected.length, "packed STFT length");
|
|
expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`));
|
|
});
|
|
|
|
test("WebGPU STFT packing이 microphone 주파수 응답을 bin별로 역보정한다", () => {
|
|
const stft = {
|
|
frameCount: 1,
|
|
microphoneCount: 1,
|
|
spectra: new Map([[8, { real: Float32Array.of(0), imaginary: Float32Array.of(2) }]]),
|
|
};
|
|
const microphones = [{
|
|
frequencyResponse: [
|
|
{ frequencyHz: 0, gainDb: 6.020599913, phaseDeg: 90 },
|
|
{ frequencyHz: 4000, gainDb: 6.020599913, phaseDeg: 90 },
|
|
],
|
|
}];
|
|
const compensated = packStftBatch(stft, [8], {
|
|
microphones,
|
|
sampleRate: 8000,
|
|
fftSize: 256,
|
|
});
|
|
assertNear(compensated[0], 1, 1e-6, "inverse response real");
|
|
assertNear(compensated[1], 0, 1e-6, "inverse response imaginary");
|
|
|
|
const uncompensated = packStftBatch(stft, [8], {
|
|
microphones,
|
|
sampleRate: 8000,
|
|
fftSize: 256,
|
|
compensateFrequencyResponse: false,
|
|
});
|
|
assertEqual(uncompensated[0], 0, "disabled compensation real");
|
|
assertEqual(uncompensated[1], 2, "disabled compensation imaginary");
|
|
|
|
const notchedMicrophones = [{
|
|
frequencyResponse: [
|
|
{ frequencyHz: 0, gainDb: -120 },
|
|
{ frequencyHz: 4000, gainDb: -120 },
|
|
],
|
|
}];
|
|
const notched = {
|
|
frameCount: 1,
|
|
microphoneCount: 1,
|
|
spectra: new Map([[8, { real: Float32Array.of(1), imaginary: Float32Array.of(0) }]]),
|
|
};
|
|
const limited = packStftBatch(notched, [8], {
|
|
microphones: notchedMicrophones,
|
|
sampleRate: 8000,
|
|
fftSize: 256,
|
|
});
|
|
const explicitLegacyLimit = packStftBatch(notched, [8], {
|
|
microphones: notchedMicrophones,
|
|
sampleRate: 8000,
|
|
fftSize: 256,
|
|
maxCompensationDb: 120,
|
|
});
|
|
assertNear(limited[0], 10 ** (24 / 20), 1e-5, "default notch boost limit");
|
|
assert(explicitLegacyLimit[0] > 999_000, "explicit 120 dB notch boost");
|
|
});
|
|
|
|
test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {
|
|
const workload = {
|
|
selectedBins: [4, 5, 6, 7, 8],
|
|
bandBins: [[4, 5, 6, 7, 8]],
|
|
frameCount: 2,
|
|
microphoneCount: 4,
|
|
pointCount: 8,
|
|
distanceBytes: 128,
|
|
outputBytes: 64,
|
|
spectrumBytesPerBin: 64,
|
|
baseWorkingBytes: 256,
|
|
};
|
|
const limits = {
|
|
maxStorageBufferBindingSize: 192,
|
|
maxBufferSize: 192,
|
|
maxComputeWorkgroupsPerDimension: 2,
|
|
maxComputeInvocationsPerWorkgroup: 64,
|
|
maxComputeWorkgroupSizeX: 64,
|
|
};
|
|
|
|
const batchSize = estimateWebGpuBatchSize({
|
|
workload,
|
|
limits,
|
|
maximumGpuWorkingBytes: 4_096,
|
|
maximumHostWorkingBytes: 4_096,
|
|
});
|
|
assertEqual(batchSize, 2, "dispatch y dimension is the tightest batch limit");
|
|
const plan = estimateWebGpuBatchPlan({
|
|
workload,
|
|
limits,
|
|
maximumGpuWorkingBytes: 4_096,
|
|
maximumHostWorkingBytes: 4_096,
|
|
});
|
|
assertEqual(plan.pointBatchSize, 2, "steering point dispatch batch");
|
|
assertEqual(plan.steeringBytes, 128, "two-pass steering buffer bytes");
|
|
|
|
let limitError;
|
|
try {
|
|
estimateWebGpuBatchSize({
|
|
workload,
|
|
limits: { ...limits, maxStorageBufferBindingSize: 31 },
|
|
maximumGpuWorkingBytes: 4_096,
|
|
maximumHostWorkingBytes: 4_096,
|
|
});
|
|
} catch (error) {
|
|
limitError = error;
|
|
}
|
|
assert(limitError instanceof WebGpuLimitError, "static calibration buffer limit error type");
|
|
|
|
let invocationLimitError;
|
|
try {
|
|
estimateWebGpuBatchSize({
|
|
workload,
|
|
limits,
|
|
maximumGpuWorkingBytes: 4_096,
|
|
maximumHostWorkingBytes: 4_096,
|
|
maximumMacsPerInvocation: 7,
|
|
});
|
|
} catch (error) {
|
|
invocationLimitError = error;
|
|
}
|
|
assert(invocationLimitError instanceof WebGpuLimitError, "per-invocation watchdog guard error type");
|
|
|
|
const scalingWorkload = {
|
|
selectedBins: [10, 11],
|
|
bandBins: [[10, 11]],
|
|
frameCount: 46,
|
|
microphoneCount: 192,
|
|
pointCount: 40_000,
|
|
distanceBytes: 40_000 * 192 * 4,
|
|
outputBytes: 40_000 * 4 * 2,
|
|
spectrumBytesPerBin: 46 * 192 * 8,
|
|
baseWorkingBytes: 40_000 * 192 * 4 + 40_000 * 4 * 3 + 512 * 8 * 2,
|
|
};
|
|
const scalingPlan = estimateWebGpuBatchPlan({
|
|
workload: scalingWorkload,
|
|
limits: {
|
|
maxStorageBufferBindingSize: 128 * 1024 ** 2,
|
|
maxBufferSize: 256 * 1024 ** 2,
|
|
maxComputeWorkgroupsPerDimension: 65_535,
|
|
maxComputeInvocationsPerWorkgroup: 256,
|
|
maxComputeWorkgroupSizeX: 256,
|
|
maxStorageBuffersPerShaderStage: 8,
|
|
maxUniformBufferBindingSize: 65_536,
|
|
},
|
|
});
|
|
assertEqual(scalingPlan.binBatchSize, 2, "200x200 preserves one CPU STFT bin batch");
|
|
assert(scalingPlan.pointBatchSize < 40_000, "200x200 steering uses point chunks");
|
|
assert(scalingPlan.steeringBytes <= 64 * 1024 ** 2, "steering target is respected");
|
|
});
|
|
|
|
test("backend auto는 typed WebGPU 운용 오류만 CPU로 fallback한다", async () => {
|
|
const calls = [];
|
|
const options = { experiment: { beamforming: { backend: "auto" } } };
|
|
const cpuResult = { backend: "cpu", bands: [], marker: "fallback-result" };
|
|
const result = await computeDasWithBackend(options, {
|
|
computeGpu: async () => {
|
|
calls.push("gpu");
|
|
throw new WebGpuUnavailableError("adapter unavailable");
|
|
},
|
|
computeCpu: async () => {
|
|
calls.push("cpu");
|
|
return cpuResult;
|
|
},
|
|
});
|
|
|
|
assertEqual(calls.join(","), "gpu,cpu", "auto backend order");
|
|
assertEqual(result.backend, "cpu", "fallback backend");
|
|
assertEqual(result.marker, "fallback-result", "CPU result is preserved");
|
|
|
|
const limitFallback = await computeDasWithBackend(options, {
|
|
computeGpu: async () => {
|
|
throw new WebGpuLimitError("device limit");
|
|
},
|
|
computeCpu: async () => cpuResult,
|
|
});
|
|
assertEqual(limitFallback.backend, "cpu", "device limit also uses CPU fallback in auto mode");
|
|
|
|
let programmingError;
|
|
try {
|
|
await computeDasWithBackend(options, {
|
|
computeGpu: async () => {
|
|
throw new TypeError("programming error");
|
|
},
|
|
computeCpu: async () => {
|
|
throw new Error("CPU must not run");
|
|
},
|
|
});
|
|
} catch (error) {
|
|
programmingError = error;
|
|
}
|
|
assert(programmingError instanceof TypeError, "unexpected GPU errors are not hidden by fallback");
|
|
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
let abortError;
|
|
try {
|
|
await computeDasWithBackend(
|
|
{ ...options, signal: controller.signal },
|
|
{
|
|
computeGpu: async () => {
|
|
throw new WebGpuUnavailableError("adapter unavailable after cancellation");
|
|
},
|
|
computeCpu: async () => {
|
|
throw new Error("cancelled auto mode must not start CPU fallback");
|
|
},
|
|
},
|
|
);
|
|
} catch (error) {
|
|
abortError = error;
|
|
}
|
|
assertEqual(abortError?.name, "AbortError", "cancellation wins over GPU fallback");
|
|
|
|
const explicitOptions = { experiment: { beamforming: { backend: "webgpu" } } };
|
|
let explicitError;
|
|
try {
|
|
await computeDasWithBackend(explicitOptions, {
|
|
computeGpu: async () => {
|
|
throw new WebGpuLimitError("forced device limit");
|
|
},
|
|
computeCpu: async () => {
|
|
throw new Error("explicit webgpu must not run CPU fallback");
|
|
},
|
|
});
|
|
} catch (error) {
|
|
explicitError = error;
|
|
}
|
|
assert(explicitError instanceof WebGpuLimitError, "explicit WebGPU preserves operational error");
|
|
});
|