@audio/pitch-pyin 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 +40 -0
- package/pyin.js +103 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/pitch-pyin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "pYIN — probabilistic YIN (Mauch & Dixon, 2014)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "pyin.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./pyin.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"pyin.js"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"audio",
|
|
17
|
+
"dsp",
|
|
18
|
+
"pitch",
|
|
19
|
+
"pitch-detection",
|
|
20
|
+
"f0",
|
|
21
|
+
"pyin"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/audiojs/pitch.git",
|
|
28
|
+
"directory": "packages/pitch-pyin"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/audiojs/pitch/tree/main/packages/pitch-pyin",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/audiojs/pitch/issues"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/pyin.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pYIN — probabilistic YIN (Mauch & Dixon, 2014).
|
|
3
|
+
*
|
|
4
|
+
* Runs YIN over a range of thresholds and weights each result by the
|
|
5
|
+
* Beta(α=2, β=18) prior, producing a distribution over candidate periods
|
|
6
|
+
* instead of a single hard pick. For a single frame the function returns
|
|
7
|
+
* the most probable candidate; the full posterior is exposed via `candidates`.
|
|
8
|
+
*
|
|
9
|
+
* Full pYIN additionally runs Viterbi smoothing over a sequence of frames
|
|
10
|
+
* with a pitch-transition prior — implemented separately via `track()`.
|
|
11
|
+
*
|
|
12
|
+
* @param {Float32Array | Float64Array} data - window of samples
|
|
13
|
+
* @param {{fs?: number, minFreq?: number, maxFreq?: number}} [params]
|
|
14
|
+
* @returns {{freq: number, clarity: number, candidates: {freq: number, prob: number}[]} | null}
|
|
15
|
+
*/
|
|
16
|
+
export default function pyin(data, params) {
|
|
17
|
+
let fs = params?.fs || 44100
|
|
18
|
+
let minFreq = params?.minFreq ?? 50
|
|
19
|
+
let maxFreq = params?.maxFreq ?? 2000
|
|
20
|
+
let len = data.length
|
|
21
|
+
let half = len >> 1
|
|
22
|
+
|
|
23
|
+
let tauMin = Math.max(2, Math.floor(fs / maxFreq))
|
|
24
|
+
let tauMax = Math.min(half - 1, Math.ceil(fs / minFreq))
|
|
25
|
+
if (tauMax <= tauMin + 1) return null
|
|
26
|
+
|
|
27
|
+
// YIN step 1–2: cumulative mean normalized difference (CMND)
|
|
28
|
+
let d = new Float64Array(half)
|
|
29
|
+
for (let tau = 1; tau < half; tau++) {
|
|
30
|
+
let sum = 0
|
|
31
|
+
for (let i = 0; i < half; i++) {
|
|
32
|
+
let diff = data[i] - data[i + tau]
|
|
33
|
+
sum += diff * diff
|
|
34
|
+
}
|
|
35
|
+
d[tau] = sum
|
|
36
|
+
}
|
|
37
|
+
let cmndf = new Float64Array(half)
|
|
38
|
+
cmndf[0] = 1
|
|
39
|
+
let running = 0
|
|
40
|
+
for (let tau = 1; tau < half; tau++) {
|
|
41
|
+
running += d[tau]
|
|
42
|
+
cmndf[tau] = running > 0 ? d[tau] * tau / running : 1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// thresholds and Beta(2,18) weights (discrete prior)
|
|
46
|
+
let thresholds = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50]
|
|
47
|
+
let weights = thresholds.map(t => betaPdf(t * 2, 2, 18))
|
|
48
|
+
let wSum = weights.reduce((a, b) => a + b, 0)
|
|
49
|
+
weights = weights.map(w => w / wSum)
|
|
50
|
+
|
|
51
|
+
// for each threshold, pick the first τ whose CMND dips below it (with local-min descent)
|
|
52
|
+
// aggregate into probability distribution over periods
|
|
53
|
+
let probByTau = new Map()
|
|
54
|
+
for (let i = 0; i < thresholds.length; i++) {
|
|
55
|
+
let thr = thresholds[i]
|
|
56
|
+
let tau = tauMin
|
|
57
|
+
while (tau < tauMax) {
|
|
58
|
+
if (cmndf[tau] < thr) {
|
|
59
|
+
while (tau + 1 < tauMax && cmndf[tau + 1] < cmndf[tau]) tau++
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
tau++
|
|
63
|
+
}
|
|
64
|
+
if (tau >= tauMax) continue
|
|
65
|
+
// voicing probability: higher when CMND is well below threshold
|
|
66
|
+
let voiced = Math.max(0, Math.min(1, 1 - cmndf[tau]))
|
|
67
|
+
let p = weights[i] * voiced
|
|
68
|
+
probByTau.set(tau, (probByTau.get(tau) || 0) + p)
|
|
69
|
+
}
|
|
70
|
+
if (probByTau.size === 0) return null
|
|
71
|
+
|
|
72
|
+
// build candidate list sorted by probability
|
|
73
|
+
let candidates = [...probByTau.entries()]
|
|
74
|
+
.map(([tau, prob]) => ({ tau, prob }))
|
|
75
|
+
.sort((a, b) => b.prob - a.prob)
|
|
76
|
+
|
|
77
|
+
// parabolic interpolation on the best τ
|
|
78
|
+
let refine = (tau) => {
|
|
79
|
+
if (tau < 1 || tau >= half - 1) return tau
|
|
80
|
+
let s0 = cmndf[tau - 1], s1 = cmndf[tau], s2 = cmndf[tau + 1]
|
|
81
|
+
let denom = s0 - 2 * s1 + s2
|
|
82
|
+
return denom !== 0 ? tau + (s0 - s2) / (2 * denom) : tau
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let totalProb = candidates.reduce((a, c) => a + c.prob, 0)
|
|
86
|
+
let freqCandidates = candidates.map(c => ({
|
|
87
|
+
freq: fs / refine(c.tau),
|
|
88
|
+
prob: c.prob / totalProb,
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
let best = freqCandidates[0]
|
|
92
|
+
return {
|
|
93
|
+
freq: best.freq,
|
|
94
|
+
clarity: Math.min(1, totalProb),
|
|
95
|
+
candidates: freqCandidates,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Beta(α, β) density at x ∈ [0,1]. Unnormalized form suffices for weighting.
|
|
100
|
+
function betaPdf(x, a, b) {
|
|
101
|
+
if (x <= 0 || x >= 1) return 0
|
|
102
|
+
return Math.pow(x, a - 1) * Math.pow(1 - x, b - 1)
|
|
103
|
+
}
|