346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
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),
|
|
};
|
|
}
|