feat: add standard full-band activity detectors
This commit is contained in:
@@ -11,6 +11,9 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
||||
try {
|
||||
await import("/tests/unit/contracts.test.js");
|
||||
await import("/tests/unit/acoustics.test.js");
|
||||
await import("/tests/unit/cfar.test.js");
|
||||
await import("/tests/unit/activity-monitor.test.js");
|
||||
await import("/tests/unit/spectral-flux.test.js");
|
||||
await import("/tests/unit/gcc-phat.test.js");
|
||||
await import("/tests/unit/music-numerics.test.js");
|
||||
await import("/tests/unit/localization.test.js");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
import {
|
||||
computeCaCfarThresholdMultiplier,
|
||||
computeOsCfarLogFalseAlarmProbability,
|
||||
computeOsCfarThresholdMultiplier,
|
||||
detectCaCfarFrame,
|
||||
detectCfarFrame,
|
||||
detectOsCfarFrame,
|
||||
} from "/packages/acoustics-core/cfar.js";
|
||||
import { fftInPlace } from "/packages/acoustics-core/fft.js";
|
||||
import { hannWindow } from "/packages/acoustics-core/stft.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertRelativeNear(actual, expected, relativeTolerance, label) {
|
||||
const scale = Math.max(Number.MIN_VALUE, Math.abs(expected));
|
||||
assertNear(actual, expected, relativeTolerance * scale, label);
|
||||
}
|
||||
|
||||
function captureError(callback) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertError(callback, ErrorType, messageFragment, label) {
|
||||
const error = captureError(callback);
|
||||
assert(error instanceof ErrorType, `${label}: expected ${ErrorType.name}`);
|
||||
assert(
|
||||
error.message.includes(messageFragment),
|
||||
`${label}: expected message containing ${messageFragment}, received ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createPowerFixture(fftSize, fill = 1) {
|
||||
return new Float64Array(fftSize / 2 + 1).fill(fill);
|
||||
}
|
||||
|
||||
function createBaseOptions(overrides = {}) {
|
||||
const fftSize = overrides.fftSize ?? 64;
|
||||
return {
|
||||
powerSpectrum: overrides.powerSpectrum ?? createPowerFixture(fftSize),
|
||||
detector: "ca-cfar",
|
||||
sampleRate: 8192,
|
||||
fftSize,
|
||||
window: "hann",
|
||||
frameComplete: true,
|
||||
channelId: "mic-0",
|
||||
frameIndex: 0,
|
||||
frameStartSample: 0,
|
||||
temporalAverageCount: 1,
|
||||
microphoneAverageCount: 1,
|
||||
trainingCellsPerSide: 4,
|
||||
guardCellsPerSide: 2,
|
||||
nominalFalseAlarmProbability: 1e-3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createHannTonePower({
|
||||
sampleRate,
|
||||
fftSize,
|
||||
frequencyHz,
|
||||
amplitude = 1,
|
||||
}) {
|
||||
const window = hannWindow(fftSize);
|
||||
const real = new Float64Array(fftSize);
|
||||
const imaginary = new Float64Array(fftSize);
|
||||
for (let sample = 0; sample < fftSize; sample += 1) {
|
||||
real[sample] =
|
||||
amplitude *
|
||||
Math.sin((2 * Math.PI * frequencyHz * sample) / sampleRate) *
|
||||
window[sample];
|
||||
}
|
||||
fftInPlace(real, imaginary);
|
||||
return Float64Array.from(
|
||||
{ length: fftSize / 2 + 1 },
|
||||
(_, bin) => real[bin] * real[bin] + imaginary[bin] * imaginary[bin],
|
||||
);
|
||||
}
|
||||
|
||||
test("CA-CFAR mean-reference multiplier recovers the requested nominal per-CUT Pfa", () => {
|
||||
const trainingCellCount = 16;
|
||||
const nominalFalseAlarmProbability = 1e-3;
|
||||
const multiplier = computeCaCfarThresholdMultiplier({
|
||||
trainingCellCount,
|
||||
nominalFalseAlarmProbability,
|
||||
});
|
||||
const recoveredProbability =
|
||||
(1 + multiplier / trainingCellCount) ** -trainingCellCount;
|
||||
assertRelativeNear(
|
||||
recoveredProbability,
|
||||
nominalFalseAlarmProbability,
|
||||
2e-14,
|
||||
"CA nominal false-alarm probability",
|
||||
);
|
||||
|
||||
const nearOneProbability = 1 - 1e-12;
|
||||
const nearOneMultiplier = computeCaCfarThresholdMultiplier({
|
||||
trainingCellCount: 1024,
|
||||
nominalFalseAlarmProbability: nearOneProbability,
|
||||
});
|
||||
assert(
|
||||
nearOneMultiplier > 0 && Number.isFinite(nearOneMultiplier),
|
||||
"expm1 form preserves a finite positive multiplier near Pfa=1",
|
||||
);
|
||||
});
|
||||
|
||||
test("OS-CFAR log-product bisection recovers Pfa and the analytic rank-one case", () => {
|
||||
const trainingCellCount = 16;
|
||||
const rank = 12;
|
||||
const nominalFalseAlarmProbability = 1e-4;
|
||||
const multiplier = computeOsCfarThresholdMultiplier({
|
||||
trainingCellCount,
|
||||
rank,
|
||||
nominalFalseAlarmProbability,
|
||||
});
|
||||
const recoveredLogProbability = computeOsCfarLogFalseAlarmProbability({
|
||||
trainingCellCount,
|
||||
rank,
|
||||
thresholdMultiplier: multiplier,
|
||||
});
|
||||
assertNear(
|
||||
recoveredLogProbability,
|
||||
Math.log(nominalFalseAlarmProbability),
|
||||
2e-12,
|
||||
"OS log false-alarm probability",
|
||||
);
|
||||
|
||||
const rankOneProbability = 0.25;
|
||||
const rankOneMultiplier = computeOsCfarThresholdMultiplier({
|
||||
trainingCellCount,
|
||||
rank: 1,
|
||||
nominalFalseAlarmProbability: rankOneProbability,
|
||||
});
|
||||
assertNear(
|
||||
rankOneMultiplier,
|
||||
trainingCellCount * (1 / rankOneProbability - 1),
|
||||
1e-11,
|
||||
"rank-one OS multiplier",
|
||||
);
|
||||
assertError(
|
||||
() => computeOsCfarThresholdMultiplier({
|
||||
trainingCellCount,
|
||||
rank,
|
||||
nominalFalseAlarmProbability,
|
||||
maximumIterations: 1,
|
||||
}),
|
||||
RangeError,
|
||||
"did not converge",
|
||||
"explicit OS bisection iteration cap",
|
||||
);
|
||||
});
|
||||
|
||||
test("CA-CFAR preserves every Hann-frame bin and detects an exact-bin tone", () => {
|
||||
const sampleRate = 8192;
|
||||
const fftSize = 256;
|
||||
const toneBin = 43;
|
||||
const powerSpectrum = createHannTonePower({
|
||||
sampleRate,
|
||||
fftSize,
|
||||
frequencyHz: toneBin * sampleRate / fftSize,
|
||||
});
|
||||
const result = detectCaCfarFrame(createBaseOptions({
|
||||
powerSpectrum,
|
||||
sampleRate,
|
||||
fftSize,
|
||||
trainingCellsPerSide: 12,
|
||||
guardCellsPerSide: 3,
|
||||
}));
|
||||
const tone = result.bins[toneBin];
|
||||
|
||||
assertEqual(result.bins.length, fftSize / 2 + 1, "all one-sided bins retained");
|
||||
assertEqual(tone.status, "detected", "exact-bin Hann tone detected");
|
||||
assertEqual(tone.cutPower, powerSpectrum[toneBin], "raw CUT power retained");
|
||||
assert(tone.referencePower >= 0, "raw reference statistic retained");
|
||||
assert(tone.thresholdPower >= 0, "raw threshold retained");
|
||||
assert(tone.thresholdRatio > 1, "raw threshold ratio exceeds one");
|
||||
assertEqual(
|
||||
result.parameters.referenceStatistic,
|
||||
"arithmetic-mean-power",
|
||||
"CA mean normalization is explicit",
|
||||
);
|
||||
assertEqual(
|
||||
result.calibration.calibratedFalseAlarmProbability,
|
||||
null,
|
||||
"nominal Pfa is not mislabeled as empirical calibration",
|
||||
);
|
||||
assertEqual(result.processing.clustering, false, "no bin clustering");
|
||||
assertEqual(result.processing.temporalPersistence, false, "no persistence");
|
||||
assertEqual(result.processing.detectorFusion, false, "no CA/OS fusion");
|
||||
});
|
||||
|
||||
test("full symmetric references exclude DC, Nyquist, and every insufficient edge bin", () => {
|
||||
const fftSize = 32;
|
||||
const result = detectCaCfarFrame(createBaseOptions({
|
||||
fftSize,
|
||||
powerSpectrum: createPowerFixture(fftSize),
|
||||
trainingCellsPerSide: 2,
|
||||
guardCellsPerSide: 1,
|
||||
nominalFalseAlarmProbability: 0.1,
|
||||
}));
|
||||
|
||||
assertEqual(result.bins[0].status, "untested", "DC status");
|
||||
assertEqual(result.bins[0].reason, "real-only-dc-bin", "DC reason");
|
||||
assertEqual(result.bins[16].status, "untested", "Nyquist status");
|
||||
assertEqual(
|
||||
result.bins[16].reason,
|
||||
"real-only-nyquist-bin",
|
||||
"Nyquist reason",
|
||||
);
|
||||
for (const bin of [1, 2, 3, 13, 14, 15]) {
|
||||
assertEqual(result.bins[bin].status, "untested", `edge bin ${bin}`);
|
||||
assertEqual(
|
||||
result.bins[bin].reason,
|
||||
"insufficient-full-symmetric-reference-window",
|
||||
`edge reason ${bin}`,
|
||||
);
|
||||
assertEqual(result.bins[bin].cutPower, 1, `edge CUT power ${bin}`);
|
||||
}
|
||||
for (let bin = 4; bin <= 12; bin += 1) {
|
||||
assertEqual(result.bins[bin].status, "not-detected", `tested bin ${bin}`);
|
||||
assertEqual(result.bins[bin].referencePower, 1, `fixed-N mean ${bin}`);
|
||||
}
|
||||
assertEqual(result.parameters.trainingCellCount, 4, "N=2R");
|
||||
assertEqual(result.parameters.firstTestedBin, 4, "first fully supported bin");
|
||||
assertEqual(result.parameters.lastTestedBin, 12, "last fully supported bin");
|
||||
assertEqual(result.parameters.testedBinCount, 9, "tested bin count");
|
||||
});
|
||||
|
||||
test("CA uses a strict threshold comparison and does not promote a tie", () => {
|
||||
const fftSize = 8;
|
||||
const nominalFalseAlarmProbability = (1 + 1 / 2) ** -2;
|
||||
const result = detectCaCfarFrame(createBaseOptions({
|
||||
fftSize,
|
||||
powerSpectrum: createPowerFixture(fftSize),
|
||||
trainingCellsPerSide: 1,
|
||||
guardCellsPerSide: 0,
|
||||
nominalFalseAlarmProbability,
|
||||
}));
|
||||
assertNear(result.parameters.thresholdMultiplier, 1, 1e-15, "unit alpha");
|
||||
assertEqual(result.bins[2].cutPower, 1, "tie CUT");
|
||||
assertNear(result.bins[2].thresholdPower, 1, 1e-15, "tie threshold");
|
||||
assertEqual(result.bins[2].status, "not-detected", "strict greater-than rule");
|
||||
});
|
||||
|
||||
test("zero thresholds preserve infinite-ratio meaning without non-JSON Infinity", () => {
|
||||
const fftSize = 16;
|
||||
const powerSpectrum = createPowerFixture(fftSize, 0);
|
||||
powerSpectrum[4] = 1;
|
||||
const result = detectCaCfarFrame(createBaseOptions({
|
||||
fftSize,
|
||||
powerSpectrum,
|
||||
trainingCellsPerSide: 1,
|
||||
guardCellsPerSide: 0,
|
||||
nominalFalseAlarmProbability: 0.1,
|
||||
}));
|
||||
const detected = result.bins[4];
|
||||
assertEqual(detected.status, "detected", "positive CUT exceeds zero threshold");
|
||||
assertEqual(detected.thresholdPower, 0, "zero threshold retained");
|
||||
assertEqual(detected.thresholdRatio, null, "non-finite ratio is JSON-safe null");
|
||||
assertEqual(
|
||||
detected.thresholdRatioStatus,
|
||||
"positive-infinity-zero-threshold",
|
||||
"positive infinite limit remains explicit",
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(result).includes('"positive-infinity-zero-threshold"'),
|
||||
"JSON serialization preserves the ratio meaning",
|
||||
);
|
||||
});
|
||||
|
||||
test("OS-CFAR remains a separate detector and resists one high training contaminant", () => {
|
||||
const fftSize = 128;
|
||||
const cutBin = 32;
|
||||
const powerSpectrum = createPowerFixture(fftSize);
|
||||
powerSpectrum[cutBin] = 100;
|
||||
powerSpectrum[23] = 1000;
|
||||
const common = createBaseOptions({
|
||||
fftSize,
|
||||
powerSpectrum,
|
||||
trainingCellsPerSide: 8,
|
||||
guardCellsPerSide: 1,
|
||||
nominalFalseAlarmProbability: 0.1,
|
||||
});
|
||||
const ca = detectCaCfarFrame(common);
|
||||
const os = detectOsCfarFrame({ ...common, rank: 12 });
|
||||
|
||||
assertEqual(ca.bins[cutBin].status, "not-detected", "CA masking exposed");
|
||||
assertEqual(os.bins[cutBin].status, "detected", "OS order statistic result");
|
||||
assertEqual(os.parameters.rank, 12, "explicit OS rank");
|
||||
assertEqual(os.parameters.rankConvention, "1-based-ascending", "rank convention");
|
||||
assertEqual(os.parameters.referencePower, undefined, "no invented fused reference");
|
||||
assertEqual(os.processing.detectorFusion, false, "detectors remain separate");
|
||||
});
|
||||
|
||||
test("a sampled above-Nyquist tone returns only its observed ADC bin", () => {
|
||||
const sampleRate = 8192;
|
||||
const fftSize = 256;
|
||||
const analogFrequencyHz = 6144;
|
||||
const expectedAdcBin = 64;
|
||||
const expectedAdcFrequencyHz = 2048;
|
||||
const powerSpectrum = createHannTonePower({
|
||||
sampleRate,
|
||||
fftSize,
|
||||
frequencyHz: analogFrequencyHz,
|
||||
});
|
||||
const result = detectOsCfarFrame(createBaseOptions({
|
||||
powerSpectrum,
|
||||
sampleRate,
|
||||
fftSize,
|
||||
trainingCellsPerSide: 12,
|
||||
guardCellsPerSide: 3,
|
||||
rank: 18,
|
||||
}));
|
||||
const observed = result.bins[expectedAdcBin];
|
||||
|
||||
assertEqual(observed.status, "detected", "sampled alias detected");
|
||||
assertNear(
|
||||
observed.adcCenterFrequencyHz,
|
||||
expectedAdcFrequencyHz,
|
||||
0,
|
||||
"observed ADC center frequency",
|
||||
);
|
||||
assertEqual(
|
||||
result.spectrum.analogFrequencyOrAliasOrderInferred,
|
||||
false,
|
||||
"no analog alias-order inference",
|
||||
);
|
||||
assertEqual(
|
||||
Object.prototype.hasOwnProperty.call(observed, "analogFrequencyHz"),
|
||||
false,
|
||||
"bin result contains no invented analog frequency",
|
||||
);
|
||||
assertEqual(result.bands, undefined, "no bins-to-band fusion");
|
||||
});
|
||||
|
||||
test("CFAR rejects nonfinite power, invalid configuration, partial frames, and averaging", () => {
|
||||
const nonfinite = createPowerFixture(64);
|
||||
nonfinite[10] = Number.NaN;
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({ powerSpectrum: nonfinite })),
|
||||
TypeError,
|
||||
"powerSpectrum[10] must be finite",
|
||||
"NaN power",
|
||||
);
|
||||
|
||||
const negative = createPowerFixture(64);
|
||||
negative[10] = -1;
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({ powerSpectrum: negative })),
|
||||
RangeError,
|
||||
"must be non-negative",
|
||||
"negative power",
|
||||
);
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({
|
||||
powerSpectrum: new Float64Array(32),
|
||||
})),
|
||||
RangeError,
|
||||
"exactly 33",
|
||||
"wrong one-sided shape",
|
||||
);
|
||||
for (const probability of [0, 1, Number.NaN]) {
|
||||
assertError(
|
||||
() => computeCaCfarThresholdMultiplier({
|
||||
trainingCellCount: 8,
|
||||
nominalFalseAlarmProbability: probability,
|
||||
}),
|
||||
RangeError,
|
||||
"strictly inside 0..1",
|
||||
`invalid Pfa ${probability}`,
|
||||
);
|
||||
}
|
||||
assertError(
|
||||
() => detectOsCfarFrame(createBaseOptions({ rank: 0 })),
|
||||
RangeError,
|
||||
"1-based ascending",
|
||||
"zero OS rank",
|
||||
);
|
||||
assertError(
|
||||
() => detectOsCfarFrame(createBaseOptions({ rank: 9 })),
|
||||
RangeError,
|
||||
"inside 1..8",
|
||||
"OS rank above N",
|
||||
);
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({ trainingCellsPerSide: 0 })),
|
||||
TypeError,
|
||||
"trainingCellsPerSide must be a positive safe integer",
|
||||
"zero training cells",
|
||||
);
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({ guardCellsPerSide: -1 })),
|
||||
TypeError,
|
||||
"guardCellsPerSide must be a non-negative safe integer",
|
||||
"negative guard cells",
|
||||
);
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({
|
||||
trainingCellsPerSide: 16,
|
||||
guardCellsPerSide: 0,
|
||||
})),
|
||||
RangeError,
|
||||
"leave no interior bin",
|
||||
"reference window larger than the usable spectrum",
|
||||
);
|
||||
assertError(
|
||||
() => detectCfarFrame(createBaseOptions({ window: "rectangular" })),
|
||||
RangeError,
|
||||
'window must be "hann"',
|
||||
"non-Hann window",
|
||||
);
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({ frameComplete: false })),
|
||||
RangeError,
|
||||
"partial frames are not tested",
|
||||
"partial frame",
|
||||
);
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({ temporalAverageCount: 2 })),
|
||||
RangeError,
|
||||
"temporalAverageCount must equal one",
|
||||
"temporal averaging",
|
||||
);
|
||||
assertError(
|
||||
() => detectCaCfarFrame(createBaseOptions({ microphoneAverageCount: 2 })),
|
||||
RangeError,
|
||||
"microphoneAverageCount must equal one",
|
||||
"microphone averaging",
|
||||
);
|
||||
});
|
||||
|
||||
test("OS-CFAR is deterministic and sorting never mutates the caller spectrum", () => {
|
||||
const fftSize = 128;
|
||||
const powerSpectrum = Float64Array.from(
|
||||
{ length: fftSize / 2 + 1 },
|
||||
(_, bin) => 1 + ((bin * 17) % 23) / 11,
|
||||
);
|
||||
powerSpectrum[28] = 40;
|
||||
powerSpectrum[41] = 75;
|
||||
const before = Float64Array.from(powerSpectrum);
|
||||
const options = createBaseOptions({
|
||||
fftSize,
|
||||
powerSpectrum,
|
||||
trainingCellsPerSide: 8,
|
||||
guardCellsPerSide: 2,
|
||||
rank: 12,
|
||||
});
|
||||
const first = detectOsCfarFrame(options);
|
||||
const second = detectOsCfarFrame(options);
|
||||
|
||||
assertEqual(JSON.stringify(second), JSON.stringify(first), "repeatable result");
|
||||
assertEqual(powerSpectrum.length, before.length, "input length retained");
|
||||
for (let bin = 0; bin < before.length; bin += 1) {
|
||||
assertEqual(powerSpectrum[bin], before[bin], `input power ${bin}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
computeDixonSpectralFlux,
|
||||
pickCausalSpectralFluxPeaks,
|
||||
} from "/packages/acoustics-core/spectral-flux.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: expected ${expected} +/- ${tolerance}, received ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertArrayNear(actual, expected, tolerance, label) {
|
||||
assertEqual(actual.length, expected.length, `${label} length`);
|
||||
for (let index = 0; index < expected.length; index += 1) {
|
||||
assertNear(actual[index], expected[index], tolerance, `${label}[${index}]`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertArrayExact(actual, expected, label) {
|
||||
assertEqual(actual.length, expected.length, `${label} length`);
|
||||
for (let index = 0; index < expected.length; index += 1) {
|
||||
assertEqual(actual[index], expected[index], `${label}[${index}]`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertThrows(callback, ErrorType, expectedText, label) {
|
||||
let caught = null;
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
assert(caught instanceof ErrorType, `${label}: expected ${ErrorType.name}`);
|
||||
assert(
|
||||
caught.message.includes(expectedText),
|
||||
`${label}: expected message containing "${expectedText}", received "${caught.message}"`,
|
||||
);
|
||||
}
|
||||
|
||||
function fluxConfiguration(samples, overrides = {}) {
|
||||
return {
|
||||
samples,
|
||||
sampleRate: 4,
|
||||
fftSize: 4,
|
||||
hopSize: 4,
|
||||
channelId: "adc-channel-0",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("Dixon flux는 Hamming linear magnitude의 양의 차이를 full DFT에서 그대로 합산한다", () => {
|
||||
const result = computeDixonSpectralFlux(fluxConfiguration(
|
||||
Float64Array.of(0, 0, 0, 0, 1, 1, 1, 1),
|
||||
{ maximumOutputBytes: 128 },
|
||||
));
|
||||
const sideMagnitude = Math.hypot(-0.69, -0.69);
|
||||
const expectedSecondFrame = [1.7, sideMagnitude, 0, sideMagnitude];
|
||||
|
||||
assertEqual(result.frameCount, 2, "frame count");
|
||||
assertEqual(result.binCount, 4, "full DFT bin count");
|
||||
assert(result.rawOdf instanceof Float64Array, "raw ODF type");
|
||||
assert(result.positiveDifferences instanceof Float64Array, "raw per-bin type");
|
||||
assertArrayExact(result.positiveDifferences.subarray(0, 4), [0, 0, 0, 0], "first frame");
|
||||
assertArrayNear(
|
||||
result.positiveDifferences.subarray(4, 8),
|
||||
expectedSecondFrame,
|
||||
1e-12,
|
||||
"second-frame positive differences",
|
||||
);
|
||||
assertNear(
|
||||
result.rawOdf[1],
|
||||
expectedSecondFrame.reduce((sum, value) => sum + value, 0),
|
||||
1e-12,
|
||||
"raw L1 ODF",
|
||||
);
|
||||
assertArrayExact(result.frameStartSamples, [0, 4], "frame starts");
|
||||
assertArrayExact(result.frameCenterSamples, [1.5, 5.5], "frame centers");
|
||||
assertArrayExact(result.frameAvailableAfterSamples, [4, 8], "causal availability");
|
||||
assertEqual(result.latency.firstDifferenceAvailableAfterSamples, 8, "first difference latency");
|
||||
assertEqual(result.provenance.input.channelId, "adc-channel-0", "explicit channel provenance");
|
||||
assertEqual(result.provenance.input.microphoneFusion, "none", "no microphone fusion");
|
||||
assertEqual(result.provenance.globalNormalization, "none", "no global normalization");
|
||||
assertEqual(result.provenance.magnitudeScale, "linear", "linear magnitude");
|
||||
assertEqual(
|
||||
result.provenance.stft.spectrumBins,
|
||||
"full-dft-bin-order-including-the-real-signal-negative-frequency-mirror",
|
||||
"full DFT aggregation disclosure",
|
||||
);
|
||||
assertEqual(result.workload.positiveDifferenceBytes, 64, "raw bin bytes");
|
||||
assertEqual(result.workload.outputBytes, 128, "total returned typed-array bytes");
|
||||
assertEqual(result.workload.maximumOutputBytes, 128, "caller output limit");
|
||||
});
|
||||
|
||||
test("Dixon flux는 silence와 incomplete tail을 패딩 없이 처리한다", () => {
|
||||
const samples = new Float64Array(9);
|
||||
samples[8] = 1_000_000;
|
||||
const result = computeDixonSpectralFlux(fluxConfiguration(samples));
|
||||
|
||||
assertEqual(result.frameCount, 2, "only two complete frames");
|
||||
assertArrayExact(result.rawOdf, [0, 0], "silent raw ODF");
|
||||
assertArrayExact(
|
||||
result.positiveDifferences,
|
||||
new Float64Array(8),
|
||||
"silent per-bin differences",
|
||||
);
|
||||
assertEqual(result.latency.completeFramesOnly, true, "complete frame policy");
|
||||
assertEqual(
|
||||
result.provenance.stft.framePolicy,
|
||||
"complete-only-no-padding",
|
||||
"no padding provenance",
|
||||
);
|
||||
|
||||
const tooShort = computeDixonSpectralFlux(fluxConfiguration(
|
||||
Float64Array.of(1, 2, 3),
|
||||
{ maximumOutputBytes: 1 },
|
||||
));
|
||||
assertEqual(tooShort.frameCount, 0, "short input has no frame");
|
||||
assertEqual(tooShort.workload.outputBytes, 0, "short input allocates no output data");
|
||||
assertEqual(tooShort.workload.workspaceBytes, 0, "short input allocates no FFT workspace");
|
||||
});
|
||||
|
||||
test("정상상태 tone은 최초 spectral rise 뒤 raw flux가 0에 가까워지는 한계를 보존한다", () => {
|
||||
const tone = Float64Array.from(
|
||||
{ length: 8 },
|
||||
(_, index) => Math.cos((2 * Math.PI * index) / 8),
|
||||
);
|
||||
const samples = new Float64Array(24);
|
||||
samples.set(tone, 8);
|
||||
samples.set(tone, 16);
|
||||
const result = computeDixonSpectralFlux({
|
||||
samples,
|
||||
sampleRate: 8,
|
||||
fftSize: 8,
|
||||
hopSize: 8,
|
||||
channelId: "tone-channel",
|
||||
});
|
||||
|
||||
assertEqual(result.frameCount, 3, "three complete frames");
|
||||
assertEqual(result.rawOdf[0], 0, "no predecessor for first frame");
|
||||
assert(result.rawOdf[1] > 0, "tone rise is nonzero");
|
||||
assertNear(result.rawOdf[2], 0, 1e-12, "unchanged tone flux");
|
||||
assertArrayNear(
|
||||
result.positiveDifferences.subarray(16, 24),
|
||||
new Float64Array(8),
|
||||
1e-12,
|
||||
"unchanged tone per-bin differences",
|
||||
);
|
||||
assertEqual(
|
||||
result.measurementSemantics.sustainedActivityMeasure,
|
||||
false,
|
||||
"not a sustained activity measure",
|
||||
);
|
||||
assert(
|
||||
result.limitations.steadySound.includes("steady spectrum produces zero flux"),
|
||||
"steady-sound limitation is structural metadata",
|
||||
);
|
||||
});
|
||||
|
||||
test("Boeck causal peak picker는 과거 max/mean, delta와 strict refractory만 사용한다", () => {
|
||||
const source = Float64Array.of(0, 1, 0, 10, 0);
|
||||
const configuration = {
|
||||
delta: 0.4,
|
||||
preMaxFrames: 1,
|
||||
preMeanFrames: 2,
|
||||
refractoryFrames: 1,
|
||||
};
|
||||
const result = pickCausalSpectralFluxPeaks(source, configuration);
|
||||
|
||||
assertArrayExact(result.rawOdf, [0, 1, 0, 10, 0], "preserved raw ODF");
|
||||
assertArrayNear(
|
||||
result.adaptiveThresholds,
|
||||
[0.4, 0.9, 1 / 3 + 0.4, 11 / 3 + 0.4, 10 / 3 + 0.4],
|
||||
1e-12,
|
||||
"causal thresholds",
|
||||
);
|
||||
assertArrayExact(result.peakFrameIndices, [1, 3], "causal peaks");
|
||||
assertArrayExact(result.peakMask, [0, 1, 0, 1, 0], "peak mask");
|
||||
assertEqual(result.configuration.postMaxFrames, 0, "no future maximum frames");
|
||||
assertEqual(result.configuration.postMeanFrames, 0, "no future mean frames");
|
||||
assertEqual(result.latency.futureContextFrames, 0, "no future context");
|
||||
assertEqual(result.provenance.globalNormalization, "none", "no peak-picker normalization");
|
||||
assertEqual(
|
||||
result.measurementSemantics.kind,
|
||||
"thresholded-onset-events-from-raw-novelty",
|
||||
"event semantics",
|
||||
);
|
||||
|
||||
const prefix = pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), configuration);
|
||||
assertArrayExact(prefix.peakFrameIndices, [1], "prefix peak");
|
||||
assertEqual(prefix.adaptiveThresholds[1], result.adaptiveThresholds[1], "future invariance");
|
||||
|
||||
const refractory = pickCausalSpectralFluxPeaks(
|
||||
Float64Array.of(0, 1, 0, 2),
|
||||
{
|
||||
delta: 0.4,
|
||||
preMaxFrames: 1,
|
||||
preMeanFrames: 1,
|
||||
refractoryFrames: 2,
|
||||
},
|
||||
);
|
||||
assertArrayExact(refractory.peakFrameIndices, [1], "strict refractory boundary");
|
||||
|
||||
source[1] = 99;
|
||||
assertEqual(result.rawOdf[1], 1, "returned raw ODF is a preserved copy");
|
||||
});
|
||||
|
||||
test("spectral flux와 causal picker는 non-finite 및 음수 novelty를 거부한다", () => {
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration(Float64Array.of(0, Number.NaN, 0, 0))),
|
||||
TypeError,
|
||||
"samples[1] must be finite",
|
||||
"NaN sample",
|
||||
);
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration(
|
||||
Float64Array.of(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE),
|
||||
)),
|
||||
RangeError,
|
||||
"STFT magnitude became non-finite",
|
||||
"overflowing transform",
|
||||
);
|
||||
assertThrows(
|
||||
() => pickCausalSpectralFluxPeaks(
|
||||
Float64Array.of(0, Number.POSITIVE_INFINITY),
|
||||
{ delta: 1, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
|
||||
),
|
||||
TypeError,
|
||||
"samples[1] must be finite",
|
||||
"infinite ODF",
|
||||
);
|
||||
assertThrows(
|
||||
() => pickCausalSpectralFluxPeaks(
|
||||
Float64Array.of(0, -1),
|
||||
{ delta: 1, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
|
||||
),
|
||||
RangeError,
|
||||
"rawOdfInput[1] must be non-negative",
|
||||
"negative ODF",
|
||||
);
|
||||
assertThrows(
|
||||
() => pickCausalSpectralFluxPeaks(
|
||||
Float64Array.of(0, 1),
|
||||
{ delta: Number.NaN, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
|
||||
),
|
||||
RangeError,
|
||||
"delta must be finite",
|
||||
"non-finite delta",
|
||||
);
|
||||
});
|
||||
|
||||
test("spectral flux 설정은 single channel, FFT, provenance와 출력 상한을 명시적으로 검증한다", () => {
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux({
|
||||
samples: Float64Array.of(0, 0, 0, 0),
|
||||
sampleRate: 4,
|
||||
fftSize: 4,
|
||||
hopSize: 4,
|
||||
}),
|
||||
TypeError,
|
||||
"channelId must be provided explicitly",
|
||||
"missing channel id",
|
||||
);
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration(Float64Array.of(0, 0, 0, 0), {
|
||||
channelId: "",
|
||||
})),
|
||||
TypeError,
|
||||
"channelId must be a non-empty string",
|
||||
"empty channel id",
|
||||
);
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration([[0], [0], [0], [0]])),
|
||||
TypeError,
|
||||
"samples[0] must be finite",
|
||||
"channel collection",
|
||||
);
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), { fftSize: 3 })),
|
||||
RangeError,
|
||||
"power-of-two integer",
|
||||
"non-radix FFT",
|
||||
);
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), {
|
||||
fftSize: 2 ** 32,
|
||||
})),
|
||||
RangeError,
|
||||
"through 536870912",
|
||||
"FFT exceeds radix implementation limit",
|
||||
);
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), { hopSize: 0 })),
|
||||
RangeError,
|
||||
"hopSize must be between 1 and fftSize",
|
||||
"zero hop",
|
||||
);
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), {
|
||||
maximumOutputBytes: 127,
|
||||
})),
|
||||
RangeError,
|
||||
"maximumOutputBytes is 127",
|
||||
"pre-allocation output cap",
|
||||
);
|
||||
assertThrows(
|
||||
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(4), {
|
||||
maximumOutputBytes: 1.5,
|
||||
})),
|
||||
RangeError,
|
||||
"maximumOutputBytes must be a positive safe integer",
|
||||
"invalid output cap",
|
||||
);
|
||||
assertThrows(
|
||||
() => pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), {
|
||||
delta: 0.1,
|
||||
preMaxFrames: 1,
|
||||
preMeanFrames: 1,
|
||||
}),
|
||||
TypeError,
|
||||
"refractoryFrames must be provided explicitly",
|
||||
"missing peak-picker parameter",
|
||||
);
|
||||
assertThrows(
|
||||
() => pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), {
|
||||
delta: 0.1,
|
||||
preMaxFrames: -1,
|
||||
preMeanFrames: 1,
|
||||
refractoryFrames: 1,
|
||||
}),
|
||||
RangeError,
|
||||
"preMaxFrames must be a non-negative safe integer",
|
||||
"negative peak-picker window",
|
||||
);
|
||||
});
|
||||
|
||||
test("spectral flux와 causal peak picker는 같은 입력에서 결정적이다", () => {
|
||||
const samples = Float64Array.of(
|
||||
0, 0.25, -0.5, 0.75,
|
||||
0.5, -0.25, 0.125, -0.625,
|
||||
-0.5, 0.25, 0.75, -0.125,
|
||||
);
|
||||
const untouched = Float64Array.from(samples);
|
||||
const options = {
|
||||
samples,
|
||||
sampleRate: 8,
|
||||
fftSize: 4,
|
||||
hopSize: 2,
|
||||
channelId: "determinism-channel",
|
||||
};
|
||||
const first = computeDixonSpectralFlux(options);
|
||||
const second = computeDixonSpectralFlux(options);
|
||||
|
||||
assertArrayExact(samples, untouched, "input samples remain unchanged");
|
||||
assertArrayExact(first.rawOdf, second.rawOdf, "deterministic raw ODF");
|
||||
assertArrayExact(
|
||||
first.positiveDifferences,
|
||||
second.positiveDifferences,
|
||||
"deterministic per-bin differences",
|
||||
);
|
||||
assertEqual(JSON.stringify(first), JSON.stringify(second), "deterministic metadata");
|
||||
|
||||
const pickerOptions = {
|
||||
delta: 0.01,
|
||||
preMaxFrames: 2,
|
||||
preMeanFrames: 3,
|
||||
refractoryFrames: 1,
|
||||
};
|
||||
const firstPeaks = pickCausalSpectralFluxPeaks(first.rawOdf, pickerOptions);
|
||||
const secondPeaks = pickCausalSpectralFluxPeaks(first.rawOdf, pickerOptions);
|
||||
assertEqual(JSON.stringify(firstPeaks), JSON.stringify(secondPeaks), "deterministic peaks");
|
||||
});
|
||||
Reference in New Issue
Block a user