@audio/pitch-hps 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/hps.js +83 -0
  2. package/package.json +43 -0
package/hps.js ADDED
@@ -0,0 +1,83 @@
1
+ import rfft from 'fourier-transform'
2
+
3
+ /**
4
+ * Harmonic Product Spectrum (Schroeder, 1968).
5
+ * Multiplies the spectrum by its downsampled copies so that harmonic peaks
6
+ * align at the fundamental. Robust to the missing-fundamental problem.
7
+ *
8
+ * @param {Float32Array | Float64Array} data - window, length must be a power of 2
9
+ * @param {{fs?: number, harmonics?: number, minFreq?: number, maxFreq?: number, threshold?: number}} [params]
10
+ * @returns {{freq: number, clarity: number} | null}
11
+ */
12
+ export default function hps(data, params) {
13
+ let fs = params?.fs || 44100
14
+ let K = params?.harmonics ?? 5
15
+ let minFreq = params?.minFreq ?? 50
16
+ let maxFreq = params?.maxFreq ?? 4000
17
+ let threshold = params?.threshold ?? 0.1
18
+ let N = data.length
19
+ if ((N & (N - 1)) !== 0) throw new Error('hps: window length must be a power of 2')
20
+
21
+ let spec = rfft(data) // length N/2, magnitude
22
+ let half = spec.length
23
+ let binHz = fs / N
24
+
25
+ let specMax = 0
26
+ for (let i = 0; i < half; i++) if (spec[i] > specMax) specMax = spec[i]
27
+ if (specMax === 0) return null
28
+ let floor = specMax * 1e-3
29
+
30
+ // Interpolated magnitude lookup — removes bin-alignment bias that would
31
+ // otherwise prefer harmonic candidates whose multiples happen to land on
32
+ // integer bin indices.
33
+ let mag = (f) => {
34
+ if (f <= 0 || f >= (half - 1) * binHz) return floor
35
+ let b = f / binHz
36
+ let bi = b | 0
37
+ let bf = b - bi
38
+ return Math.max(floor, (1 - bf) * spec[bi] + bf * spec[bi + 1])
39
+ }
40
+
41
+ // log-spaced candidates at `cents` resolution (default 10 cents)
42
+ let cents = params?.cents ?? 10
43
+ let ratio = Math.pow(2, cents / 1200)
44
+ let candidates = []
45
+ for (let f = minFreq; f <= maxFreq && f * K < (half - 1) * binHz; f *= ratio) candidates.push(f)
46
+ if (candidates.length < 3) return null
47
+
48
+ // H(f0) = Σ log |X(h·f0)| — multiplicative product in log-domain
49
+ let hpsLog = new Float64Array(candidates.length)
50
+ let best = 0, bestVal = -Infinity
51
+ for (let c = 0; c < candidates.length; c++) {
52
+ let f0 = candidates[c]
53
+ let sum = 0
54
+ for (let h = 1; h <= K; h++) sum += Math.log(mag(h * f0))
55
+ hpsLog[c] = sum
56
+ if (sum > bestVal) { bestVal = sum; best = c }
57
+ }
58
+ if (best <= 0 || best >= candidates.length - 1) return null
59
+
60
+ // parabolic interpolation in log-frequency for sub-cent accuracy
61
+ let s0 = hpsLog[best - 1], s1 = hpsLog[best], s2 = hpsLog[best + 1]
62
+ let denom = s0 - 2 * s1 + s2
63
+ let shift = denom !== 0 ? (s0 - s2) / (2 * denom) : 0
64
+ let logF = Math.log(candidates[best]) + shift * Math.log(ratio)
65
+ let freq = Math.exp(logF)
66
+
67
+ // clarity: ratio of peak height to the second-highest non-adjacent peak.
68
+ // For a truly periodic signal the fundamental towers above every octave
69
+ // and spurious candidate; for noise, several candidates sit close together.
70
+ let second = -Infinity
71
+ for (let c = 0; c < candidates.length; c++) {
72
+ if (Math.abs(c - best) < 3) continue
73
+ // only count local maxima so we don't compare against the flank of `best`
74
+ let left = c > 0 ? hpsLog[c - 1] : -Infinity
75
+ let right = c < candidates.length - 1 ? hpsLog[c + 1] : -Infinity
76
+ if (hpsLog[c] > left && hpsLog[c] > right && hpsLog[c] > second) second = hpsLog[c]
77
+ }
78
+ let clarity = second > -Infinity ? 1 - Math.exp(-(bestVal - second)) : 1
79
+ clarity = Math.max(0, Math.min(1, clarity))
80
+
81
+ if (clarity < threshold) return null
82
+ return { freq, clarity }
83
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@audio/pitch-hps",
3
+ "version": "1.0.0",
4
+ "description": "Harmonic Product Spectrum (Schroeder, 1968)",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "hps.js",
8
+ "exports": {
9
+ ".": "./hps.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "hps.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
+ "hps"
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-hps"
32
+ },
33
+ "homepage": "https://github.com/audiojs/pitch/tree/main/packages/pitch-hps",
34
+ "bugs": {
35
+ "url": "https://github.com/audiojs/pitch/issues"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }