feat: add standard full-band activity detectors
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user