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