@audio/beat-onset 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/band.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Multi-band onset detection.
3
+ * Splits spectrum into logarithmic frequency bands, computes spectral flux per band, sums.
4
+ * Better at detecting onsets across different instrument ranges.
5
+ * @param {Float32Array|Float64Array} data - Audio samples (mono)
6
+ * @param {Object} [opts]
7
+ * @param {number} [opts.fs=44100] - Sample rate
8
+ * @param {number} [opts.frameSize=2048] - STFT frame size
9
+ * @param {number} [opts.hopSize=512] - STFT hop size
10
+ * @param {number} [opts.bands=4] - Number of frequency bands (logarithmic spacing)
11
+ * @param {number} [opts.delta=1.4] - Peak-pick threshold multiplier
12
+ * @param {number} [opts.windowSize=8] - Peak-pick local mean window (frames)
13
+ * @returns {Float64Array} Onset times in seconds
14
+ * @see Klapuri, "Sound Onset Detection by Applying Psychoacoustic Knowledge" (ICASSP 1999)
15
+ */
16
+
17
+ import fft from 'fourier-transform'
18
+ import { hann } from 'window-function'
19
+ import { peakPick, validate } from '@audio/beat-core'
20
+
21
+ export default function bandOnsets(data, opts) {
22
+ validate(data, opts)
23
+ let fs = opts?.fs || 44100
24
+ let frameSize = opts?.frameSize || 2048
25
+ let hopSize = opts?.hopSize || 512
26
+ let bands = opts?.bands || 4
27
+
28
+ let nFrames = Math.floor((data.length - frameSize) / hopSize) + 1
29
+ if (nFrames < 2) return new Float64Array(0)
30
+
31
+ let win = new Float64Array(frameSize)
32
+ for (let i = 0; i < frameSize; i++) win[i] = hann(i, frameSize)
33
+
34
+ let nBins = frameSize / 2
35
+ // logarithmic band edges (psychoacoustic spacing per Klapuri 1999)
36
+ let bandEdges = [0]
37
+ let maxHz = fs / 2
38
+ let logMin = Math.log(20), logMax = Math.log(maxHz)
39
+ for (let b = 1; b <= bands; b++) {
40
+ let hz = Math.exp(logMin + (logMax - logMin) * b / bands)
41
+ bandEdges.push(Math.min(nBins, Math.max(bandEdges[b - 1] + 1, Math.round(hz / maxHz * nBins))))
42
+ }
43
+ bandEdges[bands] = nBins
44
+
45
+ // per-band ODF arrays — separate flux timeseries per band
46
+ let bandOdfs = Array.from({ length: bands }, () => new Float64Array(nFrames))
47
+ let prevMag = null
48
+
49
+ for (let f = 0; f < nFrames; f++) {
50
+ let offset = f * hopSize
51
+ let frame = new Float64Array(frameSize)
52
+ for (let i = 0; i < frameSize; i++) frame[i] = (data[offset + i] || 0) * win[i]
53
+ let mag = new Float64Array(fft(frame))
54
+
55
+ if (prevMag) {
56
+ for (let b = 0; b < bands; b++) {
57
+ let lo = bandEdges[b], hi = bandEdges[b + 1], flux = 0
58
+ for (let i = lo; i < hi; i++) {
59
+ let diff = mag[i] - prevMag[i]
60
+ if (diff > 0) flux += diff
61
+ }
62
+ bandOdfs[b][f] = flux
63
+ }
64
+ }
65
+ prevMag = mag
66
+ }
67
+
68
+ // peak-pick each band independently, then merge and deduplicate
69
+ let minGap = hopSize / fs
70
+ let all = []
71
+ for (let b = 0; b < bands; b++) {
72
+ let hasEnergy = false
73
+ for (let i = 0; i < nFrames; i++) if (bandOdfs[b][i] > 0) { hasEnergy = true; break }
74
+ if (!hasEnergy) continue
75
+ let ons = peakPick(bandOdfs[b], { hopSize, fs, ...opts })
76
+ for (let t of ons) all.push(t)
77
+ }
78
+
79
+ if (!all.length) return new Float64Array(0)
80
+
81
+ all.sort((a, b) => a - b)
82
+ let deduped = [all[0]]
83
+ for (let i = 1; i < all.length; i++)
84
+ if (all[i] - deduped[deduped.length - 1] > minGap) deduped.push(all[i])
85
+
86
+ return new Float64Array(deduped)
87
+ }
package/energy.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Energy-based onset detection.
3
+ * Per-frame RMS → positive first differences → adaptive threshold peak-pick.
4
+ * Simpler and faster than spectral flux. Best for percussive / strong transient signals.
5
+ * @param {Float32Array|Float64Array} data - Audio samples (mono)
6
+ * @param {Object} [opts]
7
+ * @param {number} [opts.fs=44100] - Sample rate
8
+ * @param {number} [opts.frameSize=2048] - Frame size
9
+ * @param {number} [opts.hopSize=512] - Hop size
10
+ * @param {number} [opts.delta=1.4] - Peak-pick threshold multiplier
11
+ * @param {number} [opts.windowSize=8] - Peak-pick local mean window (frames)
12
+ * @returns {Float64Array} Onset times in seconds
13
+ * @see Klapuri, "Auditory Model Based Beat Tracking" (ICMC 1999)
14
+ */
15
+
16
+ import { energyFlux, peakPick, validate } from '@audio/beat-core'
17
+
18
+ export default function energyOnsets(data, opts) {
19
+ validate(data, opts)
20
+ let { odf, hopSize, fs } = energyFlux(data, opts)
21
+ if (!odf.length) return new Float64Array(0)
22
+ return peakPick(odf, { hopSize, fs, ...opts })
23
+ }
package/index.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Spectral flux onset detection.
3
+ * STFT → magnitude → sum positive differences → adaptive threshold peak-pick.
4
+ * @param {Float32Array|Float64Array} data - Audio samples (mono)
5
+ * @param {Object} [opts]
6
+ * @param {number} [opts.fs=44100] - Sample rate
7
+ * @param {number} [opts.frameSize=2048] - STFT frame size
8
+ * @param {number} [opts.hopSize=512] - Hop between frames
9
+ * @param {number} [opts.delta=1.4] - Peak-pick threshold multiplier
10
+ * @param {number} [opts.windowSize=8] - Peak-pick local mean window (frames)
11
+ * @returns {Float64Array} Onset times in seconds
12
+ * @see Dixon, "Onset Detection Revisited" (DAFx 2006)
13
+ */
14
+
15
+ import { spectralFlux, peakPick, validate } from '@audio/beat-core'
16
+
17
+ export default function onsets(data, opts) {
18
+ validate(data, opts)
19
+ let { odf, hopSize, fs } = spectralFlux(data, opts)
20
+ if (!odf.length) return new Float64Array(0)
21
+ return peakPick(odf, { hopSize, fs, ...opts })
22
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@audio/beat-onset",
3
+ "version": "1.0.0",
4
+ "description": "Spectral flux onset detection",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./package.json": "./package.json",
11
+ "./energy": "./energy.js",
12
+ "./phase": "./phase.js",
13
+ "./band": "./band.js"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "energy.js",
18
+ "phase.js",
19
+ "band.js"
20
+ ],
21
+ "dependencies": {
22
+ "@audio/beat-core": "^1.0.0",
23
+ "fourier-transform": "^2.2.0",
24
+ "window-function": "^3.0.0"
25
+ },
26
+ "keywords": [
27
+ "audio",
28
+ "dsp",
29
+ "beat",
30
+ "onset",
31
+ "onset-detection"
32
+ ],
33
+ "license": "MIT",
34
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/audiojs/beat.git",
38
+ "directory": "packages/beat-onset"
39
+ },
40
+ "homepage": "https://github.com/audiojs/beat/tree/main/packages/beat-onset",
41
+ "bugs": {
42
+ "url": "https://github.com/audiojs/beat/issues"
43
+ },
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
package/phase.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Complex-domain onset detection (phase deviation).
3
+ * Measures divergence between predicted and actual STFT phase.
4
+ * More robust to steady-state signals than spectral flux.
5
+ * @param {Float32Array|Float64Array} data - Audio samples (mono)
6
+ * @param {Object} [opts]
7
+ * @param {number} [opts.fs=44100] - Sample rate
8
+ * @param {number} [opts.frameSize=2048] - STFT frame size
9
+ * @param {number} [opts.hopSize=512] - STFT hop size
10
+ * @param {number} [opts.delta=1.4] - Peak-pick threshold multiplier
11
+ * @param {number} [opts.windowSize=8] - Peak-pick local mean window (frames)
12
+ * @returns {Float64Array} Onset times in seconds
13
+ * @see Bello et al., "A Tutorial on Onset Detection in Music Signals" (TASLP 2005)
14
+ */
15
+
16
+ import { cfft } from 'fourier-transform'
17
+ import { hann } from 'window-function'
18
+ import { peakPick, validate } from '@audio/beat-core'
19
+
20
+ export default function phaseOnsets(data, opts) {
21
+ validate(data, opts)
22
+ let fs = opts?.fs || 44100
23
+ let frameSize = opts?.frameSize || 2048
24
+ let hopSize = opts?.hopSize || 512
25
+
26
+ let nFrames = Math.floor((data.length - frameSize) / hopSize) + 1
27
+ if (nFrames < 3) return new Float64Array(0)
28
+
29
+ let win = new Float64Array(frameSize)
30
+ for (let i = 0; i < frameSize; i++) win[i] = hann(i, frameSize)
31
+
32
+ let odf = new Float64Array(nFrames)
33
+ let prevPhase = null, prevPrevPhase = null
34
+
35
+ for (let f = 0; f < nFrames; f++) {
36
+ let offset = f * hopSize
37
+ let re = new Float64Array(frameSize)
38
+ let im = new Float64Array(frameSize)
39
+ for (let i = 0; i < frameSize; i++) re[i] = (data[offset + i] || 0) * win[i]
40
+ cfft(re, im)
41
+
42
+ let nBins = frameSize / 2
43
+ let phase = new Float64Array(nBins)
44
+ let mag = new Float64Array(nBins)
45
+ for (let i = 0; i < nBins; i++) {
46
+ mag[i] = Math.sqrt(re[i] * re[i] + im[i] * im[i])
47
+ phase[i] = Math.atan2(im[i], re[i])
48
+ }
49
+
50
+ // phase deviation: |actual - predicted|, predicted = 2*prev - prevPrev
51
+ if (prevPhase && prevPrevPhase) {
52
+ let deviation = 0
53
+ for (let i = 0; i < nBins; i++) {
54
+ let predicted = 2 * prevPhase[i] - prevPrevPhase[i]
55
+ let diff = phase[i] - predicted
56
+ // wrap to [-π, π]
57
+ diff -= Math.round(diff / (2 * Math.PI)) * (2 * Math.PI)
58
+ deviation += Math.abs(diff) * mag[i]
59
+ }
60
+ odf[f] = deviation
61
+ }
62
+
63
+ prevPrevPhase = prevPhase
64
+ prevPhase = phase
65
+ }
66
+
67
+ // check for silence
68
+ let hasEnergy = false
69
+ for (let i = 0; i < odf.length; i++) { if (odf[i] > 0) { hasEnergy = true; break } }
70
+ if (!hasEnergy) return new Float64Array(0)
71
+
72
+ return peakPick(odf, { hopSize, fs, ...opts })
73
+ }