373 lines
12 KiB
JavaScript
373 lines
12 KiB
JavaScript
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");
|
|
});
|