feat: add standard full-band activity detectors
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user