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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user