feat: add physically explicit narrowband MUSIC
This commit is contained in:
@@ -29,7 +29,7 @@ Tone과 band의 검증 범위는 0–1 MHz다. 이는 Nyquist cutoff가 아니
|
||||
|
||||
## 위치 추정 알고리즘
|
||||
|
||||
`localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 세 경로를
|
||||
`localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 네 경로를
|
||||
공통 interface로 제공합니다.
|
||||
|
||||
| `algorithm` | 계산 | raw scale | backend |
|
||||
@@ -37,6 +37,7 @@ Tone과 band의 검증 범위는 0–1 MHz다. 이는 Nyquist cutoff가 아니
|
||||
| `das` | conventional frequency-domain delay-and-sum | `power` | CPU, WebGPU |
|
||||
| `das-phat` | 채널 spectrum을 phase-only로 만든 DAS | `score` | CPU, WebGPU |
|
||||
| `gcc-phat` | 하나의 reference mic와 나머지 mic의 GCC-PHAT 지연 scan | `score` | CPU only |
|
||||
| `music` | Schmidt narrowband MUSIC의 근거리 spherical noise-subspace projection | `noise-projection` | CPU only |
|
||||
|
||||
Portable API에서는 top-level `algorithm`으로 선택합니다. 생략하면 기존과 같은 `das`이며, 낮은
|
||||
수준의 generic API는 `experiment.beamforming.algorithm`을 사용합니다.
|
||||
@@ -49,7 +50,7 @@ import {
|
||||
|
||||
const plan = createLocalizationPlan({
|
||||
...commonConfig, // sampleRate, array, scan, bands, stft
|
||||
algorithm: "gcc-phat", // "das" | "das-phat" | "gcc-phat"
|
||||
algorithm: "gcc-phat", // "das" | "das-phat" | "gcc-phat" | "music"
|
||||
phatFloorRatio: 1e-6,
|
||||
});
|
||||
|
||||
@@ -75,6 +76,61 @@ band에 선택된 bin이 8개 미만이면 코어는 계산을 허용하지만
|
||||
GCC-PHAT score는 pair 수, band coefficient 수와 IFFT 크기로 정규화한 0–1 양의 상관 응답이라
|
||||
scan 범위와 마이크 수에 의한 단순 재척도화는 피하지만, 여전히 확률로 보정된 confidence는 아닙니다.
|
||||
|
||||
### Narrowband MUSIC
|
||||
|
||||
`music`은 [Schmidt MUSIC](https://doi.org/10.1109/TAP.1986.1143830)을 sample covariance와
|
||||
근거리 구면파 direct-path manifold에 적용합니다. 근거리 배열 처리의 문헌 배경은
|
||||
[Huang–Barkat](https://doi.org/10.1109/8.86917)을 참고하세요. raw map은 reciprocal을 취하지
|
||||
않은 Float64 noise projection
|
||||
|
||||
`q(r)=Σ_(j=K)^(M-1)|v_jᴴa(r)|²/(a(r)ᴴa(r))`
|
||||
|
||||
이고 objective는 minimum입니다. 표준 pseudospectrum `P=1/q`와 표시용 `P/Pmax`는 위치
|
||||
확률·likelihood·confidence·신뢰구간이 아닙니다. raw `q`는 peak normalization이나 epsilon
|
||||
floor로 바꾸지 않습니다.
|
||||
|
||||
각 Experiment band는 FFT spacing에 놓인 **아날로그 carrier 가설 하나**만 포함해야 합니다.
|
||||
관측 ADC bin이 같아도 alias order가 다른 아날로그 가설은 별도 band입니다. Nyquist 위 값은
|
||||
허용하며 signed alias가 음수이면 positive-bin STFT, mic calibration과 전체 complex manifold를
|
||||
켤레 방향으로 사용합니다. 공간 위상은 `exp[-j sign(f_signed) 2π f_a Δr/c]`로 analog carrier
|
||||
크기를 유지하고 signed ADC alias는 orientation만 정합니다. 이 carrier는 Experiment에서 온 가설이고 Scene source truth는
|
||||
localizer에 전달되지 않습니다. 정확한 DC/Nyquist bin은 quadrature-degenerate라 거부합니다.
|
||||
|
||||
source 수는 `musicSourceCountMethod: "fixed"`와 `musicSourceCount: K` 또는
|
||||
[Wax–Kailath](https://doi.org/10.1109/TASSP.1985.1164557) `"aic"`/`"mdl"`로 정합니다.
|
||||
AIC/MDL은 `hopSize === fftSize`, 완전한 frame만 사용, `T>M`, roundoff보다 큰 양의 noise
|
||||
eigenvalue가 필요합니다. IID complex Gaussian snapshot, spatially white noise와 일정한 source
|
||||
수를 가정하지만 실제 데이터에서 이를 검증하지 않으며 score는 posterior가 아닙니다. 조건이
|
||||
깨지거나 signal/noise eigengap이 수치 오차보다 작으면 `MusicUnavailableError`
|
||||
(`code: "MUSIC_UNAVAILABLE"`)를 반환합니다.
|
||||
|
||||
동일 주파수의 coherent source는 covariance rank를 잃을 수 있습니다. grating lobe, 동률과 flat
|
||||
objective를 고르기 좋게 깨지 않으며, diagonal loading, eigenvalue floor, spatial smoothing,
|
||||
band 간 association/fusion을 적용하지 않습니다. [Wax–Shan–Kailath broadband 방법](https://doi.org/10.1109/TASSP.1984.1164400)은
|
||||
문헌상 별도 알고리즘이며 현재 구현하지 않았습니다.
|
||||
|
||||
빠른 portable 예:
|
||||
|
||||
```js
|
||||
const plan = createLocalizationPlan({
|
||||
sampleRate: 48_000,
|
||||
array,
|
||||
scan,
|
||||
bands: [{ id: "1k", minHz: 950, maxHz: 1_050, color: "#65f2bf" }],
|
||||
stft: { fftSize: 512, hopSize: 256 },
|
||||
algorithm: "music",
|
||||
normalization: "none",
|
||||
musicSourceCountMethod: "fixed",
|
||||
musicSourceCount: 1,
|
||||
});
|
||||
const result = await localizeMultichannel({ plan, channels });
|
||||
```
|
||||
|
||||
band는 정확히 한 FFT-grid center를 포함해야 하므로 `minHz/maxHz`는 사용 중인 `Fs/N`에서
|
||||
하나의 center만 감싸야 합니다. CPU plan/working-set, covariance accumulation, projection MAC,
|
||||
Jacobi pair-update와 FFT butterfly 상한은 할당·계산 전에 검사합니다. 기본 상한을 넘으면 부분
|
||||
eigensystem이나 축소 map을 반환하지 않습니다.
|
||||
|
||||
## 실측 다채널 스트림 위치 추정
|
||||
|
||||
`realtime-localizer.js`는 Scene·음원 합성 없이 이미 sampling된 동기식 마이크 채널을 직접
|
||||
@@ -83,8 +139,8 @@ scan 범위와 마이크 수에 의한 단순 재척도화는 피하지만, 여
|
||||
각 chunk는 동일 길이의 `Float32Array` 목록이어야 합니다.
|
||||
|
||||
`StreamingLocalizer`는 알고리즘 중립 class 이름입니다. 기존 `StreamingDasLocalizer`와 같은 class의
|
||||
export alias이므로 오래된 import도 그대로 동작하며, `createStreamingLocalizer()` factory도 세
|
||||
알고리즘을 모두 지원합니다.
|
||||
export alias이므로 오래된 import도 그대로 동작하며, `createStreamingLocalizer()` factory도 네
|
||||
알고리즘을 모두 지원합니다. MUSIC plan은 `plan.musicPlan` alias로도 접근할 수 있습니다.
|
||||
|
||||
```js
|
||||
import { createStreamingLocalizer } from "./packages/acoustics-core/realtime-localizer.js";
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT;
|
||||
|
||||
export const DEFAULT_MAX_COVARIANCE_WORK_BYTES = 32 * 1024 * 1024;
|
||||
export const DEFAULT_MAX_COVARIANCE_COMPLEX_ACCUMULATIONS = 250_000_000;
|
||||
export const DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR = 64;
|
||||
|
||||
function assertPositiveInteger(value, label) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new TypeError(`${label} must be a positive safe integer.`);
|
||||
}
|
||||
}
|
||||
|
||||
function checkedProduct(factors, label) {
|
||||
let product = 1;
|
||||
for (const factor of factors) {
|
||||
if (factor !== 0 && product > Number.MAX_SAFE_INTEGER / factor) {
|
||||
throw new RangeError(`${label} exceeds the safe integer range.`);
|
||||
}
|
||||
product *= factor;
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
function roundoffGamma(operationCount) {
|
||||
const product = operationCount * (Number.EPSILON / 2);
|
||||
if (!(product < 1)) {
|
||||
throw new RangeError("roundoff bound is undefined for this workload.");
|
||||
}
|
||||
return product / (1 - product);
|
||||
}
|
||||
|
||||
/** Estimate covariance output allocation and upper-triangle accumulation work. */
|
||||
export function estimateComplexCovarianceWorkload({ sensorCount, snapshotCount }) {
|
||||
assertPositiveInteger(sensorCount, "sensorCount");
|
||||
assertPositiveInteger(snapshotCount, "snapshotCount");
|
||||
const inputElementCount = checkedProduct(
|
||||
[sensorCount, snapshotCount],
|
||||
"covariance input element count",
|
||||
);
|
||||
const matrixElementCount = checkedProduct(
|
||||
[sensorCount, sensorCount],
|
||||
"covariance matrix element count",
|
||||
);
|
||||
const upperTriangleElementCount =
|
||||
checkedProduct([sensorCount, sensorCount + 1], "covariance upper triangle") / 2;
|
||||
const complexAccumulations = checkedProduct(
|
||||
[upperTriangleElementCount, snapshotCount],
|
||||
"covariance accumulation count",
|
||||
);
|
||||
const allocationBytes = checkedProduct(
|
||||
[2, matrixElementCount, FLOAT64_BYTES],
|
||||
"covariance allocation",
|
||||
);
|
||||
return Object.freeze({
|
||||
sensorCount,
|
||||
snapshotCount,
|
||||
inputElementCount,
|
||||
matrixElementCount,
|
||||
upperTriangleElementCount,
|
||||
complexAccumulations,
|
||||
allocationBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function compensatedAdd(sum, compensation, value) {
|
||||
const next = sum + value;
|
||||
const correction = Math.abs(sum) >= Math.abs(value)
|
||||
? (sum - next) + value
|
||||
: (value - next) + sum;
|
||||
return [next, compensation + correction];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute R = (1/T) sum_t x[t] x[t]^H in Float64.
|
||||
*
|
||||
* `snapshot-major` stores x(t,m) at `t * sensorCount + m`; `sensor-major`
|
||||
* stores it at `m * snapshotCount + t`. Only the upper triangle is
|
||||
* accumulated, with Neumaier compensation, and the lower triangle is filled
|
||||
* as its exact complex conjugate. No centering or diagonal loading is applied.
|
||||
*/
|
||||
export function computeComplexSampleCovariance({
|
||||
real,
|
||||
imaginary,
|
||||
sensorCount,
|
||||
snapshotCount,
|
||||
layout = "snapshot-major",
|
||||
maximumWorkBytes = DEFAULT_MAX_COVARIANCE_WORK_BYTES,
|
||||
maximumComplexAccumulations = DEFAULT_MAX_COVARIANCE_COMPLEX_ACCUMULATIONS,
|
||||
}) {
|
||||
const workload = estimateComplexCovarianceWorkload({ sensorCount, snapshotCount });
|
||||
if (layout !== "snapshot-major" && layout !== "sensor-major") {
|
||||
throw new RangeError("layout must be 'snapshot-major' or 'sensor-major'.");
|
||||
}
|
||||
if (real == null || imaginary == null) {
|
||||
throw new TypeError("real and imaginary snapshots are required.");
|
||||
}
|
||||
if (
|
||||
real.length !== workload.inputElementCount ||
|
||||
imaginary.length !== workload.inputElementCount
|
||||
) {
|
||||
throw new RangeError(
|
||||
`snapshot components must each contain ${workload.inputElementCount} values.`,
|
||||
);
|
||||
}
|
||||
if (!(maximumWorkBytes >= 0) || !Number.isFinite(maximumWorkBytes)) {
|
||||
throw new TypeError("maximumWorkBytes must be finite and nonnegative.");
|
||||
}
|
||||
if (
|
||||
!(maximumComplexAccumulations >= 0) ||
|
||||
!Number.isFinite(maximumComplexAccumulations)
|
||||
) {
|
||||
throw new TypeError("maximumComplexAccumulations must be finite and nonnegative.");
|
||||
}
|
||||
if (workload.allocationBytes > maximumWorkBytes) {
|
||||
throw new RangeError(
|
||||
`covariance requires ${workload.allocationBytes} typed-array bytes; limit is ${maximumWorkBytes}.`,
|
||||
);
|
||||
}
|
||||
if (workload.complexAccumulations > maximumComplexAccumulations) {
|
||||
throw new RangeError(
|
||||
`covariance requires ${workload.complexAccumulations} complex accumulations; limit is ${maximumComplexAccumulations}.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < workload.inputElementCount; index += 1) {
|
||||
if (!Number.isFinite(real[index]) || !Number.isFinite(imaginary[index])) {
|
||||
throw new TypeError("snapshot components must be finite.");
|
||||
}
|
||||
}
|
||||
|
||||
const matrixReal = new Float64Array(workload.matrixElementCount);
|
||||
const matrixImaginary = new Float64Array(workload.matrixElementCount);
|
||||
const sampleIndex = layout === "snapshot-major"
|
||||
? (sensor, snapshot) => snapshot * sensorCount + sensor
|
||||
: (sensor, snapshot) => sensor * snapshotCount + snapshot;
|
||||
let matrixScale = 0;
|
||||
let traceSum = 0;
|
||||
let traceCompensation = 0;
|
||||
|
||||
for (let row = 0; row < sensorCount; row += 1) {
|
||||
for (let column = row; column < sensorCount; column += 1) {
|
||||
let sumReal = 0;
|
||||
let compensationReal = 0;
|
||||
let sumImaginary = 0;
|
||||
let compensationImaginary = 0;
|
||||
for (let snapshot = 0; snapshot < snapshotCount; snapshot += 1) {
|
||||
const rowIndex = sampleIndex(row, snapshot);
|
||||
const columnIndex = sampleIndex(column, snapshot);
|
||||
const rowReal = real[rowIndex];
|
||||
const rowImaginary = imaginary[rowIndex];
|
||||
const columnReal = real[columnIndex];
|
||||
const columnImaginary = imaginary[columnIndex];
|
||||
const productReal =
|
||||
rowReal * columnReal + rowImaginary * columnImaginary;
|
||||
const productImaginary =
|
||||
rowImaginary * columnReal - rowReal * columnImaginary;
|
||||
if (!Number.isFinite(productReal) || !Number.isFinite(productImaginary)) {
|
||||
throw new RangeError("covariance product exceeds the finite Float64 range.");
|
||||
}
|
||||
[sumReal, compensationReal] = compensatedAdd(
|
||||
sumReal,
|
||||
compensationReal,
|
||||
productReal,
|
||||
);
|
||||
[sumImaginary, compensationImaginary] = compensatedAdd(
|
||||
sumImaginary,
|
||||
compensationImaginary,
|
||||
productImaginary,
|
||||
);
|
||||
}
|
||||
const valueReal = (sumReal + compensationReal) / snapshotCount;
|
||||
const valueImaginary = row === column
|
||||
? 0
|
||||
: (sumImaginary + compensationImaginary) / snapshotCount;
|
||||
if (!Number.isFinite(valueReal) || !Number.isFinite(valueImaginary)) {
|
||||
throw new RangeError("covariance value exceeds the finite Float64 range.");
|
||||
}
|
||||
const upper = row * sensorCount + column;
|
||||
const lower = column * sensorCount + row;
|
||||
matrixReal[upper] = valueReal;
|
||||
matrixImaginary[upper] = valueImaginary;
|
||||
matrixReal[lower] = valueReal;
|
||||
matrixImaginary[lower] = row === column ? 0 : -valueImaginary;
|
||||
matrixScale = Math.max(
|
||||
matrixScale,
|
||||
Math.abs(valueReal),
|
||||
Math.abs(valueImaginary),
|
||||
);
|
||||
if (row === column) {
|
||||
[traceSum, traceCompensation] = compensatedAdd(
|
||||
traceSum,
|
||||
traceCompensation,
|
||||
valueReal,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trace = traceSum + traceCompensation;
|
||||
const covarianceRoundoffBound =
|
||||
roundoffGamma(4 * snapshotCount + 32) * trace;
|
||||
|
||||
return {
|
||||
sensorCount,
|
||||
snapshotCount,
|
||||
inputLayout: layout,
|
||||
normalization: "1/T",
|
||||
centered: false,
|
||||
matrix: {
|
||||
real: matrixReal,
|
||||
imaginary: matrixImaginary,
|
||||
layout: "row-major",
|
||||
},
|
||||
matrixScale,
|
||||
trace,
|
||||
covarianceRoundoffBound,
|
||||
workload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit covariance eigenvalues against a scale-aware backward-roundoff bound.
|
||||
* Strictly positive values are never floored. Only negative values within the
|
||||
* bound are represented as zero in `roundoffClampedEigenvalues`; raw values
|
||||
* are always retained separately.
|
||||
*/
|
||||
export function auditCovarianceEigenvalues({
|
||||
eigenvalues,
|
||||
sensorCount = eigenvalues?.length,
|
||||
matrixScale,
|
||||
roundoffTolerance,
|
||||
covarianceRoundoffBound,
|
||||
eigensolverResidualFrobeniusNorm = 0,
|
||||
roundoffFactor = DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR,
|
||||
rejectIndefinite = true,
|
||||
}) {
|
||||
if (eigenvalues == null) throw new TypeError("eigenvalues are required.");
|
||||
assertPositiveInteger(sensorCount, "sensorCount");
|
||||
if (eigenvalues.length !== sensorCount) {
|
||||
throw new RangeError("eigenvalue count must equal sensorCount.");
|
||||
}
|
||||
if (!(roundoffFactor > 0) || !Number.isFinite(roundoffFactor)) {
|
||||
throw new TypeError("roundoffFactor must be finite and positive.");
|
||||
}
|
||||
if (matrixScale !== undefined && (!(matrixScale >= 0) || !Number.isFinite(matrixScale))) {
|
||||
throw new TypeError("matrixScale must be finite and nonnegative.");
|
||||
}
|
||||
if (
|
||||
covarianceRoundoffBound !== undefined &&
|
||||
(!(covarianceRoundoffBound >= 0) || !Number.isFinite(covarianceRoundoffBound))
|
||||
) {
|
||||
throw new TypeError("covarianceRoundoffBound must be finite and nonnegative.");
|
||||
}
|
||||
if (
|
||||
!(eigensolverResidualFrobeniusNorm >= 0) ||
|
||||
!Number.isFinite(eigensolverResidualFrobeniusNorm)
|
||||
) {
|
||||
throw new TypeError(
|
||||
"eigensolverResidualFrobeniusNorm must be finite and nonnegative.",
|
||||
);
|
||||
}
|
||||
|
||||
let eigenvalueScale = matrixScale ?? 0;
|
||||
let minimumEigenvalue = Infinity;
|
||||
for (const value of eigenvalues) {
|
||||
if (!Number.isFinite(value)) throw new TypeError("eigenvalues must be finite.");
|
||||
eigenvalueScale = Math.max(eigenvalueScale, Math.abs(value));
|
||||
minimumEigenvalue = Math.min(minimumEigenvalue, value);
|
||||
}
|
||||
const scaleDerivedRoundoffTolerance = eigenvalueScale === 0
|
||||
? 0
|
||||
: roundoffFactor * Number.EPSILON * Math.max(1, sensorCount) * eigenvalueScale;
|
||||
const effectiveRoundoffTolerance = roundoffTolerance ?? (
|
||||
covarianceRoundoffBound === undefined
|
||||
? scaleDerivedRoundoffTolerance
|
||||
: covarianceRoundoffBound + eigensolverResidualFrobeniusNorm
|
||||
);
|
||||
if (
|
||||
!(effectiveRoundoffTolerance >= 0) ||
|
||||
!Number.isFinite(effectiveRoundoffTolerance)
|
||||
) {
|
||||
throw new TypeError("roundoffTolerance must be finite and nonnegative.");
|
||||
}
|
||||
|
||||
const rawEigenvalues = Float64Array.from(eigenvalues);
|
||||
const roundoffClampedEigenvalues = Float64Array.from(eigenvalues);
|
||||
let clampedNegativeCount = 0;
|
||||
let firstIndefiniteIndex = -1;
|
||||
for (let index = 0; index < sensorCount; index += 1) {
|
||||
const value = rawEigenvalues[index];
|
||||
if (value < -effectiveRoundoffTolerance) {
|
||||
if (firstIndefiniteIndex < 0) firstIndefiniteIndex = index;
|
||||
} else if (value < 0 || Object.is(value, -0)) {
|
||||
roundoffClampedEigenvalues[index] = 0;
|
||||
clampedNegativeCount += 1;
|
||||
}
|
||||
}
|
||||
const isPositiveSemidefinite = firstIndefiniteIndex < 0;
|
||||
if (!isPositiveSemidefinite && rejectIndefinite) {
|
||||
const error = new RangeError(
|
||||
`covariance is genuinely indefinite: eigenvalue ${rawEigenvalues[firstIndefiniteIndex]} is below -${effectiveRoundoffTolerance}.`,
|
||||
);
|
||||
error.eigenvalueIndex = firstIndefiniteIndex;
|
||||
error.eigenvalue = rawEigenvalues[firstIndefiniteIndex];
|
||||
error.roundoffTolerance = effectiveRoundoffTolerance;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
rawEigenvalues,
|
||||
roundoffClampedEigenvalues,
|
||||
roundoffTolerance: effectiveRoundoffTolerance,
|
||||
roundoffFactor,
|
||||
roundoffDerivation: roundoffTolerance !== undefined
|
||||
? "explicit"
|
||||
: covarianceRoundoffBound === undefined
|
||||
? "eigenvalue-scale"
|
||||
: "covariance-accumulation-plus-eigensolver-residual",
|
||||
covarianceRoundoffBound: covarianceRoundoffBound ?? null,
|
||||
eigensolverResidualFrobeniusNorm,
|
||||
eigenvalueScale,
|
||||
minimumEigenvalue,
|
||||
clampedNegativeCount,
|
||||
firstIndefiniteIndex,
|
||||
isPositiveSemidefinite,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableSourceCount(criterion, reason, details = {}) {
|
||||
return {
|
||||
available: false,
|
||||
criterion,
|
||||
sourceCount: null,
|
||||
score: null,
|
||||
scores: null,
|
||||
reason,
|
||||
...details,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select source count with the Wax-Kailath AIC or MDL criterion.
|
||||
*
|
||||
* This pure function deliberately declines an answer unless T > M, snapshots
|
||||
* are declared independent, and every eigenvalue used by the candidate set is
|
||||
* strictly above the supplied roundoff bound. It performs no loading, rank
|
||||
* repair, or cross-frequency source-count fusion.
|
||||
*/
|
||||
export function selectWaxKailathSourceCount({
|
||||
eigenvalues,
|
||||
snapshotCount,
|
||||
independentSnapshots,
|
||||
criterion = "mdl",
|
||||
roundoffTolerance,
|
||||
}) {
|
||||
if (criterion !== "aic" && criterion !== "mdl") {
|
||||
throw new RangeError("criterion must be 'aic' or 'mdl'.");
|
||||
}
|
||||
if (eigenvalues == null || eigenvalues.length < 1) {
|
||||
throw new TypeError("at least one eigenvalue is required.");
|
||||
}
|
||||
assertPositiveInteger(snapshotCount, "snapshotCount");
|
||||
const sensorCount = eigenvalues.length;
|
||||
assertPositiveInteger(sensorCount, "sensorCount");
|
||||
let scale = 0;
|
||||
for (const value of eigenvalues) {
|
||||
if (!Number.isFinite(value)) throw new TypeError("eigenvalues must be finite.");
|
||||
scale = Math.max(scale, Math.abs(value));
|
||||
}
|
||||
const effectiveRoundoffTolerance = roundoffTolerance ?? (
|
||||
scale === 0
|
||||
? 0
|
||||
: DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR *
|
||||
Number.EPSILON * Math.max(1, sensorCount) * scale
|
||||
);
|
||||
if (
|
||||
!(effectiveRoundoffTolerance >= 0) ||
|
||||
!Number.isFinite(effectiveRoundoffTolerance)
|
||||
) {
|
||||
throw new TypeError("roundoffTolerance must be finite and nonnegative.");
|
||||
}
|
||||
|
||||
if (independentSnapshots !== true) {
|
||||
return unavailableSourceCount(criterion, "snapshots-not-independent", {
|
||||
sensorCount,
|
||||
snapshotCount,
|
||||
roundoffTolerance: effectiveRoundoffTolerance,
|
||||
});
|
||||
}
|
||||
if (snapshotCount <= sensorCount) {
|
||||
return unavailableSourceCount(criterion, "snapshot-count-not-greater-than-sensors", {
|
||||
sensorCount,
|
||||
snapshotCount,
|
||||
roundoffTolerance: effectiveRoundoffTolerance,
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < sensorCount; index += 1) {
|
||||
const value = eigenvalues[index];
|
||||
if (index > 0 && value > eigenvalues[index - 1] + effectiveRoundoffTolerance) {
|
||||
throw new RangeError("eigenvalues must be sorted in descending order.");
|
||||
}
|
||||
if (!(value > effectiveRoundoffTolerance)) {
|
||||
return unavailableSourceCount(
|
||||
criterion,
|
||||
"noise-eigenvalue-not-strictly-above-roundoff",
|
||||
{
|
||||
sensorCount,
|
||||
snapshotCount,
|
||||
roundoffTolerance: effectiveRoundoffTolerance,
|
||||
failingEigenvalueIndex: index,
|
||||
failingEigenvalue: value,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scores = new Float64Array(sensorCount);
|
||||
let selectedSourceCount = 0;
|
||||
let selectedScore = Infinity;
|
||||
let logRatioRoundoffClampCount = 0;
|
||||
for (let sourceCount = 0; sourceCount < sensorCount; sourceCount += 1) {
|
||||
const noiseCount = sensorCount - sourceCount;
|
||||
let arithmeticSum = 0;
|
||||
let arithmeticCompensation = 0;
|
||||
let logarithmSum = 0;
|
||||
let logarithmCompensation = 0;
|
||||
for (let index = sourceCount; index < sensorCount; index += 1) {
|
||||
[arithmeticSum, arithmeticCompensation] = compensatedAdd(
|
||||
arithmeticSum,
|
||||
arithmeticCompensation,
|
||||
eigenvalues[index],
|
||||
);
|
||||
[logarithmSum, logarithmCompensation] = compensatedAdd(
|
||||
logarithmSum,
|
||||
logarithmCompensation,
|
||||
Math.log(eigenvalues[index]),
|
||||
);
|
||||
}
|
||||
const arithmeticMean =
|
||||
(arithmeticSum + arithmeticCompensation) / noiseCount;
|
||||
const logarithmicGeometricMean =
|
||||
(logarithmSum + logarithmCompensation) / noiseCount;
|
||||
let logarithmicRatio = Math.log(arithmeticMean) - logarithmicGeometricMean;
|
||||
const ratioRoundoffTolerance =
|
||||
64 * Number.EPSILON * Math.max(1, noiseCount) *
|
||||
Math.max(1, Math.abs(Math.log(arithmeticMean)), Math.abs(logarithmicGeometricMean));
|
||||
if (logarithmicRatio < 0 && logarithmicRatio >= -ratioRoundoffTolerance) {
|
||||
logarithmicRatio = 0;
|
||||
logRatioRoundoffClampCount += 1;
|
||||
}
|
||||
if (!(logarithmicRatio >= 0) || !Number.isFinite(logarithmicRatio)) {
|
||||
throw new RangeError("Wax-Kailath arithmetic/geometric mean ratio is invalid.");
|
||||
}
|
||||
|
||||
const likelihoodTerm = snapshotCount * noiseCount * logarithmicRatio;
|
||||
const parameterCount = sourceCount * (2 * sensorCount - sourceCount);
|
||||
const score = criterion === "aic"
|
||||
? 2 * likelihoodTerm + 2 * parameterCount
|
||||
: likelihoodTerm + 0.5 * parameterCount * Math.log(snapshotCount);
|
||||
scores[sourceCount] = score;
|
||||
if (score < selectedScore) {
|
||||
selectedScore = score;
|
||||
selectedSourceCount = sourceCount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
criterion,
|
||||
sourceCount: selectedSourceCount,
|
||||
score: selectedScore,
|
||||
scores,
|
||||
reason: null,
|
||||
sensorCount,
|
||||
snapshotCount,
|
||||
roundoffTolerance: effectiveRoundoffTolerance,
|
||||
logRatioRoundoffClampCount,
|
||||
};
|
||||
}
|
||||
@@ -58,9 +58,10 @@ function normalizeRawMap(rawMap, scan) {
|
||||
if (
|
||||
descriptor?.scale !== undefined &&
|
||||
descriptor.scale !== "power" &&
|
||||
descriptor.scale !== "score"
|
||||
descriptor.scale !== "score" &&
|
||||
descriptor.scale !== "noise-projection"
|
||||
) {
|
||||
throw new RangeError("rawMap scale must be power or score.");
|
||||
throw new RangeError("rawMap scale must be power, score, or noise-projection.");
|
||||
}
|
||||
if (descriptor?.rows !== undefined && descriptor.rows !== scan.rows) {
|
||||
throw new RangeError("rawMap rows do not match scan.rows.");
|
||||
@@ -85,6 +86,86 @@ function rawMapScale(rawMap) {
|
||||
return "power";
|
||||
}
|
||||
|
||||
function rawMapObjectiveDirection(rawMap, mapScale) {
|
||||
const descriptor =
|
||||
rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap)
|
||||
? rawMap
|
||||
: null;
|
||||
const direction =
|
||||
descriptor?.objectiveDirection ??
|
||||
(mapScale === "noise-projection" ? "minimum" : "maximum");
|
||||
if (direction !== "maximum" && direction !== "minimum") {
|
||||
throw new RangeError('rawMap objectiveDirection must be "maximum" or "minimum".');
|
||||
}
|
||||
if (mapScale === "noise-projection" && direction !== "minimum") {
|
||||
throw new RangeError("noise-projection maps must use objectiveDirection minimum.");
|
||||
}
|
||||
if (mapScale !== "noise-projection" && direction !== "maximum") {
|
||||
throw new RangeError("power and score maps must use objectiveDirection maximum.");
|
||||
}
|
||||
return direction;
|
||||
}
|
||||
|
||||
export function normalizedMusicPseudospectrum(values) {
|
||||
if (!Array.isArray(values) && !ArrayBuffer.isView(values)) {
|
||||
throw new TypeError("noise projection values must be an array or typed array.");
|
||||
}
|
||||
if (values.length === 0) {
|
||||
throw new RangeError("noise projection values must not be empty.");
|
||||
}
|
||||
let rawMinimum = Number.POSITIVE_INFINITY;
|
||||
let rawMaximum = Number.NEGATIVE_INFINITY;
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = requireNonNegative(
|
||||
`noiseProjection[${index}]`,
|
||||
values[index],
|
||||
);
|
||||
rawMinimum = Math.min(rawMinimum, value);
|
||||
rawMaximum = Math.max(rawMaximum, value);
|
||||
}
|
||||
const pseudospectrum = new Float64Array(values.length);
|
||||
if (rawMinimum === rawMaximum) {
|
||||
pseudospectrum.fill(1);
|
||||
return {
|
||||
values: pseudospectrum,
|
||||
transform: "normalized-reciprocal-noise-projection",
|
||||
rawMinimum,
|
||||
flat: true,
|
||||
};
|
||||
}
|
||||
if (rawMinimum === 0) {
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
pseudospectrum[index] = values[index] === 0 ? 1 : 0;
|
||||
}
|
||||
} else {
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
pseudospectrum[index] = rawMinimum / values[index];
|
||||
}
|
||||
}
|
||||
return {
|
||||
values: pseudospectrum,
|
||||
transform: "normalized-reciprocal-noise-projection",
|
||||
rawMinimum,
|
||||
flat: false,
|
||||
};
|
||||
}
|
||||
|
||||
function objectiveMerit(values, direction) {
|
||||
if (direction === "maximum") {
|
||||
return { values, transform: "identity", rawMinimum: null, flat: false };
|
||||
}
|
||||
return normalizedMusicPseudospectrum(values);
|
||||
}
|
||||
|
||||
function restoreObjectiveValue(candidate, rawValues) {
|
||||
if (!candidate) return null;
|
||||
return {
|
||||
...candidate,
|
||||
value: rawValues[candidate.index],
|
||||
meritValue: candidate.value,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSources(sources) {
|
||||
if (!Array.isArray(sources)) throw new TypeError("sources must be an array.");
|
||||
const ids = new Set();
|
||||
@@ -742,8 +823,11 @@ export function measureBeamSpread(rawMap, scan, peakIndex, halfPowerRatio = 0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one nonnegative spatial power or score map. Source z coordinates
|
||||
* are projected onto scan.z, matching the image-plane ground-truth convention.
|
||||
* Evaluate one nonnegative spatial map. Noise-projection minima are detected
|
||||
* through the standard MUSIC reciprocal pseudospectrum normalized as
|
||||
* P/Pmax=qmin/q, while exported peak values remain the unmodified q values.
|
||||
* Source z coordinates are projected onto scan.z, matching the image-plane
|
||||
* ground-truth convention.
|
||||
*/
|
||||
export function evaluateLocalizationBand(options) {
|
||||
if (!options || typeof options !== "object") throw new TypeError("evaluation options are required.");
|
||||
@@ -751,9 +835,20 @@ export function evaluateLocalizationBand(options) {
|
||||
validateScan(scan);
|
||||
const values = normalizeRawMap(rawMap, scan);
|
||||
const mapScale = options.mapScale ?? rawMapScale(rawMap);
|
||||
if (mapScale !== "power" && mapScale !== "score") {
|
||||
throw new RangeError("mapScale must be power or score.");
|
||||
if (mapScale !== "power" && mapScale !== "score" && mapScale !== "noise-projection") {
|
||||
throw new RangeError("mapScale must be power, score, or noise-projection.");
|
||||
}
|
||||
const objectiveDirection =
|
||||
options.objectiveDirection ?? rawMapObjectiveDirection(rawMap, mapScale);
|
||||
if (
|
||||
(mapScale === "noise-projection" && objectiveDirection !== "minimum") ||
|
||||
(mapScale !== "noise-projection" && objectiveDirection !== "maximum")
|
||||
) {
|
||||
throw new RangeError(
|
||||
"noise-projection uses a minimum objective; power and score use a maximum objective.",
|
||||
);
|
||||
}
|
||||
const merit = objectiveMerit(values, objectiveDirection);
|
||||
const sources = normalizeSources(options.sources ?? options.scene?.sources ?? []);
|
||||
const truths = sources.map((source) => ({
|
||||
sourceId: source.id,
|
||||
@@ -789,19 +884,32 @@ export function evaluateLocalizationBand(options) {
|
||||
throw new RangeError("maximumDetections must be an integer from 1 through 128.");
|
||||
}
|
||||
|
||||
const localPeaks = findLocalPeaks(values, scan);
|
||||
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
|
||||
const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio;
|
||||
const candidates = detectionCandidates(
|
||||
values,
|
||||
const localMeritPeaks = merit.flat
|
||||
? []
|
||||
: findLocalPeaks(merit.values, scan);
|
||||
const globalMeritPeak = localMeritPeaks[0] ?? null;
|
||||
const thresholdValue = (globalMeritPeak?.value ?? 0) * detectionThresholdRatio;
|
||||
const meritCandidates = detectionCandidates(
|
||||
merit.values,
|
||||
scan,
|
||||
localPeaks,
|
||||
localMeritPeaks,
|
||||
thresholdValue,
|
||||
detectionMethod,
|
||||
);
|
||||
const detections = selectDetections(candidates, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
|
||||
(detection) => ({
|
||||
...detection,
|
||||
const selectedMeritDetections = selectDetections(
|
||||
meritCandidates,
|
||||
thresholdValue,
|
||||
minimumPeakDistanceM,
|
||||
maximumDetections,
|
||||
);
|
||||
const localPeaks = localMeritPeaks.map((peak) =>
|
||||
restoreObjectiveValue(peak, values));
|
||||
const globalPeak = restoreObjectiveValue(globalMeritPeak, values);
|
||||
const detections = selectedMeritDetections.map((detection) => {
|
||||
const restored = restoreObjectiveValue(detection, values);
|
||||
if (objectiveDirection === "minimum") return restored;
|
||||
return {
|
||||
...restored,
|
||||
beamSpread: measureBeamSpreadFromValues(
|
||||
values,
|
||||
scan,
|
||||
@@ -809,12 +917,36 @@ export function evaluateLocalizationBand(options) {
|
||||
halfPowerRatio,
|
||||
mapScale,
|
||||
),
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
|
||||
const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0;
|
||||
const recall = truths.length > 0 ? truePositiveCount / truths.length : 1;
|
||||
const mainLobe = globalPeak ? measureMainLobe(values, scan, globalPeak.index, halfPowerRatio) : null;
|
||||
const mainLobe =
|
||||
objectiveDirection === "maximum" && globalPeak
|
||||
? measureMainLobe(values, scan, globalPeak.index, halfPowerRatio)
|
||||
: null;
|
||||
const metrics = { precision, recall };
|
||||
if (objectiveDirection === "maximum") {
|
||||
Object.assign(metrics, {
|
||||
peakToSidelobeDb: peakToSidelobeDb(
|
||||
values,
|
||||
scan,
|
||||
truths,
|
||||
globalPeak,
|
||||
sidelobeExclusionRadiusM,
|
||||
dynamicRangeDb,
|
||||
),
|
||||
energyInsideTruthRatio: energyInsideRatio(
|
||||
values,
|
||||
scan,
|
||||
truths,
|
||||
truthRadiusM,
|
||||
globalPeak?.value ?? 0,
|
||||
),
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
globalPeak,
|
||||
@@ -833,6 +965,10 @@ export function evaluateLocalizationBand(options) {
|
||||
matchToleranceM,
|
||||
detectionThresholdRatio,
|
||||
detectionThresholdValue: thresholdValue,
|
||||
detectionThresholdDomain:
|
||||
objectiveDirection === "minimum"
|
||||
? "normalized-music-pseudospectrum"
|
||||
: "raw-map-value",
|
||||
detectionMethod,
|
||||
minimumPeakDistanceM,
|
||||
sidelobeExclusionRadiusM,
|
||||
@@ -840,23 +976,14 @@ export function evaluateLocalizationBand(options) {
|
||||
dynamicRangeDb,
|
||||
maximumDetections,
|
||||
},
|
||||
metrics: {
|
||||
peakToSidelobeDb: peakToSidelobeDb(
|
||||
values,
|
||||
scan,
|
||||
truths,
|
||||
globalPeak,
|
||||
sidelobeExclusionRadiusM,
|
||||
dynamicRangeDb,
|
||||
),
|
||||
energyInsideTruthRatio: energyInsideRatio(values, scan, truths, truthRadiusM, globalPeak?.value ?? 0),
|
||||
precision,
|
||||
recall,
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
},
|
||||
metrics,
|
||||
diagnostics: {
|
||||
mapScale,
|
||||
detectionCandidateCount: candidates.length,
|
||||
objectiveDirection,
|
||||
detectionTransform: merit.transform,
|
||||
...(merit.rawMinimum === null ? {} : { rawMinimum: merit.rawMinimum }),
|
||||
flatObjectiveMap: merit.flat,
|
||||
detectionCandidateCount: meritCandidates.length,
|
||||
mainLobe,
|
||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||
gridResolutionM: resolution,
|
||||
@@ -870,12 +997,14 @@ export function evaluateDasBand(options) {
|
||||
}
|
||||
|
||||
/** Select only metric names accepted by result.schema.json. */
|
||||
export function selectResultMetrics(evaluationOrMetrics, metricNames = RESULT_METRIC_KEYS) {
|
||||
export function selectResultMetrics(evaluationOrMetrics, metricNames) {
|
||||
const metrics = evaluationOrMetrics?.metrics ?? evaluationOrMetrics;
|
||||
if (!metrics || typeof metrics !== "object") throw new TypeError("evaluation metrics are required.");
|
||||
if (!Array.isArray(metricNames)) throw new TypeError("metricNames must be an array.");
|
||||
const selectedMetricNames =
|
||||
metricNames ?? RESULT_METRIC_KEYS.filter((metricName) => metricName in metrics);
|
||||
if (!Array.isArray(selectedMetricNames)) throw new TypeError("metricNames must be an array.");
|
||||
const selected = {};
|
||||
for (const metricName of metricNames) {
|
||||
for (const metricName of selectedMetricNames) {
|
||||
if (!RESULT_METRIC_KEY_SET.has(metricName)) {
|
||||
throw new RangeError(`result.schema.json does not allow metric: ${metricName}`);
|
||||
}
|
||||
@@ -927,13 +1056,16 @@ export function exportBandEvaluation(evaluation, options = {}) {
|
||||
});
|
||||
return {
|
||||
peaks,
|
||||
metrics: selectResultMetrics(evaluation, options.metricNames ?? RESULT_METRIC_KEYS),
|
||||
metrics: selectResultMetrics(evaluation, options.metricNames),
|
||||
evaluation: {
|
||||
version: "1.0.0",
|
||||
truthRadiusM: evaluation.parameters.truthRadiusM,
|
||||
matchToleranceM: evaluation.parameters.matchToleranceM,
|
||||
detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
|
||||
detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
|
||||
objectiveDirection: evaluation.diagnostics?.objectiveDirection ?? "maximum",
|
||||
detectionThresholdDomain:
|
||||
evaluation.parameters.detectionThresholdDomain ?? "raw-map-value",
|
||||
detectionMethod: evaluation.parameters.detectionMethod ?? "local-max",
|
||||
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
|
||||
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT;
|
||||
|
||||
export const DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS = 32;
|
||||
export const DEFAULT_MAX_HERMITIAN_EIGEN_WORK_BYTES = 16 * 1024 * 1024;
|
||||
export const DEFAULT_MAX_HERMITIAN_JACOBI_PAIR_UPDATES = 1_000_000_000;
|
||||
|
||||
function assertPositiveInteger(value, label) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new TypeError(`${label} must be a positive safe integer.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMaximumSweeps(value) {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 0 ||
|
||||
value > DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`maximumSweeps must be an integer from 0 through ${DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function checkedProduct(factors, label) {
|
||||
let product = 1;
|
||||
for (const factor of factors) {
|
||||
if (factor !== 0 && product > Number.MAX_SAFE_INTEGER / factor) {
|
||||
throw new RangeError(`${label} exceeds the safe integer range.`);
|
||||
}
|
||||
product *= factor;
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
function roundoffGamma(operationCount) {
|
||||
const product = operationCount * (Number.EPSILON / 2);
|
||||
if (!(product < 1)) {
|
||||
throw new RangeError("roundoff bound is undefined for this workload.");
|
||||
}
|
||||
return product / (1 - product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the bounded typed-array allocation and cyclic-Jacobi work before
|
||||
* any decomposition workspace is allocated.
|
||||
*/
|
||||
export function estimateHermitianEigenWorkload({
|
||||
order,
|
||||
maximumSweeps = DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS,
|
||||
}) {
|
||||
assertPositiveInteger(order, "order");
|
||||
assertMaximumSweeps(maximumSweeps);
|
||||
|
||||
const matrixElementCount = checkedProduct([order, order], "matrix element count");
|
||||
const pairCount = checkedProduct([order, order - 1], "Jacobi pair count") / 2;
|
||||
const worstCaseRotations = checkedProduct(
|
||||
[pairCount, maximumSweeps],
|
||||
"Jacobi rotation count",
|
||||
);
|
||||
// A rotation updates n-2 matrix row pairs and n eigenvector row pairs.
|
||||
const pairUpdatesPerRotation = Math.max(0, 2 * order - 2);
|
||||
const worstCasePairUpdates = checkedProduct(
|
||||
[worstCaseRotations, pairUpdatesPerRotation],
|
||||
"Jacobi pair-update count",
|
||||
);
|
||||
// Two split-complex matrices plus normalized and physical eigenvalue arrays.
|
||||
const typedArrayElementCount = checkedProduct(
|
||||
[4 * matrixElementCount + 2 * order, FLOAT64_BYTES],
|
||||
"Hermitian eigensolver allocation",
|
||||
) / FLOAT64_BYTES;
|
||||
const allocationBytes = checkedProduct(
|
||||
[typedArrayElementCount, FLOAT64_BYTES],
|
||||
"Hermitian eigensolver allocation",
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
order,
|
||||
maximumSweeps,
|
||||
matrixElementCount,
|
||||
pairCount,
|
||||
pairUpdatesPerRotation,
|
||||
worstCaseRotations,
|
||||
worstCasePairUpdates,
|
||||
allocationBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function inferOrder(real, imaginary, explicitOrder) {
|
||||
if (real == null || imaginary == null) {
|
||||
throw new TypeError("real and imaginary matrix components are required.");
|
||||
}
|
||||
const order = explicitOrder ?? Math.sqrt(real.length);
|
||||
assertPositiveInteger(order, "order");
|
||||
const expectedLength = checkedProduct([order, order], "matrix element count");
|
||||
if (real.length !== expectedLength || imaginary.length !== expectedLength) {
|
||||
throw new RangeError(
|
||||
`Hermitian matrix components must each contain ${expectedLength} values.`,
|
||||
);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function matrixFrobeniusNorm(real, imaginary, order) {
|
||||
let sum = 0;
|
||||
for (let index = 0; index < order * order; index += 1) {
|
||||
sum += real[index] * real[index] + imaginary[index] * imaginary[index];
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
function offDiagonalFrobeniusNorm(real, imaginary, order) {
|
||||
let upperTriangleSum = 0;
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
for (let column = row + 1; column < order; column += 1) {
|
||||
const index = row * order + column;
|
||||
upperTriangleSum +=
|
||||
real[index] * real[index] + imaginary[index] * imaginary[index];
|
||||
}
|
||||
}
|
||||
return Math.sqrt(2 * upperTriangleSum);
|
||||
}
|
||||
|
||||
function rotatePair(matrixReal, matrixImaginary, vectorsReal, vectorsImaginary, order, p, q) {
|
||||
const pq = p * order + q;
|
||||
const offReal = matrixReal[pq];
|
||||
const offImaginary = matrixImaginary[pq];
|
||||
const magnitude = Math.hypot(offReal, offImaginary);
|
||||
if (magnitude === 0) return false;
|
||||
|
||||
const diagonalP = matrixReal[p * order + p];
|
||||
const diagonalQ = matrixReal[q * order + q];
|
||||
const halfDifference = (diagonalQ - diagonalP) / 2;
|
||||
const tangent = halfDifference === 0
|
||||
? 1
|
||||
: Math.sign(halfDifference) * magnitude /
|
||||
(Math.abs(halfDifference) + Math.hypot(halfDifference, magnitude));
|
||||
const cosine = 1 / Math.hypot(1, tangent);
|
||||
const sine = tangent * cosine;
|
||||
const phaseReal = offReal / magnitude;
|
||||
const phaseImaginary = offImaginary / magnitude;
|
||||
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
if (row === p || row === q) continue;
|
||||
const rowP = row * order + p;
|
||||
const rowQ = row * order + q;
|
||||
const pReal = matrixReal[rowP];
|
||||
const pImaginary = matrixImaginary[rowP];
|
||||
const qReal = matrixReal[rowQ];
|
||||
const qImaginary = matrixImaginary[rowQ];
|
||||
|
||||
const conjugatePhaseTimesQReal =
|
||||
phaseReal * qReal + phaseImaginary * qImaginary;
|
||||
const conjugatePhaseTimesQImaginary =
|
||||
phaseReal * qImaginary - phaseImaginary * qReal;
|
||||
const phaseTimesPReal = phaseReal * pReal - phaseImaginary * pImaginary;
|
||||
const phaseTimesPImaginary = phaseReal * pImaginary + phaseImaginary * pReal;
|
||||
|
||||
const nextPReal = cosine * pReal - sine * conjugatePhaseTimesQReal;
|
||||
const nextPImaginary = cosine * pImaginary - sine * conjugatePhaseTimesQImaginary;
|
||||
const nextQReal = sine * phaseTimesPReal + cosine * qReal;
|
||||
const nextQImaginary = sine * phaseTimesPImaginary + cosine * qImaginary;
|
||||
|
||||
matrixReal[rowP] = nextPReal;
|
||||
matrixImaginary[rowP] = nextPImaginary;
|
||||
matrixReal[p * order + row] = nextPReal;
|
||||
matrixImaginary[p * order + row] = -nextPImaginary;
|
||||
matrixReal[rowQ] = nextQReal;
|
||||
matrixImaginary[rowQ] = nextQImaginary;
|
||||
matrixReal[q * order + row] = nextQReal;
|
||||
matrixImaginary[q * order + row] = -nextQImaginary;
|
||||
}
|
||||
|
||||
matrixReal[p * order + p] = diagonalP - tangent * magnitude;
|
||||
matrixImaginary[p * order + p] = 0;
|
||||
matrixReal[q * order + q] = diagonalQ + tangent * magnitude;
|
||||
matrixImaginary[q * order + q] = 0;
|
||||
matrixReal[pq] = 0;
|
||||
matrixImaginary[pq] = 0;
|
||||
matrixReal[q * order + p] = 0;
|
||||
matrixImaginary[q * order + p] = 0;
|
||||
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
const rowP = row * order + p;
|
||||
const rowQ = row * order + q;
|
||||
const pReal = vectorsReal[rowP];
|
||||
const pImaginary = vectorsImaginary[rowP];
|
||||
const qReal = vectorsReal[rowQ];
|
||||
const qImaginary = vectorsImaginary[rowQ];
|
||||
const conjugatePhaseTimesQReal =
|
||||
phaseReal * qReal + phaseImaginary * qImaginary;
|
||||
const conjugatePhaseTimesQImaginary =
|
||||
phaseReal * qImaginary - phaseImaginary * qReal;
|
||||
const phaseTimesPReal = phaseReal * pReal - phaseImaginary * pImaginary;
|
||||
const phaseTimesPImaginary = phaseReal * pImaginary + phaseImaginary * pReal;
|
||||
vectorsReal[rowP] = cosine * pReal - sine * conjugatePhaseTimesQReal;
|
||||
vectorsImaginary[rowP] = cosine * pImaginary - sine * conjugatePhaseTimesQImaginary;
|
||||
vectorsReal[rowQ] = sine * phaseTimesPReal + cosine * qReal;
|
||||
vectorsImaginary[rowQ] = sine * phaseTimesPImaginary + cosine * qImaginary;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function canonicalizeColumnPhases(real, imaginary, order) {
|
||||
for (let column = 0; column < order; column += 1) {
|
||||
let pivotRow = 0;
|
||||
let pivotMagnitude = -1;
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
const index = row * order + column;
|
||||
const magnitude = Math.hypot(real[index], imaginary[index]);
|
||||
if (magnitude > pivotMagnitude) {
|
||||
pivotMagnitude = magnitude;
|
||||
pivotRow = row;
|
||||
}
|
||||
}
|
||||
const pivotIndex = pivotRow * order + column;
|
||||
const pivotReal = real[pivotIndex];
|
||||
const pivotImaginary = imaginary[pivotIndex];
|
||||
const normalizedPivotMagnitude = Math.hypot(pivotReal, pivotImaginary);
|
||||
if (!(normalizedPivotMagnitude > 0) || !Number.isFinite(normalizedPivotMagnitude)) {
|
||||
throw new RangeError("Jacobi eigenvector phase canonicalization failed.");
|
||||
}
|
||||
const phaseReal = pivotReal / normalizedPivotMagnitude;
|
||||
const phaseImaginary = -pivotImaginary / normalizedPivotMagnitude;
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
const index = row * order + column;
|
||||
const valueReal = real[index];
|
||||
const valueImaginary = imaginary[index];
|
||||
real[index] = valueReal * phaseReal - valueImaginary * phaseImaginary;
|
||||
imaginary[index] = valueReal * phaseImaginary + valueImaginary * phaseReal;
|
||||
}
|
||||
real[pivotIndex] = normalizedPivotMagnitude;
|
||||
imaginary[pivotIndex] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function projectedInputEntry(real, imaginary, order, row, column, scale) {
|
||||
if (row === column) return [real[row * order + row] / scale, 0];
|
||||
const forward = row * order + column;
|
||||
const reverse = column * order + row;
|
||||
return [
|
||||
(real[forward] + real[reverse]) / (2 * scale),
|
||||
(imaginary[forward] - imaginary[reverse]) / (2 * scale),
|
||||
];
|
||||
}
|
||||
|
||||
function residualFrobeniusNorm(
|
||||
inputReal,
|
||||
inputImaginary,
|
||||
vectorsReal,
|
||||
vectorsImaginary,
|
||||
normalizedEigenvalues,
|
||||
order,
|
||||
scale,
|
||||
) {
|
||||
if (scale === 0) return 0;
|
||||
let residualSquared = 0;
|
||||
for (let column = 0; column < order; column += 1) {
|
||||
const eigenvalue = normalizedEigenvalues[column];
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
let productReal = 0;
|
||||
let productImaginary = 0;
|
||||
for (let inner = 0; inner < order; inner += 1) {
|
||||
const [matrixReal, matrixImaginary] = projectedInputEntry(
|
||||
inputReal,
|
||||
inputImaginary,
|
||||
order,
|
||||
row,
|
||||
inner,
|
||||
scale,
|
||||
);
|
||||
const vectorIndex = inner * order + column;
|
||||
const vectorReal = vectorsReal[vectorIndex];
|
||||
const vectorImaginary = vectorsImaginary[vectorIndex];
|
||||
productReal += matrixReal * vectorReal - matrixImaginary * vectorImaginary;
|
||||
productImaginary += matrixReal * vectorImaginary + matrixImaginary * vectorReal;
|
||||
}
|
||||
const vectorIndex = row * order + column;
|
||||
const differenceReal = productReal - eigenvalue * vectorsReal[vectorIndex];
|
||||
const differenceImaginary =
|
||||
productImaginary - eigenvalue * vectorsImaginary[vectorIndex];
|
||||
residualSquared +=
|
||||
differenceReal * differenceReal + differenceImaginary * differenceImaginary;
|
||||
}
|
||||
}
|
||||
return Math.sqrt(residualSquared);
|
||||
}
|
||||
|
||||
function orthogonalityError(real, imaginary, order) {
|
||||
let errorSquared = 0;
|
||||
for (let left = 0; left < order; left += 1) {
|
||||
for (let right = 0; right < order; right += 1) {
|
||||
let productReal = 0;
|
||||
let productImaginary = 0;
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
const leftIndex = row * order + left;
|
||||
const rightIndex = row * order + right;
|
||||
productReal +=
|
||||
real[leftIndex] * real[rightIndex] +
|
||||
imaginary[leftIndex] * imaginary[rightIndex];
|
||||
productImaginary +=
|
||||
real[leftIndex] * imaginary[rightIndex] -
|
||||
imaginary[leftIndex] * real[rightIndex];
|
||||
}
|
||||
if (left === right) productReal -= 1;
|
||||
errorSquared += productReal * productReal + productImaginary * productImaginary;
|
||||
}
|
||||
}
|
||||
return Math.sqrt(errorSquared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic full cyclic Jacobi decomposition of a complex Hermitian
|
||||
* matrix. Split-complex matrices are row-major; eigenvectors are returned as
|
||||
* row-major columns, so component `row` of eigenvector `column` is at
|
||||
* `row * order + column`.
|
||||
*
|
||||
* The input is projected to its nearest Hermitian matrix only after its
|
||||
* anti-Hermitian discrepancy has passed the reported roundoff-scale audit.
|
||||
* No diagonal loading or eigenvalue flooring is performed.
|
||||
*/
|
||||
export function decomposeHermitian({
|
||||
real,
|
||||
imaginary,
|
||||
order: explicitOrder,
|
||||
maximumSweeps = DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS,
|
||||
relativeTolerance,
|
||||
maximumWorkBytes = DEFAULT_MAX_HERMITIAN_EIGEN_WORK_BYTES,
|
||||
maximumPairUpdates = DEFAULT_MAX_HERMITIAN_JACOBI_PAIR_UPDATES,
|
||||
}) {
|
||||
const order = inferOrder(real, imaginary, explicitOrder);
|
||||
const workload = estimateHermitianEigenWorkload({ order, maximumSweeps });
|
||||
if (!(maximumWorkBytes >= 0) || !Number.isFinite(maximumWorkBytes)) {
|
||||
throw new TypeError("maximumWorkBytes must be finite and nonnegative.");
|
||||
}
|
||||
if (!(maximumPairUpdates >= 0) || !Number.isFinite(maximumPairUpdates)) {
|
||||
throw new TypeError("maximumPairUpdates must be finite and nonnegative.");
|
||||
}
|
||||
if (workload.allocationBytes > maximumWorkBytes) {
|
||||
throw new RangeError(
|
||||
`Hermitian eigensolver requires ${workload.allocationBytes} typed-array bytes; limit is ${maximumWorkBytes}.`,
|
||||
);
|
||||
}
|
||||
if (workload.worstCasePairUpdates > maximumPairUpdates) {
|
||||
throw new RangeError(
|
||||
`Hermitian eigensolver worst-case work is ${workload.worstCasePairUpdates} pair updates; limit is ${maximumPairUpdates}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let scale = 0;
|
||||
for (let index = 0; index < workload.matrixElementCount; index += 1) {
|
||||
const valueReal = real[index];
|
||||
const valueImaginary = imaginary[index];
|
||||
if (!Number.isFinite(valueReal) || !Number.isFinite(valueImaginary)) {
|
||||
throw new TypeError("Hermitian matrix components must be finite.");
|
||||
}
|
||||
// Component scaling avoids overflow in hypot() for otherwise finite
|
||||
// split-complex entries near Number.MAX_VALUE.
|
||||
scale = Math.max(scale, Math.abs(valueReal), Math.abs(valueImaginary));
|
||||
}
|
||||
|
||||
const inputHermitianTolerance =
|
||||
scale === 0 ? 0 : 32 * Number.EPSILON * Math.max(1, order) * scale;
|
||||
let inputHermitianError = 0;
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
inputHermitianError = Math.max(
|
||||
inputHermitianError,
|
||||
Math.abs(imaginary[row * order + row]),
|
||||
);
|
||||
for (let column = row + 1; column < order; column += 1) {
|
||||
const forward = row * order + column;
|
||||
const reverse = column * order + row;
|
||||
inputHermitianError = Math.max(
|
||||
inputHermitianError,
|
||||
Math.hypot(
|
||||
real[forward] - real[reverse],
|
||||
imaginary[forward] + imaginary[reverse],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (inputHermitianError > inputHermitianTolerance) {
|
||||
throw new RangeError(
|
||||
`matrix is not Hermitian within roundoff: error ${inputHermitianError}, tolerance ${inputHermitianTolerance}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const matrixReal = new Float64Array(workload.matrixElementCount);
|
||||
const matrixImaginary = new Float64Array(workload.matrixElementCount);
|
||||
const vectorsReal = new Float64Array(workload.matrixElementCount);
|
||||
const vectorsImaginary = new Float64Array(workload.matrixElementCount);
|
||||
const normalizedEigenvalues = new Float64Array(order);
|
||||
const eigenvalues = new Float64Array(order);
|
||||
|
||||
if (scale > 0) {
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
const diagonal = row * order + row;
|
||||
matrixReal[diagonal] = real[diagonal] / scale;
|
||||
for (let column = row + 1; column < order; column += 1) {
|
||||
const forward = row * order + column;
|
||||
const reverse = column * order + row;
|
||||
const projectedReal = (real[forward] + real[reverse]) / (2 * scale);
|
||||
const projectedImaginary =
|
||||
(imaginary[forward] - imaginary[reverse]) / (2 * scale);
|
||||
matrixReal[forward] = projectedReal;
|
||||
matrixImaginary[forward] = projectedImaginary;
|
||||
matrixReal[reverse] = projectedReal;
|
||||
matrixImaginary[reverse] = -projectedImaginary;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < order; index += 1) {
|
||||
vectorsReal[index * order + index] = 1;
|
||||
}
|
||||
|
||||
const matrixNormNormalized = matrixFrobeniusNorm(
|
||||
matrixReal,
|
||||
matrixImaginary,
|
||||
order,
|
||||
);
|
||||
const effectiveRelativeTolerance = relativeTolerance ?? roundoffGamma(32 * order);
|
||||
if (!(effectiveRelativeTolerance > 0) || !Number.isFinite(effectiveRelativeTolerance)) {
|
||||
throw new TypeError("relativeTolerance must be finite and positive.");
|
||||
}
|
||||
const absoluteTolerance = effectiveRelativeTolerance * matrixNormNormalized;
|
||||
let offDiagonalNormalized = offDiagonalFrobeniusNorm(
|
||||
matrixReal,
|
||||
matrixImaginary,
|
||||
order,
|
||||
);
|
||||
let converged = offDiagonalNormalized <= absoluteTolerance;
|
||||
let sweeps = 0;
|
||||
let rotations = 0;
|
||||
const pairTolerance = workload.pairCount === 0
|
||||
? 0
|
||||
: absoluteTolerance / Math.sqrt(2 * workload.pairCount);
|
||||
|
||||
while (!converged && sweeps < maximumSweeps) {
|
||||
let sweepRotations = 0;
|
||||
for (let p = 0; p < order; p += 1) {
|
||||
for (let q = p + 1; q < order; q += 1) {
|
||||
const pq = p * order + q;
|
||||
if (Math.hypot(matrixReal[pq], matrixImaginary[pq]) <= pairTolerance) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
rotatePair(
|
||||
matrixReal,
|
||||
matrixImaginary,
|
||||
vectorsReal,
|
||||
vectorsImaginary,
|
||||
order,
|
||||
p,
|
||||
q,
|
||||
)
|
||||
) {
|
||||
sweepRotations += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
rotations += sweepRotations;
|
||||
sweeps += 1;
|
||||
offDiagonalNormalized = offDiagonalFrobeniusNorm(
|
||||
matrixReal,
|
||||
matrixImaginary,
|
||||
order,
|
||||
);
|
||||
converged = offDiagonalNormalized <= absoluteTolerance;
|
||||
if (sweepRotations === 0) break;
|
||||
}
|
||||
|
||||
const stableOrder = Array.from({ length: order }, (_, index) => ({
|
||||
index,
|
||||
value: matrixReal[index * order + index],
|
||||
}));
|
||||
stableOrder.sort((left, right) => {
|
||||
if (left.value > right.value) return -1;
|
||||
if (left.value < right.value) return 1;
|
||||
return left.index - right.index;
|
||||
});
|
||||
for (let column = 0; column < order; column += 1) {
|
||||
const sourceColumn = stableOrder[column].index;
|
||||
const normalizedEigenvalue = stableOrder[column].value;
|
||||
normalizedEigenvalues[column] = Object.is(normalizedEigenvalue, -0)
|
||||
? 0
|
||||
: normalizedEigenvalue;
|
||||
const physicalEigenvalue = normalizedEigenvalues[column] * scale;
|
||||
if (!Number.isFinite(physicalEigenvalue)) {
|
||||
throw new RangeError("Hermitian eigenvalue exceeds the finite Float64 range.");
|
||||
}
|
||||
eigenvalues[column] = Object.is(physicalEigenvalue, -0) ? 0 : physicalEigenvalue;
|
||||
for (let row = 0; row < order; row += 1) {
|
||||
const destination = row * order + column;
|
||||
const source = row * order + sourceColumn;
|
||||
matrixReal[destination] = vectorsReal[source];
|
||||
matrixImaginary[destination] = vectorsImaginary[source];
|
||||
}
|
||||
}
|
||||
// A product of unitary Jacobi rotations already has unit-norm columns.
|
||||
// Canonicalize phase only; do not hide orthogonality drift by renormalizing.
|
||||
canonicalizeColumnPhases(matrixReal, matrixImaginary, order);
|
||||
|
||||
const residualNormalized = residualFrobeniusNorm(
|
||||
real,
|
||||
imaginary,
|
||||
matrixReal,
|
||||
matrixImaginary,
|
||||
normalizedEigenvalues,
|
||||
order,
|
||||
scale,
|
||||
);
|
||||
const relativeResidual = matrixNormNormalized === 0
|
||||
? 0
|
||||
: residualNormalized / matrixNormNormalized;
|
||||
const relativeOffDiagonal = matrixNormNormalized === 0
|
||||
? 0
|
||||
: offDiagonalNormalized / matrixNormNormalized;
|
||||
const residualTolerance = roundoffGamma(128 * order);
|
||||
const orthogonalityTolerance = residualTolerance * Math.sqrt(order);
|
||||
const measuredOrthogonalityError = orthogonalityError(
|
||||
matrixReal,
|
||||
matrixImaginary,
|
||||
order,
|
||||
);
|
||||
const numericallyVerified =
|
||||
converged &&
|
||||
relativeResidual <= residualTolerance &&
|
||||
measuredOrthogonalityError <= orthogonalityTolerance;
|
||||
|
||||
return {
|
||||
order,
|
||||
eigenvalues,
|
||||
eigenvectors: {
|
||||
real: matrixReal,
|
||||
imaginary: matrixImaginary,
|
||||
layout: "row-major-columns",
|
||||
},
|
||||
converged,
|
||||
sweeps,
|
||||
rotations,
|
||||
scale,
|
||||
inputHermitianError,
|
||||
inputHermitianTolerance,
|
||||
relativeTolerance: effectiveRelativeTolerance,
|
||||
offDiagonalFrobeniusNorm: offDiagonalNormalized * scale,
|
||||
relativeOffDiagonal,
|
||||
residualFrobeniusNorm: residualNormalized * scale,
|
||||
relativeResidual,
|
||||
residualTolerance,
|
||||
orthogonalityError: measuredOrthogonalityError,
|
||||
orthogonalityTolerance,
|
||||
numericallyVerified,
|
||||
workload,
|
||||
};
|
||||
}
|
||||
@@ -5,11 +5,14 @@ export * from "./fft.js";
|
||||
export * from "./frequency-response.js";
|
||||
export * from "./fractional-delay.js";
|
||||
export * from "./gcc-phat.js";
|
||||
export * from "./hermitian-eigen.js";
|
||||
export * from "./localization.js";
|
||||
export * from "./localization-algorithms.js";
|
||||
export * from "./math.js";
|
||||
export * from "./mixdown.js";
|
||||
export * from "./music.js";
|
||||
export * from "./noise.js";
|
||||
export * from "./covariance.js";
|
||||
export * from "./propagation.js";
|
||||
export * from "./realtime-localizer.js";
|
||||
export * from "./signal.js";
|
||||
|
||||
@@ -15,6 +15,7 @@ const ALGORITHM_DEFINITIONS = Object.freeze([
|
||||
definition("das", "DAS", "power", "none", ["cpu", "webgpu"]),
|
||||
definition("das-phat", "DAS-PHAT", "score", "phat", ["cpu", "webgpu"]),
|
||||
definition("gcc-phat", "GCC-PHAT", "score", "phat", ["cpu"]),
|
||||
definition("music", "MUSIC", "noise-projection", "subspace", ["cpu"]),
|
||||
]);
|
||||
|
||||
const ALGORITHM_BY_ID = new Map(
|
||||
@@ -43,6 +44,10 @@ export function isDasAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||
return id === "das" || id === "das-phat";
|
||||
}
|
||||
|
||||
export function isMusicAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||
return id === "music";
|
||||
}
|
||||
|
||||
export function localizationAlgorithmLabel(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||
return localizationAlgorithmDefinition(id)?.label ?? String(id);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
createGccPhatAnalysisPlan,
|
||||
estimateGccPhatWorkload,
|
||||
} from "./gcc-phat.js";
|
||||
import {
|
||||
computeMusicMap,
|
||||
createMusicAnalysisPlan,
|
||||
estimateMusicWorkload,
|
||||
} from "./music.js";
|
||||
import {
|
||||
isDasAlgorithm,
|
||||
requireLocalizationAlgorithmDefinition,
|
||||
@@ -27,6 +32,7 @@ export function estimateLocalizationWorkload(options) {
|
||||
const algorithm = requestedAlgorithm(options);
|
||||
if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(options);
|
||||
if (algorithm.id === "gcc-phat") return estimateGccPhatWorkload(options);
|
||||
if (algorithm.id === "music") return estimateMusicWorkload(options);
|
||||
throw new RangeError("No workload estimator is registered for " + algorithm.id + ".");
|
||||
}
|
||||
|
||||
@@ -37,6 +43,7 @@ export function createLocalizationAnalysisPlan(options) {
|
||||
const algorithm = requestedAlgorithm(options);
|
||||
if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(options);
|
||||
if (algorithm.id === "gcc-phat") return createGccPhatAnalysisPlan(options);
|
||||
if (algorithm.id === "music") return createMusicAnalysisPlan(options);
|
||||
throw new RangeError("No analysis-plan factory is registered for " + algorithm.id + ".");
|
||||
}
|
||||
|
||||
@@ -49,5 +56,6 @@ export async function computeLocalizationMap(options) {
|
||||
const algorithm = requestedAlgorithm(options);
|
||||
if (isDasAlgorithm(algorithm.id)) return computeDasMap(options);
|
||||
if (algorithm.id === "gcc-phat") return computeGccPhatMap(options);
|
||||
if (algorithm.id === "music") return computeMusicMap(options);
|
||||
throw new RangeError("No CPU localization implementation is registered for " + algorithm.id + ".");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
||||
import { findLocalPeaks } from "./evaluation.js";
|
||||
import {
|
||||
findLocalPeaks,
|
||||
normalizedMusicPseudospectrum,
|
||||
} from "./evaluation.js";
|
||||
import {
|
||||
computeLocalizationMap,
|
||||
createLocalizationAnalysisPlan,
|
||||
@@ -17,6 +20,8 @@ import {
|
||||
MAX_FREQUENCY_RESPONSE_POINTS,
|
||||
requireMaxCompensationDb,
|
||||
} from "./frequency-response.js";
|
||||
import { isMusicUnavailableError } from "./music.js";
|
||||
import { getCompleteFrameCount } from "./stft.js";
|
||||
|
||||
export const REALTIME_LOCALIZATION_PLAN_KIND = "micarrsim.realtime-localization-plan/1";
|
||||
export const DEFAULT_STREAM_RESULTS_PER_CALL = 1;
|
||||
@@ -224,11 +229,36 @@ function throwIfAborted(signal) {
|
||||
|
||||
export function detectBandPeaks(rawMap, scan, options = {}) {
|
||||
const rawScale = options.rawScale ?? rawMap?.scale ?? "power";
|
||||
if (rawScale !== "power" && rawScale !== "score") {
|
||||
throw new RangeError('peak map rawScale must be "power" or "score".');
|
||||
if (rawScale !== "power" && rawScale !== "score" && rawScale !== "noise-projection") {
|
||||
throw new RangeError(
|
||||
'peak map rawScale must be "power", "score", or "noise-projection".',
|
||||
);
|
||||
}
|
||||
const objectiveDirection =
|
||||
options.objectiveDirection ??
|
||||
rawMap?.objectiveDirection ??
|
||||
(rawScale === "noise-projection" ? "minimum" : "maximum");
|
||||
if (
|
||||
(rawScale === "noise-projection" && objectiveDirection !== "minimum") ||
|
||||
(rawScale !== "noise-projection" && objectiveDirection !== "maximum")
|
||||
) {
|
||||
throw new RangeError(
|
||||
"noise-projection uses a minimum objective; power and score use a maximum objective.",
|
||||
);
|
||||
}
|
||||
const rawValues =
|
||||
rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap)
|
||||
? rawMap.values
|
||||
: rawMap;
|
||||
let detectionMap = rawMap;
|
||||
if (objectiveDirection === "minimum") {
|
||||
const pseudospectrum =
|
||||
normalizedMusicPseudospectrum(rawValues);
|
||||
if (pseudospectrum.flat) return [];
|
||||
detectionMap = pseudospectrum.values;
|
||||
}
|
||||
const peakDetection = copyPeakDetection(options, scan);
|
||||
const localPeaks = findLocalPeaks(rawMap, scan);
|
||||
const localPeaks = findLocalPeaks(detectionMap, scan);
|
||||
const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
|
||||
const detections = [];
|
||||
for (const peak of localPeaks) {
|
||||
@@ -248,11 +278,15 @@ export function detectBandPeaks(rawMap, scan, options = {}) {
|
||||
rank: detections.length + 1,
|
||||
index: peak.index,
|
||||
position: [...peak.position],
|
||||
rawValue: peak.value,
|
||||
rawValue: rawValues[peak.index],
|
||||
rawScale,
|
||||
objectiveDirection,
|
||||
detectionMerit: peak.value,
|
||||
...(rawScale === "power"
|
||||
? { rawPower: peak.value }
|
||||
: { rawScore: peak.value }),
|
||||
? { rawPower: rawValues[peak.index] }
|
||||
: rawScale === "score"
|
||||
? { rawScore: rawValues[peak.index] }
|
||||
: { noiseProjection: rawValues[peak.index] }),
|
||||
});
|
||||
if (detections.length === peakDetection.maximumDetections) break;
|
||||
}
|
||||
@@ -291,10 +325,21 @@ export function createLocalizationPlan(config) {
|
||||
);
|
||||
if (hopSize > fftSize) throw new RangeError("stftHopSize must not exceed fftSize.");
|
||||
const speedOfSound = requirePositiveFinite(config.speedOfSound ?? 343, "speedOfSound");
|
||||
const normalization = config.normalization ?? config.beamforming?.normalization ?? "peak";
|
||||
const algorithm = requireLocalizationAlgorithmDefinition(
|
||||
config.algorithm ?? config.beamforming?.algorithm,
|
||||
);
|
||||
const normalization =
|
||||
config.normalization ??
|
||||
config.beamforming?.normalization ??
|
||||
(algorithm.id === "music" ? "none" : "peak");
|
||||
if (normalization !== "peak" && normalization !== "none") {
|
||||
throw new RangeError('normalization must be "peak" or "none".');
|
||||
}
|
||||
if (algorithm.id === "music" && normalization !== "none") {
|
||||
throw new RangeError(
|
||||
'MUSIC normalization must be "none"; raw noise projection q is not peak-normalized.',
|
||||
);
|
||||
}
|
||||
const compensateFrequencyResponse =
|
||||
config.compensateFrequencyResponse ??
|
||||
config.beamforming?.compensateFrequencyResponse ??
|
||||
@@ -307,9 +352,20 @@ export function createLocalizationPlan(config) {
|
||||
config.beamforming?.maxCompensationDb ??
|
||||
DEFAULT_MAX_COMPENSATION_DB,
|
||||
);
|
||||
const algorithm = requireLocalizationAlgorithmDefinition(
|
||||
config.algorithm ?? config.beamforming?.algorithm,
|
||||
const minimumDistance = requirePositiveFinite(
|
||||
config.minimumDistance ??
|
||||
config.beamforming?.minimumDistance ??
|
||||
0.01,
|
||||
"minimumDistance",
|
||||
);
|
||||
const musicSourceCountMethod =
|
||||
config.musicSourceCountMethod ??
|
||||
config.beamforming?.musicSourceCountMethod ??
|
||||
"fixed";
|
||||
const musicSourceCount =
|
||||
config.musicSourceCount ??
|
||||
config.beamforming?.musicSourceCount ??
|
||||
1;
|
||||
const phatRelativeFloor =
|
||||
config.phatRelativeFloor ?? config.beamforming?.phatRelativeFloor;
|
||||
const phatFloorRatio =
|
||||
@@ -326,6 +382,10 @@ export function createLocalizationPlan(config) {
|
||||
normalization,
|
||||
compensateFrequencyResponse,
|
||||
maxCompensationDb,
|
||||
minimumDistance,
|
||||
...(algorithm.id === "music"
|
||||
? { musicSourceCountMethod, musicSourceCount }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
const analysisPlan = createLocalizationAnalysisPlan({
|
||||
@@ -350,18 +410,22 @@ export function createLocalizationPlan(config) {
|
||||
speedOfSound,
|
||||
normalization,
|
||||
maxCompensationDb,
|
||||
minimumDistance,
|
||||
scene,
|
||||
experiment,
|
||||
analysisPlan,
|
||||
...(isDasAlgorithm(algorithm.id)
|
||||
? { dasPlan: analysisPlan }
|
||||
: { gccPhatPlan: analysisPlan }),
|
||||
: algorithm.id === "gcc-phat"
|
||||
? { gccPhatPlan: analysisPlan }
|
||||
: { musicPlan: analysisPlan }),
|
||||
computeOptions: Object.freeze({
|
||||
pointBatchSize: isDasAlgorithm(algorithm.id)
|
||||
? DEFAULT_REALTIME_POINT_BATCH_SIZE
|
||||
: DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||
pointBatchSize:
|
||||
algorithm.id === "gcc-phat"
|
||||
? DEFAULT_GCC_PHAT_POINT_BATCH_SIZE
|
||||
: DEFAULT_REALTIME_POINT_BATCH_SIZE,
|
||||
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
|
||||
...(!isDasAlgorithm(algorithm.id)
|
||||
...(algorithm.id === "gcc-phat"
|
||||
? { crossSpectrumBatchSize: DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE }
|
||||
: {}),
|
||||
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
|
||||
@@ -390,18 +454,49 @@ export async function localizeMultichannel(options) {
|
||||
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
|
||||
throwIfAborted(options.signal);
|
||||
const processingStartedAt = monotonicNow();
|
||||
const localization = await computeLocalizationMap({
|
||||
...plan.computeOptions,
|
||||
...(options.computeOptions ?? {}),
|
||||
channels: options.channels,
|
||||
sampleRate: plan.sampleRate,
|
||||
arrayDefinition: plan.arrayDefinition,
|
||||
scene: plan.scene,
|
||||
experiment: plan.experiment,
|
||||
analysisPlan: plan.analysisPlan,
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
});
|
||||
let localization;
|
||||
try {
|
||||
localization = await computeLocalizationMap({
|
||||
...plan.computeOptions,
|
||||
...(options.computeOptions ?? {}),
|
||||
channels: options.channels,
|
||||
sampleRate: plan.sampleRate,
|
||||
arrayDefinition: plan.arrayDefinition,
|
||||
scene: plan.scene,
|
||||
experiment: plan.experiment,
|
||||
analysisPlan: plan.analysisPlan,
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
plan.algorithm !== "music" ||
|
||||
!isMusicUnavailableError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
localization = {
|
||||
algorithm: "music",
|
||||
backend: "cpu",
|
||||
status: "unavailable",
|
||||
unavailable: {
|
||||
code: error.code,
|
||||
reason: error.reason,
|
||||
message: error.message,
|
||||
details: { ...error.details },
|
||||
},
|
||||
frameCount: getCompleteFrameCount(
|
||||
sampleCount,
|
||||
plan.stft.fftSize,
|
||||
plan.stft.hopSize,
|
||||
),
|
||||
microphoneCount: plan.channelCount,
|
||||
pointCount: plan.scan.rows * plan.scan.columns,
|
||||
objectiveDirection: "minimum",
|
||||
rawScale: "noise-projection",
|
||||
bands: [],
|
||||
};
|
||||
}
|
||||
const locations = locationsFromBands(
|
||||
localization.bands,
|
||||
plan.scan,
|
||||
|
||||
@@ -52,6 +52,17 @@ export function getFrameCount(sampleCount, fftSize, hopSize) {
|
||||
return Math.max(1, 1 + Math.ceil(Math.max(0, sampleCount - fftSize) / hopSize));
|
||||
}
|
||||
|
||||
export function getCompleteFrameCount(sampleCount, fftSize, hopSize) {
|
||||
if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) {
|
||||
throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다.");
|
||||
}
|
||||
if (!Number.isInteger(sampleCount) || sampleCount < 0) {
|
||||
throw new RangeError("sampleCount must be a non-negative integer.");
|
||||
}
|
||||
if (sampleCount < fftSize) return 0;
|
||||
return 1 + Math.floor((sampleCount - fftSize) / hopSize);
|
||||
}
|
||||
|
||||
export async function extractStftBins(channels, options) {
|
||||
const {
|
||||
fftSize,
|
||||
@@ -61,6 +72,8 @@ export async function extractStftBins(channels, options) {
|
||||
onProgress,
|
||||
transformBatchSize = 32,
|
||||
workspace: providedWorkspace,
|
||||
spectrumPrecision = "float32",
|
||||
includePartialFrame = true,
|
||||
} = options;
|
||||
if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다.");
|
||||
const sampleCount = channels[0].length;
|
||||
@@ -70,16 +83,27 @@ export async function extractStftBins(channels, options) {
|
||||
if (!Number.isInteger(bin) || bin < 0 || bin > fftSize / 2) throw new Error(`유효하지 않은 FFT bin: ${bin}`);
|
||||
}
|
||||
|
||||
const frameCount = getFrameCount(sampleCount, fftSize, hopSize);
|
||||
if (typeof includePartialFrame !== "boolean") {
|
||||
throw new TypeError("includePartialFrame must be boolean.");
|
||||
}
|
||||
const frameCount = includePartialFrame
|
||||
? getFrameCount(sampleCount, fftSize, hopSize)
|
||||
: getCompleteFrameCount(sampleCount, fftSize, hopSize);
|
||||
const microphoneCount = channels.length;
|
||||
if (!Number.isInteger(transformBatchSize) || transformBatchSize < 1) {
|
||||
throw new Error("transformBatchSize는 1 이상의 정수여야 합니다.");
|
||||
}
|
||||
if (spectrumPrecision !== "float32" && spectrumPrecision !== "float64") {
|
||||
throw new RangeError('spectrumPrecision must be "float32" or "float64".');
|
||||
}
|
||||
const SpectrumArray = spectrumPrecision === "float64"
|
||||
? Float64Array
|
||||
: Float32Array;
|
||||
const spectra = new Map();
|
||||
for (const bin of uniqueBins) {
|
||||
spectra.set(bin, {
|
||||
real: new Float32Array(frameCount * microphoneCount),
|
||||
imaginary: new Float32Array(frameCount * microphoneCount),
|
||||
real: new SpectrumArray(frameCount * microphoneCount),
|
||||
imaginary: new SpectrumArray(frameCount * microphoneCount),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,7 +140,7 @@ export async function extractStftBins(channels, options) {
|
||||
}
|
||||
onProgress?.({ completedTransforms, totalTransforms });
|
||||
throwIfAborted(signal);
|
||||
return { frameCount, microphoneCount, spectra };
|
||||
return { frameCount, microphoneCount, spectra, spectrumPrecision };
|
||||
}
|
||||
|
||||
export function binsForBand(band, sampleRate, fftSize) {
|
||||
|
||||
Reference in New Issue
Block a user