@audio/beat-core 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/index.js +167 -0
- package/package.json +43 -0
package/index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Shared internal helpers for beat-detection algorithms.
|
|
2
|
+
|
|
3
|
+
import fft from 'fourier-transform'
|
|
4
|
+
import { hann } from 'window-function'
|
|
5
|
+
|
|
6
|
+
/** Symbol key for passing a pre-computed ODF result to tempo/track functions, avoiding a second STFT pass. */
|
|
7
|
+
export const ODF = Symbol('beat-detection:odf')
|
|
8
|
+
|
|
9
|
+
function isPow2(n) { return n > 0 && (n & (n - 1)) === 0 }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate common public API inputs. Call at the boundary of every exported function.
|
|
13
|
+
* Throws TypeError / RangeError with a clear message for bad inputs.
|
|
14
|
+
* @param {*} data - Audio samples or null (null is valid when ODF cache is supplied)
|
|
15
|
+
* @param {Object} [opts]
|
|
16
|
+
*/
|
|
17
|
+
export function validate(data, opts) {
|
|
18
|
+
if (data != null && !(data instanceof Float32Array) && !(data instanceof Float64Array))
|
|
19
|
+
throw new TypeError('data must be Float32Array or Float64Array')
|
|
20
|
+
if (opts?.fs != null && opts.fs <= 0)
|
|
21
|
+
throw new RangeError('fs must be > 0')
|
|
22
|
+
if (opts?.frameSize != null && !isPow2(opts.frameSize))
|
|
23
|
+
throw new RangeError('frameSize must be a power of 2')
|
|
24
|
+
if (opts?.hopSize != null && opts?.frameSize != null && opts.hopSize >= opts.frameSize)
|
|
25
|
+
throw new RangeError('hopSize must be < frameSize')
|
|
26
|
+
if (opts?.minBpm != null && opts?.maxBpm != null && opts.minBpm >= opts.maxBpm)
|
|
27
|
+
throw new RangeError('minBpm must be < maxBpm')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compute spectral flux onset detection function (ODF).
|
|
32
|
+
* STFT → magnitude → sum positive differences.
|
|
33
|
+
* @param {Float32Array|Float64Array} data - Audio samples (mono)
|
|
34
|
+
* @param {Object} [opts]
|
|
35
|
+
* @param {number} [opts.fs=44100] - Sample rate
|
|
36
|
+
* @param {number} [opts.frameSize=2048] - FFT frame size
|
|
37
|
+
* @param {number} [opts.hopSize=512] - Hop size between frames
|
|
38
|
+
* @returns {{ odf: Float64Array, nFrames: number, hopSize: number, frameSize: number, fs: number }}
|
|
39
|
+
*/
|
|
40
|
+
export function spectralFlux(data, opts) {
|
|
41
|
+
let fs = opts?.fs || 44100
|
|
42
|
+
let frameSize = opts?.frameSize || 2048
|
|
43
|
+
let hopSize = opts?.hopSize || 512
|
|
44
|
+
|
|
45
|
+
let nFrames = Math.floor((data.length - frameSize) / hopSize) + 1
|
|
46
|
+
if (nFrames < 2) return { odf: new Float64Array(0), nFrames: 0, hopSize, frameSize, fs }
|
|
47
|
+
|
|
48
|
+
let frame = new Float64Array(frameSize)
|
|
49
|
+
let win = new Float64Array(frameSize)
|
|
50
|
+
for (let i = 0; i < frameSize; i++) win[i] = hann(i, frameSize)
|
|
51
|
+
|
|
52
|
+
let odf = new Float64Array(nFrames)
|
|
53
|
+
let prevMag = null
|
|
54
|
+
|
|
55
|
+
for (let f = 0; f < nFrames; f++) {
|
|
56
|
+
let offset = f * hopSize
|
|
57
|
+
for (let i = 0; i < frameSize; i++) frame[i] = (data[offset + i] || 0) * win[i]
|
|
58
|
+
let mag = new Float64Array(fft(frame))
|
|
59
|
+
if (prevMag) {
|
|
60
|
+
let flux = 0
|
|
61
|
+
for (let i = 0; i < mag.length; i++) {
|
|
62
|
+
let diff = mag[i] - prevMag[i]
|
|
63
|
+
if (diff > 0) flux += diff
|
|
64
|
+
}
|
|
65
|
+
odf[f] = flux
|
|
66
|
+
}
|
|
67
|
+
prevMag = mag
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// check if ODF is entirely silent
|
|
71
|
+
let hasEnergy = false
|
|
72
|
+
for (let i = 0; i < odf.length; i++) { if (odf[i] > 0) { hasEnergy = true; break } }
|
|
73
|
+
if (!hasEnergy) return { odf: new Float64Array(0), nFrames: 0, hopSize, frameSize, fs }
|
|
74
|
+
|
|
75
|
+
return { odf, nFrames, hopSize, frameSize, fs }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Peak-pick ODF with adaptive threshold (local mean × delta).
|
|
80
|
+
* @param {Float64Array} odf - Onset detection function values
|
|
81
|
+
* @param {Object} [opts]
|
|
82
|
+
* @param {number} [opts.hopSize=512] - Hop size (for time conversion)
|
|
83
|
+
* @param {number} [opts.fs=44100] - Sample rate (for time conversion)
|
|
84
|
+
* @param {number} [opts.windowSize=8] - Local mean window size (frames)
|
|
85
|
+
* @param {number} [opts.delta=1.4] - Threshold multiplier over local mean
|
|
86
|
+
* @returns {Float64Array} Onset times in seconds
|
|
87
|
+
*/
|
|
88
|
+
export function peakPick(odf, opts) {
|
|
89
|
+
if (!(odf instanceof Float64Array)) throw new TypeError('odf must be a Float64Array')
|
|
90
|
+
let windowSize = opts?.windowSize || 8
|
|
91
|
+
let delta = opts?.delta || 1.4
|
|
92
|
+
let hopSize = opts?.hopSize || 512
|
|
93
|
+
let fs = opts?.fs || 44100
|
|
94
|
+
|
|
95
|
+
let n = odf.length
|
|
96
|
+
let onsets = []
|
|
97
|
+
|
|
98
|
+
// also detect frame 0 if it has significant energy (first onset)
|
|
99
|
+
if (n > 1 && odf[0] > 0 && odf[0] >= odf[1]) {
|
|
100
|
+
let start = 0, end = Math.min(n, windowSize + 1), sum = 0
|
|
101
|
+
for (let i = start; i < end; i++) sum += odf[i]
|
|
102
|
+
let mean = sum / (end - start)
|
|
103
|
+
if (odf[0] > mean * delta) onsets.push(0)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (let f = 1; f < n - 1; f++) {
|
|
107
|
+
let start = Math.max(0, f - windowSize)
|
|
108
|
+
let end = Math.min(n, f + windowSize + 1)
|
|
109
|
+
let sum = 0
|
|
110
|
+
for (let i = start; i < end; i++) sum += odf[i]
|
|
111
|
+
let mean = sum / (end - start)
|
|
112
|
+
|
|
113
|
+
if (odf[f] > mean * delta && odf[f] > odf[f - 1] && odf[f] >= odf[f + 1]) {
|
|
114
|
+
onsets.push(f * hopSize / fs)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return new Float64Array(onsets)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Energy-based onset detection function.
|
|
123
|
+
* Computes RMS energy per frame, returns positive first differences.
|
|
124
|
+
* @param {Float32Array|Float64Array} data - Audio samples (mono)
|
|
125
|
+
* @param {Object} [opts]
|
|
126
|
+
* @param {number} [opts.fs=44100] - Sample rate
|
|
127
|
+
* @param {number} [opts.frameSize=2048] - Frame size
|
|
128
|
+
* @param {number} [opts.hopSize=512] - Hop size between frames
|
|
129
|
+
* @returns {{ odf: Float64Array, nFrames: number, hopSize: number, frameSize: number, fs: number }}
|
|
130
|
+
* @see Klapuri, "Auditory Model Based Beat Tracking" (ICMC 1999)
|
|
131
|
+
*/
|
|
132
|
+
export function energyFlux(data, opts) {
|
|
133
|
+
let fs = opts?.fs || 44100
|
|
134
|
+
let frameSize = opts?.frameSize || 2048
|
|
135
|
+
let hopSize = opts?.hopSize || 512
|
|
136
|
+
|
|
137
|
+
let nFrames = Math.floor((data.length - frameSize) / hopSize) + 1
|
|
138
|
+
if (nFrames < 2) return { odf: new Float64Array(0), nFrames: 0, hopSize, frameSize, fs }
|
|
139
|
+
|
|
140
|
+
// compute per-frame RMS energy
|
|
141
|
+
let energy = new Float64Array(nFrames)
|
|
142
|
+
for (let f = 0; f < nFrames; f++) {
|
|
143
|
+
let offset = f * hopSize, sum = 0
|
|
144
|
+
for (let i = 0; i < frameSize; i++) {
|
|
145
|
+
let s = data[offset + i] || 0
|
|
146
|
+
sum += s * s
|
|
147
|
+
}
|
|
148
|
+
energy[f] = Math.sqrt(sum / frameSize)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// first difference (positive only = energy increase)
|
|
152
|
+
let odf = new Float64Array(nFrames)
|
|
153
|
+
let hasEnergy = false
|
|
154
|
+
|
|
155
|
+
// detect initial onset: if first frame has significant energy, mark it
|
|
156
|
+
let maxE = 0
|
|
157
|
+
for (let f = 0; f < nFrames; f++) if (energy[f] > maxE) maxE = energy[f]
|
|
158
|
+
if (maxE > 0 && energy[0] > maxE * 0.05) { odf[0] = energy[0]; hasEnergy = true }
|
|
159
|
+
|
|
160
|
+
for (let f = 1; f < nFrames; f++) {
|
|
161
|
+
let diff = energy[f] - energy[f - 1]
|
|
162
|
+
if (diff > 0) { odf[f] = diff; hasEnergy = true }
|
|
163
|
+
}
|
|
164
|
+
if (!hasEnergy) return { odf: new Float64Array(0), nFrames: 0, hopSize, frameSize, fs }
|
|
165
|
+
|
|
166
|
+
return { odf, nFrames, hopSize, frameSize, fs }
|
|
167
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/beat-core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared onset-detection primitives — spectral flux, energy flux, ODF, adaptive peak picking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"fourier-transform": "^2.2.0",
|
|
17
|
+
"window-function": "^3.0.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"audio",
|
|
21
|
+
"dsp",
|
|
22
|
+
"beat",
|
|
23
|
+
"onset",
|
|
24
|
+
"spectral-flux"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/audiojs/beat.git",
|
|
31
|
+
"directory": "packages/beat-core"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/audiojs/beat/tree/main/packages/beat-core",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/audiojs/beat/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|