@audio/mir-chroma 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/chroma.js +139 -0
  2. package/package.json +34 -0
package/chroma.js ADDED
@@ -0,0 +1,139 @@
1
+ import rfft from 'fourier-transform'
2
+
3
+ const PITCH_MIN = 24 // MIDI C1
4
+ const PITCH_MAX = 96 // MIDI C7
5
+ const A4_MIDI = 69
6
+ const A4_HZ = 440
7
+
8
+ /**
9
+ * Chroma (pitch-class profile) feature — a 12-D vector where each bin holds
10
+ * the energy attributed to one pitch class (C, C♯, …, B).
11
+ *
12
+ * Two variants:
13
+ *
14
+ * - **'pcp'** (default) — classical Fujishima (1999). Each spectral bin is
15
+ * mapped to its nearest pitch class and energies are accumulated.
16
+ * - **'nnls'** — Mauch & Dixon (2010) NNLS Chroma. Fits the observed spectrum
17
+ * as a nonnegative combination of synthetic pitch-tone profiles (fundamental
18
+ * plus geometrically decaying overtones). Much cleaner on polyphonic audio,
19
+ * suppresses octave and harmonic confusion.
20
+ *
21
+ * @param {Float32Array | Float64Array} data - window, length must be a power of 2
22
+ * @param {{fs?: number, method?: 'pcp'|'nnls', minFreq?: number, maxFreq?: number, harmonics?: number, iterations?: number}} [params]
23
+ * @returns {Float64Array} length-12 chroma vector, L1-normalized
24
+ */
25
+ export default function chroma(data, params) {
26
+ let fs = params?.fs || 44100
27
+ let method = params?.method ?? 'pcp'
28
+ let minFreq = params?.minFreq ?? 65 // ~C2
29
+ let maxFreq = params?.maxFreq ?? 2093 // ~C7
30
+ let N = data.length
31
+ if ((N & (N - 1)) !== 0) throw new Error('chroma: window length must be a power of 2')
32
+
33
+ let spec = rfft(data)
34
+ let half = spec.length
35
+ let binHz = fs / N
36
+
37
+ if (method === 'pcp') return pcp(spec, half, binHz, minFreq, maxFreq)
38
+ if (method === 'nnls') return nnls(spec, half, binHz, params?.harmonics ?? 8, params?.iterations ?? 30)
39
+ throw new Error(`chroma: unknown method "${method}"`)
40
+ }
41
+
42
+ function pcp(spec, half, binHz, minFreq, maxFreq) {
43
+ let out = new Float64Array(12)
44
+ for (let b = 1; b < half; b++) {
45
+ let f = b * binHz
46
+ if (f < minFreq || f > maxFreq) continue
47
+ let midi = A4_MIDI + 12 * Math.log2(f / A4_HZ)
48
+ let pc = ((Math.round(midi) % 12) + 12) % 12
49
+ out[pc] += spec[b] * spec[b] // power
50
+ }
51
+ normalize(out)
52
+ return out
53
+ }
54
+
55
+ // cached pitch dictionary per (half, binHz, harmonics)
56
+ let dictCache = new Map()
57
+ function pitchDict(half, binHz, harmonics) {
58
+ let key = `${half}|${binHz}|${harmonics}`
59
+ let cached = dictCache.get(key)
60
+ if (cached) return cached
61
+
62
+ let P = PITCH_MAX - PITCH_MIN + 1
63
+ let D = new Array(P)
64
+ let sigma = 0.5 // Gaussian width in semitones
65
+ for (let p = 0; p < P; p++) {
66
+ let col = new Float64Array(half)
67
+ let f0 = A4_HZ * Math.pow(2, (PITCH_MIN + p - A4_MIDI) / 12)
68
+ for (let h = 1; h <= harmonics; h++) {
69
+ let fh = h * f0
70
+ if (fh >= (half - 1) * binHz) break
71
+ let w = 1 / h // 1/h amplitude decay
72
+ // Gaussian lobe in log-frequency centered at fh
73
+ let bc = fh / binHz
74
+ let bLo = Math.max(1, Math.floor(bc - 6))
75
+ let bHi = Math.min(half - 1, Math.ceil(bc + 6))
76
+ for (let b = bLo; b <= bHi; b++) {
77
+ let fBin = b * binHz
78
+ if (fBin <= 0) continue
79
+ let semis = 12 * Math.log2(fBin / fh)
80
+ col[b] += w * Math.exp(-0.5 * (semis / sigma) ** 2)
81
+ }
82
+ }
83
+ // normalize column
84
+ let colSum = 0
85
+ for (let b = 0; b < half; b++) colSum += col[b] * col[b]
86
+ if (colSum > 0) {
87
+ let s = 1 / Math.sqrt(colSum)
88
+ for (let b = 0; b < half; b++) col[b] *= s
89
+ }
90
+ D[p] = col
91
+ }
92
+ dictCache.set(key, D)
93
+ return D
94
+ }
95
+
96
+ function nnls(spec, half, binHz, harmonics, iterations) {
97
+ let D = pitchDict(half, binHz, harmonics)
98
+ let P = D.length
99
+
100
+ // observation: √spectrum for better loudness scaling
101
+ let s = new Float64Array(half)
102
+ for (let i = 0; i < half; i++) s[i] = Math.sqrt(spec[i])
103
+
104
+ // multiplicative NMF update: a ← a · (Dᵀ s) / (Dᵀ D a + ε)
105
+ let a = new Float64Array(P).fill(1)
106
+ let eps = 1e-9
107
+ for (let it = 0; it < iterations; it++) {
108
+ // reconstruction r = D a
109
+ let r = new Float64Array(half)
110
+ for (let p = 0; p < P; p++) {
111
+ let ap = a[p]
112
+ if (ap === 0) continue
113
+ let col = D[p]
114
+ for (let b = 0; b < half; b++) r[b] += ap * col[b]
115
+ }
116
+ // update each a[p]
117
+ for (let p = 0; p < P; p++) {
118
+ let col = D[p]
119
+ let num = 0, den = 0
120
+ for (let b = 0; b < half; b++) {
121
+ num += col[b] * s[b]
122
+ den += col[b] * r[b]
123
+ }
124
+ a[p] = a[p] * num / (den + eps)
125
+ }
126
+ }
127
+
128
+ // fold into chroma: sum activations per pitch class
129
+ let out = new Float64Array(12)
130
+ for (let p = 0; p < P; p++) out[(PITCH_MIN + p) % 12] += a[p]
131
+ normalize(out)
132
+ return out
133
+ }
134
+
135
+ function normalize(v) {
136
+ let sum = 0
137
+ for (let x of v) sum += x
138
+ if (sum > 0) for (let i = 0; i < v.length; i++) v[i] /= sum
139
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@audio/mir-chroma",
3
+ "version": "1.0.0",
4
+ "description": "Chroma (pitch-class profile) feature — a 12-D vector where each bin holds",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "chroma.js",
8
+ "exports": {
9
+ ".": "./chroma.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "chroma.js"
14
+ ],
15
+ "dependencies": {
16
+ "fourier-transform": "^2.2.0"
17
+ },
18
+ "keywords": [
19
+ "audio",
20
+ "dsp",
21
+ "mir",
22
+ "chroma",
23
+ "pcp",
24
+ "nnls"
25
+ ],
26
+ "license": "MIT",
27
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }