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
+51 -1
View File
@@ -1,4 +1,9 @@
import { computeDasMap } from "../acoustics-core/das.js";
import { computeGccPhatMap } from "../acoustics-core/gcc-phat.js";
import {
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
} from "../acoustics-core/localization-algorithms.js";
import { computeDasWebGpu } from "./das.js";
import { isRecoverableWebGpuError } from "./device.js";
@@ -17,7 +22,7 @@ function throwIfAborted(signal) {
* Execute the requested DAS backend. `auto` falls back only for typed WebGPU
* operational errors; invalid inputs and AbortError remain visible to callers.
*/
export async function computeDasWithBackend(options, dependencies = {}) {
async function computeDasBackend(options, dependencies = {}) {
const computeGpu = dependencies.computeGpu ?? computeDasWebGpu;
const computeCpu = dependencies.computeCpu ?? computeDasMap;
const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto";
@@ -56,3 +61,48 @@ export async function computeDasWithBackend(options, dependencies = {}) {
}
}
}
/**
* Execute a DAS-family algorithm with the requested CPU/WebGPU policy.
* Conventional DAS callers can keep using this backwards-compatible entry.
*/
export async function computeDasWithBackend(options, dependencies = {}) {
const algorithm = requireLocalizationAlgorithmDefinition(
options?.experiment?.beamforming?.algorithm,
);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(
`computeDasWithBackend does not implement ${algorithm.id}.`,
);
}
return computeDasBackend(options, dependencies);
}
/**
* Execute any registered localization algorithm. GCC-PHAT currently uses its
* portable CPU implementation for both `auto` and `cpu`; an explicit WebGPU
* request is rejected instead of silently changing the requested backend.
*/
export async function computeLocalizationWithBackend(options, dependencies = {}) {
const algorithm = requireLocalizationAlgorithmDefinition(
options?.experiment?.beamforming?.algorithm,
);
if (isDasAlgorithm(algorithm.id)) {
return computeDasBackend(options, dependencies);
}
const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto";
if (requestedBackend === "webgpu") {
throw new TypeError(
`${algorithm.label} currently supports only the CPU backend; choose auto or cpu.`,
);
}
if (requestedBackend !== "auto" && requestedBackend !== "cpu") {
throw new TypeError(`unsupported localization backend: ${requestedBackend}`);
}
const computeCpu =
dependencies.computeGccCpu ??
dependencies.computeCpu ??
computeGccPhatMap;
return computeCpu(options);
}
+80 -5
View File
@@ -9,6 +9,11 @@ import {
requireMaxCompensationDb,
} from "../acoustics-core/frequency-response.js";
import { dbToLinear } from "../acoustics-core/math.js";
import {
DEFAULT_PHAT_RELATIVE_FLOOR,
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
} from "../acoustics-core/localization-algorithms.js";
import { extractStftBins } from "../acoustics-core/stft.js";
import {
DAS_PARAMS_BYTE_LENGTH,
@@ -72,11 +77,60 @@ function minimumBufferSize(byteLength) {
return Math.max(4, Math.ceil(byteLength / 4) * 4);
}
function requirePhatRelativeFloor(value) {
if (!Number.isFinite(value) || value < 0 || value > 1) {
throw new RangeError("phatRelativeFloor must lie inside 0..1.");
}
return value;
}
function phaseNormalizePackedStft(
packed,
binCount,
frameCount,
microphoneCount,
relativeFloor,
) {
requirePhatRelativeFloor(relativeFloor);
const valuesPerBin = frameCount * microphoneCount;
for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
const binOffset = binIndex * valuesPerBin * 2;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameOffset = binOffset + frameIndex * microphoneCount * 2;
let maximumMagnitude = 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const valueOffset = frameOffset + microphoneIndex * 2;
maximumMagnitude = Math.max(
maximumMagnitude,
Math.hypot(packed[valueOffset], packed[valueOffset + 1]),
);
}
const magnitudeFloor = maximumMagnitude * relativeFloor;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const valueOffset = frameOffset + microphoneIndex * 2;
const magnitude = Math.hypot(packed[valueOffset], packed[valueOffset + 1]);
if (!(magnitude > magnitudeFloor)) {
packed[valueOffset] = 0;
packed[valueOffset + 1] = 0;
continue;
}
packed[valueOffset] /= magnitude;
packed[valueOffset + 1] /= magnitude;
}
}
}
}
export function packStftBatch(stft, bins, options = {}) {
if (!stft || !Number.isInteger(stft.frameCount) || !Number.isInteger(stft.microphoneCount)) {
throw new TypeError("유효한 STFT 결과가 필요합니다.");
}
if (!Array.isArray(bins) || bins.length === 0) throw new TypeError("하나 이상의 FFT bin이 필요합니다.");
const algorithm = requireLocalizationAlgorithmDefinition(options.algorithm ?? "das");
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`packStftBatch does not implement ${algorithm.id}.`);
}
const phaseOnly = algorithm.spectralWeighting === "phat";
const valuesPerBin = stft.frameCount * stft.microphoneCount;
const packed = new Float32Array(bins.length * valuesPerBin * 2);
const microphones = options.microphones;
@@ -105,9 +159,9 @@ export function packStftBatch(stft, bins, options = {}) {
microphones.forEach((microphone, microphoneIndex) => {
if (!Array.isArray(microphone.frequencyResponse)) return;
interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response);
const inverseGain = dbToLinear(
inverseResponseGainDb(response.gainDb, maxCompensationDb),
);
const inverseGain = phaseOnly
? 1
: dbToLinear(inverseResponseGainDb(response.gainDb, maxCompensationDb));
const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2;
inverseResponses[responseOffset] = inverseGain * Math.cos(response.phaseRadians);
inverseResponses[responseOffset + 1] = -inverseGain * Math.sin(response.phaseRadians);
@@ -138,6 +192,15 @@ export function packStftBatch(stft, bins, options = {}) {
inputReal * inverseImaginary + inputImaginary * inverseReal;
}
});
if (phaseOnly) {
phaseNormalizePackedStft(
packed,
bins.length,
stft.frameCount,
stft.microphoneCount,
options.phatRelativeFloor ?? DEFAULT_PHAT_RELATIVE_FLOOR,
);
}
return packed;
}
@@ -445,9 +508,16 @@ export async function computeDasWebGpu(options) {
maximumGpuComplexMacs = 20_000_000_000,
maximumFftButterflies = 1_000_000_000,
stftTransformBatchSize = 32,
phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
gpu,
powerPreference,
} = options;
const algorithm = requireLocalizationAlgorithmDefinition(
experiment.beamforming?.algorithm ?? "das",
);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`computeDasWebGpu does not implement ${algorithm.id}.`);
}
if (!Array.isArray(channels) || channels.length !== arrayDefinition?.microphones?.length) {
throw new Error(`채널 수 ${channels?.length ?? 0}와 마이크 수 ${arrayDefinition?.microphones?.length ?? 0}가 다릅니다.`);
}
@@ -501,7 +571,9 @@ export async function computeDasWebGpu(options) {
label: "DAS beamforming pipeline",
}),
}));
const calibration = packMicrophoneCalibration(arrayDefinition.microphones);
const calibration = packMicrophoneCalibration(arrayDefinition.microphones, {
includeAmplitude: algorithm.spectralWeighting !== "phat",
});
const calibrationBuffer = await withWebGpuErrorScopes(context.device, async () =>
createBuffer(context.device, {
label: "DAS microphone calibration",
@@ -543,12 +615,14 @@ export async function computeDasWebGpu(options) {
}),
});
const packedSpectrum = packStftBatch(stft, batchBins, {
algorithm: algorithm.id,
microphones: arrayDefinition.microphones,
sampleRate,
fftSize: experiment.stft.fftSize,
compensateFrequencyResponse:
experiment.beamforming.compensateFrequencyResponse !== false,
maxCompensationDb: experiment.beamforming.maxCompensationDb,
phatRelativeFloor,
});
stftMs += now() - stftStartedAt;
const binIndices = Uint32Array.from(batchBins);
@@ -688,9 +762,10 @@ export async function computeDasWebGpu(options) {
completedBins += batchBins.length;
}
const bands = finalizeDasBands(rawMaps, workload, experiment);
const bands = finalizeDasBands(rawMaps, workload, experiment, algorithm.rawScale);
const totalMs = now() - totalStartedAt;
return {
algorithm: algorithm.id,
backend: "webgpu",
frameCount: workload.frameCount,
microphoneCount: workload.microphoneCount,
+8 -4
View File
@@ -12,7 +12,7 @@
* Flat buffer layouts:
* spectrum: [bin][frame][microphone][real, imaginary]
* distances: [point][microphone]
* calibration: [microphone][inverse sensitivity gain, phase offset radians]
* calibration: [microphone][inverse sensitivity gain or unity, phase offset radians]
* steering: [bin][point][microphone][real, imaginary]
* output: [bin][point]
*/
@@ -197,9 +197,13 @@ export function packDasCalibration(calibration) {
return packed;
}
/** Convert array-definition microphone metadata to the shader calibration layout. */
export function packMicrophoneCalibration(microphones) {
/**
* Convert array-definition microphone metadata to the shader calibration layout.
* Phase-only algorithms set includeAmplitude=false while retaining phase offsets.
*/
export function packMicrophoneCalibration(microphones, options = {}) {
if (!Array.isArray(microphones)) throw new TypeError("microphones must be an array.");
const includeAmplitude = options.includeAmplitude !== false;
const packed = new Float32Array(microphones.length * 2);
for (let index = 0; index < microphones.length; index += 1) {
const microphone = microphones[index];
@@ -213,7 +217,7 @@ export function packMicrophoneCalibration(microphones) {
);
packed[index * 2] = requireFiniteF32(
`microphones[${index}] inverse sensitivity gain`,
10 ** (-sensitivityDb / 20),
includeAmplitude ? 10 ** (-sensitivityDb / 20) : 1,
);
packed[index * 2 + 1] = requireFiniteF32(
`microphones[${index}] phase offset`,