Files

478 lines
16 KiB
JavaScript

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";
export const DEFAULT_MAXIMUM_SPECTRAL_FLUX_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;
}
export function estimateDixonSpectralFluxWorkload(options) {
const configuration = requireConfigurationObject(options, "options");
const sampleCount = requireNonNegativeSafeInteger(
requireOwn(configuration, "sampleCount"),
"sampleCount",
);
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 maximumOutputBytes =
configuration.maximumOutputBytes ??
DEFAULT_MAXIMUM_SPECTRAL_FLUX_OUTPUT_BYTES;
if (!Number.isSafeInteger(maximumOutputBytes) || maximumOutputBytes <= 0) {
throw new RangeError("maximumOutputBytes must be a positive safe integer.");
}
const frameCount = sampleCount < fftSize
? 0
: 1 + Math.floor((sampleCount - 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.",
);
}
return Object.freeze({
sampleCount,
frameCount,
binCount,
positiveDifferenceElements,
positiveDifferenceBytes,
rawOdfBytes,
frameTimingBytes,
outputBytes,
maximumOutputBytes,
workspaceBytes,
estimatedPeakTypedArrayBytes,
});
}
/**
* 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 workload = estimateDixonSpectralFluxWorkload({
sampleCount: samples.length,
fftSize,
hopSize,
maximumOutputBytes: configuration.maximumOutputBytes,
});
const {
frameCount,
binCount,
positiveDifferenceElements,
positiveDifferenceBytes,
rawOdfBytes,
frameTimingBytes,
outputBytes,
maximumOutputBytes,
workspaceBytes,
estimatedPeakTypedArrayBytes,
} = workload;
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",
},
};
}