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
+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,