feat: add comparable localization algorithms
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user