180 lines
5.9 KiB
JavaScript
180 lines
5.9 KiB
JavaScript
import {
|
|
analyzeCfarActivity,
|
|
estimateCfarActivityWorkload,
|
|
} from "/packages/acoustics-core/activity-monitor.js";
|
|
|
|
function sinusoid(sampleCount, sampleRate, frequencyHz, amplitude = 1) {
|
|
return Float64Array.from(
|
|
{ length: sampleCount },
|
|
(_, index) =>
|
|
amplitude * Math.sin((2 * Math.PI * frequencyHz * index) / sampleRate),
|
|
);
|
|
}
|
|
|
|
function detectorOptions(overrides = {}) {
|
|
return {
|
|
samples: sinusoid(128, 6_400, 1_200),
|
|
sampleRate: 6_400,
|
|
fftSize: 64,
|
|
hopSize: 64,
|
|
window: "hann",
|
|
channelId: "mic-reference",
|
|
detector: "os-cfar",
|
|
trainingCellsPerSide: 4,
|
|
guardCellsPerSide: 1,
|
|
rank: 6,
|
|
nominalFalseAlarmProbability: 0.01,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("CFAR activity workload counts complete frames and every one-sided bin", () => {
|
|
const workload = estimateCfarActivityWorkload({
|
|
sampleCount: 160,
|
|
fftSize: 64,
|
|
hopSize: 32,
|
|
});
|
|
assertEqual(workload.frameCount, 4, "complete frame count");
|
|
assertEqual(workload.binCount, 33, "one-sided bin count");
|
|
assertEqual(workload.binDecisionCount, 132, "all frame/bin records");
|
|
assertEqual(workload.fftButterflyCount, 768, "radix-2 butterfly count");
|
|
});
|
|
|
|
test("CFAR activity monitor preserves raw bins without time, microphone, or band fusion", async () => {
|
|
const result = await analyzeCfarActivity(detectorOptions());
|
|
assertEqual(result.kind, "micarrsim.cfar-activity-analysis/1", "result kind");
|
|
assertEqual(result.channel.channelId, "mic-reference", "explicit channel");
|
|
assertEqual(result.channel.microphoneFusion, "none", "no microphone fusion");
|
|
assertEqual(result.frames.length, 2, "every complete frame retained");
|
|
assertEqual(result.frames[0].bins.length, 33, "every one-sided bin retained");
|
|
assertEqual(result.frames[0].bins[0].status, "untested", "DC remains untested");
|
|
assertEqual(
|
|
result.frames[0].bins[32].status,
|
|
"untested",
|
|
"Nyquist remains untested",
|
|
);
|
|
assertEqual(result.summary.selectionApplied, false, "no selection");
|
|
assertEqual(result.summary.clusteringApplied, false, "no clustering");
|
|
assertEqual(result.summary.localizationApplied, false, "no localization");
|
|
assertEqual(
|
|
result.detectorModel.processing.bandFusion,
|
|
false,
|
|
"no band fusion",
|
|
);
|
|
assert(
|
|
result.frames.some((frame) => frame.bins[12].status === "detected"),
|
|
"the exact 1.2 kHz ADC bin is reported as detected",
|
|
);
|
|
});
|
|
|
|
test("above-Nyquist analog tone is reported only at its observable ADC alias", async () => {
|
|
const sampleRate = 6_400;
|
|
const analogFrequencyHz = 7_600;
|
|
const result = await analyzeCfarActivity(detectorOptions({
|
|
samples: sinusoid(128, sampleRate, analogFrequencyHz),
|
|
}));
|
|
assert(
|
|
result.frames.some((frame) => frame.bins[12].status === "detected"),
|
|
"7.6 kHz samples expose the 1.2 kHz ADC bin",
|
|
);
|
|
assertEqual(
|
|
result.input.analogFrequencyOrAliasOrderInferred,
|
|
false,
|
|
"monitor does not infer analog alias order",
|
|
);
|
|
assertEqual(result.input.sceneTruthUsed, false, "Scene truth is not an input");
|
|
assertEqual(
|
|
Object.prototype.hasOwnProperty.call(result.input, "analogFrequencyHz"),
|
|
false,
|
|
"the result does not invent an analog carrier field",
|
|
);
|
|
assertEqual(
|
|
Object.prototype.hasOwnProperty.call(result.frames[0].bins[12], "analogFrequencyHz"),
|
|
false,
|
|
"the detected ADC bin contains no hidden analog carrier",
|
|
);
|
|
});
|
|
|
|
test("CA and OS activity analyses stay separate algorithms", async () => {
|
|
const ca = await analyzeCfarActivity(detectorOptions({
|
|
detector: "ca-cfar",
|
|
rank: undefined,
|
|
}));
|
|
const os = await analyzeCfarActivity(detectorOptions());
|
|
assertEqual(ca.detectorModel.detector, "ca-cfar", "CA identity");
|
|
assertEqual(os.detectorModel.detector, "os-cfar", "OS identity");
|
|
assertEqual(
|
|
ca.detectorModel.processing.detectorFusion,
|
|
false,
|
|
"CA has no detector fusion",
|
|
);
|
|
assertEqual(
|
|
os.detectorModel.processing.detectorFusion,
|
|
false,
|
|
"OS has no detector fusion",
|
|
);
|
|
});
|
|
|
|
test("CFAR activity monitor is deterministic and preserves raw powers", async () => {
|
|
const first = await analyzeCfarActivity(detectorOptions());
|
|
const second = await analyzeCfarActivity(detectorOptions());
|
|
for (let frameIndex = 0; frameIndex < first.frames.length; frameIndex += 1) {
|
|
for (let bin = 0; bin < first.frames[frameIndex].bins.length; bin += 1) {
|
|
const left = first.frames[frameIndex].bins[bin];
|
|
const right = second.frames[frameIndex].bins[bin];
|
|
assertEqual(left.status, right.status, "deterministic status");
|
|
assertEqual(left.cutPower, right.cutPower, "deterministic raw CUT power");
|
|
assertEqual(
|
|
left.referencePower,
|
|
right.referencePower,
|
|
"deterministic reference power",
|
|
);
|
|
assertEqual(
|
|
left.thresholdPower,
|
|
right.thresholdPower,
|
|
"deterministic threshold",
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("CFAR activity monitor rejects implicit channel fusion and hard-cap overflow", async () => {
|
|
let channelError = null;
|
|
try {
|
|
await analyzeCfarActivity(detectorOptions({
|
|
samples: [
|
|
sinusoid(64, 6_400, 1_200),
|
|
sinusoid(64, 6_400, 1_200),
|
|
],
|
|
}));
|
|
} catch (error) {
|
|
channelError = error;
|
|
}
|
|
assert(channelError instanceof Error, "nested channel input is rejected");
|
|
|
|
let capError = null;
|
|
try {
|
|
await analyzeCfarActivity(detectorOptions({
|
|
maximumBinDecisionCount: 65,
|
|
}));
|
|
} catch (error) {
|
|
capError = error;
|
|
}
|
|
assert(
|
|
capError?.message.includes("maximumBinDecisionCount"),
|
|
"bin decision cap rejects work instead of truncating results",
|
|
);
|
|
});
|
|
|
|
test("CFAR activity monitor honors an already-aborted signal", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
let abortError = null;
|
|
try {
|
|
await analyzeCfarActivity(detectorOptions({ signal: controller.signal }));
|
|
} catch (error) {
|
|
abortError = error;
|
|
}
|
|
assertEqual(abortError?.name, "AbortError", "typed abort");
|
|
});
|