import { fftInPlace } from "./fft.js"; const DIXON_2006_URL = "https://www.dafx.de/paper-archive/2006/papers/p_133.pdf"; const BOECK_KREBS_SCHEDL_2012_URL = "https://www.cp.jku.at/research/papers/Boeck_etal_ISMIR_2012.pdf"; const DEFAULT_MAXIMUM_OUTPUT_BYTES = 64 * 1024 * 1024; function requireConfigurationObject(value, label) { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${label} must be an object.`); } return value; } function requireOwn(configuration, key) { if (!Object.prototype.hasOwnProperty.call(configuration, key)) { throw new TypeError(`${key} must be provided explicitly.`); } return configuration[key]; } function requirePositiveFinite(value, label) { if (!Number.isFinite(value) || value <= 0) { throw new RangeError(`${label} must be finite and greater than zero.`); } return value; } function requireNonNegativeFinite(value, label) { if (!Number.isFinite(value) || value < 0) { throw new RangeError(`${label} must be finite and non-negative.`); } return value; } function requireNonNegativeSafeInteger(value, label) { if (!Number.isSafeInteger(value) || value < 0) { throw new RangeError(`${label} must be a non-negative safe integer.`); } return value; } function requirePowerOfTwo(value, label) { if ( !Number.isSafeInteger(value) || value < 2 || value > 0x20000000 || !Number.isInteger(Math.log2(value)) ) { throw new RangeError( `${label} must be a power-of-two integer from 2 through 536870912.`, ); } return value; } function requireSingleChannelSamples(samples) { const isTypedArray = ArrayBuffer.isView(samples) && !(samples instanceof DataView); if (!Array.isArray(samples) && !isTypedArray) { throw new TypeError("samples must be one numeric channel, not a channel collection."); } for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex += 1) { if (typeof samples[sampleIndex] !== "number" || !Number.isFinite(samples[sampleIndex])) { throw new TypeError(`samples[${sampleIndex}] must be finite.`); } } return samples; } function symmetricHammingWindow(size) { const window = new Float64Array(size); for (let index = 0; index < size; index += 1) { window[index] = 0.54 - 0.46 * Math.cos((2 * Math.PI * index) / (size - 1)); } return window; } function checkedElementCount(frameCount, binCount) { const elementCount = frameCount * binCount; if (!Number.isSafeInteger(elementCount)) { throw new RangeError("spectral-flux output element count exceeds safe integer range."); } return elementCount; } function checkedByteCount(elementCount, bytesPerElement, label) { const byteCount = elementCount * bytesPerElement; if (!Number.isSafeInteger(byteCount)) { throw new RangeError(`${label} byte count exceeds safe integer range.`); } return byteCount; } /** * Compute Simon Dixon's 2006 magnitude spectral-flux onset detection function. * * This function accepts exactly one numeric channel. It uses a symmetric * Hamming window, complete non-padded frames, linear DFT magnitudes, positive * (half-wave-rectified) frame differences, and an L1 sum over the full DFT. * No per-frame or global normalization, microphone fusion, filterbank, * maximum filter, location inference, or band inference is performed. */ export function computeDixonSpectralFlux(options) { const configuration = requireConfigurationObject(options, "options"); const samples = requireSingleChannelSamples(requireOwn(configuration, "samples")); const sampleRate = requirePositiveFinite( requireOwn(configuration, "sampleRate"), "sampleRate", ); const fftSize = requirePowerOfTwo( requireOwn(configuration, "fftSize"), "fftSize", ); const hopSize = requireNonNegativeSafeInteger( requireOwn(configuration, "hopSize"), "hopSize", ); if (hopSize < 1 || hopSize > fftSize) { throw new RangeError("hopSize must be between 1 and fftSize, inclusive."); } const channelId = requireOwn(configuration, "channelId"); if (typeof channelId !== "string" || channelId.length === 0) { throw new TypeError("channelId must be a non-empty string."); } const maximumOutputBytes = configuration.maximumOutputBytes ?? DEFAULT_MAXIMUM_OUTPUT_BYTES; if (!Number.isSafeInteger(maximumOutputBytes) || maximumOutputBytes <= 0) { throw new RangeError("maximumOutputBytes must be a positive safe integer."); } const frameCount = samples.length < fftSize ? 0 : 1 + Math.floor((samples.length - fftSize) / hopSize); const binCount = fftSize; const positiveDifferenceElements = checkedElementCount(frameCount, binCount); const positiveDifferenceBytes = checkedByteCount( positiveDifferenceElements, Float64Array.BYTES_PER_ELEMENT, "positiveDifferences", ); const rawOdfBytes = checkedByteCount( frameCount, Float64Array.BYTES_PER_ELEMENT, "rawOdf", ); const frameTimingBytes = checkedByteCount( frameCount * 3, Float64Array.BYTES_PER_ELEMENT, "frame timing", ); const outputBytes = positiveDifferenceBytes + rawOdfBytes + frameTimingBytes; if (!Number.isSafeInteger(outputBytes)) { throw new RangeError("spectral-flux output byte count exceeds safe integer range."); } if (outputBytes > maximumOutputBytes) { throw new RangeError( `spectral flux requires ${outputBytes.toLocaleString()} output bytes; ` + `maximumOutputBytes is ${maximumOutputBytes.toLocaleString()}.`, ); } const workspaceElements = frameCount === 0 ? 0 : fftSize * 4; const workspaceBytes = checkedByteCount( workspaceElements, Float64Array.BYTES_PER_ELEMENT, "spectral-flux workspace", ); const estimatedPeakTypedArrayBytes = outputBytes + workspaceBytes; if (!Number.isSafeInteger(estimatedPeakTypedArrayBytes)) { throw new RangeError("spectral-flux peak typed-array byte count exceeds safe integer range."); } const positiveDifferences = new Float64Array(positiveDifferenceElements); const rawOdf = new Float64Array(frameCount); const frameStartSamples = new Float64Array(frameCount); const frameCenterSamples = new Float64Array(frameCount); const frameAvailableAfterSamples = new Float64Array(frameCount); const workspaceLength = frameCount === 0 ? 0 : fftSize; const window = symmetricHammingWindow(workspaceLength); const real = new Float64Array(workspaceLength); const imaginary = new Float64Array(workspaceLength); const previousMagnitudes = new Float64Array(workspaceLength); for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { const frameStart = frameIndex * hopSize; frameStartSamples[frameIndex] = frameStart; frameCenterSamples[frameIndex] = frameStart + (fftSize - 1) / 2; frameAvailableAfterSamples[frameIndex] = frameStart + fftSize; imaginary.fill(0); for (let offset = 0; offset < fftSize; offset += 1) { real[offset] = samples[frameStart + offset] * window[offset]; } fftInPlace(real, imaginary); let frameFlux = 0; const differenceOffset = frameIndex * binCount; for (let binIndex = 0; binIndex < binCount; binIndex += 1) { const magnitude = Math.hypot(real[binIndex], imaginary[binIndex]); if (!Number.isFinite(magnitude)) { throw new RangeError( `STFT magnitude became non-finite at frame ${frameIndex}, bin ${binIndex}.`, ); } const difference = frameIndex === 0 ? 0 : Math.max(0, magnitude - previousMagnitudes[binIndex]); positiveDifferences[differenceOffset + binIndex] = difference; frameFlux += difference; previousMagnitudes[binIndex] = magnitude; } if (!Number.isFinite(frameFlux)) { throw new RangeError(`spectral flux became non-finite at frame ${frameIndex}.`); } rawOdf[frameIndex] = frameFlux; } return { rawOdf, positiveDifferences, positiveDifferenceLayout: { order: "frame-major", frameStride: binCount, binCount, firstFrameHasNoPredecessor: true, }, frameCount, binCount, sampleRate, sampleCount: samples.length, fftSize, hopSize, frameStartSamples, frameCenterSamples, frameAvailableAfterSamples, workload: { completeFrameCount: frameCount, transformCount: frameCount, binCount, comparedBinCount: Math.max(0, frameCount - 1) * binCount, positiveDifferenceElements, positiveDifferenceBytes, rawOdfBytes, frameTimingBytes, outputBytes, maximumOutputBytes, workspaceBytes, estimatedPeakTypedArrayBytes, }, latency: { completeFramesOnly: true, analysisWindowSamples: fftSize, analysisWindowSeconds: fftSize / sampleRate, updateHopSamples: hopSize, updateHopSeconds: hopSize / sampleRate, firstSpectrumAvailableAfterSamples: frameCount > 0 ? fftSize : null, firstDifferenceAvailableAfterSamples: frameCount > 1 ? fftSize + hopSize : null, timestampConvention: "frameStartSamples/frameCenterSamples are descriptive; frameAvailableAfterSamples is causal availability", }, provenance: { algorithm: "dixon-2006-linear-magnitude-half-wave-l1-spectral-flux", reference: { title: "Onset Detection Revisited", author: "Simon Dixon", year: 2006, url: DIXON_2006_URL, }, input: { channelMode: "one-explicit-channel", channelId, microphoneFusion: "none", }, stft: { window: "symmetric-hamming", windowFormula: "0.54 - 0.46*cos(2*pi*n/(N-1))", framePolicy: "complete-only-no-padding", spectrumBins: "full-dft-bin-order-including-the-real-signal-negative-frequency-mirror", }, magnitudeScale: "linear", difference: "positive-only-half-wave-rectified", frequencyAggregation: "unweighted-l1-sum-over-full-dft", spectrumNormalization: "none", globalNormalization: "none", }, measurementSemantics: { kind: "raw-onset-novelty", sustainedActivityMeasure: false, initialFrameValue: "zero-because-no-preceding-spectrum-exists", }, limitations: { steadySound: "After the initial spectral rise, an exactly steady spectrum produces zero flux; finite precision and noise can leave small positive values.", excludedInferences: [ "event probability", "sustained activity duration", "source count", "source location", "automatically selected frequency band", ], }, }; } /** * Apply the causal online peak-picking conditions evaluated by Böck, Krebs, * and Schedl (2012). All operating parameters are required from the caller. * The local-maximum and adaptive-mean windows contain only the current and * past frames; no future frames or global normalization are used. */ export function pickCausalSpectralFluxPeaks(rawOdfInput, options) { const rawOdfSource = requireSingleChannelSamples(rawOdfInput); const configuration = requireConfigurationObject(options, "options"); const delta = requireNonNegativeFinite(requireOwn(configuration, "delta"), "delta"); const preMaxFrames = requireNonNegativeSafeInteger( requireOwn(configuration, "preMaxFrames"), "preMaxFrames", ); const preMeanFrames = requireNonNegativeSafeInteger( requireOwn(configuration, "preMeanFrames"), "preMeanFrames", ); const refractoryFrames = requireNonNegativeSafeInteger( requireOwn(configuration, "refractoryFrames"), "refractoryFrames", ); const rawOdf = Float64Array.from(rawOdfSource); for (let frameIndex = 0; frameIndex < rawOdf.length; frameIndex += 1) { if (rawOdf[frameIndex] < 0) { throw new RangeError(`rawOdfInput[${frameIndex}] must be non-negative.`); } } const adaptiveThresholds = new Float64Array(rawOdf.length); const peakMask = new Uint8Array(rawOdf.length); const peakFrameIndices = []; const peaks = []; let lastPeakFrame = null; let runningMeanWindowSum = 0; for (let frameIndex = 0; frameIndex < rawOdf.length; frameIndex += 1) { const current = rawOdf[frameIndex]; runningMeanWindowSum += current; const expiredMeanIndex = frameIndex - preMeanFrames - 1; if (expiredMeanIndex >= 0) runningMeanWindowSum -= rawOdf[expiredMeanIndex]; const meanWindowStart = Math.max(0, frameIndex - preMeanFrames); const meanWindowLength = frameIndex - meanWindowStart + 1; const localMean = runningMeanWindowSum / meanWindowLength; const adaptiveThreshold = localMean + delta; if (!Number.isFinite(adaptiveThreshold)) { throw new RangeError(`adaptive threshold became non-finite at frame ${frameIndex}.`); } adaptiveThresholds[frameIndex] = adaptiveThreshold; const maxWindowStart = Math.max(0, frameIndex - preMaxFrames); let isPastLocalMaximum = true; for ( let comparisonIndex = maxWindowStart; comparisonIndex < frameIndex; comparisonIndex += 1 ) { if (rawOdf[comparisonIndex] > current) { isPastLocalMaximum = false; break; } } const clearsAdaptiveThreshold = current >= adaptiveThreshold; const clearsRefractory = lastPeakFrame === null || frameIndex - lastPeakFrame > refractoryFrames; if (isPastLocalMaximum && clearsAdaptiveThreshold && clearsRefractory) { peakMask[frameIndex] = 1; peakFrameIndices.push(frameIndex); peaks.push({ frameIndex, rawOdf: current, adaptiveThreshold, currentAndPastLocalMean: localMean, }); lastPeakFrame = frameIndex; } } return { rawOdf, adaptiveThresholds, peakMask, peakFrameIndices: Uint32Array.from(peakFrameIndices), peaks, configuration: { delta, preMaxFrames, preMeanFrames, refractoryFrames, postMaxFrames: 0, postMeanFrames: 0, }, latency: { causal: true, futureContextFrames: 0, addedLookaheadFrames: 0, }, provenance: { algorithm: "boeck-krebs-schedl-2012-causal-online-peak-picker", reference: { title: "Evaluating the Online Capabilities of Onset Detection Methods", authors: ["Sebastian Böck", "Florian Krebs", "Markus Schedl"], year: 2012, url: BOECK_KREBS_SCHEDL_2012_URL, }, localMaximum: "current-equals-maximum-of-current-and-available-past-window", adaptiveThreshold: "mean-of-current-and-available-past-window-plus-delta", refractoryCondition: "currentFrame-minus-lastPeakFrame-strictly-greater-than-refractoryFrames", edgePolicy: "truncate-windows-to-available-past", globalNormalization: "none", }, measurementSemantics: { kind: "thresholded-onset-events-from-raw-novelty", deltaMeaning: "caller-selected-detection-operating-offset", }, }; }