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
+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),
};
}