1896 lines
60 KiB
JavaScript
1896 lines
60 KiB
JavaScript
import {
|
|
auditCovarianceEigenvalues,
|
|
computeComplexSampleCovariance,
|
|
estimateComplexCovarianceWorkload,
|
|
selectWaxKailathSourceCount,
|
|
} from "./covariance.js";
|
|
import { cooperativeYield } from "./cooperative-yield.js";
|
|
import {
|
|
DEFAULT_MAX_COMPENSATION_DB,
|
|
interpolateFrequencyResponse,
|
|
inverseResponseGainDb,
|
|
requireMaxCompensationDb,
|
|
} from "./frequency-response.js";
|
|
import {
|
|
DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS,
|
|
decomposeHermitian,
|
|
estimateHermitianEigenWorkload,
|
|
} from "./hermitian-eigen.js";
|
|
import { dbToLinear, scanIndexToPosition, TAU } from "./math.js";
|
|
import { describeFrequencyAliasing } from "./sampling.js";
|
|
import { requireLocalizationAlgorithmDefinition } from "./localization-algorithms.js";
|
|
import {
|
|
binsForBand,
|
|
createStftWorkspace,
|
|
extractStftBins,
|
|
getCompleteFrameCount,
|
|
getFrameCount,
|
|
} from "./stft.js";
|
|
|
|
const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT;
|
|
const MUSIC_ANALYSIS_PLAN_KIND = "micarrsim.music-analysis-plan/1";
|
|
|
|
export class MusicUnavailableError extends Error {
|
|
constructor(reason, message, details = {}) {
|
|
super(message);
|
|
this.name = "MusicUnavailableError";
|
|
this.code = "MUSIC_UNAVAILABLE";
|
|
this.reason = reason;
|
|
this.details = Object.freeze({ ...details });
|
|
}
|
|
}
|
|
|
|
export function isMusicUnavailableError(error) {
|
|
return (
|
|
error instanceof MusicUnavailableError ||
|
|
(
|
|
error?.code === "MUSIC_UNAVAILABLE" &&
|
|
typeof error?.reason === "string"
|
|
)
|
|
);
|
|
}
|
|
|
|
export const DEFAULT_MAXIMUM_MUSIC_WORKING_BYTES = 512 * 1024 * 1024;
|
|
export const DEFAULT_MAXIMUM_MUSIC_PLAN_BYTES = 512 * 1024 * 1024;
|
|
export const DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS =
|
|
1_000_000_000;
|
|
export const DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS = 1_000_000_000;
|
|
export const DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES = 300_000_000;
|
|
export const DEFAULT_MAXIMUM_MUSIC_FFT_BUTTERFLIES = 1_000_000_000;
|
|
|
|
function createAbortError(signal) {
|
|
if (signal?.reason instanceof Error && signal.reason.name === "AbortError") {
|
|
return signal.reason;
|
|
}
|
|
const error = new Error("MUSIC 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 requireNonNegativeFinite(value, label) {
|
|
if (!Number.isFinite(value) || value < 0) {
|
|
throw new RangeError(`${label} must be a non-negative finite number.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requirePositiveSafeInteger(value, label) {
|
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
throw new RangeError(`${label} must be a positive safe integer.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function checkedProduct(factors, label) {
|
|
let product = 1;
|
|
for (const factor of factors) {
|
|
if (!Number.isSafeInteger(factor) || factor < 0) {
|
|
throw new RangeError(`${label} has an invalid factor: ${factor}.`);
|
|
}
|
|
if (factor !== 0 && product > Number.MAX_SAFE_INTEGER / factor) {
|
|
throw new RangeError(`${label} exceeds the safe integer range.`);
|
|
}
|
|
product *= factor;
|
|
}
|
|
return product;
|
|
}
|
|
|
|
function checkedSum(values, label) {
|
|
let sum = 0;
|
|
for (const value of values) {
|
|
if (
|
|
!Number.isSafeInteger(value) ||
|
|
value < 0 ||
|
|
sum > Number.MAX_SAFE_INTEGER - value
|
|
) {
|
|
throw new RangeError(`${label} exceeds the safe integer range.`);
|
|
}
|
|
sum += value;
|
|
}
|
|
return sum;
|
|
}
|
|
|
|
function requireMusicAlgorithm(experiment) {
|
|
const algorithm = requireLocalizationAlgorithmDefinition(
|
|
experiment?.beamforming?.algorithm,
|
|
);
|
|
if (algorithm.id !== "music") {
|
|
throw new RangeError(`MUSIC implementation cannot run ${algorithm.id}.`);
|
|
}
|
|
return algorithm;
|
|
}
|
|
|
|
function requireFftConfiguration(experiment) {
|
|
const fftSize = experiment?.stft?.fftSize;
|
|
const hopSize = experiment?.stft?.hopSize;
|
|
if (
|
|
!Number.isSafeInteger(fftSize) ||
|
|
fftSize < 2 ||
|
|
(fftSize & (fftSize - 1)) !== 0
|
|
) {
|
|
throw new RangeError(
|
|
"experiment.stft.fftSize must be a power of two greater than one.",
|
|
);
|
|
}
|
|
if (!Number.isSafeInteger(hopSize) || hopSize < 1 || hopSize > fftSize) {
|
|
throw new RangeError(
|
|
"experiment.stft.hopSize must be an integer inside 1..fftSize.",
|
|
);
|
|
}
|
|
return { fftSize, hopSize };
|
|
}
|
|
|
|
function requireScan(scan) {
|
|
if (!scan || typeof scan !== "object") {
|
|
throw new TypeError("experiment.scan must be an object.");
|
|
}
|
|
for (const field of ["xMin", "xMax", "yMin", "yMax", "z"]) {
|
|
if (!Number.isFinite(scan[field])) {
|
|
throw new TypeError(`experiment.scan.${field} must be finite.`);
|
|
}
|
|
}
|
|
requirePositiveSafeInteger(scan.columns, "experiment.scan.columns");
|
|
requirePositiveSafeInteger(scan.rows, "experiment.scan.rows");
|
|
if (scan.xMax < scan.xMin || scan.yMax < scan.yMin) {
|
|
throw new RangeError("experiment.scan bounds must satisfy min <= max.");
|
|
}
|
|
checkedProduct([scan.columns, scan.rows], "MUSIC scan point count");
|
|
return scan;
|
|
}
|
|
|
|
function resolveSourceCountConfiguration(
|
|
beamforming,
|
|
microphoneCount,
|
|
hopSize,
|
|
fftSize,
|
|
) {
|
|
const method = beamforming.musicSourceCountMethod ?? "fixed";
|
|
if (method !== "fixed" && method !== "mdl" && method !== "aic") {
|
|
throw new RangeError(
|
|
'musicSourceCountMethod must be "fixed", "mdl", or "aic".',
|
|
);
|
|
}
|
|
if (method === "fixed") {
|
|
const value = beamforming.musicSourceCount;
|
|
if (!Number.isSafeInteger(value) || value < 1 || value >= microphoneCount) {
|
|
throw new RangeError(
|
|
`beamforming.musicSourceCount must be an integer inside 1..${microphoneCount - 1}.`,
|
|
);
|
|
}
|
|
return Object.freeze({ method, value });
|
|
}
|
|
if (hopSize !== fftSize) {
|
|
throw new RangeError(
|
|
`MUSIC ${method.toUpperCase()} source-count selection requires non-overlapping ` +
|
|
"STFT snapshots (hopSize === fftSize); no independence correction is invented.",
|
|
);
|
|
}
|
|
return Object.freeze({ method });
|
|
}
|
|
|
|
function resolveBandBins(frequencyBands, sampleRate, fftSize) {
|
|
if (!Array.isArray(frequencyBands) || frequencyBands.length === 0) {
|
|
throw new TypeError("experiment.frequencyBands must be a non-empty array.");
|
|
}
|
|
const binWidthHz = sampleRate / fftSize;
|
|
const bandHypotheses = frequencyBands.map((band, bandIndex) => {
|
|
const bins = binsForBand(band, sampleRate, fftSize);
|
|
const label = band?.id ?? `index ${bandIndex}`;
|
|
if (bins.length !== 1) {
|
|
throw new RangeError(
|
|
`MUSIC band ${label} must select exactly one observed positive ADC FFT bin; ` +
|
|
`it selects ${bins.length} bins [${bins.join(", ")}]. ` +
|
|
"This implementation does not average or fuse multiple bins.",
|
|
);
|
|
}
|
|
const firstAnalogBin = Math.ceil(band.minHz / binWidthHz);
|
|
const lastAnalogBin = Math.floor(band.maxHz / binWidthHz);
|
|
if (firstAnalogBin !== lastAnalogBin) {
|
|
const hypothesisCount = lastAnalogBin - firstAnalogBin + 1;
|
|
throw new RangeError(
|
|
`MUSIC band ${label} contains ${hypothesisCount} analog FFT-grid hypotheses; ` +
|
|
"standard narrowband MUSIC returns one map per configured carrier. " +
|
|
"Narrow the band to one analog FFT-grid center or create separate bands. " +
|
|
"No alias-order choice, averaging, or maximum fusion is applied.",
|
|
);
|
|
}
|
|
const bin = bins[0];
|
|
if (bin === 0 || bin === fftSize / 2) {
|
|
const edge = bin === 0 ? "DC" : "Nyquist";
|
|
throw new RangeError(
|
|
`MUSIC band ${label} selects the exact ${edge} bin ${bin}; ` +
|
|
"a real ADC has no independent quadrature component there, so " +
|
|
"standard complex narrowband MUSIC is phase-degenerate.",
|
|
);
|
|
}
|
|
const analogFrequencyHz = firstAnalogBin * binWidthHz;
|
|
const alias = describeFrequencyAliasing(
|
|
analogFrequencyHz,
|
|
sampleRate,
|
|
);
|
|
const signedAdcBin = Math.round(
|
|
alias.signedAdcFrequencyHz / binWidthHz,
|
|
);
|
|
if (Math.abs(signedAdcBin) !== bin) {
|
|
throw new Error(
|
|
`MUSIC band ${label} produced an inconsistent analog/ADC FFT-grid mapping.`,
|
|
);
|
|
}
|
|
const aliasOrder = Math.round(
|
|
(analogFrequencyHz - alias.signedAdcFrequencyHz) /
|
|
sampleRate,
|
|
);
|
|
const hypothesisKey =
|
|
`analog-bin:${firstAnalogBin}|adc-bin:${bin}`;
|
|
return Object.freeze({
|
|
bandIndex,
|
|
bandId: band.id,
|
|
hypothesisKey,
|
|
analogBin: firstAnalogBin,
|
|
analogFrequencyHz,
|
|
bin,
|
|
observedFrequencyHz: bin * binWidthHz,
|
|
signedAdcBin,
|
|
signedAdcFrequencyHz: alias.signedAdcFrequencyHz,
|
|
spectralInversion: alias.spectralInversion,
|
|
orientationSign: alias.spectralInversion ? -1 : 1,
|
|
aliasOrder,
|
|
aliased: alias.aliased,
|
|
sharedObservationKey:
|
|
`fs:${sampleRate}|fft:${fftSize}|bin:${bin}`,
|
|
});
|
|
});
|
|
const bandBins = bandHypotheses.map(({ bin }) =>
|
|
Object.freeze([bin]));
|
|
const selectedBins = Object.freeze(
|
|
[...new Set(bandBins.map(([bin]) => bin))].sort(
|
|
(left, right) => left - right,
|
|
),
|
|
);
|
|
const uniqueHypotheses = new Map();
|
|
for (const hypothesis of bandHypotheses) {
|
|
if (!uniqueHypotheses.has(hypothesis.hypothesisKey)) {
|
|
uniqueHypotheses.set(hypothesis.hypothesisKey, hypothesis);
|
|
}
|
|
}
|
|
const analysisHypotheses = Object.freeze(
|
|
[...uniqueHypotheses.values()],
|
|
);
|
|
const hypothesesByBin = new Map();
|
|
for (const hypothesis of analysisHypotheses) {
|
|
const group = hypothesesByBin.get(hypothesis.bin) ?? [];
|
|
group.push(hypothesis);
|
|
hypothesesByBin.set(hypothesis.bin, group);
|
|
}
|
|
return {
|
|
bandBins: Object.freeze(bandBins),
|
|
bandHypotheses: Object.freeze(bandHypotheses),
|
|
analysisHypotheses,
|
|
hypothesesByBin,
|
|
selectedBins,
|
|
};
|
|
}
|
|
|
|
function resolveConfiguration(options) {
|
|
const { sampleRate, arrayDefinition, scene, experiment } = options;
|
|
requirePositiveFinite(sampleRate, "sampleRate");
|
|
if (
|
|
!Array.isArray(arrayDefinition?.microphones) ||
|
|
arrayDefinition.microphones.length < 2
|
|
) {
|
|
throw new RangeError("MUSIC requires at least two microphones.");
|
|
}
|
|
const microphoneCount = arrayDefinition.microphones.length;
|
|
requirePositiveSafeInteger(microphoneCount, "microphone count");
|
|
requirePositiveFinite(scene?.speedOfSound, "scene.speedOfSound");
|
|
const algorithm = requireMusicAlgorithm(experiment);
|
|
const { fftSize, hopSize } = requireFftConfiguration(experiment);
|
|
const scan = requireScan(experiment.scan);
|
|
const pointCount = checkedProduct(
|
|
[scan.columns, scan.rows],
|
|
"MUSIC scan point count",
|
|
);
|
|
const {
|
|
bandBins,
|
|
bandHypotheses,
|
|
analysisHypotheses,
|
|
hypothesesByBin,
|
|
selectedBins,
|
|
} = resolveBandBins(
|
|
experiment.frequencyBands,
|
|
sampleRate,
|
|
fftSize,
|
|
);
|
|
const beamforming = experiment.beamforming;
|
|
if (beamforming.normalization !== "none") {
|
|
throw new RangeError(
|
|
'MUSIC requires beamforming.normalization === "none"; raw q is never peak-normalized or inverted.',
|
|
);
|
|
}
|
|
const minimumDistance = requirePositiveFinite(
|
|
beamforming.minimumDistance ?? 0.01,
|
|
"beamforming.minimumDistance",
|
|
);
|
|
if (!Number.isFinite(1 / minimumDistance)) {
|
|
throw new RangeError(
|
|
"beamforming.minimumDistance is too small for finite inverse-distance steering.",
|
|
);
|
|
}
|
|
const compensateFrequencyResponse =
|
|
beamforming.compensateFrequencyResponse !== false;
|
|
const maxCompensationDb = requireMaxCompensationDb(
|
|
beamforming.maxCompensationDb ?? DEFAULT_MAX_COMPENSATION_DB,
|
|
"beamforming.maxCompensationDb",
|
|
);
|
|
const sourceCount = resolveSourceCountConfiguration(
|
|
beamforming,
|
|
microphoneCount,
|
|
hopSize,
|
|
fftSize,
|
|
);
|
|
return {
|
|
algorithm,
|
|
sampleRate,
|
|
arrayDefinition,
|
|
scene,
|
|
experiment,
|
|
microphoneCount,
|
|
pointCount,
|
|
fftSize,
|
|
hopSize,
|
|
bandBins,
|
|
bandHypotheses,
|
|
analysisHypotheses,
|
|
hypothesesByBin,
|
|
selectedBins,
|
|
minimumDistance,
|
|
compensateFrequencyResponse,
|
|
maxCompensationDb,
|
|
sourceCount,
|
|
};
|
|
}
|
|
|
|
function scanCoordinate(minimum, maximum, index, count, reverse = false) {
|
|
if (count === 1) return reverse ? maximum : minimum;
|
|
const amount = index / (count - 1);
|
|
return reverse
|
|
? maximum + (minimum - maximum) * amount
|
|
: minimum + (maximum - minimum) * amount;
|
|
}
|
|
|
|
export function createMusicDistanceMatrix(arrayDefinition, scan) {
|
|
const microphoneCount = arrayDefinition.microphones.length;
|
|
const pointCount = checkedProduct(
|
|
[scan.columns, scan.rows],
|
|
"MUSIC scan point count",
|
|
);
|
|
const elementCount = checkedProduct(
|
|
[pointCount, microphoneCount],
|
|
"MUSIC distance element count",
|
|
);
|
|
const distances = new Float64Array(elementCount);
|
|
let pointIndex = 0;
|
|
for (let row = 0; row < scan.rows; row += 1) {
|
|
const y = scanCoordinate(scan.yMin, scan.yMax, row, scan.rows, true);
|
|
for (let column = 0; column < scan.columns; column += 1) {
|
|
const x = scanCoordinate(scan.xMin, scan.xMax, column, scan.columns);
|
|
const offset = pointIndex * microphoneCount;
|
|
for (
|
|
let microphoneIndex = 0;
|
|
microphoneIndex < microphoneCount;
|
|
microphoneIndex += 1
|
|
) {
|
|
const position =
|
|
arrayDefinition.microphones[microphoneIndex]?.position;
|
|
if (
|
|
!Array.isArray(position) ||
|
|
position.length !== 3 ||
|
|
!position.every(Number.isFinite)
|
|
) {
|
|
throw new TypeError(
|
|
`arrayDefinition.microphones[${microphoneIndex}].position must be finite [x,y,z].`,
|
|
);
|
|
}
|
|
distances[offset + microphoneIndex] = Math.hypot(
|
|
x - position[0],
|
|
y - position[1],
|
|
scan.z - position[2],
|
|
);
|
|
}
|
|
pointIndex += 1;
|
|
}
|
|
}
|
|
return distances;
|
|
}
|
|
|
|
/**
|
|
* Build exact diagonal preprocessing and the residual microphone response.
|
|
* Sensitivity and phaseOffset are always removed. When response compensation
|
|
* is enabled, response phase is removed exactly and only inverse gain boost is
|
|
* capped; residual gain remains in the steering manifold.
|
|
*/
|
|
export function createMusicBinCalibration(options) {
|
|
const {
|
|
arrayDefinition,
|
|
bin,
|
|
sampleRate,
|
|
fftSize,
|
|
analogFrequencyHz: requestedAnalogFrequencyHz,
|
|
signedAdcFrequencyHz:
|
|
requestedSignedAdcFrequencyHz,
|
|
spectralInversion = false,
|
|
compensateFrequencyResponse = true,
|
|
maxCompensationDb: maximumCompensationDb,
|
|
} = options;
|
|
const microphoneCount = arrayDefinition.microphones.length;
|
|
const dataGains = new Float64Array(microphoneCount);
|
|
const dataPhases = new Float64Array(microphoneCount);
|
|
const manifoldGains = new Float64Array(microphoneCount);
|
|
const manifoldPhases = new Float64Array(microphoneCount);
|
|
const observedFrequencyHz = (bin * sampleRate) / fftSize;
|
|
const analogFrequencyHz =
|
|
requestedAnalogFrequencyHz ?? observedFrequencyHz;
|
|
const signedAdcFrequencyHz =
|
|
requestedSignedAdcFrequencyHz ??
|
|
(spectralInversion
|
|
? -observedFrequencyHz
|
|
: observedFrequencyHz);
|
|
requirePositiveFinite(
|
|
analogFrequencyHz,
|
|
"analogFrequencyHz",
|
|
);
|
|
if (
|
|
!Number.isFinite(signedAdcFrequencyHz) ||
|
|
Math.abs(
|
|
Math.abs(signedAdcFrequencyHz) - observedFrequencyHz,
|
|
) > 32 * Number.EPSILON *
|
|
Math.max(1, observedFrequencyHz)
|
|
) {
|
|
throw new RangeError(
|
|
"signedAdcFrequencyHz must map to the selected positive ADC bin.",
|
|
);
|
|
}
|
|
if ((signedAdcFrequencyHz < 0) !== spectralInversion) {
|
|
throw new RangeError(
|
|
"spectralInversion must match signedAdcFrequencyHz.",
|
|
);
|
|
}
|
|
const orientationSign = spectralInversion ? -1 : 1;
|
|
const maxCompensationDb = requireMaxCompensationDb(
|
|
maximumCompensationDb ?? DEFAULT_MAX_COMPENSATION_DB,
|
|
);
|
|
const response = {};
|
|
for (
|
|
let microphoneIndex = 0;
|
|
microphoneIndex < microphoneCount;
|
|
microphoneIndex += 1
|
|
) {
|
|
const microphone = arrayDefinition.microphones[microphoneIndex];
|
|
interpolateFrequencyResponse(
|
|
microphone.frequencyResponse,
|
|
analogFrequencyHz,
|
|
response,
|
|
);
|
|
const inverseGainDb = compensateFrequencyResponse
|
|
? inverseResponseGainDb(response.gainDb, maxCompensationDb)
|
|
: 0;
|
|
dataGains[microphoneIndex] = dbToLinear(
|
|
-(microphone.sensitivityDb ?? 0) + inverseGainDb,
|
|
);
|
|
dataPhases[microphoneIndex] =
|
|
-orientationSign *
|
|
(
|
|
((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180 +
|
|
(compensateFrequencyResponse
|
|
? response.phaseRadians
|
|
: 0)
|
|
);
|
|
manifoldGains[microphoneIndex] = dbToLinear(
|
|
response.gainDb + inverseGainDb,
|
|
);
|
|
manifoldPhases[microphoneIndex] =
|
|
compensateFrequencyResponse
|
|
? 0
|
|
: orientationSign * response.phaseRadians;
|
|
if (
|
|
!Number.isFinite(dataGains[microphoneIndex]) ||
|
|
!Number.isFinite(dataPhases[microphoneIndex]) ||
|
|
!Number.isFinite(manifoldGains[microphoneIndex]) ||
|
|
!Number.isFinite(manifoldPhases[microphoneIndex])
|
|
) {
|
|
throw new RangeError(
|
|
`microphone ${microphoneIndex} calibration is not finite at ${analogFrequencyHz} Hz.`,
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
bin,
|
|
frequencyHz: analogFrequencyHz,
|
|
analogFrequencyHz,
|
|
observedFrequencyHz,
|
|
signedAdcFrequencyHz,
|
|
spectralInversion,
|
|
orientationSign,
|
|
dataGains,
|
|
dataPhases,
|
|
manifoldGains,
|
|
manifoldPhases,
|
|
model: Object.freeze({
|
|
sensitivity: "exact-inverse",
|
|
phaseOffset: "exact-inverse",
|
|
frequencyResponse: compensateFrequencyResponse
|
|
? "analog-hypothesis-inverse-phase-capped-inverse-gain"
|
|
: "uncompensated-retained-in-manifold",
|
|
frequencyResponseEvaluation:
|
|
"configured-analog-fft-grid-hypothesis",
|
|
spectralInversion:
|
|
"positive-bin-full-complex-conjugation",
|
|
compensateFrequencyResponse,
|
|
maxCompensationDb,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function applyMusicBinCalibration(
|
|
spectrum,
|
|
frameCount,
|
|
microphoneCount,
|
|
calibration,
|
|
) {
|
|
const expectedLength = checkedProduct(
|
|
[frameCount, microphoneCount],
|
|
"MUSIC spectrum length",
|
|
);
|
|
if (
|
|
!(spectrum?.real instanceof Float64Array) ||
|
|
!(spectrum?.imaginary instanceof Float64Array) ||
|
|
spectrum.real.length !== expectedLength ||
|
|
spectrum.imaginary.length !== expectedLength
|
|
) {
|
|
throw new RangeError(
|
|
"MUSIC spectrum must contain matching Float64 components.",
|
|
);
|
|
}
|
|
if (
|
|
calibration.dataGains.length !== microphoneCount ||
|
|
calibration.dataPhases.length !== microphoneCount
|
|
) {
|
|
throw new RangeError(
|
|
"MUSIC calibration length must match microphone count.",
|
|
);
|
|
}
|
|
for (
|
|
let microphoneIndex = 0;
|
|
microphoneIndex < microphoneCount;
|
|
microphoneIndex += 1
|
|
) {
|
|
const gain = calibration.dataGains[microphoneIndex];
|
|
const phase = calibration.dataPhases[microphoneIndex];
|
|
const correctionReal = gain * Math.cos(phase);
|
|
const correctionImaginary = gain * Math.sin(phase);
|
|
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
|
const index = frameIndex * microphoneCount + microphoneIndex;
|
|
const inputReal = spectrum.real[index];
|
|
const inputImaginary = spectrum.imaginary[index];
|
|
spectrum.real[index] =
|
|
inputReal * correctionReal - inputImaginary * correctionImaginary;
|
|
spectrum.imaginary[index] =
|
|
inputReal * correctionImaginary + inputImaginary * correctionReal;
|
|
}
|
|
}
|
|
return spectrum;
|
|
}
|
|
|
|
function musicPlanByteLayout(configuration) {
|
|
const {
|
|
pointCount,
|
|
microphoneCount,
|
|
analysisHypotheses,
|
|
fftSize,
|
|
} = configuration;
|
|
const distanceBytes = checkedProduct(
|
|
[pointCount, microphoneCount, FLOAT64_BYTES],
|
|
"MUSIC distance bytes",
|
|
);
|
|
const stftWorkspaceBytes = checkedProduct(
|
|
[fftSize, 3, FLOAT64_BYTES],
|
|
"MUSIC STFT workspace bytes",
|
|
);
|
|
const steeringWorkspaceBytes = checkedProduct(
|
|
[microphoneCount, 2, FLOAT64_BYTES],
|
|
"MUSIC steering workspace bytes",
|
|
);
|
|
const mapWorkspaceBytes = checkedProduct(
|
|
[pointCount, FLOAT64_BYTES],
|
|
"MUSIC map workspace bytes",
|
|
);
|
|
const calibrationBytes = checkedProduct(
|
|
[
|
|
microphoneCount,
|
|
analysisHypotheses.length,
|
|
4,
|
|
FLOAT64_BYTES,
|
|
],
|
|
"MUSIC calibration bytes",
|
|
);
|
|
const retainedBytes = checkedSum(
|
|
[
|
|
distanceBytes,
|
|
stftWorkspaceBytes,
|
|
steeringWorkspaceBytes,
|
|
mapWorkspaceBytes,
|
|
calibrationBytes,
|
|
],
|
|
"MUSIC analysis-plan bytes",
|
|
);
|
|
return {
|
|
distanceBytes,
|
|
stftWorkspaceBytes,
|
|
steeringWorkspaceBytes,
|
|
mapWorkspaceBytes,
|
|
calibrationBytes,
|
|
retainedBytes,
|
|
};
|
|
}
|
|
|
|
export function createMusicAnalysisPlan(options) {
|
|
const configuration = resolveConfiguration(options);
|
|
const maximumPlanBytes =
|
|
options.maximumPlanBytes ?? DEFAULT_MAXIMUM_MUSIC_PLAN_BYTES;
|
|
requirePositiveSafeInteger(maximumPlanBytes, "maximumPlanBytes");
|
|
const bytes = musicPlanByteLayout(configuration);
|
|
if (bytes.retainedBytes > maximumPlanBytes) {
|
|
throw new RangeError(
|
|
`MUSIC analysis plan requires ${bytes.retainedBytes.toLocaleString()} bytes; ` +
|
|
`maximumPlanBytes is ${maximumPlanBytes.toLocaleString()}.`,
|
|
);
|
|
}
|
|
const distances = createMusicDistanceMatrix(
|
|
configuration.arrayDefinition,
|
|
configuration.experiment.scan,
|
|
);
|
|
const calibrations = new Map();
|
|
for (const hypothesis of configuration.analysisHypotheses) {
|
|
calibrations.set(
|
|
hypothesis.hypothesisKey,
|
|
createMusicBinCalibration({
|
|
arrayDefinition: configuration.arrayDefinition,
|
|
bin: hypothesis.bin,
|
|
sampleRate: configuration.sampleRate,
|
|
fftSize: configuration.fftSize,
|
|
analogFrequencyHz:
|
|
hypothesis.analogFrequencyHz,
|
|
signedAdcFrequencyHz:
|
|
hypothesis.signedAdcFrequencyHz,
|
|
spectralInversion:
|
|
hypothesis.spectralInversion,
|
|
compensateFrequencyResponse:
|
|
configuration.compensateFrequencyResponse,
|
|
maxCompensationDb: configuration.maxCompensationDb,
|
|
}),
|
|
);
|
|
}
|
|
return {
|
|
kind: MUSIC_ANALYSIS_PLAN_KIND,
|
|
...configuration,
|
|
maximumPlanBytes,
|
|
...bytes,
|
|
distances,
|
|
calibrations,
|
|
stftWorkspace: createStftWorkspace(configuration.fftSize),
|
|
steeringReal: new Float64Array(configuration.microphoneCount),
|
|
steeringImaginary: new Float64Array(configuration.microphoneCount),
|
|
mapWorkspace: new Float64Array(configuration.pointCount),
|
|
busy: false,
|
|
statistics: {
|
|
analysisAttempts: 0,
|
|
completedAnalyses: 0,
|
|
distanceMatrixBuilds: 1,
|
|
avoidedDistanceMatrixBuilds: 0,
|
|
distanceEvaluations:
|
|
configuration.pointCount * configuration.microphoneCount,
|
|
calibrationBuilds:
|
|
configuration.analysisHypotheses.length,
|
|
calibrationCacheHits: 0,
|
|
stftWorkspaceUses: 0,
|
|
avoidedStftWorkspaceAllocations: 0,
|
|
stftTransforms: 0,
|
|
fftButterflies: 0,
|
|
covarianceBuilds: 0,
|
|
covarianceComplexAccumulations: 0,
|
|
eigendecompositions: 0,
|
|
jacobiSweeps: 0,
|
|
jacobiRotations: 0,
|
|
jacobiPairUpdates: 0,
|
|
projectionComplexMacs: 0,
|
|
mapWorkspaceUses: 0,
|
|
retainedBytes: bytes.retainedBytes,
|
|
},
|
|
};
|
|
}
|
|
|
|
function assertCompatibleAnalysisPlan(plan, options) {
|
|
if (plan?.kind !== MUSIC_ANALYSIS_PLAN_KIND) {
|
|
throw new TypeError(
|
|
"analysisPlan was not created by createMusicAnalysisPlan().",
|
|
);
|
|
}
|
|
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 requireChannels(channels, microphoneCount) {
|
|
if (!Array.isArray(channels) || channels.length !== microphoneCount) {
|
|
throw new RangeError(
|
|
`channel count ${channels?.length ?? 0} does not match microphone count ${microphoneCount}.`,
|
|
);
|
|
}
|
|
const sampleCount = channels[0]?.length;
|
|
if (!Number.isSafeInteger(sampleCount) || sampleCount < 1) {
|
|
throw new RangeError(
|
|
"MUSIC channels must contain at least one sample.",
|
|
);
|
|
}
|
|
for (let index = 0; index < channels.length; index += 1) {
|
|
const channel = channels[index];
|
|
if (
|
|
!(channel instanceof Float32Array) &&
|
|
!(channel instanceof Float64Array)
|
|
) {
|
|
throw new TypeError(
|
|
`channels[${index}] must be a Float32Array or Float64Array.`,
|
|
);
|
|
}
|
|
if (channel.length !== sampleCount) {
|
|
throw new RangeError(
|
|
"all MUSIC channels must have the same sample count.",
|
|
);
|
|
}
|
|
}
|
|
return sampleCount;
|
|
}
|
|
|
|
export function estimateMusicWorkload(options) {
|
|
const configuration = options.analysisPlan ?? resolveConfiguration(options);
|
|
if (options.analysisPlan) {
|
|
assertCompatibleAnalysisPlan(options.analysisPlan, options);
|
|
}
|
|
const sampleCount = requireChannels(
|
|
options.channels,
|
|
configuration.microphoneCount,
|
|
);
|
|
const frameCount =
|
|
configuration.sourceCount.method === "fixed"
|
|
? getFrameCount(
|
|
sampleCount,
|
|
configuration.fftSize,
|
|
configuration.hopSize,
|
|
)
|
|
: getCompleteFrameCount(
|
|
sampleCount,
|
|
configuration.fftSize,
|
|
configuration.hopSize,
|
|
);
|
|
if (
|
|
configuration.sourceCount.method !== "fixed" &&
|
|
frameCount <= configuration.microphoneCount
|
|
) {
|
|
throw new MusicUnavailableError(
|
|
"insufficient-snapshots-for-source-count-selection",
|
|
`MUSIC ${configuration.sourceCount.method.toUpperCase()} source-count selection ` +
|
|
`requires frameCount > microphoneCount; got ${frameCount} <= ` +
|
|
`${configuration.microphoneCount}. Non-overlap does not prove statistical ` +
|
|
"independence; the selected Wax-Kailath model assumes it. No eigenvalue " +
|
|
"floor or fallback is applied.",
|
|
{
|
|
frameCount,
|
|
microphoneCount:
|
|
configuration.microphoneCount,
|
|
},
|
|
);
|
|
}
|
|
const {
|
|
pointCount,
|
|
microphoneCount,
|
|
} = configuration;
|
|
const uniqueBinCount = configuration.selectedBins.length;
|
|
const hypothesisCount =
|
|
configuration.analysisHypotheses.length;
|
|
const bandCount = configuration.experiment.frequencyBands.length;
|
|
const planBytes = options.analysisPlan
|
|
? options.analysisPlan.retainedBytes
|
|
: musicPlanByteLayout(configuration).retainedBytes;
|
|
const resultMapBytes = checkedProduct(
|
|
[bandCount, pointCount, 2, FLOAT64_BYTES],
|
|
"MUSIC result-map bytes",
|
|
);
|
|
const retainedHypothesisMapBytes = checkedProduct(
|
|
[hypothesisCount, pointCount, FLOAT64_BYTES],
|
|
"MUSIC retained per-hypothesis map bytes",
|
|
);
|
|
const spectrumBytesPerBin = checkedProduct(
|
|
[frameCount, microphoneCount, 2, FLOAT64_BYTES],
|
|
"MUSIC Float64 spectrum bytes per bin",
|
|
);
|
|
const calibratedSpectrumBytes = spectrumBytesPerBin;
|
|
const covariance = estimateComplexCovarianceWorkload({
|
|
sensorCount: microphoneCount,
|
|
snapshotCount: frameCount,
|
|
});
|
|
const maximumJacobiSweeps =
|
|
options.maximumJacobiSweeps ?? DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS;
|
|
const eigen = estimateHermitianEigenWorkload({
|
|
order: microphoneCount,
|
|
maximumSweeps: maximumJacobiSweeps,
|
|
});
|
|
const covarianceAuditBytes = checkedProduct(
|
|
[microphoneCount, 2, FLOAT64_BYTES],
|
|
"MUSIC covariance audit bytes",
|
|
);
|
|
const sourceCountSelectionBytes =
|
|
configuration.sourceCount.method === "fixed"
|
|
? 0
|
|
: checkedProduct(
|
|
[microphoneCount, FLOAT64_BYTES],
|
|
"MUSIC source-count score bytes",
|
|
);
|
|
const perBinNumericalBytes = checkedSum(
|
|
[
|
|
covariance.allocationBytes,
|
|
eigen.allocationBytes,
|
|
covarianceAuditBytes,
|
|
sourceCountSelectionBytes,
|
|
],
|
|
"MUSIC per-bin numerical bytes",
|
|
);
|
|
const baseWorkingBytes = checkedSum(
|
|
[
|
|
planBytes,
|
|
resultMapBytes,
|
|
retainedHypothesisMapBytes,
|
|
perBinNumericalBytes,
|
|
calibratedSpectrumBytes,
|
|
],
|
|
"MUSIC base working bytes",
|
|
);
|
|
const covarianceComplexAccumulations = checkedProduct(
|
|
[hypothesisCount, covariance.complexAccumulations],
|
|
"MUSIC covariance complex accumulations",
|
|
);
|
|
const noiseSubspaceDimension =
|
|
configuration.sourceCount.method === "fixed"
|
|
? microphoneCount - configuration.sourceCount.value
|
|
: microphoneCount;
|
|
const projectionComplexMacs = checkedProduct(
|
|
[
|
|
hypothesisCount,
|
|
pointCount,
|
|
microphoneCount,
|
|
noiseSubspaceDimension,
|
|
],
|
|
"MUSIC projection complex MAC count",
|
|
);
|
|
const jacobiWorstCasePairUpdates = checkedProduct(
|
|
[hypothesisCount, eigen.worstCasePairUpdates],
|
|
"MUSIC Jacobi pair-update count",
|
|
);
|
|
const stftButterfliesPerBatch =
|
|
frameCount *
|
|
microphoneCount *
|
|
(configuration.fftSize / 2) *
|
|
Math.log2(configuration.fftSize);
|
|
if (!Number.isSafeInteger(stftButterfliesPerBatch)) {
|
|
throw new RangeError(
|
|
"MUSIC STFT butterfly count exceeds the safe integer range.",
|
|
);
|
|
}
|
|
return {
|
|
algorithm: "music",
|
|
rawScale: "noise-projection",
|
|
objectiveDirection: "minimum",
|
|
localizationModel:
|
|
"near-field-spherical-direct-path-configured-analog-hypothesis",
|
|
originalCarrierUsed: true,
|
|
bandBins: configuration.bandBins,
|
|
bandHypotheses: configuration.bandHypotheses,
|
|
selectedBins: configuration.selectedBins,
|
|
sampleCount,
|
|
frameCount,
|
|
microphoneCount,
|
|
pointCount,
|
|
bandCount,
|
|
uniqueBinCount,
|
|
hypothesisCount,
|
|
planBytes,
|
|
resultMapBytes,
|
|
retainedBinMapBytes: retainedHypothesisMapBytes,
|
|
retainedHypothesisMapBytes,
|
|
spectrumBytesPerBin,
|
|
calibratedSpectrumBytes,
|
|
covariance,
|
|
eigen,
|
|
covarianceAuditBytes,
|
|
sourceCountSelectionBytes,
|
|
perBinNumericalBytes,
|
|
baseWorkingBytes,
|
|
covarianceComplexAccumulations,
|
|
projectionComplexMacs,
|
|
jacobiWorstCasePairUpdates,
|
|
stftButterfliesPerBatch,
|
|
sourceCountMethod: configuration.sourceCount.method,
|
|
assumedNoiseSubspaceDimension: noiseSubspaceDimension,
|
|
};
|
|
}
|
|
|
|
function assertWorkLimits(workload, options) {
|
|
const maximumWorkingBytes =
|
|
options.maximumWorkingBytes ?? DEFAULT_MAXIMUM_MUSIC_WORKING_BYTES;
|
|
const maximumCovarianceComplexAccumulations =
|
|
options.maximumCovarianceComplexAccumulations ??
|
|
DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS;
|
|
const maximumProjectionComplexMacs =
|
|
options.maximumProjectionComplexMacs ??
|
|
DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS;
|
|
const maximumJacobiPairUpdates =
|
|
options.maximumJacobiPairUpdates ??
|
|
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES;
|
|
const maximumFftButterflies =
|
|
options.maximumFftButterflies ??
|
|
DEFAULT_MAXIMUM_MUSIC_FFT_BUTTERFLIES;
|
|
requirePositiveSafeInteger(maximumWorkingBytes, "maximumWorkingBytes");
|
|
requireNonNegativeFinite(
|
|
maximumCovarianceComplexAccumulations,
|
|
"maximumCovarianceComplexAccumulations",
|
|
);
|
|
requireNonNegativeFinite(
|
|
maximumProjectionComplexMacs,
|
|
"maximumProjectionComplexMacs",
|
|
);
|
|
requireNonNegativeFinite(
|
|
maximumJacobiPairUpdates,
|
|
"maximumJacobiPairUpdates",
|
|
);
|
|
requireNonNegativeFinite(
|
|
maximumFftButterflies,
|
|
"maximumFftButterflies",
|
|
);
|
|
if (
|
|
workload.covarianceComplexAccumulations >
|
|
maximumCovarianceComplexAccumulations
|
|
) {
|
|
throw new RangeError(
|
|
`MUSIC covariance requires ${workload.covarianceComplexAccumulations.toLocaleString()} ` +
|
|
`complex accumulations; limit is ${maximumCovarianceComplexAccumulations.toLocaleString()}.`,
|
|
);
|
|
}
|
|
if (workload.projectionComplexMacs > maximumProjectionComplexMacs) {
|
|
throw new RangeError(
|
|
`MUSIC projection requires at most ${workload.projectionComplexMacs.toLocaleString()} ` +
|
|
`complex MACs; limit is ${maximumProjectionComplexMacs.toLocaleString()}.`,
|
|
);
|
|
}
|
|
if (workload.jacobiWorstCasePairUpdates > maximumJacobiPairUpdates) {
|
|
throw new RangeError(
|
|
`MUSIC eigendecomposition requires at most ` +
|
|
`${workload.jacobiWorstCasePairUpdates.toLocaleString()} Jacobi pair updates; ` +
|
|
`limit is ${maximumJacobiPairUpdates.toLocaleString()}.`,
|
|
);
|
|
}
|
|
const availableSpectrumBytes =
|
|
maximumWorkingBytes - workload.baseWorkingBytes;
|
|
const memoryBatchCapacity = Math.floor(
|
|
availableSpectrumBytes / workload.spectrumBytesPerBin,
|
|
);
|
|
if (memoryBatchCapacity < 1) {
|
|
const minimumBytes =
|
|
workload.baseWorkingBytes + workload.spectrumBytesPerBin;
|
|
throw new RangeError(
|
|
`MUSIC requires at least ${minimumBytes.toLocaleString()} typed-array bytes; ` +
|
|
`maximumWorkingBytes is ${maximumWorkingBytes.toLocaleString()}.`,
|
|
);
|
|
}
|
|
const requestedBatchLimit =
|
|
options.maximumSpectrumBatchBins ?? Number.MAX_SAFE_INTEGER;
|
|
requirePositiveSafeInteger(
|
|
requestedBatchLimit,
|
|
"maximumSpectrumBatchBins",
|
|
);
|
|
const batchSize = Math.min(
|
|
workload.uniqueBinCount,
|
|
memoryBatchCapacity,
|
|
requestedBatchLimit,
|
|
);
|
|
const spectrumBatchCount = Math.ceil(
|
|
workload.uniqueBinCount / batchSize,
|
|
);
|
|
const fftButterflyCount = checkedProduct(
|
|
[spectrumBatchCount, workload.stftButterfliesPerBatch],
|
|
"MUSIC FFT butterfly count",
|
|
);
|
|
if (fftButterflyCount > maximumFftButterflies) {
|
|
throw new RangeError(
|
|
`MUSIC STFT requires ${fftButterflyCount.toLocaleString()} butterflies; ` +
|
|
`limit is ${maximumFftButterflies.toLocaleString()}.`,
|
|
);
|
|
}
|
|
return {
|
|
maximumWorkingBytes,
|
|
maximumCovarianceComplexAccumulations,
|
|
maximumProjectionComplexMacs,
|
|
maximumJacobiPairUpdates,
|
|
maximumFftButterflies,
|
|
memoryBatchCapacity,
|
|
batchSize,
|
|
spectrumBatchCount,
|
|
fftButterflyCount,
|
|
estimatedPeakWorkingBytes:
|
|
workload.baseWorkingBytes +
|
|
batchSize * workload.spectrumBytesPerBin,
|
|
};
|
|
}
|
|
|
|
function sourceCountFromEigensystem(configuration, audit, frameCount) {
|
|
if (configuration.sourceCount.method === "fixed") {
|
|
return {
|
|
method: "fixed",
|
|
status: "available",
|
|
value: configuration.sourceCount.value,
|
|
criterion: null,
|
|
score: null,
|
|
scores: null,
|
|
assumptions: {
|
|
userSpecified: true,
|
|
independentSnapshotsRequired: false,
|
|
},
|
|
};
|
|
}
|
|
const selection = selectWaxKailathSourceCount({
|
|
eigenvalues: audit.roundoffClampedEigenvalues,
|
|
snapshotCount: frameCount,
|
|
independentSnapshots:
|
|
true,
|
|
criterion: configuration.sourceCount.method,
|
|
roundoffTolerance: audit.roundoffTolerance,
|
|
});
|
|
if (!selection.available) {
|
|
throw new MusicUnavailableError(
|
|
selection.reason,
|
|
`MUSIC ${configuration.sourceCount.method.toUpperCase()} source-count selection ` +
|
|
`is unavailable: ${selection.reason}. No fallback or eigenvalue floor is applied.`,
|
|
{
|
|
sourceCountMethod:
|
|
configuration.sourceCount.method,
|
|
snapshotCount: frameCount,
|
|
},
|
|
);
|
|
}
|
|
if (selection.sourceCount === 0) {
|
|
throw new MusicUnavailableError(
|
|
"model-order-zero-no-source",
|
|
`MUSIC ${configuration.sourceCount.method.toUpperCase()} selected zero sources; ` +
|
|
"a source location is undefined, so no arbitrary minimum is returned.",
|
|
{
|
|
sourceCountMethod:
|
|
configuration.sourceCount.method,
|
|
snapshotCount: frameCount,
|
|
},
|
|
);
|
|
}
|
|
return {
|
|
method: selection.criterion,
|
|
status: "available",
|
|
value: selection.sourceCount,
|
|
criterion: selection.criterion,
|
|
score: selection.score,
|
|
scores: Array.from(selection.scores),
|
|
assumptions: {
|
|
userSpecified: false,
|
|
independentSnapshotsRequired: true,
|
|
nonOverlappingSnapshots: true,
|
|
partialFrames: "excluded",
|
|
statisticalSnapshotIndependence: {
|
|
assumed: true,
|
|
evidence:
|
|
"non-overlapping-frames-only-not-statistical-proof",
|
|
modelSelectedByUser: true,
|
|
},
|
|
statisticalDataModel: {
|
|
assumed: true,
|
|
verified: false,
|
|
assumptions: [
|
|
"iid-complex-gaussian-snapshots",
|
|
"spatially-white-noise",
|
|
"stationary-source-count-over-snapshots",
|
|
],
|
|
interpretation:
|
|
"wax-kailath-model-order-criterion-not-probability-or-confidence",
|
|
},
|
|
snapshotCount: selection.snapshotCount,
|
|
sensorCount: selection.sensorCount,
|
|
roundoffTolerance: selection.roundoffTolerance,
|
|
logRatioRoundoffClampCount:
|
|
selection.logRatioRoundoffClampCount,
|
|
},
|
|
};
|
|
}
|
|
|
|
function requireIdentifiablePartition(
|
|
eigenvalues,
|
|
sourceCount,
|
|
audit,
|
|
eigensystem,
|
|
) {
|
|
const signalEdge = eigenvalues[sourceCount - 1];
|
|
const noiseEdge = eigenvalues[sourceCount];
|
|
const partitionTolerance = Math.max(
|
|
audit.roundoffTolerance,
|
|
eigensystem.residualFrobeniusNorm,
|
|
);
|
|
const eigenvalueGap = signalEdge - noiseEdge;
|
|
if (!(eigenvalueGap > partitionTolerance)) {
|
|
throw new MusicUnavailableError(
|
|
"signal-noise-partition-unidentifiable",
|
|
`MUSIC signal/noise subspace is not identifiable for source count ${sourceCount}: ` +
|
|
`eigenvalue gap ${eigenvalueGap} is not above numerical audit bound ` +
|
|
`${partitionTolerance}. Coherent or rank-deficient sources are not repaired ` +
|
|
"with diagonal loading or spatial smoothing.",
|
|
{
|
|
sourceCount,
|
|
eigenvalueGap,
|
|
partitionTolerance,
|
|
},
|
|
);
|
|
}
|
|
return {
|
|
signalEdge,
|
|
noiseEdge,
|
|
eigenvalueGap,
|
|
partitionTolerance,
|
|
};
|
|
}
|
|
|
|
export async function computeMusicNoiseProjectionMap(options) {
|
|
const {
|
|
distances,
|
|
pointCount,
|
|
microphoneCount,
|
|
calibration,
|
|
eigenvectors,
|
|
sourceCount,
|
|
speedOfSound,
|
|
minimumDistance,
|
|
output = new Float64Array(pointCount),
|
|
steeringReal = new Float64Array(microphoneCount),
|
|
steeringImaginary = new Float64Array(microphoneCount),
|
|
pointBatchSize = 64,
|
|
signal,
|
|
} = options;
|
|
const steeringFrequencyHz =
|
|
options.steeringFrequencyHz ??
|
|
options.observedFrequencyHz;
|
|
requirePositiveSafeInteger(pointBatchSize, "pointBatchSize");
|
|
if (
|
|
!(distances instanceof Float64Array) ||
|
|
distances.length !== pointCount * microphoneCount
|
|
) {
|
|
throw new RangeError(
|
|
"MUSIC distances must be a point-major Float64 matrix.",
|
|
);
|
|
}
|
|
if (
|
|
!(output instanceof Float64Array) ||
|
|
output.length !== pointCount ||
|
|
!(steeringReal instanceof Float64Array) ||
|
|
steeringReal.length !== microphoneCount ||
|
|
!(steeringImaginary instanceof Float64Array) ||
|
|
steeringImaginary.length !== microphoneCount
|
|
) {
|
|
throw new RangeError(
|
|
"MUSIC output and steering workspaces must be Float64 and correctly sized.",
|
|
);
|
|
}
|
|
if (
|
|
eigenvectors?.layout !== "row-major-columns" ||
|
|
eigenvectors.real?.length !== microphoneCount * microphoneCount ||
|
|
eigenvectors.imaginary?.length !==
|
|
microphoneCount * microphoneCount
|
|
) {
|
|
throw new RangeError(
|
|
"MUSIC eigenvectors must use row-major-columns layout.",
|
|
);
|
|
}
|
|
if (
|
|
calibration?.manifoldGains?.length !== microphoneCount ||
|
|
calibration?.manifoldPhases?.length !== microphoneCount
|
|
) {
|
|
throw new RangeError(
|
|
"MUSIC manifold calibration must match microphone count.",
|
|
);
|
|
}
|
|
if (
|
|
!Number.isFinite(steeringFrequencyHz) ||
|
|
steeringFrequencyHz === 0
|
|
) {
|
|
throw new RangeError(
|
|
"steeringFrequencyHz must be finite and non-zero.",
|
|
);
|
|
}
|
|
requirePositiveFinite(speedOfSound, "speedOfSound");
|
|
requirePositiveFinite(minimumDistance, "minimumDistance");
|
|
if (
|
|
!Number.isSafeInteger(sourceCount) ||
|
|
sourceCount < 1 ||
|
|
sourceCount >= microphoneCount
|
|
) {
|
|
throw new RangeError(
|
|
"sourceCount must be inside 1..microphoneCount-1.",
|
|
);
|
|
}
|
|
const angularWaveNumber =
|
|
(TAU * steeringFrequencyHz) / speedOfSound;
|
|
for (
|
|
let pointIndex = 0;
|
|
pointIndex < pointCount;
|
|
pointIndex += 1
|
|
) {
|
|
throwIfAborted(signal);
|
|
const distanceOffset = pointIndex * microphoneCount;
|
|
const referenceDistance = distances[distanceOffset];
|
|
let steeringNormSquared = 0;
|
|
for (
|
|
let microphoneIndex = 0;
|
|
microphoneIndex < microphoneCount;
|
|
microphoneIndex += 1
|
|
) {
|
|
const distance = distances[distanceOffset + microphoneIndex];
|
|
const amplitude =
|
|
calibration.manifoldGains[microphoneIndex] /
|
|
Math.max(distance, minimumDistance);
|
|
const phase =
|
|
-angularWaveNumber * (distance - referenceDistance) +
|
|
calibration.manifoldPhases[microphoneIndex];
|
|
const valueReal = amplitude * Math.cos(phase);
|
|
const valueImaginary = amplitude * Math.sin(phase);
|
|
steeringReal[microphoneIndex] = valueReal;
|
|
steeringImaginary[microphoneIndex] = valueImaginary;
|
|
steeringNormSquared +=
|
|
valueReal * valueReal + valueImaginary * valueImaginary;
|
|
}
|
|
if (
|
|
!(steeringNormSquared > 0) ||
|
|
!Number.isFinite(steeringNormSquared)
|
|
) {
|
|
throw new RangeError(
|
|
`MUSIC steering norm is not finite and positive at scan point ${pointIndex}.`,
|
|
);
|
|
}
|
|
let noiseProjection = 0;
|
|
for (
|
|
let column = sourceCount;
|
|
column < microphoneCount;
|
|
column += 1
|
|
) {
|
|
let dotReal = 0;
|
|
let dotImaginary = 0;
|
|
for (
|
|
let microphoneIndex = 0;
|
|
microphoneIndex < microphoneCount;
|
|
microphoneIndex += 1
|
|
) {
|
|
const vectorIndex =
|
|
microphoneIndex * microphoneCount + column;
|
|
const vectorReal = eigenvectors.real[vectorIndex];
|
|
const vectorImaginary =
|
|
eigenvectors.imaginary[vectorIndex];
|
|
const valueReal = steeringReal[microphoneIndex];
|
|
const valueImaginary =
|
|
steeringImaginary[microphoneIndex];
|
|
dotReal +=
|
|
vectorReal * valueReal + vectorImaginary * valueImaginary;
|
|
dotImaginary +=
|
|
vectorReal * valueImaginary - vectorImaginary * valueReal;
|
|
}
|
|
noiseProjection +=
|
|
dotReal * dotReal + dotImaginary * dotImaginary;
|
|
}
|
|
const quotient = noiseProjection / steeringNormSquared;
|
|
if (!Number.isFinite(quotient) || quotient < 0) {
|
|
throw new RangeError(
|
|
`MUSIC noise projection is not finite and non-negative at scan point ${pointIndex}.`,
|
|
);
|
|
}
|
|
output[pointIndex] = quotient;
|
|
if (
|
|
(pointIndex + 1) % pointBatchSize === 0 &&
|
|
pointIndex + 1 < pointCount
|
|
) {
|
|
await cooperativeYield();
|
|
throwIfAborted(signal);
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function findMinimum(values) {
|
|
if (values.length === 0) {
|
|
throw new RangeError(
|
|
"cannot find a MUSIC minimum in an empty map.",
|
|
);
|
|
}
|
|
let index = 0;
|
|
let value = values[0];
|
|
let maximum = values[0];
|
|
for (let candidate = 1; candidate < values.length; candidate += 1) {
|
|
maximum = Math.max(maximum, values[candidate]);
|
|
if (values[candidate] < value) {
|
|
index = candidate;
|
|
value = values[candidate];
|
|
}
|
|
}
|
|
return {
|
|
index,
|
|
value,
|
|
flat: value === maximum,
|
|
};
|
|
}
|
|
|
|
function plainEigensolverDiagnostics(eigensystem) {
|
|
return {
|
|
solver: "full-cyclic-complex-hermitian-jacobi",
|
|
converged: eigensystem.converged,
|
|
sweeps: eigensystem.sweeps,
|
|
rotations: eigensystem.rotations,
|
|
scale: eigensystem.scale,
|
|
inputHermitianError: eigensystem.inputHermitianError,
|
|
inputHermitianTolerance: eigensystem.inputHermitianTolerance,
|
|
relativeTolerance: eigensystem.relativeTolerance,
|
|
offDiagonalFrobeniusNorm:
|
|
eigensystem.offDiagonalFrobeniusNorm,
|
|
relativeOffDiagonal: eigensystem.relativeOffDiagonal,
|
|
residualFrobeniusNorm: eigensystem.residualFrobeniusNorm,
|
|
relativeResidual: eigensystem.relativeResidual,
|
|
residualTolerance: eigensystem.residualTolerance,
|
|
orthogonalityError: eigensystem.orthogonalityError,
|
|
orthogonalityTolerance: eigensystem.orthogonalityTolerance,
|
|
numericallyVerified: eigensystem.numericallyVerified,
|
|
};
|
|
}
|
|
|
|
function calibrationFor(configuration, plan, hypothesis) {
|
|
if (plan) {
|
|
plan.statistics.calibrationCacheHits += 1;
|
|
return plan.calibrations.get(
|
|
hypothesis.hypothesisKey,
|
|
);
|
|
}
|
|
return createMusicBinCalibration({
|
|
arrayDefinition: configuration.arrayDefinition,
|
|
bin: hypothesis.bin,
|
|
sampleRate: configuration.sampleRate,
|
|
fftSize: configuration.fftSize,
|
|
analogFrequencyHz:
|
|
hypothesis.analogFrequencyHz,
|
|
signedAdcFrequencyHz:
|
|
hypothesis.signedAdcFrequencyHz,
|
|
spectralInversion:
|
|
hypothesis.spectralInversion,
|
|
compensateFrequencyResponse:
|
|
configuration.compensateFrequencyResponse,
|
|
maxCompensationDb: configuration.maxCompensationDb,
|
|
});
|
|
}
|
|
|
|
function analysisCacheSummary(plan) {
|
|
const statistics = plan.statistics;
|
|
return {
|
|
analysisAttempts: statistics.analysisAttempts,
|
|
completedAnalyses: statistics.completedAnalyses,
|
|
distanceMatrixBuilds: statistics.distanceMatrixBuilds,
|
|
avoidedDistanceMatrixBuilds:
|
|
statistics.avoidedDistanceMatrixBuilds,
|
|
distanceEvaluations: statistics.distanceEvaluations,
|
|
calibrationBuilds: statistics.calibrationBuilds,
|
|
calibrationCacheHits: statistics.calibrationCacheHits,
|
|
stftWorkspaceUses: statistics.stftWorkspaceUses,
|
|
avoidedStftWorkspaceAllocations:
|
|
statistics.avoidedStftWorkspaceAllocations,
|
|
stftTransforms: statistics.stftTransforms,
|
|
fftButterflies: statistics.fftButterflies,
|
|
covarianceBuilds: statistics.covarianceBuilds,
|
|
covarianceComplexAccumulations:
|
|
statistics.covarianceComplexAccumulations,
|
|
eigendecompositions: statistics.eigendecompositions,
|
|
jacobiSweeps: statistics.jacobiSweeps,
|
|
jacobiRotations: statistics.jacobiRotations,
|
|
jacobiPairUpdates: statistics.jacobiPairUpdates,
|
|
projectionComplexMacs: statistics.projectionComplexMacs,
|
|
mapWorkspaceUses: statistics.mapWorkspaceUses,
|
|
retainedBytes: statistics.retainedBytes,
|
|
maximumPlanBytes: plan.maximumPlanBytes,
|
|
};
|
|
}
|
|
|
|
function finalizeMusicBands(
|
|
hypothesisResults,
|
|
configuration,
|
|
) {
|
|
return configuration.experiment.frequencyBands.map(
|
|
(band, bandIndex) => {
|
|
const hypothesis =
|
|
configuration.bandHypotheses[bandIndex];
|
|
const bin = hypothesis.bin;
|
|
const result = hypothesisResults.get(
|
|
hypothesis.hypothesisKey,
|
|
);
|
|
const rawMap = Float64Array.from(result.map);
|
|
const displayMap = Float64Array.from(rawMap);
|
|
const minimum = findMinimum(rawMap);
|
|
return {
|
|
id: band.id,
|
|
color: band.color,
|
|
bins: [bin],
|
|
observedFrequencyHz: result.observedFrequencyHz,
|
|
analogFrequencyHz:
|
|
hypothesis.analogFrequencyHz,
|
|
signedAdcFrequencyHz:
|
|
hypothesis.signedAdcFrequencyHz,
|
|
spectralInversion:
|
|
hypothesis.spectralInversion,
|
|
aliasOrder: hypothesis.aliasOrder,
|
|
frequencyHypothesisCount: 1,
|
|
sharedObservationKey:
|
|
hypothesis.sharedObservationKey,
|
|
localizationModel:
|
|
"near-field-spherical-direct-path-configured-analog-hypothesis",
|
|
originalCarrierUsed: true,
|
|
requestedBandAliased: hypothesis.aliased,
|
|
aliasOrderResolution: hypothesis.aliased
|
|
? "configured-single-hypothesis"
|
|
: "not-applicable",
|
|
rawMap,
|
|
rawScale: "noise-projection",
|
|
displayMap,
|
|
displayScale: "noise-projection",
|
|
displayTransform: "identity",
|
|
objectiveDirection: "minimum",
|
|
identifiability: minimum.flat
|
|
? "spatially-unidentifiable-flat-objective"
|
|
: "non-flat-objective",
|
|
peak: minimum.flat
|
|
? null
|
|
: {
|
|
index: minimum.index,
|
|
position: scanIndexToPosition(
|
|
minimum.index,
|
|
configuration.experiment.scan,
|
|
),
|
|
rawValue: minimum.value,
|
|
rawNoiseProjection: minimum.value,
|
|
rawScale: "noise-projection",
|
|
objectiveDirection: "minimum",
|
|
},
|
|
sourceCount: result.sourceCount,
|
|
diagnostics: result.diagnostics,
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
async function computeMusicMapInternal(options, plan) {
|
|
const configuration = plan ?? resolveConfiguration(options);
|
|
const {
|
|
channels,
|
|
signal,
|
|
onProgress,
|
|
pointBatchSize = 64,
|
|
stftTransformBatchSize = 32,
|
|
} = options;
|
|
requirePositiveSafeInteger(pointBatchSize, "pointBatchSize");
|
|
requirePositiveSafeInteger(
|
|
stftTransformBatchSize,
|
|
"stftTransformBatchSize",
|
|
);
|
|
const workload = estimateMusicWorkload({
|
|
...options,
|
|
...(plan ? { analysisPlan: plan } : {}),
|
|
});
|
|
const limits = assertWorkLimits(workload, options);
|
|
const distances =
|
|
plan?.distances ??
|
|
createMusicDistanceMatrix(
|
|
configuration.arrayDefinition,
|
|
configuration.experiment.scan,
|
|
);
|
|
const steeringReal =
|
|
plan?.steeringReal ??
|
|
new Float64Array(configuration.microphoneCount);
|
|
const steeringImaginary =
|
|
plan?.steeringImaginary ??
|
|
new Float64Array(configuration.microphoneCount);
|
|
const mapWorkspace =
|
|
plan?.mapWorkspace ?? new Float64Array(configuration.pointCount);
|
|
const hypothesisResults = new Map();
|
|
let completedHypotheses = 0;
|
|
|
|
for (
|
|
let batchStart = 0;
|
|
batchStart < configuration.selectedBins.length;
|
|
batchStart += limits.batchSize
|
|
) {
|
|
throwIfAborted(signal);
|
|
const batchBins = configuration.selectedBins.slice(
|
|
batchStart,
|
|
batchStart + limits.batchSize,
|
|
);
|
|
if (plan) {
|
|
plan.statistics.stftWorkspaceUses += 1;
|
|
plan.statistics.avoidedStftWorkspaceAllocations = Math.max(
|
|
0,
|
|
plan.statistics.stftWorkspaceUses - 1,
|
|
);
|
|
}
|
|
const stft = await extractStftBins(channels, {
|
|
fftSize: configuration.fftSize,
|
|
hopSize: configuration.hopSize,
|
|
bins: batchBins,
|
|
signal,
|
|
transformBatchSize: stftTransformBatchSize,
|
|
workspace: plan?.stftWorkspace,
|
|
spectrumPrecision: "float64",
|
|
includePartialFrame:
|
|
configuration.sourceCount.method === "fixed",
|
|
onProgress: ({ completedTransforms, totalTransforms }) =>
|
|
onProgress?.({
|
|
stage: "stft",
|
|
completedBins: completedHypotheses,
|
|
totalBins:
|
|
configuration.analysisHypotheses.length,
|
|
completedTransforms,
|
|
totalTransforms,
|
|
spectrumBatch:
|
|
Math.floor(batchStart / limits.batchSize) + 1,
|
|
spectrumBatchCount: limits.spectrumBatchCount,
|
|
}),
|
|
});
|
|
if (plan) {
|
|
const transformCount =
|
|
stft.frameCount * stft.microphoneCount;
|
|
plan.statistics.stftTransforms += transformCount;
|
|
plan.statistics.fftButterflies +=
|
|
transformCount *
|
|
(configuration.fftSize / 2) *
|
|
Math.log2(configuration.fftSize);
|
|
}
|
|
await cooperativeYield();
|
|
throwIfAborted(signal);
|
|
|
|
for (const bin of batchBins) {
|
|
throwIfAborted(signal);
|
|
const rawSpectrum = stft.spectra.get(bin);
|
|
const hypotheses =
|
|
configuration.hypothesesByBin.get(bin) ?? [];
|
|
for (const hypothesis of hypotheses) {
|
|
throwIfAborted(signal);
|
|
const calibration = calibrationFor(
|
|
configuration,
|
|
plan,
|
|
hypothesis,
|
|
);
|
|
const spectrum = applyMusicBinCalibration(
|
|
{
|
|
real: Float64Array.from(rawSpectrum.real),
|
|
imaginary: Float64Array.from(
|
|
rawSpectrum.imaginary,
|
|
),
|
|
},
|
|
stft.frameCount,
|
|
stft.microphoneCount,
|
|
calibration,
|
|
);
|
|
onProgress?.({
|
|
stage: "music-covariance",
|
|
completedBins: completedHypotheses,
|
|
totalBins:
|
|
configuration.analysisHypotheses.length,
|
|
});
|
|
const covariance = computeComplexSampleCovariance({
|
|
real: spectrum.real,
|
|
imaginary: spectrum.imaginary,
|
|
sensorCount: configuration.microphoneCount,
|
|
snapshotCount: stft.frameCount,
|
|
layout: "snapshot-major",
|
|
maximumWorkBytes: limits.maximumWorkingBytes,
|
|
maximumComplexAccumulations:
|
|
limits.maximumCovarianceComplexAccumulations,
|
|
});
|
|
if (plan) {
|
|
plan.statistics.covarianceBuilds += 1;
|
|
plan.statistics.covarianceComplexAccumulations +=
|
|
covariance.workload.complexAccumulations;
|
|
}
|
|
throwIfAborted(signal);
|
|
onProgress?.({
|
|
stage: "music-eigendecomposition",
|
|
completedBins: completedHypotheses,
|
|
totalBins:
|
|
configuration.analysisHypotheses.length,
|
|
});
|
|
const eigensystem = decomposeHermitian({
|
|
real: covariance.matrix.real,
|
|
imaginary: covariance.matrix.imaginary,
|
|
order: configuration.microphoneCount,
|
|
maximumSweeps:
|
|
options.maximumJacobiSweeps ??
|
|
DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS,
|
|
maximumWorkBytes: limits.maximumWorkingBytes,
|
|
maximumPairUpdates:
|
|
limits.maximumJacobiPairUpdates,
|
|
});
|
|
if (!eigensystem.converged) {
|
|
throw new RangeError(
|
|
"MUSIC Hermitian eigendecomposition did not converge after " +
|
|
`${eigensystem.sweeps} sweeps; no partial subspace is returned.`,
|
|
);
|
|
}
|
|
if (!eigensystem.numericallyVerified) {
|
|
throw new RangeError(
|
|
"MUSIC Hermitian eigendecomposition failed residual or orthogonality " +
|
|
"verification; no unverified subspace is returned.",
|
|
);
|
|
}
|
|
if (plan) {
|
|
plan.statistics.eigendecompositions += 1;
|
|
plan.statistics.jacobiSweeps +=
|
|
eigensystem.sweeps;
|
|
plan.statistics.jacobiRotations +=
|
|
eigensystem.rotations;
|
|
plan.statistics.jacobiPairUpdates +=
|
|
eigensystem.rotations *
|
|
eigensystem.workload.pairUpdatesPerRotation;
|
|
}
|
|
const audit = auditCovarianceEigenvalues({
|
|
eigenvalues: eigensystem.eigenvalues,
|
|
matrixScale: covariance.matrixScale,
|
|
sensorCount: configuration.microphoneCount,
|
|
covarianceRoundoffBound:
|
|
covariance.covarianceRoundoffBound,
|
|
eigensolverResidualFrobeniusNorm:
|
|
eigensystem.residualFrobeniusNorm,
|
|
});
|
|
const sourceCount = sourceCountFromEigensystem(
|
|
configuration,
|
|
audit,
|
|
stft.frameCount,
|
|
);
|
|
const partition = requireIdentifiablePartition(
|
|
audit.roundoffClampedEigenvalues,
|
|
sourceCount.value,
|
|
audit,
|
|
eigensystem,
|
|
);
|
|
const map = await computeMusicNoiseProjectionMap({
|
|
distances,
|
|
pointCount: configuration.pointCount,
|
|
microphoneCount: configuration.microphoneCount,
|
|
calibration,
|
|
eigenvectors: eigensystem.eigenvectors,
|
|
sourceCount: sourceCount.value,
|
|
steeringFrequencyHz:
|
|
hypothesis.orientationSign *
|
|
hypothesis.analogFrequencyHz,
|
|
speedOfSound: configuration.scene.speedOfSound,
|
|
minimumDistance: configuration.minimumDistance,
|
|
output: mapWorkspace,
|
|
steeringReal,
|
|
steeringImaginary,
|
|
pointBatchSize,
|
|
signal,
|
|
});
|
|
const projectionComplexMacs =
|
|
configuration.pointCount *
|
|
configuration.microphoneCount *
|
|
(
|
|
configuration.microphoneCount -
|
|
sourceCount.value
|
|
);
|
|
if (plan) {
|
|
plan.statistics.projectionComplexMacs +=
|
|
projectionComplexMacs;
|
|
plan.statistics.mapWorkspaceUses += 1;
|
|
}
|
|
hypothesisResults.set(hypothesis.hypothesisKey, {
|
|
observedFrequencyHz:
|
|
hypothesis.observedFrequencyHz,
|
|
map: Float64Array.from(map),
|
|
sourceCount,
|
|
diagnostics: {
|
|
frequencyHypothesis: {
|
|
model:
|
|
"configured-single-analog-fft-grid-hypothesis",
|
|
hypothesisCount: 1,
|
|
analogFrequencyHz:
|
|
hypothesis.analogFrequencyHz,
|
|
observedFrequencyHz:
|
|
hypothesis.observedFrequencyHz,
|
|
signedAdcFrequencyHz:
|
|
hypothesis.signedAdcFrequencyHz,
|
|
spectralInversion:
|
|
hypothesis.spectralInversion,
|
|
aliasOrder: hypothesis.aliasOrder,
|
|
sharedObservationKey:
|
|
hypothesis.sharedObservationKey,
|
|
},
|
|
snapshotCount: stft.frameCount,
|
|
covariance: {
|
|
model: "sample-xxh-no-mean",
|
|
normalization: covariance.normalization,
|
|
centered: covariance.centered,
|
|
inputLayout: covariance.inputLayout,
|
|
matrixScale: covariance.matrixScale,
|
|
trace: covariance.trace,
|
|
covarianceRoundoffBound:
|
|
covariance.covarianceRoundoffBound,
|
|
},
|
|
eigenvalues: Array.from(
|
|
eigensystem.eigenvalues,
|
|
),
|
|
auditedEigenvalues: Array.from(
|
|
audit.roundoffClampedEigenvalues,
|
|
),
|
|
covarianceAudit: {
|
|
roundoffTolerance: audit.roundoffTolerance,
|
|
roundoffFactor: audit.roundoffFactor,
|
|
eigenvalueScale: audit.eigenvalueScale,
|
|
minimumEigenvalue: audit.minimumEigenvalue,
|
|
isPositiveSemidefinite:
|
|
audit.isPositiveSemidefinite,
|
|
clampedNegativeCount:
|
|
audit.clampedNegativeCount,
|
|
roundoffDerivation:
|
|
audit.roundoffDerivation,
|
|
covarianceRoundoffBound:
|
|
audit.covarianceRoundoffBound,
|
|
eigensolverResidualFrobeniusNorm:
|
|
audit.eigensolverResidualFrobeniusNorm,
|
|
},
|
|
eigensolver:
|
|
plainEigensolverDiagnostics(eigensystem),
|
|
partition,
|
|
sourceCount,
|
|
projectionComplexMacs,
|
|
},
|
|
});
|
|
completedHypotheses += 1;
|
|
onProgress?.({
|
|
stage: "localization",
|
|
completedBins: completedHypotheses,
|
|
totalBins:
|
|
configuration.analysisHypotheses.length,
|
|
});
|
|
await cooperativeYield();
|
|
}
|
|
}
|
|
}
|
|
|
|
const bands = finalizeMusicBands(
|
|
hypothesisResults,
|
|
configuration,
|
|
);
|
|
return {
|
|
algorithm: "music",
|
|
backend: "cpu",
|
|
frameCount: workload.frameCount,
|
|
microphoneCount: workload.microphoneCount,
|
|
pointCount: workload.pointCount,
|
|
workload: { ...workload, ...limits },
|
|
fftButterflyCount: limits.fftButterflyCount,
|
|
objectiveDirection: "minimum",
|
|
rawScale: "noise-projection",
|
|
model: {
|
|
publicationFamily: "Schmidt narrowband MUSIC",
|
|
covariance: "sample-xxh-no-mean",
|
|
manifold:
|
|
"near-field-spherical-direct-path-configured-analog-hypothesis",
|
|
propagationPaths: "direct-only",
|
|
localizationFrequency:
|
|
"configured-single-analog-fft-grid-hypothesis-with-adc-inversion",
|
|
hypothesisSource:
|
|
"experiment-band-not-scene-truth",
|
|
originalCarrierUsed: true,
|
|
aliasOrderResolution:
|
|
"configured-single-hypothesis-no-selection",
|
|
multibandFusion:
|
|
"none-each-band-is-separate-single-analog-hypothesis",
|
|
spatialSmoothing: "none",
|
|
diagonalLoading: "none",
|
|
eigenvalueFloor: "none",
|
|
normalizationApplied: "none-raw-noise-projection-preserved",
|
|
requestedNormalization:
|
|
configuration.experiment.beamforming.normalization,
|
|
distanceAmplitude:
|
|
"inverse-distance-with-explicit-minimum-distance",
|
|
minimumDistance: configuration.minimumDistance,
|
|
microphoneCalibration: {
|
|
sensitivity: "exact-inverse",
|
|
phaseOffset: "exact-inverse",
|
|
frequencyResponse:
|
|
configuration.compensateFrequencyResponse
|
|
? "analog-hypothesis-inverse-phase-capped-inverse-gain-residual-in-manifold"
|
|
: "uncompensated-retained-in-manifold",
|
|
frequencyResponseEvaluation:
|
|
"configured-analog-fft-grid-hypothesis",
|
|
spectralInversion:
|
|
"positive-bin-full-complex-conjugation",
|
|
compensateFrequencyResponse:
|
|
configuration.compensateFrequencyResponse,
|
|
maxCompensationDb: configuration.maxCompensationDb,
|
|
},
|
|
},
|
|
bands,
|
|
...(plan ? { analysisCache: analysisCacheSummary(plan) } : {}),
|
|
};
|
|
}
|
|
|
|
export async function computeMusicMap(options) {
|
|
const plan = options.analysisPlan;
|
|
if (!plan) return computeMusicMapInternal(options, undefined);
|
|
assertCompatibleAnalysisPlan(plan, options);
|
|
if (plan.busy) {
|
|
throw new Error(
|
|
"analysisPlan is already in use by another MUSIC analysis.",
|
|
);
|
|
}
|
|
plan.busy = true;
|
|
plan.statistics.analysisAttempts += 1;
|
|
if (plan.statistics.analysisAttempts > 1) {
|
|
plan.statistics.avoidedDistanceMatrixBuilds += 1;
|
|
}
|
|
try {
|
|
const result = await computeMusicMapInternal(options, plan);
|
|
plan.statistics.completedAnalyses += 1;
|
|
result.analysisCache = analysisCacheSummary(plan);
|
|
return result;
|
|
} finally {
|
|
plan.busy = false;
|
|
}
|
|
}
|