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
+131 -32
View File
@@ -5,6 +5,11 @@ import {
requireMaxCompensationDb,
} from "./frequency-response.js";
import { cooperativeYield } from "./cooperative-yield.js";
import {
DEFAULT_PHAT_RELATIVE_FLOOR,
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
} from "./localization-algorithms.js";
import { binsForBand, createStftWorkspace, extractStftBins, getFrameCount } from "./stft.js";
function throwIfAborted(signal) {
@@ -48,20 +53,24 @@ function createBandIndexLookup(bandBins) {
return lookup;
}
function createBinCalibration(
arrayDefinition,
bin,
sampleRate,
fftSize,
compensateFrequencyResponse,
maximumCompensationDb,
) {
export function createBinCalibration(options) {
const {
arrayDefinition,
bin,
sampleRate,
fftSize,
compensateFrequencyResponse = true,
maxCompensationDb: maximumCompensationDb,
includeAmplitude = true,
} = options;
const microphoneCount = arrayDefinition.microphones.length;
const gains = new Float64Array(microphoneCount);
const phases = new Float64Array(microphoneCount);
const frequencyHz = (bin * sampleRate) / fftSize;
const response = {};
const maxCompensationDb = requireMaxCompensationDb(maximumCompensationDb);
const maxCompensationDb = includeAmplitude
? requireMaxCompensationDb(maximumCompensationDb)
: 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const microphone = arrayDefinition.microphones[microphoneIndex];
let responseGainDb = 0;
@@ -71,23 +80,72 @@ function createBinCalibration(
responseGainDb = response.gainDb;
responsePhaseDeg = response.phaseDeg;
}
const responseCompensationDb = compensateFrequencyResponse
const responseCompensationDb = includeAmplitude && compensateFrequencyResponse
? inverseResponseGainDb(responseGainDb, maxCompensationDb)
: 0;
gains[microphoneIndex] = dbToLinear(
-(microphone.sensitivityDb ?? 0) + responseCompensationDb,
);
gains[microphoneIndex] = includeAmplitude
? dbToLinear(-(microphone.sensitivityDb ?? 0) + responseCompensationDb)
: 1;
phases[microphoneIndex] =
((microphone.phaseOffsetDeg ?? 0) + responsePhaseDeg) * Math.PI / 180;
}
return { gains, phases };
}
function requirePhatRelativeFloor(value) {
if (!Number.isFinite(value) || value < 0 || value > 1) {
throw new RangeError("phatRelativeFloor must lie inside 0..1.");
}
return value;
}
export function phaseNormalizeSpectrum(
spectrum,
frameCount,
microphoneCount,
relativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
) {
const expectedLength = frameCount * microphoneCount;
if (
!spectrum ||
spectrum.real?.length !== expectedLength ||
spectrum.imaginary?.length !== expectedLength
) {
throw new RangeError("spectrum length must match frameCount and microphoneCount.");
}
requirePhatRelativeFloor(relativeFloor);
const real = new Float32Array(expectedLength);
const imaginary = new Float32Array(expectedLength);
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameOffset = frameIndex * microphoneCount;
let maximumMagnitude = 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const spectrumIndex = frameOffset + microphoneIndex;
maximumMagnitude = Math.max(
maximumMagnitude,
Math.hypot(spectrum.real[spectrumIndex], spectrum.imaginary[spectrumIndex]),
);
}
const magnitudeFloor = maximumMagnitude * relativeFloor;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const spectrumIndex = frameOffset + microphoneIndex;
const inputReal = spectrum.real[spectrumIndex];
const inputImaginary = spectrum.imaginary[spectrumIndex];
const magnitude = Math.hypot(inputReal, inputImaginary);
if (!(magnitude > magnitudeFloor)) continue;
real[spectrumIndex] = inputReal / magnitude;
imaginary[spectrumIndex] = inputImaginary / magnitude;
}
}
return { real, imaginary };
}
function fillSteeringVector({
angularWaveNumber,
distances,
distanceOffset,
calibration,
applyCalibrationGains = true,
outputReal,
outputImaginary,
outputOffset,
@@ -96,10 +154,9 @@ function fillSteeringVector({
const phase =
angularWaveNumber * distances[distanceOffset + microphoneIndex] -
calibration.phases[microphoneIndex];
outputReal[outputOffset + microphoneIndex] =
Math.cos(phase) * calibration.gains[microphoneIndex];
outputImaginary[outputOffset + microphoneIndex] =
Math.sin(phase) * calibration.gains[microphoneIndex];
const gain = applyCalibrationGains ? calibration.gains[microphoneIndex] : 1;
outputReal[outputOffset + microphoneIndex] = Math.cos(phase) * gain;
outputImaginary[outputOffset + microphoneIndex] = Math.sin(phase) * gain;
}
}
@@ -113,6 +170,7 @@ async function createSteeringMatrix(options) {
fftSize,
speedOfSound,
calibration,
applyCalibrationGains = true,
signal,
pointBatchSize,
} = options;
@@ -127,6 +185,7 @@ async function createSteeringMatrix(options) {
distances,
distanceOffset: offset,
calibration,
applyCalibrationGains,
outputReal: real,
outputImaginary: imaginary,
outputOffset: offset,
@@ -158,21 +217,32 @@ export async function computeBinMap(options) {
steeringImaginary: providedSteeringImaginary,
signal,
pointBatchSize = 64,
algorithm: algorithmId = "das",
phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
} = options;
const algorithm = requireLocalizationAlgorithmDefinition(algorithmId);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`computeBinMap does not implement ${algorithm.id}.`);
}
const phaseOnly = algorithm.spectralWeighting === "phat";
const microphoneCount = arrayDefinition.microphones.length;
const output = providedOutput ?? new Float32Array(pointCount);
const steeringReal = providedSteeringReal ?? new Float64Array(microphoneCount);
const steeringImaginary = providedSteeringImaginary ?? new Float64Array(microphoneCount);
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
const microphoneScale = 1 / microphoneCount;
const calibration = providedCalibration ?? createBinCalibration(
const calibration = providedCalibration ?? createBinCalibration({
arrayDefinition,
bin,
sampleRate,
fftSize,
compensateFrequencyResponse,
maxCompensationDb,
);
includeAmplitude: !phaseOnly,
});
const inputSpectrum = phaseOnly
? phaseNormalizeSpectrum(spectrum, frameCount, microphoneCount, phatRelativeFloor)
: spectrum;
const phaseRealValues = steeringMatrix?.real ?? steeringReal;
const phaseImaginaryValues = steeringMatrix?.imaginary ?? steeringImaginary;
if (output.length !== pointCount) throw new RangeError("output length must match pointCount.");
@@ -208,6 +278,7 @@ export async function computeBinMap(options) {
outputReal: steeringReal,
outputImaginary: steeringImaginary,
outputOffset: 0,
applyCalibrationGains: !phaseOnly,
});
}
@@ -218,8 +289,8 @@ export async function computeBinMap(options) {
let sumImaginary = 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const spectrumIndex = spectrumOffset + microphoneIndex;
const inputReal = spectrum.real[spectrumIndex];
const inputImaginary = spectrum.imaginary[spectrumIndex];
const inputReal = inputSpectrum.real[spectrumIndex];
const inputImaginary = inputSpectrum.imaginary[spectrumIndex];
const phaseReal = phaseRealValues[steeringOffset + microphoneIndex];
const phaseImaginary = phaseImaginaryValues[steeringOffset + microphoneIndex];
sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary;
@@ -265,6 +336,12 @@ export function createDasAnalysisPlan(options) {
if (!Number.isSafeInteger(maximumPlanBytes) || maximumPlanBytes <= 0) {
throw new RangeError("maximumPlanBytes must be a positive safe integer.");
}
const algorithm = requireLocalizationAlgorithmDefinition(
experiment.beamforming?.algorithm ?? "das",
);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`createDasAnalysisPlan does not implement ${algorithm.id}.`);
}
const { fftSize } = experiment.stft;
const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize));
@@ -319,6 +396,7 @@ export function createDasAnalysisPlan(options) {
arrayDefinition,
scene,
experiment,
algorithm,
microphoneCount,
pointCount,
bandBins,
@@ -394,14 +472,16 @@ function planCalibration(plan, bin) {
plan.statistics.calibrationCacheHits += 1;
return calibration;
}
calibration = createBinCalibration(
plan.arrayDefinition,
calibration = createBinCalibration({
arrayDefinition: plan.arrayDefinition,
bin,
plan.sampleRate,
plan.experiment.stft.fftSize,
plan.experiment.beamforming.compensateFrequencyResponse !== false,
plan.experiment.beamforming.maxCompensationDb,
);
sampleRate: plan.sampleRate,
fftSize: plan.experiment.stft.fftSize,
compensateFrequencyResponse:
plan.experiment.beamforming.compensateFrequencyResponse !== false,
maxCompensationDb: plan.experiment.beamforming.maxCompensationDb,
includeAmplitude: plan.algorithm.spectralWeighting !== "phat",
});
plan.calibrations.set(bin, calibration);
plan.statistics.calibrationBuilds += 1;
plan.statistics.retainedBytes += calibration.gains.byteLength + calibration.phases.byteLength;
@@ -431,6 +511,7 @@ async function planSteeringMatrix(plan, bin, calibration, signal, pointBatchSize
fftSize: plan.experiment.stft.fftSize,
speedOfSound: plan.scene.speedOfSound,
calibration,
applyCalibrationGains: plan.algorithm.spectralWeighting !== "phat",
signal,
pointBatchSize,
});
@@ -520,7 +601,10 @@ export function estimateDasWorkload(options) {
};
}
export function finalizeDasBands(rawMaps, workload, experiment) {
export function finalizeDasBands(rawMaps, workload, experiment, rawScale = "power") {
if (rawScale !== "power" && rawScale !== "score") {
throw new RangeError('rawScale must be "power" or "score".');
}
return experiment.frequencyBands.map((band, bandIndex) => {
const rawMap = rawMaps[bandIndex];
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
@@ -528,7 +612,7 @@ export function finalizeDasBands(rawMaps, workload, experiment) {
}
const rawPeak = findPeak(rawMap);
const displayMap = Float32Array.from(rawMap);
let displayScale = "power";
let displayScale = rawScale;
if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) {
displayScale = "normalized";
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
@@ -540,12 +624,17 @@ export function finalizeDasBands(rawMaps, workload, experiment) {
color: band.color,
bins: workload.bandBins[bandIndex],
rawMap,
rawScale,
displayMap,
displayScale,
peak: {
index: rawPeak.index,
position: scanIndexToPosition(rawPeak.index, experiment.scan),
rawPower: rawPeak.value,
rawValue: rawPeak.value,
rawScale,
...(rawScale === "power"
? { rawPower: rawPeak.value }
: { rawScore: rawPeak.value }),
},
};
});
@@ -566,7 +655,14 @@ async function computeDasMapInternal(options, analysisPlan) {
maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER,
pointBatchSize = 64,
stftTransformBatchSize = 32,
phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
} = options;
const algorithm = analysisPlan?.algorithm ?? requireLocalizationAlgorithmDefinition(
experiment.beamforming?.algorithm ?? "das",
);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`computeDasMap does not implement ${algorithm.id}.`);
}
if (channels.length !== arrayDefinition.microphones.length) {
throw new Error(`채널 수 ${channels.length}와 마이크 수 ${arrayDefinition.microphones.length}가 다릅니다.`);
}
@@ -685,6 +781,8 @@ async function computeDasMapInternal(options, analysisPlan) {
steeringImaginary: analysisPlan?.steeringImaginary,
signal,
pointBatchSize,
algorithm: algorithm.id,
phatRelativeFloor,
});
if (analysisPlan) {
analysisPlan.statistics.beamformingComplexMacs +=
@@ -701,9 +799,10 @@ async function computeDasMapInternal(options, analysisPlan) {
}
}
const bands = finalizeDasBands(rawMaps, workload, experiment);
const bands = finalizeDasBands(rawMaps, workload, experiment, algorithm.rawScale);
return {
algorithm: algorithm.id,
backend: "cpu",
frameCount: workload.frameCount,
microphoneCount: workload.microphoneCount,