@audio/pitch-swipe 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 +43 -0
- package/swipe.js +102 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/pitch-swipe",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SWIPE′ — Sawtooth Waveform Inspired Pitch Estimator with prime harmonics",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "swipe.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./swipe.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"swipe.js"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"fourier-transform": "^2.2.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"audio",
|
|
20
|
+
"dsp",
|
|
21
|
+
"pitch",
|
|
22
|
+
"pitch-detection",
|
|
23
|
+
"f0",
|
|
24
|
+
"swipe"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/audiojs/pitch.git",
|
|
31
|
+
"directory": "packages/pitch-swipe"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/audiojs/pitch/tree/main/packages/pitch-swipe",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/audiojs/pitch/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/swipe.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import rfft from 'fourier-transform'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SWIPE′ — Sawtooth Waveform Inspired Pitch Estimator with prime harmonics.
|
|
5
|
+
* Camacho & Harris, 2008. Measures spectral similarity between the window
|
|
6
|
+
* and a sawtooth template whose lobes sit at prime harmonics of the candidate
|
|
7
|
+
* pitch. More accurate than HPS on clean instrumental signals; robust against
|
|
8
|
+
* octave errors because only prime harmonics contribute.
|
|
9
|
+
*
|
|
10
|
+
* Simplified single-window form: uses one FFT instead of the multi-resolution
|
|
11
|
+
* loudness pyramid of the original paper — sufficient for stationary windows.
|
|
12
|
+
*
|
|
13
|
+
* @param {Float32Array | Float64Array} data - window, length must be a power of 2
|
|
14
|
+
* @param {{fs?: number, minFreq?: number, maxFreq?: number, cents?: number, threshold?: number}} [params]
|
|
15
|
+
* @returns {{freq: number, clarity: number} | null}
|
|
16
|
+
*/
|
|
17
|
+
export default function swipe(data, params) {
|
|
18
|
+
let fs = params?.fs || 44100
|
|
19
|
+
let minFreq = params?.minFreq ?? 60
|
|
20
|
+
let maxFreq = params?.maxFreq ?? 4000
|
|
21
|
+
let cents = params?.cents ?? 10 // candidate spacing in cents
|
|
22
|
+
let threshold = params?.threshold ?? 0.15
|
|
23
|
+
let N = data.length
|
|
24
|
+
if ((N & (N - 1)) !== 0) throw new Error('swipe: window length must be a power of 2')
|
|
25
|
+
|
|
26
|
+
// Hann window before FFT — shapes the main lobe to be close to quadratic
|
|
27
|
+
// so parabolic interpolation in the refinement step is accurate.
|
|
28
|
+
let windowed = new Float64Array(N)
|
|
29
|
+
for (let i = 0; i < N; i++) {
|
|
30
|
+
let w = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (N - 1))
|
|
31
|
+
windowed[i] = data[i] * w
|
|
32
|
+
}
|
|
33
|
+
let spec = rfft(windowed)
|
|
34
|
+
let half = spec.length
|
|
35
|
+
// SWIPE operates on √|X(f)| — emphasizes weaker harmonics
|
|
36
|
+
let X = new Float64Array(half)
|
|
37
|
+
for (let i = 0; i < half; i++) X[i] = Math.sqrt(spec[i])
|
|
38
|
+
|
|
39
|
+
let binHz = fs / N
|
|
40
|
+
let primes = [1, 2, 3, 5, 7, 11]
|
|
41
|
+
|
|
42
|
+
// log-spaced candidates: step of `cents` cents
|
|
43
|
+
let ratio = Math.pow(2, cents / 1200)
|
|
44
|
+
let candidates = []
|
|
45
|
+
for (let f = minFreq; f <= maxFreq; f *= ratio) candidates.push(f)
|
|
46
|
+
if (candidates.length < 3) return null
|
|
47
|
+
|
|
48
|
+
// SWIPE kernel at each harmonic: K(f; fk, f0) = cos(2π(f-fk)/f0) restricted
|
|
49
|
+
// to |f-fk| ≤ f0/2. Positive central lobe rewards energy on the harmonic;
|
|
50
|
+
// the cosine sidelobes (−1 at fk ± f0/2) penalize energy halfway between
|
|
51
|
+
// harmonics, which suppresses octave/sub-harmonic errors.
|
|
52
|
+
let strengths = new Float64Array(candidates.length)
|
|
53
|
+
let best = 0, bestVal = -Infinity
|
|
54
|
+
for (let c = 0; c < candidates.length; c++) {
|
|
55
|
+
let f0 = candidates[c]
|
|
56
|
+
let halfW = f0 / 2
|
|
57
|
+
let s = 0, kNorm = 0
|
|
58
|
+
for (let k of primes) {
|
|
59
|
+
let fk = k * f0
|
|
60
|
+
if (fk + halfW >= (half - 1) * binHz) break
|
|
61
|
+
let w = 1 / Math.sqrt(k)
|
|
62
|
+
let bLo = Math.max(1, Math.floor((fk - halfW) / binHz))
|
|
63
|
+
let bHi = Math.min(half - 1, Math.ceil((fk + halfW) / binHz))
|
|
64
|
+
for (let b = bLo; b <= bHi; b++) {
|
|
65
|
+
let df = b * binHz - fk
|
|
66
|
+
if (Math.abs(df) > halfW) continue
|
|
67
|
+
let kern = Math.cos(2 * Math.PI * df / f0)
|
|
68
|
+
s += w * kern * X[b]
|
|
69
|
+
kNorm += w * kern * kern
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let score = kNorm > 0 ? s / Math.sqrt(kNorm) : 0
|
|
73
|
+
strengths[c] = score
|
|
74
|
+
if (score > bestVal) { bestVal = score; best = c }
|
|
75
|
+
}
|
|
76
|
+
if (best <= 0 || best >= candidates.length - 1) return null
|
|
77
|
+
|
|
78
|
+
// parabolic interpolation in log-frequency for sub-cent accuracy
|
|
79
|
+
let s0 = strengths[best - 1], s1 = strengths[best], s2 = strengths[best + 1]
|
|
80
|
+
let denom = s0 - 2 * s1 + s2
|
|
81
|
+
let shift = denom !== 0 ? (s0 - s2) / (2 * denom) : 0
|
|
82
|
+
let logF = Math.log(candidates[best]) + shift * Math.log(ratio)
|
|
83
|
+
let coarse = Math.exp(logF)
|
|
84
|
+
|
|
85
|
+
// Refine by snapping to the nearest spectral peak and applying parabolic
|
|
86
|
+
// interpolation on the magnitude spectrum. SWIPE′ localizes the correct
|
|
87
|
+
// harmonic well but the coarse candidate grid introduces ±few Hz bias;
|
|
88
|
+
// refinement against the raw spectrum recovers sub-bin accuracy.
|
|
89
|
+
let centerBin = Math.round(coarse / binHz)
|
|
90
|
+
let lo = Math.max(1, centerBin - 2), hi = Math.min(half - 2, centerBin + 2)
|
|
91
|
+
let peakBin = centerBin
|
|
92
|
+
for (let b = lo; b <= hi; b++) if (spec[b] > spec[peakBin]) peakBin = b
|
|
93
|
+
let r0 = spec[peakBin - 1], r1 = spec[peakBin], r2 = spec[peakBin + 1]
|
|
94
|
+
let rDen = r0 - 2 * r1 + r2
|
|
95
|
+
let rShift = rDen !== 0 ? (r0 - r2) / (2 * rDen) : 0
|
|
96
|
+
let freq = (peakBin + rShift) * binHz
|
|
97
|
+
|
|
98
|
+
// normalized clarity: peak strength relative to max possible (sum of weights / sum of weights = 1)
|
|
99
|
+
let clarity = Math.max(0, Math.min(1, bestVal))
|
|
100
|
+
if (clarity < threshold) return null
|
|
101
|
+
return { freq, clarity }
|
|
102
|
+
}
|