feat: add comparable localization algorithms
This commit is contained in:
+131
-32
@@ -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,
|
||||
|
||||
@@ -50,8 +50,12 @@ function normalizeRawMap(rawMap, scan) {
|
||||
if (!Array.isArray(values) && !ArrayBuffer.isView(values)) {
|
||||
throw new TypeError("rawMap must be an Array, a typed array, or a map descriptor with values.");
|
||||
}
|
||||
if (descriptor?.scale !== undefined && descriptor.scale !== "power") {
|
||||
throw new RangeError("rawMap scale must be power.");
|
||||
if (
|
||||
descriptor?.scale !== undefined &&
|
||||
descriptor.scale !== "power" &&
|
||||
descriptor.scale !== "score"
|
||||
) {
|
||||
throw new RangeError("rawMap scale must be power or score.");
|
||||
}
|
||||
if (descriptor?.rows !== undefined && descriptor.rows !== scan.rows) {
|
||||
throw new RangeError("rawMap rows do not match scan.rows.");
|
||||
@@ -69,6 +73,13 @@ function normalizeRawMap(rawMap, scan) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function rawMapScale(rawMap) {
|
||||
if (rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap)) {
|
||||
return rawMap.scale ?? "power";
|
||||
}
|
||||
return "power";
|
||||
}
|
||||
|
||||
function normalizeSources(sources) {
|
||||
if (!Array.isArray(sources)) throw new TypeError("sources must be an array.");
|
||||
const ids = new Set();
|
||||
@@ -420,12 +431,14 @@ function collectHalfPowerIndices(values, scan, peakIndex, halfPowerRatio) {
|
||||
return { indices: queue.subarray(0, queueEnd), thresholdValue };
|
||||
}
|
||||
|
||||
function measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio) {
|
||||
function measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio, mapScale = "power") {
|
||||
const component = collectHalfPowerIndices(values, scan, peakIndex, halfPowerRatio);
|
||||
const peakPosition = scanIndexToPosition(peakIndex, scan);
|
||||
const base = {
|
||||
calibrated: false,
|
||||
method: "connected-half-power-power-weighted-covariance",
|
||||
method: mapScale === "score"
|
||||
? "connected-half-maximum-score-weighted-covariance"
|
||||
: "connected-half-power-power-weighted-covariance",
|
||||
halfPowerRatio,
|
||||
thresholdValue: component.thresholdValue,
|
||||
pixelCount: component.indices.length,
|
||||
@@ -506,18 +519,28 @@ export function measureBeamSpread(rawMap, scan, peakIndex, halfPowerRatio = 0.5)
|
||||
throw new RangeError("peakIndex is outside rawMap.");
|
||||
}
|
||||
requireRatio("halfPowerRatio", halfPowerRatio, { positive: true });
|
||||
return measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio);
|
||||
return measureBeamSpreadFromValues(
|
||||
values,
|
||||
scan,
|
||||
peakIndex,
|
||||
halfPowerRatio,
|
||||
rawMapScale(rawMap),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one linear-power DAS band. Source z coordinates are projected onto
|
||||
* scan.z, matching the image-plane ground-truth convention.
|
||||
* Evaluate one nonnegative spatial power or score map. Source z coordinates
|
||||
* are projected onto scan.z, matching the image-plane ground-truth convention.
|
||||
*/
|
||||
export function evaluateDasBand(options) {
|
||||
export function evaluateLocalizationBand(options) {
|
||||
if (!options || typeof options !== "object") throw new TypeError("evaluation options are required.");
|
||||
const { rawMap, scan } = options;
|
||||
validateScan(scan);
|
||||
const values = normalizeRawMap(rawMap, scan);
|
||||
const mapScale = options.mapScale ?? rawMapScale(rawMap);
|
||||
if (mapScale !== "power" && mapScale !== "score") {
|
||||
throw new RangeError("mapScale must be power or score.");
|
||||
}
|
||||
const sources = normalizeSources(options.sources ?? options.scene?.sources ?? []);
|
||||
const truths = sources.map((source) => ({
|
||||
sourceId: source.id,
|
||||
@@ -558,7 +581,13 @@ export function evaluateDasBand(options) {
|
||||
const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
|
||||
(detection) => ({
|
||||
...detection,
|
||||
beamSpread: measureBeamSpreadFromValues(values, scan, detection.index, halfPowerRatio),
|
||||
beamSpread: measureBeamSpreadFromValues(
|
||||
values,
|
||||
scan,
|
||||
detection.index,
|
||||
halfPowerRatio,
|
||||
mapScale,
|
||||
),
|
||||
}),
|
||||
);
|
||||
const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
|
||||
@@ -604,6 +633,7 @@ export function evaluateDasBand(options) {
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
},
|
||||
diagnostics: {
|
||||
mapScale,
|
||||
mainLobe,
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
gridResolutionM: resolution,
|
||||
@@ -611,6 +641,11 @@ export function evaluateDasBand(options) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Backwards-compatible name for callers that evaluate conventional DAS maps. */
|
||||
export function evaluateDasBand(options) {
|
||||
return evaluateLocalizationBand(options);
|
||||
}
|
||||
|
||||
/** Select only metric names accepted by result.schema.json. */
|
||||
export function selectResultMetrics(evaluationOrMetrics, metricNames = RESULT_METRIC_KEYS) {
|
||||
const metrics = evaluationOrMetrics?.metrics ?? evaluationOrMetrics;
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
import {
|
||||
createBinCalibration,
|
||||
createDistanceMatrix,
|
||||
finalizeDasBands,
|
||||
} from "./das.js";
|
||||
import { cooperativeYield } from "./cooperative-yield.js";
|
||||
import { fftInPlace } from "./fft.js";
|
||||
import {
|
||||
binsForBand,
|
||||
createStftWorkspace,
|
||||
extractStftBins,
|
||||
getFrameCount,
|
||||
} from "./stft.js";
|
||||
|
||||
export const GCC_PHAT_ANALYSIS_PLAN_KIND = "micarrsim.gcc-phat-analysis-plan/1";
|
||||
export const DEFAULT_GCC_PHAT_FLOOR_RATIO = 1e-6;
|
||||
export const DEFAULT_MAXIMUM_GCC_PHAT_PLAN_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
function createAbortError(signal) {
|
||||
if (signal?.reason instanceof Error && signal.reason.name === "AbortError") {
|
||||
return signal.reason;
|
||||
}
|
||||
const error = new Error("GCC-PHAT localization was canceled.");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) throw createAbortError(signal);
|
||||
}
|
||||
|
||||
function requirePositiveFinite(value, label) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new RangeError(label + " must be a positive finite number.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value, label) {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new RangeError(label + " must be a positive integer.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateArrayDefinition(arrayDefinition) {
|
||||
if (!Array.isArray(arrayDefinition?.microphones) || arrayDefinition.microphones.length < 2) {
|
||||
throw new RangeError("GCC-PHAT requires at least two microphones.");
|
||||
}
|
||||
arrayDefinition.microphones.forEach((microphone, index) => {
|
||||
if (
|
||||
!Array.isArray(microphone?.position) ||
|
||||
microphone.position.length !== 3 ||
|
||||
!microphone.position.every(Number.isFinite)
|
||||
) {
|
||||
throw new TypeError(
|
||||
"arrayDefinition.microphones[" + index + "].position must be a finite [x, y, z] array.",
|
||||
);
|
||||
}
|
||||
});
|
||||
return arrayDefinition;
|
||||
}
|
||||
|
||||
function validateConfiguration(options) {
|
||||
const sampleRate = requirePositiveFinite(options.sampleRate, "sampleRate");
|
||||
const arrayDefinition = validateArrayDefinition(options.arrayDefinition);
|
||||
const speedOfSound = requirePositiveFinite(options.scene?.speedOfSound, "scene.speedOfSound");
|
||||
const experiment = options.experiment;
|
||||
if (!experiment?.stft || !experiment?.scan || !Array.isArray(experiment.frequencyBands)) {
|
||||
throw new TypeError("experiment must contain stft, scan, and frequencyBands.");
|
||||
}
|
||||
const fftSize = requirePositiveInteger(experiment.stft.fftSize, "experiment.stft.fftSize");
|
||||
if ((fftSize & (fftSize - 1)) !== 0) {
|
||||
throw new RangeError("experiment.stft.fftSize must be a power of two.");
|
||||
}
|
||||
const hopSize = requirePositiveInteger(experiment.stft.hopSize, "experiment.stft.hopSize");
|
||||
if (hopSize > fftSize) {
|
||||
throw new RangeError("experiment.stft.hopSize must not exceed fftSize.");
|
||||
}
|
||||
const pointCount =
|
||||
requirePositiveInteger(experiment.scan.columns, "experiment.scan.columns") *
|
||||
requirePositiveInteger(experiment.scan.rows, "experiment.scan.rows");
|
||||
if (!Number.isSafeInteger(pointCount)) {
|
||||
throw new RangeError("scan point count exceeds the safe integer range.");
|
||||
}
|
||||
if (experiment.frequencyBands.length === 0) {
|
||||
throw new RangeError("experiment.frequencyBands must not be empty.");
|
||||
}
|
||||
return { sampleRate, arrayDefinition, speedOfSound, experiment, fftSize, hopSize, pointCount };
|
||||
}
|
||||
|
||||
function validateChannels(channels, microphoneCount) {
|
||||
if (!Array.isArray(channels) || channels.length !== microphoneCount) {
|
||||
throw new Error(
|
||||
"channel count " + (channels?.length ?? 0) +
|
||||
" does not match microphone count " + microphoneCount + ".",
|
||||
);
|
||||
}
|
||||
if (!channels.every((channel) => channel instanceof Float32Array)) {
|
||||
throw new TypeError("every channel must be a Float32Array.");
|
||||
}
|
||||
const sampleCount = channels[0].length;
|
||||
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
|
||||
if (channels.some((channel) => channel.length !== sampleCount)) {
|
||||
throw new Error("all channels must have the same sample count.");
|
||||
}
|
||||
return sampleCount;
|
||||
}
|
||||
|
||||
function referenceTieBreak(left, right) {
|
||||
if (left.id < right.id) return -1;
|
||||
if (left.id > right.id) return 1;
|
||||
return left.index - right.index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a stable reference without depending on microphone input order.
|
||||
*
|
||||
* A peripheral microphone generally provides a longer TDOA baseline. Exact
|
||||
* geometric ties are resolved by microphone id and finally by input index.
|
||||
*/
|
||||
export function selectGccPhatReference(arrayDefinition) {
|
||||
validateArrayDefinition(arrayDefinition);
|
||||
const centroid = [0, 0, 0];
|
||||
for (const microphone of arrayDefinition.microphones) {
|
||||
for (let axis = 0; axis < 3; axis += 1) centroid[axis] += microphone.position[axis];
|
||||
}
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
centroid[axis] /= arrayDefinition.microphones.length;
|
||||
}
|
||||
|
||||
let selected;
|
||||
arrayDefinition.microphones.forEach((microphone, index) => {
|
||||
const descriptor = {
|
||||
index,
|
||||
id: String(microphone.id ?? ""),
|
||||
microphone,
|
||||
distanceSquared:
|
||||
(microphone.position[0] - centroid[0]) ** 2 +
|
||||
(microphone.position[1] - centroid[1]) ** 2 +
|
||||
(microphone.position[2] - centroid[2]) ** 2,
|
||||
};
|
||||
if (!selected) {
|
||||
selected = descriptor;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
descriptor.distanceSquared > selected.distanceSquared ||
|
||||
(
|
||||
descriptor.distanceSquared === selected.distanceSquared &&
|
||||
referenceTieBreak(descriptor, selected) < 0
|
||||
)
|
||||
) {
|
||||
selected = descriptor;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
index: selected.index,
|
||||
id: selected.id,
|
||||
microphone: selected.microphone,
|
||||
});
|
||||
}
|
||||
|
||||
function createBandGeometry(configuration) {
|
||||
const bandBins = configuration.experiment.frequencyBands.map((band) =>
|
||||
binsForBand(band, configuration.sampleRate, configuration.fftSize)
|
||||
);
|
||||
const selectedBins = [...new Set(bandBins.flat())].sort((left, right) => left - right);
|
||||
return { bandBins, selectedBins };
|
||||
}
|
||||
|
||||
function maximumGeometricLagSamples({
|
||||
distances,
|
||||
pointCount,
|
||||
microphoneCount,
|
||||
referenceIndex,
|
||||
sampleRate,
|
||||
speedOfSound,
|
||||
}) {
|
||||
let maximumLagSamples = 0;
|
||||
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
|
||||
const distanceOffset = pointIndex * microphoneCount;
|
||||
const referenceDistance = distances[distanceOffset + referenceIndex];
|
||||
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
||||
if (microphoneIndex === referenceIndex) continue;
|
||||
const lagSamples =
|
||||
Math.abs(distances[distanceOffset + microphoneIndex] - referenceDistance) *
|
||||
sampleRate / speedOfSound;
|
||||
maximumLagSamples = Math.max(maximumLagSamples, lagSamples);
|
||||
}
|
||||
}
|
||||
return maximumLagSamples;
|
||||
}
|
||||
|
||||
function assertUnaliasedLag(maximumLagSamples, fftSize) {
|
||||
const maximumUnaliasedLag = fftSize / 2;
|
||||
if (maximumLagSamples > maximumUnaliasedLag) {
|
||||
throw new RangeError(
|
||||
"GCC-PHAT maximum absolute geometric lag " + maximumLagSamples.toFixed(3) +
|
||||
" samples exceeds fftSize/2 (" + maximumUnaliasedLag +
|
||||
"); increase fftSize or reduce the array aperture or scan range.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createGeometry(configuration, distances) {
|
||||
const microphoneCount = configuration.arrayDefinition.microphones.length;
|
||||
const reference = selectGccPhatReference(configuration.arrayDefinition);
|
||||
const pairMicrophoneIndexes = [];
|
||||
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
||||
if (microphoneIndex !== reference.index) pairMicrophoneIndexes.push(microphoneIndex);
|
||||
}
|
||||
const resolvedDistances =
|
||||
distances ?? createDistanceMatrix(configuration.arrayDefinition, configuration.experiment.scan);
|
||||
const maximumLagSamples = maximumGeometricLagSamples({
|
||||
distances: resolvedDistances,
|
||||
pointCount: configuration.pointCount,
|
||||
microphoneCount,
|
||||
referenceIndex: reference.index,
|
||||
sampleRate: configuration.sampleRate,
|
||||
speedOfSound: configuration.speedOfSound,
|
||||
});
|
||||
assertUnaliasedLag(maximumLagSamples, configuration.fftSize);
|
||||
return {
|
||||
microphoneCount,
|
||||
reference,
|
||||
pairMicrophoneIndexes,
|
||||
pairCount: pairMicrophoneIndexes.length,
|
||||
distances: resolvedDistances,
|
||||
maximumLagSamples,
|
||||
...createBandGeometry(configuration),
|
||||
};
|
||||
}
|
||||
|
||||
function requirePhatFloorRatio(value) {
|
||||
const ratio = value ?? DEFAULT_GCC_PHAT_FLOOR_RATIO;
|
||||
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
|
||||
throw new RangeError("phatFloorRatio must be a finite number from 0 through 1.");
|
||||
}
|
||||
return ratio;
|
||||
}
|
||||
|
||||
function estimateWithGeometry(options, configuration, geometry) {
|
||||
const sampleCount = validateChannels(options.channels, geometry.microphoneCount);
|
||||
const frameCount = getFrameCount(
|
||||
sampleCount,
|
||||
configuration.fftSize,
|
||||
configuration.hopSize,
|
||||
);
|
||||
const pairCount = geometry.pairCount;
|
||||
const bandCount = geometry.bandBins.length;
|
||||
const bandBinCount = geometry.bandBins.reduce((sum, bins) => sum + bins.length, 0);
|
||||
const selectedBinCount = geometry.selectedBins.length;
|
||||
const stftTransformCount = frameCount * geometry.microphoneCount;
|
||||
const inverseFftCount = pairCount * bandCount;
|
||||
const fftButterfliesPerTransform =
|
||||
(configuration.fftSize / 2) * Math.log2(configuration.fftSize);
|
||||
const stftButterflyCount = stftTransformCount * fftButterfliesPerTransform;
|
||||
const inverseFftButterflyCount = inverseFftCount * fftButterfliesPerTransform;
|
||||
const crossSpectrumSampleCount = pairCount * frameCount * bandBinCount;
|
||||
// The relative-floor pass finds the maximum magnitude, then the PHAT pass
|
||||
// recomputes and accumulates each complex cross-spectrum sample.
|
||||
const crossSpectrumComplexOpCount = crossSpectrumSampleCount * 2;
|
||||
const scanLookupCount = configuration.pointCount * pairCount * bandCount;
|
||||
const dasEquivalentComplexMacCount =
|
||||
configuration.pointCount * geometry.microphoneCount * frameCount * selectedBinCount;
|
||||
const distanceBytes =
|
||||
configuration.pointCount * geometry.microphoneCount * Float32Array.BYTES_PER_ELEMENT;
|
||||
const stftWorkspaceBytes =
|
||||
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
|
||||
const correlationWorkspaceBytes =
|
||||
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
const spectrumBytes =
|
||||
selectedBinCount * frameCount * geometry.microphoneCount *
|
||||
Float32Array.BYTES_PER_ELEMENT * 2;
|
||||
const calibrationPhaseBytes =
|
||||
selectedBinCount * geometry.microphoneCount * Float64Array.BYTES_PER_ELEMENT;
|
||||
const outputBytes =
|
||||
bandCount * configuration.pointCount * Float32Array.BYTES_PER_ELEMENT * 2;
|
||||
const retainedPlanBytes =
|
||||
options.analysisPlan?.retainedBytes ??
|
||||
distanceBytes + stftWorkspaceBytes + correlationWorkspaceBytes;
|
||||
const estimatedWorkingBytes =
|
||||
retainedPlanBytes + spectrumBytes + calibrationPhaseBytes + outputBytes;
|
||||
|
||||
return {
|
||||
bandBins: geometry.bandBins,
|
||||
selectedBins: geometry.selectedBins,
|
||||
selectedBinCount,
|
||||
bandBinCount,
|
||||
frameCount,
|
||||
microphoneCount: geometry.microphoneCount,
|
||||
pointCount: configuration.pointCount,
|
||||
bandCount,
|
||||
pairCount,
|
||||
referenceMicrophoneId: geometry.reference.id,
|
||||
referenceMicrophoneIndex: geometry.reference.index,
|
||||
maximumLagSamples: geometry.maximumLagSamples,
|
||||
stftTransformCount,
|
||||
inverseFftCount,
|
||||
stftButterflyCount,
|
||||
inverseFftButterflyCount,
|
||||
crossSpectrumSampleCount,
|
||||
crossSpectrumComplexOpCount,
|
||||
crossSpectrumCount: crossSpectrumComplexOpCount,
|
||||
complexMacCount: crossSpectrumComplexOpCount,
|
||||
scanLookupCount,
|
||||
operationCount: crossSpectrumComplexOpCount + scanLookupCount,
|
||||
dasEquivalentComplexMacCount,
|
||||
distanceBytes,
|
||||
stftWorkspaceBytes,
|
||||
correlationWorkspaceBytes,
|
||||
spectrumBytes,
|
||||
calibrationPhaseBytes,
|
||||
outputBytes,
|
||||
retainedPlanBytes,
|
||||
estimatedWorkingBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate CPU work for one reference-pair GCC-PHAT analysis.
|
||||
*/
|
||||
export function estimateGccPhatWorkload(options) {
|
||||
if (!options || typeof options !== "object") {
|
||||
throw new TypeError("options must be an object.");
|
||||
}
|
||||
const configuration = validateConfiguration(options);
|
||||
let geometry;
|
||||
if (options.analysisPlan) {
|
||||
assertCompatibleAnalysisPlan(options.analysisPlan, options);
|
||||
geometry = options.analysisPlan;
|
||||
} else {
|
||||
geometry = createGeometry(configuration);
|
||||
}
|
||||
return estimateWithGeometry(options, configuration, geometry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain geometry and FFT workspaces for repeated analyses with one immutable
|
||||
* array/scan/band configuration. Plans are stateful and cannot run concurrently.
|
||||
*/
|
||||
export function createGccPhatAnalysisPlan(options) {
|
||||
if (!options || typeof options !== "object") {
|
||||
throw new TypeError("options must be an object.");
|
||||
}
|
||||
const configuration = validateConfiguration(options);
|
||||
const maximumPlanBytes =
|
||||
options.maximumPlanBytes ?? DEFAULT_MAXIMUM_GCC_PHAT_PLAN_BYTES;
|
||||
if (!Number.isSafeInteger(maximumPlanBytes) || maximumPlanBytes <= 0) {
|
||||
throw new RangeError("maximumPlanBytes must be a positive safe integer.");
|
||||
}
|
||||
|
||||
const microphoneCount = configuration.arrayDefinition.microphones.length;
|
||||
const pointMicrophonePairs = configuration.pointCount * microphoneCount;
|
||||
const distanceBytes = pointMicrophonePairs * Float32Array.BYTES_PER_ELEMENT;
|
||||
const stftWorkspaceBytes =
|
||||
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
|
||||
const correlationWorkspaceBytes =
|
||||
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
const retainedBytes = distanceBytes + stftWorkspaceBytes + correlationWorkspaceBytes;
|
||||
if (
|
||||
!Number.isSafeInteger(pointMicrophonePairs) ||
|
||||
!Number.isSafeInteger(retainedBytes) ||
|
||||
retainedBytes > maximumPlanBytes
|
||||
) {
|
||||
throw new RangeError(
|
||||
"GCC-PHAT analysis plan requires " + retainedBytes.toLocaleString() +
|
||||
" bytes, above maximumPlanBytes " + maximumPlanBytes.toLocaleString() + ".",
|
||||
);
|
||||
}
|
||||
|
||||
const distances = createDistanceMatrix(
|
||||
configuration.arrayDefinition,
|
||||
configuration.experiment.scan,
|
||||
);
|
||||
const geometry = createGeometry(configuration, distances);
|
||||
const plan = {
|
||||
kind: GCC_PHAT_ANALYSIS_PLAN_KIND,
|
||||
sampleRate: configuration.sampleRate,
|
||||
arrayDefinition: configuration.arrayDefinition,
|
||||
scene: options.scene,
|
||||
experiment: configuration.experiment,
|
||||
fftSize: configuration.fftSize,
|
||||
hopSize: configuration.hopSize,
|
||||
pointCount: configuration.pointCount,
|
||||
maximumPlanBytes,
|
||||
retainedBytes,
|
||||
distanceBytes,
|
||||
stftWorkspaceBytes,
|
||||
correlationWorkspaceBytes,
|
||||
stftWorkspace: createStftWorkspace(configuration.fftSize),
|
||||
correlationReal: new Float64Array(configuration.fftSize),
|
||||
correlationImaginary: new Float64Array(configuration.fftSize),
|
||||
busy: false,
|
||||
statistics: {
|
||||
analysisAttempts: 0,
|
||||
completedAnalyses: 0,
|
||||
distanceMatrixBuilds: 1,
|
||||
distanceEvaluations: pointMicrophonePairs,
|
||||
avoidedDistanceEvaluations: 0,
|
||||
stftWorkspaceAllocations: 1,
|
||||
stftWorkspaceUses: 0,
|
||||
avoidedStftWorkspaceAllocations: 0,
|
||||
correlationWorkspaceAllocations: 1,
|
||||
correlationWorkspaceUses: 0,
|
||||
avoidedCorrelationWorkspaceAllocations: 0,
|
||||
stftTransforms: 0,
|
||||
inverseFftTransforms: 0,
|
||||
crossSpectrumOperations: 0,
|
||||
scanLookups: 0,
|
||||
},
|
||||
...geometry,
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
|
||||
function assertCompatibleAnalysisPlan(plan, options) {
|
||||
if (plan?.kind !== GCC_PHAT_ANALYSIS_PLAN_KIND) {
|
||||
throw new TypeError(
|
||||
"analysisPlan must be created by createGccPhatAnalysisPlan().",
|
||||
);
|
||||
}
|
||||
if (
|
||||
plan.sampleRate !== options.sampleRate ||
|
||||
plan.arrayDefinition !== options.arrayDefinition ||
|
||||
plan.scene !== options.scene ||
|
||||
plan.experiment !== options.experiment
|
||||
) {
|
||||
throw new Error(
|
||||
"analysisPlan must be used with the exact configuration objects that created it.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function analysisCacheSummary(plan) {
|
||||
const statistics = plan.statistics;
|
||||
return {
|
||||
analysisAttempts: statistics.analysisAttempts,
|
||||
completedAnalyses: statistics.completedAnalyses,
|
||||
distanceMatrixBuilds: statistics.distanceMatrixBuilds,
|
||||
distanceEvaluations: statistics.distanceEvaluations,
|
||||
avoidedDistanceEvaluations: statistics.avoidedDistanceEvaluations,
|
||||
stftWorkspaceAllocations: statistics.stftWorkspaceAllocations,
|
||||
stftWorkspaceUses: statistics.stftWorkspaceUses,
|
||||
avoidedStftWorkspaceAllocations: statistics.avoidedStftWorkspaceAllocations,
|
||||
correlationWorkspaceAllocations: statistics.correlationWorkspaceAllocations,
|
||||
correlationWorkspaceUses: statistics.correlationWorkspaceUses,
|
||||
avoidedCorrelationWorkspaceAllocations:
|
||||
statistics.avoidedCorrelationWorkspaceAllocations,
|
||||
stftTransforms: statistics.stftTransforms,
|
||||
inverseFftTransforms: statistics.inverseFftTransforms,
|
||||
crossSpectrumOperations: statistics.crossSpectrumOperations,
|
||||
scanLookups: statistics.scanLookups,
|
||||
retainedBytes: plan.retainedBytes,
|
||||
maximumPlanBytes: plan.maximumPlanBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function circularLinearSample(values, lagSamples) {
|
||||
const length = values.length;
|
||||
const wrapped = ((lagSamples % length) + length) % length;
|
||||
const firstIndex = Math.floor(wrapped);
|
||||
const fraction = wrapped - firstIndex;
|
||||
const secondIndex = (firstIndex + 1) % length;
|
||||
return values[firstIndex] * (1 - fraction) + values[secondIndex] * fraction;
|
||||
}
|
||||
|
||||
function fillBandPairCorrelation({
|
||||
correlationReal,
|
||||
correlationImaginary,
|
||||
bins,
|
||||
stft,
|
||||
microphoneIndex,
|
||||
referenceIndex,
|
||||
phasesByBin,
|
||||
phatFloorRatio,
|
||||
}) {
|
||||
correlationReal.fill(0);
|
||||
correlationImaginary.fill(0);
|
||||
const microphoneCount = stft.microphoneCount;
|
||||
let maximumMagnitude = 0;
|
||||
|
||||
for (const bin of bins) {
|
||||
const spectrum = stft.spectra.get(bin);
|
||||
for (let frameIndex = 0; frameIndex < stft.frameCount; frameIndex += 1) {
|
||||
const offset = frameIndex * microphoneCount;
|
||||
const microphoneReal = spectrum.real[offset + microphoneIndex];
|
||||
const microphoneImaginary = spectrum.imaginary[offset + microphoneIndex];
|
||||
const referenceReal = spectrum.real[offset + referenceIndex];
|
||||
const referenceImaginary = spectrum.imaginary[offset + referenceIndex];
|
||||
const crossReal =
|
||||
microphoneReal * referenceReal + microphoneImaginary * referenceImaginary;
|
||||
const crossImaginary =
|
||||
microphoneImaginary * referenceReal - microphoneReal * referenceImaginary;
|
||||
maximumMagnitude = Math.max(
|
||||
maximumMagnitude,
|
||||
Math.hypot(crossReal, crossImaginary),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const magnitudeFloor = maximumMagnitude * phatFloorRatio;
|
||||
if (!(maximumMagnitude > 0)) {
|
||||
fftInPlace(correlationReal, correlationImaginary, true);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const bin of bins) {
|
||||
const spectrum = stft.spectra.get(bin);
|
||||
const phases = phasesByBin.get(bin);
|
||||
const correctionPhase = phases[referenceIndex] - phases[microphoneIndex];
|
||||
const correctionReal = Math.cos(correctionPhase);
|
||||
const correctionImaginary = Math.sin(correctionPhase);
|
||||
let accumulatedReal = 0;
|
||||
let accumulatedImaginary = 0;
|
||||
|
||||
for (let frameIndex = 0; frameIndex < stft.frameCount; frameIndex += 1) {
|
||||
const offset = frameIndex * microphoneCount;
|
||||
const microphoneReal = spectrum.real[offset + microphoneIndex];
|
||||
const microphoneImaginary = spectrum.imaginary[offset + microphoneIndex];
|
||||
const referenceReal = spectrum.real[offset + referenceIndex];
|
||||
const referenceImaginary = spectrum.imaginary[offset + referenceIndex];
|
||||
const crossReal =
|
||||
microphoneReal * referenceReal + microphoneImaginary * referenceImaginary;
|
||||
const crossImaginary =
|
||||
microphoneImaginary * referenceReal - microphoneReal * referenceImaginary;
|
||||
const magnitude = Math.hypot(crossReal, crossImaginary);
|
||||
const denominator = Math.max(magnitude, magnitudeFloor);
|
||||
if (!(denominator > 0)) continue;
|
||||
const normalizedReal = crossReal / denominator;
|
||||
const normalizedImaginary = crossImaginary / denominator;
|
||||
accumulatedReal +=
|
||||
normalizedReal * correctionReal - normalizedImaginary * correctionImaginary;
|
||||
accumulatedImaginary +=
|
||||
normalizedReal * correctionImaginary + normalizedImaginary * correctionReal;
|
||||
}
|
||||
|
||||
const averagedReal = accumulatedReal / stft.frameCount;
|
||||
const averagedImaginary = accumulatedImaginary / stft.frameCount;
|
||||
correlationReal[bin] = averagedReal;
|
||||
correlationImaginary[bin] =
|
||||
bin === 0 || bin === correlationReal.length / 2 ? 0 : averagedImaginary;
|
||||
if (bin > 0 && bin < correlationReal.length / 2) {
|
||||
const negativeBin = correlationReal.length - bin;
|
||||
correlationReal[negativeBin] = averagedReal;
|
||||
correlationImaginary[negativeBin] = -averagedImaginary;
|
||||
}
|
||||
}
|
||||
|
||||
fftInPlace(correlationReal, correlationImaginary, true);
|
||||
}
|
||||
|
||||
function shiftAndSquareScores(rawMaps) {
|
||||
for (const rawMap of rawMaps) {
|
||||
let minimum = Infinity;
|
||||
for (const value of rawMap) minimum = Math.min(minimum, value);
|
||||
for (let pointIndex = 0; pointIndex < rawMap.length; pointIndex += 1) {
|
||||
const shifted = rawMap[pointIndex] - minimum;
|
||||
rawMap[pointIndex] = shifted * shifted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function computeGccPhatMapInternal(options, configuration, geometry, plan) {
|
||||
const {
|
||||
channels,
|
||||
signal,
|
||||
onProgress,
|
||||
maximumWorkingBytes = 512 * 1024 * 1024,
|
||||
maximumFftButterflies = 1_000_000_000,
|
||||
pointBatchSize = 256,
|
||||
stftTransformBatchSize = 128,
|
||||
} = options;
|
||||
requirePositiveInteger(pointBatchSize, "pointBatchSize");
|
||||
requirePositiveInteger(stftTransformBatchSize, "stftTransformBatchSize");
|
||||
if (!Number.isSafeInteger(maximumWorkingBytes) || maximumWorkingBytes <= 0) {
|
||||
throw new RangeError("maximumWorkingBytes must be a positive safe integer.");
|
||||
}
|
||||
const phatFloorRatio = requirePhatFloorRatio(
|
||||
options.phatFloorRatio ??
|
||||
options.phatRelativeFloor ??
|
||||
configuration.experiment.beamforming?.phatFloorRatio,
|
||||
);
|
||||
const workload = estimateWithGeometry(options, configuration, geometry);
|
||||
if (workload.estimatedWorkingBytes > maximumWorkingBytes) {
|
||||
throw new RangeError(
|
||||
"GCC-PHAT estimated working set " +
|
||||
(workload.estimatedWorkingBytes / 1024 ** 2).toFixed(1) +
|
||||
" MiB exceeds maximumWorkingBytes " +
|
||||
(maximumWorkingBytes / 1024 ** 2).toFixed(1) + " MiB.",
|
||||
);
|
||||
}
|
||||
const totalFftButterflies =
|
||||
workload.stftButterflyCount + workload.inverseFftButterflyCount;
|
||||
if (totalFftButterflies > maximumFftButterflies) {
|
||||
throw new RangeError(
|
||||
"GCC-PHAT estimated FFT butterfly count " +
|
||||
Math.ceil(totalFftButterflies).toLocaleString() +
|
||||
" exceeds maximumFftButterflies " +
|
||||
maximumFftButterflies.toLocaleString() + ".",
|
||||
);
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
if (plan) {
|
||||
plan.statistics.stftWorkspaceUses += 1;
|
||||
plan.statistics.avoidedStftWorkspaceAllocations = Math.max(
|
||||
0,
|
||||
plan.statistics.stftWorkspaceUses - 1,
|
||||
);
|
||||
plan.statistics.correlationWorkspaceUses += 1;
|
||||
plan.statistics.avoidedCorrelationWorkspaceAllocations = Math.max(
|
||||
0,
|
||||
plan.statistics.correlationWorkspaceUses - 1,
|
||||
);
|
||||
}
|
||||
const stft = await extractStftBins(channels, {
|
||||
fftSize: configuration.fftSize,
|
||||
hopSize: configuration.hopSize,
|
||||
bins: geometry.selectedBins,
|
||||
workspace: plan?.stftWorkspace,
|
||||
transformBatchSize: stftTransformBatchSize,
|
||||
signal,
|
||||
onProgress: ({ completedTransforms, totalTransforms }) =>
|
||||
onProgress?.({
|
||||
stage: "stft",
|
||||
algorithm: "gcc-phat",
|
||||
completedTransforms,
|
||||
totalTransforms,
|
||||
}),
|
||||
});
|
||||
if (plan) {
|
||||
plan.statistics.stftTransforms += workload.stftTransformCount;
|
||||
}
|
||||
await cooperativeYield();
|
||||
throwIfAborted(signal);
|
||||
|
||||
const phasesByBin = new Map();
|
||||
for (const bin of geometry.selectedBins) {
|
||||
const calibration = createBinCalibration({
|
||||
arrayDefinition: configuration.arrayDefinition,
|
||||
bin,
|
||||
sampleRate: configuration.sampleRate,
|
||||
fftSize: configuration.fftSize,
|
||||
compensateFrequencyResponse:
|
||||
configuration.experiment.beamforming?.compensateFrequencyResponse !== false,
|
||||
maxCompensationDb:
|
||||
configuration.experiment.beamforming?.maxCompensationDb,
|
||||
includeAmplitude: false,
|
||||
});
|
||||
phasesByBin.set(bin, calibration.phases);
|
||||
}
|
||||
|
||||
const correlationReal =
|
||||
plan?.correlationReal ?? new Float64Array(configuration.fftSize);
|
||||
const correlationImaginary =
|
||||
plan?.correlationImaginary ?? new Float64Array(configuration.fftSize);
|
||||
const rawMaps = geometry.bandBins.map(() => new Float32Array(configuration.pointCount));
|
||||
const totalPairs = geometry.pairCount * geometry.bandBins.length;
|
||||
let completedPairs = 0;
|
||||
|
||||
for (let bandIndex = 0; bandIndex < geometry.bandBins.length; bandIndex += 1) {
|
||||
const bins = geometry.bandBins[bandIndex];
|
||||
const rawMap = rawMaps[bandIndex];
|
||||
for (const microphoneIndex of geometry.pairMicrophoneIndexes) {
|
||||
throwIfAborted(signal);
|
||||
fillBandPairCorrelation({
|
||||
correlationReal,
|
||||
correlationImaginary,
|
||||
bins,
|
||||
stft,
|
||||
microphoneIndex,
|
||||
referenceIndex: geometry.reference.index,
|
||||
phasesByBin,
|
||||
phatFloorRatio,
|
||||
});
|
||||
if (plan) {
|
||||
plan.statistics.inverseFftTransforms += 1;
|
||||
plan.statistics.crossSpectrumOperations +=
|
||||
bins.length * stft.frameCount * 2;
|
||||
}
|
||||
|
||||
for (let pointIndex = 0; pointIndex < configuration.pointCount; pointIndex += 1) {
|
||||
const distanceOffset = pointIndex * geometry.microphoneCount;
|
||||
const predictedLagSamples =
|
||||
(
|
||||
geometry.distances[distanceOffset + microphoneIndex] -
|
||||
geometry.distances[distanceOffset + geometry.reference.index]
|
||||
) *
|
||||
configuration.sampleRate / configuration.speedOfSound;
|
||||
rawMap[pointIndex] += circularLinearSample(
|
||||
correlationReal,
|
||||
predictedLagSamples,
|
||||
);
|
||||
if (
|
||||
(pointIndex + 1) % pointBatchSize === 0 &&
|
||||
pointIndex + 1 < configuration.pointCount
|
||||
) {
|
||||
await cooperativeYield();
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
}
|
||||
if (plan) plan.statistics.scanLookups += configuration.pointCount;
|
||||
completedPairs += 1;
|
||||
onProgress?.({
|
||||
stage: "localization",
|
||||
algorithm: "gcc-phat",
|
||||
completedPairs,
|
||||
totalPairs,
|
||||
});
|
||||
if (completedPairs < totalPairs) await cooperativeYield();
|
||||
}
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
shiftAndSquareScores(rawMaps);
|
||||
const bands = finalizeDasBands(
|
||||
rawMaps,
|
||||
workload,
|
||||
configuration.experiment,
|
||||
"score",
|
||||
);
|
||||
return {
|
||||
backend: "cpu",
|
||||
algorithm: "gcc-phat",
|
||||
frameCount: workload.frameCount,
|
||||
microphoneCount: workload.microphoneCount,
|
||||
pointCount: workload.pointCount,
|
||||
referenceMicrophoneId: geometry.reference.id,
|
||||
referenceMicrophoneIndex: geometry.reference.index,
|
||||
pairCount: geometry.pairCount,
|
||||
phatFloorRatio,
|
||||
maximumLagSamples: geometry.maximumLagSamples,
|
||||
workload,
|
||||
bands,
|
||||
...(plan ? { analysisCache: analysisCacheSummary(plan) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build band-limited reference-pair GCC-PHAT maps on the CPU.
|
||||
*
|
||||
* For each pair and band, corrected PHAT cross spectra are averaged over STFT
|
||||
* frames, transformed once to circular correlation, and sampled at the
|
||||
* near-field geometric delay of each scan point.
|
||||
*/
|
||||
export async function computeGccPhatMap(options) {
|
||||
if (!options || typeof options !== "object") {
|
||||
throw new TypeError("options must be an object.");
|
||||
}
|
||||
const configuration = validateConfiguration(options);
|
||||
const plan = options.analysisPlan;
|
||||
if (!plan) {
|
||||
const geometry = createGeometry(configuration);
|
||||
return computeGccPhatMapInternal(options, configuration, geometry, undefined);
|
||||
}
|
||||
|
||||
assertCompatibleAnalysisPlan(plan, options);
|
||||
if (plan.busy) {
|
||||
throw new Error("analysisPlan is already in use by another GCC-PHAT analysis.");
|
||||
}
|
||||
plan.busy = true;
|
||||
plan.statistics.analysisAttempts += 1;
|
||||
if (plan.statistics.analysisAttempts > 1) {
|
||||
plan.statistics.avoidedDistanceEvaluations +=
|
||||
plan.pointCount * plan.microphoneCount;
|
||||
}
|
||||
try {
|
||||
const result = await computeGccPhatMapInternal(
|
||||
options,
|
||||
configuration,
|
||||
plan,
|
||||
plan,
|
||||
);
|
||||
plan.statistics.completedAnalyses += 1;
|
||||
result.analysisCache = analysisCacheSummary(plan);
|
||||
return result;
|
||||
} finally {
|
||||
plan.busy = false;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ export * from "./environment.js";
|
||||
export * from "./fft.js";
|
||||
export * from "./frequency-response.js";
|
||||
export * from "./fractional-delay.js";
|
||||
export * from "./gcc-phat.js";
|
||||
export * from "./localization.js";
|
||||
export * from "./localization-algorithms.js";
|
||||
export * from "./math.js";
|
||||
export * from "./mixdown.js";
|
||||
export * from "./noise.js";
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export const DEFAULT_LOCALIZATION_ALGORITHM = "das";
|
||||
export const DEFAULT_PHAT_RELATIVE_FLOOR = 1e-6;
|
||||
|
||||
function definition(id, label, rawScale, spectralWeighting, backends) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
label,
|
||||
rawScale,
|
||||
spectralWeighting,
|
||||
backends: Object.freeze([...backends]),
|
||||
});
|
||||
}
|
||||
|
||||
const ALGORITHM_DEFINITIONS = Object.freeze([
|
||||
definition("das", "DAS", "power", "none", ["cpu", "webgpu"]),
|
||||
definition("das-phat", "DAS-PHAT", "score", "phat", ["cpu", "webgpu"]),
|
||||
definition("gcc-phat", "GCC-PHAT", "score", "phat", ["cpu"]),
|
||||
]);
|
||||
|
||||
const ALGORITHM_BY_ID = new Map(
|
||||
ALGORITHM_DEFINITIONS.map((algorithm) => [algorithm.id, algorithm]),
|
||||
);
|
||||
|
||||
export const LOCALIZATION_ALGORITHM_IDS = Object.freeze(
|
||||
ALGORITHM_DEFINITIONS.map((algorithm) => algorithm.id),
|
||||
);
|
||||
|
||||
export function localizationAlgorithmDefinition(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||
return ALGORITHM_BY_ID.get(id);
|
||||
}
|
||||
|
||||
export function requireLocalizationAlgorithmDefinition(
|
||||
id = DEFAULT_LOCALIZATION_ALGORITHM,
|
||||
) {
|
||||
const algorithm = localizationAlgorithmDefinition(id);
|
||||
if (!algorithm) {
|
||||
throw new RangeError(`unsupported localization algorithm: ${id}`);
|
||||
}
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
export function isDasAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||
return id === "das" || id === "das-phat";
|
||||
}
|
||||
|
||||
export function localizationAlgorithmLabel(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||
return localizationAlgorithmDefinition(id)?.label ?? String(id);
|
||||
}
|
||||
|
||||
export function localizationAlgorithmSupportsBackend(id, backend) {
|
||||
return localizationAlgorithmDefinition(id)?.backends.includes(backend) ?? false;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
computeDasMap,
|
||||
createDasAnalysisPlan,
|
||||
estimateDasWorkload,
|
||||
} from "./das.js";
|
||||
import {
|
||||
computeGccPhatMap,
|
||||
createGccPhatAnalysisPlan,
|
||||
estimateGccPhatWorkload,
|
||||
} from "./gcc-phat.js";
|
||||
import {
|
||||
isDasAlgorithm,
|
||||
requireLocalizationAlgorithmDefinition,
|
||||
} from "./localization-algorithms.js";
|
||||
|
||||
function requestedAlgorithm(options) {
|
||||
return requireLocalizationAlgorithmDefinition(
|
||||
options?.experiment?.beamforming?.algorithm,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate implementation-level work for whichever localization algorithm the
|
||||
* experiment requests.
|
||||
*/
|
||||
export function estimateLocalizationWorkload(options) {
|
||||
const algorithm = requestedAlgorithm(options);
|
||||
return isDasAlgorithm(algorithm.id)
|
||||
? estimateDasWorkload(options)
|
||||
: estimateGccPhatWorkload(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile immutable geometry/workspace state for repeated CPU analyses.
|
||||
*/
|
||||
export function createLocalizationAnalysisPlan(options) {
|
||||
const algorithm = requestedAlgorithm(options);
|
||||
return isDasAlgorithm(algorithm.id)
|
||||
? createDasAnalysisPlan(options)
|
||||
: createGccPhatAnalysisPlan(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a CPU localization map using the algorithm selected by the
|
||||
* experiment. The returned `algorithm` and each band's `rawScale` make the
|
||||
* result semantics explicit.
|
||||
*/
|
||||
export async function computeLocalizationMap(options) {
|
||||
const algorithm = requestedAlgorithm(options);
|
||||
return isDasAlgorithm(algorithm.id)
|
||||
? computeDasMap(options)
|
||||
: computeGccPhatMap(options);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { computeDasMap, createDasAnalysisPlan } from "./das.js";
|
||||
import { findLocalPeaks } from "./evaluation.js";
|
||||
import {
|
||||
computeLocalizationMap,
|
||||
createLocalizationAnalysisPlan,
|
||||
} from "./localization.js";
|
||||
import {
|
||||
isDasAlgorithm,
|
||||
requireLocalizationAlgorithmDefinition,
|
||||
} from "./localization-algorithms.js";
|
||||
import {
|
||||
DEFAULT_MAX_COMPENSATION_DB,
|
||||
MAX_FREQUENCY_RESPONSE_HZ,
|
||||
@@ -214,6 +221,10 @@ function throwIfAborted(signal) {
|
||||
}
|
||||
|
||||
export function detectBandPeaks(rawMap, scan, options = {}) {
|
||||
const rawScale = options.rawScale ?? rawMap?.scale ?? "power";
|
||||
if (rawScale !== "power" && rawScale !== "score") {
|
||||
throw new RangeError('peak map rawScale must be "power" or "score".');
|
||||
}
|
||||
const peakDetection = copyPeakDetection(options, scan);
|
||||
const localPeaks = findLocalPeaks(rawMap, scan);
|
||||
const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
|
||||
@@ -235,7 +246,9 @@ export function detectBandPeaks(rawMap, scan, options = {}) {
|
||||
rank: detections.length + 1,
|
||||
index: peak.index,
|
||||
position: [...peak.position],
|
||||
rawPower: peak.value,
|
||||
rawValue: peak.value,
|
||||
rawScale,
|
||||
...(rawScale === "power" ? { rawPower: peak.value } : {}),
|
||||
});
|
||||
if (detections.length === peakDetection.maximumDetections) break;
|
||||
}
|
||||
@@ -244,7 +257,10 @@ export function detectBandPeaks(rawMap, scan, options = {}) {
|
||||
|
||||
function locationsFromBands(bands, scan, peakDetection) {
|
||||
return bands.flatMap((band) =>
|
||||
detectBandPeaks(band.rawMap, scan, peakDetection).map((peak) => ({
|
||||
detectBandPeaks(band.rawMap, scan, {
|
||||
...peakDetection,
|
||||
rawScale: band.rawScale,
|
||||
}).map((peak) => ({
|
||||
bandId: band.id,
|
||||
...peak,
|
||||
})),
|
||||
@@ -287,6 +303,13 @@ export function createLocalizationPlan(config) {
|
||||
config.beamforming?.maxCompensationDb ??
|
||||
DEFAULT_MAX_COMPENSATION_DB,
|
||||
);
|
||||
const algorithm = requireLocalizationAlgorithmDefinition(
|
||||
config.algorithm ?? config.beamforming?.algorithm,
|
||||
);
|
||||
const phatRelativeFloor =
|
||||
config.phatRelativeFloor ?? config.beamforming?.phatRelativeFloor;
|
||||
const phatFloorRatio =
|
||||
config.phatFloorRatio ?? config.beamforming?.phatFloorRatio;
|
||||
|
||||
const scene = Object.freeze({ speedOfSound });
|
||||
const experiment = Object.freeze({
|
||||
@@ -294,12 +317,14 @@ export function createLocalizationPlan(config) {
|
||||
scan,
|
||||
frequencyBands,
|
||||
beamforming: Object.freeze({
|
||||
algorithm: algorithm.id,
|
||||
backend: "cpu",
|
||||
normalization,
|
||||
compensateFrequencyResponse,
|
||||
maxCompensationDb,
|
||||
}),
|
||||
});
|
||||
const dasPlan = createDasAnalysisPlan({
|
||||
const analysisPlan = createLocalizationAnalysisPlan({
|
||||
sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
@@ -310,6 +335,7 @@ export function createLocalizationPlan(config) {
|
||||
|
||||
return Object.freeze({
|
||||
kind: REALTIME_LOCALIZATION_PLAN_KIND,
|
||||
algorithm: algorithm.id,
|
||||
sampleRate,
|
||||
channelCount: arrayDefinition.microphones.length,
|
||||
arrayDefinition,
|
||||
@@ -322,10 +348,15 @@ export function createLocalizationPlan(config) {
|
||||
maxCompensationDb,
|
||||
scene,
|
||||
experiment,
|
||||
dasPlan,
|
||||
analysisPlan,
|
||||
...(isDasAlgorithm(algorithm.id)
|
||||
? { dasPlan: analysisPlan }
|
||||
: { gccPhatPlan: analysisPlan }),
|
||||
computeOptions: Object.freeze({
|
||||
pointBatchSize: DEFAULT_REALTIME_POINT_BATCH_SIZE,
|
||||
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
|
||||
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
|
||||
...(phatFloorRatio !== undefined ? { phatFloorRatio } : {}),
|
||||
...(config.computeOptions ?? {}),
|
||||
}),
|
||||
});
|
||||
@@ -350,7 +381,7 @@ export async function localizeMultichannel(options) {
|
||||
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
|
||||
throwIfAborted(options.signal);
|
||||
const processingStartedAt = monotonicNow();
|
||||
const das = await computeDasMap({
|
||||
const localization = await computeLocalizationMap({
|
||||
...plan.computeOptions,
|
||||
...(options.computeOptions ?? {}),
|
||||
channels: options.channels,
|
||||
@@ -358,14 +389,18 @@ export async function localizeMultichannel(options) {
|
||||
arrayDefinition: plan.arrayDefinition,
|
||||
scene: plan.scene,
|
||||
experiment: plan.experiment,
|
||||
analysisPlan: plan.dasPlan,
|
||||
analysisPlan: plan.analysisPlan,
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
});
|
||||
const locations = locationsFromBands(das.bands, plan.scan, plan.peakDetection);
|
||||
const locations = locationsFromBands(
|
||||
localization.bands,
|
||||
plan.scan,
|
||||
plan.peakDetection,
|
||||
);
|
||||
const processingTimeMs = Math.max(0, monotonicNow() - processingStartedAt);
|
||||
return {
|
||||
...das,
|
||||
...localization,
|
||||
sampleRate: plan.sampleRate,
|
||||
sampleCount,
|
||||
duration: sampleCount / plan.sampleRate,
|
||||
@@ -488,6 +523,7 @@ export class StreamingDasLocalizer {
|
||||
getState() {
|
||||
return {
|
||||
sampleRate: this.plan.sampleRate,
|
||||
algorithm: this.plan.algorithm,
|
||||
channelCount: this.plan.channelCount,
|
||||
analysisWindowSamples: this.analysisWindowSamples,
|
||||
analysisHopSamples: this.analysisHopSamples,
|
||||
@@ -675,6 +711,9 @@ export class StreamingDasLocalizer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Algorithm-neutral alias; the older class name remains for compatibility. */
|
||||
export { StreamingDasLocalizer as StreamingLocalizer };
|
||||
|
||||
export function createStreamingLocalizer(config) {
|
||||
return new StreamingDasLocalizer(config);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user