@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.
- package/hps.js +83 -0
- 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
|
+
}
|