Files
micarrsim/packages/acoustics-core/das.js
T

852 lines
32 KiB
JavaScript

import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js";
import {
interpolateFrequencyResponse,
inverseResponseGainDb,
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) {
if (signal?.aborted) {
const error = new Error("계산이 취소되었습니다.");
error.name = "AbortError";
throw error;
}
}
export const DEFAULT_MAXIMUM_CPU_STEERING_CACHE_BYTES = 64 * 1024 * 1024;
export const DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES = 512 * 1024 * 1024;
const DAS_ANALYSIS_PLAN_KIND = "micarrsim.das-analysis-plan/1";
export function createDistanceMatrix(arrayDefinition, scan) {
const points = createScanPoints(scan);
const microphoneCount = arrayDefinition.microphones.length;
const pointCount = scan.columns * scan.rows;
const distances = new Float32Array(pointCount * microphoneCount);
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
const pointOffset = pointIndex * 4;
const point = [points[pointOffset], points[pointOffset + 1], points[pointOffset + 2]];
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
distances[pointIndex * microphoneCount + microphoneIndex] = distance3(
point,
arrayDefinition.microphones[microphoneIndex].position,
);
}
}
return distances;
}
function createBandIndexLookup(bandBins) {
const lookup = new Map();
bandBins.forEach((bins, bandIndex) => {
bins.forEach((bin) => {
if (!lookup.has(bin)) lookup.set(bin, []);
lookup.get(bin).push(bandIndex);
});
});
return lookup;
}
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 = includeAmplitude
? requireMaxCompensationDb(maximumCompensationDb)
: 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const microphone = arrayDefinition.microphones[microphoneIndex];
let responseGainDb = 0;
let responsePhaseDeg = 0;
if (compensateFrequencyResponse && microphone.frequencyResponse) {
interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response);
responseGainDb = response.gainDb;
responsePhaseDeg = response.phaseDeg;
}
const responseCompensationDb = includeAmplitude && compensateFrequencyResponse
? inverseResponseGainDb(responseGainDb, maxCompensationDb)
: 0;
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,
}) {
for (let microphoneIndex = 0; microphoneIndex < calibration.gains.length; microphoneIndex += 1) {
const phase =
angularWaveNumber * distances[distanceOffset + microphoneIndex] -
calibration.phases[microphoneIndex];
const gain = applyCalibrationGains ? calibration.gains[microphoneIndex] : 1;
outputReal[outputOffset + microphoneIndex] = Math.cos(phase) * gain;
outputImaginary[outputOffset + microphoneIndex] = Math.sin(phase) * gain;
}
}
async function createSteeringMatrix(options) {
const {
bin,
pointCount,
microphoneCount,
distances,
sampleRate,
fftSize,
speedOfSound,
calibration,
applyCalibrationGains = true,
signal,
pointBatchSize,
} = options;
const real = new Float64Array(pointCount * microphoneCount);
const imaginary = new Float64Array(pointCount * microphoneCount);
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
throwIfAborted(signal);
const offset = pointIndex * microphoneCount;
fillSteeringVector({
angularWaveNumber,
distances,
distanceOffset: offset,
calibration,
applyCalibrationGains,
outputReal: real,
outputImaginary: imaginary,
outputOffset: offset,
});
if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) {
await cooperativeYield();
}
}
return { real, imaginary };
}
export async function computeBinMap(options) {
const {
bin,
spectrum,
frameCount,
arrayDefinition,
pointCount,
distances,
sampleRate,
fftSize,
speedOfSound,
compensateFrequencyResponse = true,
maxCompensationDb,
calibration: providedCalibration,
steeringMatrix,
output: providedOutput,
steeringReal: providedSteeringReal,
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({
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.");
if (steeringReal.length !== microphoneCount || steeringImaginary.length !== microphoneCount) {
throw new RangeError("steering workspace length must match microphone count.");
}
if (
calibration.gains.length !== microphoneCount ||
calibration.phases.length !== microphoneCount
) {
throw new RangeError("calibration length must match microphone count.");
}
if (
steeringMatrix &&
(
steeringMatrix.real.length !== pointCount * microphoneCount ||
steeringMatrix.imaginary.length !== pointCount * microphoneCount
)
) {
throw new RangeError("steering matrix length must match point and microphone counts.");
}
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
throwIfAborted(signal);
const distanceOffset = pointIndex * microphoneCount;
const steeringOffset = steeringMatrix ? distanceOffset : 0;
if (!steeringMatrix) {
fillSteeringVector({
angularWaveNumber,
distances,
distanceOffset,
calibration,
outputReal: steeringReal,
outputImaginary: steeringImaginary,
outputOffset: 0,
applyCalibrationGains: !phaseOnly,
});
}
let accumulatedPower = 0;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const spectrumOffset = frameIndex * microphoneCount;
let sumReal = 0;
let sumImaginary = 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const spectrumIndex = spectrumOffset + microphoneIndex;
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;
sumImaginary += inputReal * phaseImaginary + inputImaginary * phaseReal;
}
accumulatedPower += (sumReal * sumReal + sumImaginary * sumImaginary) * microphoneScale * microphoneScale;
}
output[pointIndex] = accumulatedPower / frameCount;
if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) {
await cooperativeYield();
}
}
return output;
}
/**
* Cache geometry and optional steering matrices for repeated CPU DAS analyses.
*
* Plans are intentionally stateful and must not be used concurrently. They are
* local runtime objects (Maps and workspaces), not serializable data contracts.
*/
export function createDasAnalysisPlan(options) {
const {
sampleRate,
arrayDefinition,
scene,
experiment,
maximumSteeringCacheBytes = DEFAULT_MAXIMUM_CPU_STEERING_CACHE_BYTES,
maximumPlanBytes = DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES,
} = options;
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
throw new RangeError("sampleRate must be a positive finite number.");
}
if (!Array.isArray(arrayDefinition?.microphones) || arrayDefinition.microphones.length === 0) {
throw new TypeError("arrayDefinition.microphones must be a non-empty array.");
}
if (!Number.isFinite(scene?.speedOfSound) || scene.speedOfSound <= 0) {
throw new RangeError("scene.speedOfSound must be a positive finite number.");
}
if (!Number.isFinite(maximumSteeringCacheBytes) || maximumSteeringCacheBytes < 0) {
throw new RangeError("maximumSteeringCacheBytes must be a non-negative finite number.");
}
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));
const selectedBins = [...new Set(bandBins.flat())].sort((a, b) => a - b);
const microphoneCount = arrayDefinition.microphones.length;
const pointCount = experiment.scan.columns * experiment.scan.rows;
const pointMicrophonePairs = pointCount * microphoneCount;
const distanceMatrixBytes = pointMicrophonePairs * Float32Array.BYTES_PER_ELEMENT;
const stftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
const calibrationBytes =
selectedBins.length * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
const retainedBaseBytes =
distanceMatrixBytes +
pointCount * Float32Array.BYTES_PER_ELEMENT +
microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2 +
stftWorkspaceBytes;
const retainedNonSteeringBytes = retainedBaseBytes + calibrationBytes;
if (
!Number.isSafeInteger(pointMicrophonePairs) ||
!Number.isSafeInteger(retainedBaseBytes) ||
!Number.isSafeInteger(calibrationBytes) ||
!Number.isSafeInteger(retainedNonSteeringBytes) ||
retainedNonSteeringBytes > maximumPlanBytes
) {
throw new RangeError(
"CPU analysis plan requires " + retainedNonSteeringBytes.toLocaleString() +
" non-steering bytes including " + calibrationBytes.toLocaleString() +
" calibration bytes, above maximumPlanBytes " +
maximumPlanBytes.toLocaleString() + ".",
);
}
const distances = createDistanceMatrix(arrayDefinition, experiment.scan);
const binMap = new Float32Array(pointCount);
const steeringReal = new Float64Array(microphoneCount);
const steeringImaginary = new Float64Array(microphoneCount);
const stftWorkspace = createStftWorkspace(fftSize);
const steeringBytesPerBin =
pointCount * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
const requestedSteeringCacheBytes = steeringBytesPerBin * selectedBins.length;
const steeringCacheBinCapacity = Math.min(
selectedBins.length,
Math.floor(maximumSteeringCacheBytes / steeringBytesPerBin),
Math.floor((maximumPlanBytes - retainedNonSteeringBytes) / steeringBytesPerBin),
);
const steeringCacheBytes = steeringCacheBinCapacity * steeringBytesPerBin;
const steeringCacheEnabled = steeringCacheBinCapacity > 0;
const steeringCachedBins = new Set(selectedBins.slice(0, steeringCacheBinCapacity));
return {
kind: DAS_ANALYSIS_PLAN_KIND,
sampleRate,
arrayDefinition,
scene,
experiment,
algorithm,
microphoneCount,
pointCount,
bandBins,
selectedBins,
distances,
bandIndexesByBin: createBandIndexLookup(bandBins),
calibrations: new Map(),
steeringMatrices: new Map(),
steeringCacheEnabled,
steeringCacheBinCapacity,
steeringCachedBins,
steeringBytesPerBin,
steeringCacheBytes,
requestedSteeringCacheBytes,
retainedBaseBytes,
calibrationBytes,
retainedNonSteeringBytes,
maximumSteeringCacheBytes,
maximumPlanBytes,
binMap,
steeringReal,
steeringImaginary,
stftWorkspace,
busy: false,
statistics: {
analysisAttempts: 0,
completedAnalyses: 0,
distanceMatrixBuilds: 1,
distanceEvaluations: pointCount * microphoneCount,
avoidedDistanceEvaluations: 0,
calibrationBuilds: 0,
calibrationCacheHits: 0,
steeringMatrixBuilds: 0,
steeringCacheHits: 0,
uncachedSteeringUses: 0,
steeringPointMicrophonePairs: 0,
uncachedSteeringPointMicrophonePairs: 0,
avoidedSteeringPointMicrophonePairs: 0,
stftWindowBuilds: 1,
stftWorkspaceAllocations: 1,
stftWorkspaceUses: 0,
avoidedStftWindowBuilds: 0,
avoidedStftWorkspaceAllocations: 0,
stftTransforms: 0,
fftButterflies: 0,
beamformingComplexMacs: 0,
binMapAllocations: 1,
steeringWorkspaceAllocations: 1,
binMapUses: 0,
avoidedBinMapAllocations: 0,
retainedBytes: retainedBaseBytes,
},
};
}
function assertCompatibleAnalysisPlan(plan, options) {
if (plan?.kind !== DAS_ANALYSIS_PLAN_KIND) {
throw new TypeError("analysisPlan was not created by createDasAnalysisPlan().");
}
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 planCalibration(plan, bin) {
let calibration = plan.calibrations.get(bin);
if (calibration) {
plan.statistics.calibrationCacheHits += 1;
return calibration;
}
calibration = createBinCalibration({
arrayDefinition: plan.arrayDefinition,
bin,
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;
return calibration;
}
async function planSteeringMatrix(plan, bin, calibration, signal, pointBatchSize) {
if (!plan.steeringCachedBins.has(bin)) {
plan.statistics.uncachedSteeringUses += 1;
plan.statistics.uncachedSteeringPointMicrophonePairs +=
plan.pointCount * plan.microphoneCount;
return undefined;
}
let matrix = plan.steeringMatrices.get(bin);
if (matrix) {
plan.statistics.steeringCacheHits += 1;
plan.statistics.avoidedSteeringPointMicrophonePairs +=
plan.pointCount * plan.microphoneCount;
return matrix;
}
matrix = await createSteeringMatrix({
bin,
pointCount: plan.pointCount,
microphoneCount: plan.microphoneCount,
distances: plan.distances,
sampleRate: plan.sampleRate,
fftSize: plan.experiment.stft.fftSize,
speedOfSound: plan.scene.speedOfSound,
calibration,
applyCalibrationGains: plan.algorithm.spectralWeighting !== "phat",
signal,
pointBatchSize,
});
plan.steeringMatrices.set(bin, matrix);
plan.statistics.steeringMatrixBuilds += 1;
plan.statistics.steeringPointMicrophonePairs +=
plan.pointCount * plan.microphoneCount;
plan.statistics.retainedBytes += matrix.real.byteLength + matrix.imaginary.byteLength;
return matrix;
}
function analysisCacheSummary(plan) {
return {
distanceMatrixBuilds: plan.statistics.distanceMatrixBuilds,
distanceEvaluations: plan.statistics.distanceEvaluations,
avoidedDistanceEvaluations: plan.statistics.avoidedDistanceEvaluations,
calibrationBuilds: plan.statistics.calibrationBuilds,
calibrationCacheHits: plan.statistics.calibrationCacheHits,
calibrationBytes: plan.calibrationBytes,
steeringCacheEnabled: plan.steeringCacheEnabled,
steeringCacheBinCapacity: plan.steeringCacheBinCapacity,
steeringCachedBinCount: plan.steeringMatrices.size,
steeringUncachedBinCount: plan.selectedBins.length - plan.steeringCacheBinCapacity,
steeringCacheBytes: plan.steeringCacheBytes,
requestedSteeringCacheBytes: plan.requestedSteeringCacheBytes,
steeringMatrixBuilds: plan.statistics.steeringMatrixBuilds,
steeringCacheHits: plan.statistics.steeringCacheHits,
uncachedSteeringUses: plan.statistics.uncachedSteeringUses,
steeringPointMicrophonePairs: plan.statistics.steeringPointMicrophonePairs,
uncachedSteeringPointMicrophonePairs:
plan.statistics.uncachedSteeringPointMicrophonePairs,
avoidedSteeringPointMicrophonePairs:
plan.statistics.avoidedSteeringPointMicrophonePairs,
stftWindowBuilds: plan.statistics.stftWindowBuilds,
stftWorkspaceAllocations: plan.statistics.stftWorkspaceAllocations,
stftWorkspaceUses: plan.statistics.stftWorkspaceUses,
avoidedStftWindowBuilds: plan.statistics.avoidedStftWindowBuilds,
avoidedStftWorkspaceAllocations: plan.statistics.avoidedStftWorkspaceAllocations,
stftTransforms: plan.statistics.stftTransforms,
fftButterflies: plan.statistics.fftButterflies,
beamformingComplexMacs: plan.statistics.beamformingComplexMacs,
binMapAllocations: plan.statistics.binMapAllocations,
steeringWorkspaceAllocations: plan.statistics.steeringWorkspaceAllocations,
avoidedBinMapAllocations: plan.statistics.avoidedBinMapAllocations,
retainedBytes: plan.statistics.retainedBytes,
};
}
export function estimateDasWorkload(options) {
const { channels, sampleRate, arrayDefinition, experiment, analysisPlan } = options;
const { fftSize, hopSize } = experiment.stft;
const bandBins = analysisPlan?.bandBins ??
experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize));
const selectedBins = analysisPlan?.selectedBins ??
[...new Set(bandBins.flat())].sort((a, b) => a - b);
const frameCount = getFrameCount(channels[0].length, fftSize, hopSize);
const microphoneCount = arrayDefinition.microphones.length;
const pointCount = experiment.scan.columns * experiment.scan.rows;
const bandCount = experiment.frequencyBands.length;
const distanceBytes = pointCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT;
const outputBytes = bandCount * pointCount * Float32Array.BYTES_PER_ELEMENT * 2;
const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT;
const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
const steeringCacheBytes = analysisPlan?.steeringCacheBytes ?? 0;
const calibrationBytes = analysisPlan?.calibrationBytes ??
microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
const baseWorkingBytes =
distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes +
steeringCacheBytes + calibrationBytes;
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length;
return {
bandBins,
selectedBins,
frameCount,
microphoneCount,
pointCount,
distanceBytes,
outputBytes,
fftWorkspaceBytes,
steeringCacheBytes,
calibrationBytes,
steeringCacheBinCapacity: analysisPlan?.steeringCacheBinCapacity ?? 0,
spectrumBytesPerBin,
baseWorkingBytes,
complexMacCount,
};
}
export function finalizeDasBands(
rawMaps,
workload,
experiment,
rawScale = "power",
options = {},
) {
if (rawScale !== "power" && rawScale !== "score") {
throw new RangeError('rawScale must be "power" or "score".');
}
const averageBandBins = options.averageBandBins !== false;
return experiment.frequencyBands.map((band, bandIndex) => {
const rawMap = rawMaps[bandIndex];
if (averageBandBins) {
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
rawMap[pointIndex] /= workload.bandBins[bandIndex].length;
}
}
const rawPeak = findPeak(rawMap);
const displayMap = Float32Array.from(rawMap);
let displayScale = rawScale;
if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) {
displayScale = "normalized";
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
displayMap[pointIndex] /= rawPeak.value;
}
}
return {
id: band.id,
color: band.color,
bins: workload.bandBins[bandIndex],
rawMap,
rawScale,
displayMap,
displayScale,
peak: {
index: rawPeak.index,
position: scanIndexToPosition(rawPeak.index, experiment.scan),
rawValue: rawPeak.value,
rawScale,
...(rawScale === "power"
? { rawPower: rawPeak.value }
: { rawScore: rawPeak.value }),
},
};
});
}
async function computeDasMapInternal(options, analysisPlan) {
const {
channels,
sampleRate,
arrayDefinition,
scene,
experiment,
signal,
onProgress,
maximumWorkingBytes = 512 * 1024 * 1024,
maximumComplexMacs = 1_000_000_000,
maximumFftButterflies = 1_000_000_000,
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}가 다릅니다.`);
}
if (!Number.isInteger(pointBatchSize) || pointBatchSize < 1) throw new Error("pointBatchSize는 1 이상의 정수여야 합니다.");
const workload = estimateDasWorkload({
channels,
sampleRate,
arrayDefinition,
experiment,
analysisPlan,
});
if (workload.complexMacCount > maximumComplexMacs) {
throw new Error(
`CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` +
`현재 한도 ${maximumComplexMacs.toLocaleString()}회를 넘습니다. grid, duration 또는 대역폭을 줄이세요.`,
);
}
const availableSpectrumBytes = maximumWorkingBytes - workload.baseWorkingBytes;
const batchCapacity = Math.floor(availableSpectrumBytes / workload.spectrumBytesPerBin);
if (batchCapacity < 1) {
throw new Error(
`CPU DAS 기본 working set ${(workload.baseWorkingBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`현재 한도 ${(maximumWorkingBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다.`,
);
}
const batchSize = Math.max(
1,
Math.min(workload.selectedBins.length, batchCapacity, Math.max(1, maximumSpectrumBatchBins)),
);
const spectrumBatchCount = Math.ceil(workload.selectedBins.length / batchSize);
const fftButterflyCount =
spectrumBatchCount *
workload.frameCount *
workload.microphoneCount *
(experiment.stft.fftSize / 2) *
Math.log2(experiment.stft.fftSize);
if (fftButterflyCount > maximumFftButterflies) {
throw new Error(
`CPU STFT 예상 butterfly ${Math.ceil(fftButterflyCount).toLocaleString()}회가 ` +
`현재 한도 ${maximumFftButterflies.toLocaleString()}회를 넘습니다. duration, FFT 크기 또는 대역폭을 줄이세요.`,
);
}
const distances = analysisPlan?.distances ?? createDistanceMatrix(arrayDefinition, experiment.scan);
const rawMaps = experiment.frequencyBands.map(() => new Float32Array(workload.pointCount));
const bandIndexesByBin =
analysisPlan?.bandIndexesByBin ?? createBandIndexLookup(workload.bandBins);
let completedBins = 0;
for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) {
throwIfAborted(signal);
const batchBins = workload.selectedBins.slice(batchStart, batchStart + batchSize);
if (analysisPlan) {
analysisPlan.statistics.stftWorkspaceUses += 1;
const repeatedUses = Math.max(0, analysisPlan.statistics.stftWorkspaceUses - 1);
analysisPlan.statistics.avoidedStftWindowBuilds = repeatedUses;
analysisPlan.statistics.avoidedStftWorkspaceAllocations = repeatedUses;
}
const stft = await extractStftBins(channels, {
fftSize: experiment.stft.fftSize,
hopSize: experiment.stft.hopSize,
bins: batchBins,
signal,
transformBatchSize: stftTransformBatchSize,
workspace: analysisPlan?.stftWorkspace,
onProgress: ({ completedTransforms, totalTransforms }) =>
onProgress?.({
stage: "stft",
completedBins,
totalBins: workload.selectedBins.length,
completedTransforms,
totalTransforms,
spectrumBatch: Math.floor(batchStart / batchSize) + 1,
spectrumBatchCount,
}),
});
if (analysisPlan) {
const completedTransformCount = stft.frameCount * stft.microphoneCount;
analysisPlan.statistics.stftTransforms += completedTransformCount;
analysisPlan.statistics.fftButterflies +=
completedTransformCount *
(experiment.stft.fftSize / 2) *
Math.log2(experiment.stft.fftSize);
}
await cooperativeYield();
throwIfAborted(signal);
for (const bin of batchBins) {
const calibration = analysisPlan ? planCalibration(analysisPlan, bin) : undefined;
const steeringMatrix = analysisPlan
? await planSteeringMatrix(analysisPlan, bin, calibration, signal, pointBatchSize)
: undefined;
if (analysisPlan) {
analysisPlan.statistics.binMapUses += 1;
analysisPlan.statistics.avoidedBinMapAllocations = Math.max(
0,
analysisPlan.statistics.binMapUses - 1,
);
}
const binMap = await computeBinMap({
bin,
spectrum: stft.spectra.get(bin),
frameCount: stft.frameCount,
arrayDefinition,
pointCount: workload.pointCount,
distances,
sampleRate,
fftSize: experiment.stft.fftSize,
speedOfSound: scene.speedOfSound,
compensateFrequencyResponse:
experiment.beamforming.compensateFrequencyResponse !== false,
maxCompensationDb: experiment.beamforming.maxCompensationDb,
calibration,
steeringMatrix,
output: analysisPlan?.binMap,
steeringReal: analysisPlan?.steeringReal,
steeringImaginary: analysisPlan?.steeringImaginary,
signal,
pointBatchSize,
algorithm: algorithm.id,
phatRelativeFloor,
});
if (analysisPlan) {
analysisPlan.statistics.beamformingComplexMacs +=
workload.pointCount * workload.microphoneCount * stft.frameCount;
}
for (const bandIndex of bandIndexesByBin.get(bin)) {
const rawMap = rawMaps[bandIndex];
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
rawMap[pointIndex] += binMap[pointIndex];
}
}
completedBins += 1;
onProgress?.({ stage: "beamforming", completedBins, totalBins: workload.selectedBins.length });
}
}
const bands = finalizeDasBands(rawMaps, workload, experiment, algorithm.rawScale);
return {
algorithm: algorithm.id,
backend: "cpu",
frameCount: workload.frameCount,
microphoneCount: workload.microphoneCount,
pointCount: workload.pointCount,
workload,
fftButterflyCount,
bands,
...(analysisPlan ? { analysisCache: analysisCacheSummary(analysisPlan) } : {}),
};
}
export async function computeDasMap(options) {
const analysisPlan = options.analysisPlan;
if (!analysisPlan) return computeDasMapInternal(options, undefined);
assertCompatibleAnalysisPlan(analysisPlan, options);
if (analysisPlan.busy) {
throw new Error("analysisPlan is already in use by another DAS analysis.");
}
analysisPlan.busy = true;
analysisPlan.statistics.analysisAttempts += 1;
if (analysisPlan.statistics.analysisAttempts > 1) {
analysisPlan.statistics.avoidedDistanceEvaluations +=
analysisPlan.pointCount * analysisPlan.microphoneCount;
}
try {
const result = await computeDasMapInternal(options, analysisPlan);
analysisPlan.statistics.completedAnalyses += 1;
result.analysisCache = {
...result.analysisCache,
analysisAttempts: analysisPlan.statistics.analysisAttempts,
completedAnalyses: analysisPlan.statistics.completedAnalyses,
};
return result;
} finally {
analysisPlan.busy = false;
}
}