@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 +87 -0
- package/energy.js +23 -0
- package/index.js +22 -0
- package/package.json +50 -0
- package/phase.js +73 -0
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
|
+
}
|