@audio/beat-track 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/package.json +42 -0
- package/track.js +105 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/beat-track",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Dynamic programming beat tracker",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "track.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./track.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"track.js"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@audio/beat-core": "^1.0.0",
|
|
17
|
+
"@audio/beat-tempo": "^1.0.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"audio",
|
|
21
|
+
"dsp",
|
|
22
|
+
"beat",
|
|
23
|
+
"beat-tracking"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/audiojs/beat.git",
|
|
30
|
+
"directory": "packages/beat-track"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/audiojs/beat/tree/main/packages/beat-track",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/audiojs/beat/issues"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/track.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic programming beat tracker.
|
|
3
|
+
* Builds optimal beat sequence by scoring onset strength + tempo consistency.
|
|
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] - STFT hop size
|
|
9
|
+
* @param {number} [opts.minBpm=60] - Minimum BPM to consider
|
|
10
|
+
* @param {number} [opts.maxBpm=200] - Maximum BPM to consider
|
|
11
|
+
* @param {number} [opts.bpm] - Target BPM (auto-estimated if omitted)
|
|
12
|
+
* @param {number} [opts.tightness=680] - Tempo constraint weight
|
|
13
|
+
* @returns {{ beats: Float64Array, bpm: number, confidence: number }}
|
|
14
|
+
* @see Ellis, "Beat Tracking by Dynamic Programming" (JNMR 2007)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spectralFlux, ODF, validate } from '@audio/beat-core'
|
|
18
|
+
import combTempo from '@audio/beat-tempo/comb'
|
|
19
|
+
|
|
20
|
+
export default function beatTrack(data, opts) {
|
|
21
|
+
validate(data, opts)
|
|
22
|
+
let fs = opts?.fs || 44100
|
|
23
|
+
let sf = spectralFlux(data, opts)
|
|
24
|
+
let { odf, nFrames, hopSize } = sf
|
|
25
|
+
if (nFrames < 2) return { beats: new Float64Array(0), bpm: 0, confidence: 0 }
|
|
26
|
+
|
|
27
|
+
let odfRate = fs / hopSize
|
|
28
|
+
|
|
29
|
+
// reuse STFT via ODF cache — same trick detect() uses
|
|
30
|
+
let targetBpm = opts?.bpm || combTempo(data, { ...opts, [ODF]: sf }).bpm || 120
|
|
31
|
+
let targetPeriod = odfRate * 60 / targetBpm
|
|
32
|
+
|
|
33
|
+
// normalize ODF: zero-mean, unit-std — makes Ellis's tightness weight meaningful
|
|
34
|
+
let mean = 0
|
|
35
|
+
for (let i = 0; i < nFrames; i++) mean += odf[i]
|
|
36
|
+
mean /= nFrames
|
|
37
|
+
let variance = 0
|
|
38
|
+
for (let i = 0; i < nFrames; i++) { let d = odf[i] - mean; variance += d * d }
|
|
39
|
+
let std = Math.sqrt(variance / nFrames) || 1
|
|
40
|
+
let odfN = new Float64Array(nFrames)
|
|
41
|
+
for (let i = 0; i < nFrames; i++) odfN[i] = (odf[i] - mean) / std
|
|
42
|
+
|
|
43
|
+
// DP forward: C[i] = max over valid j of ( C[j] + odfN[i] - α·log(gap/p)² )
|
|
44
|
+
// log-ratio penalty gives octave symmetry — gap=2p and gap=p/2 cost the same,
|
|
45
|
+
// and the penalty is sharp enough to pin DP to the target period.
|
|
46
|
+
let alpha = opts?.tightness ?? 680
|
|
47
|
+
let score = new Float64Array(nFrames)
|
|
48
|
+
let prev = new Int32Array(nFrames).fill(-1)
|
|
49
|
+
for (let i = 0; i < nFrames; i++) score[i] = odfN[i]
|
|
50
|
+
|
|
51
|
+
let minGap = Math.max(1, Math.floor(targetPeriod * 0.5))
|
|
52
|
+
let maxGap = Math.ceil(targetPeriod * 2)
|
|
53
|
+
|
|
54
|
+
for (let i = 1; i < nFrames; i++) {
|
|
55
|
+
let lo = Math.max(0, i - maxGap)
|
|
56
|
+
let hi = Math.max(0, i - minGap)
|
|
57
|
+
for (let j = lo; j < hi; j++) {
|
|
58
|
+
let logR = Math.log((i - j) / targetPeriod)
|
|
59
|
+
let s = score[j] + odfN[i] - alpha * logR * logR
|
|
60
|
+
if (s > score[i]) {
|
|
61
|
+
score[i] = s
|
|
62
|
+
prev[i] = j
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// backtrack from best score in the last targetPeriod frames (ensures we
|
|
68
|
+
// reach the end of the signal rather than stopping at a mid-sequence peak)
|
|
69
|
+
let endLo = Math.max(0, nFrames - Math.floor(targetPeriod))
|
|
70
|
+
let bestEnd = endLo
|
|
71
|
+
for (let i = endLo + 1; i < nFrames; i++) {
|
|
72
|
+
if (score[i] > score[bestEnd]) bestEnd = i
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let frames = []
|
|
76
|
+
for (let cur = bestEnd; cur >= 0; cur = prev[cur]) frames.push(cur)
|
|
77
|
+
frames.reverse()
|
|
78
|
+
|
|
79
|
+
let beatIntervalSec = targetPeriod * hopSize / fs
|
|
80
|
+
let beatTimes = frames.map(f => f * hopSize / fs)
|
|
81
|
+
|
|
82
|
+
// extrapolate backward: DP anchors where onset strength is highest, not at t=0.
|
|
83
|
+
// walk back by beatIntervalSec, snapping beats within half a period of t=0 to t=0.
|
|
84
|
+
if (beatTimes.length > 0 && beatTimes[0] > beatIntervalSec * 0.25) {
|
|
85
|
+
let extra = []
|
|
86
|
+
for (let t = beatTimes[0] - beatIntervalSec; t > -beatIntervalSec * 0.5; t -= beatIntervalSec)
|
|
87
|
+
extra.unshift(Math.max(0, t))
|
|
88
|
+
beatTimes = [...extra, ...beatTimes]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let beats = new Float64Array(beatTimes)
|
|
92
|
+
|
|
93
|
+
let confidence = 0
|
|
94
|
+
if (beats.length >= 2) {
|
|
95
|
+
let beatSet = new Set(frames)
|
|
96
|
+
let onBeat = 0, total = 0
|
|
97
|
+
for (let i = 0; i < nFrames; i++) {
|
|
98
|
+
total += odf[i]
|
|
99
|
+
if (beatSet.has(i)) onBeat += odf[i]
|
|
100
|
+
}
|
|
101
|
+
confidence = total > 0 ? onBeat / total : 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { beats, bpm: targetBpm, confidence }
|
|
105
|
+
}
|