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