@audio/sinusoidal-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 +33 -0
- package/track.js +71 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/sinusoidal-track",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Sinusoidal analysis — STFT peaks (parabolic) + nearest-frequency partial tracking (McAulay-Quatieri 1986)",
|
|
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
|
+
"fourier-transform": "^2.2.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"audio",
|
|
20
|
+
"dsp",
|
|
21
|
+
"sinusoidal",
|
|
22
|
+
"sms",
|
|
23
|
+
"track"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/track.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Sinusoidal analysis — STFT peak picking with parabolic interpolation + nearest-frequency
|
|
2
|
+
// partial tracking (McAulay & Quatieri 1986). Returns partial trajectories for additive
|
|
3
|
+
// resynthesis (@audio/sinusoidal-synth) and residual extraction (@audio/sinusoidal-residual).
|
|
4
|
+
|
|
5
|
+
import { fft } from 'fourier-transform'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {Float32Array} data — mono PCM
|
|
9
|
+
* @param {object} opts — { fs=44100, frameSize=2048, hop=512, maxPartials=40,
|
|
10
|
+
* threshold=-60 (dB rel max), maxJump=60 (cents partial continuation) }
|
|
11
|
+
* @returns {{ partials: Array<{start:number, freqs:number[], amps:number[]}>,
|
|
12
|
+
* frames: number, hop: number, frameSize: number, fs: number }}
|
|
13
|
+
*/
|
|
14
|
+
export default function track (data, { fs = 44100, frameSize = 2048, hop = 512, maxPartials = 40, threshold = -60, maxJump = 60 } = {}) {
|
|
15
|
+
let half = frameSize / 2
|
|
16
|
+
let win = new Float64Array(frameSize)
|
|
17
|
+
let winSum = 0
|
|
18
|
+
for (let i = 0; i < frameSize; i++) { win[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / frameSize); winSum += win[i] }
|
|
19
|
+
let buf = new Float64Array(frameSize)
|
|
20
|
+
let nFrames = Math.max(0, Math.floor((data.length - frameSize) / hop) + 1)
|
|
21
|
+
|
|
22
|
+
let live = [] // active partials
|
|
23
|
+
let done = []
|
|
24
|
+
for (let t = 0; t < nFrames; t++) {
|
|
25
|
+
for (let i = 0; i < frameSize; i++) buf[i] = data[t * hop + i] * win[i]
|
|
26
|
+
let [re, im] = fft(buf)
|
|
27
|
+
let mag = new Float64Array(half + 1)
|
|
28
|
+
let max = 0
|
|
29
|
+
for (let k = 0; k <= half; k++) { mag[k] = Math.hypot(re[k], im[k]); if (mag[k] > max) max = mag[k] }
|
|
30
|
+
let floor = max * 10 ** (threshold / 20)
|
|
31
|
+
|
|
32
|
+
// peaks with parabolic interpolation (dB domain)
|
|
33
|
+
let peaks = []
|
|
34
|
+
for (let k = 2; k < half - 2; k++) {
|
|
35
|
+
if (mag[k] > floor && mag[k] > mag[k - 1] && mag[k] >= mag[k + 1]) {
|
|
36
|
+
let a = Math.log(mag[k - 1] + 1e-12), b = Math.log(mag[k] + 1e-12), c = Math.log(mag[k + 1] + 1e-12)
|
|
37
|
+
let p = 0.5 * (a - c) / (a - 2 * b + c || 1e-12)
|
|
38
|
+
if (!(p > -1 && p < 1)) p = 0
|
|
39
|
+
peaks.push({ freq: (k + p) * fs / frameSize, amp: mag[k] * 2 / winSum })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
peaks.sort((a, b) => b.amp - a.amp)
|
|
43
|
+
peaks = peaks.slice(0, maxPartials)
|
|
44
|
+
|
|
45
|
+
// continuation: match each live partial to nearest unclaimed peak within maxJump cents
|
|
46
|
+
let claimed = new Set()
|
|
47
|
+
for (let p of live) {
|
|
48
|
+
let last = p.freqs[p.freqs.length - 1]
|
|
49
|
+
let best = -1, bestC = maxJump
|
|
50
|
+
for (let j = 0; j < peaks.length; j++) {
|
|
51
|
+
if (claimed.has(j)) continue
|
|
52
|
+
let c = Math.abs(1200 * Math.log2(peaks[j].freq / last))
|
|
53
|
+
if (c < bestC) { bestC = c; best = j }
|
|
54
|
+
}
|
|
55
|
+
if (best >= 0) {
|
|
56
|
+
claimed.add(best)
|
|
57
|
+
p.freqs.push(peaks[best].freq)
|
|
58
|
+
p.amps.push(peaks[best].amp)
|
|
59
|
+
} else p.dead = true
|
|
60
|
+
}
|
|
61
|
+
done.push(...live.filter(p => p.dead))
|
|
62
|
+
live = live.filter(p => !p.dead)
|
|
63
|
+
for (let j = 0; j < peaks.length; j++) {
|
|
64
|
+
if (!claimed.has(j)) live.push({ start: t, freqs: [peaks[j].freq], amps: [peaks[j].amp] })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
done.push(...live)
|
|
68
|
+
// keep partials lasting ≥ 3 frames
|
|
69
|
+
let partials = done.filter(p => p.freqs.length >= 3)
|
|
70
|
+
return { partials, frames: nFrames, hop, frameSize, fs }
|
|
71
|
+
}
|