Files

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;
}
}