feat: add standard full-band activity detectors
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user