feat: add physically explicit narrowband MUSIC

This commit is contained in:
2026-08-29 12:40:48 +09:00
parent 044be3cf92
commit 11fee76280
40 changed files with 6473 additions and 275 deletions
+60 -4
View File
@@ -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";
+480
View File
@@ -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,
};
}
+169 -37
View File
@@ -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,
+555
View File
@@ -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,
};
}
+3
View File
@@ -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
View File
@@ -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
+122 -27
View File
@@ -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,
+28 -4
View File
@@ -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) {