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