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