@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.
Files changed (2) hide show
  1. package/index.js +167 -0
  2. 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
+ }