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
+179
View File
@@ -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");
});