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"); });