feat: add standard full-band activity detectors

This commit is contained in:
2026-08-29 13:16:08 +09:00
parent 11fee76280
commit 3ebc0f84e0
9 changed files with 2514 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
# 전 대역 활동 검출 모델
이 문서는 위치 추정 전에 ADC 대역에서 관측되는 신호 변화를 찾는 보조 분석의
의미와 한계를 정의한다. 검출기는 Scene의 음원 주파수·위치·개수를 입력으로 받지
않으며, 검출 결과를 위치 정답이나 분석 대역으로 자동 변환하지 않는다.
## 관측 경계
- 입력은 사용자가 고른 **물리 마이크 채널 하나**의 시간 샘플이다. 여러 채널의
waveform 평균, power 평균, 최대값, 투표는 배열 형상과 TDOA에 따라 다른 결과를
만들기 때문에 기본 알고리즘에 포함하지 않는다.
- 실수 ADC의 관측 범위는 \(0\ldots F_s/2\)이다. anti-alias filter가 없는 현재
시뮬레이션에서는 \(F_s/2\)를 넘는 아날로그 성분도 샘플링되지만, 검출기가 보는
것은 접힌 ADC bin이다.
- 검출된 ADC bin만으로 아날로그 alias order, spectral inversion 전의 원 주파수,
음원 수 또는 위치를 식별할 수 없다. 배열 설계 비교에서는 같은 ADC bin에
대응하는 아날로그 주파수 가설을 별도 Experiment band로 명시해 비교한다.
- 정확한 DC와 Nyquist bin은 실수 성분 하나뿐이어서 내부 complex FFT bin과
통계 모델이 다르다. CFAR의 CUT와 reference cell에서 제외한다.
## CA-CFAR와 OS-CFAR
CFAR는 한 STFT frame 안의 좁은 spectral outlier를 찾는다. frame 또는 마이크
사이의 평균을 내지 않고 각 frame을 독립적으로 검사한다. 좌우 training cell을
각각 \(R\)개, guard cell을 각각 \(G\)개 쓰며 \(N=2R\)이다.
CA-CFAR는 training power의 평균 \(Z\)를 사용한다.
\[
Y_b > \alpha Z,\qquad
\alpha=N\left(P_{fa}^{-1/N}-1\right)
\]
OS-CFAR는 training power를 오름차순 정렬한 뒤 명시한 1-based rank \(k\)의
\(Y_{(k)}\)를 사용한다.
\[
Y_b > \alpha Y_{(k)},\qquad
P_{fa}=\prod_{j=0}^{k-1}\frac{N-j}{N-j+\alpha}
\]
OS-CFAR multiplier는 이 단조식을 수치적으로 풀어 구한다. 다른 \(N,k\)의 값을
재사용하지 않는다. 두 방식은 독립 결과이며 합의, winner 선택 또는 위치 map
융합을 하지 않는다.
여기서 \(P_{fa}\)는 CUT와 모든 training cell의 power가 독립·동일 평균의 지수
분포라는 square-law complex-Gaussian 모델 아래에서만 성립하는 **tested CUT
하나당 명목 오경보율**이다. posterior probability, confidence, frame 전체의
오경보율 또는 실측 보정값이 아니다. Hann window의 인접 bin 상관, 마이크 주파수
응답, colored noise, 잔향, noise-floor 경계와 off-bin leakage는 이 전제를
깨뜨린다. 결과에는 원 power, reference statistic, threshold, ratio와
`model-derived-not-empirically-calibrated` 상태를 그대로 남긴다.
full symmetric reference window를 만들 수 없는 edge bin은 reference 수를
줄이거나 wrap하지 않고 `untested`로 남긴다. 검출된 모든 bin을 반환하며 top-K,
NMS, gap merge, persistence 또는 임의 대역 padding을 적용하지 않는다.
CA-CFAR는 [Finn–Johnson의 adaptive threshold
detector](https://www.rsp-italy.it/Electronics/Magazines/RCA%20Review/_contents/RCA%20Review%201968-09.pdf)를
기준선으로 삼고, OS-CFAR는 다중 target/clutter 환경을 다룬
[Rohling의 order-statistic CFAR](https://doi.org/10.1109/TAES.1983.309350)를
따른다. 비균질 배경에서의 한계는
[Gandhi–Kassam](https://doi.org/10.1109/7.7185), window leakage의 해석은
[Harris](https://doi.org/10.1109/PROC.1978.10837)를 참고한다.
## Spectral flux onset
갑자기 시작되는 broadband 또는 tonal 변화를 보는 별도 지표는
[Dixon의 linear-magnitude spectral
flux](https://www.dafx.de/paper-archive/2006/papers/p_133.pdf)를 사용한다.
Hamming-window STFT의 연속 frame에 대해
\[
d[n,k]=\max(0,|X[n,k]|-|X[n-1,k]|),\qquad
SF[n]=\sum_k d[n,k]
\]
를 계산한다. per-bin 양의 차이와 합계 원값을 모두 보존하며 log compression,
전역 정규화, SuperFlux 일부 또는 마이크 융합을 섞지 않는다.
실시간 peak 선택은
[Böck·Krebs·Schedl의 causal peak
picker](https://www.cp.jku.at/research/papers/Boeck_etal_ISMIR_2012.pdf)를
별도 단계로 쓸 수 있다. 현재 frame이 과거 \(w_1\) frame의 local maximum이고,
과거 \(w_3\) frame 평균보다 사용자가 지정한 \(\delta\)만큼 크며, 마지막
검출에서 \(w_5\) frame보다 멀 때 onset으로 표시한다. \(\delta\)는 확률이 아닌
운용 임계값이고 환경 데이터로 보정해야 한다. 논문에서 음악 데이터로 최적화한
값을 보편 default나 물리 상수로 취급하지 않는다.
Spectral flux는 **onset novelty**이지 지속 activity detector가 아니다. 정상
상태의 tone은 첫 상승 뒤 flux가 거의 0이 되며, slow attack은 여러 frame에
분산되어 threshold를 넘지 못할 수 있다. vibrato, tremolo와 확률적 noise
변화는 false trigger를 만들 수 있다. STFT frame을 모두 받은 뒤에야 판단할 수
있으므로 실제 availability latency는 최소 \(N/F_s\), 갱신 간격은 \(H/F_s\)다.
## 배열 설계에서의 사용 순서
1. 물리 마이크 한 개와 검출 알고리즘·파라미터를 명시한다.
2. raw ADC-bin 검출 또는 raw onset curve와 모든 설정을 비교한다.
3. 관심 ADC bin을 선택하되, 이것을 주파수 정답이나 bandwidth 추정으로 부르지
않는다.
4. baseband 또는 명시한 초과-Nyquist 아날로그 가설을 Experiment band로
추가한다.
5. 같은 Scene·Array·가설에서 DAS, PHAT, GCC-PHAT, MUSIC의 raw map과 실패
양상을 비교한다. 서로 다른 band의 map은 알려진 별도 wideband 방법을
구현하기 전까지 자동 결합하지 않는다.
+345
View File
@@ -0,0 +1,345 @@
import { detectCfarFrame } from "./cfar.js";
import { cooperativeYield } from "./cooperative-yield.js";
import { fftInPlace } from "./fft.js";
import { getCompleteFrameCount, hannWindow } from "./stft.js";
export const DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT = 4_096;
export const DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS = 2_000_000;
export const DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES = 100_000_000;
function requirePositiveSafeInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new RangeError(label + " must be a positive safe integer.");
}
return value;
}
function requireNonNegativeSafeInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new RangeError(label + " must be a non-negative safe integer.");
}
return value;
}
function requirePositiveFinite(value, label) {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(label + " must be a positive finite number.");
}
return value;
}
function requireSingleChannel(samples) {
if (
(!Array.isArray(samples) && !ArrayBuffer.isView(samples)) ||
samples instanceof DataView
) {
throw new TypeError(
"samples must be one numeric channel; channel collections and DataView are not accepted.",
);
}
for (let index = 0; index < samples.length; index += 1) {
if (typeof samples[index] !== "number" || !Number.isFinite(samples[index])) {
throw new TypeError("samples[" + index + "] must be finite.");
}
}
return samples;
}
function checkedProduct(values, label) {
let product = 1;
for (const value of values) {
product *= value;
if (!Number.isSafeInteger(product)) {
throw new RangeError(label + " exceeds the safe integer range.");
}
}
return product;
}
function throwIfAborted(signal) {
if (!signal?.aborted) return;
const error = new Error("Full-band activity analysis was canceled.");
error.name = "AbortError";
throw error;
}
export function estimateCfarActivityWorkload({
sampleCount,
fftSize,
hopSize,
}) {
requireNonNegativeSafeInteger(sampleCount, "sampleCount");
requirePositiveSafeInteger(fftSize, "fftSize");
requirePositiveSafeInteger(hopSize, "hopSize");
if (fftSize < 4 || (fftSize & (fftSize - 1)) !== 0) {
throw new RangeError("fftSize must be a power of two greater than or equal to four.");
}
if (hopSize > fftSize) {
throw new RangeError("hopSize must not exceed fftSize.");
}
const frameCount = getCompleteFrameCount(sampleCount, fftSize, hopSize);
const binCount = fftSize / 2 + 1;
const binDecisionCount = checkedProduct(
[frameCount, binCount],
"CFAR bin decision count",
);
const fftButterfliesPerFrame =
(fftSize / 2) * Math.log2(fftSize);
const fftButterflyCount = checkedProduct(
[frameCount, fftButterfliesPerFrame],
"CFAR FFT butterfly count",
);
const workingBytes =
(3 * fftSize + binCount) * Float64Array.BYTES_PER_ELEMENT;
return Object.freeze({
frameCount,
binCount,
binDecisionCount,
fftButterfliesPerFrame,
fftButterflyCount,
workingBytes,
outputRecordModel: "one-bin-object-per-frame-without-truncation",
});
}
function requireLimit(value, fallback, label) {
return requirePositiveSafeInteger(value ?? fallback, label);
}
function compactFrameResult(frameResult, fftSize, hopSize) {
return Object.freeze({
frameIndex: frameResult.frame.frameIndex,
frameStartSample: frameResult.frame.frameStartSample,
frameCenterSample:
frameResult.frame.frameStartSample + (fftSize - 1) / 2,
frameAvailableAfterSample:
frameResult.frame.frameStartSample + fftSize,
updateHopSamples: hopSize,
testedBinCount: frameResult.parameters.testedBinCount,
untestedBinCount: frameResult.parameters.untestedBinCount,
detectedBinCount: frameResult.parameters.detectedBinCount,
bins: frameResult.bins,
});
}
function sharedDetectorModel(frameResult, fftSize, hopSize, frameCount) {
const {
testedBinCount,
untestedBinCount,
detectedBinCount,
...sharedParameters
} = frameResult.parameters;
void testedBinCount;
void untestedBinCount;
void detectedBinCount;
return Object.freeze({
detector: frameResult.detector,
spectrum: Object.freeze({
...frameResult.spectrum,
statistic:
"symmetric-hann-windowed-dft-squared-magnitude-per-one-sided-bin",
powerNormalization: "none",
}),
parameters: Object.freeze(sharedParameters),
calibration: frameResult.calibration,
processing: frameResult.processing,
timeAnalysis: Object.freeze({
frameCount,
fftSize,
hopSize,
completeFramesOnly: true,
partialFramePadding: false,
temporalAveraging: false,
temporalDecisionFusion: false,
overlappingFrames:
hopSize < fftSize,
frameIndependenceClaimed: false,
}),
});
}
/**
* Run standard CA-CFAR or OS-CFAR independently on every complete STFT frame
* from one explicitly selected physical channel.
*
* Every tested and untested ADC bin is retained. This function does not merge
* neighboring bins, select peaks, infer analog alias order, combine frames,
* combine microphones, or feed a localization plan.
*/
export async function analyzeCfarActivity(options) {
if (!options || typeof options !== "object") {
throw new TypeError("activity options must be an object.");
}
const samples = requireSingleChannel(options.samples);
const sampleRate = requirePositiveFinite(options.sampleRate, "sampleRate");
const fftSize = requirePositiveSafeInteger(options.fftSize, "fftSize");
const hopSize = requirePositiveSafeInteger(options.hopSize, "hopSize");
if (typeof options.channelId !== "string" || options.channelId.length === 0) {
throw new TypeError("channelId must be a non-empty string.");
}
if (options.window !== "hann") {
throw new RangeError('window must be "hann".');
}
const transformBatchSize = requirePositiveSafeInteger(
options.transformBatchSize ?? 16,
"transformBatchSize",
);
const maximumFrameCount = requireLimit(
options.maximumFrameCount,
DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT,
"maximumFrameCount",
);
const maximumBinDecisionCount = requireLimit(
options.maximumBinDecisionCount,
DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS,
"maximumBinDecisionCount",
);
const maximumFftButterflies = requireLimit(
options.maximumFftButterflies,
DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES,
"maximumFftButterflies",
);
const workload = estimateCfarActivityWorkload({
sampleCount: samples.length,
fftSize,
hopSize,
});
if (workload.frameCount === 0) {
throw new RangeError(
"samples must contain at least one complete activity-analysis frame.",
);
}
if (workload.frameCount > maximumFrameCount) {
throw new RangeError(
"activity frame count " + workload.frameCount +
" exceeds maximumFrameCount " + maximumFrameCount + ".",
);
}
if (workload.binDecisionCount > maximumBinDecisionCount) {
throw new RangeError(
"activity bin decision count " + workload.binDecisionCount +
" exceeds maximumBinDecisionCount " + maximumBinDecisionCount + ".",
);
}
if (workload.fftButterflyCount > maximumFftButterflies) {
throw new RangeError(
"activity FFT butterfly count " + workload.fftButterflyCount +
" exceeds maximumFftButterflies " + maximumFftButterflies + ".",
);
}
const window = hannWindow(fftSize);
const real = new Float64Array(fftSize);
const imaginary = new Float64Array(fftSize);
const powerSpectrum = new Float64Array(workload.binCount);
const frames = new Array(workload.frameCount);
let detectedBinCount = 0;
let detectorModel = null;
throwIfAborted(options.signal);
for (let frameIndex = 0; frameIndex < workload.frameCount; frameIndex += 1) {
const frameStartSample = frameIndex * hopSize;
imaginary.fill(0);
for (let offset = 0; offset < fftSize; offset += 1) {
real[offset] = samples[frameStartSample + offset] * window[offset];
}
fftInPlace(real, imaginary);
for (let bin = 0; bin < workload.binCount; bin += 1) {
const power =
real[bin] * real[bin] + imaginary[bin] * imaginary[bin];
if (!Number.isFinite(power)) {
throw new RangeError(
"STFT power became non-finite at frame " + frameIndex +
", bin " + bin + ".",
);
}
powerSpectrum[bin] = power;
}
const frameResult = detectCfarFrame({
detector: options.detector,
powerSpectrum,
sampleRate,
fftSize,
window: "hann",
frameComplete: true,
channelId: options.channelId,
frameIndex,
frameStartSample,
temporalAverageCount: 1,
microphoneAverageCount: 1,
trainingCellsPerSide: options.trainingCellsPerSide,
guardCellsPerSide: options.guardCellsPerSide,
nominalFalseAlarmProbability:
options.nominalFalseAlarmProbability,
...(options.detector === "os-cfar"
? { rank: options.rank }
: {}),
});
detectorModel ??= sharedDetectorModel(
frameResult,
fftSize,
hopSize,
workload.frameCount,
);
frames[frameIndex] = compactFrameResult(
frameResult,
fftSize,
hopSize,
);
detectedBinCount += frameResult.parameters.detectedBinCount;
if (
(frameIndex + 1) % transformBatchSize === 0 &&
frameIndex + 1 < workload.frameCount
) {
options.onProgress?.({
stage: "activity-cfar",
completedFrames: frameIndex + 1,
totalFrames: workload.frameCount,
});
await cooperativeYield();
throwIfAborted(options.signal);
}
}
options.onProgress?.({
stage: "activity-cfar",
completedFrames: workload.frameCount,
totalFrames: workload.frameCount,
});
throwIfAborted(options.signal);
return {
kind: "micarrsim.cfar-activity-analysis/1",
channel: Object.freeze({
mode: "one-explicit-physical-channel",
channelId: options.channelId,
microphoneFusion: "none",
}),
input: Object.freeze({
sampleRate,
sampleCount: samples.length,
adcObservableMinimumHz: 0,
adcObservableMaximumHz: sampleRate / 2,
analogFrequencyOrAliasOrderInferred: false,
sceneTruthUsed: false,
}),
detectorModel,
workload: Object.freeze({
...workload,
limits: Object.freeze({
maximumFrameCount,
maximumBinDecisionCount,
maximumFftButterflies,
}),
}),
summary: Object.freeze({
frameCount: workload.frameCount,
binRecordCount: workload.binDecisionCount,
detectedBinCount,
selectionApplied: false,
clusteringApplied: false,
localizationApplied: false,
}),
frames: Object.freeze(frames),
};
}
+622
View File
@@ -0,0 +1,622 @@
const CFAR_FRAME_RESULT_KIND = "micarrsim.cfar-frame-result/1";
export const DEFAULT_OS_CFAR_MAXIMUM_BISECTION_ITERATIONS = 256;
function requirePositiveSafeInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new TypeError(`${label} must be a positive safe integer.`);
}
return value;
}
function requireNonNegativeSafeInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`${label} must be a non-negative safe integer.`);
}
return value;
}
function requirePositiveFinite(value, label) {
if (!Number.isFinite(value) || value <= 0) {
throw new TypeError(`${label} must be a positive finite number.`);
}
return value;
}
function requireProbability(value, label) {
if (!Number.isFinite(value) || value <= 0 || value >= 1) {
throw new RangeError(`${label} must be finite and strictly inside 0..1.`);
}
return value;
}
function requireTrainingCellCount(trainingCellCount) {
return requirePositiveSafeInteger(trainingCellCount, "trainingCellCount");
}
function requireOsRank(rank, trainingCellCount) {
if (
!Number.isSafeInteger(rank) ||
rank < 1 ||
rank > trainingCellCount
) {
throw new RangeError(
`rank must be a 1-based ascending order-statistic index inside 1..${trainingCellCount}.`,
);
}
return rank;
}
/**
* CA-CFAR multiplier for an arithmetic-mean reference statistic.
*
* Under independent, identically distributed exponential CUT/reference
* powers, Pfa = (1 + alpha / N)^(-N). The `expm1` form preserves accuracy
* when the requested probability is close to one or N is large.
*/
export function computeCaCfarThresholdMultiplier({
trainingCellCount,
nominalFalseAlarmProbability,
}) {
const count = requireTrainingCellCount(trainingCellCount);
const probability = requireProbability(
nominalFalseAlarmProbability,
"nominalFalseAlarmProbability",
);
const multiplier = count * Math.expm1(-Math.log(probability) / count);
if (!Number.isFinite(multiplier) || multiplier <= 0) {
throw new RangeError(
"CA-CFAR threshold multiplier exceeds the finite positive Float64 range.",
);
}
return multiplier;
}
/**
* Log false-alarm probability for OS-CFAR with an ascending 1-based rank.
*
* log(Pfa) = sum_(j=0)^(k-1) -log(1 + alpha / (N - j)).
* The log product avoids factorial/gamma overflow and is also used by the
* bracketed multiplier solver below.
*/
export function computeOsCfarLogFalseAlarmProbability({
trainingCellCount,
rank,
thresholdMultiplier,
}) {
const count = requireTrainingCellCount(trainingCellCount);
const selectedRank = requireOsRank(rank, count);
if (!Number.isFinite(thresholdMultiplier) || thresholdMultiplier < 0) {
throw new RangeError(
"thresholdMultiplier must be finite and non-negative.",
);
}
let sum = 0;
let compensation = 0;
for (let offset = 0; offset < selectedRank; offset += 1) {
const term = -Math.log1p(thresholdMultiplier / (count - offset));
const next = sum + term;
const correction = Math.abs(sum) >= Math.abs(term)
? (sum - next) + term
: (term - next) + sum;
sum = next;
compensation += correction;
}
return sum + compensation;
}
function solveOsCfarThresholdMultiplier({
trainingCellCount,
rank,
nominalFalseAlarmProbability,
maximumIterations,
}) {
const count = requireTrainingCellCount(trainingCellCount);
const selectedRank = requireOsRank(rank, count);
const probability = requireProbability(
nominalFalseAlarmProbability,
"nominalFalseAlarmProbability",
);
const iterationLimit = requirePositiveSafeInteger(
maximumIterations,
"maximumIterations",
);
const targetLogProbability = Math.log(probability);
const evaluate = (thresholdMultiplier) =>
computeOsCfarLogFalseAlarmProbability({
trainingCellCount: count,
rank: selectedRank,
thresholdMultiplier,
});
let lowerMultiplier = 0;
let upperMultiplier = 1;
let upperLogProbability = evaluate(upperMultiplier);
let bracketDoublings = 0;
while (upperLogProbability > targetLogProbability) {
if (upperMultiplier >= Number.MAX_VALUE / 2) {
upperMultiplier = Number.MAX_VALUE;
upperLogProbability = evaluate(upperMultiplier);
if (upperLogProbability > targetLogProbability) {
throw new RangeError(
"OS-CFAR threshold multiplier exceeds the finite Float64 range.",
);
}
break;
}
upperMultiplier *= 2;
upperLogProbability = evaluate(upperMultiplier);
bracketDoublings += 1;
}
const logarithmTolerance =
64 * Number.EPSILON *
Math.max(1, selectedRank, Math.abs(targetLogProbability));
let iterations = 0;
let multiplier = upperMultiplier;
let logarithmResidual = upperLogProbability - targetLogProbability;
let converged = Math.abs(logarithmResidual) <= logarithmTolerance;
while (!converged && iterations < iterationLimit) {
const midpoint =
lowerMultiplier + (upperMultiplier - lowerMultiplier) / 2;
if (midpoint === lowerMultiplier || midpoint === upperMultiplier) {
multiplier = Math.abs(logarithmResidual) <= logarithmTolerance
? upperMultiplier
: midpoint;
const midpointLogProbability = evaluate(multiplier);
logarithmResidual = midpointLogProbability - targetLogProbability;
converged =
Math.abs(logarithmResidual) <= logarithmTolerance ||
midpoint === lowerMultiplier ||
midpoint === upperMultiplier;
break;
}
const midpointLogProbability = evaluate(midpoint);
logarithmResidual = midpointLogProbability - targetLogProbability;
multiplier = midpoint;
iterations += 1;
if (Math.abs(logarithmResidual) <= logarithmTolerance) {
converged = true;
break;
}
if (midpointLogProbability > targetLogProbability) {
lowerMultiplier = midpoint;
} else {
upperMultiplier = midpoint;
upperLogProbability = midpointLogProbability;
}
}
if (!converged) {
throw new RangeError(
`OS-CFAR threshold multiplier did not converge within ${iterationLimit} bisection iterations.`,
);
}
if (!Number.isFinite(multiplier) || multiplier <= 0) {
throw new RangeError(
"OS-CFAR threshold multiplier exceeds the finite positive Float64 range.",
);
}
return Object.freeze({
multiplier,
iterations,
bracketDoublings,
targetLogProbability,
logarithmResidual,
logarithmTolerance,
converged: true,
});
}
/** Solve the standard exponential-background OS-CFAR multiplier by bisection. */
export function computeOsCfarThresholdMultiplier({
trainingCellCount,
rank,
nominalFalseAlarmProbability,
maximumIterations = DEFAULT_OS_CFAR_MAXIMUM_BISECTION_ITERATIONS,
}) {
return solveOsCfarThresholdMultiplier({
trainingCellCount,
rank,
nominalFalseAlarmProbability,
maximumIterations,
}).multiplier;
}
function requirePowerSpectrum(powerSpectrum, fftSize) {
if (powerSpectrum == null || !Number.isSafeInteger(powerSpectrum.length)) {
throw new TypeError("powerSpectrum must be a finite numeric array-like value.");
}
const expectedBinCount = fftSize / 2 + 1;
if (powerSpectrum.length !== expectedBinCount) {
throw new RangeError(
`powerSpectrum must contain exactly ${expectedBinCount} one-sided bins for fftSize ${fftSize}.`,
);
}
for (let bin = 0; bin < expectedBinCount; bin += 1) {
const power = powerSpectrum[bin];
if (typeof power !== "number" || !Number.isFinite(power)) {
throw new TypeError(`powerSpectrum[${bin}] must be finite.`);
}
if (power < 0) {
throw new RangeError(`powerSpectrum[${bin}] must be non-negative.`);
}
}
return expectedBinCount;
}
function scaledArithmeticMean(values) {
let scale = 0;
for (const value of values) scale = Math.max(scale, value);
if (scale === 0) return 0;
let sum = 0;
let compensation = 0;
for (const value of values) {
const normalized = value / scale;
const next = sum + normalized;
const correction = Math.abs(sum) >= Math.abs(normalized)
? (sum - next) + normalized
: (normalized - next) + sum;
sum = next;
compensation += correction;
}
return ((sum + compensation) / values.length) * scale;
}
function freezeArray(values) {
return Object.freeze(values);
}
function createFrequencyMetadata(bin, binWidthHz, nyquistHz) {
return {
adcBin: bin,
adcCenterFrequencyHz: bin * binWidthHz,
adcFftCellHz: Object.freeze({
minimumHz: Math.max(0, (bin - 0.5) * binWidthHz),
maximumHz: Math.min(nyquistHz, (bin + 0.5) * binWidthHz),
}),
};
}
function createUntestedBin({
bin,
cutPower,
binWidthHz,
nyquistHz,
reason,
}) {
return Object.freeze({
...createFrequencyMetadata(bin, binWidthHz, nyquistHz),
status: "untested",
reason,
cutPower,
referencePower: null,
thresholdPower: null,
thresholdRatio: null,
thresholdRatioStatus: "unavailable-untested-bin",
});
}
function resolveDetectorConfiguration(options, nyquistBin) {
const detector = options.detector;
if (detector !== "ca-cfar" && detector !== "os-cfar") {
throw new RangeError('detector must be "ca-cfar" or "os-cfar".');
}
const trainingCellsPerSide = requirePositiveSafeInteger(
options.trainingCellsPerSide,
"trainingCellsPerSide",
);
const guardCellsPerSide = requireNonNegativeSafeInteger(
options.guardCellsPerSide,
"guardCellsPerSide",
);
if (
trainingCellsPerSide >
Number.MAX_SAFE_INTEGER - guardCellsPerSide
) {
throw new RangeError("CFAR reference radius exceeds the safe integer range.");
}
const referenceRadius = trainingCellsPerSide + guardCellsPerSide;
if (referenceRadius > (nyquistBin - 2) / 2) {
throw new RangeError(
"CFAR training and guard cells leave no interior bin with a full symmetric reference window.",
);
}
const trainingCellCount = 2 * trainingCellsPerSide;
if (!Number.isSafeInteger(trainingCellCount)) {
throw new RangeError("trainingCellCount exceeds the safe integer range.");
}
const nominalFalseAlarmProbability = requireProbability(
options.nominalFalseAlarmProbability,
"nominalFalseAlarmProbability",
);
if (detector === "ca-cfar") {
if (options.rank !== undefined) {
throw new RangeError("rank is only valid for OS-CFAR.");
}
return {
detector,
trainingCellsPerSide,
guardCellsPerSide,
referenceRadius,
trainingCellCount,
nominalFalseAlarmProbability,
rank: null,
thresholdMultiplier: computeCaCfarThresholdMultiplier({
trainingCellCount,
nominalFalseAlarmProbability,
}),
thresholdMultiplierDerivation:
"N*expm1(-log(nominalPfa)/N)-for-arithmetic-mean-reference",
osSolver: null,
};
}
const rank = requireOsRank(options.rank, trainingCellCount);
const osSolver = solveOsCfarThresholdMultiplier({
trainingCellCount,
rank,
nominalFalseAlarmProbability,
maximumIterations:
options.maximumOsBisectionIterations ??
DEFAULT_OS_CFAR_MAXIMUM_BISECTION_ITERATIONS,
});
return {
detector,
trainingCellsPerSide,
guardCellsPerSide,
referenceRadius,
trainingCellCount,
nominalFalseAlarmProbability,
rank,
thresholdMultiplier: osSolver.multiplier,
thresholdMultiplierDerivation:
"log-product-bracketed-bisection-for-ascending-order-statistic",
osSolver,
};
}
/**
* Detect narrowband activity in one complete, single-channel, one-sided power
* spectrum. No temporal/microphone averaging, bin grouping, peak selection,
* persistence, clustering, or detector fusion is performed.
*/
export function detectCfarFrame(options) {
if (!options || typeof options !== "object") {
throw new TypeError("CFAR options must be an object.");
}
const sampleRate = requirePositiveFinite(options.sampleRate, "sampleRate");
const fftSize = requirePositiveSafeInteger(options.fftSize, "fftSize");
if (fftSize < 4 || fftSize % 2 !== 0) {
throw new RangeError("fftSize must be an even integer greater than or equal to four.");
}
if (options.window !== "hann") {
throw new RangeError('window must be "hann".');
}
if (options.frameComplete !== true) {
throw new RangeError("frameComplete must be true; partial frames are not tested.");
}
if (typeof options.channelId !== "string" || options.channelId.length === 0) {
throw new TypeError("channelId must be a non-empty string.");
}
const frameIndex = requireNonNegativeSafeInteger(
options.frameIndex,
"frameIndex",
);
const frameStartSample = requireNonNegativeSafeInteger(
options.frameStartSample,
"frameStartSample",
);
const temporalAverageCount = options.temporalAverageCount ?? 1;
const microphoneAverageCount = options.microphoneAverageCount ?? 1;
if (temporalAverageCount !== 1) {
throw new RangeError(
"temporalAverageCount must equal one; the exponential CFAR multiplier is not valid after temporal averaging.",
);
}
if (microphoneAverageCount !== 1) {
throw new RangeError(
"microphoneAverageCount must equal one; microphone averaging or fusion is not performed.",
);
}
const binCount = requirePowerSpectrum(options.powerSpectrum, fftSize);
const nyquistBin = fftSize / 2;
const configuration = resolveDetectorConfiguration(options, nyquistBin);
const {
detector,
trainingCellsPerSide,
guardCellsPerSide,
referenceRadius,
trainingCellCount,
nominalFalseAlarmProbability,
rank,
thresholdMultiplier,
thresholdMultiplierDerivation,
osSolver,
} = configuration;
const nyquistHz = sampleRate / 2;
const binWidthHz = sampleRate / fftSize;
const firstTestedBin = referenceRadius + 1;
const lastTestedBin = nyquistBin - referenceRadius - 1;
const referencePowers = new Float64Array(trainingCellCount);
const bins = new Array(binCount);
let testedBinCount = 0;
let detectedBinCount = 0;
for (let bin = 0; bin < binCount; bin += 1) {
const cutPower = options.powerSpectrum[bin];
let untestedReason = null;
if (bin === 0) {
untestedReason = "real-only-dc-bin";
} else if (bin === nyquistBin) {
untestedReason = "real-only-nyquist-bin";
} else if (bin < firstTestedBin || bin > lastTestedBin) {
untestedReason = "insufficient-full-symmetric-reference-window";
}
if (untestedReason !== null) {
bins[bin] = createUntestedBin({
bin,
cutPower,
binWidthHz,
nyquistHz,
reason: untestedReason,
});
continue;
}
let referenceIndex = 0;
const leftStart = bin - guardCellsPerSide - trainingCellsPerSide;
const leftEnd = bin - guardCellsPerSide;
for (let referenceBin = leftStart; referenceBin < leftEnd; referenceBin += 1) {
referencePowers[referenceIndex] = options.powerSpectrum[referenceBin];
referenceIndex += 1;
}
const rightStart = bin + guardCellsPerSide + 1;
const rightEnd = rightStart + trainingCellsPerSide;
for (let referenceBin = rightStart; referenceBin < rightEnd; referenceBin += 1) {
referencePowers[referenceIndex] = options.powerSpectrum[referenceBin];
referenceIndex += 1;
}
if (referenceIndex !== trainingCellCount) {
throw new Error("CFAR reference-window construction violated fixed N=2R.");
}
let referencePower;
if (detector === "ca-cfar") {
referencePower = scaledArithmeticMean(referencePowers);
} else {
referencePowers.sort();
referencePower = referencePowers[rank - 1];
}
const thresholdPower = thresholdMultiplier * referencePower;
if (!Number.isFinite(thresholdPower)) {
throw new RangeError(
`CFAR threshold exceeds the finite Float64 range at bin ${bin}.`,
);
}
const detected = cutPower > thresholdPower;
let thresholdRatio;
let thresholdRatioStatus;
if (thresholdPower === 0) {
if (cutPower === 0) {
thresholdRatio = null;
thresholdRatioStatus = "indeterminate-zero-over-zero";
} else {
thresholdRatio = null;
thresholdRatioStatus = "positive-infinity-zero-threshold";
}
} else {
thresholdRatio = cutPower / thresholdPower;
thresholdRatioStatus = "finite";
}
bins[bin] = Object.freeze({
...createFrequencyMetadata(bin, binWidthHz, nyquistHz),
status: detected ? "detected" : "not-detected",
reason: null,
cutPower,
referencePower,
thresholdPower,
thresholdRatio,
thresholdRatioStatus,
});
testedBinCount += 1;
if (detected) detectedBinCount += 1;
}
return {
kind: CFAR_FRAME_RESULT_KIND,
detector,
frame: Object.freeze({
channelId: options.channelId,
frameIndex,
frameStartSample,
complete: true,
temporalAverageCount: 1,
microphoneAverageCount: 1,
}),
spectrum: Object.freeze({
domain: "one-sided-real-adc-power-spectrum",
statistic: "caller-supplied-non-negative-power-per-bin",
sampleRate,
fftSize,
binCount,
binWidthHz,
nyquistHz,
window: "hann",
windowDefinition: "symmetric-hann-N-minus-one",
analogFrequencyOrAliasOrderInferred: false,
}),
parameters: Object.freeze({
trainingCellsPerSide,
guardCellsPerSide,
trainingCellCount,
referenceStatistic: detector === "ca-cfar"
? "arithmetic-mean-power"
: "ascending-order-statistic-power",
rank,
rankConvention: detector === "os-cfar" ? "1-based-ascending" : null,
nominalFalseAlarmProbability,
nominalPfaPerTestedCutUnderIidExponentialModel:
nominalFalseAlarmProbability,
thresholdMultiplier,
thresholdMultiplierDerivation,
thresholdComparison: "cut-power-strictly-greater-than-threshold-power",
firstTestedBin: firstTestedBin <= lastTestedBin
? firstTestedBin
: null,
lastTestedBin: firstTestedBin <= lastTestedBin
? lastTestedBin
: null,
testedBinCount,
untestedBinCount: binCount - testedBinCount,
detectedBinCount,
osSolver,
}),
calibration: Object.freeze({
status: "nominal-model-only-not-empirically-calibrated",
falseAlarmScope: "one-tested-bin-in-one-frame",
model: "independent-identically-distributed-exponential-power-with-common-mean",
calibratedFalseAlarmProbability: null,
globalFrameFalseAlarmProbability: null,
assumptions: freezeArray([
"cut-and-training-powers-are-independent",
"cut-and-training-powers-have-one-common-local-mean",
"powers-are-exponential-square-law-complex-gaussian-statistics",
]),
notCalibratedReasons: freezeArray([
"hann-window-bin-independence-is-not-guaranteed",
"colored-acoustic-noise-and-microphone-response-are-not-verified-homogeneous",
"overlapping-sliding-reference-windows-share-cells",
]),
}),
processing: Object.freeze({
completeFramesOnly: true,
temporalAveraging: false,
microphoneAveraging: false,
peakSelection: false,
topKSelection: false,
clustering: false,
contiguousBinMerging: false,
temporalPersistence: false,
detectorFusion: false,
microphoneFusion: false,
bandFusion: false,
}),
bins: Object.freeze(bins),
};
}
export function detectCaCfarFrame(options) {
return detectCfarFrame({ ...options, detector: "ca-cfar" });
}
export function detectOsCfarFrame(options) {
return detectCfarFrame({ ...options, detector: "os-cfar" });
}
+3
View File
@@ -1,3 +1,5 @@
export * from "./activity-monitor.js";
export * from "./cfar.js";
export * from "./das.js";
export * from "./evaluation.js";
export * from "./environment.js";
@@ -16,6 +18,7 @@ export * from "./covariance.js";
export * from "./propagation.js";
export * from "./realtime-localizer.js";
export * from "./signal.js";
export * from "./spectral-flux.js";
export * from "./stft.js";
export * from "./trajectory.js";
export * from "./wav.js";
+419
View File
@@ -0,0 +1,419 @@
import { fftInPlace } from "./fft.js";
const DIXON_2006_URL =
"https://www.dafx.de/paper-archive/2006/papers/p_133.pdf";
const BOECK_KREBS_SCHEDL_2012_URL =
"https://www.cp.jku.at/research/papers/Boeck_etal_ISMIR_2012.pdf";
const DEFAULT_MAXIMUM_OUTPUT_BYTES = 64 * 1024 * 1024;
function requireConfigurationObject(value, label) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label} must be an object.`);
}
return value;
}
function requireOwn(configuration, key) {
if (!Object.prototype.hasOwnProperty.call(configuration, key)) {
throw new TypeError(`${key} must be provided explicitly.`);
}
return configuration[key];
}
function requirePositiveFinite(value, label) {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(`${label} must be finite and greater than zero.`);
}
return value;
}
function requireNonNegativeFinite(value, label) {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${label} must be finite and non-negative.`);
}
return value;
}
function requireNonNegativeSafeInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new RangeError(`${label} must be a non-negative safe integer.`);
}
return value;
}
function requirePowerOfTwo(value, label) {
if (
!Number.isSafeInteger(value) ||
value < 2 ||
value > 0x20000000 ||
!Number.isInteger(Math.log2(value))
) {
throw new RangeError(
`${label} must be a power-of-two integer from 2 through 536870912.`,
);
}
return value;
}
function requireSingleChannelSamples(samples) {
const isTypedArray = ArrayBuffer.isView(samples) && !(samples instanceof DataView);
if (!Array.isArray(samples) && !isTypedArray) {
throw new TypeError("samples must be one numeric channel, not a channel collection.");
}
for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex += 1) {
if (typeof samples[sampleIndex] !== "number" || !Number.isFinite(samples[sampleIndex])) {
throw new TypeError(`samples[${sampleIndex}] must be finite.`);
}
}
return samples;
}
function symmetricHammingWindow(size) {
const window = new Float64Array(size);
for (let index = 0; index < size; index += 1) {
window[index] = 0.54 - 0.46 * Math.cos((2 * Math.PI * index) / (size - 1));
}
return window;
}
function checkedElementCount(frameCount, binCount) {
const elementCount = frameCount * binCount;
if (!Number.isSafeInteger(elementCount)) {
throw new RangeError("spectral-flux output element count exceeds safe integer range.");
}
return elementCount;
}
function checkedByteCount(elementCount, bytesPerElement, label) {
const byteCount = elementCount * bytesPerElement;
if (!Number.isSafeInteger(byteCount)) {
throw new RangeError(`${label} byte count exceeds safe integer range.`);
}
return byteCount;
}
/**
* Compute Simon Dixon's 2006 magnitude spectral-flux onset detection function.
*
* This function accepts exactly one numeric channel. It uses a symmetric
* Hamming window, complete non-padded frames, linear DFT magnitudes, positive
* (half-wave-rectified) frame differences, and an L1 sum over the full DFT.
* No per-frame or global normalization, microphone fusion, filterbank,
* maximum filter, location inference, or band inference is performed.
*/
export function computeDixonSpectralFlux(options) {
const configuration = requireConfigurationObject(options, "options");
const samples = requireSingleChannelSamples(requireOwn(configuration, "samples"));
const sampleRate = requirePositiveFinite(
requireOwn(configuration, "sampleRate"),
"sampleRate",
);
const fftSize = requirePowerOfTwo(
requireOwn(configuration, "fftSize"),
"fftSize",
);
const hopSize = requireNonNegativeSafeInteger(
requireOwn(configuration, "hopSize"),
"hopSize",
);
if (hopSize < 1 || hopSize > fftSize) {
throw new RangeError("hopSize must be between 1 and fftSize, inclusive.");
}
const channelId = requireOwn(configuration, "channelId");
if (typeof channelId !== "string" || channelId.length === 0) {
throw new TypeError("channelId must be a non-empty string.");
}
const maximumOutputBytes = configuration.maximumOutputBytes ?? DEFAULT_MAXIMUM_OUTPUT_BYTES;
if (!Number.isSafeInteger(maximumOutputBytes) || maximumOutputBytes <= 0) {
throw new RangeError("maximumOutputBytes must be a positive safe integer.");
}
const frameCount = samples.length < fftSize
? 0
: 1 + Math.floor((samples.length - fftSize) / hopSize);
const binCount = fftSize;
const positiveDifferenceElements = checkedElementCount(frameCount, binCount);
const positiveDifferenceBytes = checkedByteCount(
positiveDifferenceElements,
Float64Array.BYTES_PER_ELEMENT,
"positiveDifferences",
);
const rawOdfBytes = checkedByteCount(
frameCount,
Float64Array.BYTES_PER_ELEMENT,
"rawOdf",
);
const frameTimingBytes = checkedByteCount(
frameCount * 3,
Float64Array.BYTES_PER_ELEMENT,
"frame timing",
);
const outputBytes = positiveDifferenceBytes + rawOdfBytes + frameTimingBytes;
if (!Number.isSafeInteger(outputBytes)) {
throw new RangeError("spectral-flux output byte count exceeds safe integer range.");
}
if (outputBytes > maximumOutputBytes) {
throw new RangeError(
`spectral flux requires ${outputBytes.toLocaleString()} output bytes; ` +
`maximumOutputBytes is ${maximumOutputBytes.toLocaleString()}.`,
);
}
const workspaceElements = frameCount === 0 ? 0 : fftSize * 4;
const workspaceBytes = checkedByteCount(
workspaceElements,
Float64Array.BYTES_PER_ELEMENT,
"spectral-flux workspace",
);
const estimatedPeakTypedArrayBytes = outputBytes + workspaceBytes;
if (!Number.isSafeInteger(estimatedPeakTypedArrayBytes)) {
throw new RangeError("spectral-flux peak typed-array byte count exceeds safe integer range.");
}
const positiveDifferences = new Float64Array(positiveDifferenceElements);
const rawOdf = new Float64Array(frameCount);
const frameStartSamples = new Float64Array(frameCount);
const frameCenterSamples = new Float64Array(frameCount);
const frameAvailableAfterSamples = new Float64Array(frameCount);
const workspaceLength = frameCount === 0 ? 0 : fftSize;
const window = symmetricHammingWindow(workspaceLength);
const real = new Float64Array(workspaceLength);
const imaginary = new Float64Array(workspaceLength);
const previousMagnitudes = new Float64Array(workspaceLength);
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameStart = frameIndex * hopSize;
frameStartSamples[frameIndex] = frameStart;
frameCenterSamples[frameIndex] = frameStart + (fftSize - 1) / 2;
frameAvailableAfterSamples[frameIndex] = frameStart + fftSize;
imaginary.fill(0);
for (let offset = 0; offset < fftSize; offset += 1) {
real[offset] = samples[frameStart + offset] * window[offset];
}
fftInPlace(real, imaginary);
let frameFlux = 0;
const differenceOffset = frameIndex * binCount;
for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
const magnitude = Math.hypot(real[binIndex], imaginary[binIndex]);
if (!Number.isFinite(magnitude)) {
throw new RangeError(
`STFT magnitude became non-finite at frame ${frameIndex}, bin ${binIndex}.`,
);
}
const difference = frameIndex === 0
? 0
: Math.max(0, magnitude - previousMagnitudes[binIndex]);
positiveDifferences[differenceOffset + binIndex] = difference;
frameFlux += difference;
previousMagnitudes[binIndex] = magnitude;
}
if (!Number.isFinite(frameFlux)) {
throw new RangeError(`spectral flux became non-finite at frame ${frameIndex}.`);
}
rawOdf[frameIndex] = frameFlux;
}
return {
rawOdf,
positiveDifferences,
positiveDifferenceLayout: {
order: "frame-major",
frameStride: binCount,
binCount,
firstFrameHasNoPredecessor: true,
},
frameCount,
binCount,
sampleRate,
sampleCount: samples.length,
fftSize,
hopSize,
frameStartSamples,
frameCenterSamples,
frameAvailableAfterSamples,
workload: {
completeFrameCount: frameCount,
transformCount: frameCount,
binCount,
comparedBinCount: Math.max(0, frameCount - 1) * binCount,
positiveDifferenceElements,
positiveDifferenceBytes,
rawOdfBytes,
frameTimingBytes,
outputBytes,
maximumOutputBytes,
workspaceBytes,
estimatedPeakTypedArrayBytes,
},
latency: {
completeFramesOnly: true,
analysisWindowSamples: fftSize,
analysisWindowSeconds: fftSize / sampleRate,
updateHopSamples: hopSize,
updateHopSeconds: hopSize / sampleRate,
firstSpectrumAvailableAfterSamples: frameCount > 0 ? fftSize : null,
firstDifferenceAvailableAfterSamples: frameCount > 1 ? fftSize + hopSize : null,
timestampConvention:
"frameStartSamples/frameCenterSamples are descriptive; frameAvailableAfterSamples is causal availability",
},
provenance: {
algorithm: "dixon-2006-linear-magnitude-half-wave-l1-spectral-flux",
reference: {
title: "Onset Detection Revisited",
author: "Simon Dixon",
year: 2006,
url: DIXON_2006_URL,
},
input: {
channelMode: "one-explicit-channel",
channelId,
microphoneFusion: "none",
},
stft: {
window: "symmetric-hamming",
windowFormula: "0.54 - 0.46*cos(2*pi*n/(N-1))",
framePolicy: "complete-only-no-padding",
spectrumBins: "full-dft-bin-order-including-the-real-signal-negative-frequency-mirror",
},
magnitudeScale: "linear",
difference: "positive-only-half-wave-rectified",
frequencyAggregation: "unweighted-l1-sum-over-full-dft",
spectrumNormalization: "none",
globalNormalization: "none",
},
measurementSemantics: {
kind: "raw-onset-novelty",
sustainedActivityMeasure: false,
initialFrameValue: "zero-because-no-preceding-spectrum-exists",
},
limitations: {
steadySound:
"After the initial spectral rise, an exactly steady spectrum produces zero flux; finite precision and noise can leave small positive values.",
excludedInferences: [
"event probability",
"sustained activity duration",
"source count",
"source location",
"automatically selected frequency band",
],
},
};
}
/**
* Apply the causal online peak-picking conditions evaluated by Böck, Krebs,
* and Schedl (2012). All operating parameters are required from the caller.
* The local-maximum and adaptive-mean windows contain only the current and
* past frames; no future frames or global normalization are used.
*/
export function pickCausalSpectralFluxPeaks(rawOdfInput, options) {
const rawOdfSource = requireSingleChannelSamples(rawOdfInput);
const configuration = requireConfigurationObject(options, "options");
const delta = requireNonNegativeFinite(requireOwn(configuration, "delta"), "delta");
const preMaxFrames = requireNonNegativeSafeInteger(
requireOwn(configuration, "preMaxFrames"),
"preMaxFrames",
);
const preMeanFrames = requireNonNegativeSafeInteger(
requireOwn(configuration, "preMeanFrames"),
"preMeanFrames",
);
const refractoryFrames = requireNonNegativeSafeInteger(
requireOwn(configuration, "refractoryFrames"),
"refractoryFrames",
);
const rawOdf = Float64Array.from(rawOdfSource);
for (let frameIndex = 0; frameIndex < rawOdf.length; frameIndex += 1) {
if (rawOdf[frameIndex] < 0) {
throw new RangeError(`rawOdfInput[${frameIndex}] must be non-negative.`);
}
}
const adaptiveThresholds = new Float64Array(rawOdf.length);
const peakMask = new Uint8Array(rawOdf.length);
const peakFrameIndices = [];
const peaks = [];
let lastPeakFrame = null;
let runningMeanWindowSum = 0;
for (let frameIndex = 0; frameIndex < rawOdf.length; frameIndex += 1) {
const current = rawOdf[frameIndex];
runningMeanWindowSum += current;
const expiredMeanIndex = frameIndex - preMeanFrames - 1;
if (expiredMeanIndex >= 0) runningMeanWindowSum -= rawOdf[expiredMeanIndex];
const meanWindowStart = Math.max(0, frameIndex - preMeanFrames);
const meanWindowLength = frameIndex - meanWindowStart + 1;
const localMean = runningMeanWindowSum / meanWindowLength;
const adaptiveThreshold = localMean + delta;
if (!Number.isFinite(adaptiveThreshold)) {
throw new RangeError(`adaptive threshold became non-finite at frame ${frameIndex}.`);
}
adaptiveThresholds[frameIndex] = adaptiveThreshold;
const maxWindowStart = Math.max(0, frameIndex - preMaxFrames);
let isPastLocalMaximum = true;
for (
let comparisonIndex = maxWindowStart;
comparisonIndex < frameIndex;
comparisonIndex += 1
) {
if (rawOdf[comparisonIndex] > current) {
isPastLocalMaximum = false;
break;
}
}
const clearsAdaptiveThreshold = current >= adaptiveThreshold;
const clearsRefractory =
lastPeakFrame === null || frameIndex - lastPeakFrame > refractoryFrames;
if (isPastLocalMaximum && clearsAdaptiveThreshold && clearsRefractory) {
peakMask[frameIndex] = 1;
peakFrameIndices.push(frameIndex);
peaks.push({
frameIndex,
rawOdf: current,
adaptiveThreshold,
currentAndPastLocalMean: localMean,
});
lastPeakFrame = frameIndex;
}
}
return {
rawOdf,
adaptiveThresholds,
peakMask,
peakFrameIndices: Uint32Array.from(peakFrameIndices),
peaks,
configuration: {
delta,
preMaxFrames,
preMeanFrames,
refractoryFrames,
postMaxFrames: 0,
postMeanFrames: 0,
},
latency: {
causal: true,
futureContextFrames: 0,
addedLookaheadFrames: 0,
},
provenance: {
algorithm: "boeck-krebs-schedl-2012-causal-online-peak-picker",
reference: {
title: "Evaluating the Online Capabilities of Onset Detection Methods",
authors: ["Sebastian Böck", "Florian Krebs", "Markus Schedl"],
year: 2012,
url: BOECK_KREBS_SCHEDL_2012_URL,
},
localMaximum: "current-equals-maximum-of-current-and-available-past-window",
adaptiveThreshold: "mean-of-current-and-available-past-window-plus-delta",
refractoryCondition: "currentFrame-minus-lastPeakFrame-strictly-greater-than-refractoryFrames",
edgePolicy: "truncate-windows-to-available-past",
globalNormalization: "none",
},
measurementSemantics: {
kind: "thresholded-onset-events-from-raw-novelty",
deltaMeaning: "caller-selected-detection-operating-offset",
},
};
}
+3
View File
@@ -11,6 +11,9 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
try {
await import("/tests/unit/contracts.test.js");
await import("/tests/unit/acoustics.test.js");
await import("/tests/unit/cfar.test.js");
await import("/tests/unit/activity-monitor.test.js");
await import("/tests/unit/spectral-flux.test.js");
await import("/tests/unit/gcc-phat.test.js");
await import("/tests/unit/music-numerics.test.js");
await import("/tests/unit/localization.test.js");
+179
View File
@@ -0,0 +1,179 @@
import {
analyzeCfarActivity,
estimateCfarActivityWorkload,
} from "/packages/acoustics-core/activity-monitor.js";
function sinusoid(sampleCount, sampleRate, frequencyHz, amplitude = 1) {
return Float64Array.from(
{ length: sampleCount },
(_, index) =>
amplitude * Math.sin((2 * Math.PI * frequencyHz * index) / sampleRate),
);
}
function detectorOptions(overrides = {}) {
return {
samples: sinusoid(128, 6_400, 1_200),
sampleRate: 6_400,
fftSize: 64,
hopSize: 64,
window: "hann",
channelId: "mic-reference",
detector: "os-cfar",
trainingCellsPerSide: 4,
guardCellsPerSide: 1,
rank: 6,
nominalFalseAlarmProbability: 0.01,
...overrides,
};
}
test("CFAR activity workload counts complete frames and every one-sided bin", () => {
const workload = estimateCfarActivityWorkload({
sampleCount: 160,
fftSize: 64,
hopSize: 32,
});
assertEqual(workload.frameCount, 4, "complete frame count");
assertEqual(workload.binCount, 33, "one-sided bin count");
assertEqual(workload.binDecisionCount, 132, "all frame/bin records");
assertEqual(workload.fftButterflyCount, 768, "radix-2 butterfly count");
});
test("CFAR activity monitor preserves raw bins without time, microphone, or band fusion", async () => {
const result = await analyzeCfarActivity(detectorOptions());
assertEqual(result.kind, "micarrsim.cfar-activity-analysis/1", "result kind");
assertEqual(result.channel.channelId, "mic-reference", "explicit channel");
assertEqual(result.channel.microphoneFusion, "none", "no microphone fusion");
assertEqual(result.frames.length, 2, "every complete frame retained");
assertEqual(result.frames[0].bins.length, 33, "every one-sided bin retained");
assertEqual(result.frames[0].bins[0].status, "untested", "DC remains untested");
assertEqual(
result.frames[0].bins[32].status,
"untested",
"Nyquist remains untested",
);
assertEqual(result.summary.selectionApplied, false, "no selection");
assertEqual(result.summary.clusteringApplied, false, "no clustering");
assertEqual(result.summary.localizationApplied, false, "no localization");
assertEqual(
result.detectorModel.processing.bandFusion,
false,
"no band fusion",
);
assert(
result.frames.some((frame) => frame.bins[12].status === "detected"),
"the exact 1.2 kHz ADC bin is reported as detected",
);
});
test("above-Nyquist analog tone is reported only at its observable ADC alias", async () => {
const sampleRate = 6_400;
const analogFrequencyHz = 7_600;
const result = await analyzeCfarActivity(detectorOptions({
samples: sinusoid(128, sampleRate, analogFrequencyHz),
}));
assert(
result.frames.some((frame) => frame.bins[12].status === "detected"),
"7.6 kHz samples expose the 1.2 kHz ADC bin",
);
assertEqual(
result.input.analogFrequencyOrAliasOrderInferred,
false,
"monitor does not infer analog alias order",
);
assertEqual(result.input.sceneTruthUsed, false, "Scene truth is not an input");
assertEqual(
Object.prototype.hasOwnProperty.call(result.input, "analogFrequencyHz"),
false,
"the result does not invent an analog carrier field",
);
assertEqual(
Object.prototype.hasOwnProperty.call(result.frames[0].bins[12], "analogFrequencyHz"),
false,
"the detected ADC bin contains no hidden analog carrier",
);
});
test("CA and OS activity analyses stay separate algorithms", async () => {
const ca = await analyzeCfarActivity(detectorOptions({
detector: "ca-cfar",
rank: undefined,
}));
const os = await analyzeCfarActivity(detectorOptions());
assertEqual(ca.detectorModel.detector, "ca-cfar", "CA identity");
assertEqual(os.detectorModel.detector, "os-cfar", "OS identity");
assertEqual(
ca.detectorModel.processing.detectorFusion,
false,
"CA has no detector fusion",
);
assertEqual(
os.detectorModel.processing.detectorFusion,
false,
"OS has no detector fusion",
);
});
test("CFAR activity monitor is deterministic and preserves raw powers", async () => {
const first = await analyzeCfarActivity(detectorOptions());
const second = await analyzeCfarActivity(detectorOptions());
for (let frameIndex = 0; frameIndex < first.frames.length; frameIndex += 1) {
for (let bin = 0; bin < first.frames[frameIndex].bins.length; bin += 1) {
const left = first.frames[frameIndex].bins[bin];
const right = second.frames[frameIndex].bins[bin];
assertEqual(left.status, right.status, "deterministic status");
assertEqual(left.cutPower, right.cutPower, "deterministic raw CUT power");
assertEqual(
left.referencePower,
right.referencePower,
"deterministic reference power",
);
assertEqual(
left.thresholdPower,
right.thresholdPower,
"deterministic threshold",
);
}
}
});
test("CFAR activity monitor rejects implicit channel fusion and hard-cap overflow", async () => {
let channelError = null;
try {
await analyzeCfarActivity(detectorOptions({
samples: [
sinusoid(64, 6_400, 1_200),
sinusoid(64, 6_400, 1_200),
],
}));
} catch (error) {
channelError = error;
}
assert(channelError instanceof Error, "nested channel input is rejected");
let capError = null;
try {
await analyzeCfarActivity(detectorOptions({
maximumBinDecisionCount: 65,
}));
} catch (error) {
capError = error;
}
assert(
capError?.message.includes("maximumBinDecisionCount"),
"bin decision cap rejects work instead of truncating results",
);
});
test("CFAR activity monitor honors an already-aborted signal", async () => {
const controller = new AbortController();
controller.abort();
let abortError = null;
try {
await analyzeCfarActivity(detectorOptions({ signal: controller.signal }));
} catch (error) {
abortError = error;
}
assertEqual(abortError?.name, "AbortError", "typed abort");
});
+465
View File
@@ -0,0 +1,465 @@
import {
computeCaCfarThresholdMultiplier,
computeOsCfarLogFalseAlarmProbability,
computeOsCfarThresholdMultiplier,
detectCaCfarFrame,
detectCfarFrame,
detectOsCfarFrame,
} from "/packages/acoustics-core/cfar.js";
import { fftInPlace } from "/packages/acoustics-core/fft.js";
import { hannWindow } from "/packages/acoustics-core/stft.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
);
}
function assertRelativeNear(actual, expected, relativeTolerance, label) {
const scale = Math.max(Number.MIN_VALUE, Math.abs(expected));
assertNear(actual, expected, relativeTolerance * scale, label);
}
function captureError(callback) {
try {
callback();
} catch (error) {
return error;
}
return null;
}
function assertError(callback, ErrorType, messageFragment, label) {
const error = captureError(callback);
assert(error instanceof ErrorType, `${label}: expected ${ErrorType.name}`);
assert(
error.message.includes(messageFragment),
`${label}: expected message containing ${messageFragment}, received ${error.message}`,
);
}
function createPowerFixture(fftSize, fill = 1) {
return new Float64Array(fftSize / 2 + 1).fill(fill);
}
function createBaseOptions(overrides = {}) {
const fftSize = overrides.fftSize ?? 64;
return {
powerSpectrum: overrides.powerSpectrum ?? createPowerFixture(fftSize),
detector: "ca-cfar",
sampleRate: 8192,
fftSize,
window: "hann",
frameComplete: true,
channelId: "mic-0",
frameIndex: 0,
frameStartSample: 0,
temporalAverageCount: 1,
microphoneAverageCount: 1,
trainingCellsPerSide: 4,
guardCellsPerSide: 2,
nominalFalseAlarmProbability: 1e-3,
...overrides,
};
}
function createHannTonePower({
sampleRate,
fftSize,
frequencyHz,
amplitude = 1,
}) {
const window = hannWindow(fftSize);
const real = new Float64Array(fftSize);
const imaginary = new Float64Array(fftSize);
for (let sample = 0; sample < fftSize; sample += 1) {
real[sample] =
amplitude *
Math.sin((2 * Math.PI * frequencyHz * sample) / sampleRate) *
window[sample];
}
fftInPlace(real, imaginary);
return Float64Array.from(
{ length: fftSize / 2 + 1 },
(_, bin) => real[bin] * real[bin] + imaginary[bin] * imaginary[bin],
);
}
test("CA-CFAR mean-reference multiplier recovers the requested nominal per-CUT Pfa", () => {
const trainingCellCount = 16;
const nominalFalseAlarmProbability = 1e-3;
const multiplier = computeCaCfarThresholdMultiplier({
trainingCellCount,
nominalFalseAlarmProbability,
});
const recoveredProbability =
(1 + multiplier / trainingCellCount) ** -trainingCellCount;
assertRelativeNear(
recoveredProbability,
nominalFalseAlarmProbability,
2e-14,
"CA nominal false-alarm probability",
);
const nearOneProbability = 1 - 1e-12;
const nearOneMultiplier = computeCaCfarThresholdMultiplier({
trainingCellCount: 1024,
nominalFalseAlarmProbability: nearOneProbability,
});
assert(
nearOneMultiplier > 0 && Number.isFinite(nearOneMultiplier),
"expm1 form preserves a finite positive multiplier near Pfa=1",
);
});
test("OS-CFAR log-product bisection recovers Pfa and the analytic rank-one case", () => {
const trainingCellCount = 16;
const rank = 12;
const nominalFalseAlarmProbability = 1e-4;
const multiplier = computeOsCfarThresholdMultiplier({
trainingCellCount,
rank,
nominalFalseAlarmProbability,
});
const recoveredLogProbability = computeOsCfarLogFalseAlarmProbability({
trainingCellCount,
rank,
thresholdMultiplier: multiplier,
});
assertNear(
recoveredLogProbability,
Math.log(nominalFalseAlarmProbability),
2e-12,
"OS log false-alarm probability",
);
const rankOneProbability = 0.25;
const rankOneMultiplier = computeOsCfarThresholdMultiplier({
trainingCellCount,
rank: 1,
nominalFalseAlarmProbability: rankOneProbability,
});
assertNear(
rankOneMultiplier,
trainingCellCount * (1 / rankOneProbability - 1),
1e-11,
"rank-one OS multiplier",
);
assertError(
() => computeOsCfarThresholdMultiplier({
trainingCellCount,
rank,
nominalFalseAlarmProbability,
maximumIterations: 1,
}),
RangeError,
"did not converge",
"explicit OS bisection iteration cap",
);
});
test("CA-CFAR preserves every Hann-frame bin and detects an exact-bin tone", () => {
const sampleRate = 8192;
const fftSize = 256;
const toneBin = 43;
const powerSpectrum = createHannTonePower({
sampleRate,
fftSize,
frequencyHz: toneBin * sampleRate / fftSize,
});
const result = detectCaCfarFrame(createBaseOptions({
powerSpectrum,
sampleRate,
fftSize,
trainingCellsPerSide: 12,
guardCellsPerSide: 3,
}));
const tone = result.bins[toneBin];
assertEqual(result.bins.length, fftSize / 2 + 1, "all one-sided bins retained");
assertEqual(tone.status, "detected", "exact-bin Hann tone detected");
assertEqual(tone.cutPower, powerSpectrum[toneBin], "raw CUT power retained");
assert(tone.referencePower >= 0, "raw reference statistic retained");
assert(tone.thresholdPower >= 0, "raw threshold retained");
assert(tone.thresholdRatio > 1, "raw threshold ratio exceeds one");
assertEqual(
result.parameters.referenceStatistic,
"arithmetic-mean-power",
"CA mean normalization is explicit",
);
assertEqual(
result.calibration.calibratedFalseAlarmProbability,
null,
"nominal Pfa is not mislabeled as empirical calibration",
);
assertEqual(result.processing.clustering, false, "no bin clustering");
assertEqual(result.processing.temporalPersistence, false, "no persistence");
assertEqual(result.processing.detectorFusion, false, "no CA/OS fusion");
});
test("full symmetric references exclude DC, Nyquist, and every insufficient edge bin", () => {
const fftSize = 32;
const result = detectCaCfarFrame(createBaseOptions({
fftSize,
powerSpectrum: createPowerFixture(fftSize),
trainingCellsPerSide: 2,
guardCellsPerSide: 1,
nominalFalseAlarmProbability: 0.1,
}));
assertEqual(result.bins[0].status, "untested", "DC status");
assertEqual(result.bins[0].reason, "real-only-dc-bin", "DC reason");
assertEqual(result.bins[16].status, "untested", "Nyquist status");
assertEqual(
result.bins[16].reason,
"real-only-nyquist-bin",
"Nyquist reason",
);
for (const bin of [1, 2, 3, 13, 14, 15]) {
assertEqual(result.bins[bin].status, "untested", `edge bin ${bin}`);
assertEqual(
result.bins[bin].reason,
"insufficient-full-symmetric-reference-window",
`edge reason ${bin}`,
);
assertEqual(result.bins[bin].cutPower, 1, `edge CUT power ${bin}`);
}
for (let bin = 4; bin <= 12; bin += 1) {
assertEqual(result.bins[bin].status, "not-detected", `tested bin ${bin}`);
assertEqual(result.bins[bin].referencePower, 1, `fixed-N mean ${bin}`);
}
assertEqual(result.parameters.trainingCellCount, 4, "N=2R");
assertEqual(result.parameters.firstTestedBin, 4, "first fully supported bin");
assertEqual(result.parameters.lastTestedBin, 12, "last fully supported bin");
assertEqual(result.parameters.testedBinCount, 9, "tested bin count");
});
test("CA uses a strict threshold comparison and does not promote a tie", () => {
const fftSize = 8;
const nominalFalseAlarmProbability = (1 + 1 / 2) ** -2;
const result = detectCaCfarFrame(createBaseOptions({
fftSize,
powerSpectrum: createPowerFixture(fftSize),
trainingCellsPerSide: 1,
guardCellsPerSide: 0,
nominalFalseAlarmProbability,
}));
assertNear(result.parameters.thresholdMultiplier, 1, 1e-15, "unit alpha");
assertEqual(result.bins[2].cutPower, 1, "tie CUT");
assertNear(result.bins[2].thresholdPower, 1, 1e-15, "tie threshold");
assertEqual(result.bins[2].status, "not-detected", "strict greater-than rule");
});
test("zero thresholds preserve infinite-ratio meaning without non-JSON Infinity", () => {
const fftSize = 16;
const powerSpectrum = createPowerFixture(fftSize, 0);
powerSpectrum[4] = 1;
const result = detectCaCfarFrame(createBaseOptions({
fftSize,
powerSpectrum,
trainingCellsPerSide: 1,
guardCellsPerSide: 0,
nominalFalseAlarmProbability: 0.1,
}));
const detected = result.bins[4];
assertEqual(detected.status, "detected", "positive CUT exceeds zero threshold");
assertEqual(detected.thresholdPower, 0, "zero threshold retained");
assertEqual(detected.thresholdRatio, null, "non-finite ratio is JSON-safe null");
assertEqual(
detected.thresholdRatioStatus,
"positive-infinity-zero-threshold",
"positive infinite limit remains explicit",
);
assert(
JSON.stringify(result).includes('"positive-infinity-zero-threshold"'),
"JSON serialization preserves the ratio meaning",
);
});
test("OS-CFAR remains a separate detector and resists one high training contaminant", () => {
const fftSize = 128;
const cutBin = 32;
const powerSpectrum = createPowerFixture(fftSize);
powerSpectrum[cutBin] = 100;
powerSpectrum[23] = 1000;
const common = createBaseOptions({
fftSize,
powerSpectrum,
trainingCellsPerSide: 8,
guardCellsPerSide: 1,
nominalFalseAlarmProbability: 0.1,
});
const ca = detectCaCfarFrame(common);
const os = detectOsCfarFrame({ ...common, rank: 12 });
assertEqual(ca.bins[cutBin].status, "not-detected", "CA masking exposed");
assertEqual(os.bins[cutBin].status, "detected", "OS order statistic result");
assertEqual(os.parameters.rank, 12, "explicit OS rank");
assertEqual(os.parameters.rankConvention, "1-based-ascending", "rank convention");
assertEqual(os.parameters.referencePower, undefined, "no invented fused reference");
assertEqual(os.processing.detectorFusion, false, "detectors remain separate");
});
test("a sampled above-Nyquist tone returns only its observed ADC bin", () => {
const sampleRate = 8192;
const fftSize = 256;
const analogFrequencyHz = 6144;
const expectedAdcBin = 64;
const expectedAdcFrequencyHz = 2048;
const powerSpectrum = createHannTonePower({
sampleRate,
fftSize,
frequencyHz: analogFrequencyHz,
});
const result = detectOsCfarFrame(createBaseOptions({
powerSpectrum,
sampleRate,
fftSize,
trainingCellsPerSide: 12,
guardCellsPerSide: 3,
rank: 18,
}));
const observed = result.bins[expectedAdcBin];
assertEqual(observed.status, "detected", "sampled alias detected");
assertNear(
observed.adcCenterFrequencyHz,
expectedAdcFrequencyHz,
0,
"observed ADC center frequency",
);
assertEqual(
result.spectrum.analogFrequencyOrAliasOrderInferred,
false,
"no analog alias-order inference",
);
assertEqual(
Object.prototype.hasOwnProperty.call(observed, "analogFrequencyHz"),
false,
"bin result contains no invented analog frequency",
);
assertEqual(result.bands, undefined, "no bins-to-band fusion");
});
test("CFAR rejects nonfinite power, invalid configuration, partial frames, and averaging", () => {
const nonfinite = createPowerFixture(64);
nonfinite[10] = Number.NaN;
assertError(
() => detectCaCfarFrame(createBaseOptions({ powerSpectrum: nonfinite })),
TypeError,
"powerSpectrum[10] must be finite",
"NaN power",
);
const negative = createPowerFixture(64);
negative[10] = -1;
assertError(
() => detectCaCfarFrame(createBaseOptions({ powerSpectrum: negative })),
RangeError,
"must be non-negative",
"negative power",
);
assertError(
() => detectCaCfarFrame(createBaseOptions({
powerSpectrum: new Float64Array(32),
})),
RangeError,
"exactly 33",
"wrong one-sided shape",
);
for (const probability of [0, 1, Number.NaN]) {
assertError(
() => computeCaCfarThresholdMultiplier({
trainingCellCount: 8,
nominalFalseAlarmProbability: probability,
}),
RangeError,
"strictly inside 0..1",
`invalid Pfa ${probability}`,
);
}
assertError(
() => detectOsCfarFrame(createBaseOptions({ rank: 0 })),
RangeError,
"1-based ascending",
"zero OS rank",
);
assertError(
() => detectOsCfarFrame(createBaseOptions({ rank: 9 })),
RangeError,
"inside 1..8",
"OS rank above N",
);
assertError(
() => detectCaCfarFrame(createBaseOptions({ trainingCellsPerSide: 0 })),
TypeError,
"trainingCellsPerSide must be a positive safe integer",
"zero training cells",
);
assertError(
() => detectCaCfarFrame(createBaseOptions({ guardCellsPerSide: -1 })),
TypeError,
"guardCellsPerSide must be a non-negative safe integer",
"negative guard cells",
);
assertError(
() => detectCaCfarFrame(createBaseOptions({
trainingCellsPerSide: 16,
guardCellsPerSide: 0,
})),
RangeError,
"leave no interior bin",
"reference window larger than the usable spectrum",
);
assertError(
() => detectCfarFrame(createBaseOptions({ window: "rectangular" })),
RangeError,
'window must be "hann"',
"non-Hann window",
);
assertError(
() => detectCaCfarFrame(createBaseOptions({ frameComplete: false })),
RangeError,
"partial frames are not tested",
"partial frame",
);
assertError(
() => detectCaCfarFrame(createBaseOptions({ temporalAverageCount: 2 })),
RangeError,
"temporalAverageCount must equal one",
"temporal averaging",
);
assertError(
() => detectCaCfarFrame(createBaseOptions({ microphoneAverageCount: 2 })),
RangeError,
"microphoneAverageCount must equal one",
"microphone averaging",
);
});
test("OS-CFAR is deterministic and sorting never mutates the caller spectrum", () => {
const fftSize = 128;
const powerSpectrum = Float64Array.from(
{ length: fftSize / 2 + 1 },
(_, bin) => 1 + ((bin * 17) % 23) / 11,
);
powerSpectrum[28] = 40;
powerSpectrum[41] = 75;
const before = Float64Array.from(powerSpectrum);
const options = createBaseOptions({
fftSize,
powerSpectrum,
trainingCellsPerSide: 8,
guardCellsPerSide: 2,
rank: 12,
});
const first = detectOsCfarFrame(options);
const second = detectOsCfarFrame(options);
assertEqual(JSON.stringify(second), JSON.stringify(first), "repeatable result");
assertEqual(powerSpectrum.length, before.length, "input length retained");
for (let bin = 0; bin < before.length; bin += 1) {
assertEqual(powerSpectrum[bin], before[bin], `input power ${bin}`);
}
});
+372
View File
@@ -0,0 +1,372 @@
import {
computeDixonSpectralFlux,
pickCausalSpectralFluxPeaks,
} from "/packages/acoustics-core/spectral-flux.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} +/- ${tolerance}, received ${actual}`,
);
}
function assertArrayNear(actual, expected, tolerance, label) {
assertEqual(actual.length, expected.length, `${label} length`);
for (let index = 0; index < expected.length; index += 1) {
assertNear(actual[index], expected[index], tolerance, `${label}[${index}]`);
}
}
function assertArrayExact(actual, expected, label) {
assertEqual(actual.length, expected.length, `${label} length`);
for (let index = 0; index < expected.length; index += 1) {
assertEqual(actual[index], expected[index], `${label}[${index}]`);
}
}
function assertThrows(callback, ErrorType, expectedText, label) {
let caught = null;
try {
callback();
} catch (error) {
caught = error;
}
assert(caught instanceof ErrorType, `${label}: expected ${ErrorType.name}`);
assert(
caught.message.includes(expectedText),
`${label}: expected message containing "${expectedText}", received "${caught.message}"`,
);
}
function fluxConfiguration(samples, overrides = {}) {
return {
samples,
sampleRate: 4,
fftSize: 4,
hopSize: 4,
channelId: "adc-channel-0",
...overrides,
};
}
test("Dixon flux는 Hamming linear magnitude의 양의 차이를 full DFT에서 그대로 합산한다", () => {
const result = computeDixonSpectralFlux(fluxConfiguration(
Float64Array.of(0, 0, 0, 0, 1, 1, 1, 1),
{ maximumOutputBytes: 128 },
));
const sideMagnitude = Math.hypot(-0.69, -0.69);
const expectedSecondFrame = [1.7, sideMagnitude, 0, sideMagnitude];
assertEqual(result.frameCount, 2, "frame count");
assertEqual(result.binCount, 4, "full DFT bin count");
assert(result.rawOdf instanceof Float64Array, "raw ODF type");
assert(result.positiveDifferences instanceof Float64Array, "raw per-bin type");
assertArrayExact(result.positiveDifferences.subarray(0, 4), [0, 0, 0, 0], "first frame");
assertArrayNear(
result.positiveDifferences.subarray(4, 8),
expectedSecondFrame,
1e-12,
"second-frame positive differences",
);
assertNear(
result.rawOdf[1],
expectedSecondFrame.reduce((sum, value) => sum + value, 0),
1e-12,
"raw L1 ODF",
);
assertArrayExact(result.frameStartSamples, [0, 4], "frame starts");
assertArrayExact(result.frameCenterSamples, [1.5, 5.5], "frame centers");
assertArrayExact(result.frameAvailableAfterSamples, [4, 8], "causal availability");
assertEqual(result.latency.firstDifferenceAvailableAfterSamples, 8, "first difference latency");
assertEqual(result.provenance.input.channelId, "adc-channel-0", "explicit channel provenance");
assertEqual(result.provenance.input.microphoneFusion, "none", "no microphone fusion");
assertEqual(result.provenance.globalNormalization, "none", "no global normalization");
assertEqual(result.provenance.magnitudeScale, "linear", "linear magnitude");
assertEqual(
result.provenance.stft.spectrumBins,
"full-dft-bin-order-including-the-real-signal-negative-frequency-mirror",
"full DFT aggregation disclosure",
);
assertEqual(result.workload.positiveDifferenceBytes, 64, "raw bin bytes");
assertEqual(result.workload.outputBytes, 128, "total returned typed-array bytes");
assertEqual(result.workload.maximumOutputBytes, 128, "caller output limit");
});
test("Dixon flux는 silence와 incomplete tail을 패딩 없이 처리한다", () => {
const samples = new Float64Array(9);
samples[8] = 1_000_000;
const result = computeDixonSpectralFlux(fluxConfiguration(samples));
assertEqual(result.frameCount, 2, "only two complete frames");
assertArrayExact(result.rawOdf, [0, 0], "silent raw ODF");
assertArrayExact(
result.positiveDifferences,
new Float64Array(8),
"silent per-bin differences",
);
assertEqual(result.latency.completeFramesOnly, true, "complete frame policy");
assertEqual(
result.provenance.stft.framePolicy,
"complete-only-no-padding",
"no padding provenance",
);
const tooShort = computeDixonSpectralFlux(fluxConfiguration(
Float64Array.of(1, 2, 3),
{ maximumOutputBytes: 1 },
));
assertEqual(tooShort.frameCount, 0, "short input has no frame");
assertEqual(tooShort.workload.outputBytes, 0, "short input allocates no output data");
assertEqual(tooShort.workload.workspaceBytes, 0, "short input allocates no FFT workspace");
});
test("정상상태 tone은 최초 spectral rise 뒤 raw flux가 0에 가까워지는 한계를 보존한다", () => {
const tone = Float64Array.from(
{ length: 8 },
(_, index) => Math.cos((2 * Math.PI * index) / 8),
);
const samples = new Float64Array(24);
samples.set(tone, 8);
samples.set(tone, 16);
const result = computeDixonSpectralFlux({
samples,
sampleRate: 8,
fftSize: 8,
hopSize: 8,
channelId: "tone-channel",
});
assertEqual(result.frameCount, 3, "three complete frames");
assertEqual(result.rawOdf[0], 0, "no predecessor for first frame");
assert(result.rawOdf[1] > 0, "tone rise is nonzero");
assertNear(result.rawOdf[2], 0, 1e-12, "unchanged tone flux");
assertArrayNear(
result.positiveDifferences.subarray(16, 24),
new Float64Array(8),
1e-12,
"unchanged tone per-bin differences",
);
assertEqual(
result.measurementSemantics.sustainedActivityMeasure,
false,
"not a sustained activity measure",
);
assert(
result.limitations.steadySound.includes("steady spectrum produces zero flux"),
"steady-sound limitation is structural metadata",
);
});
test("Boeck causal peak picker는 과거 max/mean, delta와 strict refractory만 사용한다", () => {
const source = Float64Array.of(0, 1, 0, 10, 0);
const configuration = {
delta: 0.4,
preMaxFrames: 1,
preMeanFrames: 2,
refractoryFrames: 1,
};
const result = pickCausalSpectralFluxPeaks(source, configuration);
assertArrayExact(result.rawOdf, [0, 1, 0, 10, 0], "preserved raw ODF");
assertArrayNear(
result.adaptiveThresholds,
[0.4, 0.9, 1 / 3 + 0.4, 11 / 3 + 0.4, 10 / 3 + 0.4],
1e-12,
"causal thresholds",
);
assertArrayExact(result.peakFrameIndices, [1, 3], "causal peaks");
assertArrayExact(result.peakMask, [0, 1, 0, 1, 0], "peak mask");
assertEqual(result.configuration.postMaxFrames, 0, "no future maximum frames");
assertEqual(result.configuration.postMeanFrames, 0, "no future mean frames");
assertEqual(result.latency.futureContextFrames, 0, "no future context");
assertEqual(result.provenance.globalNormalization, "none", "no peak-picker normalization");
assertEqual(
result.measurementSemantics.kind,
"thresholded-onset-events-from-raw-novelty",
"event semantics",
);
const prefix = pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), configuration);
assertArrayExact(prefix.peakFrameIndices, [1], "prefix peak");
assertEqual(prefix.adaptiveThresholds[1], result.adaptiveThresholds[1], "future invariance");
const refractory = pickCausalSpectralFluxPeaks(
Float64Array.of(0, 1, 0, 2),
{
delta: 0.4,
preMaxFrames: 1,
preMeanFrames: 1,
refractoryFrames: 2,
},
);
assertArrayExact(refractory.peakFrameIndices, [1], "strict refractory boundary");
source[1] = 99;
assertEqual(result.rawOdf[1], 1, "returned raw ODF is a preserved copy");
});
test("spectral flux와 causal picker는 non-finite 및 음수 novelty를 거부한다", () => {
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration(Float64Array.of(0, Number.NaN, 0, 0))),
TypeError,
"samples[1] must be finite",
"NaN sample",
);
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration(
Float64Array.of(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE),
)),
RangeError,
"STFT magnitude became non-finite",
"overflowing transform",
);
assertThrows(
() => pickCausalSpectralFluxPeaks(
Float64Array.of(0, Number.POSITIVE_INFINITY),
{ delta: 1, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
),
TypeError,
"samples[1] must be finite",
"infinite ODF",
);
assertThrows(
() => pickCausalSpectralFluxPeaks(
Float64Array.of(0, -1),
{ delta: 1, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
),
RangeError,
"rawOdfInput[1] must be non-negative",
"negative ODF",
);
assertThrows(
() => pickCausalSpectralFluxPeaks(
Float64Array.of(0, 1),
{ delta: Number.NaN, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
),
RangeError,
"delta must be finite",
"non-finite delta",
);
});
test("spectral flux 설정은 single channel, FFT, provenance와 출력 상한을 명시적으로 검증한다", () => {
assertThrows(
() => computeDixonSpectralFlux({
samples: Float64Array.of(0, 0, 0, 0),
sampleRate: 4,
fftSize: 4,
hopSize: 4,
}),
TypeError,
"channelId must be provided explicitly",
"missing channel id",
);
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration(Float64Array.of(0, 0, 0, 0), {
channelId: "",
})),
TypeError,
"channelId must be a non-empty string",
"empty channel id",
);
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration([[0], [0], [0], [0]])),
TypeError,
"samples[0] must be finite",
"channel collection",
);
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), { fftSize: 3 })),
RangeError,
"power-of-two integer",
"non-radix FFT",
);
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), {
fftSize: 2 ** 32,
})),
RangeError,
"through 536870912",
"FFT exceeds radix implementation limit",
);
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), { hopSize: 0 })),
RangeError,
"hopSize must be between 1 and fftSize",
"zero hop",
);
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), {
maximumOutputBytes: 127,
})),
RangeError,
"maximumOutputBytes is 127",
"pre-allocation output cap",
);
assertThrows(
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(4), {
maximumOutputBytes: 1.5,
})),
RangeError,
"maximumOutputBytes must be a positive safe integer",
"invalid output cap",
);
assertThrows(
() => pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), {
delta: 0.1,
preMaxFrames: 1,
preMeanFrames: 1,
}),
TypeError,
"refractoryFrames must be provided explicitly",
"missing peak-picker parameter",
);
assertThrows(
() => pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), {
delta: 0.1,
preMaxFrames: -1,
preMeanFrames: 1,
refractoryFrames: 1,
}),
RangeError,
"preMaxFrames must be a non-negative safe integer",
"negative peak-picker window",
);
});
test("spectral flux와 causal peak picker는 같은 입력에서 결정적이다", () => {
const samples = Float64Array.of(
0, 0.25, -0.5, 0.75,
0.5, -0.25, 0.125, -0.625,
-0.5, 0.25, 0.75, -0.125,
);
const untouched = Float64Array.from(samples);
const options = {
samples,
sampleRate: 8,
fftSize: 4,
hopSize: 2,
channelId: "determinism-channel",
};
const first = computeDixonSpectralFlux(options);
const second = computeDixonSpectralFlux(options);
assertArrayExact(samples, untouched, "input samples remain unchanged");
assertArrayExact(first.rawOdf, second.rawOdf, "deterministic raw ODF");
assertArrayExact(
first.positiveDifferences,
second.positiveDifferences,
"deterministic per-bin differences",
);
assertEqual(JSON.stringify(first), JSON.stringify(second), "deterministic metadata");
const pickerOptions = {
delta: 0.01,
preMaxFrames: 2,
preMeanFrames: 3,
refractoryFrames: 1,
};
const firstPeaks = pickCausalSpectralFluxPeaks(first.rawOdf, pickerOptions);
const secondPeaks = pickCausalSpectralFluxPeaks(first.rawOdf, pickerOptions);
assertEqual(JSON.stringify(firstPeaks), JSON.stringify(secondPeaks), "deterministic peaks");
});